From 5567716a2bb6db3652d2ad6bcc6195612456f0dd Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Wed, 2 Sep 2026 14:11:31 -0400 Subject: [PATCH 1/2] feat(sports): share the scroll-card geometry the scoreboards all duplicate The card helpers moved to src/common/sports_card.py, which shared the eight scoreboards' settings lookups. Their *geometry* stayed duplicated: nine methods deciding how wide the centre strip is, how much room each logo gets, and where an upcoming card's date and time land. Five were byte-identical in all eight plugins; the other four were identical in seven, each with a different single outlier. That shape is why this is a mixin and not free functions. Comparing executable ASTs against the eight plugins, 67 of the 70 method bodies are inherited unchanged and 3 become ordinary overrides -- baseball keeps its own _logo_slot_width and _draw_upcoming_game_status, hockey its own _upcoming_date_and_time. No per-sport branching goes inside the base. It deliberately has no __init__ and no state. The plugins' constructors differ six ways and none of it is worth unifying, so adoption is one line on the class statement plus deleting what now comes from here. Placed in src/common/ rather than src/base_classes/sports/ on purpose: importing that package pulls core.py -> DisplayManager -> rgbmatrix, and this is pure geometry that must not drag a hardware import into every plugin that uses it. It sits next to sports_card.py, which the same plugins already use. Only _SCORE_PROBE varies between plugins, so leagues that reach three digits a side override that one ClassVar; the four gap constants are identical everywhere. The tests drive the mixin through a host that provides exactly the surface the module docstring names and nothing else, so the mixin growing a new self.* dependency the plugins do not have fails the contract test rather than shipping. --- src/common/sports_game_renderer.py | 247 +++++++++++++++++++++++++++++ test/test_sports_game_renderer.py | 226 ++++++++++++++++++++++++++ 2 files changed, 473 insertions(+) create mode 100644 src/common/sports_game_renderer.py create mode 100644 test/test_sports_game_renderer.py diff --git a/src/common/sports_game_renderer.py b/src/common/sports_game_renderer.py new file mode 100644 index 00000000..d88691a6 --- /dev/null +++ b/src/common/sports_game_renderer.py @@ -0,0 +1,247 @@ +"""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``. +""" + +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 ------------------------------------------------------ + + 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 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: + scaled = round(self.display_width * float(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): + 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 isinstance(value, str): + return int(float(value)) + except (TypeError, ValueError): + 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..3e83468b --- /dev/null +++ b/test/test_sports_game_renderer.py @@ -0,0 +1,226 @@ +"""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 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({}) From ad64c61d2750845c8889e32e20c242c2b00c3d34 Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Wed, 2 Sep 2026 14:50:34 -0400 Subject: [PATCH 2/2] fix(sports): reject non-finite card settings before they abort the render A center_gap of inf passes `isinstance(x, (int, float)) and x >= 0` unharmed and then raises OverflowError out of int(). The surrounding guards caught only (TypeError, ValueError), so it escaped and took the whole card render with it. The same holds for center_gap_ratio, the two clamp bounds, and layout offsets, where "inf" arrives as a string and float() is happy to produce it. Four of the five paths crashed; only a NaN ratio happened to survive, by accident of min/max rather than by design. This is pre-existing behaviour -- the bodies moved here verbatim from the eight plugins and every one of them has it today. Fixing it in the mixin fixes it in all eight at once, which is the argument for the mixin. Guarded with math.isfinite() before any int()/round(), falling back to the same defaults the finite paths already use, plus OverflowError added to the except clauses as a backstop. Ordinary settings are untouched: all 192 scroll-card renders (8 plugins x 8 panel sizes x 3 game types) stay byte-identical to pristine main. Found by CodeRabbit on #514 and confirmed by running it before fixing. --- src/common/sports_game_renderer.py | 26 ++++++++++++++++++------ test/test_sports_game_renderer.py | 32 ++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/src/common/sports_game_renderer.py b/src/common/sports_game_renderer.py index d88691a6..d42faf3f 100644 --- a/src/common/sports_game_renderer.py +++ b/src/common/sports_game_renderer.py @@ -33,6 +33,7 @@ ``_format_game_date`` and ``_format_game_time``. """ +import math from typing import ClassVar, Dict, Tuple from PIL import Image, ImageDraw @@ -55,6 +56,13 @@ class SportsGameRendererMixin: # ---- 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. @@ -80,18 +88,23 @@ def _center_gap_width(self) -> int: edge-to-edge logos. """ configured = self._scroll_card_option("center_gap") - if isinstance(configured, (int, float)) and configured >= 0: + 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: - scaled = round(self.display_width * float(ratio)) + 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): + except (TypeError, ValueError, OverflowError): return self.CENTER_GAP_MIN_PX def _logo_slot_width(self) -> int: @@ -127,10 +140,11 @@ def _layout_offset(self, element: str, axis: str, default: int = 0) -> int: if isinstance(value, bool): return default if isinstance(value, (int, float)): - return int(value) + return int(value) if math.isfinite(value) else default if isinstance(value, str): - return int(float(value)) - except (TypeError, ValueError): + parsed = float(value) + return int(parsed) if math.isfinite(parsed) else default + except (TypeError, ValueError, OverflowError): pass return default diff --git a/test/test_sports_game_renderer.py b/test/test_sports_game_renderer.py index 3e83468b..50590005 100644 --- a/test/test_sports_game_renderer.py +++ b/test/test_sports_game_renderer.py @@ -86,6 +86,38 @@ def test_a_junk_ratio_falls_back_to_the_floor(self): 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()