mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-03 01:38:06 +00:00
Sports unification phase 0: safety net, cwd-independent fonts, element_style (#425)
* fix(fonts): resolve asset paths against the install root, not the cwd
FontManager built its catalog from cwd-relative paths ('assets/fonts'),
so any process started outside the install root — the plugin safety
harness on CI being the recurring case — found no fonts and silently
degraded every plugin to PIL's default face. Several plugins grew
per-plugin workarounds for exactly this (countdown, text-display,
tide-display in the plugins monorepo).
Catalog population now falls back to the install root derived from this
module's location when the cwd-relative path is missing; behavior when
running from the install root is unchanged. Verified: resolve_font
returns the real FreeType face from a foreign cwd, and the full unit
suites (266 tests) pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* docs: seed CHANGELOG.md with the module-availability release discipline
The plugins monorepo's sunset rule ('delete a bundled fallback copy only
when the manifest floors on the first core release shipping the module')
needs core module additions recorded against version numbers. Seeds the
changelog at 3.1.0 and documents the discipline.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* ci: enroll the core unit suites in a dedicated job
The existing workflow ran only the three plugin-harness suites; the
skin-system, font-manager, data-source, extractor, scroll-helper,
adaptive-layout, and loader-compat suites (266 tests) existed but never
ran in CI, so a refactor of src/base_classes or src/common could regress
them silently. Also enrolls the new sports characterization and
element-style suites landing in this branch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* 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
* test: characterization suite for src/base_classes/sports.py ahead of unification
Pins current behavior before the planned merge of the nine drifted
plugin copies back into this ancestor: the _extract_game_details_common
key contract per sport (reusing GUARANTEED_KEYS from the skin tests),
update() flows for upcoming/recent/live against cache-seeded fixtures
under frozen time, rendering smoke per mode class, and guard rails on
the skin-system seam.
Five surprising behaviors are pinned AS-IS and flagged in comments so
the merge changes them knowingly or not at all: is_upcoming also
matching status.type.name; hockey dropping events whose competitors
lack 'statistics'; baseball reading the event-level status for innings;
no past-date filter in upcoming; and favorites-only mode with an empty
favorites list showing nothing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* ci: restrict the test workflow's GITHUB_TOKEN to contents:read
CodeQL flagged the new unit-tests job for running with the default
unrestricted token; the pre-existing job had the same exposure. Both
jobs only check out the repo and run pytest, so a workflow-level
contents:read is sufficient.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* Address CodeRabbit review: font-name traversal + offline test guard
Two Minor findings from CodeRabbit's first review of this PR.
- resolve_font_path: reject relative font names carrying path components.
font_name comes from plugin config, which the web UI writes; a value like
"../../config/config.json" escaped assets/fonts/ after os.path.join and let
a config probe arbitrary paths for existence (disclosure unlikely, since
Pillow/freetype reject non-font files, but the probe is real). Relative
names must now be bare filenames (os.path.basename(name) == name); absolute
paths keep their existing isfile() gate. Test confirms the traversal
resolved the real config.json before the guard.
- build_manager fixture: patch requests.Session.get BEFORE constructing the
manager. Construction creates both SportsCore.session and the
ESPNDataSource.session; the old code only replaced manager.session after
the fact, leaving data_source.session real and able to reach the network on
an accidental fetch. Patching the class makes every session built in the
fixture offline.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
---------
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,10 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
|
# Both jobs only check out the repo and run pytest.
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
plugin-safety:
|
plugin-safety:
|
||||||
name: Plugin safety harness + unit tests
|
name: Plugin safety harness + unit tests
|
||||||
@@ -31,3 +35,41 @@ jobs:
|
|||||||
test/plugins/test_harness.py \
|
test/plugins/test_harness.py \
|
||||||
test/plugins/test_visual_rendering.py \
|
test/plugins/test_visual_rendering.py \
|
||||||
test/plugins/test_plugin_matrix.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
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -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/<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
|
||||||
|
# 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.<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
|
||||||
+21
-1
@@ -659,6 +659,25 @@ class FontManager:
|
|||||||
|
|
||||||
# ==================== Font Discovery ====================
|
# ==================== 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):
|
def _initialize_fonts(self):
|
||||||
"""Initialize font catalog and validate configuration."""
|
"""Initialize font catalog and validate configuration."""
|
||||||
self._scan_fonts_directory()
|
self._scan_fonts_directory()
|
||||||
@@ -667,7 +686,7 @@ class FontManager:
|
|||||||
|
|
||||||
def _scan_fonts_directory(self):
|
def _scan_fonts_directory(self):
|
||||||
"""Scan assets/fonts directory for available fonts."""
|
"""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):
|
if not os.path.exists(fonts_dir):
|
||||||
logger.warning(f"Fonts directory not found: {fonts_dir}")
|
logger.warning(f"Fonts directory not found: {fonts_dir}")
|
||||||
return
|
return
|
||||||
@@ -683,6 +702,7 @@ class FontManager:
|
|||||||
def _register_common_fonts(self):
|
def _register_common_fonts(self):
|
||||||
"""Register common font aliases from common_fonts dictionary."""
|
"""Register common font aliases from common_fonts dictionary."""
|
||||||
for family_name, font_path in self.common_fonts.items():
|
for family_name, font_path in self.common_fonts.items():
|
||||||
|
font_path = self._resolve_asset_path(font_path)
|
||||||
# Check if font file exists
|
# Check if font file exists
|
||||||
if os.path.exists(font_path):
|
if os.path.exists(font_path):
|
||||||
# Register the common font name (overrides auto-generated name if exists)
|
# Register the common font name (overrides auto-generated name if exists)
|
||||||
|
|||||||
@@ -115,7 +115,17 @@ class SchemaManager:
|
|||||||
if not isinstance(schema, dict):
|
if not isinstance(schema, dict):
|
||||||
self.logger.error(f"Invalid schema format for {plugin_id}: not a dictionary")
|
self.logger.error(f"Invalid schema format for {plugin_id}: not a dictionary")
|
||||||
return None
|
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
|
# Cache the schema
|
||||||
self._schema_cache[plugin_id] = schema
|
self._schema_cache[plugin_id] = schema
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user