diff --git a/engine/pipeline/adapters.py b/engine/pipeline/adapters.py index 60af31e..388dde7 100644 --- a/engine/pipeline/adapters.py +++ b/engine/pipeline/adapters.py @@ -130,6 +130,35 @@ class EffectPluginStage(Stage): self.category = "effect" self.optional = False + @property + def stage_type(self) -> str: + """Return stage_type based on effect name. + + HUD effects are overlays. + """ + if self.name == "hud": + return "overlay" + return self.category + + @property + def render_order(self) -> int: + """Return render_order based on effect type. + + HUD effects have high render_order to appear on top. + """ + if self.name == "hud": + return 100 # High order for overlays + return 0 + + @property + def is_overlay(self) -> bool: + """Return True for HUD effects. + + HUD is an overlay - it composes on top of the buffer + rather than transforming it for the next stage. + """ + return self.name == "hud" + @property def capabilities(self) -> set[str]: return {f"effect.{self.name}"} @@ -166,6 +195,10 @@ class EffectPluginStage(Stage): if key.startswith("sensor."): effect_ctx.set_state(key, value) + # Copy metrics from PipelineContext to EffectContext + if "metrics" in ctx.state: + effect_ctx.set_state("metrics", ctx.state["metrics"]) + # Apply sensor param bindings if effect has them if hasattr(self._effect, "param_bindings") and self._effect.param_bindings: bound_config = apply_param_bindings(self._effect, effect_ctx) @@ -297,6 +330,106 @@ class CameraStage(Stage): self._camera.reset() +class FontStage(Stage): + """Stage that applies font rendering to content. + + FontStage is a Transform that takes raw content (text, headlines) + and renders it to an ANSI-formatted buffer using the configured font. + + This decouples font rendering from data sources, allowing: + - Different fonts per source + - Runtime font swapping + - Font as a pipeline stage + + Attributes: + font_path: Path to font file (None = use config default) + font_size: Font size in points (None = use config default) + font_ref: Reference name for registered font ("default", "cjk", etc.) + """ + + def __init__( + self, + font_path: str | None = None, + font_size: int | None = None, + font_ref: str | None = "default", + name: str = "font", + ): + self.name = name + self.category = "transform" + self.optional = False + self._font_path = font_path + self._font_size = font_size + self._font_ref = font_ref + self._font = None + + @property + def stage_type(self) -> str: + return "transform" + + @property + def capabilities(self) -> set[str]: + return {f"transform.{self.name}", "render.output"} + + @property + def dependencies(self) -> set[str]: + return {"source"} + + def init(self, ctx: PipelineContext) -> bool: + """Initialize font from config or path.""" + from engine import config + + if self._font_path: + try: + from PIL import ImageFont + + size = self._font_size or config.FONT_SZ + self._font = ImageFont.truetype(self._font_path, size) + except Exception: + return False + return True + + def process(self, data: Any, ctx: PipelineContext) -> Any: + """Render content with font to buffer.""" + if data is None: + return None + + from engine.render import make_block + + w = ctx.params.viewport_width if ctx.params else 80 + + # If data is already a list of strings (buffer), return as-is + if isinstance(data, list) and data and isinstance(data[0], str): + return data + + # If data is a list of items, render each with font + if isinstance(data, list): + result = [] + for item in data: + # Handle SourceItem or tuple (title, source, timestamp) + if hasattr(item, "content"): + title = item.content + src = getattr(item, "source", "unknown") + ts = getattr(item, "timestamp", "0") + elif isinstance(item, tuple): + title = item[0] if len(item) > 0 else "" + src = item[1] if len(item) > 1 else "unknown" + ts = str(item[2]) if len(item) > 2 else "0" + else: + title = str(item) + src = "unknown" + ts = "0" + + try: + block = make_block(title, src, ts, w) + result.extend(block) + except Exception: + result.append(title) + + return result + + return data + + def create_stage_from_display(display, name: str = "terminal") -> DisplayStage: """Create a Stage from a Display instance.""" return DisplayStage(display, name) @@ -317,6 +450,96 @@ def create_stage_from_camera(camera, name: str = "vertical") -> CameraStage: return CameraStage(camera, name) +def create_stage_from_font( + font_path: str | None = None, + font_size: int | None = None, + font_ref: str | None = "default", + name: str = "font", +) -> FontStage: + """Create a FontStage for rendering content with fonts.""" + return FontStage( + font_path=font_path, font_size=font_size, font_ref=font_ref, name=name + ) + + +class CanvasStage(Stage): + """Stage that manages a Canvas for rendering. + + CanvasStage creates and manages a 2D canvas that can hold rendered content. + Other stages can write to and read from the canvas via the pipeline context. + + This enables: + - Pre-rendering content off-screen + - Multiple cameras viewing different regions + - Smooth scrolling (camera moves, content stays) + - Layer compositing + + Usage: + - Add CanvasStage to pipeline + - Other stages access canvas via: ctx.get("canvas") + """ + + def __init__( + self, + width: int = 80, + height: int = 24, + name: str = "canvas", + ): + self.name = name + self.category = "system" + self.optional = True + self._width = width + self._height = height + self._canvas = None + + @property + def stage_type(self) -> str: + return "system" + + @property + def capabilities(self) -> set[str]: + return {"canvas"} + + @property + def dependencies(self) -> set[str]: + return set() + + @property + def inlet_types(self) -> set: + from engine.pipeline.core import DataType + + return {DataType.ANY} + + @property + def outlet_types(self) -> set: + from engine.pipeline.core import DataType + + return {DataType.ANY} + + def init(self, ctx: PipelineContext) -> bool: + from engine.canvas import Canvas + + self._canvas = Canvas(width=self._width, height=self._height) + ctx.set("canvas", self._canvas) + return True + + def process(self, data: Any, ctx: PipelineContext) -> Any: + """Pass through data but ensure canvas is in context.""" + if self._canvas is None: + from engine.canvas import Canvas + + self._canvas = Canvas(width=self._width, height=self._height) + ctx.set("canvas", self._canvas) + return data + + def get_canvas(self): + """Get the canvas instance.""" + return self._canvas + + def cleanup(self) -> None: + self._canvas = None + + def create_items_stage(items, name: str = "headlines") -> ItemsStage: """Create a Stage that holds pre-fetched items.""" return ItemsStage(items, name) diff --git a/engine/pipeline/controller.py b/engine/pipeline/controller.py index 162c110..e21f1d6 100644 --- a/engine/pipeline/controller.py +++ b/engine/pipeline/controller.py @@ -86,6 +86,7 @@ class Pipeline: self._capability_map = self._build_capability_map() self._execution_order = self._resolve_dependencies() self._validate_dependencies() + self._validate_types() self._initialized = True return self @@ -185,6 +186,60 @@ class Pipeline: "Missing capabilities:\n" + "\n".join(msgs), ) + def _validate_types(self) -> None: + """Validate inlet/outlet types between connected stages. + + PureData-style type validation. Each stage declares its inlet_types + (what it accepts) and outlet_types (what it produces). This method + validates that connected stages have compatible types. + + Raises StageError if type mismatch is detected. + """ + from engine.pipeline.core import DataType + + errors: list[str] = [] + + for i, name in enumerate(self._execution_order): + stage = self._stages.get(name) + if not stage: + continue + + inlet_types = stage.inlet_types + + # Check against previous stage's outlet types + if i > 0: + prev_name = self._execution_order[i - 1] + prev_stage = self._stages.get(prev_name) + if prev_stage: + prev_outlets = prev_stage.outlet_types + + # Check if any outlet type is accepted by this inlet + compatible = ( + DataType.ANY in inlet_types + or DataType.ANY in prev_outlets + or bool(prev_outlets & inlet_types) + ) + + if not compatible: + errors.append( + f" - {name} (inlet: {inlet_types}) " + f"← {prev_name} (outlet: {prev_outlets})" + ) + + # Check display/sink stages (should accept TEXT_BUFFER) + if ( + stage.category == "display" + and DataType.TEXT_BUFFER not in inlet_types + and DataType.ANY not in inlet_types + ): + errors.append(f" - {name} is display but doesn't accept TEXT_BUFFER") + + if errors: + raise StageError( + "type_validation", + "Type mismatch in pipeline connections:\n" + "\n".join(errors), + ) + def initialize(self) -> bool: """Initialize all stages in execution order.""" for name in self._execution_order: @@ -194,7 +249,12 @@ class Pipeline: return True def execute(self, data: Any | None = None) -> StageResult: - """Execute the pipeline with the given input data.""" + """Execute the pipeline with the given input data. + + Pipeline execution: + 1. Execute all non-overlay stages in dependency order + 2. Apply overlay stages on top (sorted by render_order) + """ if not self._initialized: self.build() @@ -209,11 +269,37 @@ class Pipeline: frame_start = time.perf_counter() if self._metrics_enabled else 0 stage_timings: list[StageMetrics] = [] + # Separate overlay stages from regular stages + overlay_stages: list[tuple[int, Stage]] = [] + regular_stages: list[str] = [] + for name in self._execution_order: stage = self._stages.get(name) if not stage or not stage.is_enabled(): continue + # Safely check is_overlay - handle MagicMock and other non-bool returns + try: + is_overlay = bool(getattr(stage, "is_overlay", False)) + except Exception: + is_overlay = False + + if is_overlay: + # Safely get render_order + try: + render_order = int(getattr(stage, "render_order", 0)) + except Exception: + render_order = 0 + overlay_stages.append((render_order, stage)) + else: + regular_stages.append(name) + + # Execute regular stages in dependency order + for name in regular_stages: + stage = self._stages.get(name) + if not stage or not stage.is_enabled(): + continue + stage_start = time.perf_counter() if self._metrics_enabled else 0 try: @@ -241,6 +327,42 @@ class Pipeline: ) ) + # Apply overlay stages (sorted by render_order) + overlay_stages.sort(key=lambda x: x[0]) + for render_order, stage in overlay_stages: + stage_start = time.perf_counter() if self._metrics_enabled else 0 + stage_name = f"[overlay]{stage.name}" + + try: + # Overlays receive current_data but don't pass their output to next stage + # Instead, their output is composited on top + overlay_output = stage.process(current_data, self.context) + # For now, we just let the overlay output pass through + # In a more sophisticated implementation, we'd composite it + if overlay_output is not None: + current_data = overlay_output + except Exception as e: + if not stage.optional: + return StageResult( + success=False, + data=current_data, + error=str(e), + stage_name=stage_name, + ) + + if self._metrics_enabled: + stage_duration = (time.perf_counter() - stage_start) * 1000 + chars_in = len(str(data)) if data else 0 + chars_out = len(str(current_data)) if current_data else 0 + stage_timings.append( + StageMetrics( + name=stage_name, + duration_ms=stage_duration, + chars_in=chars_in, + chars_out=chars_out, + ) + ) + if self._metrics_enabled: total_duration = (time.perf_counter() - frame_start) * 1000 self._frame_metrics.append( @@ -250,6 +372,12 @@ class Pipeline: stages=stage_timings, ) ) + + # Store metrics in context for other stages (like HUD) + # This makes metrics a first-class pipeline citizen + if self.context: + self.context.state["metrics"] = self.get_metrics_summary() + if len(self._frame_metrics) > self._max_metrics_frames: self._frame_metrics.pop(0) self._current_frame_number += 1 @@ -282,6 +410,22 @@ class Pipeline: """Get list of stage names.""" return list(self._stages.keys()) + def get_overlay_stages(self) -> list[Stage]: + """Get all overlay stages sorted by render_order.""" + overlays = [stage for stage in self._stages.values() if stage.is_overlay] + overlays.sort(key=lambda s: s.render_order) + return overlays + + def get_stage_type(self, name: str) -> str: + """Get the stage_type for a stage.""" + stage = self._stages.get(name) + return stage.stage_type if stage else "" + + def get_render_order(self, name: str) -> int: + """Get the render_order for a stage.""" + stage = self._stages.get(name) + return stage.render_order if stage else 0 + def get_metrics_summary(self) -> dict: """Get summary of collected metrics.""" if not self._frame_metrics: diff --git a/engine/pipeline/core.py b/engine/pipeline/core.py index 20eab3b..e6ea66d 100644 --- a/engine/pipeline/core.py +++ b/engine/pipeline/core.py @@ -5,17 +5,40 @@ This module provides the foundation for a clean, dependency-managed pipeline: - Stage: Base class for all pipeline components (sources, effects, displays, cameras) - PipelineContext: Dependency injection context for runtime data exchange - Capability system: Explicit capability declarations with duck-typing support +- DataType: PureData-style inlet/outlet typing for validation """ from abc import ABC, abstractmethod from collections.abc import Callable from dataclasses import dataclass, field +from enum import Enum, auto from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from engine.pipeline.params import PipelineParams +class DataType(Enum): + """PureData-style data types for inlet/outlet validation. + + Each type represents a specific data format that flows through the pipeline. + This enables compile-time-like validation of connections. + + Examples: + SOURCE_ITEMS: List[SourceItem] - raw items from sources + ITEM_TUPLES: List[tuple] - (title, source, timestamp) tuples + TEXT_BUFFER: List[str] - rendered ANSI buffer for display + RAW_TEXT: str - raw text strings + """ + + SOURCE_ITEMS = auto() # List[SourceItem] - from DataSource + ITEM_TUPLES = auto() # List[tuple] - (title, source, ts) + TEXT_BUFFER = auto() # List[str] - ANSI buffer + RAW_TEXT = auto() # str - raw text + ANY = auto() # Accepts any type + NONE = auto() # No data (terminator) + + @dataclass class StageConfig: """Configuration for a single stage.""" @@ -35,18 +58,78 @@ class Stage(ABC): - Effects: Post-processors (noise, fade, glitch, hud) - Displays: Output backends (terminal, pygame, websocket) - Cameras: Viewport controllers (vertical, horizontal, omni) + - Overlays: UI elements that compose on top (HUD) Stages declare: - capabilities: What they provide to other stages - dependencies: What they need from other stages + - stage_type: Category of stage (source, effect, overlay, display) + - render_order: Execution order within category + - is_overlay: If True, output is composited on top, not passed downstream Duck-typing is supported: any class with the required methods can act as a Stage. """ name: str - category: str # "source", "effect", "display", "camera" + category: str # "source", "effect", "overlay", "display", "camera" optional: bool = False # If True, pipeline continues even if stage fails + @property + def stage_type(self) -> str: + """Category of stage for ordering. + + Valid values: "source", "effect", "overlay", "display", "camera" + Defaults to category for backwards compatibility. + """ + return self.category + + @property + def render_order(self) -> int: + """Execution order within stage_type group. + + Higher values execute later. Useful for ordering overlays + or effects that need specific execution order. + """ + return 0 + + @property + def is_overlay(self) -> bool: + """If True, this stage's output is composited on top of the buffer. + + Overlay stages don't pass their output to the next stage. + Instead, their output is layered on top of the final buffer. + Use this for HUD, status displays, and similar UI elements. + """ + return False + + @property + def inlet_types(self) -> set[DataType]: + """Return set of data types this stage accepts. + + PureData-style inlet typing. If the connected upstream stage's + outlet_type is not in this set, the pipeline will raise an error. + + Examples: + - Source stages: {DataType.NONE} (no input needed) + - Transform stages: {DataType.ITEM_TUPLES, DataType.TEXT_BUFFER} + - Display stages: {DataType.TEXT_BUFFER} + """ + return {DataType.ANY} + + @property + def outlet_types(self) -> set[DataType]: + """Return set of data types this stage produces. + + PureData-style outlet typing. Downstream stages must accept + this type in their inlet_types. + + Examples: + - Source stages: {DataType.SOURCE_ITEMS} + - Transform stages: {DataType.TEXT_BUFFER} + - Display stages: {DataType.NONE} (consumes data) + """ + return {DataType.ANY} + @property def capabilities(self) -> set[str]: """Return set of capabilities this stage provides. diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index b1fd931..3bca468 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -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