fix(on-demand): show a named live mode; end a session that cannot resume (#748)

On-demand: a mode requested by name is shown first (even a quiet live mode); a session that can't resume after a restart, or whose plugin system failed to start, ends with status restore-failed instead of staying dead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-10-04 13:25:12 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 2236ff3081
commit 7026eeb156
8 changed files with 315 additions and 9 deletions
+7 -2
View File
@@ -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():
+29
View File
@@ -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"]
]
}
+160
View File
@@ -0,0 +1,160 @@
"""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_a_named_second_live_mode_with_content_leads(self, test_display_controller):
"""With live content both live modes are kept, nfl_live first; a
request naming ncaa_fb_live must still open on it, not rotate away."""
c = test_display_controller
_register(c, 'football-scoreboard', SPORTS_MODES, _sports_plugin(has_live_content=True))
c._activate_on_demand({'plugin_id': 'football-scoreboard', 'mode': 'ncaa_fb_live'})
assert c.on_demand_modes[0] == 'ncaa_fb_live'
assert c.on_demand_modes.count('ncaa_fb_live') == 1
assert 'nfl_live' in c.on_demand_modes[1:]
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')
def test_a_plugin_system_failure_ends_a_cached_session_not_yet_restored(
mock_config_manager, mock_display_manager, mock_cache_manager,
test_config_with_plugins, emulator_mode):
"""Initialization can fail before the cached session is read, with
on_demand_active still False: the session must still end, visibly."""
from unittest.mock import patch
from src.display_controller import DisplayController
mock_config_manager.get_config.return_value = test_config_with_plugins
mock_config_manager.load_config.return_value = test_config_with_plugins
mock_cache_manager._memory_cache['display_on_demand_config'] = {
'plugin_id': 'clock-simple', 'mode': 'clock-simple'}
with patch('src.display_controller.ConfigManager', return_value=mock_config_manager), \
patch('src.display_controller.DisplayManager', return_value=mock_display_manager), \
patch('src.display_controller.CacheManager', return_value=mock_cache_manager), \
patch('src.display_controller.FontManager'), \
patch('src.plugin_system.PluginManager', side_effect=RuntimeError("boom")):
controller = DisplayController()
try:
assert controller.plugin_manager is None
assert not controller.on_demand_active
assert controller.on_demand_status == 'error'
assert controller.on_demand_last_error == 'restore-failed'
mock_cache_manager.clear_cache.assert_any_call('display_on_demand_config')
finally:
try:
controller.cleanup()
except Exception:
pass
+28
View File
@@ -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),