forked from genewildish/Mainline
feat(integration): Complete feature rewrite with pipeline architecture, effects system, and display improvements
Major changes: - Pipeline architecture with capability-based dependency resolution - Effects plugin system with performance monitoring - Display abstraction with multiple backends (terminal, null, websocket) - Camera system for viewport scrolling - Sensor framework for real-time input - Command-and-control system via ntfy - WebSocket display backend for browser clients - Comprehensive test suite and documentation Issue #48: ADR for preset scripting language included This commit consolidates 110 individual commits into a single feature integration that can be reviewed and tested before further refinement.
This commit is contained in:
395
tests/test_websocket.py
Normal file
395
tests/test_websocket.py
Normal file
@@ -0,0 +1,395 @@
|
||||
"""
|
||||
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}"
|
||||
)
|
||||
Reference in New Issue
Block a user