mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 22:35:08 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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 == []
|
||||
Reference in New Issue
Block a user