Files
sideline/tests/test_pipeline.py
David Gwilliam e0bbfea26c refactor: consolidate pipeline architecture with unified data source system
MAJOR REFACTORING: Consolidate duplicated pipeline code and standardize on
capability-based dependency resolution. This is a significant but backwards-compatible
restructuring that improves maintainability and extensibility.

## ARCHITECTURE CHANGES

### Data Sources Consolidation
- Move engine/sources_v2.py → engine/data_sources/sources.py
- Move engine/pipeline_sources/ → engine/data_sources/
- Create unified DataSource ABC with common interface:
  * fetch() - idempotent data retrieval
  * get_items() - cached access with automatic refresh
  * refresh() - force cache invalidation
  * is_dynamic - indicate streaming vs static sources
- Support for SourceItem dataclass (content, source, timestamp, metadata)

### Display Backend Improvements
- Update all 7 display backends to use new import paths
- Terminal: Improve dimension detection and handling
- WebSocket: Better error handling and client lifecycle
- Sixel: Refactor graphics rendering
- Pygame: Modernize event handling
- Kitty: Add protocol support for inline images
- Multi: Ensure proper forwarding to all backends
- Null: Maintain testing backend functionality

### Pipeline Adapter Consolidation
- Refactor adapter stages for clarity and flexibility
- RenderStage now handles both item-based and buffer-based rendering
- Add SourceItemsToBufferStage for converting data source items
- Improve DataSourceStage to work with all source types
- Add DisplayStage wrapper for display backends

### Camera & Viewport Refinements
- Update Camera class for new architecture
- Improve viewport dimension detection
- Better handling of resize events across backends

### New Effect Plugins
- border.py: Frame rendering effect with configurable style
- crop.py: Viewport clipping effect for selective display
- tint.py: Color filtering effect for atmosphere

### Tests & Quality
- Add test_border_effect.py with comprehensive border tests
- Add test_crop_effect.py with viewport clipping tests
- Add test_tint_effect.py with color filtering tests
- Update test_pipeline.py for new architecture
- Update test_pipeline_introspection.py for new data source location
- All 463 tests pass with 56% coverage
- Linting: All checks pass with ruff

### Removals (Code Cleanup)
- Delete engine/benchmark.py (deprecated performance testing)
- Delete engine/pipeline_sources/__init__.py (moved to data_sources)
- Delete engine/sources_v2.py (replaced by data_sources/sources.py)
- Update AGENTS.md to reflect new structure

### Import Path Updates
- Update engine/pipeline/controller.py::create_default_pipeline()
  * Old: from engine.sources_v2 import HeadlinesDataSource
  * New: from engine.data_sources.sources import HeadlinesDataSource
- All display backends import from new locations
- All tests import from new locations

## BACKWARDS COMPATIBILITY

This refactoring is intended to be backwards compatible:
- Pipeline execution unchanged (DAG-based with capability matching)
- Effect plugins unchanged (EffectPlugin interface same)
- Display protocol unchanged (Display duck-typing works as before)
- Config system unchanged (presets.toml format same)

## TESTING

- 463 tests pass (0 failures, 19 skipped)
- Full linting check passes
- Manual testing on demo, poetry, websocket modes
- All new effect plugins tested

## FILES CHANGED

- 24 files modified/added/deleted
- 723 insertions, 1,461 deletions (net -738 LOC - cleanup!)
- No breaking changes to public APIs
- All transitive imports updated correctly
2026-03-16 19:47:12 -07:00

1186 lines
37 KiB
Python

