mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-03 01:38:06 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99d0ee2208 | ||
|
|
809d676d3b |
@@ -5,10 +5,6 @@ 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
|
||||||
@@ -35,41 +31,3 @@ 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
|
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
# 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).
|
|
||||||
@@ -88,6 +88,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"timezone": "America/New_York",
|
"timezone": "America/New_York",
|
||||||
|
"target_fps": 100,
|
||||||
"location": {
|
"location": {
|
||||||
"city": "Tampa",
|
"city": "Tampa",
|
||||||
"state": "Florida",
|
"state": "Florida",
|
||||||
|
|||||||
@@ -1,628 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
+1
-21
@@ -659,25 +659,6 @@ 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()
|
||||||
@@ -686,7 +667,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 = self._resolve_asset_path("assets/fonts")
|
fonts_dir = "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
|
||||||
@@ -702,7 +683,6 @@ 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)
|
||||||
|
|||||||
@@ -145,6 +145,77 @@ class BasePlugin(ABC):
|
|||||||
"""
|
"""
|
||||||
raise NotImplementedError("Plugins must implement display()")
|
raise NotImplementedError("Plugins must implement display()")
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Global (whole-device) configuration
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
@property
|
||||||
|
def global_config(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
The full LEDMatrix configuration, for reading device-wide settings.
|
||||||
|
|
||||||
|
``self.config`` is only this plugin's own slice, so cross-cutting
|
||||||
|
settings — ``target_fps``, ``timezone``, ``location`` — were previously
|
||||||
|
unreachable from a plugin without reaching into a manager by hand.
|
||||||
|
|
||||||
|
Resolution order mirrors the timezone helpers the sports plugins
|
||||||
|
already ship: ``plugin_manager.config_manager`` first (the cores that
|
||||||
|
hang it there), then ``cache_manager.config_manager``. Returns ``{}``
|
||||||
|
when neither is available, so callers can use plain ``.get()`` without
|
||||||
|
guarding, and a plugin on a core that predates this property still
|
||||||
|
loads — ``getattr(self, 'global_config', {})`` simply yields the
|
||||||
|
default.
|
||||||
|
|
||||||
|
Treat as read-only: the returned dict is the live config the core is
|
||||||
|
using, so mutating it edits every other consumer's view and can be
|
||||||
|
persisted back to disk.
|
||||||
|
|
||||||
|
Assignment is still allowed and wins over the resolved value. Several
|
||||||
|
shipped plugins (news, stock-news, ledmatrix-stocks, ledmatrix-
|
||||||
|
elections, ledmatrix-leaderboard, nfl-draft) set
|
||||||
|
``self.global_config`` to their own ``config['global']`` sub-dict; a
|
||||||
|
property without a setter would raise AttributeError and stop those
|
||||||
|
plugins loading.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
fps = self.global_config.get('target_fps')
|
||||||
|
"""
|
||||||
|
override = getattr(self, '_global_config_override', None)
|
||||||
|
if override is not None:
|
||||||
|
return override
|
||||||
|
for owner in (self.plugin_manager, self.cache_manager):
|
||||||
|
config_manager = getattr(owner, 'config_manager', None)
|
||||||
|
if config_manager is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
config = config_manager.get_config()
|
||||||
|
except Exception:
|
||||||
|
# A broken or unreadable config must never stop a plugin from
|
||||||
|
# loading; fall through to the next source, then to {}.
|
||||||
|
self.logger.debug(
|
||||||
|
"Could not read global config from %s",
|
||||||
|
type(owner).__name__, exc_info=True,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
# Only a real mapping is usable: callers do .get() on this and feed
|
||||||
|
# the result to numeric code, so handing back whatever a stub or a
|
||||||
|
# half-built manager returned would fail later and further away.
|
||||||
|
#
|
||||||
|
# An empty dict is treated as "nothing here yet" rather than a
|
||||||
|
# valid answer, so resolution continues to the next source. Both
|
||||||
|
# managers default to the same config/config.json, so falling
|
||||||
|
# through cannot pick up a different file's settings -- but it does
|
||||||
|
# rescue the case where the first manager simply hasn't loaded yet,
|
||||||
|
# which would otherwise return {} and silently disable every
|
||||||
|
# setting read through this property.
|
||||||
|
if isinstance(config, dict) and config:
|
||||||
|
return config
|
||||||
|
return {}
|
||||||
|
|
||||||
|
@global_config.setter
|
||||||
|
def global_config(self, value: Dict[str, Any]) -> None:
|
||||||
|
"""Let a plugin substitute its own view (see the getter's docstring)."""
|
||||||
|
self._global_config_override = value
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Adaptive layout support (opt-in)
|
# Adaptive layout support (opt-in)
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -116,16 +116,6 @@ class SchemaManager:
|
|||||||
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
|
||||||
|
|
||||||
|
|||||||
@@ -1,412 +0,0 @@
|
|||||||
"""
|
|
||||||
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"]
|
|
||||||
@@ -252,3 +252,123 @@ class TestBasePlugin:
|
|||||||
plugin = ConcretePlugin("test", config, mock_display_manager, mock_cache_manager, None)
|
plugin = ConcretePlugin("test", config, mock_display_manager, mock_cache_manager, None)
|
||||||
|
|
||||||
assert plugin.has_live_priority() is True
|
assert plugin.has_live_priority() is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestBasePluginGlobalConfig:
|
||||||
|
"""global_config exposes device-wide settings that self.config cannot.
|
||||||
|
|
||||||
|
The sports scoreboards read `getattr(self, 'global_config', {})` to find
|
||||||
|
the shared target_fps; before this property existed nothing ever set that
|
||||||
|
attribute, so the lookup silently returned {} and the setting could never
|
||||||
|
take effect on any core.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _plugin(display_manager, cache_manager, plugin_manager=None):
|
||||||
|
from src.plugin_system.base_plugin import BasePlugin
|
||||||
|
|
||||||
|
class ConcretePlugin(BasePlugin):
|
||||||
|
def update(self): pass
|
||||||
|
def display(self, force_clear=False): pass
|
||||||
|
|
||||||
|
return ConcretePlugin(
|
||||||
|
"test", {"enabled": True}, display_manager, cache_manager, plugin_manager
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _manager_with(config):
|
||||||
|
"""A stand-in manager exposing config_manager.get_config()."""
|
||||||
|
manager = MagicMock()
|
||||||
|
manager.config_manager.get_config.return_value = config
|
||||||
|
return manager
|
||||||
|
|
||||||
|
def test_reads_config_from_plugin_manager(self, mock_display_manager, mock_cache_manager):
|
||||||
|
plugin = self._plugin(
|
||||||
|
mock_display_manager, mock_cache_manager,
|
||||||
|
self._manager_with({"target_fps": 100}),
|
||||||
|
)
|
||||||
|
assert plugin.global_config["target_fps"] == 100
|
||||||
|
|
||||||
|
def test_falls_back_to_cache_manager(self, mock_display_manager):
|
||||||
|
# The core that hangs config_manager off the cache manager instead.
|
||||||
|
cache_manager = self._manager_with({"target_fps": 75})
|
||||||
|
plugin = self._plugin(mock_display_manager, cache_manager, plugin_manager=None)
|
||||||
|
assert plugin.global_config["target_fps"] == 75
|
||||||
|
|
||||||
|
def test_plugin_manager_wins_over_cache_manager(self, mock_display_manager):
|
||||||
|
plugin = self._plugin(
|
||||||
|
mock_display_manager,
|
||||||
|
self._manager_with({"target_fps": 75}),
|
||||||
|
self._manager_with({"target_fps": 100}),
|
||||||
|
)
|
||||||
|
assert plugin.global_config["target_fps"] == 100
|
||||||
|
|
||||||
|
def test_empty_plugin_manager_config_falls_through(self, mock_display_manager):
|
||||||
|
"""An empty first source means "not loaded yet", not "the answer".
|
||||||
|
|
||||||
|
Both managers default to the same config/config.json, so falling
|
||||||
|
through cannot pick up a different file. Returning {} here instead
|
||||||
|
would silently disable every setting read through this property --
|
||||||
|
the exact failure this property exists to fix.
|
||||||
|
"""
|
||||||
|
plugin = self._plugin(
|
||||||
|
mock_display_manager,
|
||||||
|
self._manager_with({"target_fps": 100}), # cache_manager
|
||||||
|
self._manager_with({}), # plugin_manager: empty
|
||||||
|
)
|
||||||
|
assert plugin.global_config["target_fps"] == 100
|
||||||
|
|
||||||
|
def test_returns_empty_dict_when_no_config_manager(self, mock_display_manager):
|
||||||
|
# Plain objects: no config_manager attribute at all.
|
||||||
|
plugin = self._plugin(mock_display_manager, object(), object())
|
||||||
|
assert plugin.global_config == {}
|
||||||
|
|
||||||
|
def test_unreadable_config_does_not_raise(self, mock_display_manager):
|
||||||
|
# A plugin must still load when the config on disk is broken.
|
||||||
|
broken = MagicMock()
|
||||||
|
broken.config_manager.get_config.side_effect = OSError("unreadable")
|
||||||
|
plugin = self._plugin(mock_display_manager, broken, broken)
|
||||||
|
assert plugin.global_config == {}
|
||||||
|
|
||||||
|
def test_non_dict_config_is_rejected(self, mock_display_manager):
|
||||||
|
# A stub or half-built manager can return a non-mapping; handing that
|
||||||
|
# back would blow up later in numeric code, far from the cause.
|
||||||
|
plugin = self._plugin(
|
||||||
|
mock_display_manager, object(), self._manager_with("not-a-dict")
|
||||||
|
)
|
||||||
|
assert plugin.global_config == {}
|
||||||
|
|
||||||
|
def test_missing_property_degrades_to_default(self, mock_display_manager, mock_cache_manager):
|
||||||
|
# How plugins actually call it, so a plugin written against this core
|
||||||
|
# still loads on one that predates the property.
|
||||||
|
plugin = self._plugin(mock_display_manager, mock_cache_manager, object())
|
||||||
|
assert getattr(plugin, "global_config", {}).get("target_fps") is None
|
||||||
|
|
||||||
|
def test_plugin_may_still_assign_global_config(self, mock_display_manager, mock_cache_manager):
|
||||||
|
# news, stock-news, ledmatrix-stocks, ledmatrix-elections,
|
||||||
|
# ledmatrix-leaderboard and nfl-draft all do exactly this. Without a
|
||||||
|
# setter the property raises "has no setter" and those plugins stop
|
||||||
|
# loading entirely.
|
||||||
|
from src.plugin_system.base_plugin import BasePlugin
|
||||||
|
|
||||||
|
class AssigningPlugin(BasePlugin):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.global_config = self.config.get("global", {})
|
||||||
|
|
||||||
|
def update(self): pass
|
||||||
|
def display(self, force_clear=False): pass
|
||||||
|
|
||||||
|
plugin = AssigningPlugin(
|
||||||
|
"news", {"enabled": True, "global": {"scroll_speed": 2}},
|
||||||
|
mock_display_manager, mock_cache_manager, self._manager_with({"target_fps": 100}),
|
||||||
|
)
|
||||||
|
# The plugin's own value wins over the resolved config.
|
||||||
|
assert plugin.global_config == {"scroll_speed": 2}
|
||||||
|
|
||||||
|
def test_template_ships_a_global_target_fps(self):
|
||||||
|
# The plumbing is useless if the setting isn't in the shipped config.
|
||||||
|
import json
|
||||||
|
with open("config/config.template.json") as fh:
|
||||||
|
template = json.load(fh)
|
||||||
|
assert template.get("target_fps") == 100
|
||||||
|
|||||||
@@ -1,628 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -167,6 +167,78 @@ class TestConfigAPI:
|
|||||||
'enabled': True, 'copies': 2, 'axis': 'vertical',
|
'enabled': True, 'copies': 2, 'axis': 'vertical',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def test_save_target_fps(self, client, mock_config_manager):
|
||||||
|
"""The device-wide scroll frame rate persists as a top-level int."""
|
||||||
|
response = client.post(
|
||||||
|
'/api/v3/config/main',
|
||||||
|
data={'target_fps': '90'},
|
||||||
|
content_type='application/x-www-form-urlencoded',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
saved = mock_config_manager.save_config_atomic.call_args[0][0]
|
||||||
|
# Must be the coerced int, not the raw form string -- the generic
|
||||||
|
# remaining-keys loop would otherwise write '90' back over it.
|
||||||
|
assert saved['target_fps'] == 90
|
||||||
|
|
||||||
|
def test_save_target_fps_alone_does_not_reset_other_general_settings(
|
||||||
|
self, client, mock_config_manager):
|
||||||
|
"""A target_fps-only POST must not be treated as a full General-tab save.
|
||||||
|
|
||||||
|
The general branch reads web_display_autostart as an unchecked-checkbox
|
||||||
|
(absent means False), so counting target_fps as a general update would
|
||||||
|
silently switch autostart off for anyone setting only the frame rate.
|
||||||
|
"""
|
||||||
|
mock_config_manager.load_config.return_value['web_display_autostart'] = True
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
'/api/v3/config/main',
|
||||||
|
data={'target_fps': '90'},
|
||||||
|
content_type='application/x-www-form-urlencoded',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
saved = mock_config_manager.save_config_atomic.call_args[0][0]
|
||||||
|
assert saved['web_display_autostart'] is True
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('value', [90.5, 90.0, True])
|
||||||
|
def test_save_target_fps_rejects_non_integer_json(self, client, mock_config_manager, value):
|
||||||
|
"""int() would truncate silently: 90.5 -> 90, True -> 1.
|
||||||
|
|
||||||
|
Only JSON can carry these; a form post sends '90.5', which int()
|
||||||
|
already rejects.
|
||||||
|
"""
|
||||||
|
response = client.post(
|
||||||
|
'/api/v3/config/main',
|
||||||
|
data=json.dumps({'target_fps': value}),
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('value', ['20', '250', 'fast'])
|
||||||
|
def test_save_target_fps_rejects_out_of_range(self, client, mock_config_manager, value):
|
||||||
|
"""Values ScrollHelper would silently clamp are reported instead."""
|
||||||
|
response = client.post(
|
||||||
|
'/api/v3/config/main',
|
||||||
|
data={'target_fps': value},
|
||||||
|
content_type='application/x-www-form-urlencoded',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
def test_save_target_fps_accepts_bounds(self, client, mock_config_manager):
|
||||||
|
"""Both endpoints of the documented range are valid."""
|
||||||
|
for value in ('30', '200'):
|
||||||
|
response = client.post(
|
||||||
|
'/api/v3/config/main',
|
||||||
|
data={'target_fps': value},
|
||||||
|
content_type='application/x-www-form-urlencoded',
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, f"{value} should be accepted"
|
||||||
|
saved = mock_config_manager.save_config_atomic.call_args[0][0]
|
||||||
|
assert saved['target_fps'] == int(value)
|
||||||
|
|
||||||
def test_save_double_sided_unchecked_disables(self, client, mock_config_manager):
|
def test_save_double_sided_unchecked_disables(self, client, mock_config_manager):
|
||||||
"""An omitted 'enabled' checkbox is saved as disabled, not left stale."""
|
"""An omitted 'enabled' checkbox is saved as disabled, not left stale."""
|
||||||
response = client.post(
|
response = client.post(
|
||||||
|
|||||||
@@ -747,6 +747,36 @@ def save_main_config():
|
|||||||
if 'timezone' in data:
|
if 'timezone' in data:
|
||||||
current_config['timezone'] = data['timezone']
|
current_config['timezone'] = data['timezone']
|
||||||
|
|
||||||
|
# Device-wide scroll frame rate, read by plugins via
|
||||||
|
# BasePlugin.global_config. Bounds match ScrollHelper.set_target_fps,
|
||||||
|
# which clamps silently -- rejecting here instead means a value that
|
||||||
|
# would have been quietly altered is reported rather than appearing to
|
||||||
|
# save and then behaving differently.
|
||||||
|
if 'target_fps' in data and data['target_fps'] not in ('', None):
|
||||||
|
raw_target_fps = data['target_fps']
|
||||||
|
# A JSON body can carry real floats and bools, where int() would
|
||||||
|
# silently truncate: 90.5 would save as 90, and true as 1. Reject
|
||||||
|
# them rather than storing a value the user did not ask for. Form
|
||||||
|
# posts arrive as strings, so '90.5' still fails in int() below.
|
||||||
|
if isinstance(raw_target_fps, (bool, float)):
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': "Invalid value for target_fps: must be an integer"
|
||||||
|
}), 400
|
||||||
|
try:
|
||||||
|
target_fps = int(raw_target_fps)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': "Invalid value for target_fps: must be an integer"
|
||||||
|
}), 400
|
||||||
|
if not (30 <= target_fps <= 200):
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': "Invalid value for target_fps: must be between 30 and 200"
|
||||||
|
}), 400
|
||||||
|
current_config['target_fps'] = target_fps
|
||||||
|
|
||||||
# Handle location settings
|
# Handle location settings
|
||||||
if 'city' in data or 'state' in data or 'country' in data:
|
if 'city' in data or 'state' in data or 'country' in data:
|
||||||
if 'location' not in current_config:
|
if 'location' not in current_config:
|
||||||
@@ -1282,7 +1312,7 @@ def save_main_config():
|
|||||||
if key in ['timezone', 'city', 'state', 'country',
|
if key in ['timezone', 'city', 'state', 'country',
|
||||||
'web_display_autostart', 'auto_discover',
|
'web_display_autostart', 'auto_discover',
|
||||||
'auto_load_enabled', 'development_mode',
|
'auto_load_enabled', 'development_mode',
|
||||||
'plugins_directory']:
|
'plugins_directory', 'target_fps']:
|
||||||
continue
|
continue
|
||||||
# Skip fields that are already handled above in their own named sections.
|
# Skip fields that are already handled above in their own named sections.
|
||||||
# Without this, every form field name lands as a top-level config key too.
|
# Without this, every form field name lands as a top-level config key too.
|
||||||
|
|||||||
@@ -49,6 +49,18 @@
|
|||||||
<label for="timezone" class="block text-sm font-medium text-gray-700">Timezone{{ ui.help_tip('Time zone used for clocks, schedules, and time-based content.\nChoose the zone where the display physically lives so on/off schedules fire at the correct local time.', 'Timezone') }}</label>
|
<label for="timezone" class="block text-sm font-medium text-gray-700">Timezone{{ ui.help_tip('Time zone used for clocks, schedules, and time-based content.\nChoose the zone where the display physically lives so on/off schedules fire at the correct local time.', 'Timezone') }}</label>
|
||||||
<div id="timezone_container" class="mt-1"></div>
|
<div id="timezone_container" class="mt-1"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Scroll frame rate (device-wide) -->
|
||||||
|
<div class="form-group" id="setting-general-target-fps" data-setting-key="target_fps">
|
||||||
|
<label for="target_fps" class="block text-sm font-medium text-gray-700">Scroll Frame Rate{{ ui.help_tip('Frames per second for scrolling content, applied across plugins that scroll.\nHigher is smoother but uses more CPU; lower frees CPU but looks steppier.\nRange 30-200. Default: 100.', 'Scroll Frame Rate') }}</label>
|
||||||
|
<input type="number"
|
||||||
|
id="target_fps"
|
||||||
|
name="target_fps"
|
||||||
|
value="{{ main_config.target_fps or 100 }}"
|
||||||
|
min="30"
|
||||||
|
max="200"
|
||||||
|
class="form-control">
|
||||||
|
</div>
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
// Track if already initialized to prevent re-render
|
// Track if already initialized to prevent re-render
|
||||||
|
|||||||
Reference in New Issue
Block a user