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:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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