Files
LEDMatrix/test/test_sports_card.py
ChuckandClaude Opus 5.5 07abd87d5e fix(sports): scroll and Vegas cards name the printed date's own weekday (#747)
With scroll_card.date_format "weekday", a Friday 8 PM ET game read
"Sat Oct 2" on the scroll and Vegas cards.

Cause: the extractor prints the "M/D" in the plugin's resolved zone (its
own setting, then the global one, then the system zone), but the card is
handed only the plugin's config. Its timezone ships as "", so
card_tzinfo fell back to UTC and the weekday belonged to the UTC date:
the next day for evening games in the Americas, the previous day for
morning games east of UTC (Auckland, Kiritimati).

Fix: every zone is within a day of UTC, so the printed date is the
start's UTC date or a neighbour of it. _format_date_as now takes the game
and names the weekday of whichever of those days has the printed month
and day, falling back to the zone-based weekday only when the start
cannot place the date (no offset, unparseable, or more than a day away).
The switch-mode scorebug shares the formatter and passes the game too, so
the twins stay identical; it already used the resolved zone and draws
what it drew before. Public signatures are unchanged.

Tests cover US DST end, New Year's Eve, both sides of the date line, NZ
DST start and UTC+14. The twins test's weekday pin is updated: the drawn
date now agrees, and only the bare weekday helpers still differ.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:19:05 -04:00

440 lines
21 KiB
Python

