mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-03 17:58:04 +00:00
* fix(fonts): resolve asset paths against the install root, not the cwd
FontManager built its catalog from cwd-relative paths ('assets/fonts'),
so any process started outside the install root — the plugin safety
harness on CI being the recurring case — found no fonts and silently
degraded every plugin to PIL's default face. Several plugins grew
per-plugin workarounds for exactly this (countdown, text-display,
tide-display in the plugins monorepo).
Catalog population now falls back to the install root derived from this
module's location when the cwd-relative path is missing; behavior when
running from the install root is unchanged. Verified: resolve_font
returns the real FreeType face from a foreign cwd, and the full unit
suites (266 tests) pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* docs: seed CHANGELOG.md with the module-availability release discipline
The plugins monorepo's sunset rule ('delete a bundled fallback copy only
when the manifest floors on the first core release shipping the module')
needs core module additions recorded against version numbers. Seeds the
changelog at 3.1.0 and documents the discipline.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* ci: enroll the core unit suites in a dedicated job
The existing workflow ran only the three plugin-harness suites; the
skin-system, font-manager, data-source, extractor, scroll-helper,
adaptive-layout, and loader-compat suites (266 tests) existed but never
ran in CI, so a refactor of src/base_classes or src/common could regress
them silently. Also enrolls the new sports characterization and
element-style suites landing in this branch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* feat: ship src/element_style — the per-element style resolver plugins already expect
Three plugins (of-the-day, ledmatrix-music, football-scoreboard) import
src.element_style behind guarded try/except with classic fallbacks, but
the module never existed in core, so the richer per-element styling UI
those code paths implement has been dormant. This lands it:
- ElementStyleResolver.style() resolves per-element font/size/color with
the key semantic the consumers encode: a config value counts as
user-forced only when it differs from the schema default (the web UI
bakes defaults into config.json on save), and untouched configs
resolve to exactly the caller's classic values — byte-identical
rendering, proven by of-the-day's committed goldens passing unchanged.
- defaults_from_schema_file parses both declaration forms (the compact
x-style-elements map and hand-written customization blocks).
- expand_style_elements() expands x-style-elements into full config
blocks; schema_manager.load_schema() applies it (guarded, no-op for
schemas without the declaration) so the config form and defaults
merging see the expanded UI.
- Fonts resolve cwd-independently with (path, size) caching; .bdf loads
via freetype like FontManager; nothing in the module raises out of
style().
Verified: 31 new unit tests; of-the-day's previously-skipped 9-test
spec suite now runs and passes; football's resolver tests pass (27);
music's 38 plugin tests pass; schema-manager suites pass (43).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* test: characterization suite for src/base_classes/sports.py ahead of unification
Pins current behavior before the planned merge of the nine drifted
plugin copies back into this ancestor: the _extract_game_details_common
key contract per sport (reusing GUARANTEED_KEYS from the skin tests),
update() flows for upcoming/recent/live against cache-seeded fixtures
under frozen time, rendering smoke per mode class, and guard rails on
the skin-system seam.
Five surprising behaviors are pinned AS-IS and flagged in comments so
the merge changes them knowingly or not at all: is_upcoming also
matching status.type.name; hockey dropping events whose competitors
lack 'statistics'; baseball reading the event-level status for innings;
no past-date filter in upcoming; and favorites-only mode with an empty
favorites list showing nothing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* ci: restrict the test workflow's GITHUB_TOKEN to contents:read
CodeQL flagged the new unit-tests job for running with the default
unrestricted token; the pre-existing job had the same exposure. Both
jobs only check out the repo and run pytest, so a workflow-level
contents:read is sufficient.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* Address CodeRabbit review: font-name traversal + offline test guard
Two Minor findings from CodeRabbit's first review of this PR.
- resolve_font_path: reject relative font names carrying path components.
font_name comes from plugin config, which the web UI writes; a value like
"../../config/config.json" escaped assets/fonts/ after os.path.join and let
a config probe arbitrary paths for existence (disclosure unlikely, since
Pillow/freetype reject non-font files, but the probe is real). Relative
names must now be bare filenames (os.path.basename(name) == name); absolute
paths keep their existing isfile() gate. Test confirms the traversal
resolved the real config.json before the guard.
- build_manager fixture: patch requests.Session.get BEFORE constructing the
manager. Construction creates both SportsCore.session and the
ESPNDataSource.session; the old code only replaced manager.session after
the fact, leaving data_source.session real and able to reach the network on
an accidental fetch. Patching the class makes every session built in the
fixture offline.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
---------
Co-authored-by: Claude <noreply@anthropic.com>
413 lines
18 KiB
Python
413 lines
18 KiB
Python
"""
|
|
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"]
|