feat: add pipeline introspection demo mode

- Add PipelineIntrospectionSource that renders live ASCII DAG with metrics
- Add PipelineMetricsSensor exposing pipeline performance as sensor values
- Add PipelineIntrospectionDemo controller with 3-phase animation:
  - Phase 1: Toggle effects one at a time (3s each)
  - Phase 2: LFO drives intensity default→max→min→default
  - Phase 3: All effects with shared LFO (infinite loop)
- Add pipeline-inspect preset
- Add get_frame_times() to Pipeline for sparkline data
- Add tests for new components
- Update mise.toml with pipeline-inspect preset task
This commit is contained in:
2026-03-16 16:55:57 -07:00
parent 2a41a90d79
commit f638fb7597
14 changed files with 1186 additions and 55 deletions

View File

@@ -31,7 +31,7 @@ class TestStageRegistry:
sources = StageRegistry.list("source")
assert "HeadlinesDataSource" in sources
assert "PoetryDataSource" in sources
assert "PipelineDataSource" in sources
assert "PipelineIntrospectionSource" in sources
def test_discover_stages_registers_displays(self):
"""discover_stages registers display stages."""

View File

@@ -0,0 +1,156 @@
"""
Tests for PipelineIntrospectionSource.
"""
from engine.pipeline_sources.pipeline_introspection import PipelineIntrospectionSource
class TestPipelineIntrospectionSource:
"""Tests for PipelineIntrospectionSource."""
def test_basic_init(self):
"""Source initializes with defaults."""
source = PipelineIntrospectionSource()
assert source.name == "pipeline-inspect"
assert source.is_dynamic is True
assert source.frame == 0
def test_init_with_pipelines(self):
"""Source initializes with custom pipelines list."""
source = PipelineIntrospectionSource(
pipelines=[], viewport_width=100, viewport_height=40
)
assert source.viewport_width == 100
assert source.viewport_height == 40
def test_inlet_outlet_types(self):
"""Source has correct inlet/outlet types."""
source = PipelineIntrospectionSource()
# inlet should be NONE (source), outlet should be SOURCE_ITEMS
from engine.pipeline.core import DataType
assert DataType.NONE in source.inlet_types
assert DataType.SOURCE_ITEMS in source.outlet_types
def test_fetch_returns_items(self):
"""fetch() returns SourceItem list."""
source = PipelineIntrospectionSource()
items = source.fetch()
assert len(items) == 1
assert items[0].source == "pipeline-inspect"
def test_fetch_increments_frame(self):
"""fetch() increments frame counter."""
source = PipelineIntrospectionSource()
assert source.frame == 0
source.fetch()
assert source.frame == 1
source.fetch()
assert source.frame == 2
def test_get_items(self):
"""get_items() returns list of SourceItems."""
source = PipelineIntrospectionSource()
items = source.get_items()
assert isinstance(items, list)
assert len(items) > 0
assert items[0].source == "pipeline-inspect"
def test_add_pipeline(self):
"""add_pipeline() adds pipeline to list."""
source = PipelineIntrospectionSource()
mock_pipeline = object()
source.add_pipeline(mock_pipeline)
assert mock_pipeline in source._pipelines
def test_remove_pipeline(self):
"""remove_pipeline() removes pipeline from list."""
source = PipelineIntrospectionSource()
mock_pipeline = object()
source.add_pipeline(mock_pipeline)
source.remove_pipeline(mock_pipeline)
assert mock_pipeline not in source._pipelines
class TestPipelineIntrospectionRender:
"""Tests for rendering methods."""
def test_render_header_no_pipelines(self):
"""_render_header returns default when no pipelines."""
source = PipelineIntrospectionSource()
lines = source._render_header()
assert len(lines) == 1
assert "PIPELINE INTROSPECTION" in lines[0]
def test_render_bar(self):
"""_render_bar creates correct bar."""
source = PipelineIntrospectionSource()
bar = source._render_bar(50, 10)
assert len(bar) == 10
assert bar.count("") == 5
assert bar.count("") == 5
def test_render_bar_zero(self):
"""_render_bar handles zero percentage."""
source = PipelineIntrospectionSource()
bar = source._render_bar(0, 10)
assert bar == "" * 10
def test_render_bar_full(self):
"""_render_bar handles 100%."""
source = PipelineIntrospectionSource()
bar = source._render_bar(100, 10)
assert bar == "" * 10
def test_render_sparkline(self):
"""_render_sparkline creates sparkline."""
source = PipelineIntrospectionSource()
values = [1.0, 2.0, 3.0, 4.0, 5.0]
sparkline = source._render_sparkline(values, 10)
assert len(sparkline) == 10
def test_render_sparkline_empty(self):
"""_render_sparkline handles empty values."""
source = PipelineIntrospectionSource()
sparkline = source._render_sparkline([], 10)
assert sparkline == " " * 10
def test_render_footer_no_pipelines(self):
"""_render_footer shows collecting data when no pipelines."""
source = PipelineIntrospectionSource()
lines = source._render_footer()
assert len(lines) >= 2
assert "collecting data" in lines[1] or "Frame Time" in lines[0]
class TestPipelineIntrospectionFull:
"""Integration tests."""
def test_render_empty(self):
"""_render works with no pipelines."""
source = PipelineIntrospectionSource()
lines = source._render()
assert len(lines) > 0
assert "PIPELINE INTROSPECTION" in lines[0]
def test_render_with_mock_pipeline(self):
"""_render works with mock pipeline."""
source = PipelineIntrospectionSource()
class MockStage:
category = "source"
name = "test"
class MockPipeline:
stages = {"test": MockStage()}
execution_order = ["test"]
def get_metrics_summary(self):
return {"stages": {"test": {"avg_ms": 1.5}}, "avg_ms": 2.0, "fps": 60}
def get_frame_times(self):
return [1.0, 2.0, 3.0]
source.add_pipeline(MockPipeline())
lines = source._render()
assert len(lines) > 0

