Files
LEDMatrix/test/test_run_loop_golden.py
T
ChuckandClaude Opus 5.5 9edeb6da14 fix(display): a raising display() counts as a circuit-breaker failure (#707)
The first-frame dispatch (_dispatch_first_frame) now asks PluginExecutor.execute_display() to re-raise (raise_errors=True) and records a raise inside the executor as a breaker failure, with the original exception as last_error, instead of a success. The screen is still an empty pass and rotation is unchanged; a hung display() is still recorded once, as a hang. The run-loop golden trace plugin_error.json is regenerated (crashy now records health failures and is skipped by the breaker), and behaviour 7 is dropped from docs/RUN_LOOP_REDESIGN.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 14:51:38 -04:00

222 lines
10 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_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.
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 and expired before the screen ends: never shown,
# because the notice is only checked between screens.
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),
"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]