""" Tests for engine.display.backends.websocket module. """ from unittest.mock import MagicMock, patch import pytest from engine.display.backends.websocket import WebSocketDisplay class TestWebSocketDisplayImport: """Test that websocket module can be imported.""" def test_import_does_not_error(self): """Module imports without error.""" from engine.display import backends assert backends is not None class TestWebSocketDisplayInit: """Tests for WebSocketDisplay initialization.""" def test_default_init(self): """Default initialization sets correct defaults.""" with patch("engine.display.backends.websocket.websockets", None): display = WebSocketDisplay() assert display.host == "0.0.0.0" assert display.port == 8765 assert display.http_port == 8766 assert display.width == 80 assert display.height == 24 def test_custom_init(self): """Custom initialization uses provided values.""" with patch("engine.display.backends.websocket.websockets", None): display = WebSocketDisplay(host="localhost", port=9000, http_port=9001) assert display.host == "localhost" assert display.port == 9000 assert display.http_port == 9001 def test_is_available_when_websockets_present(self): """is_available returns True when websockets is available.""" pytest.importorskip("websockets") display = WebSocketDisplay() assert display.is_available() is True @pytest.mark.skipif( pytest.importorskip("websockets") is not None, reason="websockets is available" ) def test_is_available_when_websockets_missing(self): """is_available returns False when websockets is not available.""" display = WebSocketDisplay() assert display.is_available() is False class TestWebSocketDisplayProtocol: """Test that WebSocketDisplay satisfies Display protocol.""" def test_websocket_display_is_display(self): """WebSocketDisplay satisfies Display protocol.""" with patch("engine.display.backends.websocket.websockets", MagicMock()): display = WebSocketDisplay() assert hasattr(display, "init") assert hasattr(display, "show") assert hasattr(display, "clear") assert hasattr(display, "cleanup") class TestWebSocketDisplayMethods: """Tests for WebSocketDisplay methods.""" def test_init_stores_dimensions(self): """init stores terminal dimensions.""" with patch("engine.display.backends.websocket.websockets", MagicMock()): display = WebSocketDisplay() display.init(100, 40) assert display.width == 100 assert display.height == 40 @pytest.mark.skip(reason="port binding conflict in CI environment") def test_client_count_initially_zero(self): """client_count returns 0 when no clients connected.""" with patch("engine.display.backends.websocket.websockets", MagicMock()): display = WebSocketDisplay() assert display.client_count() == 0 def test_get_ws_port(self): """get_ws_port returns configured port.""" with patch("engine.display.backends.websocket.websockets", MagicMock()): display = WebSocketDisplay(port=9000) assert display.get_ws_port() == 9000 def test_get_http_port(self): """get_http_port returns configured port.""" with patch("engine.display.backends.websocket.websockets", MagicMock()): display = WebSocketDisplay(http_port=9001) assert display.get_http_port() == 9001 def test_frame_delay_defaults_to_zero(self): """get_frame_delay returns 0 by default.""" with patch("engine.display.backends.websocket.websockets", MagicMock()): display = WebSocketDisplay() assert display.get_frame_delay() == 0.0 def test_set_frame_delay(self): """set_frame_delay stores the value.""" with patch("engine.display.backends.websocket.websockets", MagicMock()): display = WebSocketDisplay() display.set_frame_delay(0.05) assert display.get_frame_delay() == 0.05 class TestWebSocketDisplayCallbacks: """Tests for WebSocketDisplay callback methods.""" def test_set_client_connected_callback(self): """set_client_connected_callback stores callback.""" with patch("engine.display.backends.websocket.websockets", MagicMock()): display = WebSocketDisplay() callback = MagicMock() display.set_client_connected_callback(callback) assert display._client_connected_callback is callback def test_set_client_disconnected_callback(self): """set_client_disconnected_callback stores callback.""" with patch("engine.display.backends.websocket.websockets", MagicMock()): display = WebSocketDisplay() callback = MagicMock() display.set_client_disconnected_callback(callback) assert display._client_disconnected_callback is callback class TestWebSocketDisplayUnavailable: """Tests when WebSocket support is unavailable.""" @pytest.mark.skipif( pytest.importorskip("websockets") is not None, reason="websockets is available" ) def test_start_server_noop_when_unavailable(self): """start_server does nothing when websockets unavailable.""" display = WebSocketDisplay() display.start_server() assert display._server_thread is None @pytest.mark.skipif( pytest.importorskip("websockets") is not None, reason="websockets is available" ) def test_start_http_server_noop_when_unavailable(self): """start_http_server does nothing when websockets unavailable.""" display = WebSocketDisplay() display.start_http_server() assert display._http_thread is None @pytest.mark.skipif( pytest.importorskip("websockets") is not None, reason="websockets is available" ) def test_show_noops_when_unavailable(self): """show does nothing when websockets unavailable.""" display = WebSocketDisplay() display.show(["line1", "line2"]) class TestWebSocketUIPanelIntegration: """Tests for WebSocket-UIPanel integration for remote control.""" def test_set_controller_stores_controller(self): """set_controller stores the controller reference.""" with patch("engine.display.backends.websocket.websockets", MagicMock()): display = WebSocketDisplay() mock_controller = MagicMock() display.set_controller(mock_controller) assert display._controller is mock_controller def test_set_command_callback_stores_callback(self): """set_command_callback stores the callback.""" with patch("engine.display.backends.websocket.websockets", MagicMock()): display = WebSocketDisplay() callback = MagicMock() display.set_command_callback(callback) assert display._command_callback is callback def test_get_state_snapshot_returns_none_without_controller(self): """_get_state_snapshot returns None when no controller is set.""" with patch("engine.display.backends.websocket.websockets", MagicMock()): display = WebSocketDisplay() assert display._get_state_snapshot() is None def test_get_state_snapshot_returns_controller_state(self): """_get_state_snapshot returns state from controller.""" with patch("engine.display.backends.websocket.websockets", MagicMock()): display = WebSocketDisplay() # Create mock controller with expected attributes mock_controller = MagicMock() mock_controller.stages = { "test_stage": MagicMock( enabled=True, params={"intensity": 0.5}, selected=False ) } mock_controller._current_preset = "demo" mock_controller._presets = ["demo", "test"] mock_controller.selected_stage = "test_stage" display.set_controller(mock_controller) state = display._get_state_snapshot() assert state is not None assert "stages" in state assert "test_stage" in state["stages"] assert state["stages"]["test_stage"]["enabled"] is True assert state["stages"]["test_stage"]["params"] == {"intensity": 0.5} assert state["preset"] == "demo" assert state["presets"] == ["demo", "test"] assert state["selected_stage"] == "test_stage" def test_get_state_snapshot_handles_missing_attributes(self): """_get_state_snapshot handles controller without all attributes.""" with patch("engine.display.backends.websocket.websockets", MagicMock()): display = WebSocketDisplay() # Create mock controller without stages attribute using spec # This prevents MagicMock from auto-creating the attribute mock_controller = MagicMock(spec=[]) # Empty spec means no attributes display.set_controller(mock_controller) state = display._get_state_snapshot() assert state == {} def test_broadcast_state_sends_to_clients(self): """broadcast_state sends state update to all connected clients.""" with patch("engine.display.backends.websocket.websockets", MagicMock()): display = WebSocketDisplay() # Mock client with send method mock_client = MagicMock() mock_client.send = MagicMock() display._clients.add(mock_client) test_state = {"test": "state"} display.broadcast_state(test_state) # Verify send was called with JSON containing state mock_client.send.assert_called_once() call_args = mock_client.send.call_args[0][0] assert '"type": "state"' in call_args assert '"test"' in call_args def test_broadcast_state_noop_when_no_clients(self): """broadcast_state does nothing when no clients connected.""" with patch("engine.display.backends.websocket.websockets", MagicMock()): display = WebSocketDisplay() display._clients.clear() # Should not raise error display.broadcast_state({"test": "state"}) class TestWebSocketHTTPServerPath: """Tests for WebSocket HTTP server client directory path calculation.""" def test_client_dir_path_calculation(self): """Client directory path is correctly calculated from websocket.py location.""" import os # Use the actual websocket.py file location, not the test file websocket_module = __import__( "engine.display.backends.websocket", fromlist=["WebSocketDisplay"] ) websocket_file = websocket_module.__file__ parts = websocket_file.split(os.sep) if "engine" in parts: engine_idx = parts.index("engine") project_root = os.sep.join(parts[:engine_idx]) client_dir = os.path.join(project_root, "client") else: # Fallback calculation (shouldn't happen in normal test runs) client_dir = os.path.join( os.path.dirname( os.path.dirname(os.path.dirname(os.path.dirname(websocket_file))) ), "client", ) # Verify the client directory exists and contains expected files assert os.path.exists(client_dir), f"Client directory not found: {client_dir}" assert "index.html" in os.listdir(client_dir), ( "index.html not found in client directory" ) assert "editor.html" in os.listdir(client_dir), ( "editor.html not found in client directory" ) # Verify the path is correct (should be .../Mainline/client) assert client_dir.endswith("client"), ( f"Client dir should end with 'client': {client_dir}" ) assert "Mainline" in client_dir, ( f"Client dir should contain 'Mainline': {client_dir}" ) def test_http_server_directory_serves_client_files(self): """HTTP server directory correctly serves client files.""" import os # Use the actual websocket.py file location, not the test file websocket_module = __import__( "engine.display.backends.websocket", fromlist=["WebSocketDisplay"] ) websocket_file = websocket_module.__file__ parts = websocket_file.split(os.sep) if "engine" in parts: engine_idx = parts.index("engine") project_root = os.sep.join(parts[:engine_idx]) client_dir = os.path.join(project_root, "client") else: client_dir = os.path.join( os.path.dirname( os.path.dirname(os.path.dirname(os.path.dirname(websocket_file))) ), "client", ) # Verify the handler would be able to serve files from this directory # We can't actually instantiate the handler without a valid request, # but we can verify the directory is accessible assert os.access(client_dir, os.R_OK), ( f"Client directory not readable: {client_dir}" ) # Verify key files exist index_path = os.path.join(client_dir, "index.html") editor_path = os.path.join(client_dir, "editor.html") assert os.path.exists(index_path), f"index.html not found at: {index_path}" assert os.path.exists(editor_path), f"editor.html not found at: {editor_path}" # Verify files are readable assert os.access(index_path, os.R_OK), "index.html not readable" assert os.access(editor_path, os.R_OK), "editor.html not readable" def test_old_buggy_path_does_not_find_client_directory(self): """The old buggy path (3 dirname calls) should NOT find the client directory. This test verifies that the old buggy behavior would have failed. The old code used: client_dir = os.path.join( os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "client" ) This would resolve to: .../engine/client (which doesn't exist) Instead of: .../Mainline/client (which does exist) """ import os # Use the actual websocket.py file location websocket_module = __import__( "engine.display.backends.websocket", fromlist=["WebSocketDisplay"] ) websocket_file = websocket_module.__file__ # OLD BUGGY CODE: 3 dirname calls old_buggy_client_dir = os.path.join( os.path.dirname(os.path.dirname(os.path.dirname(websocket_file))), "client" ) # This path should NOT exist (it's the buggy path) assert not os.path.exists(old_buggy_client_dir), ( f"Old buggy path should not exist: {old_buggy_client_dir}\n" f"If this assertion fails, the bug may have been fixed elsewhere or " f"the test needs updating." ) # The buggy path should be .../engine/client, not .../Mainline/client assert old_buggy_client_dir.endswith("engine/client"), ( f"Old buggy path should end with 'engine/client': {old_buggy_client_dir}" ) # Verify that going up one more level (4 dirname calls) finds the correct path correct_client_dir = os.path.join( os.path.dirname( os.path.dirname(os.path.dirname(os.path.dirname(websocket_file))) ), "client", ) assert os.path.exists(correct_client_dir), ( f"Correct path should exist: {correct_client_dir}" ) assert "index.html" in os.listdir(correct_client_dir), ( f"index.html should exist in correct path: {correct_client_dir}" )