- Fix TerminalDisplay: add screen clear each frame (cursor home + erase down) - Fix CameraStage: use set_canvas_size instead of read-only viewport properties - Fix Glitch effect: preserve visible line lengths, remove cursor positioning - Fix Fade effect: return original line when fade=0 instead of empty string - Fix Noise effect: use input line length instead of terminal_width - Remove HUD effect from all presets (redundant with border FPS display) - Add regression tests for effect dimension stability - Add docs/ARCHITECTURE.md with Mermaid diagrams - Add mise tasks: diagram-ascii, diagram-validate, diagram-check - Move markdown docs to docs/ (ARCHITECTURE, Refactor, hardware specs) - Remove redundant requirements files (use pyproject.toml) - Add *.dot and *.png to .gitignore Closes #25
310 lines
10 KiB
Python
310 lines
10 KiB
Python
"""
|
|
Tests for engine.display module.
|
|
"""
|
|
|
|
import sys
|
|
from unittest.mock import MagicMock
|
|
|
|
from engine.display import DisplayRegistry, NullDisplay, TerminalDisplay
|
|
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.sixel import SixelDisplay
|
|
from engine.display.backends.websocket import WebSocketDisplay
|
|
|
|
assert DisplayRegistry.get("websocket") == WebSocketDisplay
|
|
assert DisplayRegistry.get("sixel") == SixelDisplay
|
|
|
|
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."""
|
|
display = TerminalDisplay()
|
|
display.init(80, 24)
|
|
|
|
# First call should set cache
|
|
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
|
|
from unittest.mock import patch
|
|
|
|
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
|
|
from unittest.mock import patch
|
|
|
|
# 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)}"
|
|
|
|
|
|
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 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)
|