mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-16 03:49:13 +00:00
* feat(ipc)!: remove the cache-key mailboxes (control socket stage 5) The control socket is now the only way the web interface sends the display a command. The display stops reading display_on_demand_request and plugin_error_clear_request, and the web interface stops writing them. - Display: no mailbox poll (MailboxWatch, the 1 s / 0.25 s cadence, _consume_on_demand_request, the deprecation log) and no persisted display_on_demand_processed_id guard; the error publisher reads no clear request. CacheManager.file_signature and MailboxWatch are removed. - A write to either retired key is dropped by CacheManager.save_cache and logged once per writer, naming the plugin from the call stack (or the request's plugin_id), with the API to move to. - Web: on-demand start with no display listening starts the service (when start_service) and sends the request again once the socket answers (45 s, 10 s for a running service without a socket yet); every other failure is a 503 (400 for invalid_args). Stop answers 503 when no display listens, unless stop_service. errors/clear answers 503 with a reason-specific message instead of writing a request; clear_pending is always false. src.ipc.client.should_fall_back is replaced by display_not_listening. - Kept: display_current_state, display_on_demand_state, plugin_runtime_snapshot and the heartbeat (read whenever the socket cannot answer), and display_on_demand_config (the display's resume record). Tests: mailbox-only tests removed (test_on_demand_mailbox.py, the mailbox cadence, file_signature and MailboxWatch tests); tests that injected requests through the mailbox now use the socket queue or a plugin's in-process request. The run-loop harness sends on-demand requests over its fake control socket, so four golden traces change: on-demand starts and stops land at the request instant instead of the next 0.25 s mailbox look (one frame fewer on the screen they end), and in vegas.json within one frame instead of 263 ms, which shifts the later 1 s-throttled WiFi-notice check by under a second. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: the on-demand routes' other callers ack over the socket, not the mailbox test_api_v3_bool_coercion, test_api_v3_lazy_plugin_discovery and test_web_api's stop test checked the mailbox write; they now get an ack from a mocked control socket. The stop route touches no manager any more, so it leaves the removed-catch-all sample. A request that was sent is never 'not listening' (pins the sent check the mutation run let through). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(web): a start that waits for the display answers 202 and is delivered in the background The start route held a request open for up to 45 s while a cold-started display loaded its plugins; the MQTT bridge (15 s timeout) and browsers reported a failure for a request that was then delivered. Now, when no display is listening, the route starts the service if asked and answers 202 with status "starting" at once. A single worker in the web process (web_interface/on_demand_dispatch.py) sends the request until the display acknowledges it or the wait runs out (45 s cold start, 10 s for a running service without a socket yet). A newer start supersedes the pending one; a stop cancels it (and succeeds, with cancelled_request_id, even with no display listening). The outcome is reported by /display/on-demand/status (source "web": starting, or error with start-timeout or the socket's reason, until the display publishes something newer) and by /display/current-status as on_demand_pending. Callers: the web UI's on-demand modal and "Preview on display" treat "starting" as taken (an info toast); the MQTT bridge already treats any non-error 2xx as success (now pinned by a test). Tests: the dispatcher (ack, retry then ack, start-timeout, other failures, superseded, an in-flight ack for a superseded start, stop while pending, a per-start wait, outcome lifetime); the routes (202, status routes while pending and after a timeout, a later display state replacing the failure, stop while pending, a new start superseding); a JS suite for app.js. Mutation check: 20 mutants on the worker, the routes and app.js, 20 killed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): a cancelled on-demand start never reaches the display after its replacement CI caught a race in test_a_new_start_supersedes_the_pending_one: the dispatcher's worker could read the old start, the route cancel it and send the new one, and the worker's send of the old one land after it. cancel() now waits out a send already in flight (the worker holds a send lock while it reads the pending start and sends it), so whatever the caller sends next lands after it. Pinned by test_a_cancel_waits_for_a_send_in_flight, which fails without the wait. The Linux-only TestRealSocket test for a display that went away now expects the 202 and the start-timeout that follows, as the route answers since the background dispatcher. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): a delivered on-demand start reads as starting until the display acts on it On ledpi (three cold starts) the display acknowledged the start as its socket opened, then took ~5 s to act on it while Vegas built its first strip; the status routes meanwhile showed the display's own idle state, so a UI polling every 700 ms flashed idle. The display's on-demand state now names the request it answers (request_id). A delivered start keeps reading as status "starting" with delivered: true, in /display/on-demand/status and as on_demand_pending in /display/current-status, until the display publishes state for that request id (a display without the field: any state newer than the delivery), for at most DELIVERED_SHOWN_SECONDS (30 s). The display's startup state, which can be published after the acknowledgement, names no request and does not end it. Tests: stays starting against the startup idle state (no id, an older id); the matching active state and the matching error take over; an older display's newer state takes over; the 30 s cap; the display's state names its request. Mutation check: 11 mutants, 11 killed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(display): on_demand_request_id has a class default for controllers built without __init__ _on_demand_state now publishes it, and test_state_stream_readers builds controllers with __new__. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: the case-spelling on-demand test patches _send_on_demand now the mailbox is gone Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * docs: blank line before 3.8.7 heading --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
518 lines
19 KiB
Python
518 lines
19 KiB
Python
"""
|
|
Pytest configuration and fixtures for LEDMatrix tests.
|
|
|
|
Provides common fixtures for mocking core components and test setup.
|
|
"""
|
|
|
|
import pytest
|
|
import sys
|
|
from pathlib import Path
|
|
from unittest.mock import Mock, MagicMock, NonCallableMock
|
|
from typing import Dict, Any, Optional
|
|
|
|
# Add project root to path
|
|
project_root = Path(__file__).parent.parent
|
|
if str(project_root) not in sys.path:
|
|
sys.path.insert(0, str(project_root))
|
|
|
|
|
|
class _DisarmStartupReconciliation:
|
|
"""Import hook: every ``web_interface.app`` this process builds starts disarmed.
|
|
|
|
app.py wires itself to the checkout's real config/config.json and
|
|
plugin-repos/ at import, and its before_request hook launches startup
|
|
reconciliation on the first request any test sends. Reconciliation
|
|
reinstalls every configured plugin missing on disk from the live store,
|
|
so a full run downloaded basketball-scoreboard, calendar,
|
|
football-scoreboard, leaderboard and ledmatrix-stocks into the real
|
|
plugin-repos/ (not gitignored), minutes in, from a daemon thread no test
|
|
waits on. Setting ``_reconciliation_started`` is the app's own run-once
|
|
latch; doing it as the module finishes executing covers fixtures that
|
|
import the app lazily and send a request at once, and ``importlib.reload``.
|
|
StateReconciliation itself stays fully testable.
|
|
"""
|
|
|
|
_MODULE = "web_interface.app"
|
|
|
|
def find_spec(self, fullname, path, target=None):
|
|
if fullname != self._MODULE:
|
|
return None
|
|
import importlib.machinery
|
|
spec = importlib.machinery.PathFinder.find_spec(fullname, path, target)
|
|
if spec is None or spec.loader is None:
|
|
return spec
|
|
exec_module = spec.loader.exec_module
|
|
|
|
def exec_disarmed(module):
|
|
exec_module(module)
|
|
module._reconciliation_started = True
|
|
|
|
spec.loader.exec_module = exec_disarmed
|
|
return spec
|
|
|
|
|
|
_DISARM_HOOK = _DisarmStartupReconciliation()
|
|
|
|
|
|
def pytest_configure(config):
|
|
sys.meta_path.insert(0, _DISARM_HOOK)
|
|
app_module = sys.modules.get(_DisarmStartupReconciliation._MODULE)
|
|
if app_module is not None:
|
|
app_module._reconciliation_started = True
|
|
|
|
_point_emulator_at_raw_adapter(config)
|
|
|
|
|
|
def _point_emulator_at_raw_adapter(config):
|
|
"""Point the emulator at a per-process config that binds no socket.
|
|
|
|
Six test modules set EMULATOR=true and build a real DisplayManager. The
|
|
repo's emulator_config.json selects the "browser" adapter, which binds a
|
|
fixed TCP port (8888) to serve the dev preview. That port is a machine-wide
|
|
singleton, so a second pytest process -- a CI shard, another worktree, an
|
|
agent running the suite alongside -- loses the bind. RGBMatrix construction
|
|
then raises, DisplayManager falls back to ``self.matrix = None``, and every
|
|
test that touches the matrix dies with a misleading
|
|
``AttributeError: 'NoneType' object has no attribute 'SwapOnVSync'``.
|
|
|
|
The "raw" adapter renders in memory and binds nothing, so concurrent runs
|
|
stop fighting over the port. The tests wrap SwapOnVSync on the matrix object
|
|
itself, so they are indifferent to which adapter sits underneath. Only the
|
|
adapter is overridden -- every other key is inherited from the repo config,
|
|
which stays on "browser" for ``run.py -e``.
|
|
"""
|
|
try:
|
|
from RGBMatrixEmulator.internal.emulator_config import RGBMatrixEmulatorConfig
|
|
except ImportError:
|
|
return # No emulator installed; the EMULATOR=true modules can't run anyway.
|
|
|
|
import json
|
|
import tempfile
|
|
|
|
settings = {}
|
|
try:
|
|
settings = json.loads((project_root / "emulator_config.json").read_text())
|
|
except (OSError, ValueError):
|
|
pass # Fall back to the emulator's own defaults; only the adapter matters.
|
|
if not isinstance(settings, dict):
|
|
settings = {}
|
|
settings["display_adapter"] = "raw"
|
|
# Falling back would land us back on the browser adapter and its fixed port.
|
|
settings["allow_adapter_fallback"] = False
|
|
|
|
tmp_dir = Path(tempfile.mkdtemp(prefix="ledmatrix-emulator-"))
|
|
tmp_config = tmp_dir / "emulator_config.json"
|
|
tmp_config.write_text(json.dumps(settings))
|
|
# CONFIG_PATH is a bare relative filename resolved against the CWD; an
|
|
# absolute path makes it independent of where pytest was invoked from.
|
|
RGBMatrixEmulatorConfig.CONFIG_PATH = str(tmp_config)
|
|
config._ledmatrix_emulator_tmp = tmp_dir
|
|
|
|
|
|
def pytest_unconfigure(config):
|
|
"""Undo pytest_configure: the import hook and the throwaway emulator config."""
|
|
if _DISARM_HOOK in sys.meta_path:
|
|
sys.meta_path.remove(_DISARM_HOOK)
|
|
tmp_dir = getattr(config, "_ledmatrix_emulator_tmp", None)
|
|
if tmp_dir is not None:
|
|
import shutil
|
|
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
|
|
|
|
# DisplayManager is a process-wide singleton, and the RGBMatrix /
|
|
# RGBMatrixOptions names it builds through are module globals bound once at
|
|
# import to either the emulator or the hardware library. All three are shared
|
|
# by every test module in the run, so a module that leaves a live instance in
|
|
# _instance -- or leaves patch('src.display_manager.RGBMatrix') standing --
|
|
# changes what the NEXT module constructs. None of that is visible when the
|
|
# affected file is run on its own; it surfaces as a full-suite failure that
|
|
# does not reproduce. The full-run failure diff is how a change is confirmed
|
|
# non-regressive, so it has to mean the same thing on every run.
|
|
_PRISTINE_MATRIX_BINDINGS = {}
|
|
|
|
|
|
@pytest.fixture(autouse=True, scope="module")
|
|
def _reset_display_manager_globals():
|
|
"""Undo any DisplayManager global state a test module leaves behind.
|
|
|
|
Autouse fixtures are set up ahead of the fixtures a test asks for, so this
|
|
one is finalised after them -- including after a module-scoped fixture that
|
|
owns a real DisplayManager (test_display_dirty_tracking.py's ``dm``).
|
|
Module-scoped rather than per-test: files that deliberately share one
|
|
manager across their own tests keep doing so; only the leak across the
|
|
module boundary is cut.
|
|
"""
|
|
# Record the real bindings on the way in, while no patch of this module's
|
|
# is active yet -- reading them on the way out would record the leak.
|
|
_remember_matrix_bindings()
|
|
yield
|
|
|
|
dm_mod = sys.modules.get("src.display_manager")
|
|
if dm_mod is None:
|
|
return # Module never imported it; nothing to reset.
|
|
|
|
# An instance left here is what the next module's DisplayManager() call
|
|
# gets back -- potentially one built against a MagicMock matrix.
|
|
dm_mod.DisplayManager._instance = None
|
|
|
|
# Put a binding back if a patch outlived the module that started it.
|
|
# Restoring rather than failing: a leak reported against an innocent module
|
|
# later in the run is the diagnosis problem, not the fix for it.
|
|
for name, pristine in _PRISTINE_MATRIX_BINDINGS.items():
|
|
if isinstance(getattr(dm_mod, name, None), NonCallableMock):
|
|
setattr(dm_mod, name, pristine)
|
|
|
|
|
|
def _remember_matrix_bindings():
|
|
dm_mod = sys.modules.get("src.display_manager")
|
|
if dm_mod is None:
|
|
return
|
|
for name in ("RGBMatrix", "RGBMatrixOptions"):
|
|
current = getattr(dm_mod, name, None)
|
|
if current is not None and not isinstance(current, NonCallableMock):
|
|
_PRISTINE_MATRIX_BINDINGS.setdefault(name, current)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_display_manager():
|
|
"""Create a mock DisplayManager for testing."""
|
|
mock = MagicMock()
|
|
mock.width = 128
|
|
mock.height = 32
|
|
mock.clear = Mock()
|
|
mock.draw_text = Mock()
|
|
mock.draw_image = Mock()
|
|
mock.update_display = Mock()
|
|
mock.get_font = Mock(return_value=None)
|
|
return mock
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_cache_manager():
|
|
"""Create a mock CacheManager for testing."""
|
|
mock = MagicMock()
|
|
mock._memory_cache = {}
|
|
mock._memory_cache_timestamps = {}
|
|
mock.cache_dir = "/tmp/test_cache"
|
|
|
|
def mock_get(key: str, max_age: Optional[int] = 300,
|
|
memory_ttl: Optional[int] = None) -> Optional[Dict]:
|
|
# Signature mirrors CacheManager.get — keep in sync or callers
|
|
# passing keyword args (health tracker, resource monitor) break
|
|
# only in tests, hiding real-API compatibility.
|
|
return mock._memory_cache.get(key)
|
|
|
|
def mock_set(key: str, data: Dict, ttl: Optional[int] = None) -> None:
|
|
mock._memory_cache[key] = data
|
|
|
|
def mock_clear(key: Optional[str] = None) -> None:
|
|
if key:
|
|
mock._memory_cache.pop(key, None)
|
|
else:
|
|
mock._memory_cache.clear()
|
|
|
|
mock.get = Mock(side_effect=mock_get)
|
|
mock.set = Mock(side_effect=mock_set)
|
|
mock.clear = Mock(side_effect=mock_clear)
|
|
mock.get_cached_data = Mock(side_effect=mock_get)
|
|
mock.save_cache = Mock(side_effect=mock_set)
|
|
mock.load_cache = Mock(side_effect=mock_get)
|
|
mock.get_cache_dir = Mock(return_value=mock.cache_dir)
|
|
|
|
return mock
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_config_manager():
|
|
"""Create a mock ConfigManager for testing."""
|
|
mock = MagicMock()
|
|
mock.config = {}
|
|
mock.config_path = "config/config.json"
|
|
mock.secrets_path = "config/config_secrets.json"
|
|
mock.template_path = "config/config.template.json"
|
|
|
|
def mock_load_config() -> Dict[str, Any]:
|
|
return mock.config
|
|
|
|
def mock_get_config() -> Dict[str, Any]:
|
|
return mock.config
|
|
|
|
def mock_get_secret(key: str) -> Optional[Any]:
|
|
secrets = mock.config.get('_secrets', {})
|
|
return secrets.get(key)
|
|
|
|
mock.load_config = Mock(side_effect=mock_load_config)
|
|
mock.get_config = Mock(side_effect=mock_get_config)
|
|
mock.get_secret = Mock(side_effect=mock_get_secret)
|
|
mock.get_config_path = Mock(return_value=mock.config_path)
|
|
mock.get_secrets_path = Mock(return_value=mock.secrets_path)
|
|
|
|
return mock
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_plugin_manager():
|
|
"""Create a mock PluginManager for testing."""
|
|
mock = MagicMock()
|
|
mock.plugins = {}
|
|
mock.plugin_manifests = {}
|
|
mock.get_plugin = Mock(return_value=None)
|
|
mock.load_plugin = Mock(return_value=True)
|
|
mock.unload_plugin = Mock(return_value=True)
|
|
return mock
|
|
|
|
|
|
@pytest.fixture
|
|
def test_config():
|
|
"""Provide a test configuration dictionary."""
|
|
return {
|
|
'display': {
|
|
'hardware': {
|
|
'rows': 32,
|
|
'cols': 64,
|
|
'chain_length': 2,
|
|
'parallel': 1,
|
|
'hardware_mapping': 'adafruit-hat-pwm',
|
|
'brightness': 90
|
|
},
|
|
'runtime': {
|
|
'gpio_slowdown': 2
|
|
}
|
|
},
|
|
'timezone': 'UTC',
|
|
'plugin_system': {
|
|
'plugins_directory': 'plugins'
|
|
}
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def test_cache_dir(tmp_path):
|
|
"""Provide a temporary cache directory for testing."""
|
|
cache_dir = tmp_path / "cache"
|
|
cache_dir.mkdir()
|
|
return str(cache_dir)
|
|
|
|
|
|
@pytest.fixture
|
|
def emulator_mode(monkeypatch):
|
|
"""Set emulator mode for testing."""
|
|
monkeypatch.setenv("EMULATOR", "true")
|
|
return True
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _hermetic_display_watchdog(monkeypatch):
|
|
"""Keep the render loop's watchdog off the host.
|
|
|
|
Every test that runs DisplayController.run() arms the process-wide
|
|
watchdog, which would ping a real $NOTIFY_SOCKET and write a heartbeat
|
|
into /run/ledmatrix -- the live display's, when the suite runs as root on
|
|
a device. Each test gets a fresh instance that does neither.
|
|
"""
|
|
from src import display_watchdog
|
|
monkeypatch.setattr(display_watchdog, 'watchdog',
|
|
display_watchdog.RenderWatchdog(environ={}, heartbeat_dir=None))
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _hermetic_control_socket(monkeypatch):
|
|
"""Keep the control socket (src/ipc) off the host.
|
|
|
|
DisplayController.run() would serve /run/ledmatrix/control.sock -- or
|
|
find the live display's already there, when the suite runs on a device
|
|
-- and the web routes would send on-demand commands to that display.
|
|
Off by default; the socket tests point it at a tmp_path of their own.
|
|
"""
|
|
from src.ipc.contract import SOCKET_PATH_ENV
|
|
monkeypatch.setenv(SOCKET_PATH_ENV, 'off')
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _no_pending_on_demand_dispatch():
|
|
"""Drop the web process's on-demand dispatcher after each test, so a
|
|
start one test left pending is not still being sent in the next."""
|
|
yield
|
|
module = sys.modules.get('web_interface.on_demand_dispatch')
|
|
if module is None:
|
|
return
|
|
dispatcher = module.current()
|
|
if dispatcher is not None:
|
|
dispatcher.cancel('test-teardown')
|
|
module.reset_for_tests()
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _hermetic_unit_refresh(monkeypatch, tmp_path_factory):
|
|
"""Keep updates' systemd unit refresh off the host.
|
|
|
|
perform_core_update runs web_interface/unit_refresh.py after any update
|
|
that moves HEAD, and several tests run the real one against a test clone.
|
|
On a device -- or a machine where install_service.sh was tried out -- it
|
|
would compare the clone's templates with the real /etc/systemd/system and
|
|
run the real sudo helper. Point it at a folder that does not exist: no units
|
|
installed, nothing to do. The unit refresh tests pass their own.
|
|
"""
|
|
from web_interface import unit_refresh
|
|
monkeypatch.setattr(unit_refresh, 'SYSTEMD_DIR', str(tmp_path_factory.getbasetemp() / 'no-systemd'))
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _forget_settled_espn_chunks(monkeypatch):
|
|
"""src.common.espn_dates remembers past days process-wide; tests fake
|
|
different answers for the same dates, so none may inherit another's.
|
|
|
|
"Today" is also pinned to 2000-01-01, so no date a test uses counts as
|
|
settled unless the test says so (by pinning _utc_today itself). Without
|
|
that, a test asking for last month twice passes while that month is
|
|
recent and fails once it is three days old: the second ask is answered
|
|
from memory."""
|
|
from datetime import date
|
|
from src.common import espn_dates
|
|
monkeypatch.setattr(espn_dates, "_utc_today", lambda: date(2000, 1, 1))
|
|
espn_dates.clear_settled_chunk_cache()
|
|
yield
|
|
espn_dates.clear_settled_chunk_cache()
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def reset_logging():
|
|
"""Reset logging configuration before each test."""
|
|
import logging
|
|
logging.root.handlers = []
|
|
logging.root.setLevel(logging.WARNING)
|
|
yield
|
|
logging.root.handlers = []
|
|
logging.root.setLevel(logging.WARNING)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_plugin_instance(mock_display_manager, mock_cache_manager, mock_config_manager):
|
|
"""Create a mock plugin instance with all required methods."""
|
|
from unittest.mock import MagicMock
|
|
|
|
mock_plugin = MagicMock()
|
|
mock_plugin.plugin_id = "test_plugin"
|
|
mock_plugin.config = {"enabled": True, "display_duration": 30}
|
|
mock_plugin.display_manager = mock_display_manager
|
|
mock_plugin.cache_manager = mock_cache_manager
|
|
mock_plugin.plugin_manager = MagicMock()
|
|
mock_plugin.enabled = True
|
|
|
|
# Required methods
|
|
mock_plugin.update = MagicMock(return_value=None)
|
|
mock_plugin.display = MagicMock(return_value=True)
|
|
mock_plugin.get_display_duration = MagicMock(return_value=30.0)
|
|
|
|
# Optional methods
|
|
mock_plugin.supports_dynamic_duration = MagicMock(return_value=False)
|
|
mock_plugin.get_dynamic_duration_cap = MagicMock(return_value=None)
|
|
mock_plugin.is_cycle_complete = MagicMock(return_value=True)
|
|
mock_plugin.reset_cycle_state = MagicMock(return_value=None)
|
|
mock_plugin.has_live_priority = MagicMock(return_value=False)
|
|
mock_plugin.has_live_content = MagicMock(return_value=False)
|
|
mock_plugin.get_live_modes = MagicMock(return_value=[])
|
|
mock_plugin.on_config_change = MagicMock(return_value=None)
|
|
|
|
return mock_plugin
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_plugin_with_live(mock_plugin_instance):
|
|
"""Create a mock plugin with live priority enabled."""
|
|
mock_plugin_instance.has_live_priority = MagicMock(return_value=True)
|
|
mock_plugin_instance.has_live_content = MagicMock(return_value=True)
|
|
mock_plugin_instance.get_live_modes = MagicMock(return_value=["test_plugin_live"])
|
|
mock_plugin_instance.config["live_priority"] = True
|
|
return mock_plugin_instance
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_plugin_with_dynamic(mock_plugin_instance):
|
|
"""Create a mock plugin with dynamic duration enabled."""
|
|
mock_plugin_instance.supports_dynamic_duration = MagicMock(return_value=True)
|
|
mock_plugin_instance.get_dynamic_duration_cap = MagicMock(return_value=180.0)
|
|
mock_plugin_instance.is_cycle_complete = MagicMock(return_value=False)
|
|
mock_plugin_instance.reset_cycle_state = MagicMock(return_value=None)
|
|
mock_plugin_instance.config["dynamic_duration"] = {
|
|
"enabled": True,
|
|
"max_duration_seconds": 180
|
|
}
|
|
return mock_plugin_instance
|
|
|
|
|
|
@pytest.fixture
|
|
def test_config_with_plugins(test_config):
|
|
"""Provide a test configuration with multiple plugins enabled."""
|
|
config = test_config.copy()
|
|
config.update({
|
|
"plugin1": {
|
|
"enabled": True,
|
|
"display_duration": 30,
|
|
"update_interval": 300
|
|
},
|
|
"plugin2": {
|
|
"enabled": True,
|
|
"display_duration": 45,
|
|
"update_interval": 600,
|
|
"live_priority": True
|
|
},
|
|
"plugin3": {
|
|
"enabled": False,
|
|
"display_duration": 20
|
|
},
|
|
"display": {
|
|
**config.get("display", {}),
|
|
"display_durations": {
|
|
"plugin1": 30,
|
|
"plugin2": 45,
|
|
"plugin3": 20
|
|
},
|
|
"dynamic_duration": {
|
|
"max_duration_seconds": 180
|
|
}
|
|
}
|
|
})
|
|
return config
|
|
|
|
|
|
@pytest.fixture
|
|
def test_display_controller(mock_config_manager, mock_display_manager, mock_cache_manager,
|
|
test_config_with_plugins, emulator_mode):
|
|
"""Create a test DisplayController instance with mocked dependencies."""
|
|
from unittest.mock import patch, MagicMock
|
|
from src.display_controller import DisplayController
|
|
|
|
# Set up config manager to return test config
|
|
mock_config_manager.get_config.return_value = test_config_with_plugins
|
|
mock_config_manager.load_config.return_value = test_config_with_plugins
|
|
|
|
with patch('src.display_controller.ConfigManager', return_value=mock_config_manager), \
|
|
patch('src.display_controller.DisplayManager', return_value=mock_display_manager), \
|
|
patch('src.display_controller.CacheManager', return_value=mock_cache_manager), \
|
|
patch('src.display_controller.FontManager'), \
|
|
patch('src.plugin_system.PluginManager') as mock_pm_class:
|
|
|
|
# Set up plugin manager mock
|
|
mock_pm = MagicMock()
|
|
mock_pm.discover_plugins = MagicMock(return_value=[])
|
|
mock_pm.load_plugin = MagicMock(return_value=True)
|
|
mock_pm.get_plugin = MagicMock(return_value=None)
|
|
mock_pm.plugins = {}
|
|
mock_pm.plugin_manifests = {}
|
|
mock_pm.plugin_last_update = {}
|
|
mock_pm.plugin_executor = MagicMock()
|
|
mock_pm.health_tracker = None
|
|
mock_pm_class.return_value = mock_pm
|
|
|
|
# Create controller
|
|
controller = DisplayController()
|
|
yield controller
|
|
|
|
# Cleanup
|
|
try:
|
|
controller.cleanup()
|
|
except Exception:
|
|
pass
|
|
|