mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
feat(common): sports_celebration, sports_fetch and sports_card_wrappers, promoted from the scoreboards (sports consolidation stage 3) (#672)
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>
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,344 @@
|
||||
"""src.common.sports_celebration: the palette, the takeover, and the host contract.
|
||||
|
||||
Ported from the scoreboards' own celebration tests (football's
|
||||
test_score_celebration.py, hockey's and soccer's test_goal_celebration.py),
|
||||
which drive the same code through a plugin's SportsLive. Here the host is a
|
||||
stub carrying exactly the documented contract, and the crests are drawn by the
|
||||
test, so every input is fixed. Pixel-exact goldens of every plugin's takeover
|
||||
live in ledmatrix-plugins (scripts/test_celebration_renders.py).
|
||||
"""
|
||||
|
||||
import ast
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from PIL import Image, ImageChops, ImageDraw, ImageFont
|
||||
|
||||
from src.common import sports_celebration
|
||||
from src.common.sports_celebration import (
|
||||
SportsCelebrationMixin,
|
||||
cap_luminance,
|
||||
lift_color,
|
||||
logo_palette,
|
||||
mix_color,
|
||||
rgb_luminance,
|
||||
rgb_saturation,
|
||||
scale_color,
|
||||
)
|
||||
|
||||
FONTS = Path(__file__).resolve().parents[1] / "assets" / "fonts"
|
||||
SIZES = [(64, 32), (128, 32), (64, 64), (96, 48),
|
||||
(128, 64), (256, 32), (128, 96), (256, 128)]
|
||||
|
||||
|
||||
def crest(body, band, size=64):
|
||||
"""A shield in ``body`` with a horizontal band in ``band``."""
|
||||
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
s = size / 64
|
||||
draw.polygon([(6 * s, 4 * s), (58 * s, 4 * s), (58 * s, 34 * s),
|
||||
(32 * s, 60 * s), (6 * s, 34 * s)], fill=body + (255,))
|
||||
draw.rectangle([(6 * s, 22 * s), (58 * s, 32 * s)], fill=band + (255,))
|
||||
return img
|
||||
|
||||
|
||||
CRESTS = {
|
||||
"RED": crest((200, 16, 46), (255, 255, 255)),
|
||||
"NAV": crest((12, 35, 64), (255, 184, 28)),
|
||||
"SIL": crest((165, 172, 175), (0, 0, 0)),
|
||||
}
|
||||
|
||||
|
||||
class _DisplayManager:
|
||||
def __init__(self, width, height):
|
||||
self.width, self.height = width, height
|
||||
self.image = Image.new("RGB", (width, height))
|
||||
self.updates = 0
|
||||
|
||||
def clear(self):
|
||||
self.image = Image.new("RGB", (self.width, self.height))
|
||||
|
||||
def update_display(self):
|
||||
self.updates += 1
|
||||
|
||||
|
||||
class Host(SportsCelebrationMixin):
|
||||
"""The documented contract, and not one attribute more."""
|
||||
|
||||
def __init__(self, width=128, height=32, **knobs):
|
||||
self.display_manager = _DisplayManager(width, height)
|
||||
self.display_width, self.display_height = width, height
|
||||
press = str(FONTS / "PressStart2P-Regular.ttf")
|
||||
self.fonts = {
|
||||
"time": ImageFont.truetype(press, 8),
|
||||
"status": ImageFont.truetype(str(FONTS / "4x6-font.ttf"), 6),
|
||||
"score": ImageFont.truetype(press, 16 if height >= 48 else 10),
|
||||
}
|
||||
self.logger = logging.getLogger("test.sports_celebration")
|
||||
for name, value in knobs.items():
|
||||
setattr(self, name, value)
|
||||
|
||||
def _load_and_resize_logo(self, team_id, abbr, logo_path, logo_url):
|
||||
logo = CRESTS.get(abbr)
|
||||
if logo is None:
|
||||
return None
|
||||
logo = logo.copy()
|
||||
logo.thumbnail((self.display_height, self.display_height), Image.Resampling.LANCZOS)
|
||||
return logo
|
||||
|
||||
def _draw_text_with_outline(self, draw, text, position, font,
|
||||
fill=(255, 255, 255), outline_color=(0, 0, 0)):
|
||||
x, y = position
|
||||
for dx, dy in ((-1, 0), (1, 0), (0, -1), (0, 1)):
|
||||
draw.text((x + dx, y + dy), text, font=font, fill=outline_color)
|
||||
draw.text((x, y), text, font=font, fill=fill)
|
||||
|
||||
|
||||
def celebration(scorer="RED", other="NAV", side="away", kind="score", motif="score"):
|
||||
away, home = (scorer, other) if side == "away" else (other, scorer)
|
||||
return {
|
||||
"kind": kind, "motif": motif,
|
||||
"game": {"id": "401", "away_abbr": away, "home_abbr": home},
|
||||
"scored_side": side, "team_abbr": scorer,
|
||||
"away_score": 3, "home_score": 2, "started_at": 1000.0,
|
||||
"phrase": f"{scorer} WINS!" if kind == "win" else f"{scorer} SCORES!",
|
||||
}
|
||||
|
||||
|
||||
def render(host=None, elapsed=2.0, **kwargs):
|
||||
host = host or Host()
|
||||
with mock.patch("time.time", return_value=1000.0 + elapsed):
|
||||
host._draw_celebration_layout(celebration(**kwargs), force_clear=True)
|
||||
return host.display_manager.image
|
||||
|
||||
|
||||
def brightest(img, box=None):
|
||||
region = img.crop(box) if box else img
|
||||
return max(region.convert("RGB").getextrema()[i][1] for i in range(3))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Colour helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestColourHelpers:
|
||||
def test_mix_is_clamped_to_the_two_ends(self):
|
||||
assert mix_color((0, 0, 0), (200, 100, 50), 0.5) == (100, 50, 25)
|
||||
assert mix_color((0, 0, 0), (200, 100, 50), 2) == (200, 100, 50)
|
||||
assert mix_color((0, 0, 0), (200, 100, 50), -1) == (0, 0, 0)
|
||||
|
||||
def test_scale_is_clamped_to_the_panel(self):
|
||||
assert scale_color((200, 100, 0), 2) == (255, 200, 0)
|
||||
|
||||
def test_saturation_of_black_is_zero(self):
|
||||
assert rgb_saturation((0, 0, 0)) == 0.0
|
||||
|
||||
def test_lifting_a_colour_keeps_its_hue(self):
|
||||
# Scaling channels turns Baltimore's navy-purple magenta; HSV does not.
|
||||
lifted = lift_color((39, 15, 98))
|
||||
assert rgb_luminance(lifted) >= 100
|
||||
assert lifted[2] > lifted[0] > lifted[1]
|
||||
|
||||
def test_a_colour_that_already_reads_is_left_alone(self):
|
||||
assert lift_color((255, 208, 56)) == (255, 208, 56)
|
||||
|
||||
def test_a_grey_is_just_made_bright(self):
|
||||
lifted = lift_color((40, 40, 40))
|
||||
assert lifted[0] == lifted[1] == lifted[2] and rgb_luminance(lifted) > 200
|
||||
|
||||
def test_capping_keeps_the_hue_and_the_cap(self):
|
||||
capped = cap_luminance((248, 61, 1), 34)
|
||||
assert capped[0] > capped[1] > capped[2]
|
||||
assert rgb_luminance(capped) <= 35
|
||||
|
||||
|
||||
class TestLogoPalette:
|
||||
def test_a_saturated_crest_is_its_own_headline(self):
|
||||
palette = logo_palette(CRESTS["RED"])
|
||||
r, g, b = palette["headline"]
|
||||
assert r > 150 and r > 2 * g and r > 2 * b
|
||||
assert rgb_luminance(palette["deep"]) <= 36
|
||||
|
||||
def test_a_legible_band_beats_lifting_a_dark_body(self):
|
||||
palette = logo_palette(CRESTS["NAV"])
|
||||
r, g, b = palette["headline"]
|
||||
assert r > 150 and g > 110 and b < 110, f"{palette['headline']} is not the gold band"
|
||||
assert palette["deep"][2] >= palette["deep"][0], "the backdrop lost the navy"
|
||||
|
||||
def test_a_crest_with_no_colour_falls_back_to_its_brightest_grey(self):
|
||||
palette = logo_palette(CRESTS["SIL"])
|
||||
assert palette is not None
|
||||
assert rgb_saturation(palette["headline"]) < 0.12
|
||||
|
||||
def test_nothing_opaque_is_no_palette(self):
|
||||
assert logo_palette(Image.new("RGBA", (16, 16))) is None
|
||||
|
||||
def test_an_unreadable_crest_is_no_palette(self):
|
||||
assert logo_palette(object()) is None
|
||||
|
||||
def test_every_colour_has_three_channels(self):
|
||||
palette = logo_palette(CRESTS["RED"])
|
||||
assert set(palette) == {"deep", "glow", "headline", "accent"}
|
||||
assert all(len(c) == 3 for c in palette.values())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The celebration's palette
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCelebrationPalette:
|
||||
def test_read_off_the_scoring_side(self):
|
||||
away = Host()._celebration_palette(celebration(side="away"))
|
||||
home = Host()._celebration_palette(celebration(scorer="NAV", other="RED", side="home"))
|
||||
assert away == logo_palette(Host()._load_and_resize_logo(None, "RED", None, None))
|
||||
assert home["headline"] != away["headline"]
|
||||
|
||||
def test_worked_out_once_per_celebration(self):
|
||||
host, c = Host(), celebration()
|
||||
first = host._celebration_palette(c)
|
||||
host._load_and_resize_logo = mock.Mock(side_effect=AssertionError("reloaded"))
|
||||
assert host._celebration_palette(c) is first
|
||||
|
||||
@pytest.mark.parametrize("loader", [lambda *a: None, mock.Mock(side_effect=OSError("bad png"))])
|
||||
def test_no_usable_crest_falls_back(self, loader):
|
||||
host = Host()
|
||||
host._load_and_resize_logo = loader
|
||||
assert host._celebration_palette(celebration()) == Host._DEFAULT_CELEBRATION_PALETTE
|
||||
|
||||
def test_team_colours_off_is_the_default(self):
|
||||
host = Host(celebration_team_colors=False)
|
||||
assert host._celebration_palette(celebration()) == Host._DEFAULT_CELEBRATION_PALETTE
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The takeover
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTakeover:
|
||||
def test_frame_is_presented(self):
|
||||
host = Host()
|
||||
render(host)
|
||||
assert host.display_manager.updates == 1
|
||||
assert host.display_manager.image.size == (128, 32)
|
||||
|
||||
def test_same_inputs_same_frame(self):
|
||||
assert render().tobytes() == render().tobytes()
|
||||
|
||||
def test_the_highlight_follows_the_scoring_side(self):
|
||||
away = render(side="away", scorer="RED", other="RED")
|
||||
home = render(side="home", scorer="RED", other="RED")
|
||||
assert ImageChops.difference(away, home).getbbox() is not None
|
||||
|
||||
def test_each_motif_paints_its_own_scenery(self):
|
||||
shots = {m: render(Host(celebration_confetti=False), motif=m).tobytes()
|
||||
for m in ("score", "kick", "touchdown", "net", "win")}
|
||||
assert len(set(shots.values())) == len(shots)
|
||||
|
||||
def test_an_unknown_motif_draws_the_score_scenery(self):
|
||||
host = Host(celebration_confetti=False)
|
||||
assert render(host, motif="bogus").tobytes() == render(Host(celebration_confetti=False),
|
||||
motif="score").tobytes()
|
||||
|
||||
@pytest.mark.parametrize("knob", ["celebration_team_colors", "celebration_confetti"])
|
||||
def test_switches_change_the_frame(self, knob):
|
||||
assert render(Host(**{knob: False})).tobytes() != render(Host()).tobytes()
|
||||
|
||||
def test_the_matrix_size_wins_over_the_configured_one(self):
|
||||
host = Host(width=64, height=32)
|
||||
host.display_manager.matrix = type("M", (), {"width": 128, "height": 32})()
|
||||
assert render(host).size == (128, 32)
|
||||
|
||||
@pytest.mark.parametrize("width,height", SIZES)
|
||||
def test_every_frame_is_a_finished_card(self, width, height):
|
||||
# A switch-mode board samples once a second: any frame may be the only
|
||||
# one seen, so each carries the headline and nothing is blank.
|
||||
for elapsed in [0.0] + [i + 0.5 for i in range(8)]:
|
||||
img = render(Host(width, height), elapsed=elapsed)
|
||||
assert brightest(img) > 40
|
||||
assert brightest(img, (0, 0, width, max(2, height // 4))) > 60
|
||||
|
||||
def test_the_score_stays_on_a_tall_panel(self):
|
||||
# 16px digits at 48 tall used to run off the bottom row. The goal
|
||||
# line keeps the scenery off that row, so only the score could be.
|
||||
img = render(Host(192, 48, celebration_confetti=False), motif="touchdown")
|
||||
assert brightest(img, (48, 47, 144, 48)) < 10
|
||||
assert brightest(img, (48, 24, 144, 47)) > 10
|
||||
|
||||
def test_the_scoring_side_breathes_rather_than_toggling(self):
|
||||
frames = {render(Host(celebration_confetti=False), elapsed=t).tobytes()
|
||||
for t in (0.9, 1.9, 2.9, 3.9, 4.9, 5.9)}
|
||||
assert len(frames) > 2
|
||||
|
||||
def test_confetti_is_seeded_from_the_game_not_the_clock(self):
|
||||
host, c = Host(), celebration()
|
||||
palette = host._celebration_palette(c)
|
||||
flakes = host._celebration_confetti(c, 128, 32, palette)
|
||||
again = Host()._celebration_confetti(celebration(), 128, 32, palette)
|
||||
assert flakes == again and 6 <= len(flakes) <= 22
|
||||
|
||||
def test_confetti_is_gone_by_the_end(self):
|
||||
host, c = Host(), celebration()
|
||||
palette = host._celebration_palette(c)
|
||||
overlay = Image.new("RGBA", (128, 32), (0, 0, 0, 0))
|
||||
host._draw_celebration_confetti(ImageDraw.Draw(overlay), c, 128, 32, palette, 8.0, 1.0)
|
||||
assert overlay.getbbox() is None
|
||||
|
||||
def test_the_side_that_did_not_score_is_dimmed(self):
|
||||
crests = Host()._celebration_crests(celebration(scorer="RED", other="RED"), 32)
|
||||
assert brightest(crests["home"]) < brightest(crests["away"])
|
||||
|
||||
def test_a_crest_that_fails_to_load_is_left_out(self):
|
||||
host = Host()
|
||||
host._load_and_resize_logo = mock.Mock(side_effect=OSError("bad png"))
|
||||
assert host._celebration_crests(celebration(), 32) == {"away": None, "home": None}
|
||||
assert brightest(render(host)) > 40
|
||||
|
||||
def test_fit_font_falls_back_to_the_smallest(self):
|
||||
host = Host()
|
||||
draw = ImageDraw.Draw(Image.new("RGB", (8, 8)))
|
||||
fonts = [host.fonts["time"], host.fonts["status"]]
|
||||
assert host._fit_font(draw, "A", 128, fonts) is fonts[0]
|
||||
assert host._fit_font(draw, "A VERY LONG HEADLINE", 8, fonts) is fonts[-1]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Host contract
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _self_reads():
|
||||
"""Every ``self.X`` / ``getattr(self, "X")`` the mixin reads, by parsing it."""
|
||||
tree = ast.parse(Path(sports_celebration.__file__).read_text(encoding="utf-8"))
|
||||
cls = next(n for n in tree.body
|
||||
if isinstance(n, ast.ClassDef) and n.name == "SportsCelebrationMixin")
|
||||
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 == "self"):
|
||||
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(SportsCelebrationMixin))
|
||||
undocumented = sorted(n for n in needed if f"``{n}" not in sports_celebration.__doc__)
|
||||
assert undocumented == [], f"read but not in the host contract: {undocumented}"
|
||||
|
||||
def test_the_stub_host_is_enough(self):
|
||||
# Host above sets the contract and nothing else; it drew every test.
|
||||
needed = _self_reads() - set(dir(SportsCelebrationMixin))
|
||||
host = Host(celebration_duration=8, celebration_team_colors=True,
|
||||
celebration_confetti=True)
|
||||
assert all(hasattr(host, n) for n in needed)
|
||||
|
||||
def test_the_mixin_creates_no_attributes_of_its_own(self):
|
||||
# The annotations are for type checking; the host's values must win.
|
||||
for name in ("display_manager", "fonts", "logger", "_load_and_resize_logo"):
|
||||
assert not hasattr(SportsCelebrationMixin, name)
|
||||
@@ -0,0 +1,193 @@
|
||||
"""src.common.sports_fetch: behaviour and host contract.
|
||||
|
||||
Ported from the scoreboards' tests of the same methods (football's
|
||||
test_live_odds_follow_the_rotation.py, test_lookback_only_when_it_can_matter.py
|
||||
and test_espn_date_ranges.py) against a stub host carrying exactly the
|
||||
documented contract.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import logging
|
||||
import threading
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.common import espn_dates, sports_fetch
|
||||
from src.common.sports_fetch import SportsFetchMixin
|
||||
|
||||
ET = timezone(timedelta(hours=-5))
|
||||
|
||||
|
||||
class _Response:
|
||||
status_code = 200
|
||||
content = None
|
||||
|
||||
def __init__(self, data):
|
||||
self._data = data
|
||||
|
||||
def json(self):
|
||||
return self._data
|
||||
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
|
||||
class _Session:
|
||||
def __init__(self, data=None, error=None):
|
||||
self.data, self.error, self.calls = data, error, []
|
||||
|
||||
def get(self, url, params=None, headers=None, timeout=None):
|
||||
self.calls.append((url, dict(params or {}), headers, timeout))
|
||||
if self.error:
|
||||
raise self.error
|
||||
return _Response(self.data)
|
||||
|
||||
|
||||
class _Cache:
|
||||
def __init__(self):
|
||||
self.sets = []
|
||||
|
||||
def set(self, key, data, **kwargs):
|
||||
self.sets.append((key, data, kwargs))
|
||||
|
||||
|
||||
class Host(SportsFetchMixin):
|
||||
"""The documented contract, and not one attribute more."""
|
||||
|
||||
def __init__(self, session=None):
|
||||
self.session = session or _Session(data={"events": []})
|
||||
self.headers = {"User-Agent": "test"}
|
||||
self.cache_manager = _Cache()
|
||||
self.logger = logging.getLogger("test.sports_fetch")
|
||||
self._games_lock = threading.RLock()
|
||||
|
||||
|
||||
class TestWantsLiveOdds:
|
||||
def test_cold_start_asks_for_every_game(self):
|
||||
assert Host()._wants_live_odds({"id": "a"}) is True
|
||||
|
||||
def test_only_the_game_on_screen_and_the_next(self):
|
||||
host = Host()
|
||||
host.live_games = [{"id": i} for i in "abcd"]
|
||||
host.current_game_index = 1
|
||||
assert [host._wants_live_odds({"id": i}) for i in "abcd"] == [False, True, True, False]
|
||||
|
||||
def test_the_rotation_schedule_is_followed_and_wraps(self):
|
||||
host = Host()
|
||||
host.live_games = [{"id": i} for i in "abcd"]
|
||||
host._rotation_schedule = ["d", "c", "b", "a"]
|
||||
host.current_game_index = 3
|
||||
assert [host._wants_live_odds({"id": i}) for i in "abcd"] == [True, False, False, True]
|
||||
|
||||
def test_an_index_past_the_end_starts_at_the_front(self):
|
||||
host = Host()
|
||||
host.live_games = [{"id": i} for i in "abc"]
|
||||
host.current_game_index = 9
|
||||
assert [host._wants_live_odds({"id": i}) for i in "abc"] == [True, True, False]
|
||||
|
||||
def test_the_lookahead_is_a_class_setting(self):
|
||||
class Wider(Host):
|
||||
_LIVE_ODDS_LOOKAHEAD = 2
|
||||
|
||||
host = Wider()
|
||||
host.live_games = [{"id": i} for i in "abcd"]
|
||||
host.current_game_index = 0
|
||||
assert [host._wants_live_odds({"id": i}) for i in "abcd"] == [True, True, True, False]
|
||||
|
||||
|
||||
class TestNeedsPreviousDay:
|
||||
def test_before_the_cutoff_yesterday_is_kept(self):
|
||||
assert Host()._needs_previous_day(datetime(2026, 1, 15, 5, 59, tzinfo=ET)) is True
|
||||
|
||||
def test_after_it_with_nothing_live_it_is_dropped(self):
|
||||
assert Host()._needs_previous_day(datetime(2026, 1, 15, 6, 0, tzinfo=ET)) is False
|
||||
|
||||
def test_a_live_game_from_yesterday_keeps_it(self):
|
||||
host = Host()
|
||||
host.live_games = [{"start_time_utc": datetime(2026, 1, 15, 3, 0, tzinfo=timezone.utc)}]
|
||||
assert host._needs_previous_day(datetime(2026, 1, 15, 12, 0, tzinfo=ET)) is True
|
||||
|
||||
def test_todays_live_game_does_not(self):
|
||||
host = Host()
|
||||
host.live_games = [{"start_time_utc": datetime(2026, 1, 15, 18, 0, tzinfo=timezone.utc)}]
|
||||
assert host._needs_previous_day(datetime(2026, 1, 15, 12, 0, tzinfo=ET)) is False
|
||||
|
||||
@pytest.mark.parametrize("game", [{}, {"start_time_utc": "2026-01-14"}, "not a game"])
|
||||
def test_unusable_start_times_are_skipped(self, game):
|
||||
host = Host()
|
||||
host.live_games = [game]
|
||||
assert host._needs_previous_day(datetime(2026, 1, 15, 12, 0, tzinfo=ET)) is False
|
||||
|
||||
|
||||
class TestBackgroundFetchesEspnRanges:
|
||||
def test_no_service(self):
|
||||
assert Host()._background_fetches_espn_ranges() is False
|
||||
|
||||
@pytest.mark.parametrize("flag,expected", [(True, True), (False, False), (None, False)])
|
||||
def test_follows_the_service(self, flag, expected):
|
||||
host = Host()
|
||||
host.background_service = type("S", (), {"handles_espn_date_ranges": flag})()
|
||||
assert host._background_fetches_espn_ranges() is expected
|
||||
|
||||
def test_an_old_service_without_the_flag(self):
|
||||
host = Host()
|
||||
host.background_service = object()
|
||||
assert host._background_fetches_espn_ranges() is False
|
||||
|
||||
|
||||
class TestFetchSeasonDirectly:
|
||||
def test_fetches_caches_and_returns(self):
|
||||
host = Host(_Session(data={"events": [1, 2]}))
|
||||
data = host._fetch_season_directly("http://espn/sb", "20260115", "k", "2026 season")
|
||||
assert data == {"events": [1, 2]}
|
||||
assert host.cache_manager.sets == [("k", data, {})]
|
||||
assert host.session.calls == [
|
||||
("http://espn/sb", {"dates": "20260115", "limit": espn_dates.ESPN_MAX_LIMIT},
|
||||
{"User-Agent": "test"}, 30)]
|
||||
|
||||
def test_a_ttl_reaches_the_cache(self):
|
||||
host = Host()
|
||||
host._fetch_season_directly("http://espn/sb", "20260115", "k", "x", ttl=60)
|
||||
assert host.cache_manager.sets[0][2] == {"ttl": 60}
|
||||
|
||||
def test_a_failure_returns_none_and_caches_nothing(self, caplog):
|
||||
host = Host(_Session(error=OSError("down")))
|
||||
with caplog.at_level(logging.ERROR):
|
||||
assert host._fetch_season_directly("http://espn/sb", "20260115", "k", "2026 season") is None
|
||||
assert host.cache_manager.sets == []
|
||||
assert "Failed to fetch 2026 season schedule" in caplog.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Host contract
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _self_reads():
|
||||
"""Every ``self.X`` / ``getattr(self, "X")`` the mixin reads, by parsing it."""
|
||||
tree = ast.parse(Path(sports_fetch.__file__).read_text(encoding="utf-8"))
|
||||
cls = next(n for n in tree.body
|
||||
if isinstance(n, ast.ClassDef) and n.name == "SportsFetchMixin")
|
||||
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 == "self"):
|
||||
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(SportsFetchMixin))
|
||||
undocumented = sorted(n for n in needed if f"``{n}``" not in sports_fetch.__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 ("session", "headers", "cache_manager", "logger", "_games_lock"):
|
||||
assert not hasattr(SportsFetchMixin, name)
|
||||
@@ -0,0 +1,148 @@
|
||||
"""The stage 3 sports modules still match every plugin copy that remains.
|
||||
|
||||
``sports_celebration``, ``sports_fetch`` and ``sports_card_wrappers`` were
|
||||
copied from the scoreboard plugins, which delete their copies once they floor
|
||||
on the release that ships these. Until each has, a copy that changes on its
|
||||
own is a fix one side has and the other lacks. Point LEDMATRIX_PLUGINS at a
|
||||
ledmatrix-plugins checkout and every body here is compared, as an AST with
|
||||
docstrings and type annotations removed and public names folded to the
|
||||
plugins' private spelling, against every plugin copy. A copy that is gone
|
||||
counts as adopted. Without the variable this skips: core CI has no plugins
|
||||
checkout.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.common import sports_card_wrappers, sports_celebration, sports_fetch
|
||||
|
||||
#: module -> (its mixin, plugin file, plugin class, carriers,
|
||||
#: {plugin: names it deliberately overrides}).
|
||||
MODULES = {
|
||||
sports_celebration: ("SportsCelebrationMixin", "sports.py", "SportsLive",
|
||||
("afl", "football", "hockey", "nrl", "soccer"), {}),
|
||||
sports_fetch: ("SportsFetchMixin", "sports.py", "SportsCore",
|
||||
("afl", "baseball", "basketball", "football", "hockey",
|
||||
"lacrosse", "nrl", "soccer", "ufc"), {}),
|
||||
sports_card_wrappers: ("SportsCardWrappersMixin", "game_renderer.py", "GameRenderer",
|
||||
("afl", "baseball", "basketball", "football", "hockey",
|
||||
"lacrosse", "nrl", "soccer"),
|
||||
{"football": {"_format_game_date", "_upcoming_center_mode"}}),
|
||||
}
|
||||
|
||||
#: Type aliases the modules declare for annotations; nothing to compare.
|
||||
TYPE_ALIASES = {"Color", "Palette", "Flake", "_Buckets"}
|
||||
|
||||
#: Public here, private in the plugins.
|
||||
RENAMES = {name: "_" + name for name in (
|
||||
"rgb_luminance", "rgb_saturation", "color_distance", "mix_color",
|
||||
"scale_color", "lift_color", "cap_luminance", "dim_rgba", "logo_palette")}
|
||||
|
||||
|
||||
def _plugins_root():
|
||||
raw = os.environ.get("LEDMATRIX_PLUGINS")
|
||||
if not raw:
|
||||
pytest.skip("set LEDMATRIX_PLUGINS to a ledmatrix-plugins checkout to "
|
||||
"compare these modules against the plugin copies")
|
||||
root = Path(raw)
|
||||
if (root / "plugins").is_dir():
|
||||
root = root / "plugins"
|
||||
if not (root / "football-scoreboard" / "sports.py").is_file():
|
||||
pytest.skip(f"LEDMATRIX_PLUGINS={raw} has no football-scoreboard/sports.py")
|
||||
return root
|
||||
|
||||
|
||||
class _Normalise(ast.NodeTransformer):
|
||||
"""Drop docstrings and annotations; fold public names to private ones."""
|
||||
|
||||
def visit_Name(self, node):
|
||||
node.id = RENAMES.get(node.id, node.id)
|
||||
return node
|
||||
|
||||
def visit_arg(self, node):
|
||||
node.annotation = None
|
||||
return node
|
||||
|
||||
def visit_AnnAssign(self, node):
|
||||
return self.visit(ast.Assign(targets=[node.target], value=node.value, lineno=0))
|
||||
|
||||
def visit_FunctionDef(self, node):
|
||||
node.name = RENAMES.get(node.name, node.name)
|
||||
node.returns = None
|
||||
body = node.body
|
||||
if (body and isinstance(body[0], ast.Expr)
|
||||
and isinstance(body[0].value, ast.Constant)
|
||||
and isinstance(body[0].value.value, str)):
|
||||
node.body = body[1:] or [ast.Pass()]
|
||||
self.generic_visit(node)
|
||||
return node
|
||||
|
||||
|
||||
def _dump(node):
|
||||
node = ast.parse(ast.unparse(node)).body[0] # detach and copy
|
||||
return ast.dump(_Normalise().visit(node))
|
||||
|
||||
|
||||
def _definitions(tree, class_name):
|
||||
"""Module-level functions and assignments, plus ``class_name``'s members."""
|
||||
found = {}
|
||||
|
||||
def add(node, owner):
|
||||
if isinstance(node, ast.FunctionDef):
|
||||
found[(owner, node.name)] = node
|
||||
elif isinstance(node, (ast.Assign, ast.AnnAssign)):
|
||||
target = node.targets[0] if isinstance(node, ast.Assign) else node.target
|
||||
if isinstance(target, ast.Name) and node.value is not None:
|
||||
found[(owner, target.id)] = node
|
||||
|
||||
for node in tree.body:
|
||||
add(node, "module")
|
||||
if isinstance(node, ast.ClassDef) and node.name == class_name:
|
||||
for item in node.body:
|
||||
add(item, "class")
|
||||
return found
|
||||
|
||||
|
||||
def _promoted(module, mixin):
|
||||
"""What the module moved: its functions and ``_PALETTE_*``-style constants,
|
||||
and its mixin's methods and constants (not the host-contract annotations)."""
|
||||
tree = ast.parse(Path(module.__file__).read_text(encoding="utf-8"))
|
||||
ours = {}
|
||||
for (owner, name), node in _definitions(tree, mixin).items():
|
||||
if name in TYPE_ALIASES:
|
||||
continue
|
||||
ours[(owner, RENAMES.get(name, name))] = node
|
||||
return ours
|
||||
|
||||
|
||||
CASES = [(module.__name__.rsplit(".", 1)[1], key)
|
||||
for module, (mixin, *_rest) in MODULES.items()
|
||||
for key in sorted(_promoted(module, mixin))]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("module_name,key", CASES, ids=lambda v: str(v))
|
||||
def test_every_remaining_plugin_copy_matches(module_name, key):
|
||||
root = _plugins_root()
|
||||
module = next(m for m in MODULES if m.__name__.endswith("." + module_name))
|
||||
mixin, filename, class_name, carriers, overrides = MODULES[module]
|
||||
ours = _dump(_promoted(module, mixin)[key])
|
||||
drifted, missing = [], []
|
||||
for sport in carriers:
|
||||
source = (root / f"{sport}-scoreboard" / filename).read_text(encoding="utf-8")
|
||||
theirs = _definitions(ast.parse(source), class_name).get(key)
|
||||
if key[1] in overrides.get(sport, ()):
|
||||
continue
|
||||
if theirs is None:
|
||||
# Gone is fine once the plugin uses the module; otherwise the
|
||||
# finder is not seeing its copy.
|
||||
if module.__name__ not in source:
|
||||
missing.append(sport)
|
||||
elif _dump(theirs) != ours:
|
||||
drifted.append(sport)
|
||||
assert missing == [], f"{key[1]} not found in: {missing}"
|
||||
assert drifted == [], (
|
||||
f"{key[1]} in {module_name} differs from the copy in: {drifted}. "
|
||||
f"Port the change to both, or stop treating it as shared.")
|
||||
Reference in New Issue
Block a user