View File

@@ -0,0 +1,167 @@
"""
Tests for PipelineIntrospectionDemo.
"""
from engine.pipeline.pipeline_introspection_demo import (
DemoConfig,
DemoPhase,
PhaseState,
PipelineIntrospectionDemo,
)
class MockPipeline:
"""Mock pipeline for testing."""
pass
class MockEffectConfig:
"""Mock effect config."""
def __init__(self):
self.enabled = False
self.intensity = 0.5
class MockEffect:
"""Mock effect for testing."""
def __init__(self, name):
self.name = name
self.config = MockEffectConfig()
class MockRegistry:
"""Mock effect registry."""
def __init__(self, effects):
self._effects = {e.name: e for e in effects}
def get(self, name):
return self._effects.get(name)
class TestDemoPhase:
"""Tests for DemoPhase enum."""
def test_phases_exist(self):
"""All three phases exist."""
assert DemoPhase.PHASE_1_TOGGLE is not None
assert DemoPhase.PHASE_2_LFO is not None
assert DemoPhase.PHASE_3_SHARED_LFO is not None
class TestDemoConfig:
"""Tests for DemoConfig."""
def test_defaults(self):
"""Default config has sensible values."""
config = DemoConfig()
assert config.effect_cycle_duration == 3.0
assert config.gap_duration == 1.0
assert config.lfo_duration == 4.0
assert config.phase_2_effect_duration == 4.0
assert config.phase_3_lfo_duration == 6.0
class TestPhaseState:
"""Tests for PhaseState."""
def test_defaults(self):
"""PhaseState initializes correctly."""
state = PhaseState(phase=DemoPhase.PHASE_1_TOGGLE, start_time=0.0)
assert state.phase == DemoPhase.PHASE_1_TOGGLE
assert state.start_time == 0.0
assert state.current_effect_index == 0
class TestPipelineIntrospectionDemo:
"""Tests for PipelineIntrospectionDemo."""
def test_basic_init(self):
"""Demo initializes with defaults."""
demo = PipelineIntrospectionDemo(pipeline=None)
assert demo.phase == DemoPhase.PHASE_1_TOGGLE
assert demo.effect_names == ["noise", "fade", "glitch", "firehose"]
def test_init_with_custom_effects(self):
"""Demo initializes with custom effects."""
demo = PipelineIntrospectionDemo(pipeline=None, effect_names=["noise", "fade"])
assert demo.effect_names == ["noise", "fade"]
def test_phase_display(self):
"""phase_display returns correct string."""
demo = PipelineIntrospectionDemo(pipeline=None)
assert "Phase 1" in demo.phase_display
def test_shared_oscillator_created(self):
"""Shared oscillator is created."""
demo = PipelineIntrospectionDemo(pipeline=None)
assert demo.shared_oscillator is not None
assert demo.shared_oscillator.name == "demo-lfo"
class TestPipelineIntrospectionDemoUpdate:
"""Tests for update method."""
def test_update_returns_dict(self):
"""update() returns a dict with expected keys."""
demo = PipelineIntrospectionDemo(pipeline=None)
result = demo.update()
assert "phase" in result
assert "phase_display" in result
assert "effect_states" in result
def test_update_phase_1_structure(self):
"""Phase 1 has correct structure."""
demo = PipelineIntrospectionDemo(pipeline=None)
result = demo.update()
assert result["phase"] == "PHASE_1_TOGGLE"
assert "current_effect" in result
def test_effect_states_structure(self):
"""effect_states has correct structure."""
demo = PipelineIntrospectionDemo(pipeline=None)
result = demo.update()
states = result["effect_states"]
for name in demo.effect_names:
assert name in states
assert "enabled" in states[name]
assert "intensity" in states[name]
class TestPipelineIntrospectionDemoPhases:
"""Tests for phase transitions."""
def test_phase_1_initial(self):
"""Starts in phase 1."""
demo = PipelineIntrospectionDemo(pipeline=None)
assert demo.phase == DemoPhase.PHASE_1_TOGGLE
def test_shared_oscillator_not_started_initially(self):
"""Shared oscillator not started in phase 1."""
demo = PipelineIntrospectionDemo(pipeline=None)
assert demo.shared_oscillator is not None
# The oscillator.start() is called when transitioning to phase 3
class TestPipelineIntrospectionDemoCleanup:
"""Tests for cleanup method."""
def test_cleanup_no_error(self):
"""cleanup() runs without error."""
demo = PipelineIntrospectionDemo(pipeline=None)
demo.cleanup() # Should not raise
def test_cleanup_resets_effects(self):
"""cleanup() resets effects."""
demo = PipelineIntrospectionDemo(pipeline=None)
demo._apply_effect_states(
{
"noise": {"enabled": True, "intensity": 1.0},
"fade": {"enabled": True, "intensity": 1.0},
}
)
demo.cleanup()
# If we had a mock registry, we could verify effects were reset

