feat(pipeline): add PureData-style inlet/outlet typing

- Add DataType enum (SOURCE_ITEMS, TEXT_BUFFER, etc.)
- Add inlet_types and outlet_types to Stage
- Add _validate_types() for type checking at build time
- Update tests with proper type annotations
This commit is contained in:
2026-03-16 15:39:36 -07:00
parent 4616a21359
commit 76126bdaac
4 changed files with 717 additions and 2 deletions

View File

@@ -100,16 +100,28 @@ class TestPipeline:
def test_build_resolves_dependencies(self):
"""Pipeline.build resolves execution order."""
from engine.pipeline.core import DataType
pipeline = Pipeline()
mock_source = MagicMock(spec=Stage)
mock_source.name = "source"
mock_source.category = "source"
mock_source.stage_type = "source"
mock_source.render_order = 0
mock_source.is_overlay = False
mock_source.inlet_types = {DataType.NONE}
mock_source.outlet_types = {DataType.SOURCE_ITEMS}
mock_source.dependencies = set()
mock_source.capabilities = {"source"}
mock_display = MagicMock(spec=Stage)
mock_display.name = "display"
mock_display.category = "display"
mock_display.stage_type = "display"
mock_display.render_order = 0
mock_display.is_overlay = False
mock_display.inlet_types = {DataType.ANY} # Accept any type
mock_display.outlet_types = {DataType.NONE}
mock_display.dependencies = {"source"}
mock_display.capabilities = {"display"}
@@ -123,6 +135,8 @@ class TestPipeline:
def test_execute_runs_stages(self):
"""Pipeline.execute runs all stages in order."""
from engine.pipeline.core import DataType
pipeline = Pipeline()
call_order = []
@@ -130,6 +144,11 @@ class TestPipeline:
mock_source = MagicMock(spec=Stage)
mock_source.name = "source"
mock_source.category = "source"
mock_source.stage_type = "source"
mock_source.render_order = 0
mock_source.is_overlay = False
mock_source.inlet_types = {DataType.NONE}
mock_source.outlet_types = {DataType.SOURCE_ITEMS}
mock_source.dependencies = set()
mock_source.capabilities = {"source"}
mock_source.process = lambda data, ctx: call_order.append("source") or "data"
@@ -137,6 +156,11 @@ class TestPipeline:
mock_effect = MagicMock(spec=Stage)
mock_effect.name = "effect"
mock_effect.category = "effect"
mock_effect.stage_type = "effect"
mock_effect.render_order = 0
mock_effect.is_overlay = False
mock_effect.inlet_types = {DataType.SOURCE_ITEMS}
mock_effect.outlet_types = {DataType.TEXT_BUFFER}
mock_effect.dependencies = {"source"}
mock_effect.capabilities = {"effect"}
mock_effect.process = lambda data, ctx: call_order.append("effect") or data
@@ -144,6 +168,11 @@ class TestPipeline:
mock_display = MagicMock(spec=Stage)
mock_display.name = "display"
mock_display.category = "display"
mock_display.stage_type = "display"
mock_display.render_order = 0
mock_display.is_overlay = False
mock_display.inlet_types = {DataType.TEXT_BUFFER}
mock_display.outlet_types = {DataType.NONE}
mock_display.dependencies = {"effect"}
mock_display.capabilities = {"display"}
mock_display.process = lambda data, ctx: call_order.append("display") or data
@@ -165,6 +194,9 @@ class TestPipeline:
mock_source = MagicMock(spec=Stage)
mock_source.name = "source"
mock_source.category = "source"
mock_source.stage_type = "source"
mock_source.render_order = 0
mock_source.is_overlay = False
mock_source.dependencies = set()
mock_source.capabilities = {"source"}
mock_source.process = lambda data, ctx: "data"
@@ -172,6 +204,9 @@ class TestPipeline:
mock_failing = MagicMock(spec=Stage)
mock_failing.name = "failing"
mock_failing.category = "effect"
mock_failing.stage_type = "effect"
mock_failing.render_order = 0
mock_failing.is_overlay = False
mock_failing.dependencies = {"source"}
mock_failing.capabilities = {"effect"}
mock_failing.optional = False
@@ -195,6 +230,9 @@ class TestPipeline:
mock_source = MagicMock(spec=Stage)
mock_source.name = "source"
mock_source.category = "source"
mock_source.stage_type = "source"
mock_source.render_order = 0
mock_source.is_overlay = False
mock_source.dependencies = set()
mock_source.capabilities = {"source"}
mock_source.process = lambda data, ctx: "data"
@@ -202,6 +240,9 @@ class TestPipeline:
mock_optional = MagicMock(spec=Stage)
mock_optional.name = "optional"
mock_optional.category = "effect"
mock_optional.stage_type = "effect"
mock_optional.render_order = 0
mock_optional.is_overlay = False
mock_optional.dependencies = {"source"}
mock_optional.capabilities = {"effect"}
mock_optional.optional = True
@@ -918,3 +959,227 @@ class TestPipelineMetrics:
# After reset, metrics collection starts fresh
pipeline.execute("test3")
assert pipeline.get_metrics_summary()["frame_count"] == 1
class TestOverlayStages:
"""Tests for overlay stage support."""
def test_stage_is_overlay_property(self):
"""Stage has is_overlay property defaulting to False."""
from engine.pipeline.core import Stage
class TestStage(Stage):
name = "test"
category = "effect"
def process(self, data, ctx):
return data
stage = TestStage()
assert stage.is_overlay is False
def test_stage_render_order_property(self):
"""Stage has render_order property defaulting to 0."""
from engine.pipeline.core import Stage
class TestStage(Stage):
name = "test"
category = "effect"
def process(self, data, ctx):
return data
stage = TestStage()
assert stage.render_order == 0
def test_stage_stage_type_property(self):
"""Stage has stage_type property defaulting to category."""
from engine.pipeline.core import Stage
class TestStage(Stage):
name = "test"
category = "effect"
def process(self, data, ctx):
return data
stage = TestStage()
assert stage.stage_type == "effect"
def test_pipeline_get_overlay_stages(self):
"""Pipeline.get_overlay_stages returns overlay stages sorted by render_order."""
from engine.pipeline.controller import Pipeline
from engine.pipeline.core import Stage
class OverlayStageA(Stage):
name = "overlay_a"
category = "overlay"
@property
def is_overlay(self):
return True
@property
def render_order(self):
return 10
def process(self, data, ctx):
return data
class OverlayStageB(Stage):
name = "overlay_b"
category = "overlay"
@property
def is_overlay(self):
return True
@property
def render_order(self):
return 5
def process(self, data, ctx):
return data
class RegularStage(Stage):
name = "regular"
category = "effect"
def process(self, data, ctx):
return data
pipeline = Pipeline()
pipeline.add_stage("overlay_a", OverlayStageA())
pipeline.add_stage("overlay_b", OverlayStageB())
pipeline.add_stage("regular", RegularStage())
pipeline.build()
overlays = pipeline.get_overlay_stages()
assert len(overlays) == 2
# Should be sorted by render_order
assert overlays[0].name == "overlay_b" # render_order=5
assert overlays[1].name == "overlay_a" # render_order=10
def test_pipeline_executes_overlays_after_regular(self):
"""Pipeline executes overlays after regular stages."""
from engine.pipeline.controller import Pipeline
from engine.pipeline.core import Stage
call_order = []
class RegularStage(Stage):
name = "regular"
category = "effect"
def process(self, data, ctx):
call_order.append("regular")
return data
class OverlayStage(Stage):
name = "overlay"
category = "overlay"
@property
def is_overlay(self):
return True
@property
def render_order(self):
return 100
def process(self, data, ctx):
call_order.append("overlay")
return data
pipeline = Pipeline()
pipeline.add_stage("regular", RegularStage())
pipeline.add_stage("overlay", OverlayStage())
pipeline.build()
pipeline.execute("data")
assert call_order == ["regular", "overlay"]
def test_effect_plugin_stage_hud_is_overlay(self):
"""EffectPluginStage marks HUD as overlay."""
from engine.effects.types import EffectConfig, EffectPlugin
from engine.pipeline.adapters import EffectPluginStage
class HudEffect(EffectPlugin):
name = "hud"
config = EffectConfig(enabled=True)
def process(self, buf, ctx):
return buf
def configure(self, config):
pass
stage = EffectPluginStage(HudEffect(), name="hud")
assert stage.is_overlay is True
assert stage.stage_type == "overlay"
assert stage.render_order == 100
def test_effect_plugin_stage_non_hud_not_overlay(self):
"""EffectPluginStage marks non-HUD effects as not overlay."""
from engine.effects.types import EffectConfig, EffectPlugin
from engine.pipeline.adapters import EffectPluginStage
class FadeEffect(EffectPlugin):
name = "fade"
config = EffectConfig(enabled=True)
def process(self, buf, ctx):
return buf
def configure(self, config):
pass
stage = EffectPluginStage(FadeEffect(), name="fade")
assert stage.is_overlay is False
assert stage.stage_type == "effect"
assert stage.render_order == 0
def test_pipeline_get_stage_type(self):
"""Pipeline.get_stage_type returns stage_type for a stage."""
from engine.pipeline.controller import Pipeline
from engine.pipeline.core import Stage
class TestStage(Stage):
name = "test"
category = "effect"
@property
def stage_type(self):
return "overlay"
def process(self, data, ctx):
return data
pipeline = Pipeline()
pipeline.add_stage("test", TestStage())
pipeline.build()
assert pipeline.get_stage_type("test") == "overlay"
def test_pipeline_get_render_order(self):
"""Pipeline.get_render_order returns render_order for a stage."""
from engine.pipeline.controller import Pipeline
from engine.pipeline.core import Stage
class TestStage(Stage):
name = "test"
category = "effect"
@property
def render_order(self):
return 42
def process(self, data, ctx):
return data
pipeline = Pipeline()
pipeline.add_stage("test", TestStage())
pipeline.build()
assert pipeline.get_render_order("test") == 42