- Add EffectPlugin ABC with @abstractmethod decorators for interface enforcement - Add runtime interface checking in discover_plugins() with issubclass() - Add EffectContext factory with sensible defaults - Standardize Display __init__ (remove redundant init in TerminalDisplay) - Document effect behavior when ticker_height=0 - Evaluate legacy effects: document coexistence, no deprecation needed - Research plugin patterns (VST, Python entry points) - Fix pysixel dependency (removed broken dependency) Test coverage improvements: - Add DisplayRegistry tests - Add MultiDisplay tests - Add SixelDisplay tests - Add controller._get_display tests - Add effects controller command handling tests - Add benchmark regression tests (@pytest.mark.benchmark) - Add pytest marker for benchmark tests in pyproject.toml Documentation updates: - Update AGENTS.md with 56% coverage stats and effect plugin docs - Update README.md with Sixel display mode and benchmark commands - Add new modules to architecture section
172 lines
5.9 KiB
Python
172 lines
5.9 KiB
Python
"""
|
|
Tests for engine.controller module.
|
|
"""
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from engine import config
|
|
from engine.controller import StreamController, _get_display
|
|
|
|
|
|
class TestGetDisplay:
|
|
"""Tests for _get_display function."""
|
|
|
|
@patch("engine.controller.WebSocketDisplay")
|
|
@patch("engine.controller.TerminalDisplay")
|
|
def test_get_display_terminal(self, mock_terminal, mock_ws):
|
|
"""returns TerminalDisplay for display=terminal."""
|
|
mock_terminal.return_value = MagicMock()
|
|
mock_ws.return_value = MagicMock()
|
|
|
|
cfg = config.Config(display="terminal")
|
|
display = _get_display(cfg)
|
|
|
|
mock_terminal.assert_called()
|
|
assert isinstance(display, MagicMock)
|
|
|
|
@patch("engine.controller.WebSocketDisplay")
|
|
@patch("engine.controller.TerminalDisplay")
|
|
def test_get_display_websocket(self, mock_terminal, mock_ws):
|
|
"""returns WebSocketDisplay for display=websocket."""
|
|
mock_ws_instance = MagicMock()
|
|
mock_ws.return_value = mock_ws_instance
|
|
mock_terminal.return_value = MagicMock()
|
|
|
|
cfg = config.Config(display="websocket")
|
|
_get_display(cfg)
|
|
|
|
mock_ws.assert_called()
|
|
mock_ws_instance.start_server.assert_called()
|
|
mock_ws_instance.start_http_server.assert_called()
|
|
|
|
@patch("engine.controller.SixelDisplay")
|
|
def test_get_display_sixel(self, mock_sixel):
|
|
"""returns SixelDisplay for display=sixel."""
|
|
mock_sixel.return_value = MagicMock()
|
|
cfg = config.Config(display="sixel")
|
|
_get_display(cfg)
|
|
|
|
mock_sixel.assert_called()
|
|
|
|
def test_get_display_unknown_returns_null(self):
|
|
"""returns NullDisplay for unknown display mode."""
|
|
cfg = config.Config(display="unknown")
|
|
display = _get_display(cfg)
|
|
|
|
from engine.display import NullDisplay
|
|
|
|
assert isinstance(display, NullDisplay)
|
|
|
|
@patch("engine.controller.WebSocketDisplay")
|
|
@patch("engine.controller.TerminalDisplay")
|
|
@patch("engine.controller.MultiDisplay")
|
|
def test_get_display_both(self, mock_multi, mock_terminal, mock_ws):
|
|
"""returns MultiDisplay for display=both."""
|
|
mock_terminal_instance = MagicMock()
|
|
mock_ws_instance = MagicMock()
|
|
mock_terminal.return_value = mock_terminal_instance
|
|
mock_ws.return_value = mock_ws_instance
|
|
|
|
cfg = config.Config(display="both")
|
|
_get_display(cfg)
|
|
|
|
mock_multi.assert_called()
|
|
call_args = mock_multi.call_args[0][0]
|
|
assert mock_terminal_instance in call_args
|
|
assert mock_ws_instance in call_args
|
|
|
|
|
|
class TestStreamController:
|
|
"""Tests for StreamController class."""
|
|
|
|
def test_init_default_config(self):
|
|
"""StreamController initializes with default config."""
|
|
controller = StreamController()
|
|
assert controller.config is not None
|
|
assert isinstance(controller.config, config.Config)
|
|
|
|
def test_init_custom_config(self):
|
|
"""StreamController accepts custom config."""
|
|
custom_config = config.Config(headline_limit=500)
|
|
controller = StreamController(config=custom_config)
|
|
assert controller.config.headline_limit == 500
|
|
|
|
def test_init_sources_none_by_default(self):
|
|
"""Sources are None until initialized."""
|
|
controller = StreamController()
|
|
assert controller.mic is None
|
|
assert controller.ntfy is None
|
|
|
|
@patch("engine.controller.MicMonitor")
|
|
@patch("engine.controller.NtfyPoller")
|
|
def test_initialize_sources(self, mock_ntfy, mock_mic):
|
|
"""initialize_sources creates mic and ntfy instances."""
|
|
mock_mic_instance = MagicMock()
|
|
mock_mic_instance.available = True
|
|
mock_mic_instance.start.return_value = True
|
|
mock_mic.return_value = mock_mic_instance
|
|
|
|
mock_ntfy_instance = MagicMock()
|
|
mock_ntfy_instance.start.return_value = True
|
|
mock_ntfy.return_value = mock_ntfy_instance
|
|
|
|
controller = StreamController()
|
|
mic_ok, ntfy_ok = controller.initialize_sources()
|
|
|
|
assert mic_ok is True
|
|
assert ntfy_ok is True
|
|
assert controller.mic is not None
|
|
assert controller.ntfy is not None
|
|
|
|
@patch("engine.controller.MicMonitor")
|
|
@patch("engine.controller.NtfyPoller")
|
|
def test_initialize_sources_mic_unavailable(self, mock_ntfy, mock_mic):
|
|
"""initialize_sources handles unavailable mic."""
|
|
mock_mic_instance = MagicMock()
|
|
mock_mic_instance.available = False
|
|
mock_mic.return_value = mock_mic_instance
|
|
|
|
mock_ntfy_instance = MagicMock()
|
|
mock_ntfy_instance.start.return_value = True
|
|
mock_ntfy.return_value = mock_ntfy_instance
|
|
|
|
controller = StreamController()
|
|
mic_ok, ntfy_ok = controller.initialize_sources()
|
|
|
|
assert mic_ok is False
|
|
assert ntfy_ok is True
|
|
|
|
@patch("engine.controller.MicMonitor")
|
|
def test_initialize_sources_cc_subscribed(self, mock_mic):
|
|
"""initialize_sources subscribes C&C handler."""
|
|
mock_mic_instance = MagicMock()
|
|
mock_mic_instance.available = False
|
|
mock_mic_instance.start.return_value = False
|
|
mock_mic.return_value = mock_mic_instance
|
|
|
|
with patch("engine.controller.NtfyPoller") as mock_ntfy:
|
|
mock_ntfy_instance = MagicMock()
|
|
mock_ntfy_instance.start.return_value = True
|
|
mock_ntfy.return_value = mock_ntfy_instance
|
|
|
|
controller = StreamController()
|
|
controller.initialize_sources()
|
|
|
|
mock_ntfy_instance.subscribe.assert_called()
|
|
|
|
|
|
class TestStreamControllerCleanup:
|
|
"""Tests for StreamController cleanup."""
|
|
|
|
@patch("engine.controller.MicMonitor")
|
|
def test_cleanup_stops_mic(self, mock_mic):
|
|
"""cleanup stops the microphone if running."""
|
|
mock_mic_instance = MagicMock()
|
|
mock_mic.return_value = mock_mic_instance
|
|
|
|
controller = StreamController()
|
|
controller.mic = mock_mic_instance
|
|
controller.cleanup()
|
|
|
|
mock_mic_instance.stop.assert_called_once()
|