refactor(display): run() stage 1 - golden traces and extracted helpers, no behaviour change (#704)

Adds golden trace tests for DisplayController.run() (test/test_run_loop_golden.py on a fake clock with fake plugins, 15 scenarios, fixtures in test/fixtures/run_loop_golden/) and moves twelve blocks of run() into named helpers (_dispatch_first_frame, _resolve_durations, _resolve_active_mode, _needs_high_fps, _advance_after_screen and others) with the traces identical before and after. docs/RUN_LOOP_REDESIGN.md describes the target structure. Hardware-checked on hdpi: Vegas late-frame rate unchanged in an ABBA A/B.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-10-01 14:41:43 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent eb8128a981
commit a21650e746
21 changed files with 2201 additions and 444 deletions
+19
View File
@@ -17,6 +17,25 @@ release that ships it.
accepts both, but the store flags the old spelling as deprecated
(`store_manager.py`) and only the new one is in `schema/manifest_schema.json`.
## Unreleased
### Tooling
- Golden trace tests for the display loop. `test/test_run_loop_golden.py`
runs the real `DisplayController.run()` against fake plugins on a fake
clock (`test/_run_loop_harness.py`), with no hardware and no real sleeps,
and compares which mode was shown, for how long and why it ended with
`test/fixtures/run_loop_golden/`. It has 15 scenarios: rotation,
empty and failing modes, dynamic duration, live priority, on-demand
(including pinned and resumed after a restart), the schedule and dim
schedule, WiFi notices, sync follower and Vegas. The whole file runs in
about a second. This is stage 1 of restructuring `run()`, described in
`docs/RUN_LOOP_REDESIGN.md`. The other part of stage 1 is internal and
changes no behaviour: twelve blocks of `run()` move into named helpers
(`_dispatch_first_frame`, `_resolve_durations`, `_resolve_active_mode`,
`_needs_high_fps`, `_advance_after_screen` and others), and the traces are
identical before and after the move.
## 3.8.0
Live Vegas elements: plugin content that keeps changing while it scrolls
+2 -1
View File
@@ -187,7 +187,8 @@ the scheduler), and sets up Vegas mode.
enable/disable, poll on-demand requests, run scheduled plugin updates, check
the on/off schedule and brightness, then show one screen. Priority is
on-demand, then WiFi status messages, then live priority, then Vegas mode,
then normal rotation.
then normal rotation. [RUN_LOOP_REDESIGN.md](RUN_LOOP_REDESIGN.md) is the
plan for restructuring this loop and lists its golden trace tests.
- **Rotation.** `available_modes` is the ordered list of display modes;
`current_mode_index` advances after each screen.
+277
View File
@@ -0,0 +1,277 @@
# Restructuring `DisplayController.run()`
`run()` in [`src/display_controller.py`](../src/display_controller.py) decides
what the panel shows and runs it. This document is the plan for turning it
from one long loop into three parts with clear jobs: an **Arbiter** that
decides, a **ScreenRunner** that runs one screen, and **Sources** that each
know about one kind of content. It covers the target design, the stages that
get there, and how each stage is checked.
The goal is to change how the control flow is organised, not to move code
into more files. Each stage ships as its own PR, and none of them changes
what the panel shows unless that PR says so and updates the golden traces
on purpose.
## Why
- **The priority order is written in branch order, twice.** It is
Follower, on-demand, WiFi notice, live priority, Vegas, rotation. In
`run()` that order exists only as the order of `if` blocks. Vegas
repeats part of it in its interrupt callback (`_check_vegas_interrupt`).
- **Preemption is found by re-checking.** A screen ends early when
something else changed `current_display_mode` or `is_display_active`
underneath it. `run()` notices with five separate
`current_display_mode != active_mode` checks: after an empty pass, in each
of the two frame loops, after the frame loops, and before rotating.
- **Most recent fixes were ordering bugs** between these branches (#618,
#644, #649, #652): a lost mode switch, rotating past an on-demand request,
spinning when every mode is empty.
- **It could not be tested** without threads, real sleeps and stopping the
loop by raising from a patched method.
## What `run()` does today
Each pass, in order:
1. `loop_pass()` (watchdog). Apply a pending plugin enable/disable.
2. With no modes: dwell 1 s, next pass.
3. Poll on-demand requests and expiry, release plugins loaded only for
on-demand, tick plugin updates, drop an expired WiFi notice, evaluate
the schedule (an on-demand session overrides scheduled-off), apply the
brightness target.
4. **Scheduled off:** blank, dwell up to 60 s. `_blank_while_scheduled_off`
5. **Follower:** render one frame from the leader. `_run_follower_frame`
6. **WiFi notice** (unless on-demand): draw it, dwell 0.5 s. `_show_wifi_notice`
7. **Live priority** (unless on-demand, or Vegas keeps live content in the
ticker): switch to the next live mode, or resume the rotation.
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. An interrupted one falls through to step 9 in the same pass.
9. **One screen:** pick the mode (`_resolve_active_mode`), the plugin
(`_plugin_for_mode`), draw the first frame through the executor
(`_dispatch_first_frame`). On no content, rotate at once
(`_note_empty_pass`, `_skip_failed_plugin_modes`). Otherwise work out the
bounds (`_track_dynamic_cycle`, `_resolve_durations`,
`_clamp_to_on_demand`) and the frame rate (`_needs_high_fps`), run the
125 Hz or 1 Hz frame loop, make up the minimum duration, then pick the
next mode (`_advance_after_screen`).
The helpers named above were extracted in stage 1 without changing
behaviour. The frame loops, the Vegas branch and every early exit are still
inline in `run()`.
## Target design
```python
def run(self):
while True:
inputs = self._drain_inputs() # requests, schedule, config, sync
plan = self.arbiter.decide(self.state, inputs, clock.now())
outcome = self.runner.run(plan) # ExitReason + elapsed
self.state = self.state.after(plan, outcome) # rotation, on-demand index, live resume
```
### Sources
Each kind of content is a Source. A Source looks at the state and the
inputs and either offers a screen or passes. The Arbiter asks them in this
order:
| Order | Source | Offers a screen when | Today |
|---|---|---|---|
| gate | ScheduledOff | the schedule is off and no on-demand session overrides it | step 4 |
| 1 | Follower | a sync leader is driving this panel | step 5 |
| 2 | OnDemand | a session is active (its mode list, index, expiry and pin) | `_resolve_active_mode` |
| 3 | Wifi | a status message is pending and on-demand is not active | step 6 |
| 4 | Live | a live-priority plugin has live content (round-robin across several) | step 7 |
| 5 | Vegas | Vegas is enabled and nothing above wants the panel | step 8 |
| 6 | Rotation | always: `available_modes[current_mode_index]` | step 9 |
ScheduledOff is a gate in front of the Sources because that is how it works
today: a scheduled-off panel stays blank even for a follower, and only an
on-demand session overrides it.
### Arbiter
```python
Arbiter.decide(state, inputs, now) -> ScreenPlan
```
`decide` is a pure function: it does no I/O, takes no locks and does not
sleep. It can be tested with plain tables of (state, inputs, now) mapped to
an expected plan. It returns a `ScreenPlan`:
| Field | Meaning |
|---|---|
| `source` | which Source won |
| `mode`, `plugin` | what to draw (None for a blank or follower plan) |
| `min_duration`, `max_duration` | from `_resolve_durations` and `_clamp_to_on_demand` |
| `dynamic` | run until the plugin's cycle completes, between min and max |
| `frame_policy` | today `_needs_high_fps` (125 Hz or 1 Hz); see stage 5 |
| `preemptible_by` | the Sources allowed to interrupt this plan mid-screen |
### ScreenRunner
```python
ScreenRunner(clock: FrameClock).run(plan) -> Outcome(exit_reason, elapsed)
```
The ScreenRunner draws the first frame (`_dispatch_first_frame`), runs the
frame loop that the plan's frame policy selects, services pending changes
between frames, and returns one `ExitReason`:
| ExitReason | Today's equivalent (golden-trace exit) |
|---|---|
| `DURATION` | target duration reached (`duration`) |
| `CYCLE_COMPLETE` | dynamic plugin finished after its minimum (`cycle-complete`) |
| `EMPTY` | first frame returned False (`empty`; `raised` when display() raised inside the executor) |
| `ERROR` | the dispatch itself raised (`error`) |
| `DISPLAY_FALSE` | a later frame returned False (`display-false`) |
| `PREEMPTED` | another Source took the panel (`on-demand-*`, `schedule-off`, `vegas-interrupt`, ...) |
`PREEMPTED` replaces the five `current_display_mode != active_mode` checks.
The runner asks the Arbiter, at the throttled service points it already has,
whether a Source in `plan.preemptible_by` now wants the panel.
`FrameClock` provides `now()` and `sleep()`. In production it is
`time.monotonic`/`time.sleep`. In the golden traces it is the fake clock
that the harness patches in today.
## Stages
| Stage | Change | Behaviour change | Verified by |
|---|---|---|---|
| 1 | Golden traces; extract helpers from `run()` | none | traces generated on main pass unchanged; mutation check |
| 2 | Arbiter with Follower and Wifi Sources | none | traces unchanged; Arbiter unit tables; ledpi smoke |
| 3 | ScreenRunner, FrameClock, ExitReason, `PREEMPTED`; OnDemand, Live, Rotation Sources | none | traces unchanged; ledpi frame soak A/B |
| 4 | Vegas as a Source driven by `run_frame()` | none intended | traces against the real coordinator; ledpi Vegas soak A/B |
| 5 | Plugins declare `frame_policy` | DEBUG instead of INFO for the FPS line | traces; soak on a static-heavy rotation |
### Stage 1 (this PR)
- `test/_run_loop_harness.py` builds a real `DisplayController` through
`__init__` on in-memory fakes (plugins, cache, config service, plugin
manager, sync manager, display manager). It swaps the module's `time` and
`datetime` for one fake clock and runs the real `run()` until a horizon.
The first frame of each screen still goes through the real
`PluginExecutor` and the per-plugin locks.
- `test/test_run_loop_golden.py` has 15 scenarios, each compared with
`test/fixtures/run_loop_golden/<scenario>.json`:
- plain rotation (display_durations override, a high-FPS scroller, a
plugin whose `display()` takes no `display_mode`)
- empty modes and a mode with no plugin; an all-empty rotation (the 1 s
pause)
- plugin errors and the circuit breaker
- dynamic duration (cycle complete, plugin cap, global cap)
- live priority taking over and handing back; live round-robin
- on-demand start/stop/expiry; pinned on-demand; a session resumed after
a restart
- schedule off and dim, with an on-demand override during downtime
- WiFi notice; sync follower
- Vegas, with and without `live_in_ticker`
- Each trace row is `[start, mode, duration, exit_reason, frames,
force_clear]`. The exit reason is the event that decided what came next.
- All 16 tests run in under a second. The goldens were generated from
main's `run()` before any code moved.
- Vegas uses `FakeVegas`, which implements only the contract the controller
depends on: `run_iteration()` returns True after its duration and False
when the interrupt or live check asks it to yield, checking at the real
coordinator's cadence. Running the real coordinator on the fake clock
belongs to stage 4.
- Twelve helpers were extracted from `run()` (listed under "What `run()`
does today"). Breaking any one of them fails at least one golden trace.
### Stage 2: Arbiter, starting with Follower and Wifi
1. Add `ScreenPlan` and an `Arbiter` with the ScheduledOff gate, Follower
and Wifi. Every other case returns a `LEGACY` plan, which means "carry on
with the existing code" (steps 7-9).
2. `run()` calls `decide()` after the bookkeeping in step 3 and dispatches
on `plan.source`: blank, `_run_follower_frame()`, the WiFi notice, or the
existing path. Inputs that Sources read (follower active, the pending
WiFi message, schedule state) are collected first, so `decide()` stays
pure.
3. Unit-test `decide()` with tables. The golden traces must not change.
This includes the missed WiFi notice described below: fixing it is a
separate PR.
Follower and Wifi go first because each is one self-contained branch that
ends the pass. They prove the plumbing without touching the frame loops.
### Stage 3: ScreenRunner and `PREEMPTED`
Move the two frame loops, the make-up dwell and the dynamic-duration exit
into `ScreenRunner.run(plan)` with an injected `FrameClock`. Replace the
five re-checks with `PREEMPTED`. Add the OnDemand, Live and Rotation Sources
so `LEGACY` is left meaning only Vegas.
This stage touches frame pacing (the 8 ms deadline sleep, the 1 ms yield),
so it needs a frame soak on ledpi, A/B against main. Coordinate with
whoever owns scroll performance (`docs/SCROLL_PERFORMANCE.md`).
### Stage 4: Vegas as a Source
The controller calls `coordinator.run_frame()` once per frame from the
ScreenRunner instead of handing over to `run_iteration()` for up to
`max_cycle_duration`. The interrupt callback and the second copy of the
priority order go away, because preemption becomes `PREEMPTED`. The
`vegas-plugin-tick` thread that is spawned every 4 s becomes the
controller's normal update tick. Extend the harness to drive the real
coordinator on the fake clock, which means patching its `time` and running
its prefetch inline. Verify with a Vegas soak on ledpi, A/B.
### Stage 5: `frame_policy`
Plugins declare `frame_policy` (STATIC, PERIODIC(hz), ANIMATED(fps),
SCROLL). `_needs_high_fps` becomes the mapping for legacy plugins
(`needs_high_fps`, the `static-image` special case, `enable_scrolling`),
and its per-screen INFO line drops to DEBUG.
## How each stage is verified
- **Golden traces.** Run `python -m pytest test/test_run_loop_golden.py`;
it takes about a second. A refactoring stage must leave every trace
unchanged. A deliberate behaviour change regenerates them with
`LEDMATRIX_REGEN_GOLDEN=1` in its own commit, and the commit message
explains each changed row. A new scenario's golden is generated against
main's `run()` first, then checked against the branch.
- **Mutation check.** Break each moved or new piece once, for example take
`max` of the caps instead of `min`, or skip the live hold. At least one
trace must fail each time. Stage 1 did this for all twelve helpers.
- **Full suite.** Diff the FAILED/ERROR ids against a baseline run of main
in a separate worktree. The Windows host has a stable set of
pre-existing failures, so never compare against zero.
- **ledpi soak** (stages 2-5). With the service running the branch:
`python3 scripts/frame_soak.py --preview` for 10 minutes on a scrolling
rotation, and on Vegas for stages 3-4. Alternate which build goes first.
Compare late-frame rate and freezes with main. Also check by hand that
on-demand start, stop and expiry, a live game taking over and handing
back, and the schedule turning the panel off and on all behave as before.
## Behaviour the traces pin down that may be wrong
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. **A WiFi notice is only checked between screens.** A 5 s notice posted
during a 20 s screen expires before the screen ends and is never shown
(`wifi_notice`, t=25).
2. **Vegas yields to a WiFi notice, then shows a rotation screen instead of
the notice.** An interrupted iteration falls through to step 9 in the
same pass, and the notice has expired by the next pass (`vegas`, t=200).
3. **Vegas yields to live content, then shows a rotation screen first.**
The live game appears one screen later (`vegas`, t=70-90).
4. **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).
5. **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).
6. **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.
7. **A plugin whose `display()` raises inside the executor counts as "no
content"**, and the circuit breaker records it as a success, so it never
trips (`plugin_error`, `crashy`). Only an exception raised outside the
executor counts as a failure.
+558 -443
View File
File diff suppressed because it is too large Load Diff
+814
View File
@@ -0,0 +1,814 @@
"""Drive the real DisplayController.run() on a fake clock and record a trace.
The golden trace tests (test_run_loop_golden.py) use this to pin down what
run() does today -- which mode is on the panel, for how long, and why it
left -- so that the loop can be restructured (docs/RUN_LOOP_REDESIGN.md)
without changing any of it.
What is real and what is fake
-----------------------------
Real: DisplayController itself (constructed through __init__, then run()),
PluginExecutor (each screen's first frame still goes through its thread),
the per-plugin display locks, and every controller method run() calls.
Fake, so the run is deterministic and takes milliseconds:
* the clock -- ``src.display_controller.time`` and ``datetime`` are replaced
by one FakeClock; sleeping only advances it. Scripted events (an on-demand
request, a WiFi notice, live content starting) fire as it passes them.
* plugins -- FakePlugin, whose content, liveness and dynamic-duration answers
are functions of the fake clock.
* the plugin manager, cache, config service, display manager and sync
manager -- in-memory stand-ins with no threads.
* the Vegas coordinator -- FakeVegas implements only the contract the
controller relies on (run_iteration() returning True when it ran its
duration and False when interrupted, the interrupt and live checks it
calls back into). The real coordinator spawns threads and renders a strip;
driving it on the fake clock is part of stage 4 (Vegas as a Source).
The run ends when the fake clock passes the scenario's horizon: the clock
raises StopRun, a BaseException, which run()'s ``except Exception`` lets
through after its ``finally`` has run cleanup().
How the trace is read
---------------------
Everything observable is appended to one ordered event log. reduce_trace()
folds it into screens: a screen starts at the first display() call of a
loop pass (a "pass" is one call of the watchdog's loop_pass(), at the top of
run()'s loop), or at the first follower / Vegas / WiFi / blank frame. Its
exit reason is the first reason-bearing event logged before the next screen
starts, else ``duration``.
"""
from __future__ import annotations
import json
import os
import threading
from datetime import datetime, timezone
from pathlib import Path
from types import SimpleNamespace
from typing import Any, Callable, Dict, List, Optional, Tuple
from unittest.mock import MagicMock, patch
from src.common.sync_manager import SyncRole
from src.plugin_system.plugin_executor import PluginExecutor
GOLDEN_DIR = Path(__file__).parent / "fixtures" / "run_loop_golden"
#: Monday 2026-01-05 22:59:30 UTC. The schedule scenario's windows are set
#: around 23:00; every other scenario has no schedule, so the date is moot.
T0 = datetime(2026, 1, 5, 22, 59, 30, tzinfo=timezone.utc).timestamp()
#: Loop passes allowed without the clock moving before the run is called a
#: spin. run() must sleep somewhere on every few passes.
SPIN_LIMIT = 500
class StopRun(BaseException):
"""Ends a harness run. A BaseException so run()'s handlers pass it on."""
class SpinError(BaseException):
"""run() went round SPIN_LIMIT times without the clock moving.
A BaseException for the same reason as StopRun: run() would log and
swallow anything less, and the test would see a short trace."""
# ---------------------------------------------------------------------------
# Clock
# ---------------------------------------------------------------------------
class FakeClock:
"""time.time/monotonic/perf_counter all read ``now``; sleep() advances it.
Alarms are (time, callback) pairs fired, in time order, by the sleep that
carries the clock past them. Reaching the horizon raises StopRun.
"""
def __init__(self, start: float, horizon: float):
self.start = start
self.now = start
self.horizon = start + horizon
self._alarms: List[Tuple[float, int, Callable[[], None]]] = []
self._seq = 0
self.passes_since_advance = 0
def rel(self) -> float:
return self.now - self.start
def at(self, t: float, callback: Callable[[], None]) -> None:
self._alarms.append((self.start + t, self._seq, callback))
self._seq += 1
self._alarms.sort()
def time(self) -> float:
return self.now
def sleep(self, seconds: float) -> None:
target = self.now + max(0.0, seconds)
while self._alarms and self._alarms[0][0] <= target:
when, _, callback = self._alarms.pop(0)
self.now = max(self.now, when)
callback()
self.now = target
if seconds > 0:
self.passes_since_advance = 0
if self.now >= self.horizon:
raise StopRun()
def time_module(self) -> SimpleNamespace:
return SimpleNamespace(time=self.time, monotonic=self.time,
perf_counter=self.time, sleep=self.sleep)
def datetime_class(self):
clock = self
class FakeDateTime(datetime):
@classmethod
def now(cls, tz=None): # type: ignore[override]
return datetime.fromtimestamp(clock.now, tz or timezone.utc)
return FakeDateTime
# ---------------------------------------------------------------------------
# Fakes
# ---------------------------------------------------------------------------
class FakeCache:
"""The in-memory slice of CacheManager that run() and its helpers use."""
def __init__(self):
self.data: Dict[str, Any] = {}
self.cache_dir = "/nonexistent/run-loop-harness"
def get(self, key, max_age=None, memory_ttl=None):
return self.data.get(key)
def set(self, key, data, ttl=None):
self.data[key] = data
def delete(self, key):
self.data.pop(key, None)
def clear_cache(self, key=None):
if key is None:
self.data.clear()
else:
self.data.pop(key, None)
def __getattr__(self, name):
# Anything else (stats, cleanup hooks) is a no-op.
return lambda *a, **k: None
class FakeConfigService:
def __init__(self, config):
self.config = config
def get_config(self):
return self.config
def subscribe(self, *a, **k):
pass
def unsubscribe(self, *a, **k):
pass
def shutdown(self):
pass
class FakeSync:
"""A standalone sync manager whose follower state follows the script."""
role = SyncRole.STANDALONE
def __init__(self, harness: "RunLoopHarness"):
self._h = harness
self.follower_windows: List[Tuple[float, float]] = []
def is_follower_active(self) -> bool:
t = self._h.clock.rel()
return any(a <= t < b for a, b in self.follower_windows)
def get_latest_scroll_x(self):
return None
def get_latest_frame(self):
return "leader-frame"
def stop(self):
pass
def __getattr__(self, name):
return lambda *a, **k: None
class FakeHealthTracker:
"""Circuit breaker stand-in: opens after two consecutive failures and
stays open (no wall-clock cooldown, which would not be deterministic)."""
def __init__(self, harness: "RunLoopHarness"):
self._h = harness
self.failures: Dict[str, int] = {}
def should_skip_plugin(self, plugin_id):
skip = self.failures.get(plugin_id, 0) >= 2
if skip:
self._h.log("breaker-open", plugin_id, quiet=True)
return skip
def record_success(self, plugin_id):
self.failures[plugin_id] = 0
def record_failure(self, plugin_id, exc=None):
self.failures[plugin_id] = self.failures.get(plugin_id, 0) + 1
self._h.log("health-failure", plugin_id)
class FakePluginManager:
def __init__(self):
self.plugins: Dict[str, Any] = {}
self.plugin_manifests: Dict[str, Any] = {}
self.plugin_last_update: Dict[str, float] = {}
self.health_tracker = None
self.resource_monitor = None
self.state_manager = None
self.plugin_executor = PluginExecutor()
self.no_lock: set = set()
self._locks: Dict[str, threading.Lock] = {}
self.hangs: List[str] = []
def discover_plugins(self):
return []
def discovered_plugin_ids(self):
return set(self.plugins)
def load_plugin(self, plugin_id, force_enabled=False):
return False
def get_plugin(self, plugin_id):
return self.plugins.get(plugin_id)
def unload_plugin(self, plugin_id):
self.plugins.pop(plugin_id, None)
return True
def get_plugin_lock(self, plugin_id):
if plugin_id in self.no_lock:
return None # as when loading failed part-way
return self._locks.setdefault(plugin_id, threading.Lock())
def record_display_hang(self, plugin_id, seconds):
self.hangs.append(plugin_id)
def note_display_duration(self, plugin_id, seconds):
pass
def run_scheduled_updates(self):
pass
def run_scheduled_updates_with_changes(self):
return []
def stop_update_worker(self):
pass
class FakePlugin:
"""A plugin whose answers are functions of the harness clock.
Args:
plugin_id: The plugin id.
modes: Its display modes, registered in this order.
duration: get_display_duration().
content: ``content(t, mode) -> bool``: what display() returns.
Defaults to always True.
live: ``(start, end)`` seconds during which has_live_content() is
True; get_live_modes() then names its modes ending in ``_live``.
live_priority: has_live_priority().
dynamic: Enables dynamic duration. Keys: ``cap`` (the plugin's cap),
``cycle`` (get_cycle_duration()), ``complete_after`` (seconds
after reset_cycle_state() that is_cycle_complete() turns True;
None means never).
needs_high_fps / enable_scrolling: Set as attributes only when given,
since run() tests for their presence.
raises: display() raises RuntimeError.
first_frame_only: display() returns True on a screen's first frame
and False on every later one.
"""
def __init__(self, plugin_id: str, modes: List[str], duration: float = 30,
content: Optional[Callable[[float, str], bool]] = None,
live: Optional[Tuple[float, float]] = None,
live_priority: bool = False,
dynamic: Optional[Dict[str, Any]] = None,
needs_high_fps: Optional[bool] = None,
enable_scrolling: Optional[bool] = None,
raises: bool = False,
first_frame_only: bool = False):
self.plugin_id = plugin_id
self.modes = list(modes)
self.duration = duration
self.content = content
self.live = live
self.live_priority = live_priority
self.dynamic = dynamic
self.raises = raises
self.first_frame_only = first_frame_only
if needs_high_fps is not None:
self.needs_high_fps = needs_high_fps
if enable_scrolling is not None:
self.enable_scrolling = enable_scrolling
self._h: Optional["RunLoopHarness"] = None
self._reset_at: Optional[float] = None
# -- display -----------------------------------------------------------
def display(self, display_mode=None, force_clear=False):
assert self._h is not None
return self._h.on_display(self, display_mode or self.modes[0], force_clear)
def get_display_duration(self):
return self.duration
# -- live --------------------------------------------------------------
def _is_live(self) -> bool:
if not self.live or self._h is None:
return False
t = self._h.clock.rel()
return self.live[0] <= t < self.live[1]
def has_live_priority(self):
return self.live_priority
def has_live_content(self):
return self._is_live()
def get_live_modes(self):
return [m for m in self.modes if m.endswith("_live")]
# -- dynamic duration ----------------------------------------------------
def supports_dynamic_duration(self):
return bool(self.dynamic)
def get_dynamic_duration_cap(self):
return (self.dynamic or {}).get("cap")
def get_cycle_duration(self, display_mode=None):
return (self.dynamic or {}).get("cycle")
def reset_cycle_state(self):
assert self._h is not None
self._reset_at = self._h.clock.rel()
self._h.log("cycle-reset", self.plugin_id)
def is_cycle_complete(self):
if not self.dynamic:
return True
after = self.dynamic.get("complete_after")
if after is None or self._reset_at is None or self._h is None:
return False
done = self._h.clock.rel() - self._reset_at >= after
if done:
self._h.log("cycle-complete", self.plugin_id, quiet=True)
return done
class LegacyFakePlugin(FakePlugin):
"""display() without a display_mode parameter, as older plugins have."""
def display(self, force_clear=False): # type: ignore[override]
assert self._h is not None
return self._h.on_display(self, self.modes[0], force_clear)
class FakeVegas:
"""The coordinator contract DisplayController relies on, nothing more.
run_iteration() renders frames at 125 Hz on the fake clock for
``cycle`` seconds and returns True, or returns False as soon as the
interrupt checker (every 10 frames) or the live-priority checker (every
0.25 s) asks it to yield -- the same cadence the real coordinator uses.
A live-priority pause is lifted by the next call, as in the real one.
"""
FRAME = 1.0 / 125
INTERRUPT_EVERY = 10
LIVE_EVERY = 0.25
def __init__(self, harness: "RunLoopHarness", cycle: float = 30.0,
live_in_ticker: bool = False):
self._h = harness
self.cycle = cycle
self.is_enabled = True
self.vegas_config = SimpleNamespace(live_in_ticker=live_in_ticker)
self.render_pipeline = None
self._interrupt: Optional[Callable[[], bool]] = None
self._live: Optional[Callable[[], Any]] = None
self._paused_for_live = False
def set_live_priority_checker(self, fn):
self._live = fn
def set_interrupt_checker(self, fn, check_interval=10):
self._interrupt = fn
def apply_pending_config_if_idle(self):
pass
def cleanup(self):
pass
def run_iteration(self) -> bool:
h = self._h
clock = h.clock
if self._paused_for_live:
self._paused_for_live = False
h.log("vegas-start", None, quiet=True)
start = clock.now
last_live = None
frames = 0
while True:
now = clock.now
if (self._live and not self.vegas_config.live_in_ticker
and (last_live is None or now - last_live >= self.LIVE_EVERY)):
last_live = now
if self._live():
self._paused_for_live = True
h.log("vegas-live")
return False
h.log("vegas-frame", None, quiet=True)
clock.sleep(self.FRAME)
frames += 1
if self._interrupt and frames % self.INTERRUPT_EVERY == 0 and self._interrupt():
h.log("vegas-interrupt")
return False
if clock.now - start >= self.cycle:
return True
# ---------------------------------------------------------------------------
# Harness
# ---------------------------------------------------------------------------
#: Events that can end a screen, as they appear in the trace.
REASON_EVENTS = {
"schedule-off", "schedule-on", "live", "live-ended", "on-demand-start",
"on-demand-requested-stop", "on-demand-expired",
"on-demand-no-modes-available", "vegas-live", "vegas-interrupt",
"cycle-complete", "display-false",
}
_SEGMENT_FOR = {
"follower-frame": "<follower>",
"vegas-frame": "<vegas>",
"wifi": "<wifi>",
"blank": "<off>",
}
class RunLoopHarness:
"""Build a DisplayController on fakes, run it, and return its trace."""
def __init__(self, tmp_path: Path, horizon: float):
self.clock = FakeClock(T0, horizon)
self.events: List[Tuple[float, str, Any, Dict[str, Any]]] = []
self.tmp_path = tmp_path
# The controller keeps this very dict as self.config, so a scenario
# can edit what run() reads live (durations, schedules). Values
# __init__ copies out (global_dynamic_config) are set on the
# controller instead.
self.config: Dict[str, Any] = {
"timezone": "UTC",
"display": {"hardware": {"brightness": 90}},
}
self.cache = FakeCache()
self.pm = FakePluginManager()
self.sync = FakeSync(self)
self.dm = self._display_manager()
self._displayed_this_pass = False
self.controller = self._build()
# -- event log -----------------------------------------------------------
def log(self, kind: str, subject: Any = None, quiet: bool = False, **data):
data["quiet"] = quiet
self.events.append((round(self.clock.rel(), 3), kind, subject, data))
def on_display(self, plugin: FakePlugin, mode: str, force_clear: bool):
first = not self._displayed_this_pass
self._displayed_this_pass = True
if plugin.raises:
self.log("first" if first else "frame", mode, quiet=True,
clear=bool(force_clear), result="raised")
raise RuntimeError(f"{plugin.plugin_id} display() failed")
if plugin.first_frame_only:
result = first
elif plugin.content is None:
result = True
else:
result = bool(plugin.content(self.clock.rel(), mode))
self.log("first" if first else "frame", mode, quiet=True,
clear=bool(force_clear), result=result)
return result
# -- construction ----------------------------------------------------------
def _display_manager(self):
dm = MagicMock(name="DisplayManager")
dm.width = 128
dm.height = 32
dm._sync_render_allowed = False
dm.set_brightness = MagicMock(side_effect=self._on_set_brightness)
dm.update_display = MagicMock(side_effect=self._on_update_display)
dm.get_font_height = MagicMock(return_value=8)
return dm
def _on_set_brightness(self, value):
self.log("brightness", value)
return True
def _on_update_display(self):
if getattr(self.dm, "_sync_render_allowed", False):
self.log("follower-frame", None, quiet=True)
elif not self.controller.is_display_active:
self.log("blank", None, quiet=True)
def _build(self):
from src import display_controller as dc_mod
clock = self.clock
env = {"LEDMATRIX_HOT_RELOAD": "false", "EMULATOR": "true"}
with patch.dict(os.environ, env), \
patch.object(dc_mod, "time", clock.time_module()), \
patch.object(dc_mod, "datetime", clock.datetime_class()), \
patch.object(dc_mod, "ConfigManager", MagicMock()), \
patch.object(dc_mod, "ConfigService", lambda **kw: FakeConfigService(self.config)), \
patch.object(dc_mod, "CacheManager", lambda: self.cache), \
patch.object(dc_mod, "DisplayManager", lambda config: self.dm), \
patch.object(dc_mod, "FontManager", MagicMock()), \
patch.object(dc_mod, "DisplaySyncManager", lambda **kw: self.sync), \
patch("src.plugin_system.PluginManager", lambda **kw: self.pm), \
patch("src.error_aggregator.start_error_snapshot_publisher", lambda cm: None), \
patch("src.font_usage.start_font_usage_publisher", lambda *a, **k: None), \
patch("src.plugin_system.plugin_runtime.start_plugin_runtime_publisher",
lambda *a, **k: None), \
patch("src.auto_update_setup.ensure_update_helper", lambda config: None):
controller = dc_mod.DisplayController()
# __init__ wires real health/resource monitors; swap in the fake
# breaker so failures and skips are deterministic.
self.pm.health_tracker = FakeHealthTracker(self)
self.pm.resource_monitor = None
controller.wifi_status_file = self.tmp_path / "wifi_status.json"
self._instrument(controller)
return controller
def _instrument(self, dc) -> None:
"""Log the controller's decisions without changing any of them.
Each wrapper calls straight through to the real method; only methods
that exist both before and after the stage-1 extraction are wrapped,
so the same harness records the same trace from either.
"""
h = self
def wrap(name, before, after):
real = getattr(dc, name)
def wrapper(*args, **kwargs):
token = before(*args, **kwargs)
result = real(*args, **kwargs)
after(token, *args, **kwargs)
return result
setattr(dc, name, wrapper)
wrap("_evaluate_schedule",
lambda: dc.is_display_active,
lambda was: (h.log("schedule-off") if was and not dc.is_display_active
else h.log("schedule-on") if not was and dc.is_display_active
else None))
wrap("_activate_on_demand",
lambda request: None,
lambda _, request: h.log("on-demand-start", request.get("plugin_id"))
if dc.on_demand_active else h.log("on-demand-error", dc.on_demand_last_error))
wrap("_clear_on_demand",
lambda reason=None: dc.on_demand_active,
lambda was, reason=None: h.log(f"on-demand-{reason}") if was else None)
wrap("_apply_live_priority",
lambda mode: dc.current_display_mode,
lambda prev, mode: (None if dc.current_display_mode == prev
else h.log("live" if mode else "live-ended",
dc.current_display_mode)))
real_note = dc._note_empty_pass
def note_empty_pass():
h.log("empty", dc.current_display_mode, quiet=True)
return real_note()
dc._note_empty_pass = note_empty_pass
real_wifi = dc._display_wifi_status_message
def display_wifi(status):
shown = real_wifi(status)
if shown:
h.log("wifi", status.get("message"), quiet=True)
return shown
dc._display_wifi_status_message = display_wifi
# -- scenario setup ------------------------------------------------------
def add_plugin(self, plugin: FakePlugin, lock: bool = True) -> FakePlugin:
"""Register a plugin the way _register_loaded_plugin leaves things."""
dc = self.controller
plugin._h = self
self.pm.plugins[plugin.plugin_id] = plugin
if not lock:
self.pm.no_lock.add(plugin.plugin_id)
dc.plugin_display_modes[plugin.plugin_id] = list(plugin.modes)
for mode in plugin.modes:
if mode not in dc.available_modes:
dc.available_modes.append(mode)
dc.plugin_modes[mode] = plugin
dc.mode_to_plugin_id[mode] = plugin.plugin_id
return plugin
def add_mode_without_plugin(self, mode: str) -> None:
self.controller.available_modes.append(mode)
def on_demand_request(self, t: float, request_id: str, action: str = "start", **fields):
def post():
self.log("request", f"{action}:{request_id}")
self.cache.set("display_on_demand_request",
{"request_id": request_id, "action": action, **fields})
self.clock.at(t, post)
def restore_on_demand(self, plugin_id: str, mode: Optional[str] = None,
duration: Optional[float] = None, pinned: bool = False):
"""Start with an on-demand session resumed from the cache, as after
a restart: the state _select_startup_plugins restores, then
_populate_on_demand_modes_from_plugin, as __init__ calls it."""
dc = self.controller
dc.on_demand_active = True
dc.on_demand_plugin_id = plugin_id
dc.on_demand_mode = mode
dc.on_demand_duration = duration
dc.on_demand_pinned = pinned
dc.on_demand_requested_at = self.clock.now
dc.on_demand_expires_at = self.clock.now + duration if duration else None
dc.on_demand_status = 'active'
dc.on_demand_schedule_override = True
dc._populate_on_demand_modes_from_plugin()
def wifi_message(self, t: float, message: str, duration: float = 5):
def write():
self.log("wifi-file", message)
self.controller.wifi_status_file.write_text(json.dumps(
{"message": message, "timestamp": self.clock.now, "duration": duration}),
encoding="utf-8")
self.clock.at(t, write)
def enable_vegas(self, cycle: float = 30.0, live_in_ticker: bool = False) -> FakeVegas:
"""Install FakeVegas, wired up as _initialize_vegas_mode wires the real one."""
dc = self.controller
vegas = FakeVegas(self, cycle=cycle, live_in_ticker=live_in_ticker)
vegas.set_live_priority_checker(dc._check_live_priority)
vegas.set_interrupt_checker(
lambda: dc._check_vegas_interrupt() or dc.sync_manager.is_follower_active(),
check_interval=10)
dc.vegas_coordinator = vegas
return vegas
# -- running -------------------------------------------------------------
def run(self) -> Dict[str, Any]:
from src import display_controller as dc_mod
from src import display_watchdog
clock = self.clock
watchdog = display_watchdog.watchdog
real_loop_pass = watchdog.loop_pass
def loop_pass():
self._displayed_this_pass = False
self.log("pass", None, quiet=True)
clock.passes_since_advance += 1
if clock.passes_since_advance > SPIN_LIMIT:
raise SpinError(f"run() spun {SPIN_LIMIT} passes at t={clock.rel():.3f}")
return real_loop_pass()
with patch.object(dc_mod, "time", clock.time_module()), \
patch.object(dc_mod, "datetime", clock.datetime_class()), \
patch.object(watchdog, "loop_pass", loop_pass):
try:
self.controller.run()
except StopRun:
pass
else:
# run() only returns after catching something itself.
raise AssertionError(
f"run() returned at t={clock.rel():.3f} before the horizon")
return reduce_trace(self.events, round(clock.horizon - clock.start, 3))
def reduce_trace(events, horizon: float) -> Dict[str, Any]:
"""Fold the event log into screens and the notable events."""
screens: List[Dict[str, Any]] = []
notable: List[List[Any]] = []
cur: Optional[Dict[str, Any]] = None
# What happened in the current loop pass, for attributing an empty pass.
shown_this_pass = False
failed_this_pass = False
breaker_this_pass = False
def start(t, mode, clear=None):
nonlocal cur
cur = {"t": t, "mode": mode, "frames": 0, "clear": clear, "exit": None}
screens.append(cur)
for t, kind, subject, data in events:
if not data.get("quiet"):
notable.append([t, kind] + ([subject] if subject is not None else []))
if kind == "pass":
shown_this_pass = failed_this_pass = breaker_this_pass = False
elif kind in ("first", "frame"):
if kind == "first" or cur is None:
start(t, subject, data["clear"])
shown_this_pass = True
cur["result"] = data["result"]
cur["frames"] += 1
if kind == "frame" and data["result"] is False and cur["exit"] is None:
cur["exit"] = "display-false"
elif kind in _SEGMENT_FOR:
segment = _SEGMENT_FOR[kind]
if cur is None or cur["mode"] != segment or cur["exit"] is not None:
start(t, segment)
cur["frames"] += 1
elif kind == "vegas-start":
start(t, "<vegas>")
elif kind == "health-failure":
failed_this_pass = True
elif kind == "breaker-open":
breaker_this_pass = True
elif kind == "empty":
if shown_this_pass and cur is not None and cur["exit"] is None:
# display() ran and had nothing (False) or raised.
cur["exit"] = "raised" if cur.get("result") == "raised" else "empty"
else:
# Never reached display(): no plugin, the breaker is open, or
# the dispatch itself raised.
start(t, subject)
cur["exit"] = ("error" if failed_this_pass
else "breaker" if breaker_this_pass else "no-plugin")
elif kind in REASON_EVENTS and cur is not None and cur["exit"] is None:
cur["exit"] = kind
rows = []
for i, screen in enumerate(screens):
end = screens[i + 1]["t"] if i + 1 < len(screens) else horizon
exit_reason = screen["exit"] or ("horizon" if i + 1 == len(screens) else "duration")
rows.append([screen["t"], screen["mode"], round(end - screen["t"], 3),
exit_reason, screen["frames"], screen["clear"]])
return {"screens": rows, "events": notable}
# ---------------------------------------------------------------------------
# Golden files
# ---------------------------------------------------------------------------
def dump_golden(trace: Dict[str, Any]) -> str:
"""One screen or event per line, so a diff points at the row that moved."""
def block(name, rows, last=False):
end = "" if last else ","
if not rows:
return [f' "{name}": []{end}']
return [f' "{name}": [',
",\n".join(" " + json.dumps(row) for row in rows),
f" ]{end}"]
lines = (["{"] + block("screens", trace["screens"])
+ block("events", trace["events"], last=True) + ["}"])
return "\n".join(lines) + "\n"
def check_golden(name: str, trace: Dict[str, Any]) -> None:
"""Compare against test/fixtures/run_loop_golden/<name>.json.
LEDMATRIX_REGEN_GOLDEN=1 rewrites the file instead. Only do that for a
deliberate behaviour change, and say why in the commit.
"""
path = GOLDEN_DIR / f"{name}.json"
text = dump_golden(trace)
if os.environ.get("LEDMATRIX_REGEN_GOLDEN") == "1":
GOLDEN_DIR.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8", newline="\n")
return
assert path.exists(), f"no golden trace {path}; run with LEDMATRIX_REGEN_GOLDEN=1"
expected = json.loads(path.read_text(encoding="utf-8"))
actual = json.loads(text)
if actual != expected:
import difflib
diff = "\n".join(difflib.unified_diff(
dump_golden(expected).splitlines(), text.splitlines(),
"golden", "actual", lineterm="", n=2))
raise AssertionError(f"run() trace for {name!r} changed:\n{diff}")
+14
View File
@@ -0,0 +1,14 @@
{
"screens": [
[0.0, "a", 0.0, "empty", 1, false],
[0.0, "b", 0.0, "empty", 1, true],
[0.0, "c", 1.0, "empty", 1, true],
[1.0, "a", 1.0, "empty", 1, true],
[2.0, "b", 1.0, "empty", 1, true],
[3.0, "c", 1.0, "empty", 1, true],
[4.0, "a", 1.0, "empty", 1, true],
[5.0, "b", 1.0, "empty", 1, true],
[6.0, "c", 6.0, "horizon", 6, true]
],
"events": []
}
+24
View File
@@ -0,0 +1,24 @@
{
"screens": [
[0.0, "scroller", 20.008, "cycle-complete", 2502, false],
[20.008, "news", 40.0, "duration", 40, true],
[60.008, "board", 11.0, "cycle-complete", 12, true],
[71.008, "clock", 10.0, "duration", 10, true],
[81.008, "scroller", 20.007, "cycle-complete", 2502, true],
[101.015, "news", 40.0, "duration", 40, true],
[141.015, "board", 11.0, "cycle-complete", 12, true],
[152.015, "clock", 10.0, "duration", 10, true],
[162.015, "scroller", 20.008, "cycle-complete", 2502, true],
[182.023, "news", 37.977, "horizon", 38, true]
],
"events": [
[0.0, "cycle-reset", "scroller"],
[20.008, "cycle-reset", "news"],
[60.008, "cycle-reset", "board"],
[81.008, "cycle-reset", "scroller"],
[101.015, "cycle-reset", "news"],
[141.015, "cycle-reset", "board"],
[162.015, "cycle-reset", "scroller"],
[182.023, "cycle-reset", "news"]
]
}
+22
View File
@@ -0,0 +1,22 @@
{
"screens": [
[0.0, "clock", 10.0, "duration", 10, false],
[10.0, "empty", 0.0, "empty", 1, true],
[10.0, "ghost", 0.0, "no-plugin", 0, null],
[10.0, "flaky", 12.0, "display-false", 2, true],
[22.0, "clock", 10.0, "duration", 10, true],
[32.0, "empty", 0.0, "empty", 1, true],
[32.0, "ghost", 0.0, "no-plugin", 0, null],
[32.0, "flaky", 12.0, "display-false", 2, true],
[44.0, "clock", 10.0, "duration", 10, true],
[54.0, "empty", 0.0, "empty", 1, true],
[54.0, "ghost", 0.0, "no-plugin", 0, null],
[54.0, "flaky", 12.0, "display-false", 2, true],
[66.0, "clock", 10.0, "duration", 10, true],
[76.0, "empty", 0.0, "empty", 1, true],
[76.0, "ghost", 0.0, "no-plugin", 0, null],
[76.0, "flaky", 12.0, "display-false", 2, true],
[88.0, "clock", 2.0, "horizon", 2, true]
],
"events": []
}
+10
View File
@@ -0,0 +1,10 @@
{
"screens": [
[0.0, "clock", 20.0, "duration", 20, false],
[20.0, "weather", 20.0, "duration", 20, true],
[40.0, "<follower>", 10.017, "duration", 601, null],
[50.017, "clock", 20.0, "duration", 20, true],
[70.017, "weather", 9.983, "horizon", 10, true]
],
"events": []
}
+19
View File
@@ -0,0 +1,19 @@
{
"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]
],
"events": [
[60.0, "live", "sports_live"],
[120.0, "live-ended", "sports_recent"]
]
}
+20
View File
@@ -0,0 +1,20 @@
{
"screens": [
[0.0, "nfl_live", 15.0, "duration", 15, true],
[15.0, "nfl_live", 15.0, "live", 15, false],
[30.0, "nhl_live", 15.0, "live", 15, true],
[45.0, "nfl_live", 15.0, "live", 15, true],
[60.0, "nhl_live", 15.0, "duration", 15, true],
[75.0, "nhl_live", 15.0, "duration", 15, false],
[90.0, "nhl_live", 15.0, "duration", 15, false],
[105.0, "clock", 15.0, "duration", 15, true],
[120.0, "nfl_live", 15.0, "duration", 15, true],
[135.0, "nhl_live", 15.0, "horizon", 15, true]
],
"events": [
[0.0, "live", "nfl_live"],
[30.0, "live", "nhl_live"],
[45.0, "live", "nfl_live"],
[60.0, "live", "nhl_live"]
]
}
+30
View File
@@ -0,0 +1,30 @@
{
"screens": [
[0.0, "clock", 20.0, "duration", 20, false],
[20.0, "weather", 5.0, "on-demand-start", 6, true],
[25.0, "sports_recent", 15.0, "duration", 15, true],
[40.0, "sports_upcoming", 15.0, "duration", 15, true],
[55.0, "sports_recent", 15.0, "duration", 15, true],
[70.0, "sports_upcoming", 15.0, "duration", 15, true],
[85.0, "sports_recent", 10.0, "on-demand-requested-stop", 11, true],
[95.0, "weather", 20.0, "duration", 20, true],
[115.0, "sports_recent", 15.0, "duration", 15, true],
[130.0, "sports_upcoming", 15.0, "duration", 15, true],
[145.0, "clock", 5.0, "on-demand-start", 6, true],
[150.0, "weather", 20.0, "duration", 20, true],
[170.0, "weather", 10.0, "on-demand-expired", 10, true],
[180.0, "clock", 20.0, "duration", 20, true],
[200.0, "weather", 20.0, "duration", 20, true],
[220.0, "sports_recent", 15.0, "duration", 15, true],
[235.0, "sports_upcoming", 5.0, "horizon", 5, true]
],
"events": [
[25.0, "request", "start:r1"],
[25.0, "on-demand-start", "sports"],
[95.0, "request", "stop:r2"],
[95.0, "on-demand-requested-stop"],
[150.0, "request", "start:r3"],
[150.0, "on-demand-start", "weather"],
[180.0, "on-demand-expired"]
]
}
+29
View File
@@ -0,0 +1,29 @@
{
"screens": [
[0.0, "clock", 12.0, "on-demand-start", 13, false],
[12.0, "sports_upcoming", 15.0, "duration", 15, true],
[27.0, "sports_upcoming", 15.0, "duration", 15, true],
[42.0, "sports_upcoming", 15.0, "duration", 15, true],
[57.0, "sports_upcoming", 15.0, "duration", 15, true],
[72.0, "sports_upcoming", 8.0, "on-demand-start", 9, true],
[80.0, "app_a", 0.0, "empty", 1, true],
[80.0, "app_b", 10.0, "duration", 10, true],
[90.0, "app_a", 0.0, "empty", 1, true],
[90.0, "app_b", 10.0, "duration", 10, true],
[100.0, "app_a", 0.0, "empty", 1, true],
[100.0, "app_b", 10.0, "duration", 10, true],
[110.0, "app_a", 0.0, "empty", 1, true],
[110.0, "app_b", 10.0, "on-demand-requested-stop", 10, true],
[120.0, "clock", 20.0, "duration", 20, true],
[140.0, "sports_recent", 15.0, "duration", 15, true],
[155.0, "sports_upcoming", 5.0, "horizon", 5, true]
],
"events": [
[12.0, "request", "start:p1"],
[12.0, "on-demand-start", "sports"],
[80.0, "request", "start:p2"],
[80.0, "on-demand-start", "starlark"],
[120.0, "request", "stop:p3"],
[120.0, "on-demand-requested-stop"]
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"screens": [
[0.0, "sports_upcoming", 15.0, "duration", 15, true],
[15.0, "sports_recent", 15.0, "duration", 15, true],
[30.0, "sports_upcoming", 10.0, "on-demand-expired", 10, true],
[40.0, "clock", 20.0, "duration", 20, true],
[60.0, "weather", 20.0, "duration", 20, true],
[80.0, "sports_recent", 15.0, "duration", 15, true],
[95.0, "sports_upcoming", 5.0, "horizon", 5, true]
],
"events": [
[40.0, "on-demand-expired"]
]
}
+17
View File
@@ -0,0 +1,17 @@
{
"screens": [
[0.0, "clock", 15.0, "duration", 15, false],
[15.0, "weather_now", 20.0, "duration", 20, true],
[35.0, "weather_forecast", 20.0, "duration", 20, true],
[55.0, "ticker", 10.008, "duration", 1252, true],
[65.008, "legacy", 5.0, "duration", 5, true],
[70.008, "clock", 15.0, "duration", 15, true],
[85.008, "weather_now", 20.0, "duration", 20, true],
[105.008, "weather_forecast", 20.0, "duration", 20, true],
[125.008, "ticker", 10.008, "duration", 1252, true],
[135.016, "legacy", 5.0, "duration", 5, true],
[140.016, "clock", 15.0, "duration", 15, true],
[155.016, "weather_now", 4.984, "horizon", 5, true]
],
"events": []
}
+27
View File
@@ -0,0 +1,27 @@
{
"screens": [
[0.0, "clock", 10.0, "duration", 10, false],
[10.0, "broken_a", 0.0, "error", 0, null],
[10.0, "weather", 10.0, "duration", 10, true],
[20.0, "crashy", 0.0, "raised", 1, true],
[20.0, "clock", 10.0, "duration", 10, true],
[30.0, "broken_a", 0.0, "error", 0, null],
[30.0, "weather", 10.0, "duration", 10, true],
[40.0, "crashy", 0.0, "raised", 1, true],
[40.0, "clock", 10.0, "duration", 10, true],
[50.0, "broken_a", 0.0, "breaker", 0, null],
[50.0, "broken_b", 0.0, "breaker", 0, null],
[50.0, "weather", 10.0, "duration", 10, true],
[60.0, "crashy", 0.0, "raised", 1, true],
[60.0, "clock", 10.0, "duration", 10, true],
[70.0, "broken_a", 0.0, "breaker", 0, null],
[70.0, "broken_b", 0.0, "breaker", 0, null],
[70.0, "weather", 10.0, "duration", 10, true],
[80.0, "crashy", 0.0, "raised", 1, true],
[80.0, "clock", 10.0, "horizon", 10, true]
],
"events": [
[10.0, "health-failure", "broken"],
[30.0, "health-failure", "broken"]
]
}
+31
View File
@@ -0,0 +1,31 @@
{
"screens": [
[0.0, "clock", 20.0, "duration", 20, false],
[20.0, "weather", 20.0, "duration", 20, true],
[40.0, "clock", 20.0, "duration", 20, true],
[60.0, "weather", 20.0, "duration", 20, true],
[80.0, "clock", 20.0, "duration", 20, true],
[100.0, "weather", 20.0, "duration", 20, true],
[120.0, "clock", 20.0, "duration", 20, true],
[140.0, "weather", 10.0, "schedule-off", 11, true],
[150.0, "<off>", 20.0, "on-demand-start", 2, null],
[170.0, "weather", 20.0, "on-demand-expired", 20, true],
[190.0, "weather", 20.0, "schedule-off", 20, true],
[210.0, "<off>", 120.0, "schedule-on", 2, null],
[330.0, "clock", 20.0, "duration", 20, true],
[350.0, "weather", 20.0, "duration", 20, true],
[370.0, "clock", 20.0, "duration", 20, true],
[390.0, "weather", 10.0, "horizon", 10, true]
],
"events": [
[30.0, "brightness", 30],
[150.0, "schedule-off"],
[170.0, "request", "start:s1"],
[170.0, "on-demand-start", "weather"],
[170.0, "schedule-on"],
[170.0, "brightness", 90],
[190.0, "on-demand-expired"],
[210.0, "schedule-off"],
[330.0, "schedule-on"]
]
}
+26
View File
@@ -0,0 +1,26 @@
{
"screens": [
[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],
[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],
[170.263, "clock", 5.0, "on-demand-expired", 5, true],
[175.263, "<vegas>", 24.959, "vegas-interrupt", 3120, null],
[200.222, "clock", 20.0, "duration", 20, true],
[220.222, "<vegas>", 30.008, "duration", 3751, null],
[250.23, "<vegas>", 9.77, "horizon", 1222, null]
],
"events": [
[70.255, "vegas-live"],
[150.0, "request", "start:v1"],
[150.263, "on-demand-start", "clock"],
[150.263, "vegas-interrupt"],
[175.263, "on-demand-expired"],
[200.0, "wifi-file", "Connected to HomeNet"],
[200.222, "vegas-interrupt"]
]
}
@@ -0,0 +1,9 @@
{
"screens": [
[0.0, "<vegas>", 30.008, "duration", 3751, null],
[30.008, "<vegas>", 30.007, "duration", 3751, null],
[60.015, "<vegas>", 30.008, "duration", 3751, null],
[90.023, "<vegas>", 9.977, "horizon", 1248, null]
],
"events": []
}
+19
View File
@@ -0,0 +1,19 @@
{
"screens": [
[0.0, "clock", 20.0, "duration", 20, false],
[20.0, "weather", 20.0, "duration", 20, true],
[40.0, "clock", 20.0, "on-demand-start", 20, true],
[60.0, "clock", 20.0, "on-demand-expired", 20, true],
[80.0, "<wifi>", 15.0, "duration", 30, null],
[95.0, "weather", 20.0, "duration", 20, true],
[115.0, "clock", 20.0, "duration", 20, true],
[135.0, "weather", 15.0, "horizon", 15, true]
],
"events": [
[25.0, "wifi-file", "Connected to HomeNet"],
[60.0, "request", "start:w1"],
[60.0, "on-demand-start", "clock"],
[65.0, "wifi-file", "AP mode on"],
[80.0, "on-demand-expired"]
]
}
+220
View File
@@ -0,0 +1,220 @@
"""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, which reports False:
# an empty pass ("raised"), not a failure, so its modes are not skipped.
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]