From 21825cbfbcc6671046738ebbe517d0e465457962 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:39:33 -0400 Subject: [PATCH] =?UTF-8?q?Sports=20unification=20phases=201=E2=80=932:=20?= =?UTF-8?q?package=20split,=20promoted=20methods,=20opt-in=20capabilities?= =?UTF-8?q?=20(#426)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 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 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 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 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 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 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 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 /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 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 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 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._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 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 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 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 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 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 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 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 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 Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ --------- Co-authored-by: Claude --- .github/workflows/test.yml | 6 +- CHANGELOG.md | 91 +- docs/SPORTS_UNIFICATION.md | 235 ++++ src/__init__.py | 2 +- src/base_classes/baseball.py | 17 +- src/base_classes/hockey.py | 15 +- src/base_classes/sports/__init__.py | 17 + .../sports/capabilities/__init__.py | 32 + .../sports/capabilities/celebrations.py | 418 ++++++ .../sports/capabilities/rotation.py | 246 ++++ src/base_classes/sports/core.py | 1122 +++++++++++++++++ .../{sports.py => sports/modes.py} | 1020 ++++----------- src/common/sports_scroll.py | 485 +++++++ test/test_sports_base_characterization.py | 66 +- test/test_sports_capabilities.py | 880 +++++++++++++ test/test_sports_core_promotions.py | 613 +++++++++ test/test_sports_modes_promotions.py | 585 +++++++++ test/test_sports_scroll.py | 585 +++++++++ 18 files changed, 5668 insertions(+), 767 deletions(-) create mode 100644 docs/SPORTS_UNIFICATION.md create mode 100644 src/base_classes/sports/__init__.py create mode 100644 src/base_classes/sports/capabilities/__init__.py create mode 100644 src/base_classes/sports/capabilities/celebrations.py create mode 100644 src/base_classes/sports/capabilities/rotation.py create mode 100644 src/base_classes/sports/core.py rename src/base_classes/{sports.py => sports/modes.py} (56%) create mode 100644 src/common/sports_scroll.py create mode 100644 test/test_sports_capabilities.py create mode 100644 test/test_sports_core_promotions.py create mode 100644 test/test_sports_modes_promotions.py create mode 100644 test/test_sports_scroll.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fd982e2f..cfe31c17 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -72,4 +72,8 @@ jobs: test/test_adaptive_layout.py \ test/test_loader_compat_warning.py \ test/test_sports_base_characterization.py \ - test/test_element_style.py + test/test_element_style.py \ + test/test_sports_core_promotions.py \ + test/test_sports_modes_promotions.py \ + test/test_sports_capabilities.py \ + test/test_sports_scroll.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ecfe6c59..5ff5338d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,21 @@ here, against a version number. When you add a module plugins will import via `src.*`, note it in the Unreleased section and bump `src/__init__.py` in the release that ships it. -## Unreleased +**Use `ledmatrix_min_version` in manifests, not `ledmatrix_min`.** The loader +accepts both, but the store flags the old spelling as deprecated +(`store_manager.py`) and only the new one is in `schema/manifest_schema.json`. + +## 3.2.0 + +**The first release shipping the unified sports library.** This is the version +a sports plugin floors `ledmatrix_min_version` at before deleting its bundled +copy of `sports.py`, `scroll_display.py`, `data_sources.py` or +`base_odds_manager.py` — the sunset rule in +`docs/plugin-development/08-shared-sports-code.md` keys on exactly this number. + +Adoption is deliberately staged: the modules below ship here, plugins adopt them +behind guarded imports, and only then do the bundled copies go away. Nothing in +this release changes what an existing plugin loads. ### Added - `src/element_style.py` — per-element style resolver backing the @@ -24,11 +38,86 @@ release that ships it. system, data sources, API extractors, scroll helper, adaptive layout, loader compatibility warning) plus new characterization tests for `src/base_classes/sports.py` ahead of the shared sports-code unification. +- `src/base_classes/sports/` — `sports.py` is now a package (`core.py` + + `modes.py`). The import path is unchanged: `from src.base_classes.sports + import SportsCore` still works. +- Nine methods promoted onto the sports base classes from the plugins' + bundled copies, plus the override points `_favorite_key`, + `_config_schema_path` and `_font_root` and the class attributes + `FINAL_PERIOD` / `CLOCK_COUNTS_DOWN`. See `docs/SPORTS_UNIFICATION.md`. + A plugin may start calling these once its manifest floors + `ledmatrix_min_version` at the release that ships them. + +- `src/base_classes/sports/capabilities/` — opt-in capabilities for the sports + scoreboards, composed by inheritance rather than gated by config branches + inside the base classes: + - `CelebrationMixin` — the score/win takeover, merging the goal and score + dialects behind the `score_phrase()` / `win_phrase()` hooks, the + `COALESCE_SCORING_SEQUENCE` class attribute and the `_favorite_key` seam. + Reads both the `celebrate_opponent_goals` and `celebrate_opponent_scores` + config spellings. Sports that do not mix it in have none of this code in + their MRO. + - `RotationStrategy` + a name registry (`swrr`, `weighted`, `simple`, + plus `register_rotation_strategy` for plugin-supplied orderings). Each + built-in is verified against a verbatim transcription of the plugin + implementation it replaces. An unknown name degrades to `simple`. + +- `src/common/sports_scroll.py` — `SportsScrollDisplay` and + `SportsScrollDisplayManager`, the shared scroll **orchestration** layer for + the sports scoreboards, plus native support for + `global_config['target_fps']` (the bundled plugin copies hardcode ~100 FPS + via `scroll_delay` and never consult the global target). Content building + (`prepare_scroll_content`, `_load_separator_icons`) is per-sport and stays an + override point — see `docs/SPORTS_UNIFICATION.md` for where the line falls + and why. + +### Changed +- `src/__init__.py` bumped to **3.2.0** — the number the sunset rule keys on. +- **Live games are no longer dropped when the feed omits a game clock.** + `SportsLive._is_game_really_over` previously (in the baseball and UFC + plugin lineages) coerced a missing or non-string clock to the literal + `"0:00"` and then treated the game as finished once `period >= 4`. Baseball + has no game clock and `period` is the inning, so live MLB games disappeared + from the scoreboard from the 5th inning onward; UFC was affected the same + way. The clock check is now skipped when the clock is unusable, and the + period threshold is the per-sport `FINAL_PERIOD` (hockey ends in P3). + Sports whose clocks count up — soccer, AFL, NRL — set + `CLOCK_COUNTS_DOWN = False` and never run the check at all, since `0:00` + there means kickoff rather than expiry. ### Fixed - `FontManager` resolves `assets/fonts` against the core install root instead of the process working directory, so font loading works when the process starts elsewhere (e.g. the plugin safety harness on CI). +- Hockey events whose competitors carry no `statistics` array are no longer + discarded. The extractor read `competitor["statistics"]` unguarded, so a + `KeyError` inside the generator dropped the entire event despite valid + scores and status; shot counts now fall back to `0`. +- Live baseball events that populate status only at the competition level are + no longer discarded. The extractor read the event top-level + `game_event["status"]` for the inning; real ESPN events duplicate it, but + MiLB events synthesized from the MLB Stats API do not, so the lookup raised + a bare `KeyError`. It now reads the already-validated competition-level + status. +- `SportsLive._is_game_really_over` no longer crashes the live-update pass when + a feed sends an explicit null `period`. `None >= FINAL_PERIOD` raised + `TypeError`, and the only caller (`_detect_stale_games`) has no `try/except` + — the same failure shape as the already-fixed null `period_text`. +- An expired clock spelled `"00:00"` now ends the game. The check compared the + colon-stripped clock against a hand-listed set of literals, which `"0000"` is + not a member of, so a finished game with a two-digit-minute clock stayed on + the scoreboard indefinitely. The comparison is now numeric. +- `SportsCore._load_fonts` resolves `assets/fonts` through the `_font_root()` + seam instead of the process working directory. Started outside the install + root, every scoreboard font silently degraded to PIL's default bitmap face. +- `SportsCore._should_log` no longer raises `AttributeError` on the first + warning of a run; `_last_warning_time` is initialized in `__init__` rather + than lazily by an unrelated method. +- `SportsCore._resolve_project_path` resolved relative logo directories + against `/src` instead of the repo root after `sports.py` became a + package — the class bodies moved byte-identically but `__file__` gained a + directory. Both it and `_font_root` now derive from one `_INSTALL_ROOT` + constant. ## 3.1.0 diff --git a/docs/SPORTS_UNIFICATION.md b/docs/SPORTS_UNIFICATION.md new file mode 100644 index 00000000..4b9fecdb --- /dev/null +++ b/docs/SPORTS_UNIFICATION.md @@ -0,0 +1,235 @@ +# Sports Code Unification — Architecture + +How the nine sports scoreboard plugins converge onto shared core code **without** +becoming nine clients of a god class. + +## The problem + +Nine plugins (`afl`, `baseball`, `basketball`, `football`, `hockey`, `lacrosse`, +`nrl`, `soccer`, `ufc`) each ship a ~3,000-line `sports.py` descended from this +repo's `src/base_classes/sports.py`. They have drifted into three lineages, and +only 28 of the 66 methods appearing across them are present in all nine. One +logical fix (the UTC start-time bug) cost 75 files. + +Merging everything into one base class would fix the duplication and create a +worse problem: a single 2,500-line class that all nine plugins inherit, where any +change has a nine-plugin blast radius and per-sport behavior survives only as +`if self.sport == "hockey"` branches. + +## Three properties, three mechanisms + +These are independent concerns. Conflating them is what produces god classes. + +### Upgradability — a plugin keeps working across core versions + +| Rule | Mechanism | +|---|---| +| Plugin loads on a core that predates a module | Guarded import with a bundled fallback (`try: from src.X import Y / except ModuleNotFoundError: from y import Y`) | +| Plugin loads on a core that predates a *method* | Capability probing — `hasattr(SportsCore, "_detect_stale_games")` — never a version comparison. The loader's compat check is advisory-only (it logs and continues), so probing is the real protection. | +| Core changes never break a plugin's rendering | The **view-model contract**: `_extract_game_details_common` returns a dict whose `GUARANTEED_KEYS` are frozen by `test/test_skin_system.py::TestViewModelContract`. Keys may be added, never renamed or removed. | +| A plugin can drop its bundled copy safely | The **sunset rule**: only when its manifest floors `ledmatrix_min_version` at the first core release shipping the module (recorded in `CHANGELOG.md`). | + +The core API is **additive-only**. A method the plugins call is never removed or +given a new required parameter; new behavior arrives as new methods with +defaults, or as capabilities they opt into. + +### Reusability — write once, nine plugins benefit + +Only code that is **identical in intent across all nine** moves into the base +class. That set is small and knowable — it is exactly the methods present in every +copy today (phase B1 below). Everything else stays where it is until it earns +promotion. + +### Modularity — a change to one feature cannot reach a plugin that doesn't use it + +This is the property the naive merge destroys, and it is enforced structurally: + +1. **Capabilities are separate modules composed by inheritance, not config + branches inside the base class.** Hockey has no celebrations, so + `HockeyLive` does not inherit `CelebrationMixin` — the celebration code is not + merely disabled for hockey, it is *not in hockey's MRO at all*. No shared + state, no dead branches, no risk. Contrast with + `if self.celebrations_enabled:` inside `SportsLive`, where a bug in + celebration code can still crash a plugin that never wanted the feature. + +2. **Variant behavior is a strategy object chosen by name, not a branch.** + Live rotation exists in three dialects across the lineages; core ships all + three behind `rotation_strategy: "swrr" | "weighted" | "simple"` and a plugin + may register its own. Core never learns sport names. + +3. **Sport-specific behavior is a documented override point.** The base class + declares the seam; the plugin fills it. Basketball's tournament-round parsing + and baseball's BDF sizing stay in their plugins forever — they are not + candidates for promotion, and core must never grow a branch for them. + +4. **Files bound the blast radius.** Capabilities live in their own modules so a + diff shows at a glance which plugins a change can reach. + +## Layering + +``` +src/base_classes/sports/ + __init__.py re-exports the public API (import path unchanged) + core.py SportsCore — fetch, cache, config, logos, fonts, odds, + view-model extraction, the skin seam + modes.py SportsUpcoming / SportsRecent / SportsLive + capabilities/ + celebrations.py CelebrationMixin (opt-in: 4 of 9 plugins) + rotation.py RotationStrategy + registry + +src/common/ + sports_scroll.py SportsScrollDisplay / …Manager — scroll orchestration + (content building stays in the plugins) +``` + +`from src.base_classes.sports import SportsCore` keeps working — the package +`__init__` re-exports, so the conversion is invisible to every existing importer. + +## Override points (the plugin-facing seam) + +The base class calls these; plugins implement or override them. This table is the +contract — additions require a default implementation, removals require a +deprecation cycle. + +| Hook | Purpose | Default | +|---|---|---| +| `_fetch_data()` | Sport's schedule source | abstract | +| `_extract_game_details(event)` | Sport-specific view-model fields on top of the common ones | delegates to `_extract_game_details_common` | +| `_draw_scorebug_layout(game, force_clear)` | Sport's card rendering | base layout | +| `_custom_scorebug_layout(game, draw)` | Per-sport overlay on the base layout | no-op | +| `render_skin_card(game, size)` | Skin-system entry point | built-in fallback | +| `score_phrase(points, team_abbr)` | Celebration wording (`"GOOOOAAALLL!"` vs `"TOUCHDOWN!"`). `points` is the score delta, which sports with variable-value scores use to name the play | `" SCORES!"` — only consulted when `CelebrationMixin` is present | +| `win_phrase(team_abbr)` | Win-celebration wording | `" WINS!"` — mixin only | +| `_favorite_key(game, side)` | Which view-model field identifies a team for favorites matching | `game["_abbr"]` | +| `_config_schema_path()` | Plugin's `config_schema.json` — returning it routes `_get_layout_offset` through the `src.element_style` resolver (and gives it the defaults to compare against) | `None`, i.e. the classic inline `customization.layout` read | +| `_font_root()` | Directory to resolve `assets/fonts` against | core install root | + +Two class attributes serve the same purpose for values that are per-sport +constants rather than behavior: + +| Attribute | Meaning | Default | +|---|---|---| +| `FINAL_PERIOD` | Period at/after which a zero clock can mean "over" | `4` (hockey overrides to `3`) | +| `CLOCK_COUNTS_DOWN` | Whether `0:00` means "expired" | `True` (soccer/afl/nrl override to `False` — their clocks count up, so `0:00` is kickoff) | +| `COALESCE_SCORING_SEQUENCE` | Fold score increments arriving during an active celebration into that one celebration | `False` (football overrides to `True` — a touchdown lands as +6, then +1 for the extra point) | + +### Why these are seams and not branches + +`_favorite_key` exists because NRL abbreviations are **not unique** — "NEW" is both +Newcastle Knights and New Zealand Warriors, "CAN" both Canberra and Canterbury — +so NRL matches favorites on team ID. Flattening every plugin to abbreviations +would silently select the wrong club for NRL users. The base declares the seam, +NRL fills it, and core never learns the string `"nrl"`. + +`CLOCK_COUNTS_DOWN` exists for the same reason in the opposite direction: a +soccer clock reading `0:00` means the match has not kicked off, so running the +clock-expiry branch there would evict live games. + +`COALESCE_SCORING_SEQUENCE` is the third of the same kind. In football one +scoring play arrives as two score updates, so the follow-up must be folded into +the first celebration; in soccer two increments a few seconds apart are two real +goals, and folding them would swallow one. Neither default is "right" — which is +precisely why it is a declared per-sport constant rather than a hidden +assumption baked into the shared body. + +## Capabilities + +``` +capabilities/ + celebrations.py CelebrationMixin opt-in: afl, nrl, soccer, football + rotation.py RotationStrategy + registry +``` + +**`CelebrationMixin`** merges the two dialects the lineages grew +(`_check_for_goal`/`celebrate_opponent_goals` vs +`_check_for_score`/`celebrate_opponent_scores`). Their bodies were identical +apart from three things, each now a seam: wording (`score_phrase`), follow-up +suppression (`COALESCE_SCORING_SEQUENCE`), and team identity (`_favorite_key`, +so NRL matches on id). Both config spellings are read, so a plugin adopting the +mixin keeps working with the keys already in its published schema. + +Mix it in **before** the mode class — `class SoccerLive(CelebrationMixin, +SportsLive)` — so the celebration `display()` runs first and falls through to +the scorebug via `super()`. + +**Rotation strategies.** The three "dialects" turned out to be one algorithm +(Smooth Weighted Round-Robin) in two shapes: an incremental picker holding state +across calls (afl/nrl/soccer) and a precomputed per-cycle list +(football/baseball/basketball, and hockey with a different loop shape). They +agree within a cycle and differ only at the boundary — the incremental form has +no restart seam — so core ships both rather than declaring a winner: + +```python +self.rotation = get_rotation_strategy("swrr", weight_for=self._live_weight) +``` + +`weight_for` is supplied by the host, so the *favorites* policy stays with the +plugin and `rotation.py` never learns what a favorite is. An unknown strategy +name degrades to `simple` rather than raising: the name comes from user config, +and a typo should cost the boost, not the scoreboard. When a plugin needs an +ordering that core does not ship, it calls `register_rotation_strategy` to add +its own — rather than core growing a branch for it. + +`test_sports_capabilities.py` checks each strategy against a **verbatim +transcription** of the plugin code it replaces, over every live-game shape up to +four games. That differential is what B5 deletes the bundled copies on the +strength of. + +## Scroll display — where the promotion line falls + +`src/common/sports_scroll.py` is deliberately *not* a superset of the ten +`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: + +| Layer | Evidence | Outcome | +|---|---|---| +| Orchestration — `get_all_vegas_content_items`, `clear_all`, `get_scroll_info`, `get_dynamic_duration`, `is_complete`, `display_frame` | identical to 96–100% similar across all eight | **promoted** | +| Settings — `_get_scroll_settings` | one algorithm; the copies differ *only* in which league keys they walk | **promoted**, with the ladder as data (`SCROLL_LEAGUE_KEYS`) | +| Content — `prepare_scroll_content`, `_load_separator_icons` | 8 distinct bodies across 8 plugins (145 lines, 53% similar at worst); icons 6% | **override point, permanently** | + +Same name, different job: `prepare_scroll_content` draws *this sport's* game +card. Merging the eight bodies would be the exact mistake the promotion rule +exists to prevent, so the base class raises `NotImplementedError` rather than +rendering something plausible — a base that rendered *something* would let a +plugin ship a silently blank scroll. + +The one behavior the upstreamed version adds is native +`global_config['target_fps']` support. The bundled copies hardcode ~100 FPS via +`scroll_delay = 0.01` and never consult the global smooth-scrolling target; +Part A threaded it through each copy by hand, and this makes that threading +legacy compatibility rather than the mechanism. + +## Phases + +| Phase | Scope | Risk control | +|---|---|---| +| **B0** ✅ | Characterization tests, CI unit job, `element_style`, font cwd fix, CHANGELOG discipline | — | +| **B1** ✅ | Promote the nine universal methods; convert `sports.py` → package | Characterization suite must stay green; no behavior change intended | +| **B2** ✅ | `CelebrationMixin` + rotation strategies as opt-in capabilities | Plugins that don't opt in have zero new code in their MRO; strategies checked against verbatim plugin transcriptions | +| **B3** ✅ | Upstream the scroll **orchestration** layer as `src/common/sports_scroll.py`, reading `global_config['target_fps']` natively | Plugin copies remain until sunset; content building stays per-sport | +| **B4** | Bump to 3.2.0, record modules in CHANGELOG, migrate `ledmatrix_min` → `ledmatrix_min_version` | Gives plugins a version to floor on | +| **B5** ⏳ | Pilot one plugin per lineage (hockey, soccer, football) on core imports; then the remaining six; then delete bundled copies | Pilot soaks before rollout; harness + golden suites gate each | + +**B5 is blocked on this PR merging and 3.2.0 shipping** — 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 hockey scroll-display pilot has been **validated ahead of that gate**: +adopted against a core carrying 3.2.0, `scroll_display.py` went from 691 to 289 +lines and all 16 harness renders (8 sizes × 2 screens) came out byte-for-byte +identical to the pre-adoption run. The adoption recipe and the two gotchas it +surfaced are written up in the plugins repo's +`docs/plugin-development/08-shared-sports-code.md`. + +## Rules for contributors + +- **Promote on evidence, not intuition.** A method moves to core when every copy + has it and they agree on intent. Otherwise it stays in the plugins. +- **Never add a sport name to core.** If core needs to know which sport it is, + the design is wrong — add an override point instead. +- **A capability that is not opted into must not execute.** If you find yourself + writing `if self._enabled` inside a base class, it belongs in a + mixin. +- **Touch the view-model keys only additively.** Published skins depend on them. +- **Every promotion lands with the characterization suite green**, and every + pilot adoption lands with that plugin's harness and golden suites green. diff --git a/src/__init__.py b/src/__init__.py index 7d47ee11..f8fa0daa 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -4,5 +4,5 @@ LEDMatrix Display System Core source package for the LED Matrix Display project. """ -__version__ = "3.1.0" +__version__ = "3.2.0" diff --git a/src/base_classes/baseball.py b/src/base_classes/baseball.py index 9a46dd5a..ac84bfb7 100644 --- a/src/base_classes/baseball.py +++ b/src/base_classes/baseball.py @@ -151,7 +151,12 @@ class Baseball(SportsCore): # Only log detailed information for favorite teams if is_favorite_game: - self.logger.debug(f"Full status data: {game_event['status']}") + # Use the validated competition-level `status` here too. MiLB + # events carry no event-level one, so this debug line raised a + # KeyError and dropped the very games it was meant to help + # diagnose -- and only for favourites, which is the worst way + # for it to fail. + self.logger.debug(f"Full status data: {status}") self.logger.debug(f"Status type: {game_status}, State: {status_state}") self.logger.debug(f"Status detail: {status['type'].get('detail', '')}") self.logger.debug( @@ -164,7 +169,13 @@ class Baseball(SportsCore): # Get game state information if status_state == "in": # For live games, get detailed state - inning = game_event["status"].get( + # Use the competition-level `status` already validated by + # _extract_game_details_common. Real ESPN events duplicate + # status at the event top level, but MiLB events (synthesized + # from the MLB Stats API into an ESPN-like shape) populate + # only the competition-level one, so the top-level lookup + # raised a bare KeyError and dropped the event. + inning = status.get( "period", 1 ) # Get inning from status period @@ -187,7 +198,7 @@ class Baseball(SportsCore): if "end" in status_detail or "end" in status_short: inning_half = "top" inning = ( - game_event["status"].get("period", 1) + 1 + status.get("period", 1) + 1 ) # Use period and increment for next inning if is_favorite_game: self.logger.debug( diff --git a/src/base_classes/hockey.py b/src/base_classes/hockey.py index 9c25da35..419f09ec 100644 --- a/src/base_classes/hockey.py +++ b/src/base_classes/hockey.py @@ -38,10 +38,17 @@ class Hockey(SportsCore): status = competition["status"] powerplay = False penalties = "" + # A competitor may legitimately arrive without a "statistics" + # array (pre-game feeds, and some in-progress ones). Reading it + # unguarded raised KeyError inside the generator and dropped the + # WHOLE event, discarding valid scores and status. Default to an + # empty list so the saves/shots figures fall back to 0 instead. + home_stats = home_team.get("statistics", []) + away_stats = away_team.get("statistics", []) home_team_saves = next( ( int(c["displayValue"]) - for c in home_team["statistics"] + for c in home_stats if c.get("name") == "saves" ), 0, @@ -49,7 +56,7 @@ class Hockey(SportsCore): home_team_saves_per = next( ( float(c["displayValue"]) - for c in home_team["statistics"] + for c in home_stats if c.get("name") == "savePct" ), 0.0, @@ -57,7 +64,7 @@ class Hockey(SportsCore): away_team_saves = next( ( int(c["displayValue"]) - for c in away_team["statistics"] + for c in away_stats if c.get("name") == "saves" ), 0, @@ -65,7 +72,7 @@ class Hockey(SportsCore): away_team_saves_per = next( ( float(c["displayValue"]) - for c in away_team["statistics"] + for c in away_stats if c.get("name") == "savePct" ), 0.0, diff --git a/src/base_classes/sports/__init__.py b/src/base_classes/sports/__init__.py new file mode 100644 index 00000000..a6def39f --- /dev/null +++ b/src/base_classes/sports/__init__.py @@ -0,0 +1,17 @@ +"""Sports scoreboard base classes. + +Formerly the single module ``src/base_classes/sports.py``; now a package so +capabilities can be composed instead of accumulating in one class. See +docs/SPORTS_UNIFICATION.md for the architecture. The import path is +unchanged: ``from src.base_classes.sports import SportsCore`` still works. +""" + +from .core import SportsCore +from .modes import SportsLive, SportsRecent, SportsUpcoming + +__all__ = [ + "SportsCore", + "SportsUpcoming", + "SportsRecent", + "SportsLive", +] diff --git a/src/base_classes/sports/capabilities/__init__.py b/src/base_classes/sports/capabilities/__init__.py new file mode 100644 index 00000000..e084ad7c --- /dev/null +++ b/src/base_classes/sports/capabilities/__init__.py @@ -0,0 +1,32 @@ +"""Opt-in capabilities for the sports scoreboards. + +Each module here is a feature that only *some* sports want. They are composed +by inheritance (mixins) or selected by name (strategies) — never enabled by an +``if self._enabled:`` branch inside the base classes. + +The distinction matters: hockey has no celebrations, so ``HockeyLive`` does not +inherit :class:`~.celebrations.CelebrationMixin` and the celebration code is not +in hockey's MRO at all. A bug in it cannot reach a plugin that never opted in. + +See ``docs/SPORTS_UNIFICATION.md`` for the full rationale. +""" + +from .celebrations import CelebrationMixin +from .rotation import ( + RotationStrategy, + SimpleRotation, + SmoothWeightedRotation, + WeightedCycleRotation, + get_rotation_strategy, + register_rotation_strategy, +) + +__all__ = [ + "CelebrationMixin", + "RotationStrategy", + "SimpleRotation", + "SmoothWeightedRotation", + "WeightedCycleRotation", + "get_rotation_strategy", + "register_rotation_strategy", +] diff --git a/src/base_classes/sports/capabilities/celebrations.py b/src/base_classes/sports/capabilities/celebrations.py new file mode 100644 index 00000000..49d371cd --- /dev/null +++ b/src/base_classes/sports/capabilities/celebrations.py @@ -0,0 +1,418 @@ +"""Score / win celebration takeover — an opt-in capability. + +Four of the nine scoreboards celebrate (afl, nrl, soccer, football); the other +five do not. This is a **mixin** rather than a flag inside ``SportsLive`` so the +five that do not opt in have none of this code in their MRO: a bug here cannot +reach hockey, and hockey's config never grows keys it ignores. + +Usage — mix in *before* the mode class so its ``display`` runs first:: + + class SoccerLive(CelebrationMixin, SportsLive): + def score_phrase(self, points, team_abbr): + return secrets.choice(("GOOOOAAALLL!", f"{team_abbr} SCORES!")) + +The two lineages spelled this differently (``_check_for_goal`` / +``celebrate_opponent_goals`` in the soccer lineage, ``_check_for_score`` / +``celebrate_opponent_scores`` in football) but the bodies were identical apart +from three things, each of which is a seam here rather than a branch: + +* **wording** — :meth:`score_phrase`, the hook football uses to say "TOUCHDOWN" + from the points delta and soccer uses to say "GOOOOAAALLL"; +* **follow-up suppression** — :attr:`COALESCE_SCORING_SEQUENCE`, on for football + where a touchdown lands as +6 then +1 a few seconds later, off elsewhere where + two quick goals are two real events; +* **team identity** — matching goes through ``_favorite_key``, so nrl can match + on team id (its abbreviations are ambiguous) without core knowing why. + +The config keys are read under both spellings, so a plugin adopting the mixin +keeps working with the ``*_goals`` keys already in its published schema. +""" + +from __future__ import annotations + +import re +import time +from typing import Any, Dict, List, Optional + +from PIL import Image, ImageDraw + + +class CelebrationMixin: + """Full-screen takeover when a tracked team scores or wins.""" + + #: Collapse increments that land while a celebration is already on screen + #: into that one celebration. True for sports where a single scoring play + #: arrives as more than one score update (football: touchdown +6, then the + #: extra point +1). False where consecutive increments are distinct events — + #: suppressing there would swallow a real goal. + COALESCE_SCORING_SEQUENCE = False + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + mode_config = getattr(self, "mode_config", {}) or {} + self.celebration_enabled = mode_config.get("celebration_enabled", True) + # Coerced and floored at init: this value is compared numerically on the + # display path, where a string from a hand-edited config would raise + # TypeError outside any try block, and a zero or negative value would + # arm a celebration that can never render. + raw_duration = mode_config.get("celebration_duration", 8) + try: + self.celebration_duration = max(1.0, float(raw_duration)) + except (TypeError, ValueError): + self.logger.warning( + "[Celebrations] Unusable celebration_duration %r; using 8s. " + "Set a positive number of seconds.", + raw_duration, + ) + self.celebration_duration = 8.0 + # Both spellings: the soccer lineage ships `celebrate_opponent_goals`, + # football ships `celebrate_opponent_scores`. Whichever the plugin's + # schema declares is the one its users have set. + self.celebrate_opponent_scores = mode_config.get( + "celebrate_opponent_scores", + mode_config.get("celebrate_opponent_goals", False), + ) + # Per-game score baselines: {game_id: {"away": int, "home": int}} + self._score_baselines: Dict[str, Dict[str, int]] = {} + # The active celebration (a game *snapshot*, so a win survives the game + # leaving live_games) or None. See _start_celebration for the shape. + self.active_celebration: Optional[Dict[str, Any]] = None + + # ------------------------------------------------------------------ + # Override points + # ------------------------------------------------------------------ + + def score_phrase(self, points: int, team_abbr: str) -> str: + """The wording for a score celebration. + + ``points`` is the score delta that triggered it, which sports with + variable-value scores use to name the play. The default is deliberately + sport-neutral; every celebrating plugin overrides it. + """ + return f"{team_abbr} SCORES!" + + def win_phrase(self, team_abbr: str) -> str: + """The wording for a win celebration.""" + return f"{team_abbr} WINS!" + + def _is_favorite(self, key: Optional[str]) -> bool: + """Whether ``key`` (whatever ``_favorite_key`` returns) is a favorite.""" + return bool(self.favorite_teams) and key in self.favorite_teams + + # ------------------------------------------------------------------ + # Detection + # ------------------------------------------------------------------ + + @staticmethod + def _score_to_int(score) -> Optional[int]: + """Coerce an ESPN score value (str / int / dict) to an int, or None.""" + try: + if score is None: + return None + if isinstance(score, str): + s = score.strip() + if not s: + return None + try: + return int(float(s)) + except ValueError: + numbers = re.findall(r"\d+", s) + return int(numbers[0]) if numbers else None + if isinstance(score, dict): + return int(float(score.get("value", score.get("displayValue", 0)))) + return int(float(score)) + except (ValueError, TypeError): + return None + + def _should_celebrate_for(self, game: Dict, side: str) -> bool: + """Whether a score by ``side`` in ``game`` should trigger a celebration.""" + if self._is_favorite(self._favorite_key(game, side)): + return True + if not self.favorite_teams: + # No favorites configured: the user opted to show this game, so + # celebrate any score in it. + return True + # Favorites exist but this team isn't one -> it's the opponent. + return self.celebrate_opponent_scores + + def prune_score_baselines(self, live_games: List[Dict]) -> None: + """Drop baselines for games no longer live. + + Only :meth:`_check_for_win` removes entries, and it only fires for games + seen to go final. A game that vanishes from the live list any other way + — postponed, dropped by the feed, or simply still live when the board + restarts — leaves its baseline behind forever, so on a board that runs + all season the dict grows without bound. + + Call this from ``update()`` with the current live set, alongside the + equivalent pruning in :meth:`SmoothWeightedRotation.next_game`. + """ + live_ids = {g.get("id") for g in live_games} + self._score_baselines = { + gid: baseline + for gid, baseline in self._score_baselines.items() + if gid in live_ids + } + + def has_active_celebration(self) -> bool: + """True while a celebration is within its display window.""" + celebration = self.active_celebration + return bool(celebration) and ( + time.time() - celebration["started_at"] < self.celebration_duration + ) + + def _check_for_score(self, game: Dict) -> None: + """Compare a live game's score against its baseline and arm a + celebration when a celebratable team's score increases.""" + if not self.celebration_enabled: + return + game_id = game.get("id") + if not game_id: + return + away = self._score_to_int(game.get("away_score")) + home = self._score_to_int(game.get("home_score")) + if away is None or home is None: + return + + baseline = self._score_baselines.get(game_id) + # Always refresh the baseline: a first sighting must never celebrate (a + # game already in progress at boot would false-fire), and a decrement + # (VAR, a correction) just re-bases silently. + self._score_baselines[game_id] = {"away": away, "home": home} + if baseline is None: + return + + away_delta = away - baseline["away"] + home_delta = home - baseline["home"] + if away_delta <= 0 and home_delta <= 0: + return + + # One takeover per scoring sequence, where the sport has such a thing. + # The baseline is already advanced above, so nothing re-fires later. + if self.COALESCE_SCORING_SEQUENCE and self.has_active_celebration(): + return + + scored_side = None + points = 0 + if away_delta > 0 and self._should_celebrate_for(game, "away"): + scored_side, points = "away", away_delta + if scored_side is None and home_delta > 0 and self._should_celebrate_for( + game, "home" + ): + scored_side, points = "home", home_delta + if scored_side is None: + return + + self._start_celebration( + game, + "score", + scored_side=scored_side, + team_abbr=game.get(f"{scored_side}_abbr", ""), + away_score=away, + home_score=home, + points=points, + ) + + def _check_for_win(self, game: Dict) -> None: + """When a game we were tracking live goes final, arm a win celebration + if a favorite won. Fires at most once per game.""" + if not self.celebration_enabled: + return + game_id = game.get("id") + if not game_id: + return + # Only celebrate wins for games we actually watched go live: one seen + # for the first time already-final (the board started after full time) + # has no baseline and must not fire. + if game_id not in self._score_baselines: + return + # Consume the baseline so this can only fire once. + self._score_baselines.pop(game_id, None) + + away = self._score_to_int(game.get("away_score")) + home = self._score_to_int(game.get("home_score")) + if away is None or home is None: + return + + if away > home: + winner_side = "away" + elif home > away: + winner_side = "home" + else: + return # draw -> no win celebration + + # Wins are gated strictly on favorites: every game ends, so the + # "no favorites -> celebrate all" score fallback would be far too noisy. + if not self._is_favorite(self._favorite_key(game, winner_side)): + return + + self._start_celebration( + game, + "win", + scored_side=winner_side, + team_abbr=game.get(f"{winner_side}_abbr", ""), + away_score=away, + home_score=home, + ) + + def _start_celebration( + self, + game: Dict, + kind: str, + scored_side: str, + team_abbr: str, + away_score: int, + home_score: int, + points: int = 0, + ) -> None: + """Arm a celebration. ``scored_side`` ('away'/'home') is the side whose + score digit gets highlighted.""" + phrase = ( + self.win_phrase(team_abbr) + if kind == "win" + else self.score_phrase(points, team_abbr) + ) + + self.active_celebration = { + "kind": kind, + "game": dict(game), # snapshot: survives the game leaving live_games + "scored_side": scored_side, + "team_abbr": team_abbr, + "away_score": away_score, + "home_score": home_score, + "started_at": time.time(), + "phrase": phrase, + } + # Pin focus to the involved game so the post-celebration scorebug + # resumes on it. + self.current_game = dict(game) + self.logger.info( + f"[Celebrations] {kind} armed: {phrase} " + f"[{game.get('away_abbr')} {away_score}-{home_score} {game.get('home_abbr')}]" + ) + + # ------------------------------------------------------------------ + # Rendering + # ------------------------------------------------------------------ + + def _fit_font(self, draw, text: str, max_width: int, fonts: List): + """The first font whose rendered ``text`` fits ``max_width``, falling + back to the last (smallest) font.""" + for font in fonts: + if draw.textlength(text, font=font) <= max_width - 2: + return font + return fonts[-1] + + def _draw_celebration_layout( + self, celebration: Dict, force_clear: bool = False + ) -> None: + """Render the full-screen score/win takeover.""" + if force_clear: + self.display_manager.clear() + + display_width = ( + self.display_manager.matrix.width + if hasattr(self.display_manager, "matrix") and self.display_manager.matrix + else self.display_width + ) + display_height = ( + self.display_manager.matrix.height + if hasattr(self.display_manager, "matrix") and self.display_manager.matrix + else self.display_height + ) + + elapsed = time.time() - celebration["started_at"] + game = celebration["game"] + + # Background: a brief color flash for the first ~1.2s, then black. + bg = (0, 0, 0, 255) + if elapsed < 1.2 and int(elapsed / 0.2) % 2 == 0: + bg = (12, 12, 48, 255) + main_img = Image.new("RGBA", (display_width, display_height), bg) + overlay = Image.new("RGBA", (display_width, display_height), (0, 0, 0, 0)) + draw = ImageDraw.Draw(overlay) + + # Logos at the edges (best-effort: a logo failure must not blank the + # celebration). + try: + center_y = display_height // 2 + home_logo = self._load_and_resize_logo( + game.get("home_id"), game.get("home_abbr"), + game.get("home_logo_path"), game.get("home_logo_url"), + ) + away_logo = self._load_and_resize_logo( + game.get("away_id"), game.get("away_abbr"), + game.get("away_logo_path"), game.get("away_logo_url"), + ) + if home_logo: + main_img.paste( + home_logo, + (display_width - home_logo.width + 2, center_y - home_logo.height // 2), + home_logo, + ) + if away_logo: + main_img.paste( + away_logo, (-2, center_y - away_logo.height // 2), away_logo + ) + except Exception as e: + self.logger.debug(f"[Celebrations] Logo load failed: {e}") + + # Phrase across the top, shrunk to fit the panel width. + phrase = celebration["phrase"] + phrase_font = self._fit_font( + draw, phrase, display_width, [self.fonts["time"], self.fonts["status"]] + ) + phrase_width = draw.textlength(phrase, font=phrase_font) + self._draw_text_with_outline( + draw, phrase, ((display_width - phrase_width) // 2, 1), phrase_font + ) + + # Score centered low, with the scoring/winning side's digit pulsing in a + # highlight color so the change reads at a glance. + away_text = str(celebration["away_score"]) + home_text = str(celebration["home_score"]) + score_font = self.fonts["score"] + segments = [ + (away_text, celebration["scored_side"] == "away"), + ("-", False), + (home_text, celebration["scored_side"] == "home"), + ] + total_width = sum(draw.textlength(seg, font=score_font) for seg, _ in segments) + highlight = (255, 255, 0) if int(elapsed * 4) % 2 == 0 else (255, 170, 0) + x = (display_width - total_width) // 2 + y = display_height - 14 + for seg, is_highlight in segments: + color = highlight if is_highlight else (255, 255, 255) + self._draw_text_with_outline(draw, seg, (int(x), y), score_font, fill=color) + x += draw.textlength(seg, font=score_font) + + main_img = Image.alpha_composite(main_img, overlay).convert("RGB") + self.display_manager.image = main_img + self.display_manager.update_display() + + def display(self, force_clear: bool = False) -> bool: + """Render an active celebration as a full-screen takeover; otherwise + defer to the normal live scorebug.""" + if not self.is_enabled: + return False + celebration = self.active_celebration + if celebration: + if self.has_active_celebration(): + try: + self._draw_celebration_layout(celebration, force_clear) + return True + except Exception as e: + self.logger.error( + f"[Celebrations] Error drawing celebration: {e}", exc_info=True + ) + # Disarm rather than retry: the same render would fail on + # every frame for the rest of the window, logging a + # traceback each time and leaving the scorebug off screen. + self.active_celebration = None + self.last_game_switch = time.time() + else: + self.active_celebration = None + # Reset the dwell so the scorebug resumes on the scoring/winning + # game for a full duration before rotation can move on. + self.last_game_switch = time.time() + return super().display(force_clear) diff --git a/src/base_classes/sports/capabilities/rotation.py b/src/base_classes/sports/capabilities/rotation.py new file mode 100644 index 00000000..4135ca9e --- /dev/null +++ b/src/base_classes/sports/capabilities/rotation.py @@ -0,0 +1,246 @@ +"""Live-rotation strategies — which live game to show next. + +The nine plugin copies grew three spellings of this, and the survey behind +``docs/SPORTS_UNIFICATION.md`` found they are all the *same* Smooth Weighted +Round-Robin algorithm in two shapes: + +* an **incremental picker** that holds weight state across calls and answers + "what next?" one game at a time (afl / nrl / soccer's ``_swrr_advance``), and +* a **precomputed cycle** that returns a full list of game ids up front + (football / baseball / basketball's ``_build_weighted_schedule`` and hockey's + ``_build_rotation_schedule``, which differ only in loop shape). + +They agree *within* a cycle — SWRR is deterministic — and differ only at cycle +boundaries, where the incremental form has no seam and the precomputed form +restarts. That is a real behavioral difference, so core ships both rather than +declaring a winner, and a plugin picks one by name: + + self.rotation = get_rotation_strategy("swrr", weight_for=self._live_weight) + +Core never learns which sport is asking. A plugin with a genuinely novel +ordering registers its own strategy instead of core growing a branch:: + + register_rotation_strategy("my-order", MyRotation) +""" + +from __future__ import annotations + +from typing import Callable, Dict, List, Optional, Type + + +def _game_id(game: Dict) -> Optional[str]: + """The rotation key for a game, or None if it has no usable id.""" + return game.get("id") + + +class RotationStrategy: + """Base class for live-rotation ordering. + + Subclasses implement :meth:`schedule`; :meth:`next_game` has a working + default derived from it. Strategies whose natural shape is incremental + override :meth:`next_game` instead and derive :meth:`schedule`. + + :param weight_for: callable mapping a game dict to a positive integer + weight — how many turns it gets per turn of a weight-1 game. Supplied by + the host so the *favorites* policy stays with the plugin and this module + stays free of any notion of what a favorite is. Defaults to equal + weights, which makes every strategy a plain round robin. + """ + + #: Name this strategy is registered under. Set by :func:`register_rotation_strategy`. + name: str = "" + + #: Ceiling on a per-game weight. A cycle is ``sum(weights)`` long and each + #: step scans every game, so an unbounded weight — a misread config field, + #: say — would spin the display thread for an unbounded time. On a Pi that + #: stalls rendering outright, so the bound is clamped like the floor is. + MAX_WEIGHT = 16 + + def __init__(self, weight_for: Optional[Callable[[Dict], int]] = None): + self._weight_for = weight_for or (lambda game: 1) + + def weights(self, games: List[Dict]) -> Dict[str, int]: + """``{game_id: weight}`` for games that have an id, in ``games`` order. + + A weight below 1 is clamped up: a zero or negative weight would starve + a game out of the rotation entirely, which no caller means to express + and which would make ``total_weight`` collapse. It is clamped down at + :attr:`MAX_WEIGHT` for the reason documented there. + """ + weights: Dict[str, int] = {} + for game in games: + gid = _game_id(game) + if gid is None: + continue + try: + weight = int(self._weight_for(game)) + except (TypeError, ValueError): + weight = 1 + weights[gid] = min(self.MAX_WEIGHT, max(1, weight)) + return weights + + def schedule(self, games: List[Dict]) -> List[str]: + """Game ids in display order for one cycle. Ids may repeat.""" + raise NotImplementedError + + def next_game(self, games: List[Dict]) -> Optional[Dict]: + """The next game to display, or None when there is nothing to show.""" + order = self.schedule(games) + if not order: + return None + by_id = {gid: g for g in games if (gid := _game_id(g)) is not None} + return by_id.get(order[0]) + + def reset(self) -> None: + """Drop any accumulated state. Stateless strategies need do nothing.""" + + +class SimpleRotation(RotationStrategy): + """Plain round robin: every live game once per cycle, weights ignored. + + The fallback for a plugin that wants strictly even rotation regardless of + favorites. + """ + + def schedule(self, games: List[Dict]) -> List[str]: + return [gid for g in games if (gid := _game_id(g)) is not None] + + +class WeightedCycleRotation(RotationStrategy): + """Precomputed SWRR cycle — the football / baseball / basketball / hockey shape. + + Returns a full cycle of ``sum(weights)`` ids with repeats spaced evenly + rather than clumped, highest weight scheduled first. When no game carries a + boost the cycle degenerates to a single pass in ``games`` order, which is + exactly the plain round robin it replaced. + """ + + def schedule(self, games: List[Dict]) -> List[str]: + weights = self.weights(games) + if not weights: + return [] + total_weight = sum(weights.values()) + if total_weight <= len(weights): + # No boost in effect — plain order, one pass. (Also the guard that + # keeps the loop below from being O(total_weight) for nothing.) + return list(weights) + + current = {gid: 0 for gid in weights} + order: List[str] = [] + for _ in range(total_weight): + for gid, weight in weights.items(): + current[gid] += weight + picked = max(current, key=lambda gid: current[gid]) + current[picked] -= total_weight + order.append(picked) + return order + + +class SmoothWeightedRotation(RotationStrategy): + """Incremental SWRR — the afl / nrl / soccer shape. + + Weight state persists across calls, so there is no fixed-length cycle and + therefore no clustering seam at a cycle boundary. A game seen for the first + time starts at weight 0 and receives its full weight on the next call, so a + favorite's game that has just gone live naturally wins the first pick after + it appears — "queued first on refresh" without a special-cased branch. + + State for games no longer live is dropped on each call, so a long-running + board does not accumulate entries for finished games. + """ + + def __init__(self, weight_for: Optional[Callable[[Dict], int]] = None): + super().__init__(weight_for) + self._current: Dict[str, int] = {} + + def reset(self) -> None: + self._current = {} + + def next_game(self, games: List[Dict]) -> Optional[Dict]: + if not games: + return None + weights = self.weights(games) + if not weights: + return None + + # Keep state only for games still live. + self._current = { + gid: value for gid, value in self._current.items() if gid in weights + } + for gid, weight in weights.items(): + self._current[gid] = self._current.get(gid, 0) + weight + + total_weight = sum(weights.values()) + # Iterate in `games` order so ties break toward the feed's ordering, + # which is what the plugin copies did and what makes the no-boost case + # identical to a plain round robin. + ids_in_order = [gid for g in games if (gid := _game_id(g)) in weights] + best = max(ids_in_order, key=lambda gid: self._current[gid]) + self._current[best] -= total_weight + return next(g for g in games if _game_id(g) == best) + + def schedule(self, games: List[Dict]) -> List[str]: + """One cycle's worth of picks, without disturbing live state. + + Derived by running the picker forward on a copy, so the returned order + is exactly what repeated :meth:`next_game` calls would produce from the + current state — callers can use it to preview or log the rotation + without perturbing it. + """ + weights = self.weights(games) + if not weights: + return [] + # type(self), not this class: a subclass that overrides next_game must + # be previewed through its own ordering, or the returned order is not + # the one repeated next_game calls would produce — which is exactly + # what this method promises. + preview = type(self)(self._weight_for) + preview._current = dict(self._current) + order: List[str] = [] + for _ in range(sum(weights.values())): + picked = preview.next_game(games) + if picked is None: + break + order.append(_game_id(picked)) + return order + + +_REGISTRY: Dict[str, Type[RotationStrategy]] = {} + + +def register_rotation_strategy(name: str, factory: Type[RotationStrategy]) -> None: + """Register a rotation strategy under ``name``. + + When a plugin needs an ordering that core does not ship, it registers its + own here instead of core growing a sport-specific branch. Re-registering a + name replaces it, so a plugin may also override a built-in for itself. + """ + if not name: + raise ValueError("rotation strategy name must be a non-empty string") + # Fail at registration, not at the first schedule() call several frames + # later, where the cause is no longer on the stack. + if not (isinstance(factory, type) and issubclass(factory, RotationStrategy)): + raise TypeError( + f"rotation strategy {name!r} must be a RotationStrategy subclass, " + f"got {factory!r}" + ) + factory.name = name + _REGISTRY[name] = factory + + +def get_rotation_strategy( + name: str, weight_for: Optional[Callable[[Dict], int]] = None +) -> RotationStrategy: + """Build the strategy registered under ``name``. + + Falls back to ``"simple"`` for an unknown name rather than raising: the name + arrives from user config, and a typo should cost the boost, not the + scoreboard. + """ + factory = _REGISTRY.get(name) or _REGISTRY["simple"] + return factory(weight_for=weight_for) + + +register_rotation_strategy("simple", SimpleRotation) +register_rotation_strategy("weighted", WeightedCycleRotation) +register_rotation_strategy("swrr", SmoothWeightedRotation) diff --git a/src/base_classes/sports/core.py b/src/base_classes/sports/core.py new file mode 100644 index 00000000..e43c6e45 --- /dev/null +++ b/src/base_classes/sports/core.py @@ -0,0 +1,1122 @@ +"""SportsCore — the shared fetch/cache/config/render base for the sports +scoreboards. Split out of the former ``src/base_classes/sports.py``; see +docs/SPORTS_UNIFICATION.md for the layering. +""" + +import logging +import os +import tempfile +import time +from abc import ABC, abstractmethod +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +import pytz +import requests +from PIL import Image, ImageDraw, ImageFont +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +from src.background_data_service import get_background_service + +# Import new architecture components (individual classes will import what they need) +from src.base_classes.api_extractors import APIDataExtractor +from src.base_classes.data_sources import DataSource +from src.cache_manager import CacheManager +from src.display_manager import DisplayManager +from src.dynamic_team_resolver import DynamicTeamResolver +from src.logo_downloader import LogoDownloader, download_missing_logo +try: + from src.base_odds_manager import BaseOddsManager as OddsManager +except ImportError: + OddsManager = None + +# The core install root, resolved from this module's own location so nothing +# here depends on the process working directory. +# +# The index is the number of directories between this file and the root: +# src/base_classes/sports/core.py -> sports -> base_classes -> src -> root. +# It is defined ONCE, here, because hand-counting it at each use site is +# exactly what broke when this module moved from src/base_classes/sports.py +# into the package (the old depth of 2 silently started resolving to src/). +# If this module ever moves again, this is the single line to update. +_INSTALL_ROOT = Path(__file__).resolve().parents[3] + +# Shared element-style resolver. Core always ships src.element_style, so the +# guard is never taken here — it is kept because this module's methods are +# back-copied verbatim into the plugins' bundled `sports.py`, where older +# cores genuinely lack the module (the plugins fall back to the classic +# inline config read below). +try: + from src.element_style import ElementStyleResolver, defaults_from_schema_file + STYLE_AVAILABLE = True +except ImportError: # pragma: no cover - core always ships element_style + STYLE_AVAILABLE = False + + +# --- Font catalog delegation ------------------------------------------------- +# The plugin copies each carried their own alias table mapping font *family* +# names ("press_start") to filenames. That table is a duplicate of the core +# FontManager's `common_fonts` catalog, so resolve through the catalog instead +# and let the two never drift apart. Same lazy module-level shape as +# skin_runtime._get_font_manager / base_plugin._fallback_font_manager: a +# SportsCore host has no plugin_manager to borrow a FontManager from. + +_shared_font_catalog: Optional[Any] = None + + +def _font_catalog() -> Any: + """Shared read-only FontManager used purely as a font-name catalog.""" + global _shared_font_catalog + if _shared_font_catalog is None: + from src.font_manager import FontManager + _shared_font_catalog = FontManager({}) + return _shared_font_catalog + + +def _resolve_font_family_alias(font_name: str) -> str: + """Resolve a font family alias to its filename, leaving filenames as-is. + + A config `font` value may be either a family name from the FontManager + catalog ("press_start", "four_by_six", "five_by_seven") or a literal + filename; the former resolve here, the latter pass through unchanged. + """ + try: + catalogued = _font_catalog().common_fonts.get(font_name) + except Exception: + return font_name + return os.path.basename(catalogued) if catalogued else font_name + + +def _read_bdf_native_size(bdf_path: str) -> Optional[int]: + """A BDF file's one true pixel size, delegated to FontManager. + + Deliberately NOT reimplemented here: the plugin copies grew a variant + that scans the whole file and returns a partially collected value when + parsing raises, where FontManager's stops at the first STARTCHAR and + returns None on error. + """ + try: + from src.font_manager import FontManager + return FontManager._read_bdf_native_size(bdf_path) + except Exception: + return None + + +class SportsCore(ABC): + # Which ScoreboardSkin render method this class's display path maps to. + # SportsLive inherits the default; SportsUpcoming/SportsRecent override. + SKIN_MODE = "live" + + def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str): + self.logger = logger + self.config = config + self.cache_manager = cache_manager + self.config_manager = self.cache_manager.config_manager + if OddsManager: + try: + self.odds_manager = OddsManager( + self.cache_manager, self.config_manager) + except Exception as e: + self.logger.warning(f"Failed to initialize OddsManager: {e}") + self.odds_manager = None + else: + self.odds_manager = None + self.logger.warning("OddsManager not available - odds functionality disabled") + self.display_manager = display_manager + self.display_width = self.display_manager.matrix.width + self.display_height = self.display_manager.matrix.height + + self.sport_key = sport_key + self.sport = None + self.league = None + + # Initialize new architecture components (will be overridden by sport-specific classes) + self.sport_config = None + self.api_extractor: APIDataExtractor + self.data_source: DataSource + self.mode_config = config.get(f"{sport_key}_scoreboard", {}) # Changed config key + self.is_enabled: bool = self.mode_config.get("enabled", False) + self.show_odds: bool = self.mode_config.get("show_odds", False) + # Use LogoDownloader to get the correct default logo directory for this sport + default_logo_dir = Path(LogoDownloader().get_logo_directory(sport_key)) + self.logo_dir = self._initialize_logo_dir(default_logo_dir) + self.update_interval: int = self.mode_config.get( + "update_interval_seconds", 60) + self.show_records: bool = self.mode_config.get('show_records', False) + self.show_ranking: bool = self.mode_config.get('show_ranking', False) + # Number of games to show (instead of time-based windows) + self.recent_games_to_show: int = self.mode_config.get( + "recent_games_to_show", 5) # Show last 5 games + self.upcoming_games_to_show: int = self.mode_config.get( + "upcoming_games_to_show", 10) # Show next 10 games + self.show_favorite_teams_only: bool = self.mode_config.get("show_favorite_teams_only", False) + self.show_all_live: bool = self.mode_config.get("show_all_live", False) + + self.session = requests.Session() + retry_strategy = Retry( + total=5, # increased number of retries + backoff_factor=1, # increased backoff factor + # added 429 to retry list + status_forcelist=[429, 500, 502, 503, 504], + allowed_methods=["GET", "HEAD", "OPTIONS"] + ) + adapter = HTTPAdapter(max_retries=retry_strategy) + self.session.mount("https://", adapter) + self.session.mount("http://", adapter) + + self._logo_cache = {} + + # Font caches for _load_custom_font_from_element_config: per-frame + # callers (font-ladder walks) resolve the same (name, size) over and + # over, and the BDF strike size means re-reading a file header. + # Both are released in cleanup(). + self._font_cache: Dict[Tuple[str, int], Any] = {} + self._bdf_native_size_cache: Dict[str, Optional[int]] = {} + + # Set up headers + self.headers = { + 'User-Agent': 'LEDMatrix/1.0 (https://github.com/yourusername/LEDMatrix; contact@example.com)', + 'Accept': 'application/json', + 'Accept-Language': 'en-US,en;q=0.9', + 'Accept-Encoding': 'gzip, deflate, br', + 'Connection': 'keep-alive' + } + self.last_update = 0 + self.current_game = None + # Cooldown clocks for _should_log(), one per warning type. Initialized + # here rather than lazily: _should_log() reads them unguarded, so + # whichever warning fires first would otherwise raise AttributeError + # instead of logging. `_last_warning_time` is the single-clock field + # the plugin copies expose; kept for subclasses that read it. + self._warning_cooldowns: Dict[str, float] = {} + self._last_warning_time = 0 + self.fonts = self._load_fonts() + + # Optional visual skin (see docs/SKIN_SYSTEM.md). "skin" is either a + # skin id applied to all modes, or a per-mode mapping like + # {"live": "retro", "recent": "built-in"}. Loaded lazily on first + # render so a broken skin can never block startup. + self._skin_config = self.mode_config.get("skin") + self.skin_options = self.mode_config.get("skin_options", {}) or {} + self._skin = None + self._skin_load_attempted = False + self._skin_failures = 0 + self._skin_slow_renders = 0 + + # Initialize dynamic team resolver and resolve favorite teams + self.dynamic_resolver = DynamicTeamResolver() + raw_favorite_teams = self.mode_config.get("favorite_teams", []) + self.favorite_teams = self.dynamic_resolver.resolve_teams(raw_favorite_teams, sport_key) + + # Log dynamic team resolution + if raw_favorite_teams != self.favorite_teams: + self.logger.info(f"Resolved dynamic teams: {raw_favorite_teams} -> {self.favorite_teams}") + else: + self.logger.info(f"Favorite teams: {self.favorite_teams}") + + self.logger.setLevel(logging.INFO) + + # Initialize team rankings cache + self._team_rankings_cache = {} + self._rankings_cache_timestamp = 0 + self._rankings_cache_duration = 3600 # Cache rankings for 1 hour + + # Initialize background data service with optimized settings + # Hardcoded for memory optimization: 1 worker, 30s timeout, 3 retries + self.background_service = get_background_service(self.cache_manager, max_workers=1) + self.background_fetch_requests = {} # Track background fetch requests + self.background_enabled = True + self.logger.info("Background service enabled with 1 worker (memory optimized)") + + def _initialize_logo_dir(self, configured_path: Path) -> Path: + """Resolve and ensure a writable logo directory, falling back when necessary.""" + downloader = LogoDownloader() + resolved_configured = self._resolve_project_path(configured_path) + candidates = [resolved_configured] + self._get_logo_directory_fallbacks(resolved_configured) + + for candidate in candidates: + candidate_path = self._resolve_project_path(candidate) + if downloader.ensure_logo_directory(str(candidate_path)): + if candidate_path != resolved_configured: + self.logger.warning( + "Configured logo directory '%s' is not writable; using fallback '%s'", + resolved_configured, + candidate_path, + ) + return candidate_path + + self.logger.error( + "Unable to find a writable logo directory. Logos may fail to download (last attempted: %s)", + resolved_configured, + ) + return resolved_configured + + def _resolve_project_path(self, path: Path) -> Path: + """Convert relative paths to absolute ones rooted at the project directory.""" + if path.is_absolute(): + return path + return (_INSTALL_ROOT / path).resolve() + + def _get_logo_directory_fallbacks(self, configured_dir: Path) -> List[Path]: + """Return fallback directories to try when the configured directory is not writable.""" + fallbacks: List[Path] = [] + + env_override = os.environ.get("LEDMATRIX_LOGO_DIR") + if env_override: + env_path = Path(env_override) + if not env_path.is_absolute(): + env_path = self._resolve_project_path(env_path) + fallbacks.append(env_path / self.sport_key) + + cache_dir = getattr(self.cache_manager, "cache_dir", None) + if cache_dir: + fallbacks.append(Path(cache_dir) / "logos" / self.sport_key) + + try: + fallbacks.append(Path.home() / ".ledmatrix" / "logos" / self.sport_key) + except RuntimeError as e: + self.logger.debug("Could not resolve home directory (expected for service users): %s", e) + + fallbacks.append(Path(tempfile.gettempdir()) / "ledmatrix_logos" / self.sport_key) + + unique_fallbacks: List[Path] = [] + seen = set() + for candidate in fallbacks: + if candidate == configured_dir: + continue + if candidate not in seen: + unique_fallbacks.append(candidate) + seen.add(candidate) + + return unique_fallbacks + + def _get_season_schedule_dates(self) -> tuple[str, str]: + return "", "" + + def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: + """Placeholder draw method - subclasses should override.""" + # This base method will be simple, subclasses provide specifics + try: + img = Image.new('RGB', (self.display_width, self.display_height), (0, 0, 0)) + draw = ImageDraw.Draw(img) + status = game.get("status_text", "N/A") + self._draw_text_with_outline(draw, status, (2, 2), self.fonts['status']) + self.display_manager.image.paste(img, (0, 0)) + # Don't call update_display here, let subclasses handle it after drawing + except Exception as e: + self.logger.error(f"Error in base _draw_scorebug_layout: {e}", exc_info=True) + + + def _resolve_skin_id(self) -> Optional[str]: + """The skin id configured for this instance's mode, or None for the + built-in renderer. Accepts a plain id (all modes) or a per-mode + mapping ({"live": "retro-baseball", "recent": "built-in"}).""" + skin_id = self._skin_config + if isinstance(skin_id, dict): + skin_id = skin_id.get(self.SKIN_MODE) + if not skin_id or not isinstance(skin_id, str) or skin_id == "built-in": + return None + return skin_id + + def _get_skin(self): + """Lazily load the configured skin once. Returns None (built-in + renderer) when no skin is configured or loading failed.""" + if not self._skin_load_attempted: + self._skin_load_attempted = True + skin_id = self._resolve_skin_id() + if skin_id: + try: + from src.skin_system import skin_runtime + self._skin = skin_runtime.load_skin( + skin_id, sport=self.sport, sport_key=self.sport_key, + options=self.skin_options) + except Exception as e: + self.logger.error(f"Failed to load skin '{skin_id}': {e}", exc_info=True) + self._skin = None + return self._skin + + def _render_game(self, game: Dict, force_clear: bool = False) -> None: + """Render one game: try the configured skin first, fall back to the + built-in _draw_scorebug_layout. A skin that raises 3 times in a row + is disabled for the rest of the session.""" + skin = self._get_skin() + if skin is not None and self._skin_failures < 3: + try: + from src.skin_system import skin_runtime + ctx = skin_runtime.build_context(self, game) + render = getattr(skin, f"render_{self.SKIN_MODE}") + started = time.monotonic() + handled = render(ctx, dict(game)) + elapsed = time.monotonic() - started + if elapsed > 0.15 and self._skin_slow_renders < 5: + self._skin_slow_renders += 1 + self.logger.warning( + f"Skin '{self._resolve_skin_id()}' took {elapsed * 1000:.0f}ms to " + f"render {self.SKIN_MODE} — slow renders stall the whole display loop") + if handled: + self._skin_failures = 0 + self.display_manager.image.paste(ctx.canvas, (0, 0)) + self.display_manager.update_display() + return + except Exception: + self._skin_failures += 1 + outcome = ("disabling skin for this session" if self._skin_failures >= 3 + else "falling back to built-in renderer") + self.logger.error( + f"Skin '{self._resolve_skin_id()}' failed rendering {self.SKIN_MODE} " + f"({self._skin_failures}/3); {outcome}", exc_info=True) + self._draw_scorebug_layout(game, force_clear) + + def render_skin_card(self, game: Dict, size: tuple) -> Optional[Image.Image]: + """Render one game as a standalone card via the configured skin — + for vegas mode and previews. Tries render_vegas_card at the given + size, then the mode renderer on a card-sized canvas. Returns None + when no skin is active or the skin declined, so callers can use + their default rendering.""" + skin = self._get_skin() + if skin is None or self._skin_failures >= 3: + return None + try: + from src.skin_system import skin_runtime + ctx = skin_runtime.build_context(self, game, size=size) + card = skin.render_vegas_card(ctx, dict(game)) + if card is not None: + return card + ctx = skin_runtime.build_context(self, game, size=size) + render = getattr(skin, f"render_{self.SKIN_MODE}") + if render(ctx, dict(game)): + return ctx.canvas + except Exception: + # Card failures count toward the same 3-strike session disable + # as display failures — a skin broken for vegas shouldn't get + # to throw on every scroll tick forever. + self._skin_failures += 1 + self.logger.error( + f"Skin '{self._resolve_skin_id()}' card render failed " + f"({self._skin_failures}/3)", exc_info=True) + return None + + def display(self, force_clear: bool = False) -> bool: + """Common display method for all NCAA FB managers""" # Updated docstring + if not self.is_enabled: # Check if module is enabled + return False + + if not self.current_game: + # Clear display if force_clear is True, even when there's no content + # This prevents black screens when switching to modes with no content + if force_clear: + try: + self.display_manager.clear() + self.display_manager.update_display() + except Exception as e: + self.logger.debug(f"Error clearing display when no content: {e}") + + current_time = time.time() + if not hasattr(self, '_last_warning_time'): + self._last_warning_time = 0 + if current_time - getattr(self, '_last_warning_time', 0) > 300: + self.logger.warning(f"No game data available to display in {self.__class__.__name__}") + setattr(self, '_last_warning_time', current_time) + return False + + try: + self._render_game(self.current_game, force_clear) + # display_manager.update_display() should be called within subclass draw methods + # or after calling display() in the main loop. Let's keep it out of the base display. + return True + except Exception as e: + self.logger.error(f"Error during display call in {self.__class__.__name__}: {e}", exc_info=True) + return False + + + def _load_fonts(self) -> Dict[str, Any]: + """Load fonts used by the scoreboard. + + Paths go through :meth:`_resolve_font_path` so the bundled fonts are + found regardless of the process working directory — a bare + ``"assets/fonts/..."`` silently degraded every scoreboard to the PIL + default font whenever the process started elsewhere (the plugin safety + harness on CI being the case that surfaced it). + """ + fonts: Dict[str, Any] = {} + press_start = self._resolve_font_path("PressStart2P-Regular.ttf") + four_by_six = self._resolve_font_path("4x6-font.ttf") + try: + fonts['score'] = ImageFont.truetype(press_start, 10) + fonts['time'] = ImageFont.truetype(press_start, 8) + fonts['team'] = ImageFont.truetype(press_start, 8) + fonts['status'] = ImageFont.truetype(four_by_six, 6) # Using 4x6 for status + fonts['detail'] = ImageFont.truetype(four_by_six, 6) # Added detail font + fonts['rank'] = ImageFont.truetype(press_start, 10) + self.logger.info("Successfully loaded fonts") + except OSError: + # Name the directory we searched: the usual cause is an install + # whose assets/fonts is missing, and the bare message sent people + # hunting for a font-format problem instead. + self.logger.warning( + "Fonts not found under %s, using default PIL font.", + self._font_root(), + ) + fonts['score'] = ImageFont.load_default() + fonts['time'] = ImageFont.load_default() + fonts['team'] = ImageFont.load_default() + fonts['status'] = ImageFont.load_default() + fonts['detail'] = ImageFont.load_default() + fonts['rank'] = ImageFont.load_default() + return fonts + + def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any], width: int, height: int) -> None: + """Draw odds with dynamic positioning - only show negative spread and position O/U based on favored team.""" + home_team_odds = odds.get('home_team_odds', {}) + away_team_odds = odds.get('away_team_odds', {}) + home_spread = home_team_odds.get('spread_odds') + away_spread = away_team_odds.get('spread_odds') + + # Get top-level spread as fallback + top_level_spread = odds.get('spread') + + # If we have a top-level spread and the individual spreads are None or 0, use the top-level + if top_level_spread is not None: + if home_spread is None or home_spread == 0.0: + home_spread = top_level_spread + if away_spread is None: + away_spread = -top_level_spread + + # Determine which team is favored (has negative spread) + home_favored = home_spread is not None and home_spread < 0 + away_favored = away_spread is not None and away_spread < 0 + + # Only show the negative spread (favored team) + favored_spread = None + favored_side = None + + if home_favored: + favored_spread = home_spread + favored_side = 'home' + self.logger.debug(f"Home team favored with spread: {favored_spread}") + elif away_favored: + favored_spread = away_spread + favored_side = 'away' + self.logger.debug(f"Away team favored with spread: {favored_spread}") + else: + self.logger.debug("No clear favorite - spreads: home={home_spread}, away={away_spread}") + + # Show the negative spread on the appropriate side + if favored_spread is not None: + spread_text = str(favored_spread) + font = self.fonts['detail'] # Use detail font for odds + + if favored_side == 'home': + # Home team is favored, show spread on right side + spread_width = draw.textlength(spread_text, font=font) + spread_x = width - spread_width # Top right + spread_y = 0 + self._draw_text_with_outline(draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0)) + self.logger.debug(f"Showing home spread '{spread_text}' on right side") + else: + # Away team is favored, show spread on left side + spread_x = 0 # Top left + spread_y = 0 + self._draw_text_with_outline(draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0)) + self.logger.debug(f"Showing away spread '{spread_text}' on left side") + + # Show over/under on the opposite side of the favored team + over_under = odds.get('over_under') + if over_under is not None: + ou_text = f"O/U: {over_under}" + font = self.fonts['detail'] # Use detail font for odds + ou_width = draw.textlength(ou_text, font=font) + + if favored_side == 'home': + # Home team is favored, show O/U on left side (opposite of spread) + ou_x = 0 # Top left + ou_y = 0 + self.logger.debug(f"Showing O/U '{ou_text}' on left side (home favored)") + elif favored_side == 'away': + # Away team is favored, show O/U on right side (opposite of spread) + ou_x = width - ou_width # Top right + ou_y = 0 + self.logger.debug(f"Showing O/U '{ou_text}' on right side (away favored)") + else: + # No clear favorite, show O/U in center + ou_x = (width - ou_width) // 2 + ou_y = 0 + self.logger.debug(f"Showing O/U '{ou_text}' in center (no clear favorite)") + + self._draw_text_with_outline(draw, ou_text, (ou_x, ou_y), font, fill=(0, 255, 0)) + + def _draw_text_with_outline(self, draw, text, position, font, fill=(255, 255, 255), outline_color=(0, 0, 0)): + """Draw text with a black outline for better readability.""" + x, y = position + for dx, dy in [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]: + draw.text((x + dx, y + dy), text, font=font, fill=outline_color) + draw.text((x, y), text, font=font, fill=fill) + + def _load_and_resize_logo(self, team_id: str, team_abbrev: str, logo_path: Path, logo_url: str | None ) -> Optional[Image.Image]: + """Load and resize a team logo, with caching and automatic download if missing.""" + self.logger.debug(f"Logo path: {logo_path}") + if team_abbrev in self._logo_cache: + self.logger.debug(f"Using cached logo for {team_abbrev}") + return self._logo_cache[team_abbrev] + + try: + # Try different filename variations first (for cases like TA&M vs TAANDM) + actual_logo_path = None + filename_variations = LogoDownloader.get_logo_filename_variations(team_abbrev) + + for filename in filename_variations: + test_path = logo_path.parent / filename + if test_path.exists(): + actual_logo_path = test_path + self.logger.debug(f"Found logo at alternative path: {actual_logo_path}") + break + + # If no variation found, try to download missing logo + if not actual_logo_path and not logo_path.exists(): + self.logger.info(f"Logo not found for {team_abbrev} at {logo_path}. Attempting to download.") + + # Try to download the logo from ESPN API (this will create placeholder if download fails) + download_missing_logo(self.sport_key, team_id, team_abbrev, logo_path, logo_url) + actual_logo_path = logo_path + + # Use the original path if no alternative was found + if not actual_logo_path: + actual_logo_path = logo_path + + # Only try to open the logo if the file exists + if os.path.exists(actual_logo_path): + logo = Image.open(actual_logo_path) + else: + self.logger.error(f"Logo file still doesn't exist at {actual_logo_path} after download attempt") + return None + if logo.mode != 'RGBA': + logo = logo.convert('RGBA') + + max_width = int(self.display_width * 1.5) + max_height = int(self.display_height * 1.5) + logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) + self._logo_cache[team_abbrev] = logo + return logo + + except Exception as e: + self.logger.error(f"Error loading logo for {team_abbrev}: {e}", exc_info=True) + return None + + def _fetch_odds(self, game: Dict) -> None: + """Fetch odds for a specific game using the new architecture.""" + try: + if not self.show_odds: + return + + if not self.odds_manager: + return + + # Determine update interval based on game state + is_live = game.get('is_live', False) + update_interval = self.mode_config.get("live_odds_update_interval", 60) if is_live \ + else self.mode_config.get("odds_update_interval", 3600) + + # Fetch odds using OddsManager + odds_data = self.odds_manager.get_odds( + sport=self.sport, + league=self.league, + event_id=game['id'], + update_interval_seconds=update_interval, + ) + + if odds_data: + game['odds'] = odds_data + self.logger.debug(f"Successfully fetched and attached odds for game {game['id']}") + else: + self.logger.debug(f"No odds data returned for game {game['id']}") + + except Exception as e: + self.logger.error(f"Error fetching odds for game {game.get('id', 'N/A')}: {e}") + + def _get_timezone(self): + try: + timezone_str = self.config.get('timezone', 'UTC') + return pytz.timezone(timezone_str) + except pytz.UnknownTimeZoneError: + return pytz.utc + + def _should_log(self, warning_type: str, cooldown: int = 60) -> bool: + """Whether a warning of this kind is outside its cooldown window. + + Cooldowns are tracked **per ``warning_type``**. They previously shared + one timestamp, so the parameter was accepted and ignored: an API-error + warning would silence an unrelated cache warning for the next minute, + and whichever fired first won. Nothing in core called this, so no + behavior regressed with the fix — but every caller has always been + entitled to assume its own warning type has its own clock. + """ + current_time = time.time() + if current_time - self._warning_cooldowns.get(warning_type, 0) > cooldown: + self._warning_cooldowns[warning_type] = current_time + # Kept in step for subclasses that read it directly. + self._last_warning_time = current_time + return True + return False + + def _fetch_team_rankings(self) -> Dict[str, int]: + """Fetch team rankings using the new architecture components.""" + current_time = time.time() + + # Check if we have cached rankings that are still valid + if (self._team_rankings_cache and + current_time - self._rankings_cache_timestamp < self._rankings_cache_duration): + return self._team_rankings_cache + + try: + data = self.data_source.fetch_standings(self.sport, self.league) + + rankings = {} + rankings_data = data.get('rankings', []) + + if rankings_data: + # Use the first ranking (usually AP Top 25) + first_ranking = rankings_data[0] + teams = first_ranking.get('ranks', []) + + for team_data in teams: + team_info = team_data.get('team', {}) + team_abbr = team_info.get('abbreviation', '') + current_rank = team_data.get('current', 0) + + if team_abbr and current_rank > 0: + rankings[team_abbr] = current_rank + + # Cache the results + self._team_rankings_cache = rankings + self._rankings_cache_timestamp = current_time + + self.logger.debug(f"Fetched rankings for {len(rankings)} teams") + return rankings + + except Exception as e: + self.logger.error(f"Error fetching team rankings: {e}") + return {} + + def _extract_game_details_common(self, game_event: Dict) -> tuple[Dict | None, Dict | None, Dict | None, Dict | None, Dict | None]: + if not game_event: + return None, None, None, None, None + try: + competition = game_event["competitions"][0] + status = competition["status"] + competitors = competition["competitors"] + game_date_str = game_event["date"] + situation = competition.get("situation") + start_time_utc = None + try: + # Parse the datetime string + if game_date_str.endswith('Z'): + game_date_str = game_date_str.replace('Z', '+00:00') + dt = datetime.fromisoformat(game_date_str) + # Ensure the datetime is UTC-aware (fromisoformat may create timezone-aware but not pytz.UTC) + if dt.tzinfo is None: + # If naive, assume it's UTC + start_time_utc = dt.replace(tzinfo=pytz.UTC) + else: + # Convert to pytz.UTC for consistency + start_time_utc = dt.astimezone(pytz.UTC) + except ValueError: + logging.warning(f"Could not parse game date: {game_date_str}") + + home_team = next((c for c in competitors if c.get("homeAway") == "home"), None) + away_team = next((c for c in competitors if c.get("homeAway") == "away"), None) + + if not home_team or not away_team: + self.logger.warning(f"Could not find home or away team in event: {game_event.get('id')}") + return None, None, None, None, None + + try: + home_abbr = home_team["team"]["abbreviation"] + except KeyError: + home_abbr = home_team["team"]["name"][:3] + try: + away_abbr = away_team["team"]["abbreviation"] + except KeyError: + away_abbr = away_team["team"]["name"][:3] + + # Check if this is a favorite team game BEFORE doing expensive logging + is_favorite_game = (home_abbr in self.favorite_teams or away_abbr in self.favorite_teams) + + # Only log debug info for favorite team games + if is_favorite_game: + self.logger.debug(f"Processing favorite team game: {game_event.get('id')}") + self.logger.debug(f"Found teams: {away_abbr}@{home_abbr}, Status: {status['type']['name']}, State: {status['type']['state']}") + + game_time, game_date = "", "" + if start_time_utc: + local_time = start_time_utc.astimezone(self._get_timezone()) + game_time = local_time.strftime("%I:%M%p").lstrip('0') + + # Check date format from config + use_short_date_format = self.config.get('display', {}).get('use_short_date_format', False) + if use_short_date_format: + game_date = local_time.strftime("%-m/%-d") + else: + game_date = self.display_manager.format_date_with_ordinal(local_time) + + + home_record = home_team.get('records', [{}])[0].get('summary', '') if home_team.get('records') else '' + away_record = away_team.get('records', [{}])[0].get('summary', '') if away_team.get('records') else '' + + # Don't show "0-0" records - set to blank instead + if home_record in {"0-0", "0-0-0"}: + home_record = '' + if away_record in {"0-0", "0-0-0"}: + away_record = '' + + details = { + "id": game_event.get("id"), + "game_time": game_time, + "game_date": game_date, + "start_time_utc": start_time_utc, + "status_text": status["type"]["shortDetail"], # e.g., "Final", "7:30 PM", "Q1 12:34" + "is_live": status["type"]["state"] == "in", + "is_final": status["type"]["state"] == "post", + "is_upcoming": (status["type"]["state"] == "pre" or + status["type"]["name"].lower() in ['scheduled', 'pre-game', 'status_scheduled']), + "is_halftime": status["type"]["state"] == "halftime" or status["type"]["name"] == "STATUS_HALFTIME", # Added halftime check + "is_period_break": status["type"]["name"] == "STATUS_END_PERIOD", # Added Period Break check + "home_abbr": home_abbr, + "home_id": home_team["id"], + "home_score": home_team.get("score", "0"), + "home_logo_path": self.logo_dir / Path(f"{LogoDownloader.normalize_abbreviation(home_abbr)}.png"), + "home_logo_url": home_team["team"].get("logo"), + "home_record": home_record, + "away_record": away_record, + "away_abbr": away_abbr, + "away_id": away_team["id"], + "away_score": away_team.get("score", "0"), + "away_logo_path": self.logo_dir / Path(f"{LogoDownloader.normalize_abbreviation(away_abbr)}.png"), + "away_logo_url": away_team["team"].get("logo"), + "is_within_window": True, # Whether game is within display window + + } + return details, home_team, away_team, status, situation + except Exception as e: + # Log the problematic event structure if possible + logging.error(f"Error extracting game details: {e} from event: {game_event.get('id')}", exc_info=True) + return None, None, None, None, None + + @abstractmethod + def _extract_game_details(self, game_event: dict) -> dict | None: + details, _, _, _, _ = self._extract_game_details_common(game_event) + return details + + @abstractmethod + def _fetch_data(self) -> Optional[Dict]: + pass + + def _fetch_todays_games(self) -> Optional[Dict]: + """Fetch only today's games for live updates (not entire season).""" + try: + tz = pytz.timezone("America/New_York") # Use full name (not "EST") for DST support + now = datetime.now(tz) + yesterday = now - timedelta(days=1) + formatted_date = now.strftime("%Y%m%d") + formatted_date_yesterday = yesterday.strftime("%Y%m%d") + # Fetch todays games only + url = f"https://site.api.espn.com/apis/site/v2/sports/{self.sport}/{self.league}/scoreboard" + response = self.session.get(url, params={"dates": f"{formatted_date_yesterday}-{formatted_date}", "limit": 1000}, headers=self.headers, timeout=10) + response.raise_for_status() + data = response.json() + events = data.get('events', []) + + self.logger.info(f"Fetched {len(events)} todays games for {self.sport} - {self.league}") + return {'events': events} + except requests.exceptions.RequestException as e: + self.logger.error(f"API error fetching todays games for {self.sport} - {self.league}: {e}") + return None + + def _get_weeks_data(self) -> Optional[Dict]: + """ + Get partial data for immediate display while background fetch is in progress. + This fetches current/recent games only for quick response. + """ + try: + # Fetch current week and next few days for immediate display + now = datetime.now(pytz.utc) + immediate_events = [] + + start_date = now + timedelta(weeks=-2) + end_date = now + timedelta(weeks=1) + date_str = f"{start_date.strftime('%Y%m%d')}-{end_date.strftime('%Y%m%d')}" + url = f"https://site.api.espn.com/apis/site/v2/sports/{self.sport}/{self.league}/scoreboard" + response = self.session.get(url, params={"dates": date_str, "limit": 1000},headers=self.headers, timeout=10) + response.raise_for_status() + data = response.json() + immediate_events = data.get('events', []) + + if immediate_events: + self.logger.info(f"Fetched {len(immediate_events)} events {date_str}") + return {'events': immediate_events} + + except requests.exceptions.RequestException as e: + self.logger.warning(f"Error fetching this weeks games for {self.sport} - {self.league} - {date_str}: {e}") + return None + + def _custom_scorebug_layout(self, game: dict, draw_overlay: ImageDraw.ImageDraw): + pass + + # ------------------------------------------------------------------ + # Promoted from the plugin copies (see docs/SPORTS_UNIFICATION.md). + # Everything below is present in all nine bundled `sports.py` copies; + # the canonical form of each is documented on the method. + # ------------------------------------------------------------------ + + def _favorite_key(self, game: Dict, side: str) -> Optional[str]: + """Override point: which view-model field identifies a team when + matching against ``favorite_teams``. + + ``side`` is ``"home"`` or ``"away"``. The default is the team + abbreviation — what eight of the nine scoreboards match on, and what + users type into their favorites list. + + NRL overrides this to the team **id**, because NRL abbreviations are + not unique: "NEW" is both Newcastle Knights and New Zealand Warriors, + "CAN" both Canberra Raiders and Canterbury Bulldogs. Matching those by + abbreviation selects the wrong club. It is a seam rather than a branch + precisely so core never has to learn the string "nrl":: + + def _favorite_key(self, game, side): + return str(game.get(f"{side}_id")) + + An override that stringifies should note that a missing id becomes the + literal ``"None"``, which would spuriously match a favorites list + containing that string. The default returns ``None``, which never + matches. + """ + return game.get(f"{side}_abbr") + + def _config_schema_path(self) -> Optional[str]: + """Override point: the plugin's ``config_schema.json``, used as the + reference for style-resolver defaults. + + None (the default) means no schema is available — layout offsets are + then read with the classic inline config lookup, which is exactly what + a plugin that never shipped resolver support does today. A plugin + opting into :mod:`src.element_style` returns its own schema path:: + + def _config_schema_path(self): + return os.path.join(os.path.dirname(os.path.abspath(__file__)), + 'config_schema.json') + + It must not be derived from this module's ``__file__``: after + promotion that resolves inside ``src/base_classes/sports/``, where no + ``config_schema.json`` exists. + """ + return None + + def _font_root(self) -> str: + """Override point: the directory ``assets/fonts`` resolves against. + + Defaults to the core install root, derived from this module's own + location — the same strategy as ``FontManager._resolve_asset_path``. + A plugin bundling its own fonts overrides this to return its plugin + directory. Never a cwd-relative path: fonts must load no matter where + the process was started from (e.g. the plugin safety harness on CI). + """ + return str(_INSTALL_ROOT) + + def _resolve_font_path(self, font_name: str) -> str: + """Locate a font file by filename, independently of the process cwd. + + Tries ``assets/fonts/`` relative to the cwd first (preserving + behavior for a process started from an install root), then under + :meth:`_font_root`. Returns the cwd-relative path unchanged when the + file is nowhere to be found, so callers log the familiar path. + """ + relative = os.path.join('assets', 'fonts', font_name) + if os.path.exists(relative): + return relative + candidate = os.path.join(self._font_root(), relative) + if os.path.exists(candidate): + return candidate + return relative + + def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: + """ + Get layout offset for a specific element and axis. + + Args: + element: Element name (e.g., 'home_logo', 'score', 'status_text') + axis: 'x_offset' or 'y_offset' (or 'away_x_offset', 'home_x_offset' for records) + default: Default value if not configured (default: 0) + + Returns: + Offset value from config or default (always returns int) + """ + schema_path = self._config_schema_path() if STYLE_AVAILABLE else None + if schema_path: + # Shared resolver (rebuilt if the config dict was swapped out, + # matching the classic path's read-config-on-every-call semantics). + # Note it is stricter than the classic read below: a boolean offset + # degrades to the default instead of counting as 1/0, which is the + # more correct reading of a pixel offset. + resolver = getattr(self, '_style_resolver_cached', None) + if resolver is None or resolver._config is not self.config: + resolver = ElementStyleResolver( + self.config, defaults_from_schema_file(schema_path)) + self._style_resolver_cached = resolver + return resolver.offset_value(element, axis, default) + try: + layout_config = self.config.get('customization', {}).get('layout', {}) + element_config = layout_config.get(element, {}) + offset_value = element_config.get(axis, default) + + # Ensure we return an integer (handle float/string from config) + if isinstance(offset_value, (int, float)): + return int(offset_value) + elif isinstance(offset_value, str): + # Try to convert string to int + try: + return int(float(offset_value)) + except (ValueError, TypeError): + self.logger.warning( + f"Invalid layout offset value for {element}.{axis}: '{offset_value}', using default {default}" + ) + return default + else: + return default + except Exception as e: + # Gracefully handle any config access errors + self.logger.debug(f"Error reading layout offset for {element}.{axis}: {e}, using default {default}") + return default + + def _load_custom_font_from_element_config( + self, + element_config: Dict[str, Any], + default_size: int = 8, + default_font: Optional[str] = None, + ) -> ImageFont.FreeTypeFont: + """ + Load a custom font from an element configuration dictionary. + + Args: + element_config: Configuration dict for a single element containing 'font' and 'font_size' keys + default_size: Default font size if not specified in config + default_font: Default font filename when not specified in config (e.g. '4x6-font.ttf' for odds) + + Returns: + PIL ImageFont object + """ + base_default = default_font or "PressStart2P-Regular.ttf" + font_name = element_config.get('font', base_default) + font_size = int(element_config.get('font_size', default_size)) # Ensure integer for PIL + + # Resolve family aliases (e.g. "press_start") to real filenames, then + # locate the file against _font_root() rather than the cwd. + resolved_name = _resolve_font_family_alias(font_name) + font_path = self._resolve_font_path(resolved_name) + + # Memoized: per-frame callers (font-ladder walks) resolve the same + # (name, size) repeatedly -- return the previously loaded face. + cache_key = (resolved_name, font_size) + cached_font = self._font_cache.get(cache_key) + if cached_font is not None: + return cached_font + + # Try to load the font + try: + if os.path.exists(font_path): + # Try loading as TTF first (works for both TTF and some BDF files with PIL) + if font_path.lower().endswith('.ttf'): + font = ImageFont.truetype(font_path, font_size) + self.logger.debug(f"Loaded font: {font_name} at size {font_size}") + self._font_cache[cache_key] = font + return font + elif font_path.lower().endswith('.bdf'): + # BDF fonts are fixed-size bitmaps, not scalable outlines -- + # FreeType only accepts the exact pixel size baked into the + # file (its "strike") and raises "invalid pixel size" for + # anything else. Try the requested size first (in case it + # happens to match), then fall back to the file's real + # native size, so a BDF font can still be selected via + # font_size-driven configs without the caller needing to + # know its exact strike size. + # + # This retry is the OLDER lineage's behavior and it is the + # correct one: the newer copies call truetype() on a BDF at + # any size (which simply fails) or refuse BDF outright. + try: + font = ImageFont.truetype(font_path, font_size) + self.logger.debug(f"Loaded BDF font: {font_name} at size {font_size}") + self._font_cache[cache_key] = font + return font + except OSError: + if font_path in self._bdf_native_size_cache: + native_size = self._bdf_native_size_cache[font_path] + else: + native_size = _read_bdf_native_size(font_path) + self._bdf_native_size_cache[font_path] = native_size + if native_size and native_size != font_size: + try: + font = ImageFont.truetype(font_path, native_size) + self.logger.debug( + f"Loaded BDF font: {font_name} at its native size {native_size} " + f"(requested {font_size} isn't a valid strike for this file)" + ) + self._font_cache[cache_key] = font + return font + except Exception as retry_exc: + self.logger.debug( + f"BDF font {font_name} also failed to load at native " + f"size {native_size}: {retry_exc}" + ) + self.logger.warning(f"Could not load BDF font {font_name} with PIL, using default") + # Fall through to default + else: + self.logger.warning(f"Unknown font file type: {font_name}, using default") + else: + self.logger.warning(f"Font file not found: {font_path}, using default") + except Exception as e: + self.logger.error(f"Error loading font {font_name}: {e}, using default") + + # Fall back to default font. Cached under the requested (name, size) + # key too, so a misconfigured or missing font pays the disk cost once + # instead of on every frame of a font-ladder walk. + default_font_path = self._resolve_font_path( + _resolve_font_family_alias(base_default)) + try: + if os.path.exists(default_font_path): + font = ImageFont.truetype(default_font_path, font_size) + else: + self.logger.warning("Default font not found, using PIL default") + font = ImageFont.load_default() + except Exception as e: + self.logger.error(f"Error loading default font: {e}") + font = ImageFont.load_default() + self._font_cache[cache_key] = font + return font + + def cleanup(self): + """Clean up resources when plugin is unloaded.""" + # Close HTTP session + if hasattr(self, 'session') and self.session: + try: + self.session.close() + except Exception as e: + self.logger.warning(f"Error closing session: {e}") + + # Clear caches + if hasattr(self, '_logo_cache'): + self._logo_cache.clear() + # Font caches hold PIL faces; without this they are an unbounded + # per-instance leak across enable/disable cycles. + if hasattr(self, '_font_cache'): + self._font_cache.clear() + if hasattr(self, '_bdf_native_size_cache'): + self._bdf_native_size_cache.clear() + + # NOTE: self.background_service is deliberately NOT shut down here. + # get_background_service() returns a PROCESS-WIDE singleton shared by + # every scoreboard; shutting it down from one unloading plugin would + # stop background fetching for all the others. Whoever owns the + # process owns its lifecycle. Do not "fix" this. + + self.logger.info(f"{self.__class__.__name__} cleanup completed") diff --git a/src/base_classes/sports.py b/src/base_classes/sports/modes.py similarity index 56% rename from src/base_classes/sports.py rename to src/base_classes/sports/modes.py index e9f317df..06e73467 100644 --- a/src/base_classes/sports.py +++ b/src/base_classes/sports/modes.py @@ -1,753 +1,21 @@ +"""The three display modes layered on SportsCore: SportsUpcoming, +SportsRecent and SportsLive. Split out of the former +``src/base_classes/sports.py``; see docs/SPORTS_UNIFICATION.md. +""" + import logging -import os -import tempfile import time -from abc import ABC, abstractmethod +from abc import abstractmethod from datetime import datetime, timedelta, timezone -from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List -import pytz -import requests from PIL import Image, ImageDraw, ImageFont -from requests.adapters import HTTPAdapter -from urllib3.util.retry import Retry -from src.background_data_service import get_background_service - -# Import new architecture components (individual classes will import what they need) -from src.base_classes.api_extractors import APIDataExtractor -from src.base_classes.data_sources import DataSource from src.cache_manager import CacheManager from src.display_manager import DisplayManager -from src.dynamic_team_resolver import DynamicTeamResolver -from src.logo_downloader import LogoDownloader, download_missing_logo -try: - from src.base_odds_manager import BaseOddsManager as OddsManager -except ImportError: - OddsManager = None +from .core import SportsCore -class SportsCore(ABC): - # Which ScoreboardSkin render method this class's display path maps to. - # SportsLive inherits the default; SportsUpcoming/SportsRecent override. - SKIN_MODE = "live" - - def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str): - self.logger = logger - self.config = config - self.cache_manager = cache_manager - self.config_manager = self.cache_manager.config_manager - if OddsManager: - try: - self.odds_manager = OddsManager( - self.cache_manager, self.config_manager) - except Exception as e: - self.logger.warning(f"Failed to initialize OddsManager: {e}") - self.odds_manager = None - else: - self.odds_manager = None - self.logger.warning("OddsManager not available - odds functionality disabled") - self.display_manager = display_manager - self.display_width = self.display_manager.matrix.width - self.display_height = self.display_manager.matrix.height - - self.sport_key = sport_key - self.sport = None - self.league = None - - # Initialize new architecture components (will be overridden by sport-specific classes) - self.sport_config = None - self.api_extractor: APIDataExtractor - self.data_source: DataSource - self.mode_config = config.get(f"{sport_key}_scoreboard", {}) # Changed config key - self.is_enabled: bool = self.mode_config.get("enabled", False) - self.show_odds: bool = self.mode_config.get("show_odds", False) - # Use LogoDownloader to get the correct default logo directory for this sport - default_logo_dir = Path(LogoDownloader().get_logo_directory(sport_key)) - self.logo_dir = self._initialize_logo_dir(default_logo_dir) - self.update_interval: int = self.mode_config.get( - "update_interval_seconds", 60) - self.show_records: bool = self.mode_config.get('show_records', False) - self.show_ranking: bool = self.mode_config.get('show_ranking', False) - # Number of games to show (instead of time-based windows) - self.recent_games_to_show: int = self.mode_config.get( - "recent_games_to_show", 5) # Show last 5 games - self.upcoming_games_to_show: int = self.mode_config.get( - "upcoming_games_to_show", 10) # Show next 10 games - self.show_favorite_teams_only: bool = self.mode_config.get("show_favorite_teams_only", False) - self.show_all_live: bool = self.mode_config.get("show_all_live", False) - - self.session = requests.Session() - retry_strategy = Retry( - total=5, # increased number of retries - backoff_factor=1, # increased backoff factor - # added 429 to retry list - status_forcelist=[429, 500, 502, 503, 504], - allowed_methods=["GET", "HEAD", "OPTIONS"] - ) - adapter = HTTPAdapter(max_retries=retry_strategy) - self.session.mount("https://", adapter) - self.session.mount("http://", adapter) - - self._logo_cache = {} - - # Set up headers - self.headers = { - 'User-Agent': 'LEDMatrix/1.0 (https://github.com/yourusername/LEDMatrix; contact@example.com)', - 'Accept': 'application/json', - 'Accept-Language': 'en-US,en;q=0.9', - 'Accept-Encoding': 'gzip, deflate, br', - 'Connection': 'keep-alive' - } - self.last_update = 0 - self.current_game = None - self.fonts = self._load_fonts() - - # Optional visual skin (see docs/SKIN_SYSTEM.md). "skin" is either a - # skin id applied to all modes, or a per-mode mapping like - # {"live": "retro", "recent": "built-in"}. Loaded lazily on first - # render so a broken skin can never block startup. - self._skin_config = self.mode_config.get("skin") - self.skin_options = self.mode_config.get("skin_options", {}) or {} - self._skin = None - self._skin_load_attempted = False - self._skin_failures = 0 - self._skin_slow_renders = 0 - - # Initialize dynamic team resolver and resolve favorite teams - self.dynamic_resolver = DynamicTeamResolver() - raw_favorite_teams = self.mode_config.get("favorite_teams", []) - self.favorite_teams = self.dynamic_resolver.resolve_teams(raw_favorite_teams, sport_key) - - # Log dynamic team resolution - if raw_favorite_teams != self.favorite_teams: - self.logger.info(f"Resolved dynamic teams: {raw_favorite_teams} -> {self.favorite_teams}") - else: - self.logger.info(f"Favorite teams: {self.favorite_teams}") - - self.logger.setLevel(logging.INFO) - - # Initialize team rankings cache - self._team_rankings_cache = {} - self._rankings_cache_timestamp = 0 - self._rankings_cache_duration = 3600 # Cache rankings for 1 hour - - # Initialize background data service with optimized settings - # Hardcoded for memory optimization: 1 worker, 30s timeout, 3 retries - self.background_service = get_background_service(self.cache_manager, max_workers=1) - self.background_fetch_requests = {} # Track background fetch requests - self.background_enabled = True - self.logger.info("Background service enabled with 1 worker (memory optimized)") - - def _initialize_logo_dir(self, configured_path: Path) -> Path: - """Resolve and ensure a writable logo directory, falling back when necessary.""" - downloader = LogoDownloader() - resolved_configured = self._resolve_project_path(configured_path) - candidates = [resolved_configured] + self._get_logo_directory_fallbacks(resolved_configured) - - for candidate in candidates: - candidate_path = self._resolve_project_path(candidate) - if downloader.ensure_logo_directory(str(candidate_path)): - if candidate_path != resolved_configured: - self.logger.warning( - "Configured logo directory '%s' is not writable; using fallback '%s'", - resolved_configured, - candidate_path, - ) - return candidate_path - - self.logger.error( - "Unable to find a writable logo directory. Logos may fail to download (last attempted: %s)", - resolved_configured, - ) - return resolved_configured - - def _resolve_project_path(self, path: Path) -> Path: - """Convert relative paths to absolute ones rooted at the project directory.""" - if path.is_absolute(): - return path - project_root = Path(__file__).resolve().parents[2] - return (project_root / path).resolve() - - def _get_logo_directory_fallbacks(self, configured_dir: Path) -> List[Path]: - """Return fallback directories to try when the configured directory is not writable.""" - fallbacks: List[Path] = [] - - env_override = os.environ.get("LEDMATRIX_LOGO_DIR") - if env_override: - env_path = Path(env_override) - if not env_path.is_absolute(): - env_path = self._resolve_project_path(env_path) - fallbacks.append(env_path / self.sport_key) - - cache_dir = getattr(self.cache_manager, "cache_dir", None) - if cache_dir: - fallbacks.append(Path(cache_dir) / "logos" / self.sport_key) - - try: - fallbacks.append(Path.home() / ".ledmatrix" / "logos" / self.sport_key) - except RuntimeError as e: - self.logger.debug("Could not resolve home directory (expected for service users): %s", e) - - fallbacks.append(Path(tempfile.gettempdir()) / "ledmatrix_logos" / self.sport_key) - - unique_fallbacks: List[Path] = [] - seen = set() - for candidate in fallbacks: - if candidate == configured_dir: - continue - if candidate not in seen: - unique_fallbacks.append(candidate) - seen.add(candidate) - - return unique_fallbacks - - def _get_season_schedule_dates(self) -> tuple[str, str]: - return "", "" - - def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: - """Placeholder draw method - subclasses should override.""" - # This base method will be simple, subclasses provide specifics - try: - img = Image.new('RGB', (self.display_width, self.display_height), (0, 0, 0)) - draw = ImageDraw.Draw(img) - status = game.get("status_text", "N/A") - self._draw_text_with_outline(draw, status, (2, 2), self.fonts['status']) - self.display_manager.image.paste(img, (0, 0)) - # Don't call update_display here, let subclasses handle it after drawing - except Exception as e: - self.logger.error(f"Error in base _draw_scorebug_layout: {e}", exc_info=True) - - - def _resolve_skin_id(self) -> Optional[str]: - """The skin id configured for this instance's mode, or None for the - built-in renderer. Accepts a plain id (all modes) or a per-mode - mapping ({"live": "retro-baseball", "recent": "built-in"}).""" - skin_id = self._skin_config - if isinstance(skin_id, dict): - skin_id = skin_id.get(self.SKIN_MODE) - if not skin_id or not isinstance(skin_id, str) or skin_id == "built-in": - return None - return skin_id - - def _get_skin(self): - """Lazily load the configured skin once. Returns None (built-in - renderer) when no skin is configured or loading failed.""" - if not self._skin_load_attempted: - self._skin_load_attempted = True - skin_id = self._resolve_skin_id() - if skin_id: - try: - from src.skin_system import skin_runtime - self._skin = skin_runtime.load_skin( - skin_id, sport=self.sport, sport_key=self.sport_key, - options=self.skin_options) - except Exception as e: - self.logger.error(f"Failed to load skin '{skin_id}': {e}", exc_info=True) - self._skin = None - return self._skin - - def _render_game(self, game: Dict, force_clear: bool = False) -> None: - """Render one game: try the configured skin first, fall back to the - built-in _draw_scorebug_layout. A skin that raises 3 times in a row - is disabled for the rest of the session.""" - skin = self._get_skin() - if skin is not None and self._skin_failures < 3: - try: - from src.skin_system import skin_runtime - ctx = skin_runtime.build_context(self, game) - render = getattr(skin, f"render_{self.SKIN_MODE}") - started = time.monotonic() - handled = render(ctx, dict(game)) - elapsed = time.monotonic() - started - if elapsed > 0.15 and self._skin_slow_renders < 5: - self._skin_slow_renders += 1 - self.logger.warning( - f"Skin '{self._resolve_skin_id()}' took {elapsed * 1000:.0f}ms to " - f"render {self.SKIN_MODE} — slow renders stall the whole display loop") - if handled: - self._skin_failures = 0 - self.display_manager.image.paste(ctx.canvas, (0, 0)) - self.display_manager.update_display() - return - except Exception: - self._skin_failures += 1 - outcome = ("disabling skin for this session" if self._skin_failures >= 3 - else "falling back to built-in renderer") - self.logger.error( - f"Skin '{self._resolve_skin_id()}' failed rendering {self.SKIN_MODE} " - f"({self._skin_failures}/3); {outcome}", exc_info=True) - self._draw_scorebug_layout(game, force_clear) - - def render_skin_card(self, game: Dict, size: tuple) -> Optional[Image.Image]: - """Render one game as a standalone card via the configured skin — - for vegas mode and previews. Tries render_vegas_card at the given - size, then the mode renderer on a card-sized canvas. Returns None - when no skin is active or the skin declined, so callers can use - their default rendering.""" - skin = self._get_skin() - if skin is None or self._skin_failures >= 3: - return None - try: - from src.skin_system import skin_runtime - ctx = skin_runtime.build_context(self, game, size=size) - card = skin.render_vegas_card(ctx, dict(game)) - if card is not None: - return card - ctx = skin_runtime.build_context(self, game, size=size) - render = getattr(skin, f"render_{self.SKIN_MODE}") - if render(ctx, dict(game)): - return ctx.canvas - except Exception: - # Card failures count toward the same 3-strike session disable - # as display failures — a skin broken for vegas shouldn't get - # to throw on every scroll tick forever. - self._skin_failures += 1 - self.logger.error( - f"Skin '{self._resolve_skin_id()}' card render failed " - f"({self._skin_failures}/3)", exc_info=True) - return None - - def display(self, force_clear: bool = False) -> bool: - """Common display method for all NCAA FB managers""" # Updated docstring - if not self.is_enabled: # Check if module is enabled - return False - - if not self.current_game: - # Clear display if force_clear is True, even when there's no content - # This prevents black screens when switching to modes with no content - if force_clear: - try: - self.display_manager.clear() - self.display_manager.update_display() - except Exception as e: - self.logger.debug(f"Error clearing display when no content: {e}") - - current_time = time.time() - if not hasattr(self, '_last_warning_time'): - self._last_warning_time = 0 - if current_time - getattr(self, '_last_warning_time', 0) > 300: - self.logger.warning(f"No game data available to display in {self.__class__.__name__}") - setattr(self, '_last_warning_time', current_time) - return False - - try: - self._render_game(self.current_game, force_clear) - # display_manager.update_display() should be called within subclass draw methods - # or after calling display() in the main loop. Let's keep it out of the base display. - return True - except Exception as e: - self.logger.error(f"Error during display call in {self.__class__.__name__}: {e}", exc_info=True) - return False - - - def _load_fonts(self): - """Load fonts used by the scoreboard.""" - fonts = {} - try: - fonts['score'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10) - fonts['time'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8) - fonts['team'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8) - fonts['status'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) # Using 4x6 for status - fonts['detail'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) # Added detail font - fonts['rank'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10) - logging.info("Successfully loaded fonts") # Changed log prefix - except IOError: - logging.warning("Fonts not found, using default PIL font.") # Changed log prefix - fonts['score'] = ImageFont.load_default() - fonts['time'] = ImageFont.load_default() - fonts['team'] = ImageFont.load_default() - fonts['status'] = ImageFont.load_default() - fonts['detail'] = ImageFont.load_default() - fonts['rank'] = ImageFont.load_default() - return fonts - - def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any], width: int, height: int) -> None: - """Draw odds with dynamic positioning - only show negative spread and position O/U based on favored team.""" - home_team_odds = odds.get('home_team_odds', {}) - away_team_odds = odds.get('away_team_odds', {}) - home_spread = home_team_odds.get('spread_odds') - away_spread = away_team_odds.get('spread_odds') - - # Get top-level spread as fallback - top_level_spread = odds.get('spread') - - # If we have a top-level spread and the individual spreads are None or 0, use the top-level - if top_level_spread is not None: - if home_spread is None or home_spread == 0.0: - home_spread = top_level_spread - if away_spread is None: - away_spread = -top_level_spread - - # Determine which team is favored (has negative spread) - home_favored = home_spread is not None and home_spread < 0 - away_favored = away_spread is not None and away_spread < 0 - - # Only show the negative spread (favored team) - favored_spread = None - favored_side = None - - if home_favored: - favored_spread = home_spread - favored_side = 'home' - self.logger.debug(f"Home team favored with spread: {favored_spread}") - elif away_favored: - favored_spread = away_spread - favored_side = 'away' - self.logger.debug(f"Away team favored with spread: {favored_spread}") - else: - self.logger.debug("No clear favorite - spreads: home={home_spread}, away={away_spread}") - - # Show the negative spread on the appropriate side - if favored_spread is not None: - spread_text = str(favored_spread) - font = self.fonts['detail'] # Use detail font for odds - - if favored_side == 'home': - # Home team is favored, show spread on right side - spread_width = draw.textlength(spread_text, font=font) - spread_x = width - spread_width # Top right - spread_y = 0 - self._draw_text_with_outline(draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0)) - self.logger.debug(f"Showing home spread '{spread_text}' on right side") - else: - # Away team is favored, show spread on left side - spread_x = 0 # Top left - spread_y = 0 - self._draw_text_with_outline(draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0)) - self.logger.debug(f"Showing away spread '{spread_text}' on left side") - - # Show over/under on the opposite side of the favored team - over_under = odds.get('over_under') - if over_under is not None: - ou_text = f"O/U: {over_under}" - font = self.fonts['detail'] # Use detail font for odds - ou_width = draw.textlength(ou_text, font=font) - - if favored_side == 'home': - # Home team is favored, show O/U on left side (opposite of spread) - ou_x = 0 # Top left - ou_y = 0 - self.logger.debug(f"Showing O/U '{ou_text}' on left side (home favored)") - elif favored_side == 'away': - # Away team is favored, show O/U on right side (opposite of spread) - ou_x = width - ou_width # Top right - ou_y = 0 - self.logger.debug(f"Showing O/U '{ou_text}' on right side (away favored)") - else: - # No clear favorite, show O/U in center - ou_x = (width - ou_width) // 2 - ou_y = 0 - self.logger.debug(f"Showing O/U '{ou_text}' in center (no clear favorite)") - - self._draw_text_with_outline(draw, ou_text, (ou_x, ou_y), font, fill=(0, 255, 0)) - - def _draw_text_with_outline(self, draw, text, position, font, fill=(255, 255, 255), outline_color=(0, 0, 0)): - """Draw text with a black outline for better readability.""" - x, y = position - for dx, dy in [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]: - draw.text((x + dx, y + dy), text, font=font, fill=outline_color) - draw.text((x, y), text, font=font, fill=fill) - - def _load_and_resize_logo(self, team_id: str, team_abbrev: str, logo_path: Path, logo_url: str | None ) -> Optional[Image.Image]: - """Load and resize a team logo, with caching and automatic download if missing.""" - self.logger.debug(f"Logo path: {logo_path}") - if team_abbrev in self._logo_cache: - self.logger.debug(f"Using cached logo for {team_abbrev}") - return self._logo_cache[team_abbrev] - - try: - # Try different filename variations first (for cases like TA&M vs TAANDM) - actual_logo_path = None - filename_variations = LogoDownloader.get_logo_filename_variations(team_abbrev) - - for filename in filename_variations: - test_path = logo_path.parent / filename - if test_path.exists(): - actual_logo_path = test_path - self.logger.debug(f"Found logo at alternative path: {actual_logo_path}") - break - - # If no variation found, try to download missing logo - if not actual_logo_path and not logo_path.exists(): - self.logger.info(f"Logo not found for {team_abbrev} at {logo_path}. Attempting to download.") - - # Try to download the logo from ESPN API (this will create placeholder if download fails) - download_missing_logo(self.sport_key, team_id, team_abbrev, logo_path, logo_url) - actual_logo_path = logo_path - - # Use the original path if no alternative was found - if not actual_logo_path: - actual_logo_path = logo_path - - # Only try to open the logo if the file exists - if os.path.exists(actual_logo_path): - logo = Image.open(actual_logo_path) - else: - self.logger.error(f"Logo file still doesn't exist at {actual_logo_path} after download attempt") - return None - if logo.mode != 'RGBA': - logo = logo.convert('RGBA') - - max_width = int(self.display_width * 1.5) - max_height = int(self.display_height * 1.5) - logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) - self._logo_cache[team_abbrev] = logo - return logo - - except Exception as e: - self.logger.error(f"Error loading logo for {team_abbrev}: {e}", exc_info=True) - return None - - def _fetch_odds(self, game: Dict) -> None: - """Fetch odds for a specific game using the new architecture.""" - try: - if not self.show_odds: - return - - if not self.odds_manager: - return - - # Determine update interval based on game state - is_live = game.get('is_live', False) - update_interval = self.mode_config.get("live_odds_update_interval", 60) if is_live \ - else self.mode_config.get("odds_update_interval", 3600) - - # Fetch odds using OddsManager - odds_data = self.odds_manager.get_odds( - sport=self.sport, - league=self.league, - event_id=game['id'], - update_interval_seconds=update_interval, - ) - - if odds_data: - game['odds'] = odds_data - self.logger.debug(f"Successfully fetched and attached odds for game {game['id']}") - else: - self.logger.debug(f"No odds data returned for game {game['id']}") - - except Exception as e: - self.logger.error(f"Error fetching odds for game {game.get('id', 'N/A')}: {e}") - - def _get_timezone(self): - try: - timezone_str = self.config.get('timezone', 'UTC') - return pytz.timezone(timezone_str) - except pytz.UnknownTimeZoneError: - return pytz.utc - - def _should_log(self, warning_type: str, cooldown: int = 60) -> bool: - """Check if we should log a warning based on cooldown period.""" - current_time = time.time() - if current_time - self._last_warning_time > cooldown: - self._last_warning_time = current_time - return True - return False - - def _fetch_team_rankings(self) -> Dict[str, int]: - """Fetch team rankings using the new architecture components.""" - current_time = time.time() - - # Check if we have cached rankings that are still valid - if (self._team_rankings_cache and - current_time - self._rankings_cache_timestamp < self._rankings_cache_duration): - return self._team_rankings_cache - - try: - data = self.data_source.fetch_standings(self.sport, self.league) - - rankings = {} - rankings_data = data.get('rankings', []) - - if rankings_data: - # Use the first ranking (usually AP Top 25) - first_ranking = rankings_data[0] - teams = first_ranking.get('ranks', []) - - for team_data in teams: - team_info = team_data.get('team', {}) - team_abbr = team_info.get('abbreviation', '') - current_rank = team_data.get('current', 0) - - if team_abbr and current_rank > 0: - rankings[team_abbr] = current_rank - - # Cache the results - self._team_rankings_cache = rankings - self._rankings_cache_timestamp = current_time - - self.logger.debug(f"Fetched rankings for {len(rankings)} teams") - return rankings - - except Exception as e: - self.logger.error(f"Error fetching team rankings: {e}") - return {} - - def _extract_game_details_common(self, game_event: Dict) -> tuple[Dict | None, Dict | None, Dict | None, Dict | None, Dict | None]: - if not game_event: - return None, None, None, None, None - try: - competition = game_event["competitions"][0] - status = competition["status"] - competitors = competition["competitors"] - game_date_str = game_event["date"] - situation = competition.get("situation") - start_time_utc = None - try: - # Parse the datetime string - if game_date_str.endswith('Z'): - game_date_str = game_date_str.replace('Z', '+00:00') - dt = datetime.fromisoformat(game_date_str) - # Ensure the datetime is UTC-aware (fromisoformat may create timezone-aware but not pytz.UTC) - if dt.tzinfo is None: - # If naive, assume it's UTC - start_time_utc = dt.replace(tzinfo=pytz.UTC) - else: - # Convert to pytz.UTC for consistency - start_time_utc = dt.astimezone(pytz.UTC) - except ValueError: - logging.warning(f"Could not parse game date: {game_date_str}") - - home_team = next((c for c in competitors if c.get("homeAway") == "home"), None) - away_team = next((c for c in competitors if c.get("homeAway") == "away"), None) - - if not home_team or not away_team: - self.logger.warning(f"Could not find home or away team in event: {game_event.get('id')}") - return None, None, None, None, None - - try: - home_abbr = home_team["team"]["abbreviation"] - except KeyError: - home_abbr = home_team["team"]["name"][:3] - try: - away_abbr = away_team["team"]["abbreviation"] - except KeyError: - away_abbr = away_team["team"]["name"][:3] - - # Check if this is a favorite team game BEFORE doing expensive logging - is_favorite_game = (home_abbr in self.favorite_teams or away_abbr in self.favorite_teams) - - # Only log debug info for favorite team games - if is_favorite_game: - self.logger.debug(f"Processing favorite team game: {game_event.get('id')}") - self.logger.debug(f"Found teams: {away_abbr}@{home_abbr}, Status: {status['type']['name']}, State: {status['type']['state']}") - - game_time, game_date = "", "" - if start_time_utc: - local_time = start_time_utc.astimezone(self._get_timezone()) - game_time = local_time.strftime("%I:%M%p").lstrip('0') - - # Check date format from config - use_short_date_format = self.config.get('display', {}).get('use_short_date_format', False) - if use_short_date_format: - game_date = local_time.strftime("%-m/%-d") - else: - game_date = self.display_manager.format_date_with_ordinal(local_time) - - - home_record = home_team.get('records', [{}])[0].get('summary', '') if home_team.get('records') else '' - away_record = away_team.get('records', [{}])[0].get('summary', '') if away_team.get('records') else '' - - # Don't show "0-0" records - set to blank instead - if home_record in {"0-0", "0-0-0"}: - home_record = '' - if away_record in {"0-0", "0-0-0"}: - away_record = '' - - details = { - "id": game_event.get("id"), - "game_time": game_time, - "game_date": game_date, - "start_time_utc": start_time_utc, - "status_text": status["type"]["shortDetail"], # e.g., "Final", "7:30 PM", "Q1 12:34" - "is_live": status["type"]["state"] == "in", - "is_final": status["type"]["state"] == "post", - "is_upcoming": (status["type"]["state"] == "pre" or - status["type"]["name"].lower() in ['scheduled', 'pre-game', 'status_scheduled']), - "is_halftime": status["type"]["state"] == "halftime" or status["type"]["name"] == "STATUS_HALFTIME", # Added halftime check - "is_period_break": status["type"]["name"] == "STATUS_END_PERIOD", # Added Period Break check - "home_abbr": home_abbr, - "home_id": home_team["id"], - "home_score": home_team.get("score", "0"), - "home_logo_path": self.logo_dir / Path(f"{LogoDownloader.normalize_abbreviation(home_abbr)}.png"), - "home_logo_url": home_team["team"].get("logo"), - "home_record": home_record, - "away_record": away_record, - "away_abbr": away_abbr, - "away_id": away_team["id"], - "away_score": away_team.get("score", "0"), - "away_logo_path": self.logo_dir / Path(f"{LogoDownloader.normalize_abbreviation(away_abbr)}.png"), - "away_logo_url": away_team["team"].get("logo"), - "is_within_window": True, # Whether game is within display window - - } - return details, home_team, away_team, status, situation - except Exception as e: - # Log the problematic event structure if possible - logging.error(f"Error extracting game details: {e} from event: {game_event.get('id')}", exc_info=True) - return None, None, None, None, None - - @abstractmethod - def _extract_game_details(self, game_event: dict) -> dict | None: - details, _, _, _, _ = self._extract_game_details_common(game_event) - return details - - @abstractmethod - def _fetch_data(self) -> Optional[Dict]: - pass - - def _fetch_todays_games(self) -> Optional[Dict]: - """Fetch only today's games for live updates (not entire season).""" - try: - tz = pytz.timezone("America/New_York") # Use full name (not "EST") for DST support - now = datetime.now(tz) - yesterday = now - timedelta(days=1) - formatted_date = now.strftime("%Y%m%d") - formatted_date_yesterday = yesterday.strftime("%Y%m%d") - # Fetch todays games only - url = f"https://site.api.espn.com/apis/site/v2/sports/{self.sport}/{self.league}/scoreboard" - response = self.session.get(url, params={"dates": f"{formatted_date_yesterday}-{formatted_date}", "limit": 1000}, headers=self.headers, timeout=10) - response.raise_for_status() - data = response.json() - events = data.get('events', []) - - self.logger.info(f"Fetched {len(events)} todays games for {self.sport} - {self.league}") - return {'events': events} - except requests.exceptions.RequestException as e: - self.logger.error(f"API error fetching todays games for {self.sport} - {self.league}: {e}") - return None - - def _get_weeks_data(self) -> Optional[Dict]: - """ - Get partial data for immediate display while background fetch is in progress. - This fetches current/recent games only for quick response. - """ - try: - # Fetch current week and next few days for immediate display - now = datetime.now(pytz.utc) - immediate_events = [] - - start_date = now + timedelta(weeks=-2) - end_date = now + timedelta(weeks=1) - date_str = f"{start_date.strftime('%Y%m%d')}-{end_date.strftime('%Y%m%d')}" - url = f"https://site.api.espn.com/apis/site/v2/sports/{self.sport}/{self.league}/scoreboard" - response = self.session.get(url, params={"dates": date_str, "limit": 1000},headers=self.headers, timeout=10) - response.raise_for_status() - data = response.json() - immediate_events = data.get('events', []) - - if immediate_events: - self.logger.info(f"Fetched {len(immediate_events)} events {date_str}") - return {'events': immediate_events} - - except requests.exceptions.RequestException as e: - self.logger.warning(f"Error fetching this weeks games for {self.sport} - {self.league} - {date_str}: {e}") - return None - - def _custom_scorebug_layout(self, game: dict, draw_overlay: ImageDraw.ImageDraw): - pass class SportsUpcoming(SportsCore): SKIN_MODE = "upcoming" @@ -766,6 +34,71 @@ class SportsUpcoming(SportsCore): self.last_game_switch = 0 self.game_display_duration = 15 # Display each upcoming game for 15 seconds + def _select_games_for_display( + self, processed_games: List[Dict], favorite_teams: List[str] + ) -> List[Dict]: + """ + Single-pass game selection with proper deduplication and counting. + + When a game involves two favorite teams, it counts toward BOTH teams' limits. + This prevents unexpected game counts from the multi-pass algorithm. + + Team identity goes through the ``_favorite_key`` override point rather + than reading ``home_abbr``/``away_abbr`` directly, because abbreviations + are not unique in every league (NRL matches on team ID instead). + """ + sorted_games = sorted( + processed_games, + key=lambda g: g.get("start_time_utc") + or datetime.max.replace(tzinfo=timezone.utc), + ) + + if not favorite_teams: + return sorted_games + + selected_games = [] + selected_ids = set() + team_counts = {team: 0 for team in favorite_teams} + + for game in sorted_games: + game_id = game.get("id") + if game_id in selected_ids: + continue + + home = self._favorite_key(game, "home") + away = self._favorite_key(game, "away") + + home_fav = home in favorite_teams + away_fav = away in favorite_teams + + if not home_fav and not away_fav: + continue + + home_needs = home_fav and team_counts[home] < self.upcoming_games_to_show + away_needs = away_fav and team_counts[away] < self.upcoming_games_to_show + + if home_needs or away_needs: + selected_games.append(game) + selected_ids.add(game_id) + if home_fav: + team_counts[home] += 1 + if away_fav: + team_counts[away] += 1 + + self.logger.debug( + f"Selected game {away}@{home}: team_counts={team_counts}" + ) + + if all(c >= self.upcoming_games_to_show for c in team_counts.values()): + self.logger.debug("All favorite teams satisfied, stopping selection") + break + + self.logger.info( + f"Selected {len(selected_games)} games for {len(favorite_teams)} " + f"favorite teams: {team_counts}" + ) + return selected_games + def update(self): """Update upcoming games data.""" if not self.is_enabled: return @@ -1101,6 +434,96 @@ class SportsRecent(SportsCore): self.update_interval = self.mode_config.get("recent_update_interval", 3600) # Check for recent games every hour self.last_game_switch = 0 self.game_display_duration = 15 # Display each recent game for 15 seconds + # Tracks when each game was first seen with an expired clock, keyed by + # game id. Promoted alongside the zero-clock helpers below; without it + # the first _get_zero_clock_duration() call raises AttributeError. + self._zero_clock_timestamps: Dict[str, float] = {} # Track games at 0:00 + + # -- Zero-clock tracking ------------------------------------------------ + # Byte-identical in all nine plugin copies. Note that afl/nrl/soccer define + # these but never call them — their clocks count up, so 0:00 means kickoff + # rather than expiry (see CLOCK_COUNTS_DOWN on SportsLive). That makes the + # pair a future `CountdownClockMixin` candidate so it stops appearing in the + # MRO of sports that cannot use it — B2 work, not now. + + def _get_zero_clock_duration(self, game_id: str) -> float: + """Track how long a game has been at 0:00 clock.""" + current_time = time.time() + if game_id not in self._zero_clock_timestamps: + self._zero_clock_timestamps[game_id] = current_time + return 0.0 + return current_time - self._zero_clock_timestamps[game_id] + + def _clear_zero_clock_tracking(self, game_id: str) -> None: + """Clear tracking when game clock moves away from 0:00 or game ends.""" + if game_id in self._zero_clock_timestamps: + del self._zero_clock_timestamps[game_id] + + def _select_recent_games_for_display( + self, processed_games: List[Dict], favorite_teams: List[str] + ) -> List[Dict]: + """ + Single-pass game selection for recent games with proper deduplication. + + When a game involves two favorite teams, it counts toward BOTH teams' limits. + Games are sorted by most recent first. + + Team identity goes through the ``_favorite_key`` override point rather + than reading ``home_abbr``/``away_abbr`` directly, because abbreviations + are not unique in every league (NRL matches on team ID instead). + """ + sorted_games = sorted( + processed_games, + key=lambda g: g.get("start_time_utc") + or datetime.min.replace(tzinfo=timezone.utc), + reverse=True, + ) + + if not favorite_teams: + return sorted_games + + selected_games = [] + selected_ids = set() + team_counts = {team: 0 for team in favorite_teams} + + for game in sorted_games: + game_id = game.get("id") + if game_id in selected_ids: + continue + + home = self._favorite_key(game, "home") + away = self._favorite_key(game, "away") + + home_fav = home in favorite_teams + away_fav = away in favorite_teams + + if not home_fav and not away_fav: + continue + + home_needs = home_fav and team_counts[home] < self.recent_games_to_show + away_needs = away_fav and team_counts[away] < self.recent_games_to_show + + if home_needs or away_needs: + selected_games.append(game) + selected_ids.add(game_id) + if home_fav: + team_counts[home] += 1 + if away_fav: + team_counts[away] += 1 + + self.logger.debug( + f"Selected recent game {away}@{home}: team_counts={team_counts}" + ) + + if all(c >= self.recent_games_to_show for c in team_counts.values()): + self.logger.debug("All favorite teams satisfied, stopping selection") + break + + self.logger.info( + f"Selected {len(selected_games)} recent games for {len(favorite_teams)} " + f"favorite teams: {team_counts}" + ) + return selected_games def update(self): """Update recent games data.""" @@ -1391,6 +814,17 @@ class SportsRecent(SportsCore): return False class SportsLive(SportsCore): + # Per-sport constants for the "is this live game actually over?" check. + # These are values, not behavior, so they are class attributes rather than + # override points (see docs/SPORTS_UNIFICATION.md "Override points"). + # + # FINAL_PERIOD: the period at/after which an expired clock can mean "over". + # 4 for four-quarter sports; hockey overrides to 3. + # CLOCK_COUNTS_DOWN: whether "0:00" means the clock expired. False for + # sports whose clock counts up (soccer/afl/nrl), where 0:00 is kickoff — + # running the expiry branch there would evict games that just started. + FINAL_PERIOD = 4 + CLOCK_COUNTS_DOWN = True def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str): super().__init__(config, display_manager, cache_manager, logger, sport_key) @@ -1408,11 +842,117 @@ class SportsLive(SportsCore): self.count_log_interval = 5 # Only log count data every 5 seconds # Initialize test_mode - defaults to False (live mode) self.test_mode = self.mode_config.get("test_mode", False) + # Freshness bookkeeping for _detect_stale_games(). The base class only + # *reads* this map; a subclass's update() stamps entries as it ingests a + # feed: {game_id: {"clock": ts, "score": ts, "last_seen": ts}}. + # Until a subclass writes "last_seen", the staleness branch of + # _detect_stale_games is inert and only the game-over check applies. + self.game_update_timestamps: Dict[str, Dict[str, float]] = {} + self.stale_game_timeout = self.mode_config.get("stale_game_timeout", 300) # 5 minutes default @abstractmethod def _test_mode_update(self) -> None: return + def _is_game_really_over(self, game: Dict) -> bool: + """Check if a game appears to be over even if API says it's live. + + Two independent signals: + 1. ``period_text`` says "final" — universal across every sport. + 2. The clock has expired at/after :attr:`FINAL_PERIOD` — only meaningful + where :attr:`CLOCK_COUNTS_DOWN` is true. + + Fails *safe*: anything ambiguous returns False and the game keeps being + displayed. The only caller, :meth:`_detect_stale_games`, removes games + on a True, so a false positive silently drops a live game. + """ + game_str = f"{game.get('away_abbr')}@{game.get('home_abbr')}" + + # `period_text` may be present-but-None; `or ""` keeps that from raising + # AttributeError — the caller has no try/except around this call. + period_text = (game.get("period_text") or "").lower() + if "final" in period_text: + self.logger.debug( + f"_is_game_really_over({game_str}): " + f"returning True - 'final' in period_text='{period_text}'" + ) + return True + + if not self.CLOCK_COUNTS_DOWN: + # Count-up clock: 0:00 means the match has not started. + self.logger.debug( + f"_is_game_really_over({game_str}): returning False " + f"(count-up clock, period_text='{period_text}')" + ) + return False + + raw_clock = game.get("clock") + # `or 0` rather than a get() default: feeds routinely send an explicit + # null period, and `None >= FINAL_PERIOD` raises TypeError — which would + # take down the whole live-update pass, since the only caller + # (_detect_stale_games) has no try/except around it. + period = game.get("period") or 0 + + # Only check clock-based finish if we have a valid clock string. A + # missing or non-string clock is NOT coerced to "0:00": sports without a + # game clock (e.g. baseball, where `period` is the inning) would + # otherwise be declared over from the FINAL_PERIOD-th period onward. + if isinstance(raw_clock, str) and raw_clock.strip() and period >= self.FINAL_PERIOD: + clock = raw_clock + # Compare numerically rather than against a literal set: feeds spell + # an expired clock "0:00", ":00" and "00:00" depending on sport, and + # a membership test silently misses every spelling not listed. + clock_normalized = clock.replace(":", "").strip() + if clock_normalized.isdigit() and int(clock_normalized) == 0: + self.logger.debug( + f"_is_game_really_over({game_str}): " + f"returning True - clock at 0:00 (clock='{clock}', period={period})" + ) + return True + + self.logger.debug( + f"_is_game_really_over({game_str}): returning False" + ) + return False + + def _detect_stale_games(self, games: List[Dict]) -> None: + """Remove games that appear stale or haven't updated. + + Mutates ``games`` **in place** and returns None. Removal is by value + (``list.remove`` uses ``dict.__eq__``), so two structurally-equal game + dicts in the same list would drop the first occurrence. + """ + current_time = time.time() + + for game in games[:]: # Copy list to iterate safely + game_id = game.get("id") + if not game_id: + continue + + # Check if game data is stale + timestamps = self.game_update_timestamps.get(game_id, {}) + last_seen = timestamps.get("last_seen", 0) + + if last_seen > 0 and current_time - last_seen > self.stale_game_timeout: + self.logger.warning( + f"Removing stale game {game.get('away_abbr')}@{game.get('home_abbr')} " + f"(last seen {int(current_time - last_seen)}s ago)" + ) + games.remove(game) + if game_id in self.game_update_timestamps: + del self.game_update_timestamps[game_id] + continue + + # Also check if game appears to be over + if self._is_game_really_over(game): + self.logger.debug( + f"Removing game that appears over: {game.get('away_abbr')}@{game.get('home_abbr')} " + f"(clock={game.get('clock')}, period={game.get('period')}, period_text={game.get('period_text')})" + ) + games.remove(game) + if game_id in self.game_update_timestamps: + del self.game_update_timestamps[game_id] + def update(self): """Update live game data and handle game switching.""" if not self.is_enabled: diff --git a/src/common/sports_scroll.py b/src/common/sports_scroll.py new file mode 100644 index 00000000..f9a32e37 --- /dev/null +++ b/src/common/sports_scroll.py @@ -0,0 +1,485 @@ +"""Shared scroll-display scaffolding for the sports scoreboards. + +Ten plugins ship a `scroll_display.py`. A method-level comparison of the eight +that share a shape (f1 and ufc are genuine forks) found a sharp split, and this +module is drawn along it rather than around all of it: + +* The **orchestration layer is converged** — ``get_all_vegas_content_items`` is + byte-identical in all eight, and ``clear_all``, ``get_scroll_info``, + ``get_dynamic_duration``, ``is_complete`` and ``display_frame`` are 96-100% + similar. That is what lives here. +* The **content layer has genuinely diverged** — ``prepare_scroll_content`` has + eight distinct bodies across eight plugins (145 lines, 53% similarity at + worst) and ``_load_separator_icons`` seven (6% at worst). Those build each + sport's game cards and icon strip; they are *not* drift to be merged but + per-sport rendering. They stay override points here, permanently. + +Promoting the content layer would be exactly the mistake +``docs/SPORTS_UNIFICATION.md`` warns against — merging on the intuition that +same-named methods are the same method. Same name, different job. + +The one behavior this module adds over the plugin copies is native support for +``global_config['target_fps']``: the bundled copies hardcode ~100 FPS via +``scroll_delay=0.01`` and never consult the global smooth-scrolling target. A +plugin inheriting from here gets it for free. + +Usage:: + + class HockeyScrollDisplay(SportsScrollDisplay): + SCROLL_LEAGUE_KEYS = ("nhl", "ncaa_mens", "ncaam_hockey") + + def prepare_scroll_content(self, games, game_type, leagues, rankings=None): + ... # build this sport's cards + + class HockeyScrollDisplayManager(SportsScrollDisplayManager): + display_class = HockeyScrollDisplay +""" + +from __future__ import annotations + +import logging +import time +from typing import Any, Dict, List, Optional + +from PIL import Image + +from src.common.scroll_helper import ScrollHelper + +logger = logging.getLogger(__name__) + +#: Defaults every copy agreed on. A subclass overrides +#: :meth:`SportsScrollDisplay.scroll_settings_defaults` to change them — +#: the soccer lineage uses a 24px gap and min/max duration keys instead. +DEFAULT_SCROLL_SETTINGS: Dict[str, Any] = { + "scroll_speed": 50.0, + "scroll_delay": 0.01, + "gap_between_games": 48, + "show_league_separators": True, + "dynamic_duration": True, +} + +#: Bounds on the px/second -> px/frame conversion, applied before the helper +#: sees the value. FPS is *not* clamped here — ScrollHelper.set_target_fps +#: already does that, and a second copy of the range would drift from it. +MIN_PIXELS_PER_FRAME = 0.1 +MAX_PIXELS_PER_FRAME = 5.0 + +#: Pacing to assume when scroll_delay is 0, i.e. the plugin has not set one. +ASSUMED_FPS_WHEN_UNPACED = 100.0 + + +class SportsScrollDisplay: + """One scrolling strip of game cards. + + Subclasses supply the content (:meth:`prepare_scroll_content`) and, + optionally, the per-sport league ladder and separator icons. Everything + else — helper configuration, frame pumping, completion, state — is here. + """ + + #: Config keys to walk when looking for per-league ``scroll_settings``, + #: most-preferred first. A sport's own league names, which is the *only* + #: reason the eight copies of ``_get_scroll_settings`` differ. Empty means + #: the plugin has no per-league scroll settings. + SCROLL_LEAGUE_KEYS: tuple = () + + #: Config block holding scroll settings when the plugin keeps them in one + #: place rather than per league (the afl/nrl/soccer shape). + SCROLL_CONFIG_KEY: Optional[str] = None + + def __init__( + self, + display_manager, + config: Dict[str, Any], + custom_logger: Optional[logging.Logger] = None, + global_config: Optional[Dict[str, Any]] = None, + ): + """ + :param display_manager: the core display manager + :param config: the plugin's configuration + :param custom_logger: the plugin's logger, so scroll lines are attributed + :param global_config: the LEDMatrix global config — the source of + ``target_fps``. Optional so an older caller that does not pass it + keeps working at the config-derived pacing. + """ + self.display_manager = display_manager + self.config = config + self.logger = custom_logger or logger + self.global_config = global_config or {} + + if getattr(display_manager, "matrix", None) is not None: + self.display_width = display_manager.matrix.width + self.display_height = display_manager.matrix.height + else: + self.display_width = getattr(display_manager, "width", 128) + self.display_height = getattr(display_manager, "height", 32) + + self.scroll_helper = ScrollHelper( + self.display_width, self.display_height, self.logger + ) + self._configure_scroll_helper() + + self._logo_cache: Dict[str, Image.Image] = {} + self._separator_icons: Dict[str, Image.Image] = {} + self._load_separator_icons() + + self._current_games: List[Dict] = [] + self._current_game_type: str = "" + self._current_leagues: List[str] = [] + self._vegas_content_items: List[Image.Image] = [] + self._is_scrolling = False + self._scroll_start_time: Optional[float] = None + self._last_log_time: float = 0 + self._log_interval: float = 5.0 + self._frame_count: int = 0 + self._fps_sample_start: float = time.time() + + # ------------------------------------------------------------------ + # Override points + # ------------------------------------------------------------------ + + def prepare_scroll_content( + self, + games: List[Dict], + game_type: str, + leagues: List[str], + rankings_cache: Optional[Dict[str, int]] = None, + ) -> bool: + """Render ``games`` into one wide image and hand it to the scroll helper. + + **Per-sport by nature, not by drift** — the eight plugin copies have + eight different bodies because each draws its own card. Implementations + build the strip, hand it over with + ``self.scroll_helper.set_scrolling_image(...)`` (or + ``create_scrolling_image(...)`` from a list of cards), and record + ``self._current_games`` / ``_current_game_type`` / ``_current_leagues``. + + :returns: True when there is content to scroll. + """ + raise NotImplementedError( + f"{type(self).__name__} must implement prepare_scroll_content(); " + "it builds this sport's game cards and is not shared code." + ) + + def _load_separator_icons(self) -> None: + """Populate ``self._separator_icons``. Per-sport; no-op by default.""" + + def scroll_settings_defaults(self) -> Dict[str, Any]: + """The baseline scroll settings before any config is applied.""" + defaults = dict(DEFAULT_SCROLL_SETTINGS) + defaults["game_card_width"] = self.display_width + return defaults + + # ------------------------------------------------------------------ + # Settings + # ------------------------------------------------------------------ + + def _get_scroll_settings(self, league: Optional[str] = None) -> Dict[str, Any]: + """Resolve scroll settings: defaults, then the most specific override. + + Precedence: the named ``league``, then each entry of + :attr:`SCROLL_LEAGUE_KEYS` in order, then :attr:`SCROLL_CONFIG_KEY`. + The eight plugin copies implement exactly this and differ only in which + league names they walk — which is why the ladder is data here rather + than a body per sport. + """ + settings = self.scroll_settings_defaults() + + candidates: List[str] = [] + if league: + candidates.append(league) + candidates.extend(self.SCROLL_LEAGUE_KEYS) + for key in candidates: + override = (self.config.get(key) or {}).get("scroll_settings") + if override: + return {**settings, **override} + + if self.SCROLL_CONFIG_KEY: + override = self.config.get(self.SCROLL_CONFIG_KEY) or {} + if override: + return {**settings, **override} + return settings + + def _resolve_target_fps(self) -> Optional[float]: + """The global smooth-scrolling FPS target, or None to keep config pacing. + + Coerced before use: a malformed value in the global config must degrade + to the existing ``scroll_delay`` pacing, never raise on a display path. + """ + raw = self.global_config.get("target_fps") or self.global_config.get( + "scroll_target_fps" + ) + try: + return float(raw) if raw is not None else None + except (TypeError, ValueError): + self.logger.debug("Ignoring unusable target_fps: %r", raw) + return None + + def _coerce_float(self, value: Any, default: float) -> float: + """A usable float from config, or ``default``. + + ``dict.get(key, default)`` only helps when the key is *absent*; a key + present with ``null`` or a string returns that value verbatim and blows + up in the arithmetic below — inside ``__init__``, so the whole display + fails to construct. + """ + if value is None: + return default + try: + return float(value) + except (TypeError, ValueError): + self.logger.warning( + "Ignoring unusable scroll setting %r; using %s", value, default + ) + return default + + def _configure_scroll_helper(self) -> None: + """Apply config to the scroll helper. Safe to call again after a change.""" + settings = self._get_scroll_settings() + + scroll_speed = self._coerce_float(settings.get("scroll_speed"), 50.0) + scroll_delay = self._coerce_float(settings.get("scroll_delay"), 0.01) + dynamic_duration = bool(settings.get("dynamic_duration", True)) + + self.scroll_helper.set_scroll_delay(scroll_delay) + self.scroll_helper.set_dynamic_duration_settings( + enabled=dynamic_duration, + min_duration=settings.get("min_duration", 30), + max_duration=settings.get("max_duration", 600), + buffer=0.2, # ensure the strip clears the panel completely + ) + # Frame-based scrolling: motion advances per rendered frame rather than + # per wall-clock second, which is what makes the pacing stable. + self.scroll_helper.set_frame_based_scrolling(True) + + # Config states speed in px/second; frame-based mode wants px/frame. + if scroll_delay > 0: + pixels_per_frame = scroll_speed * scroll_delay + else: + pixels_per_frame = scroll_speed / ASSUMED_FPS_WHEN_UNPACED + pixels_per_frame = max( + MIN_PIXELS_PER_FRAME, min(MAX_PIXELS_PER_FRAME, pixels_per_frame) + ) + self.scroll_helper.set_scroll_speed(pixels_per_frame) + + effective_pps = ( + pixels_per_frame / scroll_delay + if scroll_delay > 0 + else pixels_per_frame * ASSUMED_FPS_WHEN_UNPACED + ) + self.logger.info( + f"ScrollHelper configured: {pixels_per_frame:.2f} px/frame, " + f"delay={scroll_delay}s (effective {effective_pps:.1f} px/s from " + f"{scroll_speed} px/s config), dynamic_duration={dynamic_duration}" + ) + + # The reason this module exists upstream: the bundled copies hardcode + # ~100 FPS via scroll_delay and never consult the global target. + # No hasattr guard here, unlike the plugin copies: they probe because + # they may run against an older core, whereas this module ships in the + # same release as the ScrollHelper it calls. The helper clamps. + target_fps = self._resolve_target_fps() + if target_fps: + self.scroll_helper.set_target_fps(target_fps) + self.logger.info(f"Target FPS set to {target_fps}") + + # ------------------------------------------------------------------ + # Frame pumping + # ------------------------------------------------------------------ + + def display_scroll_frame(self) -> bool: + """Advance and render one frame. + + :returns: True if a frame was drawn; False when there is no content or + the frame could not be rendered. + """ + if not self.scroll_helper.cached_image: + return False + + try: + # Inside the try, not before it: advancing the position and cropping + # the visible slice are as capable of raising as the display push, + # and the promise below is that no frame failure reaches the + # plugin's loop. + self.scroll_helper.update_scroll_position() + visible = self.scroll_helper.get_visible_portion() + if not visible: + return False + + self.display_manager.image = visible + self.display_manager.update_display() + self._frame_count += 1 + self.scroll_helper.log_frame_rate() + self._log_scroll_progress() + except Exception: + # A display failure must not propagate into the plugin's loop. + self.logger.exception("Error displaying scroll frame") + return False + return True + + def _log_scroll_progress(self) -> None: + """Emit a throttled progress line.""" + now = time.time() + if now - self._last_log_time < self._log_interval: + return + self._last_log_time = now + elapsed = now - self._fps_sample_start + fps = self._frame_count / elapsed if elapsed > 0 else 0.0 + self.logger.debug( + f"Scrolling {len(self._current_games)} {self._current_game_type} " + f"game(s) at {fps:.1f} FPS" + ) + + def is_scroll_complete(self) -> bool: + """True when the strip has scrolled fully past the panel.""" + return self.scroll_helper.is_scroll_complete() + + def reset_scroll(self) -> None: + """Return the strip to its starting position, keeping the content.""" + self.scroll_helper.reset_scroll() + self._frame_count = 0 + self._fps_sample_start = time.time() + self.logger.debug("Scroll position reset") + + def clear(self) -> None: + """Drop cached content and reset tracking state.""" + self.scroll_helper.clear_cache() + self._current_games = [] + self._current_game_type = "" + self._current_leagues = [] + self._vegas_content_items = [] + self._is_scrolling = False + self._scroll_start_time = None + self.logger.debug("Scroll display cleared") + + # ------------------------------------------------------------------ + # Introspection + # ------------------------------------------------------------------ + + def get_dynamic_duration(self) -> int: + """How long this content needs to scroll fully, in seconds.""" + return self.scroll_helper.get_dynamic_duration() + + def has_cached_content(self) -> bool: + """Whether content is prepared and ready to scroll.""" + return bool(self.scroll_helper.cached_image) + + def get_current_game_count(self) -> int: + return len(self._current_games) + + def get_current_leagues(self) -> List[str]: + return list(self._current_leagues) + + def get_scroll_info(self) -> Dict[str, Any]: + """Helper state plus this display's tracking state, for logging/debug.""" + info = self.scroll_helper.get_scroll_info() + info.update( + { + "game_count": len(self._current_games), + "game_type": self._current_game_type, + "leagues": self._current_leagues, + "is_scrolling": self._is_scrolling, + } + ) + return info + + +class SportsScrollDisplayManager: + """One :class:`SportsScrollDisplay` per game type ('live'/'recent'/'upcoming'). + + Subclasses set :attr:`display_class`; everything else was near-identical + across the eight plugin copies. + """ + + #: The SportsScrollDisplay subclass to instantiate per game type. + display_class = SportsScrollDisplay + + def __init__( + self, + display_manager, + config: Dict[str, Any], + custom_logger: Optional[logging.Logger] = None, + global_config: Optional[Dict[str, Any]] = None, + ): + self.display_manager = display_manager + self.config = config + self.logger = custom_logger or logger + self.global_config = global_config or {} + self._scroll_displays: Dict[str, SportsScrollDisplay] = {} + # "" rather than None, matching SportsScrollDisplay's own empty value — + # both are falsy, so `game_type or self._current_game_type` behaved + # either way, but two spellings of "nothing active" across two classes + # is a trap for anyone comparing state between them. + self._current_game_type: str = "" + + def get_scroll_display(self, game_type: str) -> SportsScrollDisplay: + """The display for ``game_type``, created on first use.""" + if game_type not in self._scroll_displays: + self._scroll_displays[game_type] = self.display_class( + self.display_manager, + self.config, + self.logger, + global_config=self.global_config, + ) + return self._scroll_displays[game_type] + + def prepare_and_display( + self, + games: List[Dict], + game_type: str, + leagues: List[str], + rankings_cache: Optional[Dict[str, int]] = None, + ) -> bool: + """Build content for ``game_type`` and make it the active strip.""" + scroll_display = self.get_scroll_display(game_type) + try: + success = scroll_display.prepare_scroll_content( + games, game_type, leagues, rankings_cache + ) + except Exception: + # prepare_scroll_content is subclass-implemented and builds cards + # straight from feed data, which is exactly where this PR's other + # crashes came from. One sport's bad payload must not take down the + # shared orchestration for the others. + self.logger.exception( + "Error preparing scroll content for game_type=%s", game_type + ) + return False + if success: + self._current_game_type = game_type + return success + + def display_frame(self, game_type: Optional[str] = None) -> bool: + """Advance the active strip (or a named one) by one frame.""" + game_type = game_type or self._current_game_type + if not game_type: + return False + scroll_display = self._scroll_displays.get(game_type) + if scroll_display is None: + return False + return scroll_display.display_scroll_frame() + + def is_complete(self, game_type: Optional[str] = None) -> bool: + """True when the strip has finished — including when there isn't one, + so a caller waiting on completion is never wedged.""" + game_type = game_type or self._current_game_type + if not game_type: + return True + scroll_display = self._scroll_displays.get(game_type) + if scroll_display is None: + return True + return scroll_display.is_scroll_complete() + + def clear_all(self) -> None: + """Clear every display and forget which one was active.""" + for scroll_display in self._scroll_displays.values(): + scroll_display.clear() + self._current_game_type = "" + + def get_all_vegas_content_items(self) -> List[Image.Image]: + """Every display's Vegas items, for splicing into the marquee.""" + items: List[Image.Image] = [] + for scroll_display in self._scroll_displays.values(): + vegas_items = getattr(scroll_display, "_vegas_content_items", None) + if vegas_items: + items.extend(vegas_items) + return items diff --git a/test/test_sports_base_characterization.py b/test/test_sports_base_characterization.py index 55411f8e..d81919bb 100644 --- a/test/test_sports_base_characterization.py +++ b/test/test_sports_base_characterization.py @@ -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 diff --git a/test/test_sports_capabilities.py b/test/test_sports_capabilities.py new file mode 100644 index 00000000..f43a2b61 --- /dev/null +++ b/test/test_sports_capabilities.py @@ -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")]) diff --git a/test/test_sports_core_promotions.py b/test/test_sports_core_promotions.py new file mode 100644 index 00000000..eb905002 --- /dev/null +++ b/test/test_sports_core_promotions.py @@ -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 `/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) diff --git a/test/test_sports_modes_promotions.py b/test/test_sports_modes_promotions.py new file mode 100644 index 00000000..72f6a211 --- /dev/null +++ b/test/test_sports_modes_promotions.py @@ -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__ diff --git a/test/test_sports_scroll.py b/test/test_sports_scroll.py new file mode 100644 index 00000000..9f6f72e8 --- /dev/null +++ b/test/test_sports_scroll.py @@ -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