From 77e0ea91ae46f3475493099cc3b23589ffcb1286 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:59:14 -0400 Subject: [PATCH] fix(display): live games take over within a second, and straight from Vegas (#713) A game that goes live takes over within about a second (_check_live_takeover in the frame loops and the dwell sleep, throttled to 1 s, never during on-demand, scheduled-off, live_in_ticker or an already-live screen); an interrupted Vegas iteration switches straight to the game; has_live_content() is asked once per plugin per scan. Goldens: live_priority, vegas. Merged with #712: after an interrupted Vegas iteration the WiFi-notice check runs before the live switch (WiFi outranks live). Adds test/test_run_loop_wifi_and_live.py, pinning that a notice and a game arriving during the same screen (1 Hz, 125 Hz, Vegas) show the notice first, then the game, and neither while scheduled off. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 + docs/RUN_LOOP_REDESIGN.md | 14 +- src/display_controller.py | 92 +++++++- .../run_loop_golden/live_priority.json | 24 +- test/fixtures/run_loop_golden/vegas.json | 5 +- test/test_run_loop_live_takeover.py | 218 ++++++++++++++++++ test/test_run_loop_wifi_and_live.py | 157 +++++++++++++ 7 files changed, 492 insertions(+), 27 deletions(-) create mode 100644 test/test_run_loop_live_takeover.py create mode 100644 test/test_run_loop_wifi_and_live.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c8e4ff3..6cb46f66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -101,6 +101,15 @@ policies are unchanged. notice is what shows next, and Vegas resumes after it; before, a rotation screen showed instead and the notice expired behind it. An active on-demand session still holds the panel until it ends. +- A game that goes live now takes over the panel within about a second. + Live priority was only checked between screens, so a game that went live + during a 30 s screen waited for that screen to end. The frame loops and the + dwell sleep now check too, at most once a second, and not while an + on-demand session is running or a live game is already showing. When Vegas + stops for a live game, the game is the next screen. Before, one rotation + screen showed first and the game came after it. Each check also asks each + plugin `has_live_content()` once, where a plugin registered under several + modes used to be asked once per mode. ## 3.8.0 diff --git a/docs/RUN_LOOP_REDESIGN.md b/docs/RUN_LOOP_REDESIGN.md index dafe3f19..7c0e557c 100644 --- a/docs/RUN_LOOP_REDESIGN.md +++ b/docs/RUN_LOOP_REDESIGN.md @@ -46,7 +46,10 @@ Each pass, in order: the dwell sleep and an interrupted Vegas iteration end within about a second when one arrives, and a screen cut short resumes after it. 7. **Live priority** (unless on-demand, or Vegas keeps live content in the - ticker): switch to the next live mode, or resume the rotation. + ticker): switch to the next live mode, or resume the rotation. A game + that goes live during a screen is caught sooner, by + `_check_live_takeover` in the frame loops and the dwell sleep (at most + once a second, and not while a live mode is showing). 8. **Vegas** (unless on-demand, or live content preempts it): run one iteration of up to `max_cycle_duration`. A completed iteration ends the pass, and so does one that yielded for a WiFi notice or the schedule. @@ -258,14 +261,9 @@ These are recorded as they are today. Each one should be fixed in its own PR, which updates the affected trace and explains why. None of them is changed by the restructure. -1. **Vegas yields to live content, then shows a rotation screen first.** - The live game appears one screen later (`vegas`, t=70-90). -2. **Live priority only takes over between screens.** A game that goes - live mid-screen waits for that screen to end (`live_priority`: live at - t=50, shown at t=60). -3. **An on-demand session that expires during scheduled-off keeps the panel +1. **An on-demand session that expires during scheduled-off keeps the panel on** until the next minute boundary, because the schedule check runs at most once a minute (`schedule`, t=190-210). -4. **A schedule window's end minute is inclusive**, and whether the panel +2. **A schedule window's end minute is inclusive**, and whether the panel turns off at the start of that minute or the end depends on when in the minute the first check runs. diff --git a/src/display_controller.py b/src/display_controller.py index 3ebb108c..0bb32d73 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -1145,9 +1145,10 @@ class DisplayController: Also services pending changes (see _service_pending_changes), and returns early when one of them changes what the panel should show -- an on-demand start or stop, the display schedule turning the panel - on or off, or a WiFi notice arriving -- so the caller can act on it - instead of finishing a dwell that could be a minute long (sixty - seconds while scheduled off). + on or off, a WiFi notice arriving, or a live game taking over + (_check_live_takeover) -- so the caller can act on it instead of + finishing a dwell that could be a minute long (sixty seconds while + scheduled off). """ if duration <= 0: return @@ -1173,6 +1174,7 @@ class DisplayController: display_watchdog.watchdog.beat() self._tick_plugin_updates() self._service_pending_changes() + self._check_live_takeover() if (self.current_display_mode != mode or self.is_display_active != display_active or self.on_demand_active != on_demand @@ -2252,15 +2254,22 @@ class DisplayController: preserving order. A plugin registered under several mode keys (the sports plugins register one per league) contributes each live mode once. """ + self._last_live_scan = time.monotonic() live = [] seen = set() + # Asked once per plugin per scan, not once per mode key: a scoreboard + # registered under several modes computes the same answer each time. + is_live: Dict[int, bool] = {} for mode_name, plugin_instance in self.plugin_modes.items(): if not (hasattr(plugin_instance, 'has_live_priority') and hasattr(plugin_instance, 'has_live_content')): continue try: - if not (plugin_instance.has_live_priority() - and plugin_instance.has_live_content()): + key = id(plugin_instance) + if key not in is_live: + is_live[key] = bool(plugin_instance.has_live_priority() + and plugin_instance.has_live_content()) + if not is_live[key]: continue resolved = [] if hasattr(plugin_instance, 'get_live_modes'): @@ -2275,6 +2284,7 @@ class DisplayController: live.append(m) except Exception as e: logger.warning("Error checking live priority for %s: %s", mode_name, e) + self._last_live_modes = tuple(live) return live def _vegas_keeps_live_in_ticker(self) -> bool: @@ -2311,6 +2321,58 @@ class DisplayController: return self.current_display_mode return live_modes[0] + #: Shortest gap between live-priority scans made mid-screen. A scan asks + #: every live-priority plugin has_live_content(), which the scoreboards + #: compute by filtering their game lists. Once a second matches the 1 Hz + #: frame loop and is a quarter of the rate Vegas already polls at. + LIVE_TAKEOVER_INTERVAL = 1.0 + + #: Class-level defaults for controllers built without __init__ (tests). + #: When the last live-priority scan of any kind ran (_collect_live_modes). + _last_live_scan: Optional[float] = None + #: What that scan found. + _last_live_modes: Tuple[str, ...] = () + #: A mid-screen takeover chose current_display_mode and it has not been + #: shown yet, so the next pass must not advance the live round-robin past it. + _live_takeover_unshown: bool = False + + def _check_live_takeover(self) -> None: + """Hand the panel to a live game that started while a screen runs. + + Called from the frame loops and the dwell sleep. Live priority used + to be checked only between screens, so a game that went live during + a 30 s screen waited for it to end. This switches current_display_mode + to the live mode, which ends the screen the way any other mode change + does. Throttled to LIVE_TAKEOVER_INTERVAL since the last scan of any + kind. Nothing happens while an on-demand session is active, while + the panel is scheduled off, while Vegas keeps live content in its + ticker, or when the screen showing is already a live mode. + + A live screen is not rescanned at all: live priority put it there, + and live games take turns between screens, not mid-screen. + """ + if self.current_display_mode in self._last_live_modes: + return + last = self._last_live_scan + if last is not None and time.monotonic() - last < self.LIVE_TAKEOVER_INTERVAL: + return + if self.on_demand_active or not self.is_display_active: + return + try: + coordinator = getattr(self, 'vegas_coordinator', None) + if (coordinator is not None and coordinator.is_enabled + and self._vegas_keeps_live_in_ticker()): + return + live_modes = self._collect_live_modes() + if not live_modes or self.current_display_mode in live_modes: + return + self._apply_live_priority(live_modes[0]) + self._live_takeover_unshown = True + except Exception: # pylint: disable=broad-except + # Called from inside the frame loops; a failure here must not + # take the display loop down with it. + logger.exception("Error checking for a live-priority takeover") + # -- Pieces of run() -------------------------------------------------- # Extracted from run() unchanged, as the first step of restructuring it # into an Arbiter / ScreenRunner / Sources (docs/RUN_LOOP_REDESIGN.md). @@ -2984,11 +3046,15 @@ class DisplayController: # Skipped when the ticker is keeping live content: switching # the rotation underneath Vegas would move current_mode_index # and stash a resume point for a takeover that never happens. + # After a mid-screen takeover (_check_live_takeover) the live + # mode is already chosen but not shown yet: don't advance past it. if (not self.on_demand_active and not (self._is_vegas_mode_active() and self._vegas_keeps_live_in_ticker())): - live_priority_mode = self._check_live_priority(advance=True) + live_priority_mode = self._check_live_priority( + advance=not self._live_takeover_unshown) self._apply_live_priority(live_priority_mode) + self._live_takeover_unshown = False # Vegas scroll mode - continuous ticker across all plugins # Priority: on-demand > wifi-status > live-priority > vegas > normal rotation @@ -3016,7 +3082,19 @@ class DisplayController: # It yielded for a WiFi notice: the next # pass shows it, not a rotation screen # that would outlast a short notice. + # Checked before live content: WiFi + # outranks live, and step 7 of a later + # pass switches to the game. continue + # Live content stopped the ticker: switch to + # the game now. Step 7 ran before the game + # went live, so without this a rotation screen + # showed first and the game a screen later. + if (not self.on_demand_active + and not self._vegas_keeps_live_in_ticker()): + live_mode = self._check_live_priority(advance=True) + if live_mode: + self._apply_live_priority(live_mode) except Exception: logger.exception("Vegas mode error") # Fall through to normal rotation on error @@ -3169,6 +3247,7 @@ class DisplayController: self._tick_plugin_updates() # Throttled: one clock compare between passes. self._service_pending_changes() + self._check_live_takeover() # Pace to the frame deadline rather than sleeping a flat # interval on top of the work. display() has already @@ -3250,6 +3329,7 @@ class DisplayController: self._send_follower_frame(manager_to_display) self._service_pending_changes() + self._check_live_takeover() if (self.current_display_mode != active_mode or not self.is_display_active or self._wifi_notice_pending()): diff --git a/test/fixtures/run_loop_golden/live_priority.json b/test/fixtures/run_loop_golden/live_priority.json index 301ca37b..b8d8cf26 100644 --- a/test/fixtures/run_loop_golden/live_priority.json +++ b/test/fixtures/run_loop_golden/live_priority.json @@ -2,18 +2,20 @@ "screens": [ [0.0, "clock", 20.0, "duration", 20, false], [20.0, "weather", 20.0, "duration", 20, true], - [40.0, "sports_recent", 20.0, "live", 20, true], - [60.0, "sports_live", 20.0, "duration", 20, true], - [80.0, "sports_live", 20.0, "duration", 20, false], - [100.0, "sports_live", 20.0, "display-false", 11, false], - [120.0, "sports_recent", 20.0, "duration", 20, true], - [140.0, "sports_live", 0.0, "empty", 1, true], - [140.0, "clock", 20.0, "duration", 20, true], - [160.0, "weather", 20.0, "duration", 20, true], - [180.0, "sports_recent", 20.0, "horizon", 20, true] + [40.0, "sports_recent", 10.0, "live", 11, true], + [50.0, "sports_live", 20.0, "duration", 20, true], + [70.0, "sports_live", 20.0, "duration", 20, false], + [90.0, "sports_live", 20.0, "live-ended", 20, false], + [110.0, "sports_recent", 20.0, "duration", 20, true], + [130.0, "sports_live", 0.0, "empty", 1, true], + [130.0, "clock", 20.0, "duration", 20, true], + [150.0, "weather", 20.0, "duration", 20, true], + [170.0, "sports_recent", 20.0, "duration", 20, true], + [190.0, "sports_live", 0.0, "empty", 1, true], + [190.0, "clock", 10.0, "horizon", 10, true] ], "events": [ - [60.0, "live", "sports_live"], - [120.0, "live-ended", "sports_recent"] + [50.0, "live", "sports_live"], + [110.0, "live-ended", "sports_recent"] ] } diff --git a/test/fixtures/run_loop_golden/vegas.json b/test/fixtures/run_loop_golden/vegas.json index 4508a713..8a7d4235 100644 --- a/test/fixtures/run_loop_golden/vegas.json +++ b/test/fixtures/run_loop_golden/vegas.json @@ -3,8 +3,8 @@ [0.0, "", 30.008, "duration", 3751, null], [30.008, "", 30.007, "duration", 3751, null], [60.015, "", 10.24, "vegas-live", 1280, null], - [70.255, "clock", 20.0, "duration", 20, false], - [90.255, "sports_live", 20.0, "display-false", 11, true], + [70.255, "sports_live", 20.0, "duration", 20, true], + [90.255, "sports_live", 20.0, "display-false", 11, false], [110.255, "", 30.008, "duration", 3751, null], [140.263, "", 10.0, "on-demand-start", 1250, null], [150.263, "clock", 20.0, "duration", 20, true], @@ -16,6 +16,7 @@ ], "events": [ [70.255, "vegas-live"], + [70.255, "live", "sports_live"], [150.0, "request", "start:v1"], [150.263, "on-demand-start", "clock"], [150.263, "vegas-interrupt"], diff --git a/test/test_run_loop_live_takeover.py b/test/test_run_loop_live_takeover.py new file mode 100644 index 00000000..5a0c0213 --- /dev/null +++ b/test/test_run_loop_live_takeover.py @@ -0,0 +1,218 @@ +"""Live priority takes the panel promptly, through the real run() loop. + +Two behaviours the golden traces recorded (docs/RUN_LOOP_REDESIGN.md): + +* A game that went live mid-screen waited for that screen to end. Now the + frame loops and the dwell sleep check, at most once a second, and switch. +* When Vegas yielded to live content, one rotation screen showed before the + game. Now the game is what shows next. + +These run the real DisplayController.run() on the fake clock from +test/_run_loop_harness.py. Each trace row is +[start, mode, duration, exit_reason, frames, force_clear]. +""" + +import os + +os.environ.setdefault("EMULATOR", "true") + +from test._run_loop_harness import FakePlugin, RunLoopHarness # noqa: E402 + + +def _run(tmp_path, horizon, build): + harness = RunLoopHarness(tmp_path, horizon=horizon) + build(harness) + return harness, harness.run()["screens"] + + +def _first(rows, mode): + return next(row for row in rows if row[1] == mode) + + +def _counting(plugin): + """Count has_live_content() calls, with the fake-clock time of each.""" + calls = [] + real = plugin.has_live_content + + def has_live_content(): + calls.append(plugin._h.clock.rel()) + return real() + plugin.has_live_content = has_live_content + return calls + + +class TestMidScreenTakeover: + def test_live_game_cuts_a_one_hz_screen_short(self, tmp_path): + def build(h): + h.add_plugin(FakePlugin("clock", ["clock"], duration=30)) + h.add_plugin(FakePlugin("sports", ["sports_live"], duration=20, + live=(12.5, 100), live_priority=True)) + _, rows = _run(tmp_path, 60, build) + clock = rows[0] + assert clock[1] == "clock" and clock[3] == "live" + live = _first(rows, "sports_live") + # Taken over at the first check after 12.5 s, not at 30 s. + assert 12.5 <= live[0] <= 13.5 + + def test_live_game_cuts_a_scrolling_screen_short(self, tmp_path): + def build(h): + h.add_plugin(FakePlugin("ticker", ["ticker"], duration=30, needs_high_fps=True)) + h.add_plugin(FakePlugin("sports", ["sports_live"], duration=20, + live=(7.2, 100), live_priority=True)) + _, rows = _run(tmp_path, 40, build) + assert rows[0][1] == "ticker" and rows[0][3] == "live" + assert 7.2 <= _first(rows, "sports_live")[0] <= 8.3 + + def test_live_game_cuts_a_make_up_dwell_short(self, tmp_path): + # display() returns False after the first frame, so the 1 Hz loop + # breaks and the rest of the 30 s is a dwell sleep. + def build(h): + h.add_plugin(FakePlugin("flaky", ["flaky"], duration=30, first_frame_only=True)) + h.add_plugin(FakePlugin("sports", ["sports_live"], duration=20, + live=(10, 100), live_priority=True)) + _, rows = _run(tmp_path, 50, build) + assert rows[0][1] == "flaky" + assert 10 <= _first(rows, "sports_live")[0] <= 11 + + def test_on_demand_is_never_preempted(self, tmp_path): + def build(h): + h.add_plugin(FakePlugin("clock", ["clock"], duration=20)) + h.add_plugin(FakePlugin("weather", ["weather"], duration=20)) + h.add_plugin(FakePlugin("sports", ["sports_live"], duration=20, + live=(10, 200), live_priority=True)) + h.on_demand_request(2, "od", plugin_id="weather", duration=40) + _, rows = _run(tmp_path, 60, build) + on_demand = [row for row in rows if 2 <= row[0] < 42] + assert on_demand and all(row[1] == "weather" for row in on_demand) + # Not even interrupted and restarted: each on-demand screen runs out. + assert all(row[3] != "live" for row in on_demand) + assert on_demand[0][2] == 20.0 + # Once the session expires, the live game takes over. + after = [row for row in rows if row[0] >= 42] + assert after[0][1] == "sports_live" + + def test_simultaneous_games_still_take_turns(self, tmp_path): + # Both go live during the clock screen. The takeover shows the first + # one; the next pass must not advance the round-robin past it. + def build(h): + h.add_plugin(FakePlugin("clock", ["clock"], duration=30)) + h.add_plugin(FakePlugin("nfl", ["nfl_live"], duration=15, + live=(10, 200), live_priority=True)) + h.add_plugin(FakePlugin("nhl", ["nhl_live"], duration=15, + live=(10, 200), live_priority=True)) + _, rows = _run(tmp_path, 75, build) + modes = [row[1] for row in rows] + assert modes[:5] == ["clock", "nfl_live", "nhl_live", "nfl_live", "nhl_live"] + assert 10 <= rows[1][0] <= 11 + + +class TestTakeoverCheck: + """_check_live_takeover() on its own, on a controller built by the harness.""" + + def _controller(self, tmp_path, current="clock"): + h = RunLoopHarness(tmp_path, horizon=10) + h.add_plugin(FakePlugin("clock", ["clock"], duration=20)) + sports = h.add_plugin(FakePlugin("sports", ["sports_recent", "sports_live"], + duration=20, live=(0, 100), live_priority=True)) + dc = h.controller + dc.current_display_mode = current + dc.current_mode_index = dc.available_modes.index(current) + return h, dc, _counting(sports) + + def test_switches_to_the_live_mode(self, tmp_path): + _, dc, calls = self._controller(tmp_path) + dc._check_live_takeover() + assert dc.current_display_mode == "sports_live" + assert dc.force_change is True + assert dc._live_takeover_unshown is True + # The rotation resumes from the screen that was cut short. + assert dc._live_resume_index == 0 + assert len(calls) == 1 # once per plugin, not per mode key + + def test_on_demand_session_is_left_alone(self, tmp_path): + _, dc, calls = self._controller(tmp_path) + dc.on_demand_active = True + dc._check_live_takeover() + assert dc.current_display_mode == "clock" + assert calls == [] + + def test_scheduled_off_is_left_alone(self, tmp_path): + _, dc, calls = self._controller(tmp_path) + dc.is_display_active = False + dc._check_live_takeover() + assert dc.current_display_mode == "clock" + assert calls == [] + + def test_vegas_keeping_live_in_the_ticker_is_left_alone(self, tmp_path): + h, dc, calls = self._controller(tmp_path) + h.enable_vegas(live_in_ticker=True) + dc._check_live_takeover() + assert dc.current_display_mode == "clock" + assert calls == [] + + def test_live_screen_already_showing_is_not_rescanned(self, tmp_path): + _, dc, calls = self._controller(tmp_path, current="sports_live") + dc._collect_live_modes() # the scan that put the live mode up + dc._last_live_scan = None # throttle out of the way + dc._check_live_takeover() + assert dc.current_display_mode == "sports_live" + assert dc._live_takeover_unshown is False + assert len(calls) == 1 + + +class TestLiveContentPollingCost: + def test_at_most_once_a_second_during_a_rotation_screen(self, tmp_path): + holder = {} + + def build(h): + h.add_plugin(FakePlugin("clock", ["clock"], duration=30, needs_high_fps=True)) + # Two mode keys on one plugin: still asked once per scan. + sports = h.add_plugin(FakePlugin("sports", ["sports_recent", "sports_live"], + duration=20, live_priority=True, + content=lambda t, m: m != "sports_live")) + holder["calls"] = _counting(sports) + _run(tmp_path, 29, build) + calls = holder["calls"] + # A 125 Hz screen, 29 s long: about one scan a second, never two + # within a second of each other. + assert len(calls) <= 30 + assert all(b - a >= 0.99 for a, b in zip(calls, calls[1:])) + + def test_not_rescanned_while_a_live_game_is_showing(self, tmp_path): + holder = {} + + def build(h): + h.add_plugin(FakePlugin("clock", ["clock"], duration=20)) + sports = h.add_plugin(FakePlugin("sports", ["sports_live"], duration=30, + live=(0, 200), live_priority=True)) + holder["calls"] = _counting(sports) + _, rows = _run(tmp_path, 90, build) + assert all(row[1] == "sports_live" for row in rows) + # Per 30 s live screen: the scan before it and the hold check after + # it, as before -- nothing from inside the screen. + assert len(holder["calls"]) <= 2 * len(rows) + + +class TestVegasYieldsToLive: + def test_live_game_shows_next_without_a_rotation_screen(self, tmp_path): + def build(h): + h.add_plugin(FakePlugin("clock", ["clock"], duration=20)) + h.add_plugin(FakePlugin("weather", ["weather"], duration=20)) + h.add_plugin(FakePlugin("sports", ["sports_live"], duration=20, + live=(40, 200), live_priority=True)) + h.enable_vegas(cycle=30) + _, rows = _run(tmp_path, 80, build) + assert rows[0][1] == "" + yielded = next(i for i, row in enumerate(rows) if row[3] == "vegas-live") + nxt = rows[yielded + 1] + assert nxt[1] == "sports_live" + assert nxt[0] == rows[yielded][0] + rows[yielded][2] + + def test_live_in_ticker_keeps_the_ticker(self, tmp_path): + def build(h): + h.add_plugin(FakePlugin("clock", ["clock"], duration=20)) + h.add_plugin(FakePlugin("sports", ["sports_live"], duration=20, + live=(10, 200), live_priority=True)) + h.enable_vegas(cycle=30, live_in_ticker=True) + _, rows = _run(tmp_path, 70, build) + assert all(row[1] == "" for row in rows) diff --git a/test/test_run_loop_wifi_and_live.py b/test/test_run_loop_wifi_and_live.py new file mode 100644 index 00000000..c135a61f --- /dev/null +++ b/test/test_run_loop_wifi_and_live.py @@ -0,0 +1,157 @@ +"""A WiFi notice and a live game that both want the panel at once. + +The two preempt the current screen independently (_wifi_notice_pending and +_check_live_takeover, both polled from the frame loops and the dwell sleep), +so these pin down how they combine. The documented priority is follower, +on-demand, WiFi, live, Vegas, rotation: the notice shows first, then the +game, with no rotation screen in between, and the scheduled-off panel shows +neither. Runs the real run() loop on the fake clock of +test/_run_loop_harness.py. Each trace row is +[start, mode, duration, exit_reason, frames, force_clear]. +""" + +import os + +import pytest + +os.environ.setdefault("EMULATOR", "true") + +from test._run_loop_harness import FakePlugin, RunLoopHarness # noqa: E402 + + +def _run(tmp_path, horizon, build): + harness = RunLoopHarness(tmp_path, horizon=horizon) + build(harness) + return harness.run() + + +def _sports(h, live, **kwargs): + h.add_plugin(FakePlugin("sports", ["sports_live"], duration=20, live=live, + live_priority=True, **kwargs)) + + +def _notice_then_game(rows, after, posted, expires): + """Check the rows from index `after` on: notice, then game, nothing else. + + The notice is up within about a second of being posted and stays up + until it expires (it may be redrawn across pass boundaries, so it can + span several rows). The game follows it directly. + """ + wifi = [] + i = after + while rows[i][1] == "": + wifi.append(rows[i]) + i += 1 + assert wifi, rows + assert posted <= wifi[0][0] <= posted + 1.25 + # Continuous: each notice row starts where the one before it ended. + for prev, cur in zip(wifi, wifi[1:]): + assert cur[0] == pytest.approx(prev[0] + prev[2]) + assert wifi[-1][0] + wifi[-1][2] >= expires + game = rows[i] + assert game[1] == "sports_live" + assert game[0] == pytest.approx(wifi[-1][0] + wifi[-1][2]) + return i + + +@pytest.mark.parametrize("live_at, wifi_at", [(10.2, 10.4), (10.4, 10.2)], + ids=["game-first", "notice-first"]) +def test_both_during_a_static_screen(tmp_path, live_at, wifi_at): + def build(h): + h.add_plugin(FakePlugin("clock", ["clock"], duration=30)) + h.add_plugin(FakePlugin("weather", ["weather"], duration=20)) + _sports(h, (live_at, 60)) + h.wifi_message(wifi_at, "Connected to HomeNet", duration=5) + trace = _run(tmp_path, 90, build) + rows = trace["screens"] + + # The 1 Hz loop's next check after both arrive ends clock's screen. + assert rows[0][1] == "clock" and rows[0][2] <= 11.0 + _notice_then_game(rows, 1, wifi_at, wifi_at + 5) + # The game is never on the panel before the notice. + first_game = next(row for row in rows if row[1] == "sports_live") + first_wifi = next(row for row in rows if row[1] == "") + assert first_wifi[0] < first_game[0] + # Once the game ends, the rotation resumes at the screen it cut short. + after_game = next(row for row in rows if row[0] >= 60 and row[1] != "sports_live") + assert after_game[1] == "clock" + + +def test_both_at_once_during_a_scrolling_screen(tmp_path): + def build(h): + h.add_plugin(FakePlugin("ticker", ["ticker"], duration=30, enable_scrolling=True)) + h.add_plugin(FakePlugin("weather", ["weather"], duration=20)) + _sports(h, (10.0, 60)) + h.wifi_message(10.0, "AP mode on", duration=4) + rows = _run(tmp_path, 90, build)["screens"] + + assert rows[0][1] == "ticker" and rows[0][0] + rows[0][2] <= 11.0 + _notice_then_game(rows, 1, 10.0, 14.0) + assert "weather" not in [row[1] for row in rows if row[0] < 60] + + +def test_vegas_yields_to_both_with_no_rotation_screen(tmp_path): + def build(h): + h.add_plugin(FakePlugin("clock", ["clock"], duration=20)) + _sports(h, (40, 70), content=lambda t, mode: 40 <= t < 70) + h.enable_vegas(cycle=30) + h.wifi_message(40, "Connected to HomeNet", duration=3) + trace = _run(tmp_path, 110, build) + rows = trace["screens"] + + yielded = next(i for i, row in enumerate(rows) + if row[1] == "" and row[3] in ("vegas-live", "vegas-interrupt")) + # Vegas's live check (4 Hz) can see the game before the notice file's + # 1 Hz stat sees the notice; then the game is up for at most a second + # before the notice preempts it. + i = yielded + 1 + if rows[i][1] == "sports_live": + assert rows[i][2] <= 1.0 and rows[i][3] == "wifi" + i += 1 + i = _notice_then_game(rows, i, 40, 43) + # Neither the rotation nor the ticker runs while the game is live. + during = [row[1] for row in rows[yielded + 1:] if row[0] < 70] + assert set(during) <= {"sports_live", ""} + assert rows[-1][1] == "" + + +def test_vegas_stopped_for_a_game_shows_a_known_notice_first(tmp_path): + # The notice is already posted when Vegas stops for the game: each Vegas + # frame runs its live check before its interrupt check, so the game can + # be what stops it. Without the interrupt check the notice is only + # learned after the yield, which pins the order the yield path checks + # them in: the notice first. + def build(h): + h.add_plugin(FakePlugin("clock", ["clock"], duration=20)) + _sports(h, (40, 70), content=lambda t, mode: 40 <= t < 70) + vegas = h.enable_vegas(cycle=30) + vegas.set_interrupt_checker(lambda: False) + h.wifi_message(39.5, "Connected to HomeNet", duration=4) + rows = _run(tmp_path, 110, build)["screens"] + + yielded = next(i for i, row in enumerate(rows) if row[3] == "vegas-live") + assert 40.0 <= rows[yielded][0] + rows[yielded][2] <= 40.3 + _notice_then_game(rows, yielded + 1, 40.0, 43.5) + + +def test_scheduled_off_shows_neither(tmp_path): + # The harness clock starts at 22:59:30: off from 23:00 (t=30) to 23:05 + # (t=330). The notice and the game both arrive at t=120, well inside it + # (whichever way the end minute is counted). + def build(h): + h.config["schedule"] = {"enabled": True, "start_time": "23:05", "end_time": "23:00"} + h.add_plugin(FakePlugin("clock", ["clock"], duration=20)) + h.add_plugin(FakePlugin("weather", ["weather"], duration=20)) + _sports(h, (120, 400)) + h.wifi_message(120, "AP mode on", duration=30) + trace = _run(tmp_path, 380, build) + rows = trace["screens"] + + off = next(i for i, row in enumerate(rows) if row[1] == "") + assert rows[off][0] <= 90.0 + assert rows[off][0] + rows[off][2] == 330.0 and rows[off][3] == "schedule-on" + assert not any(row[1] == "" for row in rows) + live_events = [e for e in trace["events"] if e[1] == "live"] + assert live_events and all(e[0] >= 330.0 for e in live_events) + # The game, still live when the panel comes back, is what shows. + assert rows[off + 1][1] == "sports_live"