From 110d7b7f8b56e40376d62816b19b893223231db5 Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Wed, 2 Sep 2026 13:49:20 -0400 Subject: [PATCH] feat(sports): share the card helpers the eight scoreboards each carried 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. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 --- src/common/sports_card.py | 460 ++++++++++++++++++++++++++++++++++++++ test/test_sports_card.py | 195 ++++++++++++++++ 2 files changed, 655 insertions(+) create mode 100644 src/common/sports_card.py create mode 100644 test/test_sports_card.py diff --git a/src/common/sports_card.py b/src/common/sports_card.py new file mode 100644 index 00000000..5a1f8ebb --- /dev/null +++ b/src/common/sports_card.py @@ -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..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 diff --git a/test/test_sports_card.py b/test/test_sports_card.py new file mode 100644 index 00000000..8de04e47 --- /dev/null +++ b/test/test_sports_card.py @@ -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