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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-24 15:51:35 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 1fe7237799
commit abedc46104
4 changed files with 798 additions and 121 deletions
+17
View File
@@ -81,6 +81,23 @@ floor on the release that ships them):
- `src.common.api_helper`: `USER_AGENT`, `DEFAULT_HTTP_HEADERS` (read-only). - `src.common.api_helper`: `USER_AGENT`, `DEFAULT_HTTP_HEADERS` (read-only).
- `src.logo_downloader`: `fetch_logo`, `save_png_atomically`, - `src.logo_downloader`: `fetch_logo`, `save_png_atomically`,
`shared_downloader`. `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 ### Logo downloads
+49 -16
View File
@@ -339,6 +339,18 @@ def format_game_date(config: Optional[Dict[str, Any]], logger, date_text: str,
if not raw: if not raw:
return "" return ""
fmt = str(scroll_card_option(config, "date_format", "abbrev") or "abbrev") 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": if fmt == "numeric":
return raw return raw
parts = raw.replace("-", "/").split("/") 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]) month, day = int(parts[0]), int(parts[1])
if not 1 <= month <= 12: if not 1 <= month <= 12:
return raw return raw
name = MONTH_ABBR[month - 1] name = months[month - 1]
if fmt == "numeric_day_first": if fmt == "numeric_day_first":
return f"{day}/{month}" return f"{day}/{month}"
if fmt == "day_first": if fmt == "day_first":
return f"{day} {name}" return f"{day} {name}"
if fmt == "weekday": if fmt == "weekday":
weekday = weekday_for(config, logger, game) day_name = weekday()
return f"{weekday} {name} {day}" if weekday else f"{name} {day}" return f"{day_name} {name} {day}" if day_name else f"{name} {day}"
return 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]] = {} _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]: def schema_font_size(schema_path: str, element_key) -> Optional[int]:
"""The font_size this plugin's config_schema.json declares, or None. """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 return None
cache = _SCHEMA_FONT_SIZE_CACHE.get(schema_path) cache = _SCHEMA_FONT_SIZE_CACHE.get(schema_path)
if cache is None: if cache is None:
cache = {}
try: try:
import json cache = _read_schema_font_sizes(schema_path)
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)
except Exception as exc: except Exception as exc:
# See sports_shared._schema_font_size: an unreadable schema # See sports_shared._schema_font_size: an unreadable schema
# silently disables the pixel-grid snap for every element. # 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) 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. """Give each colourable element its own face object.
The colour a draw gets is resolved from the face it was handed, and 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 the ability to tell two elements apart does. Faces that cannot be
rebuilt (a BDF loaded through freetype.Face, anything without a usable rebuilt (a BDF loaded through freetype.Face, anything without a usable
path) are left shared, and their draws stay white as before. 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: try:
from src.common.font_layout import load_truetype as _load from src.common.font_layout import load_truetype as _load
except ImportError: # pragma: no cover except ImportError: # pragma: no cover
return fonts return fonts
if element_for_font is None:
element_for_font = ELEMENT_FOR_FONT
seen = {} seen = {}
for key in ELEMENT_FOR_FONT: for key in element_for_font:
font = fonts.get(key) font = fonts.get(key)
if font is None: if font is None:
continue continue
+60 -105
View File
@@ -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 a side and override it, the same two that override ``_SCORE_PROBE`` on
``SportsGameRendererMixin``. ``SportsGameRendererMixin``.
DELIBERATELY NOT MERGED WITH sports_card TWINS IN sports_card
---------------------------------------- --------------------
Fourteen of these have same-named twins in ``src/common/sports_card.py``, which Many of these have same-named twins in ``src/common/sports_card.py``, which the
the scoreboards' ``game_renderer.py`` already uses. They are NOT wired together scoreboards' ``game_renderer.py`` uses. ``test/test_sports_twins.py`` calls
here. Only five are provably equivalent by source comparison; the other nine each pair with the same inputs (the plugins' fixture games in every payload
differ in ways inspection cannot settle, and a wrong guess silently changes what shape, plus edge cases) and splits them in two:
every scoreboard draws. Merging them needs differential testing against both
implementations, and is left for its own change. - 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 from __future__ import annotations
@@ -87,6 +100,7 @@ import pytz
from src.common.espn_dates import fetch_espn_scoreboard from src.common.espn_dates import fetch_espn_scoreboard
import requests import requests
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
from src.common import sports_card as _card
from src.common.font_layout import load_truetype from src.common.font_layout import load_truetype
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -171,19 +185,17 @@ class SportsCoreSharedMixin:
_ELEMENT_FOR_FONT: ClassVar[Dict[str, str]] = { _ELEMENT_FOR_FONT: ClassVar[Dict[str, str]] = {
"score": "score_text", "time": "period_text", "team": "team_text", "score": "score_text", "time": "period_text", "team": "team_text",
"detail": "detail_text", "status": "status_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. #: Default tint for a favourite team's finished game.
FAVORITE_RESULT_COLOR_DEFAULTS: ClassVar[Dict[str, Tuple[int, int, int]]] = { FAVORITE_RESULT_COLOR_DEFAULTS: ClassVar[Dict[str, Tuple[int, int, int]]] = dict(
"win": (0, 255, 0), "loss": (255, 0, 0), "tie": (255, 200, 0)} _card.FAVORITE_RESULT_COLOR_DEFAULTS)
_MONTH_ABBR: ClassVar[Tuple[str, ...]] = ( _MONTH_ABBR: ClassVar[Tuple[str, ...]] = _card.MONTH_ABBR
"Jan", "Feb", "Mar", "Apr", "May", "Jun", _WEEKDAY_ABBR: ClassVar[Tuple[str, ...]] = _card.WEEKDAY_ABBR
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
_WEEKDAY_ABBR: ClassVar[Tuple[str, ...]] = (
"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
#: Bitmap fonts snap to their native pixel grid. #: Bitmap fonts snap to their native pixel grid.
_FONT_PIXEL_GRID: ClassVar[Dict[str, int]] = { _FONT_PIXEL_GRID: ClassVar[Dict[str, int]] = dict(_card.FONT_PIXEL_GRID)
"PressStart2P-Regular.ttf": 8, "4x6-font.ttf": 7} _FONT_NAME_ALIASES: ClassVar[Dict[str, str]] = dict(_card.FONT_NAME_ALIASES)
_FONT_NAME_ALIASES: ClassVar[Dict[str, str]] = {
"press_start": "PressStart2P-Regular.ttf", "four_by_six": "4x6-font.ttf"}
#: Accepted values for the other-games quality filter. #: Accepted values for the other-games quality filter.
_QUALITY_CHOICES: ClassVar[frozenset] = frozenset({"any", "ranked"}) _QUALITY_CHOICES: ClassVar[frozenset] = frozenset({"any", "ranked"})
#: How long to stay quiet between ranking-coverage warnings. #: 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. """Snap *desired* to the nearest size *font_file* renders crisply at.
A face with no known grid is returned unchanged, so a user-supplied 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) return _card.crisp_size(font_file, desired,
grid = cls._FONT_PIXEL_GRID.get(font_file) cls._FONT_NAME_ALIASES, cls._FONT_PIXEL_GRID)
if not grid or not desired or desired <= 0:
return desired
return max(grid, int(round(float(desired) / grid)) * grid)
#: Absolute path of this plugin's directory, declared by the plugin #: Absolute path of this plugin's directory, declared by the plugin
#: itself. The mixin cannot work it out -- see _plugin_dir. #: 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.""" """The font_size this plugin's config_schema.json declares, or None."""
if not element_key: if not element_key:
return None 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) cache = getattr(self.__class__, '_SCHEMA_FONT_SIZES', None)
if cache is None: if cache is None:
cache = {} cache = {}
try: try:
import json
directory = self._plugin_dir() directory = self._plugin_dir()
if directory is None: if directory is None:
raise FileNotFoundError("no config_schema.json on the MRO") raise FileNotFoundError("no config_schema.json on the MRO")
with open(os.path.join(directory, 'config_schema.json')) as fh: cache = _card._read_schema_font_sizes(
schema = json.load(fh) os.path.join(directory, 'config_schema.json'))
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)
except Exception as exc: except Exception as exc:
# Say so. An unreadable schema is not cosmetic: every element's # Say so. An unreadable schema is not cosmetic: every element's
# configured size then stops matching "the schema default", is # 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: def _card_option(self, key: str, default: Any = None) -> Any:
"""Read one key from the scroll_card config block.""" """Read one key from the scroll_card config block."""
block = (self.config or {}).get("scroll_card") return _card.scroll_card_option(self.config, key, default)
if isinstance(block, dict) and block.get(key) is not None:
return block.get(key)
return default
def _switch_upcoming_center(self) -> str: def _switch_upcoming_center(self) -> str:
"""Middle of the full-screen upcoming scorebug: 'vs', 'date_time' or 'none'.""" """Middle of the full-screen upcoming scorebug: 'vs', 'date_time' or 'none'."""
@@ -354,7 +357,7 @@ class SportsCoreSharedMixin:
def _vs_text(self) -> str: def _vs_text(self) -> str:
"""Separator drawn between the teams -- "VS", "@", "at", anything.""" """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: def _switch_date_format(self) -> str:
"""Date style for the full-screen scorebug. """Date style for the full-screen scorebug.
@@ -374,28 +377,19 @@ class SportsCoreSharedMixin:
return fmt return fmt
def _format_game_date(self, date_text: str, game: Optional[Dict] = None) -> str: 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() raw = str(date_text or "").strip()
if not raw: if not raw:
return raw return raw
fmt = self._switch_date_format() return _card._format_date_as(self._switch_date_format(), raw,
if fmt == "numeric": lambda: self._weekday_for(game),
return raw self._MONTH_ABBR)
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}"
def _weekday_for(self, game: Optional[Dict]) -> str: def _weekday_for(self, game: Optional[Dict]) -> str:
"""Weekday abbreviation from the game's start time, or ''.""" """Weekday abbreviation from the game's start time, or ''."""
@@ -413,22 +407,7 @@ class SportsCoreSharedMixin:
def _format_game_time(self, time_text: str) -> str: def _format_game_time(self, time_text: str) -> str:
"""Return the time as-is (12h) or converted to 24h.""" """Return the time as-is (12h) or converted to 24h."""
raw = str(time_text or "").strip() return _card.format_game_time(self.config, time_text)
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}"
def _scorebug_font(self, draw, text: str, width: int): def _scorebug_font(self, draw, text: str, width: int):
"""The face this scorebug draws its date and time in. """The face this scorebug draws its date and time in.
@@ -560,15 +539,7 @@ class SportsCoreSharedMixin:
@staticmethod @staticmethod
def _coerce_rgb(value, fallback): def _coerce_rgb(value, fallback):
"""Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple."""
# Checked before unpacking: a 3-character string ("123") would otherwise return _card.coerce_rgb(value, fallback)
# 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)
@staticmethod @staticmethod
def _side_is_favorite(game: Dict, side: str, favorites: set) -> bool: 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 the ability to tell two elements apart does. Faces that cannot be
rebuilt (a BDF loaded through freetype.Face, anything without a usable rebuilt (a BDF loaded through freetype.Face, anything without a usable
path) are left shared, and their draws stay white as before. 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: return _card.unshare_element_fonts(self.logger, fonts,
from src.common.font_layout import load_truetype as _load self._ELEMENT_FOR_FONT)
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
def _font_color(self, font, default: Tuple[int, int, int] = (255, 255, 255)): def _font_color(self, font, default: Tuple[int, int, int] = (255, 255, 255)):
"""Colour for whichever element owns this face. """Colour for whichever element owns this face.
+672
View File
@@ -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"<raises {type(exc).__name__}>"
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 <side>_abbr / <side>_id keys;
# the card also reads <side>_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)