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 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-10-01 15:59:14 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 6c6394a1a7
commit 77e0ea91ae
7 changed files with 492 additions and 27 deletions
+9
View File
@@ -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
+6 -8
View File
@@ -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.
+86 -6
View File
@@ -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()):
+13 -11
View File
@@ -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"]
]
}
+3 -2
View File
@@ -3,8 +3,8 @@
[0.0, "<vegas>", 30.008, "duration", 3751, null],
[30.008, "<vegas>", 30.007, "duration", 3751, null],
[60.015, "<vegas>", 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, "<vegas>", 30.008, "duration", 3751, null],
[140.263, "<vegas>", 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"],
+218
View File
@@ -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] == "<vegas>"
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] == "<vegas>" for row in rows)
+157
View File
@@ -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>":
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] == "<wifi>")
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] == "<vegas>" 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", "<wifi>"}
assert rows[-1][1] == "<vegas>"
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] == "<off>")
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] == "<wifi>" 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"