feat: Complete pipeline hot-rebuild implementation with acceptance tests

- Implements pipeline hot-rebuild with state preservation (issue #43)
- Adds auto-injection of MVP stages for missing capabilities
- Adds radial camera mode for polar coordinate scanning
- Adds afterimage and motionblur effects using framebuffer history
- Adds comprehensive acceptance tests for camera modes and pipeline rebuild
- Updates presets.toml with new effect configurations

Related to: #35 (Pipeline Mutation API epic)
Closes: #43, #44, #45
This commit is contained in:
2026-03-19 03:34:06 -07:00
parent 0eb5f1d5ff
commit 238bac1bb2
30 changed files with 3438 additions and 378 deletions

View File

@@ -129,7 +129,7 @@ class TestPipeline:
pipeline.add_stage("source", mock_source)
pipeline.add_stage("display", mock_display)
pipeline.build()
pipeline.build(auto_inject=False)
assert pipeline._initialized is True
assert "source" in pipeline.execution_order
@@ -182,7 +182,7 @@ class TestPipeline:
pipeline.add_stage("source", mock_source)
pipeline.add_stage("effect", mock_effect)
pipeline.add_stage("display", mock_display)
pipeline.build()
pipeline.build(auto_inject=False)
result = pipeline.execute(None)
@@ -218,7 +218,7 @@ class TestPipeline:
pipeline.add_stage("source", mock_source)
pipeline.add_stage("failing", mock_failing)
pipeline.build()
pipeline.build(auto_inject=False)
result = pipeline.execute(None)
@@ -254,7 +254,7 @@ class TestPipeline:
pipeline.add_stage("source", mock_source)
pipeline.add_stage("optional", mock_optional)
pipeline.build()
pipeline.build(auto_inject=False)
result = pipeline.execute(None)
@@ -302,7 +302,7 @@ class TestCapabilityBasedDependencies:
pipeline = Pipeline()
pipeline.add_stage("headlines", SourceStage())
pipeline.add_stage("render", RenderStage())
pipeline.build()
pipeline.build(auto_inject=False)
assert "headlines" in pipeline.execution_order
assert "render" in pipeline.execution_order
@@ -334,7 +334,7 @@ class TestCapabilityBasedDependencies:
pipeline.add_stage("render", RenderStage())
try:
pipeline.build()
pipeline.build(auto_inject=False)
raise AssertionError("Should have raised StageError")
except StageError as e:
assert "Missing capabilities" in e.message
@@ -394,7 +394,7 @@ class TestCapabilityBasedDependencies:
pipeline.add_stage("headlines", SourceA())
pipeline.add_stage("poetry", SourceB())
pipeline.add_stage("display", DisplayStage())
pipeline.build()
pipeline.build(auto_inject=False)
assert pipeline.execution_order[0] == "headlines"
@@ -791,7 +791,7 @@ class TestFullPipeline:
pipeline.add_stage("b", StageB())
try:
pipeline.build()
pipeline.build(auto_inject=False)
raise AssertionError("Should detect circular dependency")
except Exception:
pass
@@ -815,7 +815,7 @@ class TestPipelineMetrics:
config = PipelineConfig(enable_metrics=True)
pipeline = Pipeline(config=config)
pipeline.add_stage("dummy", DummyStage())
pipeline.build()
pipeline.build(auto_inject=False)
pipeline.execute("test_data")
@@ -838,7 +838,7 @@ class TestPipelineMetrics:
config = PipelineConfig(enable_metrics=False)
pipeline = Pipeline(config=config)
pipeline.add_stage("dummy", DummyStage())
pipeline.build()
pipeline.build(auto_inject=False)
pipeline.execute("test_data")
@@ -860,7 +860,7 @@ class TestPipelineMetrics:
config = PipelineConfig(enable_metrics=True)
pipeline = Pipeline(config=config)
pipeline.add_stage("dummy", DummyStage())
pipeline.build()
pipeline.build(auto_inject=False)
pipeline.execute("test1")
pipeline.execute("test2")
@@ -964,7 +964,7 @@ class TestOverlayStages:
pipeline.add_stage("overlay_a", OverlayStageA())
pipeline.add_stage("overlay_b", OverlayStageB())
pipeline.add_stage("regular", RegularStage())
pipeline.build()
pipeline.build(auto_inject=False)
overlays = pipeline.get_overlay_stages()
assert len(overlays) == 2
@@ -1006,7 +1006,7 @@ class TestOverlayStages:
pipeline = Pipeline()
pipeline.add_stage("regular", RegularStage())
pipeline.add_stage("overlay", OverlayStage())
pipeline.build()
pipeline.build(auto_inject=False)
pipeline.execute("data")
@@ -1070,7 +1070,7 @@ class TestOverlayStages:
pipeline = Pipeline()
pipeline.add_stage("test", TestStage())
pipeline.build()
pipeline.build(auto_inject=False)
assert pipeline.get_stage_type("test") == "overlay"
@@ -1092,7 +1092,7 @@ class TestOverlayStages:
pipeline = Pipeline()
pipeline.add_stage("test", TestStage())
pipeline.build()
pipeline.build(auto_inject=False)
assert pipeline.get_render_order("test") == 42
@@ -1142,7 +1142,7 @@ class TestInletOutletTypeValidation:
pipeline.add_stage("consumer", ConsumerStage())
with pytest.raises(StageError) as exc_info:
pipeline.build()
pipeline.build(auto_inject=False)
assert "Type mismatch" in str(exc_info.value)
assert "TEXT_BUFFER" in str(exc_info.value)
@@ -1190,7 +1190,7 @@ class TestInletOutletTypeValidation:
pipeline.add_stage("consumer", ConsumerStage())
# Should not raise
pipeline.build()
pipeline.build(auto_inject=False)
def test_any_type_accepts_everything(self):
"""DataType.ANY accepts any upstream type."""
@@ -1234,7 +1234,7 @@ class TestInletOutletTypeValidation:
pipeline.add_stage("consumer", ConsumerStage())
# Should not raise because consumer accepts ANY
pipeline.build()
pipeline.build(auto_inject=False)
def test_multiple_compatible_types(self):
"""Stage can declare multiple inlet types."""
@@ -1278,7 +1278,7 @@ class TestInletOutletTypeValidation:
pipeline.add_stage("consumer", ConsumerStage())
# Should not raise because consumer accepts SOURCE_ITEMS
pipeline.build()
pipeline.build(auto_inject=False)
def test_display_must_accept_text_buffer(self):
"""Display stages must accept TEXT_BUFFER type."""
@@ -1302,7 +1302,7 @@ class TestInletOutletTypeValidation:
pipeline.add_stage("display", BadDisplayStage())
with pytest.raises(StageError) as exc_info:
pipeline.build()
pipeline.build(auto_inject=False)
assert "display" in str(exc_info.value).lower()
@@ -1349,7 +1349,7 @@ class TestPipelineMutation:
"""add_stage() initializes stage when pipeline already initialized."""
pipeline = Pipeline()
mock_stage = self._create_mock_stage("test")
pipeline.build()
pipeline.build(auto_inject=False)
pipeline._initialized = True
pipeline.add_stage("test", mock_stage, initialize=True)
@@ -1478,7 +1478,7 @@ class TestPipelineMutation:
pipeline.add_stage("a", stage_a, initialize=False)
pipeline.add_stage("b", stage_b, initialize=False)
pipeline.add_stage("c", stage_c, initialize=False)
pipeline.build()
pipeline.build(auto_inject=False)
result = pipeline.move_stage("a", after="c")
@@ -1497,7 +1497,7 @@ class TestPipelineMutation:
pipeline.add_stage("a", stage_a, initialize=False)
pipeline.add_stage("b", stage_b, initialize=False)
pipeline.add_stage("c", stage_c, initialize=False)
pipeline.build()
pipeline.build(auto_inject=False)
result = pipeline.move_stage("c", before="a")
@@ -1512,7 +1512,7 @@ class TestPipelineMutation:
stage = self._create_mock_stage("test")
pipeline.add_stage("test", stage, initialize=False)
pipeline.build()
pipeline.build(auto_inject=False)
result = pipeline.move_stage("nonexistent", after="test")
@@ -1613,7 +1613,7 @@ class TestPipelineMutation:
pipeline.add_stage("s1", stage1, initialize=False)
pipeline.add_stage("s2", stage2, initialize=False)
pipeline.build()
pipeline.build(auto_inject=False)
info = pipeline.get_pipeline_info()
@@ -1640,7 +1640,7 @@ class TestPipelineMutation:
pipeline.add_stage("source", source, initialize=False)
pipeline.add_stage("effect", effect, initialize=False)
pipeline.add_stage("display", display, initialize=False)
pipeline.build()
pipeline.build(auto_inject=False)
assert pipeline.execution_order == ["source", "effect", "display"]
@@ -1664,7 +1664,7 @@ class TestPipelineMutation:
pipeline.add_stage("source", source, initialize=False)
pipeline.add_stage("display", display, initialize=False)
pipeline.build()
pipeline.build(auto_inject=False)
new_stage = self._create_mock_stage(
"effect", "effect", capabilities={"effect"}, dependencies={"source"}
@@ -1757,7 +1757,7 @@ class TestPipelineMutation:
pipeline.add_stage("source", TestSource(), initialize=False)
pipeline.add_stage("effect", TestEffect(), initialize=False)
pipeline.add_stage("display", TestDisplay(), initialize=False)
pipeline.build()
pipeline.build(auto_inject=False)
pipeline.initialize()
result = pipeline.execute(None)