feat(app): add direct CLI mode, validation framework, fixtures, and UI panel integration

- Add run_pipeline_mode_direct() for constructing pipelines from CLI flags
- Add engine/pipeline/validation.py with validate_pipeline_config() and MVP rules
- Add fixtures system: engine/fixtures/headlines.json for cached test data
- Enhance fetch.py to use fixtures cache path
- Support fixture source in run_pipeline_mode()
- Add --pipeline-* CLI flags: source, effects, camera, display, UI, border
- Integrate UIPanel: raw mode, preset picker, event callbacks, param adjustment
- Add UI_PRESET support in app and hot-rebuild pipeline on preset change
- Add test UIPanel rendering and interaction tests

This provides a flexible pipeline construction interface with validation and interactive control.

Fixes #29, #30, #31
This commit is contained in:
2026-03-18 12:19:18 -07:00
parent 21fb210c6e
commit cdcdb7b172
8 changed files with 1793 additions and 5 deletions

184
tests/test_ui_panel.py Normal file
View File

@@ -0,0 +1,184 @@
"""
Tests for UIPanel.
"""
from engine.pipeline.ui import StageControl, UIConfig, UIPanel
class MockStage:
"""Mock stage for testing."""
def __init__(self, name, category="effect"):
self.name = name
self.category = category
self._enabled = True
def is_enabled(self):
return self._enabled
class TestUIPanel:
"""Tests for UIPanel."""
def test_init(self):
"""UIPanel initializes with default config."""
panel = UIPanel()
assert panel.config.panel_width == 24
assert panel.config.stage_list_height == 12
assert panel.scroll_offset == 0
assert panel.selected_stage is None
def test_register_stage(self):
"""register_stage adds a stage control."""
panel = UIPanel()
stage = MockStage("noise")
panel.register_stage(stage, enabled=True)
assert "noise" in panel.stages
ctrl = panel.stages["noise"]
assert ctrl.name == "noise"
assert ctrl.enabled is True
assert ctrl.selected is False
def test_select_stage(self):
"""select_stage sets selection."""
panel = UIPanel()
stage1 = MockStage("noise")
stage2 = MockStage("fade")
panel.register_stage(stage1)
panel.register_stage(stage2)
panel.select_stage("fade")
assert panel.selected_stage == "fade"
assert panel.stages["fade"].selected is True
assert panel.stages["noise"].selected is False
def test_toggle_stage(self):
"""toggle_stage flips enabled state."""
panel = UIPanel()
stage = MockStage("glitch")
panel.register_stage(stage, enabled=True)
result = panel.toggle_stage("glitch")
assert result is False
assert panel.stages["glitch"].enabled is False
result = panel.toggle_stage("glitch")
assert result is True
def test_get_enabled_stages(self):
"""get_enabled_stages returns only enabled stage names."""
panel = UIPanel()
panel.register_stage(MockStage("noise"), enabled=True)
panel.register_stage(MockStage("fade"), enabled=False)
panel.register_stage(MockStage("glitch"), enabled=True)
enabled = panel.get_enabled_stages()
assert set(enabled) == {"noise", "glitch"}
def test_scroll_stages(self):
"""scroll_stages moves the view."""
panel = UIPanel(UIConfig(stage_list_height=3))
for i in range(10):
panel.register_stage(MockStage(f"stage{i}"))
assert panel.scroll_offset == 0
panel.scroll_stages(1)
assert panel.scroll_offset == 1
panel.scroll_stages(-1)
assert panel.scroll_offset == 0
# Clamp at max
panel.scroll_stages(100)
assert panel.scroll_offset == 7 # 10 - 3 = 7
def test_render_produces_lines(self):
"""render produces list of strings of correct width."""
panel = UIPanel(UIConfig(panel_width=20))
panel.register_stage(MockStage("noise"), enabled=True)
panel.register_stage(MockStage("fade"), enabled=False)
panel.select_stage("noise")
lines = panel.render(80, 24)
# All lines should be exactly panel_width chars (20)
for line in lines:
assert len(line) == 20
# Should have header, stage rows, separator, params area, footer
assert len(lines) >= 5
def test_process_key_event_space_toggles_stage(self):
"""process_key_event with space toggles UI panel visibility."""
panel = UIPanel()
stage = MockStage("glitch")
panel.register_stage(stage, enabled=True)
panel.select_stage("glitch")
# Space should now toggle UI panel visibility, not stage
assert panel._show_panel is True
handled = panel.process_key_event(" ")
assert handled is True
assert panel._show_panel is False
# Pressing space again should show panel
handled = panel.process_key_event(" ")
assert panel._show_panel is True
def test_process_key_event_space_does_not_toggle_in_picker(self):
"""Space should not toggle UI panel when preset picker is active."""
panel = UIPanel()
panel._show_panel = True
panel._show_preset_picker = True
handled = panel.process_key_event(" ")
assert handled is False # Not handled when picker active
assert panel._show_panel is True # Unchanged
def test_process_key_event_s_selects_next(self):
"""process_key_event with s cycles selection."""
panel = UIPanel()
panel.register_stage(MockStage("noise"))
panel.register_stage(MockStage("fade"))
panel.register_stage(MockStage("glitch"))
panel.select_stage("noise")
handled = panel.process_key_event("s")
assert handled is True
assert panel.selected_stage == "fade"
def test_process_key_event_hjkl_navigation(self):
"""process_key_event with HJKL keys."""
panel = UIPanel()
stage = MockStage("noise")
panel.register_stage(stage)
panel.select_stage("noise")
# J or Down should scroll or adjust param
assert panel.scroll_stages(1) is None # Just test it doesn't error
# H or Left should adjust param (when param selected)
panel.selected_stage = "noise"
panel._focused_param = "intensity"
panel.stages["noise"].params["intensity"] = 0.5
# Left/H should decrease
handled = panel.process_key_event("h")
assert handled is True
# L or Right should increase
handled = panel.process_key_event("l")
assert handled is True
# K should scroll up
panel.selected_stage = None
handled = panel.process_key_event("k")
assert handled is True
def test_set_event_callback(self):
"""set_event_callback registers callback."""
panel = UIPanel()
called = []
def callback(stage_name, enabled):
called.append((stage_name, enabled))
panel.set_event_callback("stage_toggled", callback)
panel.toggle_stage("test") # No stage, won't trigger
# Simulate toggle through event
panel._emit_event("stage_toggled", stage_name="noise", enabled=False)
assert called == [("noise", False)]
def test_register_stage_returns_control(self):
"""register_stage should return the StageControl instance."""
panel = UIPanel()
stage = MockStage("noise_effect")
control = panel.register_stage(stage, enabled=True)
assert control is not None
assert isinstance(control, StageControl)
assert control.name == "noise_effect"
assert control.enabled is True