Compare commits

..
Author SHA1 Message Date
ChuckBuildsandClaude Sonnet 5 aba04755cf Fix two CodeRabbit-flagged test assertions in vegas density tests
test_prepared_group_is_used_without_refetching had a tautological final
assertion; now checks stream.calls directly. test_no_partial_letter_at_either_edge
required both crop edges to be blank, but the left edge here is always the
crop's start position with no lead-in gap in word_strip, so it legitimately
carries ink — only the right edge is an actual cut and needs the check.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
2026-07-31 09:26:54 -04:00
ChuckBuildsandClaude d14381980a Stop Vegas mode showing last night's games as if they were live
A game that was live in the evening was still being drawn as live the next
morning. Two faults combined to freeze plugin visuals indefinitely.

PR #291 added a call to plugin_adapter.invalidate_plugin_scroll_cache() so
a plugin's own cached scroll image would be rebuilt from fresh data. That
method was never implemented. hot_swap_content() wraps the call in a broad
except, so every hot swap has raised AttributeError and been swallowed
silently ever since — which is why the visuals it was meant to keep fresh
never were.

Continuous scrolling then removed the only path that reached it at all:
should_recompose() and hot_swap_content() are called from the
non-continuous branch of run_frame(), and continuous_scroll defaults to
True. So on a default install the pending-update flags were set by the
update tick, never consumed, and grew without bound.

Together these froze content completely, because refetching is not enough
on its own: the sports plugins' get_vegas_content() regenerates only "if
the cache is empty", so take_next_group() kept receiving the same picture
however often it asked.

Fixed by:

- Implementing invalidate_plugin_scroll_cache(). It covers both layouts —
  a helper directly on the plugin (stocks, news, odds-ticker) and one
  owned by a scroll-display manager (the sports scoreboards, which is the
  shape that produced this bug) — and clears cached_image and
  cached_array together, since the array is the image's numpy mirror.

- Adding StreamManager.invalidate_pending_updates() and calling it from
  the continuous branch. It only drops the caches; the plugin recomposes
  when it next comes round in the rotation. process_updates() is wrong
  here: it refetches synchronously and merges into the active buffer that
  continuous mode bypasses, and hot_swap_content() rebuilds and
  repositions the whole strip, which is the freeze-and-jump this mode
  exists to avoid.

Tests assert the fix rather than the implementation: 14 of the 17 new
tests fail without it. Includes the wiring itself, since the regression
was a call that was simply absent, and a check that the scroll position is
untouched so this cannot regress into the swap's visible jump.

All Vegas suites pass (355 tests). test_display_controller_vegas_tick.py
still cannot be collected off-device for want of rgbmatrix, identically
with and without this change.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
2026-07-30 10:28:54 -04:00
ChuckBuildsandClaude a413849891 Add overflow handling: keep ordered content whole instead of rotating a window
The width budget split any oversized plugin by advancing a window each cycle.
That is right for interchangeable items — news headlines, odds, stock prices —
but wrong for ordered content: a league table showed ranks 1-6, then resumed at
7 two rotations later, which reads as out of order and out of context. Nobody
needs rank 23 in a ticker; they need the top of the table, every time.

overflow_mode chooses between them:

  rotate   — advance a window each cycle so everything is seen eventually
             (unchanged default)
  truncate — always show the start and drop the rest, keeping ordered content
             coherent. Records no window state, so every pass starts at the top.

Per-plugin vegas_overflow overrides the global setting, since one install has
both kinds of plugin. Also adds per-plugin vegas_max_width_screens, so content
that must stay whole can be given more room — or uncapped with 0 — without
lifting the cap on every ticker.

Applied on the test rig: f1-scoreboard and ledmatrix-leaderboard set to
truncate, and baseball given 4.5 screens because it was showing 8 of 9 games
when the whole slate needed only a little more room. Verified: F1 now reports
"the first 10 of 116 ... the rest are not shown", baseball has dropped out of
the budget log entirely, and stocks, odds-ticker and stock-news still rotate.

Also corrects the crop log, which claimed "window advances next cycle"
unconditionally and so misreported truncated crops. A test now pins the
behaviour behind the message: truncate must leave no offset recorded.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
2026-07-29 15:40:37 -04:00
ChuckBuildsandClaude 7b6b7cd153 Sub-pixel scrolling: motion at the frame rate, not the pixel rate
With integer positioning the number of distinct frames per second equals the
scroll speed in px/s, however fast the loop renders. Measured at 50px/s and
78.7fps, 36% of frames were byte-identical: the extra frames cost work and
bought no motion, and what was left was 50 discrete 1px steps a second.

Two things were wrong with the pre-existing sub-pixel support. get_visible_portion
never consulted sub_pixel_scrolling — it always took the integer path, so the flag
and _get_visible_portion_subpixel were dead code. And that implementation needed
scipy.ndimage.shift, which is not installed on the target devices (HAS_SCIPY is
False there), so it would not have interpolated even if reached. Verified both:
positions 1000.0 and 1000.5 produced identical frames either way.

Blending is now wired up and implemented with numpy. Two details make it
affordable: slice cached_array directly instead of building two PIL images only
to convert them straight back (the naive version measured 15x the integer path),
and use fixed-point uint16 multiply-add rather than float32, which suits the Pi's
cores and gives finer weighting than the panel can resolve. Result 0.939ms
against 0.237ms — 0.70ms added per frame, a 1065fps ceiling.

Measured on hardware: 81.2 fps with blending on, against 78.7 with it off, so no
cost within noise — and every frame is now a distinct position rather than one in
three being a repeat.

The trade is a slight horizontal softening of text, since each frame blends two
positions. Set smooth_scroll false for maximum crispness.

Also benchmarked and cleared as non-issues: extending the strip costs 9.4ms on an
11,000px strip and trimming 2.5ms, both under one frame at this rate.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
2026-07-29 15:14:41 -04:00
ChuckBuildsandClaude 932e89ccb8 Pace the Vegas frame loop adaptively: 31.5 -> 78.7 fps
The loop slept a fixed frame_interval on top of however long the frame took, so
at a measured 31.6ms per frame a flat 8ms of that was pure idle — a quarter of
the budget spent not rendering. It now sleeps only the remainder of the budget.

Measured on hardware: 31.5 fps to 78.7 fps sustained, with CPU going *down* from
150% to 127%. Scroll speed is unchanged at 49.9px/s against a configured 50,
because motion is derived from elapsed time rather than frame count — this buys
smoothness, not speed.

Worth recording what the bottleneck was not: the per-frame render path measures
0.34ms in total (0.18ms for the numpy slice, 0.17ms for the dirty-tracking
digest), which is a theoretical 2900 fps. Optimising any of that would have been
wasted effort. The frame was idle, not busy.

Also nices the prefetch thread. Its work is PIL and numpy that releases the GIL,
so the scheduler can act on the priority, and without it the prefetch competes
for the same cores as the render loop.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
2026-07-29 14:39:16 -04:00
ChuckBuildsandClaude 94032e2101 Vegas mode: one continuous strip instead of swapping cycles
A cycle used to be a discrete strip that got replaced: motion stopped, every
pixel was substituted at once, and the next group started with the viewport
already full. That is the freeze, the flash and the jump.

The strip is now extended rather than replaced. ScrollHelper gains
append_content(), which adds items on the right without touching
scroll_position or total_distance_scrolled, so motion continues and the next
group simply arrives from the right. Because completion is measured against
total_scroll_width, extending also defers completion — there is no longer a
cycle boundary to see.

drop_scrolled_prefix() reclaims what has gone past, keeping the strip bounded
however long Vegas runs (observed 5,000-11,000px against an unbounded strip
otherwise). It shifts total_distance_scrolled and total_scroll_width together so
the completion arithmetic is unchanged, and refuses to run while the viewport is
wrapping: wrapping reads the head of the strip into the right of the frame, so
trimming the head there would visibly change the picture. A test caught that.

Groups are prepared off the render thread. The constraint is that the canvas and
the matrix proxy are process-wide mutable state, so narrowing or capturing
through them from another thread would corrupt the frame the render loop is
pushing. get_content() therefore takes offscreen_only: the background thread uses
only paths that avoid the canvas, and anything needing it is marked and picked up
on the render thread. That puts the expensive work (native renders of leaderboard
and baseball cards, seconds each) in the background and leaves the cheap work
(display capture, 40-600ms) in the foreground.

DisplayManager's capture flag is now thread-local. As a shared flag, a background
capture would have suppressed the render loop's own frame pushes for its
duration, freezing the panel precisely when the point was to avoid a freeze.

Canvas-bound plugins are drained one at a time rather than as a batch: six at
once held the render thread for 1.75s. Drains are also spaced by two seconds
while the lookahead is healthy, since taking them back to back turns one long
stall into a run of short ones. When the strip is genuinely running short the
throttle is ignored, because content matters more than smoothness there.

Measured on hardware: zero cycle-complete swaps, drains landing 2-4s apart,
lookahead holding at 1,200-3,500px, no errors.

Set continuous_scroll false to restore the swap behaviour; the old path is intact.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
2026-07-29 14:31:24 -04:00
ChuckBuildsandClaude 554fb858af Hold capture_mode for every plugin render, not just narrowed ones
The native content path only entered capture_mode when it was also narrowing
the canvas, so at full width — which is every plugin without a vegas_width_pct
override, i.e. most of them — a plugin calling update_display() while building
its Vegas content wrote straight to the hardware. That is a visible flash
mid-scroll, and it lines up with the flash reported at cycle transitions, when
several plugins are fetched back to back.

Suppression is now unconditional; the narrowing context stays separate because
it is already a no-op at full width.

Both contexts are reached through helpers that degrade to nullcontext when the
display manager lacks them. That matters more than it looks: the adapter's
handlers are deliberately broad, so an AttributeError from a missing context
does not surface as an error — it surfaces as the plugin contributing nothing.
Making the call unconditional without this turned 44 tests red for exactly that
reason, all of them reporting lost content rather than the real cause.

The test double now provides capture_mode and render_size too, so tests
exercise the real contexts instead of silently taking the degraded path.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
2026-07-29 13:33:06 -04:00
ChuckBuildsandClaude a1c528a091 Only cut oversized segments at real gaps between items
The width-budget crop snapped to the nearest blank column, and in rendered text
the gap between two characters is a single column. So a cut routinely landed
inside a word: the cycle showed "Wednesda" and the orphaned "y" turned up as a
lone floating letter in the next cycle, positioned after whatever plugin
happened to precede it.

Measured on the clock-simple segment to confirm: its blank runs are
[1, 1, 1, 1, 1, 8, 8] — five single-column letter gaps, every one of which
find_blank_cut would happily have chosen.

Cuts now only land in a run of at least min_cut_gap blank columns (default 6),
which excludes letter spacing while still finding the gaps plugins put between
items (the stocks ticker uses 32px, baseball 48px). Where no boundary falls
inside the budget the cut waits for the next one and overruns, because
splitting an item is worse than a slightly long segment.

Continuous content is treated differently on purpose: an image with no internal
gaps is a map or a chart, where any column is as good as another, so it is still
cut to the budget exactly. The gap rule protects discrete items; letting a solid
image escape the cap in its name would be wrong.

blank_runs() is vectorised — 48ms for a 17,000px strip, against seconds for a
per-column Python loop.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
2026-07-29 13:19:51 -04:00
ChuckBuildsandClaude 105c6df019 Fix IndexError in find_blank_cut when the cut lands on the image edge
A cut position after the last column is legitimate — _crop_to_budget asks for
min(start + budget, img.width), which equals the width whenever the remaining
strip is shorter than the budget. find_blank_cut clamped target to width but
then walked leftwards starting at target itself, so ink[width] raised
IndexError.

Caught on hardware: it killed the ledmatrix-stocks fetch, and because
_fetch_plugin_content catches broadly that surfaced as the plugin silently
contributing nothing for the cycle.

Only reachable on the second or later pass of the rotating window over a single
oversized image, which is why the existing tests missed it — they all exercised
the first pass, where start is 0 and start + budget is comfortably inside the
image. Added TestRotationAcrossMultipleCycles, which walks the window round
several times and asserts content is never lost, plus direct coverage of
find_blank_cut at and beyond the image edge.

Both bounds now stop at width - 1 so neither direction can index past the end.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
2026-07-29 09:42:51 -04:00
ChuckBuildsandClaude 62919a13e3 Vegas mode: end cycles before the wrap, keep the width budget honest
Three fixes, the first a regression from lead_in_width defaulting to 0.

get_visible_portion wraps: once scroll_position + display_width passes the end
of the strip it fills the right of the frame from the *head* of the same strip.
So the final display_width of travel showed the cycle's first plugin re-entering
on the right while its last plugin exited on the left, and the recompose that
followed replaced both at once. On a 512px panel at 50px/s that was 10.2s of
two plugins on screen at once, ending in a hard cut — reported as the ticker
"switching mid-scroll" from F1 to news.

That used to be invisible because the strip began with a full display_width of
blank, so the wrapped-in region was black. Removing that blank (it was 10s of
dead panel per cycle) exposed the wrap. Cycles now end one display width
earlier, before any wrapped content appears, clamped for strips no wider than
the display so they don't complete instantly and spin the recompose loop.

Verified on hardware: a 3936px strip now completes at 68.5s, exactly
(3936 - 512) / 50.

Second, auto_trim=False also skipped the width budget, which is an unrelated
concern — turning off margin cropping should not let one plugin hold the panel
for minutes. Seen in the field: the F1 scoreboard contributed 116 images and
14,848px untouched, giving a 33,821px cycle (11 minutes of content). The budget
now applies regardless of trimming; with it restored that cycle is 6,362px.

Third, the budget accounted for row gaps using the flat intra_plugin_gap while
the compositor had moved to measured separation, so it under-counted by up to
(min_content_separation - intra_plugin_gap) per row and a many-row plugin
overran its cap. Both now use the same separation_gap() rule, and a test
asserts the composed block fits the budget end to end rather than trusting the
two paths to agree.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
2026-07-29 09:35:23 -04:00
ChuckBuildsandClaude 616d21c6d3 Vegas mode: render plugins narrower, space rows by measured separation
Trimming reclaims blank margins but cannot compact a layout that genuinely
spans the display — a five-column forecast, a progress bar drawn at 100%
width, a stat block with the panel's whole width between its elements. Those
need the plugin to make different layout decisions, which means telling it the
screen is narrower while it renders.

DisplayManager.render_size() presents a smaller logical canvas for the
duration of a Vegas content fetch, reusing the same _LogicalMatrix
indirection double-sided mode already relies on so plugins see a consistent
size from every accessor. Plugins that size themselves from matrix.width need
no changes at all; one that wants to be explicit can read the new
BasePlugin.get_vegas_render_width().

Width is a percentage so a single setting travels across panel sizes:
vegas_scroll.render_width_pct globally, or vegas_width_pct in an individual
plugin's config. Measured on a 512x64 panel with real data:

  ledmatrix-weather   1536px -> 576px   (forecast becomes narrow cards)
  youtube-stats        353px -> 199px   (2% blank left, so genuinely compact)
  geochron             453px -> 153px   (ink density rises to 100%)
  ledmatrix-flights    950px -> 740px

The youtube-stats figure is the clearest evidence the layout itself changed
rather than being cropped: at full width the content had to be trimmed from
512px to 353px, whereas at 40% it arrives with almost no blank to reclaim.

Row spacing is now measured rather than added. A flat gap gets it wrong in
both directions at once — content drawn flush to its own edges ends up nearly
touching (reported for recent sports scores, which sat 8px apart), while
content already carrying wide margins gets pushed even further out.
separation_gap() measures the blank each pair already has and adds only the
shortfall, up to min_content_separation (default 24). intra_plugin_gap stays
as a floor applied regardless.

Two tests shipped in the previous commit encoded the old flat-gap arithmetic
and are updated to the measured semantics, including one renamed to reflect
that zero intra_plugin_gap alone no longer butts rows together.

Also fixes a real bug found while testing: the harness display manager had no
render_size(), and because the adapter catches broadly that surfaced as "no
content" rather than an error, silently dropping five plugins. Added the
context to VisualTestDisplayManager for parity, and _render_at() now degrades
to a no-op on any display manager lacking it, so a third-party or older
harness loses the narrowing rather than the content.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
2026-07-29 09:01:28 -04:00
ChuckBuildsandClaude d69dfbbaee Align Vegas API bounds with validate(), fix audit config plumbing
Both from review feedback on #423.

The web API's accepted ranges disagreed with VegasModeConfig.validate(),
which is what actually gates Vegas starting:

  scroll_speed      1-100  -> 1-200   (a slider value of 150 returned 400)
  separator_width   0-500  -> 0-128
  target_fps        1-200  -> 30-200
  buffer_ahead      1-20   -> 1-5

The three loose ones were the dangerous direction: the value saved with a
200, then VegasModeCoordinator.start() failed validation with only a log
line, so the ticker silently never ran. The UI already matched validate() in
all four cases, so the API was the odd one out.

test_vegas_api_bounds_match_validate parses the numeric_fields map out of
api_v3 and asserts every bound against validate(), plus that validate()
accepts both endpoints and rejects just outside them, so these cannot drift
apart again. That test immediately caught a missing upper bound on
min_plugin_width, now added — unbounded it would drop every segment and
leave a blank ticker.

Separately, vegas_audit.py constructed PluginAdapter without the config, so
it fell back to VegasModeConfig() defaults and would report trimming and
width-budget behaviour that differed from the user's config.json. It now
passes the loaded config exactly as the coordinator does. This is the same
class of drift the explicit lead_gap and grouping arguments already guard
against. Output is unchanged on a rig whose config matches the defaults.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
2026-07-28 20:32:09 -04:00
ChuckBuildsandClaude ec96422803 Drop unused Optional import from the vegas audit script
Flagged by Codacy (F401). Any, Dict and List are all still used.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
2026-07-28 20:26:22 -04:00
ChuckBuildsandClaude 8d57a748a7 Vegas mode: reclaim dead space and pace the rotation
On a wide panel Vegas mode spent much of its time showing black. At 50px/s
on a 512px display, one display width of blank is 10.2 seconds, which makes
several long-standing behaviours expensive:

- ScrollHelper prepended a full display width of black as an "initial gap",
  charged once per cycle — 10.2s of black at the start of every rotation.
- Plugins without get_vegas_content() are captured off a full-display canvas,
  so their blank margins entered the ticker too. Measured: of-the-day drew
  35px of "No Data" on a 512px canvas (92% blank), youtube-stats 142px of
  content with 185px of black either side. Only the scroll_helper path had
  any trimming.
- Cycle transitions deliberately pushed a blank frame and then recomposed
  synchronously: 84ms at best, 4.8s at worst, every millisecond of it black.
- buffer_ahead doubled as the cycle size, so a 21-plugin install showed 3
  plugins per cycle and took ~7 cycles to come around.
- separator_width was applied between every image rather than at plugin
  boundaries, so a per-row ticker like the F1 scoreboard (116 images, which
  it renders 4px apart internally) got a 32px chasm between each row — and
  the width budget didn't count those gaps, so the plugin quietly occupied
  far more of the panel than intended.

Changes:

- src/vegas_mode/geometry.py: numpy column-ink primitives shared by the
  trimmer and the audit tool, so the number reported is the number acted on.
  A Python per-column loop over a 17,000px strip is far too slow for the
  render path.
- PluginAdapter trims every content path, not just scroll_helper. Only outer
  edges are cropped: interior blank columns are the plugin's own layout
  (logo left, score right) and closing them would corrupt the design. A
  plugin on a non-black background is inherently unaffected.
- ScrollHelper.create_scrolling_image takes an explicit lead_gap, still
  defaulting to display_width so the many standalone-ticker callers are
  unchanged. Vegas passes lead_in_width (default 0).
- Cycle end holds the last rendered frame instead of blanking, turning the
  recompose into a brief freeze rather than the panel switching off.
- plugins_per_cycle (default 6) is split from buffer_ahead, which goes back
  to being only a prefetch low-water mark.
- max_plugin_width_ratio (default 3x display width) caps one plugin's share
  of a cycle. Overflow is deferred, not discarded: a rotation offset advances
  each fetch so later rows appear on subsequent cycles. Single oversized
  images are cropped at a blank column so the cut misses glyphs.
- Composition groups images by plugin: rows are joined by intra_plugin_gap
  (default 8) and separator_width applies only between plugins. The width
  budget now counts those gaps.
- Plugin data updates no longer run on the Vegas render path.

All new settings are user-configurable in Display -> Vegas Scroll, including
min/max cycle duration and dynamic duration, which previously existed in code
but were reachable only by hand-editing config.json.

Measured with scripts/dev/vegas_audit.py on a 512x64 panel:

  mean ink coverage    42.7% -> 69.4%
  fully blank           5.9% -> 0%
  reads as empty        13.6% -> 0%
  worst blank stretch    4.8s -> 0s
  full rotation          414s -> 123s
  plugins per cycle         3 -> 6

Note the metric choice: a "fully blank" scan (>=95% black viewport) reported
only 0.4% and badly understated the problem, because two full-width segments
with mid-canvas content never fully blank the viewport — they hold it at ~28%.
window_coverage_stats grades every viewport position by how much ink it
carries, which is what tracks perceived dead time.

