mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-18 04:49:13 +00:00
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>
This commit is contained in:
Vendored
+17
@@ -159,6 +159,23 @@
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/config/plugin-schedules",
|
||||
"api_v3.get_plugin_schedules",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/config/plugin-schedules",
|
||||
"api_v3.save_plugin_schedule",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/config/raw/main",
|
||||
"api_v3.save_raw_main_config",
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"""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]
|
||||
|
||||
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_key = None
|
||||
dc._plugin_hidden = 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 = {}
|
||||
dc._plugin_hidden_key = None
|
||||
assert dc._collect_live_modes() == ['nfl_live']
|
||||
|
||||
|
||||
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'}
|
||||
|
||||
|
||||
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']
|
||||
Reference in New Issue
Block a user