diff --git a/CHANGELOG.md b/CHANGELOG.md index 7003d150..4d7ffd30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,20 @@ ships this): says `score`, and `records`/`record` and `status_text`/`status` split seven to two across the published schemas. A lookup tries the exact name first, so this is inert for a config that already matches. +- `src.element_style.element_visible(config, element, default, mode)`, + `element_align(...)` and `element_scale(...)` — the stateless reads for the + three knobs the resolver already understood but no draw path consumed, so an + element could be marked hidden in the web UI and still render. +- `SportsCoreSharedMixin._draw_text_with_outline(..., element="score_text")` — + naming the element resolves its colour by name and honours its visibility + toggle. Without a name the colour is inferred from font-object identity, + which cannot separate two elements sharing a face; that is the case every + bitmap font is in, because a `freetype.Face` cannot be re-instantiated, and + it is how a BDF-rendered element silently lost a configured colour. Shared + faces now resolve when exactly one sharer has a colour set. +- `LogoHelper.load_logo(..., scale=)` — applies a user's image scale, and keys + the cache on the scaled box so two elements scaled differently cannot be + served each other's image. - `src.element_style.native_bdf_size(font)` — the one pixel size a bitmap font can render at, or None for a scalable one. The web UI needs this to know whether a size control can take effect at all. diff --git a/src/common/logo_helper.py b/src/common/logo_helper.py index 0361a08b..c8a5c221 100644 --- a/src/common/logo_helper.py +++ b/src/common/logo_helper.py @@ -35,6 +35,29 @@ from src.common.permission_utils import ( # trade for not re-warning about a file nobody is going to add. MISSING_LOGO_RECHECK_SECONDS = 3600.0 +#: Bounds on a user-supplied logo scale. Wide enough to be useful, closed +#: enough that a typo cannot ask for a 4000px image on a 64px panel. +MIN_LOGO_SCALE = 0.05 +MAX_LOGO_SCALE = 8.0 + + +def _usable_scale(scale) -> float: + """A scale that can be applied, or 1.0. + + Anything unusable -- None, a string, zero, a negative, NaN, infinity -- + means "as shipped", because the alternative is a blank panel from a + mistyped number. + """ + try: + value = float(scale) + except (TypeError, ValueError): + return 1.0 + if value != value or value in (float('inf'), float('-inf')): + return 1.0 + if value < MIN_LOGO_SCALE or value > MAX_LOGO_SCALE: + return 1.0 + return value + # Well above any real team logo; bounds what a remote URL can write to disk. @@ -88,18 +111,23 @@ class LogoHelper: 'Accept': 'image/*', }) - def load_logo(self, team_abbr: str, logo_path: Union[str, Path], - max_width: Optional[int] = None, - max_height: Optional[int] = None) -> Optional[Image.Image]: + def load_logo(self, team_abbr: str, logo_path: Union[str, Path], + max_width: Optional[int] = None, + max_height: Optional[int] = None, + scale: float = 1.0) -> Optional[Image.Image]: """ Load and resize a team logo. - + Args: team_abbr: Team abbreviation for caching logo_path: Path to the logo file max_width: Maximum width (defaults to display_width * 1.5) max_height: Maximum height (defaults to display_height * 1.5) - + scale: User's size multiplier for this image, from + ``customization.layout..scale``. 1.0 is untouched and + takes exactly the path it always did. Callers hold the config, + so they resolve the element name; this only applies the number. + Returns: PIL Image object or None if loading fails @@ -115,6 +143,12 @@ class LogoHelper: max_width = int(self.display_width * 1.5) if max_height is None: max_height = int(self.display_height * 1.5) + scale = _usable_scale(scale) + if scale != 1.0: + max_width = max(1, int(round(max_width * scale))) + max_height = max(1, int(round(max_height * scale))) + # The key carries the scaled box, so two elements scaled differently + # cannot be served each other's image. cache_key = f"{team_abbr}_{logo_path}_{max_width}x{max_height}" if cache_key in self._logo_cache: self.logger.debug(f"Using cached logo for {team_abbr}") @@ -146,7 +180,8 @@ class LogoHelper: logo = logo.convert('RGBA') # Resize if needed - logo = self._resize_logo(logo, max_width, max_height) + logo = self._resize_logo(logo, max_width, max_height, + allow_upscale=scale > 1.0) # Cache the logo self._cache_logo(cache_key, logo) @@ -158,10 +193,11 @@ class LogoHelper: self.logger.error(f"Error loading logo for {team_abbr}: {e}") return None - def load_logo_with_download(self, team_abbr: str, logo_path: Union[str, Path], + def load_logo_with_download(self, team_abbr: str, logo_path: Union[str, Path], logo_url: Optional[str] = None, max_width: Optional[int] = None, - max_height: Optional[int] = None) -> Optional[Image.Image]: + max_height: Optional[int] = None, + scale: float = 1.0) -> Optional[Image.Image]: """ Load logo with automatic download if missing. @@ -181,7 +217,8 @@ class LogoHelper: # failed download does not count: it wears the real logo's filename, so # trusting the file's existence is what left teams as grey boxes. if logo_path.exists() and not self._is_stale_placeholder(logo_path): - return self.load_logo(team_abbr, logo_path, max_width, max_height) + return self.load_logo(team_abbr, logo_path, max_width, max_height, + scale) # Download if URL provided and file doesn't exist if logo_url: @@ -193,7 +230,8 @@ class LogoHelper: # from the cache before touching the disk -- so without this the # real logo would not appear until the process restarted. self._invalidate_cached_logo(team_abbr, logo_path) - return self.load_logo(team_abbr, logo_path, max_width, max_height) + return self.load_logo(team_abbr, logo_path, max_width, max_height, + scale) except Exception as e: self.logger.error(f"Failed to download logo for {team_abbr}: {e}") # The retry failed, so restart the back-off. The stale @@ -328,18 +366,31 @@ class LogoHelper: ), } - def _resize_logo(self, logo: Image.Image, max_width: Optional[int] = None, - max_height: Optional[int] = None) -> Image.Image: - """Resize logo to fit display dimensions.""" + def _resize_logo(self, logo: Image.Image, max_width: Optional[int] = None, + max_height: Optional[int] = None, + allow_upscale: bool = False) -> Image.Image: + """Resize logo to fit display dimensions. + + ``allow_upscale`` is only set when the user asked for a scale above 1: + the fit rule is "never larger than the box", and growing an image + nobody asked to grow would change every existing render. + """ if max_width is None: max_width = int(self.display_width * 1.5) if max_height is None: max_height = int(self.display_height * 1.5) - + # Only resize if necessary if logo.width <= max_width and logo.height <= max_height: - return logo - + if not allow_upscale or not logo.width or not logo.height: + return logo + ratio = min(max_width / logo.width, max_height / logo.height) + if ratio <= 1: + return logo + return logo.resize((max(1, int(logo.width * ratio)), + max(1, int(logo.height * ratio))), + Image.Resampling.LANCZOS) + # Maintain aspect ratio logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) return logo diff --git a/src/common/sports_card.py b/src/common/sports_card.py index beda5c9c..c7fa4878 100644 --- a/src/common/sports_card.py +++ b/src/common/sports_card.py @@ -113,26 +113,59 @@ def element_color(config: Optional[Dict[str, Any]], element: str, return _shared(config, element, default, mode) -def font_color(config: Optional[Dict[str, Any]], fonts: Optional[Dict[str, Any]], - font, default: Tuple[int, int, int] = (255, 255, 255)): +def resolve_font_color(config: Optional[Dict[str, Any]], + fonts: Optional[Dict[str, Any]], font, + default: Tuple[int, int, int], + element_for_font: Dict[str, str], + mode: Optional[str] = None): """Colour for whichever element owns this face. - Matched on identity, and deliberately gives up when one object is - shared: the last-resort font path can hand the same face to several - keys, and there is no right answer for which element's colour that is. - White is what those draws used before, so ambiguity costs nothing. + Identity matching is a stand-in for the element name, used where the draw + site only ever received a font. Prefer ``element=`` on the draw call; this + is the fallback for the sites that have not been annotated yet. + + One object can legitimately belong to several elements -- a size resolver + can land two of them on the same face, and a BDF face cannot be un-shared + at all because ``freetype.Face`` objects cannot be rebuilt from a path. + Those draws used to go out white, which is how an element rendered in any + of the 32 shipped bitmap fonts could silently lose a colour the user had + set. So ambiguity is now narrowed before it is given up on: among the + elements sharing a face, a single configured colour is the only thing the + user can have meant, and several that agree mean the same thing. Only a + genuine disagreement falls back to *default*. + + The element vocabulary is a parameter because the two callers disagree + about it -- the mixin's map says ``team_text`` where this module's says + ``team_name`` -- and quietly re-pointing either at the other's names would + change which colour setting a live install honours. """ try: fonts = fonts or {} - matches = [element for key, element in ELEMENT_FOR_FONT.items() + matches = [element for key, element in element_for_font.items() if fonts.get(key) is font] if len(matches) == 1: - return element_color(config, matches[0], default) + return element_color(config, matches[0], default, mode) + if len(matches) > 1: + configured = [] + for element in matches: + colour = element_color(config, element, None, mode) + if colour is not None and colour not in configured: + configured.append(colour) + if len(configured) == 1: + return configured[0] except (AttributeError, TypeError): pass return default +def font_color(config: Optional[Dict[str, Any]], fonts: Optional[Dict[str, Any]], + font, default: Tuple[int, int, int] = (255, 255, 255), + mode: Optional[str] = None): + """Colour for whichever element owns this face, by this module's map.""" + return resolve_font_color(config, fonts, font, default, ELEMENT_FOR_FONT, + mode) + + def coerce_rgb(value, fallback): """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" # Checked before unpacking: a 3-character string ("123") would otherwise diff --git a/src/common/sports_shared.py b/src/common/sports_shared.py index a988eaef..054378ba 100644 --- a/src/common/sports_shared.py +++ b/src/common/sports_shared.py @@ -191,7 +191,8 @@ class SportsCoreSharedMixin: img = Image.new("RGB", (self.display_width, self.display_height), (0, 0, 0)) draw = ImageDraw.Draw(img) status = game.get("status_text", "N/A") - self._draw_text_with_outline(draw, status, (2, 2), self.fonts["status"]) + self._draw_text_with_outline(draw, status, (2, 2), self.fonts["status"], + element="status_text") self.display_manager.image.paste(img, (0, 0)) # Don't call update_display here, let subclasses handle it after drawing except Exception as e: @@ -507,8 +508,10 @@ class SportsCoreSharedMixin: + self._get_layout_offset('score', 'x_offset')) vs_y = (center_y - 3 + self._get_layout_offset('score', 'y_offset')) + vs_x = self._aligned_x('score_text', vs_width, width, vs_x) self._draw_text_with_outline( - draw, vs_text, (vs_x, vs_y), self.fonts["score"] + draw, vs_text, (vs_x, vs_y), self.fonts["score"], + element="score_text" ) # "vs" and "none" both push the date and time out to the edges, time @@ -783,6 +786,46 @@ class SportsCoreSharedMixin: return _shared(self.config, element, default, getattr(self, "SKIN_MODE", None)) + def _element_visible(self, element: str, default: bool = True) -> bool: + """Whether ``customization..visible`` allows this draw. + + Mode-aware like the colour read, so a user can hide the records on the + recent card and keep them on the upcoming one. + """ + from src.element_style import element_visible + return element_visible(self.config, element, default, + getattr(self, "SKIN_MODE", None)) + + def _element_align(self, element: str, default: Optional[str] = None): + """``customization..align``: 'left', 'center' or 'right'.""" + from src.element_style import element_align + return element_align(self.config, element, default, + getattr(self, "SKIN_MODE", None)) + + def _element_scale(self, element: str, default: float = 1.0) -> float: + """``customization.layout..scale`` -- logos, mostly.""" + from src.element_style import element_scale + return element_scale(self.config, element, default, + getattr(self, "SKIN_MODE", None)) + + def _aligned_x(self, element: str, text_width: float, container_width: int, + centered_x: float) -> float: + """Where a run of text starts, honouring ``align``. + + Unset means "leave it exactly where it was", so this returns the + caller's own x rather than re-deriving a centre: these draws have + accumulated per-sport nudges and a centre computed here would not be + the same pixel. + """ + align = self._element_align(element) + if not align: + return centered_x + if align == 'left': + return 0 + if align == 'right': + return max(0, container_width - text_width) + return centered_x + def _unshare_element_fonts(self, fonts): """Give each colourable element its own face object. @@ -824,25 +867,31 @@ class SportsCoreSharedMixin: def _font_color(self, font, default: Tuple[int, int, int] = (255, 255, 255)): """Colour for whichever element owns this face. - Matched on identity, and deliberately gives up when one object is - shared: the last-resort font path can hand the same face to several - keys, and there is no right answer for which element's colour that is. - White is what those draws used before, so ambiguity costs nothing. + The fallback for draw sites that were only ever handed a font. Prefer + ``element=`` on :meth:`_draw_text_with_outline`, which needs none of + this. Shared with the scroll card's copy so the narrowing rule that + rescues bitmap-font colours lives in one place; the element vocabulary + stays this class's own, because its map says ``team_text`` where + sports_card's says ``team_name``. """ - try: - fonts = getattr(self, "fonts", None) or {} - matches = [element for key, element in self._ELEMENT_FOR_FONT.items() - if fonts.get(key) is font] - if len(matches) == 1: - return self._element_color(matches[0], default) - except (AttributeError, TypeError): - pass - return default + from src.common.sports_card import resolve_font_color + return resolve_font_color( + getattr(self, "config", None), getattr(self, "fonts", None), font, + default, self._ELEMENT_FOR_FONT, getattr(self, "SKIN_MODE", None)) def _draw_text_with_outline( - self, draw, text, position, font, fill=None, outline_color=(0, 0, 0) + self, draw, text, position, font, fill=None, outline_color=(0, 0, 0), + element=None ): - """Draw text with a black outline for better readability.""" + """Draw text with a black outline for better readability. + + Pass ``element`` (``"score_text"``, ``"status_text"``, ...) wherever the + caller knows what it is drawing: the colour is then read by name, which + is exact. Without it the colour has to be inferred from the identity of + the font object, which cannot tell two elements apart when they share a + face -- the case every bitmap font is in, because a ``freetype.Face`` + cannot be re-instantiated. + """ # Disable anti-aliasing: pixel/bitmap fonts (e.g. PressStart2P) get # anti-aliased into dim partial-lit pixels on a 1:1 LED matrix, muddying # glyphs. 1-bit mode keeps strokes crisp. @@ -852,7 +901,14 @@ class SportsCoreSharedMixin: # and they only ever changed the font. An explicit fill still wins: # the odds colours and the favourite-result score tint mean something # the palette does not. - if fill is None: + if element is not None: + # Named, so both questions can be answered exactly: whether this + # element is meant to be on screen at all, and what colour it is. + if not self._element_visible(element): + return + if fill is None: + fill = self._element_color(element) + elif fill is None: fill = self._font_color(font) draw.fontmode = "1" x, y = position diff --git a/src/element_style.py b/src/element_style.py index 22aa4a8f..5319ee07 100644 --- a/src/element_style.py +++ b/src/element_style.py @@ -1115,6 +1115,83 @@ def element_color(config: Any, element_key: str, return default +def _element_field(config: Any, element_key: str, field: str, + mode: Optional[str] = None, in_layout: bool = False): + """Raw ``customization[.layout]..``, mode first. + + The shared body behind the stateless visible/align/scale readers. Returns + None for "not configured", which every caller turns into its own default -- + None must not collapse into a value here, because for a mode it is the + inherit sentinel. + """ + block = config.get('customization') if isinstance(config, dict) else None + block = block if isinstance(block, dict) else {} + + def _read(source): + if not isinstance(source, dict): + return None + holder = source.get('layout') if in_layout else source + return _lookup_element(holder, element_key).get(field) + + if mode: + modes = block.get('modes') + mode_block = modes.get(mode) if isinstance(modes, dict) else None + override = _read(mode_block) + if override is not None: + return override + return _read(block) + + +def element_visible(config: Any, element_key: str, default: bool = True, + mode: Optional[str] = None) -> bool: + """``customization..visible``, or *default*. + + The stateless form of the flag :meth:`ElementStyleResolver.style` already + resolves, for the scoreboard draw paths that hold a config rather than a + resolver. + """ + try: + value = _element_field(config, element_key, 'visible', mode) + return default if value is None else _coerce_bool(value, default) + except Exception as e: + logger.warning("Error reading visibility for %s: %s", element_key, e) + return default + + +def element_align(config: Any, element_key: str, + default: Optional[str] = None, + mode: Optional[str] = None) -> Optional[str]: + """``customization..align`` ('left'/'center'/'right'), or *default*.""" + try: + value = _element_field(config, element_key, 'align', mode) + if value is None: + return default + # _coerce_align answers None for anything that is not an alignment; + # that is "no preference", which means the caller's default. + coerced = _coerce_align(value) + return default if coerced is None else coerced + except Exception as e: + logger.warning("Error reading alignment for %s: %s", element_key, e) + return default + + +def element_scale(config: Any, element_key: str, default: float = 1.0, + mode: Optional[str] = None) -> float: + """``customization.layout..scale``, or *default*. + + Scale sits in the layout block beside the offsets, because it positions and + sizes rather than styles -- a logo has no font or colour but is very much + something users want smaller. + """ + try: + value = _element_field(config, element_key, 'scale', mode, + in_layout=True) + return default if value is None else _coerce_scale(value, default) + except Exception as e: + logger.warning("Error reading scale for %s: %s", element_key, e) + return default + + class ElementStyleResolver: """Resolves per-element user styling against schema defaults. diff --git a/test/test_element_visibility_align_scale.py b/test/test_element_visibility_align_scale.py new file mode 100644 index 00000000..171f9f4e --- /dev/null +++ b/test/test_element_visibility_align_scale.py @@ -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) diff --git a/test/test_sports_card.py b/test/test_sports_card.py index 6c0d854d..0bf88f37 100644 --- a/test/test_sports_card.py +++ b/test/test_sports_card.py @@ -65,11 +65,37 @@ class TestColour: cfg = {"customization": {"score_text": {"text_color": [4, 5, 6]}}} assert C.font_color(cfg, {"score": a, "team": b}, a) == (4, 5, 6) - def test_a_shared_face_gives_up_rather_than_guessing(self): - """One object used for two elements has no single right colour.""" + def test_a_shared_face_takes_the_one_colour_that_was_configured(self): + """A face shared by two elements used to go out white even when only + one of them had a colour set -- and a bitmap face is *always* shared, + because a freetype.Face cannot be re-instantiated to un-share it. That + is how an element in any of the 32 shipped BDF fonts silently lost the + colour its picker had offered all along. One configured colour among + the sharers is the only thing the user can have meant.""" shared = object() cfg = {"customization": {"score_text": {"text_color": [4, 5, 6]}}} - assert C.font_color(cfg, {"score": shared, "team": shared}, shared) == (255, 255, 255) + assert C.font_color(cfg, {"score": shared, "team": shared}, + shared) == (4, 5, 6) + + def test_a_shared_face_still_gives_up_when_the_colours_disagree(self): + """Two different answers is the case with no right answer.""" + shared = object() + cfg = {"customization": {"score_text": {"text_color": [4, 5, 6]}, + "team_name": {"text_color": [7, 8, 9]}}} + assert C.font_color(cfg, {"score": shared, "team": shared}, + shared) == (255, 255, 255) + + def test_sharers_that_agree_resolve_to_that_colour(self): + shared = object() + cfg = {"customization": {"score_text": {"text_color": [4, 5, 6]}, + "team_name": {"text_color": [4, 5, 6]}}} + assert C.font_color(cfg, {"score": shared, "team": shared}, + shared) == (4, 5, 6) + + def test_an_unconfigured_shared_face_keeps_the_default(self): + shared = object() + assert C.font_color({}, {"score": shared, "team": shared}, shared, + (9, 9, 9)) == (9, 9, 9) class TestFavourites: