mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 22:35:08 +00:00
fix(on-demand): show a named live mode; end a session that cannot resume
A request naming a *_live mode (football-scoreboard / ncaa_fb_live with 15 college games on) answered 200 and showed nfl_recent. The session's mode list kept live modes only when has_live_content() said so, which is the live-priority question and is answered for favourite teams only. A mode the request names now leads the session; display() decides whether it has anything to draw, and an empty one moves on to the plugin's next mode as any empty on-demand mode does. The name is saved in display_on_demand_config (named_mode) so a restart resumes on it. A bare plugin-id request still skips quiet live modes, as before. A restart during a session whose plugin then failed to load (clock-simple failed config validation after a crash on ledpi) left the session active with no modes and its cached request in place. It now ends at startup with status error / restore-failed, and the cached request is dropped; likewise when the plugin system fails to start. Golden traces: two new scenarios (on_demand_named_live, on_demand_restore_failed); every existing trace is unchanged. The harness's restore_on_demand takes named_mode and logs a failed restore. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -724,11 +724,14 @@ class RunLoopHarness:
|
||||
self.clock.at(t, post)
|
||||
|
||||
def restore_on_demand(self, plugin_id: str, mode: Optional[str] = None,
|
||||
duration: Optional[float] = None, pinned: bool = False):
|
||||
duration: Optional[float] = None, pinned: bool = False,
|
||||
named_mode: Optional[str] = None):
|
||||
"""Start with an on-demand session resumed from the cache, as after
|
||||
a restart: the state _select_startup_plugins restores, then
|
||||
_populate_on_demand_modes_from_plugin, as __init__ calls it."""
|
||||
_populate_on_demand_modes_from_plugin, as __init__ calls it. A
|
||||
session that cannot resume is logged as ``on-demand-error``."""
|
||||
dc = self.controller
|
||||
dc._on_demand_named_mode = named_mode
|
||||
dc.on_demand_active = True
|
||||
dc.on_demand_plugin_id = plugin_id
|
||||
dc.on_demand_mode = mode
|
||||
@@ -739,6 +742,8 @@ class RunLoopHarness:
|
||||
dc.on_demand_status = 'active'
|
||||
dc.on_demand_schedule_override = True
|
||||
dc._populate_on_demand_modes_from_plugin()
|
||||
if dc.on_demand_status == 'error':
|
||||
self.log("on-demand-error", dc.on_demand_last_error)
|
||||
|
||||
def wifi_message(self, t: float, message: str, duration: float = 5):
|
||||
def write():
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"screens": [
|
||||
[0.0, "clock", 5.0, "on-demand-start", 6, false],
|
||||
[5.0, "sports_live", 15.0, "duration", 15, true],
|
||||
[20.0, "sports_recent", 15.0, "duration", 15, true],
|
||||
[35.0, "sports_upcoming", 5.0, "on-demand-requested-stop", 6, true],
|
||||
[40.0, "clock", 20.0, "duration", 20, true],
|
||||
[60.0, "sports_live", 15.0, "display-false", 11, true],
|
||||
[75.0, "sports_recent", 15.0, "duration", 15, true],
|
||||
[90.0, "sports_upcoming", 10.0, "on-demand-start", 11, true],
|
||||
[100.0, "sports_live", 0.0, "empty", 1, true],
|
||||
[100.0, "sports_recent", 15.0, "duration", 15, true],
|
||||
[115.0, "sports_upcoming", 15.0, "duration", 15, true],
|
||||
[130.0, "sports_live", 0.0, "empty", 1, true],
|
||||
[130.0, "sports_recent", 10.0, "on-demand-requested-stop", 11, true],
|
||||
[140.0, "sports_upcoming", 15.0, "duration", 15, true],
|
||||
[155.0, "clock", 5.0, "horizon", 5, true]
|
||||
],
|
||||
"events": [
|
||||
[5.0, "request", "start:n1"],
|
||||
[5.0, "on-demand-start", "sports"],
|
||||
[40.0, "request", "stop:n2"],
|
||||
[40.0, "on-demand-requested-stop"],
|
||||
[100.0, "request", "start:n3"],
|
||||
[100.0, "on-demand-start", "sports"],
|
||||
[140.0, "request", "stop:n4"],
|
||||
[140.0, "on-demand-requested-stop"]
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"screens": [
|
||||
[0.0, "clock", 20.0, "duration", 20, false],
|
||||
[20.0, "weather", 20.0, "duration", 20, true],
|
||||
[40.0, "clock", 20.0, "horizon", 20, true]
|
||||
],
|
||||
"events": [
|
||||
[0.0, "on-demand-error", "restore-failed"]
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Two on-demand edges seen on a rig.
|
||||
|
||||
* A request naming a ``*_live`` mode got HTTP 200 and a different mode on
|
||||
the panel. The session's mode list kept live modes only when the plugin's
|
||||
has_live_content() said so, and that is the live-priority question,
|
||||
which the sports plugins answer for favourite teams only: fifteen college
|
||||
games on, no favourite playing, and ``ncaa_fb_live`` became
|
||||
``nfl_recent``.
|
||||
* A restart during a session whose plugin then failed to load (its config
|
||||
no longer validated) logged "No valid display modes found ... after
|
||||
restoration" and left the session active with no modes: published as
|
||||
active for a plugin that was not running, with its cached request kept
|
||||
for the next restart.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
SPORTS_MODES = ['nfl_live', 'nfl_recent', 'nfl_upcoming',
|
||||
'ncaa_fb_live', 'ncaa_fb_recent', 'ncaa_fb_upcoming']
|
||||
|
||||
|
||||
def _sports_plugin(has_live_content=False):
|
||||
plugin = MagicMock(spec=['display', 'has_live_content', 'has_live_priority',
|
||||
'get_live_modes'])
|
||||
plugin.has_live_content.return_value = has_live_content
|
||||
plugin.has_live_priority.return_value = True
|
||||
plugin.get_live_modes.return_value = []
|
||||
return plugin
|
||||
|
||||
|
||||
def _register(controller, plugin_id, modes, plugin):
|
||||
controller.plugin_display_modes[plugin_id] = list(modes)
|
||||
for mode in modes:
|
||||
controller.plugin_modes[mode] = plugin
|
||||
controller.mode_to_plugin_id[mode] = plugin_id
|
||||
if mode not in controller.available_modes:
|
||||
controller.available_modes.append(mode)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def football(test_display_controller):
|
||||
c = test_display_controller
|
||||
_register(c, 'football-scoreboard', SPORTS_MODES, _sports_plugin())
|
||||
return c
|
||||
|
||||
|
||||
class TestANamedLiveModeIsShown:
|
||||
|
||||
def test_it_is_the_first_screen(self, football):
|
||||
football._activate_on_demand({'plugin_id': 'football-scoreboard',
|
||||
'mode': 'ncaa_fb_live'})
|
||||
assert football.on_demand_active
|
||||
assert football.current_display_mode == 'ncaa_fb_live'
|
||||
assert football.on_demand_mode == 'ncaa_fb_live'
|
||||
|
||||
def test_the_plugins_other_modes_follow_it(self, football):
|
||||
football._activate_on_demand({'plugin_id': 'football-scoreboard',
|
||||
'mode': 'ncaa_fb_live'})
|
||||
assert football.on_demand_modes[0] == 'ncaa_fb_live'
|
||||
assert set(football.on_demand_modes[1:]) == {
|
||||
'nfl_recent', 'nfl_upcoming', 'ncaa_fb_recent', 'ncaa_fb_upcoming'}
|
||||
|
||||
def test_pinned_holds_it(self, football):
|
||||
football._activate_on_demand({'plugin_id': 'football-scoreboard',
|
||||
'mode': 'ncaa_fb_live', 'pinned': True})
|
||||
assert football.on_demand_modes == ['ncaa_fb_live']
|
||||
|
||||
def test_a_bare_plugin_request_still_skips_quiet_live_modes(self, football):
|
||||
"""Only a mode asked for by name is kept: a plugin-only request
|
||||
resolves to the plugin's first mode (nfl_live), and opening on an
|
||||
empty live screen there is what the ordering exists to avoid."""
|
||||
football._activate_on_demand({'plugin_id': 'football-scoreboard'})
|
||||
assert not any(m.endswith('_live') for m in football.on_demand_modes)
|
||||
|
||||
def test_the_named_mode_survives_a_restart(self, football):
|
||||
football._activate_on_demand({'plugin_id': 'football-scoreboard',
|
||||
'mode': 'ncaa_fb_live'})
|
||||
saved = football.cache_manager.set.call_args_list[-1]
|
||||
assert saved.args[0] == 'display_on_demand_config'
|
||||
config = saved.args[1]
|
||||
assert config['named_mode'] == 'ncaa_fb_live'
|
||||
|
||||
football._reset_on_demand_fields()
|
||||
football._select_startup_plugins(['football-scoreboard'], config)
|
||||
football._populate_on_demand_modes_from_plugin()
|
||||
assert football.on_demand_modes[football.on_demand_mode_index] == 'ncaa_fb_live'
|
||||
|
||||
|
||||
class TestARestoreWithNothingToResume:
|
||||
|
||||
@pytest.fixture
|
||||
def restored(self, test_display_controller):
|
||||
c = test_display_controller
|
||||
c.config['clock-simple'] = {'enabled': True}
|
||||
c._select_startup_plugins(['clock-simple'],
|
||||
{'plugin_id': 'clock-simple', 'mode': 'clock-simple'})
|
||||
assert c.on_demand_active
|
||||
# The plugin's load then fails: nothing is registered for it.
|
||||
c.cache_manager.clear_cache.reset_mock()
|
||||
c._populate_on_demand_modes_from_plugin()
|
||||
return c
|
||||
|
||||
def test_the_session_ends(self, restored):
|
||||
assert not restored.on_demand_active
|
||||
assert restored.on_demand_plugin_id is None
|
||||
assert not restored.on_demand_schedule_override
|
||||
|
||||
def test_it_is_reported_as_an_error(self, restored):
|
||||
assert restored.on_demand_status == 'error'
|
||||
assert restored.on_demand_last_error == 'restore-failed'
|
||||
published = restored.cache_manager.set.call_args_list[-1]
|
||||
assert published.args[0] == 'display_on_demand_state'
|
||||
assert published.args[1]['status'] == 'error'
|
||||
assert published.args[1]['error'] == 'restore-failed'
|
||||
|
||||
def test_the_cached_request_is_dropped(self, restored):
|
||||
restored.cache_manager.clear_cache.assert_any_call('display_on_demand_config')
|
||||
@@ -133,6 +133,32 @@ def scenario_on_demand_restored(h: RunLoopHarness):
|
||||
h.restore_on_demand("sports", mode="sports_upcoming", duration=40)
|
||||
|
||||
|
||||
def scenario_on_demand_named_live(h: RunLoopHarness):
|
||||
# Games are on until t=70, but none involves a favourite, so
|
||||
# has_live_content() (the live-priority answer) stays False throughout.
|
||||
# A request naming sports_live still opens on it (it opened on
|
||||
# sports_recent); asked for again after the games end, it has nothing to
|
||||
# draw and the session moves on to the plugin's next mode.
|
||||
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
|
||||
h.add_plugin(FakePlugin(
|
||||
"sports", ["sports_live", "sports_recent", "sports_upcoming"], duration=15,
|
||||
live_priority=True,
|
||||
content=lambda t, mode: mode != "sports_live" or t < 70))
|
||||
h.on_demand_request(5, "n1", plugin_id="sports", mode="sports_live")
|
||||
h.on_demand_request(40, "n2", action="stop")
|
||||
h.on_demand_request(100, "n3", plugin_id="sports", mode="sports_live")
|
||||
h.on_demand_request(140, "n4", action="stop")
|
||||
|
||||
|
||||
def scenario_on_demand_restore_failed(h: RunLoopHarness):
|
||||
# A restart during a session whose plugin then fails to load: the
|
||||
# session ends as an error before the first screen, and the rotation
|
||||
# runs normally from the top.
|
||||
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
|
||||
h.add_plugin(FakePlugin("weather", ["weather"], duration=20))
|
||||
h.restore_on_demand("gone", mode="gone", duration=40)
|
||||
|
||||
|
||||
def scenario_schedule(h: RunLoopHarness):
|
||||
# The clock starts at 22:59:30. Off from 23:01 until 23:05 (the window
|
||||
# spans midnight); dimmed from 23:00 until 23:01.
|
||||
@@ -194,6 +220,8 @@ SCENARIOS = {
|
||||
"on_demand": (scenario_on_demand, 240),
|
||||
"on_demand_pinned": (scenario_on_demand_pinned, 160),
|
||||
"on_demand_restored": (scenario_on_demand_restored, 100),
|
||||
"on_demand_named_live": (scenario_on_demand_named_live, 160),
|
||||
"on_demand_restore_failed": (scenario_on_demand_restore_failed, 60),
|
||||
"schedule": (scenario_schedule, 400),
|
||||
"wifi_notice": (scenario_wifi_notice, 150),
|
||||
"follower": (scenario_follower, 80),
|
||||
|
||||
Reference in New Issue
Block a user