feat(figment): complete pipeline integration with native effect plugin

- Add engine/effects/plugins/figment.py (native pipeline implementation)
- Add engine/figment_render.py, engine/figment_trigger.py, engine/themes.py
- Add 3 SVG assets in figments/ (Mexican/Aztec motif)
- Add engine/display/backends/animation_report.py for debugging
- Add engine/pipeline/adapters/frame_capture.py for frame capture
- Add test-figment preset to presets.toml
- Add cairosvg optional dependency to pyproject.toml
- Update EffectPluginStage to support is_overlay attribute (for overlay effects)
- Add comprehensive tests: test_figment_effect.py, test_figment_pipeline.py, test_figment_render.py
- Remove obsolete test_ui_simple.py
- Update TODO.md with test cleanup plan
- Refactor test_adapters.py to use real components instead of mocks

This completes the figment SVG overlay feature integration using the modern pipeline architecture, avoiding legacy effects_plugins. All tests pass (758 total).
This commit is contained in:
2026-03-21 13:09:37 -07:00
parent ef0c43266a
commit 7185005f9b
17 changed files with 1990 additions and 181 deletions

View File

@@ -1,17 +1,16 @@
"""
Tests for engine/pipeline/adapters.py - Stage adapters for the pipeline.
Tests Stage adapters that bridge existing components to the Stage interface:
- DataSourceStage: Wraps DataSource objects
- DisplayStage: Wraps Display backends
- PassthroughStage: Simple pass-through stage for pre-rendered data
- SourceItemsToBufferStage: Converts SourceItem objects to text buffers
- EffectPluginStage: Wraps effect plugins
Tests Stage adapters that bridge existing components to the Stage interface.
Focuses on behavior testing rather than mock interactions.
"""
from unittest.mock import MagicMock
from engine.data_sources.sources import SourceItem
from engine.display.backends.null import NullDisplay
from engine.effects.plugins import discover_plugins
from engine.effects.registry import get_registry
from engine.pipeline.adapters import (
DataSourceStage,
DisplayStage,
@@ -25,28 +24,14 @@ from engine.pipeline.core import PipelineContext
class TestDataSourceStage:
"""Test DataSourceStage adapter."""
def test_datasource_stage_name(self):
"""DataSourceStage stores name correctly."""
def test_datasource_stage_properties(self):
"""DataSourceStage has correct name, category, and capabilities."""
mock_source = MagicMock()
stage = DataSourceStage(mock_source, name="headlines")
assert stage.name == "headlines"
def test_datasource_stage_category(self):
"""DataSourceStage has 'source' category."""
mock_source = MagicMock()
stage = DataSourceStage(mock_source, name="headlines")
assert stage.category == "source"
def test_datasource_stage_capabilities(self):
"""DataSourceStage advertises source capability."""
mock_source = MagicMock()
stage = DataSourceStage(mock_source, name="headlines")
assert "source.headlines" in stage.capabilities
def test_datasource_stage_dependencies(self):
"""DataSourceStage has no dependencies."""
mock_source = MagicMock()
stage = DataSourceStage(mock_source, name="headlines")
assert stage.dependencies == set()
def test_datasource_stage_process_calls_get_items(self):
@@ -64,7 +49,7 @@ class TestDataSourceStage:
assert result == mock_items
mock_source.get_items.assert_called_once()
def test_datasource_stage_process_fallback_returns_data(self):
def test_datasource_stage_process_fallback(self):
"""DataSourceStage.process() returns data if no get_items method."""
mock_source = MagicMock(spec=[]) # No get_items method
stage = DataSourceStage(mock_source, name="headlines")
@@ -76,124 +61,68 @@ class TestDataSourceStage:
class TestDisplayStage:
"""Test DisplayStage adapter."""
"""Test DisplayStage adapter using NullDisplay for real behavior."""
def test_display_stage_properties(self):
"""DisplayStage has correct name, category, and capabilities."""
display = NullDisplay()
stage = DisplayStage(display, name="terminal")
def test_display_stage_name(self):
"""DisplayStage stores name correctly."""
mock_display = MagicMock()
stage = DisplayStage(mock_display, name="terminal")
assert stage.name == "terminal"
def test_display_stage_category(self):
"""DisplayStage has 'display' category."""
mock_display = MagicMock()
stage = DisplayStage(mock_display, name="terminal")
assert stage.category == "display"
def test_display_stage_capabilities(self):
"""DisplayStage advertises display capability."""
mock_display = MagicMock()
stage = DisplayStage(mock_display, name="terminal")
assert "display.output" in stage.capabilities
def test_display_stage_dependencies(self):
"""DisplayStage depends on render.output."""
mock_display = MagicMock()
stage = DisplayStage(mock_display, name="terminal")
assert "render.output" in stage.dependencies
def test_display_stage_init(self):
"""DisplayStage.init() calls display.init() with dimensions."""
mock_display = MagicMock()
mock_display.init.return_value = True
stage = DisplayStage(mock_display, name="terminal")
def test_display_stage_init_and_process(self):
"""DisplayStage initializes display and processes buffer."""
from engine.pipeline.params import PipelineParams
display = NullDisplay()
stage = DisplayStage(display, name="terminal")
ctx = PipelineContext()
ctx.params = MagicMock()
ctx.params.viewport_width = 100
ctx.params.viewport_height = 30
ctx.params = PipelineParams()
ctx.params.viewport_width = 80
ctx.params.viewport_height = 24
# Initialize
result = stage.init(ctx)
assert result is True
mock_display.init.assert_called_once_with(100, 30, reuse=False)
def test_display_stage_init_uses_defaults(self):
"""DisplayStage.init() uses defaults when params missing."""
mock_display = MagicMock()
mock_display.init.return_value = True
stage = DisplayStage(mock_display, name="terminal")
# Process buffer
buffer = ["Line 1", "Line 2", "Line 3"]
output = stage.process(buffer, ctx)
assert output == buffer
ctx = PipelineContext()
ctx.params = None
# Verify display captured the buffer
assert display._last_buffer == buffer
result = stage.init(ctx)
assert result is True
mock_display.init.assert_called_once_with(80, 24, reuse=False)
def test_display_stage_process_calls_show(self):
"""DisplayStage.process() calls display.show() with data."""
mock_display = MagicMock()
stage = DisplayStage(mock_display, name="terminal")
test_buffer = [[["A", "red"] for _ in range(80)] for _ in range(24)]
ctx = PipelineContext()
result = stage.process(test_buffer, ctx)
assert result == test_buffer
mock_display.show.assert_called_once_with(test_buffer)
def test_display_stage_process_skips_none_data(self):
def test_display_stage_skips_none_data(self):
"""DisplayStage.process() skips show() if data is None."""
mock_display = MagicMock()
stage = DisplayStage(mock_display, name="terminal")
display = NullDisplay()
stage = DisplayStage(display, name="terminal")
ctx = PipelineContext()
result = stage.process(None, ctx)
assert result is None
mock_display.show.assert_not_called()
def test_display_stage_cleanup(self):
"""DisplayStage.cleanup() calls display.cleanup()."""
mock_display = MagicMock()
stage = DisplayStage(mock_display, name="terminal")
stage.cleanup()
mock_display.cleanup.assert_called_once()
assert display._last_buffer is None
class TestPassthroughStage:
"""Test PassthroughStage adapter."""
def test_passthrough_stage_name(self):
"""PassthroughStage stores name correctly."""
def test_passthrough_stage_properties(self):
"""PassthroughStage has correct properties."""
stage = PassthroughStage(name="test")
assert stage.name == "test"
def test_passthrough_stage_category(self):
"""PassthroughStage has 'render' category."""
stage = PassthroughStage()
assert stage.category == "render"
def test_passthrough_stage_is_optional(self):
"""PassthroughStage is optional."""
stage = PassthroughStage()
assert stage.optional is True
def test_passthrough_stage_capabilities(self):
"""PassthroughStage advertises render output capability."""
stage = PassthroughStage()
assert "render.output" in stage.capabilities
def test_passthrough_stage_dependencies(self):
"""PassthroughStage depends on source."""
stage = PassthroughStage()
assert "source" in stage.dependencies
def test_passthrough_stage_process_returns_data_unchanged(self):
def test_passthrough_stage_process_unchanged(self):
"""PassthroughStage.process() returns data unchanged."""
stage = PassthroughStage()
ctx = PipelineContext()
@@ -210,32 +139,17 @@ class TestPassthroughStage:
class TestSourceItemsToBufferStage:
"""Test SourceItemsToBufferStage adapter."""
def test_source_items_to_buffer_stage_name(self):
"""SourceItemsToBufferStage stores name correctly."""
def test_source_items_to_buffer_stage_properties(self):
"""SourceItemsToBufferStage has correct properties."""
stage = SourceItemsToBufferStage(name="custom-name")
assert stage.name == "custom-name"
def test_source_items_to_buffer_stage_category(self):
"""SourceItemsToBufferStage has 'render' category."""
stage = SourceItemsToBufferStage()
assert stage.category == "render"
def test_source_items_to_buffer_stage_is_optional(self):
"""SourceItemsToBufferStage is optional."""
stage = SourceItemsToBufferStage()
assert stage.optional is True
def test_source_items_to_buffer_stage_capabilities(self):
"""SourceItemsToBufferStage advertises render output capability."""
stage = SourceItemsToBufferStage()
assert "render.output" in stage.capabilities
def test_source_items_to_buffer_stage_dependencies(self):
"""SourceItemsToBufferStage depends on source."""
stage = SourceItemsToBufferStage()
assert "source" in stage.dependencies
def test_source_items_to_buffer_stage_process_single_line_item(self):
def test_source_items_to_buffer_stage_process_single_line(self):
"""SourceItemsToBufferStage converts single-line SourceItem."""
stage = SourceItemsToBufferStage()
ctx = PipelineContext()
@@ -247,10 +161,10 @@ class TestSourceItemsToBufferStage:
assert isinstance(result, list)
assert len(result) >= 1
# Result should be lines of text
assert all(isinstance(line, str) for line in result)
assert "Single line content" in result[0]
def test_source_items_to_buffer_stage_process_multiline_item(self):
def test_source_items_to_buffer_stage_process_multiline(self):
"""SourceItemsToBufferStage splits multiline SourceItem content."""
stage = SourceItemsToBufferStage()
ctx = PipelineContext()
@@ -283,63 +197,76 @@ class TestSourceItemsToBufferStage:
class TestEffectPluginStage:
"""Test EffectPluginStage adapter."""
"""Test EffectPluginStage adapter with real effect plugins."""
def test_effect_plugin_stage_name(self):
"""EffectPluginStage stores name correctly."""
mock_effect = MagicMock()
stage = EffectPluginStage(mock_effect, name="blur")
assert stage.name == "blur"
def test_effect_plugin_stage_properties(self):
"""EffectPluginStage has correct properties for real effects."""
discover_plugins()
registry = get_registry()
effect = registry.get("noise")
def test_effect_plugin_stage_category(self):
"""EffectPluginStage has 'effect' category."""
mock_effect = MagicMock()
stage = EffectPluginStage(mock_effect, name="blur")
stage = EffectPluginStage(effect, name="noise")
assert stage.name == "noise"
assert stage.category == "effect"
def test_effect_plugin_stage_is_not_optional(self):
"""EffectPluginStage is required when configured."""
mock_effect = MagicMock()
stage = EffectPluginStage(mock_effect, name="blur")
assert stage.optional is False
def test_effect_plugin_stage_capabilities(self):
"""EffectPluginStage advertises effect capability with name."""
mock_effect = MagicMock()
stage = EffectPluginStage(mock_effect, name="blur")
assert "effect.blur" in stage.capabilities
def test_effect_plugin_stage_dependencies(self):
"""EffectPluginStage has no static dependencies."""
mock_effect = MagicMock()
stage = EffectPluginStage(mock_effect, name="blur")
# EffectPluginStage has empty dependencies - they are resolved dynamically
assert stage.dependencies == set()
def test_effect_plugin_stage_stage_type(self):
"""EffectPluginStage.stage_type returns effect for non-HUD."""
mock_effect = MagicMock()
stage = EffectPluginStage(mock_effect, name="blur")
assert stage.stage_type == "effect"
assert "effect.noise" in stage.capabilities
def test_effect_plugin_stage_hud_special_handling(self):
"""EffectPluginStage has special handling for HUD effect."""
mock_effect = MagicMock()
stage = EffectPluginStage(mock_effect, name="hud")
discover_plugins()
registry = get_registry()
hud_effect = registry.get("hud")
stage = EffectPluginStage(hud_effect, name="hud")
assert stage.stage_type == "overlay"
assert stage.is_overlay is True
assert stage.render_order == 100
def test_effect_plugin_stage_process(self):
"""EffectPluginStage.process() calls effect.process()."""
mock_effect = MagicMock()
mock_effect.process.return_value = "processed_data"
def test_effect_plugin_stage_process_real_effect(self):
"""EffectPluginStage.process() calls real effect.process()."""
from engine.pipeline.params import PipelineParams
stage = EffectPluginStage(mock_effect, name="blur")
discover_plugins()
registry = get_registry()
effect = registry.get("noise")
stage = EffectPluginStage(effect, name="noise")
ctx = PipelineContext()
test_buffer = "test_buffer"
ctx.params = PipelineParams()
ctx.params.viewport_width = 80
ctx.params.viewport_height = 24
ctx.params.frame_number = 0
test_buffer = ["Line 1", "Line 2", "Line 3"]
result = stage.process(test_buffer, ctx)
assert result == "processed_data"
mock_effect.process.assert_called_once()
# Should return a list (possibly modified buffer)
assert isinstance(result, list)
# Noise effect should preserve line count
assert len(result) == len(test_buffer)
def test_effect_plugin_stage_process_with_real_figment(self):
"""EffectPluginStage processes figment effect correctly."""
from engine.pipeline.params import PipelineParams
discover_plugins()
registry = get_registry()
figment = registry.get("figment")
stage = EffectPluginStage(figment, name="figment")
ctx = PipelineContext()
ctx.params = PipelineParams()
ctx.params.viewport_width = 80
ctx.params.viewport_height = 24
ctx.params.frame_number = 0
test_buffer = ["Line 1", "Line 2", "Line 3"]
result = stage.process(test_buffer, ctx)
# Figment is an overlay effect
assert stage.is_overlay is True
assert stage.stage_type == "overlay"
# Result should be a list
assert isinstance(result, list)