CLAUDE.md (#580) says to read display_manager.width/height because matrix is None when hardware init fails; the development guide, the safety-harness doc and two DisplayManager docstrings still recommended matrix.width/height. The bundled starlark-apps plugin read matrix.width unguarded, so its magnify recommendation and frame scaling raised in fallback mode (e.g. after the Pi 5 hardware refusal). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5.7 KiB
Plugin Safety Harness
Renders a plugin across every declared screen (mode) and a spread of matrix sizes, and fails if any combination crashes, draws past the panel edge, or — for plugins that ship golden images — drifts visually. The goal: change a plugin without breaking a size or screen you didn't think to test.
Sizes: a sample, not a fixed list
There is no fixed set of supported panel sizes — an RGB matrix build can be
any width/height and configuration (square, rectangle, 2×2, 4×4, 8×2, long
strips, tall stacks). Plugins are expected to read dimensions dynamically
(self.display_manager.width/height — not matrix.width/height, since
matrix is None when hardware init fails) and lay themselves out
accordingly, so a hardcoded coordinate or unscaled font shows up as a failure
here.
The harness therefore renders against a representative sample that spans the
axes of variation (DEFAULT_TEST_SIZES in src/plugin_system/testing/sizes.py),
not an authoritative list:
Each module is 64×32; entries are real panel-grid arrangements (cols × rows):
| Size | Grid | Why it's in the sample |
|---|---|---|
| 64×32 | 1×1 | single panel — tightest common rectangle |
| 128×32 | 2×1 | the baseline most plugins are tuned for |
| 64×64 | 1×2 | stacked — tall-narrow centering |
| 128×64 | 2×2 | block — icon scaling / vertical centering |
| 256×32 | 4×1 | long strip — wide horizontal layout |
| 128×96 | 2×3 | tall — vertical overflow |
| 256×128 | 4×4 | large block — both dimensions big at once |
Override the sizes entirely to test your actual hardware (or any shape):
# CLI — one-off:
python scripts/check_plugin.py --plugin clock-simple --sizes 8x16,64x64,256x32
# pytest — force every plugin onto your panel(s):
LEDMATRIX_TEST_SIZES="8x16,128x128" pytest test/plugins/test_plugin_matrix.py
# Per-plugin — declare the shapes a plugin targets in its test/harness.json:
# { "sizes": [[8, 16], [64, 64]] }
Precedence: LEDMATRIX_TEST_SIZES env (global) → per-plugin harness.json
sizes → the default sample. Bounds checking adapts to whatever sizes a run
uses — the backing canvas is padded out to the largest panel in the run, so
a coordinate meant for a big build is still caught when rendering a small one.
Quick start
# Functional + bounds check across all sizes/screens:
python scripts/check_plugin.py --plugin clock-simple
# Every discovered plugin:
python scripts/check_plugin.py --all
# Dump PNGs to eyeball each size/screen:
python scripts/check_plugin.py --plugin ledmatrix-weather --out-dir /tmp/preview
Exit code is non-zero if any (plugin, size, screen) fails. Plugins are
discovered in plugin-repos/ and plugins/ (override with --plugin-dir).
What it checks (Phase 1 — always on)
- Loads and builds its mode list.
- Renders every screen at every size without raising.
update()may fail (no network in CI) and is tolerated; a crash indisplay()is a failure —display()must handle the no-data state. - Bounds: nothing is drawn past the right/bottom edge. Implemented by
BoundsCheckingDisplayManager, which backs the declared panel with an oversized canvas and flags any pixels that land in the margin. (Left/top overflow at negative coordinates and BDF text are not flagged — golden images cover those.)
Golden images (Phase 2 — opt-in per plugin)
A plugin opts in by committing reference PNGs and (usually) a small harness spec:
plugins/<id>/test/harness.json # how to render deterministically
plugins/<id>/test/fixtures/mock.json # optional cached data
plugins/<id>/test/golden/<WxH>/<mode>.png
test/harness.json keys (all optional):
{
"config": { "timezone": "UTC" },
"mock_data": "fixtures/mock.json",
"freeze_time": "2025-08-01 15:25:00",
"skip_update": false,
"sizes": [[128, 32], [128, 64]]
}
Generate / refresh goldens after an intentional visual change, then review the diff before committing:
python scripts/check_plugin.py --plugin clock-simple --update-golden \
--config '{"timezone":"UTC"}' --freeze-time "2025-08-01 15:25:00"
Comparison is exact by default (compare_images in harness.py accepts a
tolerance for known anti-aliasing noise). Determinism requires a pinned Pillow
and the bundled fonts — keep both stable when regenerating goldens.
Tests & CI
test/plugins/test_harness.py— unit tests for bounds detection, image comparison, and mode enumeration (run anywhere).test/plugins/test_plugin_matrix.py— parametrized over discovered plugins × sizes × screens; honors each plugin'stest/harness.jsonand goldens. Skips when no plugins are present (e.g. a fresh core checkout); setLEDMATRIX_REQUIRE_PLUGINS=1in a pipeline where plugins must be present to turn an empty discovery into a hard failure instead. Point it at the monorepo withLEDMATRIX_PLUGINS_DIR=/path/to/ledmatrix-plugins/plugins..github/workflows/test.yml— runs the harness + visual tests on every PR.
The plugin monorepo has its own Plugin Safety workflow that runs this harness
against changed plugins on every PR.
Developer workflow
- Change the plugin on a branch.
python scripts/check_plugin.py --plugin <id> --out-dir /tmp/previewand eyeball the PNGs.- Intentional visual change?
--update-golden, review diffs, commit goldens. - (Monorepo) bump
manifest.jsonversion and let the pre-commit hook syncplugins.json. - Push — CI re-runs the harness across all sizes and gates the PR.