Compare commits

..
Author SHA1 Message Date
ChuckandClaude Opus 5.5 aa6d0a213d chore: prepare the 3.8.4 release (#787)
Bumps src.__version__ to 3.8.4 and turns Unreleased into ## 3.8.4: the
refresh-cap report (#759), a failed on-demand request ending its own session
(#779), reason codes instead of exception messages in API errors (#778),
and src.common.sports_rotation (#786, sports family 7), which the
scoreboards adopt by flooring on 3.8.4. src/common/README.md says 3.8.4 for
it, and the SPORTS_UNIFICATION module table gains its row.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:47:37 -04:00
ChuckandClaude Opus 5.5 e186ae8b24 feat(common): sports_rotation -- the reconciled other-games rotation (sports family 7) (#786)
* feat(common): sports_rotation -- the reconciled other-games rotation (sports family 7)

New hardware-free module src/common/sports_rotation.py, copied from
ledmatrix-plugins claude/family7-reconcile once the nine scoreboards 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 body each. All of them live on the plugins'
SportsCore, so one mixin, SportsRotationMixin, carries exactly those five plus
the default _rankings_loaded, and no manager gains a method it lacks today.

- The window advances under _games_lock: update() and display() both advance
  it, and interleaved, each added a width and skipped 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 (decided 2026-10-09: ufc
  too), skipped without an odds manager.
- _rankings_loaded() is the seam _by_importance asks: the abbreviation table
  by default; football overrides it to count its by-id rankings.

- test/test_sports_rotation.py: the plugins' pinned cases (importance order
  per rankings table and the seam, the window over time, the display-path
  rotation incl. the card on screen, redraws and recompose counts, update()
  and display() in sequence and interleaved on two threads -- with and
  without the lock -- and rotated-in odds), plus the host contract.
- test/test_sports_rotation_parity.py: with LEDMATRIX_PLUGINS, compares each
  body with every plugin copy (drift-report normalisation plus decorators),
  checks no other plugin class carries a copy, and that only football
  overrides _rankings_loaded. Passes against claude/family7-reconcile; fails
  against the pre-reconcile tree, as it should.
- mypy ratchet, src/common/README.md, CHANGELOG (Unreleased, New modules).
- sports_shared docstring: _by_importance and _other_games_window (and the
  long-promoted _is_favorite_game) leave the "stay per-plugin" list.
- docs/SPORTS_UNIFICATION.md: family 7 status, decisions, the
  _rankings_loaded seam; families 5 and 6 marked done and adopted (3.8.1 /
  ledmatrix-plugins #631, 3.8.2 / #637) instead of "adoption waits".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(sports): stage 4 is adopted (core 3.8.0, ledmatrix-plugins #594)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: cite ledmatrix-plugins #641 for the family 7 reconcile

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:31:27 -04:00
9 changed files with 1024 additions and 21 deletions
+23
View File
@@ -19,6 +19,14 @@ accepts both, but the store flags the old spelling as deprecated
## Unreleased ## Unreleased
## 3.8.4
A panel that cannot reach its refresh cap is reported with a cap it can hold
(#759); a failed on-demand request ends its own session (#779); exception
messages stay out of API responses (#778); and sports consolidation family 7,
`src.common.sports_rotation`, which the scoreboards adopt by flooring on
3.8.4 (#786).
### Scroll speed: a panel slower than its refresh cap is reported ### Scroll speed: a panel slower than its refresh cap is reported
- Scroll speeds are solved against `limit_refresh_rate_hz`, so a panel that - Scroll speeds are solved against `limit_refresh_rate_hz`, so a panel that
@@ -50,6 +58,21 @@ accepts both, but the store flags the old spelling as deprecated
but drop systemctl's `stdout`/`stderr`; WiFi, unit-refresh and but drop systemctl's `stdout`/`stderr`; WiFi, unit-refresh and
config-save failures say what failed and point at the log. 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 3.8.4, and deletes its copies then. (#786)
## 3.8.3 ## 3.8.3
Fresh installs on Raspberry Pi OS Lite work again: 3.8.2's installer reported Fresh installs on Raspberry Pi OS Lite work again: 3.8.2's installer reported
+60 -14
View File
@@ -93,6 +93,7 @@ more. Shared sports code lives in `src/common`:
| `sports_font_path.py` | 3.8.0 | `resolve_font_path` — what the plugins' `_resolve_font_path` copies return | | `sports_font_path.py` | 3.8.0 | `resolve_font_path` — what the plugins' `_resolve_font_path` copies return |
| `sports_game_over.py` | 3.8.1 | `SportsGameOverMixin` — `_is_game_really_over`, with the `FINAL_PERIOD` seam (family 5) | | `sports_game_over.py` | 3.8.1 | `SportsGameOverMixin` — `_is_game_really_over`, with the `FINAL_PERIOD` seam (family 5) |
| `sports_favorites.py` | 3.8.2 | `SportsFavoritesMixin`, `SportsUpcomingFavoritesMixin`, `SportsRecentFavoritesMixin` — `_is_favorite_game` and the favourites-only picks, on the `_favorite_key` seam (family 6) | | `sports_favorites.py` | 3.8.2 | `SportsFavoritesMixin`, `SportsUpcomingFavoritesMixin`, `SportsRecentFavoritesMixin` — `_is_favorite_game` and the favourites-only picks, on the `_favorite_key` seam (family 6) |
| `sports_rotation.py` | 3.8.4 | `SportsRotationMixin` — the other-games rotation (importance order, the window, odds on rotated-in games), on the `_rankings_loaded` seam (family 7) |
Each is described in [src/common/README.md](../src/common/README.md). Each is described in [src/common/README.md](../src/common/README.md).
@@ -128,6 +129,7 @@ deprecation cycle.
| `_custom_scorebug_layout(game, draw)` | Per-sport overlay on the base layout | no-op | | `_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 | `"<abbr> SCORES!"` — only consulted when `CelebrationMixin` is present | | `score_phrase(points, team_abbr)` | Celebration wording (`"GOOOOAAALLL!"` vs `"TOUCHDOWN!"`). `points` is the score delta, which sports with variable-value scores use to name the play | `"<abbr> SCORES!"` — only consulted when `CelebrationMixin` is present |
| `win_phrase(team_abbr)` | Win-celebration wording | `"<abbr> WINS!"` — mixin only | | `win_phrase(team_abbr)` | Win-celebration wording | `"<abbr> 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["<side>_abbr"]`. nrl returns the ESPN team id, `None` when it is missing | | `_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["<side>_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 | | `_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 | | `_font_root()` | Directory to resolve `assets/fonts` against | core install root |
@@ -268,7 +270,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 nine plugins at the eight default sizes, 72 scroll/Vegas cards, 135
celebration frames), with a parent-vs-parent rerun as the determinism control. 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 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 lists 58 families identical in every copy. Stage 4 moves the ones that are
@@ -303,7 +305,7 @@ Left in the plugins, though identical:
renderers) is already core's, in `SportsHelpersMixin`; a renderer that renderers) is already core's, in `SportsHelpersMixin`; a renderer that
wants it can inherit 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` 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 (#621) pinned 3,115 answers across the nine plugins first; the reconcile
@@ -315,9 +317,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 cells in hockey, basketball, football and lacrosse). The harness renders
were pixel-identical. `src/common/sports_game_over.py` holds the body; 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 `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 ledmatrix-plugins `scripts/test_favourite_matching.py` (#634) pinned 204 rows
across the nine plugins first: `_is_favorite_game` on each manager role, the across the nine plugins first: `_is_favorite_game` on each manager role, the
@@ -337,7 +340,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 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` bodies, one mixin per carrying class; `test/test_sports_favorites_parity.py`
compares them with the plugin copies and checks that only nrl overrides 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 Left for later families: the live screens' favourites-only filter
(`_classify_live_game` and its inline copies) and favourites-first sort still (`_classify_live_game` and its inline copies) and favourites-first sort still
@@ -346,6 +350,38 @@ compare abbreviations exactly, and
abbreviation (or by `_team_in` where a plugin has one) instead of through abbreviation (or by `_team_in` where a plugin has one) instead of through
`_favorite_key`. The result-colour helpers also wait (decision above). `_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 ### Why the method changes
Byte-identical promotion has nearly run dry. Measured on ledmatrix-plugins Byte-identical promotion has nearly run dry. Measured on ledmatrix-plugins
@@ -429,10 +465,10 @@ release.
| # | Family | Methods (variants) | Why here | | # | 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) | | 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`; adoption waits for the release that ships it. See [Family 5](#family-5-the-game-over-check-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`; 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`; adoption waits for the release that ships it. See [Family 6](#family-6-favourite-matching-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`; 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) | One outlier each; football carries two fixes the other eight lack | | 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 | | 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 | | 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` | | 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 +522,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, 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 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. game with an id can be a duplicate in the selection methods.
- **7, other-games rotation.** football advances the rotation window under - **7, other-games rotation. Decided 2026-10-09, done:** two fixes ported
`_games_lock` (update() and display() both advance it; interleaved, a from football. The window advances under `_games_lock`: `update()` and
window of games is skipped) and fixes a favourites-only pool that recomposed `display()` both advance it, and interleaved, each added a width and a
the list on every frame. Port both. ufc does not attach odds to fights window of games was never shown. The display path's due-check looks at the
rotated in: decide whether rotated fights show odds. 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* - **8, rankings.** (a) afl, basketball, nrl and soccer turn a *standings*
payload into ranks (a pro league's standings position becomes the rank payload into ranks (a pro league's standings position becomes the rank
badge); baseball, hockey, lacrosse, ufc and football do not. Which is badge); baseball, hockey, lacrosse, ufc and football do not. Which is
+1
View File
@@ -41,6 +41,7 @@ src/common/sports_font_path.py
src/common/sports_game_over.py src/common/sports_game_over.py
src/common/sports_live_scroll.py src/common/sports_live_scroll.py
src/common/sports_plugin_host.py src/common/sports_plugin_host.py
src/common/sports_rotation.py
src/common/sports_scroll.py src/common/sports_scroll.py
src/common/sports_timezone.py src/common/sports_timezone.py
src/common/sports_vegas.py src/common/sports_vegas.py
+1 -1
View File
@@ -4,5 +4,5 @@ LEDMatrix Display System
Core source package for the LED Matrix Display project. Core source package for the LED Matrix Display project.
""" """
__version__ = "3.8.3" __version__ = "3.8.4"
+16
View File
@@ -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_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_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_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) | 3.8.4 |
| [`sports_scroll`](#sports_scroll) | Scoreboard scroll-display orchestration | Yes (scoreboards) | 3.2.0 | | [`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_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 | | [`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 `display()` never waits on the network), `get_vegas_content_type()` and small
dynamic-duration helpers. List it before `BasePlugin`. 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
[`sports_scroll.py`](sports_scroll.py). `SportsScrollDisplay` and [`sports_scroll.py`](sports_scroll.py). `SportsScrollDisplay` and
+342
View File
@@ -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.<attr>`` 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()
+8 -6
View File
@@ -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 Methods that stay per-plugin, because they are not identical across the eight
(or, for ``_get_timezone``, because they bind per-plugin modules): (or, for ``_get_timezone``, because they bind per-plugin modules):
``_get_layout_offset``, ``_by_importance``, ``_other_games_window``, ``_get_layout_offset``, ``_upcoming_date_and_time_text``,
``_upcoming_date_and_time_text``, ``_extract_game_details_common``, ``_extract_game_details_common``, ``_load_division_team_ids``,
``_load_division_team_ids``, ``_get_timezone``, ``_is_favorite_game``, ``_get_timezone``, ``_is_ranked_game``, ``_passes_other_filters``. Three
``_is_ranked_game``, ``_passes_other_filters``. (``_is_game_really_over``, families were here until the plugins reconciled them: ``_is_game_really_over``,
which ``_detect_stale_games`` below calls, was here too until the plugins which ``_detect_stale_games`` below calls, is now ``src.common.sports_game_over``;
reconciled it; it 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 Of the fourteen shared class constants, thirteen are identical everywhere and
live here. Only ``_SCORE_PROBE_TEXT`` varies -- afl and basketball reach three digits live here. Only ``_SCORE_PROBE_TEXT`` varies -- afl and basketball reach three digits
+439
View File
@@ -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
+134
View File
@@ -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