""" 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.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 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)