View File

@@ -0,0 +1,113 @@
"""
Tests for PipelineMetricsSensor.
"""
from engine.sensors.pipeline_metrics import PipelineMetricsSensor
class MockPipeline:
"""Mock pipeline for testing."""
def __init__(self, metrics=None):
self._metrics = metrics or {}
def get_metrics_summary(self):
return self._metrics
class TestPipelineMetricsSensor:
"""Tests for PipelineMetricsSensor."""
def test_basic_init(self):
"""Sensor initializes with defaults."""
sensor = PipelineMetricsSensor()
assert sensor.name == "pipeline"
assert sensor.available is False
def test_init_with_pipeline(self):
"""Sensor initializes with pipeline."""
mock = MockPipeline()
sensor = PipelineMetricsSensor(mock)
assert sensor.available is True
def test_set_pipeline(self):
"""set_pipeline() updates pipeline."""
sensor = PipelineMetricsSensor()
assert sensor.available is False
sensor.set_pipeline(MockPipeline())
assert sensor.available is True
def test_read_no_pipeline(self):
"""read() returns None when no pipeline."""
sensor = PipelineMetricsSensor()
assert sensor.read() is None
def test_read_with_metrics(self):
"""read() returns sensor value with metrics."""
mock = MockPipeline(
{
"total_ms": 18.5,
"fps": 54.0,
"avg_ms": 18.5,
"min_ms": 15.0,
"max_ms": 22.0,
"stages": {"render": {"avg_ms": 12.0}, "noise": {"avg_ms": 3.0}},
}
)
sensor = PipelineMetricsSensor(mock)
val = sensor.read()
assert val is not None
assert val.sensor_name == "pipeline"
assert val.value == 18.5
def test_read_with_error(self):
"""read() returns None when metrics have error."""
mock = MockPipeline({"error": "No metrics collected"})
sensor = PipelineMetricsSensor(mock)
assert sensor.read() is None
def test_get_stage_timing(self):
"""get_stage_timing() returns stage timing."""
mock = MockPipeline(
{
"stages": {"render": {"avg_ms": 12.0}, "noise": {"avg_ms": 3.0}},
}
)
sensor = PipelineMetricsSensor(mock)
assert sensor.get_stage_timing("render") == 12.0
assert sensor.get_stage_timing("noise") == 3.0
assert sensor.get_stage_timing("nonexistent") == 0.0
def test_get_stage_timing_no_pipeline(self):
"""get_stage_timing() returns 0 when no pipeline."""
sensor = PipelineMetricsSensor()
assert sensor.get_stage_timing("test") == 0.0
def test_get_all_timings(self):
"""get_all_timings() returns all stage timings."""
mock = MockPipeline(
{
"stages": {"render": {"avg_ms": 12.0}, "noise": {"avg_ms": 3.0}},
}
)
sensor = PipelineMetricsSensor(mock)
timings = sensor.get_all_timings()
assert timings == {"render": {"avg_ms": 12.0}, "noise": {"avg_ms": 3.0}}
def test_get_frame_history(self):
"""get_frame_history() returns frame times."""
MockPipeline()
class MockPipelineWithFrames:
def get_frame_times(self):
return [1.0, 2.0, 3.0]
sensor = PipelineMetricsSensor(MockPipelineWithFrames())
history = sensor.get_frame_history()
assert history == [1.0, 2.0, 3.0]
def test_start_stop(self):
"""start() and stop() work."""
sensor = PipelineMetricsSensor()
assert sensor.start() is True
sensor.stop() # Should not raise