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
+14
View File
@@ -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.
+67 -16
View File
@@ -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.<element>.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
+41 -8
View File
@@ -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
+74 -18
View File
@@ -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.<element>.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.<element>.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.<element>.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
+77
View File
@@ -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].<element>.<field>``, 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.<element>.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.<element>.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.<element>.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.
+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)
+29 -3
View File
@@ -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: