mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
* refactor(plugins): remove the no-op PluginHealthMonitor Its monitor loop did nothing (`if callbacks: pass`), register_health_check had no callers and api_v3.health_monitor was never read by any route. The live health data comes from PluginHealthTracker, which is untouched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(store): drop the never-set uninstall tombstones Nothing in production called mark_recently_uninstalled, so the reconciler's was_recently_uninstalled check was always False. The persistent uninstall registry is what actually stops resurrection; the reconciler test now exercises that gate instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(common): delete unused config/display/game helpers, utils and error_handler Nothing in core, the web UI, scripts or the plugin monorepo imports config_helper, display_helper, game_helper, utils or error_handler; only their own tests did. The error_handler re-exports leave src.common's __all__; APIHelper, TextHelper, ScrollHelper, LogoHelper and the adaptive layout exports are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(config): drop ConfigService's unused versioning and save API ConfigVersion, get_version/get_version_history/get_version_config, rollback, save_config, reload, get_plugin_config and the backward-compat load_config/get_config_path/get_secrets_path had no callers. The display controller only uses get_config, subscribe, unsubscribe and shutdown, plus the file watcher. Change detection now compares against the current checksum instead of the last history entry. The subscriber tests asserted `callback.called or True`; they now reload the way the watcher does and assert the notification. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(plugins): drop unread plugin state history and callbacks plugin_state.PluginStateManager kept a bounded per-plugin transition history that only get_state_history (tests only) read; get_state_info reports a separate lifetime count, which stays. set_error_info and record_display had no callers, and set_state_with_error's `error` argument only fed the history. The web-side state_manager.PluginStateManager loses subscribe_to_state_changes, _notify_callbacks, set_plugin_error and get_state_version, none of which had callers; with no subscribers the old-state copy in update_plugin_state went with them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(plugins): remove unused PluginManager methods and attribute guards update_all_plugins was only called by a test (the display loop uses run_scheduled_updates); get_plugin_health_metrics, get_plugin_resource_metrics and get_plugin_state had no callers; and plugin_modules was written but never read. plugin_directories is now initialised in __init__, so the hasattr() guards around it go. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(plugins): remove unused executor, loader, store and package helpers - PluginExecutor.execute_safe: no callers. - PluginLoader._parse_semver: only its own tests; compatibility.parse_semver is the live copy and test_compatibility.py already covers it. - PluginStoreManager.get_installed_plugin_info: no callers. - PluginResourceMonitor._local: never read. - src.plugin_system.get_store_manager and __api_version__: no importers in core, scripts or the plugin monorepo. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(wifi): stop storing Wi-Fi passwords in wifi_config.json WiFiManager appended every joined network's SSID and password, in plaintext, to saved_networks in config/wifi_config.json, and nothing (web UI, backup restore, scripts) ever read them back: NetworkManager keeps its own credentials. The writes are gone, and loading the config now drops any saved_networks key and rewrites the file, so passwords already on disk are scrubbed. Also removes _check_dnsmasq_conflict (never called) and _detect_trixie, whose result only reached one log line, along with the NM_CONNECTIONS_PATHS constant only it used. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(display): remove unreachable and unused DisplayController code - _follower_rebuild_scroll_image: never called. - mode_duration (never read) and last_mode_change (write-only). - The `chosen_cap <= 0` branch: chosen_cap is either the minimum of caps already filtered to > 0 or DEFAULT_DYNAMIC_DURATION_CAP (180). - The `max_duration < min_duration` branch directly after `max_duration = max(min_duration, max_duration)`. - The circuit-breaker branch's `display_result = False` and `manager_to_display = None`: the first is overwritten a few lines later, the second is already None there. - The bool-to-bool conversion of execute_display's result, which is always a bool. - The `loaded_plugins` lookup in _update_modules: PluginManager has no such attribute, so it always fell through to `plugins`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(vegas): remove unused config update, boundary finder and refresh VegasModeConfig.update had no callers outside its own tests (the coordinator rebuilds the config with from_config on a change); geometry.find_item_boundary and StreamManager._refresh_plugin_content had no callers at all. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(run): drop the debug block that pretended to import the plugin system In debug mode run.py put src/plugin_system itself on sys.path and printed "Plugin system import successful" without importing anything. Nothing imports plugin_system modules by bare name, so the path entry did nothing either. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: delete tests that test nothing - test/plugins/test_{basketball_scoreboard,calendar,clock_simple, odds_ticker,soccer_scoreboard,text_display}.py skip everywhere the named plugins are not installed, including CI (LEDMATRIX_PLUGINS_DIR holds only the fixture plugin); test_plugin_matrix.py already covers every discovered plugin. Their PluginTestBase and the fixtures only it used (plugins_dir, mock_display_manager, mock_cache_manager, mock_plugin_manager, base_plugin_config in test/plugins/conftest.py) go with them. - test_plugin_system.py: test_discover_plugins (body was `pass`) and test_dependency_check (a comment), plus the test_plugin_manager fixture only the former requested. - test_display_manager.py: test_draw_image asserted that an image it had just assigned was not None. - test_display_controller.py: the rotation and schedule-override tests re-implemented the run-loop arithmetic inline and asserted on their own result without calling the controller. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: expect one plugin_last_update success stamp after update_all_plugins EveryStampRecordsACompletion required at least two success-path stamps; the second was update_all_plugins, removed as test-only. The worker and synchronous paths share the remaining stamp in _execute_update_now, and the check that every stamp calls _note_update_completed is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
354 lines
16 KiB
Python
354 lines
16 KiB
Python
import time
|
|
from unittest.mock import MagicMock, patch
|
|
from pathlib import Path
|
|
from src.plugin_system.plugin_manager import PluginManager
|
|
from src.plugin_system.plugin_state import PluginState
|
|
from src.plugin_system.resource_monitor import PluginResourceMonitor
|
|
|
|
class TestPluginManager:
|
|
"""Test PluginManager functionality."""
|
|
|
|
def test_init(self, mock_config_manager, mock_display_manager, mock_cache_manager):
|
|
"""Test PluginManager initialization."""
|
|
with patch('src.plugin_system.plugin_manager.ensure_directory_permissions'):
|
|
pm = PluginManager(
|
|
plugins_dir="plugins",
|
|
config_manager=mock_config_manager,
|
|
display_manager=mock_display_manager,
|
|
cache_manager=mock_cache_manager
|
|
)
|
|
assert pm.plugins_dir == Path("plugins")
|
|
assert pm.config_manager == mock_config_manager
|
|
assert pm.display_manager == mock_display_manager
|
|
assert pm.cache_manager == mock_cache_manager
|
|
assert pm.plugins == {}
|
|
|
|
def test_load_plugin_success(self, mock_config_manager, mock_display_manager, mock_cache_manager):
|
|
"""Test successful plugin loading."""
|
|
with patch('src.plugin_system.plugin_manager.ensure_directory_permissions'), \
|
|
patch('src.plugin_system.plugin_manager.PluginManager._scan_directory_for_plugins'), \
|
|
patch('src.plugin_system.plugin_manager.PluginLoader') as MockLoader, \
|
|
patch('src.plugin_system.plugin_manager.SchemaManager'):
|
|
|
|
pm = PluginManager(
|
|
plugins_dir="plugins",
|
|
config_manager=mock_config_manager,
|
|
display_manager=mock_display_manager,
|
|
cache_manager=mock_cache_manager
|
|
)
|
|
|
|
# Setup mocks
|
|
pm.plugin_manifests = {"test_plugin": {"id": "test_plugin", "name": "Test Plugin"}}
|
|
|
|
mock_loader = MockLoader.return_value
|
|
mock_loader.find_plugin_directory.return_value = Path("plugins/test_plugin")
|
|
mock_loader.load_plugin.return_value = (MagicMock(), MagicMock())
|
|
|
|
# Test loading
|
|
result = pm.load_plugin("test_plugin")
|
|
|
|
assert result is True
|
|
assert "test_plugin" in pm.plugins
|
|
# PluginManager sets state to ENABLED after successful load
|
|
assert pm.state_manager.get_state("test_plugin") == PluginState.ENABLED
|
|
|
|
def test_load_plugin_missing_manifest(self, mock_config_manager, mock_display_manager, mock_cache_manager):
|
|
"""Test loading plugin with missing manifest."""
|
|
with patch('src.plugin_system.plugin_manager.ensure_directory_permissions'):
|
|
pm = PluginManager(
|
|
plugins_dir="plugins",
|
|
config_manager=mock_config_manager,
|
|
display_manager=mock_display_manager,
|
|
cache_manager=mock_cache_manager
|
|
)
|
|
|
|
# No manifest in pm.plugin_manifests
|
|
result = pm.load_plugin("non_existent_plugin")
|
|
|
|
assert result is False
|
|
assert pm.state_manager.get_state("non_existent_plugin") == PluginState.ERROR
|
|
|
|
def test_run_scheduled_updates_calls_update_with_resource_monitor(
|
|
self, mock_config_manager, mock_display_manager, mock_cache_manager
|
|
):
|
|
"""Regression test: run_scheduled_updates() must actually call a
|
|
plugin's update() when self.resource_monitor is set (as it is in
|
|
every real deployment -- display_controller.py and web_interface/
|
|
app.py both assign a real PluginResourceMonitor after construction).
|
|
|
|
Previously, the resource_monitor branch wrapped the call in a
|
|
function stored as a *class* attribute on a dynamically-built type
|
|
(`type('obj', (object,), {'update': monitored_update})()`), which
|
|
the descriptor protocol turns into a bound method on access --
|
|
silently passing the synthetic instance as an implicit first
|
|
argument to monitored_update(), which takes none. Every plugin's
|
|
scheduled update failed with "monitored_update() takes 0 positional
|
|
arguments but 1 was given" and was silently swallowed into a
|
|
circuit-breaker retry loop that never succeeded, so plugin data
|
|
(scores, odds, etc.) never refreshed.
|
|
"""
|
|
with patch('src.plugin_system.plugin_manager.ensure_directory_permissions'):
|
|
pm = PluginManager(
|
|
plugins_dir="plugins",
|
|
config_manager=mock_config_manager,
|
|
display_manager=mock_display_manager,
|
|
cache_manager=mock_cache_manager
|
|
)
|
|
|
|
plugin_instance = MagicMock()
|
|
plugin_instance.enabled = True
|
|
plugin_instance.update = MagicMock()
|
|
|
|
pm.plugins["test_plugin"] = plugin_instance
|
|
pm.plugin_manifests["test_plugin"] = {"update_interval": 10}
|
|
pm.state_manager.set_state("test_plugin", PluginState.ENABLED)
|
|
# Plain MagicMock, not the mock_cache_manager fixture: this test
|
|
# is about run_scheduled_updates() actually invoking update()
|
|
# through the resource-monitor wrapper, not about
|
|
# PluginResourceMonitor's own cache-backed metrics persistence
|
|
# (which calls cache_manager.get(..., memory_ttl=...) --
|
|
# a kwarg the fixture's mock_get() doesn't accept).
|
|
pm.resource_monitor = PluginResourceMonitor(MagicMock())
|
|
|
|
pm.run_scheduled_updates(current_time=time.time())
|
|
|
|
# Updates now execute on the background worker (the scheduler
|
|
# returns immediately) — wait for completion before asserting.
|
|
deadline = time.time() + 5
|
|
while (plugin_instance.update.call_count == 0
|
|
and time.time() < deadline):
|
|
time.sleep(0.02)
|
|
pm.stop_update_worker()
|
|
|
|
plugin_instance.update.assert_called_once()
|
|
assert "test_plugin" in pm.plugin_last_update
|
|
assert pm.state_manager.get_state("test_plugin") == PluginState.ENABLED
|
|
|
|
|
|
class TestPluginExecutor:
|
|
"""Test PluginExecutor functionality."""
|
|
|
|
def test_execute_display_success(self):
|
|
"""Test successful display execution."""
|
|
from src.plugin_system.plugin_executor import PluginExecutor
|
|
executor = PluginExecutor()
|
|
|
|
mock_plugin = MagicMock()
|
|
mock_plugin.display.return_value = True
|
|
|
|
result = executor.execute_display(mock_plugin, "test_plugin")
|
|
|
|
assert result is True
|
|
mock_plugin.display.assert_called_once()
|
|
|
|
def test_execute_display_exception(self):
|
|
"""Test display execution with exception."""
|
|
from src.plugin_system.plugin_executor import PluginExecutor
|
|
executor = PluginExecutor()
|
|
|
|
mock_plugin = MagicMock()
|
|
mock_plugin.display.side_effect = Exception("Test error")
|
|
|
|
result = executor.execute_display(mock_plugin, "test_plugin")
|
|
|
|
assert result is False
|
|
|
|
def test_execute_update_timeout(self):
|
|
"""Test update execution timeout."""
|
|
# Using a very short timeout for testing
|
|
from src.plugin_system.plugin_executor import PluginExecutor
|
|
executor = PluginExecutor(default_timeout=0.01)
|
|
|
|
mock_plugin = MagicMock()
|
|
def slow_update():
|
|
time.sleep(0.05)
|
|
mock_plugin.update.side_effect = slow_update
|
|
|
|
result = executor.execute_update(mock_plugin, "test_plugin")
|
|
|
|
assert result is False
|
|
|
|
|
|
class TestPluginHealth:
|
|
"""Test plugin health monitoring."""
|
|
|
|
def test_circuit_breaker(self, mock_cache_manager):
|
|
"""Test circuit breaker activation."""
|
|
from src.plugin_system.plugin_health import PluginHealthTracker
|
|
tracker = PluginHealthTracker(cache_manager=mock_cache_manager, failure_threshold=3, cooldown_period=60)
|
|
|
|
plugin_id = "test_plugin"
|
|
|
|
# Initial state
|
|
assert tracker.should_skip_plugin(plugin_id) is False
|
|
|
|
# Failures
|
|
tracker.record_failure(plugin_id, Exception("Error 1"))
|
|
assert tracker.should_skip_plugin(plugin_id) is False
|
|
|
|
tracker.record_failure(plugin_id, Exception("Error 2"))
|
|
assert tracker.should_skip_plugin(plugin_id) is False
|
|
|
|
tracker.record_failure(plugin_id, Exception("Error 3"))
|
|
# Should trip now
|
|
assert tracker.should_skip_plugin(plugin_id) is True
|
|
|
|
# Recovery (simulate timeout - need to update health state correctly)
|
|
if plugin_id in tracker._health_state:
|
|
tracker._health_state[plugin_id]["last_failure"] = time.time() - 61
|
|
tracker._health_state[plugin_id]["circuit_state"] = "closed"
|
|
assert tracker.should_skip_plugin(plugin_id) is False
|
|
|
|
|
|
class TestBasePlugin:
|
|
"""Test BasePlugin functionality."""
|
|
|
|
def test_dynamic_duration_defaults(self, mock_display_manager, mock_cache_manager):
|
|
"""Test default dynamic duration behavior."""
|
|
from src.plugin_system.base_plugin import BasePlugin
|
|
|
|
# Concrete implementation for testing
|
|
class ConcretePlugin(BasePlugin):
|
|
def update(self): pass
|
|
def display(self, force_clear=False): pass
|
|
|
|
config = {"enabled": True}
|
|
plugin = ConcretePlugin("test", config, mock_display_manager, mock_cache_manager, None)
|
|
|
|
assert plugin.supports_dynamic_duration() is False
|
|
assert plugin.get_dynamic_duration_cap() is None
|
|
assert plugin.is_cycle_complete() is True
|
|
|
|
def test_live_priority_config(self, mock_display_manager, mock_cache_manager):
|
|
"""Test live priority configuration."""
|
|
from src.plugin_system.base_plugin import BasePlugin
|
|
|
|
class ConcretePlugin(BasePlugin):
|
|
def update(self): pass
|
|
def display(self, force_clear=False): pass
|
|
|
|
config = {"enabled": True, "live_priority": True}
|
|
plugin = ConcretePlugin("test", config, mock_display_manager, mock_cache_manager, None)
|
|
|
|
assert plugin.has_live_priority() is True
|
|
|
|
|
|
class TestBasePluginGlobalConfig:
|
|
"""global_config exposes device-wide settings that self.config cannot.
|
|
|
|
The sports scoreboards read `getattr(self, 'global_config', {})` to find
|
|
the shared target_fps; before this property existed nothing ever set that
|
|
attribute, so the lookup silently returned {} and the setting could never
|
|
take effect on any core.
|
|
"""
|
|
|
|
@staticmethod
|
|
def _plugin(display_manager, cache_manager, plugin_manager=None):
|
|
from src.plugin_system.base_plugin import BasePlugin
|
|
|
|
class ConcretePlugin(BasePlugin):
|
|
def update(self): pass
|
|
def display(self, force_clear=False): pass
|
|
|
|
return ConcretePlugin(
|
|
"test", {"enabled": True}, display_manager, cache_manager, plugin_manager
|
|
)
|
|
|
|
@staticmethod
|
|
def _manager_with(config):
|
|
"""A stand-in manager exposing config_manager.get_config()."""
|
|
manager = MagicMock()
|
|
manager.config_manager.get_config.return_value = config
|
|
return manager
|
|
|
|
def test_reads_config_from_plugin_manager(self, mock_display_manager, mock_cache_manager):
|
|
plugin = self._plugin(
|
|
mock_display_manager, mock_cache_manager,
|
|
self._manager_with({"target_fps": 100}),
|
|
)
|
|
assert plugin.global_config["target_fps"] == 100
|
|
|
|
def test_falls_back_to_cache_manager(self, mock_display_manager):
|
|
# The core that hangs config_manager off the cache manager instead.
|
|
cache_manager = self._manager_with({"target_fps": 75})
|
|
plugin = self._plugin(mock_display_manager, cache_manager, plugin_manager=None)
|
|
assert plugin.global_config["target_fps"] == 75
|
|
|
|
def test_plugin_manager_wins_over_cache_manager(self, mock_display_manager):
|
|
plugin = self._plugin(
|
|
mock_display_manager,
|
|
self._manager_with({"target_fps": 75}),
|
|
self._manager_with({"target_fps": 100}),
|
|
)
|
|
assert plugin.global_config["target_fps"] == 100
|
|
|
|
def test_empty_plugin_manager_config_falls_through(self, mock_display_manager):
|
|
"""An empty first source means "not loaded yet", not "the answer".
|
|
|
|
Both managers default to the same config/config.json, so falling
|
|
through cannot pick up a different file. Returning {} here instead
|
|
would silently disable every setting read through this property --
|
|
the exact failure this property exists to fix.
|
|
"""
|
|
plugin = self._plugin(
|
|
mock_display_manager,
|
|
self._manager_with({"target_fps": 100}), # cache_manager
|
|
self._manager_with({}), # plugin_manager: empty
|
|
)
|
|
assert plugin.global_config["target_fps"] == 100
|
|
|
|
def test_returns_empty_dict_when_no_config_manager(self, mock_display_manager):
|
|
# Plain objects: no config_manager attribute at all.
|
|
plugin = self._plugin(mock_display_manager, object(), object())
|
|
assert plugin.global_config == {}
|
|
|
|
def test_unreadable_config_does_not_raise(self, mock_display_manager):
|
|
# A plugin must still load when the config on disk is broken.
|
|
broken = MagicMock()
|
|
broken.config_manager.get_config.side_effect = OSError("unreadable")
|
|
plugin = self._plugin(mock_display_manager, broken, broken)
|
|
assert plugin.global_config == {}
|
|
|
|
def test_non_dict_config_is_rejected(self, mock_display_manager):
|
|
# A stub or half-built manager can return a non-mapping; handing that
|
|
# back would blow up later in numeric code, far from the cause.
|
|
plugin = self._plugin(
|
|
mock_display_manager, object(), self._manager_with("not-a-dict")
|
|
)
|
|
assert plugin.global_config == {}
|
|
|
|
def test_missing_property_degrades_to_default(self, mock_display_manager, mock_cache_manager):
|
|
# How plugins actually call it, so a plugin written against this core
|
|
# still loads on one that predates the property.
|
|
plugin = self._plugin(mock_display_manager, mock_cache_manager, object())
|
|
assert getattr(plugin, "global_config", {}).get("target_fps") is None
|
|
|
|
def test_plugin_may_still_assign_global_config(self, mock_display_manager, mock_cache_manager):
|
|
# news, stock-news, ledmatrix-stocks, ledmatrix-elections,
|
|
# ledmatrix-leaderboard and nfl-draft all do exactly this. Without a
|
|
# setter the property raises "has no setter" and those plugins stop
|
|
# loading entirely.
|
|
from src.plugin_system.base_plugin import BasePlugin
|
|
|
|
class AssigningPlugin(BasePlugin):
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
self.global_config = self.config.get("global", {})
|
|
|
|
def update(self): pass
|
|
def display(self, force_clear=False): pass
|
|
|
|
plugin = AssigningPlugin(
|
|
"news", {"enabled": True, "global": {"scroll_speed": 2}},
|
|
mock_display_manager, mock_cache_manager, self._manager_with({"target_fps": 100}),
|
|
)
|
|
# The plugin's own value wins over the resolved config.
|
|
assert plugin.global_config == {"scroll_speed": 2}
|
|
|
|
def test_template_ships_a_global_target_fps(self):
|
|
# The plumbing is useless if the setting isn't in the shipped config.
|
|
import json
|
|
with open("config/config.template.json") as fh:
|
|
template = json.load(fh)
|
|
assert template.get("target_fps") == 100
|