mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
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:
+13
-11
@@ -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
@@ -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"],
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user