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
+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"