mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +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:
+49
-16
@@ -339,6 +339,18 @@ def format_game_date(config: Optional[Dict[str, Any]], logger, date_text: str,
|
||||
if not raw:
|
||||
return ""
|
||||
fmt = str(scroll_card_option(config, "date_format", "abbrev") or "abbrev")
|
||||
return _format_date_as(fmt, raw, lambda: weekday_for(config, logger, game))
|
||||
|
||||
|
||||
def _format_date_as(fmt: str, raw: str, weekday, months=MONTH_ABBR) -> str:
|
||||
"""Render a stripped, non-empty "M/D" *raw* in style *fmt*.
|
||||
|
||||
The body both date formatters share. They differ in which setting names the
|
||||
style and in which zone the weekday is taken from (see
|
||||
``SportsCoreSharedMixin._format_game_date``), so those arrive as arguments:
|
||||
*weekday* is a zero-argument callable, only called for the "weekday" style.
|
||||
*months* lets the mixin keep reading its (overridable) ``_MONTH_ABBR``.
|
||||
"""
|
||||
if fmt == "numeric":
|
||||
return raw
|
||||
parts = raw.replace("-", "/").split("/")
|
||||
@@ -347,14 +359,14 @@ def format_game_date(config: Optional[Dict[str, Any]], logger, date_text: str,
|
||||
month, day = int(parts[0]), int(parts[1])
|
||||
if not 1 <= month <= 12:
|
||||
return raw
|
||||
name = MONTH_ABBR[month - 1]
|
||||
name = months[month - 1]
|
||||
if fmt == "numeric_day_first":
|
||||
return f"{day}/{month}"
|
||||
if fmt == "day_first":
|
||||
return f"{day} {name}"
|
||||
if fmt == "weekday":
|
||||
weekday = weekday_for(config, logger, game)
|
||||
return f"{weekday} {name} {day}" if weekday else f"{name} {day}"
|
||||
day_name = weekday()
|
||||
return f"{day_name} {name} {day}" if day_name else f"{name} {day}"
|
||||
return f"{name} {day}"
|
||||
|
||||
|
||||
@@ -388,6 +400,29 @@ def format_game_time(config: Optional[Dict[str, Any]], time_text: str) -> str:
|
||||
_SCHEMA_FONT_SIZE_CACHE: Dict[str, Dict[str, int]] = {}
|
||||
|
||||
|
||||
def _read_schema_font_sizes(schema_path: str) -> Dict[str, int]:
|
||||
"""``{element: font_size default}`` from a config_schema.json. Raises.
|
||||
|
||||
The parse both schema-default lookups share. Each keeps its own cache --
|
||||
this function per schema path, ``SportsCoreSharedMixin._schema_font_size``
|
||||
per class -- because the lifetimes differ: a class is rebuilt when the
|
||||
display service reloads a plugin, a module-level path cache is not. One
|
||||
cache would change when a reloaded plugin sees an edited schema.
|
||||
"""
|
||||
import json
|
||||
with open(schema_path) as fh:
|
||||
schema = json.load(fh)
|
||||
props = (schema.get('properties', {})
|
||||
.get('customization', {})
|
||||
.get('properties', {}))
|
||||
sizes: Dict[str, int] = {}
|
||||
for key, spec in props.items():
|
||||
size = spec.get('properties', {}).get('font_size', {}).get('default')
|
||||
if size is not None:
|
||||
sizes[key] = int(size)
|
||||
return sizes
|
||||
|
||||
|
||||
def schema_font_size(schema_path: str, element_key) -> Optional[int]:
|
||||
"""The font_size this plugin's config_schema.json declares, or None.
|
||||
|
||||
@@ -399,18 +434,8 @@ def schema_font_size(schema_path: str, element_key) -> Optional[int]:
|
||||
return None
|
||||
cache = _SCHEMA_FONT_SIZE_CACHE.get(schema_path)
|
||||
if cache is None:
|
||||
cache = {}
|
||||
try:
|
||||
import json
|
||||
with open(schema_path) as fh:
|
||||
schema = json.load(fh)
|
||||
props = (schema.get('properties', {})
|
||||
.get('customization', {})
|
||||
.get('properties', {}))
|
||||
for key, spec in props.items():
|
||||
size = spec.get('properties', {}).get('font_size', {}).get('default')
|
||||
if size is not None:
|
||||
cache[key] = int(size)
|
||||
cache = _read_schema_font_sizes(schema_path)
|
||||
except Exception as exc:
|
||||
# See sports_shared._schema_font_size: an unreadable schema
|
||||
# silently disables the pixel-grid snap for every element.
|
||||
@@ -444,7 +469,7 @@ def resolve_font_size(schema_path: str, element_config, element_key,
|
||||
return crisp_size(font_name, default_size, aliases, grid_table)
|
||||
|
||||
|
||||
def unshare_element_fonts(logger, fonts):
|
||||
def unshare_element_fonts(logger, fonts, element_for_font=None):
|
||||
"""Give each colourable element its own face object.
|
||||
|
||||
The colour a draw gets is resolved from the face it was handed, and
|
||||
@@ -459,13 +484,21 @@ def unshare_element_fonts(logger, fonts):
|
||||
the ability to tell two elements apart does. Faces that cannot be
|
||||
rebuilt (a BDF loaded through freetype.Face, anything without a usable
|
||||
path) are left shared, and their draws stay white as before.
|
||||
|
||||
*element_for_font* names the font keys to consider, in order (the first
|
||||
holder of a face keeps it); it defaults to this module's
|
||||
:data:`ELEMENT_FOR_FONT`. ``SportsCoreSharedMixin`` passes its own map,
|
||||
which names different keys -- see ``resolve_font_color`` for why the two
|
||||
vocabularies are kept apart.
|
||||
"""
|
||||
try:
|
||||
from src.common.font_layout import load_truetype as _load
|
||||
except ImportError: # pragma: no cover
|
||||
return fonts
|
||||
if element_for_font is None:
|
||||
element_for_font = ELEMENT_FOR_FONT
|
||||
seen = {}
|
||||
for key in ELEMENT_FOR_FONT:
|
||||
for key in element_for_font:
|
||||
font = fonts.get(key)
|
||||
if font is None:
|
||||
continue
|
||||
|
||||
+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
|
||||
``SportsGameRendererMixin``.
|
||||
|
||||
DELIBERATELY NOT MERGED WITH sports_card
|
||||
----------------------------------------
|
||||
Fourteen of these have same-named twins in ``src/common/sports_card.py``, which
|
||||
the scoreboards' ``game_renderer.py`` already uses. They are NOT wired together
|
||||
here. Only five are provably equivalent by source comparison; the other nine
|
||||
differ in ways inspection cannot settle, and a wrong guess silently changes what
|
||||
every scoreboard draws. Merging them needs differential testing against both
|
||||
implementations, and is left for its own change.
|
||||
TWINS IN sports_card
|
||||
--------------------
|
||||
Many of these have same-named twins in ``src/common/sports_card.py``, which the
|
||||
scoreboards' ``game_renderer.py`` uses. ``test/test_sports_twins.py`` calls
|
||||
each pair with the same inputs (the plugins' fixture games in every payload
|
||||
shape, plus edge cases) and splits them in two:
|
||||
|
||||
- Identical: ``_card_option``, ``_vs_text``, ``_format_game_time``,
|
||||
``_coerce_rgb``, ``_crisp_size``, ``_unshare_element_fonts`` (given the same
|
||||
element map) and the constant tables. These are now thin wrappers over the
|
||||
``sports_card`` function; ``_format_game_date`` and ``_schema_font_size``
|
||||
share its body/parser while keeping their own setting, zone and cache.
|
||||
``_resolve_font_size`` agrees too but keeps its body, because it dispatches
|
||||
through the overridable ``_schema_font_size``/``_crisp_size``.
|
||||
- Different, and pinned as they are: ``_side_is_favorite`` /
|
||||
``_favorite_result`` / ``_recent_score_color`` (flat keys and the host's
|
||||
favourites only), ``_weekday_for`` (the plugin's resolved zone, not
|
||||
``config["timezone"]``), ``_font_color`` / ``_ELEMENT_FOR_FONT`` (another
|
||||
element vocabulary), ``_element_color`` (passes ``SKIN_MODE``). Each shows
|
||||
up in one display mode only, so which side is right is a product decision;
|
||||
the test that pins it names the difference.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -87,6 +100,7 @@ import pytz
|
||||
from src.common.espn_dates import fetch_espn_scoreboard
|
||||
import requests
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from src.common import sports_card as _card
|
||||
from src.common.font_layout import load_truetype
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -171,19 +185,17 @@ class SportsCoreSharedMixin:
|
||||
_ELEMENT_FOR_FONT: ClassVar[Dict[str, str]] = {
|
||||
"score": "score_text", "time": "period_text", "team": "team_text",
|
||||
"detail": "detail_text", "status": "status_text"}
|
||||
# The tables below are sports_card's (and font_layout's) values. The dicts
|
||||
# are copies, so a caller that mutates one module's table -- or a subclass
|
||||
# that replaces it -- does not reach into the other.
|
||||
#: Default tint for a favourite team's finished game.
|
||||
FAVORITE_RESULT_COLOR_DEFAULTS: ClassVar[Dict[str, Tuple[int, int, int]]] = {
|
||||
"win": (0, 255, 0), "loss": (255, 0, 0), "tie": (255, 200, 0)}
|
||||
_MONTH_ABBR: ClassVar[Tuple[str, ...]] = (
|
||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
||||
_WEEKDAY_ABBR: ClassVar[Tuple[str, ...]] = (
|
||||
"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
|
||||
FAVORITE_RESULT_COLOR_DEFAULTS: ClassVar[Dict[str, Tuple[int, int, int]]] = dict(
|
||||
_card.FAVORITE_RESULT_COLOR_DEFAULTS)
|
||||
_MONTH_ABBR: ClassVar[Tuple[str, ...]] = _card.MONTH_ABBR
|
||||
_WEEKDAY_ABBR: ClassVar[Tuple[str, ...]] = _card.WEEKDAY_ABBR
|
||||
#: Bitmap fonts snap to their native pixel grid.
|
||||
_FONT_PIXEL_GRID: ClassVar[Dict[str, int]] = {
|
||||
"PressStart2P-Regular.ttf": 8, "4x6-font.ttf": 7}
|
||||
_FONT_NAME_ALIASES: ClassVar[Dict[str, str]] = {
|
||||
"press_start": "PressStart2P-Regular.ttf", "four_by_six": "4x6-font.ttf"}
|
||||
_FONT_PIXEL_GRID: ClassVar[Dict[str, int]] = dict(_card.FONT_PIXEL_GRID)
|
||||
_FONT_NAME_ALIASES: ClassVar[Dict[str, str]] = dict(_card.FONT_NAME_ALIASES)
|
||||
#: Accepted values for the other-games quality filter.
|
||||
_QUALITY_CHOICES: ClassVar[frozenset] = frozenset({"any", "ranked"})
|
||||
#: How long to stay quiet between ranking-coverage warnings.
|
||||
@@ -213,13 +225,11 @@ class SportsCoreSharedMixin:
|
||||
"""Snap *desired* to the nearest size *font_file* renders crisply at.
|
||||
|
||||
A face with no known grid is returned unchanged, so a user-supplied
|
||||
font is never second-guessed.
|
||||
font is never second-guessed. The class's own tables are passed, so a
|
||||
host that declares extra faces keeps them.
|
||||
"""
|
||||
font_file = cls._FONT_NAME_ALIASES.get(font_file, font_file)
|
||||
grid = cls._FONT_PIXEL_GRID.get(font_file)
|
||||
if not grid or not desired or desired <= 0:
|
||||
return desired
|
||||
return max(grid, int(round(float(desired) / grid)) * grid)
|
||||
return _card.crisp_size(font_file, desired,
|
||||
cls._FONT_NAME_ALIASES, cls._FONT_PIXEL_GRID)
|
||||
|
||||
#: Absolute path of this plugin's directory, declared by the plugin
|
||||
#: itself. The mixin cannot work it out -- see _plugin_dir.
|
||||
@@ -281,23 +291,19 @@ class SportsCoreSharedMixin:
|
||||
"""The font_size this plugin's config_schema.json declares, or None."""
|
||||
if not element_key:
|
||||
return None
|
||||
# Cached per class, not in sports_card's per-path cache: the display
|
||||
# service rebuilds the class when it reloads a plugin, and that is
|
||||
# what makes an edited schema take effect. Both caches parse through
|
||||
# sports_card._read_schema_font_sizes.
|
||||
cache = getattr(self.__class__, '_SCHEMA_FONT_SIZES', None)
|
||||
if cache is None:
|
||||
cache = {}
|
||||
try:
|
||||
import json
|
||||
directory = self._plugin_dir()
|
||||
if directory is None:
|
||||
raise FileNotFoundError("no config_schema.json on the MRO")
|
||||
with open(os.path.join(directory, 'config_schema.json')) as fh:
|
||||
schema = json.load(fh)
|
||||
props = (schema.get('properties', {})
|
||||
.get('customization', {})
|
||||
.get('properties', {}))
|
||||
for key, spec in props.items():
|
||||
size = spec.get('properties', {}).get('font_size', {}).get('default')
|
||||
if size is not None:
|
||||
cache[key] = int(size)
|
||||
cache = _card._read_schema_font_sizes(
|
||||
os.path.join(directory, 'config_schema.json'))
|
||||
except Exception as exc:
|
||||
# Say so. An unreadable schema is not cosmetic: every element's
|
||||
# configured size then stops matching "the schema default", is
|
||||
@@ -339,10 +345,7 @@ class SportsCoreSharedMixin:
|
||||
|
||||
def _card_option(self, key: str, default: Any = None) -> Any:
|
||||
"""Read one key from the scroll_card config block."""
|
||||
block = (self.config or {}).get("scroll_card")
|
||||
if isinstance(block, dict) and block.get(key) is not None:
|
||||
return block.get(key)
|
||||
return default
|
||||
return _card.scroll_card_option(self.config, key, default)
|
||||
|
||||
def _switch_upcoming_center(self) -> str:
|
||||
"""Middle of the full-screen upcoming scorebug: 'vs', 'date_time' or 'none'."""
|
||||
@@ -354,7 +357,7 @@ class SportsCoreSharedMixin:
|
||||
|
||||
def _vs_text(self) -> str:
|
||||
"""Separator drawn between the teams -- "VS", "@", "at", anything."""
|
||||
return str(self._card_option("vs_text", "VS"))
|
||||
return _card.vs_text(self.config)
|
||||
|
||||
def _switch_date_format(self) -> str:
|
||||
"""Date style for the full-screen scorebug.
|
||||
@@ -374,28 +377,19 @@ class SportsCoreSharedMixin:
|
||||
return fmt
|
||||
|
||||
def _format_game_date(self, date_text: str, game: Optional[Dict] = None) -> str:
|
||||
"""Format an upcoming date per scroll_card.switch_date_format."""
|
||||
"""Format an upcoming date per scroll_card.switch_date_format.
|
||||
|
||||
The formatting is sports_card's. What differs from the card's
|
||||
``format_game_date`` is passed in: the setting (``switch_date_format``,
|
||||
see :meth:`_switch_date_format`) and the weekday, which comes from
|
||||
:meth:`_weekday_for` and so from this plugin's resolved timezone.
|
||||
"""
|
||||
raw = str(date_text or "").strip()
|
||||
if not raw:
|
||||
return raw
|
||||
fmt = self._switch_date_format()
|
||||
if fmt == "numeric":
|
||||
return raw
|
||||
parts = raw.replace("-", "/").split("/")
|
||||
if not (len(parts) >= 2 and parts[0].strip().isdigit() and parts[1].strip().isdigit()):
|
||||
return raw
|
||||
month, day = int(parts[0]), int(parts[1])
|
||||
if not 1 <= month <= 12:
|
||||
return raw
|
||||
name = self._MONTH_ABBR[month - 1]
|
||||
if fmt == "numeric_day_first":
|
||||
return f"{day}/{month}"
|
||||
if fmt == "day_first":
|
||||
return f"{day} {name}"
|
||||
if fmt == "weekday":
|
||||
weekday = self._weekday_for(game)
|
||||
return f"{weekday} {name} {day}" if weekday else f"{name} {day}"
|
||||
return f"{name} {day}"
|
||||
return _card._format_date_as(self._switch_date_format(), raw,
|
||||
lambda: self._weekday_for(game),
|
||||
self._MONTH_ABBR)
|
||||
|
||||
def _weekday_for(self, game: Optional[Dict]) -> str:
|
||||
"""Weekday abbreviation from the game's start time, or ''."""
|
||||
@@ -413,22 +407,7 @@ class SportsCoreSharedMixin:
|
||||
|
||||
def _format_game_time(self, time_text: str) -> str:
|
||||
"""Return the time as-is (12h) or converted to 24h."""
|
||||
raw = str(time_text or "").strip()
|
||||
if not raw or str(self._card_option("time_format", "12h")) != "24h":
|
||||
return raw
|
||||
cleaned = raw.upper().replace(" ", "")
|
||||
meridiem = "AM" if cleaned.endswith("AM") else "PM" if cleaned.endswith("PM") else ""
|
||||
if not meridiem:
|
||||
return raw
|
||||
try:
|
||||
hh, _, mm = cleaned[:-2].partition(":")
|
||||
hour, minute = int(hh), int(mm or 0)
|
||||
except ValueError:
|
||||
return raw
|
||||
if not (0 <= hour <= 12 and 0 <= minute <= 59):
|
||||
return raw
|
||||
hour = hour % 12 + (12 if meridiem == "PM" else 0)
|
||||
return f"{hour:02d}:{minute:02d}"
|
||||
return _card.format_game_time(self.config, time_text)
|
||||
|
||||
def _scorebug_font(self, draw, text: str, width: int):
|
||||
"""The face this scorebug draws its date and time in.
|
||||
@@ -560,15 +539,7 @@ class SportsCoreSharedMixin:
|
||||
@staticmethod
|
||||
def _coerce_rgb(value, fallback):
|
||||
"""Turn a configured [R, G, B] list into a clamped (r, g, b) tuple."""
|
||||
# Checked before unpacking: a 3-character string ("123") would otherwise
|
||||
# iterate into three digits and yield a colour rather than the fallback.
|
||||
if not isinstance(value, (list, tuple)) or len(value) != 3:
|
||||
return fallback
|
||||
try:
|
||||
r, g, b = (max(0, min(255, int(channel))) for channel in value)
|
||||
except (TypeError, ValueError):
|
||||
return fallback
|
||||
return (r, g, b)
|
||||
return _card.coerce_rgb(value, fallback)
|
||||
|
||||
@staticmethod
|
||||
def _side_is_favorite(game: Dict, side: str, favorites: set) -> bool:
|
||||
@@ -849,28 +820,12 @@ class SportsCoreSharedMixin:
|
||||
the ability to tell two elements apart does. Faces that cannot be
|
||||
rebuilt (a BDF loaded through freetype.Face, anything without a usable
|
||||
path) are left shared, and their draws stay white as before.
|
||||
|
||||
The body is sports_card's; this class's own element map is passed, so
|
||||
the keys considered are the ones this class colours by.
|
||||
"""
|
||||
try:
|
||||
from src.common.font_layout import load_truetype as _load
|
||||
except ImportError: # pragma: no cover
|
||||
return fonts
|
||||
seen = {}
|
||||
for key in self._ELEMENT_FOR_FONT:
|
||||
font = fonts.get(key)
|
||||
if font is None:
|
||||
continue
|
||||
if id(font) not in seen:
|
||||
seen[id(font)] = key
|
||||
continue
|
||||
path, size = getattr(font, "path", None), getattr(font, "size", None)
|
||||
if not path or not size:
|
||||
continue
|
||||
try:
|
||||
fonts[key] = _load(path, size)
|
||||
except (OSError, ValueError, TypeError):
|
||||
self.logger.debug(
|
||||
"Could not un-share the %s face; it keeps the default colour", key)
|
||||
return fonts
|
||||
return _card.unshare_element_fonts(self.logger, fonts,
|
||||
self._ELEMENT_FOR_FONT)
|
||||
|
||||
def _font_color(self, font, default: Tuple[int, int, int] = (255, 255, 255)):
|
||||
"""Colour for whichever element owns this face.
|
||||
|
||||
Reference in New Issue
Block a user