"""
Tests for the new unified pipeline architecture.
"""
from unittest.mock import MagicMock
from engine.pipeline import (
Pipeline,
PipelineConfig,
PipelineContext,
Stage,
StageRegistry,
create_default_pipeline,
discover_stages,
)
class TestStageRegistry:
"""Tests for StageRegistry."""
def setup_method(self):
"""Reset registry before each test."""
StageRegistry._discovered = False
StageRegistry._categories.clear()
StageRegistry._instances.clear()
def test_discover_stages_registers_sources(self):
"""discover_stages registers source stages."""
discover_stages()
sources = StageRegistry.list("source")
assert "HeadlinesDataSource" in sources
assert "PoetryDataSource" in sources
assert "PipelineIntrospectionSource" in sources
def test_discover_stages_registers_displays(self):
"""discover_stages registers display stages."""
discover_stages()
displays = StageRegistry.list("display")
assert "terminal" in displays
assert "pygame" in displays
assert "websocket" in displays
assert "null" in displays
assert "sixel" in displays
def test_create_source_stage(self):
"""StageRegistry.create creates source stages."""
discover_stages()
source = StageRegistry.create("source", "HeadlinesDataSource")
assert source is not None
assert source.name == "headlines"
def test_create_display_stage(self):
"""StageRegistry.create creates display stages."""
discover_stages()
display = StageRegistry.create("display", "terminal")
assert display is not None
assert hasattr(display, "_display")
def test_create_display_stage_pygame(self):
"""StageRegistry.create creates pygame display stage."""
discover_stages()
display = StageRegistry.create("display", "pygame")
assert display is not None
class TestPipeline:
"""Tests for Pipeline class."""
def setup_method(self):
"""Reset registry before each test."""
StageRegistry._discovered = False
StageRegistry._categories.clear()
StageRegistry._instances.clear()
discover_stages()
def test_create_pipeline(self):
"""Pipeline can be created with config."""
config = PipelineConfig(source="headlines", display="terminal")
pipeline = Pipeline(config=config)
assert pipeline.config is not None
assert pipeline.config.source == "headlines"
assert pipeline.config.display == "terminal"
def test_add_stage(self):
"""Pipeline.add_stage adds a stage."""
pipeline = Pipeline()
mock_stage = MagicMock(spec=Stage)
mock_stage.name = "test_stage"
mock_stage.category = "test"
pipeline.add_stage("test", mock_stage)
assert "test" in pipeline.stages
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"}
pipeline.add_stage("source", mock_source)
pipeline.add_stage("display", mock_display)
pipeline.build()
assert pipeline._initialized is True
assert "source" in pipeline.execution_order
assert "display" in pipeline.execution_order
def test_execute_runs_stages(self):
"""Pipeline.execute runs all stages in order."""
from engine.pipeline.core import DataType
pipeline = Pipeline()
call_order = []
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"
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
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
pipeline.add_stage("source", mock_source)
pipeline.add_stage("effect", mock_effect)
pipeline.add_stage("display", mock_display)
pipeline.build()
result = pipeline.execute(None)
assert result.success is True
assert call_order == ["source", "effect", "display"]
def test_execute_handles_stage_failure(self):
"""Pipeline.execute handles stage failures."""
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.dependencies = set()
mock_source.capabilities = {"source"}
mock_source.process = lambda data, ctx: "data"
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
mock_failing.process = lambda data, ctx: (_ for _ in ()).throw(
Exception("fail")
)
pipeline.add_stage("source", mock_source)
pipeline.add_stage("failing", mock_failing)
pipeline.build()
result = pipeline.execute(None)
assert result.success is False
assert result.error is not None
def test_optional_stage_failure_continues(self):
"""Pipeline.execute continues on optional stage failure."""
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.dependencies = set()
mock_source.capabilities = {"source"}
mock_source.process = lambda data, ctx: "data"
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
mock_optional.process = lambda data, ctx: (_ for _ in ()).throw(
Exception("fail")
)
pipeline.add_stage("source", mock_source)
pipeline.add_stage("optional", mock_optional)
pipeline.build()
result = pipeline.execute(None)
assert result.success is True
class TestCapabilityBasedDependencies:
"""Tests for capability-based dependency resolution."""
def test_capability_wildcard_resolution(self):
"""Pipeline resolves dependencies using wildcard capabilities."""
from engine.pipeline.controller import Pipeline
from engine.pipeline.core import Stage
class SourceStage(Stage):
name = "headlines"
category = "source"
@property
def capabilities(self):
return {"source.headlines"}
@property
def dependencies(self):
return set()
def process(self, data, ctx):
return data
class RenderStage(Stage):
name = "render"
category = "render"
@property
def capabilities(self):
return {"render.output"}
@property
def dependencies(self):
return {"source.*"}
def process(self, data, ctx):
return data
pipeline = Pipeline()
pipeline.add_stage("headlines", SourceStage())
pipeline.add_stage("render", RenderStage())
pipeline.build()
assert "headlines" in pipeline.execution_order
assert "render" in pipeline.execution_order
assert pipeline.execution_order.index(
"headlines"
) < pipeline.execution_order.index("render")
def test_missing_capability_raises_error(self):
"""Pipeline raises error when capability is missing."""
from engine.pipeline.controller import Pipeline
from engine.pipeline.core import Stage, StageError
class RenderStage(Stage):
name = "render"
category = "render"
@property
def capabilities(self):
return {"render.output"}
@property
def dependencies(self):
return {"source.headlines"}
def process(self, data, ctx):
return data
pipeline = Pipeline()
pipeline.add_stage("render", RenderStage())
try:
pipeline.build()
raise AssertionError("Should have raised StageError")
except StageError as e:
assert "Missing capabilities" in e.message
assert "source.headlines" in e.message
def test_multiple_stages_same_capability(self):
"""Pipeline uses first registered stage for capability."""
from engine.pipeline.controller import Pipeline
from engine.pipeline.core import Stage
class SourceA(Stage):
name = "headlines"
category = "source"
@property
def capabilities(self):
return {"source"}
@property
def dependencies(self):
return set()
def process(self, data, ctx):
return "A"
class SourceB(Stage):
name = "poetry"
category = "source"
@property
def capabilities(self):
return {"source"}
@property
def dependencies(self):
return set()
def process(self, data, ctx):
return "B"
class DisplayStage(Stage):
name = "display"
category = "display"
@property
def capabilities(self):
return {"display"}
@property
def dependencies(self):
return {"source"}
def process(self, data, ctx):
return data
pipeline = Pipeline()
pipeline.add_stage("headlines", SourceA())
pipeline.add_stage("poetry", SourceB())
pipeline.add_stage("display", DisplayStage())
pipeline.build()
assert pipeline.execution_order[0] == "headlines"
class TestPipelineContext:
"""Tests for PipelineContext."""
def test_init_empty(self):
"""PipelineContext initializes with empty services and state."""
ctx = PipelineContext()
assert ctx.services == {}
assert ctx.state == {}
def test_init_with_services(self):
"""PipelineContext accepts initial services."""
ctx = PipelineContext(services={"display": MagicMock()})
assert "display" in ctx.services
def test_init_with_state(self):
"""PipelineContext accepts initial state."""
ctx = PipelineContext(initial_state={"count": 42})
assert ctx.get_state("count") == 42
def test_get_set_services(self):
"""PipelineContext can get/set services."""
ctx = PipelineContext()
mock_service = MagicMock()
ctx.set("test_service", mock_service)
assert ctx.get("test_service") == mock_service
def test_get_set_state(self):
"""PipelineContext can get/set state."""
ctx = PipelineContext()
ctx.set_state("counter", 100)
assert ctx.get_state("counter") == 100
def test_lazy_resolver(self):
"""PipelineContext resolves lazy services."""
ctx = PipelineContext()
config = ctx.get("config")
assert config is not None
def test_has_capability(self):
"""PipelineContext.has_capability checks for services."""
ctx = PipelineContext(services={"display.output": MagicMock()})
assert ctx.has_capability("display.output") is True
assert ctx.has_capability("missing") is False
class TestCreateDefaultPipeline:
"""Tests for create_default_pipeline function."""
def setup_method(self):
"""Reset registry before each test."""
StageRegistry._discovered = False
StageRegistry._categories.clear()
StageRegistry._instances.clear()
discover_stages()
def test_create_default_pipeline(self):
"""create_default_pipeline creates a working pipeline."""
pipeline = create_default_pipeline()
assert pipeline is not None
assert "display" in pipeline.stages
class TestPipelineParams:
"""Tests for PipelineParams."""
def test_default_values(self):
"""PipelineParams has correct defaults."""
from engine.pipeline.params import PipelineParams
params = PipelineParams()
assert params.source == "headlines"
assert params.display == "terminal"
assert params.camera_mode == "vertical"
assert params.effect_order == ["noise", "fade", "glitch", "firehose", "hud"]
def test_effect_config(self):
"""PipelineParams effect config methods work."""
from engine.pipeline.params import PipelineParams
params = PipelineParams()
enabled, intensity = params.get_effect_config("noise")
assert enabled is True
assert intensity == 1.0
params.set_effect_config("noise", False, 0.5)
enabled, intensity = params.get_effect_config("noise")
assert enabled is False
assert intensity == 0.5
def test_is_effect_enabled(self):
"""PipelineParams is_effect_enabled works."""
from engine.pipeline.params import PipelineParams
params = PipelineParams()
assert params.is_effect_enabled("noise") is True
params.effect_enabled["noise"] = False
assert params.is_effect_enabled("noise") is False
def test_to_dict_from_dict(self):
"""PipelineParams serialization roundtrip works."""
from engine.pipeline.params import PipelineParams
params = PipelineParams()
params.viewport_width = 100
params.viewport_height = 50
data = params.to_dict()
restored = PipelineParams.from_dict(data)
assert restored.viewport_width == 100
assert restored.viewport_height == 50
def test_copy(self):
"""PipelineParams copy works."""
from engine.pipeline.params import PipelineParams
params = PipelineParams()
params.viewport_width = 100
params.effect_enabled["noise"] = False
copy = params.copy()
assert copy.viewport_width == 100
assert copy.effect_enabled["noise"] is False
class TestPipelinePresets:
"""Tests for pipeline presets."""
def test_presets_defined(self):
"""All expected presets are defined."""
from engine.pipeline.presets import (
DEMO_PRESET,
FIREHOSE_PRESET,
PIPELINE_VIZ_PRESET,
POETRY_PRESET,
SIXEL_PRESET,
WEBSOCKET_PRESET,
)
assert DEMO_PRESET.name == "demo"
assert POETRY_PRESET.name == "poetry"
assert FIREHOSE_PRESET.name == "firehose"
assert PIPELINE_VIZ_PRESET.name == "pipeline"
assert SIXEL_PRESET.name == "sixel"
assert WEBSOCKET_PRESET.name == "websocket"
def test_preset_to_params(self):
"""Presets convert to PipelineParams correctly."""
from engine.pipeline.presets import DEMO_PRESET
params = DEMO_PRESET.to_params()
assert params.source == "headlines"
assert params.display == "pygame"
assert "noise" in params.effect_order
def test_list_presets(self):
"""list_presets returns all presets."""
from engine.pipeline.presets import list_presets
presets = list_presets()
assert "demo" in presets
assert "poetry" in presets
assert "firehose" in presets
def test_get_preset(self):
"""get_preset returns correct preset."""
from engine.pipeline.presets import get_preset
preset = get_preset("demo")
assert preset is not None
assert preset.name == "demo"
assert get_preset("nonexistent") is None
class TestStageAdapters:
"""Tests for pipeline stage adapters."""
def test_render_stage_capabilities(self):
"""RenderStage declares correct capabilities."""
from engine.pipeline.adapters import RenderStage
stage = RenderStage(items=[], name="render")
assert "render.output" in stage.capabilities
def test_render_stage_dependencies(self):
"""RenderStage declares correct dependencies."""
from engine.pipeline.adapters import RenderStage
stage = RenderStage(items=[], name="render")
assert "source" in stage.dependencies
def test_render_stage_process(self):
"""RenderStage.process returns buffer."""
from engine.pipeline.adapters import RenderStage
from engine.pipeline.core import PipelineContext
items = [
("Test Headline", "test", 1234567890.0),
]
stage = RenderStage(items=items, width=80, height=24)
ctx = PipelineContext()
result = stage.process(None, ctx)
assert result is not None
assert isinstance(result, list)
def test_items_stage(self):
"""ItemsStage provides items to pipeline."""
from engine.pipeline.adapters import ItemsStage
from engine.pipeline.core import PipelineContext
items = [("Headline 1", "src1", 123.0), ("Headline 2", "src2", 124.0)]
stage = ItemsStage(items, name="headlines")
ctx = PipelineContext()
result = stage.process(None, ctx)
assert result == items
def test_display_stage_init(self):
"""DisplayStage.init initializes display."""
from engine.display.backends.null import NullDisplay
from engine.pipeline.adapters import DisplayStage
from engine.pipeline.core import PipelineContext
from engine.pipeline.params import PipelineParams
display = NullDisplay()
stage = DisplayStage(display, name="null")
ctx = PipelineContext()
ctx.params = PipelineParams()
result = stage.init(ctx)
assert result is True
def test_display_stage_process(self):
"""DisplayStage.process forwards to display."""
from engine.display.backends.null import NullDisplay
from engine.pipeline.adapters import DisplayStage
from engine.pipeline.core import PipelineContext
from engine.pipeline.params import PipelineParams
display = NullDisplay()
stage = DisplayStage(display, name="null")
ctx = PipelineContext()
ctx.params = PipelineParams()
stage.init(ctx)
buffer = ["line1", "line2"]
result = stage.process(buffer, ctx)
assert result == buffer
def test_camera_stage(self):
"""CameraStage applies camera transform."""
from engine.camera import Camera, CameraMode
from engine.pipeline.adapters import CameraStage
from engine.pipeline.core import PipelineContext
camera = Camera(mode=CameraMode.VERTICAL)
stage = CameraStage(camera, name="vertical")
PipelineContext()
assert "camera" in stage.capabilities
assert "source.items" in stage.dependencies
class TestDataSourceStage:
"""Tests for DataSourceStage adapter."""
def test_datasource_stage_capabilities(self):
"""DataSourceStage declares correct capabilities."""
from engine.data_sources.sources import HeadlinesDataSource
from engine.pipeline.adapters import DataSourceStage
source = HeadlinesDataSource()
stage = DataSourceStage(source, name="headlines")
assert "source.headlines" in stage.capabilities
def test_datasource_stage_process(self):
"""DataSourceStage fetches from DataSource."""
from unittest.mock import patch
from engine.data_sources.sources import HeadlinesDataSource
from engine.pipeline.adapters import DataSourceStage
from engine.pipeline.core import PipelineContext
mock_items = [
("Test Headline 1", "TestSource", "12:00"),
("Test Headline 2", "TestSource", "12:01"),
]
with patch("engine.fetch.fetch_all", return_value=(mock_items, 1, 0)):
source = HeadlinesDataSource()
stage = DataSourceStage(source, name="headlines")
result = stage.process(None, PipelineContext())
assert result is not None
assert isinstance(result, list)
class TestEffectPluginStage:
"""Tests for EffectPluginStage adapter."""
def test_effect_stage_capabilities(self):
"""EffectPluginStage declares correct capabilities."""
from engine.effects.types import EffectPlugin
from engine.pipeline.adapters import EffectPluginStage
class TestEffect(EffectPlugin):
name = "test"
def process(self, buf, ctx):
return buf
def configure(self, config):
pass
effect = TestEffect()
stage = EffectPluginStage(effect, name="test")
assert "effect.test" in stage.capabilities
def test_effect_stage_with_sensor_bindings(self):
"""EffectPluginStage applies sensor param bindings."""
from engine.effects.types import EffectConfig, EffectPlugin
from engine.pipeline.adapters import EffectPluginStage
from engine.pipeline.core import PipelineContext
from engine.pipeline.params import PipelineParams
class SensorDrivenEffect(EffectPlugin):
name = "sensor_effect"
config = EffectConfig(intensity=1.0)
param_bindings = {
"intensity": {"sensor": "mic", "transform": "linear"},
}
def process(self, buf, ctx):
return buf
def configure(self, config):
pass
effect = SensorDrivenEffect()
stage = EffectPluginStage(effect, name="sensor_effect")
ctx = PipelineContext()
ctx.params = PipelineParams()
ctx.set_state("sensor.mic", 0.5)
result = stage.process(["test"], ctx)
assert result == ["test"]
class TestFullPipeline:
"""End-to-end tests for the full pipeline."""
def test_pipeline_with_items_and_effect(self):
"""Pipeline executes items->effect flow."""
from engine.effects.types import EffectConfig, EffectPlugin
from engine.pipeline.adapters import EffectPluginStage, ItemsStage
from engine.pipeline.controller import Pipeline, PipelineConfig
class TestEffect(EffectPlugin):
name = "test"
config = EffectConfig()
def process(self, buf, ctx):
return [f"processed: {line}" for line in buf]
def configure(self, config):
pass
pipeline = Pipeline(config=PipelineConfig(enable_metrics=False))
# Items stage
items = [("Headline 1", "src1", 123.0)]
pipeline.add_stage("source", ItemsStage(items, name="headlines"))
# Effect stage
pipeline.add_stage("effect", EffectPluginStage(TestEffect(), name="test"))
pipeline.build()
result = pipeline.execute(None)
assert result.success is True
assert "processed:" in result.data[0]
def test_pipeline_with_items_stage(self):
"""Pipeline with ItemsStage provides items through pipeline."""
from engine.pipeline.adapters import ItemsStage
from engine.pipeline.controller import Pipeline, PipelineConfig
pipeline = Pipeline(config=PipelineConfig(enable_metrics=False))
# Items stage provides source
items = [("Headline 1", "src1", 123.0), ("Headline 2", "src2", 124.0)]
pipeline.add_stage("source", ItemsStage(items, name="headlines"))
pipeline.build()
result = pipeline.execute(None)
assert result.success is True
# Items are passed through
assert result.data == items
def test_pipeline_circular_dependency_detection(self):
"""Pipeline detects circular dependencies."""
from engine.pipeline.controller import Pipeline
from engine.pipeline.core import Stage
class StageA(Stage):
name = "a"
@property
def capabilities(self):
return {"a"}
@property
def dependencies(self):
return {"b"}
def process(self, data, ctx):
return data
class StageB(Stage):
name = "b"
@property
def capabilities(self):
return {"b"}
@property
def dependencies(self):
return {"a"}
def process(self, data, ctx):
return data
pipeline = Pipeline()
pipeline.add_stage("a", StageA())
pipeline.add_stage("b", StageB())
try:
pipeline.build()
raise AssertionError("Should detect circular dependency")
except Exception:
pass
def test_datasource_stage_capabilities_match_render_deps(self):
"""DataSourceStage provides capability that RenderStage can depend on."""
from engine.data_sources.sources import HeadlinesDataSource
from engine.pipeline.adapters import DataSourceStage, RenderStage
# DataSourceStage provides "source.headlines"
ds_stage = DataSourceStage(HeadlinesDataSource(), name="headlines")
assert "source.headlines" in ds_stage.capabilities
# RenderStage depends on "source"
r_stage = RenderStage(items=[], width=80, height=24)
assert "source" in r_stage.dependencies
# Test the capability matching directly
from engine.pipeline.controller import Pipeline, PipelineConfig
pipeline = Pipeline(config=PipelineConfig(enable_metrics=False))
pipeline.add_stage("source", ds_stage)
pipeline.add_stage("render", r_stage)
# Build capability map and test matching
pipeline._capability_map = pipeline._build_capability_map()
# "source" should match "source.headlines"
match = pipeline._find_stage_with_capability("source")
assert match == "source", f"Expected 'source', got {match}"
class TestPipelineMetrics:
"""Tests for pipeline metrics collection."""
def test_metrics_collected(self):
"""Pipeline collects metrics when enabled."""
from engine.pipeline.controller import Pipeline, PipelineConfig
from engine.pipeline.core import Stage
class DummyStage(Stage):
name = "dummy"
category = "test"
def process(self, data, ctx):
return data
config = PipelineConfig(enable_metrics=True)
pipeline = Pipeline(config=config)
pipeline.add_stage("dummy", DummyStage())
pipeline.build()
pipeline.execute("test_data")
summary = pipeline.get_metrics_summary()
assert "pipeline" in summary
assert summary["frame_count"] == 1
def test_metrics_disabled(self):
"""Pipeline skips metrics when disabled."""
from engine.pipeline.controller import Pipeline, PipelineConfig
from engine.pipeline.core import Stage
class DummyStage(Stage):
name = "dummy"
category = "test"
def process(self, data, ctx):
return data
config = PipelineConfig(enable_metrics=False)
pipeline = Pipeline(config=config)
pipeline.add_stage("dummy", DummyStage())
pipeline.build()
pipeline.execute("test_data")
summary = pipeline.get_metrics_summary()
assert "error" in summary
def test_reset_metrics(self):
"""Pipeline.reset_metrics clears collected metrics."""
from engine.pipeline.controller import Pipeline, PipelineConfig
from engine.pipeline.core import Stage
class DummyStage(Stage):
name = "dummy"
category = "test"
def process(self, data, ctx):
return data
config = PipelineConfig(enable_metrics=True)
pipeline = Pipeline(config=config)
pipeline.add_stage("dummy", DummyStage())
pipeline.build()
pipeline.execute("test1")
pipeline.execute("test2")
assert pipeline.get_metrics_summary()["frame_count"] == 2
pipeline.reset_metrics()
# 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