mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-14 02:56:36 +00:00
feat(element-style): declare per-mode overrides in config_schema.json
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>
This commit is contained in:
+88
-1
@@ -330,6 +330,11 @@ def expand_style_elements(schema: Dict[str, Any]) -> Dict[str, Any]:
|
||||
for element_key, block in layout_props.items():
|
||||
layout['properties'].setdefault(element_key, block)
|
||||
|
||||
modes = customization.get('x-style-modes')
|
||||
if isinstance(modes, list) and modes:
|
||||
props.setdefault('modes',
|
||||
_modes_block(declaration, modes))
|
||||
|
||||
return expanded
|
||||
except Exception as e:
|
||||
logger.warning("Error expanding x-style-elements: %s", e)
|
||||
@@ -348,6 +353,10 @@ def _element_block_from_spec(element_key: str,
|
||||
'type': 'string',
|
||||
'title': 'Font Family',
|
||||
'x-advanced': True,
|
||||
# The core already ships this widget and the config form already
|
||||
# allowlists it; without the hint the field rendered as a bare
|
||||
# text box the user had to type a filename into.
|
||||
'x-widget': 'font-selector',
|
||||
}
|
||||
if 'default' in font_spec:
|
||||
font_prop['default'] = font_spec['default']
|
||||
@@ -418,6 +427,84 @@ def _offset_block_from_spec(element_key: str,
|
||||
}
|
||||
|
||||
|
||||
def _nullable(prop: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""The same property, retyped as "this or unset".
|
||||
|
||||
A mode field defaults to null, meaning inherit the base element. The
|
||||
default has to be null rather than the base value: the save flow writes
|
||||
schema defaults into config.json wholesale, so a concrete default here
|
||||
would turn every mode into a copy of the base the moment a user pressed
|
||||
Save, and the base would stop reaching them.
|
||||
"""
|
||||
out = dict(prop)
|
||||
declared = out.get('type', 'string')
|
||||
types = declared if isinstance(declared, list) else [declared]
|
||||
if 'null' not in types:
|
||||
types = list(types) + ['null']
|
||||
out['type'] = types
|
||||
out['default'] = None
|
||||
return out
|
||||
|
||||
|
||||
def _mode_element_block(element_key: str, spec: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""One element's override block for one mode: every field nullable."""
|
||||
base = _element_block_from_spec(element_key, spec)
|
||||
base['properties'] = {k: _nullable(v)
|
||||
for k, v in base.get('properties', {}).items()}
|
||||
base['description'] = ('Leave blank to use the settings above for this '
|
||||
'mode.')
|
||||
return base
|
||||
|
||||
|
||||
def _mode_offset_block(element_key: str, spec: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""One element's offset overrides for one mode: both axes nullable."""
|
||||
base = _offset_block_from_spec(element_key, spec)
|
||||
base['properties'] = {k: _nullable(v)
|
||||
for k, v in base.get('properties', {}).items()}
|
||||
return base
|
||||
|
||||
|
||||
def _modes_block(declaration: Dict[str, Any],
|
||||
modes: Any) -> Dict[str, Any]:
|
||||
"""``customization.modes`` — one override group per declared mode."""
|
||||
mode_props: Dict[str, Any] = {}
|
||||
for mode in modes:
|
||||
if not isinstance(mode, str) or not mode:
|
||||
continue
|
||||
element_props: Dict[str, Any] = {}
|
||||
layout_props: Dict[str, Any] = {}
|
||||
for element_key, spec in declaration.items():
|
||||
if not isinstance(spec, dict):
|
||||
continue
|
||||
element_props[element_key] = _mode_element_block(element_key, spec)
|
||||
if spec.get('offsets'):
|
||||
layout_props[element_key] = _mode_offset_block(element_key, spec)
|
||||
if layout_props:
|
||||
element_props['layout'] = {
|
||||
'type': 'object',
|
||||
'title': 'Layout Offsets',
|
||||
'x-advanced': True,
|
||||
'additionalProperties': False,
|
||||
'properties': layout_props,
|
||||
}
|
||||
mode_props[mode] = {
|
||||
'type': 'object',
|
||||
'title': mode.replace('_', ' ').title(),
|
||||
'x-style-managed': True,
|
||||
'additionalProperties': False,
|
||||
'properties': element_props,
|
||||
}
|
||||
return {
|
||||
'type': 'object',
|
||||
'title': 'Per-Mode Overrides',
|
||||
'description': 'Override the settings above for one display mode. '
|
||||
'Anything left blank follows the settings above.',
|
||||
'x-advanced': True,
|
||||
'additionalProperties': False,
|
||||
'properties': mode_props,
|
||||
}
|
||||
|
||||
|
||||
def defaults_from_schema(schema: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Extract per-element style defaults from a config schema dict.
|
||||
|
||||
@@ -458,7 +545,7 @@ def defaults_from_schema(schema: Dict[str, Any]) -> Dict[str, Any]:
|
||||
properties = customization.get('properties')
|
||||
if isinstance(properties, dict):
|
||||
for element_key, block in properties.items():
|
||||
if element_key == 'layout' or element_key in elements:
|
||||
if element_key in ('layout', 'modes') or element_key in elements:
|
||||
continue
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
|
||||
@@ -16,6 +16,7 @@ ledmatrix-music, football-scoreboard):
|
||||
- style() never raises; malformed input degrades to the classic style.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
|
||||
@@ -681,3 +682,145 @@ class TestModeOffsets:
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user