feat(sports): resolve element colour by name, and honour visible/align/scale

Two of the three gaps this framework shipped with.

Colour by name. A draw resolved its colour by comparing the *identity* of the
font object it was handed, which cannot tell two elements apart when they share
a face -- so those draws went out white. Every bitmap font is in that case,
because a freetype.Face cannot be re-instantiated to un-share it, which is how
an element rendered in any of the 32 shipped BDF fonts silently lost a colour
its picker had offered all along. _draw_text_with_outline now takes
element="score_text" and reads the colour by name; the identity path remains
for un-annotated callers, but narrows before giving up -- one configured colour
among the sharers is the only thing the user can have meant.

Visible, align and scale. The resolver has understood these since the
framework landed and nothing consumed them: an element could be marked hidden
in the web UI and still render. Adds the stateless readers, the mixin
accessors, and a scale parameter on the one shared logo-sizing seam (keyed into
the cache, so two elements scaled differently cannot be served each other's
image). Naming an element in a draw also honours its visibility.

Untouched configs are unaffected: every new parameter defaults to today's
behaviour, and all ten affected plugins render pixel-identically to main across
every harness size.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-13 10:39:51 -04:00
co-authored by Claude Opus 5
parent 1df51b172a
commit da3cef0bc0
7 changed files with 489 additions and 45 deletions
+187
View File
@@ -0,0 +1,187 @@
"""Visibility, alignment and scale reach the draw.
The resolver has understood these three since the framework landed, but nothing
on the scoreboard path consumed them: an element could be marked hidden in the
web UI and still render. This covers the readers, the mixin accessors the draw
paths use, and the one shared logo-sizing seam.
The rule throughout is that an untouched config takes exactly the path it took
before -- so each test that asserts an effect has a sibling asserting the
default is inert.
"""
import logging
import sys
from pathlib import Path
import pytest
from PIL import Image
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
from src.common.logo_helper import LogoHelper # noqa: E402
from src.common.sports_shared import SportsCoreSharedMixin # noqa: E402
from src.element_style import ( # noqa: E402
element_align, element_scale, element_visible)
HIDDEN = {"customization": {"records": {"visible": False}}}
ALIGNED = {"customization": {"score_text": {"align": "right"}}}
SCALED = {"customization": {"layout": {"home_logo": {"scale": 0.5}}}}
class TestTheReaders:
def test_an_element_is_visible_unless_it_says_otherwise(self):
assert element_visible({}, "records") is True
assert element_visible(HIDDEN, "records") is False
def test_a_mode_can_hide_what_the_base_shows(self):
cfg = {"customization": {"records": {"visible": True},
"modes": {"recent": {"records": {"visible": False}}}}}
assert element_visible(cfg, "records") is True
assert element_visible(cfg, "records", mode="recent") is False
def test_a_mode_that_says_nothing_inherits(self):
"""None is the inherit sentinel and must not read as False."""
cfg = {"customization": {"records": {"visible": False},
"modes": {"live": {"records": {"visible": None}}}}}
assert element_visible(cfg, "records", mode="live") is False
def test_alignment_is_unset_by_default(self):
assert element_align({}, "score_text") is None
assert element_align(ALIGNED, "score_text") == "right"
def test_scale_reads_the_layout_block(self):
assert element_scale({}, "home_logo") == 1.0
assert element_scale(SCALED, "home_logo") == 0.5
def test_scale_takes_a_per_mode_override(self):
cfg = {"customization": {"layout": {"home_logo": {"scale": 0.5}},
"modes": {"live": {"layout": {"home_logo": {"scale": 2.0}}}}}}
assert element_scale(cfg, "home_logo", mode="live") == 2.0
assert element_scale(cfg, "home_logo") == 0.5
@pytest.mark.parametrize("cfg", [
None, {}, "nonsense", {"customization": "nonsense"},
{"customization": {"records": "nonsense"}},
{"customization": {"records": {"visible": "maybe"}}},
])
def test_a_hostile_config_never_raises(self, cfg):
assert element_visible(cfg, "records") is True
assert element_align(cfg, "records") is None
assert element_scale(cfg, "records") == 1.0
class _Draw:
"""Records what would have been drawn."""
def __init__(self):
self.calls = []
self.fontmode = ""
def text(self, position, text, font=None, fill=None):
self.calls.append({"position": position, "text": text, "fill": fill})
class _Host(SportsCoreSharedMixin):
def __init__(self, config, mode=None):
self.config = config
self.fonts = {}
self.logger = logging.getLogger("test")
self.SKIN_MODE = mode
class TestTheDrawPath:
def test_a_hidden_element_is_not_drawn_at_all(self):
draw = _Draw()
_Host(HIDDEN)._draw_text_with_outline(
draw, "9-1", (0, 0), None, element="records")
assert draw.calls == [], (
"a hidden element must produce no draw, outline included")
def test_a_visible_element_still_draws(self):
draw = _Draw()
_Host({})._draw_text_with_outline(
draw, "9-1", (0, 0), None, element="records")
assert draw.calls, "the default must be inert"
def test_naming_the_element_resolves_its_colour(self):
draw = _Draw()
cfg = {"customization": {"score_text": {"text_color": [1, 2, 3]}}}
_Host(cfg)._draw_text_with_outline(
draw, "21", (0, 0), None, element="score_text")
# Last call is the text itself; the earlier eight are the outline.
assert draw.calls[-1]["fill"] == (1, 2, 3)
def test_an_explicit_fill_still_wins(self):
"""Odds colours and the favourite-result tint mean something the
palette does not."""
draw = _Draw()
cfg = {"customization": {"score_text": {"text_color": [1, 2, 3]}}}
_Host(cfg)._draw_text_with_outline(
draw, "21", (0, 0), None, fill=(9, 9, 9), element="score_text")
assert draw.calls[-1]["fill"] == (9, 9, 9)
def test_a_mode_hides_only_that_mode(self):
cfg = {"customization": {"modes": {"recent": {"records": {"visible": False}}}}}
recent, live = _Draw(), _Draw()
_Host(cfg, "recent")._draw_text_with_outline(
recent, "9-1", (0, 0), None, element="records")
_Host(cfg, "live")._draw_text_with_outline(
live, "9-1", (0, 0), None, element="records")
assert recent.calls == []
assert live.calls
class TestAlignment:
def test_unset_leaves_the_caller_where_it_was(self):
"""These draws carry per-sport nudges; a centre recomputed here would
not be the same pixel."""
assert _Host({})._aligned_x("score_text", 20, 64, 22) == 22
def test_right_pushes_to_the_edge(self):
assert _Host(ALIGNED)._aligned_x("score_text", 20, 64, 22) == 44
def test_left_goes_to_zero(self):
cfg = {"customization": {"score_text": {"align": "left"}}}
assert _Host(cfg)._aligned_x("score_text", 20, 64, 22) == 0
def test_text_wider_than_the_panel_is_not_pushed_off(self):
assert _Host(ALIGNED)._aligned_x("score_text", 100, 64, 0) == 0
class TestLogoScale:
@pytest.fixture
def logo(self, tmp_path):
path = tmp_path / "team.png"
Image.new("RGBA", (40, 40), (255, 0, 0, 255)).save(path)
return path
def test_an_unscaled_load_is_unchanged(self, logo):
helper = LogoHelper(display_width=64, display_height=32)
plain = helper.load_logo("AAA", logo, 32, 32)
assert plain.size == (32, 32)
def test_a_half_scale_logo_is_half_the_box(self, logo):
helper = LogoHelper(display_width=64, display_height=32)
small = helper.load_logo("AAA", logo, 32, 32, scale=0.5)
assert small.size == (16, 16)
def test_scaling_up_grows_the_logo(self, logo):
"""The fit rule never grows an image, so this only happens when the
user asks for it."""
helper = LogoHelper(display_width=64, display_height=32)
big = helper.load_logo("AAA", logo, 64, 64, scale=2.0)
assert big.size[0] > 40
def test_two_scales_do_not_share_a_cache_entry(self, logo):
helper = LogoHelper(display_width=64, display_height=32)
full = helper.load_logo("AAA", logo, 32, 32)
half = helper.load_logo("AAA", logo, 32, 32, scale=0.5)
assert full.size == (32, 32) and half.size == (16, 16)
@pytest.mark.parametrize("bad", [0, -1, None, "big", float("inf"),
float("nan")])
def test_an_unusable_scale_is_ignored(self, logo, bad):
helper = LogoHelper(display_width=64, display_height=32)
assert helper.load_logo("AAA", logo, 32, 32, scale=bad).size == (32, 32)