From abedc4610436b5dc2d009179b5843ee36ed4dab7 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:51:35 -0400 Subject: [PATCH] refactor(sports): merge the sports_shared/sports_card twins that behave identically (#626) * refactor(sports): wrap the sports_card twins that behave identically SportsCoreSharedMixin (switch mode, via each scoreboard's sports.py) and sports_card (scroll/Vegas mode, via game_renderer.py) carried the same helpers twice. test/test_sports_twins.py now calls every pair with the same inputs -- the eight scoreboards' harness fixture games in flat, flat+nested and nested-only shapes, plus edge cases (favourites by id and abbreviation, NRL's colliding abbreviations, missing and non-numeric scores, bad zones, out-of-range dates, shared font faces). Identical pairs become thin wrappers over the sports_card function: _card_option, _vs_text, _format_game_time, _coerce_rgb, _crisp_size (with the class's own tables), _unshare_element_fonts (with the class's own element map, via a new optional argument), and the colour/month/weekday/ font-grid tables (dicts copied, not aliased). _format_game_date shares the card's formatting body but keeps its own setting, weekday zone and month table; _schema_font_size shares the parser but keeps its per-class cache, because a reloaded plugin gets new classes and a shared path cache would stop it seeing an edited schema. _resolve_font_size agrees but keeps its body so it still dispatches through the overridable hooks. No behaviour change: old and new mixin/card agree on all 22,994 comparisons over the test corpus, and the pairs that do differ (favourite-result colours on nested payloads and by favourites source, the weekday's timezone, the element-name map, per-mode colours) are left alone and pinned in TestPinnedDivergence for an owner decision. Co-Authored-By: Claude Opus 5.5 * test(sports): pin that an ambiguous NRL abbreviation tints in both modes NRL's resolver passes a shared abbreviation ("NEW") through with an error and its _is_favorite_game matches ids only, but both favourite-colour helpers match on abbreviation as well, so both display modes tint a Knights or Warriors result for a user who typed "NEW". The twins agree; neither consults the _favorite_key seam. Pinned so a fix is deliberate. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 17 + src/common/sports_card.py | 65 +++- src/common/sports_shared.py | 165 ++++----- test/test_sports_twins.py | 672 ++++++++++++++++++++++++++++++++++++ 4 files changed, 798 insertions(+), 121 deletions(-) create mode 100644 test/test_sports_twins.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c2ae2dc5..92011dd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,23 @@ floor on the release that ships them): - `src.common.api_helper`: `USER_AGENT`, `DEFAULT_HTTP_HEADERS` (read-only). - `src.logo_downloader`: `fetch_logo`, `save_png_atomically`, `shared_downloader`. +- `src.common.sports_card.unshare_element_fonts` takes an optional third + argument, `element_for_font` (default: the module's `ELEMENT_FOR_FONT`, so + existing calls are unchanged). + +### Sports twins + +- The `SportsCoreSharedMixin` helpers that behave identically to their + `sports_card` twins (`_card_option`, `_vs_text`, `_format_game_time`, + `_coerce_rgb`, `_crisp_size`, `_unshare_element_fonts`, the colour/month/ + weekday/font-grid tables) are now thin wrappers over the `sports_card` + functions, and `_format_game_date` / `_schema_font_size` share its + formatting body and schema parser. No method was removed or renamed and + nothing renders differently: `test/test_sports_twins.py` checks each pair + against the same inputs, and the old and new mixin agree on every input + there. The pairs that do differ -- favourite-result colours on nested + payloads, the weekday's timezone, the element-name map, per-mode colours -- + are left as they are and pinned in that test. ### Logo downloads diff --git a/src/common/sports_card.py b/src/common/sports_card.py index b2b7d563..2c5c88fb 100644 --- a/src/common/sports_card.py +++ b/src/common/sports_card.py @@ -339,6 +339,18 @@ def format_game_date(config: Optional[Dict[str, Any]], logger, date_text: str, if not raw: return "" fmt = str(scroll_card_option(config, "date_format", "abbrev") or "abbrev") + return _format_date_as(fmt, raw, lambda: weekday_for(config, logger, game)) + + +def _format_date_as(fmt: str, raw: str, weekday, months=MONTH_ABBR) -> str: + """Render a stripped, non-empty "M/D" *raw* in style *fmt*. + + The body both date formatters share. They differ in which setting names the + style and in which zone the weekday is taken from (see + ``SportsCoreSharedMixin._format_game_date``), so those arrive as arguments: + *weekday* is a zero-argument callable, only called for the "weekday" style. + *months* lets the mixin keep reading its (overridable) ``_MONTH_ABBR``. + """ if fmt == "numeric": return raw parts = raw.replace("-", "/").split("/") @@ -347,14 +359,14 @@ def format_game_date(config: Optional[Dict[str, Any]], logger, date_text: str, month, day = int(parts[0]), int(parts[1]) if not 1 <= month <= 12: return raw - name = MONTH_ABBR[month - 1] + name = months[month - 1] if fmt == "numeric_day_first": return f"{day}/{month}" if fmt == "day_first": return f"{day} {name}" if fmt == "weekday": - weekday = weekday_for(config, logger, game) - return f"{weekday} {name} {day}" if weekday else f"{name} {day}" + day_name = weekday() + return f"{day_name} {name} {day}" if day_name else f"{name} {day}" return f"{name} {day}" @@ -388,6 +400,29 @@ def format_game_time(config: Optional[Dict[str, Any]], time_text: str) -> str: _SCHEMA_FONT_SIZE_CACHE: Dict[str, Dict[str, int]] = {} +def _read_schema_font_sizes(schema_path: str) -> Dict[str, int]: + """``{element: font_size default}`` from a config_schema.json. Raises. + + The parse both schema-default lookups share. Each keeps its own cache -- + this function per schema path, ``SportsCoreSharedMixin._schema_font_size`` + per class -- because the lifetimes differ: a class is rebuilt when the + display service reloads a plugin, a module-level path cache is not. One + cache would change when a reloaded plugin sees an edited schema. + """ + import json + with open(schema_path) as fh: + schema = json.load(fh) + props = (schema.get('properties', {}) + .get('customization', {}) + .get('properties', {})) + sizes: Dict[str, int] = {} + for key, spec in props.items(): + size = spec.get('properties', {}).get('font_size', {}).get('default') + if size is not None: + sizes[key] = int(size) + return sizes + + def schema_font_size(schema_path: str, element_key) -> Optional[int]: """The font_size this plugin's config_schema.json declares, or None. @@ -399,18 +434,8 @@ def schema_font_size(schema_path: str, element_key) -> Optional[int]: return None cache = _SCHEMA_FONT_SIZE_CACHE.get(schema_path) if cache is None: - cache = {} try: - import json - with open(schema_path) as fh: - schema = json.load(fh) - props = (schema.get('properties', {}) - .get('customization', {}) - .get('properties', {})) - for key, spec in props.items(): - size = spec.get('properties', {}).get('font_size', {}).get('default') - if size is not None: - cache[key] = int(size) + cache = _read_schema_font_sizes(schema_path) except Exception as exc: # See sports_shared._schema_font_size: an unreadable schema # silently disables the pixel-grid snap for every element. @@ -444,7 +469,7 @@ def resolve_font_size(schema_path: str, element_config, element_key, return crisp_size(font_name, default_size, aliases, grid_table) -def unshare_element_fonts(logger, fonts): +def unshare_element_fonts(logger, fonts, element_for_font=None): """Give each colourable element its own face object. The colour a draw gets is resolved from the face it was handed, and @@ -459,13 +484,21 @@ def unshare_element_fonts(logger, fonts): the ability to tell two elements apart does. Faces that cannot be rebuilt (a BDF loaded through freetype.Face, anything without a usable path) are left shared, and their draws stay white as before. + + *element_for_font* names the font keys to consider, in order (the first + holder of a face keeps it); it defaults to this module's + :data:`ELEMENT_FOR_FONT`. ``SportsCoreSharedMixin`` passes its own map, + which names different keys -- see ``resolve_font_color`` for why the two + vocabularies are kept apart. """ try: from src.common.font_layout import load_truetype as _load except ImportError: # pragma: no cover return fonts + if element_for_font is None: + element_for_font = ELEMENT_FOR_FONT seen = {} - for key in ELEMENT_FOR_FONT: + for key in element_for_font: font = fonts.get(key) if font is None: continue diff --git a/src/common/sports_shared.py b/src/common/sports_shared.py index 8ac40a3a..e9112ec6 100644 --- a/src/common/sports_shared.py +++ b/src/common/sports_shared.py @@ -64,14 +64,27 @@ live here. Only ``_SCORE_PROBE_TEXT`` varies -- afl and basketball reach three d a side and override it, the same two that override ``_SCORE_PROBE`` on ``SportsGameRendererMixin``. -DELIBERATELY NOT MERGED WITH sports_card ----------------------------------------- -Fourteen of these have same-named twins in ``src/common/sports_card.py``, which -the scoreboards' ``game_renderer.py`` already uses. They are NOT wired together -here. Only five are provably equivalent by source comparison; the other nine -differ in ways inspection cannot settle, and a wrong guess silently changes what -every scoreboard draws. Merging them needs differential testing against both -implementations, and is left for its own change. +TWINS IN sports_card +-------------------- +Many of these have same-named twins in ``src/common/sports_card.py``, which the +scoreboards' ``game_renderer.py`` uses. ``test/test_sports_twins.py`` calls +each pair with the same inputs (the plugins' fixture games in every payload +shape, plus edge cases) and splits them in two: + +- Identical: ``_card_option``, ``_vs_text``, ``_format_game_time``, + ``_coerce_rgb``, ``_crisp_size``, ``_unshare_element_fonts`` (given the same + element map) and the constant tables. These are now thin wrappers over the + ``sports_card`` function; ``_format_game_date`` and ``_schema_font_size`` + share its body/parser while keeping their own setting, zone and cache. + ``_resolve_font_size`` agrees too but keeps its body, because it dispatches + through the overridable ``_schema_font_size``/``_crisp_size``. +- Different, and pinned as they are: ``_side_is_favorite`` / + ``_favorite_result`` / ``_recent_score_color`` (flat keys and the host's + favourites only), ``_weekday_for`` (the plugin's resolved zone, not + ``config["timezone"]``), ``_font_color`` / ``_ELEMENT_FOR_FONT`` (another + element vocabulary), ``_element_color`` (passes ``SKIN_MODE``). Each shows + up in one display mode only, so which side is right is a product decision; + the test that pins it names the difference. """ from __future__ import annotations @@ -87,6 +100,7 @@ import pytz from src.common.espn_dates import fetch_espn_scoreboard import requests from PIL import Image, ImageDraw, ImageFont +from src.common import sports_card as _card from src.common.font_layout import load_truetype logger = logging.getLogger(__name__) @@ -171,19 +185,17 @@ class SportsCoreSharedMixin: _ELEMENT_FOR_FONT: ClassVar[Dict[str, str]] = { "score": "score_text", "time": "period_text", "team": "team_text", "detail": "detail_text", "status": "status_text"} + # The tables below are sports_card's (and font_layout's) values. The dicts + # are copies, so a caller that mutates one module's table -- or a subclass + # that replaces it -- does not reach into the other. #: Default tint for a favourite team's finished game. - FAVORITE_RESULT_COLOR_DEFAULTS: ClassVar[Dict[str, Tuple[int, int, int]]] = { - "win": (0, 255, 0), "loss": (255, 0, 0), "tie": (255, 200, 0)} - _MONTH_ABBR: ClassVar[Tuple[str, ...]] = ( - "Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec") - _WEEKDAY_ABBR: ClassVar[Tuple[str, ...]] = ( - "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun") + FAVORITE_RESULT_COLOR_DEFAULTS: ClassVar[Dict[str, Tuple[int, int, int]]] = dict( + _card.FAVORITE_RESULT_COLOR_DEFAULTS) + _MONTH_ABBR: ClassVar[Tuple[str, ...]] = _card.MONTH_ABBR + _WEEKDAY_ABBR: ClassVar[Tuple[str, ...]] = _card.WEEKDAY_ABBR #: Bitmap fonts snap to their native pixel grid. - _FONT_PIXEL_GRID: ClassVar[Dict[str, int]] = { - "PressStart2P-Regular.ttf": 8, "4x6-font.ttf": 7} - _FONT_NAME_ALIASES: ClassVar[Dict[str, str]] = { - "press_start": "PressStart2P-Regular.ttf", "four_by_six": "4x6-font.ttf"} + _FONT_PIXEL_GRID: ClassVar[Dict[str, int]] = dict(_card.FONT_PIXEL_GRID) + _FONT_NAME_ALIASES: ClassVar[Dict[str, str]] = dict(_card.FONT_NAME_ALIASES) #: Accepted values for the other-games quality filter. _QUALITY_CHOICES: ClassVar[frozenset] = frozenset({"any", "ranked"}) #: How long to stay quiet between ranking-coverage warnings. @@ -213,13 +225,11 @@ class SportsCoreSharedMixin: """Snap *desired* to the nearest size *font_file* renders crisply at. A face with no known grid is returned unchanged, so a user-supplied - font is never second-guessed. + font is never second-guessed. The class's own tables are passed, so a + host that declares extra faces keeps them. """ - font_file = cls._FONT_NAME_ALIASES.get(font_file, font_file) - grid = cls._FONT_PIXEL_GRID.get(font_file) - if not grid or not desired or desired <= 0: - return desired - return max(grid, int(round(float(desired) / grid)) * grid) + return _card.crisp_size(font_file, desired, + cls._FONT_NAME_ALIASES, cls._FONT_PIXEL_GRID) #: Absolute path of this plugin's directory, declared by the plugin #: itself. The mixin cannot work it out -- see _plugin_dir. @@ -281,23 +291,19 @@ class SportsCoreSharedMixin: """The font_size this plugin's config_schema.json declares, or None.""" if not element_key: return None + # Cached per class, not in sports_card's per-path cache: the display + # service rebuilds the class when it reloads a plugin, and that is + # what makes an edited schema take effect. Both caches parse through + # sports_card._read_schema_font_sizes. cache = getattr(self.__class__, '_SCHEMA_FONT_SIZES', None) if cache is None: cache = {} try: - import json directory = self._plugin_dir() if directory is None: raise FileNotFoundError("no config_schema.json on the MRO") - with open(os.path.join(directory, 'config_schema.json')) as fh: - schema = json.load(fh) - props = (schema.get('properties', {}) - .get('customization', {}) - .get('properties', {})) - for key, spec in props.items(): - size = spec.get('properties', {}).get('font_size', {}).get('default') - if size is not None: - cache[key] = int(size) + cache = _card._read_schema_font_sizes( + os.path.join(directory, 'config_schema.json')) except Exception as exc: # Say so. An unreadable schema is not cosmetic: every element's # configured size then stops matching "the schema default", is @@ -339,10 +345,7 @@ class SportsCoreSharedMixin: def _card_option(self, key: str, default: Any = None) -> Any: """Read one key from the scroll_card config block.""" - block = (self.config or {}).get("scroll_card") - if isinstance(block, dict) and block.get(key) is not None: - return block.get(key) - return default + return _card.scroll_card_option(self.config, key, default) def _switch_upcoming_center(self) -> str: """Middle of the full-screen upcoming scorebug: 'vs', 'date_time' or 'none'.""" @@ -354,7 +357,7 @@ class SportsCoreSharedMixin: def _vs_text(self) -> str: """Separator drawn between the teams -- "VS", "@", "at", anything.""" - return str(self._card_option("vs_text", "VS")) + return _card.vs_text(self.config) def _switch_date_format(self) -> str: """Date style for the full-screen scorebug. @@ -374,28 +377,19 @@ class SportsCoreSharedMixin: return fmt def _format_game_date(self, date_text: str, game: Optional[Dict] = None) -> str: - """Format an upcoming date per scroll_card.switch_date_format.""" + """Format an upcoming date per scroll_card.switch_date_format. + + The formatting is sports_card's. What differs from the card's + ``format_game_date`` is passed in: the setting (``switch_date_format``, + see :meth:`_switch_date_format`) and the weekday, which comes from + :meth:`_weekday_for` and so from this plugin's resolved timezone. + """ raw = str(date_text or "").strip() if not raw: return raw - fmt = self._switch_date_format() - if fmt == "numeric": - return raw - parts = raw.replace("-", "/").split("/") - if not (len(parts) >= 2 and parts[0].strip().isdigit() and parts[1].strip().isdigit()): - return raw - month, day = int(parts[0]), int(parts[1]) - if not 1 <= month <= 12: - return raw - name = self._MONTH_ABBR[month - 1] - if fmt == "numeric_day_first": - return f"{day}/{month}" - if fmt == "day_first": - return f"{day} {name}" - if fmt == "weekday": - weekday = self._weekday_for(game) - return f"{weekday} {name} {day}" if weekday else f"{name} {day}" - return f"{name} {day}" + return _card._format_date_as(self._switch_date_format(), raw, + lambda: self._weekday_for(game), + self._MONTH_ABBR) def _weekday_for(self, game: Optional[Dict]) -> str: """Weekday abbreviation from the game's start time, or ''.""" @@ -413,22 +407,7 @@ class SportsCoreSharedMixin: def _format_game_time(self, time_text: str) -> str: """Return the time as-is (12h) or converted to 24h.""" - raw = str(time_text or "").strip() - if not raw or str(self._card_option("time_format", "12h")) != "24h": - return raw - cleaned = raw.upper().replace(" ", "") - meridiem = "AM" if cleaned.endswith("AM") else "PM" if cleaned.endswith("PM") else "" - if not meridiem: - return raw - try: - hh, _, mm = cleaned[:-2].partition(":") - hour, minute = int(hh), int(mm or 0) - except ValueError: - return raw - if not (0 <= hour <= 12 and 0 <= minute <= 59): - return raw - hour = hour % 12 + (12 if meridiem == "PM" else 0) - return f"{hour:02d}:{minute:02d}" + return _card.format_game_time(self.config, time_text) def _scorebug_font(self, draw, text: str, width: int): """The face this scorebug draws its date and time in. @@ -560,15 +539,7 @@ class SportsCoreSharedMixin: @staticmethod def _coerce_rgb(value, fallback): """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" - # Checked before unpacking: a 3-character string ("123") would otherwise - # iterate into three digits and yield a colour rather than the fallback. - if not isinstance(value, (list, tuple)) or len(value) != 3: - return fallback - try: - r, g, b = (max(0, min(255, int(channel))) for channel in value) - except (TypeError, ValueError): - return fallback - return (r, g, b) + return _card.coerce_rgb(value, fallback) @staticmethod def _side_is_favorite(game: Dict, side: str, favorites: set) -> bool: @@ -849,28 +820,12 @@ class SportsCoreSharedMixin: the ability to tell two elements apart does. Faces that cannot be rebuilt (a BDF loaded through freetype.Face, anything without a usable path) are left shared, and their draws stay white as before. + + The body is sports_card's; this class's own element map is passed, so + the keys considered are the ones this class colours by. """ - try: - from src.common.font_layout import load_truetype as _load - except ImportError: # pragma: no cover - return fonts - seen = {} - for key in self._ELEMENT_FOR_FONT: - font = fonts.get(key) - if font is None: - continue - if id(font) not in seen: - seen[id(font)] = key - continue - path, size = getattr(font, "path", None), getattr(font, "size", None) - if not path or not size: - continue - try: - fonts[key] = _load(path, size) - except (OSError, ValueError, TypeError): - self.logger.debug( - "Could not un-share the %s face; it keeps the default colour", key) - return fonts + return _card.unshare_element_fonts(self.logger, fonts, + self._ELEMENT_FOR_FONT) def _font_color(self, font, default: Tuple[int, int, int] = (255, 255, 255)): """Colour for whichever element owns this face. diff --git a/test/test_sports_twins.py b/test/test_sports_twins.py new file mode 100644 index 00000000..4202468d --- /dev/null +++ b/test/test_sports_twins.py @@ -0,0 +1,672 @@ +"""The twins: ``SportsCoreSharedMixin`` methods vs ``sports_card`` functions. + +Every scoreboard draws the same game twice over: switch mode through its +``sports.py`` (``SportsCoreSharedMixin``, ``self._recent_score_color(...)``) +and scroll/Vegas mode through its ``game_renderer.py`` +(``sports_card``, ``_card.recent_score_color(...)``). The two modules grew +same-named helpers independently, so this file calls each pair with the same +inputs and says which ones agree. + +Two kinds of test live here, and the difference matters: + +* ``TestIdentical`` -- pairs that agree on every input below. Most mixin + methods in this set are now thin wrappers over the ``sports_card`` function, + so the check is also what keeps a future "fix" to one side from quietly + becoming a divergence (a mixin body re-grown, a wrapper given different + arguments). +* ``TestPinnedDivergence`` -- pairs that do NOT agree. Their current behaviour + is pinned on purpose, with the minimal input that shows the difference. A + divergence here is user-visible (a colour, a weekday) in one display mode, and + which side is right is an owner decision, not a refactor. When that decision + is made, the test that pins it is the one to edit, deliberately. + +The game corpus is the plugins' own harness fixtures +(``plugins/*/test/fixtures/mock.json`` in ledmatrix-plugins), reduced to the +keys these helpers read and embedded below, in the three payload shapes the +helpers are handed: flat (what ``_extract_game_details_common`` builds -- the +switch-mode input), flat plus nested (what the renderers' +``_normalize_game_payload`` hands the scroll card), and nested only. Point +``LEDMATRIX_PLUGINS`` at a ledmatrix-plugins checkout to add every event in +those fixtures to the corpus. +""" + +import itertools +import json +import logging +import os +from datetime import datetime, timezone +from pathlib import Path +from zoneinfo import ZoneInfo + +import pytest + +from src.common import sports_card as C +from src.common.font_layout import load_truetype, resolve_asset_path +from src.common.sports_shared import SportsCoreSharedMixin + +LOG = logging.getLogger("test_sports_twins") + +# --------------------------------------------------------------------------- +# Corpus +# --------------------------------------------------------------------------- + +#: (plugin, start, home abbr, home id, home score, away abbr, away id, +#: away score, state) -- one row per distinct event in the eight scoreboards' +#: test/fixtures/mock.json. +FIXTURE_EVENTS = [ + ("afl", "2026-07-10T09:40Z", "COLL", "17", "89", "NMFC", "5", "85", "post"), + ("afl", "2026-07-11T03:15Z", "STK", "18", "0", "PORT", "7", "0", "pre"), + ("afl", "2026-07-10T11:30Z", "FRE", "1", "54", "SYD", "4", "48", "in"), + ("baseball", "2026-07-09T02:10Z", "LAD", "19", "5", "SF", "26", "3", "post"), + ("baseball", "2026-07-10T10:05Z", "NYY", "10", "4", "BOS", "2", "3", "in"), + ("baseball", "2026-07-11T23:10Z", "NYM", "21", "0", "ATL", "15", "0", "pre"), + ("basketball", "2026-01-14T00:30Z", "BOS", "2", "112", "NY", "18", "104", "post"), + ("basketball", "2026-01-15T03:30Z", "LAL", "13", "78", "GS", "9", "72", "in"), + ("basketball", "2026-01-16T02:00Z", "DEN", "7", "0", "DAL", "6", "0", "pre"), + ("football", "2026-01-14T01:15Z", "KC", "12", "27", "BUF", "2", "24", "post"), + ("football", "2026-01-15T10:30Z", "PHI", "21", "17", "DAL", "6", "14", "in"), + ("football", "2026-01-18T23:30Z", "DET", "8", "0", "GB", "9", "0", "pre"), + ("hockey", "2026-01-14T00:00Z", "BOS", "1", "4", "TOR", "21", "2", "post"), + ("hockey", "2026-01-15T10:30Z", "TB", "20", "3", "DAL", "9", "2", "in"), + ("hockey", "2026-01-16T00:00Z", "CHI", "4", "0", "NYR", "13", "0", "pre"), + ("lacrosse", "2026-04-14T18:00Z", "DUKE", "150", "14", "SYR", "183", "11", "post"), + ("lacrosse", "2026-04-15T10:30Z", "JHU", "2305", "8", "UVA", "258", "7", "in"), + ("lacrosse", "2026-04-16T22:00Z", "COR", "172", "0", "PSU", "213", "0", "pre"), + ("nrl", "2026-07-10T09:00Z", "BRI", "16", "18", "PEN", "18", "12", "in"), + ("nrl", "2026-07-09T09:00Z", "MEL", "12", "24", "SYD", "20", "10", "post"), + ("nrl", "2026-07-12T09:00Z", "PAR", "14", "0", "PEN", "18", "0", "pre"), + ("soccer", "2026-01-14T20:00Z", "ARS", "359", "2", "CHE", "363", "1", "post"), + ("soccer", "2026-01-15T11:30Z", "LIV", "364", "1", "MNC", "382", "1", "in"), + ("soccer", "2026-01-16T20:00Z", "TOT", "367", "0", "MAN", "360", "0", "pre"), +] + +_LEAGUE = {"afl": "afl", "baseball": "mlb", "basketball": "nba", "football": "nfl", + "hockey": "nhl", "lacrosse": "ncaa_mens_lacrosse", "nrl": "nrl", + "soccer": "eng.1"} + + +def _extra_fixture_events(): + """Every event in a ledmatrix-plugins checkout, when one is named.""" + raw = os.environ.get("LEDMATRIX_PLUGINS") + if not raw: + return [] + root = Path(raw) + if (root / "plugins").is_dir(): + root = root / "plugins" + rows = [] + for path in sorted(root.glob("*-scoreboard/test/fixtures/mock.json")): + plugin = path.parts[-4].replace("-scoreboard", "") + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + continue + for block in data.values(): + for ev in (block.get("events") or []) if isinstance(block, dict) else []: + try: + comp = ev["competitions"][0] + sides = {c["homeAway"]: c for c in comp["competitors"]} + rows.append((plugin, ev["date"], + sides["home"]["team"]["abbreviation"], + sides["home"]["team"]["id"], sides["home"].get("score"), + sides["away"]["team"]["abbreviation"], + sides["away"]["team"]["id"], sides["away"].get("score"), + "")) + except (KeyError, IndexError, TypeError): + continue + return rows + + +def _flat(row): + plugin, start, ha, hid, hs, aa, aid, as_, _state = row + return { + "league": _LEAGUE.get(plugin, plugin), + "home_abbr": ha, "home_id": hid, "home_score": hs, + "away_abbr": aa, "away_id": aid, "away_score": as_, + "start_time_utc": datetime.fromisoformat(start.replace("Z", "+00:00")), + } + + +def _with_nested(game): + """The scroll card's input: flat keys kept, nested team dicts added.""" + out = dict(game) + for side in ("home", "away"): + out[f"{side}_team"] = {"abbrev": game.get(f"{side}_abbr"), + "id": game.get(f"{side}_id"), + "score": game.get(f"{side}_score")} + return out + + +def _nested_only(game): + out = {k: v for k, v in _with_nested(game).items() + if not k.startswith(("home_abbr", "home_id", "home_score", + "away_abbr", "away_id", "away_score"))} + return out + + +_ROWS = FIXTURE_EVENTS + _extra_fixture_events() +FLAT_GAMES = [_flat(r) for r in _ROWS] +EDGE_FLAT_GAMES = [ + # NRL: abbreviations are not unique, ids are. + {"league": "nrl", "home_abbr": "NEW", "home_id": "4", "home_score": "20", + "away_abbr": "NEW", "away_id": "12", "away_score": "10"}, + {"league": "nfl", "home_abbr": "KC", "away_abbr": "BUF"}, + {"league": "nfl", "home_abbr": "KC", "away_abbr": "BUF", "home_score": "", "away_score": ""}, + {"league": "nfl", "home_abbr": "KC", "away_abbr": "BUF", "home_score": "-", "away_score": "-"}, + {"league": "nfl", "home_abbr": "KC", "away_abbr": "BUF", "home_score": "5.0", + "away_score": "2.0"}, + {"league": "nfl", "home_abbr": "KC", "home_id": 12, "away_abbr": "BUF", "away_id": 2, + "home_score": 3, "away_score": 3}, + {"league": "nfl", "home_abbr": None, "away_abbr": "BUF", "home_score": "1", + "away_score": "2"}, + {"league": "nfl", "home_abbr": " kc ", "away_abbr": "BUF", "home_score": "1", + "away_score": "2"}, +] +ALL_FLAT = FLAT_GAMES + EDGE_FLAT_GAMES +SCROLL_SHAPED = [_with_nested(g) for g in ALL_FLAT] + + +def _favorite_choices(game): + """Every way a favourites list can relate to this game.""" + out = [[], ["AP_TOP_25"], ["NOBODY"]] + for side in ("home", "away"): + for key in ("abbr", "id"): + value = game.get(f"{side}_{key}") + if value is not None: + out.append([str(value)]) + out.append([" " + str(value).lower() + " "]) + if game.get("home_abbr") and game.get("away_abbr"): + out.append([game["home_abbr"], game["away_abbr"]]) + return out + + +# --------------------------------------------------------------------------- +# Hosts +# --------------------------------------------------------------------------- + +class _Host(SportsCoreSharedMixin): + """The mixin with just the state these helpers read.""" + + def __init__(self, config=None, favorites=None, tz=timezone.utc, fonts=None): + self.config = config + self.favorite_teams = favorites + self.logger = LOG + self.fonts = fonts or {} + self._tz = tz + + def _get_timezone(self): + return self._tz + + +#: The map seven of the eight scoreboards' sports.py declare over the mixin's +#: default (football is the one that inherits the default). +PLUGIN_ELEMENT_FOR_FONT = { + "odds": "odds_text", "score": "score_text", "time": "period_text", + "team": "team_name", "status": "status_text", "detail": "detail_text", + "rank": "rank_text", +} + + +def _call(fn, *args): + """Result or the exception type, so a raise on one side is a difference.""" + try: + return fn(*args) + except Exception as exc: # noqa: BLE001 - the type is the result here + return f"" + + +def _mismatches(pairs): + return [(label, a, b) for label, a, b in pairs if a != b] + + +RESULT_COLOURS = [ + {"enabled": True}, + {"enabled": True, "win_color": [1, 2, 3], "loss_color": "123", + "tie_color": [999, -1, "7"]}, + {"enabled": False}, + {}, +] + +SCROLL_CARD_CONFIGS = [ + None, {}, {"scroll_card": None}, {"scroll_card": {}}, {"scroll_card": "notadict"}, + {"scroll_card": {"vs_text": "@", "date_format": "weekday", "time_format": "24h", + "switch_date_format": "inherit"}}, + {"scroll_card": {"vs_text": None, "date_format": "day_first", "time_format": "12h", + "switch_date_format": "inherit"}}, + {"scroll_card": {"vs_text": 7, "date_format": "numeric", "switch_date_format": "inherit"}}, + {"scroll_card": {"date_format": "numeric_day_first", "time_format": "24h", + "switch_date_format": "inherit"}}, + {"scroll_card": {"date_format": "abbrev", "switch_date_format": "inherit"}}, + {"scroll_card": {"date_format": "bogus", "switch_date_format": "inherit"}}, +] +TIMES = ["7:30 PM", "12:00 AM", "12:05pm", "7 PM", "13:00 PM", "TBD", "", None, + "7:61 PM", "x:30 PM", " 9:05 am ", "12:00 PM", "0:15 AM"] +DATES = ["9/19", "09-19", "13/19", "Sep 19", "", None, "9/19/2026", " 1/2 ", "0/5"] +STARTS = [datetime(2026, 9, 19, 23, 30, tzinfo=timezone.utc), "2026-09-19T23:30Z", + "2026-09-20T02:00:00+00:00", "garbage", None, "", datetime(2026, 1, 1)] +TIMEZONES = ["America/New_York", "Australia/Sydney", "UTC", "Not/AZone", None] + +PS = resolve_asset_path("assets/fonts/PressStart2P-Regular.ttf") +F46 = resolve_asset_path("assets/fonts/4x6-font.ttf") + + +def _font_sets(): + a, b, c = load_truetype(PS, 8), load_truetype(PS, 16), load_truetype(F46, 7) + keys = ("odds", "score", "time", "team", "status", "detail", "rank") + return { + "distinct": {"score": a, "time": b, "team": c, "status": load_truetype(PS, 8), + "detail": load_truetype(F46, 7), "rank": load_truetype(F46, 14), + "odds": load_truetype(F46, 7)}, + "score+time share": {"score": a, "time": a, "team": c}, + "team+rank share": {"score": a, "time": b, "team": c, "rank": c}, + "odds+score share": {"score": a, "odds": a, "time": b}, + "all share": {k: a for k in keys}, + } + + +def _partition(fonts): + """Which keys still share one face object -- what unsharing decides.""" + groups = {} + for key, font in fonts.items(): + groups.setdefault(id(font), []).append(key) + return sorted(sorted(keys) for keys in groups.values()) + + +def _faces(fonts): + return {k: (getattr(f, "path", None), getattr(f, "size", None)) for k, f in fonts.items()} + + +def _schema_dir(tmp_path, name, text): + d = tmp_path / name + d.mkdir() + if text is not None: + (d / "config_schema.json").write_text(text) + return d + + +SCHEMAS = { + "good": json.dumps({"properties": {"customization": {"properties": { + "score_text": {"properties": {"font_size": {"default": 10}}}, + "period_text": {"properties": {"font_size": {"default": 8}}}, + "detail_text": {"properties": {"font_size": {"default": "6"}}}, + "team_name": {"properties": {"font": {"default": "x"}}}}}}}), + "bad_json": "{not json", + "bad_default": json.dumps({"properties": {"customization": {"properties": { + "score_text": {"properties": {"font_size": {"default": "big"}}}}}}}), + "missing": None, +} + + +# --------------------------------------------------------------------------- +# Identical pairs +# --------------------------------------------------------------------------- + +class TestIdentical: + """Pairs that agree on every input. Keep it that way.""" + + def test_scroll_card_option(self): + pairs = [] + for i, cfg in enumerate(SCROLL_CARD_CONFIGS): + host = _Host(cfg) + for key, default in itertools.product( + ("vs_text", "date_format", "time_format", "missing"), (None, "D", 0)): + pairs.append((f"cfg{i} {key} {default!r}", + _call(host._card_option, key, default), + _call(C.scroll_card_option, cfg, key, default))) + assert not _mismatches(pairs) + + def test_vs_text(self): + pairs = [(f"cfg{i}", _call(_Host(cfg)._vs_text), _call(C.vs_text, cfg)) + for i, cfg in enumerate(SCROLL_CARD_CONFIGS)] + assert not _mismatches(pairs) + + def test_format_game_time(self): + pairs = [(f"cfg{i} {t!r}", _call(_Host(cfg)._format_game_time, t), + _call(C.format_game_time, cfg, t)) + for (i, cfg), t in itertools.product(enumerate(SCROLL_CARD_CONFIGS), TIMES)] + assert not _mismatches(pairs) + + def test_coerce_rgb(self): + values = [[1, 2, 3], (300, -4, "5"), "123", [1, 2], [1, 2, 3, 4], None, 42, + {"r": 1, "g": 2, "b": 3}, ["a", 1, 2], [1.9, 2, 3], [None, 1, 2]] + pairs = [(repr(v), _call(_Host._coerce_rgb, v, (4, 5, 6)), + _call(C.coerce_rgb, v, (4, 5, 6))) for v in values] + assert not _mismatches(pairs) + + def test_crisp_size(self): + names = ["PressStart2P-Regular.ttf", "4x6-font.ttf", "press_start", "four_by_six", + "5by7.regular.ttf", "user.ttf", None] + sizes = [None, 0, -3, 1, 4, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16, 20, 7.5, "8"] + pairs = [(f"{n} {s!r}", _call(_Host._crisp_size, n, s), _call(C.crisp_size, n, s)) + for n, s in itertools.product(names, sizes)] + assert not _mismatches(pairs) + + def test_crisp_size_honours_a_hosts_own_tables(self): + """A class that declares extra faces keeps them through the wrapper.""" + cls = type("Extra", (_Host,), {"_FONT_PIXEL_GRID": {"extra.ttf": 5}, + "_FONT_NAME_ALIASES": {"x": "extra.ttf"}}) + assert cls._crisp_size("x", 12) == C.crisp_size("x", 12, {"x": "extra.ttf"}, + {"extra.ttf": 5}) == 10 + + def test_constant_tables(self): + assert SportsCoreSharedMixin.FAVORITE_RESULT_COLOR_DEFAULTS == \ + C.FAVORITE_RESULT_COLOR_DEFAULTS + assert SportsCoreSharedMixin._MONTH_ABBR == C.MONTH_ABBR + assert SportsCoreSharedMixin._WEEKDAY_ABBR == C.WEEKDAY_ABBR + assert SportsCoreSharedMixin._FONT_PIXEL_GRID == C.FONT_PIXEL_GRID + assert SportsCoreSharedMixin._FONT_NAME_ALIASES == C.FONT_NAME_ALIASES + + def test_constant_dicts_are_not_aliased(self): + # Equal, but separate objects: a caller mutating one table (tests do) + # must not reach into the other module. + assert SportsCoreSharedMixin.FAVORITE_RESULT_COLOR_DEFAULTS is not \ + C.FAVORITE_RESULT_COLOR_DEFAULTS + assert SportsCoreSharedMixin._FONT_PIXEL_GRID is not C.FONT_PIXEL_GRID + assert SportsCoreSharedMixin._FONT_NAME_ALIASES is not C.FONT_NAME_ALIASES + + @pytest.mark.parametrize("schema", sorted(SCHEMAS)) + def test_schema_font_size_and_resolve_font_size(self, tmp_path, schema): + d = _schema_dir(tmp_path, schema, SCHEMAS[schema]) + host = type("H_" + schema, (_Host,), {"_PLUGIN_DIR": str(d)})() + path = str(d / "config_schema.json") + pairs = [] + for key in ("score_text", "period_text", "detail_text", "team_name", "nope", "", None): + pairs.append((f"schema {key!r}", _call(host._schema_font_size, key), + _call(C.schema_font_size, path, key))) + for ec, name, size in itertools.product( + (None, {}, {"font_size": 10}, {"font_size": "10"}, {"font_size": 11}, + {"font_size": "big"}, {"font_size": None}, {"font_size": 8.7}), + ("PressStart2P-Regular.ttf", "4x6-font.ttf", "press_start", "user.ttf"), + (6, 8, 10, None)): + pairs.append((f"resolve {key!r} {ec} {name} {size}", + _call(host._resolve_font_size, ec, key, size, name), + _call(C.resolve_font_size, path, ec, key, size, name))) + assert not _mismatches(pairs) + + @pytest.mark.parametrize("element_map", ["mixin default", "plugin sports.py"]) + def test_unshare_element_fonts_given_the_same_map(self, element_map): + """Same map in, same faces out. (The maps themselves differ; pinned below.)""" + mapping = (SportsCoreSharedMixin._ELEMENT_FOR_FONT if element_map == "mixin default" + else PLUGIN_ELEMENT_FOR_FONT) + host = type("H", (_Host,), {"_ELEMENT_FOR_FONT": mapping})() + for name, fonts in _font_sets().items(): + mine, theirs = dict(fonts), dict(fonts) + host._unshare_element_fonts(mine) + C.unshare_element_fonts(LOG, theirs, mapping) + assert _partition(mine) == _partition(theirs), name + assert _faces(mine) == _faces(theirs), name + + def test_unshare_element_fonts_default_map_is_unchanged(self): + """Omitting the new argument keeps the card's own map.""" + for name, fonts in _font_sets().items(): + default, explicit = dict(fonts), dict(fonts) + C.unshare_element_fonts(LOG, default) + C.unshare_element_fonts(LOG, explicit, C.ELEMENT_FOR_FONT) + assert _partition(default) == _partition(explicit), name + + def test_format_game_date_when_both_read_the_same_setting_and_zone(self): + """With ``switch_date_format: inherit`` the scorebug reads the card's + ``date_format``; given the same zone the two then format identically.""" + pairs = [] + for (i, cfg), tzname in itertools.product(enumerate(SCROLL_CARD_CONFIGS[5:]), + TIMEZONES): + conf = dict(cfg, timezone=tzname) if tzname else dict(cfg) + host = _Host(conf, tz=C.card_tzinfo(conf, LOG)) + for d, start in itertools.product(DATES, STARTS): + game = {"start_time_utc": start} if start is not None else {} + pairs.append((f"cfg{i} tz={tzname} {d!r} {start!r}", + _call(host._format_game_date, d, game), + _call(C.format_game_date, conf, LOG, d, game))) + pairs.append((f"weekday cfg{i} tz={tzname} {start!r}", + _call(host._weekday_for, game), + _call(C.weekday_for, conf, LOG, game))) + assert not _mismatches(pairs) + + def test_format_game_date_honours_a_hosts_month_table(self): + """The scoreboards redeclare _MONTH_ABBR; the shared body must read it.""" + cls = type("Months", (_Host,), {"_MONTH_ABBR": tuple(f"M{i}" for i in range(1, 13))}) + host = cls({"scroll_card": {"switch_date_format": "abbrev"}}) + assert host._format_game_date("9/19") == "M9 19" + + def test_favorite_result_on_the_games_the_scoreboards_build(self): + """Production shape: the extractor stamps ``favorite_teams`` (the + manager's resolved list) on every game, and the manager holds the same + list. On those games -- flat for switch mode, flat plus nested for the + scroll card -- the two sides agree on every result and every colour.""" + pairs = [] + for game in ALL_FLAT: + for favs in _favorite_choices(game): + stamped = dict(game, favorite_teams=list(favs)) + for colours in RESULT_COLOURS: + cfg = {"customization": {"favorite_result_colors": colours}} + host = _Host(cfg, favorites=list(favs)) + pairs.append((f"{game} {favs}", + _call(host._favorite_result, stamped), + _call(C.favorite_result, cfg, _with_nested(stamped)))) + pairs.append((f"{game} {favs} {colours}", + _call(host._recent_score_color, stamped, (9, 9, 9)), + _call(C.recent_score_color, cfg, LOG, + _with_nested(stamped), (9, 9, 9)))) + assert not _mismatches(pairs) + # And the corpus is not vacuous: every verdict actually occurs. + verdicts = {a for _, a, _ in pairs if isinstance(a, str) or a is None} + assert {"win", "loss", "tie", None} <= verdicts + + def test_side_is_favorite_on_flat_games(self): + pairs = [] + for game in ALL_FLAT: + for favs in _favorite_choices(game): + fav_set = {str(f).strip().upper() for f in favs if str(f).strip()} + for side in ("home", "away"): + pairs.append((f"{game} {side} {fav_set}", + _call(_Host._side_is_favorite, game, side, fav_set), + _call(C.side_is_favorite, game, side, fav_set))) + assert not _mismatches(pairs) + + def test_nrl_collision_is_resolved_the_same_way_on_both_sides(self): + """The _favorite_key seam: NRL's "NEW" is two clubs. Neither helper + calls the seam; both match abbreviation OR id, so an id favourite picks + one club and an abbreviation favourite picks both (no verdict).""" + game = EDGE_FLAT_GAMES[0] + for favs, expected in ((["4"], "win"), (["12"], "loss"), (["NEW"], None)): + host = _Host({}, favorites=favs) + assert host._favorite_result(game) == expected + assert C.favorite_result({"favorite_teams": favs}, game) == expected + + def test_an_ambiguous_nrl_abbreviation_tints_on_both_sides(self): + """Agreed -- and at odds with NRL's own favourite rule. + + NRL's resolver logs a shared abbreviation ("NEW") as an error and + passes it through unchanged, and its _is_favorite_game matches ids + only, so selection never treats "NEW" as a favourite. Both colour + helpers match on abbreviation too, so both modes tint a Knights result + (and a Warriors one) for a user who typed "NEW". Not a twin + divergence; a seam neither helper consults. + """ + on = {"customization": {"favorite_result_colors": {"enabled": True}}} + game = {"league": "3", "home_abbr": "NEW", "home_id": "4", "home_score": "20", + "away_abbr": "MEL", "away_id": "12", "away_score": "10", + "favorite_teams": ["NEW"]} + cfg = dict(on, favorite_teams=["NEW"]) + assert _Host(cfg, favorites=["NEW"])._recent_score_color(game, (9, 9, 9)) \ + == C.recent_score_color(cfg, LOG, game, (9, 9, 9)) == (0, 255, 0) + + +# --------------------------------------------------------------------------- +# Pinned divergences -- owner decision pending. Edit deliberately. +# --------------------------------------------------------------------------- + +class TestPinnedDivergence: + """Each test pins one difference between the twins as it stands today. + + None of these is changed by the consolidation that made the identical pairs + wrappers: each one is a colour, a weekday or a font face that one display + mode shows differently from the other, so choosing a side is a product + decision. If you are here because one of these failed, you changed which + side wins -- make sure that was the decision, then update the pin. + """ + + NESTED_WIN = {"league": "nhl", + "home_team": {"abbrev": "TB", "score": "4"}, + "away_team": {"abbrev": "BOS", "score": "1"}} + + def test_side_is_favorite_nested_payload(self): + # DIVERGENCE: the mixin reads only the flat _abbr / _id keys; + # the card also reads _team.{abbrev,abbreviation,id}. Unreachable + # from the scoreboards' own extractors (always flat), reachable from a + # nested-only payload. + assert _Host._side_is_favorite(self.NESTED_WIN, "home", {"TB"}) is False + assert C.side_is_favorite(self.NESTED_WIN, "home", {"TB"}) is True + + def test_favorite_result_nested_payload(self): + # DIVERGENCE: follows from the one above, plus score source: the mixin + # reads home_score/away_score only; the card prefers the nested score. + host = _Host({}, favorites=["TB"]) + game = dict(self.NESTED_WIN, favorite_teams=["TB"]) + assert host._favorite_result(game) is None + assert C.favorite_result({}, game) == "win" + + def test_favorite_result_when_nested_and_flat_scores_disagree(self): + # DIVERGENCE: same game, two score sources. The mixin uses the flat + # score, the card the nested one. The renderers' normaliser only fills + # a nested score that is missing, so this needs a payload that already + # carried both. + game = {"home_abbr": "TB", "away_abbr": "BOS", "home_score": "1", + "away_score": "4", "home_team": {"abbrev": "TB", "score": "4"}, + "away_team": {"abbrev": "BOS", "score": "1"}, "favorite_teams": ["TB"]} + assert _Host({}, favorites=["TB"])._favorite_result(game) == "loss" + assert C.favorite_result({}, game) == "win" + + def test_favorite_result_favourite_sources(self): + # DIVERGENCE: where the favourites come from. The mixin reads only + # self.favorite_teams (the manager's list, resolved at construction); + # the card reads the game's stamped favorite_teams plus the config's + # league block (or root). All eight scoreboards stamp the game, so in + # production both see the same list -- this pins the hand-built case. + game = {"league": "mlb", "home_abbr": "ATL", "away_abbr": "NYM", + "home_score": "5", "away_score": "2"} + on = {"customization": {"favorite_result_colors": {"enabled": True}}} + # Host favourites only, nothing stamped, nothing in config: + assert _Host(on, favorites=["ATL"])._favorite_result(game) == "win" + assert C.favorite_result(on, game) is None + # Config league block only, host list empty: + cfg = dict(on, mlb={"favorite_teams": ["ATL"]}) + assert _Host(cfg, favorites=[])._favorite_result(game) is None + assert C.favorite_result(cfg, game) == "win" + # Stamped on the game only, host list empty: + stamped = dict(game, favorite_teams=["ATL"]) + assert _Host(on, favorites=[])._favorite_result(stamped) is None + assert C.favorite_result(on, stamped) == "win" + # ...which is what reaches the colour: + assert _Host(on, favorites=["ATL"])._recent_score_color(game, (9, 9, 9)) == (0, 255, 0) + assert C.recent_score_color(on, LOG, game, (9, 9, 9)) == (9, 9, 9) + + def test_weekday_zone_source(self): + # DIVERGENCE, user-visible: the scorebug asks the plugin's + # _get_timezone() (plugin setting -> global setting -> system zone); + # the card reads only config["timezone"] and falls back to UTC. The + # scoreboards' schemas default that key to "", and the scroll display + # hands the renderer the plugin config, so a board that sets only the + # global zone gets UTC weekdays in scroll mode: an evening kickoff in + # New York is labelled with the next day. + game = {"start_time_utc": "2026-09-20T00:30:00+00:00"} # Sat 20:30 EDT + host = _Host({}, tz=ZoneInfo("America/New_York")) + assert host._weekday_for(game) == "Sat" + assert C.weekday_for({}, LOG, game) == "Sun" + cfg = {"scroll_card": {"date_format": "weekday", "switch_date_format": "inherit"}} + host = _Host(cfg, tz=ZoneInfo("America/New_York")) + assert host._format_game_date("9/19", game) == "Sat Sep 19" + assert C.format_game_date(cfg, LOG, "9/19", game) == "Sun Sep 19" + + def test_weekday_out_of_range_start(self): + # DIVERGENCE: the mixin catches OverflowError from astimezone() and + # drops the weekday; the card lets it escape to its caller. + game = {"start_time_utc": "9999-12-31T23:59:00+00:00"} + sydney = {"timezone": "Australia/Sydney"} + assert _Host(sydney, tz=ZoneInfo("Australia/Sydney"))._weekday_for(game) == "" + with pytest.raises(OverflowError): + C.weekday_for(sydney, LOG, game) + + def test_date_format_setting(self): + # DIVERGENCE BY DESIGN (documented on _switch_date_format): the scorebug + # reads scroll_card.switch_date_format (default "numeric", the "9/19" + # it has always drawn); the card reads scroll_card.date_format (default + # "abbrev"). "inherit" opts the scorebug into the card's setting. + assert _Host({})._format_game_date("9/19") == "9/19" + assert C.format_game_date({}, LOG, "9/19") == "Sep 19" + + def test_upcoming_centre_setting(self): + # DIVERGENCE BY DESIGN: not a same-named twin, but the same question. + # switch_upcoming_center defaults to "date_time"; the card's + # upcoming_center to "vs". "inherit" opts the scorebug in. + assert _Host({})._switch_upcoming_center() == "date_time" + assert C.upcoming_center_mode({}) == "vs" + cfg = {"scroll_card": {"switch_upcoming_center": "inherit"}} + assert _Host(cfg)._switch_upcoming_center() == C.upcoming_center_mode(cfg) == "vs" + + def test_element_for_font_maps(self): + # DIVERGENCE: the element vocabulary. The mixin default says team_text + # and has no rank/odds; the card says team_name and has rank but no + # odds. Seven scoreboards override the mixin map in sports.py with + # PLUGIN_ELEMENT_FOR_FONT (team_name, rank, odds); football inherits + # the default, and its schema declares team_name, not team_text. + assert SportsCoreSharedMixin._ELEMENT_FOR_FONT == { + "score": "score_text", "time": "period_text", "team": "team_text", + "detail": "detail_text", "status": "status_text"} + assert C.ELEMENT_FOR_FONT == { + "score": "score_text", "time": "period_text", "team": "team_name", + "status": "status_text", "detail": "detail_text", "rank": "rank_text"} + + def test_font_color_team_element(self): + # DIVERGENCE (consequence of the maps): a colour set on team_name + # reaches the card's team face but not the mixin-default one. + team = load_truetype(F46, 7) + fonts = {"score": load_truetype(PS, 8), "team": team} + cfg = {"customization": {"team_name": {"text_color": [1, 1, 1]}}} + assert _Host(cfg, fonts=fonts)._font_color(team, (7, 7, 7)) == (7, 7, 7) + assert C.font_color(cfg, fonts, team, (7, 7, 7)) == (1, 1, 1) + plugin_host = type("P", (_Host,), {"_ELEMENT_FOR_FONT": PLUGIN_ELEMENT_FOR_FONT}) + assert plugin_host(cfg, fonts=fonts)._font_color(team, (7, 7, 7)) == (1, 1, 1) + + def test_unshare_element_fonts_odds_face(self): + # DIVERGENCE (consequence of the maps): the scoreboards' sports.py map + # includes "odds", so switch mode gives the odds face its own object; + # the card's map has no "odds", so scroll mode leaves it sharing the + # score's face (and _card.font_color then colours it as score_text). + shared = load_truetype(PS, 8) + mine = {"score": shared, "odds": shared} + theirs = dict(mine) + type("P", (_Host,), {"_ELEMENT_FOR_FONT": PLUGIN_ELEMENT_FOR_FONT})() \ + ._unshare_element_fonts(mine) + C.unshare_element_fonts(LOG, theirs) + assert mine["odds"] is not mine["score"] + assert theirs["odds"] is theirs["score"] + + def test_schema_default_cache_lifetimes(self, tmp_path): + # DELIBERATE, and the reason there are still two caches: the mixin + # caches per class, the card per schema path. The display service + # builds new classes when it reloads a plugin, so switch mode picks up + # an edited schema then; the card's module-level cache does not. One + # shared cache would change what switch mode does after a reload. + d = _schema_dir(tmp_path, "reload", SCHEMAS["good"]) + path = str(d / "config_schema.json") + first = type("First", (_Host,), {"_PLUGIN_DIR": str(d)})() + assert first._schema_font_size("score_text") == 10 + assert C.schema_font_size(path, "score_text") == 10 + (d / "config_schema.json").write_text(SCHEMAS["good"].replace("10", "16")) + reloaded = type("Reloaded", (_Host,), {"_PLUGIN_DIR": str(d)})() + assert reloaded._schema_font_size("score_text") == 16 + assert first._schema_font_size("score_text") == 10 + assert C.schema_font_size(path, "score_text") == 10 + + def test_element_color_mode(self): + # DIVERGENCE at the call site, not in a body: both resolve through + # src.element_style, but the mixin passes the instance's SKIN_MODE + # ("live"/"recent"/"upcoming", set by all eight scoreboards) and the + # renderers call _card.element_color with no mode, so a per-mode colour + # override applies in switch mode only. + cfg = {"customization": {"score_text": {"text_color": [255, 0, 0]}, + "modes": {"recent": {"score_text": {"text_color": [0, 0, 255]}}}}} + host = _Host(cfg) + host.SKIN_MODE = "recent" + assert host._element_color("score_text") == (0, 0, 255) + assert C.element_color(cfg, "score_text") == (255, 0, 0)