Files
LEDMatrix/test/test_plugin_schedule.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

206 lines
9.2 KiB
Python

"""Per-plugin schedules (config['plugin_schedules'], src/plugin_schedule.py).
The window rule itself is the display schedule's, covered case by case in
test_display_controller_schedule.py through _check_schedule; here: the
plugin-level answer, and the three places it is applied -- the rotation,
the live scan, and the Vegas ticker -- plus the save endpoint.
"""
import copy
import os
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
os.environ.setdefault("EMULATOR", "true")
from src.display_arbiter import ScreenPlan, Source # noqa: E402
from src.display_controller import DisplayController # noqa: E402
from src.plugin_schedule import hidden_plugins, schedule_state, wants_live_scan # noqa: E402
from src.vegas_mode.config import VegasModeConfig # noqa: E402
from src.vegas_mode.stream_manager import StreamManager # noqa: E402
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: E402,F401
DAYS = ('monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday')
SUNDAY_1PM = datetime(2026, 10, 18, 13, 0) # a Sunday
MONDAY_1PM = datetime(2026, 10, 19, 13, 0)
def sundays_off(**extra):
"""The gameday schedule: every day all day, except Sunday."""
days = {d: {'enabled': d != 'sunday', 'start_time': '00:00', 'end_time': '23:59'}
for d in DAYS}
return {'enabled': True, 'mode': 'per-day', 'days': days, **extra}
ALWAYS_OFF = {'enabled': True, 'mode': 'per-day',
'days': {d: {'enabled': False} for d in DAYS}}
class TestHiddenPlugins:
def test_outside_window_is_hidden(self):
schedules = {'news': sundays_off(), 'stocks': sundays_off()}
assert hidden_plugins(schedules, SUNDAY_1PM, False) == {'news', 'stocks'}
assert hidden_plugins(schedules, MONDAY_1PM, False) == frozenset()
def test_hide_when_live_only_while_live(self):
schedules = {'news': {'enabled': False, 'hide_when_live': True}}
assert hidden_plugins(schedules, MONDAY_1PM, True) == {'news'}
assert hidden_plugins(schedules, MONDAY_1PM, False) == frozenset()
def test_disabled_schedule_and_junk_entries_hide_nothing(self):
schedules = {'news': {**ALWAYS_OFF, 'enabled': False}, 'bad': 'x', 'none': None}
assert hidden_plugins(schedules, SUNDAY_1PM, True) == frozenset()
def test_bad_time_shows_rather_than_hides(self):
assert schedule_state({'mode': 'global', 'start_time': '25:00'}, MONDAY_1PM)[0]
@pytest.mark.parametrize('schedule', [
{'mode': 1},
{'mode': 'per-day', 'days': 5},
{'mode': 'per-day', 'days': {'sunday': False}},
{'mode': 'per-day', 'days': ['sunday']},
])
def test_wrong_shapes_show_rather_than_raise(self, schedule):
# Falls back to the global 07:00-23:00 window, which 13:00 is inside.
assert schedule_state(schedule, SUNDAY_1PM)[0]
def test_global_mode_ignores_days(self):
schedule = {**sundays_off(), 'mode': 'global', 'start_time': '12:00', 'end_time': '14:00'}
assert schedule_state(schedule, SUNDAY_1PM)[0]
def test_wants_live_scan(self):
assert not wants_live_scan({'news': sundays_off()})
assert wants_live_scan({'news': sundays_off(), 'stocks': {'hide_when_live': True}})
def plugin(plugin_id, live=False, priority=False):
return SimpleNamespace(plugin_id=plugin_id, display=lambda **_: True,
has_live_content=lambda: live,
has_live_priority=lambda: priority,
get_live_modes=lambda: [f'{plugin_id}_live'])
def controller(schedules, plugin_modes):
dc = object.__new__(DisplayController)
dc.config = {'plugin_schedules': schedules}
dc._tz = None
dc.plugin_modes = plugin_modes
dc._plugin_hidden_cache = (None, frozenset())
dc._game_live = False
dc._game_live_at = float('-inf')
dc._health_tracker = lambda: None
return dc
class TestController:
def test_rotation_skips_hidden_but_on_demand_shows_it(self):
news = plugin('news')
dc = controller({'news': ALWAYS_OFF}, {'news': news})
assert dc._plugin_to_draw(ScreenPlan(Source.ROTATION, mode='news')) is None
assert dc._plugin_to_draw(ScreenPlan(Source.ON_DEMAND, mode='news')) is news
def test_no_schedules_changes_nothing(self):
news = plugin('news')
dc = controller({}, {'news': news})
assert dc._plugin_to_draw(ScreenPlan(Source.ROTATION, mode='news')) is news
def test_any_live_game_hides_news_even_without_live_priority(self):
modes = {'news': plugin('news'), 'nfl_live': plugin('nfl', live=True)}
dc = controller({'news': {'enabled': False, 'hide_when_live': True}}, modes)
assert dc._hidden_plugins() == {'news'}
def test_scoreboard_scheduled_off_does_not_win_live(self):
modes = {'nfl_live': plugin('nfl', live=True, priority=True)}
dc = controller({'nfl': ALWAYS_OFF}, modes)
assert dc._collect_live_modes() == []
dc.config = {}
assert dc._collect_live_modes() == ['nfl_live']
def test_a_scheduled_off_live_mode_does_not_hold_the_rotation(self):
modes = {'nfl_live': plugin('nfl', live=True, priority=True)}
dc = controller({'nfl': ALWAYS_OFF}, modes)
assert not dc._still_live('nfl_live')
dc.config = {}
assert dc._still_live('nfl_live')
@pytest.mark.parametrize('schedules', [['news'], 'news', 5])
def test_wrong_shaped_plugin_schedules_hide_nothing(self, schedules):
dc = controller(schedules, {'news': plugin('news')})
assert dc._hidden_plugins() == frozenset()
def test_a_reload_is_never_answered_from_the_old_schedules(self):
dc = controller({'news': ALWAYS_OFF}, {'news': plugin('news')})
assert dc._hidden_plugins() == {'news'}
# A new config in the same minute, with no cache reset.
dc.config = {'plugin_schedules': {'news': {'enabled': False}}}
assert dc._hidden_plugins() == frozenset()
class TestVegas:
def test_hidden_plugins_sit_out_the_ticker(self):
plugins = {pid: SimpleNamespace(enabled=True, config={}) for pid in ('news', 'nfl')}
stream = StreamManager(VegasModeConfig(), SimpleNamespace(plugins=plugins), MagicMock())
stream._refresh_plugin_list()
assert set(stream._ordered_plugins) == {'news', 'nfl'}
stream.hidden_plugins = lambda: frozenset({'news'})
stream._refresh_plugin_list()
assert set(stream._ordered_plugins) == {'nfl'}
def test_a_changed_hidden_set_skips_the_refresh_interval(self):
plugins = {pid: SimpleNamespace(enabled=True, config={}) for pid in ('news', 'nfl')}
stream = StreamManager(VegasModeConfig(), SimpleNamespace(plugins=plugins), MagicMock())
stream.refresh() # the interval's refresh
assert set(stream._ordered_plugins) == {'news', 'nfl'}
stream.refresh() # inside the interval, nothing changed: no rebuild
stream.hidden_plugins = lambda: frozenset({'news'})
stream.refresh() # still inside the interval, but the set changed
assert set(stream._ordered_plugins) == {'nfl'}
class TestSaveEndpoint:
@pytest.fixture
def store(self, api_v3_module, monkeypatch):
state = {'config': {}}
api_v3_module.api_v3.config_manager.load_config.side_effect = \
lambda *a, **k: copy.deepcopy(state['config'])
api_v3_module.api_v3.plugin_catalog.plugin_manifests = {'news': {}}
def fake_save(_manager, config, **_kwargs):
state['config'] = copy.deepcopy(config)
return True, ''
monkeypatch.setattr(api_v3_module, '_save_config_atomic', fake_save)
return state
def test_round_trip(self, api_v3_client, store):
body = {'plugin_id': 'news', 'hide_when_live': 'on', **sundays_off()}
resp = api_v3_client.post('/api/v3/config/plugin-schedules', json=body)
assert resp.status_code == 200, resp.get_json()
saved = store['config']['plugin_schedules']['news']
assert saved['hide_when_live'] is True
assert saved['days']['sunday']['enabled'] is False
got = api_v3_client.get('/api/v3/config/plugin-schedules').get_json()['data']
assert got == {'news': saved}
def test_schedule_that_does_nothing_is_removed(self, api_v3_client, store):
store['config'] = {'plugin_schedules': {'news': sundays_off()}}
resp = api_v3_client.post('/api/v3/config/plugin-schedules',
json={'plugin_id': 'news', 'enabled': False})
assert resp.status_code == 200, resp.get_json()
assert store['config']['plugin_schedules'] == {}
@pytest.mark.parametrize('body,status', [
({'plugin_id': 'display', 'enabled': True}, 400), # a core section
({'plugin_id': 'nope', 'enabled': True}, 404), # not installed
({'plugin_id': 'news', 'enabled': True, 'mode': 'global',
'start_time': '25:00', 'end_time': '23:00'}, 400), # bad time
({'enabled': True}, 400), # no id
])
def test_refusals(self, api_v3_client, store, body, status):
resp = api_v3_client.post('/api/v3/config/plugin-schedules', json=body)
assert resp.status_code == status, resp.get_json()
assert 'plugin_schedules' not in store['config']