mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-16 03:49:13 +00:00
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>
This commit is contained in:
@@ -19,6 +19,20 @@ accepts both, but the store flags the old spelling as deprecated
|
||||
|
||||
## Unreleased
|
||||
|
||||
### 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 the release that ships it, and deletes its copies then.
|
||||
|
||||
## 3.8.6
|
||||
|
||||
A **Start Display on Boot** toggle on the General tab (#792), two fixes to
|
||||
|
||||
@@ -133,6 +133,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 +456,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)
|
||||
|
||||
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 +582,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`. See [Family 10](#family-10-the-view-model-reconciled-promoted) |
|
||||
| 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 +696,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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) | next release |
|
||||
| [`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).
|
||||
|
||||
@@ -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
|
||||
@@ -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)] == []
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user