mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-05 23:05:10 +00:00
feat(sports): share the card helpers the eight scoreboards each carried (#513)
Twenty methods were byte-identical in all eight scoreboards' game_renderer.py: the colour pickers, the scroll_card settings lookup, the date and time formatting, the favourite-team rules and the font-size grid snapping. Every fix to any of it had to be made eight times, and a new scoreboard began by copying them a ninth. src/common/sports_card.py holds them once. 245 lines leave each plugin. **Free functions, not a base class.** Every helper takes config/logger/fonts as arguments rather than reading them off an instance, so a plugin keeps its method and delegates the body -- call sites, signatures and override points are all untouched. Adoption is therefore per-function and reversible, which is what let all eight move with byte-identical renders. The bodies are the plugins' code moved, not rewritten. Two deliberate differences, both verified: - crisp_size takes the seven-plugin guard (`not desired`) rather than football's. They agree on every real input; the extra guard only stops a None size raising TypeError, so adopting it is a no-op for seven plugins and removes a crash path for the eighth. - schema_font_size caches per schema PATH. The plugins cached on their own class, which is the same distinction expressed without a class to hang it on; two plugins never share an entry. The path has to be passed in because the plugins derived it from __file__, and __file__ here is the core's. Verified before any plugin was touched: 534 differential comparisons of the helpers against afl's originals and 704 more of the font-sizing chain against all eight plugins' originals -- 1,238 comparisons, zero differences. Writing the constant tables by hand introduced two errors that check caught: the tie colour was (255,255,0) instead of the plugins' (255,200,0), and a "five_by_seven" alias that does not exist. Both are now taken from the plugins verbatim. 43 tests pin the contract, including the cases the plugins' own comments record as having bitten: a three-character string must not iterate into a colour, a shared font face must give up rather than guess an element, a font_size equal to the schema default carries no intent, and a bad timezone falls back to UTC rather than blanking the card. Full suite 3768 passed, 6 skipped. Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,460 @@
|
|||||||
|
"""Card-drawing helpers shared by every sports scoreboard plugin.
|
||||||
|
|
||||||
|
The eight scoreboards each carried byte-identical copies of the functions
|
||||||
|
below: the colour pickers, the settings lookup, the date and time formatting,
|
||||||
|
the favourite-team rules and the font-size grid snapping. One fix had to be
|
||||||
|
made eight times, and a new scoreboard started by copying them a ninth.
|
||||||
|
|
||||||
|
Everything here is a **free function taking explicit arguments**, not a base
|
||||||
|
class. Adoption is therefore per-function and reversible: a plugin keeps its
|
||||||
|
method and delegates the body, so the call sites and the override points are
|
||||||
|
untouched. That is also why `config`, `logger` and `fonts` are parameters
|
||||||
|
rather than attributes -- the helper never reaches back into the caller.
|
||||||
|
|
||||||
|
The bodies are the plugins' own code, moved rather than rewritten. The one
|
||||||
|
deliberate difference is `crisp_size`, which takes the seven-plugin guard
|
||||||
|
(`not desired`) instead of football's: they agree on every real input, and
|
||||||
|
the extra guard only stops a None size raising TypeError.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Dict, Optional, Tuple
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ELEMENT_FOR_FONT", "FAVORITE_RESULT_COLOR_DEFAULTS", "FONT_NAME_ALIASES",
|
||||||
|
"FONT_PIXEL_GRID", "MONTH_ABBR", "WEEKDAY_ABBR",
|
||||||
|
"scroll_card_option", "element_color", "font_color", "coerce_rgb",
|
||||||
|
"score_color_for", "recent_score_color", "favorite_teams_for",
|
||||||
|
"side_is_favorite", "side_score", "favorite_result",
|
||||||
|
"card_tzinfo", "weekday_for", "format_game_date", "format_game_time",
|
||||||
|
"vs_text", "upcoming_center_mode", "crisp_size", "schema_font_size",
|
||||||
|
"resolve_font_size", "unshare_element_fonts",
|
||||||
|
]
|
||||||
|
|
||||||
|
#: Which customization element owns each font key, for colour resolution.
|
||||||
|
ELEMENT_FOR_FONT: Dict[str, str] = {
|
||||||
|
"score": "score_text",
|
||||||
|
"time": "period_text",
|
||||||
|
"team": "team_name",
|
||||||
|
"status": "status_text",
|
||||||
|
"detail": "detail_text",
|
||||||
|
"rank": "rank_text",
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Fallback colours when favourite_result_colors is on but a slot is unset.
|
||||||
|
FAVORITE_RESULT_COLOR_DEFAULTS: Dict[str, Tuple[int, int, int]] = {
|
||||||
|
"win": (0, 255, 0),
|
||||||
|
"loss": (255, 0, 0),
|
||||||
|
"tie": (255, 200, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Family aliases the web UI may write, mapped to the shipped filename.
|
||||||
|
FONT_NAME_ALIASES: Dict[str, str] = {
|
||||||
|
"press_start": "PressStart2P-Regular.ttf",
|
||||||
|
"four_by_six": "4x6-font.ttf",
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Pixel grid each face renders crisply on. Off-grid sizes anti-alias, which
|
||||||
|
#: on an LED matrix is a dim lamp rather than a soft edge.
|
||||||
|
FONT_PIXEL_GRID: Dict[str, int] = {
|
||||||
|
"PressStart2P-Regular.ttf": 8,
|
||||||
|
"4x6-font.ttf": 7,
|
||||||
|
}
|
||||||
|
|
||||||
|
MONTH_ABBR = ("Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||||
|
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
||||||
|
WEEKDAY_ABBR = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Settings lookup
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def scroll_card_option(config: Optional[Dict[str, Any]], key: str,
|
||||||
|
default: Any = None) -> Any:
|
||||||
|
"""Read one key from the scroll_card config block."""
|
||||||
|
block = (config or {}).get("scroll_card")
|
||||||
|
if isinstance(block, dict) and block.get(key) is not None:
|
||||||
|
return block.get(key)
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def vs_text(config: Optional[Dict[str, Any]]) -> str:
|
||||||
|
"""Separator drawn between the teams -- "VS", "@", "at", anything."""
|
||||||
|
return str(scroll_card_option(config, "vs_text", "VS"))
|
||||||
|
|
||||||
|
|
||||||
|
def upcoming_center_mode(config: Optional[Dict[str, Any]]) -> str:
|
||||||
|
"""Middle of an upcoming card: 'vs', 'date_time' or 'none'."""
|
||||||
|
mode = str(scroll_card_option(config, "upcoming_center", "vs") or "vs").lower()
|
||||||
|
return mode if mode in ("vs", "date_time", "none") else "vs"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Colour
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def element_color(config: Optional[Dict[str, Any]], element: str,
|
||||||
|
default: Tuple[int, int, int] = (255, 255, 255)):
|
||||||
|
"""Per-element text colour from customization.<element>.text_color."""
|
||||||
|
try:
|
||||||
|
cfg = (config or {}).get("customization", {}).get(element, {})
|
||||||
|
value = cfg.get("text_color")
|
||||||
|
if isinstance(value, (list, tuple)) and len(value) == 3:
|
||||||
|
return tuple(max(0, min(255, int(c))) for c in value)
|
||||||
|
if isinstance(value, str) and value.startswith("#") and len(value) == 7:
|
||||||
|
return tuple(int(value[i:i + 2], 16) for i in (1, 3, 5))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def font_color(config: Optional[Dict[str, Any]], fonts: Optional[Dict[str, Any]],
|
||||||
|
font, default: Tuple[int, int, int] = (255, 255, 255)):
|
||||||
|
"""Colour for whichever element owns this face.
|
||||||
|
|
||||||
|
Matched on identity, and deliberately gives up when one object is
|
||||||
|
shared: the last-resort font path can hand the same face to several
|
||||||
|
keys, and there is no right answer for which element's colour that is.
|
||||||
|
White is what those draws used before, so ambiguity costs nothing.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
fonts = fonts or {}
|
||||||
|
matches = [element for key, element in ELEMENT_FOR_FONT.items()
|
||||||
|
if fonts.get(key) is font]
|
||||||
|
if len(matches) == 1:
|
||||||
|
return element_color(config, matches[0], default)
|
||||||
|
except (AttributeError, TypeError):
|
||||||
|
pass
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Favourite teams
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def favorite_teams_for(config: Dict[str, Any], game: Dict[str, Any]) -> list:
|
||||||
|
"""Favorite teams that apply to this game.
|
||||||
|
|
||||||
|
Both sources are used. Games carry the league manager's *resolved*
|
||||||
|
favorites, which is the only place dynamic groups such as AP_TOP_25
|
||||||
|
appear expanded; the config is read as well so an edit takes effect on
|
||||||
|
already-fetched games, and so hand-built game dicts (tests, other
|
||||||
|
callers) still work.
|
||||||
|
"""
|
||||||
|
favorites = list(game.get("favorite_teams") or [])
|
||||||
|
league_config = config.get(str(game.get("league", "") or ""))
|
||||||
|
if isinstance(league_config, dict):
|
||||||
|
favorites += list(league_config.get("favorite_teams") or [])
|
||||||
|
else:
|
||||||
|
favorites += list(config.get("favorite_teams") or [])
|
||||||
|
return favorites
|
||||||
|
|
||||||
|
|
||||||
|
def side_is_favorite(game: Dict[str, Any], side: str, favorites: set) -> bool:
|
||||||
|
"""Is the home/away side of this game a favorite team?
|
||||||
|
|
||||||
|
Reads both the flat (``home_abbr``) and nested (``home_team.abbrev``)
|
||||||
|
payload shapes, and matches on the ESPN id too, because a couple of
|
||||||
|
leagues (NRL) key favorites by id where abbreviations collide.
|
||||||
|
"""
|
||||||
|
candidates = [game.get(f"{side}_abbr"), game.get(f"{side}_id")]
|
||||||
|
team = game.get(f"{side}_team")
|
||||||
|
if isinstance(team, dict):
|
||||||
|
candidates += [team.get("abbrev"), team.get("abbreviation"), team.get("id")]
|
||||||
|
for value in candidates:
|
||||||
|
if value is not None and str(value).strip().upper() in favorites:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def side_score(game: Dict[str, Any], side: str) -> Optional[int]:
|
||||||
|
"""Numeric score for one side, from either payload shape."""
|
||||||
|
raw = None
|
||||||
|
team = game.get(f"{side}_team")
|
||||||
|
if isinstance(team, dict) and team.get("score") is not None:
|
||||||
|
raw = team.get("score")
|
||||||
|
if raw is None:
|
||||||
|
raw = game.get(f"{side}_score")
|
||||||
|
try:
|
||||||
|
return int(float(str(raw).strip()))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def favorite_result(config: Dict[str, Any], game: Dict[str, Any]) -> Optional[str]:
|
||||||
|
"""Say how the favorite team did in a finished game.
|
||||||
|
|
||||||
|
Returns 'win', 'loss' or 'tie', or None when there is no single team
|
||||||
|
to root for: no favorites configured, neither side is a favorite, or
|
||||||
|
*both* are -- a favorite-vs-favorite game has no losing side worth
|
||||||
|
flagging in red. Also None when the scores are not usable numbers.
|
||||||
|
"""
|
||||||
|
favorites = {
|
||||||
|
str(team).strip().upper()
|
||||||
|
for team in favorite_teams_for(config, game)
|
||||||
|
if str(team).strip()
|
||||||
|
}
|
||||||
|
if not favorites:
|
||||||
|
return None
|
||||||
|
|
||||||
|
home_fav = side_is_favorite(game, "home", favorites)
|
||||||
|
away_fav = side_is_favorite(game, "away", favorites)
|
||||||
|
if home_fav == away_fav:
|
||||||
|
return None
|
||||||
|
|
||||||
|
home_score = side_score(game, "home")
|
||||||
|
away_score = side_score(game, "away")
|
||||||
|
if home_score is None or away_score is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if home_score == away_score:
|
||||||
|
return "tie"
|
||||||
|
favorite_score, other_score = (
|
||||||
|
(home_score, away_score) if home_fav else (away_score, home_score)
|
||||||
|
)
|
||||||
|
return "win" if favorite_score > other_score else "loss"
|
||||||
|
|
||||||
|
|
||||||
|
def recent_score_color(config: Dict[str, Any], logger, game: Dict[str, Any], default):
|
||||||
|
"""Fill color for a finished game's score, per favorite_result_colors."""
|
||||||
|
try:
|
||||||
|
settings = (config.get("customization") or {}).get(
|
||||||
|
"favorite_result_colors"
|
||||||
|
) or {}
|
||||||
|
if not settings.get("enabled", False):
|
||||||
|
return default
|
||||||
|
result = favorite_result(config, game)
|
||||||
|
if result is None:
|
||||||
|
return default
|
||||||
|
return coerce_rgb(
|
||||||
|
settings.get(f"{result}_color"),
|
||||||
|
FAVORITE_RESULT_COLOR_DEFAULTS[result],
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Could not resolve favorite result color", exc_info=True)
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def score_color_for(config: Dict[str, Any], logger, game: Dict[str, Any],
|
||||||
|
game_type: str, default=None):
|
||||||
|
"""Fill color for a game card's score. Only finished games are tinted.
|
||||||
|
|
||||||
|
The default is the configured score colour rather than a flat white,
|
||||||
|
so customization.score_text.text_color shows on games the favourite
|
||||||
|
tint does not apply to. The tint still wins where it applies.
|
||||||
|
"""
|
||||||
|
if default is None:
|
||||||
|
default = element_color(config, 'score_text')
|
||||||
|
if game_type != "recent":
|
||||||
|
return default
|
||||||
|
return recent_score_color(config, logger, game, default)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Date and time
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def card_tzinfo(config: Optional[Dict[str, Any]], logger):
|
||||||
|
"""Timezone for weekday/24h conversions; falls back to UTC."""
|
||||||
|
configured = (config or {}).get("timezone")
|
||||||
|
if configured:
|
||||||
|
try:
|
||||||
|
return ZoneInfo(configured)
|
||||||
|
except (KeyError, ValueError, TypeError, OSError) as exc:
|
||||||
|
# KeyError covers ZoneInfoNotFoundError. A bad zone name in
|
||||||
|
# config should fall back to UTC, not blank the card.
|
||||||
|
logger.debug("Unusable timezone %r: %s", configured, exc)
|
||||||
|
return timezone.utc
|
||||||
|
|
||||||
|
|
||||||
|
def weekday_for(config: Optional[Dict[str, Any]], logger,
|
||||||
|
game: Optional[Dict]) -> str:
|
||||||
|
"""Weekday abbreviation from the game's start time, or ''."""
|
||||||
|
if not game:
|
||||||
|
return ""
|
||||||
|
raw = game.get("start_time_utc") or game.get("start_time")
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
start = raw if isinstance(raw, datetime) else datetime.fromisoformat(
|
||||||
|
str(raw).replace("Z", "+00:00"))
|
||||||
|
return WEEKDAY_ABBR[start.astimezone(card_tzinfo(config, logger)).weekday()]
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def format_game_date(config: Optional[Dict[str, Any]], logger, date_text: str,
|
||||||
|
game: Optional[Dict] = None) -> str:
|
||||||
|
"""Format an upcoming card's date per scroll_card.date_format."""
|
||||||
|
raw = str(date_text or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
fmt = str(scroll_card_option(config, "date_format", "abbrev") or "abbrev")
|
||||||
|
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 = 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 = weekday_for(config, logger, game)
|
||||||
|
return f"{weekday} {name} {day}" if weekday else f"{name} {day}"
|
||||||
|
return f"{name} {day}"
|
||||||
|
|
||||||
|
|
||||||
|
def format_game_time(config: Optional[Dict[str, Any]], 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(scroll_card_option(config, "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}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Font sizing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#: Per-schema caches, keyed by the schema's absolute path. Keyed rather than
|
||||||
|
#: global because each plugin declares its own defaults; keyed rather than
|
||||||
|
#: per-class because the helper has no class to hang it on.
|
||||||
|
_SCHEMA_FONT_SIZE_CACHE: Dict[str, Dict[str, int]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def crisp_size(font_file, desired, aliases=None, grid_table=None):
|
||||||
|
"""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.
|
||||||
|
|
||||||
|
``aliases`` and ``grid_table`` default to the shared tables; a plugin
|
||||||
|
that ships an extra face can pass its own without forking this.
|
||||||
|
"""
|
||||||
|
aliases = FONT_NAME_ALIASES if aliases is None else aliases
|
||||||
|
grid_table = FONT_PIXEL_GRID if grid_table is None else grid_table
|
||||||
|
font_file = aliases.get(font_file, font_file)
|
||||||
|
grid = grid_table.get(font_file)
|
||||||
|
if not grid or not desired or desired <= 0:
|
||||||
|
return desired
|
||||||
|
return max(grid, int(round(float(desired) / grid)) * grid)
|
||||||
|
|
||||||
|
|
||||||
|
def schema_font_size(schema_path: str, element_key) -> Optional[int]:
|
||||||
|
"""The font_size this plugin's config_schema.json declares, or None.
|
||||||
|
|
||||||
|
Cached per schema path. The plugins cached this on their own class; the
|
||||||
|
path is the same distinction expressed without one, so two plugins never
|
||||||
|
share an entry.
|
||||||
|
"""
|
||||||
|
if not element_key:
|
||||||
|
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)
|
||||||
|
except Exception:
|
||||||
|
cache = {}
|
||||||
|
_SCHEMA_FONT_SIZE_CACHE[schema_path] = cache
|
||||||
|
return cache.get(element_key)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_font_size(schema_path: str, element_config, element_key,
|
||||||
|
default_size, font_name, aliases=None, grid_table=None):
|
||||||
|
"""Size to render at: the user's choice, or a grid-snapped default.
|
||||||
|
|
||||||
|
A configured size counts as a real choice only when it differs from
|
||||||
|
the schema default. The web UI writes the whole schema default block
|
||||||
|
on every save, so "font_size == schema default" carries no intent and
|
||||||
|
would otherwise pin every install to an anti-aliased size forever.
|
||||||
|
"""
|
||||||
|
configured = (element_config or {}).get('font_size')
|
||||||
|
if configured is not None:
|
||||||
|
try:
|
||||||
|
configured = int(configured)
|
||||||
|
if configured != schema_font_size(schema_path, element_key):
|
||||||
|
return configured
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
return crisp_size(font_name, default_size, aliases, grid_table)
|
||||||
|
|
||||||
|
|
||||||
|
def unshare_element_fonts(logger, fonts):
|
||||||
|
"""Give each colourable element its own face object.
|
||||||
|
|
||||||
|
The colour a draw gets is resolved from the face it was handed, and
|
||||||
|
several of these loaders legitimately hand one object to more than one
|
||||||
|
element -- a size resolver that lands two elements on the same face, a
|
||||||
|
fallback that fills every key from one default, football's narrowing
|
||||||
|
step that deliberately shrinks the clock along with the score. Sharing
|
||||||
|
the object makes the element ambiguous and the colour unresolvable.
|
||||||
|
|
||||||
|
Re-instantiating from the same path and size gives a distinct object
|
||||||
|
with identical metrics, so nothing about the rendering changes; only
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from PIL import ImageFont as _IF
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
return fonts
|
||||||
|
seen = {}
|
||||||
|
for key in 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] = _IF.truetype(path, size)
|
||||||
|
except (OSError, ValueError, TypeError):
|
||||||
|
logger.debug(
|
||||||
|
"Could not un-share the %s face; it keeps the default colour", key)
|
||||||
|
return fonts
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"""The card helpers the eight scoreboards now share.
|
||||||
|
|
||||||
|
These bodies lived in eight byte-identical copies. Moving them here means one
|
||||||
|
fix reaches every scoreboard — and that a mistake does too, which is what this
|
||||||
|
file guards. Each case below is one the plugins' own code already handled; the
|
||||||
|
point is that it keeps handling it.
|
||||||
|
|
||||||
|
The functions take ``config``/``logger``/``fonts`` as arguments rather than
|
||||||
|
reading them off an instance, so a plugin keeps its method and delegates the
|
||||||
|
body. That is what let all eight adopt this with byte-identical renders.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.common import sports_card as C
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def log():
|
||||||
|
return logging.getLogger("test_sports_card")
|
||||||
|
|
||||||
|
|
||||||
|
class TestSettingsLookup:
|
||||||
|
def test_reads_the_scroll_card_block(self):
|
||||||
|
cfg = {"scroll_card": {"vs_text": "@"}}
|
||||||
|
assert C.scroll_card_option(cfg, "vs_text", "VS") == "@"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("cfg", [None, {}, {"scroll_card": None},
|
||||||
|
{"scroll_card": {"vs_text": None}}])
|
||||||
|
def test_missing_or_null_falls_back(self, cfg):
|
||||||
|
"""A null in config means "unset", not "empty string"."""
|
||||||
|
assert C.scroll_card_option(cfg, "vs_text", "VS") == "VS"
|
||||||
|
|
||||||
|
def test_upcoming_center_rejects_unknown_modes(self):
|
||||||
|
for bad in ("sideways", "", None, 7):
|
||||||
|
assert C.upcoming_center_mode({"scroll_card": {"upcoming_center": bad}}) == "vs"
|
||||||
|
assert C.upcoming_center_mode({"scroll_card": {"upcoming_center": "DATE_TIME"}}) == "date_time"
|
||||||
|
|
||||||
|
|
||||||
|
class TestColour:
|
||||||
|
def test_rgb_list_and_hex_both_work(self):
|
||||||
|
assert C.element_color({"customization": {"score_text": {"text_color": [1, 2, 3]}}},
|
||||||
|
"score_text") == (1, 2, 3)
|
||||||
|
assert C.element_color({"customization": {"score_text": {"text_color": "#ff8000"}}},
|
||||||
|
"score_text") == (255, 128, 0)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", ["nope", "#fff", [1, 2], None, ["a", "b", "c"]])
|
||||||
|
def test_unusable_colour_falls_back(self, value):
|
||||||
|
cfg = {"customization": {"score_text": {"text_color": value}}}
|
||||||
|
assert C.element_color(cfg, "score_text", (9, 9, 9)) == (9, 9, 9)
|
||||||
|
|
||||||
|
def test_coerce_rgb_clamps_rather_than_rejecting(self):
|
||||||
|
assert C.coerce_rgb([300, -5, 20], (0, 0, 0)) == (255, 0, 20)
|
||||||
|
|
||||||
|
def test_coerce_rgb_refuses_a_three_character_string(self):
|
||||||
|
""""123" would otherwise iterate into three digits and yield a colour."""
|
||||||
|
assert C.coerce_rgb("123", (7, 7, 7)) == (7, 7, 7)
|
||||||
|
|
||||||
|
def test_font_colour_is_resolved_by_identity(self):
|
||||||
|
a, b = object(), object()
|
||||||
|
cfg = {"customization": {"score_text": {"text_color": [4, 5, 6]}}}
|
||||||
|
assert C.font_color(cfg, {"score": a, "team": b}, a) == (4, 5, 6)
|
||||||
|
|
||||||
|
def test_a_shared_face_gives_up_rather_than_guessing(self):
|
||||||
|
"""One object used for two elements has no single right colour."""
|
||||||
|
shared = object()
|
||||||
|
cfg = {"customization": {"score_text": {"text_color": [4, 5, 6]}}}
|
||||||
|
assert C.font_color(cfg, {"score": shared, "team": shared}, shared) == (255, 255, 255)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFavourites:
|
||||||
|
GAME = {"home_abbr": "TB", "away_abbr": "NO", "home_score": "21", "away_score": "17"}
|
||||||
|
|
||||||
|
def test_win_loss_and_tie(self):
|
||||||
|
cfg = {"favorite_teams": ["TB"]}
|
||||||
|
assert C.favorite_result(cfg, self.GAME) == "win"
|
||||||
|
assert C.favorite_result({"favorite_teams": ["NO"]}, self.GAME) == "loss"
|
||||||
|
tied = dict(self.GAME, home_score="3", away_score="3")
|
||||||
|
assert C.favorite_result(cfg, tied) == "tie"
|
||||||
|
|
||||||
|
def test_no_verdict_without_exactly_one_favourite_side(self):
|
||||||
|
assert C.favorite_result({}, self.GAME) is None
|
||||||
|
assert C.favorite_result({"favorite_teams": ["TB", "NO"]}, self.GAME) is None
|
||||||
|
assert C.favorite_result({"favorite_teams": ["SEA"]}, self.GAME) is None
|
||||||
|
|
||||||
|
def test_unusable_scores_give_no_verdict(self):
|
||||||
|
bad = dict(self.GAME, home_score="x")
|
||||||
|
assert C.favorite_result({"favorite_teams": ["TB"]}, bad) is None
|
||||||
|
|
||||||
|
def test_nested_payload_shape_is_read_too(self):
|
||||||
|
game = {"home_team": {"abbrev": "TB", "score": 9},
|
||||||
|
"away_team": {"abbrev": "NO", "score": 2}}
|
||||||
|
assert C.side_score(game, "home") == 9
|
||||||
|
assert C.side_is_favorite(game, "home", {"TB"}) is True
|
||||||
|
|
||||||
|
def test_matches_on_id_where_abbreviations_collide(self):
|
||||||
|
"""NRL keys favourites by ESPN id; abbreviations are not unique there."""
|
||||||
|
game = {"home_abbr": "SYD", "home_id": "4321"}
|
||||||
|
assert C.side_is_favorite(game, "home", {"4321"}) is True
|
||||||
|
|
||||||
|
def test_game_and_config_favourites_are_both_used(self):
|
||||||
|
"""Games carry resolved dynamic groups; config catches later edits."""
|
||||||
|
game = dict(self.GAME, favorite_teams=["NO"], league="nfl")
|
||||||
|
assert set(C.favorite_teams_for({"nfl": {"favorite_teams": ["TB"]}}, game)) == {"NO", "TB"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestDateAndTime:
|
||||||
|
def test_date_formats(self, log):
|
||||||
|
for fmt, want in [("abbrev", "Sep 5"), ("numeric", "9/5"),
|
||||||
|
("day_first", "5 Sep"), ("numeric_day_first", "5/9")]:
|
||||||
|
cfg = {"scroll_card": {"date_format": fmt}}
|
||||||
|
assert C.format_game_date(cfg, log, "9/5") == want
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw", ["", "garbage", "13/40", "no/slash/here"])
|
||||||
|
def test_unparseable_dates_pass_through(self, log, raw):
|
||||||
|
assert C.format_game_date({}, log, raw) == raw.strip()
|
||||||
|
|
||||||
|
def test_24h_conversion(self):
|
||||||
|
cfg = {"scroll_card": {"time_format": "24h"}}
|
||||||
|
assert C.format_game_time(cfg, "7:30 PM") == "19:30"
|
||||||
|
assert C.format_game_time(cfg, "12:00 AM") == "00:00"
|
||||||
|
assert C.format_game_time(cfg, "12:15 PM") == "12:15"
|
||||||
|
|
||||||
|
def test_12h_is_left_alone_and_junk_survives(self):
|
||||||
|
assert C.format_game_time({}, "7:30 PM") == "7:30 PM"
|
||||||
|
assert C.format_game_time({"scroll_card": {"time_format": "24h"}}, "soon") == "soon"
|
||||||
|
|
||||||
|
def test_a_bad_timezone_falls_back_to_utc(self, log):
|
||||||
|
"""A typo in config should not blank the card."""
|
||||||
|
from datetime import timezone
|
||||||
|
assert C.card_tzinfo({"timezone": "Not/AZone"}, log) is timezone.utc
|
||||||
|
|
||||||
|
|
||||||
|
class TestFontSizing:
|
||||||
|
def test_snaps_to_the_faces_pixel_grid(self):
|
||||||
|
assert C.crisp_size("4x6-font.ttf", 6) == 7 # 7px grid
|
||||||
|
assert C.crisp_size("PressStart2P-Regular.ttf", 10) == 8
|
||||||
|
assert C.crisp_size("PressStart2P-Regular.ttf", 13) == 16
|
||||||
|
|
||||||
|
def test_an_unknown_face_is_never_second_guessed(self):
|
||||||
|
assert C.crisp_size("SomeUserFont.ttf", 11) == 11
|
||||||
|
|
||||||
|
def test_aliases_resolve_before_the_grid_lookup(self):
|
||||||
|
assert C.crisp_size("four_by_six", 6) == C.crisp_size("4x6-font.ttf", 6)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("desired", [0, -3, None])
|
||||||
|
def test_unusable_sizes_pass_through_without_raising(self, desired):
|
||||||
|
"""None reached this in the field; football's variant raised TypeError."""
|
||||||
|
assert C.crisp_size("4x6-font.ttf", desired) == desired
|
||||||
|
|
||||||
|
def test_schema_cache_is_keyed_per_schema_not_globally(self, tmp_path):
|
||||||
|
"""Two plugins declaring different defaults must not share an answer."""
|
||||||
|
a, b = tmp_path / "a.json", tmp_path / "b.json"
|
||||||
|
for path, size in ((a, 11), (b, 22)):
|
||||||
|
path.write_text(json.dumps({"properties": {"customization": {"properties": {
|
||||||
|
"score_text": {"properties": {"font_size": {"default": size}}}}}}}))
|
||||||
|
assert C.schema_font_size(str(a), "score_text") == 11
|
||||||
|
assert C.schema_font_size(str(b), "score_text") == 22
|
||||||
|
|
||||||
|
def test_a_missing_schema_is_not_an_error(self, tmp_path):
|
||||||
|
assert C.schema_font_size(str(tmp_path / "nope.json"), "score_text") is None
|
||||||
|
|
||||||
|
def test_a_configured_size_matching_the_schema_default_is_not_a_choice(self, tmp_path):
|
||||||
|
"""The web UI writes the whole default block on every save, so
|
||||||
|
font_size == schema default carries no intent and must not pin the
|
||||||
|
install to an off-grid size forever."""
|
||||||
|
schema = tmp_path / "s.json"
|
||||||
|
schema.write_text(json.dumps({"properties": {"customization": {"properties": {
|
||||||
|
"score_text": {"properties": {"font_size": {"default": 10}}}}}}}))
|
||||||
|
got = C.resolve_font_size(str(schema), {"font_size": 10}, "score_text", 10,
|
||||||
|
"PressStart2P-Regular.ttf")
|
||||||
|
assert got == 8, "a default-valued size should snap to the grid"
|
||||||
|
|
||||||
|
def test_a_real_choice_wins(self, tmp_path):
|
||||||
|
schema = tmp_path / "s.json"
|
||||||
|
schema.write_text(json.dumps({"properties": {"customization": {"properties": {
|
||||||
|
"score_text": {"properties": {"font_size": {"default": 10}}}}}}}))
|
||||||
|
got = C.resolve_font_size(str(schema), {"font_size": 13}, "score_text", 10,
|
||||||
|
"PressStart2P-Regular.ttf")
|
||||||
|
assert got == 13, "an explicit size the user chose is not second-guessed"
|
||||||
|
|
||||||
|
|
||||||
|
class TestTables:
|
||||||
|
def test_every_font_key_maps_to_an_element(self):
|
||||||
|
assert set(C.ELEMENT_FOR_FONT) == {"score", "time", "team", "status", "detail", "rank"}
|
||||||
|
|
||||||
|
def test_result_colours_cover_every_verdict(self):
|
||||||
|
assert set(C.FAVORITE_RESULT_COLOR_DEFAULTS) == {"win", "loss", "tie"}
|
||||||
|
|
||||||
|
def test_month_and_weekday_tables_are_complete(self):
|
||||||
|
assert len(C.MONTH_ABBR) == 12 and len(C.WEEKDAY_ABBR) == 7
|
||||||
Reference in New Issue
Block a user