mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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": []
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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": []
|
||||
}
|
||||
@@ -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"]
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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"]
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"]
|
||||
]
|
||||
}
|
||||
@@ -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]
|
||||
Reference in New Issue
Block a user