feat(common): sports_plugin_host, sports_live_scroll, sports_display_rules and sports_font_path, promoted from the scoreboards (sports consolidation stage 4)

The identical sweep: method families every scoreboard carrying them has as
an identical copy (executable AST, docstrings stripped, decorators compared),
re-measured at ledmatrix-plugins 56c4f15. Four new hardware-free modules;
the bodies are the plugins', with type annotations and type comments for
the mypy ratchet.

- src/common/sports_plugin_host.py: SportsPluginHostMixin, ten helpers of
  the scoreboard plugin class (manager.py) identical in all nine:
  _dispatch_switch_refresh (with _SWITCH_REFRESH_MIN_GAP_SECONDS),
  get_vegas_priority_weight, _favorite_team_is_live, _favorite_scan_targets,
  _favorite_scan_games, _game_involves, get_vegas_content_type,
  _dynamic_feature_enabled, _get_total_games_for_manager,
  _build_manager_key. Listed before BasePlugin, whose defaults two of them
  override.
- src/common/sports_live_scroll.py: SportsLiveScrollMixin, the eight
  manager.py methods that rebuild a live scroll strip mid-cycle (all eight
  scoreboards with a strip; ufc has none), with the two rebuild-rate
  constants. LIVE_VOLATILE_FIELDS differs (afl/nrl/soccer add period_text)
  and stays in each plugin as host contract.
- src/common/sports_display_rules.py: SportsCardOptionsMixin (_card_option,
  _recent_date_text; the eight team scoreboards; must precede
  SportsCoreSharedMixin, whose _card_option it wraps) and
  SportsGameRulesMixin (_filtered_or_all, all but football;
  _effective_live_duration, all but ufc). Split by carrier so ufc does not
  gain a _card_option override it never had.
- src/common/sports_font_path.py: resolve_font_path, what the 17
  _resolve_font_path copies (nine sports.py, eight game_renderer.py) return
  on a core that ships it: the cwd first, then resolve_asset_path. Not
  resolve_asset_path alone, which skips the cwd.

Left in the plugins: _get_timezone, _extract_game_details and _fetch_data
(per-plugin import, abstract contract), _schema_font_size and
_resolve_font_size (each plugin's _SCHEMA_PATH), and the families carried by
seven plugins or fewer.

Tests: behaviour ported from the plugins' own tests against stub hosts with
exactly the documented contract, a host-contract test per mixin, and
test_sports_stage4_parity.py, which with LEDMATRIX_PLUGINS set compares
every promoted method and constant with every plugin copy using
scripts/sports_drift_report.py's normalisation plus decorators and values
(27 pass against 56c4f15; mutation-checked: a changed body, a swapped
decorator, a changed constant, a dropped @contextmanager and an unlisted
new method each fail it). test_sports_font_path.py runs both on the same
paths from a temporary cwd. All four modules are on the mypy ratchet, in
src/common/README.md, the CHANGELOG's Unreleased section (module entries
for the sunset floor; the version is not bumped) and SPORTS_UNIFICATION.
Nothing in core uses them yet.

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