mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-18 04:49:13 +00:00
Merge main into the api_v3 split, carrying the three fixes it would have dropped
main modified web_interface/blueprints/api_v3.py while this branch deletes it, so git reports a modify/delete conflict. Resolving that by accepting the deletion - the obvious move - would silently discard every api_v3.py change merged since this branch forked. Three were: #560 the sudo diagnosis on /system/action -> api_v3/system.py #557 the stale-reconciliation-findings filter -> api_v3/plugins.py #550 the deletion of dead _validate_time_range -> api_v3/__init__.py All three were verified absent from the split beforehand: _sudo_hint_for, _SUDO_NEEDS_PASSWORD and _drop_stale_reconciliation_findings appeared in zero files, while _validate_time_range was still sitting at __init__.py:343 after #550 removed it from main. Each is now ported to the module that owns the affected route, and the dead function is gone. Two further changes this surfaced: - api_v3/__init__.py imported fcntl at module scope. fcntl is POSIX-only, the monolithic blueprint never imported it, and on this branch it made the whole api_v3 package unimportable off POSIX: the pristine branch cannot run its test suite on Windows at all - 14 collection errors interrupt the run. The import now happens inside _starlark_manifest_lock(), the only thing that uses it. Deliberately NOT degraded to a no-op lock off POSIX; that function's docstring describes a real lost-update race, and silently not locking would be worse than failing loudly on a platform that cannot drive the display. - test/test_reconciliation_status_endpoint.py is new. #557's endpoint wiring had no test - only the pure still_unresolved() helper in src/ was covered, and that lives outside web_interface so it survived the split untouched. Losing the filter during this merge would therefore have been completely silent. Removing the filter call now fails three of its seven cases. Suite on this merge: 115 failed / 4317 passed. main today: 105 / 4252. All 11 extra failures are in test_starlark_pixlet_routes.py and need POSIX file locks, so they are Windows-only; on that same file the pristine branch shows 19 failed plus 39 errors, so this is a large net improvement rather than a regression. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
"""Tests for the metrics the live status stream reports.
|
||||
|
||||
Two gaps made a struggling 1GB Pi look healthy on the dashboard:
|
||||
|
||||
* ``disk_used_percent`` was the literal ``0`` in the SSE generator, so every
|
||||
consumer of the live stream displayed 0% disk no matter how full the card was.
|
||||
/api/v3/system/status computed it properly, but the live view -- the one people
|
||||
actually watch -- did not.
|
||||
|
||||
* ``memory_available_mb`` was absent. /api/v3/system/status carries it with a
|
||||
comment explaining that MemAvailable, not used%, is what separates a board
|
||||
that is fine from one about to fail fork(). The live stream omitted the one
|
||||
number that predicts the failure.
|
||||
|
||||
A wrong number is worse than a missing one, so an unreadable disk now reports
|
||||
None (the UI renders '--') rather than a confident 0.
|
||||
|
||||
These import web_interface.system_metrics rather than web_interface.app on
|
||||
purpose: importing the app constructs a Flask application and a CacheManager,
|
||||
and the latter claims the cache directory with a cleanup thread -- which broke
|
||||
test_cache_cleanup_thread_ownership and the starlark route tests when this file
|
||||
first reached for the generator directly.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from web_interface.system_metrics import collect_system_metrics # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def metrics(monkeypatch):
|
||||
"""collect_system_metrics() with psutil's readings under our control."""
|
||||
def _collect(disk_percent=12.0, available_bytes=512 * 1024 * 1024,
|
||||
used_percent=67.7, raise_disk=False):
|
||||
import psutil
|
||||
if raise_disk:
|
||||
monkeypatch.setattr(psutil, "disk_usage",
|
||||
lambda _p: (_ for _ in ()).throw(OSError("no such mount")))
|
||||
else:
|
||||
monkeypatch.setattr(psutil, "disk_usage",
|
||||
lambda _p: SimpleNamespace(percent=disk_percent))
|
||||
monkeypatch.setattr(psutil, "virtual_memory",
|
||||
lambda: SimpleNamespace(percent=used_percent,
|
||||
available=available_bytes))
|
||||
return collect_system_metrics()
|
||||
return _collect
|
||||
|
||||
|
||||
class TestDiskUsageIsReal:
|
||||
def test_reported_value_comes_from_the_filesystem(self, metrics):
|
||||
# The regression: this field was the literal 0.
|
||||
assert metrics(disk_percent=91.4)["disk_used_percent"] == 91.4
|
||||
|
||||
def test_a_full_disk_is_not_reported_as_zero(self, metrics):
|
||||
assert metrics(disk_percent=99.9)["disk_used_percent"] != 0
|
||||
|
||||
def test_unreadable_disk_is_null_not_a_confident_zero(self, metrics):
|
||||
# null renders as '--'; 0 would read as "plenty of room".
|
||||
assert metrics(raise_disk=True)["disk_used_percent"] is None
|
||||
|
||||
|
||||
class TestMemAvailableIsReported:
|
||||
def test_mem_available_is_present_and_in_mb(self, metrics):
|
||||
got = metrics(available_bytes=292 * 1024 * 1024)["memory_available_mb"]
|
||||
assert got == pytest.approx(292.0, abs=0.1)
|
||||
|
||||
def test_the_1gb_danger_case_is_visible(self, metrics):
|
||||
"""67% used can be fine or nearly out of memory; only MemAvailable says."""
|
||||
m = metrics(used_percent=67.7, available_bytes=40 * 1024 * 1024)
|
||||
assert m["memory_used_percent"] == 67.7
|
||||
assert m["memory_available_mb"] == pytest.approx(40.0, abs=0.1)
|
||||
|
||||
|
||||
class TestEveryPredictiveFieldIsPresent:
|
||||
def test_nothing_the_dashboard_needs_is_missing(self, metrics):
|
||||
m = metrics()
|
||||
for field in ("cpu_percent", "cpu_temp", "memory_used_percent",
|
||||
"memory_available_mb", "disk_used_percent"):
|
||||
assert field in m, f"{field} missing from collected metrics"
|
||||
|
||||
def test_absent_psutil_still_yields_every_key(self, monkeypatch):
|
||||
"""The no-psutil fallback must not drop fields the UI reads."""
|
||||
import builtins
|
||||
real_import = builtins.__import__
|
||||
|
||||
def _no_psutil(name, *args, **kwargs):
|
||||
if name == "psutil":
|
||||
raise ImportError("psutil missing")
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", _no_psutil)
|
||||
m = collect_system_metrics()
|
||||
for field in ("cpu_percent", "cpu_temp", "memory_used_percent",
|
||||
"memory_available_mb", "disk_used_percent"):
|
||||
assert field in m
|
||||
assert m["disk_used_percent"] is None
|
||||
@@ -0,0 +1,159 @@
|
||||
"""The scheduler actually polls faster while a plugin reports live content.
|
||||
|
||||
test_plugin_dynamic_update_interval.py proves _get_plugin_update_interval()
|
||||
returns the plugin's requested number. That is not the same claim as "the
|
||||
plugin gets updated more often", and the gap between those two is exactly where
|
||||
the original bug lived: the plugin knew it wanted 15s, said so in
|
||||
live_update_interval, and nothing downstream acted on it.
|
||||
|
||||
So this drives the real run_scheduled_updates() over a simulated hour of ticks
|
||||
and counts dispatches. It is the test that would have caught the reported bug:
|
||||
|
||||
"the football plugin with live games only updates the live game in
|
||||
progress if I restart the display"
|
||||
|
||||
Measured on a rig before the fix, during an NFL fourth quarter, ESPN was polled
|
||||
at 23:21:49 / 23:22:50 / 23:23:50 / 23:24:50 -- exactly the manifest's 60s,
|
||||
never the configured 15.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.plugin_system.plugin_manager import PluginManager
|
||||
from src.plugin_system.plugin_state import PluginState
|
||||
|
||||
|
||||
class _StateManager:
|
||||
"""Enough of the real state machine for reservation to behave."""
|
||||
|
||||
def __init__(self):
|
||||
self.states = {}
|
||||
|
||||
def can_execute(self, plugin_id):
|
||||
return self.states.get(plugin_id, PluginState.ENABLED) != PluginState.RUNNING
|
||||
|
||||
def set_state(self, plugin_id, state):
|
||||
self.states[plugin_id] = state
|
||||
|
||||
|
||||
class _Plugin:
|
||||
"""A sports plugin: fast while something is live, quiet otherwise."""
|
||||
|
||||
enabled = True
|
||||
|
||||
def __init__(self, live_interval=15):
|
||||
self.live = False
|
||||
self._live_interval = live_interval
|
||||
|
||||
def get_update_interval(self):
|
||||
return self._live_interval if self.live else None
|
||||
|
||||
def update(self):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scheduler():
|
||||
pm = PluginManager.__new__(PluginManager)
|
||||
import threading
|
||||
pm._update_interval_cache = {}
|
||||
pm.plugin_manifests = {"sports": {"update_interval": 60}}
|
||||
pm.config_manager = None
|
||||
pm.logger = MagicMock()
|
||||
pm.state_manager = _StateManager()
|
||||
pm.health_tracker = None
|
||||
pm._synchronous_updates = False
|
||||
pm._reservation_lock = threading.Lock()
|
||||
pm._plugin_last_update_lock = threading.Lock()
|
||||
pm.plugin_last_update = {}
|
||||
pm.dispatched = []
|
||||
|
||||
# Stand in for the background worker: record the dispatch and complete it,
|
||||
# so the next tick can reserve the plugin again.
|
||||
def _enqueue(plugin_id, scheduled_time):
|
||||
pm.dispatched.append(scheduled_time)
|
||||
pm.plugin_last_update[plugin_id] = scheduled_time
|
||||
pm.state_manager.set_state(plugin_id, PluginState.ENABLED)
|
||||
|
||||
pm._enqueue_update = _enqueue
|
||||
return pm
|
||||
|
||||
|
||||
def _run_for(pm, plugin, seconds, start=1_000_000.0, step=1.0):
|
||||
"""Tick the real scheduler once a second for `seconds`."""
|
||||
pm.plugins = {"sports": plugin}
|
||||
t = start
|
||||
end = start + seconds
|
||||
while t < end:
|
||||
pm.run_scheduled_updates(current_time=t)
|
||||
t += step
|
||||
return len(pm.dispatched)
|
||||
|
||||
|
||||
class TestTheReportedBug:
|
||||
def test_a_live_game_is_polled_at_the_live_interval(self, scheduler):
|
||||
"""15s while live, not the manifest's 60s. This is the fix."""
|
||||
plugin = _Plugin(live_interval=15)
|
||||
plugin.live = True
|
||||
count = _run_for(scheduler, plugin, seconds=600) # ten minutes
|
||||
# 600s / 15s = 40, allowing one for the first tick's free run.
|
||||
assert 38 <= count <= 41, (
|
||||
f"{count} updates in 10 minutes of a live game; expected ~40 at 15s. "
|
||||
f"At the manifest's 60s it would be ~10 -- the reported bug.")
|
||||
|
||||
def test_the_same_window_without_the_hook_is_the_old_behaviour(self, scheduler):
|
||||
"""Pin what the bug actually looked like, so the contrast is asserted."""
|
||||
plugin = _Plugin()
|
||||
plugin.live = True
|
||||
plugin.get_update_interval = lambda: None # pre-fix: no opinion
|
||||
count = _run_for(scheduler, plugin, seconds=600)
|
||||
assert 9 <= count <= 11, (
|
||||
f"{count} updates in 10 minutes; expected ~10 at the manifest's 60s")
|
||||
|
||||
def test_idle_polling_is_unchanged(self, scheduler):
|
||||
"""The regression that would be worse than the bug.
|
||||
|
||||
Asking for 15s year-round would poll ESPN four times a minute all
|
||||
summer. Idle must stay exactly on the manifest.
|
||||
"""
|
||||
plugin = _Plugin()
|
||||
plugin.live = False
|
||||
count = _run_for(scheduler, plugin, seconds=3600) # a full hour idle
|
||||
assert 59 <= count <= 61, (
|
||||
f"{count} updates in an idle hour; expected ~60 at the manifest's 60s")
|
||||
|
||||
|
||||
class TestTheCadenceTracksTheGame:
|
||||
def test_it_speeds_up_when_a_game_starts_and_slows_when_it_ends(self, scheduler):
|
||||
"""No reload, no restart -- the whole point of a per-tick hook."""
|
||||
plugin = _Plugin(live_interval=15)
|
||||
pm = scheduler
|
||||
|
||||
_run_for(pm, plugin, seconds=300, start=1_000_000.0)
|
||||
idle_before = len(pm.dispatched)
|
||||
|
||||
plugin.live = True
|
||||
_run_for(pm, plugin, seconds=300, start=1_000_300.0)
|
||||
during = len(pm.dispatched) - idle_before
|
||||
|
||||
plugin.live = False
|
||||
_run_for(pm, plugin, seconds=300, start=1_000_600.0)
|
||||
idle_after = len(pm.dispatched) - idle_before - during
|
||||
|
||||
assert during > idle_before * 3, (
|
||||
f"live window got {during} updates vs {idle_before} idle; "
|
||||
"the hook did not speed anything up")
|
||||
assert idle_after <= idle_before + 1, (
|
||||
f"{idle_after} updates after the game ended vs {idle_before} before; "
|
||||
"the fast cadence leaked past the live window")
|
||||
|
||||
|
||||
def test_a_broken_hook_does_not_stop_the_plugin_updating(scheduler):
|
||||
"""A scheduler that propagates one plugin's bug stops every other plugin."""
|
||||
plugin = _Plugin()
|
||||
plugin.get_update_interval = MagicMock(side_effect=RuntimeError("boom"))
|
||||
count = _run_for(scheduler, plugin, seconds=600)
|
||||
assert 9 <= count <= 11, (
|
||||
f"{count} updates; a raising hook should fall back to the manifest's 60s")
|
||||
@@ -24,7 +24,8 @@ import pytest
|
||||
import requests
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
|
||||
from src.common.logo_helper import MAX_LOGO_BYTES, LogoHelper
|
||||
from src.common.logo_helper import (MAX_LOGO_BYTES, MISSING_LOGO_RECHECK_SECONDS,
|
||||
LogoHelper)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -494,3 +495,70 @@ class TestStalePlaceholderHandling:
|
||||
helper.load_logo_with_download("COLL", path, "http://x/c.png")
|
||||
|
||||
assert should_attempt_download(path) is False
|
||||
|
||||
|
||||
class TestMissingLogosAreRememberedNotRewarned:
|
||||
"""A file that is not there does not become there by being asked again.
|
||||
|
||||
load_logo() stat'd the path and logged a WARNING on every call, so a
|
||||
permanently absent logo produced one warning per rotation for as long as
|
||||
the process ran -- measured at 114 lines in 24 hours for a single missing
|
||||
ticker icon. The positive cache never covered this because a miss returns
|
||||
None and caches nothing.
|
||||
"""
|
||||
|
||||
def test_a_missing_logo_warns_once(self, helper, tmp_path, caplog):
|
||||
absent = tmp_path / "VOO.png"
|
||||
with caplog.at_level(logging.WARNING):
|
||||
for _ in range(50):
|
||||
assert helper.load_logo("VOO", absent) is None
|
||||
assert caplog.text.count("Logo not found for VOO") == 1, (
|
||||
f"warned {caplog.text.count('Logo not found for VOO')} times in 50 calls")
|
||||
|
||||
def test_the_miss_is_not_remembered_forever(self, helper, tmp_path, monkeypatch):
|
||||
"""A logo written later -- logo_downloader does this at runtime -- must
|
||||
still be picked up without a restart."""
|
||||
path = tmp_path / "LATE.png"
|
||||
assert helper.load_logo("LATE", path) is None
|
||||
|
||||
write_logo(path)
|
||||
# Still inside the recheck window: the remembered miss stands.
|
||||
assert helper.load_logo("LATE", path) is None
|
||||
|
||||
# Once it ages out, the next call stats the disk again and finds it.
|
||||
for key in helper._missing_logos:
|
||||
helper._missing_logos[key] -= MISSING_LOGO_RECHECK_SECONDS + 1
|
||||
assert helper.load_logo("LATE", path) is not None
|
||||
|
||||
def test_a_download_clears_the_miss_immediately(self, helper, tmp_path):
|
||||
"""load_logo_with_download() must not be defeated by its own miss record.
|
||||
|
||||
load_logo() consults _missing_logos before it stats the disk, so a
|
||||
logo we just downloaded would stay invisible for the whole recheck
|
||||
window without the explicit invalidation.
|
||||
"""
|
||||
path = tmp_path / "NEW.png"
|
||||
assert helper.load_logo("NEW", path) is None
|
||||
assert any(k.startswith("NEW_") for k in helper._missing_logos)
|
||||
|
||||
def _fake_download(url, file_path):
|
||||
write_logo(Path(file_path))
|
||||
|
||||
with patch.object(helper, "_download_logo", side_effect=_fake_download):
|
||||
got = helper.load_logo_with_download("NEW", path, logo_url="http://x/NEW.png")
|
||||
assert got is not None, "a freshly downloaded logo was hidden by the miss record"
|
||||
assert not any(k.startswith("NEW_") for k in helper._missing_logos)
|
||||
|
||||
def test_clear_cache_forgets_misses_too(self, helper, tmp_path):
|
||||
helper.load_logo("GONE", tmp_path / "GONE.png")
|
||||
assert helper._missing_logos
|
||||
helper.clear_cache()
|
||||
assert helper._missing_logos == {}
|
||||
|
||||
def test_a_present_logo_is_unaffected(self, helper, tmp_path, caplog):
|
||||
path = write_logo(tmp_path / "OK.png")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
for _ in range(10):
|
||||
assert helper.load_logo("OK", path) is not None
|
||||
assert "Logo not found" not in caplog.text
|
||||
assert helper._missing_logos == {}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"""A plugin can ask to be polled faster while it has something live.
|
||||
|
||||
The manifest carries one static `update_interval`, and until now that was the
|
||||
only thing the scheduler would look at. A sports scoreboard needs 15 seconds
|
||||
while a game is in progress and 15 minutes when nothing is on, and no single
|
||||
number expresses that.
|
||||
|
||||
Measured consequence, on a live rig during an NFL game in its fourth quarter:
|
||||
football-scoreboard's manifest pins update_interval to 60, so ESPN was polled
|
||||
exactly once a minute --
|
||||
|
||||
23:21:49 23:22:50 23:23:50 23:24:50 23:25:50
|
||||
|
||||
-- while the plugin's own live_update_interval said 15. The clock and score on
|
||||
the panel therefore lagged by up to a minute during a two-minute drill, which
|
||||
reads to a viewer as a frozen display.
|
||||
|
||||
get_update_interval() lets the plugin say what it needs per tick. These tests
|
||||
pin the parts that are easy to get wrong: that the hook wins, that "no opinion"
|
||||
falls back cleanly, that a broken hook cannot stop a plugin updating, and that
|
||||
the hook is not accidentally cached (which would defeat the whole point).
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.plugin_system.plugin_manager import PluginManager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def manager():
|
||||
pm = PluginManager.__new__(PluginManager)
|
||||
pm._update_interval_cache = {}
|
||||
pm.plugin_manifests = {"sports": {"update_interval": 60}}
|
||||
pm.config_manager = None
|
||||
pm.logger = MagicMock()
|
||||
return pm
|
||||
|
||||
|
||||
class _Plugin:
|
||||
def __init__(self, wants=None, raises=False):
|
||||
self._wants = wants
|
||||
self._raises = raises
|
||||
self.calls = 0
|
||||
|
||||
def get_update_interval(self):
|
||||
self.calls += 1
|
||||
if self._raises:
|
||||
raise RuntimeError("plugin is broken")
|
||||
return self._wants
|
||||
|
||||
|
||||
def test_the_hook_overrides_the_manifest(manager):
|
||||
"""The whole point: 15s while live, not the manifest's 60."""
|
||||
assert manager._get_plugin_update_interval("sports", _Plugin(wants=15)) == 15.0
|
||||
|
||||
|
||||
def test_none_means_no_opinion_and_the_manifest_applies(manager):
|
||||
"""A plugin with nothing live should not have to restate the default."""
|
||||
assert manager._get_plugin_update_interval("sports", _Plugin(wants=None)) == 60.0
|
||||
|
||||
|
||||
def test_a_plugin_without_the_hook_is_unaffected(manager):
|
||||
"""Every existing plugin predates this and must keep its manifest value."""
|
||||
assert manager._get_plugin_update_interval("sports", MagicMock(spec=[])) == 60.0
|
||||
|
||||
|
||||
def test_the_hook_is_consulted_every_time(manager):
|
||||
"""Caching the answer would make it exactly as static as the manifest.
|
||||
|
||||
The static path *is* cached -- reading config is expensive -- so it would be
|
||||
an easy mistake to let the dynamic answer ride along in that cache.
|
||||
"""
|
||||
plugin = _Plugin(wants=15)
|
||||
for _ in range(5):
|
||||
manager._get_plugin_update_interval("sports", plugin)
|
||||
assert plugin.calls == 5, f"hook called {plugin.calls} times in 5 lookups"
|
||||
|
||||
|
||||
def test_the_cadence_can_change_between_ticks(manager):
|
||||
"""A game going final must slow the polling back down without a reload."""
|
||||
plugin = _Plugin(wants=15)
|
||||
assert manager._get_plugin_update_interval("sports", plugin) == 15.0
|
||||
plugin._wants = None # game ended
|
||||
assert manager._get_plugin_update_interval("sports", plugin) == 60.0
|
||||
plugin._wants = 15 # another game started
|
||||
assert manager._get_plugin_update_interval("sports", plugin) == 15.0
|
||||
|
||||
|
||||
class TestABrokenHookCannotStopUpdates:
|
||||
"""A scheduler that propagates a plugin's bug stops every other plugin too."""
|
||||
|
||||
def test_a_raising_hook_falls_back_to_the_manifest(self, manager):
|
||||
assert manager._get_plugin_update_interval("sports", _Plugin(raises=True)) == 60.0
|
||||
|
||||
@pytest.mark.parametrize("junk", ["soon", object(), [], {}])
|
||||
def test_a_non_numeric_hook_falls_back(self, manager, junk):
|
||||
assert manager._get_plugin_update_interval("sports", _Plugin(wants=junk)) == 60.0
|
||||
|
||||
@pytest.mark.parametrize("junk", [float("nan"), float("inf"), float("-inf")])
|
||||
def test_nan_and_infinity_fall_back(self, manager, junk):
|
||||
assert manager._get_plugin_update_interval("sports", _Plugin(wants=junk)) == 60.0
|
||||
|
||||
@pytest.mark.parametrize("junk", [True, False])
|
||||
def test_a_bool_hook_falls_back(self, manager, junk):
|
||||
"""bool is a subclass of int, so it must be rejected before float()
|
||||
turns it into 0.0/1.0 and the floor silently swallows the mistake."""
|
||||
assert manager._get_plugin_update_interval("sports", _Plugin(wants=junk)) == 60.0
|
||||
|
||||
|
||||
class TestTheFloor:
|
||||
"""A plugin asking for 0 would be re-entered on every tick of the render
|
||||
loop, which is a busy-wait against whatever API it fetches."""
|
||||
|
||||
@pytest.mark.parametrize("wants", [0, 0.5, -10])
|
||||
def test_small_and_negative_requests_are_clamped(self, manager, wants):
|
||||
got = manager._get_plugin_update_interval("sports", _Plugin(wants=wants))
|
||||
assert got == PluginManager.MIN_DYNAMIC_UPDATE_INTERVAL
|
||||
|
||||
def test_a_reasonable_request_is_not_clamped(self, manager):
|
||||
assert manager._get_plugin_update_interval("sports", _Plugin(wants=15)) == 15.0
|
||||
|
||||
|
||||
def test_the_static_path_still_prefers_the_manifest_over_config():
|
||||
"""Deliberately unchanged, and not an oversight.
|
||||
|
||||
Config values that have sat inert behind the manifest are stale by
|
||||
definition -- nothing has been honouring them. On one rig, football and
|
||||
baseball both carry update_interval 3600 in config against a manifest 60;
|
||||
making config win would slow those plugins by 60x. The dynamic hook is the
|
||||
supported way for a plugin to vary its own cadence, so flipping this is
|
||||
both risky and no longer necessary.
|
||||
"""
|
||||
pm = PluginManager.__new__(PluginManager)
|
||||
pm._update_interval_cache = {}
|
||||
pm.plugin_manifests = {"sports": {"update_interval": 60}}
|
||||
cfg = MagicMock()
|
||||
cfg.get_config.return_value = {"sports": {"update_interval": 3600}}
|
||||
pm.config_manager = cfg
|
||||
pm.logger = MagicMock()
|
||||
assert pm._get_plugin_update_interval("sports", MagicMock(spec=[])) == 60.0
|
||||
|
||||
|
||||
def test_config_is_still_used_when_the_manifest_is_silent():
|
||||
pm = PluginManager.__new__(PluginManager)
|
||||
pm._update_interval_cache = {}
|
||||
pm.plugin_manifests = {"sports": {}}
|
||||
cfg = MagicMock()
|
||||
cfg.get_config.return_value = {"sports": {"update_interval": 120}}
|
||||
pm.config_manager = cfg
|
||||
pm.logger = MagicMock()
|
||||
assert pm._get_plugin_update_interval("sports", MagicMock(spec=[])) == 120.0
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Tests for plugin-state reconciliation reporting phantom inconsistencies.
|
||||
|
||||
Observed on a device running four installed, configured, working plugins: the
|
||||
web UI showed "Stale plugin config entries found: football-scoreboard,
|
||||
odds-ticker, data, ledmatrix-weather, starlark-apps. Remove them from
|
||||
config.json" for hours. Three separate defects combined to produce that:
|
||||
|
||||
1. load_config() merges config_secrets.json into the config it returns, and the
|
||||
ignore list named only 'github' and 'youtube'. A 'data' key in the secrets
|
||||
file therefore became a phantom plugin, reported as in-config-not-on-disk.
|
||||
|
||||
2. The auto-fix for "on disk but not in config" assigned
|
||||
``config[plugin_id] = {'enabled': False}`` unconditionally. When detection was
|
||||
wrong, that traded a plugin's real configuration (4.9KB of league settings in
|
||||
the reported case) for a stub. It only avoided firing there because the write
|
||||
failed with EACCES.
|
||||
|
||||
3. The verdict is a snapshot written once per run and served to the UI from a
|
||||
status file, and a run that fails to apply a fix also declines to retry. So a
|
||||
condition that had since resolved was reported indefinitely.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.plugin_system.state_reconciliation import (
|
||||
InconsistencyType,
|
||||
StateReconciliation,
|
||||
still_unresolved,
|
||||
)
|
||||
|
||||
|
||||
class _ConfigManager:
|
||||
"""Minimal config manager with the secrets path the real one exposes."""
|
||||
|
||||
def __init__(self, config, secrets_path=None):
|
||||
self._config = config
|
||||
self._secrets_path = secrets_path
|
||||
self.saved = []
|
||||
|
||||
def load_config(self):
|
||||
return dict(self._config)
|
||||
|
||||
def save_config(self, config):
|
||||
self.saved.append(config)
|
||||
self._config = dict(config)
|
||||
|
||||
def get_secrets_path(self):
|
||||
return self._secrets_path
|
||||
|
||||
|
||||
def _reconciler(config, plugins_dir, secrets_path=None):
|
||||
return StateReconciliation(
|
||||
state_manager=Mock(),
|
||||
config_manager=_ConfigManager(config, secrets_path),
|
||||
plugin_manager=Mock(),
|
||||
plugins_dir=plugins_dir,
|
||||
)
|
||||
|
||||
|
||||
class TestSecretsKeysAreNotPlugins:
|
||||
def test_secrets_key_is_not_treated_as_a_plugin(self, tmp_path):
|
||||
secrets = tmp_path / "config_secrets.json"
|
||||
# The shape actually found on the device: display state written here.
|
||||
secrets.write_text(json.dumps({"data": {"mode": "nfl_recent"},
|
||||
"timestamp": 1.0}), encoding="utf-8")
|
||||
config = {
|
||||
"data": {"mode": "nfl_recent"},
|
||||
"football-scoreboard": {"enabled": True, "nfl": {"favorite_teams": ["TB"]}},
|
||||
}
|
||||
state = _reconciler(config, tmp_path, secrets).\
|
||||
_get_config_state()
|
||||
|
||||
assert "data" not in state, "secrets key leaked in as a phantom plugin"
|
||||
assert "football-scoreboard" in state
|
||||
|
||||
def test_missing_secrets_file_is_survivable(self, tmp_path):
|
||||
state = _reconciler({"odds-ticker": {"enabled": True}}, tmp_path,
|
||||
tmp_path / "does-not-exist.json")._get_config_state()
|
||||
assert "odds-ticker" in state
|
||||
|
||||
def test_non_path_secrets_location_is_survivable(self, tmp_path):
|
||||
"""Regression: a non-path return raised TypeError out of the helper,
|
||||
which the broad handler in _get_config_state() swallowed as "Error
|
||||
reading config state" -- emptying the config state and making every
|
||||
downstream detection wrong."""
|
||||
r = _reconciler({"odds-ticker": {"enabled": True}}, tmp_path,
|
||||
secrets_path=Mock())
|
||||
assert "odds-ticker" in r._get_config_state()
|
||||
|
||||
def test_config_manager_without_secrets_path_is_survivable(self, tmp_path):
|
||||
r = StateReconciliation(state_manager=Mock(),
|
||||
config_manager=Mock(spec=["load_config", "save_config"]),
|
||||
plugin_manager=Mock(), plugins_dir=tmp_path)
|
||||
r.config_manager.load_config.return_value = {"odds-ticker": {"enabled": True}}
|
||||
assert "odds-ticker" in r._get_config_state()
|
||||
|
||||
|
||||
class TestFixNeverClobbersRealConfig:
|
||||
def _missing_in_config(self, plugin_id):
|
||||
inc = Mock()
|
||||
inc.inconsistency_type = InconsistencyType.PLUGIN_MISSING_IN_CONFIG
|
||||
inc.plugin_id = plugin_id
|
||||
return inc
|
||||
|
||||
def test_existing_entry_is_not_overwritten(self, tmp_path):
|
||||
rich = {"enabled": True, "nfl": {"favorite_teams": ["TB"], "show_odds": True}}
|
||||
r = _reconciler({"football-scoreboard": dict(rich)}, tmp_path)
|
||||
|
||||
assert r._fix_inconsistency(self._missing_in_config("football-scoreboard")) is True
|
||||
# The regression: this wrote {'enabled': False} over the real settings.
|
||||
assert r.config_manager.saved == []
|
||||
assert r.config_manager.load_config()["football-scoreboard"] == rich
|
||||
|
||||
def test_genuinely_absent_plugin_is_still_added(self, tmp_path):
|
||||
r = _reconciler({"other-plugin": {"enabled": True}}, tmp_path)
|
||||
|
||||
assert r._fix_inconsistency(self._missing_in_config("new-plugin")) is True
|
||||
assert len(r.config_manager.saved) == 1
|
||||
assert r.config_manager.load_config()["new-plugin"] == {"enabled": False}
|
||||
# and it must not disturb what was already there
|
||||
assert r.config_manager.load_config()["other-plugin"] == {"enabled": True}
|
||||
|
||||
|
||||
class TestStaleFindingsAreDropped:
|
||||
IN_CONFIG = InconsistencyType.PLUGIN_MISSING_IN_CONFIG.value
|
||||
ON_DISK = InconsistencyType.PLUGIN_MISSING_ON_DISK.value
|
||||
|
||||
def test_resolved_missing_in_config_is_dropped(self):
|
||||
entries = [{"plugin_id": "football-scoreboard", "type": self.IN_CONFIG}]
|
||||
assert still_unresolved(entries, {"football-scoreboard"}, set()) == []
|
||||
|
||||
def test_genuinely_missing_in_config_is_kept(self):
|
||||
entries = [{"plugin_id": "football-scoreboard", "type": self.IN_CONFIG}]
|
||||
assert still_unresolved(entries, set(), set()) == entries
|
||||
|
||||
def test_resolved_missing_on_disk_is_dropped(self):
|
||||
entries = [{"plugin_id": "odds-ticker", "type": self.ON_DISK}]
|
||||
assert still_unresolved(entries, set(), {"odds-ticker"}) == []
|
||||
|
||||
def test_genuinely_missing_on_disk_is_kept(self):
|
||||
entries = [{"plugin_id": "data", "type": self.ON_DISK}]
|
||||
assert still_unresolved(entries, set(), {"odds-ticker"}) == entries
|
||||
|
||||
def test_unrecheckable_kinds_are_kept(self):
|
||||
# Filtering must only ever remove what it can prove stale.
|
||||
entries = [{"plugin_id": "x", "type": "plugin_version_mismatch"}]
|
||||
assert still_unresolved(entries, set(), set()) == entries
|
||||
|
||||
def test_the_reported_device_state_clears_every_false_finding(self):
|
||||
"""The exact verdict the device served, against its real state."""
|
||||
entries = [
|
||||
{"plugin_id": "football-scoreboard", "type": self.IN_CONFIG},
|
||||
{"plugin_id": "odds-ticker", "type": self.IN_CONFIG},
|
||||
{"plugin_id": "data", "type": self.ON_DISK},
|
||||
{"plugin_id": "ledmatrix-weather", "type": self.IN_CONFIG},
|
||||
{"plugin_id": "starlark-apps", "type": self.IN_CONFIG},
|
||||
]
|
||||
installed = {"football-scoreboard", "ledmatrix-weather", "odds-ticker",
|
||||
"starlark-apps", "web-ui-info"}
|
||||
live = still_unresolved(entries, installed, installed)
|
||||
|
||||
# All four "installed but not in config" findings were false and clear.
|
||||
# 'data' legitimately is not installed, so this filter keeps it; it stops
|
||||
# being reported because _get_config_state() no longer invents it from the
|
||||
# secrets file, which takes effect on the next reconciliation run.
|
||||
assert [e["plugin_id"] for e in live] == ["data"]
|
||||
@@ -0,0 +1,112 @@
|
||||
"""The reconciliation-status endpoint must re-check its stored verdict.
|
||||
|
||||
#557 added _drop_stale_reconciliation_findings() so a resolved condition stops
|
||||
being reported: the verdict is a snapshot written once per run, and a run that
|
||||
fails to apply a fix also declines to retry, so a device whose plugins were all
|
||||
present in config kept being told for hours that four of them were missing.
|
||||
|
||||
That wiring had no test. Only the pure still_unresolved() helper in
|
||||
src/plugin_system/ was covered, which lives outside web_interface and therefore
|
||||
survived the api_v3 blueprint split untouched -- so when #553 moved this
|
||||
endpoint into api_v3/plugins.py, losing the filter would have been completely
|
||||
silent. This test exists so that cannot happen again.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from web_interface.blueprints.api_v3 import api_v3 # noqa: E402
|
||||
|
||||
IN_CONFIG = "plugin_missing_in_config"
|
||||
ON_DISK = "plugin_missing_on_disk"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path, monkeypatch):
|
||||
"""App whose status file, config and plugins dir are all under our control."""
|
||||
monkeypatch.setattr(tempfile, "gettempdir", lambda: str(tmp_path))
|
||||
|
||||
plugins_dir = tmp_path / "plugin-repos"
|
||||
plugins_dir.mkdir()
|
||||
|
||||
def _install(plugin_id):
|
||||
d = plugins_dir / plugin_id
|
||||
d.mkdir()
|
||||
(d / "manifest.json").write_text(json.dumps({"id": plugin_id}), encoding="utf-8")
|
||||
|
||||
def _setup(verdict, config=None, installed=()):
|
||||
(tmp_path / "ledmatrix_reconciliation.json").write_text(
|
||||
json.dumps(verdict), encoding="utf-8")
|
||||
for pid in installed:
|
||||
_install(pid)
|
||||
api_v3.config_manager = MagicMock()
|
||||
api_v3.config_manager.load_config.return_value = dict(config or {})
|
||||
api_v3.plugin_manager = MagicMock()
|
||||
api_v3.plugin_manager.plugins_dir = str(plugins_dir)
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
app.register_blueprint(api_v3, url_prefix="/api/v3")
|
||||
return app.test_client()
|
||||
|
||||
return _setup
|
||||
|
||||
|
||||
def _get(c):
|
||||
return c.get("/api/v3/plugins/reconciliation-status").get_json()["data"]
|
||||
|
||||
|
||||
class TestStaleFindingsAreDroppedByTheEndpoint:
|
||||
def test_a_plugin_now_in_config_is_no_longer_reported(self, client):
|
||||
c = client({"done": True, "unresolved": [
|
||||
{"plugin_id": "football-scoreboard", "type": IN_CONFIG}]},
|
||||
config={"football-scoreboard": {"enabled": True}})
|
||||
# The regression: this kept being reported for hours after it resolved.
|
||||
assert _get(c)["unresolved"] == []
|
||||
|
||||
def test_a_plugin_now_installed_is_no_longer_reported(self, client):
|
||||
c = client({"done": True, "unresolved": [
|
||||
{"plugin_id": "odds-ticker", "type": ON_DISK}]},
|
||||
installed=["odds-ticker"])
|
||||
assert _get(c)["unresolved"] == []
|
||||
|
||||
def test_a_finding_that_still_holds_is_kept(self, client):
|
||||
c = client({"done": True, "unresolved": [
|
||||
{"plugin_id": "ghost-plugin", "type": ON_DISK}]})
|
||||
assert [e["plugin_id"] for e in _get(c)["unresolved"]] == ["ghost-plugin"]
|
||||
|
||||
def test_the_reported_device_verdict_clears(self, client):
|
||||
"""The five findings the live device served, against its real state."""
|
||||
installed = ["football-scoreboard", "ledmatrix-weather",
|
||||
"odds-ticker", "starlark-apps"]
|
||||
c = client({"done": True, "unresolved": [
|
||||
{"plugin_id": p, "type": IN_CONFIG} for p in installed]},
|
||||
config={p: {"enabled": True} for p in installed},
|
||||
installed=installed)
|
||||
assert _get(c)["unresolved"] == []
|
||||
|
||||
|
||||
class TestTheEndpointStaysRobust:
|
||||
def test_an_empty_verdict_is_passed_through(self, client):
|
||||
c = client({"done": True, "unresolved": []})
|
||||
assert _get(c) == {"done": True, "unresolved": []}
|
||||
|
||||
def test_a_broken_config_manager_leaves_findings_untouched(self, client):
|
||||
"""Best-effort: a stale warning beats a failed endpoint."""
|
||||
c = client({"done": True, "unresolved": [
|
||||
{"plugin_id": "football-scoreboard", "type": IN_CONFIG}]})
|
||||
api_v3.config_manager.load_config.side_effect = OSError("config unreadable")
|
||||
body = _get(c)
|
||||
assert [e["plugin_id"] for e in body["unresolved"]] == ["football-scoreboard"]
|
||||
|
||||
def test_a_run_still_in_progress_is_reported_as_such(self, client):
|
||||
c = client({"done": False, "unresolved": []})
|
||||
assert _get(c)["done"] is False
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Tests that SportsCore's decoded-logo cache is LRU-bounded.
|
||||
|
||||
``self._logo_cache`` was a plain dict keyed by team abbreviation, with no
|
||||
eviction. The entries are decoded RGBA thumbnails sized to display*1.5 -- about
|
||||
36KB on a 256x64 panel, more for wide wordmarks -- and
|
||||
assets/sports/ncaa_logos ships 307 of them. A plugin that walked a full league
|
||||
therefore held the whole league in memory: roughly 11-18MB per manager
|
||||
instance, and a league runs three of them (live/recent/upcoming) each with its
|
||||
own cache. On a 1GB Pi 3B+ with ~290MB available that is worth recovering.
|
||||
|
||||
The bound has to be LRU rather than "clear when full": the logos on screen
|
||||
right now are exactly the ones that must not be thrown away.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
# src.base_classes.sports transitively imports the hardware matrix driver.
|
||||
sys.modules.setdefault("rgbmatrix", MagicMock())
|
||||
|
||||
from src.base_classes.sports import SportsCore # noqa: E402
|
||||
|
||||
LOGGER = logging.getLogger("test_sports_logo_cache_bounded")
|
||||
|
||||
|
||||
class _StubSports(SportsCore):
|
||||
def _fetch_data(self):
|
||||
return None
|
||||
|
||||
def _extract_game_details(self, game_event):
|
||||
return None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def host(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(
|
||||
SportsCore, "_initialize_logo_dir", lambda self, configured: tmp_path)
|
||||
monkeypatch.setattr(
|
||||
"src.base_classes.sports.core.get_background_service",
|
||||
lambda *args, **kwargs: MagicMock())
|
||||
# Never reach for the network: every logo these tests ask for exists.
|
||||
monkeypatch.setattr(
|
||||
"src.base_classes.sports.core.download_missing_logo",
|
||||
lambda *a, **k: None)
|
||||
|
||||
display_manager = MagicMock()
|
||||
display_manager.matrix.width = 128
|
||||
display_manager.matrix.height = 32
|
||||
display_manager.width = 128
|
||||
display_manager.height = 32
|
||||
display_manager.image = Image.new("RGB", (128, 32))
|
||||
cache_manager = MagicMock()
|
||||
cache_manager.cache_dir = str(tmp_path)
|
||||
instance = _StubSports({"timezone": "UTC"}, display_manager,
|
||||
cache_manager, LOGGER, "nhl")
|
||||
instance._logo_dir = tmp_path
|
||||
return instance, tmp_path
|
||||
|
||||
|
||||
def _load(host, abbrev):
|
||||
"""Create a real logo file for `abbrev` and load it through the cache."""
|
||||
instance, tmp_path = host
|
||||
path = tmp_path / f"{abbrev}.png"
|
||||
if not path.exists():
|
||||
Image.new("RGBA", (64, 64), (1, 2, 3, 255)).save(path)
|
||||
return instance._load_and_resize_logo(abbrev, abbrev, path, None)
|
||||
|
||||
|
||||
class TestTheCacheIsBounded:
|
||||
def test_it_never_exceeds_the_limit(self, host):
|
||||
instance, _ = host
|
||||
limit = instance._LOGO_CACHE_MAX
|
||||
for i in range(limit + 40):
|
||||
assert _load(host, f"T{i}") is not None
|
||||
# The regression: this grew to limit + 40, and for NCAA to 307.
|
||||
assert len(instance._logo_cache) == limit
|
||||
|
||||
def test_the_limit_is_smaller_than_a_real_league(self, host):
|
||||
"""ncaa_logos ships 307 files; the cap has to be well under that."""
|
||||
instance, _ = host
|
||||
assert instance._LOGO_CACHE_MAX < 307
|
||||
|
||||
|
||||
class TestEvictionIsLRUNotArbitrary:
|
||||
def test_the_least_recently_used_goes_first(self, host):
|
||||
instance, _ = host
|
||||
limit = instance._LOGO_CACHE_MAX
|
||||
for i in range(limit):
|
||||
_load(host, f"T{i}")
|
||||
_load(host, "NEW")
|
||||
assert "T0" not in instance._logo_cache, "oldest should have been evicted"
|
||||
assert "NEW" in instance._logo_cache
|
||||
|
||||
def test_touching_an_entry_saves_it(self, host):
|
||||
instance, _ = host
|
||||
limit = instance._LOGO_CACHE_MAX
|
||||
for i in range(limit):
|
||||
_load(host, f"T{i}")
|
||||
_load(host, "T0") # a cache hit -- T0 is on screen again
|
||||
_load(host, "NEW") # forces one eviction
|
||||
|
||||
assert "T0" in instance._logo_cache, "a logo in use was thrown away"
|
||||
assert "T1" not in instance._logo_cache, "T1 was the true LRU entry"
|
||||
|
||||
|
||||
class TestHitsStillAvoidDiskWork:
|
||||
def test_a_second_request_returns_the_cached_object(self, host, monkeypatch):
|
||||
instance, _ = host
|
||||
first = _load(host, "TB")
|
||||
|
||||
def _boom(*a, **k):
|
||||
raise AssertionError("cache hit re-opened the file from disk")
|
||||
|
||||
monkeypatch.setattr(Image, "open", _boom)
|
||||
assert _load(host, "TB") is first
|
||||
@@ -0,0 +1,131 @@
|
||||
"""A privileged system action that cannot run must say why.
|
||||
|
||||
The web interface runs unprivileged. Its systemctl/reboot/journalctl calls only
|
||||
work once scripts/install/configure_web_sudo.sh has granted NOPASSWD, and
|
||||
first_time_install.sh never invokes that script -- so on a fresh device every
|
||||
one of those actions fails. What the user got back was:
|
||||
|
||||
{"message": "Action failed; see logs for details", "status": "error"}
|
||||
|
||||
...and the log viewer was broken for exactly the same reason, so "see logs" led
|
||||
nowhere. This is the closed loop that src/web_interface/error_handler.py's
|
||||
describe_exception() was written to break; /system/action's exception handler
|
||||
was simply still discarding the cause.
|
||||
|
||||
Observed live: POSTing reboot_system to a Pi returned that bare sentence, and
|
||||
pinning down "no passwordless sudo" took reading the installer instead of
|
||||
reading the response.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from web_interface.blueprints.api_v3 import api_v3 # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
app = Flask(__name__)
|
||||
app.config['TESTING'] = True
|
||||
app.register_blueprint(api_v3, url_prefix='/api/v3')
|
||||
return app.test_client()
|
||||
|
||||
|
||||
def _act(client, action='stop_display'):
|
||||
return client.post('/api/v3/system/action', json={'action': action})
|
||||
|
||||
|
||||
def _result(args, rc=0, stderr=''):
|
||||
return subprocess.CompletedProcess(args, rc, stdout='', stderr=stderr)
|
||||
|
||||
|
||||
class TestSudoRefusalIsNamed:
|
||||
@pytest.mark.parametrize("stderr", [
|
||||
"sudo: a password is required",
|
||||
"sudo: no tty present and no askpass program specified",
|
||||
"sudo: a terminal is required to read the password",
|
||||
])
|
||||
def test_every_sudo_refusal_wording_is_recognised(self, client, stderr):
|
||||
with patch('subprocess.run', side_effect=lambda a, **k: _result(a, 1, stderr)):
|
||||
body = _act(client).get_json()
|
||||
|
||||
assert body['status'] == 'error'
|
||||
# The point: the response tells the user what to do about it.
|
||||
assert 'configure_web_sudo.sh' in body['message']
|
||||
|
||||
def test_the_raw_stderr_is_still_returned(self, client):
|
||||
with patch('subprocess.run',
|
||||
side_effect=lambda a, **k: _result(a, 1, "sudo: a password is required")):
|
||||
body = _act(client).get_json()
|
||||
assert 'a password is required' in body['stderr']
|
||||
|
||||
|
||||
class TestUnrelatedFailuresAreNotMisattributed:
|
||||
def test_a_real_systemctl_error_keeps_the_generic_message(self, client):
|
||||
with patch('subprocess.run',
|
||||
side_effect=lambda a, **k: _result(a, 5, "Unit ledmatrix.service not found.")):
|
||||
body = _act(client).get_json()
|
||||
|
||||
assert 'configure_web_sudo.sh' not in body['message'], \
|
||||
"a missing unit was blamed on sudo"
|
||||
assert 'Unit ledmatrix.service not found.' in body['stderr']
|
||||
assert body['returncode'] == 5
|
||||
|
||||
|
||||
class TestTheExceptionPathNamesTheCause:
|
||||
def test_the_exception_type_and_message_are_returned(self, client):
|
||||
with patch('subprocess.run', side_effect=FileNotFoundError(2, "sudo")):
|
||||
body = _act(client).get_json()
|
||||
|
||||
# The regression: 'details' was absent and the cause was discarded.
|
||||
assert 'FileNotFoundError' in body['details']
|
||||
|
||||
def test_a_sudo_shaped_exception_also_gets_the_hint(self, client):
|
||||
with patch('subprocess.run',
|
||||
side_effect=PermissionError("sudo: a password is required")):
|
||||
body = _act(client).get_json()
|
||||
assert 'configure_web_sudo.sh' in body['message']
|
||||
|
||||
|
||||
class TestTheEarlyReturnPathsAlsoGetTheHint:
|
||||
"""start_display with a mode builds its own response and returns before the
|
||||
shared nonzero-result path, so it needed the hint separately. Found by
|
||||
CodeRabbit on #560; verified against the code before fixing."""
|
||||
|
||||
def test_on_demand_start_reports_the_sudo_cause(self, client):
|
||||
with patch('subprocess.run',
|
||||
side_effect=lambda a, **k: _result(a, 1, "sudo: a password is required")):
|
||||
body = client.post('/api/v3/system/action',
|
||||
json={'action': 'start_display', 'mode': 'nfl_live'}).get_json()
|
||||
|
||||
assert 'configure_web_sudo.sh' in body['message'], "the on-demand branch still reported only 'Failed to start display'"
|
||||
assert body['status'] == 'error'
|
||||
|
||||
def test_on_demand_start_keeps_its_success_message(self, client):
|
||||
with patch('subprocess.run', side_effect=lambda a, **k: _result(a, 0)):
|
||||
body = client.post('/api/v3/system/action',
|
||||
json={'action': 'start_display', 'mode': 'nfl_live'}).get_json()
|
||||
assert body['status'] == 'success'
|
||||
assert body['message'] == 'Display started'
|
||||
|
||||
def test_an_unrelated_on_demand_failure_is_not_blamed_on_sudo(self, client):
|
||||
with patch('subprocess.run',
|
||||
side_effect=lambda a, **k: _result(a, 5, "Unit not found.")):
|
||||
body = client.post('/api/v3/system/action',
|
||||
json={'action': 'start_display', 'mode': 'nfl_live'}).get_json()
|
||||
assert body['message'] == 'Failed to start display'
|
||||
|
||||
|
||||
class TestSuccessIsUnchanged:
|
||||
def test_a_working_action_still_reports_success(self, client):
|
||||
with patch('subprocess.run', side_effect=lambda a, **k: _result(a, 0)):
|
||||
body = _act(client).get_json()
|
||||
assert body['status'] == 'success'
|
||||
assert 'configure_web_sudo.sh' not in body.get('message', '')
|
||||
@@ -17,6 +17,8 @@ editing files under /etc and restarting services is the installer's job, not
|
||||
something a display process should do to a machine while it boots.
|
||||
"""
|
||||
import logging
|
||||
import shlex
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
@@ -150,3 +152,122 @@ def test_a_missing_installed_unit_is_silent(validator, tmp_path):
|
||||
validator._validate_systemd_units()
|
||||
assert not validator.warnings
|
||||
assert not validator.errors
|
||||
|
||||
|
||||
# --- the web unit: one template, three copies, and a warning that never cleared ---
|
||||
#
|
||||
# install_service.sh and install_web_service.sh each carried their own inline
|
||||
# heredoc of ledmatrix-web.service. install_service.sh's had drifted -- no
|
||||
# Wants=network-online.target, RestartSec, SyslogIdentifier or CacheDirectory --
|
||||
# and that is what was installed on real rigs. The validator correctly reported
|
||||
# the drift and told the user to re-run install_service.sh, which reinstalled the
|
||||
# same stale copy, so the warning could never clear. Separately, the template
|
||||
# hardcoded User=root while the installers write whoever ran them, so even the
|
||||
# *correct* installer produced a permanent warning on any non-root install.
|
||||
|
||||
|
||||
def _render(template_text, project_root, user):
|
||||
"""Exactly what the installers' sed does."""
|
||||
return (template_text
|
||||
.replace("__PROJECT_ROOT_DIR__", str(project_root))
|
||||
.replace("__USER__", user))
|
||||
|
||||
|
||||
def test_the_web_unit_installed_as_a_non_root_user_is_not_drift(validator, tmp_path):
|
||||
"""The web interface runs as whoever installed it, not as root.
|
||||
|
||||
This is the case that warned forever: nothing the user could do would make
|
||||
an installed `User=pi` match a template that said `User=root`.
|
||||
"""
|
||||
project_root = Path("src/startup_validator.py").resolve().parent.parent
|
||||
template_rel = "systemd/ledmatrix-web.service"
|
||||
template = project_root / template_rel
|
||||
if not template.is_file():
|
||||
pytest.skip("repo unit template not present")
|
||||
|
||||
installed = tmp_path / "ledmatrix-web.service"
|
||||
installed.write_text(
|
||||
_render(template.read_text(encoding="utf-8"), project_root, "hdpi"),
|
||||
encoding="utf-8")
|
||||
|
||||
validator._UNITS = ((template_rel, str(installed)),)
|
||||
validator._validate_systemd_units()
|
||||
assert not validator.warnings, (
|
||||
f"a correctly installed non-root web unit warned: {validator.warnings}")
|
||||
|
||||
|
||||
def test_the_web_unit_still_reports_a_real_changed_directive(validator, tmp_path):
|
||||
"""Ignoring User= must not make the check blind to everything else."""
|
||||
project_root = Path("src/startup_validator.py").resolve().parent.parent
|
||||
template_rel = "systemd/ledmatrix-web.service"
|
||||
template = project_root / template_rel
|
||||
if not template.is_file():
|
||||
pytest.skip("repo unit template not present")
|
||||
|
||||
rendered = _render(template.read_text(encoding="utf-8"), project_root, "hdpi")
|
||||
# Drop RestartSec -- one of the directives the stale heredoc was missing.
|
||||
stale = "\n".join(line for line in rendered.splitlines() if not line.startswith("RestartSec="))
|
||||
installed = tmp_path / "ledmatrix-web.service"
|
||||
installed.write_text(stale + "\n", encoding="utf-8")
|
||||
|
||||
validator._UNITS = ((template_rel, str(installed)),)
|
||||
validator._validate_systemd_units()
|
||||
assert validator.warnings, "a web unit missing RestartSec= produced no warning"
|
||||
assert not validator.errors
|
||||
|
||||
|
||||
def test_installed_user_falls_back_to_root():
|
||||
"""systemd defaults a system unit with no User= to root, so we must too."""
|
||||
assert StartupValidator._installed_user("[Service]\nExecStart=/x\n") == "root"
|
||||
assert StartupValidator._installed_user("[Service]\nUser=pi\n") == "pi"
|
||||
assert StartupValidator._installed_user("[Service]\n User=hdpi \n") == "hdpi"
|
||||
|
||||
|
||||
def test_sed_escape_replacement_preserves_special_characters():
|
||||
"""A project path or username containing sed-special characters must render literally.
|
||||
|
||||
The installers build their sed expression by interpolating a shell
|
||||
variable into the replacement side of `sed s|pattern|replacement|`.
|
||||
Unescaped, sed treats `&` as "insert the whole match" and `\\` as an
|
||||
escape character, so a path like `/opt/led&matrix` would corrupt the
|
||||
rendered unit instead of being substituted as-is. lib_systemd_render.sh's
|
||||
sed_escape_replacement exists to prevent exactly that.
|
||||
"""
|
||||
project_root = Path("src/startup_validator.py").resolve().parent.parent
|
||||
helper = project_root / "scripts" / "install" / "lib_systemd_render.sh"
|
||||
if not helper.is_file():
|
||||
pytest.skip("install helper not present")
|
||||
|
||||
value = "/opt/led&matrix\\pi|two"
|
||||
escape_cmd = f'source {shlex.quote(str(helper))}; sed_escape_replacement {shlex.quote(value)}'
|
||||
escaped = subprocess.run(
|
||||
["bash", "-c", escape_cmd], capture_output=True, text=True, check=True
|
||||
).stdout
|
||||
|
||||
rendered = subprocess.run(
|
||||
["sed", f"s|__X__|{escaped}|g"],
|
||||
input="path=__X__\n", capture_output=True, text=True, check=True,
|
||||
).stdout
|
||||
|
||||
assert rendered == f"path={value}\n", (
|
||||
"a sed-special character in the replacement was not preserved literally")
|
||||
|
||||
|
||||
def test_no_installer_carries_its_own_copy_of_a_unit():
|
||||
"""The regression guard.
|
||||
|
||||
Both installers used to inline the unit as a heredoc, and the two copies
|
||||
drifted from the template and from each other. A unit body in a shell script
|
||||
is the bug, so assert there isn't one rather than asserting the current
|
||||
contents match -- matching contents is exactly what silently stops being
|
||||
true.
|
||||
"""
|
||||
project_root = Path("src/startup_validator.py").resolve().parent.parent
|
||||
offenders = []
|
||||
for script in sorted((project_root / "scripts" / "install").glob("*.sh")):
|
||||
text = script.read_text(encoding="utf-8", errors="replace")
|
||||
if "[Unit]" in text and "Description=" in text:
|
||||
offenders.append(script.name)
|
||||
assert not offenders, (
|
||||
f"{offenders} contain an inline systemd unit; render "
|
||||
f"systemd/*.service instead so there is one source of truth")
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Tests for the web interface's autostart default.
|
||||
|
||||
Regression under test: ``start_web_conditionally.py`` read the flag with
|
||||
``config_data.get("web_display_autostart", False)``, so a config that simply
|
||||
lacked the key got no web interface. Both config.template.json and
|
||||
first_time_install.sh ship the key as ``true``, so absence means an older or
|
||||
hand-edited config -- not a request to stay down.
|
||||
|
||||
The failure was silent in the worst way: the "not starting" path exits 0, so
|
||||
``systemctl status ledmatrix-web`` reported the unit as successfully started
|
||||
while nothing was listening on the port, and the journal's only trace was one
|
||||
line saying the flag was "false or not set".
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
SCRIPT = (Path(__file__).resolve().parents[1]
|
||||
/ "scripts" / "utils" / "start_web_conditionally.py")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def mod():
|
||||
spec = importlib.util.spec_from_file_location("start_web_conditionally", SCRIPT)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class TestAutostartDefault:
|
||||
def test_absent_key_still_starts(self, mod):
|
||||
# The regression: this returned False and the UI silently stayed down.
|
||||
assert mod.autostart_enabled({}) is True
|
||||
|
||||
def test_unreadable_config_still_starts(self, mod):
|
||||
# main() falls back to {} when the config is missing or corrupt, because
|
||||
# the web interface is how a broken config gets repaired.
|
||||
assert mod.autostart_enabled({}) is True
|
||||
|
||||
def test_explicit_true_starts(self, mod):
|
||||
assert mod.autostart_enabled({"web_display_autostart": True}) is True
|
||||
|
||||
def test_explicit_false_does_not_start(self, mod):
|
||||
assert mod.autostart_enabled({"web_display_autostart": False}) is False
|
||||
|
||||
@pytest.mark.parametrize("value", ["off", "false", "no", "0", "OFF", " False "])
|
||||
def test_disabling_strings_do_not_start(self, mod, value):
|
||||
assert mod.autostart_enabled({"web_display_autostart": value}) is False
|
||||
|
||||
@pytest.mark.parametrize("value", ["on", "true", "yes", "1", "TRUE"])
|
||||
def test_enabling_strings_start(self, mod, value):
|
||||
assert mod.autostart_enabled({"web_display_autostart": value}) is True
|
||||
|
||||
|
||||
class TestShippedDefaultsAgree:
|
||||
def test_template_ships_autostart_true(self):
|
||||
"""The code default must match what the installer actually writes."""
|
||||
import json
|
||||
template = json.loads(
|
||||
(SCRIPT.parents[2] / "config" / "config.template.json").read_text(encoding="utf-8"))
|
||||
assert template["web_display_autostart"] is True
|
||||
Reference in New Issue
Block a user