From 66f4957c24092bb173d1cbaad71298694e2a31dd Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Sat, 21 Mar 2026 15:50:56 -0700 Subject: [PATCH] test(verification): Add visual verification tests for message overlay --- tests/test_visual_verification.py | 234 ++++++++++++++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 tests/test_visual_verification.py diff --git a/tests/test_visual_verification.py b/tests/test_visual_verification.py new file mode 100644 index 0000000..5c7ce00 --- /dev/null +++ b/tests/test_visual_verification.py @@ -0,0 +1,234 @@ +""" +Visual verification tests for message overlay and effect rendering. + +These tests verify that the sideline pipeline produces visual output +that matches the expected behavior of upstream/main, even if the +buffer format differs due to architectural differences. +""" + +import json +from pathlib import Path + +import pytest + +from engine.display import DisplayRegistry +from engine.pipeline import Pipeline, PipelineConfig, PipelineContext +from engine.pipeline.adapters import create_stage_from_display +from engine.pipeline.params import PipelineParams +from engine.pipeline.presets import get_preset + + +class TestMessageOverlayVisuals: + """Test message overlay visual rendering.""" + + def test_message_overlay_produces_output(self): + """Verify message overlay stage produces output when ntfy message is present.""" + # This test verifies the message overlay stage is working + # It doesn't compare with upstream, just verifies functionality + + from engine.pipeline.adapters.message_overlay import MessageOverlayStage + from engine.pipeline.adapters import MessageOverlayConfig + + # Test the rendering function directly + stage = MessageOverlayStage( + config=MessageOverlayConfig(enabled=True, display_secs=30) + ) + + # Test with a mock message + msg = ("Test Title", "Test Message Body", 0.0) + w, h = 80, 24 + + # Render overlay + overlay, _ = stage._render_message_overlay(msg, w, h, (None, None)) + + # Verify overlay has content + assert len(overlay) > 0, "Overlay should have content when message is present" + + # Verify overlay contains expected content + overlay_text = "".join(overlay) + # Note: Message body is rendered as block characters, not text + # The title appears in the metadata line + assert "Test Title" in overlay_text, "Overlay should contain message title" + assert "ntfy" in overlay_text, "Overlay should contain ntfy metadata" + assert "\033[" in overlay_text, "Overlay should contain ANSI codes" + + def test_message_overlay_appears_in_correct_position(self): + """Verify message overlay appears in centered position.""" + # This test verifies the message overlay positioning logic + # It checks that the overlay coordinates are calculated correctly + + from engine.pipeline.adapters.message_overlay import MessageOverlayStage + from engine.pipeline.adapters import MessageOverlayConfig + + stage = MessageOverlayStage(config=MessageOverlayConfig()) + + # Test positioning calculation + msg = ("Test Title", "Test Body", 0.0) + w, h = 80, 24 + + # Render overlay + overlay, _ = stage._render_message_overlay(msg, w, h, (None, None)) + + # Verify overlay has content + assert len(overlay) > 0, "Overlay should have content" + + # Verify overlay contains cursor positioning codes + overlay_text = "".join(overlay) + assert "\033[" in overlay_text, "Overlay should contain ANSI codes" + assert "H" in overlay_text, "Overlay should contain cursor positioning" + + # Verify panel is centered (check first line's position) + # Panel height is len(msg_rows) + 2 (content + meta + border) + # panel_top = max(0, (h - panel_h) // 2) + # First content line should be at panel_top + 1 + first_line = overlay[0] + assert "\033[" in first_line, "First line should have cursor positioning" + assert ";1H" in first_line, "First line should position at column 1" + + def test_theme_system_integration(self): + """Verify theme system is integrated with message overlay.""" + from engine import config as engine_config + from engine.themes import THEME_REGISTRY + + # Verify theme registry has expected themes + assert "green" in THEME_REGISTRY, "Green theme should exist" + assert "orange" in THEME_REGISTRY, "Orange theme should exist" + assert "purple" in THEME_REGISTRY, "Purple theme should exist" + + # Verify active theme is set + assert engine_config.ACTIVE_THEME is not None, "Active theme should be set" + assert engine_config.ACTIVE_THEME.name in THEME_REGISTRY, ( + "Active theme should be in registry" + ) + + # Verify theme has gradient colors + assert len(engine_config.ACTIVE_THEME.main_gradient) == 12, ( + "Main gradient should have 12 colors" + ) + assert len(engine_config.ACTIVE_THEME.message_gradient) == 12, ( + "Message gradient should have 12 colors" + ) + + +class TestPipelineExecutionOrder: + """Test pipeline execution order for visual consistency.""" + + def test_message_overlay_after_camera(self): + """Verify message overlay is applied after camera transformation.""" + from engine.pipeline import Pipeline, PipelineConfig, PipelineContext + from engine.pipeline.adapters import ( + create_stage_from_display, + MessageOverlayStage, + MessageOverlayConfig, + ) + from engine.display import DisplayRegistry + + # Create pipeline + config = PipelineConfig( + source="empty", + display="null", + camera="feed", + effects=[], + ) + + ctx = PipelineContext() + pipeline = Pipeline(config=config, context=ctx) + + # Add stages + from engine.data_sources.sources import EmptyDataSource + from engine.pipeline.adapters import DataSourceStage + + pipeline.add_stage( + "source", + DataSourceStage(EmptyDataSource(width=80, height=24), name="empty"), + ) + pipeline.add_stage( + "message_overlay", MessageOverlayStage(config=MessageOverlayConfig()) + ) + pipeline.add_stage( + "display", create_stage_from_display(DisplayRegistry.create("null"), "null") + ) + + # Build and check order + pipeline.build() + execution_order = pipeline.execution_order + + # Verify message_overlay comes after camera stages + camera_idx = next( + (i for i, name in enumerate(execution_order) if "camera" in name), -1 + ) + msg_idx = next( + (i for i, name in enumerate(execution_order) if "message_overlay" in name), + -1, + ) + + if camera_idx >= 0 and msg_idx >= 0: + assert msg_idx > camera_idx, "Message overlay should be after camera stage" + + +class TestCapturedOutputAnalysis: + """Test analysis of captured output files.""" + + def test_captured_files_exist(self): + """Verify captured output files exist.""" + sideline_path = Path("output/sideline_demo.json") + upstream_path = Path("output/upstream_demo.json") + + assert sideline_path.exists(), "Sideline capture file should exist" + assert upstream_path.exists(), "Upstream capture file should exist" + + def test_captured_files_valid(self): + """Verify captured output files are valid JSON.""" + sideline_path = Path("output/sideline_demo.json") + upstream_path = Path("output/upstream_demo.json") + + with open(sideline_path) as f: + sideline = json.load(f) + with open(upstream_path) as f: + upstream = json.load(f) + + # Verify structure + assert "frames" in sideline, "Sideline should have frames" + assert "frames" in upstream, "Upstream should have frames" + assert len(sideline["frames"]) > 0, "Sideline should have at least one frame" + assert len(upstream["frames"]) > 0, "Upstream should have at least one frame" + + def test_sideline_buffer_format(self): + """Verify sideline buffer format is plain text.""" + sideline_path = Path("output/sideline_demo.json") + + with open(sideline_path) as f: + sideline = json.load(f) + + # Check first frame + frame0 = sideline["frames"][0]["buffer"] + + # Sideline should have plain text lines (no cursor positioning) + # Check first few lines + for i, line in enumerate(frame0[:5]): + # Should not start with cursor positioning + if line.strip(): + assert not line.startswith("\033["), ( + f"Line {i} should not start with cursor positioning" + ) + # Should have actual content + assert len(line.strip()) > 0, f"Line {i} should have content" + + def test_upstream_buffer_format(self): + """Verify upstream buffer format includes cursor positioning.""" + upstream_path = Path("output/upstream_demo.json") + + with open(upstream_path) as f: + upstream = json.load(f) + + # Check first frame + frame0 = upstream["frames"][0]["buffer"] + + # Upstream should have cursor positioning codes + overlay_text = "".join(frame0[:10]) + assert "\033[" in overlay_text, "Upstream buffer should contain ANSI codes" + assert "H" in overlay_text, "Upstream buffer should contain cursor positioning" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])