diff --git a/CHANGELOG.md b/CHANGELOG.md index 4148689d..d557a04e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/SPORTS_UNIFICATION.md b/docs/SPORTS_UNIFICATION.md index ab40f5aa..db953b20 100644 --- a/docs/SPORTS_UNIFICATION.md +++ b/docs/SPORTS_UNIFICATION.md @@ -133,6 +133,7 @@ deprecation cycle. | `win_phrase(team_abbr)` | Win-celebration wording | `" 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["_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`) | `.png`. nrl returns `_.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: diff --git a/mypy-clean.txt b/mypy-clean.txt index a3f8920f..d9f63a32 100644 --- a/mypy-clean.txt +++ b/mypy-clean.txt @@ -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 diff --git a/src/common/README.md b/src/common/README.md index e757abad..d65259c6 100644 --- a/src/common/README.md +++ b/src/common/README.md @@ -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). diff --git a/src/common/sports_view_model.py b/src/common/sports_view_model.py new file mode 100644 index 00000000..691ac029 --- /dev/null +++ b/src/common/sports_view_model.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 ``.png`` (``LogoDownloader.normalize_abbreviation``); +NRL overrides it with ``_.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.`` 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 diff --git a/test/test_sports_view_model.py b/test/test_sports_view_model.py new file mode 100644 index 00000000..419e150f --- /dev/null +++ b/test/test_sports_view_model.py @@ -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)] == [] diff --git a/test/test_sports_view_model_parity.py b/test/test_sports_view_model_parity.py new file mode 100644 index 00000000..63b0f151 --- /dev/null +++ b/test/test_sports_view_model_parity.py @@ -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