mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
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:
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user