Files
LEDMatrix/test/test_display_controller_empty_rotation.py
T
ChuckandClaude Opus 5.5 1fe7965f5c feat(schedule): per-plugin schedules and hide-while-live (#799)
* feat(schedule): per-plugin schedules and hide-while-live

Users asked to turn off news and stocks on gameday so only scores show.
Each plugin can now have its own schedule (global or per-day window in
which it may show) plus hide_when_live, which hides it whenever any
plugin reports a live game. A hidden plugin is skipped in the rotation,
the live takeover and the Vegas ticker; it stays loaded, and on-demand
still shows it.

- src/plugin_schedule.py: the window rule, now shared with the display
  schedule (_check_schedule calls schedule_state)
- core config key plugin_schedules; GET/POST /api/v3/config/plugin-schedules
- Schedule page: Plugin Schedules card, one picker per enabled plugin

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(schedule): tolerate wrong-shaped schedules, quiet skipped modes

- schedule_state treats a non-string mode, non-dict days or a non-dict
  day entry as missing instead of raising in the display loop
- the run loop no longer warns "No plugin manager found" for a mode it
  skips: _plugin_to_draw / _plugin_for_mode already log why (a
  scheduled-off plugin at DEBUG)
- Schedule page DOM test: every form is marked data-reports-result, not
  exactly two (each plugin adds one)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(schedule): guard plugin_schedules shape and the cross-thread cache

- a plugin_schedules that is not a dict (list, string) hides nothing
  instead of raising in the display loop
- the hidden-set cache keys on the schedules themselves and is published
  as one tuple, so a Vegas-thread call still holding the pre-reload
  config cannot leave its answer standing after a reload; the separate
  invalidation in _refresh_config_cache is gone

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(schedule): no live hold on a scheduled-off mode; blank an empty rotation

- _still_live ignores a plugin its schedule hides, so a rotation landing
  on a scheduled-off live mode advances instead of holding a mode it skips
- once a whole rotation has had nothing to show (every plugin empty or
  scheduled off), the panel is cleared once instead of leaving the last
  screen up

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(schedule): push the empty-rotation blank to the web preview

clear() alone blanked the matrix but left the preview snapshot on the
last frame (seen on ledpi with every plugin scheduled off). Follow it
with update_display(), as the scheduled-off blank does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(schedule): write the empty-rotation blank to the preview

The preview throttle skipped the blank frame (written 12 s after the
last snapshot), and write_owed_snapshot() only runs after a display()
call, which an all-empty rotation never makes: on ledpi the preview
kept the startup splash while the panel was dark. Flush the owed
snapshot on each paused empty pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(schedule): quiet the once-a-second logs when nothing can show

With every plugin scheduled off (seen on ledpi), the loop retries once
a second and journaled three to four lines each time: Vegas's
"No plugins available" WARNING, the coordinator's "Failed to initialize
stream manager" ERROR, the 0-plugin rotation summary and "Switching to
mode". Vegas now warns once until its list fills again, the coordinator
line is DEBUG (initialize only fails for an empty list), and the
mode-switch line is DEBUG once a whole rotation has been empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(schedule): end the scroll before the empty blank; Vegas sees hidden changes sooner

- the empty-rotation blank is controller-drawn, so it ends the last
  scroll's state first (_end_scroll_before_core_screen), as the
  scheduled-off blank does
- StreamManager.refresh() rebuilds the plugin list when the hidden set
  changed, even inside its 30 s interval, so a newly hidden plugin leaves
  at the next scroll group rather than staying for later ones

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:16:47 -04:00

176 lines
6.7 KiB
Python

"""The run loop must not spin when no mode has anything to show.
A mode with no content rotates to the next one at once. When every enabled
mode is empty (only a sports plugin enabled in its off-season, say), the loop
used to go round with no sleep at all: a full core, a plugin-executor thread
per pass and several log lines each time, forever. After one empty rotation it
now pauses between passes, and it stops pausing as soon as something shows.
"""
import os
from unittest.mock import MagicMock
os.environ.setdefault("EMULATOR", "true")
from src.display_controller import DisplayController # noqa: E402
def _bare_controller(modes, on_demand_modes=None):
dc = object.__new__(DisplayController)
dc.available_modes = list(modes)
dc.on_demand_active = on_demand_modes is not None
dc.on_demand_modes = list(on_demand_modes or [])
dc.sleeps = []
dc._sleep_with_plugin_updates = lambda duration, tick_interval=1.0: dc.sleeps.append(duration)
dc.display_manager = MagicMock()
return dc
class TestNoteEmptyPass:
def test_a_partly_empty_rotation_never_pauses(self):
dc = _bare_controller(["a", "b", "c"])
dc._note_empty_pass()
dc._note_empty_pass()
assert dc.sleeps == []
def test_a_fully_empty_rotation_pauses_every_further_pass(self):
dc = _bare_controller(["a", "b", "c"])
for _ in range(5):
dc._note_empty_pass()
# Passes 3, 4 and 5 are at or past one full rotation.
assert dc.sleeps == [DisplayController.EMPTY_ROTATION_PAUSE] * 3
# Blanked once, so the last screen drawn does not stay up.
assert dc.display_manager.clear.call_count == 1
# A controller-drawn blank: the last scroll's state is ended first.
dc.display_manager.set_scrolling_state.assert_called_once_with(False)
assert dc.display_manager.update_display.call_count == 1
# ...and the preview gets the blank frame however the throttle fell.
assert dc.display_manager.write_owed_snapshot.call_count == 3
def test_a_partly_empty_rotation_never_blanks(self):
dc = _bare_controller(["a", "b", "c"])
dc._note_empty_pass()
dc._note_empty_pass()
dc.display_manager.clear.assert_not_called()
def test_content_resets_the_streak(self):
dc = _bare_controller(["a", "b"])
dc._note_empty_pass()
dc._note_empty_pass()
assert len(dc.sleeps) == 1
dc._empty_pass_streak = 0 # what the run loop does when a mode shows
dc._note_empty_pass()
assert len(dc.sleeps) == 1
def test_on_demand_counts_its_own_modes(self):
dc = _bare_controller(["a", "b", "c", "d"], on_demand_modes=["x"])
dc._note_empty_pass()
assert dc.sleeps == [DisplayController.EMPTY_ROTATION_PAUSE]
def test_a_single_mode_pauses_straight_away(self):
dc = _bare_controller(["only"])
dc._note_empty_pass()
assert dc.sleeps == [DisplayController.EMPTY_ROTATION_PAUSE]
class TestRunLoopWithNothingToShow:
def test_run_pauses_after_one_empty_rotation(self, test_display_controller):
controller = test_display_controller
# Modes with no plugin behind them take the "nothing to display" path.
controller.available_modes = ["m1", "m2", "m3"]
controller.current_mode_index = 0
controller.plugin_modes = {}
# Count passes at the top of the loop. The cap stops a spinning loop
# (the old behaviour) instead of hanging the test run.
passes = []
original_poll = controller._poll_on_demand_requests
def counting_poll():
passes.append(1)
if len(passes) > 50:
raise RuntimeError("stop-test-loop: spinning")
original_poll()
sleeps = []
def fake_sleep(duration, tick_interval=1.0):
sleeps.append((len(passes), duration))
if len(sleeps) >= 2:
raise RuntimeError("stop-test-loop")
controller._poll_on_demand_requests = counting_poll
controller._sleep_with_plugin_updates = fake_sleep
controller.run()
# Three empty passes (one full rotation) before the first pause, then
# one pause per further pass -- not thousands of passes a second.
assert [n for n, _ in sleeps] == [3, 4]
assert all(d == DisplayController.EMPTY_ROTATION_PAUSE for _, d in sleeps)
class TestRotationChanges:
def test_a_new_rotation_starts_a_new_streak(self):
# A long empty streak in a one-mode on-demand session must not make
# the normal rotation pause before its own modes have been tried.
dc = _bare_controller(["a", "b", "c"], on_demand_modes=["x"])
for _ in range(4):
dc._note_empty_pass()
assert len(dc.sleeps) == 4
dc.on_demand_active = False
dc._note_empty_pass()
dc._note_empty_pass()
assert len(dc.sleeps) == 4
dc._note_empty_pass()
assert len(dc.sleeps) == 5
def test_a_changed_mode_list_starts_a_new_streak(self):
dc = _bare_controller(["a"])
dc._note_empty_pass()
assert len(dc.sleeps) == 1
dc.available_modes = ["a", "b"] # a plugin was enabled
dc._note_empty_pass()
assert len(dc.sleeps) == 1
class TestOnDemandDuringThePause:
def test_the_requested_mode_is_shown_first(self, test_display_controller):
controller = test_display_controller
controller.available_modes = ["m1", "m2", "m3"]
controller.current_mode_index = 0
controller.plugin_modes = {}
tried = []
original_note = controller._note_empty_pass
def recording_note():
tried.append(controller.current_display_mode)
if len(tried) > 50:
raise RuntimeError("stop-test-loop: spinning")
original_note()
sleeps = []
def fake_sleep(duration, tick_interval=1.0):
sleeps.append(duration)
if len(sleeps) == 1:
# What the real pause does when an on-demand request lands:
# activate it and return early.
controller.on_demand_active = True
controller.on_demand_modes = ["x", "y"]
controller.on_demand_mode_index = 0
controller.current_display_mode = "x"
else:
raise RuntimeError("stop-test-loop")
controller._note_empty_pass = recording_note
controller._sleep_with_plugin_updates = fake_sleep
controller.run()
# m1, m2, m3 empty -> pause -> on-demand starts; "x" must come next,
# not be skipped by rotating the empty pass that was interrupted.
assert tried[:3] == ["m1", "m2", "m3"]
assert tried[3] == "x"