mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
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>
251 lines
12 KiB
Python
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]
|