diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1b3a7f71..fd982e2f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,6 +5,10 @@ on: push: branches: [main] +# Both jobs only check out the repo and run pytest. +permissions: + contents: read + jobs: plugin-safety: name: Plugin safety harness + unit tests @@ -31,3 +35,41 @@ jobs: test/plugins/test_harness.py \ test/plugins/test_visual_rendering.py \ test/plugins/test_plugin_matrix.py + + unit-tests: + name: Core unit tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 + with: + python-version: "3.12" + cache: pip + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt -r requirements-test.txt + pip install RGBMatrixEmulator + + # Safety net for the shared sports/scroll/style infrastructure. These + # suites existed but were not enrolled in CI, so a refactor of + # src/base_classes or src/common could regress them silently. Enrolled + # explicitly (not `pytest test/`) so known hardware-only suites don't + # break CI; grow this list as more suites are made headless. + - name: Run core unit suites + run: | + pytest --no-cov \ + test/test_skin_system.py \ + test/test_font_manager.py \ + test/test_data_sources.py \ + test/test_api_extractors.py \ + test/test_scroll_helper.py \ + test/test_scroll_helper_continuous.py \ + test/test_adaptive_layout.py \ + test/test_loader_compat_warning.py \ + test/test_sports_base_characterization.py \ + test/test_element_style.py diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..ecfe6c59 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,37 @@ +# Changelog + +Notable changes to the LEDMatrix core. The version below is the value of +`src.__version__`, which the plugin loader reports to compatibility checks and +which plugin manifests reference via `ledmatrix_min_version`. + +**Why this file exists:** the plugin monorepo bundles fallback copies of several +core modules (see `docs/plugin-development/08-shared-sports-code.md` in +[ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins)). A plugin +may delete its bundled copy only when its manifest floors on the first core +release that ships the module — which requires module additions to be recorded +here, against a version number. When you add a module plugins will import via +`src.*`, note it in the Unreleased section and bump `src/__init__.py` in the +release that ships it. + +## Unreleased + +### Added +- `src/element_style.py` — per-element style resolver backing the + `x-style-elements` config-schema extension. Already consumed (behind guarded + imports with classic fallbacks) by the `of-the-day`, `ledmatrix-music`, and + `football-scoreboard` plugins. +- Core unit-test CI job enrolling the previously unenrolled suites (skin + system, data sources, API extractors, scroll helper, adaptive layout, loader + compatibility warning) plus new characterization tests for + `src/base_classes/sports.py` ahead of the shared sports-code unification. + +### Fixed +- `FontManager` resolves `assets/fonts` against the core install root instead + of the process working directory, so font loading works when the process + starts elsewhere (e.g. the plugin safety harness on CI). + +## 3.1.0 + +Baseline for this changelog. Highlights already shipped at this version: +skin system for sports scoreboards (#419), Vegas continuous-scroll overhaul +(#423), plugin update surfacing (#421). diff --git a/src/element_style.py b/src/element_style.py new file mode 100644 index 00000000..1d9a08ea --- /dev/null +++ b/src/element_style.py @@ -0,0 +1,628 @@ +""" +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/`` + 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/`` 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 + # A relative name must be a bare filename. font_name comes from plugin + # config, which the web UI writes; a value like "../../config/config.json" + # would otherwise escape assets/fonts/ once joined and let a config probe + # arbitrary paths for existence. os.path.basename collapses any such value + # to its last component, so a name that isn't already bare is rejected. + if os.path.basename(font_name) != font_name: + return 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. 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": {"": {"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.`` (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..`` 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 diff --git a/src/font_manager.py b/src/font_manager.py index 08a3f31e..8be283be 100644 --- a/src/font_manager.py +++ b/src/font_manager.py @@ -659,6 +659,25 @@ class FontManager: # ==================== Font Discovery ==================== + @staticmethod + def _resolve_asset_path(relative_path: str) -> str: + """Resolve a repo-relative asset path independently of the process cwd. + + Prefers the working directory (preserving behavior when the process + runs from the install root), then falls back to the install root + derived from this module's own location. Without the fallback, any + process started outside the install root (e.g. the plugin safety + harness on CI) silently loses every font and degrades to PIL's + default face. + """ + if os.path.exists(relative_path): + return relative_path + install_root = Path(__file__).resolve().parent.parent + candidate = install_root / relative_path + if candidate.exists(): + return str(candidate) + return relative_path + def _initialize_fonts(self): """Initialize font catalog and validate configuration.""" self._scan_fonts_directory() @@ -667,7 +686,7 @@ class FontManager: def _scan_fonts_directory(self): """Scan assets/fonts directory for available fonts.""" - fonts_dir = "assets/fonts" + fonts_dir = self._resolve_asset_path("assets/fonts") if not os.path.exists(fonts_dir): logger.warning(f"Fonts directory not found: {fonts_dir}") return @@ -683,6 +702,7 @@ class FontManager: def _register_common_fonts(self): """Register common font aliases from common_fonts dictionary.""" for family_name, font_path in self.common_fonts.items(): + font_path = self._resolve_asset_path(font_path) # Check if font file exists if os.path.exists(font_path): # Register the common font name (overrides auto-generated name if exists) diff --git a/src/plugin_system/schema_manager.py b/src/plugin_system/schema_manager.py index 3ca80630..5b894100 100644 --- a/src/plugin_system/schema_manager.py +++ b/src/plugin_system/schema_manager.py @@ -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 diff --git a/test/test_element_style.py b/test/test_element_style.py new file mode 100644 index 00000000..8d0535ab --- /dev/null +++ b/test/test_element_style.py @@ -0,0 +1,412 @@ +""" +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) + + @pytest.mark.parametrize("hostile", [ + "../../config/config.json", + "../secrets.txt", + "sub/dir/font.ttf", + "..", + ]) + def test_relative_font_name_with_path_components_is_rejected(self, hostile): + # font_name comes from plugin config (web-UI writable); a relative name + # carrying path separators would escape assets/fonts/ after os.path.join + # and let a config probe arbitrary paths. Only bare filenames resolve. + assert resolve_font_path(hostile) is None + + def test_bare_filename_still_resolves(self): + # The guard must not reject legitimate bare names. + assert resolve_font_path("PressStart2P-Regular.ttf") is not None + + 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"] diff --git a/test/test_sports_base_characterization.py b/test/test_sports_base_characterization.py new file mode 100644 index 00000000..55411f8e --- /dev/null +++ b/test/test_sports_base_characterization.py @@ -0,0 +1,628 @@ +"""Characterization tests for src/base_classes/sports.py. + +These tests PIN the current behavior of SportsCore / SportsUpcoming / +SportsRecent / SportsLive ahead of the sports-unification merge (features +from nine drifted plugin copies are about to be folded in). They assert +what the code DOES today, not what it should do — a few pinned behaviors +look like bugs and are flagged inline with "PINNED AS-IS". + +Coverage: +- `_extract_game_details_common` + the four sport extractors + (football/hockey/baseball/basketball) against realistic ESPN scoreboard + events (adapted from the ledmatrix-plugins monorepo test fixtures). + The output must remain a superset of the frozen skin view-model + contract (GUARANTEED_KEYS, imported from test_skin_system). +- update() flow for concrete SportsUpcoming/SportsRecent/SportsLive + subclasses: population, favorite-team filtering, empty/failed-fetch + tolerance. All offline: `_fetch_data` reads a pre-seeded mocked cache + and every instance's requests session raises ConnectionError. +- Rendering smoke: one `display()` per mode class at 128x32 draws + non-zero ink onto a real PIL image. +- Guard rails: the skin-system seam methods on SportsCore must survive + the merge. +""" + +import logging +import sys +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +import pytz +import requests +from freezegun import freeze_time +from PIL import Image + +# src.base_classes.sports transitively imports the hardware matrix driver; +# stub it so these tests can import the sports base classes off-device. +sys.modules.setdefault("rgbmatrix", MagicMock()) + +from src.base_classes.baseball import Baseball +from src.base_classes.basketball import Basketball +from src.base_classes.football import Football +from src.base_classes.hockey import Hockey, HockeyLive +from src.base_classes.sports import ( + SportsCore, + SportsLive, + SportsRecent, + SportsUpcoming, +) + +# Reuse the frozen v1.0 skin view-model contract rather than redeclaring it. +from test.test_skin_system import GUARANTEED_KEYS + +SPORT_CLASSES = [Football, Hockey, Baseball, Basketball] +SPORT_IDS = ["football", "hockey", "baseball", "basketball"] + +# All update()-flow tests run at this frozen instant so the 21-day +# SportsRecent window and time.time() interval gates are deterministic. +FROZEN_NOW = "2026-01-20 12:00:00" + + +# --------------------------------------------------------------------------- +# ESPN scoreboard event builders (shape adapted from the monorepo fixtures, +# e.g. ledmatrix-plugins/plugins/hockey-scoreboard/test/fixtures/mock.json: +# team-shaped competitors with status/score/records). +# --------------------------------------------------------------------------- + +def _competitor(abbr, team_id, score, home_away, record="30-10-5"): + return { + "homeAway": home_away, + "id": team_id, + "score": score, + "team": { + "id": team_id, + "abbreviation": abbr, + "name": abbr.title(), + "displayName": abbr.title(), + "logo": None, + }, + "records": [{"summary": record}], + # The hockey extractor iterates competitor["statistics"] and + # returns None for the whole event when the key is absent (see + # test_hockey_event_without_statistics_returns_none). + "statistics": [], + } + + +def make_event(event_id, state, date, home=("TB", "20", "3"), + away=("DAL", "9", "2"), period=2, clock="12:45", + name=None, short_detail=None, situation=None, + home_record="30-10-5", away_record="25-14-6"): + """Build a realistic ESPN scoreboard event in the given state + ('in' / 'post' / 'pre').""" + defaults = { + "in": ("STATUS_IN_PROGRESS", f"P{period} {clock}"), + "post": ("STATUS_FINAL", "Final"), + "pre": ("STATUS_SCHEDULED", "1/15 - 6:30 PM"), + } + default_name, default_detail = defaults[state] + status = { + "clock": 0.0, + "displayClock": clock, + "period": period, + "type": { + "id": "2", + "name": name or default_name, + "state": state, + "completed": state == "post", + "description": short_detail or default_detail, + "detail": short_detail or default_detail, + "shortDetail": short_detail or default_detail, + }, + } + competition = { + "id": event_id, + "date": date, + "status": status, + "competitors": [ + _competitor(home[0], home[1], home[2], "home", home_record), + _competitor(away[0], away[1], away[2], "away", away_record), + ], + } + if situation is not None: + competition["situation"] = situation + return { + "id": event_id, + "date": date, + "name": f"{away[0]} at {home[0]}", + "shortName": f"{away[0]} @ {home[0]}", + "competitions": [competition], + # Real ESPN payloads duplicate status at the event top level; the + # baseball extractor reads it there for live innings. + "status": status, + } + + +def make_probe(favorites=None): + """Bare-bones SportsCore stand-in for exercising the real extractors + unbound (same pattern as TestViewModelContract in test_skin_system).""" + probe = MagicMock() + probe.logger = logging.getLogger("test_sports_base_characterization") + probe.favorite_teams = list(favorites or []) + probe.config = {} + probe.logo_dir = Path("assets/logos") + probe._get_timezone.return_value = pytz.utc + probe.display_manager.format_date_with_ordinal.return_value = "Jan 15th" + # The sport extractors call self._extract_game_details_common — route + # it to the real implementation instead of a MagicMock. + probe._extract_game_details_common = ( + lambda event: SportsCore._extract_game_details_common(probe, event)) + return probe + + +def extract(sport_cls, event, favorites=None): + return sport_cls._extract_game_details(make_probe(favorites), event) + + +# --------------------------------------------------------------------------- +# 1. _extract_game_details_common contract, per wired sport +# --------------------------------------------------------------------------- + +class TestExtractGameDetailsContract: + @pytest.mark.parametrize("sport_cls", SPORT_CLASSES, ids=SPORT_IDS) + def test_live_event_guaranteed_keys_and_values(self, sport_cls): + event = make_event("401", "in", "2026-01-15T18:30:00Z") + details = extract(sport_cls, event) + assert details is not None + missing = [k for k in GUARANTEED_KEYS if k not in details] + assert not missing, ( + f"{sport_cls.__name__} extractor no longer emits {missing} — " + "these keys are the frozen skin view-model contract.") + assert details["id"] == "401" + assert details["home_abbr"] == "TB" + assert details["away_abbr"] == "DAL" + assert details["home_id"] == "20" + assert details["away_id"] == "9" + assert details["home_score"] == "3" + assert details["away_score"] == "2" + assert details["home_record"] == "30-10-5" + assert details["away_record"] == "25-14-6" + assert details["is_live"] is True + assert details["is_final"] is False + assert details["is_upcoming"] is False + assert details["status_text"] == "P2 12:45" + assert details["start_time_utc"] == datetime( + 2026, 1, 15, 18, 30, tzinfo=timezone.utc) + # Sport-specific formatting of the same event: + if sport_cls in (Football, Basketball): + assert details["period_text"] == "Q2" + assert details["clock"] == "12:45" + elif sport_cls is Hockey: + assert details["period_text"] == "P2" + assert details["clock"] == "12:45" + else: # Baseball keys inning/status instead of period_text + assert details["inning"] == 2 + assert details["status_state"] == "in" + + @pytest.mark.parametrize("sport_cls", SPORT_CLASSES, ids=SPORT_IDS) + def test_final_event_classification(self, sport_cls): + event = make_event("402", "post", "2026-01-14T00:00:00Z", + home=("BOS", "1", "4"), away=("TOR", "21", "2"), + period=3, clock="0:00") + details = extract(sport_cls, event) + assert details is not None + assert details["is_final"] is True + assert details["is_live"] is False + assert details["is_upcoming"] is False + assert details["home_score"] == "4" + assert details["away_score"] == "2" + if sport_cls in (Football, Hockey, Basketball): + assert details["period_text"] == "Final" + + @pytest.mark.parametrize("sport_cls", SPORT_CLASSES, ids=SPORT_IDS) + def test_upcoming_event_classification(self, sport_cls): + event = make_event("403", "pre", "2026-01-15T18:30:00Z", + home=("NYR", "13", "0"), away=("PIT", "16", "0"), + period=0, clock="0:00") + details = extract(sport_cls, event) + assert details is not None + assert details["is_upcoming"] is True + assert details["is_live"] is False + assert details["is_final"] is False + # Local time formatting (probe timezone is UTC): 18:30Z -> 6:30PM, + # date rendered through display_manager.format_date_with_ordinal. + assert details["game_time"] == "6:30PM" + assert details["game_date"] == "Jan 15th" + + def test_halftime_state_flags(self): + # is_halftime keys off name STATUS_HALFTIME (or state "halftime") + # while state "in" still counts as live. + event = make_event("404", "in", "2026-01-15T18:30:00Z", + name="STATUS_HALFTIME", short_detail="Halftime") + details, *_ = SportsCore._extract_game_details_common( + make_probe(), event) + assert details["is_live"] is True + assert details["is_halftime"] is True + + def test_state_name_conflict_is_both_final_and_upcoming(self): + # PINNED AS-IS (looks like a bug): is_upcoming also matches on + # status.type.name ('scheduled'/'pre-game'/'status_scheduled'), so + # an event with state="post" but name="Scheduled" reports BOTH + # is_final and is_upcoming True. + event = make_event("405", "post", "2026-01-14T00:00:00Z", + name="Scheduled") + details, *_ = SportsCore._extract_game_details_common( + make_probe(), event) + assert details["is_final"] is True + assert details["is_upcoming"] is True + + def test_zero_zero_record_blanked(self): + event = make_event("406", "pre", "2026-01-15T18:30:00Z", + home_record="0-0", away_record="0-0-0") + details, *_ = SportsCore._extract_game_details_common( + make_probe(), event) + assert details["home_record"] == "" + assert details["away_record"] == "" + + def test_missing_abbreviation_uses_name_prefix(self): + event = make_event("407", "pre", "2026-01-15T18:30:00Z") + for comp in event["competitions"][0]["competitors"]: + del comp["team"]["abbreviation"] + comp["team"]["name"] = "Sharks" if comp["homeAway"] == "home" \ + else "Penguins" + details, *_ = SportsCore._extract_game_details_common( + make_probe(), event) + assert details["home_abbr"] == "Sha" + assert details["away_abbr"] == "Pen" + + def test_empty_or_malformed_event_returns_none_tuple(self): + probe = make_probe() + assert SportsCore._extract_game_details_common(probe, {}) == \ + (None, None, None, None, None) + assert SportsCore._extract_game_details_common(probe, None) == \ + (None, None, None, None, None) + # Malformed event (no competitions) is swallowed, not raised. + assert SportsCore._extract_game_details_common( + probe, {"id": "999"}) == (None, None, None, None, None) + + def test_football_live_situation_fields(self): + event = make_event( + "408", "in", "2026-01-15T18:30:00Z", + situation={ + "shortDownDistanceText": "3rd & 4", + "downDistanceText": "3rd & 4 at TB 30", + "isRedZone": False, + "possession": "20", + "homeTimeouts": 2, + "awayTimeouts": 3, + }) + details = extract(Football, event) + assert details["down_distance_text"] == "3rd & 4" + assert details["down_distance_text_long"] == "3rd & 4 at TB 30" + assert details["possession"] == "20" + assert details["possession_indicator"] == "home" # matches home id + assert details["home_timeouts"] == 2 + assert details["away_timeouts"] == 3 + + def test_hockey_live_power_play_and_default_shots(self): + event = make_event("409", "in", "2026-01-15T18:30:00Z", + situation={"isPowerPlay": True, "penalties": ""}) + details = extract(Hockey, event) + assert details["power_play"] is True + # Empty statistics arrays -> save-percentage math yields 0 shots. + assert details["home_shots"] == 0 + assert details["away_shots"] == 0 + + def test_hockey_event_without_statistics_returns_none(self): + # PINNED AS-IS: the hockey extractor unconditionally iterates + # competitor["statistics"]; a competitor without the key raises + # KeyError internally and the WHOLE event is dropped (returns + # None), even though scores/status are present. + event = make_event("410", "in", "2026-01-15T18:30:00Z") + for comp in event["competitions"][0]["competitors"]: + del comp["statistics"] + assert extract(Hockey, event) is None + + def test_baseball_live_inning_and_count(self): + event = make_event( + "411", "in", "2026-07-16T23:05:00Z", + home=("LAD", "19", "5"), away=("SF", "26", "3"), + period=7, short_detail="Bot 7th", + situation={ + "count": {"balls": 2, "strikes": 1}, + "outs": 2, + "onFirst": True, + "onSecond": False, + "onThird": True, + }) + details = extract(Baseball, event) + assert details["inning"] == 7 # from top-level status period + assert details["inning_half"] == "bottom" + assert details["balls"] == 2 + assert details["strikes"] == 1 + assert details["outs"] == 2 + assert details["bases_occupied"] == [True, False, True] + assert details["status"] == "status_in_progress" + assert details["series_summary"] == "" + + def test_baseball_live_without_top_level_status_returns_none(self): + # PINNED AS-IS: for live games the baseball extractor reads + # game_event["status"] (the event TOP-LEVEL status, not the + # competition status) for the inning; an otherwise-valid live + # event lacking that duplicate key is dropped entirely. + event = make_event("412", "in", "2026-07-16T23:05:00Z") + del event["status"] + assert extract(Baseball, event) is None + + +# --------------------------------------------------------------------------- +# 2. update() flow on concrete subclasses (offline, cache-fed) +# --------------------------------------------------------------------------- + +class _UpcomingHarness(Hockey, SportsUpcoming): + """Cheapest concrete SportsUpcoming: hockey extractor + cache-fed data.""" + + def _fetch_data(self): + return self.cache_manager.get(f"{self.sport_key}_schedule") + + +class _RecentHarness(Hockey, SportsRecent): + def _fetch_data(self): + return self.cache_manager.get(f"{self.sport_key}_schedule") + + +class _LiveHarness(HockeyLive): + def _fetch_data(self): + return self.cache_manager.get(f"{self.sport_key}_schedule") + + +def make_schedule(): + """A mixed schedule around the frozen 'now' of 2026-01-20.""" + return {"events": [ + # Final 6 days ago — inside the recent 21-day window. + make_event("9001", "post", "2026-01-14T00:00:00Z", + home=("BOS", "1", "4"), away=("TOR", "21", "2"), + period=3, clock="0:00"), + # Live game. + make_event("9002", "in", "2026-01-15T00:30:00Z", + home=("TB", "20", "3"), away=("DAL", "9", "2")), + # Two scheduled games. + make_event("9003", "pre", "2026-01-16T00:00:00Z", + home=("NYR", "13", "0"), away=("PIT", "16", "0"), + period=0), + make_event("9004", "pre", "2026-01-17T00:00:00Z", + home=("BOS", "1", "0"), away=("MTL", "10", "0"), + period=0), + # Final from November — outside the recent 21-day window. + make_event("9005", "post", "2025-11-01T00:00:00Z", + home=("SEA", "124292", "1"), away=("VAN", "22", "5"), + period=3, clock="0:00"), + ]} + + +@pytest.fixture +def build_manager(monkeypatch, tmp_path): + """Factory for concrete sports managers: mocked display/cache managers, + logo dir redirected to tmp, background service stubbed, and the + requests session rigged to prove nothing hits the network.""" + monkeypatch.setattr( + SportsCore, "_initialize_logo_dir", lambda self, configured: tmp_path) + monkeypatch.setattr( + "src.base_classes.sports.get_background_service", + lambda *args, **kwargs: MagicMock()) + + # Rig requests.Session BEFORE any manager is built. Construction creates + # both SportsCore.session and the ESPNDataSource.session; replacing only + # manager.session after the fact (below) leaves data_source.session real, + # so an accidental fetch during or right after construction could reach + # the network. Patching the class makes every session created here raise. + def _offline_get(*args, **kwargs): + raise requests.exceptions.ConnectionError( + "characterization tests are offline") + + monkeypatch.setattr(requests.Session, "get", _offline_get) + + def build(cls, schedule, **mode_cfg): + config = { + "timezone": "UTC", + "display": {}, + "nhl_scoreboard": {"enabled": True, **mode_cfg}, + } + display_manager = MagicMock() + display_manager.matrix.width = 128 + display_manager.matrix.height = 32 + display_manager.width = 128 + display_manager.height = 32 + display_manager.image = Image.new("RGB", (128, 32)) + display_manager.format_date_with_ordinal.side_effect = ( + lambda dt: dt.strftime("%b %d")) + cache_manager = MagicMock() + cache_manager.get.return_value = schedule + cache_manager.cache_dir = str(tmp_path) + manager = cls(config, display_manager, cache_manager, + logging.getLogger("test_sports_base_characterization"), + "nhl") + # Safety net: any accidental network fetch must fail loudly. + manager.session = MagicMock() + manager.session.get.side_effect = requests.exceptions.ConnectionError( + "characterization tests are offline") + return manager + + return build + + +def _ids(games): + return [g["id"] for g in games] + + +@freeze_time(FROZEN_NOW) +class TestUpcomingUpdateFlow: + def test_populates_games_list_sorted_by_start_time(self, build_manager): + manager = build_manager(_UpcomingHarness, make_schedule()) + manager.update() + # PINNED AS-IS: SportsUpcoming filters purely on is_upcoming + # (state 'pre') — there is NO date filter, so 'pre' games whose + # start time is already in the past (9003/9004 vs frozen 1/20) + # are still shown. + assert _ids(manager.games_list) == ["9003", "9004"] + assert manager.current_game["id"] == "9003" + + def test_filters_by_favorite_teams(self, build_manager): + manager = build_manager(_UpcomingHarness, make_schedule(), + show_favorite_teams_only=True, + favorite_teams=["BOS"]) + manager.update() + assert _ids(manager.games_list) == ["9004"] + assert manager.current_game["id"] == "9004" + + def test_favorites_only_with_no_favorites_shows_nothing( + self, build_manager): + # PINNED AS-IS: show_favorite_teams_only=True with an empty + # favorite_teams list drops every game rather than falling back + # to showing all games. + manager = build_manager(_UpcomingHarness, make_schedule(), + show_favorite_teams_only=True, + favorite_teams=[]) + manager.update() + assert manager.games_list == [] + assert manager.current_game is None + + def test_caps_at_upcoming_games_to_show(self, build_manager): + manager = build_manager(_UpcomingHarness, make_schedule(), + upcoming_games_to_show=1) + manager.update() + assert _ids(manager.games_list) == ["9003"] + + def test_tolerates_empty_events_list(self, build_manager): + manager = build_manager(_UpcomingHarness, {"events": []}) + manager.update() # must not raise + assert manager.games_list == [] + assert manager.current_game is None + + def test_tolerates_fetch_returning_none(self, build_manager): + manager = build_manager(_UpcomingHarness, None) + manager.update() # must not raise + assert manager.games_list == [] + assert manager.current_game is None + + def test_disabled_manager_update_is_noop(self, build_manager): + manager = build_manager(_UpcomingHarness, make_schedule(), + enabled=False) + manager.update() + assert manager.games_list == [] + manager.cache_manager.get.assert_not_called() + + +@freeze_time(FROZEN_NOW) +class TestRecentUpdateFlow: + def test_populates_only_finals_within_21_day_window(self, build_manager): + manager = build_manager(_RecentHarness, make_schedule()) + manager.update() + # 9001 (final, 6 days old) kept; 9005 (final, ~80 days old) + # excluded by the 21-day cutoff; live/pre games excluded. + assert _ids(manager.games_list) == ["9001"] + assert manager.current_game["id"] == "9001" + assert manager.current_game["is_final"] is True + + def test_filters_by_favorite_teams(self, build_manager): + manager = build_manager(_RecentHarness, make_schedule(), + show_favorite_teams_only=True, + favorite_teams=["TOR"]) + manager.update() + assert _ids(manager.games_list) == ["9001"] + + stranger = build_manager(_RecentHarness, make_schedule(), + show_favorite_teams_only=True, + favorite_teams=["XXX"]) + stranger.update() + assert stranger.games_list == [] + assert stranger.current_game is None + + def test_tolerates_empty_events_list(self, build_manager): + manager = build_manager(_RecentHarness, {"events": []}) + manager.update() # must not raise + assert manager.games_list == [] + assert manager.current_game is None + + +@freeze_time(FROZEN_NOW) +class TestLiveUpdateFlow: + def test_selects_only_live_games(self, build_manager): + manager = build_manager(_LiveHarness, make_schedule()) + manager.update() + assert _ids(manager.live_games) == ["9002"] + assert manager.current_game["id"] == "9002" + assert manager.current_game["is_live"] is True + + def test_no_live_games_clears_current_game(self, build_manager): + schedule = {"events": [ + make_event("9001", "post", "2026-01-14T00:00:00Z"), + make_event("9003", "pre", "2026-01-16T00:00:00Z", period=0), + ]} + manager = build_manager(_LiveHarness, schedule) + manager.update() + assert manager.live_games == [] + assert manager.current_game is None + + +# --------------------------------------------------------------------------- +# 3. Rendering smoke — one display() per mode class at 128x32 +# --------------------------------------------------------------------------- + +def _fake_logo(*args, **kwargs): + return Image.new("RGBA", (24, 24), (180, 30, 30, 255)) + + +@freeze_time(FROZEN_NOW) +class TestRenderingSmoke: + def _assert_rendered(self, manager): + manager.display_manager.update_display.assert_called() + assert manager.display_manager.image.convert("L").getbbox() is not None + + def test_upcoming_display_draws_ink(self, build_manager): + manager = build_manager(_UpcomingHarness, make_schedule()) + manager.update() + manager._load_and_resize_logo = _fake_logo + assert manager.display(force_clear=True) is True + self._assert_rendered(manager) + + def test_recent_display_draws_ink(self, build_manager): + manager = build_manager(_RecentHarness, make_schedule()) + manager.update() + manager._load_and_resize_logo = _fake_logo + assert manager.display(force_clear=True) is True + self._assert_rendered(manager) + + def test_live_display_draws_ink(self, build_manager): + manager = build_manager(_LiveHarness, make_schedule()) + manager.update() + manager._load_and_resize_logo = _fake_logo + assert manager.display(force_clear=True) is True + self._assert_rendered(manager) + + def test_draw_scorebug_layout_direct_call_does_not_raise( + self, build_manager): + # The base-class placeholder renderer must also stay callable. + manager = build_manager(_UpcomingHarness, make_schedule()) + game = manager._extract_game_details(make_schedule()["events"][2]) + manager._load_and_resize_logo = _fake_logo + SportsCore._draw_scorebug_layout(manager, game) + assert manager.display_manager.image.convert("L").getbbox() is not None + + +# --------------------------------------------------------------------------- +# 4. Guard rails — seams the merge must not silently drop +# --------------------------------------------------------------------------- + +class TestGuardRails: + def test_skin_seam_methods_survive(self): + for name in ("_resolve_skin_id", "_get_skin", "_render_game", + "render_skin_card"): + assert callable(getattr(SportsCore, name, None)), ( + f"SportsCore.{name} is part of the skin-system seam " + "(src/skin_system) — the sports-unification merge must " + "keep it.") + + def test_skin_mode_per_class(self): + assert SportsCore.SKIN_MODE == "live" + assert SportsUpcoming.SKIN_MODE == "upcoming" + assert SportsRecent.SKIN_MODE == "recent" + assert SportsLive.SKIN_MODE == "live" # inherits the default + + def test_core_display_and_extractor_seams_survive(self): + for name in ("display", "_draw_scorebug_layout", + "_extract_game_details_common", "update"): + owner = SportsCore if name != "update" else SportsUpcoming + assert callable(getattr(owner, name, None)), name