mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-03 01:38:06 +00:00
Sports unification phases 1–2: package split, promoted methods, opt-in capabilities (#426)
* fix(fonts): resolve asset paths against the install root, not the cwd
FontManager built its catalog from cwd-relative paths ('assets/fonts'),
so any process started outside the install root — the plugin safety
harness on CI being the recurring case — found no fonts and silently
degraded every plugin to PIL's default face. Several plugins grew
per-plugin workarounds for exactly this (countdown, text-display,
tide-display in the plugins monorepo).
Catalog population now falls back to the install root derived from this
module's location when the cwd-relative path is missing; behavior when
running from the install root is unchanged. Verified: resolve_font
returns the real FreeType face from a foreign cwd, and the full unit
suites (266 tests) pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* docs: seed CHANGELOG.md with the module-availability release discipline
The plugins monorepo's sunset rule ('delete a bundled fallback copy only
when the manifest floors on the first core release shipping the module')
needs core module additions recorded against version numbers. Seeds the
changelog at 3.1.0 and documents the discipline.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* ci: enroll the core unit suites in a dedicated job
The existing workflow ran only the three plugin-harness suites; the
skin-system, font-manager, data-source, extractor, scroll-helper,
adaptive-layout, and loader-compat suites (266 tests) existed but never
ran in CI, so a refactor of src/base_classes or src/common could regress
them silently. Also enrolls the new sports characterization and
element-style suites landing in this branch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* feat: ship src/element_style — the per-element style resolver plugins already expect
Three plugins (of-the-day, ledmatrix-music, football-scoreboard) import
src.element_style behind guarded try/except with classic fallbacks, but
the module never existed in core, so the richer per-element styling UI
those code paths implement has been dormant. This lands it:
- ElementStyleResolver.style() resolves per-element font/size/color with
the key semantic the consumers encode: a config value counts as
user-forced only when it differs from the schema default (the web UI
bakes defaults into config.json on save), and untouched configs
resolve to exactly the caller's classic values — byte-identical
rendering, proven by of-the-day's committed goldens passing unchanged.
- defaults_from_schema_file parses both declaration forms (the compact
x-style-elements map and hand-written customization blocks).
- expand_style_elements() expands x-style-elements into full config
blocks; schema_manager.load_schema() applies it (guarded, no-op for
schemas without the declaration) so the config form and defaults
merging see the expanded UI.
- Fonts resolve cwd-independently with (path, size) caching; .bdf loads
via freetype like FontManager; nothing in the module raises out of
style().
Verified: 31 new unit tests; of-the-day's previously-skipped 9-test
spec suite now runs and passes; football's resolver tests pass (27);
music's 38 plugin tests pass; schema-manager suites pass (43).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* test: characterization suite for src/base_classes/sports.py ahead of unification
Pins current behavior before the planned merge of the nine drifted
plugin copies back into this ancestor: the _extract_game_details_common
key contract per sport (reusing GUARANTEED_KEYS from the skin tests),
update() flows for upcoming/recent/live against cache-seeded fixtures
under frozen time, rendering smoke per mode class, and guard rails on
the skin-system seam.
Five surprising behaviors are pinned AS-IS and flagged in comments so
the merge changes them knowingly or not at all: is_upcoming also
matching status.type.name; hockey dropping events whose competitors
lack 'statistics'; baseball reading the event-level status for innings;
no past-date filter in upcoming; and favorites-only mode with an empty
favorites list showing nothing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* ci: restrict the test workflow's GITHUB_TOKEN to contents:read
CodeQL flagged the new unit-tests job for running with the default
unrestricted token; the pre-existing job had the same exposure. Both
jobs only check out the repo and run pytest, so a workflow-level
contents:read is sufficient.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* refactor(sports): convert sports.py into a package (pure move)
Phase B1a of docs/SPORTS_UNIFICATION.md. src/base_classes/sports.py
becomes a package so the upcoming capability modules have a home and
diffs show their blast radius:
sports/__init__.py re-exports the public API
sports/core.py SportsCore
sports/modes.py SportsUpcoming / SportsRecent / SportsLive
No logic change: the 1515 class-body lines are byte-identical to the
original (verified by concatenating the two modules and diffing against
HEAD). Only module docstrings and the redistributed import blocks are
new. MRO and __abstractmethods__ are unchanged, and every existing
import site — including 'from src.base_classes.sports import SportsCore'
in the sport subclasses, the skin tests, and the characterization
suite — resolves through the package __init__.
One test edit was required: the characterization suite monkeypatched
'src.base_classes.sports.get_background_service', which is no longer a
module attribute on a package. Retargeted to
'src.base_classes.sports.core.get_background_service' — the module whose
globals SportsCore.__init__ actually resolves, so the patch is effective
exactly as before. No test logic or assertion changed.
Also adds docs/SPORTS_UNIFICATION.md: the architecture for the whole
B1-B5 sequence — how upgradability (guarded imports, capability probing,
frozen view-model keys, the sunset rule), reusability (promote only what
all nine copies share), and modularity (capabilities as opt-in mixins
rather than config branches, variants as named strategies, sport-unique
code as declared override points) are kept as three separate mechanisms.
Verified: characterization + skin 94 passed; the 10-file unit suite 338
passed; test/plugins 60 passed — all identical to pre-change counts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* feat(sports): promote the nine universal methods into the base classes
Phase B1b of docs/SPORTS_UNIFICATION.md. Every method here is present in
all nine bundled plugin sports.py copies and absent from core, so this is
reuse of code the fleet already agreed on — not new behavior. The
promotions are inert until B5: the plugins' own overrides still run.
SportsCore: cleanup, _get_layout_offset, _load_custom_font_from_element_config
SportsUpcoming: _select_games_for_display
SportsRecent: _get_zero_clock_duration, _clear_zero_clock_tracking,
_select_recent_games_for_display
SportsLive: _is_game_really_over, _detect_stale_games
Where the copies disagreed, the canonical form was chosen on evidence and
the genuine per-sport differences became seams rather than branches:
- _favorite_key(game, side) -- NRL matches favorites on team id because its
abbreviations are ambiguous (NEW is both Newcastle Knights and New
Zealand Warriors). Default is the abbreviation; NRL overrides. Core never
learns the string nrl.
- FINAL_PERIOD / CLOCK_COUNTS_DOWN -- hockey ends in P3, and soccer/afl/nrl
clocks count UP, so 0:00 means kickoff, not expiry.
- _config_schema_path() / _font_root() -- plugin-supplied locations, never
derived from this module's __file__.
BEHAVIOR CHANGE (baseball, ufc): the rejected variant coerced a missing or
non-str clock to the literal 0:00 and then declared the game over at
period >= 4. MLB has no game clock and period is the inning, so live games
were being evicted from the 5th inning onward; UFC likewise. The promoted
variant skips the clock check when the clock is unusable -- it fails safe
(keeps showing the game) instead of failing destructive.
Also fixes a regression from the package move in e591cec: the bodies were
byte-identical but __file__ gained a directory, so _resolve_project_path's
parents[2] silently began resolving to <root>/src instead of the repo root.
Both it and _font_root now derive from a single _INSTALL_ROOT constant, so
a future move needs one line changed rather than two hand-counted depths.
Tests assert the resolved values, not the index.
The font loader takes baseball's body (BDF memo cache + native-strike
retry) under hockey's Optional signature -- the older lineage is the
correct one here, and basketball's positional str default breaks on an
explicit None. It resolves through _font_root rather than the cwd, so it
does not reintroduce the bug just fixed for FontManager, and delegates to
FontManager for the alias table and BDF header parse instead of shipping
second copies. cleanup gained the two new font caches and still leaves
background_service alone -- it is a process-wide singleton.
Verified: 111 new tests (48 core + 59 modes + 4 install-root regression);
characterization + skin suites still exactly 94, unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* fix(sports): stop dropping hockey and baseball events on optional feed keys
Both bugs were pinned AS-IS by the B0 characterization suite so this
phase could change them knowingly. Both fixes are adoptions of code the
corresponding plugins already ship, not new inventions.
Hockey: the extractor read competitor["statistics"] unguarded, so a
competitor arriving without that array raised KeyError inside the
generator and the WHOLE event was discarded -- valid scores and status
included. Shot/save counts now default to 0, which is already what the
suite expects for an empty statistics array.
Baseball: for live games the extractor read game_event["status"], the
event TOP-LEVEL status, to get the inning. Real ESPN events duplicate
status there, but MiLB events (synthesized from the MLB Stats API into
an ESPN-like shape) populate only the competition-level one, so the
lookup raised a bare KeyError and dropped the event. It now reads the
competition-level status that _extract_game_details_common has already
validated, so it cannot be missing at that point.
The two characterization tests that pinned the old behaviour are
rewritten to assert the fix rather than deleted, so the suite still
documents the edge case -- and still totals 94.
CHANGELOG records these plus the live-clock change from aaabc61 under
Changed/Fixed, since all three are user-visible. The two new promotion
suites join the CI unit job (449 tests).
Verified: unit job 449 passed, plugin-safety job 60 passed, and the
hockey (16) and baseball (24) plugin harnesses render clean at every
panel size.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* fix(sports): harden the live game-over check and font/log init
Follow-up review findings on the promoted base-class methods.
_is_game_really_over:
- `period` present-but-None raised TypeError on `None >= FINAL_PERIOD`,
taking down the whole live-update pass (_detect_stale_games has no
try/except). Same failure shape as the null `period_text` already fixed.
- An expired clock spelled "00:00" normalizes to "0000", which matched
none of the hand-listed literals, so a finished game with a two-digit
minute clock stayed on the scoreboard forever. Compare numerically.
SportsCore:
- _load_fonts kept the cwd-relative "assets/fonts/..." literals the
_font_root() seam exists to remove, so every scoreboard font degraded
to PIL's default face outside the install root.
- _should_log read self._last_warning_time unguarded while only an
unrelated method initialized it lazily; the first warning of a run
raised AttributeError. Initialize it in __init__.
Also documents that game_update_timestamps is written by subclasses, not
by the base class, so the staleness branch is inert until B5 adoption.
14 new tests. Gates: 463 core unit, 60 plugin safety.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* feat(sports): opt-in celebration and rotation capabilities
Phase B2 of the sports unification. Both features exist in only some of
the nine scoreboards, so they ship as capabilities the plugin composes,
never as `if self.<feature>_enabled` branches inside the base classes: a
sport that does not opt in has none of this code in its MRO.
CelebrationMixin (afl, nrl, soccer, football)
The two lineages spelled this differently -- _check_for_goal /
celebrate_opponent_goals vs _check_for_score / celebrate_opponent_scores
-- but the bodies were identical apart from three things, each now a
seam rather than a branch:
- wording -> score_phrase() / win_phrase() hooks
- follow-up suppression -> COALESCE_SCORING_SEQUENCE, on for football
where a touchdown lands as +6 then +1, off where two increments are
two real goals
- team identity -> _favorite_key, so nrl matches on team id without
core learning why its abbreviations are ambiguous
Both config spellings are read, so a plugin adopting the mixin keeps
working with the keys already in its published schema.
Rotation strategies
The three "dialects" turned out to be one algorithm (SWRR) in two
shapes: an incremental picker holding state across calls, and a
precomputed per-cycle list. They agree within a cycle and differ only at
the boundary, so core ships both behind a name registry rather than
declaring a winner. weight_for is supplied by the host, so rotation.py
never learns what a favorite is; an unknown name degrades to "simple"
because it arrives from user config.
Each strategy is checked against a verbatim transcription of the plugin
code it replaces, over every live-game shape up to four games -- the
differential B5 will delete the bundled copies on the strength of.
185 new tests. Gates: 648 core unit, 60 plugin safety.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* feat(scroll): upstream the scroll orchestration layer; release 3.2.0
Phases B3 and B4.
B3 -- src/common/sports_scroll.py is deliberately NOT a superset of the
ten plugin scroll_display.py copies. A method-level comparison of the
eight that share a shape (f1 and ufc are genuine forks) found a sharp
split, and the module is drawn along it:
promoted orchestration -- get_all_vegas_content_items is identical
in all eight; clear_all, get_scroll_info,
get_dynamic_duration, is_complete and display_frame are
96-100% similar
promoted settings -- one algorithm; the copies differ only in which
league keys they walk, so the ladder is data
(SCROLL_LEAGUE_KEYS) rather than a body per sport
NOT content -- prepare_scroll_content has 8 distinct bodies
across 8 plugins (145 lines, 53% similar at worst) and
_load_separator_icons 7 (6% at worst)
Same name, different job: prepare_scroll_content draws *this sport's*
game card. Merging those eight bodies would be exactly the mistake the
promotion rule exists to prevent, so the base raises NotImplementedError
rather than rendering something plausible -- a base that rendered
something would let a plugin ship a silently blank scroll.
The one behavior added over the plugin copies is native
global_config['target_fps'] support. The bundled copies hardcode ~100
FPS via scroll_delay and never consult the global target; Part A
threaded it through each copy by hand, and this makes that threading
legacy compatibility rather than the mechanism.
66 tests, including three against the real ScrollHelper rather than a
double -- a suite built entirely on MagicMock would sail straight past a
rename in the helper.
B4 -- bump src/__init__.py to 3.2.0 and close the CHANGELOG's Unreleased
section against it. This is the number the sunset rule keys on: the
first core release shipping the unified sports library, and therefore
the floor a plugin sets ledmatrix_min_version to before deleting its
bundled copies. The version bump and the changelog release heading move
together on purpose -- separating them would leave a commit whose
changelog announces 3.2.0 while the code still reports 3.1.0.
Nothing here changes what an existing plugin loads; adoption is B5.
Gates: 714 core unit, 66 plugin safety.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* fix(sports): per-type warning cooldowns and font-load logging
Follow-ups from the second review pass, both on already-fixed findings:
_should_log accepted a warning_type and ignored it, sharing one
timestamp across every kind of warning -- so an API-error warning
silenced an unrelated cache warning for the next minute, and whichever
fired first won. Cooldowns are now keyed by type. Nothing in core calls
this method, so no behavior regressed; _last_warning_time is kept in
step for subclasses that read it directly.
_load_fonts logged through the module-level logger, dropping the manager
context, and had no return type hint. It now uses self.logger (set well
before _load_fonts runs) and names the directory it searched -- the bare
"Fonts not found" sent people hunting for a font-format problem when the
actual cause is an install missing assets/fonts.
Gates: 717 core unit, 66 plugin safety.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* docs: record the validated hockey scroll-display pilot for B5
B5 cannot ship until this PR merges and 3.2.0 exists -- a plugin cannot
floor ledmatrix_min_version at a release that does not exist, and an
unguarded src.common.sports_scroll import would break every user on
3.1.0.
The pilot has been validated ahead of that gate: hockey's
scroll_display.py adopted against a core carrying 3.2.0 goes from 691 to
289 lines with all 16 harness renders byte-for-byte identical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* fix(sports): harden the B2/B3 capabilities against bad config and subclasses
Review pass on the phase B2-B4 changes. Every fix here is the same shape
as the crashes this PR already fixed in the hockey and baseball
extractors: a config or feed value that is present-but-wrong reaching
arithmetic or a comparison on a path with no guard.
celebrations:
- celebration_duration is coerced and floored at init. It is compared
numerically in display() *outside* any try block, so a string from a
hand-edited config propagated a TypeError straight out; zero or
negative armed a celebration that could never render.
- A render failure now disarms instead of staying armed. It previously
retried the same broken render on every frame for the rest of the
window -- a traceback per frame, and no scorebug either.
- prune_score_baselines() for the live set. Only _check_for_win removed
entries, so a game that left the live list any other way leaked its
baseline and the dict grew all season.
- display() reuses has_active_celebration() rather than repeating its
window comparison, and log lines carry a [Celebrations] prefix.
rotation:
- MAX_WEIGHT ceiling. A cycle is sum(weights) long and each step scans
every game, so an unbounded weight from a misread config spins the
display thread -- on a Pi that stalls rendering outright.
- register_rotation_strategy rejects a non-subclass factory at
registration instead of failing frames later inside schedule().
- schedule() previews through type(self), so a subclass overriding
next_game is previewed with its own ordering -- which is what the
method promises.
sports_scroll:
- scroll_speed / scroll_delay coerced. dict.get(key, default) only helps
when the key is absent; present-but-null reached the multiplication
inside __init__ and the display failed to construct at all.
- update_scroll_position and get_visible_portion moved inside the try.
They ran outside it, so a raise there reached the plugin's frame loop
despite the comment promising none can.
- prepare_and_display guards the subclass call, so one sport's bad
payload cannot take down the shared orchestration for the others.
- _current_game_type spells "nothing active" as "" in both classes; the
manager said None while the display said "".
Not taken: the report that baseball's favorite-team debug path still
reads event-level status. Verified against current code -- there are no
remaining game_event["status"] reads in that file; it was fixed in
2486bdb and the finding is stale.
Gates: 747 core unit, 66 plugin safety.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* fix(baseball): don't drop favourite MiLB games on the diagnostic path
The competition-level status fallback fixed the inning lookup, but the
favourite-team debug block a few lines above still read the event top-level
game_event["status"]. MiLB events (synthesized from the MLB Stats API into an
ESPN-like shape) populate only the competition-level status, so the identical
event that extracted fine for a non-favourite raised KeyError and returned
None once the team was a favourite.
Worst possible shape for the bug: it only hit the games the user cared most
about, and only on the path meant to help diagnose them. The existing
regression test missed it because it never passes favourites, so
is_favorite_game was False and the block never ran.
Uses the validated competition-level `status`, which
_extract_game_details_common guarantees is present by that point. Adds a
favourites-passing companion test; confirmed it reproduces the KeyError
without the fix.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
* docs(sports): type-hint game_update_timestamps to match its sibling
Addresses the last remaining sub-point on the modes.py review thread. The
design finding itself is already handled: the base class documents that it
only reads game_update_timestamps and that a subclass's update() owns writing
"last_seen" (and afl/etc. do, so stale-game eviction works in practice). The
one concrete gap was the missing annotation -- _zero_clock_timestamps is typed
Dict[str, float] while this nested map had none. Now Dict[str, Dict[str, float]].
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
* Address CodeRabbit review: font-name traversal + offline test guard
Two Minor findings from CodeRabbit's first review of this PR.
- resolve_font_path: reject relative font names carrying path components.
font_name comes from plugin config, which the web UI writes; a value like
"../../config/config.json" escaped assets/fonts/ after os.path.join and let
a config probe arbitrary paths for existence (disclosure unlikely, since
Pillow/freetype reject non-font files, but the probe is real). Relative
names must now be bare filenames (os.path.basename(name) == name); absolute
paths keep their existing isfile() gate. Test confirms the traversal
resolved the real config.json before the guard.
- build_manager fixture: patch requests.Session.get BEFORE constructing the
manager. Construction creates both SportsCore.session and the
ESPNDataSource.session; the old code only replaced manager.session after
the fact, leaving data_source.session real and able to reach the network on
an accidental fetch. Patching the class makes every session built in the
fixture offline.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
* test(celebrations): make the expiry tests actually test expiry
CodeRabbit (Major) on the merge re-review: celebration_duration is clamped to
a 1.0s floor, so the two expiry tests that configured 0 and expected instant
expiration never actually hit the expiry branch. They passed only because
_draw_celebration_layout raises in the harness (no real fonts) and its
exception branch clears the celebration the same way -- so they were really
re-testing the render-failure path, not expiry.
Now use a valid 1s duration, backdate started_at past the window, and mock
_draw_celebration_layout with assert_not_called() so an expired celebration
provably does NOT render. Verified discriminating: both fail if
has_active_celebration is forced to never expire.
Production code unchanged -- the expiry logic was already correct; only the
tests were mismodelling it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
---------
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -79,9 +79,10 @@ def _competitor(abbr, team_id, score, home_away, record="30-10-5"):
|
||||
"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).
|
||||
# The hockey extractor reads competitor["statistics"] for shot counts;
|
||||
# it now defaults to an empty list when the key is absent rather than
|
||||
# dropping the whole event (see
|
||||
# test_hockey_event_without_statistics_still_extracts).
|
||||
"statistics": [],
|
||||
}
|
||||
|
||||
@@ -305,15 +306,25 @@ class TestExtractGameDetailsContract:
|
||||
assert details["home_shots"] == 0
|
||||
assert details["away_shots"] == 0
|
||||
|
||||
def test_hockey_event_without_statistics_returns_none(self):
|
||||
# PINNED AS-IS: the hockey extractor unconditionally iterates
|
||||
# competitor["statistics"]; a competitor without the key raises
|
||||
# KeyError internally and the WHOLE event is dropped (returns
|
||||
# None), even though scores/status are present.
|
||||
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"]
|
||||
assert extract(Hockey, event) is None
|
||||
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(
|
||||
@@ -337,14 +348,35 @@ class TestExtractGameDetailsContract:
|
||||
assert details["status"] == "status_in_progress"
|
||||
assert details["series_summary"] == ""
|
||||
|
||||
def test_baseball_live_without_top_level_status_returns_none(self):
|
||||
# PINNED AS-IS: for live games the baseball extractor reads
|
||||
# game_event["status"] (the event TOP-LEVEL status, not the
|
||||
# competition status) for the inning; an otherwise-valid live
|
||||
# event lacking that duplicate key is dropped entirely.
|
||||
event = make_event("412", "in", "2026-07-16T23:05:00Z")
|
||||
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"]
|
||||
assert extract(Baseball, event) is None
|
||||
details = extract(Baseball, event)
|
||||
assert details is not None
|
||||
assert details["inning"] == 7
|
||||
|
||||
def test_baseball_live_without_top_level_status_extracts_for_favorites(self):
|
||||
# The favourite-team branch logs the status payload for diagnostics and
|
||||
# read the same event top-level key the test above proves can be
|
||||
# absent. So the identical MiLB event that extracts fine for a
|
||||
# non-favourite raised KeyError and was dropped once the team WAS a
|
||||
# favourite -- the worst shape for the bug, since it only hit the games
|
||||
# the user cared most about, and only on the diagnostic path that was
|
||||
# supposed to help debug them.
|
||||
event = make_event("413", "in", "2026-07-16T23:05:00Z", period=7)
|
||||
del event["status"]
|
||||
details = extract(Baseball, event, favorites=["TB"])
|
||||
assert details is not None
|
||||
assert details["inning"] == 7
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -400,7 +432,7 @@ def build_manager(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(
|
||||
SportsCore, "_initialize_logo_dir", lambda self, configured: tmp_path)
|
||||
monkeypatch.setattr(
|
||||
"src.base_classes.sports.get_background_service",
|
||||
"src.base_classes.sports.core.get_background_service",
|
||||
lambda *args, **kwargs: MagicMock())
|
||||
|
||||
# Rig requests.Session BEFORE any manager is built. Construction creates
|
||||
|
||||
@@ -0,0 +1,880 @@
|
||||
"""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
|
||||
import time
|
||||
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)
|
||||
|
||||
@pytest.mark.parametrize("bad", [
|
||||
SimpleRotation(), # an instance, not the class
|
||||
str, # unrelated class
|
||||
lambda **kw: None, # a factory function
|
||||
])
|
||||
def test_a_non_strategy_factory_is_rejected(self, bad):
|
||||
"""Fail at registration, not several frames later inside schedule(),
|
||||
where the cause is no longer on the stack."""
|
||||
with pytest.raises(TypeError):
|
||||
register_rotation_strategy("bad-factory", bad)
|
||||
|
||||
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}
|
||||
|
||||
def test_huge_weights_are_clamped(self):
|
||||
"""A cycle is sum(weights) long and each step scans every game, so an
|
||||
unbounded weight from a misread config spins the display thread."""
|
||||
strategy = get_rotation_strategy("weighted", weight_for=lambda g: 10_000)
|
||||
assert strategy.weights([game("a")]) == {"a": RotationStrategy.MAX_WEIGHT}
|
||||
|
||||
def test_a_clamped_cycle_stays_bounded(self):
|
||||
strategy = get_rotation_strategy("weighted", weight_for=lambda g: 10_000)
|
||||
order = strategy.schedule([game("a"), game("b")])
|
||||
assert len(order) == 2 * RotationStrategy.MAX_WEIGHT
|
||||
|
||||
|
||||
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_preview_uses_the_subclass_ordering(self):
|
||||
"""schedule() promises the order repeated next_game calls produce. Built
|
||||
from the base class, a subclass that overrides next_game gets a preview
|
||||
of the wrong algorithm."""
|
||||
class Reversed(SmoothWeightedRotation):
|
||||
def next_game(self, games):
|
||||
return super().next_game(list(reversed(games)))
|
||||
|
||||
games = [game("a"), game("b"), game("c")]
|
||||
strategy = Reversed()
|
||||
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
|
||||
|
||||
@pytest.mark.parametrize("bad", ["eight", None, {}, []])
|
||||
def test_unusable_duration_falls_back(self, celebrating, bad):
|
||||
"""The duration is compared numerically on the display path, outside
|
||||
any try block — a string from a hand-edited config would propagate a
|
||||
TypeError straight out of display()."""
|
||||
manager = celebrating(mode_config={"celebration_duration": bad})
|
||||
assert manager.celebration_duration == 8.0
|
||||
|
||||
@pytest.mark.parametrize("bad", [0, -5])
|
||||
def test_non_positive_duration_is_floored(self, celebrating, bad):
|
||||
"""Zero or negative would arm a celebration that can never render."""
|
||||
manager = celebrating(mode_config={"celebration_duration": bad})
|
||||
assert manager.celebration_duration == 1.0
|
||||
|
||||
def test_numeric_string_duration_is_accepted(self, celebrating):
|
||||
assert celebrating(
|
||||
mode_config={"celebration_duration": "12"}).celebration_duration == 12.0
|
||||
|
||||
|
||||
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):
|
||||
# A short-but-valid duration: celebration_duration is clamped to a 1.0s
|
||||
# floor, so a config of 0 does NOT expire on the next frame. Backdate
|
||||
# started_at past the window to exercise the real expiry branch —
|
||||
# otherwise the celebration is still active and this only passes
|
||||
# because _draw_celebration_layout happens to raise in the harness
|
||||
# (that path is covered by test_a_render_failure_falls_through).
|
||||
manager = celebrating(mode_config={"celebration_duration": 1})
|
||||
manager._check_for_score(game("g1", home_score=0))
|
||||
manager._check_for_score(game("g1", home_score=1))
|
||||
manager.active_celebration["started_at"] = time.time() - 2 # past the 1s window
|
||||
# Mock the layout so a render can't raise: otherwise the exception
|
||||
# branch clears the celebration too, and this test would pass whether
|
||||
# or not expiry actually fired. An expired celebration must NOT render.
|
||||
manager._draw_celebration_layout = MagicMock()
|
||||
assert manager.display() is True
|
||||
manager._draw_celebration_layout.assert_not_called()
|
||||
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": 1})
|
||||
manager._check_for_score(game("g1", home_score=0))
|
||||
manager._check_for_score(game("g1", home_score=1))
|
||||
manager.active_celebration["started_at"] = time.time() - 2 # past the 1s window
|
||||
manager._draw_celebration_layout = MagicMock() # expiry, not a render failure
|
||||
manager.display()
|
||||
manager._draw_celebration_layout.assert_not_called()
|
||||
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_a_render_failure_disarms_rather_than_retrying(self, celebrating):
|
||||
"""Left armed, the same render fails on every frame for the rest of the
|
||||
window — a traceback per frame, and no scorebug."""
|
||||
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"))
|
||||
manager.display()
|
||||
assert manager.active_celebration is None
|
||||
manager.display()
|
||||
assert manager._draw_celebration_layout.call_count == 1
|
||||
|
||||
|
||||
class TestBaselinePruning:
|
||||
"""`_score_baselines` gains an entry per game and only _check_for_win ever
|
||||
removed one, so a board running all season grows the dict without bound."""
|
||||
|
||||
def test_prunes_games_no_longer_live(self, celebrating):
|
||||
manager = celebrating()
|
||||
for gid in ("g1", "g2", "g3"):
|
||||
manager._check_for_score(game(gid, home_score=1))
|
||||
manager.prune_score_baselines([game("g2")])
|
||||
assert set(manager._score_baselines) == {"g2"}
|
||||
|
||||
def test_keeps_every_still_live_game(self, celebrating):
|
||||
manager = celebrating()
|
||||
for gid in ("g1", "g2"):
|
||||
manager._check_for_score(game(gid, home_score=1))
|
||||
manager.prune_score_baselines([game("g1"), game("g2")])
|
||||
assert set(manager._score_baselines) == {"g1", "g2"}
|
||||
|
||||
def test_empty_live_set_clears_everything(self, celebrating):
|
||||
manager = celebrating()
|
||||
manager._check_for_score(game("g1", home_score=1))
|
||||
manager.prune_score_baselines([])
|
||||
assert manager._score_baselines == {}
|
||||
|
||||
def test_pruning_does_not_disturb_a_surviving_baseline(self, celebrating):
|
||||
manager = celebrating()
|
||||
manager._check_for_score(game("g1", home_score=2))
|
||||
manager.prune_score_baselines([game("g1")])
|
||||
manager._check_for_score(game("g1", home_score=3))
|
||||
assert manager.active_celebration is not None, (
|
||||
"pruning must not drop a live game's baseline and re-trigger the "
|
||||
"first-sighting suppression")
|
||||
|
||||
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")])
|
||||
@@ -0,0 +1,613 @@
|
||||
"""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._warning_cooldowns["api"] -= 61
|
||||
assert manager._should_log("api", cooldown=60) is True
|
||||
|
||||
def test_cooldowns_are_tracked_per_warning_type(self, build):
|
||||
"""The parameter was accepted and ignored: one shared timestamp meant an
|
||||
API warning silenced an unrelated cache warning for the next minute."""
|
||||
manager = build()
|
||||
assert manager._should_log("api", cooldown=60) is True
|
||||
assert manager._should_log("cache", cooldown=60) is True
|
||||
assert manager._should_log("api", cooldown=60) is False
|
||||
assert manager._should_log("cache", cooldown=60) is False
|
||||
|
||||
def test_one_type_expiring_does_not_free_another(self, build):
|
||||
manager = build()
|
||||
manager._should_log("api")
|
||||
manager._should_log("cache")
|
||||
manager._warning_cooldowns["api"] -= 61
|
||||
assert manager._should_log("api") is True
|
||||
assert manager._should_log("cache") is False
|
||||
|
||||
def test_legacy_single_clock_field_is_kept_in_step(self, build):
|
||||
"""Subclasses in the plugin copies read _last_warning_time directly."""
|
||||
manager = build()
|
||||
manager._should_log("api")
|
||||
assert manager._last_warning_time == manager._warning_cooldowns["api"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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)
|
||||
@@ -0,0 +1,585 @@
|
||||
"""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__
|
||||
@@ -0,0 +1,585 @@
|
||||
"""Tests for src/common/sports_scroll.py (phase B3).
|
||||
|
||||
The module is drawn along the split the survey found: the orchestration layer
|
||||
is shared, the content layer is not. Two things are worth asserting beyond
|
||||
"it works":
|
||||
|
||||
* ``prepare_scroll_content`` must stay an override point — a base class that
|
||||
quietly rendered *something* would let a plugin ship a blank scroll.
|
||||
* ``target_fps`` must actually reach the helper. That is the whole reason this
|
||||
module exists upstream; the bundled plugin copies hardcode ~100 FPS.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
sys.modules.setdefault("rgbmatrix", MagicMock())
|
||||
|
||||
from src.common.sports_scroll import ( # noqa: E402
|
||||
DEFAULT_SCROLL_SETTINGS,
|
||||
MAX_PIXELS_PER_FRAME,
|
||||
MIN_PIXELS_PER_FRAME,
|
||||
SportsScrollDisplay,
|
||||
SportsScrollDisplayManager,
|
||||
)
|
||||
|
||||
LOGGER = logging.getLogger("test.sports_scroll")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def display_manager():
|
||||
manager = MagicMock()
|
||||
manager.matrix.width = 128
|
||||
manager.matrix.height = 32
|
||||
return manager
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fake_scroll_helper(monkeypatch):
|
||||
"""Replace ScrollHelper with a recording double.
|
||||
|
||||
The real helper is covered by test_scroll_helper.py; here what matters is
|
||||
*which* calls this module makes and with what values.
|
||||
"""
|
||||
created = []
|
||||
|
||||
def _factory(width, height, logger):
|
||||
helper = MagicMock()
|
||||
helper.width, helper.height = width, height
|
||||
helper.cached_image = None
|
||||
helper.is_scroll_complete.return_value = False
|
||||
helper.get_dynamic_duration.return_value = 42
|
||||
helper.get_scroll_info.return_value = {"position": 0}
|
||||
created.append(helper)
|
||||
return helper
|
||||
|
||||
monkeypatch.setattr("src.common.sports_scroll.ScrollHelper", _factory)
|
||||
return created
|
||||
|
||||
|
||||
class _Display(SportsScrollDisplay):
|
||||
"""A minimal concrete subclass, as a plugin would write it."""
|
||||
|
||||
SCROLL_LEAGUE_KEYS = ("nhl", "ncaa_mens")
|
||||
|
||||
def prepare_scroll_content(self, games, game_type, leagues, rankings_cache=None):
|
||||
self._current_games = list(games)
|
||||
self._current_game_type = game_type
|
||||
self._current_leagues = list(leagues)
|
||||
self.scroll_helper.cached_image = Image.new("RGB", (400, 32))
|
||||
return bool(games)
|
||||
|
||||
|
||||
class _Manager(SportsScrollDisplayManager):
|
||||
display_class = _Display
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def build(display_manager):
|
||||
def _build(config=None, global_config=None, cls=_Display):
|
||||
return cls(display_manager, config or {}, LOGGER, global_config=global_config)
|
||||
return _build
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Construction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestConstruction:
|
||||
def test_dimensions_come_from_the_matrix(self, build):
|
||||
display = build()
|
||||
assert (display.display_width, display.display_height) == (128, 32)
|
||||
|
||||
def test_dimensions_fall_back_when_there_is_no_matrix(self, display_manager):
|
||||
display_manager.matrix = None
|
||||
display_manager.width, display_manager.height = 256, 64
|
||||
display = _Display(display_manager, {}, LOGGER)
|
||||
assert (display.display_width, display.display_height) == (256, 64)
|
||||
|
||||
def test_final_fallback_dimensions(self):
|
||||
"""A display manager exposing neither must not crash construction."""
|
||||
bare = MagicMock(spec=[])
|
||||
display = _Display(bare, {}, LOGGER)
|
||||
assert (display.display_width, display.display_height) == (128, 32)
|
||||
|
||||
def test_global_config_is_optional(self, build):
|
||||
"""An older caller that doesn't pass it keeps working."""
|
||||
assert build().global_config == {}
|
||||
|
||||
def test_state_starts_empty(self, build):
|
||||
display = build()
|
||||
assert display._current_games == []
|
||||
assert display._vegas_content_items == []
|
||||
assert display.get_current_game_count() == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Settings resolution — the league ladder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestScrollSettings:
|
||||
def test_defaults_when_nothing_is_configured(self, build):
|
||||
settings = build()._get_scroll_settings()
|
||||
for key, value in DEFAULT_SCROLL_SETTINGS.items():
|
||||
assert settings[key] == value
|
||||
|
||||
def test_card_width_defaults_to_the_panel_width(self, build):
|
||||
assert build()._get_scroll_settings()["game_card_width"] == 128
|
||||
|
||||
def test_named_league_wins(self, build):
|
||||
display = build({"nhl": {"scroll_settings": {"scroll_speed": 10}},
|
||||
"ncaa_mens": {"scroll_settings": {"scroll_speed": 20}}})
|
||||
assert display._get_scroll_settings("ncaa_mens")["scroll_speed"] == 20
|
||||
|
||||
def test_ladder_is_walked_in_order(self, build):
|
||||
"""The only reason the eight plugin copies differed: which league keys
|
||||
to try, and in what order."""
|
||||
display = build({"ncaa_mens": {"scroll_settings": {"scroll_speed": 20}},
|
||||
"nhl": {"scroll_settings": {"scroll_speed": 10}}})
|
||||
assert display._get_scroll_settings()["scroll_speed"] == 10
|
||||
|
||||
def test_ladder_falls_through_to_the_next_key(self, build):
|
||||
display = build({"ncaa_mens": {"scroll_settings": {"scroll_speed": 20}}})
|
||||
assert display._get_scroll_settings()["scroll_speed"] == 20
|
||||
|
||||
def test_overrides_merge_onto_defaults(self, build):
|
||||
display = build({"nhl": {"scroll_settings": {"scroll_speed": 10}}})
|
||||
settings = display._get_scroll_settings()
|
||||
assert settings["scroll_speed"] == 10
|
||||
assert settings["gap_between_games"] == DEFAULT_SCROLL_SETTINGS[
|
||||
"gap_between_games"]
|
||||
|
||||
def test_empty_override_does_not_shadow_the_next_candidate(self, build):
|
||||
display = build({"nhl": {"scroll_settings": {}},
|
||||
"ncaa_mens": {"scroll_settings": {"scroll_speed": 20}}})
|
||||
assert display._get_scroll_settings()["scroll_speed"] == 20
|
||||
|
||||
def test_single_block_config_shape(self, build):
|
||||
"""The afl/nrl/soccer shape: one scroll_mode block, no league concept."""
|
||||
class _Single(_Display):
|
||||
SCROLL_LEAGUE_KEYS = ()
|
||||
SCROLL_CONFIG_KEY = "scroll_mode"
|
||||
|
||||
display = build({"scroll_mode": {"scroll_speed": 33}}, cls=_Single)
|
||||
assert display._get_scroll_settings()["scroll_speed"] == 33
|
||||
|
||||
def test_defaults_are_overridable_by_a_subclass(self, build):
|
||||
class _Wide(_Display):
|
||||
def scroll_settings_defaults(self):
|
||||
return {**super().scroll_settings_defaults(),
|
||||
"gap_between_games": 24}
|
||||
|
||||
assert build(cls=_Wide)._get_scroll_settings()["gap_between_games"] == 24
|
||||
|
||||
def test_a_null_league_block_is_tolerated(self, build):
|
||||
"""`config['nhl'] = None` appears in hand-edited configs."""
|
||||
display = build({"nhl": None})
|
||||
assert (display._get_scroll_settings()["scroll_speed"]
|
||||
== DEFAULT_SCROLL_SETTINGS["scroll_speed"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper configuration — including the reason this module exists upstream
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestConfigureScrollHelper:
|
||||
def test_speed_is_converted_to_pixels_per_frame(self, build):
|
||||
display = build({"nhl": {"scroll_settings": {
|
||||
"scroll_speed": 100.0, "scroll_delay": 0.02}}})
|
||||
# 100 px/s * 0.02 s/frame = 2 px/frame
|
||||
display.scroll_helper.set_scroll_speed.assert_called_with(2.0)
|
||||
|
||||
def test_conversion_is_clamped_low(self, build):
|
||||
display = build({"nhl": {"scroll_settings": {
|
||||
"scroll_speed": 0.001, "scroll_delay": 0.001}}})
|
||||
display.scroll_helper.set_scroll_speed.assert_called_with(MIN_PIXELS_PER_FRAME)
|
||||
|
||||
def test_conversion_is_clamped_high(self, build):
|
||||
display = build({"nhl": {"scroll_settings": {
|
||||
"scroll_speed": 5000.0, "scroll_delay": 0.5}}})
|
||||
display.scroll_helper.set_scroll_speed.assert_called_with(MAX_PIXELS_PER_FRAME)
|
||||
|
||||
def test_zero_delay_assumes_a_pacing_instead_of_dividing_by_zero(self, build):
|
||||
display = build({"nhl": {"scroll_settings": {
|
||||
"scroll_speed": 100.0, "scroll_delay": 0}}})
|
||||
display.scroll_helper.set_scroll_speed.assert_called_with(1.0)
|
||||
|
||||
def test_frame_based_scrolling_is_enabled(self, build):
|
||||
build().scroll_helper.set_frame_based_scrolling.assert_called_once_with(True)
|
||||
|
||||
def test_dynamic_duration_settings_are_applied(self, build):
|
||||
display = build({"nhl": {"scroll_settings": {
|
||||
"dynamic_duration": False, "min_duration": 5, "max_duration": 50}}})
|
||||
_, kwargs = display.scroll_helper.set_dynamic_duration_settings.call_args
|
||||
assert kwargs["enabled"] is False
|
||||
assert kwargs["min_duration"] == 5
|
||||
assert kwargs["max_duration"] == 50
|
||||
|
||||
def test_target_fps_reaches_the_helper(self, build):
|
||||
"""The whole point of upstreaming: the bundled copies hardcode ~100 FPS
|
||||
via scroll_delay and never consult the global target."""
|
||||
display = build(global_config={"target_fps": 120})
|
||||
display.scroll_helper.set_target_fps.assert_called_once_with(120.0)
|
||||
|
||||
def test_legacy_key_is_honored(self, build):
|
||||
display = build(global_config={"scroll_target_fps": 90})
|
||||
display.scroll_helper.set_target_fps.assert_called_once_with(90.0)
|
||||
|
||||
def test_modern_key_wins_over_legacy(self, build):
|
||||
display = build(global_config={"target_fps": 120, "scroll_target_fps": 90})
|
||||
display.scroll_helper.set_target_fps.assert_called_once_with(120.0)
|
||||
|
||||
def test_absent_target_fps_leaves_config_pacing_alone(self, build):
|
||||
build().scroll_helper.set_target_fps.assert_not_called()
|
||||
|
||||
@pytest.mark.parametrize("bad", ["fast", None, {}, [], "", 0])
|
||||
def test_unusable_target_fps_degrades_instead_of_raising(self, build, bad):
|
||||
"""A malformed global config must cost the FPS target, not the display."""
|
||||
display = build(global_config={"target_fps": bad})
|
||||
display.scroll_helper.set_target_fps.assert_not_called()
|
||||
|
||||
def test_string_digits_are_accepted(self, build):
|
||||
display = build(global_config={"target_fps": "120"})
|
||||
display.scroll_helper.set_target_fps.assert_called_once_with(120.0)
|
||||
|
||||
@pytest.mark.parametrize("bad", [None, "fast", {}, []])
|
||||
def test_unusable_scroll_speed_degrades_instead_of_crashing(self, build, bad):
|
||||
"""`.get(key, default)` only helps when the key is *absent*. A key
|
||||
present with null reaches the arithmetic and raises inside __init__,
|
||||
taking the whole display down before it renders anything."""
|
||||
display = build({"nhl": {"scroll_settings": {"scroll_speed": bad}}})
|
||||
# 50.0 px/s * 0.01 s/frame == 0.5 px/frame, i.e. the default speed.
|
||||
display.scroll_helper.set_scroll_speed.assert_called_with(0.5)
|
||||
|
||||
@pytest.mark.parametrize("bad", [None, "slow", {}])
|
||||
def test_unusable_scroll_delay_degrades_instead_of_crashing(self, build, bad):
|
||||
display = build({"nhl": {"scroll_settings": {"scroll_delay": bad}}})
|
||||
display.scroll_helper.set_scroll_delay.assert_called_with(0.01)
|
||||
|
||||
def test_numeric_strings_are_accepted(self, build):
|
||||
display = build({"nhl": {"scroll_settings": {
|
||||
"scroll_speed": "100", "scroll_delay": "0.02"}}})
|
||||
display.scroll_helper.set_scroll_speed.assert_called_with(2.0)
|
||||
|
||||
def test_fps_clamping_is_left_to_the_helper(self, build):
|
||||
"""Deliberately not clamped here — a second copy of the range would
|
||||
drift from ScrollHelper.set_target_fps."""
|
||||
display = build(global_config={"target_fps": 5000})
|
||||
display.scroll_helper.set_target_fps.assert_called_once_with(5000.0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The content seam
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestContentIsAnOverridePoint:
|
||||
def test_base_refuses_to_render(self, display_manager):
|
||||
"""Eight plugins have eight different bodies for this; a base class that
|
||||
rendered *something* would let a plugin ship a silently blank scroll."""
|
||||
display = SportsScrollDisplay(display_manager, {}, LOGGER)
|
||||
with pytest.raises(NotImplementedError, match="prepare_scroll_content"):
|
||||
display.prepare_scroll_content([], "live", [])
|
||||
|
||||
def test_the_error_names_the_offending_class(self, display_manager):
|
||||
class Incomplete(SportsScrollDisplay):
|
||||
pass
|
||||
|
||||
with pytest.raises(NotImplementedError, match="Incomplete"):
|
||||
Incomplete(display_manager, {}, LOGGER).prepare_scroll_content(
|
||||
[], "live", [])
|
||||
|
||||
def test_separator_icons_default_to_a_no_op(self, build):
|
||||
assert build()._separator_icons == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Frame pumping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFramePumping:
|
||||
def test_no_content_means_no_frame(self, build):
|
||||
assert build().display_scroll_frame() is False
|
||||
|
||||
def test_a_frame_is_drawn_and_pushed(self, build):
|
||||
display = build()
|
||||
display.prepare_scroll_content([{"id": "g1"}], "live", ["nhl"])
|
||||
display.scroll_helper.get_visible_portion.return_value = Image.new(
|
||||
"RGB", (128, 32))
|
||||
assert display.display_scroll_frame() is True
|
||||
display.scroll_helper.update_scroll_position.assert_called_once()
|
||||
display.display_manager.update_display.assert_called_once()
|
||||
|
||||
def test_no_visible_portion_means_no_frame(self, build):
|
||||
display = build()
|
||||
display.prepare_scroll_content([{"id": "g1"}], "live", ["nhl"])
|
||||
display.scroll_helper.get_visible_portion.return_value = None
|
||||
assert display.display_scroll_frame() is False
|
||||
|
||||
def test_a_display_failure_is_contained(self, build):
|
||||
"""A display error must not propagate into the plugin's render loop."""
|
||||
display = build()
|
||||
display.prepare_scroll_content([{"id": "g1"}], "live", ["nhl"])
|
||||
display.scroll_helper.get_visible_portion.return_value = Image.new(
|
||||
"RGB", (128, 32))
|
||||
display.display_manager.update_display.side_effect = RuntimeError("boom")
|
||||
assert display.display_scroll_frame() is False
|
||||
|
||||
@pytest.mark.parametrize("failing", ["update_scroll_position",
|
||||
"get_visible_portion"])
|
||||
def test_a_scroll_helper_failure_is_contained_too(self, build, failing):
|
||||
"""These ran outside the try, so a raise there reached the caller's
|
||||
frame loop despite the stated promise that none can."""
|
||||
display = build()
|
||||
display.prepare_scroll_content([{"id": "g1"}], "live", ["nhl"])
|
||||
getattr(display.scroll_helper, failing).side_effect = RuntimeError("boom")
|
||||
assert display.display_scroll_frame() is False
|
||||
|
||||
def test_frames_are_counted(self, build):
|
||||
display = build()
|
||||
display.prepare_scroll_content([{"id": "g1"}], "live", ["nhl"])
|
||||
display.scroll_helper.get_visible_portion.return_value = Image.new(
|
||||
"RGB", (128, 32))
|
||||
for _ in range(3):
|
||||
display.display_scroll_frame()
|
||||
assert display._frame_count == 3
|
||||
|
||||
def test_progress_logging_is_throttled(self, build):
|
||||
display = build()
|
||||
display._log_interval = 10_000
|
||||
display._last_log_time = 0
|
||||
display._log_scroll_progress()
|
||||
first = display._last_log_time
|
||||
display._log_scroll_progress()
|
||||
assert display._last_log_time == first
|
||||
|
||||
|
||||
class TestLifecycle:
|
||||
def test_reset_keeps_content_but_rewinds(self, build):
|
||||
display = build()
|
||||
display.prepare_scroll_content([{"id": "g1"}], "live", ["nhl"])
|
||||
display._frame_count = 9
|
||||
display.reset_scroll()
|
||||
display.scroll_helper.reset_scroll.assert_called_once()
|
||||
assert display._frame_count == 0
|
||||
assert display._current_games, "reset must not drop content"
|
||||
|
||||
def test_clear_drops_everything(self, build):
|
||||
display = build()
|
||||
display.prepare_scroll_content([{"id": "g1"}], "live", ["nhl"])
|
||||
display._vegas_content_items = [Image.new("RGB", (8, 8))]
|
||||
display.clear()
|
||||
display.scroll_helper.clear_cache.assert_called_once()
|
||||
assert display._current_games == []
|
||||
assert display._current_game_type == ""
|
||||
assert display._vegas_content_items == []
|
||||
|
||||
def test_completion_delegates_to_the_helper(self, build):
|
||||
display = build()
|
||||
display.scroll_helper.is_scroll_complete.return_value = True
|
||||
assert display.is_scroll_complete() is True
|
||||
|
||||
def test_dynamic_duration_delegates(self, build):
|
||||
assert build().get_dynamic_duration() == 42
|
||||
|
||||
def test_has_cached_content(self, build):
|
||||
display = build()
|
||||
assert display.has_cached_content() is False
|
||||
display.prepare_scroll_content([{"id": "g1"}], "live", ["nhl"])
|
||||
assert display.has_cached_content() is True
|
||||
|
||||
def test_scroll_info_merges_helper_and_local_state(self, build):
|
||||
display = build()
|
||||
display.prepare_scroll_content([{"id": "g1"}], "live", ["nhl"])
|
||||
info = display.get_scroll_info()
|
||||
assert info["position"] == 0 # from the helper
|
||||
assert info["game_count"] == 1 # from this display
|
||||
assert info["leagues"] == ["nhl"]
|
||||
|
||||
def test_leagues_are_returned_as_a_copy(self, build):
|
||||
display = build()
|
||||
display.prepare_scroll_content([{"id": "g1"}], "live", ["nhl"])
|
||||
display.get_current_leagues().append("mutated")
|
||||
assert display.get_current_leagues() == ["nhl"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Manager
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestManager:
|
||||
@pytest.fixture
|
||||
def manager(self, display_manager):
|
||||
return _Manager(display_manager, {}, LOGGER, global_config={"target_fps": 120})
|
||||
|
||||
def test_displays_are_created_lazily_and_reused(self, manager):
|
||||
first = manager.get_scroll_display("live")
|
||||
assert manager.get_scroll_display("live") is first
|
||||
assert isinstance(first, _Display)
|
||||
|
||||
def test_each_game_type_gets_its_own(self, manager):
|
||||
assert manager.get_scroll_display("live") is not manager.get_scroll_display(
|
||||
"recent")
|
||||
|
||||
def test_global_config_is_threaded_to_children(self, manager):
|
||||
"""A missed hand-off here is exactly how the plugin copies ended up
|
||||
never honoring target_fps."""
|
||||
child = manager.get_scroll_display("live")
|
||||
assert child.global_config == {"target_fps": 120}
|
||||
child.scroll_helper.set_target_fps.assert_called_once_with(120.0)
|
||||
|
||||
def test_prepare_sets_the_active_type(self, manager):
|
||||
assert manager.prepare_and_display([{"id": "g1"}], "live", ["nhl"]) is True
|
||||
assert manager._current_game_type == "live"
|
||||
|
||||
def test_failed_prepare_does_not_become_active(self, manager):
|
||||
assert manager.prepare_and_display([], "live", ["nhl"]) is False
|
||||
assert not manager._current_game_type
|
||||
|
||||
def test_a_raising_subclass_does_not_escape_the_orchestration(self, manager):
|
||||
"""prepare_scroll_content is subclass code building cards from feed
|
||||
data. One sport's bad payload must not take down the others."""
|
||||
display = manager.get_scroll_display("live")
|
||||
display.prepare_scroll_content = MagicMock(side_effect=KeyError("status"))
|
||||
assert manager.prepare_and_display([{"id": "g1"}], "live", ["nhl"]) is False
|
||||
assert not manager._current_game_type
|
||||
|
||||
def test_empty_game_type_sentinel_matches_the_display(self, manager):
|
||||
"""Both classes must spell 'nothing active' the same way; two spellings
|
||||
across two classes is a trap for anyone comparing their state."""
|
||||
manager.prepare_and_display([{"id": "g1"}], "live", ["nhl"])
|
||||
manager.clear_all()
|
||||
assert (manager._current_game_type
|
||||
== manager.get_scroll_display("live")._current_game_type == "")
|
||||
|
||||
def test_display_frame_uses_the_active_type(self, manager):
|
||||
manager.prepare_and_display([{"id": "g1"}], "live", ["nhl"])
|
||||
display = manager.get_scroll_display("live")
|
||||
display.scroll_helper.get_visible_portion.return_value = Image.new(
|
||||
"RGB", (128, 32))
|
||||
assert manager.display_frame() is True
|
||||
|
||||
def test_display_frame_with_no_active_type(self, manager):
|
||||
assert manager.display_frame() is False
|
||||
|
||||
def test_display_frame_for_an_unknown_type(self, manager):
|
||||
assert manager.display_frame("never-prepared") is False
|
||||
|
||||
def test_completion_is_true_when_there_is_nothing_to_scroll(self, manager):
|
||||
"""A caller waiting on completion must never be wedged by absence."""
|
||||
assert manager.is_complete() is True
|
||||
assert manager.is_complete("never-prepared") is True
|
||||
|
||||
def test_completion_delegates_to_the_active_display(self, manager):
|
||||
manager.prepare_and_display([{"id": "g1"}], "live", ["nhl"])
|
||||
manager.get_scroll_display("live").scroll_helper \
|
||||
.is_scroll_complete.return_value = True
|
||||
assert manager.is_complete() is True
|
||||
|
||||
def test_clear_all_clears_every_display(self, manager):
|
||||
manager.prepare_and_display([{"id": "g1"}], "live", ["nhl"])
|
||||
manager.prepare_and_display([{"id": "g2"}], "recent", ["nhl"])
|
||||
manager.clear_all()
|
||||
assert not manager._current_game_type
|
||||
for game_type in ("live", "recent"):
|
||||
assert manager.get_scroll_display(game_type)._current_games == []
|
||||
|
||||
def test_vegas_items_are_collected_across_displays(self, manager):
|
||||
for game_type in ("live", "recent"):
|
||||
manager.get_scroll_display(game_type)._vegas_content_items = [
|
||||
Image.new("RGB", (8, 8))]
|
||||
assert len(manager.get_all_vegas_content_items()) == 2
|
||||
|
||||
def test_vegas_collection_tolerates_empty_displays(self, manager):
|
||||
manager.get_scroll_display("live")
|
||||
assert manager.get_all_vegas_content_items() == []
|
||||
|
||||
def test_display_class_is_the_subclass_seam(self, display_manager):
|
||||
class Other(_Display):
|
||||
pass
|
||||
|
||||
class OtherManager(SportsScrollDisplayManager):
|
||||
display_class = Other
|
||||
|
||||
manager = OtherManager(display_manager, {}, LOGGER)
|
||||
assert isinstance(manager.get_scroll_display("live"), Other)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration — against the real ScrollHelper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _RealDisplay(SportsScrollDisplay):
|
||||
"""Builds a strip the way a plugin would, via the real helper API."""
|
||||
|
||||
SCROLL_LEAGUE_KEYS = ("nhl",)
|
||||
|
||||
def prepare_scroll_content(self, games, game_type, leagues, rankings_cache=None):
|
||||
settings = self._get_scroll_settings()
|
||||
self.scroll_helper.create_scrolling_image(
|
||||
[Image.new("RGB", (100, 32), (20, 20, 20)) for _ in games],
|
||||
item_gap=settings["gap_between_games"],
|
||||
)
|
||||
self._current_games = list(games)
|
||||
self._current_game_type = game_type
|
||||
self._current_leagues = list(leagues)
|
||||
return bool(games)
|
||||
|
||||
|
||||
class _RealManager(SportsScrollDisplayManager):
|
||||
display_class = _RealDisplay
|
||||
|
||||
|
||||
class TestAgainstTheRealScrollHelper:
|
||||
"""The mocked tests above pin *which* calls this module makes; these pin
|
||||
that those calls exist and mean what we think. Without this, a rename in
|
||||
ScrollHelper would sail past a suite built entirely on MagicMock."""
|
||||
|
||||
@pytest.fixture
|
||||
def real(self, display_manager, monkeypatch):
|
||||
from src.common.scroll_helper import ScrollHelper
|
||||
monkeypatch.setattr("src.common.sports_scroll.ScrollHelper", ScrollHelper)
|
||||
return _RealManager(
|
||||
display_manager,
|
||||
{"nhl": {"scroll_settings": {"scroll_speed": 500.0,
|
||||
"scroll_delay": 0.001}}},
|
||||
LOGGER,
|
||||
global_config={"target_fps": 120},
|
||||
)
|
||||
|
||||
def test_configuration_lands_on_the_real_helper(self, real):
|
||||
helper = real.get_scroll_display("live").scroll_helper
|
||||
assert helper.target_fps == 120.0
|
||||
assert helper.frame_based_scrolling is True
|
||||
assert helper.scroll_speed == pytest.approx(0.5) # 500 px/s * 0.001 s
|
||||
|
||||
def test_a_strip_is_built_and_scrolls_to_completion(self, real):
|
||||
import time
|
||||
|
||||
assert real.prepare_and_display(
|
||||
[{"id": "a"}, {"id": "b"}, {"id": "c"}], "live", ["nhl"]) is True
|
||||
display = real.get_scroll_display("live")
|
||||
# 3 cards of 100px + gaps, so the strip is wider than the 128px panel.
|
||||
assert display.scroll_helper.cached_image.width > 128
|
||||
|
||||
# Frame-based scrolling is wall-clock gated on scroll_delay, so the
|
||||
# loop has to actually pass time rather than spin.
|
||||
deadline = time.time() + 20
|
||||
while not real.is_complete() and time.time() < deadline:
|
||||
real.display_frame()
|
||||
time.sleep(0.0012)
|
||||
|
||||
# Asserted separately so a host too slow to sustain the frame rate
|
||||
# reports a timeout rather than looking like a scrolling defect.
|
||||
assert time.time() < deadline, (
|
||||
"scroll did not finish within 20s — the host may be too slow to "
|
||||
"sustain the configured frame rate")
|
||||
assert real.is_complete() is True
|
||||
assert display.scroll_helper.scroll_position > 128
|
||||
|
||||
def test_dynamic_duration_is_a_real_number(self, real):
|
||||
real.prepare_and_display([{"id": "a"}], "live", ["nhl"])
|
||||
assert real.get_scroll_display("live").get_dynamic_duration() > 0
|
||||
Reference in New Issue
Block a user