mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
Three new hardware-free modules holding code the scoreboard plugins carry as identical copies (executable AST, docstrings stripped, checked across every carrying plugin at ledmatrix-plugins 30455671). The bodies are the plugins'; the changes are type annotations for the mypy ratchet, the colour helpers losing their leading underscore as public free functions, and two comments that described the plugins' files. - src/common/sports_celebration.py: SportsCelebrationMixin, the score/win takeover drawn by afl, football, hockey, nrl and soccer (_draw_celebration_layout and the palette, backdrop, scenery, confetti, crest and _fit_font steps, with their class constants), plus the colour helpers (logo_palette, lift_color, cap_luminance, mix_color, ...). Only the drawing: _start_celebration, _check_for_goal/_check_for_score, _check_for_win and display() differ between the plugins and stay there. - src/common/sports_fetch.py: SportsFetchMixin, the four SportsCore methods identical in all nine scoreboards: _fetch_season_directly, _background_fetches_espn_ranges, _needs_previous_day and _wants_live_odds, with _LOOKBACK_CUTOFF_HOUR and _LIVE_ODDS_LOOKAHEAD. _get_timezone, _extract_game_details and _fetch_data are as identical and stay behind, for the reasons sports_shared gives (a per-plugin import; the abstract contract); so does SportsUpcoming.__init__, since no src/common mixin has a constructor. - src/common/sports_card_wrappers.py: SportsCardWrappersMixin, the seventeen sports_card delegations the eight game renderers carry (15 in all eight, 2 in all but football, whose own versions override them). _schema_font_size/_resolve_font_size look identical but read each plugin's own _SCHEMA_PATH, so they stay. Each mixin has no __init__ and creates no attributes (the host contract is declared as annotations only), defines no name the mixins beside it define, and documents the attributes it reads; a host-contract test parses each and fails on an undocumented read. A method kept on a plugin's class wins over the mixin's. Tests: behaviour ported from the plugins' celebration, odds, lookback and date-range tests against stub hosts carrying exactly the contract, with crests drawn by the test (test_sports_celebration.py, test_sports_fetch.py, test_sports_card_wrappers.py), and test_sports_stage3_parity.py, which with LEDMATRIX_PLUGINS set compares every body with every plugin copy that is left (58 pass against the plugins today; a copy that is gone counts as adopted). All three modules are on the mypy ratchet, in src/common/README.md, the CHANGELOG's Unreleased section and SPORTS_UNIFICATION's module table. Nothing in core uses them yet. Full suite: the same 67 failing test ids as main (Windows-only), 77 more passing. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
153 lines
6.4 KiB
Python
153 lines
6.4 KiB
Python
"""src.common.sports_card_wrappers: each delegation, and the host contract.
|
|
|
|
Every method here forwards to the ``sports_card`` function it names with the
|
|
host's ``config`` and ``logger``. The tests pin what each returns for a
|
|
configured card, so a delegation that passes the wrong thing -- an empty
|
|
config, the other side, a dropped default -- fails here rather than as a
|
|
wrong colour on a panel.
|
|
"""
|
|
|
|
import ast
|
|
import logging
|
|
from pathlib import Path
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from PIL import ImageFont
|
|
|
|
from src.common import sports_card, sports_card_wrappers, sports_game_renderer
|
|
from src.common.sports_card_wrappers import SportsCardWrappersMixin
|
|
from src.common.sports_game_renderer import SportsGameRendererMixin
|
|
|
|
CONFIG = {
|
|
"timezone": "America/Chicago",
|
|
"favorite_teams": ["BOS"],
|
|
"scroll_card": {"vs_text": "@", "upcoming_center": "date_time",
|
|
"date_format": "weekday", "time_format": "24h"},
|
|
"customization": {
|
|
"score_text": {"text_color": [9, 9, 9]},
|
|
"favorite_result_colors": {"enabled": True, "win_color": [0, 200, 0]},
|
|
},
|
|
}
|
|
|
|
GAME = {"home_abbr": "BOS", "away_abbr": "NYY", "home_score": "3", "away_score": "1",
|
|
"start_time_utc": "2026-09-19T23:00:00Z"}
|
|
|
|
|
|
class Host(SportsCardWrappersMixin):
|
|
"""The documented contract, and not one attribute more."""
|
|
|
|
_FONT_NAME_ALIASES = dict(sports_card.FONT_NAME_ALIASES)
|
|
_FONT_PIXEL_GRID = dict(sports_card.FONT_PIXEL_GRID)
|
|
|
|
def __init__(self, config=CONFIG):
|
|
self.config = config
|
|
self.logger = logging.getLogger("test.sports_card_wrappers")
|
|
self.fonts = {"score": ImageFont.load_default()}
|
|
|
|
|
|
class TestDelegations:
|
|
def test_card_options(self):
|
|
host = Host()
|
|
assert host._scroll_card_option("vs_text", "VS") == "@"
|
|
assert host._scroll_card_option("missing", "fallback") == "fallback"
|
|
assert host._vs_text() == "@"
|
|
assert host._upcoming_center_mode() == "date_time"
|
|
|
|
def test_dates_and_times(self):
|
|
host = Host()
|
|
assert host._card_tzinfo() == ZoneInfo("America/Chicago")
|
|
assert host._weekday_for(GAME) == "Sat" # 18:00 in Chicago
|
|
assert host._format_game_time("7:05 PM") == "19:05"
|
|
assert host._format_game_date("9/19", GAME) == sports_card.format_game_date(
|
|
CONFIG, host.logger, "9/19", GAME)
|
|
assert host._format_game_date("9/19", GAME) != "9/19"
|
|
|
|
def test_colours(self):
|
|
host = Host()
|
|
assert tuple(host._element_color("score_text")) == (9, 9, 9)
|
|
assert tuple(host._element_color("missing_element", (1, 2, 3))) == (1, 2, 3)
|
|
assert tuple(host._font_color(host.fonts["score"])) == (9, 9, 9)
|
|
assert host._coerce_rgb([300, -1, "7"], (1, 2, 3)) == (255, 0, 7)
|
|
|
|
def test_favourites(self):
|
|
host = Host()
|
|
assert host._side_is_favorite(GAME, "home", {"BOS"}) is True
|
|
assert host._side_is_favorite(GAME, "away", {"BOS"}) is False
|
|
assert host._side_score(GAME, "home") == 3
|
|
assert host._favorite_result(GAME) == "win"
|
|
assert host._recent_score_color(GAME, (1, 1, 1)) == (0, 200, 0)
|
|
assert host._score_color_for(GAME, "recent") == (0, 200, 0)
|
|
assert tuple(host._score_color_for(GAME, "live")) == (9, 9, 9)
|
|
|
|
def test_fonts(self):
|
|
host = Host()
|
|
font = host.fonts["score"]
|
|
unshared = host._unshare_element_fonts({"score": font, "time": font})
|
|
assert set(unshared) == {"score", "time"}
|
|
|
|
def test_crisp_size_uses_the_hosts_own_tables(self):
|
|
class NoTables(Host):
|
|
_FONT_NAME_ALIASES = {}
|
|
_FONT_PIXEL_GRID = {}
|
|
|
|
assert Host._crisp_size("PressStart2P-Regular.ttf", 9) == 8 # snapped
|
|
assert NoTables._crisp_size("PressStart2P-Regular.ttf", 9) == 9 # unknown face
|
|
|
|
|
|
class TestComposition:
|
|
def test_it_supplies_what_the_geometry_mixin_needs(self):
|
|
# sports_game_renderer's docstring lists these as host-provided.
|
|
for name in ("_scroll_card_option", "_upcoming_center_mode", "_vs_text",
|
|
"_element_color", "_format_game_date", "_format_game_time"):
|
|
assert f"``{name}``" in sports_game_renderer.__doc__
|
|
assert name in SportsCardWrappersMixin.__dict__
|
|
|
|
def test_the_two_mixins_share_no_names(self):
|
|
ours = {n for n in SportsCardWrappersMixin.__dict__ if not n.startswith("__")}
|
|
theirs = {n for n in SportsGameRendererMixin.__dict__ if not n.startswith("__")}
|
|
assert ours & theirs == set()
|
|
|
|
def test_a_renderers_own_method_wins(self):
|
|
class Renderer(SportsCardWrappersMixin, SportsGameRendererMixin):
|
|
def _vs_text(self):
|
|
return "v"
|
|
|
|
def __init__(self):
|
|
self.config, self.logger = CONFIG, logging.getLogger("t")
|
|
|
|
assert Renderer()._vs_text() == "v"
|
|
assert Renderer()._upcoming_center_mode() == "date_time"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Host contract
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _self_reads():
|
|
"""Every ``self.X`` / ``cls.X`` / ``getattr(self, "X")`` the mixin reads."""
|
|
tree = ast.parse(Path(sports_card_wrappers.__file__).read_text(encoding="utf-8"))
|
|
cls = next(n for n in tree.body
|
|
if isinstance(n, ast.ClassDef) and n.name == "SportsCardWrappersMixin")
|
|
names = set()
|
|
for node in ast.walk(cls):
|
|
if (isinstance(node, ast.Attribute) and isinstance(node.ctx, ast.Load)
|
|
and isinstance(node.value, ast.Name) and node.value.id in ("self", "cls")):
|
|
names.add(node.attr)
|
|
if (isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
|
|
and node.func.id == "getattr" and len(node.args) >= 2
|
|
and isinstance(node.args[0], ast.Name) and node.args[0].id == "self"
|
|
and isinstance(node.args[1], ast.Constant)):
|
|
names.add(node.args[1].value)
|
|
return names
|
|
|
|
|
|
class TestHostContract:
|
|
def test_every_host_read_is_documented(self):
|
|
needed = _self_reads() - set(dir(SportsCardWrappersMixin))
|
|
undocumented = sorted(n for n in needed if f"``{n}``" not in sports_card_wrappers.__doc__)
|
|
assert undocumented == [], f"read but not in the host contract: {undocumented}"
|
|
|
|
def test_the_mixin_creates_no_attributes_of_its_own(self):
|
|
for name in ("config", "logger", "fonts", "_FONT_NAME_ALIASES", "_FONT_PIXEL_GRID"):
|
|
assert not hasattr(SportsCardWrappersMixin, name)
|