mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
run() stage 2: a pure Arbiter.decide() (src/display_arbiter.py) chooses scheduled-off, follower and WiFi notices; everything else takes the existing path. Golden traces byte-identical. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
243 lines
9.2 KiB
Python
243 lines
9.2 KiB
Python
"""Display-controller fixes found testing on a real Pi (ledpi).
|
|
|
|
- systemd stops the service with SIGTERM; it must run the same cleanup as Ctrl-C.
|
|
- The current mode is republished while it stays on screen, so the web UI's
|
|
"Now showing" does not turn into "unknown" after its 120 s max_age.
|
|
- Waking from scheduled-off, blanking, or on-demand starting or ending with
|
|
the mode name unchanged is republished at once, not up to 30 s later.
|
|
- Switching Vegas on in the web UI works when Vegas was off at startup.
|
|
"""
|
|
|
|
import os
|
|
import signal
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
os.environ.setdefault("EMULATOR", "true")
|
|
|
|
from src import display_controller as dc_module
|
|
from src.display_controller import DisplayController
|
|
|
|
|
|
# --- SIGTERM ----------------------------------------------------------------
|
|
|
|
def test_sigterm_handler_raises_keyboard_interrupt():
|
|
with pytest.raises(KeyboardInterrupt):
|
|
dc_module._raise_keyboard_interrupt(signal.SIGTERM, None)
|
|
|
|
|
|
def test_main_routes_sigterm_through_run_cleanup():
|
|
"""main() installs the handler before run(); SIGTERM then ends run() the
|
|
way Ctrl-C does, so its finally-cleanup runs."""
|
|
events = []
|
|
|
|
class FakeController:
|
|
def run(self):
|
|
try:
|
|
handler = signal.getsignal(signal.SIGTERM)
|
|
handler(signal.SIGTERM, None) # what the signal would do
|
|
except KeyboardInterrupt:
|
|
events.append("interrupted")
|
|
finally:
|
|
events.append("cleanup")
|
|
|
|
previous = signal.getsignal(signal.SIGTERM)
|
|
try:
|
|
with patch.object(dc_module, "DisplayController", FakeController):
|
|
dc_module.main()
|
|
finally:
|
|
signal.signal(signal.SIGTERM, previous)
|
|
assert events == ["interrupted", "cleanup"]
|
|
|
|
|
|
# --- current-state heartbeat ------------------------------------------------
|
|
|
|
def _publisher():
|
|
dc = object.__new__(DisplayController)
|
|
dc.cache_manager = MagicMock()
|
|
dc.current_display_mode = "mlb_live"
|
|
dc.mode_to_plugin_id = {"mlb_live": "baseball-scoreboard"}
|
|
dc.current_mode_index = 0
|
|
dc.available_modes = ["mlb_live"]
|
|
dc.on_demand_active = False
|
|
dc.is_display_active = True
|
|
dc._last_published_mode = None
|
|
dc._last_published_at = 0.0
|
|
return dc
|
|
|
|
|
|
def test_unchanged_mode_is_republished_after_the_refresh_interval():
|
|
dc = _publisher()
|
|
now = [1000.0]
|
|
with patch.object(dc_module.time, "monotonic", lambda: now[0]):
|
|
dc._publish_current_mode_state_if_changed() # first publish
|
|
now[0] += 5
|
|
dc._publish_current_mode_state_if_changed() # unchanged, recent
|
|
assert dc.cache_manager.set.call_count == 1
|
|
now[0] += dc_module.CURRENT_STATE_REFRESH_SECONDS # same mode, stale
|
|
dc._publish_current_mode_state_if_changed()
|
|
assert dc.cache_manager.set.call_count == 2
|
|
|
|
|
|
def _published(dc):
|
|
return dc.cache_manager.set.call_args[0][1]
|
|
|
|
|
|
@pytest.mark.parametrize("attr,before,after", [
|
|
("is_display_active", False, True), # woke from scheduled-off
|
|
("is_display_active", True, False), # blanked for scheduled-off
|
|
("on_demand_active", False, True), # on-demand started on this mode
|
|
("on_demand_active", True, False), # ...and ended on it
|
|
])
|
|
def test_a_flag_change_with_the_same_mode_is_republished_at_once(attr, before, after):
|
|
"""ledpi: current-status showed is_display_active stale for up to 30 s
|
|
after a wake from scheduled-off, because the mode name had not changed."""
|
|
dc = _publisher()
|
|
setattr(dc, attr, before)
|
|
now = [1000.0]
|
|
with patch.object(dc_module.time, "monotonic", lambda: now[0]):
|
|
dc._publish_current_mode_state() # e.g. the blank's publish
|
|
assert _published(dc)[attr] is before
|
|
now[0] += 1 # well inside the refresh
|
|
setattr(dc, attr, after)
|
|
dc._publish_current_mode_state_if_changed() # the next publish point
|
|
assert dc.cache_manager.set.call_count == 2
|
|
assert _published(dc)[attr] is after
|
|
assert _published(dc)["mode"] == "mlb_live"
|
|
|
|
|
|
def test_wake_inside_the_blank_sleep_is_published_by_the_service_helper():
|
|
"""The blank sleeps through _service_pending_changes; the schedule turning
|
|
the display back on there reaches the web UI on that same call."""
|
|
dc = _publisher()
|
|
dc.is_display_active = False
|
|
dc._last_pending_service = None
|
|
dc.PENDING_CHANGES_INTERVAL = 0.25
|
|
for name in ("_poll_on_demand_requests", "_check_on_demand_expiration",
|
|
"_apply_brightness_target"):
|
|
setattr(dc, name, MagicMock())
|
|
|
|
def wake():
|
|
dc.is_display_active = True
|
|
dc._evaluate_schedule = MagicMock(side_effect=wake)
|
|
now = [5000.0]
|
|
with patch.object(dc_module.time, "monotonic", lambda: now[0]):
|
|
dc._publish_current_mode_state() # published while blank
|
|
now[0] += 2
|
|
dc._service_pending_changes()
|
|
assert dc.cache_manager.set.call_count == 2
|
|
assert _published(dc)["is_display_active"] is True
|
|
|
|
|
|
def test_unchanged_flags_do_not_add_writes():
|
|
dc = _publisher()
|
|
now = [1000.0]
|
|
with patch.object(dc_module.time, "monotonic", lambda: now[0]):
|
|
dc._publish_current_mode_state_if_changed()
|
|
for _ in range(5):
|
|
now[0] += 1
|
|
dc._publish_current_mode_state_if_changed()
|
|
assert dc.cache_manager.set.call_count == 1
|
|
|
|
|
|
def test_refresh_interval_is_well_inside_the_web_max_age():
|
|
# api_v3/display.py reads display_current_state with max_age=120.
|
|
assert dc_module.CURRENT_STATE_REFRESH_SECONDS < 120 / 2
|
|
|
|
|
|
# --- Vegas switched on after startup ----------------------------------------
|
|
|
|
def _vegas_controller(enabled_at_start):
|
|
dc = object.__new__(DisplayController)
|
|
dc.config = {"display": {"vegas_scroll": {"enabled": enabled_at_start}}}
|
|
dc.vegas_coordinator = None
|
|
dc._pending_vegas_init = False
|
|
dc.on_demand_active = False
|
|
dc._refresh_config_cache = MagicMock()
|
|
dc._enabled_set_changed = MagicMock(return_value=False)
|
|
dc._enabled_plugin_not_running = MagicMock(return_value=False)
|
|
return dc
|
|
|
|
|
|
def test_enabling_vegas_live_creates_the_coordinator_on_the_render_thread():
|
|
dc = _vegas_controller(enabled_at_start=False)
|
|
old = {"display": {"vegas_scroll": {"enabled": False}}}
|
|
new = {"display": {"vegas_scroll": {"enabled": True}}}
|
|
|
|
dc._controller_config_change(old, new) # watcher thread: flag only
|
|
assert dc._pending_vegas_init is True
|
|
|
|
coordinator = MagicMock(is_enabled=True)
|
|
|
|
def init():
|
|
dc.vegas_coordinator = coordinator
|
|
dc._initialize_vegas_mode = MagicMock(side_effect=init)
|
|
|
|
assert dc._is_vegas_mode_active() is True # render thread: created here
|
|
dc._initialize_vegas_mode.assert_called_once_with()
|
|
assert dc._pending_vegas_init is False
|
|
dc._is_vegas_mode_active()
|
|
dc._initialize_vegas_mode.assert_called_once_with() # not re-created
|
|
|
|
|
|
def test_config_changes_without_vegas_enabled_do_not_flag_init():
|
|
dc = _vegas_controller(enabled_at_start=False)
|
|
same = {"display": {"vegas_scroll": {"enabled": False}}}
|
|
dc._controller_config_change(same, same)
|
|
assert dc._pending_vegas_init is False
|
|
|
|
|
|
def test_sigterm_handler_is_installed_only_after_construction():
|
|
"""A SIGTERM while plugins load in __init__ keeps the default exit."""
|
|
seen = {}
|
|
|
|
class FakeController:
|
|
def __init__(self):
|
|
seen["during_init"] = signal.getsignal(signal.SIGTERM)
|
|
|
|
def run(self):
|
|
seen["during_run"] = signal.getsignal(signal.SIGTERM)
|
|
|
|
previous = signal.getsignal(signal.SIGTERM)
|
|
try:
|
|
signal.signal(signal.SIGTERM, signal.SIG_DFL)
|
|
with patch.object(dc_module, "DisplayController", FakeController):
|
|
dc_module.main()
|
|
finally:
|
|
signal.signal(signal.SIGTERM, previous)
|
|
assert seen["during_init"] is not dc_module._raise_keyboard_interrupt
|
|
assert seen["during_run"] is dc_module._raise_keyboard_interrupt
|
|
|
|
|
|
def test_service_pending_changes_keeps_the_state_fresh_during_long_renders():
|
|
"""Vegas iterations and long screens call _service_pending_changes, not
|
|
the main loop, for minutes at a time; it republishes a stale state."""
|
|
dc = _publisher()
|
|
dc._last_pending_service = None
|
|
dc.PENDING_CHANGES_INTERVAL = 0.25
|
|
for name in ("_poll_on_demand_requests", "_check_on_demand_expiration",
|
|
"_evaluate_schedule", "_apply_brightness_target"):
|
|
setattr(dc, name, MagicMock())
|
|
now = [5000.0]
|
|
with patch.object(dc_module.time, "monotonic", lambda: now[0]):
|
|
dc._last_published_mode = "mlb_live"
|
|
dc._last_published_at = now[0] - dc_module.CURRENT_STATE_REFRESH_SECONDS - 1
|
|
dc._service_pending_changes()
|
|
dc.cache_manager.set.assert_called_once()
|
|
|
|
|
|
def test_pending_vegas_init_is_applied_by_the_helper_the_main_loop_calls():
|
|
dc = _vegas_controller(enabled_at_start=False)
|
|
dc._pending_vegas_init = True
|
|
dc._initialize_vegas_mode = MagicMock()
|
|
dc._apply_pending_vegas_init()
|
|
dc._initialize_vegas_mode.assert_called_once_with()
|
|
assert dc._pending_vegas_init is False
|
|
|
|
|
|
def test_main_loop_applies_pending_vegas_init_before_the_follower_branch():
|
|
import inspect
|
|
src = inspect.getsource(DisplayController.run)
|
|
assert src.index("self._apply_pending_vegas_init()") < src.index("self._run_follower_frame()")
|