From 3ad0438e751d4b5974cbdf7ee58100225ab3da2f Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Thu, 1 Oct 2026 10:10:22 -0400 Subject: [PATCH] feat(common): sports consolidation stage 4 -- the identical sweep (plugin host, live scroll, display rules, font path) (#705) Moves the code every scoreboard plugin carries identically into core: src.common.sports_plugin_host, sports_live_scroll, sports_display_rules and sports_font_path, with unit tests and a parity test against the ledmatrix-plugins copies (LEDMATRIX_PLUGINS). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 32 +++ docs/SPORTS_UNIFICATION.md | 41 +++- mypy-clean.txt | 4 + scripts/sports_drift_report.py | 8 + src/common/README.md | 40 ++++ src/common/sports_display_rules.py | 164 ++++++++++++++ src/common/sports_font_path.py | 41 ++++ src/common/sports_live_scroll.py | 277 +++++++++++++++++++++++ src/common/sports_plugin_host.py | 270 ++++++++++++++++++++++ test/test_sports_display_rules.py | 203 +++++++++++++++++ test/test_sports_font_path.py | 120 ++++++++++ test/test_sports_live_scroll.py | 349 +++++++++++++++++++++++++++++ test/test_sports_plugin_host.py | 280 +++++++++++++++++++++++ test/test_sports_stage4_parity.py | 176 +++++++++++++++ 14 files changed, 2004 insertions(+), 1 deletion(-) create mode 100644 src/common/sports_display_rules.py create mode 100644 src/common/sports_font_path.py create mode 100644 src/common/sports_live_scroll.py create mode 100644 src/common/sports_plugin_host.py create mode 100644 test/test_sports_display_rules.py create mode 100644 test/test_sports_font_path.py create mode 100644 test/test_sports_live_scroll.py create mode 100644 test/test_sports_plugin_host.py create mode 100644 test/test_sports_stage4_parity.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cbfac3a..1181cc50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -536,6 +536,38 @@ read any of them: in AP mode with no internet. They get a local `static/v3/plugin-frame.css` with the v2 palette they were written against. +### New modules (sports consolidation stage 4) + +A plugin may import these via `src.*` once it floors on the release that +ships them (the first release cut from this section). All four hold code the +scoreboard plugins carry as identical copies (checked at ledmatrix-plugins +`56c4f15`), moved without behaviour change under the plugins' own names; +each docstring lists what the host class must provide. Nothing in core uses +them yet. The plugins delete their copies when they floor on that release. + +- `src/common/sports_plugin_host.py` — `SportsPluginHostMixin`, ten helpers + of the scoreboard plugin class (`manager.py`) identical in all nine: + `_dispatch_switch_refresh` (with `_SWITCH_REFRESH_MIN_GAP_SECONDS`), + `get_vegas_priority_weight`, `_favorite_team_is_live`, + `_favorite_scan_targets`, `_favorite_scan_games`, `_game_involves`, + `get_vegas_content_type`, `_dynamic_feature_enabled`, + `_get_total_games_for_manager` and `_build_manager_key`. List it before + `BasePlugin`: two of these override its defaults. +- `src/common/sports_live_scroll.py` — `SportsLiveScrollMixin`, the eight + `manager.py` methods that rebuild a live scroll strip mid-cycle without + moving the marquee (`_live_scroll_needs_rebuild`, + `_preserving_scroll_position`, ...), with `LIVE_SCROLL_REBUILD_MIN_SECONDS` + and `LIVE_SCROLL_REBUILD_DUTY_DIVISOR`; identical in the eight scoreboards + with a strip (not ufc). `LIVE_VOLATILE_FIELDS` stays in each plugin. +- `src/common/sports_display_rules.py` — `SportsCardOptionsMixin` + (`_card_option`, `_recent_date_text`; the eight team scoreboards; list it + before `SportsCoreSharedMixin`) and `SportsGameRulesMixin` + (`_filtered_or_all`, `_effective_live_duration`; all nine). +- `src/common/sports_font_path.py` — `resolve_font_path`, what every + scoreboard's `_resolve_font_path` (nine `sports.py`, eight + `game_renderer.py`) returns on a core that ships it: the path as given when + it exists, else `font_layout.resolve_asset_path`. + ## 3.7.0 Sports consolidation stage 3 (#672). No behaviour change: nothing in core diff --git a/docs/SPORTS_UNIFICATION.md b/docs/SPORTS_UNIFICATION.md index 03dbd53a..fe72fe06 100644 --- a/docs/SPORTS_UNIFICATION.md +++ b/docs/SPORTS_UNIFICATION.md @@ -87,6 +87,10 @@ more. Shared sports code lives in `src/common`: | `sports_celebration.py` | 3.7.0 | `SportsCelebrationMixin` — draws the score/win takeover; colour helpers | | `sports_fetch.py` | 3.7.0 | `SportsFetchMixin` — season fetch, live lookback and live-odds decisions | | `sports_card_wrappers.py` | 3.7.0 | `SportsCardWrappersMixin` — the game renderer's `sports_card` delegations | +| `sports_plugin_host.py` | next release | `SportsPluginHostMixin` — the plugin class's (`manager.py`) identical helpers: Vegas weight, off-thread switch refresh | +| `sports_live_scroll.py` | next release | `SportsLiveScrollMixin` — rebuild a live scroll strip mid-cycle, keeping the marquee's place | +| `sports_display_rules.py` | next release | `SportsCardOptionsMixin`, `SportsGameRulesMixin` — scorebug date options, the no-favourites filter, non-favourite live dwell | +| `sports_font_path.py` | next release | `resolve_font_path` — what the plugins' `_resolve_font_path` copies return | Each is described in [src/common/README.md](../src/common/README.md). @@ -259,6 +263,41 @@ gave pixel-identical output for all 399 frames (192 harness screens across the nine plugins at the eight default sizes, 72 scroll/Vegas cards, 135 celebration frames), with a parent-vs-parent rerun as the determinism control. +### Stage 4: the identical sweep (core done; adoption waits for a release) + +Re-measured on ledmatrix-plugins `56c4f15` (2026-09-30) the report still +lists 58 families identical in every copy. Stage 4 moves the ones that are +identical across the nine, or across eight with the ninth lacking the +method, into four new modules: `sports_plugin_host` (ten `manager.py` +helpers, all nine), `sports_live_scroll` (eight `manager.py` methods, every +plugin with a live strip, so not ufc), `sports_display_rules` (four +`sports.py` methods, in two mixins because their carriers differ) and +`sports_font_path`. The parity test (`test/test_sports_stage4_parity.py`) +compares each with every plugin copy using this report's own normalisation, +plus decorators and constant values, which the normalisation drops. + +`_resolve_font_path` was meant to be replaced by +`font_layout.resolve_asset_path`, but that never looks in the cwd, and the +plugins' copy does first, so the swap would change which font a process +started from another checkout loads. `resolve_font_path` is the copy's +behaviour on a core that ships it, checked path for path against all 17 +copies (`test/test_sports_font_path.py`). + +Left in the plugins, though identical: + +- `_get_timezone`, `_extract_game_details`, `_fetch_data` (nine): a + per-plugin import and the abstract contract, as in stage 3. +- `_schema_font_size`, `_resolve_font_size` (eight renderers): they read the + plugin's own `_SCHEMA_PATH`, as in stage 3. +- The 29 families carried by seven plugins or fewer: the afl/nrl/soccer + lineage's own helpers (`_swrr_advance`, `_refresh_switch_mode_managers`, + `_initialize_logo_dir`, ...), the multi-league helpers + (`_resolve_managers_for_mode`, `_extract_mode_type`, ...), and eleven + two-plugin helpers. Each is one lineage's code; most go when + family 13 or 14 reconciles the code around them. `_odds_color` (seven + renderers) is already core's, in `SportsHelpersMixin`; a renderer that + wants it can inherit that. + ### Why the method changes Byte-identical promotion has nearly run dry. Measured on ledmatrix-plugins @@ -342,7 +381,7 @@ release. | # | Family | Methods (variants) | Why here | |---|---|---|---| -| 4 | Identical sweep | `manager.py`: `_dispatch_switch_refresh`, `_favorite_team_is_live`, `get_vegas_priority_weight`, `_game_involves`, `_favorite_scan_targets`, `_favorite_scan_games`, `_get_total_games_for_manager` (all nine, 1); the live-scroll helpers `_preserving_scroll_position`, `_refresh_live_scroll_managers`, `_live_scroll_managers`, `_note_live_scroll_built`, `_live_scroll_needs_rebuild`, `_live_scroll_fields` (eight, 1). `sports.py`: `_card_option`, `_filtered_or_all`, `_effective_live_duration`, `_recent_date_text` (eight, 1). 58 identical families in all | Nothing to decide; brings `manager.py` into core as a `SportsPluginHostMixin`. `_resolve_font_path` (identical in nine `sports.py` and eight renderers) is replaced by core's `font_layout.resolve_asset_path` rather than promoted | +| 4 | Identical sweep | `manager.py`: `_dispatch_switch_refresh`, `_favorite_team_is_live`, `get_vegas_priority_weight`, `_game_involves`, `_favorite_scan_targets`, `_favorite_scan_games`, `_get_total_games_for_manager` (all nine, 1); the live-scroll helpers `_preserving_scroll_position`, `_refresh_live_scroll_managers`, `_live_scroll_managers`, `_note_live_scroll_built`, `_live_scroll_needs_rebuild`, `_live_scroll_fields` (eight, 1). `sports.py`: `_card_option`, `_filtered_or_all`, `_effective_live_duration`, `_recent_date_text` (eight, 1). 58 identical families in all | Nothing to decide; brings `manager.py` into core as a `SportsPluginHostMixin`. `_resolve_font_path` (identical in nine `sports.py` and eight renderers) becomes `sports_font_path.resolve_font_path`, not `font_layout.resolve_asset_path`, which skips the cwd. Core side done; see [Stage 4](#stage-4-the-identical-sweep-core-done-adoption-waits-for-a-release) | | 5 | Game-over check | `SportsLive._is_game_really_over` (5) | Pure logic, no pixels; its seams (`FINAL_PERIOD`, `CLOCK_COUNTS_DOWN`) were designed in B1. The pilot for the procedure | | 6 | Favourite matching | `_is_favorite_game` (7 across three classes), `_select_games_for_display` (2: nrl), `_select_recent_games_for_display` (3) | Everything that asks "is this a favourite" goes through the 3.5.0 `_favorite_key` seam | | 7 | Other-games rotation | `_by_importance`, `_other_games_window`, `_advance_other_games_if_due` (2 each: football), `_rotate_other_games_on_display` (2: ufc) | One outlier each; football carries two fixes the other eight lack | diff --git a/mypy-clean.txt b/mypy-clean.txt index 1006bdb3..be7d4937 100644 --- a/mypy-clean.txt +++ b/mypy-clean.txt @@ -34,7 +34,11 @@ src/common/snapshot_policy.py src/common/sports_card.py src/common/sports_card_wrappers.py src/common/sports_celebration.py +src/common/sports_display_rules.py src/common/sports_fetch.py +src/common/sports_font_path.py +src/common/sports_live_scroll.py +src/common/sports_plugin_host.py src/common/sports_scroll.py src/common/sports_timezone.py src/common/sports_vegas.py diff --git a/scripts/sports_drift_report.py b/scripts/sports_drift_report.py index 4f239b07..de4377ce 100644 --- a/scripts/sports_drift_report.py +++ b/scripts/sports_drift_report.py @@ -140,6 +140,14 @@ class _Canonical(ast.NodeTransformer): node.annotation = None return node + def visit_AnnAssign(self, node): + # ``x: T = v`` is ``x = v``; a bare ``x: T`` does nothing at runtime. + self.generic_visit(node) + if node.value is None: + return None + return ast.copy_location( + ast.Assign(targets=[node.target], value=node.value), node) + class _Folded(_Canonical): """Canonical, plus sport names folded out of identifiers and strings.""" diff --git a/src/common/README.md b/src/common/README.md index 30341e39..5b11188c 100644 --- a/src/common/README.md +++ b/src/common/README.md @@ -40,9 +40,13 @@ Rules for the package: | [`sports_card`](#sports_card) | Scoreboard card settings, colours, fonts, dates | Yes (scoreboards) | 3.3.0 | | [`sports_card_wrappers`](#sports_card_wrappers) | The game renderer's `sports_card` delegations | Yes (scoreboards) | 3.7.0 | | [`sports_celebration`](#sports_celebration) | Draw a scoreboard's score/win celebration | Yes (scoreboards) | 3.7.0 | +| [`sports_display_rules`](#sports_display_rules) | Which games a scoreboard shows, for how long, and its scorebug date line | Yes (scoreboards) | Unreleased | | [`sports_fetch`](#sports_fetch) | Scoreboard season fetch, lookback and live-odds decisions | Yes (scoreboards) | 3.7.0 | +| [`sports_font_path`](#sports_font_path) | Find a scoreboard's bundled font whatever the cwd | Yes (scoreboards) | Unreleased | | [`sports_game_renderer`](#sports_game_renderer) | Scoreboard scroll/Vegas card geometry | Yes (scoreboards) | 3.3.0 | | [`sports_helpers`](#sports_helpers) | Small helpers every scoreboard `sports.py` copies | Yes (scoreboards) | 3.5.0 | +| [`sports_live_scroll`](#sports_live_scroll) | Rebuild a live scroll strip mid-cycle without moving it | Yes (scoreboards) | Unreleased | +| [`sports_plugin_host`](#sports_plugin_host) | Helpers of a scoreboard's plugin class (`manager.py`) | Yes (scoreboards) | Unreleased | | [`sports_scroll`](#sports_scroll) | Scoreboard scroll-display orchestration | Yes (scoreboards) | 3.2.0 | | [`sports_shared`](#sports_shared) | Sport-independent `sports.py` methods | Yes (scoreboards) | 3.3.0 | | [`sports_vegas`](#sports_vegas) | Live Vegas cards: keys, card cache, sticky odds, finished games | Yes (scoreboards) | 3.8.0 | @@ -239,6 +243,16 @@ The colour helpers are free functions (`logo_palette()`, `lift_color()`, `mix_color()`, ...). Deciding *when* to celebrate stays in the plugin, which builds the celebration dict the docstring describes. +### sports_display_rules + +[`sports_display_rules.py`](sports_display_rules.py). Two `SportsCore` +mixins: `SportsCardOptionsMixin` (`_card_option()`, which never lets the +upcoming scorebug lose both its date and time, and `_recent_date_text()`; +list it before `SportsCoreSharedMixin`) and `SportsGameRulesMixin` +(`_filtered_or_all()`, the no-favourites quality filter that fails open, and +`_effective_live_duration()`, the shorter dwell for a non-favourite live +game). + ### sports_fetch [`sports_fetch.py`](sports_fetch.py). `SportsFetchMixin`: the `SportsCore` @@ -247,6 +261,13 @@ methods that decide which requests a scoreboard makes -- `_background_fetches_espn_ranges()`, `_needs_previous_day()` (the live lookback) and `_wants_live_odds()` (odds only for games near the screen). +### sports_font_path + +[`sports_font_path.py`](sports_font_path.py). `resolve_font_path(path)`: the +path as given when it exists (relative to the cwd), else +`font_layout.resolve_asset_path(path)`. What the scoreboards' +`_resolve_font_path` copies return on a core that ships it. + ### sports_game_renderer [`sports_game_renderer.py`](sports_game_renderer.py). @@ -264,6 +285,25 @@ what differs. `_odds_color` and `_upcoming_date_and_time_text` under their existing names. Nothing in core uses it. +### sports_live_scroll + +[`sports_live_scroll.py`](sports_live_scroll.py). `SportsLiveScrollMixin`: +keeps a live scroll strip current. It fingerprints the live games (the clock +and the display pipeline's own keys excluded, via the host's +`LIVE_VOLATILE_FIELDS`), rebuilds when they change, rate-limited by what a +rebuild costs, and `_preserving_scroll_position()` keeps the marquee where +it was. Pairs with `SportsPluginHostMixin`, whose `_dispatch_switch_refresh()` +it uses. + +### sports_plugin_host + +[`sports_plugin_host.py`](sports_plugin_host.py). `SportsPluginHostMixin`: +helpers of a scoreboard's `BasePlugin` subclass. `get_vegas_priority_weight()` +(more Vegas slots while a favourite plays, found across every plugin's data +shape), `_dispatch_switch_refresh()` (a manager refresh on a daemon thread, so +`display()` never waits on the network), `get_vegas_content_type()` and small +dynamic-duration helpers. List it before `BasePlugin`. + ### sports_scroll [`sports_scroll.py`](sports_scroll.py). `SportsScrollDisplay` and diff --git a/src/common/sports_display_rules.py b/src/common/sports_display_rules.py new file mode 100644 index 00000000..5d12c6c4 --- /dev/null +++ b/src/common/sports_display_rules.py @@ -0,0 +1,164 @@ +"""Which games a scoreboard shows, for how long, and what its scorebug dates say. + +Four ``sports.py`` methods are identical (executable AST, docstrings +stripped, decorators compared) in every scoreboard that carries them, and +were copied here from ledmatrix-plugins ``56c4f15`` (origin/main, +2026-09-30) under their existing names. They split into two mixins because +their carriers differ, and a plugin should not gain an override it did not +have: + +``SportsCardOptionsMixin`` -- afl, baseball, basketball, football, hockey, +lacrosse, nrl and soccer (ufc draws no team scorebug): + +- ``_card_option`` -- reads one ``scroll_card`` key through + ``SportsCoreSharedMixin._card_option``, but never lets the upcoming + scorebug lose both its date and its time (the combination a settings-form + bug saved for a whole cohort of boards); +- ``_recent_date_text`` -- the date line of the full-screen recent scorebug. + +``SportsGameRulesMixin`` -- all nine: + +- ``_filtered_or_all`` (all but football, which has no such method) -- the + quality and division filters on a board with no favourites, failing open + to every game rather than a blank panel; +- ``_effective_live_duration`` (all but ufc, which has none) -- how long a + live game stays up: ``non_favorite_live_game_duration`` for a + non-favourite when favourites are set, else ``game_display_duration``. + afl, nrl and soccer carry it on ``SportsCore``, the other five on + ``SportsLive``; the bodies are the same. + +The plugin missing a method gains one it never calls, which changes nothing: +nothing in that plugin, nor in core, calls it. + +A new module rather than more methods on ``sports_shared``, for the reason +``sports_helpers`` gives: a missing module fails at load, where the version +checks see it; a missing method fails mid-update. + +WHAT A HOST MUST PROVIDE +------------------------ +Derived by walking every ``self.`` the mixins read; the host-contract +test in ``test/test_sports_display_rules.py`` fails if a read is added +without being listed here. + +``SportsCardOptionsMixin``: + +- ``SportsCoreSharedMixin`` (``src.common.sports_shared``) in the MRO + **after** this mixin: ``_card_option`` calls that mixin's ``_card_option`` + and ``_switch_upcoming_center`` by name, and ``_recent_date_text`` its + ``_format_game_date``. List this mixin first -- + ``class SportsCore(SportsCardOptionsMixin, SportsGameRulesMixin, + SportsFetchMixin, SportsCoreSharedMixin, SportsHelpersMixin, ABC)`` -- + or ``SportsCoreSharedMixin._card_option`` wins and the rescue is lost. + Through it: ``config`` (the ``scroll_card`` block it reads). + +``SportsGameRulesMixin``: + +- ``_passes_other_filters(game)`` -- the plugin's own quality/division + filter (``_filtered_or_all``). +- ``_check_ranking_coverage(games)`` -- from ``SportsCoreSharedMixin``. +- ``favorite_teams``, ``game_display_duration`` and + ``_is_favorite_game(game)``; ``non_favorite_live_game_duration`` read with + ``getattr`` (``_effective_live_duration``). + +Neither mixin has an ``__init__`` or state. A method on the plugin's own +class still wins over either. +""" + +from typing import Any, Callable, Dict, List, Optional + +from src.common.sports_shared import SportsCoreSharedMixin + + +class SportsCardOptionsMixin: + """The scorebug's ``scroll_card`` reads. See module docstring.""" + + # The host contract, declared for type checking only. + _format_game_date: Callable[..., str] + + def _card_option(self, key: str, default: Any = None) -> Any: + """Read one scroll_card key, never blanking the upcoming scorebug. + + With the middle set to "date and time" and both of those lines + switched off, the full-screen upcoming scorebug is two logos and + "Next Game" with nothing to say when the game is. Nobody picks that + on purpose -- "vs" and "none" are the settings for a card without the + stack -- yet a whole cohort of boards has it: switch_show_date/_time + shipped while the core's settings form still drew keys missing from + the saved config as unchecked boxes, so the next Save wrote both as + false (fixed in LEDMatrix #597). That one combination therefore reads + as both on. Hiding either line alone, or both under "vs" or "none", + is still honoured. + """ + # The mixin named outright, not super(): tests lift this method onto + # stand-in classes that are not SportsCore subclasses. + base = SportsCoreSharedMixin._card_option + keys = ("switch_show_date", "switch_show_time") + value = base(self, key, default) # type: ignore[arg-type] + if (key in keys and not value + and not any(base(self, k, True) for k in keys) # type: ignore[arg-type] + and SportsCoreSharedMixin._switch_upcoming_center(self) == "date_time"): # type: ignore[arg-type] + return True + return value + + def _recent_date_text(self, game: Optional[Dict]) -> str: + """When a finished game was played, for the full-screen scorebug. + + Formatted by switch_date_format, like the upcoming scorebug, so the + two dates on this display agree; its "numeric" default returns the + extractor's "9/23" unchanged. ``switch_recent_show_date`` (default + true) is the off switch. + """ + if not self._card_option("switch_recent_show_date", True): + return "" + return self._format_game_date(str((game or {}).get("game_date") or ""), game) + + +class SportsGameRulesMixin: + """Which games are worth showing, and for how long. See module docstring.""" + + # The host contract, declared for type checking only. + favorite_teams: List[str] + game_display_duration: float + _passes_other_filters: Callable[[Dict], bool] + _check_ranking_coverage: Callable[[List[Dict]], None] + _is_favorite_game: Callable[[Dict], bool] + + def _filtered_or_all(self, games: List[Dict]) -> List[Dict]: + """The games worth watching, or all of them if that leaves none. + + With no favourites configured every game selected is a non-favourite + game, so the quality and division settings have to apply here too. They + governed only the top-up slice, which this branch never uses, so a + board with an empty favourites list had both settings silently inert -- + it could ask for ranked games only and still get the next N kickoffs. + + Fails open as a whole, not just per check. `_passes_other_filters` + allows a game whose data could not be resolved, but a filter working + exactly as asked can still match nothing on a given day, and here there + is no favourite left to carry the mode -- an empty list is a blank + panel rather than a short one. + """ + kept = [g for g in games if self._passes_other_filters(g)] + self._check_ranking_coverage(games) + return kept or games + + def _effective_live_duration(self, game) -> float: + """How long the given live game should stay on screen before rotating. + + Non-favorite live games use non_favorite_live_game_duration, but only + when it is set (> 0) AND favorite teams are configured. With no favorites + (or the knob at 0) every live game uses game_display_duration - identical + to the prior single-duration behavior. When show_favorite_teams_only is + on, non-favorite games are never shown, so this naturally never fires.""" + non_fav = getattr(self, "non_favorite_live_game_duration", 0) or 0 + if ( + non_fav > 0 + and self.favorite_teams + and game is not None + and not self._is_favorite_game(game) + ): + return non_fav + return self.game_display_duration + + +__all__ = ["SportsCardOptionsMixin", "SportsGameRulesMixin"] diff --git a/src/common/sports_font_path.py b/src/common/sports_font_path.py new file mode 100644 index 00000000..b449e5f6 --- /dev/null +++ b/src/common/sports_font_path.py @@ -0,0 +1,41 @@ +"""Where a scoreboard's bundled font file is, whatever the working directory. + +Every scoreboard's ``sports.py`` (nine) and ``game_renderer.py`` (eight) +carries the same module-level ``_resolve_font_path``. It predates +:func:`src.common.font_layout.resolve_asset_path`, and probes the core for +it: the path as given when it exists (relative to the cwd), else the core's +resolver (``FontManager._resolve_asset_path``, which delegates to +``resolve_asset_path``), else the path joined to the install root, else the +path unchanged so the caller's ``ImageFont.truetype`` raises and falls back +as before. + +On every core this module ships in, the probe always finds the resolver, and +the install-root join repeats what the resolver already tried. What is left +is two steps, and :func:`resolve_font_path` is exactly those: the cwd first, +then ``resolve_asset_path``. ``test/test_sports_font_path.py`` checks that +against the plugins' own copies, path for path. It is the same rule as +``sports_shared._resolve_font_path``, made public so a plugin can import it. + +Why not ``resolve_asset_path`` alone: it never consults the cwd, so a +process started from another checkout would switch to the install root's +fonts. Keeping the cwd first keeps that behaviour exactly. +""" + +import os + +from src.common.font_layout import resolve_asset_path + + +def resolve_font_path(path: str) -> str: + """``path`` if it exists, else :func:`resolve_asset_path` of it. + + Absolute paths that exist come back untouched; a relative path is tried + against the cwd, then the install root; a path found nowhere comes back + unchanged, so the caller still raises and falls back. + """ + if os.path.exists(path): + return path + return resolve_asset_path(path) + + +__all__ = ["resolve_font_path"] diff --git a/src/common/sports_live_scroll.py b/src/common/sports_live_scroll.py new file mode 100644 index 00000000..3d82b7f0 --- /dev/null +++ b/src/common/sports_live_scroll.py @@ -0,0 +1,277 @@ +"""Keep a live scoreboard's scrolling strip current without restarting it. + +In scroll mode a scoreboard renders its games into one wide image and +scrolls it past the panel. The strip used to be rebuilt only when a cycle +completed, so a score changed mid-cycle stayed frozen in the pixels until the +marquee finished. Eight scoreboards -- afl, baseball, basketball, football, +hockey, lacrosse, nrl and soccer (ufc has no live strip) -- carry the same +fix in their ``manager.py``: fingerprint the live games, rebuild when the +fingerprint changes (rate-limited, and never for the clock alone), and keep +the marquee's position across the rebuild. Its eight methods and two class +constants are identical (executable AST, docstrings stripped, decorators +compared) in all eight and were copied here from ledmatrix-plugins +``56c4f15`` (origin/main, 2026-09-30) under their existing names: + +- ``_live_scroll_managers`` -- the live managers whose games are on the strip; +- ``_refresh_live_scroll_managers`` -- let them refresh before they are + fingerprinted, off the render thread; +- ``_live_scroll_fields``, ``_fingerprint_games`` and + ``_live_scroll_fingerprint`` -- what the strip was drawn from; +- ``_live_scroll_needs_rebuild`` (with ``LIVE_SCROLL_REBUILD_MIN_SECONDS`` + and ``LIVE_SCROLL_REBUILD_DUTY_DIVISOR``) and ``_note_live_scroll_built`` + -- when to rebuild; +- ``_preserving_scroll_position`` -- a context manager that keeps the marquee + where it was across a rebuild. + +``LIVE_VOLATILE_FIELDS`` stays in each plugin: afl, nrl and soccer also +exclude ``period_text``, which embeds the clock in those sports. + +A separate module from ``sports_plugin_host`` because ufc has no live strip: +it inherits that mixin and not this one, so none of this is in its MRO. + +WHAT A HOST MUST PROVIDE +------------------------ +Derived by walking every ``self.`` / ``cls.`` the mixin reads; +the host-contract test in ``test/test_sports_live_scroll.py`` fails if a read +is added without being listed here. + +- ``LIVE_VOLATILE_FIELDS`` -- a class constant: the game-dict keys a rebuild + ignores (the clock, and what the display pipeline adds). +- ``_live_scroll_fingerprints``, ``_live_scroll_rebuilt_at`` and + ``_live_scroll_rebuild_cost`` -- empty dicts the host creates in + ``__init__``, keyed by scroll key. +- ``logger``. +- ``_dispatch_switch_refresh(manager)`` -- from ``SportsPluginHostMixin``. +- ``_league_registry`` (``{league: {"enabled": bool, "managers": {"live": + manager}}}``) or a ``_get_manager(mode_type)`` accessor, both read with + ``getattr`` -- ``_live_scroll_managers``. A host with neither gets no + managers, which leaves the feature inert rather than wrong. +- ``_scroll_manager``, read with ``getattr`` -- + ``_preserving_scroll_position`` asks it for the mode's scroll helper. + +Add it as a base of the plugin class beside ``SportsPluginHostMixin``, before +``BasePlugin``: ``class SoccerScoreboardPlugin(SportsPluginHostMixin, +SportsLiveScrollMixin, BasePlugin)``. The two define no name in common. No +``__init__``; a method on the plugin's own class still wins over the mixin's. +""" + +import logging +import time +from contextlib import contextmanager +from typing import Any, Callable, ClassVar, Dict, FrozenSet, Iterator, List + + +class SportsLiveScrollMixin: + """Mid-cycle rebuilds of a live scroll strip. See module docstring.""" + + # The host contract, declared for type checking only: these create no + # attributes, so the host's own values are what the methods read. + logger: logging.Logger + LIVE_VOLATILE_FIELDS: ClassVar[FrozenSet[str]] + _live_scroll_fingerprints: Dict[Any, Any] + _live_scroll_rebuilt_at: Dict[Any, float] + _live_scroll_rebuild_cost: Dict[Any, float] + _dispatch_switch_refresh: Callable[[Any], None] + + #: Floor between mid-cycle strip rebuilds, and the duty-cycle cap that can + #: raise it. + #: + #: A rebuild re-renders every card into one wide image, on the render + #: thread, so the marquee is frozen for however long it takes. Measured on a + #: Pi 4: 28ms for one game, 139ms for five, 435ms for fifteen. A fixed 5s + #: floor is fine for one game and wrong for a full slate -- with fifteen + #: live games a pitch lands somewhere every second or so, the fingerprint + #: changes continuously, and 435ms every 5s is nearly a tenth of the time + #: spent not scrolling. + #: + #: So the floor also scales with what the last rebuild actually cost: never + #: spend more than 1/LIVE_SCROLL_REBUILD_DUTY_DIVISOR of wall time + #: rebuilding. Fifteen games self-limits to a rebuild every ~8.7s; one game + #: stays on the 5s floor. No per-sport tuning, and it adapts to slate size + #: and panel width on its own. + LIVE_SCROLL_REBUILD_MIN_SECONDS: ClassVar[float] = 5.0 + LIVE_SCROLL_REBUILD_DUTY_DIVISOR: ClassVar[float] = 20.0 + + def _live_scroll_managers(self, league=None): + """The live managers whose games are on the strip. + + Two shapes across the scoreboard lineage: a _league_registry (baseball, + basketball, hockey, lacrosse, soccer, football) and a _get_manager + accessor on the single-league plugins (afl, nrl). Anything else returns + nothing, which leaves this feature inert rather than wrong. + """ + registry = getattr(self, "_league_registry", None) + if isinstance(registry, dict) and registry: + managers = [] + for league_id, entry in registry.items(): + if league is not None and league_id != league: + continue + entry = entry or {} + if not entry.get("enabled", False): + continue + manager = (entry.get("managers") or {}).get("live") + if manager is not None: + managers.append(manager) + return managers + getter = getattr(self, "_get_manager", None) + if callable(getter): + try: + # pylint: disable=not-callable + # The lineages that lack _get_manager infer this as None, so a + # static checker calls it uncallable. callable() above is the + # runtime guard; the branch is simply dead in those plugins. + manager = getter("live") + except (AttributeError, KeyError, TypeError, ValueError, OSError): + return [] + return [manager] if manager is not None else [] + return [] + + def _refresh_live_scroll_managers(self, league=None) -> None: + """Let the live managers refresh before their games are fingerprinted. + + Switch mode stays current because _try_manager_display() calls + _ensure_manager_updated() on every pass. Scroll mode had no equivalent: + its only refresh sat inside the block gated by the rebuild decision, and + that decision is computed from the data the refresh would replace. So + once the first strip was built nothing could change it, and the score on + the marquee stayed frozen until the process restarted. + + The refresh runs off the render thread -- see _dispatch_switch_refresh(). + This is called on every scroll frame, and a due manager.update() is a + network round trip: run inline, it froze the marquee for the length of + the ESPN request. The refreshed games land a few frames later, and the + fingerprint check that follows this call picks them up on the next frame + after they do. Dispatches for a manager are rate-limited, so the frames + where nothing is due cost a dict lookup and a clock read. + + Deliberately NOT gated on mode_type == "live". A recent/upcoming strip + never rebuilds from the fingerprint (_live_scroll_needs_rebuild returns + early for those), so refreshing here looks like wasted work -- but with + live_priority the plugin only switches TO live mode once it knows live + games exist, and it learns that from these same managers. Refreshing + only while live mode is on screen would rebuild the same circularity one + level up, and a game that went live would wait for the background + plugin update -- an hour, on a rig that sets update_interval: 3600. + """ + for manager in self._live_scroll_managers(league) or []: + try: + self._dispatch_switch_refresh(manager) + except (AttributeError, KeyError, TypeError, ValueError, OSError, + RuntimeError) as exc: + # Narrow on purpose: the update itself runs on another thread, + # and _ensure_manager_updated() swallows whatever it raises, so + # anything arriving here is a lookup error or a thread that + # could not be started, not a fetch failure. + self.logger.debug("Live scroll refresh skipped: %s", exc) + + @classmethod + def _live_scroll_fields(cls, game) -> tuple: + """One game as sorted ``(key, value)`` strings, minus the volatile keys.""" + try: + items = list(game.items()) + except AttributeError: + return (("", str(game)),) + return tuple(sorted((str(k), str(v)) for k, v in items + if k not in cls.LIVE_VOLATILE_FIELDS)) + + @classmethod + def _fingerprint_games(cls, games) -> tuple: + """Order-independent fingerprint of a list of games.""" + return tuple(sorted(cls._live_scroll_fields(g) for g in (games or []))) + + def _live_scroll_fingerprint(self, league=None) -> tuple: + """Fingerprint of every live game the strip's managers hold now.""" + games: List[Any] = [] + for manager in self._live_scroll_managers(league): + games.extend(getattr(manager, "live_games", None) or []) + return self._fingerprint_games(games) + + def _live_scroll_needs_rebuild(self, scroll_key, mode_type, league=None) -> bool: + """True when the live card would draw differently than the strip does. + + _scroll_prepared is cleared only when the cycle *completes*, so a score + scored mid-cycle stayed frozen in the rendered strip until the marquee + finished -- minutes, for a long game list. Restarting the display forces + a rebuild, which is the workaround users find. + """ + if mode_type != "live": + return False + known = self._live_scroll_fingerprints.get(scroll_key) + if known is None: + return False # nothing built yet; normal path + if self._live_scroll_fingerprint(league) == known: + return False + last = self._live_scroll_rebuilt_at.get(scroll_key, 0.0) + cost = self._live_scroll_rebuild_cost.get(scroll_key, 0.0) + floor = max(self.LIVE_SCROLL_REBUILD_MIN_SECONDS, + cost * self.LIVE_SCROLL_REBUILD_DUTY_DIVISOR) + if time.time() - last < floor: + return False # deferred, not dropped + return True + + def _note_live_scroll_built(self, scroll_key, mode_type, fingerprint=None, + league=None) -> None: + """Record what the strip was built from. + + Takes a fingerprint captured from the *managers* immediately before the + render, not one computed from the games handed to the renderer. Those + two are not comparable: _collect_games_for_scroll() decorates each game + with extra keys ("league", "status"), so a fingerprint taken from its + output can never equal one taken from the managers -- every check past + the rate limiter would rebuild, defeating the clock exclusion entirely. + That is not hypothetical; it is what the first version of this did, and + an end-to-end simulation caught it rebuilding on a bare clock tick. + + Capturing before the render also closes the race a plain re-read would + open: a background update landing mid-render would otherwise be recorded + as though the strip already contained it. + """ + if mode_type != "live": + return + self._live_scroll_fingerprints[scroll_key] = ( + fingerprint if fingerprint is not None + else self._live_scroll_fingerprint(league)) + self._live_scroll_rebuilt_at[scroll_key] = time.time() + + @contextmanager + def _preserving_scroll_position(self, mode_type, active, scroll_key=None) -> Iterator[None]: + """Keep the marquee where it is across a mid-cycle rebuild. + + ScrollHelper.set_scrolling_image() resets two counters and both matter: + scroll_position (without it the marquee snaps back to the start, which + looks worse than the stale score being fixed) and total_distance_scrolled + (without it the cycle restarts, so a game that keeps scoring could stop + the strip ever completing). Restored clamped to the new strip, since a + score gaining a digit changes its card's width by a few pixels. + + A no-op unless `active` -- a first build should start at zero. + """ + helper = None + if active and getattr(self, "_scroll_manager", None): + try: + helper = self._scroll_manager.get_scroll_display(mode_type).scroll_helper # type: ignore[attr-defined] + except Exception: # pragma: no cover - defensive + helper = None + position = getattr(helper, "scroll_position", None) if helper else None + distance = getattr(helper, "total_distance_scrolled", None) if helper else None + started = time.time() + try: + yield + finally: + # What this render cost, so the next floor can scale with it. Keyed by + # scroll_key, which is what _live_scroll_needs_rebuild() reads -- + # they are only the same string in some of these plugins, and keying + # by mode_type made the duty cap silently inert in the rest. + self._live_scroll_rebuild_cost[scroll_key or mode_type] = time.time() - started + if helper is not None and position is not None: + width = max(getattr(helper, "total_scroll_width", 0) - 1, 0) + helper.scroll_position = min(position, width) + if distance is not None: + helper.total_distance_scrolled = distance + helper.scroll_complete = False + self.logger.info( + "[Scroll] Live card changed; rebuilt the %s strip in place " + "at position %d", mode_type, int(helper.scroll_position)) + + +__all__ = ["SportsLiveScrollMixin"] diff --git a/src/common/sports_plugin_host.py b/src/common/sports_plugin_host.py new file mode 100644 index 00000000..7122e65b --- /dev/null +++ b/src/common/sports_plugin_host.py @@ -0,0 +1,270 @@ +"""The scoreboard plugin class's helpers every ``manager.py`` copies. + +Each scoreboard's ``manager.py`` holds its ``BasePlugin`` subclass (the +"host": ``SoccerScoreboardPlugin``, ``UFCScoreboardPlugin``, ...). Ten of its +methods, and the class constant one of them reads, are identical +(executable AST, docstrings stripped, decorators compared) in all nine +scoreboards -- afl, baseball, basketball, football, hockey, lacrosse, nrl, +soccer and ufc -- and were copied here from ledmatrix-plugins ``56c4f15`` +(origin/main, 2026-09-30) under their existing names: + +- ``_dispatch_switch_refresh`` (with ``_SWITCH_REFRESH_MIN_GAP_SECONDS``) -- + run a manager's refresh on a daemon thread so ``display()`` never blocks + on the network; +- ``get_vegas_priority_weight``, ``_favorite_team_is_live``, + ``_favorite_scan_targets``, ``_favorite_scan_games`` and + ``_game_involves`` -- how many Vegas slots the plugin asks for, and + whether a configured favourite is playing live; +- ``get_vegas_content_type`` -- ``'multi'``: a scoreboard is a list of games; +- ``_dynamic_feature_enabled``, ``_get_total_games_for_manager`` and + ``_build_manager_key`` -- small dynamic-duration helpers. + +This is stage 4 of the consolidation (docs/SPORTS_UNIFICATION.md): the +families that needed no reconciling. The rest of ``manager.py`` has drifted +and is reconciled one family per release before it moves. + +A new module rather than more methods on an existing mixin, for the reason +``sports_helpers`` gives: a missing module fails at load, where the version +checks see it; a missing method fails mid-frame. + +WHAT A HOST MUST PROVIDE +------------------------ +Derived by walking every ``self.`` the mixin reads; the host-contract +test in ``test/test_sports_plugin_host.py`` fails if a read is added without +being listed here. + +- ``_ensure_manager_updated(manager)`` -- ``_dispatch_switch_refresh`` runs + it on the thread it starts. It must swallow its own errors: nothing joins + the thread. +- ``global_config``, ``has_live_priority()``, ``has_live_content()`` and + ``supports_dynamic_duration()`` -- all on ``BasePlugin``; the scoreboards + override the last three. +- ``is_enabled`` -- set by each scoreboard's ``__init__`` (``BasePlugin`` + calls its flag ``enabled``). +- ``_switch_refresh_threads`` and ``_switch_refresh_at``, read with + ``getattr`` -- ``_dispatch_switch_refresh`` creates both on first use, so + a host need not. +- The live managers it scans for favourites are found through ``vars(self)`` + (``_favorite_scan_targets``): any attribute, or value of a dict + attribute, with ``favorite_teams`` (or ``favorite_fighters``) and + ``live_games`` (or ``live_matches``, or an ``active_celebration`` dict + holding a ``game``). + +Add it as a base of the plugin class, **before** ``BasePlugin``, e.g. +``class SoccerScoreboardPlugin(SportsPluginHostMixin, BasePlugin)``: +``get_vegas_priority_weight`` and ``get_vegas_content_type`` override +``BasePlugin``'s defaults. A method on the plugin's own class still wins over +the mixin's. The mixin has no ``__init__`` and creates no class attributes +beyond its one constant. +""" + +import logging +import threading +import time +from typing import Any, Callable, ClassVar, Dict, Iterator, Optional + + +class SportsPluginHostMixin: + """The scoreboard plugin class's identical helpers. See module docstring.""" + + # The host contract, declared for type checking only: these create no + # attributes, so the host's own values are what the methods read. + logger: logging.Logger + is_enabled: bool + global_config: Dict[str, Any] + _ensure_manager_updated: Callable[[Any], Any] + has_live_priority: Callable[[], bool] + has_live_content: Callable[[], bool] + supports_dynamic_duration: Callable[[], bool] + # Created on first use by _dispatch_switch_refresh, per instance. + _switch_refresh_threads: Dict[int, threading.Thread] + _switch_refresh_at: Dict[int, float] + + #: Floor between two draw-time refresh dispatches for one manager. The + #: manager's own update() still decides whether anything is fetched; this + #: only stops display() starting a thread on every frame just to be told + #: the interval has not elapsed. + _SWITCH_REFRESH_MIN_GAP_SECONDS: ClassVar[float] = 5.0 + + def _dispatch_switch_refresh(self, manager) -> None: + """Run _ensure_manager_updated(manager) on a daemon thread. + + Called from display(), so it must not block: when an update is due, + manager.update() fetches rankings and the schedule over the network, + and doing that inline stalled the frame for the length of the round + trip. The refreshed games land in the manager a few frames later -- + still within the manager's own interval, which is the freshness the + switch path was missing. + + At most one refresh per manager runs at a time, and dispatches for the + same manager are at least _SWITCH_REFRESH_MIN_GAP_SECONDS apart. Only + the render thread touches the two bookkeeping dicts, so they need no + lock; manager.update() stamps last_update before it fetches, so a + concurrent background plugin.update() for the same manager returns + early rather than fetching twice. + """ + threads: Optional[Dict[int, threading.Thread]] = getattr(self, "_switch_refresh_threads", None) + if threads is None: + threads = self._switch_refresh_threads = {} + stamps: Optional[Dict[int, float]] = getattr(self, "_switch_refresh_at", None) + if stamps is None: + stamps = self._switch_refresh_at = {} + + key = id(manager) + running = threads.get(key) + if running is not None and running.is_alive(): + return + now = time.monotonic() + last = stamps.get(key) + if last is not None and now - last < self._SWITCH_REFRESH_MIN_GAP_SECONDS: + return + stamps[key] = now + thread = threading.Thread( + target=self._ensure_manager_updated, + args=(manager,), + daemon=True, + name="SwitchRefresh-%s" % type(manager).__name__, + ) + threads[key] = thread + thread.start() + + # ---- Vegas weighting: is a favourite playing? ----------------------- + # + # With display.vegas_scroll.live_in_ticker set, the marquee keeps running + # through a live game and plugins can claim more than one slot per cycle. + # The core already gives any plugin with live content `live_weight`; this + # exists for the one thing the core cannot work out for itself, which is + # *whose* game is live. See PLUGIN_API_REFERENCE, "Vegas scroll hooks", + # and ADVANCED_FEATURES, "Live content in the ticker". + + def get_vegas_priority_weight(self): + """Slots per Vegas cycle: more when a favorite team is playing. + + Returns None when nothing is live, which leaves the decision to the + core rather than asserting a weight of 1 -- the core may have its own + reason to boost this plugin later. + """ + try: + if not (self.has_live_priority() and self.has_live_content()): + return None + vegas = (self.global_config or {}).get('display', {}).get( + 'vegas_scroll', {}) + if self._favorite_team_is_live(): + return vegas.get('favorite_live_weight', 5) + return vegas.get('live_weight', 3) + except Exception: + # Never let a weighting question break the rotation; the core + # treats an exception as weight 1 anyway, and None says the same + # thing more cheaply. + return None + + def _favorite_team_is_live(self): + """Whether any live game or fight involves a configured favorite. + + The sports plugins do not share one data shape, so this enumerates the + real ones rather than assuming. An earlier version looked only for an + attribute holding `live_games` alongside `favorite_teams`, which was + true of five plugins and quietly false for four others -- they simply + never reported a favorite, and no test noticed because the tests used + the assumed shape rather than each plugin's own. + + Handled: + + * managers held directly on the plugin *and* inside a dict such as + ``self._managers`` (nrl, afl) + * ``live_games`` (most) and ``live_matches`` (cricket) + * ``favorite_teams`` (most) and ``favorite_fighters`` (ufc) + * identifiers ``home_abbr``/``away_abbr``, ``home_id``/``away_id``, + ``fighter1_name``/``fighter2_name``, and cricket's nested + ``teams: [{name, abbr, short_name}]`` + * ``active_celebration["game"]``, a snapshot the live manager keeps + precisely because the game leaves ``live_games`` while the + celebration is still on screen + """ + for holder in self._favorite_scan_targets(): + favorites = (getattr(holder, 'favorite_teams', None) + or getattr(holder, 'favorite_fighters', None)) + if not favorites: + continue + wanted = {str(f).strip().lower() for f in favorites if f} + if not wanted: + continue + for game in self._favorite_scan_games(holder): + if self._game_involves(game, wanted): + return True + return False + + def _favorite_scan_targets(self) -> Iterator[Any]: + """Objects that might carry live content: attributes, and dict values. + + nrl and afl keep their per-league managers in a ``self._managers`` + dict, so walking attribute values alone finds the dict and stops. + """ + for value in list(vars(self).values()): + yield value + if isinstance(value, dict): + for nested in list(value.values()): + yield nested + + @staticmethod + def _favorite_scan_games(holder) -> Iterator[Dict[str, Any]]: + """Every game/fight on a holder that a favorite could be playing in.""" + for attr in ('live_games', 'live_matches'): + for game in (getattr(holder, attr, None) or []): + if isinstance(game, dict): + yield game + celebration = getattr(holder, 'active_celebration', None) + if isinstance(celebration, dict) and isinstance(celebration.get('game'), dict): + yield celebration['game'] + + @staticmethod + def _game_involves(game, wanted) -> bool: + """Whether a game/fight involves one of the wanted names.""" + for field in ('home_abbr', 'away_abbr', 'home_id', 'away_id', + 'fighter1_name', 'fighter2_name'): + value = game.get(field) + if value is not None and str(value).strip().lower() in wanted: + return True + # Cricket nests its sides and matches on any of three names, by + # substring -- "india" should match "India Women". Mirrors that + # plugin's own _match_has_team rather than inventing a second rule. + for team in (game.get('teams') or []): + if not isinstance(team, dict): + continue + hay = " ".join(str(team.get(k) or '') for k in + ('name', 'abbr', 'short_name')).lower() + if any(name in hay for name in wanted): + return True + return False + + def get_vegas_content_type(self) -> str: + """Plugin provides multiple scrollable items (games).""" + return 'multi' + + # ---- dynamic duration ------------------------------------------------ + + def _dynamic_feature_enabled(self) -> bool: + """Dynamic duration applies: the plugin is enabled and supports it.""" + if not self.is_enabled: + return False + return self.supports_dynamic_duration() + + @staticmethod + def _get_total_games_for_manager(manager) -> int: + """How many games a manager holds, from the first list it carries.""" + if manager is None: + return 0 + for attr in ("live_games", "games_list", "recent_games", "upcoming_games"): + value = getattr(manager, attr, None) + if isinstance(value, list): + return len(value) + return 0 + + @staticmethod + def _build_manager_key(mode_name: str, manager) -> str: + """``":"``, the key progress is tracked under.""" + manager_name = manager.__class__.__name__ if manager else "None" + return f"{mode_name}:{manager_name}" + + +__all__ = ["SportsPluginHostMixin"] diff --git a/test/test_sports_display_rules.py b/test/test_sports_display_rules.py new file mode 100644 index 00000000..58e1ebfc --- /dev/null +++ b/test/test_sports_display_rules.py @@ -0,0 +1,203 @@ +"""src.common.sports_display_rules: behaviour and host contract. + +Ported from the scoreboards' tests of the same methods (hockey's +test_switch_show_date_time.py, baseball's test_recent_game_date.py, the +test_non_favorite_live_duration.py copies), against stub hosts composed the +way the plugins compose ``SportsCore``: the new mixins first, then +``SportsCoreSharedMixin``. +""" + +import ast +from pathlib import Path + +import pytest + +from src.common import sports_display_rules +from src.common.sports_display_rules import SportsCardOptionsMixin, SportsGameRulesMixin +from src.common.sports_shared import SportsCoreSharedMixin + + +class Core(SportsCardOptionsMixin, SportsGameRulesMixin, SportsCoreSharedMixin): + """A SportsCore stand-in in the documented base order.""" + + def __init__(self, scroll_card=None, favorites=(), non_fav=0, duration=15, + passes=lambda g: True, quality="any"): + self.config = {"scroll_card": dict(scroll_card or {})} + self.favorite_teams = list(favorites) + self.non_favorite_live_game_duration = non_fav + self.game_display_duration = duration + self._passes = passes + self.other_games_min_quality = quality + self.coverage_checked = [] + + def _passes_other_filters(self, game): + return self._passes(game) + + def _check_ranking_coverage(self, games): + self.coverage_checked.append(list(games)) + + def _is_favorite_game(self, game): + return game.get("home_abbr") in self.favorite_teams + + +# --------------------------------------------------------------------------- +# _card_option +# --------------------------------------------------------------------------- + +class TestCardOption: + def test_ordinary_keys_read_through(self): + core = Core({"vs_text": "@"}) + assert core._card_option("vs_text", "VS") == "@" + assert core._card_option("missing", 7) == 7 + + def test_both_lines_off_under_date_time_reads_as_both_on(self): + core = Core({"switch_show_date": False, "switch_show_time": False}) + assert core._card_option("switch_show_date", True) is True + assert core._card_option("switch_show_time", True) is True + + @pytest.mark.parametrize("center", ["vs", "none"]) + def test_both_off_is_honoured_without_the_stack(self, center): + core = Core({"switch_show_date": False, "switch_show_time": False, + "switch_upcoming_center": center}) + assert core._card_option("switch_show_date", True) is False + assert core._card_option("switch_show_time", True) is False + + def test_one_line_off_is_honoured(self): + core = Core({"switch_show_date": False, "switch_show_time": True}) + assert core._card_option("switch_show_date", True) is False + assert core._card_option("switch_show_time", True) is True + + def test_inherit_follows_the_card_center(self): + core = Core({"switch_show_date": False, "switch_show_time": False, + "switch_upcoming_center": "inherit", "upcoming_center": "vs"}) + assert core._card_option("switch_show_date", True) is False + + def test_it_must_come_before_the_shared_mixin(self): + """In the other order the shared reader wins and the rescue is lost.""" + + class Wrong(SportsCoreSharedMixin, SportsCardOptionsMixin): + pass + + wrong = Wrong() + wrong.config = {"scroll_card": {"switch_show_date": False, "switch_show_time": False}} + assert wrong._card_option("switch_show_date", True) is False + assert Core._card_option is SportsCardOptionsMixin._card_option + + def test_works_lifted_onto_a_stand_in(self): + """Plugin tests lift it onto classes that are not SportsCore subclasses.""" + + class StandIn: + config = {"scroll_card": {"switch_show_date": False, "switch_show_time": False}} + _card_option = SportsCardOptionsMixin._card_option + _switch_upcoming_center = SportsCoreSharedMixin._switch_upcoming_center + + assert StandIn()._card_option("switch_show_time", True) is True + + +class TestRecentDateText: + def test_numeric_default_is_the_extractors_text(self): + assert Core()._recent_date_text({"game_date": "9/23"}) == "9/23" + + def test_follows_switch_date_format(self): + core = Core({"switch_date_format": "abbrev"}) + assert core._recent_date_text({"game_date": "9/23"}) == "Sep 23" + + def test_the_off_switch(self): + core = Core({"switch_recent_show_date": False}) + assert core._recent_date_text({"game_date": "9/23"}) == "" + + @pytest.mark.parametrize("game", [None, {}, {"game_date": None}]) + def test_no_date_is_empty(self, game): + assert Core()._recent_date_text(game) == "" + + +# --------------------------------------------------------------------------- +# _filtered_or_all +# --------------------------------------------------------------------------- + +class TestFilteredOrAll: + def test_keeps_what_passes(self): + games = [{"id": 1, "ok": True}, {"id": 2, "ok": False}] + core = Core(passes=lambda g: g["ok"]) + assert core._filtered_or_all(games) == [games[0]] + + def test_fails_open_when_nothing_passes(self): + games = [{"id": 1}, {"id": 2}] + assert Core(passes=lambda g: False)._filtered_or_all(games) == games + + def test_ranking_coverage_is_checked_on_every_game(self): + games = [{"id": 1}, {"id": 2}] + core = Core(passes=lambda g: g["id"] == 1) + core._filtered_or_all(games) + assert core.coverage_checked == [games] + + +# --------------------------------------------------------------------------- +# _effective_live_duration +# --------------------------------------------------------------------------- + +class TestEffectiveLiveDuration: + FAV = {"home_abbr": "DAL"} + OTHER = {"home_abbr": "NYG"} + + def test_non_favourite_gets_the_shorter_dwell(self): + core = Core(favorites=["DAL"], non_fav=5, duration=20) + assert core._effective_live_duration(self.OTHER) == 5 + assert core._effective_live_duration(self.FAV) == 20 + + def test_no_favourites_means_one_duration(self): + assert Core(non_fav=5, duration=20)._effective_live_duration(self.OTHER) == 20 + + @pytest.mark.parametrize("knob", [0, None]) + def test_the_knob_off(self, knob): + core = Core(favorites=["DAL"], non_fav=knob, duration=20) + assert core._effective_live_duration(self.OTHER) == 20 + + def test_no_game(self): + assert Core(favorites=["DAL"], non_fav=5, duration=20)._effective_live_duration(None) == 20 + + def test_a_host_without_the_knob(self): + core = Core(favorites=["DAL"], duration=20) + del core.non_favorite_live_game_duration + assert core._effective_live_duration(self.OTHER) == 20 + + +# --------------------------------------------------------------------------- +# Host contract +# --------------------------------------------------------------------------- + +def _self_reads(class_name): + tree = ast.parse(Path(sports_display_rules.__file__).read_text(encoding="utf-8")) + cls = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == class_name) + names = set() + for node in ast.walk(cls): + if (isinstance(node, ast.Attribute) and isinstance(node.ctx, ast.Load) + and isinstance(node.value, ast.Name) and node.value.id == "self"): + names.add(node.attr) + if (isinstance(node, ast.Call) and isinstance(node.func, ast.Name) + and node.func.id == "getattr" and len(node.args) >= 2 + and isinstance(node.args[0], ast.Name) and node.args[0].id == "self" + and isinstance(node.args[1], ast.Constant)): + names.add(node.args[1].value) + return names + + +class TestHostContract: + @pytest.mark.parametrize("mixin", [SportsCardOptionsMixin, SportsGameRulesMixin]) + def test_every_host_read_is_documented(self, mixin): + needed = _self_reads(mixin.__name__) - set(dir(mixin)) + undocumented = sorted(n for n in needed if f"``{n}" not in sports_display_rules.__doc__) + assert undocumented == [], f"read but not in the host contract: {undocumented}" + + def test_the_mixins_create_no_attributes(self): + for name in ("_format_game_date", "favorite_teams", "game_display_duration", + "_passes_other_filters", "_check_ranking_coverage", "_is_favorite_game"): + assert not hasattr(SportsCardOptionsMixin, name) + assert not hasattr(SportsGameRulesMixin, name) + for mixin in (SportsCardOptionsMixin, SportsGameRulesMixin): + assert "__init__" not in vars(mixin) + + def test_the_two_define_no_name_in_common(self): + a = {n for n in vars(SportsCardOptionsMixin) if not n.startswith("__")} + b = {n for n in vars(SportsGameRulesMixin) if not n.startswith("__")} + assert a & b == set() diff --git a/test/test_sports_font_path.py b/test/test_sports_font_path.py new file mode 100644 index 00000000..1f2711a1 --- /dev/null +++ b/test/test_sports_font_path.py @@ -0,0 +1,120 @@ +"""src.common.sports_font_path: the plugins' ``_resolve_font_path``, path for path. + +The plugins' copy probes the core for ``FontManager._resolve_asset_path`` +and falls back to its own install-root join; ``resolve_font_path`` is what +that comes to on a core that ships it. The bodies differ, so instead of an +AST comparison this runs both on the same paths -- found in the cwd only, +under the install root only, in both, absolute, and nowhere -- from a +temporary cwd, and requires the same string back. The plugin copies are read +from LEDMATRIX_PLUGINS (every sports.py and game_renderer.py that still has +one); without it, the comparison is against the copy transcribed below. +""" + +import ast +import os +from pathlib import Path + +import pytest + +from src.common.font_layout import resolve_asset_path +from src.common.sports_font_path import resolve_font_path + +REPO = Path(__file__).resolve().parents[1] +BUNDLED = "assets/fonts/PressStart2P-Regular.ttf" + +#: ledmatrix-plugins 56c4f15, plugins/*-scoreboard/sports.py (docstring and +#: comments dropped). The same body is in every sports.py and game_renderer.py. +TRANSCRIBED = ''' +def _resolve_font_path(path: str) -> str: + if os.path.exists(path): + return path + try: + import src.font_manager as _core_fonts + manager = getattr(_core_fonts, "FontManager", None) + resolver = getattr(manager, "_resolve_asset_path", None) + if resolver is not None: + resolved = resolver(path) + if resolved and os.path.exists(resolved): + return resolved + root = os.path.dirname(os.path.dirname(os.path.abspath(_core_fonts.__file__))) + candidate = os.path.join(root, path) + if os.path.exists(candidate): + return candidate + except (ImportError, AttributeError, OSError): + return path + return path +''' + + +def _compile(source: str): + namespace = {"os": os} + exec(compile(source, "", "exec"), namespace) # nosec B102 - test-only, source is a plugin file # nosemgrep + return namespace["_resolve_font_path"] + + +def _plugin_copies(): + """(label, function) for every plugin copy, or the transcription.""" + raw = os.environ.get("LEDMATRIX_PLUGINS") + root = Path(raw) if raw else None + if root is not None and (root / "plugins").is_dir(): + root = root / "plugins" + copies = [] + if root is not None and root.is_dir(): + for path in sorted(root.glob("*-scoreboard/*.py")): + if path.name not in ("sports.py", "game_renderer.py"): + continue + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in tree.body: + if isinstance(node, ast.FunctionDef) and node.name == "_resolve_font_path": + copies.append((f"{path.parent.name}/{path.name}", + _compile(ast.unparse(node)))) + if not copies: + copies.append(("transcribed", _compile(TRANSCRIBED))) + return copies + + +COPIES = _plugin_copies() + + +@pytest.fixture +def elsewhere(tmp_path, monkeypatch): + """A cwd that is not the install root, holding one font of its own and a + shadow of a bundled one.""" + (tmp_path / "cwd_only.ttf").write_bytes(b"x") + shadow = tmp_path / BUNDLED + shadow.parent.mkdir(parents=True) + shadow.write_bytes(b"x") + monkeypatch.chdir(tmp_path) + return tmp_path + + +def _cases(cwd: Path): + return [ + "cwd_only.ttf", # in the cwd only + BUNDLED, # in both: the cwd wins + "assets/fonts/4x6-font.ttf", # under the install root only + str(REPO / BUNDLED), # absolute, exists + str(cwd / "missing.ttf"), # absolute, missing + "assets/fonts/no-such-font.ttf", # relative, nowhere + "", # empty + ] + + +@pytest.mark.parametrize("label,copy", COPIES, ids=[c[0] for c in COPIES]) +def test_same_answer_as_the_plugin_copy(label, copy, elsewhere): + for path in _cases(elsewhere): + assert resolve_font_path(path) == copy(path), (label, path) + + +def test_the_cwd_comes_first(elsewhere): + assert resolve_font_path(BUNDLED) == BUNDLED + assert resolve_asset_path(BUNDLED) != BUNDLED # what dropping it would change + + +def test_the_install_root_after_it(elsewhere): + found = resolve_font_path("assets/fonts/4x6-font.ttf") + assert Path(found).is_absolute() and Path(found).is_file() + + +def test_nowhere_comes_back_unchanged(elsewhere): + assert resolve_font_path("assets/fonts/no-such-font.ttf") == "assets/fonts/no-such-font.ttf" diff --git a/test/test_sports_live_scroll.py b/test/test_sports_live_scroll.py new file mode 100644 index 00000000..742edb92 --- /dev/null +++ b/test/test_sports_live_scroll.py @@ -0,0 +1,349 @@ +"""src.common.sports_live_scroll: behaviour and host contract. + +Ported from the eight scoreboards' test_live_scroll_refresh.py, against a +stub host carrying only the documented contract: what counts as a change +(the clock and the display pipeline's decoration do not), the rate limit and +its duty-cycle scaling, the marquee keeping its place across a rebuild, both +manager shapes (a league registry; afl/nrl's ``_get_manager``), and the +refresh that runs before the fingerprint. +""" + +import ast +import threading +import time +from pathlib import Path + +import pytest + +from src.common import sports_live_scroll +from src.common.sports_live_scroll import SportsLiveScrollMixin +from src.common.sports_plugin_host import SportsPluginHostMixin + +KEY = "live" + + +def game(gid="1", home="2", away="1", **extra): + g = {"id": gid, "home_score": home, "away_score": away, + "period": 3, "period_text": "3rd", + "clock": "12:04", "status_text": "12:04 - 3rd", + "is_final": False, "is_halftime": False, + "home_abbr": "AAA", "away_abbr": "BBB"} + g.update(extra) + return g + + +class _Manager: + def __init__(self, games=()): + self.live_games = list(games) + + +class _Helper: + """Stands in for ScrollHelper, including the reset that makes this hard.""" + + def __init__(self): + self.scroll_position = 0.0 + self.total_distance_scrolled = 0.0 + self.total_scroll_width = 5000 + self.scroll_complete = True + + def set_scrolling_image(self, width=5000): + self.total_scroll_width = width + self.scroll_position = 0.0 + self.total_distance_scrolled = 0.0 + self.scroll_complete = False + + +class _Log: + def __init__(self): + self.lines = [] + + def info(self, msg, *args): + self.lines.append(msg % args) + + def debug(self, msg, *args): + self.lines.append(msg % args) + + +class Host(SportsLiveScrollMixin): + """The documented contract (registry shape).""" + + LIVE_VOLATILE_FIELDS = frozenset({"clock", "status_text", "display_clock", + "league", "status"}) + + def __init__(self, games=(), helper=None, second_league_games=()): + self._league_registry = { + "primary": {"enabled": True, "managers": {"live": _Manager(games)}}, + "disabled": {"enabled": False, + "managers": {"live": _Manager(second_league_games)}}, + } + self._live_scroll_fingerprints = {} + self._live_scroll_rebuilt_at = {} + self._live_scroll_rebuild_cost = {} + self.logger = _Log() + self.dispatched = [] + + class _SM: + def get_scroll_display(self, mode_type): + return type("SD", (), {"scroll_helper": helper})() + + self._scroll_manager = _SM() if helper else None + + def _dispatch_switch_refresh(self, manager): + self.dispatched.append(manager) + + def _games(self): + return self._league_registry["primary"]["managers"]["live"].live_games + + def _set(self, games): + self._league_registry["primary"]["managers"]["live"].live_games = list(games) + + +def fresh(games=(), **kw): + host = Host(games, **kw) + host._note_live_scroll_built(KEY, "live", host._live_scroll_fingerprint()) + host._live_scroll_rebuilt_at[KEY] = 0.0 # past the rate-limit floor + return host + + +class TestManagers: + def test_the_enabled_league_only(self): + host = Host([game()], second_league_games=[game(gid="9")]) + assert [m.live_games for m in host._live_scroll_managers()] == [host._games()] + + def test_one_league_by_name(self): + host = Host([game()]) + assert len(host._live_scroll_managers("primary")) == 1 + assert host._live_scroll_managers("nope") == [] + + def test_the_single_league_shape(self): + host = Host([game()]) + host._league_registry = None + only = _Manager([game()]) + host._get_manager = lambda mode: only if mode == "live" else None + assert host._live_scroll_managers() == [only] + + def test_a_failing_accessor_is_no_managers(self): + host = Host() + host._league_registry = {} + + def broken(mode): + raise KeyError(mode) + + host._get_manager = broken + assert host._live_scroll_managers() == [] + + def test_neither_shape_is_inert(self): + host = Host() + host._league_registry = None + assert host._live_scroll_managers() == [] + + +class TestWhatCountsAsAChange: + def test_nothing_changed(self): + assert not fresh([game()])._live_scroll_needs_rebuild(KEY, "live") + + def test_the_clock_ticking_is_not_a_rebuild(self): + host = fresh([game()]) + host._set([game(clock="11:58", status_text="11:58 - 3rd")]) + assert not host._live_scroll_needs_rebuild(KEY, "live") + + @pytest.mark.parametrize("change", [ + {"home": "3"}, {"period_text": "OT", "period": 4}, {"is_final": True}, + {"is_halftime": True}, {"situation": "power play"}, + {"some_new_field_a_card_draws": "x"}]) + def test_anything_else_is(self, change): + host = fresh([game()]) + host._set([game(**change)]) + assert host._live_scroll_needs_rebuild(KEY, "live") + + def test_a_second_game_going_live(self): + host = fresh([game()]) + host._set([game(), game(gid="2")]) + assert host._live_scroll_needs_rebuild(KEY, "live") + + def test_the_pipelines_decoration_is_not_a_change(self): + host = fresh([game()]) + host._set([dict(game(), league="nhl", status={"state": "in"})]) + assert not host._live_scroll_needs_rebuild(KEY, "live") + host = fresh([dict(game(), league="nhl", status={"state": "in"})]) + host._set([game()]) + assert not host._live_scroll_needs_rebuild(KEY, "live") + + def test_the_hosts_volatile_fields_are_the_ones_read(self): + """afl, nrl and soccer also ignore period_text; that stays theirs.""" + + class ClockInLabel(Host): + LIVE_VOLATILE_FIELDS = Host.LIVE_VOLATILE_FIELDS | {"period_text"} + + host = ClockInLabel([game()]) + host._note_live_scroll_built(KEY, "live") + host._live_scroll_rebuilt_at[KEY] = 0.0 + host._set([game(period_text="3rd 11:58")]) + assert not host._live_scroll_needs_rebuild(KEY, "live") + + @pytest.mark.parametrize("mode", ["recent", "upcoming"]) + def test_other_modes_never_rebuild(self, mode): + host = fresh([game()]) + host._set([game(home="5")]) + assert not host._live_scroll_needs_rebuild(KEY, mode) + + def test_a_first_build_is_not_a_change(self): + assert not Host([game()])._live_scroll_needs_rebuild(KEY, "live") + + def test_a_non_dict_game_still_fingerprints(self): + assert Host._fingerprint_games(["odd", None]) == tuple(sorted( + ((("", "odd"),), (("", "None"),)))) + + +class TestRateLimit: + def test_a_change_inside_the_floor_is_deferred_not_lost(self): + host = Host([game()]) + host._note_live_scroll_built(KEY, "live", host._live_scroll_fingerprint()) + host._set([game(home="3")]) + assert not host._live_scroll_needs_rebuild(KEY, "live") + host._live_scroll_rebuilt_at[KEY] = 0.0 + assert host._live_scroll_needs_rebuild(KEY, "live") + + def test_an_expensive_rebuild_raises_the_floor(self): + host = fresh([game()]) + host._live_scroll_rebuild_cost[KEY] = 0.463 # 0.463 x 20 = 9.3s + host._live_scroll_rebuilt_at[KEY] = time.time() - 6.0 + host._set([game(home="9")]) + assert not host._live_scroll_needs_rebuild(KEY, "live") + host._live_scroll_rebuilt_at[KEY] = time.time() - 10.0 + assert host._live_scroll_needs_rebuild(KEY, "live") + + def test_a_cheap_rebuild_stays_on_the_minimum(self): + host = fresh([game()]) + host._live_scroll_rebuild_cost[KEY] = 0.029 + host._live_scroll_rebuilt_at[KEY] = time.time() - 6.0 + host._set([game(home="9")]) + assert host._live_scroll_needs_rebuild(KEY, "live") + + def test_the_constants(self): + assert SportsLiveScrollMixin.LIVE_SCROLL_REBUILD_MIN_SECONDS == 5.0 + assert SportsLiveScrollMixin.LIVE_SCROLL_REBUILD_DUTY_DIVISOR == 20.0 + + def test_noting_a_non_live_build_records_nothing(self): + host = Host([game()]) + host._note_live_scroll_built(KEY, "recent") + assert host._live_scroll_fingerprints == {} and host._live_scroll_rebuilt_at == {} + + +class TestPreservingScrollPosition: + def test_position_and_progress_survive(self): + helper = _Helper() + host = Host([game()], helper=helper) + helper.scroll_position = helper.total_distance_scrolled = 812.0 + with host._preserving_scroll_position("live", active=True): + helper.set_scrolling_image() + assert helper.scroll_position == 812.0 + assert helper.total_distance_scrolled == 812.0 + assert helper.scroll_complete is False + assert any("rebuilt the live strip in place at position 812" in line + for line in host.logger.lines) + + def test_clamped_to_a_shorter_strip(self): + helper = _Helper() + host = Host([game()], helper=helper) + helper.scroll_position = 1300.0 + with host._preserving_scroll_position("live", active=True): + helper.set_scrolling_image(width=1200) + assert helper.scroll_position == 1199 + + def test_a_first_build_starts_at_zero(self): + helper = _Helper() + host = Host([game()], helper=helper) + helper.scroll_position = 500.0 + with host._preserving_scroll_position("live", active=False): + helper.set_scrolling_image() + assert helper.scroll_position == 0.0 + + def test_no_scroll_manager_is_survivable_and_still_costed(self): + host = Host([game()], helper=None) + with host._preserving_scroll_position("live", active=True, scroll_key="nhl_live"): + pass + assert "nhl_live" in host._live_scroll_rebuild_cost + + def test_the_cost_is_keyed_by_mode_without_a_scroll_key(self): + host = Host([game()]) + with host._preserving_scroll_position("live", active=False): + pass + assert set(host._live_scroll_rebuild_cost) == {"live"} + + +class TestRefresh: + def test_every_live_manager_is_dispatched(self): + host = Host([game()]) + host._refresh_live_scroll_managers() + assert host.dispatched == [host._league_registry["primary"]["managers"]["live"]] + + def test_a_dispatch_error_is_logged_not_raised(self): + host = Host([game()]) + + def broken(manager): + raise RuntimeError("can't start new thread") + + host._dispatch_switch_refresh = broken + host._refresh_live_scroll_managers() + assert any("Live scroll refresh skipped" in line for line in host.logger.lines) + + def test_with_the_host_mixin_it_runs_off_thread(self): + """The real pairing: _dispatch_switch_refresh from sports_plugin_host.""" + + class Plugin(SportsPluginHostMixin, SportsLiveScrollMixin): + LIVE_VOLATILE_FIELDS = Host.LIVE_VOLATILE_FIELDS + + def __init__(self): + self.manager = _Manager([game()]) + self._league_registry = {"a": {"enabled": True, + "managers": {"live": self.manager}}} + self.logger = _Log() + self.updated = threading.Event() + + def _ensure_manager_updated(self, manager): + self.updated.set() + + plugin = Plugin() + plugin._refresh_live_scroll_managers() + assert plugin.updated.wait(5) + + +# --------------------------------------------------------------------------- +# Host contract +# --------------------------------------------------------------------------- + +def _self_reads(): + tree = ast.parse(Path(sports_live_scroll.__file__).read_text(encoding="utf-8")) + cls = next(n for n in tree.body + if isinstance(n, ast.ClassDef) and n.name == "SportsLiveScrollMixin") + names = set() + for node in ast.walk(cls): + if (isinstance(node, ast.Attribute) and isinstance(node.ctx, ast.Load) + and isinstance(node.value, ast.Name) and node.value.id in ("self", "cls")): + names.add(node.attr) + if (isinstance(node, ast.Call) and isinstance(node.func, ast.Name) + and node.func.id == "getattr" and len(node.args) >= 2 + and isinstance(node.args[0], ast.Name) and node.args[0].id == "self" + and isinstance(node.args[1], ast.Constant)): + names.add(node.args[1].value) + return names + + +class TestHostContract: + def test_every_host_read_is_documented(self): + needed = _self_reads() - set(dir(SportsLiveScrollMixin)) + undocumented = sorted(n for n in needed if f"``{n}" not in sports_live_scroll.__doc__) + assert undocumented == [], f"read but not in the host contract: {undocumented}" + + def test_the_mixin_creates_no_attributes_of_its_own(self): + for name in ("logger", "LIVE_VOLATILE_FIELDS", "_live_scroll_fingerprints", + "_live_scroll_rebuilt_at", "_live_scroll_rebuild_cost", + "_dispatch_switch_refresh"): + assert not hasattr(SportsLiveScrollMixin, name) + assert "__init__" not in vars(SportsLiveScrollMixin) + + def test_no_name_in_common_with_the_host_mixin(self): + ours = {n for n in vars(SportsLiveScrollMixin) if not n.startswith("__")} + theirs = {n for n in vars(SportsPluginHostMixin) if not n.startswith("__")} + assert ours & theirs == set() diff --git a/test/test_sports_plugin_host.py b/test/test_sports_plugin_host.py new file mode 100644 index 00000000..2ab7c83a --- /dev/null +++ b/test/test_sports_plugin_host.py @@ -0,0 +1,280 @@ +"""src.common.sports_plugin_host: behaviour and host contract. + +Ported from the scoreboards' own tests of the same methods +(test_vegas_priority_weight.py in each, test_switch_refresh_off_render_thread.py +in baseball, basketball, football, hockey, lacrosse and ufc), against a stub +host carrying only the documented contract. Each plugin's data shape is +covered: managers as attributes and in a dict (nrl, afl), favourite fighters +(ufc), team ids (nrl), cricket's nested sides, and the celebration snapshot. +""" + +import ast +import threading +import time +from pathlib import Path + +import pytest + +from src.common import sports_plugin_host +from src.common.sports_plugin_host import SportsPluginHostMixin + + +class Host(SportsPluginHostMixin): + """The documented contract, and nothing else the mixin could lean on.""" + + def __init__(self, live_priority=True, live_content=True, vegas=None, + enabled=True, dynamic=True): + self.has_live_priority = lambda: live_priority + self.has_live_content = lambda: live_content + self.global_config = {"display": {"vegas_scroll": vegas or {}}} + self.is_enabled = enabled + self.supports_dynamic_duration = lambda: dynamic + self.refreshed = [] + self.release = threading.Event() + self.release.set() + + def _ensure_manager_updated(self, manager): + self.release.wait(5) + self.refreshed.append(manager) + + +class LiveManager: + def __init__(self, games=(), favorites=(), attr="live_games", + fav_attr="favorite_teams", celebrating=None): + setattr(self, attr, list(games)) + setattr(self, fav_attr, list(favorites)) + if celebrating is not None: + self.active_celebration = {"game": celebrating, "started_at": 0} + + +def _game(home="DAL", away="PHI", **extra): + return {"home_abbr": home, "away_abbr": away, **extra} + + +# --------------------------------------------------------------------------- +# get_vegas_priority_weight +# --------------------------------------------------------------------------- + +class TestVegasPriorityWeight: + def test_nothing_live_has_no_opinion(self): + assert Host(live_content=False).get_vegas_priority_weight() is None + assert Host(live_priority=False).get_vegas_priority_weight() is None + + def test_a_live_game_without_a_favourite_gets_the_live_weight(self): + host = Host(vegas={"live_weight": 3, "favorite_live_weight": 5}) + host.nfl_live = LiveManager([_game()], ["NYG"]) + assert host.get_vegas_priority_weight() == 3 + + def test_a_favourite_playing_gets_the_favourite_weight(self): + host = Host(vegas={"live_weight": 3, "favorite_live_weight": 7}) + host.nfl_live = LiveManager([_game()], ["dal"]) + assert host.get_vegas_priority_weight() == 7 + + def test_defaults_when_the_config_says_nothing(self): + host = Host() + host.nfl_live = LiveManager([_game()], ["NYG"]) + assert host.get_vegas_priority_weight() == 3 + host.nfl_live.favorite_teams = ["DAL"] + assert host.get_vegas_priority_weight() == 5 + + def test_matching_ignores_case_and_space(self): + host = Host() + host.nfl_live = LiveManager([_game()], [" dAl "]) + assert host.get_vegas_priority_weight() == 5 + + def test_managers_inside_a_dict_are_found(self): + """nrl and afl keep their managers in ``self._managers``.""" + host = Host() + host._managers = {"live": LiveManager([_game()], ["PHI"])} + assert host.get_vegas_priority_weight() == 5 + + def test_a_later_manager_is_still_found(self): + host = Host() + host.a = LiveManager([], ["DAL"]) + host.b = LiveManager([_game()], ["DAL"]) + assert host.get_vegas_priority_weight() == 5 + + def test_junk_in_the_game_list_is_skipped(self): + host = Host() + host.nfl_live = LiveManager(["not-a-dict", None], ["DAL"]) + assert host.get_vegas_priority_weight() == 3 + + def test_an_exception_is_none_not_a_raise(self): + host = Host() + host.has_live_content = lambda: (_ for _ in ()).throw(RuntimeError("boom")) + assert host.get_vegas_priority_weight() is None + + +class TestFavoriteTeamIsLive: + def test_ufc_fighters(self): + host = Host() + host.ufc_live = LiveManager( + [{"fighter1_name": "Jon Jones", "fighter2_name": "Stipe Miocic"}], + ["jon jones"], fav_attr="favorite_fighters") + assert host._favorite_team_is_live() is True + + def test_nrl_team_ids(self): + host = Host() + host._managers = {"live": LiveManager([_game(home_id="17", away_id="9")], ["9"])} + assert host._favorite_team_is_live() is True + + def test_cricket_nested_sides_match_by_substring(self): + host = Host() + host.cricket = LiveManager( + [{"teams": [{"name": "India Women", "abbr": "INDW"}, "junk"]}], + ["india"], attr="live_matches") + assert host._favorite_team_is_live() is True + + def test_a_celebrating_game_counts_after_it_leaves_live_games(self): + host = Host() + host.nfl_live = LiveManager([], ["DAL"], celebrating=_game()) + assert host._favorite_team_is_live() is True + + def test_no_favourites_or_only_blank_ones(self): + host = Host() + host.nfl_live = LiveManager([_game()], ["", None]) + assert host._favorite_team_is_live() is False + host.nfl_live.favorite_teams = [] + assert host._favorite_team_is_live() is False + + def test_scan_targets_walk_attributes_and_dict_values(self): + host = Host() + inner = object() + host.plain = 1 + host.bag = {"x": inner} + targets = list(host._favorite_scan_targets()) + assert 1 in targets and host.bag in targets and inner in targets + + +# --------------------------------------------------------------------------- +# _dispatch_switch_refresh +# --------------------------------------------------------------------------- + +class TestDispatchSwitchRefresh: + def test_runs_off_the_calling_thread(self): + host = Host() + host.release.clear() + manager = object() + started = time.monotonic() + host._dispatch_switch_refresh(manager) + assert time.monotonic() - started < 1.0 # did not wait on the update + assert host.refreshed == [] + host.release.set() + host._switch_refresh_threads[id(manager)].join(5) + assert host.refreshed == [manager] + + def test_one_refresh_per_manager_at_a_time(self): + host = Host() + host.release.clear() + manager = object() + host._dispatch_switch_refresh(manager) + first = host._switch_refresh_threads[id(manager)] + host._switch_refresh_at[id(manager)] = 0.0 # past the gap, still running + host._dispatch_switch_refresh(manager) + assert host._switch_refresh_threads[id(manager)] is first + host.release.set() + first.join(5) + assert host.refreshed == [manager] + + def test_dispatches_are_rate_limited(self): + host = Host() + manager = object() + host._dispatch_switch_refresh(manager) + host._switch_refresh_threads[id(manager)].join(5) + host._dispatch_switch_refresh(manager) # inside the gap + assert host.refreshed == [manager] + host._switch_refresh_at[id(manager)] -= host._SWITCH_REFRESH_MIN_GAP_SECONDS + host._dispatch_switch_refresh(manager) + host._switch_refresh_threads[id(manager)].join(5) + assert host.refreshed == [manager, manager] + + def test_threads_are_daemons_named_for_the_manager(self): + host = Host() + + class NFLLiveManager: + pass + + manager = NFLLiveManager() + host._dispatch_switch_refresh(manager) + thread = host._switch_refresh_threads[id(manager)] + thread.join(5) + assert thread.daemon and thread.name == "SwitchRefresh-NFLLiveManager" + + def test_the_gap_is_a_class_setting(self): + assert SportsPluginHostMixin._SWITCH_REFRESH_MIN_GAP_SECONDS == 5.0 + + +# --------------------------------------------------------------------------- +# The small ones +# --------------------------------------------------------------------------- + +class TestSmallHelpers: + def test_content_type_is_multi(self): + assert Host().get_vegas_content_type() == "multi" + + @pytest.mark.parametrize("enabled,dynamic,expected", [ + (True, True, True), (True, False, False), (False, True, False)]) + def test_dynamic_feature_enabled(self, enabled, dynamic, expected): + assert Host(enabled=enabled, dynamic=dynamic)._dynamic_feature_enabled() is expected + + def test_total_games_takes_the_first_list(self): + manager = type("M", (), {"live_games": None, "games_list": [1, 2], + "recent_games": [1, 2, 3]})() + assert Host._get_total_games_for_manager(manager) == 2 + assert Host._get_total_games_for_manager(None) == 0 + assert Host._get_total_games_for_manager(object()) == 0 + + def test_manager_key(self): + class NHLRecentManager: + pass + + assert Host._build_manager_key("nhl_recent", NHLRecentManager()) == "nhl_recent:NHLRecentManager" + assert Host._build_manager_key("nhl_recent", None) == "nhl_recent:None" + + +def test_it_overrides_base_plugin_when_listed_first(): + from src.plugin_system.base_plugin import BasePlugin + + class Plugin(SportsPluginHostMixin, BasePlugin): + def update(self): + pass + + def display(self, force_clear=False): + pass + + assert Plugin.get_vegas_content_type is SportsPluginHostMixin.get_vegas_content_type + assert Plugin.get_vegas_priority_weight is SportsPluginHostMixin.get_vegas_priority_weight + + +# --------------------------------------------------------------------------- +# Host contract +# --------------------------------------------------------------------------- + +def _self_reads(module, class_name): + """Every ``self.X`` / ``cls.X`` / ``getattr(self, "X")`` a mixin reads.""" + tree = ast.parse(Path(module.__file__).read_text(encoding="utf-8")) + cls = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == class_name) + names = set() + for node in ast.walk(cls): + if (isinstance(node, ast.Attribute) and isinstance(node.ctx, ast.Load) + and isinstance(node.value, ast.Name) and node.value.id in ("self", "cls")): + names.add(node.attr) + if (isinstance(node, ast.Call) and isinstance(node.func, ast.Name) + and node.func.id == "getattr" and len(node.args) >= 2 + and isinstance(node.args[0], ast.Name) and node.args[0].id == "self" + and isinstance(node.args[1], ast.Constant)): + names.add(node.args[1].value) + return names + + +class TestHostContract: + def test_every_host_read_is_documented(self): + needed = _self_reads(sports_plugin_host, "SportsPluginHostMixin") - set(dir(SportsPluginHostMixin)) + undocumented = sorted(n for n in needed if f"``{n}" not in sports_plugin_host.__doc__) + assert undocumented == [], f"read but not in the host contract: {undocumented}" + + def test_the_mixin_creates_no_attributes_of_its_own(self): + for name in ("logger", "is_enabled", "global_config", "_ensure_manager_updated", + "has_live_priority", "has_live_content", "supports_dynamic_duration"): + assert not hasattr(SportsPluginHostMixin, name) + assert "__init__" not in vars(SportsPluginHostMixin) diff --git a/test/test_sports_stage4_parity.py b/test/test_sports_stage4_parity.py new file mode 100644 index 00000000..f3cc469c --- /dev/null +++ b/test/test_sports_stage4_parity.py @@ -0,0 +1,176 @@ +"""The stage 4 sports modules still match every plugin copy that remains. + +``sports_plugin_host``, ``sports_live_scroll`` and ``sports_display_rules`` +were copied from the scoreboard plugins, which delete their copies once they +floor on the release that ships these. Until each has, a copy that changes on +its own is a fix one side has and the other lacks. + +Point LEDMATRIX_PLUGINS at a ledmatrix-plugins checkout and every method here +is compared with every plugin copy using ``scripts/sports_drift_report.py``'s +own normalisation -- the AST with docstrings, decorators and annotations +dropped, which is how the report decided these families are identical -- and, +because that normalisation drops them, the decorators are compared as well +(``@staticmethod`` vs ``@classmethod`` vs ``@contextmanager`` is behaviour). +Class constants are compared by value. A copy that is gone counts as adopted +when the plugin's file names the module. Without the variable this skips: +core CI has no plugins checkout. + +``sports_font_path`` is compared by behaviour instead (its body is the +plugins' probe with the dead branches removed); see test_sports_font_path.py. +""" + +import ast +import importlib.util +import os +from pathlib import Path + +import pytest + +from src.common import sports_display_rules, sports_live_scroll, sports_plugin_host + +REPO = Path(__file__).resolve().parents[1] + +ALL = ("afl", "baseball", "basketball", "football", "hockey", "lacrosse", + "nrl", "soccer", "ufc") +NO_UFC = tuple(s for s in ALL if s != "ufc") + + +def _is_plugin_class(name: str) -> bool: + return name.endswith("ScoreboardPlugin") + + +#: (module, mixin, plugin file, which plugin classes may hold a copy, +#: {promoted name: the plugins that carry it}). +#: A name's carriers are the plugins whose copy was compared when it moved; +#: the others never had one, and must not grow one either. +PROMOTED = [ + (sports_plugin_host, "SportsPluginHostMixin", "manager.py", _is_plugin_class, + {name: ALL for name in ( + "_SWITCH_REFRESH_MIN_GAP_SECONDS", "_dispatch_switch_refresh", + "get_vegas_priority_weight", "_favorite_team_is_live", + "_favorite_scan_targets", "_favorite_scan_games", "_game_involves", + "get_vegas_content_type", "_dynamic_feature_enabled", + "_get_total_games_for_manager", "_build_manager_key")}), + (sports_live_scroll, "SportsLiveScrollMixin", "manager.py", _is_plugin_class, + {name: NO_UFC for name in ( + "LIVE_SCROLL_REBUILD_MIN_SECONDS", "LIVE_SCROLL_REBUILD_DUTY_DIVISOR", + "_live_scroll_managers", "_refresh_live_scroll_managers", + "_live_scroll_fields", "_fingerprint_games", "_live_scroll_fingerprint", + "_live_scroll_needs_rebuild", "_note_live_scroll_built", + "_preserving_scroll_position")}), + (sports_display_rules, "SportsCardOptionsMixin", "sports.py", + lambda name: name == "SportsCore", + {"_card_option": NO_UFC, "_recent_date_text": NO_UFC}), + (sports_display_rules, "SportsGameRulesMixin", "sports.py", + lambda name: name in ("SportsCore", "SportsLive"), + {"_filtered_or_all": tuple(s for s in ALL if s != "football"), + "_effective_live_duration": NO_UFC}), +] + + +def _drift_report(): + """scripts/sports_drift_report.py, loaded by path (scripts/ is no package).""" + spec = importlib.util.spec_from_file_location( + "sports_drift_report", REPO / "scripts" / "sports_drift_report.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +DRIFT = _drift_report() + + +def _plugins_root(): + root = DRIFT.resolve_plugins_dir(os.environ.get("LEDMATRIX_PLUGINS")) + if root is None: + pytest.skip("set LEDMATRIX_PLUGINS to a ledmatrix-plugins checkout to " + "compare these modules against the plugin copies") + return root + + +def _members(tree, wanted): + """{name: node} for the functions and constants of the classes ``wanted`` accepts.""" + found = {} + for node in tree.body: + if not (isinstance(node, ast.ClassDef) and wanted(node.name)): + continue + for item in node.body: + if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)): + found.setdefault(item.name, []).append(item) + elif isinstance(item, (ast.Assign, ast.AnnAssign)) and item.value is not None: + target = item.targets[0] if isinstance(item, ast.Assign) else item.target + if isinstance(target, ast.Name): + found.setdefault(target.id, []).append(item) + return found + + +def _fingerprint(node): + """What must agree: the drift report's body digest plus the decorators, + or a constant's value.""" + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + return ("def", DRIFT._digest(node, DRIFT._Canonical()), + tuple(ast.unparse(d) for d in node.decorator_list)) + return ("value", ast.dump(node.value)) + + +def _core_members(module, mixin): + tree = ast.parse(Path(module.__file__).read_text(encoding="utf-8")) + return {name: nodes[0] for name, nodes in _members(tree, lambda n: n == mixin).items()} + + +CASES = [(module.__name__.rsplit(".", 1)[1], mixin, name) + for module, mixin, _file, _cls, carriers in PROMOTED + for name in sorted(carriers)] + + +def test_every_promoted_name_has_a_parity_case(): + """A method added to a mixin without a row above would go unchecked.""" + for module, mixin, _file, _cls, carriers in PROMOTED: + assert sorted(_core_members(module, mixin)) == sorted(carriers), mixin + + +@pytest.mark.parametrize("module_name,mixin,name", CASES, ids=lambda v: str(v)) +def test_every_remaining_plugin_copy_matches(module_name, mixin, name): + root = _plugins_root() + module, _mixin, filename, wanted, by_name = next( + row for row in PROMOTED if row[1] == mixin) + carriers = by_name[name] + ours = _fingerprint(_core_members(module, mixin)[name]) + drifted, missing, extra = [], [], [] + for sport in ALL: + path = root / f"{sport}-scoreboard" / filename + source = path.read_text(encoding="utf-8") + copies = _members(ast.parse(source), wanted).get(name, []) + if sport not in carriers: + if copies: + extra.append(sport) + continue + if not copies: + # Gone is fine once the plugin uses the module; otherwise the + # finder is not seeing its copy. + if module.__name__ not in source: + missing.append(sport) + continue + drifted += [sport for c in copies if _fingerprint(c) != ours] + assert missing == [], f"{name} not found in: {missing}" + assert extra == [], ( + f"{name} appeared in {extra}, which had no copy when it moved; " + f"decide whether {module_name} should cover it") + assert drifted == [], ( + f"{name} in {module_name} differs from the copy in: {drifted}. " + f"Port the change to both, or stop treating it as shared.") + + +def test_the_drift_report_still_calls_them_identical(): + """The report's own verdict, per family, while any copy is left.""" + root = _plugins_root() + families = DRIFT.build(root, ("sports.py", "manager.py")) + rows = {(r["file"], r["family"]): r + for r in (DRIFT.summarise(k, v) for k, v in families.items())} + not_identical = [] + for _module, _mixin, filename, _cls, carriers in PROMOTED: + for name in carriers: + row = rows.get((filename, name)) + if row is not None and row["worst_class_variants"] != 1: + not_identical.append(f"{filename}::{name}") + assert not_identical == []