forked from genewildish/Mainline
feat(integration): Complete feature rewrite with pipeline architecture, effects system, and display improvements
Major changes: - Pipeline architecture with capability-based dependency resolution - Effects plugin system with performance monitoring - Display abstraction with multiple backends (terminal, null, websocket) - Camera system for viewport scrolling - Sensor framework for real-time input - Command-and-control system via ntfy - WebSocket display backend for browser clients - Comprehensive test suite and documentation Issue #48: ADR for preset scripting language included This commit consolidates 110 individual commits into a single feature integration that can be reviewed and tested before further refinement.
This commit is contained in:
405
tests/test_pipeline_rebuild.py
Normal file
405
tests/test_pipeline_rebuild.py
Normal file
@@ -0,0 +1,405 @@
|
||||
"""
|
||||
Integration tests for pipeline hot-rebuild and state preservation.
|
||||
|
||||
Tests:
|
||||
1. Viewport size control via --viewport flag
|
||||
2. NullDisplay recording and save/load functionality
|
||||
3. Pipeline state preservation during hot-rebuild
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from engine.display import DisplayRegistry
|
||||
from engine.display.backends.null import NullDisplay
|
||||
from engine.display.backends.replay import ReplayDisplay
|
||||
from engine.effects import get_registry
|
||||
from engine.fetch import load_cache
|
||||
from engine.pipeline import Pipeline, PipelineConfig, PipelineContext
|
||||
from engine.pipeline.adapters import (
|
||||
EffectPluginStage,
|
||||
FontStage,
|
||||
ViewportFilterStage,
|
||||
create_stage_from_display,
|
||||
create_stage_from_effect,
|
||||
)
|
||||
from engine.pipeline.params import PipelineParams
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def viewport_dims():
|
||||
"""Small viewport dimensions for testing."""
|
||||
return (40, 15)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def items():
|
||||
"""Load cached source items."""
|
||||
items = load_cache()
|
||||
if not items:
|
||||
pytest.skip("No fixture cache available")
|
||||
return items
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def null_display(viewport_dims):
|
||||
"""Create a NullDisplay for testing."""
|
||||
display = DisplayRegistry.create("null")
|
||||
display.init(viewport_dims[0], viewport_dims[1])
|
||||
return display
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pipeline_with_null_display(items, null_display):
|
||||
"""Create a pipeline with NullDisplay for testing."""
|
||||
import engine.effects.plugins as effects_plugins
|
||||
|
||||
effects_plugins.discover_plugins()
|
||||
|
||||
width, height = null_display.width, null_display.height
|
||||
params = PipelineParams()
|
||||
params.viewport_width = width
|
||||
params.viewport_height = height
|
||||
|
||||
config = PipelineConfig(
|
||||
source="fixture",
|
||||
display="null",
|
||||
camera="scroll",
|
||||
effects=["noise", "fade"],
|
||||
)
|
||||
|
||||
pipeline = Pipeline(config=config, context=PipelineContext())
|
||||
|
||||
from engine.camera import Camera
|
||||
from engine.data_sources.sources import ListDataSource
|
||||
from engine.pipeline.adapters import CameraClockStage, CameraStage, DataSourceStage
|
||||
|
||||
list_source = ListDataSource(items, name="fixture")
|
||||
pipeline.add_stage("source", DataSourceStage(list_source, name="fixture"))
|
||||
|
||||
# Add camera stages (required by ViewportFilterStage)
|
||||
camera = Camera.scroll(speed=0.3)
|
||||
camera.set_canvas_size(200, 200)
|
||||
pipeline.add_stage("camera_update", CameraClockStage(camera, name="camera-clock"))
|
||||
pipeline.add_stage("camera", CameraStage(camera, name="scroll"))
|
||||
|
||||
pipeline.add_stage("viewport_filter", ViewportFilterStage(name="viewport-filter"))
|
||||
pipeline.add_stage("font", FontStage(name="font"))
|
||||
|
||||
effect_registry = get_registry()
|
||||
for effect_name in config.effects:
|
||||
effect = effect_registry.get(effect_name)
|
||||
if effect:
|
||||
pipeline.add_stage(
|
||||
f"effect_{effect_name}",
|
||||
create_stage_from_effect(effect, effect_name),
|
||||
)
|
||||
|
||||
pipeline.add_stage("display", create_stage_from_display(null_display, "null"))
|
||||
pipeline.build()
|
||||
|
||||
if not pipeline.initialize():
|
||||
pytest.fail("Failed to initialize pipeline")
|
||||
|
||||
ctx = pipeline.context
|
||||
ctx.params = params
|
||||
ctx.set("display", null_display)
|
||||
ctx.set("items", items)
|
||||
ctx.set("pipeline", pipeline)
|
||||
ctx.set("pipeline_order", pipeline.execution_order)
|
||||
ctx.set("camera_y", 0)
|
||||
|
||||
yield pipeline, params, null_display
|
||||
|
||||
pipeline.cleanup()
|
||||
null_display.cleanup()
|
||||
|
||||
|
||||
class TestNullDisplayRecording:
|
||||
"""Tests for NullDisplay recording functionality."""
|
||||
|
||||
def test_null_display_initialization(self, viewport_dims):
|
||||
"""NullDisplay initializes with correct dimensions."""
|
||||
display = NullDisplay()
|
||||
display.init(viewport_dims[0], viewport_dims[1])
|
||||
assert display.width == viewport_dims[0]
|
||||
assert display.height == viewport_dims[1]
|
||||
|
||||
def test_start_stop_recording(self, null_display):
|
||||
"""NullDisplay can start and stop recording."""
|
||||
assert not null_display._is_recording
|
||||
|
||||
null_display.start_recording()
|
||||
assert null_display._is_recording is True
|
||||
|
||||
null_display.stop_recording()
|
||||
assert null_display._is_recording is False
|
||||
|
||||
def test_record_frames(self, null_display, pipeline_with_null_display):
|
||||
"""NullDisplay records frames when recording is enabled."""
|
||||
pipeline, params, display = pipeline_with_null_display
|
||||
|
||||
display.start_recording()
|
||||
assert len(display._recorded_frames) == 0
|
||||
|
||||
for frame in range(5):
|
||||
params.frame_number = frame
|
||||
pipeline.context.params = params
|
||||
pipeline.execute([])
|
||||
|
||||
assert len(display._recorded_frames) == 5
|
||||
|
||||
def test_get_frames(self, null_display, pipeline_with_null_display):
|
||||
"""NullDisplay.get_frames() returns recorded buffers."""
|
||||
pipeline, params, display = pipeline_with_null_display
|
||||
|
||||
display.start_recording()
|
||||
|
||||
for frame in range(3):
|
||||
params.frame_number = frame
|
||||
pipeline.context.params = params
|
||||
pipeline.execute([])
|
||||
|
||||
frames = display.get_frames()
|
||||
assert len(frames) == 3
|
||||
assert all(isinstance(f, list) for f in frames)
|
||||
|
||||
def test_clear_recording(self, null_display, pipeline_with_null_display):
|
||||
"""NullDisplay.clear_recording() clears recorded frames."""
|
||||
pipeline, params, display = pipeline_with_null_display
|
||||
|
||||
display.start_recording()
|
||||
for frame in range(3):
|
||||
params.frame_number = frame
|
||||
pipeline.context.params = params
|
||||
pipeline.execute([])
|
||||
|
||||
assert len(display._recorded_frames) == 3
|
||||
|
||||
display.clear_recording()
|
||||
assert len(display._recorded_frames) == 0
|
||||
|
||||
def test_save_load_recording(self, null_display, pipeline_with_null_display):
|
||||
"""NullDisplay can save and load recordings."""
|
||||
pipeline, params, display = pipeline_with_null_display
|
||||
|
||||
display.start_recording()
|
||||
for frame in range(3):
|
||||
params.frame_number = frame
|
||||
pipeline.context.params = params
|
||||
pipeline.execute([])
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
||||
temp_path = f.name
|
||||
|
||||
try:
|
||||
display.save_recording(temp_path)
|
||||
|
||||
with open(temp_path) as f:
|
||||
data = json.load(f)
|
||||
|
||||
assert data["version"] == 1
|
||||
assert data["display"] == "null"
|
||||
assert data["frame_count"] == 3
|
||||
assert len(data["frames"]) == 3
|
||||
|
||||
display2 = NullDisplay()
|
||||
display2.load_recording(temp_path)
|
||||
assert len(display2._recorded_frames) == 3
|
||||
|
||||
finally:
|
||||
Path(temp_path).unlink(missing_ok=True)
|
||||
|
||||
|
||||
class TestReplayDisplay:
|
||||
"""Tests for ReplayDisplay functionality."""
|
||||
|
||||
def test_replay_display_initialization(self, viewport_dims):
|
||||
"""ReplayDisplay initializes correctly."""
|
||||
display = ReplayDisplay()
|
||||
display.init(viewport_dims[0], viewport_dims[1])
|
||||
assert display.width == viewport_dims[0]
|
||||
assert display.height == viewport_dims[1]
|
||||
|
||||
def test_set_and_get_frames(self):
|
||||
"""ReplayDisplay can set and retrieve frames."""
|
||||
display = ReplayDisplay()
|
||||
frames = [
|
||||
{"buffer": ["line1", "line2"], "width": 40, "height": 15},
|
||||
{"buffer": ["line3", "line4"], "width": 40, "height": 15},
|
||||
]
|
||||
display.set_frames(frames)
|
||||
|
||||
frame = display.get_next_frame()
|
||||
assert frame == ["line1", "line2"]
|
||||
|
||||
frame = display.get_next_frame()
|
||||
assert frame == ["line3", "line4"]
|
||||
|
||||
frame = display.get_next_frame()
|
||||
assert frame is None
|
||||
|
||||
def test_replay_loop_mode(self):
|
||||
"""ReplayDisplay can loop playback."""
|
||||
display = ReplayDisplay()
|
||||
display.set_loop(True)
|
||||
frames = [
|
||||
{"buffer": ["frame1"], "width": 40, "height": 15},
|
||||
{"buffer": ["frame2"], "width": 40, "height": 15},
|
||||
]
|
||||
display.set_frames(frames)
|
||||
|
||||
assert display.get_next_frame() == ["frame1"]
|
||||
assert display.get_next_frame() == ["frame2"]
|
||||
assert display.get_next_frame() == ["frame1"]
|
||||
assert display.get_next_frame() == ["frame2"]
|
||||
|
||||
def test_replay_seek_and_reset(self):
|
||||
"""ReplayDisplay supports seek and reset."""
|
||||
display = ReplayDisplay()
|
||||
frames = [
|
||||
{"buffer": [f"frame{i}"], "width": 40, "height": 15} for i in range(5)
|
||||
]
|
||||
display.set_frames(frames)
|
||||
|
||||
display.seek(3)
|
||||
assert display.get_next_frame() == ["frame3"]
|
||||
|
||||
display.reset()
|
||||
assert display.get_next_frame() == ["frame0"]
|
||||
|
||||
|
||||
class TestPipelineHotRebuild:
|
||||
"""Tests for pipeline hot-rebuild and state preservation."""
|
||||
|
||||
def test_pipeline_runs_with_null_display(self, pipeline_with_null_display):
|
||||
"""Pipeline executes successfully with NullDisplay."""
|
||||
pipeline, params, display = pipeline_with_null_display
|
||||
|
||||
for frame in range(5):
|
||||
params.frame_number = frame
|
||||
pipeline.context.params = params
|
||||
result = pipeline.execute([])
|
||||
|
||||
assert result.success
|
||||
assert display._last_buffer is not None
|
||||
|
||||
def test_effect_toggle_during_execution(self, pipeline_with_null_display):
|
||||
"""Effects can be toggled during pipeline execution."""
|
||||
pipeline, params, display = pipeline_with_null_display
|
||||
|
||||
params.frame_number = 0
|
||||
pipeline.context.params = params
|
||||
pipeline.execute([])
|
||||
buffer1 = display._last_buffer
|
||||
|
||||
fade_stage = pipeline.get_stage("effect_fade")
|
||||
assert fade_stage is not None
|
||||
assert isinstance(fade_stage, EffectPluginStage)
|
||||
|
||||
fade_stage._enabled = False
|
||||
fade_stage._effect.config.enabled = False
|
||||
|
||||
params.frame_number = 1
|
||||
pipeline.context.params = params
|
||||
pipeline.execute([])
|
||||
buffer2 = display._last_buffer
|
||||
|
||||
assert buffer1 != buffer2
|
||||
|
||||
def test_state_preservation_across_rebuild(self, pipeline_with_null_display):
|
||||
"""Pipeline state is preserved across hot-rebuild events."""
|
||||
pipeline, params, display = pipeline_with_null_display
|
||||
|
||||
for frame in range(5):
|
||||
params.frame_number = frame
|
||||
pipeline.context.params = params
|
||||
pipeline.execute([])
|
||||
|
||||
camera_y_before = pipeline.context.get("camera_y")
|
||||
|
||||
fade_stage = pipeline.get_stage("effect_fade")
|
||||
if fade_stage and isinstance(fade_stage, EffectPluginStage):
|
||||
fade_stage.set_enabled(not fade_stage.is_enabled())
|
||||
fade_stage._effect.config.enabled = fade_stage.is_enabled()
|
||||
|
||||
params.frame_number = 5
|
||||
pipeline.context.params = params
|
||||
pipeline.execute([])
|
||||
|
||||
pipeline.context.get("camera_y")
|
||||
|
||||
assert camera_y_before is not None
|
||||
|
||||
|
||||
class TestViewportControl:
|
||||
"""Tests for viewport size control."""
|
||||
|
||||
def test_viewport_dimensions_applied(self, items):
|
||||
"""Viewport dimensions are correctly applied to pipeline."""
|
||||
width, height = 40, 15
|
||||
|
||||
display = DisplayRegistry.create("null")
|
||||
display.init(width, height)
|
||||
|
||||
params = PipelineParams()
|
||||
params.viewport_width = width
|
||||
params.viewport_height = height
|
||||
|
||||
config = PipelineConfig(
|
||||
source="fixture",
|
||||
display="null",
|
||||
camera="scroll",
|
||||
effects=[],
|
||||
)
|
||||
|
||||
pipeline = Pipeline(config=config, context=PipelineContext())
|
||||
|
||||
from engine.camera import Camera
|
||||
from engine.data_sources.sources import ListDataSource
|
||||
from engine.pipeline.adapters import (
|
||||
CameraClockStage,
|
||||
CameraStage,
|
||||
DataSourceStage,
|
||||
)
|
||||
|
||||
list_source = ListDataSource(items, name="fixture")
|
||||
pipeline.add_stage("source", DataSourceStage(list_source, name="fixture"))
|
||||
|
||||
# Add camera stages (required by ViewportFilterStage)
|
||||
camera = Camera.scroll(speed=0.3)
|
||||
camera.set_canvas_size(200, 200)
|
||||
pipeline.add_stage(
|
||||
"camera_update", CameraClockStage(camera, name="camera-clock")
|
||||
)
|
||||
pipeline.add_stage("camera", CameraStage(camera, name="scroll"))
|
||||
|
||||
pipeline.add_stage(
|
||||
"viewport_filter", ViewportFilterStage(name="viewport-filter")
|
||||
)
|
||||
pipeline.add_stage("font", FontStage(name="font"))
|
||||
pipeline.add_stage("display", create_stage_from_display(display, "null"))
|
||||
pipeline.build()
|
||||
|
||||
assert pipeline.initialize()
|
||||
|
||||
ctx = pipeline.context
|
||||
ctx.params = params
|
||||
ctx.set("display", display)
|
||||
ctx.set("items", items)
|
||||
ctx.set("pipeline", pipeline)
|
||||
ctx.set("camera_y", 0)
|
||||
|
||||
result = pipeline.execute(items)
|
||||
|
||||
assert result.success
|
||||
assert display._last_buffer is not None
|
||||
|
||||
pipeline.cleanup()
|
||||
display.cleanup()
|
||||
Reference in New Issue
Block a user