"""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
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
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_out_of_range_components_are_clamped_not_rejected(self):
"""The readers this replaced clamped, and the eight scoreboards' own
tests pin it. Rejecting instead turned a typo'd [999, -5, 20] -- a
bright red -- into the default white, which is a colour the user never
asked for rather than the one they nearly asked for."""
cfg = {"customization": {"score_text": {"text_color": [999, -5, 20]}}}
assert C.element_color(cfg, "score_text", (9, 9, 9)) == (255, 0, 20)
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_takes_the_one_colour_that_was_configured(self):
"""A face shared by two elements used to go out white even when only
one of them had a colour set -- and a bitmap face is *always* shared,
because a freetype.Face cannot be re-instantiated to un-share it. That
is how an element in any of the 32 shipped BDF fonts silently lost the
colour its picker had offered all along. One configured colour among
the sharers is the only thing the user can have meant."""
shared = object()
cfg = {"customization": {"score_text": {"text_color": [4, 5, 6]}}}
assert C.font_color(cfg, {"score": shared, "team": shared},
shared) == (4, 5, 6)
def test_a_shared_face_still_gives_up_when_the_colours_disagree(self):
"""Two different answers is the case with no right answer."""
shared = object()
cfg = {"customization": {"score_text": {"text_color": [4, 5, 6]},
"team_name": {"text_color": [7, 8, 9]}}}
assert C.font_color(cfg, {"score": shared, "team": shared},
shared) == (255, 255, 255)
def test_sharers_that_agree_resolve_to_that_colour(self):
shared = object()
cfg = {"customization": {"score_text": {"text_color": [4, 5, 6]},
"team_name": {"text_color": [4, 5, 6]}}}
assert C.font_color(cfg, {"score": shared, "team": shared},
shared) == (4, 5, 6)
def test_an_unconfigured_shared_face_keeps_the_default(self):
shared = object()
assert C.font_color({}, {"score": shared, "team": shared}, shared,
(9, 9, 9)) == (9, 9, 9)
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 TestWeekdayMatchesThePrintedDate:
"""The weekday is the printed date's, whichever zone printed it.
The extractor prints "M/D" in the plugin's resolved zone (its own
setting, else the global one, else the system zone). The card is handed
only the plugin's config, whose ``timezone`` ships as "" -- so a weekday
taken in card_tzinfo's zone was UTC's, and an evening kickoff in the
Americas read "Sat Oct 2" for a Friday game.
"""
WEEKDAY = {"timezone": "", "scroll_card": {"date_format": "weekday"}}
@staticmethod
def _as_printed(start_utc, zone):
"""The game dict and the date text, as the extractor builds them."""
local = datetime.fromisoformat(start_utc).astimezone(ZoneInfo(zone))
game = {"start_time_utc": datetime.fromisoformat(start_utc),
"game_date": f"{local.month}/{local.day}"}
want = f"{C.WEEKDAY_ABBR[local.weekday()]} {C.MONTH_ABBR[local.month - 1]} {local.day}"
return game, want
@pytest.mark.parametrize("start_utc, zone, want", [
# Friday 8 PM EDT is Saturday in UTC.
("2026-10-03T00:00:00+00:00", "America/New_York", "Fri Oct 2"),
# The night US clocks go back: 8:30 PM EDT Saturday, then 11 PM EST
# Sunday, each the next day in UTC.
("2026-11-01T00:30:00+00:00", "America/New_York", "Sat Oct 31"),
("2026-11-02T04:00:00+00:00", "America/New_York", "Sun Nov 1"),
# New Year's Eve on the west coast is New Year's Day in UTC.
("2027-01-01T04:00:00+00:00", "America/Los_Angeles", "Thu Dec 31"),
# Just east of the date line: Pago Pago's Friday evening.
("2026-10-03T05:00:00+00:00", "Pacific/Pago_Pago", "Fri Oct 2"),
# Just west of it, the other way: Saturday morning in Auckland is
# Friday in UTC -- and the 10 AM game on the day NZ clocks go forward.
("2026-10-02T20:00:00+00:00", "Pacific/Auckland", "Sat Oct 3"),
("2026-09-26T21:00:00+00:00", "Pacific/Auckland", "Sun Sep 27"),
# UTC+14, the furthest any zone sits from UTC.
("2026-10-02T11:00:00+00:00", "Pacific/Kiritimati", "Sat Oct 3"),
# A zone on UTC's own date needs nothing.
("2026-10-02T19:00:00+00:00", "Europe/London", "Fri Oct 2"),
])
def test_the_shipped_blank_timezone(self, log, start_utc, zone, want):
game, printed = self._as_printed(start_utc, zone)
assert printed == want # the case says what the extractor prints
assert C.format_game_date(self.WEEKDAY, log, game["game_date"], game) == want
def test_an_iso_string_start_reads_the_same(self, log):
game = {"start_time_utc": "2026-10-03T00:00:00Z"}
assert C.format_game_date(self.WEEKDAY, log, "10/2", game) == "Fri Oct 2"
assert C.format_game_date(self.WEEKDAY, log, "10/02", game) == "Fri Oct 2"
def test_a_plugin_level_zone_still_agrees(self, log):
game, want = self._as_printed("2026-10-03T00:00:00+00:00", "America/Chicago")
cfg = dict(self.WEEKDAY, timezone="America/Chicago")
assert C.format_game_date(cfg, log, game["game_date"], game) == want == "Fri Oct 2"
def test_a_date_no_zone_could_print_keeps_the_zone_weekday(self, log):
"""More than a day from the start: nothing to anchor to, so the
weekday is card_tzinfo's, as it always was."""
game = {"start_time_utc": datetime(2026, 10, 3, 0, 0, tzinfo=timezone.utc)}
assert C.format_game_date(self.WEEKDAY, log, "10/9", game) == "Sat Oct 9"
def test_a_start_without_an_offset_keeps_the_zone_weekday(self, log):
"""A naive time names no instant, so it cannot place the date."""
game = {"start_time_utc": datetime(2026, 10, 2, 20, 0)}
assert C.format_game_date(self.WEEKDAY, log, "10/3", game) == \
f"{C.weekday_for(self.WEEKDAY, log, game)} Oct 3"
@pytest.mark.parametrize("game", [None, {}, {"start_time_utc": "garbage"}])
def test_no_usable_start_draws_no_weekday(self, log, game):
assert C.format_game_date(self.WEEKDAY, log, "10/2", game) == "Oct 2"
def test_the_scorebug_twin_formats_the_same(self, log):
"""Switch mode (SportsCoreSharedMixin) shares the formatter body."""
from src.common.sports_shared import SportsCoreSharedMixin
class Host(SportsCoreSharedMixin):
config = {"scroll_card": {"date_format": "weekday",
"switch_date_format": "inherit"}}
logger = log
def _get_timezone(self):
return ZoneInfo("America/New_York")
game, want = self._as_printed("2026-11-01T00:30:00+00:00", "America/New_York")
assert Host()._format_game_date(game["game_date"], game) == want
assert C.format_game_date(Host.config, log, game["game_date"], game) == want
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
class TestUnshareElementFonts:
"""Re-instantiated faces must match the ones they replace.
``unshare_element_fonts`` rebuilds a duplicate face purely so two elements
can be told apart by ``id()``. That is only safe while the rebuilt face
lays text out identically -- and PIL's two layout engines disagree on
fractional advances, which is why ``src.common.font_layout`` exists and
pins one. Rebuilding through bare ``ImageFont.truetype`` took the default
engine instead, so a re-instantiated face could measure differently from
the shared face it replaced.
"""
def _face(self, size=10):
from src.common.font_layout import load_truetype
path = os.path.join("assets", "fonts", "PressStart2P-Regular.ttf")
return load_truetype(path, size)
def test_rebuilt_face_goes_through_the_pinned_loader(self, log, monkeypatch):
"""Asserted on the loader, not on the resulting engine value.
PIL only selects Raqm when it is installed; where it is not, a bare
``ImageFont.truetype`` returns BASIC too, so comparing engine values
passes on those machines whether or not the pin is honoured -- this
test did exactly that before it was rewritten. Spying on the pinned
loader fails on every machine when the pin is bypassed, which is the
point: the cross-machine mismatch font_layout exists to prevent
cannot be reproduced on a Raqm-less runner.
"""
import src.common.font_layout as fl
# Build the face BEFORE patching: _face() loads through the same
# pinned loader, so patching first would let the fixture's own call
# satisfy the assertion and the test would pass either way.
shared = self._face()
calls = []
real = fl.load_truetype
def spy(font, size, **kwargs):
calls.append((font, size))
return real(font, size, **kwargs)
monkeypatch.setattr(fl, "load_truetype", spy)
fonts = {"score": shared, "time": shared}
C.unshare_element_fonts(log, fonts)
assert fonts["time"] is not shared, "the duplicate should have been rebuilt"
assert calls, "the rebuild must go through the pinned loader"
def test_rebuilt_face_keeps_the_shared_faces_layout_engine(self, log):
shared = self._face()
fonts = {"score": shared, "time": shared}
C.unshare_element_fonts(log, fonts)
assert fonts["time"].layout_engine == shared.layout_engine
def test_rebuilt_face_measures_identically(self, log):
shared = self._face()
fonts = {"score": shared, "time": shared}
C.unshare_element_fonts(log, fonts)
text = "88-88"
assert (fonts["time"].getlength(text)
== shared.getlength(text)), "metrics must not shift"
def test_distinct_faces_are_left_alone(self, log):
a, b = self._face(10), self._face(8)
fonts = {"score": a, "time": b}
C.unshare_element_fonts(log, fonts)
assert fonts["score"] is a and fonts["time"] is b
class TestSharedElementReaders:
"""The scoreboards' colour and offset reads now go through one
implementation.
There were two copies of the colour read and three of the offset read.
They had already drifted: the scroll-card renderer carries a comment
noting it used to ignore offsets its own schema advertised. Sharing one
reader is what lets a fix (or an alias, or a per-mode override) reach
the full-screen scorebug and the scroll card together.
"""
CONFIG = {
"customization": {
"score_text": {"text_color": [255, 200, 0]},
"status": {"text_color": "#00ff00"},
"layout": {"score": {"y_offset": -3}},
"modes": {
"live": {"score_text": {"text_color": [255, 0, 0]},
"layout": {"score": {"y_offset": 7}}},
},
},
}
def test_colour_reads_the_configured_value(self, log):
assert C.element_color(self.CONFIG, "score_text") == (255, 200, 0)
def test_hex_colours_are_still_accepted(self, log):
"""These readers have always taken '#RRGGBB' as well as [r, g, b],
so the shared one had to learn it rather than the callers losing it."""
assert C.element_color(self.CONFIG, "status") == (0, 255, 0)
def test_colour_resolves_through_an_alias(self, log):
"""The style block says status_text where this config says status."""
assert C.element_color(self.CONFIG, "status_text") == (0, 255, 0)
def test_a_mode_overrides_the_colour(self, log):
assert C.element_color(self.CONFIG, "score_text",
mode="live") == (255, 0, 0)
def test_an_unset_element_gets_the_default(self, log):
assert C.element_color(self.CONFIG, "nothing", (1, 2, 3)) == (1, 2, 3)
@pytest.mark.parametrize("config", [
None, {}, "nonsense", {"customization": "nonsense"},
{"customization": {"score_text": "nonsense"}},
{"customization": {"score_text": {"text_color": "not a colour"}}},
])
def test_a_hostile_config_gives_the_default(self, config, log):
assert C.element_color(config, "score_text", (9, 9, 9)) == (9, 9, 9)