mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-14 11:06:39 +00:00
A plugin adds "x-style-modes": ["live", "upcoming", "recent"] alongside its x-style-elements declaration and gets a customization.modes.<mode> group per mode, with every field of every declared element repeated as an override. Those override fields are typed nullable and default to null, which is the whole trick. The save flow writes schema defaults into config.json wholesale, so giving a mode field the base element's default would make every mode a frozen copy of the base the first time a user pressed Save, and the base would stop reaching them. Null means inherit. The mutation test for this is explicit: with concrete defaults, a base font_size of 14 resolves as 10 with user_forced set. min/max from the declaration carry into the mode blocks, so an out-of-range override is rejected by validation rather than clamped silently at render time. Also: the emitted font field now carries "x-widget": "font-selector". The widget already shipped and the config form already allowlisted it -- the hint was simply never emitted, so the field rendered as a bare text box that the user had to type a font filename into. Verified through the real SchemaManager path -- load_schema, defaults extraction, merge_with_defaults, validation, then resolution -- rather than against a hand-built dict, since the thing at risk is what that pipeline does to a null. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
827 lines
37 KiB
Python
827 lines
37 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 copy
|
|
import json
|
|
import os
|
|
|
|
import pytest
|
|
from PIL import ImageFont
|
|
|
|
from src.element_style import (
|
|
ElementStyleResolver,
|
|
_load_font_sized,
|
|
native_bdf_size,
|
|
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"]
|
|
class TestBdfSizing:
|
|
"""A BDF is a fixed-size bitmap strike, not a scalable outline.
|
|
|
|
FreeType accepts only the exact pixel size baked into the file and raises
|
|
for anything else, and 32 of the 35 shipped fonts are BDF -- so a size
|
|
picked in the web UI usually is not a valid strike. This used to fall
|
|
through to the generic font-load except and return *PressStart2P*, so
|
|
asking for 5x7.bdf at size 10 silently rendered a different typeface.
|
|
It now falls back to the file's own size instead, matching what
|
|
SportsCore._load_custom_font_from_element_config already did.
|
|
"""
|
|
|
|
def test_a_valid_strike_loads_at_that_size(self):
|
|
import freetype
|
|
font, realised = _load_font_sized("5x7.bdf", 7)
|
|
assert isinstance(font, freetype.Face)
|
|
assert realised == 7
|
|
|
|
@pytest.mark.parametrize("requested", [4, 10, 16, 32])
|
|
def test_a_wrong_size_keeps_the_font_and_snaps_the_size(self, requested):
|
|
"""The regression: the face must still be 5x7, not a substitute."""
|
|
import freetype
|
|
font, realised = _load_font_sized("5x7.bdf", requested)
|
|
assert isinstance(font, freetype.Face), (
|
|
"a BDF asked for a bad size used to come back as a PIL "
|
|
"PressStart2P face -- a different font entirely")
|
|
assert realised == 7
|
|
|
|
def test_the_reported_size_is_what_was_realised(self, style_schema_path):
|
|
"""ElementStyle.font_size drives caller layout, so it must not report
|
|
a size nothing was drawn at."""
|
|
config = {"customization": {"title_text": {"font": "5x7.bdf",
|
|
"font_size": 20}}}
|
|
style = ElementStyleResolver(
|
|
config, defaults_from_schema_file(style_schema_path)
|
|
).style("title_text", classic_font="PressStart2P-Regular.ttf",
|
|
classic_size=8)
|
|
assert style.font_name == "5x7.bdf"
|
|
assert style.font_size == 7
|
|
|
|
def test_native_bdf_size_reads_the_file(self):
|
|
assert native_bdf_size("5x7.bdf") == 7
|
|
|
|
@pytest.mark.parametrize("name", ["PressStart2P-Regular.ttf",
|
|
"no-such-font.bdf", "", None])
|
|
def test_native_bdf_size_is_none_when_size_is_a_free_choice(self, name):
|
|
"""None is the web UI's signal that the size field stays editable."""
|
|
assert native_bdf_size(name) is None
|
|
|
|
def test_a_scalable_font_realises_the_requested_size(self):
|
|
for size in (6, 8, 13):
|
|
font, realised = _load_font_sized("PressStart2P-Regular.ttf", size)
|
|
assert realised == size
|
|
assert not isinstance(font, type(None))
|
|
|
|
|
|
class TestFontCacheIsBounded:
|
|
"""The display process runs for weeks and every config save can add a new
|
|
(font, size) pair; this cache was unbounded, against the house style of
|
|
every other hot cache in the codebase."""
|
|
|
|
def test_the_cache_evicts_past_its_bound(self):
|
|
import src.element_style as es
|
|
es._font_cache.clear()
|
|
try:
|
|
for size in range(1, es._FONT_CACHE_MAX + 40):
|
|
load_font("PressStart2P-Regular.ttf", size)
|
|
assert len(es._font_cache) <= es._FONT_CACHE_MAX
|
|
finally:
|
|
es._font_cache.clear()
|
|
|
|
def test_a_repeated_load_is_the_same_object(self):
|
|
import src.element_style as es
|
|
es._font_cache.clear()
|
|
try:
|
|
first = load_font("PressStart2P-Regular.ttf", 8)
|
|
assert load_font("PressStart2P-Regular.ttf", 8) is first
|
|
finally:
|
|
es._font_cache.clear()
|
|
|
|
def test_eviction_is_least_recently_used(self):
|
|
import src.element_style as es
|
|
es._font_cache.clear()
|
|
try:
|
|
oldest = load_font("PressStart2P-Regular.ttf", 1)
|
|
for size in range(2, es._FONT_CACHE_MAX + 1):
|
|
load_font("PressStart2P-Regular.ttf", size)
|
|
# Touch the oldest so it is no longer the eviction candidate,
|
|
# then overflow by one.
|
|
assert load_font("PressStart2P-Regular.ttf", 1) is oldest
|
|
load_font("PressStart2P-Regular.ttf", es._FONT_CACHE_MAX + 1)
|
|
assert load_font("PressStart2P-Regular.ttf", 1) is oldest
|
|
finally:
|
|
es._font_cache.clear()
|
|
|
|
class TestModes:
|
|
"""Per-mode overrides: one element styled differently per situation.
|
|
|
|
The motivating case is a scoreboard, where the score wants a bigger font
|
|
on a live card than on an upcoming one. Live/Upcoming/Recent are already
|
|
separate instances with distinct SKIN_MODE values, so the mode is bound
|
|
to the resolver rather than threaded through every call site.
|
|
|
|
A mode layer is pure override: its fields default to None, meaning
|
|
inherit. That is why None and 0 must stay distinct -- a mode y_offset of
|
|
0 means "sit at the base position", not "no preference".
|
|
"""
|
|
|
|
def _resolver(self, config, mode=None, schema=None):
|
|
defaults = defaults_from_schema_file(schema) if schema else {}
|
|
return ElementStyleResolver(config, defaults, mode=mode)
|
|
|
|
def test_no_mode_block_resolves_exactly_as_before(self, style_schema_path):
|
|
config = {"customization": {"title_text": {"font_size": 12}}}
|
|
plain = self._resolver(config, schema=style_schema_path)
|
|
moded = self._resolver(config, mode="live", schema=style_schema_path)
|
|
a = plain.style("title_text", classic_size=8)
|
|
b = moded.style("title_text", classic_size=8)
|
|
assert (a.font_name, a.font_size, a.color) == (b.font_name, b.font_size,
|
|
b.color)
|
|
|
|
def test_a_mode_overrides_the_base_size(self, style_schema_path):
|
|
config = {"customization": {
|
|
"title_text": {"font_size": 12},
|
|
"modes": {"live": {"title_text": {"font_size": 16}}}}}
|
|
r = self._resolver(config, mode="live", schema=style_schema_path)
|
|
assert r.style("title_text", classic_size=8).font_size == 16
|
|
assert r.style("title_text", classic_size=8).user_forced is True
|
|
|
|
def test_a_different_mode_is_unaffected(self, style_schema_path):
|
|
config = {"customization": {
|
|
"title_text": {"font_size": 12},
|
|
"modes": {"live": {"title_text": {"font_size": 16}}}}}
|
|
r = self._resolver(config, mode="upcoming", schema=style_schema_path)
|
|
assert r.style("title_text", classic_size=8).font_size == 12
|
|
|
|
def test_two_resolvers_share_a_config_and_differ_by_mode(
|
|
self, style_schema_path):
|
|
"""The SportsUpcoming / SportsRecent case: same config dict, two
|
|
instances, two answers."""
|
|
config = {"customization": {
|
|
"title_text": {"font_size": 12},
|
|
"modes": {"live": {"title_text": {"font_size": 16}},
|
|
"recent": {"title_text": {"font_size": 6}}}}}
|
|
live = self._resolver(config, mode="live", schema=style_schema_path)
|
|
recent = self._resolver(config, mode="recent", schema=style_schema_path)
|
|
assert live.style("title_text", classic_size=8).font_size == 16
|
|
assert recent.style("title_text", classic_size=8).font_size == 6
|
|
|
|
def test_a_mode_overrides_font_and_colour(self, style_schema_path):
|
|
config = {"customization": {
|
|
"modes": {"live": {"title_text": {"font": "4x6-font.ttf",
|
|
"text_color": [1, 2, 3]}}}}}
|
|
st = self._resolver(config, mode="live",
|
|
schema=style_schema_path).style(
|
|
"title_text", classic_font="PressStart2P-Regular.ttf",
|
|
classic_color=(255, 255, 255))
|
|
assert st.font_name == "4x6-font.ttf"
|
|
assert st.color == (1, 2, 3)
|
|
assert st.user_forced and st.user_forced_color
|
|
|
|
def test_an_unset_mode_field_inherits_rather_than_resetting(
|
|
self, style_schema_path):
|
|
"""Only font_size is overridden; the colour must survive."""
|
|
config = {"customization": {
|
|
"title_text": {"text_color": [9, 9, 9]},
|
|
"modes": {"live": {"title_text": {"font_size": 16}}}}}
|
|
st = self._resolver(config, mode="live",
|
|
schema=style_schema_path).style(
|
|
"title_text", classic_color=(255, 255, 255))
|
|
assert st.font_size == 16
|
|
assert st.color == (9, 9, 9)
|
|
|
|
def test_a_null_mode_field_means_inherit(self, style_schema_path):
|
|
config = {"customization": {
|
|
"title_text": {"font_size": 12},
|
|
"modes": {"live": {"title_text": {"font_size": None}}}}}
|
|
st = self._resolver(config, mode="live",
|
|
schema=style_schema_path).style("title_text")
|
|
assert st.font_size == 12
|
|
|
|
def test_a_per_call_mode_overrides_the_bound_one(self, style_schema_path):
|
|
config = {"customization": {"modes": {
|
|
"live": {"title_text": {"font_size": 16}},
|
|
"recent": {"title_text": {"font_size": 6}}}}}
|
|
r = self._resolver(config, mode="live", schema=style_schema_path)
|
|
assert r.style("title_text").font_size == 16
|
|
assert r.style("title_text", mode="recent").font_size == 6
|
|
|
|
def test_the_memo_does_not_leak_between_modes(self, style_schema_path):
|
|
config = {"customization": {"modes": {
|
|
"live": {"title_text": {"font_size": 16}},
|
|
"recent": {"title_text": {"font_size": 6}}}}}
|
|
r = self._resolver(config, mode="live", schema=style_schema_path)
|
|
assert r.style("title_text").font_size == 16
|
|
assert r.style("title_text", mode="recent").font_size == 6
|
|
assert r.style("title_text").font_size == 16
|
|
|
|
def test_mode_is_exposed(self):
|
|
assert ElementStyleResolver({}, {}, mode="live").mode == "live"
|
|
assert ElementStyleResolver({}, {}).mode is None
|
|
|
|
|
|
class TestModeOffsets:
|
|
def _r(self, config, mode=None):
|
|
return ElementStyleResolver(config, {}, mode=mode)
|
|
|
|
def test_a_mode_offset_wins(self):
|
|
config = {"customization": {
|
|
"layout": {"score": {"y_offset": -2}},
|
|
"modes": {"live": {"layout": {"score": {"y_offset": 5}}}}}}
|
|
assert self._r(config, "live").offset_value("score", "y_offset") == 5
|
|
|
|
def test_an_absent_mode_offset_inherits_the_base(self):
|
|
config = {"customization": {
|
|
"layout": {"score": {"y_offset": -2}},
|
|
"modes": {"live": {"layout": {"score": {"x_offset": 1}}}}}}
|
|
r = self._r(config, "live")
|
|
assert r.offset_value("score", "y_offset") == -2
|
|
assert r.offset_value("score", "x_offset") == 1
|
|
|
|
def test_an_explicit_zero_is_an_override_not_an_absence(self):
|
|
"""The reason None is the inherit sentinel: 0 has to mean something."""
|
|
config = {"customization": {
|
|
"layout": {"score": {"y_offset": -2}},
|
|
"modes": {"live": {"layout": {"score": {"y_offset": 0}}}}}}
|
|
assert self._r(config, "live").offset_value("score", "y_offset") == 0
|
|
|
|
def test_a_null_mode_offset_inherits(self):
|
|
config = {"customization": {
|
|
"layout": {"score": {"y_offset": -2}},
|
|
"modes": {"live": {"layout": {"score": {"y_offset": None}}}}}}
|
|
assert self._r(config, "live").offset_value("score", "y_offset") == -2
|
|
|
|
def test_offset_pair_is_mode_aware(self):
|
|
config = {"customization": {
|
|
"layout": {"score": {"x_offset": 1, "y_offset": 2}},
|
|
"modes": {"live": {"layout": {"score": {"y_offset": 9}}}}}}
|
|
assert self._r(config, "live").offset("score") == (1, 9)
|
|
|
|
def test_an_arbitrary_axis_is_mode_aware(self):
|
|
"""The scoreboards use away_x_offset / home_x_offset."""
|
|
config = {"customization": {
|
|
"layout": {"records": {"away_x_offset": 3}},
|
|
"modes": {"recent": {"layout": {"records": {"away_x_offset": 7}}}}}}
|
|
assert self._r(config, "recent").offset_value(
|
|
"records", "away_x_offset") == 7
|
|
|
|
@pytest.mark.parametrize("modes", [
|
|
None, "nonsense", {"live": "nonsense"}, {"live": {"layout": 5}},
|
|
{"live": {"layout": {"score": {"y_offset": "bad"}}}},
|
|
])
|
|
def test_garbage_modes_degrade_to_the_base(self, modes):
|
|
config = {"customization": {"layout": {"score": {"y_offset": -2}},
|
|
"modes": modes}}
|
|
assert self._r(config, "live").offset_value("score", "y_offset") == -2
|
|
|
|
@pytest.mark.parametrize("modes", [
|
|
None, "nonsense", {"live": "nonsense"},
|
|
{"live": {"title_text": "nonsense"}},
|
|
{"live": {"title_text": {"font_size": "huge", "text_color": "red"}}},
|
|
])
|
|
def test_garbage_mode_styles_degrade(self, modes, style_schema_path):
|
|
config = {"customization": {"title_text": {"font_size": 12},
|
|
"modes": modes}}
|
|
st = ElementStyleResolver(
|
|
config, defaults_from_schema_file(style_schema_path),
|
|
mode="live").style("title_text", classic_size=8)
|
|
assert st.font_size == 12
|
|
|
|
MODE_SCHEMA = {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {
|
|
"enabled": {"type": "boolean", "default": False},
|
|
"customization": {
|
|
"type": "object",
|
|
"x-style-modes": ["live", "recent"],
|
|
"x-style-elements": {
|
|
"score_text": {
|
|
"title": "Score",
|
|
"font": {"default": "PressStart2P-Regular.ttf"},
|
|
"size": {"default": 10, "min": 4, "max": 16},
|
|
"color": {"default": [255, 255, 255]},
|
|
"offsets": True,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
class TestModeSchemaEmission:
|
|
def _expanded(self):
|
|
return expand_style_elements(MODE_SCHEMA)["properties"]["customization"]
|
|
|
|
def test_a_modes_block_is_emitted_per_declared_mode(self):
|
|
assert sorted(self._expanded()["properties"]["modes"]["properties"]) == [
|
|
"live", "recent"]
|
|
|
|
def test_mode_fields_are_nullable_and_default_to_null(self):
|
|
"""Null is the inherit sentinel. A concrete default here would turn
|
|
every mode into a copy of the base the moment the user pressed Save,
|
|
because the save flow writes schema defaults into config wholesale."""
|
|
live = self._expanded()["properties"]["modes"]["properties"]["live"]
|
|
block = live["properties"]["score_text"]["properties"]
|
|
for name, prop in block.items():
|
|
assert prop["default"] is None, name
|
|
assert "null" in prop["type"], name
|
|
|
|
def test_mode_offsets_are_nullable_too(self):
|
|
live = self._expanded()["properties"]["modes"]["properties"]["live"]
|
|
axes = live["properties"]["layout"]["properties"]["score_text"]["properties"]
|
|
assert set(axes) == {"x_offset", "y_offset"}
|
|
for prop in axes.values():
|
|
assert prop["default"] is None
|
|
assert "null" in prop["type"]
|
|
|
|
def test_the_base_block_keeps_its_real_defaults(self):
|
|
block = self._expanded()["properties"]["score_text"]["properties"]
|
|
assert block["font_size"]["default"] == 10
|
|
assert block["font"]["default"] == "PressStart2P-Regular.ttf"
|
|
|
|
def test_the_font_field_asks_for_the_font_selector_widget(self):
|
|
"""The widget already existed and was already allowlisted by the
|
|
config form; without the hint the field was a bare text box the user
|
|
had to type a filename into."""
|
|
block = self._expanded()["properties"]["score_text"]["properties"]
|
|
assert block["font"]["x-widget"] == "font-selector"
|
|
|
|
def test_no_declared_modes_emits_no_modes_block(self):
|
|
schema = copy.deepcopy(MODE_SCHEMA)
|
|
del schema["properties"]["customization"]["x-style-modes"]
|
|
expanded = expand_style_elements(schema)["properties"]["customization"]
|
|
assert "modes" not in expanded["properties"]
|
|
|
|
@pytest.mark.parametrize("modes", ["nonsense", [], [None], [""], 5, {}])
|
|
def test_garbage_mode_declarations_do_not_break_expansion(self, modes):
|
|
schema = copy.deepcopy(MODE_SCHEMA)
|
|
schema["properties"]["customization"]["x-style-modes"] = modes
|
|
expanded = expand_style_elements(schema)["properties"]["customization"]
|
|
assert "score_text" in expanded["properties"]
|
|
|
|
def test_the_input_schema_is_not_mutated(self):
|
|
expand_style_elements(MODE_SCHEMA)
|
|
assert "properties" not in MODE_SCHEMA["properties"]["customization"]
|
|
|
|
|
|
class TestModeSaveRoundTrip:
|
|
"""The path a real save takes: schema -> defaults -> merge -> validate ->
|
|
resolve. The risk being covered is that the save flow writes schema
|
|
defaults into config.json wholesale, so a mode block has to survive that
|
|
still meaning "inherit"."""
|
|
|
|
@pytest.fixture
|
|
def plugin(self, tmp_path):
|
|
from src.plugin_system.schema_manager import SchemaManager
|
|
pdir = tmp_path / "plugins" / "demo"
|
|
pdir.mkdir(parents=True)
|
|
(pdir / "config_schema.json").write_text(json.dumps(MODE_SCHEMA),
|
|
encoding="utf-8")
|
|
sm = SchemaManager(plugins_dir=tmp_path / "plugins",
|
|
project_root=tmp_path)
|
|
schema = sm.load_schema("demo", use_cache=False)
|
|
return sm, schema, sm.extract_defaults_from_schema(schema), pdir
|
|
|
|
def test_a_save_leaves_mode_blocks_meaning_inherit(self, plugin):
|
|
sm, schema, defaults, pdir = plugin
|
|
merged = sm.merge_with_defaults(
|
|
{"enabled": True, "customization": {"score_text": {"font_size": 14}}},
|
|
defaults)
|
|
live = merged["customization"]["modes"]["live"]["score_text"]
|
|
assert live == {"font": None, "font_size": None, "text_color": None}
|
|
|
|
ok, errors = sm.validate_config_against_schema(merged, schema)
|
|
assert ok, errors
|
|
|
|
style = ElementStyleResolver(
|
|
merged, defaults_from_schema_file(str(pdir / "config_schema.json")),
|
|
mode="live").style("score_text",
|
|
classic_font="PressStart2P-Regular.ttf",
|
|
classic_size=10)
|
|
assert style.font_size == 14, "the base must still reach the mode"
|
|
|
|
def test_a_real_mode_override_validates_and_wins(self, plugin):
|
|
sm, schema, defaults, pdir = plugin
|
|
merged = sm.merge_with_defaults({"customization": {
|
|
"score_text": {"font_size": 14},
|
|
"modes": {"live": {"score_text": {"font_size": 16},
|
|
"layout": {"score_text": {"y_offset": -3}}}},
|
|
}}, defaults)
|
|
ok, errors = sm.validate_config_against_schema(merged, schema)
|
|
assert ok, errors
|
|
|
|
schema_defaults = defaults_from_schema_file(
|
|
str(pdir / "config_schema.json"))
|
|
live = ElementStyleResolver(merged, schema_defaults, mode="live")
|
|
recent = ElementStyleResolver(merged, schema_defaults, mode="recent")
|
|
assert live.style("score_text", classic_size=10).font_size == 16
|
|
assert live.offset("score_text") == (0, -3)
|
|
assert recent.style("score_text", classic_size=10).font_size == 14
|
|
assert recent.offset("score_text") == (0, 0)
|
|
|
|
def test_a_mode_size_outside_the_declared_range_is_rejected(self, plugin):
|
|
"""min/max from the declaration must carry into the mode blocks."""
|
|
sm, schema, defaults, _ = plugin
|
|
merged = sm.merge_with_defaults(
|
|
{"customization": {"modes": {"live": {"score_text": {
|
|
"font_size": 99}}}}}, defaults)
|
|
ok, _errors = sm.validate_config_against_schema(merged, schema)
|
|
assert not ok
|