Compare commits

...
2 Commits
Author SHA1 Message Date
ChuckandClaude Opus 5.5 456a1ac16c chore: prepare the 3.8.7 release (#797)
Bumps src.__version__ to 3.8.7 and turns Unreleased into ## 3.8.7:
src.common.sports_view_model (#796, sports family 10), which the scoreboards
adopt by flooring on 3.8.7. src/common/README.md and
docs/SPORTS_UNIFICATION.md say 3.8.7 for the module.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:41:40 -04:00
ChuckandClaude Opus 5.5 f4435d79e0 feat(common): sports_view_model -- the reconciled game view model (sports family 10) (#796)
* feat(common): sports_view_model -- the reconciled game view model (sports family 10)

New hardware-free module src/common/sports_view_model.py, copied from
ledmatrix-plugins claude/family10-reconcile once the nine scoreboards made
_extract_game_details_common (nine bodies) one. It lives on the plugins'
SportsCore, so one mixin, SportsViewModelMixin, carries exactly that method,
the helpers it calls (_status_is_final, _score_text, _team_logo_url,
_extract_team_record), the _logo_filename seam and the two status-name
sets; no manager gains a method it lacks today. The module docstring lists
every key of the game dict with its type and default; keys are additive
only.

Owner decisions (2026-10-13):
- One final rule: state "post", not completed: false (a payload without
  the flag is judged on state and name), and not a not-played status (the
  union of the nine lists). A forfeit is final. A called-off game is never
  live, even while ESPN still reports it "in".
- Scores are integer text everywhere; a missing, empty or non-numeric
  score is "0".
- A time without an offset is UTC.
- Halftime reads "HALF" with no clock (each sport's own extractor; not in
  this module).
- Superset with safe defaults: period (0) and clock (""). period_text has
  none: the renderers read its absence (baseball) as "draw ESPN's status
  text", and an empty default blanked baseball's Recent "Final" in the
  harness.

- test/test_sports_view_model.py: the plugins' pinned cases (states, the
  final rule, scores, records, logos, ids, broadcast, dates, offsets, DST,
  malformed events), the seam, and the host contract (every self read
  documented, every key in the docstring's table). Mutation-checked (11
  mutants, all caught).
- test/test_sports_view_model_parity.py: with LEDMATRIX_PLUGINS, compares
  each method and both status sets with every plugin copy. Passes against
  claude/family10-reconcile; fails against the pre-reconcile tree.
- test_sports_twins.py, sports_card and sports_game_renderer tests pass
  with the reconciled plugins as the corpus.
- mypy ratchet, src/common/README.md, CHANGELOG (Unreleased: New modules),
  docs/SPORTS_UNIFICATION.md family 10 status and decisions, and the
  _logo_filename seam in the override-points table.

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

* docs: cite ledmatrix-plugins #654 for the family 10 reconcile

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

* test(sports): view-model parity requires SportsCore to inherit the mixin

A plugin without a copy counted as adopted if its source merely mentioned
the module. It now also has to list SportsViewModelMixin among SportsCore's
bases (CodeRabbit on #796).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:07:38 -04:00
8 changed files with 913 additions and 4 deletions
+20
View File
@@ -19,6 +19,26 @@ accepts both, but the store flags the old spelling as deprecated
## Unreleased
## 3.8.7
Sports consolidation family 10: `src.common.sports_view_model`, the game
dict every scoreboard screen draws, which the scoreboards adopt by flooring
on 3.8.7 (#796).
### New modules
- `src/common/sports_view_model.py` -- sports consolidation family 10, once
the plugins made `_extract_game_details_common` (nine bodies) one:
`SportsViewModelMixin` (`SportsCore`), the game dict every scoreboard screen
draws from one ESPN event, with `_status_is_final`, `_score_text`,
`_team_logo_url`, `_extract_team_record` and the `_logo_filename` seam
beside it. One rule for a final (state "post", not `completed: false`, not
postponed, canceled, suspended, abandoned, delayed or pending; a forfeit is
final); a called-off game is never live; scores are integer text; a time
without an offset is UTC. Keys are additive only; the module docstring
lists every key, its type and default. A plugin may inherit the mixin once
it floors on 3.8.7, and deletes its copies then. (#796)
## 3.8.6
A **Start Display on Boot** toggle on the General tab (#792), two fixes to
+60 -3
View File
@@ -96,6 +96,7 @@ more. Shared sports code lives in `src/common`:
| `sports_rotation.py` | 3.8.4 | `SportsRotationMixin` — the other-games rotation (importance order, the window, odds on rotated-in games), on the `_rankings_loaded` seam (family 7) |
| `sports_rankings.py` | 3.8.5 | `SportsRankingsMixin` — which teams are ranked (by id and abbreviation), the poll choice, stale polls hidden, the "ranked" filter; `choose_top_division_poll()` (family 8) |
| `sports_live_fetch.py` | 3.8.6 | `SportsLiveFetchMixin` — the live poll's scoreboard request (always fresh, shared key) and odds fetched off the update path by one worker per manager (family 9) |
| `sports_view_model.py` | 3.8.7 | `SportsViewModelMixin` — the game dict every screen draws (`_extract_game_details_common`), the final and live rules, score text, logos, records; the `_logo_filename` seam (family 10) |
Each is described in [src/common/README.md](../src/common/README.md).
@@ -133,6 +134,7 @@ deprecation cycle.
| `win_phrase(team_abbr)` | Win-celebration wording | `"<abbr> WINS!"` — mixin only |
| `_rankings_loaded()` | Whether a poll loaded, so `_by_importance` orders by rank (`sports_rotation`) | `_team_rankings_cache` is non-empty. football also counts its rankings keyed by team id |
| `_favorite_key(game, side)` | Which view-model field identifies a team for favorites matching. `sports_favorites` compares it, and each `favorite_teams` entry, stripped and upper-cased; a `None` matches nothing | `game["<side>_abbr"]`. nrl returns the ESPN team id, `None` when it is missing |
| `_logo_filename(abbr, team_id)` | The file under `logo_dir` a team's logo is kept in (`sports_view_model`) | `<ABBR>.png`. nrl returns `<ABBR>_<id>.png`: its abbreviations are not unique |
| `_config_schema_path()` | Plugin's `config_schema.json` — returning it routes `_get_layout_offset` through the `src.element_style` resolver (and gives it the defaults to compare against) | `None`, i.e. the classic inline `customization.layout` read |
| `_font_root()` | Directory to resolve `assets/fonts` against | core install root |
@@ -455,6 +457,43 @@ thread per rotation and can route through `_fetch_odds` once every plugin
inherits this mixin; and ESPN lists only some college basketball games
without `groups=50` (7 of 21 on 2026-03-14), dated or not.
### Family 10: the view model (reconciled; promoted, core 3.8.7)
ledmatrix-plugins `scripts/test_view_model.py` (#653) pinned both
`_extract_game_details_common` and each plugin's final `_extract_game_details`
first, key by key, over 52 ESPN-shaped events: every state, OT, postponed,
canceled, delayed and forfeit games, a missing `completed` flag, scores as
int, "21.0", a dict, None, "" or missing, the two record shapes, missing logos
or ids, colliding abbreviations, broadcast objects, and dates with offsets,
without them, "TBD", and both DST changes. The nine bodies differed in how a
final was decided (five rules), how a score was read (raw in five), which
record and logo shapes were read, where a team id came from, how a time
without an offset was read (UTC, US Eastern in hockey and lacrosse, the host's
zone in ufc), and whether `favorite_teams` and basketball's tournament keys
were built there. The reconcile (ledmatrix-plugins #654)
made the method one body on `SportsCore`, with `_status_is_final`,
`_score_text`, `_team_logo_url`, `_extract_team_record` and the
`_logo_filename` seam beside it; basketball's tournament keys moved to its own
`_extract_game_details` (its dict keeps them), football's missing-badge
request to its own, and ufc's MMA extractor, which builds its own dict, uses the
shared helpers. Every changed cell is one of the decisions below or a fix.
All 208 harness renders of the nine are pixel-identical on core main.
`src/common/sports_view_model.py` holds the bodies in one mixin,
`SportsViewModelMixin`, whose docstring lists every key, its type and default;
`test/test_sports_view_model_parity.py` compares them with the plugin copies.
`period_text` is deliberately not given a default: the renderers read its
absence (baseball's) as "draw ESPN's status text", which is how an
extra-inning final reads "Final/10"; an empty default blanked baseball's
Recent "Final". `period` (0) and `clock` ("") are, and baseball now carries
them. `is_within_window` and `broadcast` are never read but are kept: keys are
additive only.
Left for later: the period labels stay in each sport's
`_extract_game_details` (quarters, periods, halves, extra time, rounds), and
soccer still names extra time's half-time "ETH"; ufc's MMA dict keeps its own
keys and its ordinal date (`display_manager.format_date_with_ordinal`).
### Why the method changes
Byte-identical promotion has nearly run dry. Measured on ledmatrix-plugins
@@ -544,7 +583,7 @@ release.
| 7 | Other-games rotation | `_by_importance`, `_other_games_window`, `_advance_other_games_if_due` (2 each: football), `_rotate_other_games_on_display` (2: ufc), with `_attach_odds_to_rotated_games` (3; ufc had none) | One outlier each; football carried two fixes the other eight lacked. Reconciled to one body each, on a `_rankings_loaded` seam, and promoted as `sports_rotation`; done: core 3.8.4, adopted (ledmatrix-plugins #643). See [Family 7](#family-7-the-other-games-rotation-done-core-384-adopted) |
| 8 | Rankings | `_fetch_team_rankings` (3), `_choose_poll` (3), `_load_division_team_ids`, `_passes_other_filters`, `_best_rank`, `_is_ranked_game` (2 each: football) | Needs 7; the rank badge and the "ranked only" filter read it. Reconciled to one body each and promoted as `sports_rankings`; done: core 3.8.5, adopted (ledmatrix-plugins #648). See [Family 8](#family-8-rankings-done-core-385-adopted) |
| 9 | Live fetch and odds | `_fetch_todays_games` (7 when measured), `_fetch_odds` (3); `_attach_odds_to_rotated_games` went with 7 | The prerequisite for one shared ESPN poller across plugins. Reconciled to one body each and promoted as `sports_live_fetch` (core 3.8.6). See [Family 9](#family-9-live-fetch-and-odds-reconciled-promoted-core-386) |
| 10 | View model | `_extract_game_details_common` (9 of 9) | Every renderer reads it; its keys are additive-only, so reconcile to the superset and leave sport extras in `_extract_game_details` |
| 10 | View model | `_extract_game_details_common` (9 of 9) | Every renderer reads it; its keys are additive-only, so reconcile to the superset and leave sport extras in `_extract_game_details`. Reconciled to one body and promoted as `sports_view_model` (core 3.8.7). See [Family 10](#family-10-the-view-model-reconciled-promoted-core-387) |
| 11 | Switch scorebug | `_load_fonts` (4), `_load_custom_font_from_element_config` (6), `_get_layout_offset` (5), `_fit_score_font` (2: football), `_load_and_resize_logo` (9), `_draw_dynamic_odds` (9), then `_draw_scorebug_layout` (20: Upcoming 9, Recent 8, Live 2, ufc's Core 1) | Needs 10 and the twin decisions below. Several releases: fonts and offsets, logos, odds, then one mode's layout per release |
| 12 | Scroll/Vegas card | `game_renderer.py`: `render_game_card` (6), `_load_and_resize_logo` (8), `_load_custom_font`, `preload_logos`, `__init__` (7 each), `_draw_records_or_rankings` (6), `_load_fonts`, `_draw_dynamic_odds`, `_draw_live_game_status`, `_draw_recent_game_status`, `_get_team_display_text` (5 each), `_draw_text_with_outline` (2: football) | Same decisions as 11; sequence after the scroll-performance work on pre-rendered strips lands |
| 13 | Mode lifecycle | `sports.py`: `update` (23), `__init__` (16), `display` (13), `_advance_live_game_if_due` (6) | Where per-sport behaviour lives; last in `sports.py`. Each difference becomes a seam or a strategy chosen by name (live rotation already has three) |
@@ -658,8 +697,26 @@ suspected behaviour that needs a payload or a rig to confirm first.
ported: an answer with `"events": null` is no games instead of a
`TypeError` out of the fetch (all nine), and `get_espn_scoreboard` no longer
caches it under the shared key.
- **10, view model.** Per key, whether every sport emits it. Additive only:
no key is renamed or removed.
- **10, view model. Decided 2026-10-13, reconciled.** Additive only: no key
is renamed or removed. (a) One rule for a final, in all nine: state "post",
not `completed: false` (a payload without the flag is judged on state and
name), and not a not-played status, the union of the nine lists (postponed,
canceled, suspended, abandoned, delayed, a pending forfeit or no-contest).
A forfeit is a result (basketball and soccer had it as not played). (b)
Scores are integer text everywhere ("21" for 21, "21.0" or {"value": 21};
"0" for a missing or empty one, never "None"). (c) Halftime reads "HALF",
with no period or clock, in every sport's `period_text` (basketball,
football, hockey and lacrosse showed the period while ESPN said "in"; nrl
and soccer appended "0:00"). (d) A time without an offset is UTC (hockey and
lacrosse read US Eastern, ufc the host's zone). Fixes ported: football's
postponed or canceled game says so, not "Final"; the team-schedule record
shape is read in all nine; a logo URL is `team.logo`, else `logos[0].href`;
a team id is the competitor's, else its team's (eight dropped the game);
ufc never shows a score of "None"; a called-off game ESPN still reports as
"in" is not live (baseball's guard). Superset: ufc's fights carry
`favorite_teams` ([]), baseball's games `period` and `clock`. Per-sport:
ufc's own dict and ordinal date, nrl's id-based logo file and favourites,
the period labels.
- **11 and 12, the scorebug and the card.** The pinned divergences in
`test/test_sports_twins.py`, where switch mode and scroll mode draw the
same game differently:
+1
View File
@@ -47,6 +47,7 @@ src/common/sports_rotation.py
src/common/sports_scroll.py
src/common/sports_timezone.py
src/common/sports_vegas.py
src/common/sports_view_model.py
src/config_service.py
src/core_config_keys.py
src/deprecation.py
+1 -1
View File
@@ -4,5 +4,5 @@ LEDMatrix Display System
Core source package for the LED Matrix Display project.
"""
__version__ = "3.8.6"
__version__ = "3.8.7"
+15
View File
@@ -60,6 +60,7 @@ Rules for the package:
| [`sports_shared`](#sports_shared) | Sport-independent `sports.py` methods | Yes (scoreboards) | 3.3.0 |
| [`sports_vegas`](#sports_vegas) | Live Vegas cards: keys, card cache, sticky odds, finished games | Yes (scoreboards) | 3.8.0 |
| [`sports_timezone`](#sports_timezone) | Which timezone a scoreboard draws start times in | Yes (scoreboards) | 3.6.0 |
| [`sports_view_model`](#sports_view_model) | The game dict every scoreboard screen draws, built from one ESPN event | Yes (scoreboards) | 3.8.7 |
| [`sync_manager`](#sync_manager) | Leader/follower sync between two displays | No, core-internal | n/a |
| [`text_helper`](#text_helper) | Outlined text, wrapping, measurement | Yes | — |
@@ -447,6 +448,20 @@ through a live poll that left them out), and `finished_games()` /
FINAL). `SportsScrollDisplay.build_vegas_elements()` in `sports_scroll` puts
them together; a scoreboard not built on it (UFC) uses them directly.
### sports_view_model
[`sports_view_model.py`](sports_view_model.py). Sports family 10:
`SportsViewModelMixin` (`SportsCore`). `_extract_game_details_common` turns
one ESPN scoreboard event into the game dict every renderer reads (the key
table is in the module docstring; keys are additive only) and hands back the
two competitors, the status and the situation for the sport's own
`_extract_game_details`. One rule decides a final (`_status_is_final`: state
"post", not `completed: false`, not a postponed, canceled, suspended,
abandoned, delayed or pending status; a forfeit is final); a called-off game
is never live; scores are integer text (`_score_text`); a time without an
offset is UTC. `_logo_filename(abbr, team_id)` is the seam for the logo file
under `logo_dir` (NRL names it by team id too).
### sports_timezone
[`sports_timezone.py`](sports_timezone.py).
+336
View File
@@ -0,0 +1,336 @@
"""The game dict every scoreboard screen draws (sports family 10).
The scoreboards' ``_extract_game_details_common``, reconciled in
ledmatrix-plugins (family 10) from nine bodies into one, and copied here
under the existing names with the helpers it calls. It lives on the plugins'
``SportsCore``, so there is one mixin, ``SportsViewModelMixin``:
- ``_extract_game_details_common(game_event)``: one ESPN scoreboard event to
``(details, home competitor, away competitor, status, situation)``, or five
Nones when the event has no competition, status, date or home/away pair.
Each sport's ``_extract_game_details`` adds its extras to ``details``.
- ``_status_is_final(status_type)``: whether ``status.type`` is a result.
- ``_score_text(score)``: a competitor's score as integer text.
- ``_team_logo_url(competitor)``, ``_extract_team_record(competitor)``.
- ``_logo_filename(abbr, team_id)``: the seam below.
THE KEYS
--------
Additive only: a key is never renamed or dropped, because every renderer
(``sports.py``'s scorebug, ``game_renderer.py``'s card, ``sports_card``,
``sports_game_renderer``, ``sports_shared``, ``sports_game_over``,
``sports_vegas``...) reads them. A sport's extras (down and distance, innings,
shots, tournament seeds...) belong in its own ``_extract_game_details``.
================== ================== ==========================================
key type value (default)
================== ================== ==========================================
id str | None the event id
game_time str local start, "7:00PM" ("" without a date)
game_date str local "MM/DD"; "M/D" with
``display.use_short_date_format`` ("")
start_time_utc datetime | None aware, UTC; a time without an offset
is read as UTC (None if unparseable)
status_text str ESPN ``status.type.shortDetail``
is_live bool state "in", not a called-off status
is_final bool ``_status_is_final(status.type)``
is_upcoming bool state "pre", or a scheduled status name
is_halftime bool state "halftime" or STATUS_HALFTIME
is_period_break bool STATUS_END_PERIOD
broadcast str ``str()`` of ESPN's field (""); unread
home_abbr/away_abbr str abbreviation, else the name's first 3
home_id/away_id str | None competitor id, else its team's id
home_score/ str ``_score_text``: integer text ("0")
away_score
home_logo_path/ Path ``logo_dir / _logo_filename(abbr, id)``
away_logo_path
home_logo_url/ str | None ``_team_logo_url``: ``team.logo``, else
away_logo_url ``team.logos[0].href`` (None)
home_record/ str ``_extract_team_record``; "0-0" and
away_record "0-0-0" read as "" ("")
is_within_window bool always True; unread
favorite_teams list the resolved favourites, for the card's
result colour ([])
period int 0 until the sport sets it
clock str "" until the sport sets it
================== ================== ==========================================
``period_text`` is not set here: each sport's extractor sets it, and the
renderers read its absence (baseball's) as "draw ESPN's status text", which
is how an extra-inning final reads "Final/10".
THE RULES (decided 2026-10-13)
------------------------------
A game is final when its state is "post", ESPN has not marked it
``completed: false`` (a payload without the flag is judged on state and name
alone), and its status is not one of ``_NOT_PLAYED_STATUS_NAMES``: postponed,
canceled, suspended, abandoned (``_CALLED_OFF_STATUS_NAMES``), delayed, or a
pending forfeit or no-contest. A forfeit is a result. A called-off game is
never live, even while ESPN still reports it "in". Scores are integer text in
every sport: 21, "21.0" and {"value": 21} are "21"; a missing, empty or
non-numeric score is "0". Halftime reads "HALF" with no clock in every
sport's ``period_text`` (each sport's own extractor, not this module).
THE SEAM
--------
``_logo_filename(abbr, team_id)``: the file under ``logo_dir`` a team's logo
is kept in. Default ``<ABBR>.png`` (``LogoDownloader.normalize_abbreviation``);
NRL overrides it with ``<ABBR>_<id>.png`` because its abbreviations are not
unique ("NEW" is two clubs).
A new module rather than more methods on ``sports_fetch``, for the reason
``sports_helpers`` gives: a missing module fails at load, where the version
checks see it; a missing method fails mid-update.
WHAT A HOST MUST PROVIDE
------------------------
Derived by walking every ``self.<attr>`` the mixin reads; the host-contract
test in ``test/test_sports_view_model.py`` fails if a read is added without
being listed here. Every scoreboard ``SportsCore`` supplies all of them.
- ``logger``, ``config`` (``display.use_short_date_format``), ``logo_dir``
(a ``Path``), ``favorite_teams`` (a list or None), ``_get_timezone``
(a callable returning the zone game times are shown in).
BASE ORDER
----------
No other mixin defines these names, so the position in the bases does not
change which body runs; a method on the plugin's own class still wins. The
mixin has no ``__init__``.
"""
import re
from datetime import datetime
from pathlib import Path
from typing import Any, Callable, ClassVar, Dict, List, Optional, Tuple
import pytz
from src.logo_downloader import LogoDownloader
class SportsViewModelMixin:
"""``SportsCore``'s game dict. See module docstring."""
# The host contract, declared for type checking only.
logger: Any
config: Dict[str, Any]
logo_dir: Path
favorite_teams: Optional[List[str]]
_get_timezone: Callable[[], Any]
#: ESPN status names for a game called off: never live, never a result.
#: ESPN files a postponed or washed-out game under state "post" with both
#: scores "0" (so "post" alone drew it on Recent as "Final 0-0"), and a
#: suspended one can still read "in".
_CALLED_OFF_STATUS_NAMES: ClassVar[frozenset] = frozenset({
"STATUS_POSTPONED", "STATUS_CANCELED", "STATUS_CANCELLED",
"STATUS_SUSPENDED", "STATUS_ABANDONED",
})
#: Status names under state "post" that are not a result. A forfeit
#: (STATUS_FORFEIT) is one; a pending forfeit or no-contest is not.
_NOT_PLAYED_STATUS_NAMES: ClassVar[frozenset] = _CALLED_OFF_STATUS_NAMES | frozenset({
"STATUS_DELAYED", "STATUS_FORFEIT_PENDING", "STATUS_NO_CONTEST_PENDING",
})
@classmethod
def _status_is_final(cls, status_type: Any) -> bool:
"""Whether an ESPN ``status.type`` is a game played to a result.
State "post", a ``completed`` flag that is not False, and a name
outside ``_NOT_PLAYED_STATUS_NAMES``. A payload without the flag is
judged on state and name alone; ESPN sends False for a postponement.
"""
if not isinstance(status_type, dict) or status_type.get("state") != "post":
return False
if status_type.get("completed") is False:
return False
return str(status_type.get("name") or "").upper() not in cls._NOT_PLAYED_STATUS_NAMES
def _score_text(self, score: Any) -> str:
"""A competitor's score as integer text: "21" for 21, "21.0" or
{"value": 21.0}; "0" when it is missing or holds no number."""
if isinstance(score, dict):
value = score.get("value", 0)
score = value if value != 0 else score.get("displayValue", score.get("score", 0))
if isinstance(score, str):
text = score.strip()
try:
score = float(text)
except ValueError:
digits = re.findall(r"\d+", text)
if not digits:
if text:
self.logger.warning(f"Could not extract score from string: {text!r}")
return "0"
score = float(digits[0])
if score is None:
return "0"
try:
return str(int(float(score)))
except (TypeError, ValueError, OverflowError) as e:
self.logger.warning(f"Error extracting score: {e}, score value: {score!r}")
return "0"
@staticmethod
def _team_logo_url(competitor: Dict) -> Optional[str]:
"""The team's ``logo``, else the first of its ``logos``, else None."""
team = competitor.get("team") or {}
logos = team.get("logos") or []
first = logos[0] if logos and isinstance(logos[0], dict) else {}
return team.get("logo") or first.get("href")
@staticmethod
def _extract_team_record(team_data: Dict) -> str:
"""Extract the overall record string from a competitor/team object.
The ESPN scoreboard API uses ``records`` (plural) with a ``summary``
field, while the team-schedule API uses ``record`` (singular) with a
``displayValue`` field. This helper handles both formats so that
records display correctly regardless of which API provided the data.
"""
# Scoreboard API format: records[0].summary (e.g. "10-2")
records = team_data.get("records")
if records and isinstance(records, list) and len(records) > 0:
return records[0].get("summary", "") # type: ignore[no-any-return]
# Team-schedule API format: record[0].displayValue (e.g. "7-0")
record = team_data.get("record")
if record and isinstance(record, list) and len(record) > 0:
return record[0].get("displayValue", record[0].get("summary", "")) # type: ignore[no-any-return]
return ""
def _logo_filename(self, abbr: str, team_id: Any) -> str:
"""Seam: the file a team's logo is kept in under ``logo_dir``."""
return f"{LogoDownloader.normalize_abbreviation(abbr)}.png"
def _extract_game_details_common(
self, game_event: Dict
) -> Tuple[Optional[Dict], Optional[Dict], Optional[Dict], Optional[Dict], Optional[Dict]]:
"""The game dict every renderer reads, built from one ESPN event.
Returns (details, home competitor, away competitor, status,
situation), or five Nones when the event has no competition, status,
date or home/away pair. Keys are additive only: never rename or drop
one. Sport extras belong in the sport's ``_extract_game_details``.
"""
if not game_event:
return None, None, None, None, None
try:
competitions = game_event.get("competitions", [])
if not competitions:
self.logger.warning(f"No competitions data for game {game_event.get('id', 'unknown')}")
return None, None, None, None, None
competition = competitions[0]
status = competition.get("status")
if not status:
self.logger.warning(f"No status data for game {game_event.get('id', 'unknown')}")
return None, None, None, None, None
competitors = competition.get("competitors", [])
game_date_str = game_event["date"]
situation = competition.get("situation")
start_time_utc = None
try:
dt = datetime.fromisoformat(game_date_str.replace("Z", "+00:00"))
# A time without an offset is read as UTC, as ESPN sends it.
start_time_utc = (dt.replace(tzinfo=pytz.UTC) if dt.tzinfo is None
else dt.astimezone(pytz.UTC))
except ValueError:
self.logger.warning(f"Could not parse game date: {game_date_str}")
home_team = next(
(c for c in competitors if c.get("homeAway") == "home"), None
)
away_team = next(
(c for c in competitors if c.get("homeAway") == "away"), None
)
if not home_team or not away_team:
self.logger.warning(
f"Could not find home or away team in event: {game_event.get('id')}"
)
return None, None, None, None, None
try:
home_abbr = home_team["team"]["abbreviation"]
except KeyError:
home_abbr = home_team["team"]["name"][:3]
try:
away_abbr = away_team["team"]["abbreviation"]
except KeyError:
away_abbr = away_team["team"]["name"][:3]
home_id = home_team.get("id") or home_team["team"].get("id")
away_id = away_team.get("id") or away_team["team"].get("id")
game_time, game_date = "", ""
if start_time_utc:
local_time = start_time_utc.astimezone(self._get_timezone())
game_time = local_time.strftime("%I:%M%p").lstrip("0")
if self.config.get("display", {}).get("use_short_date_format", False):
# %-m/%-d are glibc extensions: strftime raises ValueError on
# Windows and musl. Build the same text portably instead.
game_date = f"{local_time.month}/{local_time.day}"
else:
game_date = local_time.strftime("%m/%d")
home_record = self._extract_team_record(home_team)
away_record = self._extract_team_record(away_team)
# Don't show "0-0" records - set to blank instead
if home_record in {"0-0", "0-0-0"}:
home_record = ""
if away_record in {"0-0", "0-0-0"}:
away_record = ""
status_type = status["type"]
status_name = str(status_type.get("name") or "").upper()
details = {
"id": game_event.get("id"),
"game_time": game_time,
"game_date": game_date,
"start_time_utc": start_time_utc,
"status_text": status_type["shortDetail"], # e.g. "Final", "7:30 PM", "Q1 12:34"
"is_live": (status_type["state"] == "in"
and status_name not in self._CALLED_OFF_STATUS_NAMES),
"is_final": self._status_is_final(status_type),
"is_upcoming": (
status_type["state"] == "pre"
or status_type["name"].lower()
in ["scheduled", "pre-game", "status_scheduled"]
),
"is_halftime": status_type["state"] == "halftime"
or status_type["name"] == "STATUS_HALFTIME",
"is_period_break": status_type["name"] == "STATUS_END_PERIOD",
# str(): ESPN's undocumented payloads have served this field as
# an object in some sports. Plain game metadata: nothing reads it.
"broadcast": str(competition.get("broadcast") or ""),
"home_abbr": home_abbr,
"home_id": home_id,
"home_score": self._score_text(home_team.get("score")),
"home_logo_path": self.logo_dir / Path(self._logo_filename(home_abbr, home_id)),
"home_logo_url": self._team_logo_url(home_team),
"home_record": home_record,
"away_record": away_record,
"away_abbr": away_abbr,
"away_id": away_id,
"away_score": self._score_text(away_team.get("score")),
"away_logo_path": self.logo_dir / Path(self._logo_filename(away_abbr, away_id)),
"away_logo_url": self._team_logo_url(away_team),
"is_within_window": True, # Whether game is within display window
# The resolved favorites for this league (dynamic groups such
# as AP_TOP_25 already expanded). Carried on the game so the
# scroll/Vegas renderer, which only ever sees the game dict and
# the raw config, can color a final score by the result.
"favorite_teams": list(self.favorite_teams or []),
# Defaults; a sport's _extract_game_details sets its own. No
# period_text: the renderers read its absence (baseball) as
# "draw ESPN's status text", e.g. "Final/10".
"period": 0,
"clock": "",
}
return details, home_team, away_team, status, situation
except Exception as e:
self.logger.error(
f"Error extracting game details: {e} from event: {game_event.get('id')}",
exc_info=True,
)
return None, None, None, None, None
+346
View File
@@ -0,0 +1,346 @@
"""src.common.sports_view_model: the game dict built from one ESPN event.
The cases follow ledmatrix-plugins' ``scripts/test_view_model.py`` (the table
the family 10 reconcile was checked against); the expected values are its
``EXPECTED_COMMON`` cells, which agree across all nine scoreboards once the
reconcile landed (nrl differs only through the ``_logo_filename`` seam and
its id favourites, which are host data). Each row is one change to one base
event, a scheduled AAA (home, id 1) v BBB (away, id 2) at 7:00 PM EDT, and
lists only the keys whose value differs from the base game. The host shows
times in America/New_York. Nothing reaches the network.
"""
import ast
import copy
import logging
from datetime import datetime
from pathlib import Path
import pytest
import pytz
from src.common import sports_view_model
from src.common.sports_view_model import SportsViewModelMixin
LOGO_DIR = Path("logos")
class Host(SportsViewModelMixin):
def __init__(self, config=None):
self.logger = logging.getLogger("test_sports_view_model")
self.config = config or {}
self.logo_dir = LOGO_DIR
self.favorite_teams = ["AAA"]
def _get_timezone(self):
return pytz.timezone("America/New_York")
def competitor(home_away, tid, abbr, name, record):
return {"id": tid, "homeAway": home_away, "score": "0",
"team": {"id": tid, "abbreviation": abbr, "name": name,
"logo": f"https://a.espncdn.com/i/teamlogos/{abbr.lower()}.png"},
"records": [{"type": "total", "summary": record}]}
def base_event():
return {"id": "401", "date": "2026-10-12T23:00Z", "competitions": [{
"id": "4011", "broadcast": "ESPN",
"status": {"displayClock": "0:00", "period": 0, "type": {
"name": "STATUS_SCHEDULED", "state": "pre", "completed": False,
"shortDetail": "10/12 - 7:00 PM EDT"}},
"competitors": [competitor("home", "1", "AAA", "Alpha", "3-1"),
competitor("away", "2", "BBB", "Bravo", "2-2")]}]}
BASE = {
"id": "401", "game_time": "7:00PM", "game_date": "10/12",
"start_time_utc": datetime(2026, 10, 12, 23, 0, tzinfo=pytz.UTC),
"status_text": "10/12 - 7:00 PM EDT", "is_live": False, "is_final": False,
"is_upcoming": True, "is_halftime": False, "is_period_break": False,
"broadcast": "ESPN", "home_abbr": "AAA", "home_id": "1", "home_score": "0",
"home_logo_path": LOGO_DIR / "AAA.png",
"home_logo_url": "https://a.espncdn.com/i/teamlogos/aaa.png",
"home_record": "3-1", "away_record": "2-2", "away_abbr": "BBB", "away_id": "2",
"away_score": "0", "away_logo_path": LOGO_DIR / "BBB.png",
"away_logo_url": "https://a.espncdn.com/i/teamlogos/bbb.png",
"is_within_window": True, "favorite_teams": ["AAA"],
"period": 0, "clock": "",
}
def comp(e):
return e["competitions"][0]
def side(e, home_away):
return next(c for c in comp(e)["competitors"] if c["homeAway"] == home_away)
def status(state, name, short, completed=False, drop_completed=False):
def edit(e):
st = {"name": name, "state": state, "completed": completed, "shortDetail": short}
if drop_completed:
del st["completed"]
comp(e)["status"] = {"displayClock": "5:32", "period": 2, "type": st}
return edit
def scores(home, away):
def edit(e):
side(e, "home")["score"], side(e, "away")["score"] = home, away
return edit
def drop_scores(e):
for c in comp(e)["competitors"]:
del c["score"]
def both(fn):
def edit(e):
for c in comp(e)["competitors"]:
fn(c)
return edit
def dated(date):
def edit(e):
e["date"] = date
return edit
def chain(*edits):
def edit(e):
for fn in edits:
fn(e)
return edit
def utc(*args):
return datetime(*args, tzinfo=pytz.UTC)
FINAL = status("post", "STATUS_FINAL", "Final", completed=True)
POST = {"is_upcoming": False, "is_final": True, "status_text": "Final"}
#: label -> (edit, extra host config, keys that differ from BASE; None: no game)
ROWS = {
"pre": (None, None, {}),
"short date format": (dated("2026-03-05T23:00Z"), {"display": {"use_short_date_format": True}},
{"game_date": "3/5", "game_time": "6:00PM",
"start_time_utc": utc(2026, 3, 5, 23, 0)}),
"in progress": (status("in", "STATUS_IN_PROGRESS", "5:32 - 2nd"), None,
{"is_live": True, "is_upcoming": False, "status_text": "5:32 - 2nd"}),
"halftime": (status("in", "STATUS_HALFTIME", "Halftime"), None,
{"is_live": True, "is_halftime": True, "is_upcoming": False,
"status_text": "Halftime"}),
"end of period": (status("in", "STATUS_END_PERIOD", "End of 1st"), None,
{"is_live": True, "is_period_break": True, "is_upcoming": False,
"status_text": "End of 1st"}),
"suspended while in": (status("in", "STATUS_SUSPENDED", "Suspended"), None,
{"is_upcoming": False, "status_text": "Suspended"}),
"final": (FINAL, None, POST),
"final, no completed flag": (status("post", "STATUS_FINAL", "Final", drop_completed=True),
None, POST),
"final, completed false": (status("post", "STATUS_FINAL", "Final"), None,
{"is_upcoming": False, "status_text": "Final"}),
"postponed": (status("post", "STATUS_POSTPONED", "Postponed"), None,
{"is_upcoming": False, "status_text": "Postponed"}),
"canceled": (status("post", "STATUS_CANCELED", "Canceled"), None,
{"is_upcoming": False, "status_text": "Canceled"}),
"delayed, completed": (status("post", "STATUS_DELAYED", "Delayed", completed=True), None,
{"is_upcoming": False, "status_text": "Delayed"}),
"forfeit, completed": (status("post", "STATUS_FORFEIT", "Forfeit", completed=True), None,
{**POST, "status_text": "Forfeit"}),
"forfeit pending": (status("post", "STATUS_FORFEIT_PENDING", "Forfeit", completed=True),
None, {"is_upcoming": False, "status_text": "Forfeit"}),
"scores int": (chain(FINAL, scores(21, 3)), None, {**POST, "home_score": "21", "away_score": "3"}),
"scores '21.0'": (chain(FINAL, scores("21.0", " 3 ")), None,
{**POST, "home_score": "21", "away_score": "3"}),
"scores dict": (chain(FINAL, scores({"value": 21.0, "displayValue": "21"},
{"value": 0, "displayValue": "4"})), None,
{**POST, "home_score": "21", "away_score": "4"}),
"scores None and ''": (chain(FINAL, scores(None, "")), None, POST),
"scores missing": (chain(FINAL, drop_scores), None, POST),
"scores not numbers": (chain(FINAL, scores("SO", "12 (3)")), None, {**POST, "away_score": "12"}),
"records 0-0 and 0-0-0": (lambda e: (side(e, "home")["records"][0].update(summary="0-0"),
side(e, "away")["records"][0].update(summary="0-0-0")),
None, {"home_record": "", "away_record": ""}),
"records missing": (both(lambda c: c.pop("records")), None,
{"home_record": "", "away_record": ""}),
"record, team-schedule shape": (both(lambda c: (c.pop("records"),
c.update(record=[{"displayValue": "7-0"}]))),
None, {"home_record": "7-0", "away_record": "7-0"}),
"no abbreviation": (both(lambda c: c["team"].pop("abbreviation")), None,
{"home_abbr": "Alp", "away_abbr": "Bra",
"home_logo_path": LOGO_DIR / "ALP.png",
"away_logo_path": LOGO_DIR / "BRA.png"}),
"logos array only": (both(lambda c: c["team"].update(
logo=None, logos=[{"href": "https://a.espncdn.com/x.png"}])), None,
{"home_logo_url": "https://a.espncdn.com/x.png", "away_logo_url": "https://a.espncdn.com/x.png"}),
"logo and logos": (both(lambda c: c["team"].update(logos=[{"href": "https://a.espncdn.com/x.png"}])),
None, {}),
"no logos": (both(lambda c: c["team"].pop("logo")), None,
{"home_logo_url": None, "away_logo_url": None}),
"competitor without id": (lambda e: side(e, "home").pop("id"), None, {}),
"no ids at all": (lambda e: (side(e, "home").pop("id"), side(e, "home")["team"].pop("id")),
None, {"home_id": None}),
"broadcast object": (lambda e: comp(e).update(broadcast={"names": ["ESPN"]}), None,
{"broadcast": "{'names': ['ESPN']}"}),
"broadcast missing": (lambda e: comp(e).pop("broadcast"), None, {"broadcast": ""}),
"date 'TBD'": (dated("TBD"), None, {"game_date": "", "game_time": "", "start_time_utc": None}),
"date +10:00": (dated("2026-10-13T19:40+10:00"), None,
{"game_date": "10/13", "game_time": "5:40AM",
"start_time_utc": utc(2026, 10, 13, 9, 40)}),
"date naive, read as UTC": (dated("2026-10-12T23:00:00"), None, {}),
"late UTC, previous local day": (dated("2026-10-13T03:30Z"), None,
{"game_time": "11:30PM", "start_time_utc": utc(2026, 10, 13, 3, 30)}),
"DST ends, 05:30Z (EDT)": (dated("2026-11-01T05:30Z"), None,
{"game_date": "11/01", "game_time": "1:30AM",
"start_time_utc": utc(2026, 11, 1, 5, 30)}),
"DST ends, 06:30Z (EST)": (dated("2026-11-01T06:30Z"), None,
{"game_date": "11/01", "game_time": "1:30AM",
"start_time_utc": utc(2026, 11, 1, 6, 30)}),
"DST starts, 07:30Z": (dated("2026-03-08T07:30Z"), None,
{"game_date": "03/08", "game_time": "3:30AM",
"start_time_utc": utc(2026, 3, 8, 7, 30)}),
"naive, repeated DST hour": (dated("2026-11-01T01:30:00"), None,
{"game_date": "10/31", "game_time": "9:30PM",
"start_time_utc": utc(2026, 11, 1, 1, 30)}),
"one competitor": (lambda e: comp(e)["competitors"].pop(), None, None),
"no competitors": (lambda e: comp(e).pop("competitors"), None, None),
"no competitions": (lambda e: e.update(competitions=[]), None, None),
"no status": (lambda e: comp(e).pop("status"), None, None),
"no date": (lambda e: e.pop("date"), None, None),
}
@pytest.mark.parametrize("label", ROWS)
def test_the_game_dict(label):
edit, config, changed = ROWS[label]
event = base_event()
if edit:
edit(event)
details, home, away, st, situation = Host(config)._extract_game_details_common(event)
if changed is None:
assert (details, home, away, st, situation) == (None,) * 5
return
assert details == {**BASE, **changed}
assert home is side(event, "home") and away is side(event, "away")
assert st is comp(event)["status"] and situation is None
def test_an_empty_event_is_no_game():
assert Host()._extract_game_details_common({}) == (None,) * 5
def test_the_situation_is_handed_back():
event = base_event()
comp(event)["situation"] = {"possession": "1"}
assert Host()._extract_game_details_common(event)[4] == {"possession": "1"}
def test_the_logo_filename_seam():
class ById(Host):
def _logo_filename(self, abbr, team_id):
return f"{abbr}_{team_id}.png"
details = ById()._extract_game_details_common(base_event())[0]
assert (details["home_logo_path"], details["away_logo_path"]) == (
LOGO_DIR / "AAA_1.png", LOGO_DIR / "BBB_2.png")
def test_favourites_are_a_copy():
host = Host()
host.favorite_teams = None
assert host._extract_game_details_common(base_event())[0]["favorite_teams"] == []
host.favorite_teams = ["AAA"]
details = host._extract_game_details_common(base_event())[0]
assert details["favorite_teams"] == ["AAA"] and details["favorite_teams"] is not host.favorite_teams
@pytest.mark.parametrize("status_type, final", [
({"state": "post", "name": "STATUS_FINAL", "completed": True}, True),
({"state": "post", "name": "STATUS_FINAL"}, True),
({"state": "post", "name": "status_final", "completed": True}, True),
({"state": "post", "name": "STATUS_FINAL", "completed": False}, False),
({"state": "post", "name": "STATUS_FORFEIT", "completed": True}, True),
({"state": "in", "name": "STATUS_IN_PROGRESS"}, False),
({"state": "post"}, True),
(None, False),
("post", False),
] + [({"state": "post", "name": n, "completed": True}, False)
for n in sorted(SportsViewModelMixin._NOT_PLAYED_STATUS_NAMES)])
def test_status_is_final(status_type, final):
assert SportsViewModelMixin._status_is_final(status_type) is final
def test_the_status_lists():
assert SportsViewModelMixin._CALLED_OFF_STATUS_NAMES == {
"STATUS_POSTPONED", "STATUS_CANCELED", "STATUS_CANCELLED",
"STATUS_SUSPENDED", "STATUS_ABANDONED"}
assert SportsViewModelMixin._NOT_PLAYED_STATUS_NAMES == (
SportsViewModelMixin._CALLED_OFF_STATUS_NAMES
| {"STATUS_DELAYED", "STATUS_FORFEIT_PENDING", "STATUS_NO_CONTEST_PENDING"})
@pytest.mark.parametrize("score, text", [
(21, "21"), (21.9, "21"), ("21", "21"), ("21.0", "21"), (" 7 ", "7"), ("", "0"),
(None, "0"), ("SO", "0"), ("12 (3)", "12"), ({"value": 21.0}, "21"),
({"value": 0, "displayValue": "4"}, "4"), ({"value": None}, "0"), ([], "0"),
(float("nan"), "0"), (float("inf"), "0"),
])
def test_score_text(score, text):
assert Host()._score_text(score) == text
def test_records_do_not_mutate_the_event():
event = base_event()
before = copy.deepcopy(event)
Host()._extract_game_details_common(event)
assert event == before
# ---------------------------------------------------------------------------
# Carrier and host contract
# ---------------------------------------------------------------------------
MEMBERS = ["_CALLED_OFF_STATUS_NAMES", "_NOT_PLAYED_STATUS_NAMES",
"_extract_game_details_common", "_extract_team_record", "_logo_filename",
"_score_text", "_status_is_final", "_team_logo_url"]
def _self_reads():
tree = ast.parse(Path(sports_view_model.__file__).read_text(encoding="utf-8"))
return {node.attr for node in ast.walk(tree)
if isinstance(node, ast.Attribute) and isinstance(node.ctx, ast.Load)
and isinstance(node.value, ast.Name) and node.value.id in ("self", "cls")}
class TestHostContract:
def test_the_mixin_carries_only_the_family(self):
assert sorted(n for n in vars(SportsViewModelMixin) if not n.startswith("__")) == MEMBERS
def test_every_host_read_is_documented(self):
undocumented = sorted(n for n in _self_reads() - set(MEMBERS)
if f"``{n}``" not in sports_view_model.__doc__)
assert undocumented == [], f"read but not in the host contract: {undocumented}"
def test_every_key_is_in_the_key_table(self):
doc = sports_view_model.__doc__
table = doc[doc.index("THE KEYS"):doc.index("THE RULES")]
missing = [k for k in BASE if k not in table]
assert missing == []
def test_no_other_shared_mixin_defines_these(self):
from src.common import (sports_display_rules, sports_favorites, sports_fetch,
sports_helpers, sports_live_fetch, sports_rankings,
sports_rotation, sports_shared)
others = [sports_shared.SportsCoreSharedMixin, sports_shared.SportsRecentSharedMixin,
sports_shared.SportsLiveSharedMixin, sports_helpers.SportsHelpersMixin,
sports_favorites.SportsFavoritesMixin, sports_rotation.SportsRotationMixin,
sports_rankings.SportsRankingsMixin, sports_fetch.SportsFetchMixin,
sports_live_fetch.SportsLiveFetchMixin,
sports_display_rules.SportsGameRulesMixin]
assert [m for m in MEMBERS if any(m in vars(o) for o in others)] == []
+134
View File
@@ -0,0 +1,134 @@
"""sports_view_model still matches every plugin copy.
``src.common.sports_view_model`` was copied from the scoreboards once family
10 had made ``_extract_game_details_common`` one body in all nine
``SportsCore`` classes, with the helpers it calls (``_status_is_final``,
``_score_text``, ``_team_logo_url``, ``_extract_team_record``, the
``_logo_filename`` seam) and the two status-name sets. The plugins delete
their copies once they floor on the release that ships this module. nrl
overrides the seam on ``BaseNrlManager``, not on ``SportsCore``. Until each has, a copy that changes on its own is a fix one
side has and the other lacks.
Point LEDMATRIX_PLUGINS at a ledmatrix-plugins checkout and each method is
compared with every plugin copy using ``scripts/sports_drift_report.py``'s own
normalisation (the AST with docstrings and annotations dropped), plus the
decorators. A copy that is gone counts as adopted when the plugin's ``sports.py`` names the module.
Without the variable this skips: core CI has no plugins checkout.
"""
import ast
import importlib.util
import os
from pathlib import Path
import pytest
from src.common import sports_view_model
REPO = Path(__file__).resolve().parents[1]
SPORTS = ("afl", "baseball", "basketball", "football", "hockey", "lacrosse",
"nrl", "soccer", "ufc")
CARRIER, MIXIN = "SportsCore", "SportsViewModelMixin"
METHODS = ("_extract_game_details_common", "_status_is_final", "_score_text",
"_team_logo_url", "_extract_team_record", "_logo_filename")
CONSTANTS = ("_CALLED_OFF_STATUS_NAMES", "_NOT_PLAYED_STATUS_NAMES")
def _drift_report():
"""scripts/sports_drift_report.py, loaded by path (scripts/ is no package)."""
spec = importlib.util.spec_from_file_location(
"sports_drift_report", REPO / "scripts" / "sports_drift_report.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
DRIFT = _drift_report()
def _plugins_root():
root = DRIFT.resolve_plugins_dir(os.environ.get("LEDMATRIX_PLUGINS"))
if root is None:
pytest.skip("set LEDMATRIX_PLUGINS to a ledmatrix-plugins checkout to "
"compare this module against the plugin copies")
return root
def _class(tree, name):
return next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == name)
def _method(cls, name):
return next((n for n in cls.body
if isinstance(n, ast.FunctionDef) and n.name == name), None)
def _constant(cls, name):
"""The value assigned to ``name`` in the class body, as an AST dump."""
return next((ast.dump(n.value) for n in cls.body
if isinstance(n, ast.AnnAssign) and getattr(n.target, "id", None) == name),
None)
def _adopted(source, tree):
"""The plugin imports core's module and its carrier inherits the mixin."""
bases = _class(tree, CARRIER).bases
return (sports_view_model.__name__ in source
and any(getattr(b, "id", getattr(b, "attr", None)) == MIXIN for b in bases))
def _fingerprint(node):
return (DRIFT._digest(node, DRIFT._Canonical()),
tuple(ast.unparse(d) for d in node.decorator_list))
def _plugin_tree(root, sport):
source = (root / f"{sport}-scoreboard" / "sports.py").read_text(encoding="utf-8")
return source, ast.parse(source)
OURS = _class(ast.parse(Path(sports_view_model.__file__).read_text(encoding="utf-8")), MIXIN)
@pytest.mark.parametrize("sport, name", [(s, n) for s in SPORTS for n in METHODS])
def test_every_remaining_plugin_copy_matches(sport, name):
source, tree = _plugin_tree(_plugins_root(), sport)
copy = _method(_class(tree, CARRIER), name)
if copy is None:
assert _adopted(source, tree), (
f"{sport}: no {name} on {CARRIER}, and {CARRIER} does not inherit {MIXIN}")
else:
assert _fingerprint(copy) == _fingerprint(_method(OURS, name)), (
f"{CARRIER}.{name} in {sport} differs from sports_view_model. "
f"Port the change to both, or stop treating it as shared.")
@pytest.mark.parametrize("sport, name", [(s, n) for s in SPORTS for n in CONSTANTS])
def test_every_remaining_status_set_matches(sport, name):
source, tree = _plugin_tree(_plugins_root(), sport)
copy = _constant(_class(tree, CARRIER), name)
if copy is None:
assert _adopted(source, tree), (
f"{sport}: no {name} on {CARRIER}, and {CARRIER} does not inherit {MIXIN}")
else:
assert copy == _constant(OURS, name), f"{CARRIER}.{name} in {sport} differs"
@pytest.mark.parametrize("sport", SPORTS)
def test_no_other_plugin_class_carries_a_copy(sport):
"""A copy on another class would shadow the shared one for that class."""
_, tree = _plugin_tree(_plugins_root(), sport)
strays = [f"{node.name}.{name}" for node in tree.body if isinstance(node, ast.ClassDef)
for name in METHODS if node.name != CARRIER and _method(node, name) is not None]
assert strays == []
def test_the_drift_report_calls_them_identical():
families = DRIFT.build(_plugins_root(), ("sports.py",))
rows = {(r["file"], r["family"]): r
for r in (DRIFT.summarise(k, v) for k, v in families.items())}
for name in METHODS:
row = rows.get(("sports.py", name))
assert row is None or row["worst_class_variants"] == 1, name