Files
sideline/tests/test_display.py
David Gwilliam 238bac1bb2 feat: Complete pipeline hot-rebuild implementation with acceptance tests
- Implements pipeline hot-rebuild with state preservation (issue #43)
- Adds auto-injection of MVP stages for missing capabilities
- Adds radial camera mode for polar coordinate scanning
- Adds afterimage and motionblur effects using framebuffer history
- Adds comprehensive acceptance tests for camera modes and pipeline rebuild
- Updates presets.toml with new effect configurations

Related to: #35 (Pipeline Mutation API epic)
Closes: #43, #44, #45
2026-03-19 03:34:06 -07:00

434 lines
15 KiB
Python

"""
Tests for engine.display module.
"""
import sys
from unittest.mock import MagicMock, patch
import pytest
from engine.display import DisplayRegistry, NullDisplay, TerminalDisplay, render_border
from engine.display.backends.multi import MultiDisplay
class TestDisplayProtocol:
"""Test that display backends satisfy the Display protocol."""
def test_terminal_display_is_display(self):
"""TerminalDisplay satisfies Display protocol."""
display = TerminalDisplay()
assert hasattr(display, "init")
assert hasattr(display, "show")
assert hasattr(display, "clear")
assert hasattr(display, "cleanup")
def test_null_display_is_display(self):
"""NullDisplay satisfies Display protocol."""
display = NullDisplay()
assert hasattr(display, "init")
assert hasattr(display, "show")
assert hasattr(display, "clear")
assert hasattr(display, "cleanup")
class TestDisplayRegistry:
"""Tests for DisplayRegistry class."""
def setup_method(self):
"""Reset registry before each test."""
DisplayRegistry._backends = {}
DisplayRegistry._initialized = False
def test_register_adds_backend(self):
"""register adds a backend to the registry."""
DisplayRegistry.register("test", TerminalDisplay)
assert DisplayRegistry.get("test") == TerminalDisplay
def test_register_case_insensitive(self):
"""register is case insensitive."""
DisplayRegistry.register("TEST", TerminalDisplay)
assert DisplayRegistry.get("test") == TerminalDisplay
def test_get_returns_none_for_unknown(self):
"""get returns None for unknown backend."""
assert DisplayRegistry.get("unknown") is None
def test_list_backends_returns_all(self):
"""list_backends returns all registered backends."""
DisplayRegistry.register("a", TerminalDisplay)
DisplayRegistry.register("b", NullDisplay)
backends = DisplayRegistry.list_backends()
assert "a" in backends
assert "b" in backends
def test_create_returns_instance(self):
"""create returns a display instance."""
DisplayRegistry.register("test", NullDisplay)
display = DisplayRegistry.create("test")
assert isinstance(display, NullDisplay)
def test_create_returns_none_for_unknown(self):
"""create returns None for unknown backend."""
display = DisplayRegistry.create("unknown")
assert display is None
def test_initialize_registers_defaults(self):
"""initialize registers default backends."""
DisplayRegistry.initialize()
assert DisplayRegistry.get("terminal") == TerminalDisplay
assert DisplayRegistry.get("null") == NullDisplay
from engine.display.backends.pygame import PygameDisplay
from engine.display.backends.websocket import WebSocketDisplay
assert DisplayRegistry.get("websocket") == WebSocketDisplay
assert DisplayRegistry.get("pygame") == PygameDisplay
def test_initialize_idempotent(self):
"""initialize can be called multiple times safely."""
DisplayRegistry.initialize()
DisplayRegistry._backends["custom"] = TerminalDisplay
DisplayRegistry.initialize()
assert "custom" in DisplayRegistry.list_backends()
class TestTerminalDisplay:
"""Tests for TerminalDisplay class."""
def test_init_sets_dimensions(self):
"""init stores terminal dimensions."""
display = TerminalDisplay()
display.init(80, 24)
assert display.width == 80
assert display.height == 24
def test_show_returns_none(self):
"""show returns None after writing to stdout."""
display = TerminalDisplay()
display.width = 80
display.height = 24
display.show(["line1", "line2"])
def test_clear_does_not_error(self):
"""clear works without error."""
display = TerminalDisplay()
display.clear()
def test_cleanup_does_not_error(self):
"""cleanup works without error."""
display = TerminalDisplay()
display.cleanup()
def test_get_dimensions_returns_cached_value(self):
"""get_dimensions returns cached dimensions for stability."""
import os
from unittest.mock import patch
# Mock terminal size to ensure deterministic dimensions
term_size = os.terminal_size((80, 24))
with patch("os.get_terminal_size", return_value=term_size):
display = TerminalDisplay()
display.init(80, 24)
d1 = display.get_dimensions()
assert d1 == (80, 24)
def test_show_clears_screen_before_each_frame(self):
"""show clears previous frame to prevent visual wobble.
Regression test: Previously show() didn't clear the screen,
causing old content to remain and creating visual wobble.
The fix adds \\033[H\\033[J (cursor home + erase down) before each frame.
"""
from io import BytesIO
display = TerminalDisplay()
display.init(80, 24)
buffer = ["line1", "line2", "line3"]
fake_buffer = BytesIO()
fake_stdout = MagicMock()
fake_stdout.buffer = fake_buffer
with patch.object(sys, "stdout", fake_stdout):
display.show(buffer)
output = fake_buffer.getvalue().decode("utf-8")
assert output.startswith("\033[H\033[J"), (
f"Output should start with clear sequence, got: {repr(output[:20])}"
)
def test_show_clears_screen_on_subsequent_frames(self):
"""show clears screen on every frame, not just the first.
Regression test: Ensures each show() call includes the clear sequence.
"""
from io import BytesIO
# Use target_fps=0 to disable frame skipping in test
display = TerminalDisplay(target_fps=0)
display.init(80, 24)
buffer = ["line1", "line2"]
for i in range(3):
fake_buffer = BytesIO()
fake_stdout = MagicMock()
fake_stdout.buffer = fake_buffer
with patch.object(sys, "stdout", fake_stdout):
display.show(buffer)
output = fake_buffer.getvalue().decode("utf-8")
assert output.startswith("\033[H\033[J"), (
f"Frame {i} should start with clear sequence"
)
def test_get_dimensions_stable_across_rapid_calls(self):
"""get_dimensions should not fluctuate when called rapidly.
This test catches the bug where os.get_terminal_size() returns
inconsistent values, causing visual wobble.
"""
display = TerminalDisplay()
display.init(80, 24)
# Get dimensions 10 times rapidly (simulating frame loop)
dims = [display.get_dimensions() for _ in range(10)]
# All should be the same - this would fail if os.get_terminal_size()
# returns different values each call
assert len(set(dims)) == 1, f"Dimensions should be stable, got: {set(dims)}"
def test_show_with_border_uses_render_border(self):
"""show with border=True calls render_border with FPS."""
from unittest.mock import MagicMock
display = TerminalDisplay()
display.init(80, 24)
buffer = ["line1", "line2"]
# Mock get_monitor to provide FPS
mock_monitor = MagicMock()
mock_monitor.get_stats.return_value = {
"pipeline": {"avg_ms": 16.5},
"frame_count": 100,
}
# Mock render_border to verify it's called
with (
patch("engine.display.get_monitor", return_value=mock_monitor),
patch("engine.display.render_border", wraps=render_border) as mock_render,
):
display.show(buffer, border=True)
# Verify render_border was called with correct arguments
assert mock_render.called
args, kwargs = mock_render.call_args
# Arguments: buffer, width, height, fps, frame_time (positional)
assert args[0] == buffer
assert args[1] == 80
assert args[2] == 24
assert args[3] == pytest.approx(60.6, rel=0.1) # fps = 1000/16.5
assert args[4] == pytest.approx(16.5, rel=0.1)
assert kwargs == {} # no keyword arguments
class TestNullDisplay:
"""Tests for NullDisplay class."""
def test_init_stores_dimensions(self):
"""init stores dimensions."""
display = NullDisplay()
display.init(100, 50)
assert display.width == 100
assert display.height == 50
def test_show_does_nothing(self):
"""show discards buffer without error."""
display = NullDisplay()
display.show(["line1", "line2", "line3"])
def test_clear_does_nothing(self):
"""clear does nothing."""
display = NullDisplay()
display.clear()
def test_cleanup_does_nothing(self):
"""cleanup does nothing."""
display = NullDisplay()
display.cleanup()
def test_show_stores_last_buffer(self):
"""show stores last buffer for testing inspection."""
display = NullDisplay()
display.init(80, 24)
buffer = ["line1", "line2", "line3"]
display.show(buffer)
assert display._last_buffer == buffer
def test_show_tracks_last_buffer_across_calls(self):
"""show updates last_buffer on each call."""
display = NullDisplay()
display.init(80, 24)
display.show(["first"])
assert display._last_buffer == ["first"]
display.show(["second"])
assert display._last_buffer == ["second"]
class TestRenderBorder:
"""Tests for render_border function."""
def test_render_border_adds_corners(self):
"""render_border adds corner characters."""
from engine.display import render_border
buffer = ["hello", "world"]
result = render_border(buffer, width=10, height=5)
assert result[0][0] in "┌┎┍" # top-left
assert result[0][-1] in "┐┒┓" # top-right
assert result[-1][0] in "└┚┖" # bottom-left
assert result[-1][-1] in "┘┛┙" # bottom-right
def test_render_border_dimensions(self):
"""render_border output matches requested dimensions."""
from engine.display import render_border
buffer = ["line1", "line2", "line3"]
result = render_border(buffer, width=20, height=10)
# Output should be exactly height lines
assert len(result) == 10
# Each line should be exactly width characters
for line in result:
assert len(line) == 20
def test_render_border_with_fps(self):
"""render_border includes FPS in top border when provided."""
from engine.display import render_border
buffer = ["test"]
result = render_border(buffer, width=20, height=5, fps=60.0)
top_line = result[0]
assert "FPS:60" in top_line or "FPS: 60" in top_line
def test_render_border_with_frame_time(self):
"""render_border includes frame time in bottom border when provided."""
from engine.display import render_border
buffer = ["test"]
result = render_border(buffer, width=20, height=5, frame_time=16.5)
bottom_line = result[-1]
assert "16.5ms" in bottom_line
def test_render_border_crops_content_to_fit(self):
"""render_border crops content to fit within borders."""
from engine.display import render_border
# Buffer larger than viewport
buffer = ["x" * 100] * 50
result = render_border(buffer, width=20, height=10)
# Result shrinks to fit viewport
assert len(result) == 10
for line in result[1:-1]: # Skip border lines
assert len(line) == 20
def test_render_border_preserves_content(self):
"""render_border preserves content within borders."""
from engine.display import render_border
buffer = ["hello world", "test line"]
result = render_border(buffer, width=20, height=5)
# Content should appear in the middle rows
content_lines = result[1:-1]
assert any("hello world" in line for line in content_lines)
def test_render_border_with_small_buffer(self):
"""render_border handles buffers smaller than viewport."""
from engine.display import render_border
buffer = ["hi"]
result = render_border(buffer, width=20, height=10)
# Should still produce full viewport with padding
assert len(result) == 10
# All lines should be full width
for line in result:
assert len(line) == 20
class TestMultiDisplay:
"""Tests for MultiDisplay class."""
def test_init_stores_dimensions(self):
"""init stores dimensions and forwards to displays."""
mock_display1 = MagicMock()
mock_display2 = MagicMock()
multi = MultiDisplay([mock_display1, mock_display2])
multi.init(120, 40)
assert multi.width == 120
assert multi.height == 40
mock_display1.init.assert_called_once_with(120, 40, reuse=False)
mock_display2.init.assert_called_once_with(120, 40, reuse=False)
def test_show_forwards_to_all_displays(self):
"""show forwards buffer to all displays."""
mock_display1 = MagicMock()
mock_display2 = MagicMock()
multi = MultiDisplay([mock_display1, mock_display2])
buffer = ["line1", "line2"]
multi.show(buffer, border=False)
mock_display1.show.assert_called_once_with(buffer, border=False)
mock_display2.show.assert_called_once_with(buffer, border=False)
def test_clear_forwards_to_all_displays(self):
"""clear forwards to all displays."""
mock_display1 = MagicMock()
mock_display2 = MagicMock()
multi = MultiDisplay([mock_display1, mock_display2])
multi.clear()
mock_display1.clear.assert_called_once()
mock_display2.clear.assert_called_once()
def test_cleanup_forwards_to_all_displays(self):
"""cleanup forwards to all displays."""
mock_display1 = MagicMock()
mock_display2 = MagicMock()
multi = MultiDisplay([mock_display1, mock_display2])
multi.cleanup()
mock_display1.cleanup.assert_called_once()
mock_display2.cleanup.assert_called_once()
def test_empty_displays_list(self):
"""handles empty displays list gracefully."""
multi = MultiDisplay([])
multi.init(80, 24)
multi.show(["test"])
multi.clear()
multi.cleanup()
def test_init_with_reuse(self):
"""init passes reuse flag to child displays."""
mock_display = MagicMock()
multi = MultiDisplay([mock_display])
multi.init(80, 24, reuse=True)
mock_display.init.assert_called_once_with(80, 24, reuse=True)