feat: ship src/element_style — the per-element style resolver plugins already expect

Three plugins (of-the-day, ledmatrix-music, football-scoreboard) import
src.element_style behind guarded try/except with classic fallbacks, but
the module never existed in core, so the richer per-element styling UI
those code paths implement has been dormant. This lands it:

- ElementStyleResolver.style() resolves per-element font/size/color with
  the key semantic the consumers encode: a config value counts as
  user-forced only when it differs from the schema default (the web UI
  bakes defaults into config.json on save), and untouched configs
  resolve to exactly the caller's classic values — byte-identical
  rendering, proven by of-the-day's committed goldens passing unchanged.
- defaults_from_schema_file parses both declaration forms (the compact
  x-style-elements map and hand-written customization blocks).
- expand_style_elements() expands x-style-elements into full config
  blocks; schema_manager.load_schema() applies it (guarded, no-op for
  schemas without the declaration) so the config form and defaults
  merging see the expanded UI.
- Fonts resolve cwd-independently with (path, size) caching; .bdf loads
  via freetype like FontManager; nothing in the module raises out of
  style().

Verified: 31 new unit tests; of-the-day's previously-skipped 9-test
spec suite now runs and passes; football's resolver tests pass (27);
music's 38 plugin tests pass; schema-manager suites pass (43).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
This commit is contained in:
Claude
2026-08-01 14:14:18 +00:00
parent dc659940ab
commit 5fe9c07f80
3 changed files with 1028 additions and 1 deletions
+621
View File
@@ -0,0 +1,621 @@
"""
Shared per-element style resolution for plugins (the x-style-elements system).
Plugins expose user-customizable text styling — font, size, color, and x/y
pixel offsets per named element — through their ``config_schema.json``. Two
declaration forms exist in the plugin ecosystem:
- The compact ``x-style-elements`` map on the ``customization`` object
(of-the-day is the reference). ``expand_style_elements()`` turns it into
the full per-element property blocks the web-UI config form renders.
- The manual ``customization`` block: hand-written per-element objects with
``font`` / ``font_size`` / ``text_color`` defaults (the scoreboards,
ledmatrix-music). No expansion needed — the defaults are read as-is.
At render time a plugin builds an ``ElementStyleResolver`` from its config
and the schema-file defaults, then asks for each element's resolved style::
from src.element_style import ElementStyleResolver, defaults_from_schema_file
resolver = ElementStyleResolver(config, defaults_from_schema_file(schema_path))
title = resolver.style('title_text', classic_font='PressStart2P-Regular.ttf',
classic_size=8, classic_color=(255, 255, 255))
# title.font (PIL font / freetype.Face), title.color (RGB tuple),
# title.offset ((dx, dy)), title.user_forced, title.user_forced_color
The central subtlety is what "the user set it" means. The web UI's save flow
(``schema_manager.merge_with_defaults``) writes the FULL schema-default
object into ``config.json`` on every save, whether or not the user touched
the styling section — so a value merely being *present* in config is not an
override. A value only counts as user-forced when it genuinely differs from
the schema default for that element. When nothing is forced, ``style()``
returns exactly the ``classic_*`` values the caller passes (the plugin's
pre-customization styling), so an untouched config renders byte-identically
to the classic code path. Note the classic values and the schema defaults
may legitimately differ (e.g. football's status_text: schema declares 4x6,
the classic loader fell back to PressStart) — the schema default is the
override *reference*, the classic values are the *fallback*.
``style()`` never raises: any malformed config value degrades to the classic
style with a logged warning. Font faces are cached module-wide by
(resolved path, size), and font files resolve independently of the caller's
cwd (cwd ``assets/fonts/`` first for compatibility, then the core install
root derived from this module's own location).
"""
import copy
import json
import logging
import os
from dataclasses import dataclass
from typing import Any, Dict, Optional, Tuple, Union
from PIL import ImageFont
try:
import freetype
except ImportError: # pragma: no cover - freetype ships with the core
freetype = None
logger = logging.getLogger(__name__)
# Core install root (the directory that contains src/ and assets/fonts/),
# derived from this file so fonts resolve regardless of the caller's cwd.
_CORE_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_FONTS_SUBDIR = os.path.join('assets', 'fonts')
# Last-resort font when a requested file can't be found or loaded.
_FALLBACK_FONT_NAME = 'PressStart2P-Regular.ttf'
# (resolved absolute path, size) -> loaded font face. BDF faces are stateful
# in principle, but the core's own FontManager shares faces the same way.
_font_cache: Dict[Tuple[str, int], Any] = {}
# Config keys a style element block carries, in schema/UI order.
_STYLE_KEYS = ('font', 'font_size', 'text_color')
@dataclass(frozen=True)
class ElementStyle:
"""A fully resolved style for one named element."""
font: Any # PIL ImageFont or freetype.Face
color: Tuple[int, int, int] # resolved RGB
offset: Tuple[int, int] # user layout (x, y) offset, default (0, 0)
font_name: str # resolved font filename
font_size: int # resolved pixel size
user_forced: bool # font or size genuinely overridden
user_forced_color: bool # color genuinely overridden
# ---------------------------------------------------------------------------
# Font loading (cwd-independent, cached)
# ---------------------------------------------------------------------------
def resolve_font_path(font_name: str) -> Optional[str]:
"""Locate a font file by name, independent of the caller's cwd.
Tries, in order: an absolute path as given; ``assets/fonts/<name>``
relative to the cwd (the classic loaders' behavior, kept first so a
process running from a different checkout keeps its own fonts); then
``assets/fonts/<name>`` under the core install root. Returns an
absolute path, or None when the file doesn't exist anywhere.
"""
if not font_name or not isinstance(font_name, str):
return None
if os.path.isabs(font_name):
return font_name if os.path.isfile(font_name) else None
candidates = (
os.path.join(os.getcwd(), _FONTS_SUBDIR, font_name),
os.path.join(_CORE_ROOT, _FONTS_SUBDIR, font_name),
)
for candidate in candidates:
if os.path.isfile(candidate):
return os.path.abspath(candidate)
return None
def load_font(font_name: str, size: int) -> Any:
"""Load a font by filename at a pixel size, with caching and fallback.
``.bdf`` files load as ``freetype.Face`` (matching FontManager), other
files through ``PIL.ImageFont.truetype``. A missing or unloadable font
degrades to ``PressStart2P-Regular.ttf`` at the requested size, then to
PIL's built-in default — this function never raises.
"""
try:
size = max(1, int(size))
except (TypeError, ValueError):
size = 8
path = resolve_font_path(font_name)
if path is None:
logger.warning("Font file not found: %s, using fallback", font_name)
return _load_fallback_font(size)
cache_key = (path, size)
cached = _font_cache.get(cache_key)
if cached is not None:
return cached
try:
if path.lower().endswith('.bdf'):
if freetype is None:
raise RuntimeError("freetype not available for BDF fonts")
face = freetype.Face(path)
# Character size in 1/64th points at 72dpi == pixel size.
face.set_char_size(size * 64, size * 64, 72, 72)
font: Any = face
else:
font = ImageFont.truetype(path, size)
except Exception as e:
logger.warning("Error loading font %s at %spx: %s, using fallback",
path, size, e)
return _load_fallback_font(size)
_font_cache[cache_key] = font
return font
def _load_fallback_font(size: int) -> Any:
"""PressStart2P at the requested size, else PIL's built-in default."""
path = resolve_font_path(_FALLBACK_FONT_NAME)
if path is not None:
cache_key = (path, size)
cached = _font_cache.get(cache_key)
if cached is not None:
return cached
try:
font = ImageFont.truetype(path, size)
_font_cache[cache_key] = font
return font
except Exception as e:
logger.error("Error loading fallback font: %s", e)
return ImageFont.load_default()
# ---------------------------------------------------------------------------
# Schema parsing
# ---------------------------------------------------------------------------
def expand_style_elements(schema: Dict[str, Any]) -> Dict[str, Any]:
"""Expand a ``customization.x-style-elements`` declaration into the full
per-element property blocks the web-UI config form renders.
Each declared element becomes an object with ``font`` / ``font_size`` /
``text_color`` properties (only the sub-fields the declaration carries),
tagged ``x-style-managed: true``; elements declaring ``offsets: true``
additionally get an entry under ``customization.layout`` with
``x_offset`` / ``y_offset`` integers defaulting to 0. Hand-written
element blocks with the same key are left untouched.
Returns the schema unchanged (same object) when there is nothing to
expand; otherwise returns an expanded deep copy. Never raises.
"""
try:
customization = schema.get('properties', {}).get('customization')
if not isinstance(customization, dict):
return schema
declaration = customization.get('x-style-elements')
if not isinstance(declaration, dict) or not declaration:
return schema
expanded = copy.deepcopy(schema)
customization = expanded['properties']['customization']
customization.setdefault('type', 'object')
props = customization.setdefault('properties', {})
layout_props: Dict[str, Any] = {}
for element_key, spec in declaration.items():
if not isinstance(spec, dict):
continue
if element_key not in props:
props[element_key] = _element_block_from_spec(element_key, spec)
if spec.get('offsets'):
layout_props[element_key] = _offset_block_from_spec(
element_key, spec)
if layout_props:
layout = props.setdefault('layout', {
'type': 'object',
'title': 'Layout Offsets',
'description': 'Pixel offsets applied to each element '
'(positive x moves right, positive y moves down)',
'x-advanced': True,
'properties': {},
'additionalProperties': False,
})
layout.setdefault('properties', {})
for element_key, block in layout_props.items():
layout['properties'].setdefault(element_key, block)
return expanded
except Exception as e:
logger.warning("Error expanding x-style-elements: %s", e)
return schema
def _element_block_from_spec(element_key: str,
spec: Dict[str, Any]) -> Dict[str, Any]:
"""Build one expanded per-element schema block from its declaration."""
properties: Dict[str, Any] = {}
order = []
font_spec = spec.get('font')
if isinstance(font_spec, dict):
font_prop: Dict[str, Any] = {
'type': 'string',
'title': 'Font Family',
'x-advanced': True,
}
if 'default' in font_spec:
font_prop['default'] = font_spec['default']
if isinstance(font_spec.get('enum'), list):
font_prop['enum'] = list(font_spec['enum'])
properties['font'] = font_prop
order.append('font')
size_spec = spec.get('size')
if isinstance(size_spec, dict):
size_prop: Dict[str, Any] = {
'type': 'integer',
'title': 'Font Size',
'description': 'Font size in pixels',
'x-advanced': True,
}
if 'default' in size_spec:
size_prop['default'] = size_spec['default']
if 'min' in size_spec:
size_prop['minimum'] = size_spec['min']
if 'max' in size_spec:
size_prop['maximum'] = size_spec['max']
properties['font_size'] = size_prop
order.append('font_size')
color_spec = spec.get('color')
if isinstance(color_spec, dict):
color_prop: Dict[str, Any] = {
'type': 'array',
'title': 'Text Color',
'items': {'type': 'integer', 'minimum': 0, 'maximum': 255},
'minItems': 3,
'maxItems': 3,
'x-widget': 'color-picker',
}
if 'default' in color_spec:
color_prop['default'] = list(color_spec['default'])
properties['text_color'] = color_prop
order.append('text_color')
return {
'type': 'object',
'title': spec.get('title', element_key),
'x-style-managed': True,
'x-propertyOrder': order,
'additionalProperties': False,
'properties': properties,
}
def _offset_block_from_spec(element_key: str,
spec: Dict[str, Any]) -> Dict[str, Any]:
"""Build one layout.<element> offset block (x/y, default 0)."""
axis = {
'type': 'integer',
'default': 0,
'x-advanced': True,
}
return {
'type': 'object',
'title': spec.get('title', element_key),
'x-style-managed': True,
'additionalProperties': False,
'properties': {
'x_offset': dict(axis, title='X Offset'),
'y_offset': dict(axis, title='Y Offset'),
},
}
def defaults_from_schema(schema: Dict[str, Any]) -> Dict[str, Any]:
"""Extract per-element style defaults from a config schema dict.
Understands both declaration forms: the compact ``x-style-elements``
map, and hand-written per-element blocks under
``customization.properties`` (their ``font`` / ``font_size`` /
``text_color`` property defaults). Returns a config-shaped dict::
{"customization": {"<element>": {"font": ..., "font_size": ...,
"text_color": [...]}, ...}}
Elements with no declared defaults are omitted. Never raises.
"""
elements: Dict[str, Dict[str, Any]] = {}
try:
customization = schema.get('properties', {}).get('customization')
if not isinstance(customization, dict):
return {'customization': elements}
declaration = customization.get('x-style-elements')
if isinstance(declaration, dict):
for element_key, spec in declaration.items():
if not isinstance(spec, dict):
continue
defaults: Dict[str, Any] = {}
font_spec = spec.get('font')
if isinstance(font_spec, dict) and 'default' in font_spec:
defaults['font'] = font_spec['default']
size_spec = spec.get('size')
if isinstance(size_spec, dict) and 'default' in size_spec:
defaults['font_size'] = size_spec['default']
color_spec = spec.get('color')
if isinstance(color_spec, dict) and 'default' in color_spec:
defaults['text_color'] = list(color_spec['default'])
if defaults:
elements[element_key] = defaults
properties = customization.get('properties')
if isinstance(properties, dict):
for element_key, block in properties.items():
if element_key == 'layout' or element_key in elements:
continue
if not isinstance(block, dict):
continue
block_props = block.get('properties')
if not isinstance(block_props, dict):
continue
defaults = {}
for style_key in _STYLE_KEYS:
prop = block_props.get(style_key)
if isinstance(prop, dict) and 'default' in prop:
defaults[style_key] = prop['default']
if defaults:
elements[element_key] = defaults
except Exception as e:
logger.warning("Error extracting style defaults from schema: %s", e)
return {'customization': elements}
def defaults_from_schema_file(schema_path: Union[str, os.PathLike]) -> Dict[str, Any]:
"""``defaults_from_schema`` for a schema file on disk. A missing or
malformed file yields empty defaults (with a logged warning) — every
configured value then counts as a user override, which is the safe
degradation. Never raises."""
try:
with open(schema_path, 'r', encoding='utf-8') as f:
schema = json.load(f)
if not isinstance(schema, dict):
raise ValueError("schema is not a JSON object")
except Exception as e:
logger.warning("Could not read style defaults from %s: %s",
schema_path, e)
return {'customization': {}}
return defaults_from_schema(schema)
# ---------------------------------------------------------------------------
# Resolver
# ---------------------------------------------------------------------------
def _normalize_color(value: Any) -> Optional[Tuple[int, int, int]]:
"""An (r, g, b) tuple of ints in 0..255, or None for anything else."""
if isinstance(value, (list, tuple)) and len(value) == 3:
try:
rgb = tuple(int(c) for c in value)
except (TypeError, ValueError):
return None
if all(0 <= c <= 255 for c in rgb):
return rgb # type: ignore[return-value]
return None
class ElementStyleResolver:
"""Resolves per-element user styling against schema defaults.
Built from a plugin's live config dict and the defaults extracted from
its own ``config_schema.json`` (``defaults_from_schema_file``). The
config dict is held by reference as ``_config`` — consumers compare
identity (``resolver._config is not self.config``) to decide when a
resolver must be rebuilt after ``on_config_change`` swaps the dict.
A configured font/size/color counts as user-forced only when it differs
from the schema default (see module docstring); otherwise ``style()``
returns the caller's classic values verbatim, keeping untouched configs
byte-identical to pre-customization rendering.
"""
def __init__(self, config: Optional[Dict[str, Any]],
defaults: Optional[Dict[str, Any]] = None):
# Keep the exact object for identity-based invalidation, even if the
# caller hands us something odd; reads are guarded.
self._config = config
if isinstance(defaults, dict):
element_defaults = defaults.get('customization', {})
else:
element_defaults = {}
self._defaults: Dict[str, Any] = (
element_defaults if isinstance(element_defaults, dict) else {})
self._memo: Dict[Any, ElementStyle] = {}
# -- internal accessors -------------------------------------------------
def _customization(self) -> Dict[str, Any]:
config = self._config if isinstance(self._config, dict) else {}
customization = config.get('customization', {})
return customization if isinstance(customization, dict) else {}
def _element_config(self, element_key: str) -> Dict[str, Any]:
element = self._customization().get(element_key, {})
return element if isinstance(element, dict) else {}
def _element_defaults(self, element_key: str) -> Dict[str, Any]:
defaults = self._defaults.get(element_key, {})
return defaults if isinstance(defaults, dict) else {}
# -- public API ---------------------------------------------------------
def style(self, element_key: str,
classic_font: str = _FALLBACK_FONT_NAME,
classic_size: int = 8,
classic_color: Optional[Tuple[int, int, int]] = None) -> ElementStyle:
"""Resolve one element's style. Never raises.
Args:
element_key: Key under ``config['customization']`` (e.g.
``'title_text'``).
classic_font: Font filename the plugin's classic (pre-
customization) code used for this element.
classic_size: Classic pixel size.
classic_color: Classic RGB color, or None when the caller only
cares about the font (``.color`` then falls back to the
schema default color, else white).
Returns:
ElementStyle with the loaded font face, RGB color, (x, y)
offset, and the ``user_forced`` / ``user_forced_color`` flags.
"""
try:
memo_key = (element_key, classic_font, classic_size,
_normalize_color(classic_color) or classic_color)
memoized = self._memo.get(memo_key)
if memoized is not None:
return memoized
except Exception:
memo_key = None
try:
resolved = self._resolve(element_key, classic_font,
classic_size, classic_color)
except Exception as e:
logger.warning("Error resolving style for element '%s': %s"
"using classic style", element_key, e)
resolved = self._classic_style(classic_font, classic_size,
classic_color)
if memo_key is not None:
self._memo[memo_key] = resolved
return resolved
def offset(self, element_key: str) -> Tuple[int, int]:
"""The user's ``customization.layout.<element>`` (x, y) pixel
offset, defaulting to (0, 0). Never raises."""
return (self.offset_value(element_key, 'x_offset', 0),
self.offset_value(element_key, 'y_offset', 0))
def offset_value(self, element_key: str, axis: str, default: int = 0) -> int:
"""One ``customization.layout.<element>.<axis>`` value as an int.
``axis`` is usually ``'x_offset'`` / ``'y_offset'`` but any key is
honored (e.g. the scoreboards' ``'away_x_offset'``). Numeric
strings are coerced; anything else degrades to ``default``. Never
raises.
"""
try:
layout = self._customization().get('layout', {})
if not isinstance(layout, dict):
return int(default)
element = layout.get(element_key, {})
if not isinstance(element, dict):
return int(default)
value = element.get(axis, default)
if isinstance(value, bool):
return int(default)
if isinstance(value, (int, float)):
return int(value)
if isinstance(value, str):
try:
return int(float(value))
except (TypeError, ValueError):
logger.warning(
"Invalid layout offset for %s.%s: %r, using %s",
element_key, axis, value, default)
return int(default)
return int(default)
except Exception as e:
logger.warning("Error reading layout offset %s.%s: %s",
element_key, axis, e)
try:
return int(default)
except (TypeError, ValueError):
return 0
# -- resolution internals -----------------------------------------------
def _resolve(self, element_key: str, classic_font: str,
classic_size: int,
classic_color: Optional[Tuple[int, int, int]]) -> ElementStyle:
element_config = self._element_config(element_key)
element_defaults = self._element_defaults(element_key)
# Font family: forced only when it differs from the schema default
# (falling back to the classic font as the reference when the
# schema declares none).
default_font = element_defaults.get('font', classic_font)
configured_font = element_config.get('font')
font_forced = (isinstance(configured_font, str) and configured_font
and configured_font != default_font)
# Font size: same rule, with defensive int coercion.
default_size = self._coerce_size(
element_defaults.get('font_size'), None)
if default_size is None:
default_size = self._coerce_size(classic_size, 8)
configured_size = self._coerce_size(element_config.get('font_size'),
None)
size_forced = (configured_size is not None
and configured_size != default_size)
font_name = configured_font if font_forced else classic_font
font_size = configured_size if size_forced else self._coerce_size(
classic_size, 8)
user_forced = bool(font_forced or size_forced)
# Color: forced only when it differs from the schema default (or,
# absent one, from the classic color).
default_color = _normalize_color(element_defaults.get('text_color'))
configured_color = _normalize_color(element_config.get('text_color'))
reference_color = (default_color if default_color is not None
else _normalize_color(classic_color))
color_forced = (configured_color is not None
and configured_color != reference_color)
if color_forced:
color = configured_color
else:
color = (_normalize_color(classic_color) or default_color
or (255, 255, 255))
return ElementStyle(
font=load_font(font_name, font_size),
color=color,
offset=self.offset(element_key),
font_name=font_name,
font_size=font_size,
user_forced=user_forced,
user_forced_color=bool(color_forced),
)
def _classic_style(self, classic_font: str, classic_size: int,
classic_color: Optional[Tuple[int, int, int]]) -> ElementStyle:
"""The untouched fallback style — used when resolution itself
fails, so ``style()`` can keep its never-raises promise."""
size = self._coerce_size(classic_size, 8)
return ElementStyle(
font=load_font(classic_font, size),
color=_normalize_color(classic_color) or (255, 255, 255),
offset=(0, 0),
font_name=classic_font,
font_size=size,
user_forced=False,
user_forced_color=False,
)
@staticmethod
def _coerce_size(value: Any, default: Optional[int]) -> Optional[int]:
"""An int pixel size, or ``default`` for None/garbage."""
if value is None or isinstance(value, bool):
return default
try:
size = int(value)
except (TypeError, ValueError):
return default
return size if size > 0 else default
+11 -1
View File
@@ -115,7 +115,17 @@ class SchemaManager:
if not isinstance(schema, dict):
self.logger.error(f"Invalid schema format for {plugin_id}: not a dictionary")
return None
# Expand any customization.x-style-elements declaration into the
# full per-element style blocks (font/size/color + layout
# offsets) the web-UI config form renders. No-op for schemas
# without the declaration; never raises.
try:
from src.element_style import expand_style_elements
schema = expand_style_elements(schema)
except ImportError:
pass
# Cache the schema
self._schema_cache[plugin_id] = schema
+396
View File
@@ -0,0 +1,396 @@
"""
Tests for src.element_style — the shared per-element style resolver behind
the x-style-elements system.
The contract under test (defined by the plugin consumers: of-the-day,
ledmatrix-music, football-scoreboard):
- defaults_from_schema_file parses BOTH declaration forms — the compact
x-style-elements map and hand-written customization blocks.
- expand_style_elements turns an x-style-elements declaration into the full
per-element blocks (plus layout offsets) the web-UI form renders.
- A config value counts as user-forced only when it genuinely differs from
the schema default; untouched (or schema-default-populated) configs
resolve to EXACTLY the classic font/size/color, keeping rendering
byte-identical.
- style() never raises; malformed input degrades to the classic style.
"""
import json
import os
import pytest
from PIL import ImageFont
from src.element_style import (
ElementStyleResolver,
defaults_from_schema,
defaults_from_schema_file,
expand_style_elements,
load_font,
resolve_font_path,
)
# ---------------------------------------------------------------------------
# Schema fixtures
# ---------------------------------------------------------------------------
# Compact declaration form (of-the-day's shape).
STYLE_ELEMENTS_SCHEMA = {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": False},
"customization": {
"type": "object",
"x-style-elements": {
"title_text": {
"title": "Title",
"font": {"default": "PressStart2P-Regular.ttf"},
"size": {"default": 8, "min": 4, "max": 16},
"color": {"default": [255, 255, 255]},
"offsets": True,
},
"body_text": {
"title": "Body Text",
"font": {"default": "4x6-font.ttf"},
"size": {"default": 6, "min": 4, "max": 12},
"color": {"default": [200, 200, 200]},
"offsets": True,
},
},
},
},
}
# Manual declaration form (the scoreboards' / music's shape).
MANUAL_SCHEMA = {
"type": "object",
"properties": {
"customization": {
"type": "object",
"properties": {
"status_text": {
"type": "object",
"properties": {
"font": {"type": "string",
"default": "4x6-font.ttf"},
"font_size": {"type": "integer", "default": 6},
},
},
"score_text": {
"type": "object",
"properties": {
"font": {"type": "string",
"default": "PressStart2P-Regular.ttf"},
"font_size": {"type": "integer", "default": 10},
"text_color": {"type": "array",
"default": [255, 255, 0]},
},
},
"layout": {"type": "object", "properties": {}},
},
},
},
}
@pytest.fixture
def style_schema_path(tmp_path):
path = tmp_path / "config_schema.json"
path.write_text(json.dumps(STYLE_ELEMENTS_SCHEMA))
return str(path)
@pytest.fixture
def manual_schema_path(tmp_path):
path = tmp_path / "config_schema.json"
path.write_text(json.dumps(MANUAL_SCHEMA))
return str(path)
def _resolver(config, schema_path):
return ElementStyleResolver(config, defaults_from_schema_file(schema_path))
# ---------------------------------------------------------------------------
# Schema parsing
# ---------------------------------------------------------------------------
class TestDefaultsFromSchema:
def test_x_style_elements_defaults(self, style_schema_path):
defaults = defaults_from_schema_file(style_schema_path)
cust = defaults["customization"]
assert cust["title_text"] == {"font": "PressStart2P-Regular.ttf",
"font_size": 8,
"text_color": [255, 255, 255]}
assert cust["body_text"]["font_size"] == 6
assert cust["body_text"]["text_color"] == [200, 200, 200]
def test_manual_block_defaults(self, manual_schema_path):
defaults = defaults_from_schema_file(manual_schema_path)
cust = defaults["customization"]
assert cust["status_text"] == {"font": "4x6-font.ttf", "font_size": 6}
assert cust["score_text"]["text_color"] == [255, 255, 0]
assert "layout" not in cust
def test_missing_file_degrades_to_empty(self, tmp_path):
defaults = defaults_from_schema_file(str(tmp_path / "nope.json"))
assert defaults == {"customization": {}}
def test_malformed_file_degrades_to_empty(self, tmp_path):
path = tmp_path / "bad.json"
path.write_text("{not json")
assert defaults_from_schema_file(str(path)) == {"customization": {}}
def test_schema_without_customization(self):
assert defaults_from_schema({"properties": {}}) == {"customization": {}}
class TestExpandStyleElements:
def test_expansion_generates_blocks(self):
expanded = expand_style_elements(STYLE_ELEMENTS_SCHEMA)
cust = expanded["properties"]["customization"]["properties"]
title = cust["title_text"]
assert title["x-style-managed"] is True
assert title["properties"]["font"]["default"] == \
"PressStart2P-Regular.ttf"
assert title["properties"]["font_size"]["default"] == 8
assert title["properties"]["font_size"]["minimum"] == 4
assert title["properties"]["font_size"]["maximum"] == 16
assert cust["body_text"]["properties"]["text_color"]["default"] == \
[200, 200, 200]
def test_expansion_generates_layout_offsets(self):
expanded = expand_style_elements(STYLE_ELEMENTS_SCHEMA)
layout = expanded["properties"]["customization"]["properties"]["layout"]
assert "title_text" in layout["properties"]
offsets = layout["properties"]["body_text"]["properties"]
assert offsets["x_offset"]["default"] == 0
assert offsets["y_offset"]["default"] == 0
def test_input_schema_not_mutated(self):
before = json.dumps(STYLE_ELEMENTS_SCHEMA, sort_keys=True)
expand_style_elements(STYLE_ELEMENTS_SCHEMA)
assert json.dumps(STYLE_ELEMENTS_SCHEMA, sort_keys=True) == before
def test_no_declaration_returns_same_object(self):
assert expand_style_elements(MANUAL_SCHEMA) is MANUAL_SCHEMA
empty = {"properties": {}}
assert expand_style_elements(empty) is empty
def test_garbage_input_never_raises(self):
bad = {"properties": {"customization": {"x-style-elements": "nope"}}}
assert expand_style_elements(bad) is bad
# ---------------------------------------------------------------------------
# Classic identity: untouched configs resolve to the classic style
# ---------------------------------------------------------------------------
class TestClassicIdentity:
def test_bare_config_resolves_classic(self, style_schema_path):
r = _resolver({}, style_schema_path)
style = r.style("title_text", classic_font="PressStart2P-Regular.ttf",
classic_size=8, classic_color=(255, 255, 255))
assert style.font_name == "PressStart2P-Regular.ttf"
assert style.font_size == 8
assert style.color == (255, 255, 255)
assert style.offset == (0, 0)
assert not style.user_forced
assert not style.user_forced_color
assert isinstance(style.font, ImageFont.FreeTypeFont)
assert style.font.size == 8
def test_schema_populated_config_is_not_an_override(self, style_schema_path):
# The web UI's save flow writes the full schema defaults into config
# on every save — that must not count as a user override.
config = {"customization": {
"title_text": {"font": "PressStart2P-Regular.ttf", "font_size": 8,
"text_color": [255, 255, 255]},
"layout": {"title_text": {"x_offset": 0, "y_offset": 0}},
}}
style = _resolver(config, style_schema_path).style(
"title_text", classic_font="PressStart2P-Regular.ttf",
classic_size=8, classic_color=(255, 255, 255))
assert not style.user_forced
assert not style.user_forced_color
assert style.font_size == 8
assert style.color == (255, 255, 255)
assert style.offset == (0, 0)
def test_schema_default_falls_back_to_classic_not_schema_font(
self, manual_schema_path):
# Classic values and schema defaults can legitimately differ
# (football's status_text: schema says 4x6, classic loader used
# PressStart). A schema-default config value must yield the CLASSIC
# font, byte-identical to the old loader.
config = {"customization": {"status_text": {"font": "4x6-font.ttf",
"font_size": 6}}}
style = _resolver(config, manual_schema_path).style(
"status_text", classic_font="PressStart2P-Regular.ttf",
classic_size=6)
assert not style.user_forced
assert style.font_name == "PressStart2P-Regular.ttf"
assert style.font_size == 6
def test_same_font_object_from_cache(self, style_schema_path):
r = _resolver({}, style_schema_path)
s1 = r.style("title_text", classic_font="PressStart2P-Regular.ttf",
classic_size=8)
s2 = ElementStyleResolver({}, {}).style(
"title_text", classic_font="PressStart2P-Regular.ttf",
classic_size=8)
assert s1.font is s2.font
# ---------------------------------------------------------------------------
# User overrides engage
# ---------------------------------------------------------------------------
class TestUserOverrides:
def test_font_override(self, style_schema_path):
config = {"customization": {"title_text": {"font": "4x6-font.ttf"}}}
style = _resolver(config, style_schema_path).style(
"title_text", classic_font="PressStart2P-Regular.ttf",
classic_size=8)
assert style.user_forced
assert style.font_name == "4x6-font.ttf"
assert style.font_size == 8 # size untouched -> classic
def test_size_override(self, style_schema_path):
config = {"customization": {"title_text": {
"font": "PressStart2P-Regular.ttf", "font_size": 16}}}
style = _resolver(config, style_schema_path).style(
"title_text", classic_font="PressStart2P-Regular.ttf",
classic_size=8)
assert style.user_forced
assert style.font_name == "PressStart2P-Regular.ttf"
assert style.font_size == 16
assert style.font.size == 16
def test_size_override_detected_vs_schema_default(self, manual_schema_path):
# font_size 8 differs from the schema default 6 -> forced.
config = {"customization": {"status_text": {"font": "4x6-font.ttf",
"font_size": 8}}}
style = _resolver(config, manual_schema_path).style(
"status_text", classic_font="PressStart2P-Regular.ttf",
classic_size=6)
assert style.user_forced
assert style.font_size == 8
def test_color_override(self, style_schema_path):
config = {"customization": {"title_text": {"text_color": [255, 0, 0]}}}
style = _resolver(config, style_schema_path).style(
"title_text", classic_font="PressStart2P-Regular.ttf",
classic_size=8, classic_color=(255, 255, 255))
assert style.user_forced_color
assert not style.user_forced
assert style.color == (255, 0, 0)
def test_offsets(self, style_schema_path):
config = {"customization": {"layout": {
"title_text": {"x_offset": 4, "y_offset": -2}}}}
r = _resolver(config, style_schema_path)
assert r.offset("title_text") == (4, -2)
assert r.offset("body_text") == (0, 0)
style = r.style("title_text", classic_font="PressStart2P-Regular.ttf",
classic_size=8)
assert style.offset == (4, -2)
def test_offset_value_arbitrary_axis_and_strings(self, style_schema_path):
# The scoreboards read non-standard axes (away_x_offset) and configs
# can carry numeric strings/floats.
config = {"customization": {"layout": {"records": {
"away_x_offset": "3", "home_x_offset": 2.7}}}}
r = _resolver(config, style_schema_path)
assert r.offset_value("records", "away_x_offset", 0) == 3
assert r.offset_value("records", "home_x_offset", 0) == 2
assert r.offset_value("records", "missing_axis", 5) == 5
# ---------------------------------------------------------------------------
# Defensive degradation
# ---------------------------------------------------------------------------
class TestDegradation:
@pytest.mark.parametrize("config", [
None,
{"customization": "not a dict"},
{"customization": {"title_text": "not a dict"}},
{"customization": {"title_text": {"font": 42, "font_size": "huge",
"text_color": "red"}}},
{"customization": {"layout": {"title_text": {"x_offset": "junk"}}}},
])
def test_bad_config_degrades_to_classic(self, config, style_schema_path):
style = _resolver(config, style_schema_path).style(
"title_text", classic_font="PressStart2P-Regular.ttf",
classic_size=8, classic_color=(10, 20, 30))
assert not style.user_forced
assert not style.user_forced_color
assert style.font_name == "PressStart2P-Regular.ttf"
assert style.font_size == 8
assert style.color == (10, 20, 30)
assert style.offset == (0, 0)
def test_unknown_font_falls_back(self, style_schema_path):
config = {"customization": {"title_text": {"font": "no-such.ttf"}}}
style = _resolver(config, style_schema_path).style(
"title_text", classic_font="PressStart2P-Regular.ttf",
classic_size=8)
# The override IS honored as forced, but the face degrades safely.
assert style.user_forced
assert style.font is not None
def test_empty_defaults_treats_config_as_reference_to_classic(self):
# No schema defaults at all: a config value equal to the classic
# value is not forced; a different one is.
r = ElementStyleResolver(
{"customization": {"e": {"font": "4x6-font.ttf"}}}, {})
assert not r.style("e", classic_font="4x6-font.ttf",
classic_size=6).user_forced
assert r.style("e", classic_font="PressStart2P-Regular.ttf",
classic_size=6).user_forced
# ---------------------------------------------------------------------------
# Resolver plumbing the consumers rely on
# ---------------------------------------------------------------------------
class TestResolverPlumbing:
def test_config_identity_exposed(self, style_schema_path):
# Consumers rebuild the resolver when the config dict is swapped:
# `resolver._config is not self.config`.
config = {"customization": {}}
r = _resolver(config, style_schema_path)
assert r._config is config
def test_font_path_resolution_is_cwd_independent(self, tmp_path,
monkeypatch):
monkeypatch.chdir(tmp_path) # no assets/fonts under cwd
path = resolve_font_path("PressStart2P-Regular.ttf")
assert path is not None and os.path.isfile(path)
font = load_font("PressStart2P-Regular.ttf", 8)
assert isinstance(font, ImageFont.FreeTypeFont)
def test_bdf_font_loads_as_freetype_face(self):
import freetype
font = load_font("5x7.bdf", 7)
assert isinstance(font, freetype.Face)
def test_schema_manager_expands_on_load(self, tmp_path):
# The web-UI form path: SchemaManager.load_schema serves the
# expanded schema so the style blocks actually appear in the UI.
from src.plugin_system.schema_manager import SchemaManager
plugin_dir = tmp_path / "plugins" / "styled"
plugin_dir.mkdir(parents=True)
(plugin_dir / "config_schema.json").write_text(
json.dumps(STYLE_ELEMENTS_SCHEMA))
(plugin_dir / "manifest.json").write_text(json.dumps({
"id": "styled", "config_schema": "config_schema.json"}))
manager = SchemaManager(plugins_dir=tmp_path / "plugins",
project_root=tmp_path)
schema = manager.load_schema("styled")
assert schema is not None
cust = schema["properties"]["customization"]["properties"]
assert cust["title_text"]["x-style-managed"] is True
assert "title_text" in cust["layout"]["properties"]