diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fa8b8a0..0b8db318 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,22 @@ accepts both, but the store flags the old spelling as deprecated but drop systemctl's `stdout`/`stderr`; WiFi, unit-refresh and config-save failures say what failed and point at the log. +### New modules + +- `src/common/sports_rotation.py` -- sports consolidation family 7, once the + plugins made `_by_importance`, `_other_games_window`, + `_advance_other_games_if_due` (two bodies each), `_rotate_other_games_on_display` + (two) and `_attach_odds_to_rotated_games` (three; ufc had none) one each: + `SportsRotationMixin` (`SportsCore`), the other-games rotation. The window + advances under `_games_lock`, so `update()` and `display()` advancing it at + once no longer skip a window; the display path's due-check reads the pool + `_compose_selection` will cut, unfiltered fallback included; rotated-in + games get odds when `show_odds` is on. `_rankings_loaded()` is the seam + `_by_importance` asks (default: the abbreviation table is non-empty; + football also counts its by-id table). A plugin may inherit the mixin once + it floors on the release that ships this module, and deletes its copies + then. + ## 3.8.3 Fresh installs on Raspberry Pi OS Lite work again: 3.8.2's installer reported diff --git a/docs/SPORTS_UNIFICATION.md b/docs/SPORTS_UNIFICATION.md index 4363e43d..0f720449 100644 --- a/docs/SPORTS_UNIFICATION.md +++ b/docs/SPORTS_UNIFICATION.md @@ -128,6 +128,7 @@ deprecation cycle. | `_custom_scorebug_layout(game, draw)` | Per-sport overlay on the base layout | no-op | | `score_phrase(points, team_abbr)` | Celebration wording (`"GOOOOAAALLL!"` vs `"TOUCHDOWN!"`). `points` is the score delta, which sports with variable-value scores use to name the play | `" SCORES!"` — only consulted when `CelebrationMixin` is present | | `win_phrase(team_abbr)` | Win-celebration wording | `" WINS!"` — mixin only | +| `_rankings_loaded()` | Whether a poll loaded, so `_by_importance` orders by rank (`sports_rotation`) | `_team_rankings_cache` is non-empty. football also counts its rankings keyed by team id | | `_favorite_key(game, side)` | Which view-model field identifies a team for favorites matching. `sports_favorites` compares it, and each `favorite_teams` entry, stripped and upper-cased; a `None` matches nothing | `game["_abbr"]`. nrl returns the ESPN team id, `None` when it is missing | | `_config_schema_path()` | Plugin's `config_schema.json` — returning it routes `_get_layout_offset` through the `src.element_style` resolver (and gives it the defaults to compare against) | `None`, i.e. the classic inline `customization.layout` read | | `_font_root()` | Directory to resolve `assets/fonts` against | core install root | @@ -268,7 +269,7 @@ 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) +### Stage 4: the identical sweep (done: core 3.8.0, adopted) 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 @@ -303,7 +304,7 @@ Left in the plugins, though identical: renderers) is already core's, in `SportsHelpersMixin`; a renderer that wants it can inherit that. -### Family 5: the game-over check (core done; adoption waits for a release) +### Family 5: the game-over check (done: core 3.8.1, adopted) The pilot of the method below. ledmatrix-plugins `scripts/test_game_over_check.py` (#621) pinned 3,115 answers across the nine plugins first; the reconcile @@ -315,9 +316,10 @@ clock rule (65 cells), baseball's dormant one (53, every one a game with a cells in hockey, basketball, football and lacrosse). The harness renders were pixel-identical. `src/common/sports_game_over.py` holds the body; `test/test_sports_game_over_parity.py` compares it, and each plugin's -`FINAL_PERIOD`, with the plugin copies. +`FINAL_PERIOD`, with the plugin copies. Core 3.8.1 shipped it, and all nine +scoreboards inherit it and floor on 3.8.1 (ledmatrix-plugins #631). -### Family 6: favourite matching (core done; adoption waits for a release) +### Family 6: favourite matching (done: core 3.8.2, adopted) ledmatrix-plugins `scripts/test_favourite_matching.py` (#634) pinned 204 rows across the nine plugins first: `_is_favorite_game` on each manager role, the @@ -337,7 +339,8 @@ each), nrl's key (6) and its "None" match (6), and the INFO line in baseball, football and ufc. The harness renders were byte-identical. `src/common/sports_favorites.py` holds the bodies, one mixin per carrying class; `test/test_sports_favorites_parity.py` compares them with the plugin copies and checks that only nrl overrides -`_favorite_key`. +`_favorite_key`. Core 3.8.2 shipped it, and all nine scoreboards inherit it and +floor on 3.8.2 (ledmatrix-plugins #637). Left for later families: the live screens' favourites-only filter (`_classify_live_game` and its inline copies) and favourites-first sort still @@ -346,6 +349,38 @@ compare abbreviations exactly, and abbreviation (or by `_team_in` where a plugin has one) instead of through `_favorite_key`. The result-colour helpers also wait (decision above). +### Family 7: the other-games rotation (core done; adoption waits for a release) + +ledmatrix-plugins `scripts/test_other_games_rotation.py` (#640) pinned 96 rows +across the nine plugins first: `_by_importance` per rankings table, core's +`_favorites_first` pools per favourites, quality, divisions and rankings, the +window over time, the real `update()` followed by `display()`'s rotation call +(the list, the card on screen, redraws and how often the list is recomposed), +`update()` and `display()` advancing the window in sequence and interleaved on +two threads, odds for rotated-in games, and `favorite_rotation_boost`'s +switch order. The reconcile (ledmatrix-plugins #641) +made `_by_importance`, `_other_games_window`, `_advance_other_games_if_due`, +`_rotate_other_games_on_display` and `_attach_odds_to_rotated_games` one body +on `SportsCore` (ufc gains the odds helper), with `_rankings_loaded` as the +seam `_by_importance` asks: the abbreviation table by default, football's +override also counts its rankings keyed by team id. Of 864 cells only those +the decisions below explain changed: the window lock (the interleaved row, in +the seven plugins with a reachable `update()` other than football), the +due-check's fallback pool (two rows in the same seven) and ufc's rotated-in +odds (one cell). The harness renders were byte-identical (208 PNGs). +`src/common/sports_rotation.py` holds the bodies in one mixin, +`SportsRotationMixin`; `test/test_sports_rotation_parity.py` compares them +with the plugin copies and checks that only football overrides +`_rankings_loaded`. + +Left for later families: in eight plugins the no-favourites branch of +`update()` still picks a fixed "next N" through `_filtered_or_all` and never +builds the pools, so nothing rotates on a board with no favourites; football +routes it through `_favorites_first(games, 0, N)` (decision below, family 13). +ufc's MMA managers override `update()` and never build the pools, so the +rotation is dormant there. `_best_rank`, `_is_ranked_game` and +`_passes_other_filters` are family 8. + ### Why the method changes Byte-identical promotion has nearly run dry. Measured on ledmatrix-plugins @@ -429,10 +464,10 @@ 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) 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; one seam, `FINAL_PERIOD`. The pilot for the procedure. Reconciled to one body and promoted as `sports_game_over`; adoption waits for the release that ships it. See [Family 5](#family-5-the-game-over-check-core-done-adoption-waits-for-a-release) | -| 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. Reconciled to one body each and promoted as `sports_favorites`; adoption waits for the release that ships it. See [Family 6](#family-6-favourite-matching-core-done-adoption-waits-for-a-release) | -| 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 | +| 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. Done: core 3.8.0, adopted (ledmatrix-plugins #594); see [Stage 4](#stage-4-the-identical-sweep-done-core-380-adopted) | +| 5 | Game-over check | `SportsLive._is_game_really_over` (5) | Pure logic, no pixels; one seam, `FINAL_PERIOD`. The pilot for the procedure. Reconciled to one body and promoted as `sports_game_over`; shipped in 3.8.1 and adopted. See [Family 5](#family-5-the-game-over-check-done-core-381-adopted) | +| 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. Reconciled to one body each and promoted as `sports_favorites`; shipped in 3.8.2 and adopted. See [Family 6](#family-6-favourite-matching-done-core-382-adopted) | +| 7 | Other-games rotation | `_by_importance`, `_other_games_window`, `_advance_other_games_if_due` (2 each: football), `_rotate_other_games_on_display` (2: ufc), with `_attach_odds_to_rotated_games` (3; ufc had none) | One outlier each; football carried two fixes the other eight lacked. Reconciled to one body each, on a `_rankings_loaded` seam, and promoted as `sports_rotation`; adoption waits for the release that ships it. See [Family 7](#family-7-the-other-games-rotation-core-done-adoption-waits-for-a-release) | | 8 | Rankings | `_fetch_team_rankings` (3), `_choose_poll` (3), `_load_division_team_ids`, `_passes_other_filters`, `_best_rank`, `_is_ranked_game` (2 each: football) | Needs 7; the rank badge and the "ranked only" filter read it | | 9 | Live fetch and odds | `_fetch_todays_games` (5), `_fetch_odds` (3), `_attach_odds_to_rotated_games` (3) | The prerequisite for one shared ESPN poller across plugins | | 10 | View model | `_extract_game_details_common` (9 of 9) | Every renderer reads it; its keys are additive-only, so reconcile to the superset and leave sport extras in `_extract_game_details` | @@ -486,11 +521,21 @@ suspected behaviour that needs a payload or a rig to confirm first. nine. ufc stays on the shared body, dormant: its favourites are fighters, which its MMA managers match themselves (a follow-up). Fix ported: only a game with an id can be a duplicate in the selection methods. -- **7, other-games rotation.** football advances the rotation window under - `_games_lock` (update() and display() both advance it; interleaved, a - window of games is skipped) and fixes a favourites-only pool that recomposed - the list on every frame. Port both. ufc does not attach odds to fights - rotated in: decide whether rotated fights show odds. +- **7, other-games rotation. Decided 2026-10-09, done:** two fixes ported + from football. The window advances under `_games_lock`: `update()` and + `display()` both advance it, and interleaved, each added a width and a + window of games was never shown. The display path's due-check looks at the + pool `_compose_selection` actually cuts from, including the unfiltered + fallback when nothing else survived. The other eight never rotated that + fallback between fetches (it moved only when `update()` ran, then several + windows at once); guessing it whenever the filtered pool was empty would + recompose an identical list on every frame while a favourite played. + Rotated-in fights in ufc follow its `show_odds` like every other fight (no + separate toggle; the rotation is dormant in ufc today, so no board + changes). `_rankings_loaded` is a seam (default: the abbreviation table is + non-empty; football counts its by-id table too). Kept for family 13: the + no-favourites branch of `update()` keeps its fixed "next N" in the eight + plugins that have it, rather than football's rotating pools. - **8, rankings.** (a) afl, basketball, nrl and soccer turn a *standings* payload into ranks (a pro league's standings position becomes the rank badge); baseball, hockey, lacrosse, ufc and football do not. Which is diff --git a/mypy-clean.txt b/mypy-clean.txt index a5b8fae0..d12993f7 100644 --- a/mypy-clean.txt +++ b/mypy-clean.txt @@ -41,6 +41,7 @@ src/common/sports_font_path.py src/common/sports_game_over.py src/common/sports_live_scroll.py src/common/sports_plugin_host.py +src/common/sports_rotation.py src/common/sports_scroll.py src/common/sports_timezone.py src/common/sports_vegas.py diff --git a/src/common/README.md b/src/common/README.md index 6dbb924b..ea47aaf0 100644 --- a/src/common/README.md +++ b/src/common/README.md @@ -53,6 +53,7 @@ Rules for the package: | [`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) | 3.8.0 | | [`sports_plugin_host`](#sports_plugin_host) | Helpers of a scoreboard's plugin class (`manager.py`) | Yes (scoreboards) | 3.8.0 | +| [`sports_rotation`](#sports_rotation) | Which non-favourite games a scoreboard shows, and when the slice moves | Yes (scoreboards) | next release | | [`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 | @@ -368,6 +369,21 @@ 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_rotation + +[`sports_rotation.py`](sports_rotation.py). Sports family 7: +`SportsRotationMixin` (`SportsCore`), the other-games rotation. +`_by_importance` orders the non-favourite pool best matchup first, one game per +team, when `_rankings_loaded()` says a poll loaded (football overrides that to +count its by-id rankings). `_other_games_window` cuts the slice on screen, +advancing by its width every `other_rotation_interval_seconds` under +`_games_lock`, catching up on missed intervals and wrapping. +`_rotate_other_games_on_display` (with `_advance_other_games_if_due`) re-cuts +it from `display()` between fetches, looking at the pool `_compose_selection` +will cut, unfiltered fallback included, and keeps the card on screen when it +survives; `_attach_odds_to_rotated_games` fetches odds for the games it brought +in when `show_odds` is on. + ### sports_scroll [`sports_scroll.py`](sports_scroll.py). `SportsScrollDisplay` and diff --git a/src/common/sports_rotation.py b/src/common/sports_rotation.py new file mode 100644 index 00000000..22735af5 --- /dev/null +++ b/src/common/sports_rotation.py @@ -0,0 +1,342 @@ +"""Which non-favourite games a scoreboard shows, and when the slice moves (sports family 7). + +The scoreboards' other-games rotation, reconciled in ledmatrix-plugins +(family 7) from two bodies each into one, and copied here under the existing +names. All of it lives on the plugins' ``SportsCore``, so there is one mixin, +``SportsRotationMixin``: + +- ``_by_importance(games, newest_first)``: the non-favourite pool, best + matchup first and one game per team, when a poll has loaded; kickoff order + otherwise. ``SportsCoreSharedMixin._favorites_first`` asks it for both the + filtered and the unfiltered pool. +- ``_other_games_window(others, limit)``: the slice of a pool on screen now. + It advances by its own width every ``other_rotation_interval_seconds`` + (catching up on intervals that passed unseen) and wraps. + ``SportsCoreSharedMixin._compose_selection`` cuts with it. +- ``_advance_other_games_if_due`` and ``_rotate_other_games_on_display``: + the display path's re-cut between fetches. The plugins' ``display()`` calls + ``_rotate_other_games_on_display`` before its dwell check; the card on + screen keeps its place if it survived the cut. +- ``_attach_odds_to_rotated_games``: odds for the games a rotation + brought in, on a daemon thread, when ``show_odds`` is on and there is an + odds manager. +- ``_rankings_loaded()``: the override point (below). + +THE RULES +--------- +``update()`` (through ``_favorites_first``) and ``display()`` (through +``_rotate_other_games_on_display``) both advance the window, so the advance +holds ``_games_lock`` (the plugins' RLock): interleaved without it, both saw +the interval elapse and each added a width, skipping a window nobody saw. + +The display path's due-check looks at the pool ``_compose_selection`` will +actually cut from: the filtered others, or, when nothing survived at all (no +favourite to show either), the unfiltered fallback. Guessing the unfiltered +pool whenever the others were empty recomposed an identical list on every +frame while a favourite was playing; never looking at it left the fallback +moving only when ``update()`` ran. + +Rotated-in games get odds by the same rule as the games ``update()`` picks: +``show_odds`` on (decided 2026-10-09, which brought ufc-scoreboard in line). + +OVERRIDE POINT +-------------- +``_rankings_loaded()`` -- did a poll load? ``_by_importance`` keeps kickoff +order when not. The default is ``_team_rankings_cache`` being non-empty. +football-scoreboard overrides it to count its rankings keyed by ESPN team id +(``_ranked_team_ids``) as well, the table its ``_best_rank`` reads first. + +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 mixin reads; the host-contract +test in ``test/test_sports_rotation.py`` fails if a read is added without being +listed here. Every scoreboard ``SportsCore`` supplies all of them. + +- ``other_rotation_interval_seconds`` -- seconds per window; 0 pins it. +- ``_other_window_start`` and ``_other_window_rotated_at`` -- the window's + position and the monotonic time it last moved (0: never cut). Written here. +- ``_games_lock`` -- the re-entrant lock around ``games_list``. +- ``_selection_pools`` -- what ``_favorites_first`` settled (``favorites``, + ``others``, ``unfiltered``, ``favorite_limit``, ``other_limit``); read with + getattr, so a manager that never built it does not rotate. +- ``_compose_selection`` -- from ``SportsCoreSharedMixin``. +- ``_best_rank`` -- a game's better poll position, 99 if neither side ranks + (family 8, still per-plugin). +- ``_team_rankings_cache`` -- read with getattr by the default + ``_rankings_loaded``. +- ``games_list``, ``current_game``, ``current_game_index``, + ``last_game_switch`` -- the switch-mode state a rotation swaps. +- ``logger`` -- one INFO line per rotation. +- ``show_odds`` and ``odds_manager`` (read with getattr), ``mode_config`` + (``odds_update_interval``), ``sport``, ``league`` and ``sport_key`` -- the + rotated-in odds fetch. + +The methods read the game dict's ``id``, ``start_time_utc``, ``home_abbr``, +``away_abbr`` and ``odds``; any may be missing. + +BASE ORDER +---------- +No other mixin defines these methods, so the position in the bases does not +change which body runs; a method on the plugin's own class (football's +``_rankings_loaded``) still wins. The mixin has no ``__init__``; the state it +writes is the host's. +""" + +import threading +import time +from datetime import datetime, timezone +from typing import Any, Callable, Dict, List, Optional + + +class SportsRotationMixin: + """``SportsCore``'s other-games rotation. See module docstring.""" + + # The host contract, declared for type checking only. + other_rotation_interval_seconds: int + _other_window_start: int + _other_window_rotated_at: float + _games_lock: Any + _compose_selection: Callable[[], List[Dict]] + _best_rank: Callable[[Dict], int] + games_list: List[Dict] + current_game: Optional[Dict] + current_game_index: int + last_game_switch: float + logger: Any + odds_manager: Any + mode_config: Dict[str, Any] + sport: str + league: str + sport_key: str + + def _rankings_loaded(self) -> bool: + """Did a poll load at all? The ranking reads fail open when not. + + The seam ``_by_importance`` asks before ordering by rank: by default + the abbreviation table (``_team_rankings_cache``). football-scoreboard + overrides it to count its table keyed by ESPN team id as well. + """ + return bool(getattr(self, "_team_rankings_cache", None)) + + def _by_importance(self, games: List[Dict], newest_first: bool = False) -> List[Dict]: + """Non-favourite games, best matchup first. + + The quality filter already declares the poll to be the thing worth + showing -- and then selection ignored the number entirely. #1 against #2 + and #25 against an unranked side were interchangeable, and whichever + kicked off sooner took the slot, so the biggest game of the week had no + better chance of being seen than any other. + + The rotation still walks the entire pool, so nothing is lost and + coverage is unchanged; it now walks DOWN the ladder instead of along the + clock. The first window after a restart holds the best games available + rather than the earliest ones, which is the case that matters -- a board + is far more often freshly started or freshly updated than three hours + into a lap. + + Ties fall back to kickoff order, and a league with no poll keeps the + chronological order it had, because there is nothing to sort on. + + One game per team, which is the part rank ordering cannot do without. + The upcoming pool is not a week of fixtures -- for college football it + is the whole season, 947 games on a real board -- so ordering by rank + alone put all twelve of the #1 team's games above the #2 team's first + one, and the board walked one team's season. Measured on ledpi the + moment this shipped: KENT@OSU, ILL@OSU, then OSU@IOWA, MD@OSU. Keeping + only the soonest game per team makes the pool "what each team has + next", which is both what an upcoming board means and inherently + near-term, since a team's next game is by definition the closest one. + """ + if not self._rankings_loaded(): + return games + if newest_first: + def key(game): + when = game.get("start_time_utc") or datetime.min.replace(tzinfo=timezone.utc) + return (self._best_rank(game), -when.timestamp()) + else: + def key(game): + when = game.get("start_time_utc") or datetime.max.replace(tzinfo=timezone.utc) + return (self._best_rank(game), when.timestamp()) + + # Soonest-first so "one per team" keeps each team's NEXT game, then + # re-ordered by rank. Doing it the other way round would keep whichever + # of a team's games happened to sort first by rank, which for a game + # between two ranked sides is not necessarily the next one. + soonest_first = sorted( + games, + key=lambda g: (g.get("start_time_utc") + or datetime.max.replace(tzinfo=timezone.utc)).timestamp(), + reverse=newest_first, + ) + seen, once_each = set(), [] # type: ignore[var-annotated] + for game in soonest_first: + sides = (game.get("home_abbr"), game.get("away_abbr")) + if any(side in seen for side in sides): + continue + seen.update(s for s in sides if s) + once_each.append(game) + return sorted(once_each, key=key) + + def _other_games_window(self, others: List[Dict], limit: int) -> List[Dict]: + """A rotating slice of the non-favourite games. + + The window advances by its own width, so consecutive windows are + disjoint and the board walks the schedule rather than resampling the + same front of it. It wraps, so a short list still cycles. + + Advancing is time-based, not per-update. update() runs every 30s; if + the window moved with it the games list would change identity on every + pass, reset the display index, and no card past the first would ever be + reached. + """ + if limit <= 0 or not others: + return [] + if len(others) <= limit: + return others[:limit] + + interval = self.other_rotation_interval_seconds + # Under the lock: update() advances this window through + # _favorites_first, and display() advances it through + # _rotate_other_games_on_display, so the read-modify-write below has two + # writers. Interleaved, both can see the interval elapsed and each add a + # width, skipping a window of games nobody ever sees. _games_lock is an + # RLock and the display path takes it again straight after, which is + # why this can be the same lock rather than another one to reason about. + with self._games_lock: + if interval > 0: + now = time.monotonic() + if not self._other_window_rotated_at: + self._other_window_rotated_at = now + elapsed = now - self._other_window_rotated_at + if elapsed >= interval: + # Advance by however many intervals actually passed. The + # board is not guaranteed to be running -- or this mode + # displayed -- for every one of them, and stepping once + # would let a plugin that sat idle crawl a step at a time. + steps = int(elapsed // interval) + self._other_window_start += steps * limit + self._other_window_rotated_at = now + + start = self._other_window_start % len(others) + window = others[start:start + limit] + if len(window) < limit: + window += others[:limit - len(window)] + return window + + def _rotate_other_games_on_display(self) -> bool: + """Swap in a freshly cut slice when the rotation interval has passed. + + Returns True when the list changed, so the caller forces a redraw. + + The card currently on screen keeps its place if it survived the cut: + rotating the pool should change what comes NEXT, not interrupt whatever + someone is reading. Only when it is gone does the index reset, and then + the dwell resets with it so the replacement gets a full turn rather than + the tail of its predecessor's. + """ + rebuilt = self._advance_other_games_if_due() + if not rebuilt: + return False + with self._games_lock: + if [g.get("id") for g in rebuilt] == [g.get("id") for g in self.games_list]: + return False + current_id = (self.current_game or {}).get("id") + self.games_list = rebuilt + for index, game in enumerate(rebuilt): + if game.get("id") == current_id: + self.current_game_index = index + self.current_game = game + break + else: + self.current_game_index = 0 + self.current_game = rebuilt[0] + self.last_game_switch = time.time() + self.logger.info( + "Rotated the other-games slice to: %s", + ", ".join("%s@%s" % (g.get("away_abbr"), g.get("home_abbr")) + for g in rebuilt), + ) + self._attach_odds_to_rotated_games(rebuilt) + return True + + def _attach_odds_to_rotated_games(self, games: List[Dict]) -> None: + """Fetch odds for freshly rotated-in games off the display path. + + The rotation deliberately does no network work, but odds are only + attached in update(), and for an upcoming list that runs hourly -- + far longer than any rotated-in card stays on screen. Every slice cut + between updates therefore rendered without a line even though ESPN + had one, while the favourites, which survive every cut, kept the + odds update() gave them. + + One daemon thread per rotation, bounded by the slice size rather + than the pool's: only games actually going on screen are asked + about, and get_odds caches per game, so one re-entering the window + inside its TTL costs a cache lookup rather than a request. The + thread mutates each game dict in place; the renderer re-reads + game["odds"] every frame, so a line appears as soon as its fetch + lands, mid-dwell included. Same as football-scoreboard #343. + """ + # getattr: managers are built partially in places (the plugin tests + # among them) that never set show_odds or an odds manager. + if not getattr(self, "show_odds", False) or not getattr(self, "odds_manager", None): + return + pending = [g for g in games if not g.get("odds")] + if not pending: + return + interval = self.mode_config.get("odds_update_interval", 3600) + + def fetch() -> None: + for game in pending: + try: + odds = self.odds_manager.get_odds( + sport=self.sport, + league=self.league, + event_id=game["id"], + update_interval_seconds=interval, + ) + if odds: + game["odds"] = odds + except Exception as exc: + self.logger.debug( + "Odds fetch for rotated-in game %s failed: %s", + game.get("id"), exc) + + threading.Thread( + target=fetch, daemon=True, + name="%s-rotated-odds" % self.sport_key).start() + + def _advance_other_games_if_due(self) -> List[Dict]: + """Re-cut the non-favourite slice on the display path, or [] if not due. + + Costs one list slice and a sort of at most a few games -- no fetch, no + parsing, no network. Returns the new list rather than assigning it, + because the two callers keep different bookkeeping around games_list + and both hold their own lock while they swap it in. + """ + pools = getattr(self, "_selection_pools", None) + if not pools: + return [] + interval = self.other_rotation_interval_seconds + limit = max(0, pools["other_limit"]) + # Whichever pool _compose_selection will actually slice. It falls back + # to the unfiltered list only when NOTHING survived -- favourites + # included. With a favourite playing and the filters rejecting every + # other game, compose keeps the favourites-only list, so guessing the + # unfiltered pool here made the due-check fire on every display() call + # forever, recomposing an identical list each frame. + others = pools["others"] + favorites_fill = pools["favorites"] and pools["favorite_limit"] > 0 + if not others and limit > 0 and not favorites_fill: + others = pools["unfiltered"] + if interval <= 0 or limit <= 0 or len(others) <= limit: + return [] # pinned, favourites-only, or nothing to rotate through + if not self._other_window_rotated_at: + return [] # no window has been cut yet; update() does the first + if time.monotonic() - self._other_window_rotated_at < interval: + return [] + return self._compose_selection() diff --git a/src/common/sports_shared.py b/src/common/sports_shared.py index e6d6403d..a02c896c 100644 --- a/src/common/sports_shared.py +++ b/src/common/sports_shared.py @@ -54,12 +54,14 @@ State: ``config``, ``fonts``, ``logger``, ``display_width``, ``display_height``, Methods that stay per-plugin, because they are not identical across the eight (or, for ``_get_timezone``, because they bind per-plugin modules): -``_get_layout_offset``, ``_by_importance``, ``_other_games_window``, -``_upcoming_date_and_time_text``, ``_extract_game_details_common``, -``_load_division_team_ids``, ``_get_timezone``, ``_is_favorite_game``, -``_is_ranked_game``, ``_passes_other_filters``. (``_is_game_really_over``, -which ``_detect_stale_games`` below calls, was here too until the plugins -reconciled it; it is now ``src.common.sports_game_over``.) +``_get_layout_offset``, ``_upcoming_date_and_time_text``, +``_extract_game_details_common``, ``_load_division_team_ids``, +``_get_timezone``, ``_is_ranked_game``, ``_passes_other_filters``. Three +families were here until the plugins reconciled them: ``_is_game_really_over``, +which ``_detect_stale_games`` below calls, is now ``src.common.sports_game_over``; +``_is_favorite_game`` is ``src.common.sports_favorites``; and +``_by_importance`` and ``_other_games_window``, which ``_favorites_first`` and +``_compose_selection`` below call, are ``src.common.sports_rotation``. Of the fourteen shared class constants, thirteen are identical everywhere and live here. Only ``_SCORE_PROBE_TEXT`` varies -- afl and basketball reach three digits diff --git a/test/test_sports_rotation.py b/test/test_sports_rotation.py new file mode 100644 index 00000000..f68ce3f2 --- /dev/null +++ b/test/test_sports_rotation.py @@ -0,0 +1,439 @@ +"""src.common.sports_rotation: behaviour, the lock, the seam, host contract. + +The cases follow ledmatrix-plugins' ``scripts/test_other_games_rotation.py`` +(the tables the family 7 reconcile was checked against): the expected values +are its columns, for the abbreviation-ranked sports and, where the +``_rankings_loaded`` seam is overridden, for football. The host is the mixin +over core's ``SportsCoreSharedMixin`` (``_favorites_first`` and +``_compose_selection`` call into it) with the family-8 ranking reads copied in, +and ``_favorites_first`` stands in for the plugins' ``update()``. +""" + +import ast +import logging +import threading +from datetime import datetime, timedelta, timezone +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from src.common import sports_rotation +from src.common.sports_favorites import SportsFavoritesMixin +from src.common.sports_helpers import SportsHelpersMixin +from src.common.sports_rotation import SportsRotationMixin +from src.common.sports_shared import SportsCoreSharedMixin + +LOG = logging.getLogger("test_sports_rotation") +NOW = datetime(2026, 10, 9, 12, tzinfo=timezone.utc) +T0 = 100_000.0 # not 0: the window reads a zero stamp as "never cut" + +TEAM = {str(n): chr(64 + n) * 3 for n in range(1, 17)} # 1 AAA ... 16 PPP +ID_OF = {abbr: int(tid) for tid, abbr in TEAM.items()} + + +def game(gid, home, away, hours, recent=False): + g = {"id": gid, "home_id": home, "away_id": away, + "home_abbr": TEAM[home], "away_abbr": TEAM[away]} + if hours is not None: + g["start_time_utc"] = NOW + timedelta(hours=-hours if recent else hours) + return g + + +class Host(SportsRotationMixin, SportsCoreSharedMixin, SportsFavoritesMixin, + SportsHelpersMixin): + """A scoreboard SportsCore as far as the rotation reaches.""" + + league = sport = "test" + sport_key = "test" + + def __init__(self, favorites=(), interval=60, quality="any", rankings=None): + self.logger = LOG + self.favorite_teams = list(favorites) + self.other_rotation_interval_seconds = interval + self.other_games_min_quality = quality + self.other_games_divisions = [] + self._team_rankings_cache = dict(rankings or {}) + self._ranking_coverage_logged_at = 0.0 + self._other_window_start = 0 + self._other_window_rotated_at = 0.0 + self._games_lock = threading.RLock() + self.games_list, self.current_game, self.current_game_index = [], None, 0 + self.last_game_switch = 0.0 + self.mode_config = {} + + # Family 8, still per plugin: the abbreviation sports' bodies. + def _is_ranked_game(self, game): + rankings = self._team_rankings_cache + return bool(rankings.get(game.get("home_abbr"), 0) + or rankings.get(game.get("away_abbr"), 0)) + + def _best_rank(self, game): + rankings = self._team_rankings_cache + ranked = [r for r in (rankings.get(game.get("home_abbr"), 0), + rankings.get(game.get("away_abbr"), 0)) if r] + return min(ranked) if ranked else 99 + + def _passes_other_filters(self, game): + return not (self.other_games_min_quality == "ranked" + and self._team_rankings_cache and not self._is_ranked_game(game)) + + +class IdRankedHost(Host): + """football's override of the seam, and its by-id first rank read.""" + + def __init__(self, ranked_ids=None, **kw): + super().__init__(**kw) + self._ranked_team_ids = dict(ranked_ids or {}) + + def _rankings_loaded(self): + return bool(self._ranked_team_ids or self._team_rankings_cache) + + def _best_rank(self, game): + try: + ids = [int(game["home_id"]), int(game["away_id"])] + except (KeyError, TypeError, ValueError): + ids = [] + if self._ranked_team_ids and ids: + found = [r for r in (self._ranked_team_ids.get(i, 0) for i in ids) if r] + return min(found) if found else 99 + return super()._best_rank(game) + + +class Clock: + def __init__(self): + self.now = T0 + + def __call__(self): + return self.now + + +@pytest.fixture +def clock(monkeypatch): + c = Clock() + monkeypatch.setattr(sports_rotation.time, "monotonic", c) + monkeypatch.setattr(sports_rotation.time, "time", c) + return c + + +def ids(games): + return ",".join(g["id"] for g in games) or "none" + + +# --------------------------------------------------------------------------- +# _by_importance and the _rankings_loaded seam +# --------------------------------------------------------------------------- + +def importance_slate(): + no_abbr = game("n9", "15", "16", 9) + del no_abbr["home_abbr"], no_abbr["away_abbr"] + return [game("i3", "6", "7", 3), game("i1", "1", "3", 1), game("i2", "4", "5", 2), + game("i4", "8", "9", 4), game("i5", "2", "10", 5), game("i6", "11", "12", 6), + game("i7", "4", "11", 7), game("i8", "13", "14", None), no_abbr, + game("i10", "2", "15", 10)] + + +RANKED = {"HHH": 1, "FFF": 2, "DDD": 3, "BBB": 4} +TIED = {"HHH": 1, "FFF": 2, "DDD": 2, "BBB": 4} +AS_LISTED = "i3,i1,i2,i4,i5,i6,i7,i8,n9,i10" + + +@pytest.mark.parametrize("rankings, newest, expected", [ + ({}, False, AS_LISTED), + ({}, True, AS_LISTED), + (RANKED, False, "i4,i3,i2,i5,i1,i6,n9,i8"), + (RANKED, True, "i4,i3,i7,i10,n9,i1,i8"), + (TIED, False, "i4,i2,i3,i5,i1,i6,n9,i8"), # a tie keeps kickoff order + (TIED, True, "i4,i7,i3,i10,n9,i1,i8"), +]) +def test_by_importance(rankings, newest, expected): + assert ids(Host(rankings=rankings)._by_importance(importance_slate(), newest)) == expected + + +BY_ID = {ID_OF[a]: r for a, r in RANKED.items()} + + +@pytest.mark.parametrize("abbr, by_id, newest, default, overridden", [ + ({}, BY_ID, False, AS_LISTED, "i4,i3,i2,i5,i1,i6,n9,i8"), + ({}, BY_ID, True, AS_LISTED, "i4,i3,i7,i10,n9,i1,i8"), + ({"HHH": 1, "FFF": 2}, {ID_OF["CCC"]: 1}, False, + "i4,i3,i1,i2,i5,i6,n9,i8", "i1,i2,i3,i4,i5,i6,n9,i8"), + ({"HHH": 1, "FFF": 2}, {ID_OF["CCC"]: 1}, True, + "i4,i3,i10,n9,i7,i1,i8", "i1,i10,n9,i7,i4,i3,i8"), +]) +def test_the_rankings_loaded_seam(abbr, by_id, newest, default, overridden): + """The default asks the abbreviation table; football counts its id table too.""" + plain = Host(rankings=abbr) + plain._ranked_team_ids = by_id # read by nothing in the default + assert ids(plain._by_importance(importance_slate(), newest)) == default + football = IdRankedHost(ranked_ids=by_id, rankings=abbr) + assert ids(football._by_importance(importance_slate(), newest)) == overridden + + +def test_rankings_loaded_default(): + assert Host()._rankings_loaded() is False + assert Host(rankings={"AAA": 1})._rankings_loaded() is True + + +# --------------------------------------------------------------------------- +# _other_games_window +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("size, limit, interval, expected", [ + (0, 3, 60, "none / none / none / none / none / none"), + (7, 0, 60, "none / none / none / none / none / none"), + (1, 3, 60, "o1 / o1 / o1 / o1 / o1 / o1"), + (2, 2, 60, "o1,o2 / o1,o2 / o1,o2 / o1,o2 / o1,o2 / o1,o2"), + (7, 3, 0, "o1,o2,o3 / o1,o2,o3 / o1,o2,o3 / o1,o2,o3 / o1,o2,o3 / o1,o2,o3"), + (7, 2, 60, "o1,o2 / o1,o2 / o3,o4 / o3,o4 / o5,o6 / o4,o5"), + (7, 3, 60, "o1,o2,o3 / o1,o2,o3 / o4,o5,o6 / o4,o5,o6 / o7,o1,o2 / o2,o3,o4"), +]) +def test_the_window_advances_catches_up_and_wraps(clock, size, limit, interval, expected): + """At 0, 30, 60, 119, 120 and 300 s: 300 is three intervals after 120.""" + host = Host(interval=interval) + pool = [{"id": f"o{n}"} for n in range(1, size + 1)] + seen = [] + for t in (0, 30, 60, 119, 120, 300): + clock.now = T0 + t + seen.append(ids(host._other_games_window(pool, limit))) + assert " / ".join(seen) == expected + + +# --------------------------------------------------------------------------- +# The display path: _advance_other_games_if_due, _rotate_other_games_on_display +# --------------------------------------------------------------------------- + +UPDATE_SLATE = (("u1", "1", "2", 1), ("u2", "3", "4", 2), ("u3", "5", "6", 3), + ("u4", "7", "8", 4), ("u5", "2", "3", 5), ("u6", "4", "5", 6), + ("u7", "6", "7", 7), ("u8", "1", "8", 8)) + + +def updated(clock, favorites, favorite_limit=2, other_limit=2, recent=False, **kw): + """What the plugins' update() leaves: _favorites_first's pick, its last card on screen.""" + host = Host(favorites=favorites, **kw) + clock.now = T0 + games = [game(*row, recent=recent) for row in UPDATE_SLATE] + host.games_list = host._favorites_first(games, favorite_limit, other_limit, + newest_first=recent) + if host.games_list: + host.current_game_index = len(host.games_list) - 1 + host.current_game = host.games_list[-1] + return host + + +def ticks(clock, host): + """games_list@on screen after update() and each tick (^: redraw), and #composes.""" + calls = [] + compose = host._compose_selection + + def counted(): + calls.append(1) + return compose() + host._compose_selection = counted + + def shown(): + return f"{ids(host.games_list)}@{(host.current_game or {}).get('id')}" + out = [shown()] + for t in (30, 60, 90, 125, 305): + clock.now = T0 + t + redraw = host._rotate_other_games_on_display() + out.append(shown() + ("^" if redraw else "")) + return f"{' > '.join(out)} #{len(calls)}" + + +ROTATION = { + "AAA, 2 others, 60s": ( + {"favorites": ["AAA"]}, + "u1,u2,u3,u8@u8 > u1,u2,u3,u8@u8 > u1,u4,u5,u8@u8^ > u1,u4,u5,u8@u8 > " + "u1,u6,u7,u8@u8^ > u1,u6,u7,u8@u8 #3"), + "pinned (0s)": ( + {"favorites": ["AAA"], "interval": 0}, + "u1,u2,u3,u8@u8 > u1,u2,u3,u8@u8 > u1,u2,u3,u8@u8 > u1,u2,u3,u8@u8 > " + "u1,u2,u3,u8@u8 > u1,u2,u3,u8@u8 #0"), + "0 others": ( + {"favorites": ["AAA"], "other_limit": 0}, + "u1,u8@u8 > u1,u8@u8 > u1,u8@u8 > u1,u8@u8 > u1,u8@u8 > u1,u8@u8 #0"), + "no favourite slots, 3 others": ( + {"favorites": [], "favorite_limit": 0, "other_limit": 3}, + "u1,u2,u3@u3 > u1,u2,u3@u3 > u4,u5,u6@u4^ > u4,u5,u6@u4 > u1,u7,u8@u1^ > " + "u1,u2,u8@u1^ #3"), + "ranked, poll on the slate": ( + {"favorites": ["AAA"], "quality": "ranked", "rankings": RANKED}, + "u1,u3,u4,u8@u8 > u1,u3,u4,u8@u8 > u1,u2,u4,u8@u8^ > u1,u2,u4,u8@u8 > " + "u1,u2,u3,u8@u8^ > u1,u2,u3,u8@u8 #3"), + # The favourite still fills the board: no recompose on any frame. + "ranked, poll matches nothing, favourite playing": ( + {"favorites": ["AAA"], "quality": "ranked", "rankings": {"ZZZ": 1}}, + "u1,u8@u8 > u1,u8@u8 > u1,u8@u8 > u1,u8@u8 > u1,u8@u8 > u1,u8@u8 #0"), + # Nothing survived, so compose cuts the unfiltered fallback: it rotates too. + "ranked, poll matches nothing, favourite not playing": ( + {"favorites": ["ZZZ"], "quality": "ranked", "rankings": {"ZZZ": 1}}, + "u1,u2@u2 > u1,u2@u2 > u3,u4@u3^ > u3,u4@u3 > u1,u2@u1^ > u3,u4@u3^ #3"), +} + + +@pytest.mark.parametrize("recent", [False, True], ids=["upcoming", "recent"]) +@pytest.mark.parametrize("scenario", list(ROTATION)) +def test_the_display_path_rotation(clock, scenario, recent): + settings, expected = ROTATION[scenario] + assert ticks(clock, updated(clock, recent=recent, **settings)) == expected + + +def test_no_pools_no_rotation(clock): + """A manager whose update() never built the pools (ufc's MMA ones) never rotates.""" + host = Host() + clock.now = T0 + 3600 + assert host._advance_other_games_if_due() == [] + assert host._rotate_other_games_on_display() is False + + +# --------------------------------------------------------------------------- +# update() and display() both advancing: the lock +# --------------------------------------------------------------------------- + +def _racing(host): + """Run display()'s rotation on a second thread inside update()'s advance. + + The first time this thread reads ``_other_window_start`` -- the advance's + read-modify-write, after the interval was found elapsed -- the display path + starts; it either finishes first (no lock) or waits on ``_games_lock``. + """ + owner, threads = threading.get_ident(), [] + + class Interleaved(type(host)): + @property + def _other_window_start(self): + if threading.get_ident() == owner and not threads: + display = threading.Thread(target=self._rotate_other_games_on_display, + daemon=True) + threads.append(display) + display.start() + display.join(2.0) + return self.__dict__["_other_window_start"] + + @_other_window_start.setter + def _other_window_start(self, value): + self.__dict__["_other_window_start"] = value + + host.__class__ = Interleaved + return threads + + +class _NoLock: + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + +def _update_again(host): + host.games_list = host._favorites_first( + [game(*row) for row in UPDATE_SLATE], 2, 2) + + +@pytest.mark.parametrize("lock, windows, shown", [ + (True, 1, "u1,u4,u5,u8"), + (False, 2, "u1,u6,u7,u8"), # what the eight plugins did before +]) +def test_display_inside_updates_advance(clock, lock, windows, shown): + host = updated(clock, ["AAA"]) + if not lock: + host._games_lock = _NoLock() + clock.now = T0 + 61 + threads = _racing(host) + _update_again(host) + for t in threads: + t.join(10) + assert threads, "update() never advanced the window" + assert (host.__dict__["_other_window_start"] // 2, ids(host.games_list)) == (windows, shown) + + +@pytest.mark.parametrize("display_first", [False, True]) +def test_update_and_display_in_sequence(clock, display_first): + host = updated(clock, ["AAA"]) + clock.now = T0 + 61 + if display_first: + host._rotate_other_games_on_display() + _update_again(host) + if not display_first: + host._rotate_other_games_on_display() + assert (host._other_window_start // 2, ids(host.games_list)) == (1, "u1,u4,u5,u8") + + +# --------------------------------------------------------------------------- +# Odds for rotated-in games +# --------------------------------------------------------------------------- + +def _rotate_with_odds(clock, show_odds=True, manager=True, preset=None): + host = updated(clock, ["AAA"]) + host.show_odds = show_odds + host.odds_manager = MagicMock() if manager else None + if manager: + host.odds_manager.get_odds.side_effect = lambda **k: {"details": k["event_id"]} + for g in host._selection_pools["favorites"] + host._selection_pools["others"]: + if g["id"] in (preset or ()): + g["odds"] = {"details": "already"} + clock.now = T0 + 61 + assert host._rotate_other_games_on_display() + for t in threading.enumerate(): + if t.name == "test-rotated-odds": + t.join(10) + return host + + +def test_rotated_in_games_get_odds(clock): + host = _rotate_with_odds(clock, preset={"u1"}) + assert ids(host.games_list) == "u1,u4,u5,u8" + assert {g["id"]: g["odds"]["details"] for g in host.games_list} == { + "u1": "already", "u4": "u4", "u5": "u5", "u8": "u8"} + asked = [c.kwargs["event_id"] for c in host.odds_manager.get_odds.call_args_list] + assert asked == ["u4", "u5", "u8"] + + +@pytest.mark.parametrize("show_odds, manager", [(False, True), (True, False)]) +def test_no_odds_without_show_odds_or_a_manager(clock, show_odds, manager): + host = _rotate_with_odds(clock, show_odds=show_odds, manager=manager) + assert not any(g.get("odds") for g in host.games_list) + if manager: + host.odds_manager.get_odds.assert_not_called() + + +# --------------------------------------------------------------------------- +# Carrier and host contract +# --------------------------------------------------------------------------- + +METHODS = ["_advance_other_games_if_due", "_attach_odds_to_rotated_games", + "_by_importance", "_other_games_window", "_rankings_loaded", + "_rotate_other_games_on_display"] + + +def _self_reads(): + tree = ast.parse(Path(sports_rotation.__file__).read_text(encoding="utf-8")) + return {node.attr for node in ast.walk(tree) + if isinstance(node, ast.Attribute) and isinstance(node.ctx, ast.Load) + and isinstance(node.value, ast.Name) and node.value.id == "self"} + + +class TestHostContract: + def test_the_mixin_carries_only_the_family(self): + """Every scoreboard's SportsCore has all six, so adopting it adds none.""" + assert sorted(n for n in vars(SportsRotationMixin) + if not n.startswith("__")) == METHODS + + def test_every_host_read_is_documented(self): + undocumented = sorted(n for n in _self_reads() + if f"``{n}``" not in sports_rotation.__doc__) + assert undocumented == [], f"read but not in the host contract: {undocumented}" + + def test_no_other_shared_mixin_defines_these(self): + from src.common import sports_display_rules, sports_shared + others = [sports_shared.SportsCoreSharedMixin, sports_shared.SportsRecentSharedMixin, + sports_shared.SportsLiveSharedMixin, SportsHelpersMixin, + SportsFavoritesMixin, sports_display_rules.SportsGameRulesMixin] + assert [m for m in METHODS if any(m in vars(o) for o in others)] == [] + + def test_the_host_class_wins(self): + """football keeps its _rankings_loaded; the plugin's own method runs.""" + assert IdRankedHost(ranked_ids={1: 1})._rankings_loaded() is True + assert Host()._rankings_loaded() is False diff --git a/test/test_sports_rotation_parity.py b/test/test_sports_rotation_parity.py new file mode 100644 index 00000000..3b1c9116 --- /dev/null +++ b/test/test_sports_rotation_parity.py @@ -0,0 +1,134 @@ +"""sports_rotation still matches every plugin copy, and only football overrides the seam. + +``src.common.sports_rotation`` was copied from the scoreboards once family 7 +had made each method one body in all nine ``SportsCore`` classes: +``_by_importance``, ``_other_games_window``, ``_advance_other_games_if_due``, +``_rotate_other_games_on_display``, ``_attach_odds_to_rotated_games`` and the +default ``_rankings_loaded``. The plugins delete their copies once they floor +on the release that ships this module. 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 each method is +compared with every plugin copy using ``scripts/sports_drift_report.py``'s own +normalisation (the AST with docstrings and annotations dropped), plus the +decorators. A copy that is gone counts as adopted when the plugin's +``sports.py`` names the module. football's ``_rankings_loaded`` is the +decided override (it counts its rankings keyed by team id), checked as the only +one; it stays in the plugin after adoption. Without the variable this skips: +core CI has no plugins checkout. +""" + +import ast +import importlib.util +import os +from pathlib import Path + +import pytest + +from src.common import sports_rotation + +REPO = Path(__file__).resolve().parents[1] +SPORTS = ("afl", "baseball", "basketball", "football", "hockey", "lacrosse", + "nrl", "soccer", "ufc") +CARRIER, MIXIN = "SportsCore", "SportsRotationMixin" +METHODS = ("_by_importance", "_other_games_window", "_advance_other_games_if_due", + "_rotate_other_games_on_display", "_attach_odds_to_rotated_games", + "_rankings_loaded") +SEAM = "_rankings_loaded" + +#: The owner's decision (docs/SPORTS_UNIFICATION.md, family 7): the sports +#: whose own _rankings_loaded replaces the default. +OVERRIDES_RANKINGS_LOADED = {"football"} + + +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 this module against the plugin copies") + return root + + +def _class(tree, name): + return next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == name) + + +def _method(cls, name): + return next((n for n in cls.body + if isinstance(n, ast.FunctionDef) and n.name == name), None) + + +def _fingerprint(node): + return (DRIFT._digest(node, DRIFT._Canonical()), + tuple(ast.unparse(d) for d in node.decorator_list)) + + +def _ours(name): + tree = ast.parse(Path(sports_rotation.__file__).read_text(encoding="utf-8")) + return _method(_class(tree, MIXIN), name) + + +def _plugin_tree(root, sport): + source = (root / f"{sport}-scoreboard" / "sports.py").read_text(encoding="utf-8") + return source, ast.parse(source) + + +CASES = [(sport, name) for sport in SPORTS for name in METHODS + if not (name == SEAM and sport in OVERRIDES_RANKINGS_LOADED)] + + +@pytest.mark.parametrize("sport, name", CASES) +def test_every_remaining_plugin_copy_matches(sport, name): + source, tree = _plugin_tree(_plugins_root(), sport) + copy = _method(_class(tree, CARRIER), name) + if copy is None: + assert sports_rotation.__name__ in source, ( + f"{sport}: no {name} on {CARRIER} and no {sports_rotation.__name__} import") + else: + assert _fingerprint(copy) == _fingerprint(_ours(name)), ( + f"{CARRIER}.{name} in {sport} differs from sports_rotation. " + f"Port the change to both, or stop treating it as shared.") + + +@pytest.mark.parametrize("sport", SPORTS) +def test_no_other_plugin_class_carries_a_copy(sport): + """A copy on another class would shadow the shared one for that class.""" + _, tree = _plugin_tree(_plugins_root(), sport) + strays = [f"{node.name}.{name}" for node in tree.body if isinstance(node, ast.ClassDef) + for name in METHODS if node.name != CARRIER and _method(node, name) is not None] + assert strays == [] + + +def test_only_the_decided_sports_override_the_seam(): + root = _plugins_root() + ours = _fingerprint(_ours(SEAM)) + overriding = set() + for sport in SPORTS: + copy = _method(_class(_plugin_tree(root, sport)[1], CARRIER), SEAM) + if copy is not None and _fingerprint(copy) != ours: + overriding.add(sport) + assert overriding == OVERRIDES_RANKINGS_LOADED + + +def test_the_drift_report_still_calls_them_identical(): + root = _plugins_root() + families = DRIFT.build(root, ("sports.py",)) + rows = {(r["file"], r["family"]): r + for r in (DRIFT.summarise(k, v) for k, v in families.items())} + for name in METHODS: + if name == SEAM: + continue # the default and football's override: two by design + row = rows.get(("sports.py", name)) + assert row is None or row["worst_class_variants"] == 1, name