Known remaining: cycle transitions still freeze ~3.5s while the next cycle is
fetched. Fixing that needs background prefetch, which is deferred because the
fallback-capture path mutates the shared display_manager.image and racing it
against the render loop risks torn frames.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
2026-07-28 20:18:51 -04:00
19 changed files with 748 additions and 6024 deletions
-45
View File
@@ -5,10 +5,6 @@ on:
push:
branches: [main]
# Both jobs only check out the repo and run pytest.
permissions:
contents: read
jobs:
plugin-safety:
name: Plugin safety harness + unit tests
@@ -35,44 +31,3 @@ jobs:
test/plugins/test_harness.py \
test/plugins/test_visual_rendering.py \
test/plugins/test_plugin_matrix.py
unit-tests:
name: Core unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
with:
python-version: "3.12"
cache: pip
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt -r requirements-test.txt
pip install RGBMatrixEmulator
# Safety net for the shared sports/scroll/style infrastructure. These
# suites existed but were not enrolled in CI, so a refactor of
# src/base_classes or src/common could regress them silently. Enrolled
# explicitly (not `pytest test/`) so known hardware-only suites don't
# break CI; grow this list as more suites are made headless.
- name: Run core unit suites
run: |
pytest --no-cov \
test/test_skin_system.py \
test/test_font_manager.py \
test/test_data_sources.py \
test/test_api_extractors.py \
test/test_scroll_helper.py \
test/test_scroll_helper_continuous.py \
test/test_adaptive_layout.py \
test/test_loader_compat_warning.py \
test/test_sports_base_characterization.py \
test/test_element_style.py \
test/test_sports_core_promotions.py \
test/test_sports_modes_promotions.py \
test/test_sports_capabilities.py
-103
View File
@@ -1,103 +0,0 @@
# Changelog
Notable changes to the LEDMatrix core. The version below is the value of
`src.__version__`, which the plugin loader reports to compatibility checks and
which plugin manifests reference via `ledmatrix_min_version`.
**Why this file exists:** the plugin monorepo bundles fallback copies of several
core modules (see `docs/plugin-development/08-shared-sports-code.md` in
[ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins)). A plugin
may delete its bundled copy only when its manifest floors on the first core
release that ships the module — which requires module additions to be recorded
here, against a version number. When you add a module plugins will import via
`src.*`, note it in the Unreleased section and bump `src/__init__.py` in the
release that ships it.
## Unreleased
### Added
- `src/element_style.py` — per-element style resolver backing the
`x-style-elements` config-schema extension. Already consumed (behind guarded
imports with classic fallbacks) by the `of-the-day`, `ledmatrix-music`, and
`football-scoreboard` plugins.
- Core unit-test CI job enrolling the previously unenrolled suites (skin
system, data sources, API extractors, scroll helper, adaptive layout, loader
compatibility warning) plus new characterization tests for
`src/base_classes/sports.py` ahead of the shared sports-code unification.
- `src/base_classes/sports/``sports.py` is now a package (`core.py` +
`modes.py`). The import path is unchanged: `from src.base_classes.sports
import SportsCore` still works.
- Nine methods promoted onto the sports base classes from the plugins'
bundled copies, plus the override points `_favorite_key`,
`_config_schema_path` and `_font_root` and the class attributes
`FINAL_PERIOD` / `CLOCK_COUNTS_DOWN`. See `docs/SPORTS_UNIFICATION.md`.
A plugin may start calling these once its manifest floors
`ledmatrix_min_version` at the release that ships them.
- `src/base_classes/sports/capabilities/` — opt-in capabilities for the sports
scoreboards, composed by inheritance rather than gated by config branches
inside the base classes:
- `CelebrationMixin` — the score/win takeover, merging the goal and score
dialects behind the `score_phrase()` / `win_phrase()` hooks, the
`COALESCE_SCORING_SEQUENCE` class attribute and the `_favorite_key` seam.
Reads both the `celebrate_opponent_goals` and `celebrate_opponent_scores`
config spellings. Sports that do not mix it in have none of this code in
their MRO.
- `RotationStrategy` + a name registry (`swrr`, `weighted`, `simple`,
plus `register_rotation_strategy` for plugin-supplied orderings). Each
built-in is verified against a verbatim transcription of the plugin
implementation it replaces. An unknown name degrades to `simple`.
### Changed
- **Live games are no longer dropped when the feed omits a game clock.**
`SportsLive._is_game_really_over` previously (in the baseball and UFC
plugin lineages) coerced a missing or non-string clock to the literal
`"0:00"` and then treated the game as finished once `period >= 4`. Baseball
has no game clock and `period` is the inning, so live MLB games disappeared
from the scoreboard from the 5th inning onward; UFC was affected the same
way. The clock check is now skipped when the clock is unusable, and the
period threshold is the per-sport `FINAL_PERIOD` (hockey ends in P3).
Sports whose clocks count up — soccer, AFL, NRL — set
`CLOCK_COUNTS_DOWN = False` and never run the check at all, since `0:00`
there means kickoff rather than expiry.
### Fixed
- `FontManager` resolves `assets/fonts` against the core install root instead
of the process working directory, so font loading works when the process
starts elsewhere (e.g. the plugin safety harness on CI).
- Hockey events whose competitors carry no `statistics` array are no longer
discarded. The extractor read `competitor["statistics"]` unguarded, so a
`KeyError` inside the generator dropped the entire event despite valid
scores and status; shot counts now fall back to `0`.
- Live baseball events that populate status only at the competition level are
no longer discarded. The extractor read the event top-level
`game_event["status"]` for the inning; real ESPN events duplicate it, but
MiLB events synthesized from the MLB Stats API do not, so the lookup raised
a bare `KeyError`. It now reads the already-validated competition-level
status.
- `SportsLive._is_game_really_over` no longer crashes the live-update pass when
a feed sends an explicit null `period`. `None >= FINAL_PERIOD` raised
`TypeError`, and the only caller (`_detect_stale_games`) has no `try/except`
— the same failure shape as the already-fixed null `period_text`.
- An expired clock spelled `"00:00"` now ends the game. The check compared the
colon-stripped clock against a hand-listed set of literals, which `"0000"` is
not a member of, so a finished game with a two-digit-minute clock stayed on
the scoreboard indefinitely. The comparison is now numeric.
- `SportsCore._load_fonts` resolves `assets/fonts` through the `_font_root()`
seam instead of the process working directory. Started outside the install
root, every scoreboard font silently degraded to PIL's default bitmap face.
- `SportsCore._should_log` no longer raises `AttributeError` on the first
warning of a run; `_last_warning_time` is initialized in `__init__` rather
than lazily by an unrelated method.
- `SportsCore._resolve_project_path` resolved relative logo directories
against `<root>/src` instead of the repo root after `sports.py` became a
package — the class bodies moved byte-identically but `__file__` gained a
directory. Both it and `_font_root` now derive from one `_INSTALL_ROOT`
constant.
## 3.1.0
Baseline for this changelog. Highlights already shipped at this version:
skin system for sports scoreboards (#419), Vegas continuous-scroll overhaul
(#423), plugin update surfacing (#421).
-196
View File
@@ -1,196 +0,0 @@
# Sports Code Unification — Architecture
How the nine sports scoreboard plugins converge onto shared core code **without**
becoming nine clients of a god class.
## The problem
Nine plugins (`afl`, `baseball`, `basketball`, `football`, `hockey`, `lacrosse`,
`nrl`, `soccer`, `ufc`) each ship a ~3,000-line `sports.py` descended from this
repo's `src/base_classes/sports.py`. They have drifted into three lineages, and
only 28 of the 66 methods appearing across them are present in all nine. One
logical fix (the UTC start-time bug) cost 75 files.
Merging everything into one base class would fix the duplication and create a
worse problem: a single 2,500-line class that all nine plugins inherit, where any
change has a nine-plugin blast radius and per-sport behavior survives only as
`if self.sport == "hockey"` branches.
## Three properties, three mechanisms
These are independent concerns. Conflating them is what produces god classes.
### Upgradability — a plugin keeps working across core versions
| Rule | Mechanism |
|---|---|
| Plugin loads on a core that predates a module | Guarded import with a bundled fallback (`try: from src.X import Y / except ModuleNotFoundError: from y import Y`) |
| Plugin loads on a core that predates a *method* | Capability probing — `hasattr(SportsCore, "_detect_stale_games")` — never a version comparison. The loader's compat check is advisory-only (it logs and continues), so probing is the real protection. |
| Core changes never break a plugin's rendering | The **view-model contract**: `_extract_game_details_common` returns a dict whose `GUARANTEED_KEYS` are frozen by `test/test_skin_system.py::TestViewModelContract`. Keys may be added, never renamed or removed. |
| A plugin can drop its bundled copy safely | The **sunset rule**: only when its manifest floors `ledmatrix_min_version` at the first core release shipping the module (recorded in `CHANGELOG.md`). |
The core API is **additive-only**. A method the plugins call is never removed or
given a new required parameter; new behavior arrives as new methods with
defaults, or as capabilities they opt into.
### Reusability — write once, nine plugins benefit
Only code that is **identical in intent across all nine** moves into the base
class. That set is small and knowable — it is exactly the methods present in every
copy today (phase B1 below). Everything else stays where it is until it earns
promotion.
### Modularity — a change to one feature cannot reach a plugin that doesn't use it
This is the property the naive merge destroys, and it is enforced structurally:
1. **Capabilities are separate modules composed by inheritance, not config
branches inside the base class.** Hockey has no celebrations, so
`HockeyLive` does not inherit `CelebrationMixin` — the celebration code is not
merely disabled for hockey, it is *not in hockey's MRO at all*. No shared
state, no dead branches, no risk. Contrast with
`if self.celebrations_enabled:` inside `SportsLive`, where a bug in
celebration code can still crash a plugin that never wanted the feature.
2. **Variant behavior is a strategy object chosen by name, not a branch.**
Live rotation exists in three dialects across the lineages; core ships all
three behind `rotation_strategy: "swrr" | "weighted" | "simple"` and a plugin
may register its own. Core never learns sport names.
3. **Sport-specific behavior is a documented override point.** The base class
declares the seam; the plugin fills it. Basketball's tournament-round parsing
and baseball's BDF sizing stay in their plugins forever — they are not
candidates for promotion, and core must never grow a branch for them.
4. **Files bound the blast radius.** Capabilities live in their own modules so a
diff shows at a glance which plugins a change can reach.
## Layering
```
src/base_classes/sports/
__init__.py re-exports the public API (import path unchanged)
core.py SportsCore — fetch, cache, config, logos, fonts, odds,
view-model extraction, the skin seam
modes.py SportsUpcoming / SportsRecent / SportsLive
capabilities/
celebrations.py CelebrationMixin (opt-in: 4 of 9 plugins)
rotation.py RotationStrategy + registry
```
`from src.base_classes.sports import SportsCore` keeps working — the package
`__init__` re-exports, so the conversion is invisible to every existing importer.
## Override points (the plugin-facing seam)
The base class calls these; plugins implement or override them. This table is the
contract — additions require a default implementation, removals require a
deprecation cycle.
| Hook | Purpose | Default |
|---|---|---|
| `_fetch_data()` | Sport's schedule source | abstract |
| `_extract_game_details(event)` | Sport-specific view-model fields on top of the common ones | delegates to `_extract_game_details_common` |
| `_draw_scorebug_layout(game, force_clear)` | Sport's card rendering | base layout |
| `_custom_scorebug_layout(game, draw)` | Per-sport overlay on the base layout | no-op |
| `render_skin_card(game, size)` | Skin-system entry point | built-in fallback |
| `score_phrase(points, team_abbr)` | Celebration wording (`"GOOOOAAALLL!"` vs `"TOUCHDOWN!"`). `points` is the score delta, which sports with variable-value scores use to name the play | `"<abbr> SCORES!"` — only consulted when `CelebrationMixin` is present |
| `win_phrase(team_abbr)` | Win-celebration wording | `"<abbr> WINS!"` — mixin only |
| `_favorite_key(game, side)` | Which view-model field identifies a team for favorites matching | `game["<side>_abbr"]` |
| `_config_schema_path()` | Plugin's `config_schema.json` — returning it routes `_get_layout_offset` through the `src.element_style` resolver (and gives it the defaults to compare against) | `None`, i.e. the classic inline `customization.layout` read |
| `_font_root()` | Directory to resolve `assets/fonts` against | core install root |
Two class attributes serve the same purpose for values that are per-sport
constants rather than behavior:
| Attribute | Meaning | Default |
|---|---|---|
| `FINAL_PERIOD` | Period at/after which a zero clock can mean "over" | `4` (hockey overrides to `3`) |
| `CLOCK_COUNTS_DOWN` | Whether `0:00` means "expired" | `True` (soccer/afl/nrl override to `False` — their clocks count up, so `0:00` is kickoff) |
| `COALESCE_SCORING_SEQUENCE` | Fold score increments arriving during an active celebration into that one celebration | `False` (football overrides to `True` — a touchdown lands as +6, then +1 for the extra point) |
### Why these are seams and not branches
`_favorite_key` exists because NRL abbreviations are **not unique** — "NEW" is both
Newcastle Knights and New Zealand Warriors, "CAN" both Canberra and Canterbury —
so NRL matches favorites on team ID. Flattening every plugin to abbreviations
would silently select the wrong club for NRL users. The base declares the seam,
NRL fills it, and core never learns the string `"nrl"`.
`CLOCK_COUNTS_DOWN` exists for the same reason in the opposite direction: a
soccer clock reading `0:00` means the match has not kicked off, so running the
clock-expiry branch there would evict live games.
`COALESCE_SCORING_SEQUENCE` is the third of the same kind. In football one
scoring play arrives as two score updates, so the follow-up must be folded into
the first celebration; in soccer two increments a few seconds apart are two real
goals, and folding them would swallow one. Neither default is "right" — which is
precisely why it is a declared per-sport constant rather than a hidden
assumption baked into the shared body.
## Capabilities
```
capabilities/
celebrations.py CelebrationMixin opt-in: afl, nrl, soccer, football
rotation.py RotationStrategy + registry
```
**`CelebrationMixin`** merges the two dialects the lineages grew
(`_check_for_goal`/`celebrate_opponent_goals` vs
`_check_for_score`/`celebrate_opponent_scores`). Their bodies were identical
apart from three things, each now a seam: wording (`score_phrase`), follow-up
suppression (`COALESCE_SCORING_SEQUENCE`), and team identity (`_favorite_key`,
so NRL matches on id). Both config spellings are read, so a plugin adopting the
mixin keeps working with the keys already in its published schema.
Mix it in **before** the mode class — `class SoccerLive(CelebrationMixin,
SportsLive)` — so the celebration `display()` runs first and falls through to
the scorebug via `super()`.
**Rotation strategies.** The three "dialects" turned out to be one algorithm
(Smooth Weighted Round-Robin) in two shapes: an incremental picker holding state
across calls (afl/nrl/soccer) and a precomputed per-cycle list
(football/baseball/basketball, and hockey with a different loop shape). They
agree within a cycle and differ only at the boundary — the incremental form has
no restart seam — so core ships both rather than declaring a winner:
```python
self.rotation = get_rotation_strategy("swrr", weight_for=self._live_weight)
```
`weight_for` is supplied by the host, so the *favorites* policy stays with the
plugin and `rotation.py` never learns what a favorite is. An unknown strategy
name degrades to `simple` rather than raising: the name comes from user config,
and a typo should cost the boost, not the scoreboard. A plugin needing an
ordering core does not ship calls `register_rotation_strategy` instead of core
growing a branch.
`test_sports_capabilities.py` checks each strategy against a **verbatim
transcription** of the plugin code it replaces, over every live-game shape up to
four games. That differential is what B5 deletes the bundled copies on the
strength of.
## Phases
| Phase | Scope | Risk control |
|---|---|---|
| **B0** ✅ | Characterization tests, CI unit job, `element_style`, font cwd fix, CHANGELOG discipline | — |
| **B1** ✅ | Promote the nine universal methods; convert `sports.py` → package | Characterization suite must stay green; no behavior change intended |
| **B2** ✅ | `CelebrationMixin` + rotation strategies as opt-in capabilities | Plugins that don't opt in have zero new code in their MRO; strategies checked against verbatim plugin transcriptions |
| **B3** | Upstream `ScrollDisplay` as `src/common/sports_scroll.py`, reading `global_config['target_fps']` natively | Plugin copies remain until sunset |
| **B4** | Bump to 3.2.0, record modules in CHANGELOG, migrate `ledmatrix_min``ledmatrix_min_version` | Gives plugins a version to floor on |
| **B5** | Pilot one plugin per lineage (hockey, soccer, football) on guarded core imports; then the remaining six; then delete bundled copies | Pilot soaks before rollout; harness + golden suites gate each |
## Rules for contributors
- **Promote on evidence, not intuition.** A method moves to core when every copy
has it and they agree on intent. Otherwise it stays in the plugins.
- **Never add a sport name to core.** If core needs to know which sport it is,
the design is wrong — add an override point instead.
- **A capability that is not opted into must not execute.** If you find yourself
writing `if self.<capability>_enabled` inside a base class, it belongs in a
mixin.
- **Touch the view-model keys only additively.** Published skins depend on them.
- **Every promotion lands with the characterization suite green**, and every
pilot adoption lands with that plugin's harness and golden suites green.
+2 -8
View File
@@ -164,13 +164,7 @@ class Baseball(SportsCore):
# Get game state information
if status_state == "in":
# For live games, get detailed state
# Use the competition-level `status` already validated by
# _extract_game_details_common. Real ESPN events duplicate
# status at the event top level, but MiLB events (synthesized
# from the MLB Stats API into an ESPN-like shape) populate
# only the competition-level one, so the top-level lookup
# raised a bare KeyError and dropped the event.
inning = status.get(
inning = game_event["status"].get(
"period", 1
) # Get inning from status period
@@ -193,7 +187,7 @@ class Baseball(SportsCore):
if "end" in status_detail or "end" in status_short:
inning_half = "top"
inning = (
status.get("period", 1) + 1
game_event["status"].get("period", 1) + 1
) # Use period and increment for next inning
if is_favorite_game:
self.logger.debug(
+4 -11
View File
@@ -38,17 +38,10 @@ class Hockey(SportsCore):
status = competition["status"]
powerplay = False
penalties = ""
# A competitor may legitimately arrive without a "statistics"
# array (pre-game feeds, and some in-progress ones). Reading it
# unguarded raised KeyError inside the generator and dropped the
# WHOLE event, discarding valid scores and status. Default to an
# empty list so the saves/shots figures fall back to 0 instead.
home_stats = home_team.get("statistics", [])
away_stats = away_team.get("statistics", [])
home_team_saves = next(
(
int(c["displayValue"])
for c in home_stats
for c in home_team["statistics"]
if c.get("name") == "saves"
),
0,
@@ -56,7 +49,7 @@ class Hockey(SportsCore):
home_team_saves_per = next(
(
float(c["displayValue"])
for c in home_stats
for c in home_team["statistics"]
if c.get("name") == "savePct"
),
0.0,
@@ -64,7 +57,7 @@ class Hockey(SportsCore):
away_team_saves = next(
(
int(c["displayValue"])
for c in away_stats
for c in away_team["statistics"]
if c.get("name") == "saves"
),
0,
@@ -72,7 +65,7 @@ class Hockey(SportsCore):
away_team_saves_per = next(
(
float(c["displayValue"])
for c in away_stats
for c in away_team["statistics"]
if c.get("name") == "savePct"
),
0.0,
File diff suppressed because it is too large Load Diff
-17
View File
@@ -1,17 +0,0 @@
"""Sports scoreboard base classes.
Formerly the single module ``src/base_classes/sports.py``; now a package so
capabilities can be composed instead of accumulating in one class. See
docs/SPORTS_UNIFICATION.md for the architecture. The import path is
unchanged: ``from src.base_classes.sports import SportsCore`` still works.
"""
from .core import SportsCore
from .modes import SportsLive, SportsRecent, SportsUpcoming
__all__ = [
"SportsCore",
"SportsUpcoming",
"SportsRecent",
"SportsLive",
]
@@ -1,32 +0,0 @@
"""Opt-in capabilities for the sports scoreboards.
Each module here is a feature that only *some* sports want. They are composed
by inheritance (mixins) or selected by name (strategies) never enabled by an
``if self.<feature>_enabled:`` branch inside the base classes.
The distinction matters: hockey has no celebrations, so ``HockeyLive`` does not
inherit :class:`~.celebrations.CelebrationMixin` and the celebration code is not
in hockey's MRO at all. A bug in it cannot reach a plugin that never opted in.
See ``docs/SPORTS_UNIFICATION.md`` for the full rationale.
"""
from .celebrations import CelebrationMixin
from .rotation import (
RotationStrategy,
SimpleRotation,
SmoothWeightedRotation,
WeightedCycleRotation,
get_rotation_strategy,
register_rotation_strategy,
)
__all__ = [
"CelebrationMixin",
"RotationStrategy",
"SimpleRotation",
"SmoothWeightedRotation",
"WeightedCycleRotation",
"get_rotation_strategy",
"register_rotation_strategy",
]
@@ -1,379 +0,0 @@
"""Score / win celebration takeover — an opt-in capability.
Four of the nine scoreboards celebrate (afl, nrl, soccer, football); the other
five do not. This is a **mixin** rather than a flag inside ``SportsLive`` so the
five that do not opt in have none of this code in their MRO: a bug here cannot
reach hockey, and hockey's config never grows keys it ignores.
Usage mix in *before* the mode class so its ``display`` runs first::
class SoccerLive(CelebrationMixin, SportsLive):
def score_phrase(self, points, team_abbr):
return secrets.choice(("GOOOOAAALLL!", f"{team_abbr} SCORES!"))
The two lineages spelled this differently (``_check_for_goal`` /
``celebrate_opponent_goals`` in the soccer lineage, ``_check_for_score`` /
``celebrate_opponent_scores`` in football) but the bodies were identical apart
from three things, each of which is a seam here rather than a branch:
* **wording** :meth:`score_phrase`, the hook football uses to say "TOUCHDOWN"
from the points delta and soccer uses to say "GOOOOAAALLL";
* **follow-up suppression** :attr:`COALESCE_SCORING_SEQUENCE`, on for football
where a touchdown lands as +6 then +1 a few seconds later, off elsewhere where
two quick goals are two real events;
* **team identity** matching goes through ``_favorite_key``, so nrl can match
on team id (its abbreviations are ambiguous) without core knowing why.
The config keys are read under both spellings, so a plugin adopting the mixin
keeps working with the ``*_goals`` keys already in its published schema.
"""
from __future__ import annotations
import re
import time
from typing import Any, Dict, List, Optional
from PIL import Image, ImageDraw
class CelebrationMixin:
"""Full-screen takeover when a tracked team scores or wins."""
#: Collapse increments that land while a celebration is already on screen
#: into that one celebration. True for sports where a single scoring play
#: arrives as more than one score update (football: touchdown +6, then the
#: extra point +1). False where consecutive increments are distinct events —
#: suppressing there would swallow a real goal.
COALESCE_SCORING_SEQUENCE = False
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
mode_config = getattr(self, "mode_config", {}) or {}
self.celebration_enabled = mode_config.get("celebration_enabled", True)
self.celebration_duration = mode_config.get("celebration_duration", 8)
# Both spellings: the soccer lineage ships `celebrate_opponent_goals`,
# football ships `celebrate_opponent_scores`. Whichever the plugin's
# schema declares is the one its users have set.
self.celebrate_opponent_scores = mode_config.get(
"celebrate_opponent_scores",
mode_config.get("celebrate_opponent_goals", False),
)
# Per-game score baselines: {game_id: {"away": int, "home": int}}
self._score_baselines: Dict[str, Dict[str, int]] = {}
# The active celebration (a game *snapshot*, so a win survives the game
# leaving live_games) or None. See _start_celebration for the shape.
self.active_celebration: Optional[Dict[str, Any]] = None
# ------------------------------------------------------------------
# Override points
# ------------------------------------------------------------------
def score_phrase(self, points: int, team_abbr: str) -> str:
"""The wording for a score celebration.
``points`` is the score delta that triggered it, which sports with
variable-value scores use to name the play. The default is deliberately
sport-neutral; every celebrating plugin overrides it.
"""
return f"{team_abbr} SCORES!"
def win_phrase(self, team_abbr: str) -> str:
"""The wording for a win celebration."""
return f"{team_abbr} WINS!"
def _is_favorite(self, key: Optional[str]) -> bool:
"""Whether ``key`` (whatever ``_favorite_key`` returns) is a favorite."""
return bool(self.favorite_teams) and key in self.favorite_teams
# ------------------------------------------------------------------
# Detection
# ------------------------------------------------------------------
@staticmethod
def _score_to_int(score) -> Optional[int]:
"""Coerce an ESPN score value (str / int / dict) to an int, or None."""
try:
if score is None:
return None
if isinstance(score, str):
s = score.strip()
if not s:
return None
try:
return int(float(s))
except ValueError:
numbers = re.findall(r"\d+", s)
return int(numbers[0]) if numbers else None
if isinstance(score, dict):
return int(float(score.get("value", score.get("displayValue", 0))))
return int(float(score))
except (ValueError, TypeError):
return None
def _should_celebrate_for(self, game: Dict, side: str) -> bool:
"""Whether a score by ``side`` in ``game`` should trigger a celebration."""
if self._is_favorite(self._favorite_key(game, side)):
return True
if not self.favorite_teams:
# No favorites configured: the user opted to show this game, so
# celebrate any score in it.
return True
# Favorites exist but this team isn't one -> it's the opponent.
return self.celebrate_opponent_scores
def has_active_celebration(self) -> bool:
"""True while a celebration is within its display window."""
celebration = self.active_celebration
return bool(celebration) and (
time.time() - celebration["started_at"] < self.celebration_duration
)
def _check_for_score(self, game: Dict) -> None:
"""Compare a live game's score against its baseline and arm a
celebration when a celebratable team's score increases."""
if not self.celebration_enabled:
return
game_id = game.get("id")
if not game_id:
return
away = self._score_to_int(game.get("away_score"))
home = self._score_to_int(game.get("home_score"))
if away is None or home is None:
return
baseline = self._score_baselines.get(game_id)
# Always refresh the baseline: a first sighting must never celebrate (a
# game already in progress at boot would false-fire), and a decrement
# (VAR, a correction) just re-bases silently.
self._score_baselines[game_id] = {"away": away, "home": home}
if baseline is None:
return
away_delta = away - baseline["away"]
home_delta = home - baseline["home"]
if away_delta <= 0 and home_delta <= 0:
return
# One takeover per scoring sequence, where the sport has such a thing.
# The baseline is already advanced above, so nothing re-fires later.
if self.COALESCE_SCORING_SEQUENCE and self.has_active_celebration():
return
scored_side = None
points = 0
if away_delta > 0 and self._should_celebrate_for(game, "away"):
scored_side, points = "away", away_delta
if scored_side is None and home_delta > 0 and self._should_celebrate_for(
game, "home"
):
scored_side, points = "home", home_delta
if scored_side is None:
return
self._start_celebration(
game,
"score",
scored_side=scored_side,
team_abbr=game.get(f"{scored_side}_abbr", ""),
away_score=away,
home_score=home,
points=points,
)
def _check_for_win(self, game: Dict) -> None:
"""When a game we were tracking live goes final, arm a win celebration
if a favorite won. Fires at most once per game."""
if not self.celebration_enabled:
return
game_id = game.get("id")
if not game_id:
return
# Only celebrate wins for games we actually watched go live: one seen
# for the first time already-final (the board started after full time)
# has no baseline and must not fire.
if game_id not in self._score_baselines:
return
# Consume the baseline so this can only fire once.
self._score_baselines.pop(game_id, None)
away = self._score_to_int(game.get("away_score"))
home = self._score_to_int(game.get("home_score"))
if away is None or home is None:
return
if away > home:
winner_side = "away"
elif home > away:
winner_side = "home"
else:
return # draw -> no win celebration
# Wins are gated strictly on favorites: every game ends, so the
# "no favorites -> celebrate all" score fallback would be far too noisy.
if not self._is_favorite(self._favorite_key(game, winner_side)):
return
self._start_celebration(
game,
"win",
scored_side=winner_side,
team_abbr=game.get(f"{winner_side}_abbr", ""),
away_score=away,
home_score=home,
)
def _start_celebration(
self,
game: Dict,
kind: str,
scored_side: str,
team_abbr: str,
away_score: int,
home_score: int,
points: int = 0,
) -> None:
"""Arm a celebration. ``scored_side`` ('away'/'home') is the side whose
score digit gets highlighted."""
phrase = (
self.win_phrase(team_abbr)
if kind == "win"
else self.score_phrase(points, team_abbr)
)
self.active_celebration = {
"kind": kind,
"game": dict(game), # snapshot: survives the game leaving live_games
"scored_side": scored_side,
"team_abbr": team_abbr,
"away_score": away_score,
"home_score": home_score,
"started_at": time.time(),
"phrase": phrase,
}
# Pin focus to the involved game so the post-celebration scorebug
# resumes on it.
self.current_game = dict(game)
self.logger.info(
f"Celebration ({kind}) armed: {phrase} "
f"[{game.get('away_abbr')} {away_score}-{home_score} {game.get('home_abbr')}]"
)
# ------------------------------------------------------------------
# Rendering
# ------------------------------------------------------------------
def _fit_font(self, draw, text: str, max_width: int, fonts: List):
"""The first font whose rendered ``text`` fits ``max_width``, falling
back to the last (smallest) font."""
for font in fonts:
if draw.textlength(text, font=font) <= max_width - 2:
return font
return fonts[-1]
def _draw_celebration_layout(
self, celebration: Dict, force_clear: bool = False
) -> None:
"""Render the full-screen score/win takeover."""
if force_clear:
self.display_manager.clear()
display_width = (
self.display_manager.matrix.width
if hasattr(self.display_manager, "matrix") and self.display_manager.matrix
else self.display_width
)
display_height = (
self.display_manager.matrix.height
if hasattr(self.display_manager, "matrix") and self.display_manager.matrix
else self.display_height
)
elapsed = time.time() - celebration["started_at"]
game = celebration["game"]
# Background: a brief color flash for the first ~1.2s, then black.
bg = (0, 0, 0, 255)
if elapsed < 1.2 and int(elapsed / 0.2) % 2 == 0:
bg = (12, 12, 48, 255)
main_img = Image.new("RGBA", (display_width, display_height), bg)
overlay = Image.new("RGBA", (display_width, display_height), (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay)
# Logos at the edges (best-effort: a logo failure must not blank the
# celebration).
try:
center_y = display_height // 2
home_logo = self._load_and_resize_logo(
game.get("home_id"), game.get("home_abbr"),
game.get("home_logo_path"), game.get("home_logo_url"),
)
away_logo = self._load_and_resize_logo(
game.get("away_id"), game.get("away_abbr"),
game.get("away_logo_path"), game.get("away_logo_url"),
)
if home_logo:
main_img.paste(
home_logo,
(display_width - home_logo.width + 2, center_y - home_logo.height // 2),
home_logo,
)
if away_logo:
main_img.paste(
away_logo, (-2, center_y - away_logo.height // 2), away_logo
)
except Exception as e:
self.logger.debug(f"Celebration logo load failed: {e}")
# Phrase across the top, shrunk to fit the panel width.
phrase = celebration["phrase"]
phrase_font = self._fit_font(
draw, phrase, display_width, [self.fonts["time"], self.fonts["status"]]
)
phrase_width = draw.textlength(phrase, font=phrase_font)
self._draw_text_with_outline(
draw, phrase, ((display_width - phrase_width) // 2, 1), phrase_font
)
# Score centered low, with the scoring/winning side's digit pulsing in a
# highlight color so the change reads at a glance.
away_text = str(celebration["away_score"])
home_text = str(celebration["home_score"])
score_font = self.fonts["score"]
segments = [
(away_text, celebration["scored_side"] == "away"),
("-", False),
(home_text, celebration["scored_side"] == "home"),
]
total_width = sum(draw.textlength(seg, font=score_font) for seg, _ in segments)
highlight = (255, 255, 0) if int(elapsed * 4) % 2 == 0 else (255, 170, 0)
x = (display_width - total_width) // 2
y = display_height - 14
for seg, is_highlight in segments:
color = highlight if is_highlight else (255, 255, 255)
self._draw_text_with_outline(draw, seg, (int(x), y), score_font, fill=color)
x += draw.textlength(seg, font=score_font)
main_img = Image.alpha_composite(main_img, overlay).convert("RGB")
self.display_manager.image = main_img
self.display_manager.update_display()
def display(self, force_clear: bool = False) -> bool:
"""Render an active celebration as a full-screen takeover; otherwise
defer to the normal live scorebug."""
if not self.is_enabled:
return False
celebration = self.active_celebration
if celebration:
if time.time() - celebration["started_at"] < self.celebration_duration:
try:
self._draw_celebration_layout(celebration, force_clear)
return True
except Exception as e:
self.logger.error(f"Error drawing celebration: {e}", exc_info=True)
else:
self.active_celebration = None
# Reset the dwell so the scorebug resumes on the scoring/winning
# game for a full duration before rotation can move on.
self.last_game_switch = time.time()
return super().display(force_clear)
@@ -1,228 +0,0 @@
"""Live-rotation strategies — which live game to show next.
The nine plugin copies grew three spellings of this, and the survey behind
``docs/SPORTS_UNIFICATION.md`` found they are all the *same* Smooth Weighted
Round-Robin algorithm in two shapes:
* an **incremental picker** that holds weight state across calls and answers
"what next?" one game at a time (afl / nrl / soccer's ``_swrr_advance``), and
* a **precomputed cycle** that returns a full list of game ids up front
(football / baseball / basketball's ``_build_weighted_schedule`` and hockey's
``_build_rotation_schedule``, which differ only in loop shape).
They agree *within* a cycle SWRR is deterministic and differ only at cycle
boundaries, where the incremental form has no seam and the precomputed form
restarts. That is a real behavioral difference, so core ships both rather than
declaring a winner, and a plugin picks one by name:
self.rotation = get_rotation_strategy("swrr", weight_for=self._live_weight)
Core never learns which sport is asking. A plugin with a genuinely novel
ordering registers its own strategy instead of core growing a branch::
register_rotation_strategy("my-order", MyRotation)
"""
from __future__ import annotations
from typing import Callable, Dict, List, Optional, Type
def _game_id(game: Dict) -> Optional[str]:
"""The rotation key for a game, or None if it has no usable id."""
return game.get("id")
class RotationStrategy:
"""Base class for live-rotation ordering.
Subclasses implement :meth:`schedule`; :meth:`next_game` has a working
default derived from it. Strategies whose natural shape is incremental
override :meth:`next_game` instead and derive :meth:`schedule`.
:param weight_for: callable mapping a game dict to a positive integer
weight how many turns it gets per turn of a weight-1 game. Supplied by
the host so the *favorites* policy stays with the plugin and this module
stays free of any notion of what a favorite is. Defaults to equal
weights, which makes every strategy a plain round robin.
"""
#: Name this strategy is registered under. Set by :func:`register_rotation_strategy`.
name: str = ""
def __init__(self, weight_for: Optional[Callable[[Dict], int]] = None):
self._weight_for = weight_for or (lambda game: 1)
def weights(self, games: List[Dict]) -> Dict[str, int]:
"""``{game_id: weight}`` for games that have an id, in ``games`` order.
A weight below 1 is clamped up: a zero or negative weight would starve
a game out of the rotation entirely, which no caller means to express
and which would make ``total_weight`` collapse.
"""
weights: Dict[str, int] = {}
for game in games:
gid = _game_id(game)
if gid is None:
continue
try:
weight = int(self._weight_for(game))
except (TypeError, ValueError):
weight = 1
weights[gid] = max(1, weight)
return weights
def schedule(self, games: List[Dict]) -> List[str]:
"""Game ids in display order for one cycle. Ids may repeat."""
raise NotImplementedError
def next_game(self, games: List[Dict]) -> Optional[Dict]:
"""The next game to display, or None when there is nothing to show."""
order = self.schedule(games)
if not order:
return None
by_id = {gid: g for g in games if (gid := _game_id(g)) is not None}
return by_id.get(order[0])
def reset(self) -> None:
"""Drop any accumulated state. Stateless strategies need do nothing."""
class SimpleRotation(RotationStrategy):
"""Plain round robin: every live game once per cycle, weights ignored.
The fallback for a plugin that wants strictly even rotation regardless of
favorites.
"""
def schedule(self, games: List[Dict]) -> List[str]:
return [gid for g in games if (gid := _game_id(g)) is not None]
class WeightedCycleRotation(RotationStrategy):
"""Precomputed SWRR cycle — the football / baseball / basketball / hockey shape.
Returns a full cycle of ``sum(weights)`` ids with repeats spaced evenly
rather than clumped, highest weight scheduled first. When no game carries a
boost the cycle degenerates to a single pass in ``games`` order, which is
exactly the plain round robin it replaced.
"""
def schedule(self, games: List[Dict]) -> List[str]:
weights = self.weights(games)
if not weights:
return []
total_weight = sum(weights.values())
if total_weight <= len(weights):
# No boost in effect — plain order, one pass. (Also the guard that
# keeps the loop below from being O(total_weight) for nothing.)
return list(weights)
current = {gid: 0 for gid in weights}
order: List[str] = []
for _ in range(total_weight):
for gid, weight in weights.items():
current[gid] += weight
picked = max(current, key=lambda gid: current[gid])
current[picked] -= total_weight
order.append(picked)
return order
class SmoothWeightedRotation(RotationStrategy):
"""Incremental SWRR — the afl / nrl / soccer shape.
Weight state persists across calls, so there is no fixed-length cycle and
therefore no clustering seam at a cycle boundary. A game seen for the first
time starts at weight 0 and receives its full weight on the next call, so a
favorite's game that has just gone live naturally wins the first pick after
it appears "queued first on refresh" without a special-cased branch.
State for games no longer live is dropped on each call, so a long-running
board does not accumulate entries for finished games.
"""
def __init__(self, weight_for: Optional[Callable[[Dict], int]] = None):
super().__init__(weight_for)
self._current: Dict[str, int] = {}
def reset(self) -> None:
self._current = {}
def next_game(self, games: List[Dict]) -> Optional[Dict]:
if not games:
return None
weights = self.weights(games)
if not weights:
return None
# Keep state only for games still live.
self._current = {
gid: value for gid, value in self._current.items() if gid in weights
}
for gid, weight in weights.items():
self._current[gid] = self._current.get(gid, 0) + weight
total_weight = sum(weights.values())
# Iterate in `games` order so ties break toward the feed's ordering,
# which is what the plugin copies did and what makes the no-boost case
# identical to a plain round robin.
ids_in_order = [gid for g in games if (gid := _game_id(g)) in weights]
best = max(ids_in_order, key=lambda gid: self._current[gid])
self._current[best] -= total_weight
return next(g for g in games if _game_id(g) == best)
def schedule(self, games: List[Dict]) -> List[str]:
"""One cycle's worth of picks, without disturbing live state.
Derived by running the picker forward on a copy, so the returned order
is exactly what repeated :meth:`next_game` calls would produce from the
current state callers can use it to preview or log the rotation
without perturbing it.
"""
weights = self.weights(games)
if not weights:
return []
preview = SmoothWeightedRotation(self._weight_for)
preview._current = dict(self._current)
order: List[str] = []
for _ in range(sum(weights.values())):
picked = preview.next_game(games)
if picked is None:
break
order.append(_game_id(picked))
return order
_REGISTRY: Dict[str, Type[RotationStrategy]] = {}
def register_rotation_strategy(name: str, factory: Type[RotationStrategy]) -> None:
"""Register a rotation strategy under ``name``.
A plugin needing an ordering core does not ship registers it here rather
than core growing a sport-specific branch. Re-registering a name replaces
it, so a plugin may also override a built-in for itself.
"""
if not name:
raise ValueError("rotation strategy name must be a non-empty string")
factory.name = name
_REGISTRY[name] = factory
def get_rotation_strategy(
name: str, weight_for: Optional[Callable[[Dict], int]] = None
) -> RotationStrategy:
"""Build the strategy registered under ``name``.
Falls back to ``"simple"`` for an unknown name rather than raising: the name
arrives from user config, and a typo should cost the boost, not the
scoreboard.
"""
factory = _REGISTRY.get(name) or _REGISTRY["simple"]
return factory(weight_for=weight_for)
register_rotation_strategy("simple", SimpleRotation)
register_rotation_strategy("weighted", WeightedCycleRotation)
register_rotation_strategy("swrr", SmoothWeightedRotation)
File diff suppressed because it is too large Load Diff
-621
View File
@@ -1,621 +0,0 @@
"""
Shared per-element style resolution for plugins (the x-style-elements system).
Plugins expose user-customizable text styling font, size, color, and x/y
pixel offsets per named element through their ``config_schema.json``. Two
declaration forms exist in the plugin ecosystem:
- The compact ``x-style-elements`` map on the ``customization`` object
(of-the-day is the reference). ``expand_style_elements()`` turns it into
the full per-element property blocks the web-UI config form renders.
- The manual ``customization`` block: hand-written per-element objects with
``font`` / ``font_size`` / ``text_color`` defaults (the scoreboards,
ledmatrix-music). No expansion needed the defaults are read as-is.
At render time a plugin builds an ``ElementStyleResolver`` from its config
and the schema-file defaults, then asks for each element's resolved style::
from src.element_style import ElementStyleResolver, defaults_from_schema_file
resolver = ElementStyleResolver(config, defaults_from_schema_file(schema_path))
title = resolver.style('title_text', classic_font='PressStart2P-Regular.ttf',
classic_size=8, classic_color=(255, 255, 255))
# title.font (PIL font / freetype.Face), title.color (RGB tuple),
# title.offset ((dx, dy)), title.user_forced, title.user_forced_color
The central subtlety is what "the user set it" means. The web UI's save flow
(``schema_manager.merge_with_defaults``) writes the FULL schema-default
object into ``config.json`` on every save, whether or not the user touched
the styling section so a value merely being *present* in config is not an
override. A value only counts as user-forced when it genuinely differs from
the schema default for that element. When nothing is forced, ``style()``
returns exactly the ``classic_*`` values the caller passes (the plugin's
pre-customization styling), so an untouched config renders byte-identically
to the classic code path. Note the classic values and the schema defaults
may legitimately differ (e.g. football's status_text: schema declares 4x6,
the classic loader fell back to PressStart) the schema default is the
override *reference*, the classic values are the *fallback*.
``style()`` never raises: any malformed config value degrades to the classic
style with a logged warning. Font faces are cached module-wide by
(resolved path, size), and font files resolve independently of the caller's
cwd (cwd ``assets/fonts/`` first for compatibility, then the core install
root derived from this module's own location).
"""
import copy
import json
import logging
import os
from dataclasses import dataclass
from typing import Any, Dict, Optional, Tuple, Union
from PIL import ImageFont
try:
import freetype
except ImportError: # pragma: no cover - freetype ships with the core
freetype = None
logger = logging.getLogger(__name__)
# Core install root (the directory that contains src/ and assets/fonts/),
# derived from this file so fonts resolve regardless of the caller's cwd.
_CORE_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_FONTS_SUBDIR = os.path.join('assets', 'fonts')
# Last-resort font when a requested file can't be found or loaded.
_FALLBACK_FONT_NAME = 'PressStart2P-Regular.ttf'
# (resolved absolute path, size) -> loaded font face. BDF faces are stateful
# in principle, but the core's own FontManager shares faces the same way.
_font_cache: Dict[Tuple[str, int], Any] = {}
# Config keys a style element block carries, in schema/UI order.
_STYLE_KEYS = ('font', 'font_size', 'text_color')
@dataclass(frozen=True)
class ElementStyle:
"""A fully resolved style for one named element."""
font: Any # PIL ImageFont or freetype.Face
color: Tuple[int, int, int] # resolved RGB
offset: Tuple[int, int] # user layout (x, y) offset, default (0, 0)
font_name: str # resolved font filename
font_size: int # resolved pixel size
user_forced: bool # font or size genuinely overridden
user_forced_color: bool # color genuinely overridden
# ---------------------------------------------------------------------------
# Font loading (cwd-independent, cached)
# ---------------------------------------------------------------------------
def resolve_font_path(font_name: str) -> Optional[str]:
"""Locate a font file by name, independent of the caller's cwd.
Tries, in order: an absolute path as given; ``assets/fonts/<name>``
relative to the cwd (the classic loaders' behavior, kept first so a
process running from a different checkout keeps its own fonts); then
``assets/fonts/<name>`` under the core install root. Returns an
absolute path, or None when the file doesn't exist anywhere.
"""
if not font_name or not isinstance(font_name, str):
return None
if os.path.isabs(font_name):
return font_name if os.path.isfile(font_name) else None
candidates = (
os.path.join(os.getcwd(), _FONTS_SUBDIR, font_name),
os.path.join(_CORE_ROOT, _FONTS_SUBDIR, font_name),
)
for candidate in candidates:
if os.path.isfile(candidate):
return os.path.abspath(candidate)
return None
def load_font(font_name: str, size: int) -> Any:
"""Load a font by filename at a pixel size, with caching and fallback.
``.bdf`` files load as ``freetype.Face`` (matching FontManager), other
files through ``PIL.ImageFont.truetype``. A missing or unloadable font
degrades to ``PressStart2P-Regular.ttf`` at the requested size, then to
PIL's built-in default — this function never raises.
"""
try:
size = max(1, int(size))
except (TypeError, ValueError):
size = 8
path = resolve_font_path(font_name)
if path is None:
logger.warning("Font file not found: %s, using fallback", font_name)
return _load_fallback_font(size)
cache_key = (path, size)
cached = _font_cache.get(cache_key)
if cached is not None:
return cached
try:
if path.lower().endswith('.bdf'):
if freetype is None:
raise RuntimeError("freetype not available for BDF fonts")
face = freetype.Face(path)
# Character size in 1/64th points at 72dpi == pixel size.
face.set_char_size(size * 64, size * 64, 72, 72)
font: Any = face
else:
font = ImageFont.truetype(path, size)
except Exception as e:
logger.warning("Error loading font %s at %spx: %s, using fallback",
path, size, e)
return _load_fallback_font(size)
_font_cache[cache_key] = font
return font
def _load_fallback_font(size: int) -> Any:
"""PressStart2P at the requested size, else PIL's built-in default."""
path = resolve_font_path(_FALLBACK_FONT_NAME)
if path is not None:
cache_key = (path, size)
cached = _font_cache.get(cache_key)
if cached is not None:
return cached
try:
font = ImageFont.truetype(path, size)
_font_cache[cache_key] = font
return font
except Exception as e:
logger.error("Error loading fallback font: %s", e)
return ImageFont.load_default()
# ---------------------------------------------------------------------------
# Schema parsing
# ---------------------------------------------------------------------------
def expand_style_elements(schema: Dict[str, Any]) -> Dict[str, Any]:
"""Expand a ``customization.x-style-elements`` declaration into the full
per-element property blocks the web-UI config form renders.
Each declared element becomes an object with ``font`` / ``font_size`` /
``text_color`` properties (only the sub-fields the declaration carries),
tagged ``x-style-managed: true``; elements declaring ``offsets: true``
additionally get an entry under ``customization.layout`` with
``x_offset`` / ``y_offset`` integers defaulting to 0. Hand-written
element blocks with the same key are left untouched.
Returns the schema unchanged (same object) when there is nothing to
expand; otherwise returns an expanded deep copy. Never raises.
"""
try:
customization = schema.get('properties', {}).get('customization')
if not isinstance(customization, dict):
return schema
declaration = customization.get('x-style-elements')
if not isinstance(declaration, dict) or not declaration:
return schema
expanded = copy.deepcopy(schema)
customization = expanded['properties']['customization']
customization.setdefault('type', 'object')
props = customization.setdefault('properties', {})
layout_props: Dict[str, Any] = {}
for element_key, spec in declaration.items():
if not isinstance(spec, dict):
continue
if element_key not in props:
props[element_key] = _element_block_from_spec(element_key, spec)
if spec.get('offsets'):
layout_props[element_key] = _offset_block_from_spec(
element_key, spec)
if layout_props:
layout = props.setdefault('layout', {
'type': 'object',
'title': 'Layout Offsets',
'description': 'Pixel offsets applied to each element '
'(positive x moves right, positive y moves down)',
'x-advanced': True,
'properties': {},
'additionalProperties': False,
})
layout.setdefault('properties', {})
for element_key, block in layout_props.items():
layout['properties'].setdefault(element_key, block)
return expanded
except Exception as e:
logger.warning("Error expanding x-style-elements: %s", e)
return schema
def _element_block_from_spec(element_key: str,
spec: Dict[str, Any]) -> Dict[str, Any]:
"""Build one expanded per-element schema block from its declaration."""
properties: Dict[str, Any] = {}
order = []
font_spec = spec.get('font')
if isinstance(font_spec, dict):
font_prop: Dict[str, Any] = {
'type': 'string',
'title': 'Font Family',
'x-advanced': True,
}
if 'default' in font_spec:
font_prop['default'] = font_spec['default']
if isinstance(font_spec.get('enum'), list):
font_prop['enum'] = list(font_spec['enum'])
properties['font'] = font_prop
order.append('font')
size_spec = spec.get('size')
if isinstance(size_spec, dict):
size_prop: Dict[str, Any] = {
'type': 'integer',
'title': 'Font Size',
'description': 'Font size in pixels',
'x-advanced': True,
}
if 'default' in size_spec:
size_prop['default'] = size_spec['default']
if 'min' in size_spec:
size_prop['minimum'] = size_spec['min']
if 'max' in size_spec:
size_prop['maximum'] = size_spec['max']
properties['font_size'] = size_prop
order.append('font_size')
color_spec = spec.get('color')
if isinstance(color_spec, dict):
color_prop: Dict[str, Any] = {
'type': 'array',
'title': 'Text Color',
'items': {'type': 'integer', 'minimum': 0, 'maximum': 255},
'minItems': 3,
'maxItems': 3,
'x-widget': 'color-picker',
}
if 'default' in color_spec:
color_prop['default'] = list(color_spec['default'])
properties['text_color'] = color_prop
order.append('text_color')
return {
'type': 'object',
'title': spec.get('title', element_key),
'x-style-managed': True,
'x-propertyOrder': order,
'additionalProperties': False,
'properties': properties,
}
def _offset_block_from_spec(element_key: str,
spec: Dict[str, Any]) -> Dict[str, Any]:
"""Build one layout.<element> offset block (x/y, default 0)."""
axis = {
'type': 'integer',
'default': 0,
'x-advanced': True,
}
return {
'type': 'object',
'title': spec.get('title', element_key),
'x-style-managed': True,
'additionalProperties': False,
'properties': {
'x_offset': dict(axis, title='X Offset'),
'y_offset': dict(axis, title='Y Offset'),
},
}
def defaults_from_schema(schema: Dict[str, Any]) -> Dict[str, Any]:
"""Extract per-element style defaults from a config schema dict.
Understands both declaration forms: the compact ``x-style-elements``
map, and hand-written per-element blocks under
``customization.properties`` (their ``font`` / ``font_size`` /
``text_color`` property defaults). Returns a config-shaped dict::
{"customization": {"<element>": {"font": ..., "font_size": ...,
"text_color": [...]}, ...}}
Elements with no declared defaults are omitted. Never raises.
"""
elements: Dict[str, Dict[str, Any]] = {}
try:
customization = schema.get('properties', {}).get('customization')
if not isinstance(customization, dict):
return {'customization': elements}
declaration = customization.get('x-style-elements')
if isinstance(declaration, dict):
for element_key, spec in declaration.items():
if not isinstance(spec, dict):
continue
defaults: Dict[str, Any] = {}
font_spec = spec.get('font')
if isinstance(font_spec, dict) and 'default' in font_spec:
defaults['font'] = font_spec['default']
size_spec = spec.get('size')
if isinstance(size_spec, dict) and 'default' in size_spec:
defaults['font_size'] = size_spec['default']
color_spec = spec.get('color')
if isinstance(color_spec, dict) and 'default' in color_spec:
defaults['text_color'] = list(color_spec['default'])
if defaults:
elements[element_key] = defaults
properties = customization.get('properties')
if isinstance(properties, dict):
for element_key, block in properties.items():
if element_key == 'layout' or element_key in elements:
continue
if not isinstance(block, dict):
continue
block_props = block.get('properties')
if not isinstance(block_props, dict):
continue
defaults = {}
for style_key in _STYLE_KEYS:
prop = block_props.get(style_key)
if isinstance(prop, dict) and 'default' in prop:
defaults[style_key] = prop['default']
if defaults:
elements[element_key] = defaults
except Exception as e:
logger.warning("Error extracting style defaults from schema: %s", e)
return {'customization': elements}
def defaults_from_schema_file(schema_path: Union[str, os.PathLike]) -> Dict[str, Any]:
"""``defaults_from_schema`` for a schema file on disk. A missing or
malformed file yields empty defaults (with a logged warning) every
configured value then counts as a user override, which is the safe
degradation. Never raises."""
try:
with open(schema_path, 'r', encoding='utf-8') as f:
schema = json.load(f)
if not isinstance(schema, dict):
raise ValueError("schema is not a JSON object")
except Exception as e:
logger.warning("Could not read style defaults from %s: %s",
schema_path, e)
return {'customization': {}}
return defaults_from_schema(schema)
# ---------------------------------------------------------------------------
# Resolver
# ---------------------------------------------------------------------------
def _normalize_color(value: Any) -> Optional[Tuple[int, int, int]]:
"""An (r, g, b) tuple of ints in 0..255, or None for anything else."""
if isinstance(value, (list, tuple)) and len(value) == 3:
try:
rgb = tuple(int(c) for c in value)
except (TypeError, ValueError):
return None
if all(0 <= c <= 255 for c in rgb):
return rgb # type: ignore[return-value]
return None
class ElementStyleResolver:
"""Resolves per-element user styling against schema defaults.
Built from a plugin's live config dict and the defaults extracted from
its own ``config_schema.json`` (``defaults_from_schema_file``). The
config dict is held by reference as ``_config`` consumers compare
identity (``resolver._config is not self.config``) to decide when a
resolver must be rebuilt after ``on_config_change`` swaps the dict.
A configured font/size/color counts as user-forced only when it differs
from the schema default (see module docstring); otherwise ``style()``
returns the caller's classic values verbatim, keeping untouched configs
byte-identical to pre-customization rendering.
"""
def __init__(self, config: Optional[Dict[str, Any]],
defaults: Optional[Dict[str, Any]] = None):
# Keep the exact object for identity-based invalidation, even if the
# caller hands us something odd; reads are guarded.
self._config = config
if isinstance(defaults, dict):
element_defaults = defaults.get('customization', {})
else:
element_defaults = {}
self._defaults: Dict[str, Any] = (
element_defaults if isinstance(element_defaults, dict) else {})
self._memo: Dict[Any, ElementStyle] = {}
# -- internal accessors -------------------------------------------------
def _customization(self) -> Dict[str, Any]:
config = self._config if isinstance(self._config, dict) else {}
customization = config.get('customization', {})
return customization if isinstance(customization, dict) else {}
def _element_config(self, element_key: str) -> Dict[str, Any]:
element = self._customization().get(element_key, {})
return element if isinstance(element, dict) else {}
def _element_defaults(self, element_key: str) -> Dict[str, Any]:
defaults = self._defaults.get(element_key, {})
return defaults if isinstance(defaults, dict) else {}
# -- public API ---------------------------------------------------------
def style(self, element_key: str,
classic_font: str = _FALLBACK_FONT_NAME,
classic_size: int = 8,
classic_color: Optional[Tuple[int, int, int]] = None) -> ElementStyle:
"""Resolve one element's style. Never raises.
Args:
element_key: Key under ``config['customization']`` (e.g.
``'title_text'``).
classic_font: Font filename the plugin's classic (pre-
customization) code used for this element.
classic_size: Classic pixel size.
classic_color: Classic RGB color, or None when the caller only
cares about the font (``.color`` then falls back to the
schema default color, else white).
Returns:
ElementStyle with the loaded font face, RGB color, (x, y)
offset, and the ``user_forced`` / ``user_forced_color`` flags.
"""
try:
memo_key = (element_key, classic_font, classic_size,
_normalize_color(classic_color) or classic_color)
memoized = self._memo.get(memo_key)
if memoized is not None:
return memoized
except Exception:
memo_key = None
try:
resolved = self._resolve(element_key, classic_font,
classic_size, classic_color)
except Exception as e:
logger.warning("Error resolving style for element '%s': %s"
"using classic style", element_key, e)
resolved = self._classic_style(classic_font, classic_size,
classic_color)
if memo_key is not None:
self._memo[memo_key] = resolved
return resolved
def offset(self, element_key: str) -> Tuple[int, int]:
"""The user's ``customization.layout.<element>`` (x, y) pixel
offset, defaulting to (0, 0). Never raises."""
return (self.offset_value(element_key, 'x_offset', 0),
self.offset_value(element_key, 'y_offset', 0))
def offset_value(self, element_key: str, axis: str, default: int = 0) -> int:
"""One ``customization.layout.<element>.<axis>`` value as an int.
``axis`` is usually ``'x_offset'`` / ``'y_offset'`` but any key is
honored (e.g. the scoreboards' ``'away_x_offset'``). Numeric
strings are coerced; anything else degrades to ``default``. Never
raises.
"""
try:
layout = self._customization().get('layout', {})
if not isinstance(layout, dict):
return int(default)
element = layout.get(element_key, {})
if not isinstance(element, dict):
return int(default)
value = element.get(axis, default)
if isinstance(value, bool):
return int(default)
if isinstance(value, (int, float)):
return int(value)
if isinstance(value, str):
try:
return int(float(value))
except (TypeError, ValueError):
logger.warning(
"Invalid layout offset for %s.%s: %r, using %s",
element_key, axis, value, default)
return int(default)
return int(default)
except Exception as e:
logger.warning("Error reading layout offset %s.%s: %s",
element_key, axis, e)
try:
return int(default)
except (TypeError, ValueError):
return 0
# -- resolution internals -----------------------------------------------
def _resolve(self, element_key: str, classic_font: str,
classic_size: int,
classic_color: Optional[Tuple[int, int, int]]) -> ElementStyle:
element_config = self._element_config(element_key)
element_defaults = self._element_defaults(element_key)
# Font family: forced only when it differs from the schema default
# (falling back to the classic font as the reference when the
# schema declares none).
default_font = element_defaults.get('font', classic_font)
configured_font = element_config.get('font')
font_forced = (isinstance(configured_font, str) and configured_font
and configured_font != default_font)
# Font size: same rule, with defensive int coercion.
default_size = self._coerce_size(
element_defaults.get('font_size'), None)
if default_size is None:
default_size = self._coerce_size(classic_size, 8)
configured_size = self._coerce_size(element_config.get('font_size'),
None)
size_forced = (configured_size is not None
and configured_size != default_size)
font_name = configured_font if font_forced else classic_font
font_size = configured_size if size_forced else self._coerce_size(
classic_size, 8)
user_forced = bool(font_forced or size_forced)
# Color: forced only when it differs from the schema default (or,
# absent one, from the classic color).
default_color = _normalize_color(element_defaults.get('text_color'))
configured_color = _normalize_color(element_config.get('text_color'))
reference_color = (default_color if default_color is not None
else _normalize_color(classic_color))
color_forced = (configured_color is not None
and configured_color != reference_color)
if color_forced:
color = configured_color
else:
color = (_normalize_color(classic_color) or default_color
or (255, 255, 255))
return ElementStyle(
font=load_font(font_name, font_size),
color=color,
offset=self.offset(element_key),
font_name=font_name,
font_size=font_size,
user_forced=user_forced,
user_forced_color=bool(color_forced),
)
def _classic_style(self, classic_font: str, classic_size: int,
classic_color: Optional[Tuple[int, int, int]]) -> ElementStyle:
"""The untouched fallback style — used when resolution itself
fails, so ``style()`` can keep its never-raises promise."""
size = self._coerce_size(classic_size, 8)
return ElementStyle(
font=load_font(classic_font, size),
color=_normalize_color(classic_color) or (255, 255, 255),
offset=(0, 0),
font_name=classic_font,
font_size=size,
user_forced=False,
user_forced_color=False,
)
@staticmethod
def _coerce_size(value: Any, default: Optional[int]) -> Optional[int]:
"""An int pixel size, or ``default`` for None/garbage."""
if value is None or isinstance(value, bool):
return default
try:
size = int(value)
except (TypeError, ValueError):
return default
return size if size > 0 else default
+1 -21
View File
@@ -659,25 +659,6 @@ class FontManager:
# ==================== Font Discovery ====================
@staticmethod
def _resolve_asset_path(relative_path: str) -> str:
"""Resolve a repo-relative asset path independently of the process cwd.
Prefers the working directory (preserving behavior when the process
runs from the install root), then falls back to the install root
derived from this module's own location. Without the fallback, any
process started outside the install root (e.g. the plugin safety
harness on CI) silently loses every font and degrades to PIL's
default face.
"""
if os.path.exists(relative_path):
return relative_path
install_root = Path(__file__).resolve().parent.parent
candidate = install_root / relative_path
if candidate.exists():
return str(candidate)
return relative_path
def _initialize_fonts(self):
"""Initialize font catalog and validate configuration."""
self._scan_fonts_directory()
@@ -686,7 +667,7 @@ class FontManager:
def _scan_fonts_directory(self):
"""Scan assets/fonts directory for available fonts."""
fonts_dir = self._resolve_asset_path("assets/fonts")
fonts_dir = "assets/fonts"
if not os.path.exists(fonts_dir):
logger.warning(f"Fonts directory not found: {fonts_dir}")
return
@@ -702,7 +683,6 @@ class FontManager:
def _register_common_fonts(self):
"""Register common font aliases from common_fonts dictionary."""
for family_name, font_path in self.common_fonts.items():
font_path = self._resolve_asset_path(font_path)
# Check if font file exists
if os.path.exists(font_path):
# Register the common font name (overrides auto-generated name if exists)
+1 -11
View File
@@ -115,17 +115,7 @@ class SchemaManager:
if not isinstance(schema, dict):
self.logger.error(f"Invalid schema format for {plugin_id}: not a dictionary")
return None
# Expand any customization.x-style-elements declaration into the
# full per-element style blocks (font/size/color + layout
# offsets) the web-UI config form renders. No-op for schemas
# without the declaration; never raises.
try:
from src.element_style import expand_style_elements
schema = expand_style_elements(schema)
except ImportError:
pass
# Cache the schema
self._schema_cache[plugin_id] = schema
-396
View File
@@ -1,396 +0,0 @@
"""
Tests for src.element_style the shared per-element style resolver behind
the x-style-elements system.
The contract under test (defined by the plugin consumers: of-the-day,
ledmatrix-music, football-scoreboard):
- defaults_from_schema_file parses BOTH declaration forms the compact
x-style-elements map and hand-written customization blocks.
- expand_style_elements turns an x-style-elements declaration into the full
per-element blocks (plus layout offsets) the web-UI form renders.
- A config value counts as user-forced only when it genuinely differs from
the schema default; untouched (or schema-default-populated) configs
resolve to EXACTLY the classic font/size/color, keeping rendering
byte-identical.
- style() never raises; malformed input degrades to the classic style.
"""
import json
import os
import pytest
from PIL import ImageFont
from src.element_style import (
ElementStyleResolver,
defaults_from_schema,
defaults_from_schema_file,
expand_style_elements,
load_font,
resolve_font_path,
)
# ---------------------------------------------------------------------------
# Schema fixtures
# ---------------------------------------------------------------------------
# Compact declaration form (of-the-day's shape).
STYLE_ELEMENTS_SCHEMA = {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": False},
"customization": {
"type": "object",
"x-style-elements": {
"title_text": {
"title": "Title",
"font": {"default": "PressStart2P-Regular.ttf"},
"size": {"default": 8, "min": 4, "max": 16},
"color": {"default": [255, 255, 255]},
"offsets": True,
},
"body_text": {
"title": "Body Text",
"font": {"default": "4x6-font.ttf"},
"size": {"default": 6, "min": 4, "max": 12},
"color": {"default": [200, 200, 200]},
"offsets": True,
},
},
},
},
}
# Manual declaration form (the scoreboards' / music's shape).
MANUAL_SCHEMA = {
"type": "object",
"properties": {
"customization": {
"type": "object",
"properties": {
"status_text": {
"type": "object",
"properties": {
"font": {"type": "string",
"default": "4x6-font.ttf"},
"font_size": {"type": "integer", "default": 6},
},
},
"score_text": {
"type": "object",
"properties": {
"font": {"type": "string",
"default": "PressStart2P-Regular.ttf"},
"font_size": {"type": "integer", "default": 10},
"text_color": {"type": "array",
"default": [255, 255, 0]},
},
},
"layout": {"type": "object", "properties": {}},
},
},
},
}
@pytest.fixture
def style_schema_path(tmp_path):
path = tmp_path / "config_schema.json"
path.write_text(json.dumps(STYLE_ELEMENTS_SCHEMA))
return str(path)
@pytest.fixture
def manual_schema_path(tmp_path):
path = tmp_path / "config_schema.json"
path.write_text(json.dumps(MANUAL_SCHEMA))
return str(path)
def _resolver(config, schema_path):
return ElementStyleResolver(config, defaults_from_schema_file(schema_path))
# ---------------------------------------------------------------------------
# Schema parsing
# ---------------------------------------------------------------------------
class TestDefaultsFromSchema:
def test_x_style_elements_defaults(self, style_schema_path):
defaults = defaults_from_schema_file(style_schema_path)
cust = defaults["customization"]
assert cust["title_text"] == {"font": "PressStart2P-Regular.ttf",
"font_size": 8,
"text_color": [255, 255, 255]}
assert cust["body_text"]["font_size"] == 6
assert cust["body_text"]["text_color"] == [200, 200, 200]
def test_manual_block_defaults(self, manual_schema_path):
defaults = defaults_from_schema_file(manual_schema_path)
cust = defaults["customization"]
assert cust["status_text"] == {"font": "4x6-font.ttf", "font_size": 6}
assert cust["score_text"]["text_color"] == [255, 255, 0]
assert "layout" not in cust
def test_missing_file_degrades_to_empty(self, tmp_path):
defaults = defaults_from_schema_file(str(tmp_path / "nope.json"))
assert defaults == {"customization": {}}
def test_malformed_file_degrades_to_empty(self, tmp_path):
path = tmp_path / "bad.json"
path.write_text("{not json")
assert defaults_from_schema_file(str(path)) == {"customization": {}}
def test_schema_without_customization(self):
assert defaults_from_schema({"properties": {}}) == {"customization": {}}
class TestExpandStyleElements:
def test_expansion_generates_blocks(self):
expanded = expand_style_elements(STYLE_ELEMENTS_SCHEMA)
cust = expanded["properties"]["customization"]["properties"]
title = cust["title_text"]
assert title["x-style-managed"] is True
assert title["properties"]["font"]["default"] == \
"PressStart2P-Regular.ttf"
assert title["properties"]["font_size"]["default"] == 8
assert title["properties"]["font_size"]["minimum"] == 4
assert title["properties"]["font_size"]["maximum"] == 16
assert cust["body_text"]["properties"]["text_color"]["default"] == \
[200, 200, 200]
def test_expansion_generates_layout_offsets(self):
expanded = expand_style_elements(STYLE_ELEMENTS_SCHEMA)
layout = expanded["properties"]["customization"]["properties"]["layout"]
assert "title_text" in layout["properties"]
offsets = layout["properties"]["body_text"]["properties"]
assert offsets["x_offset"]["default"] == 0
assert offsets["y_offset"]["default"] == 0
def test_input_schema_not_mutated(self):
before = json.dumps(STYLE_ELEMENTS_SCHEMA, sort_keys=True)
expand_style_elements(STYLE_ELEMENTS_SCHEMA)
assert json.dumps(STYLE_ELEMENTS_SCHEMA, sort_keys=True) == before
def test_no_declaration_returns_same_object(self):
assert expand_style_elements(MANUAL_SCHEMA) is MANUAL_SCHEMA
empty = {"properties": {}}
assert expand_style_elements(empty) is empty
def test_garbage_input_never_raises(self):
bad = {"properties": {"customization": {"x-style-elements": "nope"}}}
assert expand_style_elements(bad) is bad
# ---------------------------------------------------------------------------
# Classic identity: untouched configs resolve to the classic style
# ---------------------------------------------------------------------------
class TestClassicIdentity:
def test_bare_config_resolves_classic(self, style_schema_path):
r = _resolver({}, style_schema_path)
style = r.style("title_text", classic_font="PressStart2P-Regular.ttf",
classic_size=8, classic_color=(255, 255, 255))
assert style.font_name == "PressStart2P-Regular.ttf"
assert style.font_size == 8
assert style.color == (255, 255, 255)
assert style.offset == (0, 0)
assert not style.user_forced
assert not style.user_forced_color
assert isinstance(style.font, ImageFont.FreeTypeFont)
assert style.font.size == 8
def test_schema_populated_config_is_not_an_override(self, style_schema_path):
# The web UI's save flow writes the full schema defaults into config
# on every save — that must not count as a user override.
config = {"customization": {
"title_text": {"font": "PressStart2P-Regular.ttf", "font_size": 8,
"text_color": [255, 255, 255]},
"layout": {"title_text": {"x_offset": 0, "y_offset": 0}},
}}
style = _resolver(config, style_schema_path).style(
"title_text", classic_font="PressStart2P-Regular.ttf",
classic_size=8, classic_color=(255, 255, 255))
assert not style.user_forced
assert not style.user_forced_color
assert style.font_size == 8
assert style.color == (255, 255, 255)
assert style.offset == (0, 0)
def test_schema_default_falls_back_to_classic_not_schema_font(
self, manual_schema_path):
# Classic values and schema defaults can legitimately differ
# (football's status_text: schema says 4x6, classic loader used
# PressStart). A schema-default config value must yield the CLASSIC
# font, byte-identical to the old loader.
config = {"customization": {"status_text": {"font": "4x6-font.ttf",
"font_size": 6}}}
style = _resolver(config, manual_schema_path).style(
"status_text", classic_font="PressStart2P-Regular.ttf",
classic_size=6)
assert not style.user_forced
assert style.font_name == "PressStart2P-Regular.ttf"
assert style.font_size == 6
def test_same_font_object_from_cache(self, style_schema_path):
r = _resolver({}, style_schema_path)
s1 = r.style("title_text", classic_font="PressStart2P-Regular.ttf",
classic_size=8)
s2 = ElementStyleResolver({}, {}).style(
"title_text", classic_font="PressStart2P-Regular.ttf",
classic_size=8)
assert s1.font is s2.font
# ---------------------------------------------------------------------------
# User overrides engage
# ---------------------------------------------------------------------------
class TestUserOverrides:
def test_font_override(self, style_schema_path):
config = {"customization": {"title_text": {"font": "4x6-font.ttf"}}}
style = _resolver(config, style_schema_path).style(
"title_text", classic_font="PressStart2P-Regular.ttf",
classic_size=8)
assert style.user_forced
assert style.font_name == "4x6-font.ttf"
assert style.font_size == 8 # size untouched -> classic
def test_size_override(self, style_schema_path):
config = {"customization": {"title_text": {
"font": "PressStart2P-Regular.ttf", "font_size": 16}}}
style = _resolver(config, style_schema_path).style(
"title_text", classic_font="PressStart2P-Regular.ttf",
classic_size=8)
assert style.user_forced
assert style.font_name == "PressStart2P-Regular.ttf"
assert style.font_size == 16
assert style.font.size == 16
def test_size_override_detected_vs_schema_default(self, manual_schema_path):
# font_size 8 differs from the schema default 6 -> forced.
config = {"customization": {"status_text": {"font": "4x6-font.ttf",
"font_size": 8}}}
style = _resolver(config, manual_schema_path).style(
"status_text", classic_font="PressStart2P-Regular.ttf",
classic_size=6)
assert style.user_forced
assert style.font_size == 8
def test_color_override(self, style_schema_path):
config = {"customization": {"title_text": {"text_color": [255, 0, 0]}}}
style = _resolver(config, style_schema_path).style(
"title_text", classic_font="PressStart2P-Regular.ttf",
classic_size=8, classic_color=(255, 255, 255))
assert style.user_forced_color
assert not style.user_forced
assert style.color == (255, 0, 0)
def test_offsets(self, style_schema_path):
config = {"customization": {"layout": {
"title_text": {"x_offset": 4, "y_offset": -2}}}}
r = _resolver(config, style_schema_path)
assert r.offset("title_text") == (4, -2)
assert r.offset("body_text") == (0, 0)
style = r.style("title_text", classic_font="PressStart2P-Regular.ttf",
classic_size=8)
assert style.offset == (4, -2)
def test_offset_value_arbitrary_axis_and_strings(self, style_schema_path):
# The scoreboards read non-standard axes (away_x_offset) and configs
# can carry numeric strings/floats.
config = {"customization": {"layout": {"records": {
"away_x_offset": "3", "home_x_offset": 2.7}}}}
r = _resolver(config, style_schema_path)
assert r.offset_value("records", "away_x_offset", 0) == 3
assert r.offset_value("records", "home_x_offset", 0) == 2
assert r.offset_value("records", "missing_axis", 5) == 5
# ---------------------------------------------------------------------------
# Defensive degradation
# ---------------------------------------------------------------------------
class TestDegradation:
@pytest.mark.parametrize("config", [
None,
{"customization": "not a dict"},
{"customization": {"title_text": "not a dict"}},
{"customization": {"title_text": {"font": 42, "font_size": "huge",
"text_color": "red"}}},
{"customization": {"layout": {"title_text": {"x_offset": "junk"}}}},
])
def test_bad_config_degrades_to_classic(self, config, style_schema_path):
style = _resolver(config, style_schema_path).style(
"title_text", classic_font="PressStart2P-Regular.ttf",
classic_size=8, classic_color=(10, 20, 30))
assert not style.user_forced
assert not style.user_forced_color
assert style.font_name == "PressStart2P-Regular.ttf"
assert style.font_size == 8
assert style.color == (10, 20, 30)
assert style.offset == (0, 0)
def test_unknown_font_falls_back(self, style_schema_path):
config = {"customization": {"title_text": {"font": "no-such.ttf"}}}
style = _resolver(config, style_schema_path).style(
"title_text", classic_font="PressStart2P-Regular.ttf",
classic_size=8)
# The override IS honored as forced, but the face degrades safely.
assert style.user_forced
assert style.font is not None
def test_empty_defaults_treats_config_as_reference_to_classic(self):
# No schema defaults at all: a config value equal to the classic
# value is not forced; a different one is.
r = ElementStyleResolver(
{"customization": {"e": {"font": "4x6-font.ttf"}}}, {})
assert not r.style("e", classic_font="4x6-font.ttf",
classic_size=6).user_forced
assert r.style("e", classic_font="PressStart2P-Regular.ttf",
classic_size=6).user_forced
# ---------------------------------------------------------------------------
# Resolver plumbing the consumers rely on
# ---------------------------------------------------------------------------
class TestResolverPlumbing:
def test_config_identity_exposed(self, style_schema_path):
# Consumers rebuild the resolver when the config dict is swapped:
# `resolver._config is not self.config`.
config = {"customization": {}}
r = _resolver(config, style_schema_path)
assert r._config is config
def test_font_path_resolution_is_cwd_independent(self, tmp_path,
monkeypatch):
monkeypatch.chdir(tmp_path) # no assets/fonts under cwd
path = resolve_font_path("PressStart2P-Regular.ttf")
assert path is not None and os.path.isfile(path)
font = load_font("PressStart2P-Regular.ttf", 8)
assert isinstance(font, ImageFont.FreeTypeFont)
def test_bdf_font_loads_as_freetype_face(self):
import freetype
font = load_font("5x7.bdf", 7)
assert isinstance(font, freetype.Face)
def test_schema_manager_expands_on_load(self, tmp_path):
# The web-UI form path: SchemaManager.load_schema serves the
# expanded schema so the style blocks actually appear in the UI.
from src.plugin_system.schema_manager import SchemaManager
plugin_dir = tmp_path / "plugins" / "styled"
plugin_dir.mkdir(parents=True)
(plugin_dir / "config_schema.json").write_text(
json.dumps(STYLE_ELEMENTS_SCHEMA))
(plugin_dir / "manifest.json").write_text(json.dumps({
"id": "styled", "config_schema": "config_schema.json"}))
manager = SchemaManager(plugins_dir=tmp_path / "plugins",
project_root=tmp_path)
schema = manager.load_schema("styled")
assert schema is not None
cust = schema["properties"]["customization"]["properties"]
assert cust["title_text"]["x-style-managed"] is True
assert "title_text" in cust["layout"]["properties"]
-634
View File
@@ -1,634 +0,0 @@
"""Characterization tests for src/base_classes/sports.py.
These tests PIN the current behavior of SportsCore / SportsUpcoming /
SportsRecent / SportsLive ahead of the sports-unification merge (features
from nine drifted plugin copies are about to be folded in). They assert
what the code DOES today, not what it should do a few pinned behaviors
look like bugs and are flagged inline with "PINNED AS-IS".
Coverage:
- `_extract_game_details_common` + the four sport extractors
(football/hockey/baseball/basketball) against realistic ESPN scoreboard
events (adapted from the ledmatrix-plugins monorepo test fixtures).
The output must remain a superset of the frozen skin view-model
contract (GUARANTEED_KEYS, imported from test_skin_system).
- update() flow for concrete SportsUpcoming/SportsRecent/SportsLive
subclasses: population, favorite-team filtering, empty/failed-fetch
tolerance. All offline: `_fetch_data` reads a pre-seeded mocked cache
and every instance's requests session raises ConnectionError.
- Rendering smoke: one `display()` per mode class at 128x32 draws
non-zero ink onto a real PIL image.
- Guard rails: the skin-system seam methods on SportsCore must survive
the merge.
"""
import logging
import sys
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import MagicMock
import pytest
import pytz
import requests
from freezegun import freeze_time
from PIL import Image
# src.base_classes.sports transitively imports the hardware matrix driver;
# stub it so these tests can import the sports base classes off-device.
sys.modules.setdefault("rgbmatrix", MagicMock())
from src.base_classes.baseball import Baseball
from src.base_classes.basketball import Basketball
from src.base_classes.football import Football
from src.base_classes.hockey import Hockey, HockeyLive
from src.base_classes.sports import (
SportsCore,
SportsLive,
SportsRecent,
SportsUpcoming,
)
# Reuse the frozen v1.0 skin view-model contract rather than redeclaring it.
from test.test_skin_system import GUARANTEED_KEYS
SPORT_CLASSES = [Football, Hockey, Baseball, Basketball]
SPORT_IDS = ["football", "hockey", "baseball", "basketball"]
# All update()-flow tests run at this frozen instant so the 21-day
# SportsRecent window and time.time() interval gates are deterministic.
FROZEN_NOW = "2026-01-20 12:00:00"
# ---------------------------------------------------------------------------
# ESPN scoreboard event builders (shape adapted from the monorepo fixtures,
# e.g. ledmatrix-plugins/plugins/hockey-scoreboard/test/fixtures/mock.json:
# team-shaped competitors with status/score/records).
# ---------------------------------------------------------------------------
def _competitor(abbr, team_id, score, home_away, record="30-10-5"):
return {
"homeAway": home_away,
"id": team_id,
"score": score,
"team": {
"id": team_id,
"abbreviation": abbr,
"name": abbr.title(),
"displayName": abbr.title(),
"logo": None,
},
"records": [{"summary": record}],
# The hockey extractor iterates competitor["statistics"] and
# returns None for the whole event when the key is absent (see
# test_hockey_event_without_statistics_returns_none).
"statistics": [],
}
def make_event(event_id, state, date, home=("TB", "20", "3"),
away=("DAL", "9", "2"), period=2, clock="12:45",
name=None, short_detail=None, situation=None,
home_record="30-10-5", away_record="25-14-6"):
"""Build a realistic ESPN scoreboard event in the given state
('in' / 'post' / 'pre')."""
defaults = {
"in": ("STATUS_IN_PROGRESS", f"P{period} {clock}"),
"post": ("STATUS_FINAL", "Final"),
"pre": ("STATUS_SCHEDULED", "1/15 - 6:30 PM"),
}
default_name, default_detail = defaults[state]
status = {
"clock": 0.0,
"displayClock": clock,
"period": period,
"type": {
"id": "2",
"name": name or default_name,
"state": state,
"completed": state == "post",
"description": short_detail or default_detail,
"detail": short_detail or default_detail,
"shortDetail": short_detail or default_detail,
},
}
competition = {
"id": event_id,
"date": date,
"status": status,
"competitors": [
_competitor(home[0], home[1], home[2], "home", home_record),
_competitor(away[0], away[1], away[2], "away", away_record),
],
}
if situation is not None:
competition["situation"] = situation
return {
"id": event_id,
"date": date,
"name": f"{away[0]} at {home[0]}",
"shortName": f"{away[0]} @ {home[0]}",
"competitions": [competition],
# Real ESPN payloads duplicate status at the event top level; the
# baseball extractor reads it there for live innings.
"status": status,
}
def make_probe(favorites=None):
"""Bare-bones SportsCore stand-in for exercising the real extractors
unbound (same pattern as TestViewModelContract in test_skin_system)."""
probe = MagicMock()
probe.logger = logging.getLogger("test_sports_base_characterization")
probe.favorite_teams = list(favorites or [])
probe.config = {}
probe.logo_dir = Path("assets/logos")
probe._get_timezone.return_value = pytz.utc
probe.display_manager.format_date_with_ordinal.return_value = "Jan 15th"
# The sport extractors call self._extract_game_details_common — route
# it to the real implementation instead of a MagicMock.
probe._extract_game_details_common = (
lambda event: SportsCore._extract_game_details_common(probe, event))
return probe
def extract(sport_cls, event, favorites=None):
return sport_cls._extract_game_details(make_probe(favorites), event)
# ---------------------------------------------------------------------------
# 1. _extract_game_details_common contract, per wired sport
# ---------------------------------------------------------------------------
class TestExtractGameDetailsContract:
@pytest.mark.parametrize("sport_cls", SPORT_CLASSES, ids=SPORT_IDS)
def test_live_event_guaranteed_keys_and_values(self, sport_cls):
event = make_event("401", "in", "2026-01-15T18:30:00Z")
details = extract(sport_cls, event)
assert details is not None
missing = [k for k in GUARANTEED_KEYS if k not in details]
assert not missing, (
f"{sport_cls.__name__} extractor no longer emits {missing}"
"these keys are the frozen skin view-model contract.")
assert details["id"] == "401"
assert details["home_abbr"] == "TB"
assert details["away_abbr"] == "DAL"
assert details["home_id"] == "20"
assert details["away_id"] == "9"
assert details["home_score"] == "3"
assert details["away_score"] == "2"
assert details["home_record"] == "30-10-5"
assert details["away_record"] == "25-14-6"
assert details["is_live"] is True
assert details["is_final"] is False
assert details["is_upcoming"] is False
assert details["status_text"] == "P2 12:45"
assert details["start_time_utc"] == datetime(
2026, 1, 15, 18, 30, tzinfo=timezone.utc)
# Sport-specific formatting of the same event:
if sport_cls in (Football, Basketball):
assert details["period_text"] == "Q2"
assert details["clock"] == "12:45"
elif sport_cls is Hockey:
assert details["period_text"] == "P2"
assert details["clock"] == "12:45"
else: # Baseball keys inning/status instead of period_text
assert details["inning"] == 2
assert details["status_state"] == "in"
@pytest.mark.parametrize("sport_cls", SPORT_CLASSES, ids=SPORT_IDS)
def test_final_event_classification(self, sport_cls):
event = make_event("402", "post", "2026-01-14T00:00:00Z",
home=("BOS", "1", "4"), away=("TOR", "21", "2"),
period=3, clock="0:00")
details = extract(sport_cls, event)
assert details is not None
assert details["is_final"] is True
assert details["is_live"] is False
assert details["is_upcoming"] is False
assert details["home_score"] == "4"
assert details["away_score"] == "2"
if sport_cls in (Football, Hockey, Basketball):
assert details["period_text"] == "Final"
@pytest.mark.parametrize("sport_cls", SPORT_CLASSES, ids=SPORT_IDS)
def test_upcoming_event_classification(self, sport_cls):
event = make_event("403", "pre", "2026-01-15T18:30:00Z",
home=("NYR", "13", "0"), away=("PIT", "16", "0"),
period=0, clock="0:00")
details = extract(sport_cls, event)
assert details is not None
assert details["is_upcoming"] is True
assert details["is_live"] is False
assert details["is_final"] is False
# Local time formatting (probe timezone is UTC): 18:30Z -> 6:30PM,
# date rendered through display_manager.format_date_with_ordinal.
assert details["game_time"] == "6:30PM"
assert details["game_date"] == "Jan 15th"
def test_halftime_state_flags(self):
# is_halftime keys off name STATUS_HALFTIME (or state "halftime")
# while state "in" still counts as live.
event = make_event("404", "in", "2026-01-15T18:30:00Z",
name="STATUS_HALFTIME", short_detail="Halftime")
details, *_ = SportsCore._extract_game_details_common(
make_probe(), event)
assert details["is_live"] is True
assert details["is_halftime"] is True
def test_state_name_conflict_is_both_final_and_upcoming(self):
# PINNED AS-IS (looks like a bug): is_upcoming also matches on
# status.type.name ('scheduled'/'pre-game'/'status_scheduled'), so
# an event with state="post" but name="Scheduled" reports BOTH
# is_final and is_upcoming True.
event = make_event("405", "post", "2026-01-14T00:00:00Z",
name="Scheduled")
details, *_ = SportsCore._extract_game_details_common(
make_probe(), event)
assert details["is_final"] is True
assert details["is_upcoming"] is True
def test_zero_zero_record_blanked(self):
event = make_event("406", "pre", "2026-01-15T18:30:00Z",
home_record="0-0", away_record="0-0-0")
details, *_ = SportsCore._extract_game_details_common(
make_probe(), event)
assert details["home_record"] == ""
assert details["away_record"] == ""
def test_missing_abbreviation_uses_name_prefix(self):
event = make_event("407", "pre", "2026-01-15T18:30:00Z")
for comp in event["competitions"][0]["competitors"]:
del comp["team"]["abbreviation"]
comp["team"]["name"] = "Sharks" if comp["homeAway"] == "home" \
else "Penguins"
details, *_ = SportsCore._extract_game_details_common(
make_probe(), event)
assert details["home_abbr"] == "Sha"
assert details["away_abbr"] == "Pen"
def test_empty_or_malformed_event_returns_none_tuple(self):
probe = make_probe()
assert SportsCore._extract_game_details_common(probe, {}) == \
(None, None, None, None, None)
assert SportsCore._extract_game_details_common(probe, None) == \
(None, None, None, None, None)
# Malformed event (no competitions) is swallowed, not raised.
assert SportsCore._extract_game_details_common(
probe, {"id": "999"}) == (None, None, None, None, None)
def test_football_live_situation_fields(self):
event = make_event(
"408", "in", "2026-01-15T18:30:00Z",
situation={
"shortDownDistanceText": "3rd & 4",
"downDistanceText": "3rd & 4 at TB 30",
"isRedZone": False,
"possession": "20",
"homeTimeouts": 2,
"awayTimeouts": 3,
})
details = extract(Football, event)
assert details["down_distance_text"] == "3rd & 4"
assert details["down_distance_text_long"] == "3rd & 4 at TB 30"
assert details["possession"] == "20"
assert details["possession_indicator"] == "home" # matches home id
assert details["home_timeouts"] == 2
assert details["away_timeouts"] == 3
def test_hockey_live_power_play_and_default_shots(self):
event = make_event("409", "in", "2026-01-15T18:30:00Z",
situation={"isPowerPlay": True, "penalties": ""})
details = extract(Hockey, event)
assert details["power_play"] is True
# Empty statistics arrays -> save-percentage math yields 0 shots.
assert details["home_shots"] == 0
assert details["away_shots"] == 0
def test_hockey_event_without_statistics_still_extracts(self):
# FIXED (was pinned as returning None): the hockey extractor used to
# iterate competitor["statistics"] unguarded, so a competitor without
# the key raised KeyError internally and the WHOLE event was dropped
# despite valid scores and status. It now defaults to an empty list,
# matching the behaviour already shipped in the hockey plugin, so the
# event survives with zeroed shot counts -- the same values
# test_hockey_live_power_play_and_default_shots already expects for an
# EMPTY statistics array.
event = make_event("410", "in", "2026-01-15T18:30:00Z")
for comp in event["competitions"][0]["competitors"]:
del comp["statistics"]
details = extract(Hockey, event)
assert details is not None
assert details["home_abbr"] == "TB"
assert details["away_abbr"] == "DAL"
assert details["home_score"] == "3"
assert details["home_shots"] == 0
assert details["away_shots"] == 0
def test_baseball_live_inning_and_count(self):
event = make_event(
"411", "in", "2026-07-16T23:05:00Z",
home=("LAD", "19", "5"), away=("SF", "26", "3"),
period=7, short_detail="Bot 7th",
situation={
"count": {"balls": 2, "strikes": 1},
"outs": 2,
"onFirst": True,
"onSecond": False,
"onThird": True,
})
details = extract(Baseball, event)
assert details["inning"] == 7 # from top-level status period
assert details["inning_half"] == "bottom"
assert details["balls"] == 2
assert details["strikes"] == 1
assert details["outs"] == 2
assert details["bases_occupied"] == [True, False, True]
assert details["status"] == "status_in_progress"
assert details["series_summary"] == ""
def test_baseball_live_without_top_level_status_still_extracts(self):
# FIXED (was pinned as returning None): the baseball extractor read
# game_event["status"] -- the event TOP-LEVEL status -- for the
# inning, so an otherwise-valid live event lacking that duplicate key
# was dropped entirely. Real ESPN events carry status in both places,
# but MiLB events (synthesized from the MLB Stats API into an
# ESPN-like shape) populate only the competition-level one. It now
# reads the competition-level `status` that
# _extract_game_details_common has already validated, so it can never
# be missing at that point.
event = make_event("412", "in", "2026-07-16T23:05:00Z", period=7)
del event["status"]
details = extract(Baseball, event)
assert details is not None
assert details["inning"] == 7
# ---------------------------------------------------------------------------
# 2. update() flow on concrete subclasses (offline, cache-fed)
# ---------------------------------------------------------------------------
class _UpcomingHarness(Hockey, SportsUpcoming):
"""Cheapest concrete SportsUpcoming: hockey extractor + cache-fed data."""
def _fetch_data(self):
return self.cache_manager.get(f"{self.sport_key}_schedule")
class _RecentHarness(Hockey, SportsRecent):
def _fetch_data(self):
return self.cache_manager.get(f"{self.sport_key}_schedule")
class _LiveHarness(HockeyLive):
def _fetch_data(self):
return self.cache_manager.get(f"{self.sport_key}_schedule")
def make_schedule():
"""A mixed schedule around the frozen 'now' of 2026-01-20."""
return {"events": [
# Final 6 days ago — inside the recent 21-day window.
make_event("9001", "post", "2026-01-14T00:00:00Z",
home=("BOS", "1", "4"), away=("TOR", "21", "2"),
period=3, clock="0:00"),
# Live game.
make_event("9002", "in", "2026-01-15T00:30:00Z",
home=("TB", "20", "3"), away=("DAL", "9", "2")),
# Two scheduled games.
make_event("9003", "pre", "2026-01-16T00:00:00Z",
home=("NYR", "13", "0"), away=("PIT", "16", "0"),
period=0),
make_event("9004", "pre", "2026-01-17T00:00:00Z",
home=("BOS", "1", "0"), away=("MTL", "10", "0"),
period=0),
# Final from November — outside the recent 21-day window.
make_event("9005", "post", "2025-11-01T00:00:00Z",
home=("SEA", "124292", "1"), away=("VAN", "22", "5"),
period=3, clock="0:00"),
]}
@pytest.fixture
def build_manager(monkeypatch, tmp_path):
"""Factory for concrete sports managers: mocked display/cache managers,
logo dir redirected to tmp, background service stubbed, and the
requests session rigged to prove nothing hits the network."""
monkeypatch.setattr(
SportsCore, "_initialize_logo_dir", lambda self, configured: tmp_path)
monkeypatch.setattr(
"src.base_classes.sports.core.get_background_service",
lambda *args, **kwargs: MagicMock())
def build(cls, schedule, **mode_cfg):
config = {
"timezone": "UTC",
"display": {},
"nhl_scoreboard": {"enabled": True, **mode_cfg},
}
display_manager = MagicMock()
display_manager.matrix.width = 128
display_manager.matrix.height = 32
display_manager.width = 128
display_manager.height = 32
display_manager.image = Image.new("RGB", (128, 32))
display_manager.format_date_with_ordinal.side_effect = (
lambda dt: dt.strftime("%b %d"))
cache_manager = MagicMock()
cache_manager.get.return_value = schedule
cache_manager.cache_dir = str(tmp_path)
manager = cls(config, display_manager, cache_manager,
logging.getLogger("test_sports_base_characterization"),
"nhl")
# Safety net: any accidental network fetch must fail loudly.
manager.session = MagicMock()
manager.session.get.side_effect = requests.exceptions.ConnectionError(
"characterization tests are offline")
return manager
return build
def _ids(games):
return [g["id"] for g in games]
@freeze_time(FROZEN_NOW)
class TestUpcomingUpdateFlow:
def test_populates_games_list_sorted_by_start_time(self, build_manager):
manager = build_manager(_UpcomingHarness, make_schedule())
manager.update()
# PINNED AS-IS: SportsUpcoming filters purely on is_upcoming
# (state 'pre') — there is NO date filter, so 'pre' games whose
# start time is already in the past (9003/9004 vs frozen 1/20)
# are still shown.
assert _ids(manager.games_list) == ["9003", "9004"]
assert manager.current_game["id"] == "9003"
def test_filters_by_favorite_teams(self, build_manager):
manager = build_manager(_UpcomingHarness, make_schedule(),
show_favorite_teams_only=True,
favorite_teams=["BOS"])
manager.update()
assert _ids(manager.games_list) == ["9004"]
assert manager.current_game["id"] == "9004"
def test_favorites_only_with_no_favorites_shows_nothing(
self, build_manager):
# PINNED AS-IS: show_favorite_teams_only=True with an empty
# favorite_teams list drops every game rather than falling back
# to showing all games.
manager = build_manager(_UpcomingHarness, make_schedule(),
show_favorite_teams_only=True,
favorite_teams=[])
manager.update()
assert manager.games_list == []
assert manager.current_game is None
def test_caps_at_upcoming_games_to_show(self, build_manager):
manager = build_manager(_UpcomingHarness, make_schedule(),
upcoming_games_to_show=1)
manager.update()
assert _ids(manager.games_list) == ["9003"]
def test_tolerates_empty_events_list(self, build_manager):
manager = build_manager(_UpcomingHarness, {"events": []})
manager.update() # must not raise
assert manager.games_list == []
assert manager.current_game is None
def test_tolerates_fetch_returning_none(self, build_manager):
manager = build_manager(_UpcomingHarness, None)
manager.update() # must not raise
assert manager.games_list == []
assert manager.current_game is None
def test_disabled_manager_update_is_noop(self, build_manager):
manager = build_manager(_UpcomingHarness, make_schedule(),
enabled=False)
manager.update()
assert manager.games_list == []
manager.cache_manager.get.assert_not_called()
@freeze_time(FROZEN_NOW)
class TestRecentUpdateFlow:
def test_populates_only_finals_within_21_day_window(self, build_manager):
manager = build_manager(_RecentHarness, make_schedule())
manager.update()
# 9001 (final, 6 days old) kept; 9005 (final, ~80 days old)
# excluded by the 21-day cutoff; live/pre games excluded.
assert _ids(manager.games_list) == ["9001"]
assert manager.current_game["id"] == "9001"
assert manager.current_game["is_final"] is True
def test_filters_by_favorite_teams(self, build_manager):
manager = build_manager(_RecentHarness, make_schedule(),
show_favorite_teams_only=True,
favorite_teams=["TOR"])
manager.update()
assert _ids(manager.games_list) == ["9001"]
stranger = build_manager(_RecentHarness, make_schedule(),
show_favorite_teams_only=True,
favorite_teams=["XXX"])
stranger.update()
assert stranger.games_list == []
assert stranger.current_game is None
def test_tolerates_empty_events_list(self, build_manager):
manager = build_manager(_RecentHarness, {"events": []})
manager.update() # must not raise
assert manager.games_list == []
assert manager.current_game is None
@freeze_time(FROZEN_NOW)
class TestLiveUpdateFlow:
def test_selects_only_live_games(self, build_manager):
manager = build_manager(_LiveHarness, make_schedule())
manager.update()
assert _ids(manager.live_games) == ["9002"]
assert manager.current_game["id"] == "9002"
assert manager.current_game["is_live"] is True
def test_no_live_games_clears_current_game(self, build_manager):
schedule = {"events": [
make_event("9001", "post", "2026-01-14T00:00:00Z"),
make_event("9003", "pre", "2026-01-16T00:00:00Z", period=0),
]}
manager = build_manager(_LiveHarness, schedule)
manager.update()
assert manager.live_games == []
assert manager.current_game is None
# ---------------------------------------------------------------------------
# 3. Rendering smoke — one display() per mode class at 128x32
# ---------------------------------------------------------------------------
def _fake_logo(*args, **kwargs):
return Image.new("RGBA", (24, 24), (180, 30, 30, 255))
@freeze_time(FROZEN_NOW)
class TestRenderingSmoke:
def _assert_rendered(self, manager):
manager.display_manager.update_display.assert_called()
assert manager.display_manager.image.convert("L").getbbox() is not None
def test_upcoming_display_draws_ink(self, build_manager):
manager = build_manager(_UpcomingHarness, make_schedule())
manager.update()
manager._load_and_resize_logo = _fake_logo
assert manager.display(force_clear=True) is True
self._assert_rendered(manager)
def test_recent_display_draws_ink(self, build_manager):
manager = build_manager(_RecentHarness, make_schedule())
manager.update()
manager._load_and_resize_logo = _fake_logo
assert manager.display(force_clear=True) is True
self._assert_rendered(manager)
def test_live_display_draws_ink(self, build_manager):
manager = build_manager(_LiveHarness, make_schedule())
manager.update()
manager._load_and_resize_logo = _fake_logo
assert manager.display(force_clear=True) is True
self._assert_rendered(manager)
def test_draw_scorebug_layout_direct_call_does_not_raise(
self, build_manager):
# The base-class placeholder renderer must also stay callable.
manager = build_manager(_UpcomingHarness, make_schedule())
game = manager._extract_game_details(make_schedule()["events"][2])
manager._load_and_resize_logo = _fake_logo
SportsCore._draw_scorebug_layout(manager, game)
assert manager.display_manager.image.convert("L").getbbox() is not None
# ---------------------------------------------------------------------------
# 4. Guard rails — seams the merge must not silently drop
# ---------------------------------------------------------------------------
class TestGuardRails:
def test_skin_seam_methods_survive(self):
for name in ("_resolve_skin_id", "_get_skin", "_render_game",
"render_skin_card"):
assert callable(getattr(SportsCore, name, None)), (
f"SportsCore.{name} is part of the skin-system seam "
"(src/skin_system) — the sports-unification merge must "
"keep it.")
def test_skin_mode_per_class(self):
assert SportsCore.SKIN_MODE == "live"
assert SportsUpcoming.SKIN_MODE == "upcoming"
assert SportsRecent.SKIN_MODE == "recent"
assert SportsLive.SKIN_MODE == "live" # inherits the default
def test_core_display_and_extractor_seams_survive(self):
for name in ("display", "_draw_scorebug_layout",
"_extract_game_details_common", "update"):
owner = SportsCore if name != "update" else SportsUpcoming
assert callable(getattr(owner, name, None)), name
-764
View File
@@ -1,764 +0,0 @@
"""Tests for the opt-in sports capabilities (phase B2).
Two properties matter beyond "the code works":
1. **Opting out is structural.** A mode class that does not mix in
``CelebrationMixin`` must have none of its attributes or methods not
merely a disabled flag. ``TestOptOutIsStructural`` asserts that directly,
because it is the property the whole mixin design exists to buy.
2. **The promoted behavior matches the plugin copies.** These bodies came from
afl/soccer/nrl (goal dialect) and football (score dialect); the tests pin
the reconciled behavior of both, including the three seams where the
lineages genuinely disagreed.
See docs/SPORTS_UNIFICATION.md.
"""
import sys
from unittest.mock import MagicMock
import pytest
sys.modules.setdefault("rgbmatrix", MagicMock())
from src.base_classes.sports.capabilities import ( # noqa: E402
CelebrationMixin,
RotationStrategy,
SimpleRotation,
SmoothWeightedRotation,
WeightedCycleRotation,
get_rotation_strategy,
register_rotation_strategy,
)
def game(gid, home="HOM", away="AWY", home_score=0, away_score=0, **extra):
g = {
"id": gid,
"home_abbr": home,
"away_abbr": away,
"home_id": f"{gid}-h",
"away_id": f"{gid}-a",
"home_score": home_score,
"away_score": away_score,
}
g.update(extra)
return g
# ---------------------------------------------------------------------------
# Rotation strategies
# ---------------------------------------------------------------------------
def boost(favorites, factor=3):
"""A weight_for callable of the shape the plugins supply."""
return lambda g: factor if g.get("home_abbr") in favorites else 1
class TestRegistry:
@pytest.mark.parametrize("name,cls", [
("simple", SimpleRotation),
("weighted", WeightedCycleRotation),
("swrr", SmoothWeightedRotation),
])
def test_builtin_names_resolve(self, name, cls):
assert isinstance(get_rotation_strategy(name), cls)
def test_unknown_name_falls_back_to_simple(self):
"""The name comes from user config; a typo should cost the boost, not
the scoreboard."""
assert isinstance(get_rotation_strategy("typo"), SimpleRotation)
def test_a_plugin_can_register_its_own(self):
class MyRotation(SimpleRotation):
pass
register_rotation_strategy("test-only", MyRotation)
try:
assert isinstance(get_rotation_strategy("test-only"), MyRotation)
assert MyRotation.name == "test-only"
finally:
from src.base_classes.sports.capabilities import rotation
rotation._REGISTRY.pop("test-only", None)
def test_empty_name_is_rejected(self):
with pytest.raises(ValueError):
register_rotation_strategy("", SimpleRotation)
def test_weight_for_is_optional(self):
"""Default weights are equal, so every strategy degenerates to a plain
round robin the pre-boost behavior."""
games = [game("a"), game("b"), game("c")]
for name in ("simple", "weighted", "swrr"):
assert get_rotation_strategy(name).schedule(games) == ["a", "b", "c"]
class TestWeights:
def test_games_without_an_id_are_skipped(self):
strategy = get_rotation_strategy("weighted")
assert strategy.weights([game("a"), {"home_abbr": "X"}]) == {"a": 1}
@pytest.mark.parametrize("bad", [0, -5])
def test_non_positive_weights_are_clamped_to_one(self, bad):
"""A zero weight would starve the game out of the rotation entirely and
collapse total_weight no caller means that."""
strategy = get_rotation_strategy("weighted", weight_for=lambda g: bad)
assert strategy.weights([game("a")]) == {"a": 1}
@pytest.mark.parametrize("bad", [None, "three", object()])
def test_unusable_weights_fall_back_to_one(self, bad):
strategy = get_rotation_strategy("weighted", weight_for=lambda g: bad)
assert strategy.weights([game("a")]) == {"a": 1}
class TestSimpleRotation:
def test_one_pass_in_feed_order(self):
games = [game("a"), game("b"), game("c")]
assert SimpleRotation().schedule(games) == ["a", "b", "c"]
def test_weights_are_ignored(self):
games = [game("a", home="FAV"), game("b")]
strategy = SimpleRotation(weight_for=boost({"FAV"}, 5))
assert strategy.schedule(games) == ["a", "b"]
def test_empty(self):
assert SimpleRotation().schedule([]) == []
assert SimpleRotation().next_game([]) is None
class TestWeightedCycleRotation:
def test_no_boost_is_a_single_pass(self):
games = [game("a"), game("b"), game("c")]
strategy = WeightedCycleRotation(weight_for=boost({"NONE"}))
assert strategy.schedule(games) == ["a", "b", "c"]
def test_favorite_gets_boost_many_slots(self):
games = [game("a", home="FAV"), game("b")]
order = WeightedCycleRotation(weight_for=boost({"FAV"}, 3)).schedule(games)
assert len(order) == 4
assert order.count("a") == 3
assert order.count("b") == 1
def test_repeats_are_spaced_not_clumped(self):
"""The point of SWRR over naive repetition: 'aaab' is what we must NOT
produce."""
games = [game("a", home="FAV"), game("b")]
order = WeightedCycleRotation(weight_for=boost({"FAV"}, 3)).schedule(games)
assert order != ["a", "a", "a", "b"]
assert order[0] == "a", "highest weight is scheduled first"
def test_is_stateless_across_calls(self):
games = [game("a", home="FAV"), game("b")]
strategy = WeightedCycleRotation(weight_for=boost({"FAV"}, 3))
assert strategy.schedule(games) == strategy.schedule(games)
def test_next_game_returns_the_first_of_the_cycle(self):
games = [game("a"), game("b", home="FAV")]
strategy = WeightedCycleRotation(weight_for=boost({"FAV"}, 4))
assert strategy.next_game(games)["id"] == "b"
def test_empty(self):
assert WeightedCycleRotation().schedule([]) == []
class TestSmoothWeightedRotation:
def test_no_boost_is_plain_round_robin(self):
games = [game("a"), game("b"), game("c")]
strategy = SmoothWeightedRotation()
assert [strategy.next_game(games)["id"] for _ in range(6)] == [
"a", "b", "c", "a", "b", "c"]
def test_favorite_wins_the_share_over_a_long_run(self):
games = [game("a", home="FAV"), game("b")]
strategy = SmoothWeightedRotation(weight_for=boost({"FAV"}, 3))
picks = [strategy.next_game(games)["id"] for _ in range(40)]
assert picks.count("a") == 30
assert picks.count("b") == 10
def test_no_clustering_seam_across_cycle_boundaries(self):
"""The property that motivates keeping this strategy separate from the
precomputed one: state persists, so there is no restart every N picks
and therefore no place where repeats bunch up."""
games = [game("a", home="FAV"), game("b")]
strategy = SmoothWeightedRotation(weight_for=boost({"FAV"}, 3))
picks = [strategy.next_game(games)["id"] for _ in range(40)]
assert "aaaa" not in "".join(picks)
def test_a_new_favorite_is_queued_first(self):
"""A favorite's game that has just gone live starts at weight 0, gets
its full weight on the next call, and so wins the first pick after it
appears without a special-cased branch."""
games = [game("a"), game("b")]
strategy = SmoothWeightedRotation(weight_for=boost({"FAV"}, 5))
for _ in range(3):
strategy.next_game(games)
games.append(game("c", home="FAV"))
assert strategy.next_game(games)["id"] == "c"
def test_state_for_games_no_longer_live_is_dropped(self):
games = [game("a"), game("b")]
strategy = SmoothWeightedRotation()
strategy.next_game(games)
strategy.next_game([game("a")])
assert set(strategy._current) == {"a"}
def test_reset_clears_state(self):
games = [game("a"), game("b")]
strategy = SmoothWeightedRotation()
strategy.next_game(games)
strategy.reset()
assert strategy._current == {}
assert strategy.next_game(games)["id"] == "a"
def test_schedule_previews_without_perturbing_state(self):
games = [game("a", home="FAV"), game("b")]
strategy = SmoothWeightedRotation(weight_for=boost({"FAV"}, 3))
preview = strategy.schedule(games)
actual = [strategy.next_game(games)["id"] for _ in range(len(preview))]
assert preview == actual
def test_empty(self):
assert SmoothWeightedRotation().next_game([]) is None
assert SmoothWeightedRotation().schedule([]) == []
def test_games_without_ids_are_ignored(self):
assert SmoothWeightedRotation().next_game([{"home_abbr": "X"}]) is None
class TestStrategiesAgreeWithinACycle:
"""The survey's core finding: the 'three dialects' are one algorithm. They
must produce the same order within a cycle; they differ only at the
boundary, which is why both shapes survive."""
@pytest.mark.parametrize("factor", [2, 3, 5])
def test_first_cycle_matches(self, factor):
games = [game("a", home="FAV"), game("b"), game("c")]
weight_for = boost({"FAV"}, factor)
assert (SmoothWeightedRotation(weight_for=weight_for).schedule(games)
== WeightedCycleRotation(weight_for=weight_for).schedule(games))
# ---------------------------------------------------------------------------
# Differential: core strategies vs. the plugin implementations they replace
# ---------------------------------------------------------------------------
BOOST = 3
def _is_fav(g):
return g.get("home_abbr") == "FAV"
def _weight_for(g):
return BOOST if _is_fav(g) else 1
class _PluginSwrr:
"""afl / nrl / soccer ``_swrr_advance``, transcribed verbatim."""
favorite_live_boost = BOOST
def _is_favorite_game(self, g):
return _is_fav(g)
def advance(self, games):
if not games:
return None
weights = {}
for g in games:
gid = g.get("id")
if gid is None:
continue
weights[gid] = self.favorite_live_boost if self._is_favorite_game(g) else 1
if not weights:
return None
if not hasattr(self, "_swrr_weights"):
self._swrr_weights = {}
self._swrr_weights = {
gid: w for gid, w in self._swrr_weights.items() if gid in weights}
for gid, w in weights.items():
self._swrr_weights[gid] = self._swrr_weights.get(gid, 0) + w
total_weight = sum(weights.values())
ids_in_order = [g.get("id") for g in games if g.get("id") in weights]
best_gid = max(ids_in_order, key=lambda gid: self._swrr_weights[gid])
self._swrr_weights[best_gid] -= total_weight
return next(g for g in games if g.get("id") == best_gid)
def _plugin_weighted_schedule(games):
"""football / baseball / basketball ``_build_weighted_schedule``, verbatim."""
if not games:
return []
weights = {g["id"]: (BOOST if _is_fav(g) else 1) for g in games}
total_weight = sum(weights.values())
if total_weight <= len(games):
return [g["id"] for g in games]
current_weight = {gid: 0 for gid in weights}
schedule = []
for _ in range(total_weight):
for gid in weights:
current_weight[gid] += weights[gid]
picked = max(current_weight, key=lambda gid: current_weight[gid])
current_weight[picked] -= total_weight
schedule.append(picked)
return schedule
def _plugin_rotation_schedule(games):
"""hockey ``_build_rotation_schedule``, transcribed verbatim."""
weights = [(g["id"], BOOST if _is_fav(g) else 1) for g in games]
total_weight = sum(w for _, w in weights)
if not weights or total_weight <= 0:
return [g["id"] for g in games]
current_weights = {gid: 0 for gid, _ in weights}
schedule = []
for _ in range(total_weight):
best_id, best_current = None, None
for gid, w in weights:
current_weights[gid] += w
if best_current is None or current_weights[gid] > best_current:
best_id, best_current = gid, current_weights[gid]
current_weights[best_id] -= total_weight
schedule.append(best_id)
return schedule
def _cases():
"""Every live-game shape up to 4 games: each either a favorite or not.
Exhaustive rather than random so the gate is deterministic a rotation
regression must fail the same way on every run.
"""
import itertools
for size in range(1, 5):
for flags in itertools.product(("FAV", "OTH"), repeat=size):
yield [game(f"g{i}", home=abbr) for i, abbr in enumerate(flags)]
class TestMatchesThePluginImplementations:
"""The promotion is only safe if these reproduce the plugin copies exactly.
B5 deletes the bundled copies on the strength of this: each core strategy is
checked against the verbatim source it replaces, over every live-game shape
up to four games.
"""
@pytest.mark.parametrize("games", list(_cases()))
def test_swrr_matches_the_incremental_plugin_picker(self, games):
plugin = _PluginSwrr()
core = SmoothWeightedRotation(weight_for=_weight_for)
# 60 picks: long enough to cross many cycle boundaries, where a
# state-handling divergence would show up.
assert ([plugin.advance(games)["id"] for _ in range(60)]
== [core.next_game(games)["id"] for _ in range(60)])
@pytest.mark.parametrize("games", list(_cases()))
def test_weighted_matches_the_football_lineage(self, games):
assert (_plugin_weighted_schedule(games)
== WeightedCycleRotation(weight_for=_weight_for).schedule(games))
@pytest.mark.parametrize("games", list(_cases()))
def test_weighted_matches_hockeys_loop_shape(self, games):
assert (_plugin_rotation_schedule(games)
== WeightedCycleRotation(weight_for=_weight_for).schedule(games))
# ---------------------------------------------------------------------------
# Celebrations
# ---------------------------------------------------------------------------
class _FakeLive:
"""Stand-in for SportsLive: just the surface the mixin touches."""
def __init__(self, mode_config=None, favorite_teams=None):
self.mode_config = mode_config or {}
self.favorite_teams = favorite_teams or []
self.logger = MagicMock()
self.display_manager = MagicMock()
self.is_enabled = True
self.current_game = None
self.last_game_switch = 0
self.display_calls = []
def _favorite_key(self, game, side):
return game.get(f"{side}_abbr")
def display(self, force_clear=False):
self.display_calls.append(force_clear)
return True
class _Celebrating(CelebrationMixin, _FakeLive):
pass
class _Coalescing(CelebrationMixin, _FakeLive):
COALESCE_SCORING_SEQUENCE = True
def score_phrase(self, points, team_abbr):
return "TOUCHDOWN!" if points >= 6 else f"{team_abbr} FIELD GOAL!"
class _ById(CelebrationMixin, _FakeLive):
"""The nrl shape: ambiguous abbreviations, so favorites match on team id."""
def _favorite_key(self, game, side):
return game.get(f"{side}_id")
@pytest.fixture
def celebrating():
def _build(cls=_Celebrating, mode_config=None, favorites=None):
return cls(mode_config=mode_config, favorite_teams=favorites)
return _build
class TestOptOutIsStructural:
"""The property the mixin design exists to buy: a class that does not opt in
has none of this code not a disabled flag, not an unused attribute."""
def test_a_non_celebrating_class_has_no_celebration_surface(self):
plain = _FakeLive()
for attribute in ("active_celebration", "_score_baselines",
"celebration_enabled", "celebration_duration",
"_check_for_score", "_check_for_win",
"has_active_celebration", "_draw_celebration_layout"):
assert not hasattr(plain, attribute), (
f"{attribute} leaked onto a class that never opted in")
def test_the_mixin_is_absent_from_a_non_celebrating_mro(self):
assert CelebrationMixin not in _FakeLive.__mro__
assert CelebrationMixin in _Celebrating.__mro__
def test_mixin_does_not_require_the_base_to_know_about_it(self):
"""SportsLive must carry no celebration hooks — that would be the
god-class shape the mixin replaces."""
from src.base_classes.sports import SportsLive
source = __import__("inspect").getsource(SportsLive)
assert "celebration" not in source.lower()
class TestCelebrationConfig:
def test_defaults(self, celebrating):
manager = celebrating()
assert manager.celebration_enabled is True
assert manager.celebration_duration == 8
assert manager.celebrate_opponent_scores is False
assert manager.active_celebration is None
def test_reads_the_goal_spelling_of_the_opponent_key(self, celebrating):
"""The soccer lineage's published schema says `celebrate_opponent_goals`;
adopting the mixin must not silently reset users' setting."""
manager = celebrating(mode_config={"celebrate_opponent_goals": True})
assert manager.celebrate_opponent_scores is True
def test_reads_the_score_spelling_of_the_opponent_key(self, celebrating):
manager = celebrating(mode_config={"celebrate_opponent_scores": True})
assert manager.celebrate_opponent_scores is True
def test_score_spelling_wins_when_both_are_present(self, celebrating):
manager = celebrating(mode_config={"celebrate_opponent_scores": False,
"celebrate_opponent_goals": True})
assert manager.celebrate_opponent_scores is False
class TestScoreDetection:
def test_first_sighting_never_celebrates(self, celebrating):
"""A game already in progress at boot must not false-fire."""
manager = celebrating()
manager._check_for_score(game("g1", home_score=3, away_score=1))
assert manager.active_celebration is None
assert manager._score_baselines["g1"] == {"away": 1, "home": 3}
def test_increment_arms_a_celebration(self, celebrating):
manager = celebrating()
manager._check_for_score(game("g1", home_score=0, away_score=0))
manager._check_for_score(game("g1", home_score=1, away_score=0))
assert manager.active_celebration["kind"] == "score"
assert manager.active_celebration["scored_side"] == "home"
def test_no_change_does_not_fire(self, celebrating):
manager = celebrating()
manager._check_for_score(game("g1", home_score=2))
manager._check_for_score(game("g1", home_score=2))
assert manager.active_celebration is None
def test_decrement_rebases_silently(self, celebrating):
"""A disallowed goal / correction must not celebrate, and must not leave
a stale baseline that fires on the way back up."""
manager = celebrating()
manager._check_for_score(game("g1", home_score=2))
manager._check_for_score(game("g1", home_score=1))
assert manager.active_celebration is None
assert manager._score_baselines["g1"]["home"] == 1
def test_disabled_never_fires(self, celebrating):
manager = celebrating(mode_config={"celebration_enabled": False})
manager._check_for_score(game("g1", home_score=0))
manager._check_for_score(game("g1", home_score=1))
assert manager.active_celebration is None
def test_game_without_an_id_is_ignored(self, celebrating):
manager = celebrating()
manager._check_for_score({"home_score": 1, "away_score": 0})
assert manager.active_celebration is None
@pytest.mark.parametrize("score", [None, "", "not-a-number-at-all"])
def test_unusable_scores_are_ignored(self, celebrating, score):
manager = celebrating()
manager._check_for_score(game("g1", home_score=score))
assert manager._score_baselines == {}
@pytest.mark.parametrize("raw,expected", [
("7", 7), (7, 7), (7.0, 7), (" 7 ", 7), ("7 (SO)", 7),
({"value": 7}, 7), ({"displayValue": "7"}, 7),
])
def test_score_coercion(self, raw, expected):
assert CelebrationMixin._score_to_int(raw) == expected
def test_away_side_is_detected(self, celebrating):
manager = celebrating()
manager._check_for_score(game("g1", away_score=0))
manager._check_for_score(game("g1", away_score=1))
assert manager.active_celebration["scored_side"] == "away"
class TestWhoGetsCelebrated:
def test_no_favorites_celebrates_everyone(self, celebrating):
"""The user opted to show this game at all, so any score in it counts."""
manager = celebrating(favorites=[])
manager._check_for_score(game("g1", home="XXX", home_score=0))
manager._check_for_score(game("g1", home="XXX", home_score=1))
assert manager.active_celebration is not None
def test_favorite_scores(self, celebrating):
manager = celebrating(favorites=["FAV"])
manager._check_for_score(game("g1", home="FAV", home_score=0))
manager._check_for_score(game("g1", home="FAV", home_score=1))
assert manager.active_celebration is not None
def test_opponent_suppressed_by_default(self, celebrating):
manager = celebrating(favorites=["FAV"])
manager._check_for_score(game("g1", home="OPP", away="FAV", home_score=0))
manager._check_for_score(game("g1", home="OPP", away="FAV", home_score=1))
assert manager.active_celebration is None
def test_opponent_celebrated_when_opted_in(self, celebrating):
manager = celebrating(mode_config={"celebrate_opponent_scores": True},
favorites=["FAV"])
manager._check_for_score(game("g1", home="OPP", away="FAV", home_score=0))
manager._check_for_score(game("g1", home="OPP", away="FAV", home_score=1))
assert manager.active_celebration is not None
def test_matching_goes_through_the_favorite_key_seam(self, celebrating):
"""nrl matches on team id because its abbreviations are ambiguous
('NEW' is both Newcastle and New Zealand). Core must not care why."""
manager = celebrating(_ById, favorites=["g1-h"])
manager._check_for_score(game("g1", home="NEW", home_score=0))
manager._check_for_score(game("g1", home="NEW", home_score=1))
assert manager.active_celebration is not None
def test_favorite_key_seam_also_excludes(self, celebrating):
manager = celebrating(_ById, favorites=["someone-else"])
manager._check_for_score(game("g1", home="NEW", home_score=0))
manager._check_for_score(game("g1", home="NEW", home_score=1))
assert manager.active_celebration is None
class TestPhrasing:
def test_default_phrase_is_sport_neutral(self, celebrating):
manager = celebrating()
manager._check_for_score(game("g1", home="HOM", home_score=0))
manager._check_for_score(game("g1", home="HOM", home_score=1))
assert manager.active_celebration["phrase"] == "HOM SCORES!"
def test_score_phrase_hook_sees_the_points_delta(self, celebrating):
manager = celebrating(_Coalescing)
manager._check_for_score(game("g1", home_score=0))
manager._check_for_score(game("g1", home_score=6))
assert manager.active_celebration["phrase"] == "TOUCHDOWN!"
def test_score_phrase_hook_distinguishes_smaller_plays(self, celebrating):
manager = celebrating(_Coalescing)
manager._check_for_score(game("g1", home_score=0))
manager._check_for_score(game("g1", home_score=3))
assert manager.active_celebration["phrase"] == "HOM FIELD GOAL!"
def test_win_phrase(self, celebrating):
manager = celebrating(favorites=["HOM"])
manager._check_for_score(game("g1", home_score=1))
manager._check_for_win(game("g1", home_score=2, away_score=1))
assert manager.active_celebration["phrase"] == "HOM WINS!"
class TestCoalescing:
def test_off_by_default_two_goals_are_two_celebrations(self, celebrating):
"""Soccer/afl/nrl: consecutive increments are distinct events, so
suppressing the second would swallow a real goal."""
manager = celebrating()
manager._check_for_score(game("g1", home_score=0))
manager._check_for_score(game("g1", home_score=1))
first = manager.active_celebration["started_at"]
manager._check_for_score(game("g1", home_score=2))
assert manager.active_celebration["started_at"] != first
assert manager.active_celebration["home_score"] == 2
def test_on_suppresses_the_extra_point_follow_up(self, celebrating):
"""Football: a touchdown lands as +6, then +1 seconds later. One
takeover per scoring sequence."""
manager = celebrating(_Coalescing)
manager._check_for_score(game("g1", home_score=0))
manager._check_for_score(game("g1", home_score=6))
armed = manager.active_celebration
manager._check_for_score(game("g1", home_score=7))
assert manager.active_celebration is armed
def test_suppression_still_advances_the_baseline(self, celebrating):
"""Nothing may re-fire once the window closes."""
manager = celebrating(_Coalescing)
manager._check_for_score(game("g1", home_score=0))
manager._check_for_score(game("g1", home_score=6))
manager._check_for_score(game("g1", home_score=7))
assert manager._score_baselines["g1"]["home"] == 7
class TestWinDetection:
def test_win_requires_a_baseline(self, celebrating):
"""A game seen for the first time already-final (board started after
full time) must not fire."""
manager = celebrating(favorites=["HOM"])
manager._check_for_win(game("g1", home_score=3, away_score=1))
assert manager.active_celebration is None
def test_win_fires_once_only(self, celebrating):
manager = celebrating(favorites=["HOM"])
manager._check_for_score(game("g1", home_score=1))
manager._check_for_win(game("g1", home_score=3, away_score=1))
manager.active_celebration = None
manager._check_for_win(game("g1", home_score=3, away_score=1))
assert manager.active_celebration is None
def test_draw_does_not_celebrate(self, celebrating):
manager = celebrating(favorites=["HOM"])
manager._check_for_score(game("g1", home_score=1))
manager._check_for_win(game("g1", home_score=2, away_score=2))
assert manager.active_celebration is None
def test_win_is_gated_strictly_on_favorites(self, celebrating):
"""Unlike scores, a win with no favorites configured does NOT celebrate:
every game ends, so the fallback would be constant noise."""
manager = celebrating(favorites=[])
manager._check_for_score(game("g1", home_score=1))
manager._check_for_win(game("g1", home_score=3, away_score=1))
assert manager.active_celebration is None
def test_losing_favorite_does_not_celebrate(self, celebrating):
manager = celebrating(favorites=["HOM"])
manager._check_for_score(game("g1", home_score=1))
manager._check_for_win(game("g1", home_score=1, away_score=4))
assert manager.active_celebration is None
def test_away_favorite_wins(self, celebrating):
manager = celebrating(favorites=["AWY"])
manager._check_for_score(game("g1", away_score=1))
manager._check_for_win(game("g1", home_score=1, away_score=4))
assert manager.active_celebration["scored_side"] == "away"
class TestCelebrationSnapshot:
def test_the_game_is_snapshotted_not_referenced(self, celebrating):
"""A win must survive the game leaving live_games."""
manager = celebrating()
live = game("g1", home_score=0)
manager._check_for_score(live)
live = game("g1", home_score=1)
manager._check_for_score(live)
live["home_abbr"] = "MUTATED"
assert manager.active_celebration["game"]["home_abbr"] == "HOM"
def test_focus_is_pinned_to_the_involved_game(self, celebrating):
manager = celebrating()
manager._check_for_score(game("g1", home_score=0))
manager._check_for_score(game("g1", home_score=1))
assert manager.current_game["id"] == "g1"
class TestDisplayTakeover:
def test_no_celebration_defers_to_the_scorebug(self, celebrating):
manager = celebrating()
assert manager.display(force_clear=True) is True
assert manager.display_calls == [True]
def test_active_celebration_takes_over(self, celebrating):
manager = celebrating()
manager._check_for_score(game("g1", home_score=0))
manager._check_for_score(game("g1", home_score=1))
manager._draw_celebration_layout = MagicMock()
assert manager.display() is True
assert manager.display_calls == [], "the scorebug must not also render"
manager._draw_celebration_layout.assert_called_once()
def test_expired_celebration_clears_and_defers(self, celebrating):
manager = celebrating(mode_config={"celebration_duration": 0})
manager._check_for_score(game("g1", home_score=0))
manager._check_for_score(game("g1", home_score=1))
assert manager.display() is True
assert manager.active_celebration is None
assert manager.display_calls == [False]
def test_expiry_resets_the_dwell_clock(self, celebrating):
"""So the scorebug resumes on the scoring game for a full duration
before rotation can move on."""
manager = celebrating(mode_config={"celebration_duration": 0})
manager._check_for_score(game("g1", home_score=0))
manager._check_for_score(game("g1", home_score=1))
manager.display()
assert manager.last_game_switch > 0
def test_a_render_failure_falls_through_to_the_scorebug(self, celebrating):
"""A broken celebration must never blank the display."""
manager = celebrating()
manager._check_for_score(game("g1", home_score=0))
manager._check_for_score(game("g1", home_score=1))
manager._draw_celebration_layout = MagicMock(side_effect=RuntimeError("boom"))
assert manager.display() is True
assert manager.display_calls == [False]
def test_disabled_manager_renders_nothing(self, celebrating):
manager = celebrating()
manager.is_enabled = False
assert manager.display() is False
class TestFitFont:
def test_returns_the_first_font_that_fits(self, celebrating):
manager = celebrating()
draw = MagicMock()
draw.textlength.side_effect = [100, 20]
big, small = MagicMock(), MagicMock()
assert manager._fit_font(draw, "GOAL", 50, [big, small]) is small
def test_falls_back_to_the_smallest(self, celebrating):
manager = celebrating()
draw = MagicMock()
draw.textlength.return_value = 999
big, small = MagicMock(), MagicMock()
assert manager._fit_font(draw, "GOAL", 50, [big, small]) is small
class TestCapabilityExports:
@pytest.mark.parametrize("name", [
"CelebrationMixin", "RotationStrategy", "SimpleRotation",
"SmoothWeightedRotation", "WeightedCycleRotation",
"get_rotation_strategy", "register_rotation_strategy",
])
def test_public_name_is_importable(self, name):
"""Plugins import these behind a guarded fallback; the names are the
contract."""
import src.base_classes.sports.capabilities as capabilities
assert hasattr(capabilities, name)
def test_rotation_strategy_base_requires_a_schedule(self):
with pytest.raises(NotImplementedError):
RotationStrategy().schedule([game("a")])
-590
View File
@@ -1,590 +0,0 @@
"""Tests for the methods promoted onto SportsCore from the nine bundled
plugin copies of ``sports.py`` (phase B1 of docs/SPORTS_UNIFICATION.md).
Three methods and their seams land here:
- ``cleanup()`` byte-identical in all nine copies. The tests pin the
ordering (session close, then caches, then the completion log) and the
deliberate *omission*: the process-wide background service must never be
shut down by one unloading plugin.
- ``_get_layout_offset()`` football's resolver-backed variant, with the
classic inline config read as the fallback used by every plugin that
doesn't hand core a ``_config_schema_path()``.
- ``_load_custom_font_from_element_config()`` baseball's body (the only
copy that handles BDF strikes correctly) under hockey's wider signature,
resolving font files through the ``_font_root()`` seam instead of the
process cwd.
"""
import ast
import json
import logging
import os
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from PIL import Image, ImageFont
# src.base_classes.sports transitively imports the hardware matrix driver;
# stub it so these tests can import the sports base classes off-device.
sys.modules.setdefault("rgbmatrix", MagicMock())
from src.base_classes.sports import SportsCore
LOGGER = logging.getLogger("test_sports_core_promotions")
CORE_ROOT = Path(__file__).resolve().parents[1]
FONTS_DIR = CORE_ROOT / "assets" / "fonts"
TTF_NAME = "PressStart2P-Regular.ttf"
BDF_NAME = "5x7.bdf" # a BDF whose only valid strike is 7px
BDF_NATIVE_SIZE = 7
class _StubSports(SportsCore):
"""Minimal concrete SportsCore — the abstract methods are never called
by anything under test here."""
def _fetch_data(self):
return None
def _extract_game_details(self, game_event):
return None
@pytest.fixture
def build(monkeypatch, tmp_path):
"""Factory for real SportsCore instances: logo dir redirected to tmp and
the process-wide background service replaced with a MagicMock so the
tests can assert nothing ever calls it."""
monkeypatch.setattr(
SportsCore, "_initialize_logo_dir", lambda self, configured: tmp_path)
monkeypatch.setattr(
"src.base_classes.sports.core.get_background_service",
lambda *args, **kwargs: MagicMock())
def _build(config=None, cls=_StubSports):
display_manager = MagicMock()
display_manager.matrix.width = 128
display_manager.matrix.height = 32
display_manager.width = 128
display_manager.height = 32
display_manager.image = Image.new("RGB", (128, 32))
cache_manager = MagicMock()
cache_manager.cache_dir = str(tmp_path)
return cls(config if config is not None else {"timezone": "UTC"},
display_manager, cache_manager, LOGGER, "nhl")
return _build
def probe(config=None):
"""Unbound-call stand-in for hosts we don't need a full instance for
(same pattern as make_probe in test_sports_base_characterization)."""
host = MagicMock()
host.logger = LOGGER
host.config = config if config is not None else {}
host._font_cache = {}
host._bdf_native_size_cache = {}
host._config_schema_path.return_value = None
host._font_root.side_effect = lambda: SportsCore._font_root(host)
host._resolve_font_path.side_effect = (
lambda name: SportsCore._resolve_font_path(host, name))
return host
def offset(host, element, axis, default=0):
return SportsCore._get_layout_offset(host, element, axis, default)
def load_font(host, *args, **kwargs):
return SportsCore._load_custom_font_from_element_config(host, *args, **kwargs)
# ---------------------------------------------------------------------------
# 1. cleanup()
# ---------------------------------------------------------------------------
class TestCleanup:
def test_closes_session_and_clears_all_caches(self, build):
manager = build()
session = MagicMock()
manager.session = session
manager._logo_cache["TB"] = object()
manager._font_cache[("PressStart2P-Regular.ttf", 8)] = object()
manager._bdf_native_size_cache["assets/fonts/5x7.bdf"] = 7
manager.cleanup()
session.close.assert_called_once_with()
assert manager._logo_cache == {}
# Promoted alongside the font loader: these hold PIL faces and are
# an unbounded leak across enable/disable cycles if never released.
assert manager._font_cache == {}
assert manager._bdf_native_size_cache == {}
def test_second_cleanup_is_a_noop(self, build):
manager = build()
manager.session = MagicMock()
manager._logo_cache["TB"] = object()
manager._font_cache[("x", 8)] = object()
manager.cleanup()
manager.cleanup() # must not raise on already-released state
assert manager._logo_cache == {}
assert manager._font_cache == {}
assert manager._bdf_native_size_cache == {}
assert manager.session.close.call_count == 2
def test_does_not_shut_down_the_shared_background_service(self, build):
# get_background_service() hands out a PROCESS-WIDE singleton shared
# by every scoreboard. One plugin unloading must not stop background
# fetching for the other eight — cleanup() touches it not at all.
manager = build()
service = manager.background_service
manager.session = MagicMock()
manager.cleanup()
assert service.shutdown.called is False
assert service.stop.called is False
assert service.method_calls == [], (
"cleanup() called into the shared background service: "
f"{service.method_calls}")
def test_completion_is_logged_even_when_session_close_raises(self, build, caplog):
manager = build()
manager.session = MagicMock()
manager.session.close.side_effect = RuntimeError("socket already gone")
manager._logo_cache["TB"] = object()
with caplog.at_level(logging.DEBUG, logger=LOGGER.name):
manager.cleanup()
messages = [r.message for r in caplog.records]
assert any("Error closing session" in m for m in messages)
# Ordering is load-bearing: the caches still get cleared and the
# completion log still fires after a failed close.
assert manager._logo_cache == {}
assert any("cleanup completed" in m for m in messages)
def test_tolerates_missing_attributes(self):
# The hasattr guards exist so a partially constructed instance (an
# __init__ that raised) can still be cleaned up.
host = MagicMock(spec=["logger"])
host.logger = LOGGER
SportsCore.cleanup(host)
# ---------------------------------------------------------------------------
# 2. _get_layout_offset() + the _config_schema_path() seam
# ---------------------------------------------------------------------------
def layout_config(element, axis, value):
return {"customization": {"layout": {element: {axis: value}}}}
class TestLayoutOffsetClassicPath:
"""The default path: _config_schema_path() returns None, so offsets come
from the inline customization.layout read every plugin ships today."""
def test_config_schema_path_defaults_to_none(self, build):
manager = build()
assert manager._config_schema_path() is None
def test_reads_configured_int(self):
host = probe(layout_config("home_logo", "x_offset", 5))
assert offset(host, "home_logo", "x_offset") == 5
def test_float_is_truncated_to_int(self):
host = probe(layout_config("score", "y_offset", 2.9))
result = offset(host, "score", "y_offset")
assert result == 2 and isinstance(result, int)
def test_numeric_string_is_coerced(self):
host = probe(layout_config("score", "x_offset", "-3.5"))
assert offset(host, "score", "x_offset") == -3
def test_unconfigured_element_and_axis_use_default(self):
host = probe(layout_config("score", "x_offset", 5))
assert offset(host, "status_text", "x_offset", 7) == 7
assert offset(host, "score", "y_offset", -1) == -1
assert offset(probe(), "score", "x_offset", 4) == 4
def test_non_numeric_string_degrades_to_default(self):
host = probe(layout_config("score", "x_offset", "left"))
assert offset(host, "score", "x_offset", 3) == 3
def test_unsupported_type_degrades_to_default(self):
host = probe(layout_config("score", "x_offset", {"nested": 1}))
assert offset(host, "score", "x_offset", 2) == 2
host = probe(layout_config("score", "x_offset", None))
assert offset(host, "score", "x_offset", 2) == 2
def test_broken_config_object_degrades_to_default(self):
host = probe()
host.config = "not a dict"
assert offset(host, "score", "x_offset", 6) == 6
def test_boolean_counts_as_one(self):
# PINNED AS-IS: the classic read predates the resolver and treats a
# bool as its int value (True -> 1). See the resolver test below for
# the stricter, more correct handling.
host = probe(layout_config("score", "x_offset", True))
assert offset(host, "score", "x_offset", 4) == 1
class TestLayoutOffsetResolverPath:
"""When a plugin supplies its config_schema.json, offsets resolve through
src.element_style instead."""
@pytest.fixture
def schema_path(self, tmp_path):
path = tmp_path / "config_schema.json"
path.write_text(json.dumps({
"type": "object",
"properties": {
"customization": {
"type": "object",
"properties": {
"layout": {"type": "object", "properties": {}},
},
},
},
}))
return str(path)
def host(self, schema_path, config):
host = probe(config)
host._config_schema_path.return_value = schema_path
del host._style_resolver_cached # MagicMock auto-attrs otherwise
host._style_resolver_cached = None
return host
def test_reads_configured_offsets(self, schema_path):
host = self.host(schema_path, layout_config("home_logo", "x_offset", 5))
assert offset(host, "home_logo", "x_offset") == 5
def test_numeric_string_is_coerced(self, schema_path):
host = self.host(schema_path, layout_config("score", "x_offset", "-3.5"))
assert offset(host, "score", "x_offset") == -3
def test_missing_value_uses_default(self, schema_path):
host = self.host(schema_path, layout_config("score", "x_offset", 5))
assert offset(host, "score", "y_offset", 9) == 9
def test_bad_input_degrades_to_default(self, schema_path):
host = self.host(schema_path, layout_config("score", "x_offset", "left"))
assert offset(host, "score", "x_offset", 3) == 3
host = self.host(schema_path, {"customization": {"layout": "nope"}})
assert offset(host, "score", "x_offset", 3) == 3
def test_boolean_is_rejected_unlike_the_classic_path(self, schema_path):
# The intended behavior difference: a bool is not a pixel offset, so
# the resolver returns the default where the classic read returns 1.
host = self.host(schema_path, layout_config("score", "x_offset", True))
assert offset(host, "score", "x_offset", 4) == 4
def test_resolver_is_cached_and_rebuilt_when_config_is_swapped(self, schema_path):
host = self.host(schema_path, layout_config("score", "x_offset", 5))
assert offset(host, "score", "x_offset") == 5
first = host._style_resolver_cached
assert offset(host, "score", "x_offset") == 5
assert host._style_resolver_cached is first
# on_config_change swaps the dict object; the resolver must follow.
host.config = layout_config("score", "x_offset", 11)
assert offset(host, "score", "x_offset") == 11
assert host._style_resolver_cached is not first
def test_missing_schema_file_still_resolves_offsets(self, tmp_path):
# Offsets don't depend on schema defaults, so an unreadable schema
# must not cost the plugin its layout customization.
host = self.host(str(tmp_path / "absent.json"),
layout_config("score", "x_offset", 5))
assert offset(host, "score", "x_offset") == 5
# ---------------------------------------------------------------------------
# 3. _load_custom_font_from_element_config() + the _font_root() seam
# ---------------------------------------------------------------------------
class TestFontRootSeam:
def test_default_font_root_is_the_core_install_root(self, build):
manager = build()
assert Path(manager._font_root()) == CORE_ROOT
assert (Path(manager._font_root()) / "assets" / "fonts").is_dir()
def test_resolve_font_path_honors_an_overridden_root(self, tmp_path):
fonts = tmp_path / "assets" / "fonts"
fonts.mkdir(parents=True)
(fonts / "Bundled.ttf").write_bytes(b"not really a font")
host = probe()
host._font_root.side_effect = lambda: str(tmp_path)
assert SportsCore._resolve_font_path(host, "Bundled.ttf") == str(
fonts / "Bundled.ttf")
def test_unknown_font_returns_the_familiar_relative_path(self):
host = probe()
assert SportsCore._resolve_font_path(host, "Nope.ttf") == os.path.join(
"assets", "fonts", "Nope.ttf")
class TestFontLoaderSignature:
"""Hockey's signature is the only safe superset: basketball's positional
``default_font: str`` blows up on an explicit None."""
def test_two_arg_call(self):
font = load_font(probe(), {"font": TTF_NAME, "font_size": 10})
assert isinstance(font, ImageFont.FreeTypeFont)
assert font.size == 10
def test_default_size_is_used_when_config_omits_it(self):
assert load_font(probe(), {}, 12).size == 12
def test_three_positional_args(self):
font = load_font(probe(), {}, 6, "4x6-font.ttf")
assert isinstance(font, ImageFont.FreeTypeFont)
assert font.size == 6
assert font.path.endswith("4x6-font.ttf")
def test_explicit_default_font_none(self):
# The regression this signature guards: os.path.join(..., None).
font = load_font(probe(), {"font_size": 9}, default_font=None)
assert isinstance(font, ImageFont.FreeTypeFont)
assert font.path.endswith(TTF_NAME)
def test_config_font_wins_over_default_font(self):
font = load_font(probe(), {"font": TTF_NAME}, 8, "4x6-font.ttf")
assert font.path.endswith(TTF_NAME)
def test_string_font_size_is_coerced(self):
assert load_font(probe(), {"font": TTF_NAME, "font_size": "11"}).size == 11
class TestFontLoaderBehavior:
def test_family_alias_resolves_through_the_font_manager_catalog(self):
# "press_start" is a FontManager catalog family, not a filename; the
# promoted loader must not carry its own duplicate alias table.
host = probe()
font = load_font(host, {"font": "press_start", "font_size": 8})
assert font.path.endswith(TTF_NAME)
assert ("PressStart2P-Regular.ttf", 8) in host._font_cache
def test_memo_cache_returns_the_same_face(self):
host = probe()
first = load_font(host, {"font": TTF_NAME, "font_size": 8})
second = load_font(host, {"font": TTF_NAME, "font_size": 8})
assert first is second
assert len(host._font_cache) == 1
# A different size is a different face.
assert load_font(host, {"font": TTF_NAME, "font_size": 9}) is not first
assert len(host._font_cache) == 2
def test_bdf_loads_at_its_native_strike_when_the_request_misses(self):
# BDF is a fixed-size bitmap format: FreeType raises "invalid pixel
# size" for anything but the file's own strike. Baseball's retry is
# the only copy that gets this right.
host = probe()
font = load_font(host, {"font": BDF_NAME, "font_size": 8})
assert isinstance(font, ImageFont.FreeTypeFont)
assert font.size == BDF_NATIVE_SIZE
assert font.path.endswith(BDF_NAME)
assert set(host._bdf_native_size_cache.values()) == {BDF_NATIVE_SIZE}
# The retried face is memoized under the REQUESTED size.
assert host._font_cache[(BDF_NAME, 8)] is font
def test_bdf_at_its_native_size_needs_no_retry(self):
host = probe()
font = load_font(host, {"font": BDF_NAME, "font_size": BDF_NATIVE_SIZE})
assert font.size == BDF_NATIVE_SIZE
assert host._bdf_native_size_cache == {}
def test_bdf_strike_lookup_is_memoized(self, monkeypatch):
calls = []
real = SportsCore.__module__
def counting(path):
calls.append(path)
from src.font_manager import FontManager
return FontManager._read_bdf_native_size(path)
monkeypatch.setattr(f"{real}._read_bdf_native_size", counting)
host = probe()
load_font(host, {"font": BDF_NAME, "font_size": 8})
host._font_cache.clear() # force the load path again
load_font(host, {"font": BDF_NAME, "font_size": 8})
assert len(calls) == 1
def test_missing_font_falls_back_and_caches_the_fallback(self, caplog):
host = probe()
with caplog.at_level(logging.WARNING, logger=LOGGER.name):
font = load_font(host, {"font": "DoesNotExist.ttf", "font_size": 8})
assert isinstance(font, ImageFont.FreeTypeFont)
assert font.path.endswith(TTF_NAME)
assert any("Font file not found" in r.message for r in caplog.records)
# Cached under the requested name so a misconfiguration costs one
# disk probe, not one per frame.
assert host._font_cache[("DoesNotExist.ttf", 8)] is font
def test_unknown_extension_falls_back(self):
host = probe()
font = load_font(host, {"font": "AUTHORS", "font_size": 8})
assert font.path.endswith(TTF_NAME)
def test_fallback_honors_the_supplied_default_font(self):
host = probe()
font = load_font(host, {"font": "DoesNotExist.ttf"}, 6, "4x6-font.ttf")
assert font.path.endswith("4x6-font.ttf")
class TestFontLoaderCwdIndependence:
"""The bug the _font_root() seam exists to prevent: every plugin copy
joins 'assets/fonts' onto the process cwd, so a process started anywhere
else silently degrades to PIL's default bitmap face (the same defect
already fixed in FontManager see CHANGELOG Unreleased/Fixed)."""
@pytest.mark.parametrize("font_name,expected_size",
[(TTF_NAME, 8), (BDF_NAME, BDF_NATIVE_SIZE)])
def test_fonts_load_from_an_unrelated_cwd(self, monkeypatch, font_name,
expected_size):
monkeypatch.chdir("/")
host = probe()
font = load_font(host, {"font": font_name, "font_size": 8})
assert isinstance(font, ImageFont.FreeTypeFont), (
f"{font_name} degraded to PIL's default face when the process "
"runs outside the install root")
assert font.size == expected_size
assert Path(font.path) == FONTS_DIR / font_name
def test_fallback_font_also_survives_an_unrelated_cwd(self, monkeypatch):
monkeypatch.chdir("/")
font = load_font(probe(), {"font": "DoesNotExist.ttf", "font_size": 8})
assert isinstance(font, ImageFont.FreeTypeFont)
assert Path(font.path) == FONTS_DIR / TTF_NAME
@pytest.mark.parametrize("key", ["score", "time", "team", "status",
"detail", "rank"])
def test_load_fonts_survives_an_unrelated_cwd(self, monkeypatch, key):
"""`_load_fonts` had the same cwd-relative literals the seam exists to
remove; every scoreboard font silently became PIL's default bitmap face
when the process started outside the install root."""
monkeypatch.chdir("/")
fonts = SportsCore._load_fonts(probe())
assert isinstance(fonts[key], ImageFont.FreeTypeFont), (
f"fonts['{key}'] degraded to PIL's default face outside the "
"install root")
class TestShouldLogCooldown:
"""`_should_log` reads `self._last_warning_time` unguarded, so it must be
initialized in __init__ otherwise the first warning of a run raises
AttributeError instead of logging."""
def test_cooldown_clock_is_initialized(self, build):
assert build()._last_warning_time == 0
def test_first_call_logs_then_cools_down(self, build):
manager = build()
assert manager._should_log("api", cooldown=60) is True
assert manager._should_log("api", cooldown=60) is False
def test_cooldown_expires(self, build):
manager = build()
assert manager._should_log("api", cooldown=60) is True
manager._last_warning_time -= 61
assert manager._should_log("api", cooldown=60) is True
# ---------------------------------------------------------------------------
# 4. Seam guard rails
# ---------------------------------------------------------------------------
class TestPromotedSeamsExist:
@pytest.mark.parametrize("name", [
"cleanup", "_get_layout_offset", "_load_custom_font_from_element_config",
"_config_schema_path", "_font_root", "_resolve_font_path",
])
def test_method_is_callable_on_the_base_class(self, name):
assert callable(getattr(SportsCore, name, None)), (
f"SportsCore.{name} is part of the promoted plugin-facing seam "
"(docs/SPORTS_UNIFICATION.md) — plugins probe for it with "
"hasattr before delegating.")
def test_no_sport_names_leaked_into_core(self):
"""core.py must never branch on which sport it is (prose and skin-id
examples in docstrings are fine executable code is not)."""
tree = ast.parse((CORE_ROOT / "src" / "base_classes" / "sports"
/ "core.py").read_text())
docstrings = set()
for node in ast.walk(tree):
if isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef,
ast.AsyncFunctionDef)):
first = node.body[0] if node.body else None
if (isinstance(first, ast.Expr)
and isinstance(first.value, ast.Constant)
and isinstance(first.value.value, str)):
docstrings.add(id(first.value))
tokens = []
for node in ast.walk(tree):
if isinstance(node, ast.Constant) and isinstance(node.value, str):
if id(node) not in docstrings:
tokens.append(node.value)
elif isinstance(node, ast.Name):
tokens.append(node.id)
elif isinstance(node, ast.Attribute):
tokens.append(node.attr)
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef,
ast.ClassDef)):
tokens.append(node.name)
haystack = " ".join(tokens).lower()
for sport in ("afl", "nrl", "hockey", "baseball", "basketball",
"football", "lacrosse", "soccer", "ufc"):
assert sport not in haystack, (
f"core.py code mentions '{sport}' — core must never learn "
"sport names; add an override point instead.")
class TestInstallRootResolution:
"""Guards the depth bug the sports.py -> package move introduced.
The move was byte-identical in every class body, but `__file__` gained a
directory, so `Path(__file__).resolve().parents[2]` silently changed from
the repo root to `<root>/src`. Textual identity is not semantic identity
when code measures its own location: these tests assert the resolved
values, not the index.
"""
def test_install_root_is_the_repo_root(self):
from src.base_classes.sports.core import _INSTALL_ROOT
# The repo root is the directory that actually holds src/ and assets/.
assert (_INSTALL_ROOT / "src").is_dir()
assert (_INSTALL_ROOT / "src" / "base_classes" / "sports").is_dir()
assert _INSTALL_ROOT.name != "src", (
"_INSTALL_ROOT resolved to src/ — the parents[] depth is off by "
"one, which is exactly the regression the package move caused.")
def test_resolve_project_path_roots_at_repo_not_src(self):
from src.base_classes.sports.core import SportsCore, _INSTALL_ROOT
resolved = SportsCore._resolve_project_path(None, Path("assets/fonts"))
assert resolved == _INSTALL_ROOT / "assets" / "fonts"
assert "src" not in resolved.relative_to(_INSTALL_ROOT).parts
def test_absolute_paths_pass_through_unchanged(self):
from src.base_classes.sports.core import SportsCore
absolute = Path("/tmp/some/logo/dir")
assert SportsCore._resolve_project_path(None, absolute) == absolute
def test_font_root_and_project_path_share_one_anchor(self):
"""Both consumers must derive from the same constant, so a future
move needs exactly one line changed rather than two."""
from src.base_classes.sports.core import SportsCore, _INSTALL_ROOT
assert SportsCore._font_root(None) == str(_INSTALL_ROOT)
-585
View File
@@ -1,585 +0,0 @@
"""Tests for the methods promoted onto SportsUpcoming / SportsRecent /
SportsLive from the nine plugin copies (phase B1 of the sports unification;
see docs/SPORTS_UNIFICATION.md).
Covered promotions:
- SportsRecent: `_get_zero_clock_duration` / `_clear_zero_clock_tracking`
(+ the `_zero_clock_timestamps` initializer).
- SportsLive: `_is_game_really_over` / `_detect_stale_games`
(+ `game_update_timestamps` / `stale_game_timeout`, and the
`FINAL_PERIOD` / `CLOCK_COUNTS_DOWN` class attributes).
- SportsUpcoming: `_select_games_for_display`.
- SportsRecent: `_select_recent_games_for_display`.
The live pair is the risk centre: `_detect_stale_games` is the only caller
that *removes* games, so `_is_game_really_over` returning a false positive
silently drops a live game from the display. The canonical form deliberately
declines to treat a missing clock as 0:00 the plugin variant that did so
dropped clockless sports (baseball) from the FINAL_PERIOD-th period onward.
That regression is pinned by
`test_missing_clock_at_late_period_is_not_over`.
"""
import logging
import sys
import time
from datetime import datetime, timezone
from unittest.mock import MagicMock
import pytest
import requests
from freezegun import freeze_time
from PIL import Image
# src.base_classes.sports transitively imports the hardware matrix driver;
# stub it so these tests can import the sports base classes off-device.
sys.modules.setdefault("rgbmatrix", MagicMock())
from src.base_classes.hockey import Hockey, HockeyLive
from src.base_classes.sports import (
SportsCore,
SportsLive,
SportsRecent,
SportsUpcoming,
)
# ---------------------------------------------------------------------------
# Harnesses
# ---------------------------------------------------------------------------
class _UpcomingHarness(Hockey, SportsUpcoming):
"""Cheapest concrete SportsUpcoming: hockey extractor + cache-fed data.
`_favorite_key` is inherited from SportsCore these harnesses
deliberately do NOT define it, so the selection tests exercise the real
seam rather than a local stand-in.
"""
def _fetch_data(self):
return None
class _RecentHarness(Hockey, SportsRecent):
def _fetch_data(self):
return None
class _LiveHarness(HockeyLive):
def _fetch_data(self):
return None
class _ThreePeriodLiveHarness(_LiveHarness):
"""Hockey-shaped: regulation ends after period 3."""
FINAL_PERIOD = 3
class _CountUpLiveHarness(_LiveHarness):
"""Soccer/AFL/NRL-shaped: the clock counts up, so 0:00 is kickoff."""
CLOCK_COUNTS_DOWN = False
class _IdFavoriteUpcomingHarness(_UpcomingHarness):
"""NRL-shaped: abbreviations are ambiguous, so favorites match on team id."""
def _favorite_key(self, game, side):
team_id = game.get(f"{side}_id")
return str(team_id) if team_id is not None else None
class _IdFavoriteRecentHarness(_RecentHarness):
def _favorite_key(self, game, side):
team_id = game.get(f"{side}_id")
return str(team_id) if team_id is not None else None
@pytest.fixture
def build_manager(monkeypatch, tmp_path):
"""Factory for concrete sports managers: mocked display/cache managers,
logo dir redirected to tmp, background service stubbed, and the requests
session rigged to prove nothing hits the network."""
monkeypatch.setattr(
SportsCore, "_initialize_logo_dir", lambda self, configured: tmp_path)
monkeypatch.setattr(
"src.base_classes.sports.core.get_background_service",
lambda *args, **kwargs: MagicMock())
def build(cls, **mode_cfg):
config = {
"timezone": "UTC",
"display": {},
"nhl_scoreboard": {"enabled": True, **mode_cfg},
}
display_manager = MagicMock()
display_manager.matrix.width = 128
display_manager.matrix.height = 32
display_manager.width = 128
display_manager.height = 32
display_manager.image = Image.new("RGB", (128, 32))
cache_manager = MagicMock()
cache_manager.get.return_value = None
cache_manager.cache_dir = str(tmp_path)
manager = cls(config, display_manager, cache_manager,
logging.getLogger("test_sports_modes_promotions"),
"nhl")
manager.session = MagicMock()
manager.session.get.side_effect = requests.exceptions.ConnectionError(
"promotion tests are offline")
return manager
return build
def game(game_id="1", home="BOS", away="TOR", start=None, home_id=None,
away_id=None, **extra):
g = {
"id": game_id,
"home_abbr": home,
"away_abbr": away,
"home_id": home_id,
"away_id": away_id,
"start_time_utc": start,
}
g.update(extra)
return g
def at(day, hour=12):
return datetime(2026, 1, day, hour, tzinfo=timezone.utc)
def _ids(games):
return [g["id"] for g in games]
# ---------------------------------------------------------------------------
# Tier 1 — zero-clock tracking (SportsRecent)
# ---------------------------------------------------------------------------
class TestZeroClockTracking:
def test_initializer_present_and_empty(self, build_manager):
manager = build_manager(_RecentHarness)
assert manager._zero_clock_timestamps == {}
def test_first_call_returns_zero_and_starts_tracking(self, build_manager):
manager = build_manager(_RecentHarness)
assert manager._get_zero_clock_duration("g1") == 0.0
assert "g1" in manager._zero_clock_timestamps
def test_subsequent_call_returns_elapsed_seconds(self, build_manager):
manager = build_manager(_RecentHarness)
with freeze_time("2026-01-20 12:00:00") as frozen:
assert manager._get_zero_clock_duration("g1") == 0.0
frozen.tick(45)
assert manager._get_zero_clock_duration("g1") == pytest.approx(45.0)
frozen.tick(15)
assert manager._get_zero_clock_duration("g1") == pytest.approx(60.0)
def test_tracking_is_per_game(self, build_manager):
manager = build_manager(_RecentHarness)
with freeze_time("2026-01-20 12:00:00") as frozen:
manager._get_zero_clock_duration("g1")
frozen.tick(30)
assert manager._get_zero_clock_duration("g2") == 0.0
assert manager._get_zero_clock_duration("g1") == pytest.approx(30.0)
def test_clear_resets_tracking(self, build_manager):
manager = build_manager(_RecentHarness)
with freeze_time("2026-01-20 12:00:00") as frozen:
manager._get_zero_clock_duration("g1")
frozen.tick(30)
manager._clear_zero_clock_tracking("g1")
assert "g1" not in manager._zero_clock_timestamps
# Restarts from zero after clearing.
assert manager._get_zero_clock_duration("g1") == 0.0
def test_clear_unknown_game_is_a_noop(self, build_manager):
manager = build_manager(_RecentHarness)
manager._clear_zero_clock_tracking("never-seen") # must not raise
assert manager._zero_clock_timestamps == {}
# ---------------------------------------------------------------------------
# Tier 2a — _is_game_really_over (SportsLive)
# ---------------------------------------------------------------------------
class TestIsGameReallyOver:
def test_missing_clock_at_late_period_is_not_over(self, build_manager):
"""THE baseball regression: no `clock` key at all, period 7.
The rejected variant coerced a missing clock to the literal "0:00" and
declared the game over dropping every MLB game from the 5th inning
onward, since baseball has no game clock and `period` is the inning.
A missing clock must fail safe.
"""
manager = build_manager(_LiveHarness)
g = game(period=7, period_text="Top 7th")
assert "clock" not in g
assert manager._is_game_really_over(g) is False
def test_none_clock_at_late_period_is_not_over(self, build_manager):
manager = build_manager(_LiveHarness)
assert manager._is_game_really_over(
game(clock=None, period=7, period_text="Top 7th")) is False
def test_non_string_clock_at_late_period_is_not_over(self, build_manager):
manager = build_manager(_LiveHarness)
assert manager._is_game_really_over(
game(clock=0, period=9, period_text="Bot 9th")) is False
def test_blank_clock_at_late_period_is_not_over(self, build_manager):
manager = build_manager(_LiveHarness)
assert manager._is_game_really_over(
game(clock=" ", period=5, period_text="5th")) is False
@pytest.mark.parametrize("period_text", ["Final", "final", "Final/OT",
"FINAL", "Final - SO"])
def test_final_period_text_is_over(self, build_manager, period_text):
manager = build_manager(_LiveHarness)
assert manager._is_game_really_over(
game(clock="12:00", period=2, period_text=period_text)) is True
def test_none_period_text_does_not_raise(self, build_manager):
"""All nine plugin copies called `.lower()` on `game.get("period_text", "")`,
which is None when the key is present-but-None; `_detect_stale_games`
has no try/except around the call."""
manager = build_manager(_LiveHarness)
assert manager._is_game_really_over(
game(clock="12:00", period=2, period_text=None)) is False
def test_missing_period_text_does_not_raise(self, build_manager):
manager = build_manager(_LiveHarness)
assert manager._is_game_really_over(game(clock="12:00", period=2)) is False
@pytest.mark.parametrize(
"clock", ["0:00", ":00", "00", "000", " 0:00 ", "00:00", "0", "0000"])
def test_expired_clock_at_final_period_is_over(self, build_manager, clock):
"""Every spelling of a zeroed clock counts, not a hand-listed few.
"00:00" is the one that motivated comparing numerically: it normalizes
to "0000", which matched none of the literals the plugin copies listed,
so a two-digit-minute expired clock kept the game on screen forever.
"""
manager = build_manager(_LiveHarness)
assert manager._is_game_really_over(
game(clock=clock, period=4, period_text="Q4")) is True
def test_none_period_at_expired_clock_does_not_raise(self, build_manager):
"""`period` present-but-None: `None >= FINAL_PERIOD` is a TypeError, and
`_detect_stale_games` has no try/except the same failure shape as the
`period_text` case above."""
manager = build_manager(_LiveHarness)
assert manager._is_game_really_over(
game(clock="0:00", period=None, period_text="Q4")) is False
def test_none_period_with_running_clock_does_not_raise(self, build_manager):
manager = build_manager(_LiveHarness)
assert manager._is_game_really_over(
game(clock="8:12", period=None, period_text="Q2")) is False
def test_expired_clock_after_final_period_is_over(self, build_manager):
manager = build_manager(_LiveHarness)
assert manager._is_game_really_over(
game(clock="0:00", period=5, period_text="OT")) is True
def test_expired_clock_before_final_period_is_not_over(self, build_manager):
manager = build_manager(_LiveHarness)
assert manager._is_game_really_over(
game(clock="0:00", period=3, period_text="Q3")) is False
@pytest.mark.parametrize("clock", [":40", "0:40", "1:00"])
def test_running_clock_is_not_over(self, build_manager, clock):
"""Sub-minute clocks like ':40' are legitimate, not expired."""
manager = build_manager(_LiveHarness)
assert manager._is_game_really_over(
game(clock=clock, period=4, period_text="Q4")) is False
def test_defaults_are_four_period_countdown(self):
assert SportsLive.FINAL_PERIOD == 4
assert SportsLive.CLOCK_COUNTS_DOWN is True
def test_final_period_override_three(self, build_manager):
"""Hockey-shaped subclass: regulation ends after period 3."""
manager = build_manager(_ThreePeriodLiveHarness)
assert manager.FINAL_PERIOD == 3
assert manager._is_game_really_over(
game(clock="0:00", period=3, period_text="P3")) is True
assert manager._is_game_really_over(
game(clock="0:00", period=2, period_text="P2")) is False
# And the unmodified default still requires period 4.
assert build_manager(_LiveHarness)._is_game_really_over(
game(clock="0:00", period=3, period_text="P3")) is False
def test_count_up_clock_never_expires(self, build_manager):
"""Soccer/AFL/NRL: 0:00 means kickoff, so the clock branch must not run."""
manager = build_manager(_CountUpLiveHarness)
assert manager.CLOCK_COUNTS_DOWN is False
for period in (1, 2, 4, 9):
assert manager._is_game_really_over(
game(clock="0:00", period=period, period_text="1st Half")) is False
def test_count_up_clock_still_honors_final_text(self, build_manager):
manager = build_manager(_CountUpLiveHarness)
assert manager._is_game_really_over(
game(clock="0:00", period=2, period_text="Final")) is True
# ---------------------------------------------------------------------------
# Tier 2b — _detect_stale_games (SportsLive)
# ---------------------------------------------------------------------------
class TestDetectStaleGames:
def test_initializer_defaults(self, build_manager):
manager = build_manager(_LiveHarness)
assert manager.game_update_timestamps == {}
assert manager.stale_game_timeout == 300
def test_stale_timeout_is_configurable(self, build_manager):
manager = build_manager(_LiveHarness, stale_game_timeout=42)
assert manager.stale_game_timeout == 42
def test_mutates_caller_list_in_place_and_returns_none(self, build_manager):
manager = build_manager(_LiveHarness)
fresh = game("1", period_text="P2", clock="10:00", period=2)
over = game("2", period_text="Final", clock="0:00", period=3)
games = [fresh, over]
original = games
result = manager._detect_stale_games(games)
assert result is None
assert games is original # same object, mutated in place
assert _ids(games) == ["1"]
def test_evicts_only_past_timeout_games(self, build_manager):
manager = build_manager(_LiveHarness)
with freeze_time("2026-01-20 12:00:00"):
now = time.time()
manager.game_update_timestamps = {
"1": {"last_seen": now - 10}, # fresh
"2": {"last_seen": now - 299}, # just inside the timeout
"3": {"last_seen": now - 301}, # past the timeout
}
games = [game("1", period_text="P1", clock="10:00", period=1),
game("2", period_text="P1", clock="10:00", period=1),
game("3", period_text="P1", clock="10:00", period=1)]
manager._detect_stale_games(games)
assert _ids(games) == ["1", "2"]
assert "3" not in manager.game_update_timestamps
assert set(manager.game_update_timestamps) == {"1", "2"}
def test_unknown_last_seen_is_never_stale(self, build_manager):
"""last_seen == 0 (or no entry) means 'never recorded', not 'ancient'."""
manager = build_manager(_LiveHarness)
games = [game("1", period_text="P1", clock="10:00", period=1),
game("2", period_text="P1", clock="10:00", period=1)]
manager.game_update_timestamps = {"1": {"last_seen": 0}}
manager._detect_stale_games(games)
assert _ids(games) == ["1", "2"]
def test_removes_games_that_are_really_over(self, build_manager):
manager = build_manager(_LiveHarness)
manager.game_update_timestamps = {"2": {"last_seen": 0}}
games = [game("1", period_text="Q2", clock="5:00", period=2),
game("2", period_text="Final", clock="0:00", period=4)]
manager._detect_stale_games(games)
assert _ids(games) == ["1"]
assert "2" not in manager.game_update_timestamps
def test_keeps_clockless_late_game(self, build_manager):
"""The end-to-end form of the baseball regression: a clockless game in
the 7th must survive the removal path."""
manager = build_manager(_LiveHarness)
games = [game("mlb-1", period=7, period_text="Top 7th")]
manager._detect_stale_games(games)
assert _ids(games) == ["mlb-1"]
def test_games_without_id_are_skipped(self, build_manager):
manager = build_manager(_LiveHarness)
no_id = {"home_abbr": "BOS", "away_abbr": "TOR",
"period_text": "Final", "clock": "0:00", "period": 4}
games = [no_id]
manager._detect_stale_games(games)
# `continue` fires before the "really over" check, so it stays.
assert games == [no_id]
def test_empty_list_is_tolerated(self, build_manager):
manager = build_manager(_LiveHarness)
games = []
assert manager._detect_stale_games(games) is None
assert games == []
def test_removal_is_by_value_not_identity(self, build_manager):
"""Sharp edge worth pinning: `list.remove` compares with `dict.__eq__`,
so two structurally-equal dicts drop the FIRST occurrence."""
manager = build_manager(_LiveHarness)
first = game("1", period_text="Final", clock="0:00", period=4)
twin = dict(first)
games = [first, twin]
manager._detect_stale_games(games)
# Both entries are removed here (two iterations, two removals), but the
# first removal deletes `first`, not the dict being iterated.
assert games == []
# ---------------------------------------------------------------------------
# Tier 3a — _select_games_for_display (SportsUpcoming)
# ---------------------------------------------------------------------------
class TestSelectGamesForDisplay:
def test_no_favorites_returns_all_sorted_ascending(self, build_manager):
manager = build_manager(_UpcomingHarness)
games = [game("late", start=at(20)), game("early", start=at(10)),
game("mid", start=at(15))]
assert _ids(manager._select_games_for_display(games, [])) == [
"early", "mid", "late"]
def test_missing_start_time_sorts_last(self, build_manager):
manager = build_manager(_UpcomingHarness)
games = [game("none", start=None), game("early", start=at(10))]
assert _ids(manager._select_games_for_display(games, [])) == [
"early", "none"]
def test_filters_to_favorite_teams(self, build_manager):
manager = build_manager(_UpcomingHarness)
games = [game("1", home="BOS", away="TOR", start=at(10)),
game("2", home="NYR", away="PIT", start=at(11)),
game("3", home="MTL", away="BOS", start=at(12))]
assert _ids(manager._select_games_for_display(games, ["BOS"])) == ["1", "3"]
def test_respects_upcoming_games_to_show_per_team(self, build_manager):
manager = build_manager(_UpcomingHarness, upcoming_games_to_show=2)
games = [game(str(i), home="BOS", away="TOR", start=at(10 + i))
for i in range(5)]
assert _ids(manager._select_games_for_display(games, ["BOS"])) == ["0", "1"]
def test_game_between_two_favorites_counts_for_both(self, build_manager):
manager = build_manager(_UpcomingHarness, upcoming_games_to_show=1)
games = [game("shared", home="BOS", away="TOR", start=at(10)),
game("bos2", home="BOS", away="NYR", start=at(11)),
game("tor2", home="TOR", away="PIT", start=at(12))]
# "shared" fills both BOS's and TOR's single slot, so nothing else fits.
assert _ids(manager._select_games_for_display(
games, ["BOS", "TOR"])) == ["shared"]
def test_deduplicates_by_game_id(self, build_manager):
manager = build_manager(_UpcomingHarness, upcoming_games_to_show=5)
g = game("dupe", home="BOS", away="TOR", start=at(10))
assert _ids(manager._select_games_for_display(
[g, dict(g)], ["BOS"])) == ["dupe"]
def test_non_favorite_games_excluded(self, build_manager):
manager = build_manager(_UpcomingHarness)
games = [game("1", home="NYR", away="PIT", start=at(10))]
assert manager._select_games_for_display(games, ["BOS"]) == []
def test_favorite_key_seam_supports_id_matching(self, build_manager):
"""The NRL case: two clubs share the abbreviation 'NEW', so favorites
must be matched on team id. Only the seam changes the promoted
method is identical."""
games = [
game("knights", home="NEW", away="SYD", home_id=1, away_id=2,
start=at(10)),
game("warriors", home="NEW", away="SYD", home_id=99, away_id=2,
start=at(11)),
]
abbr_manager = build_manager(_UpcomingHarness)
# Abbreviation matching cannot tell the two "NEW" clubs apart.
assert _ids(abbr_manager._select_games_for_display(games, ["NEW"])) == [
"knights", "warriors"]
id_manager = build_manager(_IdFavoriteUpcomingHarness)
assert _ids(id_manager._select_games_for_display(games, ["99"])) == [
"warriors"]
def test_favorite_key_none_never_matches(self, build_manager):
"""`_favorite_key` returning None (missing id) must not match, even
against a favorites list holding the string 'None'."""
manager = build_manager(_IdFavoriteUpcomingHarness)
games = [game("1", home="BOS", away="TOR", start=at(10))] # no ids
assert manager._select_games_for_display(games, ["None"]) == []
# ---------------------------------------------------------------------------
# Tier 3b — _select_recent_games_for_display (SportsRecent)
# ---------------------------------------------------------------------------
class TestSelectRecentGamesForDisplay:
def test_no_favorites_returns_all_sorted_descending(self, build_manager):
manager = build_manager(_RecentHarness)
games = [game("early", start=at(10)), game("late", start=at(20)),
game("mid", start=at(15))]
assert _ids(manager._select_recent_games_for_display(games, [])) == [
"late", "mid", "early"]
def test_missing_start_time_sorts_last(self, build_manager):
manager = build_manager(_RecentHarness)
games = [game("none", start=None), game("late", start=at(20))]
assert _ids(manager._select_recent_games_for_display(games, [])) == [
"late", "none"]
def test_respects_recent_games_to_show_per_team(self, build_manager):
manager = build_manager(_RecentHarness, recent_games_to_show=2)
games = [game(str(i), home="BOS", away="TOR", start=at(10 + i))
for i in range(5)]
# Most recent first.
assert _ids(manager._select_recent_games_for_display(
games, ["BOS"])) == ["4", "3"]
def test_game_between_two_favorites_counts_for_both(self, build_manager):
manager = build_manager(_RecentHarness, recent_games_to_show=1)
games = [game("shared", home="BOS", away="TOR", start=at(20)),
game("bos2", home="BOS", away="NYR", start=at(19)),
game("tor2", home="TOR", away="PIT", start=at(18))]
assert _ids(manager._select_recent_games_for_display(
games, ["BOS", "TOR"])) == ["shared"]
def test_deduplicates_by_game_id(self, build_manager):
manager = build_manager(_RecentHarness, recent_games_to_show=5)
g = game("dupe", home="BOS", away="TOR", start=at(10))
assert _ids(manager._select_recent_games_for_display(
[g, dict(g)], ["BOS"])) == ["dupe"]
def test_non_favorite_games_excluded(self, build_manager):
manager = build_manager(_RecentHarness)
games = [game("1", home="NYR", away="PIT", start=at(10))]
assert manager._select_recent_games_for_display(games, ["BOS"]) == []
def test_favorite_key_seam_supports_id_matching(self, build_manager):
games = [
game("knights", home="NEW", away="SYD", home_id=1, away_id=2,
start=at(10)),
game("warriors", home="NEW", away="SYD", home_id=99, away_id=2,
start=at(11)),
]
id_manager = build_manager(_IdFavoriteRecentHarness)
assert _ids(id_manager._select_recent_games_for_display(
games, ["99"])) == ["warriors"]
# ---------------------------------------------------------------------------
# Seam / inertness guards
# ---------------------------------------------------------------------------
class TestPromotionShape:
def test_promoted_methods_live_on_the_right_classes(self):
assert hasattr(SportsRecent, "_get_zero_clock_duration")
assert hasattr(SportsRecent, "_clear_zero_clock_tracking")
assert hasattr(SportsRecent, "_select_recent_games_for_display")
assert hasattr(SportsUpcoming, "_select_games_for_display")
assert hasattr(SportsLive, "_is_game_really_over")
assert hasattr(SportsLive, "_detect_stale_games")
def test_modes_does_not_define_the_favorite_key_seam(self):
"""`_favorite_key` is a SportsCore override point. modes.py must call
it, never define it this test fails if the seam is added in the
wrong file."""
import src.base_classes.sports.modes as modes
for cls in (SportsUpcoming, SportsRecent, SportsLive):
assert "_favorite_key" not in vars(cls)
assert "_favorite_key" not in modes.__dict__