diff --git a/src/common/sports_game_renderer.py b/src/common/sports_game_renderer.py new file mode 100644 index 00000000..d42faf3f --- /dev/null +++ b/src/common/sports_game_renderer.py @@ -0,0 +1,261 @@ +"""The scroll/Vegas card geometry the sports scoreboards all share. + +Eight scoreboards -- afl, baseball, basketball, football, hockey, lacrosse, +nrl and soccer -- each carried their own ``game_renderer.py``. After the card +helpers moved to ``src/common/sports_card.py`` the settings lookups were +shared, but the *geometry* was not: nine methods that decide how wide the +centre gap is, how much room a logo gets, and where an upcoming card's date +and time land were still eight separate copies. Five were byte-identical +across all eight plugins; the other four were identical in seven, with a +different single outlier each time. + +That last detail is why this is a mixin rather than free functions. There is +no per-sport branching to write -- baseball needs its own +``_draw_upcoming_game_status`` and ``_logo_slot_width``, hockey its own +``_upcoming_date_and_time``, football its own ``_score_reserve_width``, and +every one of those is an ordinary override. The seven that agree inherit and +say nothing. + +It is deliberately *only* a mixin: no ``__init__``, no state of its own. The +plugins' constructors differ in six ways and none of that difference is worth +unifying, so adoption is one line on the class statement plus deleting the +methods that now come from here. + +What a host class must provide +------------------------------ +Attributes: ``display_width``, ``display_height``, ``config``, ``fonts``, +``logger``, ``_team_rankings_cache``. + +Methods: ``_draw_text_with_outline(draw, text, position, font, fill=None, +outline_color=(0, 0, 0))`` -- the one hook whose body genuinely varies -- plus +the ``sports_card`` delegations ``_scroll_card_option``, +``_upcoming_center_mode``, ``_vs_text``, ``_element_color``, +``_format_game_date`` and ``_format_game_time``. +""" + +import math +from typing import ClassVar, Dict, Tuple + +from PIL import Image, ImageDraw + + +class SportsGameRendererMixin: + """Shared card geometry for the sports scoreboards. See module docstring.""" + + #: Centre strip as a fraction of card width, before clamping. + CENTER_GAP_RATIO: ClassVar[float] = 0.28 + #: Clamp floor for the derived centre gap. + CENTER_GAP_MIN_PX: ClassVar[int] = 22 + #: Clamp ceiling for the derived centre gap. + CENTER_GAP_MAX_PX: ClassVar[int] = 40 + #: Breathing room kept between the score and each logo. + _SCORE_LOGO_GUTTER_PX: ClassVar[int] = 4 + #: Widest score the centre strip must fit. Leagues that can reach three + #: digits a side override this with "000-000". + _SCORE_PROBE: ClassVar[str] = "00-00" + + # ---- geometry ------------------------------------------------------ + + # Non-finite settings are rejected before any int()/round(): "inf" reaches + # these from config as a float or a string, passes an `isinstance` plus + # `>= 0` check unharmed, and then raises OverflowError out of int() -- + # which the old `except (TypeError, ValueError)` did not catch, so it + # aborted the whole card render. Present in all eight plugins before this + # moved to the core; fixing it here fixes it in all eight. + + def _score_reserve_width(self) -> int: + """Centre strip the score actually needs, measured rather than assumed. + + The gap was derived from the card width alone (width x + CENTER_GAP_RATIO, clamped to CENTER_GAP_MAX_PX) while the score's size + comes from config and the element-style resolver. Nothing compared the + two, so any score wider than the clamp was drawn over the logos. + Measuring it keeps the strip wide enough for whatever font is in play. + """ + try: + probe = ImageDraw.Draw(Image.new("RGB", (4, 4))) + width = probe.textlength(self._SCORE_PROBE, font=self.fonts['score']) + return int(width) + 2 * self._SCORE_LOGO_GUTTER_PX + except Exception: + self.logger.debug("Score reserve measurement failed", exc_info=True) + return 0 + + def _center_gap_width(self) -> int: + """Width of the middle strip kept clear of logos. + + ``scroll_card.center_gap`` pins it outright; otherwise it scales with + the card width between the configurable min and max. 0 restores + edge-to-edge logos. + """ + configured = self._scroll_card_option("center_gap") + if (isinstance(configured, (int, float)) + and math.isfinite(configured) and configured >= 0): + return int(configured) + ratio = self._scroll_card_option("center_gap_ratio", self.CENTER_GAP_RATIO) + low = self._scroll_card_option("center_gap_min", self.CENTER_GAP_MIN_PX) + high = self._scroll_card_option("center_gap_max", self.CENTER_GAP_MAX_PX) + try: + ratio, low, high = float(ratio), float(low), float(high) + if not (math.isfinite(ratio) and math.isfinite(low) + and math.isfinite(high)): + return self.CENTER_GAP_MIN_PX + scaled = round(self.display_width * ratio) + derived = int(max(int(low), min(int(high), scaled))) + # A strip narrower than the score is the bug, not a style choice. + # An explicit ``center_gap`` is still honoured above, including 0. + return max(derived, self._score_reserve_width()) + except (TypeError, ValueError, OverflowError): + return self.CENTER_GAP_MIN_PX + + def _logo_slot_width(self) -> int: + """Per-side logo slot, leaving the center gap clear. + + No longer capped at display_height: the card is sized as two + full-height logos plus the measured gap, so what is left after the gap + is exactly the logo's share. The cap was what froze the logos at 46px + on the old flat 128px card. + """ + available = (self.display_width - self._center_gap_width()) // 2 + return max(8, available) + + def _logo_cache_key(self, name: str) -> str: + """Cache key scoped to the logo slot. + + One cache dict is shared by renderers built for different card widths, + so a logo sized for a wide slot must not be handed to a narrow one. + """ + return f"{name}@{self._logo_slot_width()}x{self.display_height}" + + def _layout_offset(self, element: str, axis: str, default: int = 0) -> int: + """X/Y nudge for one element, from customization.layout. + + Same block the full-screen scorebug reads (sports.py + _get_layout_offset), so a nudge configured in the web UI now moves + the element on the scroll/Vegas card too -- previously the schema + advertised these offsets but this renderer ignored them. + """ + try: + layout = (self.config or {}).get("customization", {}).get("layout", {}) + value = (layout.get(element) or {}).get(axis, default) + if isinstance(value, bool): + return default + if isinstance(value, (int, float)): + return int(value) if math.isfinite(value) else default + if isinstance(value, str): + parsed = float(value) + return int(parsed) if math.isfinite(parsed) else default + except (TypeError, ValueError, OverflowError): + pass + return default + + # ---- upcoming cards ------------------------------------------------ + + def _upcoming_date_and_time(self, game: Dict) -> Tuple[str, str]: + """(date, time) for an upcoming card, from the extractor's flat keys.""" + return ( + str(game.get("game_date", "") or ""), + str(game.get("game_time", "") or ""), + ) + + def _draw_upcoming_center(self, draw: "ImageDraw.ImageDraw", game: Dict) -> None: + """Draw the middle of an upcoming card. + + Never a score: an upcoming game has not started, so the extractor's + 0-0 is noise. Either the VS text (default), the date and time stacked, + or nothing at all. + """ + mode = self._upcoming_center_mode() + if mode == "none": + return + + if mode == "vs": + vs_text = self._vs_text() + if not vs_text: + return + vs_width = draw.textlength(vs_text, font=self.fonts['score']) + vs_x = (self.display_width - vs_width) // 2 + self._layout_offset('score', 'x_offset') + vs_y = (self.display_height // 2) - 3 + self._layout_offset('score', 'y_offset') + self._draw_text_with_outline( + draw, vs_text, (vs_x, vs_y), self.fonts['score'], + fill=self._element_color('score_text') + ) + return + + date_text, time_text = self._upcoming_date_and_time(game) + lines = [] + if self._scroll_card_option("show_date", True): + lines.append(self._format_game_date(date_text, game)) + if self._scroll_card_option("show_time", True): + lines.append(self._format_game_time(time_text)) + lines = [t for t in lines if t] + if not lines: + return + font = self.fonts.get('detail') or self.fonts['time'] + line_h = 7 + top = (self.display_height // 2) - (len(lines) * line_h) // 2 + top += self._layout_offset('score', 'y_offset') + for i, line in enumerate(lines): + width = draw.textlength(line, font=font) + x = (self.display_width - width) // 2 + self._layout_offset('score', 'x_offset') + self._draw_text_with_outline( + draw, line, (x, top + i * line_h), font, + fill=self._element_color('detail_text') + ) + + def _draw_upcoming_game_status(self, draw: "ImageDraw.ImageDraw", game: Dict) -> None: + """Draw the date and time around an upcoming card. + + Time top and date bottom by default; scroll_card.swap_date_time puts + the date on top instead. Skipped when the pair is stacked in the + middle, which would otherwise print them twice. + """ + if self._upcoming_center_mode() == "date_time": + return + + date_raw, time_raw = self._upcoming_date_and_time(game) + date_text = (self._format_game_date(date_raw, game) + if self._scroll_card_option("show_date", True) else "") + time_text = (self._format_game_time(time_raw) + if self._scroll_card_option("show_time", True) else "") + + if self._scroll_card_option("swap_date_time", False): + top_text, top_el, bottom_text, bottom_el = ( + date_text, 'date', time_text, 'time') + top_font = self.fonts.get('detail') or self.fonts['time'] + bottom_font = self.fonts['time'] + top_color, bottom_color = 'detail_text', 'period_text' + else: + top_text, top_el, bottom_text, bottom_el = ( + time_text, 'time', date_text, 'date') + top_font = self.fonts['time'] + bottom_font = self.fonts.get('detail') or self.fonts['time'] + top_color, bottom_color = 'period_text', 'detail_text' + + if top_text: + top_width = draw.textlength(top_text, font=top_font) + top_x = (self.display_width - top_width) // 2 + self._layout_offset(top_el, 'x_offset') + top_y = 1 + self._layout_offset(top_el, 'y_offset') + self._draw_text_with_outline( + draw, top_text, (top_x, top_y), top_font, + fill=self._element_color(top_color) + ) + + if bottom_text: + bottom_width = draw.textlength(bottom_text, font=bottom_font) + bottom_x = ((self.display_width - bottom_width) // 2 + + self._layout_offset(bottom_el, 'x_offset')) + # Measured, not a fixed -7: the detail font is 6px in most plugins + # but 10px in soccer and nrl, where "Sep 19" ran past the card. + ink_bottom = draw.textbbox((0, 0), bottom_text, font=bottom_font)[3] + bottom_y = (max(0, self.display_height - ink_bottom - 1) + + self._layout_offset(bottom_el, 'y_offset')) + self._draw_text_with_outline( + draw, bottom_text, (bottom_x, bottom_y), bottom_font, + fill=self._element_color(bottom_color) + ) + + # ---- rankings ------------------------------------------------------ + + def set_rankings_cache(self, rankings: Dict[str, int]) -> None: + """Set the team rankings cache for display.""" + self._team_rankings_cache = rankings diff --git a/test/test_sports_game_renderer.py b/test/test_sports_game_renderer.py new file mode 100644 index 00000000..50590005 --- /dev/null +++ b/test/test_sports_game_renderer.py @@ -0,0 +1,258 @@ +"""The shared card geometry, exercised against a host that supplies only what +the mixin's contract names. + +The point of these is the contract, not the arithmetic. The mixin reaches for +``display_width``, ``fonts``, ``config`` and six ``sports_card`` delegations +through ``self``, and the eight plugins are what actually provide them. A +stub host that provides exactly the documented surface and nothing else is +what catches the mixin quietly growing a dependency the plugins do not have. +""" + +import pytest +from PIL import Image, ImageDraw, ImageFont + +from src.common.sports_game_renderer import SportsGameRendererMixin + + +class Host(SportsGameRendererMixin): + """The documented contract, and not one attribute more.""" + + def __init__(self, width=128, height=32, config=None, scroll=None): + self.display_width = width + self.display_height = height + self.config = config or {} + self.logger = _Logger() + self._team_rankings_cache = {} + self._scroll = scroll or {} + font = ImageFont.load_default() + self.fonts = {'score': font, 'time': font, 'detail': font} + self.drawn = [] + + # -- the six sports_card delegations the mixin calls -- + def _scroll_card_option(self, key, default=None): + return self._scroll.get(key, default) + + def _upcoming_center_mode(self): + return self._scroll.get('upcoming_center', 'vs') + + def _vs_text(self): + return self._scroll.get('vs_text', 'VS') + + def _element_color(self, element): + return (255, 255, 255) + + def _format_game_date(self, raw, game): + return raw + + def _format_game_time(self, raw): + return raw + + # -- the one hook whose body genuinely varies per plugin -- + def _draw_text_with_outline(self, draw, text, position, font, + fill=None, outline_color=(0, 0, 0)): + self.drawn.append((text, position)) + + +class _Logger: + def debug(self, *a, **k): + pass + + +def _draw(): + return ImageDraw.Draw(Image.new("RGB", (256, 64))) + + +class TestCenterGap: + def test_explicit_center_gap_wins_outright(self): + assert Host(scroll={'center_gap': 31})._center_gap_width() == 31 + + def test_explicit_zero_restores_edge_to_edge_logos(self): + # 0 is a real setting, not a falsy miss -- the guard is `>= 0`. + assert Host(scroll={'center_gap': 0})._center_gap_width() == 0 + + def test_otherwise_it_scales_with_card_width_within_the_clamp(self): + h = Host(width=512) + # 512 * 0.28 = 143, clamped to the 40px ceiling. + assert h._center_gap_width() >= h.CENTER_GAP_MIN_PX + + def test_the_gap_never_ends_up_narrower_than_the_score(self): + # This is the bug the measurement exists to prevent: a derived gap + # smaller than the rendered score drew the score over the logos. + h = Host(width=64) + assert h._center_gap_width() >= h._score_reserve_width() + + def test_a_junk_ratio_falls_back_to_the_floor(self): + h = Host(scroll={'center_gap_ratio': 'wide'}) + assert h._center_gap_width() == h.CENTER_GAP_MIN_PX + + +class TestNonFiniteSettings: + """inf reaches int() and raises OverflowError, which the old + `except (TypeError, ValueError)` did not catch -- so one bad config value + aborted the entire card render rather than falling back.""" + + @pytest.mark.parametrize("bad", [float("inf"), float("-inf")]) + def test_a_non_finite_center_gap_falls_back(self, bad): + h = Host(scroll={'center_gap': bad}) + assert h._center_gap_width() >= h.CENTER_GAP_MIN_PX + + @pytest.mark.parametrize("bad", [float("inf"), float("-inf"), float("nan")]) + def test_a_non_finite_ratio_falls_back_to_the_floor(self, bad): + h = Host(scroll={'center_gap_ratio': bad}) + assert h._center_gap_width() == h.CENTER_GAP_MIN_PX + + @pytest.mark.parametrize("bad", [float("inf"), float("-inf")]) + def test_non_finite_clamp_bounds_fall_back(self, bad): + h = Host(scroll={'center_gap_min': bad, 'center_gap_max': bad}) + assert h._center_gap_width() == h.CENTER_GAP_MIN_PX + + @pytest.mark.parametrize("bad", [float("inf"), float("-inf"), "inf", "-inf", "nan"]) + def test_a_non_finite_layout_offset_gives_the_default(self, bad): + cfg = {'customization': {'layout': {'score': {'x_offset': bad}}}} + assert Host(config=cfg)._layout_offset('score', 'x_offset', 7) == 7 + + def test_a_finite_value_is_still_honoured(self): + # The guard must not swallow ordinary settings. + assert Host(scroll={'center_gap': 31})._center_gap_width() == 31 + cfg = {'customization': {'layout': {'score': {'x_offset': -3}}}} + assert Host(config=cfg)._layout_offset('score', 'x_offset', 7) == -3 + + +class TestScoreReserve: + def test_it_measures_the_probe_plus_both_gutters(self): + h = Host() + assert h._score_reserve_width() > 2 * h._SCORE_LOGO_GUTTER_PX + + def test_a_wider_probe_reserves_more(self): + class Wide(Host): + _SCORE_PROBE = "000-000" + assert Wide()._score_reserve_width() > Host()._score_reserve_width() + + def test_an_unmeasurable_font_reserves_nothing_rather_than_raising(self): + h = Host() + h.fonts = {'score': object()} + assert h._score_reserve_width() == 0 + + +class TestLogoSlot: + def test_the_slot_is_what_is_left_after_the_gap(self): + h = Host(width=128, scroll={'center_gap': 40}) + assert h._logo_slot_width() == 44 + + def test_it_is_not_capped_at_the_card_height(self): + # The height cap is what froze logos at 46px on a 128px card. + h = Host(width=512, height=32, scroll={'center_gap': 40}) + assert h._logo_slot_width() > h.display_height + + def test_a_gap_wider_than_the_card_still_leaves_a_usable_slot(self): + assert Host(width=64, scroll={'center_gap': 200})._logo_slot_width() == 8 + + def test_the_cache_key_is_scoped_to_the_slot_not_just_the_name(self): + # One cache dict is shared by renderers of different card widths. + narrow = Host(width=64, scroll={'center_gap': 20})._logo_cache_key("NYY") + wide = Host(width=256, scroll={'center_gap': 20})._logo_cache_key("NYY") + assert narrow != wide + + +class TestLayoutOffset: + def _host(self, value): + return Host(config={'customization': {'layout': {'score': {'x_offset': value}}}}) + + def test_it_reads_the_same_block_as_the_full_screen_scorebug(self): + assert self._host(5)._layout_offset('score', 'x_offset') == 5 + + def test_a_string_offset_from_the_web_ui_is_coerced(self): + assert self._host("-3")._layout_offset('score', 'x_offset') == -3 + + def test_a_bool_is_not_silently_an_offset_of_one(self): + assert self._host(True)._layout_offset('score', 'x_offset', 9) == 9 + + @pytest.mark.parametrize("cfg", [{}, {'customization': {}}, + {'customization': {'layout': {}}}]) + def test_a_missing_block_gives_the_default(self, cfg): + assert Host(config=cfg)._layout_offset('score', 'x_offset', 7) == 7 + + def test_an_unparseable_offset_gives_the_default(self): + assert self._host("left")._layout_offset('score', 'x_offset', 4) == 4 + + +class TestUpcomingCenter: + def test_none_draws_nothing(self): + h = Host(scroll={'upcoming_center': 'none'}) + h._draw_upcoming_center(_draw(), {}) + assert h.drawn == [] + + def test_vs_is_the_default_and_never_a_score(self): + # An upcoming game has not started; the extractor's 0-0 is noise. + h = Host() + h._draw_upcoming_center(_draw(), {'home_score': 0, 'away_score': 0}) + assert [t for t, _ in h.drawn] == ['VS'] + + def test_an_empty_vs_string_draws_nothing(self): + h = Host(scroll={'vs_text': ''}) + h._draw_upcoming_center(_draw(), {}) + assert h.drawn == [] + + def test_date_time_stacks_both_lines(self): + h = Host(scroll={'upcoming_center': 'date_time'}) + h._draw_upcoming_center(_draw(), {'game_date': 'Sep 19', 'game_time': '7:00 PM'}) + assert [t for t, _ in h.drawn] == ['Sep 19', '7:00 PM'] + + def test_hiding_both_lines_draws_nothing(self): + h = Host(scroll={'upcoming_center': 'date_time', + 'show_date': False, 'show_time': False}) + h._draw_upcoming_center(_draw(), {'game_date': 'Sep 19', 'game_time': '7:00 PM'}) + assert h.drawn == [] + + +class TestUpcomingStatus: + def test_time_on_top_and_date_below_by_default(self): + h = Host() + h._draw_upcoming_game_status(_draw(), {'game_date': 'Sep 19', 'game_time': '7:00 PM'}) + assert [t for t, _ in h.drawn] == ['7:00 PM', 'Sep 19'] + + def test_swap_date_time_reverses_them(self): + h = Host(scroll={'swap_date_time': True}) + h._draw_upcoming_game_status(_draw(), {'game_date': 'Sep 19', 'game_time': '7:00 PM'}) + assert [t for t, _ in h.drawn] == ['Sep 19', '7:00 PM'] + + def test_it_stays_out_of_the_way_when_the_centre_already_has_them(self): + # Otherwise the date and time print twice on the same card. + h = Host(scroll={'upcoming_center': 'date_time'}) + h._draw_upcoming_game_status(_draw(), {'game_date': 'Sep 19', 'game_time': '7:00 PM'}) + assert h.drawn == [] + + def test_the_bottom_line_is_measured_not_a_fixed_offset(self): + # A fixed -7 ran "Sep 19" past the card wherever the detail font is + # 10px rather than 6px. + h = Host(height=64) + h._draw_upcoming_game_status(_draw(), {'game_date': 'Sep 19', 'game_time': '7:00 PM'}) + bottom_y = h.drawn[1][1][1] + assert 0 <= bottom_y < h.display_height + + +class TestRankings: + def test_set_rankings_cache_replaces_the_cache(self): + h = Host() + h.set_rankings_cache({'UGA': 1}) + assert h._team_rankings_cache == {'UGA': 1} + + +class TestContract: + def test_the_mixin_carries_no_state_of_its_own(self): + # Adoption must be one line on the class statement; a mixin with an + # __init__ would force eight constructors to cooperate. + assert '__init__' not in SportsGameRendererMixin.__dict__ + + def test_a_host_providing_the_documented_surface_needs_nothing_more(self): + # Host defines exactly what the module docstring names. If the mixin + # grows a new self.* dependency, this is what fails. + h = Host() + h._center_gap_width() + h._logo_slot_width() + h._logo_cache_key("X") + h._layout_offset('score', 'x_offset') + h._upcoming_date_and_time({}) + h._draw_upcoming_center(_draw(), {}) + h._draw_upcoming_game_status(_draw(), {}) + h.set_rankings_cache({})