diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c18b24d..7ae09d8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,32 @@ accepts both, but the store flags the old spelling as deprecated ## Unreleased +### New modules + +A plugin may import these via `src.*` once it floors on the release that +ships them. All three hold code the scoreboard plugins carry as identical +copies, moved without behaviour change under the plugins' own method names; +each docstring lists what the host class must provide. The plugins delete +their copies when they floor on that release. + +- `src/common/sports_celebration.py` — `SportsCelebrationMixin`, the + score/win celebration takeover drawn by afl, football, hockey, nrl and + soccer (`_draw_celebration_layout` and the palette, backdrop, scenery, + confetti and crest steps behind it), plus its colour helpers as free + functions: `logo_palette`, `lift_color`, `cap_luminance`, `mix_color`, + `scale_color`, `dim_rgba`, `rgb_luminance`, `rgb_saturation`, + `color_distance`. Only the drawing: when to celebrate, the phrase and the + scenery stay in each plugin. +- `src/common/sports_fetch.py` — `SportsFetchMixin`, 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`). +- `src/common/sports_card_wrappers.py` — `SportsCardWrappersMixin`, the + seventeen `sports_card` delegations the eight scoreboard game renderers + carry (`_vs_text`, `_element_color`, `_format_game_date`, ...): the methods + `SportsGameRendererMixin` expects its host to provide. + ## 3.6.2 A fix to `src.common.favorite_team_check` (#670). diff --git a/docs/SPORTS_UNIFICATION.md b/docs/SPORTS_UNIFICATION.md index ac6d9471..cdb73d05 100644 --- a/docs/SPORTS_UNIFICATION.md +++ b/docs/SPORTS_UNIFICATION.md @@ -83,6 +83,9 @@ more. Shared sports code lives in `src/common`: | `espn_dates.py` | 3.5.0 | ESPN date-range and `limit` workarounds | | `favorite_team_check.py` | 3.6.0 | `FavoriteTeamCheck` — logs why a favourite team code shows nothing | | `sports_timezone.py` | 3.6.0 | Which timezone start times are drawn in (`resolve_timezone_name`) | +| `sports_celebration.py` | Unreleased | `SportsCelebrationMixin` — draws the score/win takeover; colour helpers | +| `sports_fetch.py` | Unreleased | `SportsFetchMixin` — season fetch, live lookback and live-odds decisions | +| `sports_card_wrappers.py` | Unreleased | `SportsCardWrappersMixin` — the game renderer's `sports_card` delegations | Each is described in [src/common/README.md](../src/common/README.md). @@ -168,6 +171,15 @@ Mix it in **before** the mode class — `class SoccerLive(CelebrationMixin, SportsLive)` — so the celebration `display()` runs first and falls through to the scorebug via `super()`. +What shipped is narrower. `src/common/sports_celebration.py` +(`SportsCelebrationMixin`) holds only the drawing, which is identical in the +five scoreboards that celebrate (afl, football, hockey, nrl, soccer — hockey +grew celebrations after this was written). Arming a celebration stays in each +plugin: the trigger bodies differ (nrl matches favourites by team id, football +folds a touchdown's extra point into one celebration and picks scenery by +points), and so does `display()`. The seams above were not needed to move the +drawing, so none was added. + **Rotation strategies.** The three "dialects" turned out to be one algorithm (Smooth Weighted Round-Robin) in two shapes: an incremental picker holding state across calls (afl/nrl/soccer) and a precomputed per-cycle list diff --git a/mypy-clean.txt b/mypy-clean.txt index b3706103..bd7330c4 100644 --- a/mypy-clean.txt +++ b/mypy-clean.txt @@ -32,6 +32,9 @@ src/common/render_gate.py src/common/scroll_config.py src/common/snapshot_policy.py src/common/sports_card.py +src/common/sports_card_wrappers.py +src/common/sports_celebration.py +src/common/sports_fetch.py src/common/sports_scroll.py src/common/sports_timezone.py src/config_service.py diff --git a/src/common/README.md b/src/common/README.md index d1eac8ea..d979ab66 100644 --- a/src/common/README.md +++ b/src/common/README.md @@ -38,6 +38,9 @@ Rules for the package: | [`scroll_helper`](#scroll_helper) | Pre-rendered horizontal scrolling | Yes | — | | [`snapshot_policy`](#snapshot_policy) | When to write the web preview frame | No, core-internal | n/a | | [`sports_card`](#sports_card) | Scoreboard card settings, colours, fonts, dates | Yes (scoreboards) | 3.3.0 | +| [`sports_card_wrappers`](#sports_card_wrappers) | The game renderer's `sports_card` delegations | Yes (scoreboards) | Unreleased | +| [`sports_celebration`](#sports_celebration) | Draw a scoreboard's score/win celebration | Yes (scoreboards) | Unreleased | +| [`sports_fetch`](#sports_fetch) | Scoreboard season fetch, lookback and live-odds decisions | Yes (scoreboards) | Unreleased | | [`sports_game_renderer`](#sports_game_renderer) | Scoreboard scroll/Vegas card geometry | Yes (scoreboards) | 3.3.0 | | [`sports_helpers`](#sports_helpers) | Small helpers every scoreboard `sports.py` copies | Yes (scoreboards) | 3.5.0 | | [`sports_scroll`](#sports_scroll) | Scoreboard scroll-display orchestration | Yes (scoreboards) | 3.2.0 | @@ -46,7 +49,7 @@ Rules for the package: | [`sync_manager`](#sync_manager) | Leader/follower sync between two displays | No, core-internal | n/a | | [`text_helper`](#text_helper) | Outlined text, wrapping, measurement | Yes | — | -The four `sports_*` mixin and card modules hold code the scoreboard plugins +The `sports_*` mixin and card modules hold code the scoreboard plugins used to carry as identical copies. Each module docstring lists what a host class must provide. The plan behind them is in [docs/SPORTS_UNIFICATION.md](../../docs/SPORTS_UNIFICATION.md). @@ -216,6 +219,33 @@ dates (`format_game_date()`, `format_game_time()`, `card_tzinfo()`) and font sizes (`schema_font_size()`, `resolve_font_size()`). A plugin keeps its own method and delegates the body. +### sports_card_wrappers + +[`sports_card_wrappers.py`](sports_card_wrappers.py). +`SportsCardWrappersMixin`: the one-line methods a scoreboard's game renderer +uses to call `sports_card` with its own `config` and `logger` +(`_vs_text()`, `_element_color()`, `_format_game_date()`, ... seventeen in +all), under their existing names. They are what `sports_game_renderer`'s +mixin expects its host to provide. No `__init__` and no state. + +### sports_celebration + +[`sports_celebration.py`](sports_celebration.py). `SportsCelebrationMixin` +draws the full-screen takeover a scoreboard shows when a team scores or wins +(`_draw_celebration_layout(celebration)`): a backdrop in the scoring team's +colours read off its crest, scenery, confetti, the headline and the score. +The colour helpers are free functions (`logo_palette()`, `lift_color()`, +`mix_color()`, ...). Deciding *when* to celebrate stays in the plugin, which +builds the celebration dict the docstring describes. + +### sports_fetch + +[`sports_fetch.py`](sports_fetch.py). `SportsFetchMixin`: the `SportsCore` +methods that decide which requests a scoreboard makes -- +`_fetch_season_directly()` (a season, in chunks ESPN accepts), +`_background_fetches_espn_ranges()`, `_needs_previous_day()` (the live +lookback) and `_wants_live_odds()` (odds only for games near the screen). + ### sports_game_renderer [`sports_game_renderer.py`](sports_game_renderer.py). diff --git a/src/common/sports_card_wrappers.py b/src/common/sports_card_wrappers.py new file mode 100644 index 00000000..a35f2058 --- /dev/null +++ b/src/common/sports_card_wrappers.py @@ -0,0 +1,136 @@ +"""The ``sports_card`` delegations every scoreboard's game renderer carries. + +After the card helpers moved to ``sports_card`` (3.3.0), each of the eight +scoreboards with a ``game_renderer.py`` -- afl, baseball, basketball, +football, hockey, lacrosse, nrl and soccer -- kept one-line methods that +forward to them with its own ``config`` and ``logger``. Seventeen are +identical in all eight (executable AST, docstrings stripped) or in all but +football, and were copied here from ledmatrix-plugins ``30455671`` +(origin/main, 2026-09-29) under their existing names. Football's own +``_format_game_date`` and ``_upcoming_center_mode`` (they follow the +switch-mode settings when it draws the full-screen scorebug) stay in football +and override these. + +``_schema_font_size`` and ``_resolve_font_size`` look the same in every copy +but are not moved: they read ``_SCHEMA_PATH``, a module global that is each +plugin's own ``config_schema.json``. + +These are the methods ``SportsGameRendererMixin`` (``sports_game_renderer``) +lists among what its host must provide, so a renderer that inherits both no +longer has to write them. Like that mixin this has no ``__init__`` and no +state. It is a separate module rather than more methods there for the reason +``sports_helpers`` gives: a missing module fails at load, where the version +checks see it; a missing method fails mid-render. + +WHAT A HOST MUST PROVIDE +------------------------ +Derived by walking every ``self.`` the mixin reads; the host-contract +test in ``test/test_sports_card_wrappers.py`` fails if a read is added +without being listed here. + +- ``config`` and ``logger``. +- ``fonts``, read with ``getattr`` -- ``_font_color``. +- ``_FONT_NAME_ALIASES`` and ``_FONT_PIXEL_GRID`` class attributes -- + ``_crisp_size``, which passes them to ``sports_card.crisp_size`` so a + renderer that declares extra faces keeps them. + +Add it as a base of the plugin's renderer, e.g. +``class GameRenderer(SportsCardWrappersMixin, SportsGameRendererMixin)``. +The two define no name in common; a method on the plugin's own class still +wins over either. +""" + +import logging +from typing import Any, ClassVar, Dict, Optional, Tuple + +from src.common import sports_card as _card + + +class SportsCardWrappersMixin: + """The game renderer's ``sports_card`` delegations. See module docstring.""" + + # The host contract, declared for type checking only: these create no + # attributes, so the host's own values are what the methods read. + config: Dict[str, Any] + logger: logging.Logger + _FONT_NAME_ALIASES: ClassVar[Dict[str, str]] + _FONT_PIXEL_GRID: ClassVar[Dict[str, Any]] + + # ---- fonts --------------------------------------------------------- + + @classmethod + def _crisp_size(cls, font_file, desired): + """``sports_card.crisp_size`` with this renderer's font tables.""" + return _card.crisp_size(font_file, desired, + cls._FONT_NAME_ALIASES, cls._FONT_PIXEL_GRID) + + def _unshare_element_fonts(self, fonts): + """``sports_card.unshare_element_fonts``.""" + return _card.unshare_element_fonts(self.logger, fonts) + + def _font_color(self, font, default: Tuple[int, int, int] = (255, 255, 255)): + """``sports_card.font_color`` for one of ``self.fonts``.""" + return _card.font_color(self.config, getattr(self, "fonts", None), font, default) + + # ---- colours and favourites --------------------------------------- + + @staticmethod + def _coerce_rgb(value, fallback): + """``sports_card.coerce_rgb``.""" + return _card.coerce_rgb(value, fallback) + + @staticmethod + def _side_is_favorite(game: Dict[str, Any], side: str, favorites: set) -> bool: + """``sports_card.side_is_favorite``.""" + return _card.side_is_favorite(game, side, favorites) + + @staticmethod + def _side_score(game: Dict[str, Any], side: str) -> Optional[int]: + """``sports_card.side_score``.""" + return _card.side_score(game, side) + + def _favorite_result(self, game: Dict[str, Any]) -> Optional[str]: + """``sports_card.favorite_result``.""" + return _card.favorite_result(self.config, game) + + def _score_color_for(self, game: Dict[str, Any], game_type: str, default=None): + """``sports_card.score_color_for``.""" + return _card.score_color_for(self.config, self.logger, game, game_type, default) + + def _recent_score_color(self, game: Dict[str, Any], default): + """``sports_card.recent_score_color``.""" + return _card.recent_score_color(self.config, self.logger, game, default) + + def _element_color(self, element: str, default: Tuple[int, int, int] = (255, 255, 255)): + """``sports_card.element_color``.""" + return _card.element_color(self.config, element, default) + + # ---- card options, dates and times -------------------------------- + + def _scroll_card_option(self, key: str, default: Any = None) -> Any: + """``sports_card.scroll_card_option``.""" + return _card.scroll_card_option(self.config, key, default) + + def _upcoming_center_mode(self) -> str: + """``sports_card.upcoming_center_mode``.""" + return _card.upcoming_center_mode(self.config) + + def _vs_text(self) -> str: + """``sports_card.vs_text``.""" + return _card.vs_text(self.config) + + def _format_game_date(self, date_text: str, game: Optional[Dict] = None) -> str: + """``sports_card.format_game_date``.""" + return _card.format_game_date(self.config, self.logger, date_text, game) + + def _weekday_for(self, game: Optional[Dict]) -> str: + """``sports_card.weekday_for``.""" + return _card.weekday_for(self.config, self.logger, game) + + def _card_tzinfo(self): + """``sports_card.card_tzinfo``.""" + return _card.card_tzinfo(self.config, self.logger) + + def _format_game_time(self, time_text: str) -> str: + """``sports_card.format_game_time``.""" + return _card.format_game_time(self.config, time_text) diff --git a/src/common/sports_celebration.py b/src/common/sports_celebration.py new file mode 100644 index 00000000..700c76ef --- /dev/null +++ b/src/common/sports_celebration.py @@ -0,0 +1,780 @@ +"""How the scoreboards draw a score or win celebration. + +Five scoreboards -- afl, football, hockey, nrl and soccer -- take over the +panel when a team scores or wins: a backdrop in the scoring team's colours +read off its crest, scenery for the kind of score, confetti, the headline and +the score with the scoring side's digits breathing. The drawing is identical +in all five ``sports.py`` copies (executable AST, docstrings stripped), and +so are the colour helpers it uses; they were copied here from +ledmatrix-plugins ``30455671`` (origin/main, 2026-09-29). + +Only the drawing moved. What *arms* a celebration stays in each plugin, +because it differs: which scores count (``_check_for_goal`` / +``_check_for_score``, and nrl matches favourites by team id), the phrase and +the scenery (``_start_celebration``), and when a win fires +(``_check_for_win``). So does ``display()``, which decides whether the +takeover or the scorebug is on screen. A plugin hands this mixin a +celebration dict and it draws it. + +The colour helpers are public free functions here (``logo_palette``, +``lift_color``, ``mix_color``, ...); in the plugins they were the same +functions with a leading underscore. + +THE CELEBRATION DICT +-------------------- +Built by the plugin's ``_start_celebration``. Read here: ``game`` (a +view-model dict; ``_id``, ``_abbr``, ``_logo_path`` and +``_logo_url`` for the crests, ``id`` for the confetti seed), +``scored_side`` (``"away"`` or ``"home"``), ``away_score``, ``home_score``, +``phrase``, ``started_at`` (a ``time.time()`` value) and ``motif`` +(``"score"``, ``"kick"``, ``"touchdown"``, ``"net"`` or ``"win"``; anything +else draws the ``"score"`` diagonals). The drawing caches what it derives in +the same dict, under ``_palette``, ``_backdrop``, ``_confetti`` and +``_crests``, so each is worked out once per celebration. + +WHAT A HOST MUST PROVIDE +------------------------ +Derived by walking every ``self.`` the mixin reads; the host-contract +test in ``test/test_sports_celebration.py`` fails if a read is added without +being listed here. All five scoreboards' ``SportsLive`` provide them. + +- ``display_manager`` -- ``image`` is replaced with the frame, then + ``update_display()``; ``clear()`` on ``force_clear``. Its ``matrix`` + width and height are used when it has a matrix, else ``display_width`` / + ``display_height``. +- ``fonts`` -- ``"time"`` and ``"status"`` for the headline (the first that + fits), ``"score"`` for the score. +- ``logger``. +- ``_load_and_resize_logo(team_id, abbr, logo_path, logo_url)`` -- a crest + as an RGBA image, or ``None``. +- ``_draw_text_with_outline(draw, text, position, font, fill=...)`` -- on + ``SportsCoreSharedMixin``. +- ``celebration_duration``, ``celebration_team_colors`` and + ``celebration_confetti``, read with ``getattr`` (defaults 8, on, on). + +Mix it in ahead of the mode classes, e.g. +``class SportsLive(SportsCelebrationMixin, SportsLiveSharedMixin, +SportsCore)``. It defines nothing any of them define, so the order only +matters for a plugin that keeps its own copy of one of these methods: a +method on the plugin's class always wins over the mixin's. +""" + +import colorsys +import logging +import math +import random +import time +from typing import Any, Callable, ClassVar, Dict, List, Optional, Sequence, Tuple + +from PIL import Image, ImageDraw + +#: A colour as the helpers return it: three 0-255 channels. +Color = Tuple[int, ...] +#: ``deep``, ``glow``, ``headline`` and ``accent``; see ``logo_palette``. +Palette = Dict[str, Color] +#: One confetti flake: column, start height, fall speed, sway phase, size +#: in pixels, colour. +Flake = Tuple[float, float, float, float, int, Color] + +# ---------------------------------------------------------------------- +# Colour helpers for the score/win celebration +# +# Module level rather than methods: they are pure, which is what makes the +# palette testable without standing up a live manager, and they are shared by +# the takeover's backdrop, confetti and text. +# ---------------------------------------------------------------------- + +#: The crest is sampled at this resolution. Big enough that a secondary +#: colour survives (a helmet stripe, a trim), small enough that the whole +#: sample is ~1600 pixels of pure-Python work, once per team. +_PALETTE_SAMPLE_PX = 40 +#: Above this, a colour carries team identity; below it, it is a grey. +_PALETTE_VIVID_SATURATION = 0.22 +#: Ignore pixels this dark -- crest outlines, drop shadows, anti-aliasing. +_PALETTE_MIN_CHANNEL = 24 +#: How far apart two bins must be to count as a second, different colour. +_PALETTE_DISTINCT_DISTANCE = 90.0 +#: Never bleed a lifted colour below this saturation; past it a hue stops +#: being the team's colour and starts being a pastel. +_PALETTE_MIN_SATURATION = 0.42 +#: Lift a headline colour until it is at least this luminous. Chosen so +#: midnight navy reaches a blue that reads at 6px on a panel without +#: becoming a different colour. +_PALETTE_HEADLINE_LUMINANCE = 112.0 +#: A crest colour this luminous already reads on a panel, so it is preferred +#: over a darker one that would have to be lifted to get there. Lifting is a +#: compromise -- Green Bay's dark green only reaches legibility as a teal -- +#: and most teams whose primary is dark carry a bright second colour that is +#: just as much theirs. This is what picks the Packers' gold over that teal. +_PALETTE_LEGIBLE_LUMINANCE = 90.0 +#: ...but only from a colour the crest actually means. The pixels where a +#: bright edge is anti-aliased into a dark fill are luminous too, and there is +#: always a band of them: Kansas City's white-on-red outline leaves a pink at +#: luminance 90 that would otherwise be preferred over the red itself. A blend +#: is a mix, so it is markedly less saturated than either colour it sits +#: between -- that pink is 0.48 where the red is 0.96 and the Packers' gold, +#: which this must keep, is 0.89. +_PALETTE_LEGIBLE_SATURATION = 0.65 +#: And it has to be a band of the crest, not a speck of one. +_PALETTE_LEGIBLE_AREA = 0.02 +#: Cap the backdrop's luminance so the headline stays legible over it, +#: and the scenery's so it stays behind the headline. Both are luminance and +#: not HSV value on purpose: a silver crest -- the Raiders, or the grey +#: placeholder a failed logo download leaves behind -- has a value of ~0.95, +#: and capping that at 0.34 still yields a light grey card that white text +#: then vanishes into. Scaling the channels down is also hue-exact, which is +#: what lets this be the plain arithmetic that lifting a colour cannot be. +_PALETTE_BACKDROP_LUMINANCE = 34.0 +_PALETTE_SCENERY_LUMINANCE = 70.0 + + +def rgb_luminance(color: Sequence[float]) -> float: + """Rec. 709 relative luminance, 0-255.""" + return 0.2126 * color[0] + 0.7152 * color[1] + 0.0722 * color[2] + + +def rgb_saturation(color: Sequence[float]) -> float: + """HSV saturation, 0-1.""" + high = max(color) + return (high - min(color)) / high if high else 0.0 + + +def color_distance(a: Sequence[float], b: Sequence[float]) -> float: + """Euclidean distance between two colours in RGB.""" + return math.sqrt(sum((x - y) ** 2 for x, y in zip(a, b))) + + +def mix_color(a: Sequence[float], b: Sequence[float], t: float) -> Color: + """Blend ``a`` towards ``b``; t=0 is all a, t=1 is all b.""" + t = min(max(t, 0.0), 1.0) + return tuple(int(round(a[i] + (b[i] - a[i]) * t)) for i in range(3)) + + +def scale_color(color: Sequence[float], factor: float) -> Color: + """Scale a colour's brightness, clamped to the panel's range.""" + return tuple(min(255, max(0, int(round(c * factor)))) for c in color) + + +def lift_color(color: Sequence[float], min_luminance: float = _PALETTE_HEADLINE_LUMINANCE, + cap_saturation: float = 0.92) -> Color: + """Raise a colour's brightness until it reads on a panel, keeping its hue. + + Scaling the channels directly is what the obvious version of this does, + and it shifts hue badly on exactly the colours that need lifting: it turns + Baltimore's navy-purple into magenta. Working in HSV and raising only the + value leaves the hue where the team put it. + """ + if rgb_luminance(color) >= min_luminance: + return tuple(int(c) for c in color) + hue, saturation, value = colorsys.rgb_to_hsv(*[c / 255.0 for c in color]) + if saturation < 0.12: + # A grey or a silver has no hue to preserve; just make it bright. + lifted = colorsys.hsv_to_rgb(hue, saturation, max(value, 0.85)) + return tuple(int(round(c * 255)) for c in lifted) + saturation = min(saturation, cap_saturation) + + def _rgb(s: float, v: float) -> Color: + return tuple(int(round(c * 255)) for c in colorsys.hsv_to_rgb(hue, s, v)) + + out = _rgb(saturation, value) + while value < 1.0 and rgb_luminance(out) < min_luminance: + value = min(1.0, value + 0.05) + out = _rgb(saturation, value) + # Blue carries almost no luminance -- pure blue sits at 18 of 255 -- so a + # navy or a deep purple runs out of value long before it is legible. + # Bleeding saturation out of it is the only way up, and it keeps the hue + # (Baltimore stays purple, just a lighter one) where giving up would + # leave the headline unreadable. Floored so it never washes out to white. + while saturation > _PALETTE_MIN_SATURATION and rgb_luminance(out) < min_luminance: + saturation = max(_PALETTE_MIN_SATURATION, saturation - 0.05) + out = _rgb(saturation, value) + return out + + +def cap_luminance(color: Sequence[float], max_luminance: float) -> Color: + """Darken a colour until it is no brighter than ``max_luminance``. + + A straight channel scale, which is exactly hue-preserving on the way down + -- unlike lifting, where clamping at 255 is what bends the hue. + """ + luminance = rgb_luminance(color) + if luminance <= max_luminance or luminance <= 0: + return tuple(int(c) for c in color) + return scale_color(color, max_luminance / luminance) + + +def dim_rgba(image: Image.Image, factor: float) -> Image.Image: + """Scale an RGBA image's colour channels, leaving its alpha alone. + + ImageEnhance.Brightness would scale the alpha band too, which fades the + crest out instead of dimming it and leaves its anti-aliased edge looking + chewed against the backdrop. + """ + red, green, blue, alpha = image.split() + lut = [min(255, int(i * factor)) for i in range(256)] + return Image.merge( + "RGBA", (red.point(lut), green.point(lut), blue.point(lut), alpha) + ) + + +_Buckets = Dict[Tuple[int, int, int], List[int]] + + +def _palette_buckets(logo: Image.Image) -> Tuple[_Buckets, _Buckets]: + """Bucket a crest's opaque pixels into coarse colour bins. + + Returns ``(vivid, neutral)``; each maps a 3-bit-per-channel key to + ``[r_sum, g_sum, b_sum, count]``. Neutral holds the greys, silvers and + whites that carry no identity on their own but are all a monochrome crest + -- the Raiders' silver on black -- has to offer. + """ + sample = logo.convert("RGBA") + sample.thumbnail((_PALETTE_SAMPLE_PX, _PALETTE_SAMPLE_PX), Image.Resampling.BOX) + vivid: _Buckets = {} + neutral: _Buckets = {} + # tobytes() rather than getdata(): same pixels, no per-pixel Python + # object, and getdata() is deprecated from Pillow 14. + raw = sample.tobytes() + for i in range(0, len(raw) - 3, 4): + red, green, blue, alpha = raw[i], raw[i + 1], raw[i + 2], raw[i + 3] + if alpha < 160: + continue + high, low = max(red, green, blue), min(red, green, blue) + if high < _PALETTE_MIN_CHANNEL: + continue + target = vivid if (high - low) / high >= _PALETTE_VIVID_SATURATION else neutral + acc = target.setdefault((red >> 5, green >> 5, blue >> 5), [0, 0, 0, 0]) + acc[0] += red + acc[1] += green + acc[2] += blue + acc[3] += 1 + return vivid, neutral + + +def _bucket_mean(acc: List[int]) -> Color: + count = acc[3] + return (acc[0] // count, acc[1] // count, acc[2] // count) + + +def _bucket_headline_score(acc: List[int]) -> float: + """How well a colour bin would serve as 6px of text on a panel. + + Area alone picks the biggest block of colour, which on a lot of crests is + a dark navy fill -- correct as a backdrop, invisible as text. Weighting + area by saturation and by luminance picks the colour the team is loud in: + Chicago's orange over its navy, Baltimore's gold over its purple. + """ + color = _bucket_mean(acc) + return ( + acc[3] + * (0.30 + 0.70 * rgb_saturation(color)) + * (0.20 + 0.80 * min(1.0, rgb_luminance(color) / 120.0)) + ) + + +def logo_palette(logo: Image.Image) -> Optional[Palette]: + """Pick a celebration palette out of a team crest, or None. + + Two rankings, because a crest's largest colour and its most legible one + are usually not the same and the takeover needs both: + + * ``deep`` -- the largest vivid area, darkened into the background wash. + This is what the team reads as at a glance: Chicago navy, Dallas navy, + Baltimore purple. + * ``headline`` -- the vivid area that best survives being shrunk to text, + then lifted until it is legible: Chicago orange, Baltimore gold. + * ``accent`` -- the next vivid colour far enough away from the headline to + be told apart, for confetti. Falls back to the headline. + + A crest with no vivid pixels at all falls back to its brightest neutral, + which for the Raiders' silver-on-black is exactly the right answer. + """ + try: + vivid, neutral = _palette_buckets(logo) + except Exception: # noqa: BLE001 - a crest is never worth the takeover + return None + + pool = list(vivid.values()) + if not pool and neutral: + pool = [ + max( + neutral.values(), + key=lambda acc: acc[3] + * (0.2 + 0.8 * min(1.0, rgb_luminance(_bucket_mean(acc)) / 160.0)), + ) + ] + if not pool: + return None + + deep_base = _bucket_mean(max(pool, key=lambda acc: acc[3])) + ranked = sorted(pool, key=_bucket_headline_score, reverse=True) + headline_base = _bucket_mean(ranked[0]) + vivid_pixels = sum(acc[3] for acc in pool) + for acc in ranked: + candidate = _bucket_mean(acc) + if ( + rgb_luminance(candidate) >= _PALETTE_LEGIBLE_LUMINANCE + and rgb_saturation(candidate) >= _PALETTE_LEGIBLE_SATURATION + and acc[3] >= max(3, vivid_pixels * _PALETTE_LEGIBLE_AREA) + ): + headline_base = candidate + break + headline = lift_color(headline_base) + + accent = headline + for acc in ranked[1:]: + candidate = _bucket_mean(acc) + if color_distance(candidate, headline_base) > _PALETTE_DISTINCT_DISTANCE: + accent = lift_color(candidate) + break + + deep = cap_luminance(deep_base, _PALETTE_BACKDROP_LUMINANCE) + return { + "deep": deep, + # Scenery is the backdrop carried a little way towards the headline: + # tied to the team's colours, and guaranteed to be visible even when + # the backdrop is nearly black. + "glow": cap_luminance( + mix_color(deep, headline, 0.22), _PALETTE_SCENERY_LUMINANCE + ), + "headline": headline, + "accent": accent, + } + + +class SportsCelebrationMixin: + """Draws a score/win celebration takeover. See the module docstring.""" + + # The host contract, declared for type checking only: these create no + # attributes, so the host's own values are what the methods read. + display_manager: Any + display_width: int + display_height: int + fonts: Dict[str, Any] + logger: logging.Logger + _load_and_resize_logo: Callable[..., Optional[Image.Image]] + _draw_text_with_outline: Callable[..., None] + + def _fit_font(self, draw, text: str, max_width: int, fonts: list): + """Return the first font whose rendered ``text`` fits ``max_width``, + falling back to the last (smallest) font.""" + for font in fonts: + if draw.textlength(text, font=font) <= max_width - 2: + return font + return fonts[-1] + + # ------------------------------------------------------------------ + # Celebration palette + # + # The takeover is drawn in the scoring team's own colours, taken from the + # pixels of its crest. + # + # ESPN does serve team.color / team.alternateColor, but only inside + # _extract_game_details_common -- a function each scoreboard lineage + # keeps its own copy of -- so reading it there would drag every one of + # them into a celebration change. The crest is already downloaded, + # decoded and sitting in the logo cache by the time a celebration draws, + # so the colours come from it instead: no extra request, no per-league + # colour table to maintain, and it works for any team ESPN can name -- + # including the FCS opponents no table would list. + # + # Where a crest's colour differs from the club's published one it + # tends to differ usefully: a published primary is often a near-black + # navy, or an actual #000000, where what the crest carries is the colour + # that reads on an LED panel. Measured across all 32 clubs in + # football-scoreboard. + # ------------------------------------------------------------------ + + #: Used when the crest yields nothing (no logo on disk yet, or the grey + #: placeholder a failed download leaves) or team colours are switched + #: off -- the navy and amber the celebration wore before it had a palette. + _DEFAULT_CELEBRATION_PALETTE: ClassVar[Palette] = { + "deep": (10, 10, 40), + "glow": (30, 30, 86), + "headline": (255, 208, 56), + "accent": (255, 255, 255), + } + + def _celebration_palette(self, celebration: Dict) -> Palette: + """The scoring team's colours, derived once per celebration.""" + cached: Optional[Palette] = celebration.get("_palette") + if cached is not None: + return cached + + palette = dict(self._DEFAULT_CELEBRATION_PALETTE) + if getattr(self, "celebration_team_colors", True): + try: + game = celebration["game"] + side = celebration.get("scored_side") or "home" + logo = self._load_and_resize_logo( + game.get("%s_id" % side), + game.get("%s_abbr" % side), + game.get("%s_logo_path" % side), + game.get("%s_logo_url" % side), + ) + derived = logo_palette(logo) if logo is not None else None + if derived: + palette = derived + except Exception as e: # noqa: BLE001 - never lose a takeover to a crest + self.logger.debug(f"Celebration palette fell back to the default: {e}") + + celebration["_palette"] = palette + return palette + + # ------------------------------------------------------------------ + # Celebration choreography + # + # Every frame is a finished card. The beats below shift the emphasis -- + # an opening colour hit, confetti, a breathing score -- but none of them + # leaves the panel mid-wipe, because on a switch-mode board the core + # drives this plugin at 1 FPS (display_controller reserves its high-FPS + # loop for plugins that scroll or declare needs_high_fps), so any single + # frame may be the only one a viewer ever sees of it. + # ------------------------------------------------------------------ + + #: Fraction of the celebration spent on the opening colour hit. + _CELEBRATION_IMPACT: ClassVar[float] = 0.11 + #: Fraction of it after which the takeover eases back down. + _CELEBRATION_SETTLE: ClassVar[float] = 0.80 + #: Seconds per breath of the scoring side's digits. Deliberately a + #: continuous sine rather than an on/off toggle: the 4 Hz flash this + #: replaced was sampled once a second on a switch-mode board, which + #: aliases into a colour that changes at random. A ramp degrades into a + #: slow glow instead, and still reads as a pulse at 125 FPS. + _CELEBRATION_BREATH_SECONDS: ClassVar[float] = 1.7 + + def _celebration_backdrop( + self, + celebration: Dict, + width: int, + height: int, + palette: Palette, + ) -> Image.Image: + """The static half of the takeover: a team-colour gradient with the + scenery for this kind of score painted into it. + + Built once per celebration per panel size and copied per frame, so the + per-pixel work never lands on the render path. + """ + cached: Optional[Tuple[Tuple[int, int], Image.Image]] = celebration.get("_backdrop") + if cached is not None and cached[0] == (width, height): + return cached[1] + + # One column, then stretched: filling the panel pixel by pixel would + # be `width` times the work for the same image. + column = Image.new("RGB", (1, max(height, 1))) + pixels: Any = column.load() + for y in range(height): + k = y / max(height - 1, 1) + pixels[0, y] = mix_color(palette["deep"], (0, 0, 0), 0.18 + 0.82 * k) + backdrop = column.resize((width, height)).convert("RGBA") + + try: + self._draw_celebration_motif( + ImageDraw.Draw(backdrop), + celebration.get("motif") or "score", + width, + height, + palette, + ) + except Exception as e: # noqa: BLE001 - scenery is never worth a blank panel + self.logger.debug(f"Celebration motif skipped: {e}") + + celebration["_backdrop"] = ((width, height), backdrop) + return backdrop + + def _draw_celebration_motif( + self, + draw, + motif: str, + width: int, + height: int, + palette: Palette, + ) -> None: + """Paint the scenery for one kind of score, dim enough to stay behind + the headline and the score instead of competing with them.""" + glow = palette["glow"] + if motif == "kick": + # The uprights a field goal or an extra point went through, + # spread wide enough to frame the score rather than sit beside it. + half = max(8, min(width // 3, height)) + mid = width // 2 + crossbar = int(height * 0.60) + draw.line([(mid - half, int(height * 0.08)), (mid - half, crossbar)], fill=glow) + draw.line([(mid + half, int(height * 0.08)), (mid + half, crossbar)], fill=glow) + draw.line([(mid - half, crossbar), (mid + half, crossbar)], fill=glow) + draw.line([(mid, crossbar), (mid, height - 1)], fill=glow) + elif motif == "touchdown": + # The goal line, with its hash marks. + line_y = int(height * 0.36) + draw.line([(0, line_y), (width, line_y)], fill=glow) + for x in range(3, width, 9): + draw.line([(x, line_y - 2), (x, line_y + 2)], fill=glow) + elif motif == "net": + # The goal a puck just went into: frame, posts and mesh, sized to + # frame the score the way the uprights do. + half = max(7, min(width // 4, height)) + mid = width // 2 + top = int(height * 0.34) + draw.rectangle([(mid - half, top), (mid + half, height - 1)], outline=glow) + step = max(3, (half * 2) // 6) + for x in range(mid - half + step, mid + half, step): + draw.line([(x, top + 1), (x, height - 2)], fill=glow) + for y in range(top + step, height - 1, step): + draw.line([(mid - half + 1, y), (mid + half - 1, y)], fill=glow) + elif motif == "win": + # A sunburst behind the winner. + cx, cy = width // 2, height // 2 + reach = max(width, height) + for i in range(10): + angle = (math.pi * 2 * i / 10) + math.pi / 20 + draw.line( + [ + (cx, cy), + (cx + math.cos(angle) * reach, cy + math.sin(angle) * reach), + ], + fill=glow, + ) + else: + for x in range(-height, width + height, 11): + draw.line([(x, height), (x + height, 0)], fill=glow) + + def _celebration_confetti( + self, + celebration: Dict, + width: int, + height: int, + palette: Palette, + ) -> List[Flake]: + """Seed the confetti once per celebration. + + Seeded from the game rather than the clock, so the same score always + produces the same fall -- which is what lets a golden screen lock the + effect down instead of having to tolerate it. + """ + cached: Optional[Tuple[Tuple[int, int], List[Flake]]] = celebration.get("_confetti") + if cached is not None and cached[0] == (width, height): + return cached[1] + + # Sparse on purpose. At one flake per 170 square pixels a 128x32 + # panel carried 24 single-pixel specks over the headline and the + # score, which reads as a dead-pixel problem rather than as confetti. + count = max(6, min(22, (width * height) // 260)) + seed = "%s/%s" % ( + (celebration.get("game") or {}).get("id", "?"), + celebration.get("phrase", ""), + ) + rng = random.Random(seed) # nosec B311 - confetti, not security + # Team colours, plus a pale tint of the headline rather than a flat + # white, so the fall still belongs to the team that scored. + colors = [ + palette["headline"], + palette["accent"], + mix_color(palette["headline"], (255, 255, 255), 0.55), + ] + flakes = [ + ( + float(rng.randrange(max(width, 1))), # column + rng.uniform(0.0, float(height)), # start height + rng.uniform(0.40, 1.15), # fall speed + rng.uniform(0.0, math.pi * 2), # sway phase + 2 if rng.random() < 0.6 else 1, # size in pixels + colors[rng.randrange(len(colors))], + ) + for _ in range(count) + ] + celebration["_confetti"] = ((width, height), flakes) + return flakes + + def _draw_celebration_confetti( + self, + draw, + celebration: Dict, + width: int, + height: int, + palette: Palette, + elapsed: float, + progress: float, + ) -> None: + """Draw the confetti for this instant, thinning it out as the + celebration eases back towards the scorebug.""" + flakes = self._celebration_confetti(celebration, width, height, palette) + fade = 1.0 + if progress > self._CELEBRATION_SETTLE: + fade = max( + 0.0, + 1.0 + - (progress - self._CELEBRATION_SETTLE) + / (1.0 - self._CELEBRATION_SETTLE), + ) + if fade <= 0.02: + return + alpha = int(235 * fade) + for column, start, speed, phase, size, color in flakes: + y = (start + speed * elapsed * height * 0.42) % (height + 4) - 2 + x = column + math.sin(elapsed * 2.1 + phase) * 2.4 + draw.rectangle( + [(int(x), int(y)), (int(x) + size - 1, int(y) + size - 1)], + fill=tuple(color) + (alpha,), + ) + + def _celebration_crests( + self, celebration: Dict, height: int + ) -> Dict[str, Optional[Image.Image]]: + """The two crests for the takeover, with the side that did not score + dimmed so the scoring team reads at a glance.""" + cached: Optional[Tuple[int, Dict[str, Optional[Image.Image]]]] = celebration.get("_crests") + if cached is not None and cached[0] == height: + return cached[1] + + game = celebration["game"] + scored = celebration.get("scored_side") + crests: Dict[str, Optional[Image.Image]] = {} + for side in ("away", "home"): + logo = None + try: + logo = self._load_and_resize_logo( + game.get("%s_id" % side), + game.get("%s_abbr" % side), + game.get("%s_logo_path" % side), + game.get("%s_logo_url" % side), + ) + except Exception as e: # noqa: BLE001 - a crest is never worth the panel + self.logger.debug(f"Celebration logo load failed: {e}") + if logo is not None and side != scored: + logo = dim_rgba(logo, 0.40) + crests[side] = logo + + celebration["_crests"] = (height, crests) + return crests + + def _draw_celebration_layout(self, celebration: Dict, force_clear: bool = False) -> None: + """Render the full-screen goal/win takeover.""" + if force_clear: + self.display_manager.clear() + + display_width = ( + self.display_manager.matrix.width + if hasattr(self.display_manager, "matrix") and self.display_manager.matrix + else self.display_width + ) + display_height = ( + self.display_manager.matrix.height + if hasattr(self.display_manager, "matrix") and self.display_manager.matrix + else self.display_height + ) + + elapsed = max(0.0, time.time() - celebration["started_at"]) + # getattr throughout the render path: the golden-screen tests build a + # live manager through __new__ and set only what they draw with, and a + # celebration must never be lost to a missing knob. + duration = max(float(getattr(self, "celebration_duration", 8) or 8), 0.5) + progress = min(elapsed / duration, 1.0) + palette = self._celebration_palette(celebration) + + main_img = self._celebration_backdrop( + celebration, display_width, display_height, palette + ).copy() + + # Crests at the edges, bleeding off as the scorebug's do. + crests = self._celebration_crests(celebration, display_height) + center_y = display_height // 2 + home_logo, away_logo = crests.get("home"), crests.get("away") + if home_logo is not None: + main_img.paste( + home_logo, + (display_width - home_logo.width + 2, center_y - home_logo.height // 2), + home_logo, + ) + if away_logo is not None: + main_img.paste(away_logo, (-2, center_y - away_logo.height // 2), away_logo) + + # The opening hit: the team's headline colour washes the panel and + # decays out of it. Held below opaque so the outlined text drawn on + # top still reads in whichever frame happens to catch it. + impact = max(0.0, 1.0 - progress / self._CELEBRATION_IMPACT) + if impact > 0.0: + # Scaled by how colourful the team is. A saturated crest gets the + # full hit; a silver one -- the Raiders, or the grey placeholder a + # failed logo download leaves -- would otherwise wash the whole + # panel out to the same flat grey as its own headline colour. + punch = 0.45 + 0.55 * rgb_saturation(palette["headline"]) + alpha = int(140 * punch * (impact ** 1.5)) + if alpha > 0: + main_img = Image.alpha_composite( + main_img, + Image.new( + "RGBA", + (display_width, display_height), + tuple(palette["headline"]) + (alpha,), + ), + ) + + overlay = Image.new("RGBA", (display_width, display_height), (0, 0, 0, 0)) + draw = ImageDraw.Draw(overlay) + + if getattr(self, "celebration_confetti", True): + try: + self._draw_celebration_confetti( + draw, + celebration, + display_width, + display_height, + palette, + elapsed, + progress, + ) + except Exception as e: # noqa: BLE001 + self.logger.debug(f"Celebration confetti skipped: {e}") + + # Headline across the top, shrunk to fit the panel width, struck + # white on the opening hit and settling into the team's colour. + phrase = celebration["phrase"] + phrase_font = self._fit_font( + draw, phrase, display_width, [self.fonts["time"], self.fonts["status"]] + ) + phrase_width = draw.textlength(phrase, font=phrase_font) + # Eased in by colour rather than by position. Sliding it down into + # place put the first frame at y=-3 with its top row cut off, and on a + # 1 FPS board that clipped frame can be the only one anyone sees. + self._draw_text_with_outline( + draw, + phrase, + ((display_width - phrase_width) // 2, 1), + phrase_font, + fill=mix_color(palette["headline"], (255, 255, 255), impact), + ) + + # Score centred low, the scoring side's digits breathing in the team's + # headline colour so the change reads at a glance. + away_text = str(celebration["away_score"]) + home_text = str(celebration["home_score"]) + score_font = self.fonts["score"] + segments = [ + (away_text, celebration["scored_side"] == "away"), + ("-", False), + (home_text, celebration["scored_side"] == "home"), + ] + total_width = sum(draw.textlength(seg, font=score_font) for seg, _ in segments) + breath = 0.72 + 0.28 * ( + 0.5 + + 0.5 * math.sin(2 * math.pi * elapsed / self._CELEBRATION_BREATH_SECONDS) + ) + highlight = scale_color(palette["headline"], breath) + x = (display_width - total_width) // 2 + # display_height - 14 was sized for the old fixed 8px score. #338 + # scales the score with the panel (16px at 48 and 64 tall), which put + # the bottom of the digits off the panel. Lift it by the measured ink + # (+1 for the outline stroke) only when it would clip, so panels where + # it always fitted render exactly as before. + score_text = "".join(seg for seg, _ in segments) + ink_bottom = draw.textbbox((0, 0), score_text, font=score_font)[3] + y = min(display_height - 14, display_height - ink_bottom - 2) + for seg, is_highlight in segments: + color = highlight if is_highlight else (216, 216, 216) + self._draw_text_with_outline(draw, seg, (int(x), y), score_font, fill=color) + x += draw.textlength(seg, font=score_font) + + main_img = Image.alpha_composite(main_img, overlay).convert("RGB") + self.display_manager.image = main_img + self.display_manager.update_display() diff --git a/src/common/sports_fetch.py b/src/common/sports_fetch.py new file mode 100644 index 00000000..fa73d720 --- /dev/null +++ b/src/common/sports_fetch.py @@ -0,0 +1,188 @@ +"""Which requests a scoreboard makes: season fetches, the lookback, live odds. + +Four ``SportsCore`` methods are identical (executable AST, docstrings +stripped) in all nine scoreboards' ``sports.py`` -- afl, baseball, +basketball, football, hockey, lacrosse, nrl, soccer and ufc -- and were +copied here from ledmatrix-plugins ``30455671`` (origin/main, 2026-09-29) +under their existing names: + +- ``_background_fetches_espn_ranges`` -- whether the core's background + service can fetch an ESPN date range, or the plugin must; +- ``_fetch_season_directly`` -- fetch and cache a season in chunks ESPN + accepts, on the calling thread; +- ``_needs_previous_day`` (with ``_LOOKBACK_CUTOFF_HOUR``) -- whether the + live fetch still has to ask for yesterday; +- ``_wants_live_odds`` (with ``_LIVE_ODDS_LOOKAHEAD``) -- whether a live + game is close enough to the screen to be worth an odds request. + +Three other ``SportsCore`` methods are as identical and stay in the plugins, +for the reasons ``sports_shared`` gives: ``_get_timezone`` binds each +plugin's own ``resolve_timezone`` shim, and ``_extract_game_details`` / +``_fetch_data`` are the abstract sport-specific contract. So does +``SportsUpcoming.__init__``: the mixins in ``src/common`` hold no +constructor, so the plugins' constructor signature stays theirs. + +A new module rather than more methods on ``sports_shared``, for the reason +``sports_helpers`` gives: a missing module fails at load, where the version +checks see it; a missing method fails mid-update. + +WHAT A HOST MUST PROVIDE +------------------------ +Derived by walking every ``self.`` the mixin reads; the host-contract +test in ``test/test_sports_fetch.py`` fails if a read is added without being +listed here. + +- ``session``, ``headers``, ``cache_manager`` and ``logger`` -- + ``_fetch_season_directly``. +- ``_games_lock`` -- ``_wants_live_odds``, which also reads ``live_games``, + ``current_game_index`` and ``_rotation_schedule`` with ``getattr`` + (only ``SportsLive`` has them). +- ``live_games``, read with ``getattr`` -- ``_needs_previous_day``. +- ``background_service``, read with ``getattr`` -- + ``_background_fetches_espn_ranges``. + +Add it as a base of the plugin's ``SportsCore``, e.g. +``class SportsCore(SportsFetchMixin, SportsCoreSharedMixin, +SportsHelpersMixin, ABC)``. It defines nothing those define; a method or +constant on the plugin's own class still wins over the mixin's. +""" + +import logging +import threading +from datetime import datetime, timedelta +from typing import Any, ClassVar, Dict, Optional + +from src.common.espn_dates import ESPN_MAX_LIMIT, fetch_espn_scoreboard + + +class SportsFetchMixin: + """Season fetch, lookback and live-odds decisions. See module docstring.""" + + # The host contract, declared for type checking only: these create no + # attributes, so the host's own values are what the methods read. + session: Any + headers: Dict[str, str] + cache_manager: Any + logger: logging.Logger + _games_lock: threading.RLock + + #: How many games past the one on screen keep their odds warm. One is + #: enough for the line to be ready when the rotation advances; more just + #: re-creates the whole-slate fetch this replaced. + _LIVE_ODDS_LOOKAHEAD: ClassVar[int] = 1 + + def _wants_live_odds(self, game: Dict) -> bool: + """Whether a live game is near enough the front of the rotation to be + worth an odds request. + + Odds used to be fetched for *every* live game in the league on every + update. The renderer only ever draws ``current_game``, and a full + rotation of a big slate takes minutes while ``live_odds_update_interval`` + is 60s -- so all but one of those requests expired before the game they + belonged to came round. + + Measured 2026-09-19 over a full college-football slate: 11,978 odds + requests in 13h on one rig, 54% of all its ESPN traffic, across only + ~140 distinct games. The eager loop also cost up to 2s of ``update()`` + per live game, because ``_fetch_odds`` waits on its worker thread. + + Mirrors the narrowing already applied to the upcoming path and to + ``_attach_odds_to_rotated_games``: only games about to be on screen are + asked about. ``get_odds`` still caches per game, so a game re-entering + the window inside its TTL costs a cache lookup, not a request. + + The rotation state read here is the previous cycle's -- the new list is + still being built -- which is exactly the question being asked: is this + game at or near the position currently on the panel? + """ + # Read defensively: this predicate lives on SportsCore so it sits + # beside _fetch_odds, but live_games/_rotation_schedule belong to + # SportsLive, which is the only caller. + with self._games_lock: + games = list(getattr(self, "live_games", ()) or ()) + index = getattr(self, "current_game_index", 0) + schedule = list(getattr(self, "_rotation_schedule", ()) or ()) + if not games: + # Cold start: nothing is on screen yet, so let the games seen on + # this first pass through rather than render a blank line for a + # whole cycle. Bounded -- the next pass has a rotation to narrow by. + return True + order = schedule or [g.get("id") for g in games] + if not order: + return True + start = index if 0 <= index < len(order) else 0 + wanted = { + order[(start + offset) % len(order)] + for offset in range(self._LIVE_ODDS_LOOKAHEAD + 1) + } + return game.get("id") in wanted + + #: Hour of the Eastern day past which last night's games are assumed over. + #: + #: The live fetch asks ESPN for a two-day window so a game that started + #: yesterday and is still running is not lost. ESPN rejects date *ranges*, + #: so that window is split into one request per day -- doubling every live + #: poll. Measured 2026-09-19: 1,858 requests per rig spent on yesterday's + #: date, which after breakfast holds nothing but final games. + #: + #: No sport on these boards runs six hours past midnight, and one that + #: somehow did is still covered: a game already being tracked keeps its own + #: day in the window regardless of the hour. + _LOOKBACK_CUTOFF_HOUR: ClassVar[int] = 6 + + def _needs_previous_day(self, now: datetime) -> bool: + """Whether the previous Eastern day can still hold a live game.""" + if now.hour < self._LOOKBACK_CUTOFF_HOUR: + return True + previous = (now - timedelta(days=1)).strftime("%Y%m%d") + for game in (getattr(self, "live_games", None) or []): + start: Any = game.get("start_time_utc") if hasattr(game, "get") else None + try: + if start.astimezone(now.tzinfo).strftime("%Y%m%d") == previous: + return True + except (AttributeError, ValueError, OSError, OverflowError): + continue + return False + + def _background_fetches_espn_ranges(self) -> bool: + """Can the core's background service fetch an ESPN date range? + + Cores from before the 2026-09-15 fix send a season range to ESPN as-is, + which now answers 400 for every sport. On those cores the managers fetch + the season themselves with _fetch_season_directly instead. + """ + service = getattr(self, "background_service", None) + return bool(getattr(service, "handles_espn_date_ranges", False)) + + def _fetch_season_directly( + self, + url: str, + datestring: str, + cache_key: str, + label: str, + ttl: Optional[int] = None, + ) -> Optional[Dict]: + """Fetch a season schedule on this thread, in chunks ESPN accepts, and cache it. + + ``label`` names the schedule in log lines, e.g. ``"2026 season"``. + """ + try: + data = fetch_espn_scoreboard( + self.session, + url, + params={"dates": datestring, "limit": ESPN_MAX_LIMIT}, + headers=self.headers, + timeout=30, + logger=self.logger, + ) + except Exception as e: + self.logger.error(f"Failed to fetch {label} schedule: {e}") + return None + if ttl is None: + self.cache_manager.set(cache_key, data) + else: + self.cache_manager.set(cache_key, data, ttl=ttl) + self.logger.info( + f"Fetched {label} schedule: {len(data.get('events', []))} events" + ) + return data diff --git a/test/test_sports_card_wrappers.py b/test/test_sports_card_wrappers.py new file mode 100644 index 00000000..6db8a9a6 --- /dev/null +++ b/test/test_sports_card_wrappers.py @@ -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) diff --git a/test/test_sports_celebration.py b/test/test_sports_celebration.py new file mode 100644 index 00000000..04f1bd0e --- /dev/null +++ b/test/test_sports_celebration.py @@ -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) diff --git a/test/test_sports_fetch.py b/test/test_sports_fetch.py new file mode 100644 index 00000000..a2f25d3f --- /dev/null +++ b/test/test_sports_fetch.py @@ -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) diff --git a/test/test_sports_stage3_parity.py b/test/test_sports_stage3_parity.py new file mode 100644 index 00000000..7efaf327 --- /dev/null +++ b/test/test_sports_stage3_parity.py @@ -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.")