forked from genewildish/Mainline
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:
@@ -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."""
|
||||
|
||||
156
tests/test_pipeline_introspection.py
Normal file
156
tests/test_pipeline_introspection.py
Normal 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
|
||||
167
tests/test_pipeline_introspection_demo.py
Normal file
167
tests/test_pipeline_introspection_demo.py
Normal 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
|
||||
113
tests/test_pipeline_metrics_sensor.py
Normal file
113
tests/test_pipeline_metrics_sensor.py
Normal 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
|
||||
Reference in New Issue
Block a user