Files
LEDMatrix/test/test_run_loop_golden.py
T
ChuckandClaude Opus 5.5 c6b064b220 fix(on-demand): show a named live mode; end a session that cannot resume
A request naming a *_live mode (football-scoreboard / ncaa_fb_live with
15 college games on) answered 200 and showed nfl_recent. The session's
mode list kept live modes only when has_live_content() said so, which is
the live-priority question and is answered for favourite teams only. A
mode the request names now leads the session; display() decides whether
it has anything to draw, and an empty one moves on to the plugin's next
mode as any empty on-demand mode does. The name is saved in
display_on_demand_config (named_mode) so a restart resumes on it. A bare
plugin-id request still skips quiet live modes, as before.

A restart during a session whose plugin then failed to load (clock-simple
failed config validation after a crash on ledpi) left the session active
with no modes and its cached request in place. It now ends at startup
with status error / restore-failed, and the cached request is dropped;
likewise when the plugin system fails to start.

Golden traces: two new scenarios (on_demand_named_live,
on_demand_restore_failed); every existing trace is unchanged. The
harness's restore_on_demand takes named_mode and logs a failed restore.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:20:26 -04:00

251 lines
12 KiB
Python

"""Golden traces of DisplayController.run(): what is shown, for how long, and why.
Each scenario runs the real run() loop against fake plugins on a fake clock
(see test/_run_loop_harness.py) and compares the screens it produced with
test/fixtures/run_loop_golden/<scenario>.json. A trace row is
[start_s, mode, duration_s, exit_reason, frames, force_clear_on_first_frame]
and ``events`` lists what else happened (requests, live changes, schedule,
brightness) with its time.
These pin down today's behaviour so run() can be restructured into an
Arbiter / ScreenRunner / Sources (docs/RUN_LOOP_REDESIGN.md) without changing
it. A diff here is a behaviour change: if it is intended, regenerate with
LEDMATRIX_REGEN_GOLDEN=1 and explain the change in the commit message.
"""
import os
import pytest
os.environ.setdefault("EMULATOR", "true")
from test._run_loop_harness import ( # noqa: E402
FakePlugin,
LegacyFakePlugin,
RunLoopHarness,
check_golden,
)
def scenario_plain_rotation(h: RunLoopHarness):
# clock: duration from display_durations, which beats the plugin's own.
# weather: the plugin's own duration. ticker: scrolls, so high-FPS.
# legacy: display() without display_mode.
h.config["display"]["display_durations"] = {"clock": 15}
h.add_plugin(FakePlugin("clock", ["clock"], duration=99))
h.add_plugin(FakePlugin("weather", ["weather_now", "weather_forecast"], duration=20))
h.add_plugin(FakePlugin("ticker", ["ticker"], duration=10, enable_scrolling=True))
h.add_plugin(LegacyFakePlugin("legacy", ["legacy"], duration=5))
def scenario_empty_modes(h: RunLoopHarness):
# empty: never has content, skipped at once. ghost: a mode with no
# plugin behind it. flaky: content on the first frame only, so the
# 1 s loop breaks early and the dwell is made up by sleeping.
h.add_plugin(FakePlugin("clock", ["clock"], duration=10))
h.add_plugin(FakePlugin("empty", ["empty"], duration=10, content=lambda t, m: False))
h.add_mode_without_plugin("ghost")
h.add_plugin(FakePlugin("flaky", ["flaky"], duration=12, first_frame_only=True))
def scenario_all_empty(h: RunLoopHarness):
# Nothing to show anywhere: one rotation of empty passes, then a 1 s
# pause per pass instead of a spin.
h.add_plugin(FakePlugin("a", ["a"], content=lambda t, m: False))
h.add_plugin(FakePlugin("b", ["b"], content=lambda t, m: False))
h.add_plugin(FakePlugin("c", ["c"], content=lambda t, m: t >= 6))
def scenario_plugin_error(h: RunLoopHarness):
# broken's dispatch raises (no display lock: loading failed part-way),
# so all its modes are skipped together; two failures open the breaker.
# crashy's display() raises inside the executor: an empty pass
# ("raised") that also counts as a breaker failure, so after two raises
# it is skipped by the breaker. Its modes are not skipped together.
h.add_plugin(FakePlugin("clock", ["clock"], duration=10))
h.add_plugin(FakePlugin("broken", ["broken_a", "broken_b"], duration=10), lock=False)
h.add_plugin(FakePlugin("weather", ["weather"], duration=10))
h.add_plugin(FakePlugin("crashy", ["crashy"], duration=10, raises=True))
def scenario_dynamic_duration(h: RunLoopHarness):
# Read once at startup, so set where __init__ left it.
h.controller.global_dynamic_config = {"max_duration_seconds": 50}
# scroller: high-FPS, completes its cycle 20 s after each reset.
h.add_plugin(FakePlugin("scroller", ["scroller"], duration=10, needs_high_fps=True,
dynamic={"cap": None, "complete_after": 20}))
# news: 1 s loop, asks for 45 s but its own cap is 40; never completes.
h.add_plugin(FakePlugin("news", ["news"], duration=10,
dynamic={"cap": 40, "cycle": 45, "complete_after": None}))
# board: no cap of its own, so the global 50 s applies; done after 5 s,
# but the 10 s minimum (+0.5 s grace) holds it.
h.add_plugin(FakePlugin("board", ["board"], duration=10,
dynamic={"cap": None, "complete_after": 5}))
h.add_plugin(FakePlugin("clock", ["clock"], duration=10))
def scenario_live_priority(h: RunLoopHarness):
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
h.add_plugin(FakePlugin("weather", ["weather"], duration=20))
h.add_plugin(FakePlugin(
"sports", ["sports_recent", "sports_live"], duration=20,
live=(50, 110), live_priority=True,
content=lambda t, mode: mode != "sports_live" or 50 <= t < 110))
def scenario_live_round_robin(h: RunLoopHarness):
h.add_plugin(FakePlugin("clock", ["clock"], duration=15))
h.add_plugin(FakePlugin("nfl", ["nfl_live"], duration=15, live=(0, 70), live_priority=True))
h.add_plugin(FakePlugin("nhl", ["nhl_live"], duration=15, live=(20, 100), live_priority=True))
def scenario_on_demand(h: RunLoopHarness):
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
h.add_plugin(FakePlugin("weather", ["weather"], duration=20))
h.add_plugin(FakePlugin("sports", ["sports_recent", "sports_upcoming"], duration=15))
# Mid-way through clock's first screen; then stopped by request.
h.on_demand_request(25, "r1", plugin_id="sports")
h.on_demand_request(95, "r2", action="stop")
# A timed request that expires on its own.
h.on_demand_request(150, "r3", plugin_id="weather", duration=30)
def scenario_on_demand_pinned(h: RunLoopHarness):
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
h.add_plugin(FakePlugin("sports", ["sports_recent", "sports_upcoming"], duration=15))
h.on_demand_request(12, "p1", plugin_id="sports", mode="sports_upcoming", pinned=True)
# An on-demand mode with nothing to show is skipped like any other.
h.add_plugin(FakePlugin("starlark", ["app_a", "app_b"], duration=10,
content=lambda t, mode: mode != "app_a"))
h.on_demand_request(80, "p2", plugin_id="starlark")
h.on_demand_request(120, "p3", action="stop")
def scenario_on_demand_restored(h: RunLoopHarness):
# A restart during an on-demand session resumes it: the first screen is
# the saved mode (with a full clear), not the rotation's first mode, and
# the rotation starts from the top once it expires.
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
h.add_plugin(FakePlugin("weather", ["weather"], duration=20))
h.add_plugin(FakePlugin("sports", ["sports_recent", "sports_upcoming"], duration=15))
h.restore_on_demand("sports", mode="sports_upcoming", duration=40)
def scenario_on_demand_named_live(h: RunLoopHarness):
# Games are on until t=70, but none involves a favourite, so
# has_live_content() (the live-priority answer) stays False throughout.
# A request naming sports_live still opens on it (it opened on
# sports_recent); asked for again after the games end, it has nothing to
# draw and the session moves on to the plugin's next mode.
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
h.add_plugin(FakePlugin(
"sports", ["sports_live", "sports_recent", "sports_upcoming"], duration=15,
live_priority=True,
content=lambda t, mode: mode != "sports_live" or t < 70))
h.on_demand_request(5, "n1", plugin_id="sports", mode="sports_live")
h.on_demand_request(40, "n2", action="stop")
h.on_demand_request(100, "n3", plugin_id="sports", mode="sports_live")
h.on_demand_request(140, "n4", action="stop")
def scenario_on_demand_restore_failed(h: RunLoopHarness):
# A restart during a session whose plugin then fails to load: the
# session ends as an error before the first screen, and the rotation
# runs normally from the top.
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
h.add_plugin(FakePlugin("weather", ["weather"], duration=20))
h.restore_on_demand("gone", mode="gone", duration=40)
def scenario_schedule(h: RunLoopHarness):
# The clock starts at 22:59:30. Off from 23:01 until 23:05 (the window
# spans midnight); dimmed from 23:00 until 23:01.
h.config["schedule"] = {"enabled": True, "start_time": "23:05", "end_time": "23:01"}
h.config["dim_schedule"] = {"enabled": True, "start_time": "23:00",
"end_time": "23:01", "dim_brightness": 30}
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
h.add_plugin(FakePlugin("weather", ["weather"], duration=20))
# An on-demand request during scheduled downtime overrides it; when it
# expires the panel blanks at once, not at the next minute.
h.on_demand_request(170, "s1", plugin_id="weather", duration=20)
def scenario_wifi_notice(h: RunLoopHarness):
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
h.add_plugin(FakePlugin("weather", ["weather"], duration=20))
# Posted mid-screen: it preempts the screen at its next frame, stays up
# until it expires, and the interrupted mode then comes back in full.
h.wifi_message(25, "Connected to HomeNet", duration=5)
# While on-demand is active the notice waits.
h.on_demand_request(60, "w1", plugin_id="clock", duration=20)
h.wifi_message(65, "AP mode on", duration=30)
def scenario_follower(h: RunLoopHarness):
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
h.add_plugin(FakePlugin("weather", ["weather"], duration=20))
# Only checked at the top of a pass, so it takes over when the screen
# running at t=35 ends, and hands back the pass after it ends.
h.sync.follower_windows = [(35, 50)]
def scenario_vegas(h: RunLoopHarness):
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
h.add_plugin(FakePlugin(
"sports", ["sports_live"], duration=20, live=(70, 100), live_priority=True,
content=lambda t, mode: 70 <= t < 100))
h.enable_vegas(cycle=30)
# On-demand takes the panel from Vegas mid-iteration, then hands back.
h.on_demand_request(150, "v1", plugin_id="clock", duration=25)
h.wifi_message(200, "Connected to HomeNet", duration=3)
def scenario_vegas_live_in_ticker(h: RunLoopHarness):
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
h.add_plugin(FakePlugin("sports", ["sports_live"], duration=20, live=(10, 50),
live_priority=True))
h.enable_vegas(cycle=30, live_in_ticker=True)
SCENARIOS = {
"plain_rotation": (scenario_plain_rotation, 160),
"empty_modes": (scenario_empty_modes, 90),
"all_empty": (scenario_all_empty, 12),
"plugin_error": (scenario_plugin_error, 90),
"dynamic_duration": (scenario_dynamic_duration, 220),
"live_priority": (scenario_live_priority, 200),
"live_round_robin": (scenario_live_round_robin, 150),
"on_demand": (scenario_on_demand, 240),
"on_demand_pinned": (scenario_on_demand_pinned, 160),
"on_demand_restored": (scenario_on_demand_restored, 100),
"on_demand_named_live": (scenario_on_demand_named_live, 160),
"on_demand_restore_failed": (scenario_on_demand_restore_failed, 60),
"schedule": (scenario_schedule, 400),
"wifi_notice": (scenario_wifi_notice, 150),
"follower": (scenario_follower, 80),
"vegas": (scenario_vegas, 260),
"vegas_live_in_ticker": (scenario_vegas_live_in_ticker, 100),
}
@pytest.mark.parametrize("name", sorted(SCENARIOS))
def test_run_loop_golden_trace(name, tmp_path):
build, horizon = SCENARIOS[name]
harness = RunLoopHarness(tmp_path, horizon=horizon)
build(harness)
trace = harness.run()
check_golden(name, trace)
def test_traces_are_repeatable(tmp_path):
"""Two runs of the busiest scenario give the identical trace."""
traces = []
for i in range(2):
(tmp_path / str(i)).mkdir()
harness = RunLoopHarness(tmp_path / str(i), horizon=240)
scenario_on_demand(harness)
traces.append(harness.run())
assert traces[0] == traces[1]