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