mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-14 02:56:36 +00:00
feat(element-style): visibility, alignment and scale per element
Completes the customization vocabulary: hide an element, align it, and resize a logo, alongside the font/size/colour/offset that already existed. All three per mode. They resolve to "change nothing" until the user asks for something -- True, None and 1.0 -- rather than to whatever the schema declares. That is the same invariant the font fields keep: a caller that honours them still renders an untouched config exactly as it did before they existed. A schema default therefore does not count as a choice, which matters because the save flow writes that default into config either way. scale sits in the layout block with the offsets rather than in the element block, because it is geometry: a logo has a scale and no font. The widget's columns come from the schema, so a logo row shows visibility, offsets and scale and no empty font cell. Two bugs found by the tests rather than by reading: - A nullable enum needs null in its enum list, not just in its type. The mode copy of `align` defaulted to null and then failed its own schema, so a plugin declaring any enum field with modes could not save at all. Six tests failed on this before any of them reached what they were testing. - defaults_from_schema only ever extracted font/font_size/text_color, so the schema defaults for the new fields were invisible to the resolver and a declared default read as a user choice. Widget: the table scrolls horizontally and pins the element-name column. Nine columns do not fit the config panel, and clipping them hid the offsets entirely while scrolling them made every row anonymous. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -824,3 +824,160 @@ class TestModeSaveRoundTrip:
|
||||
"font_size": 99}}}}}, defaults)
|
||||
ok, _errors = sm.validate_config_against_schema(merged, schema)
|
||||
assert not ok
|
||||
|
||||
EXTRA_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"customization": {
|
||||
"type": "object",
|
||||
"x-style-modes": ["live"],
|
||||
"x-style-elements": {
|
||||
"score_text": {
|
||||
"title": "Score",
|
||||
"font": {"default": "PressStart2P-Regular.ttf"},
|
||||
"size": {"default": 10},
|
||||
"color": {"default": [255, 255, 255]},
|
||||
"visible": True,
|
||||
"align": {"default": "center"},
|
||||
"offsets": True,
|
||||
},
|
||||
"home_logo": {
|
||||
"title": "Home Logo",
|
||||
"offsets": True,
|
||||
"visible": True,
|
||||
"scale": {"default": 1.0, "min": 0.25, "max": 4},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def extra_schema_path(tmp_path):
|
||||
path = tmp_path / "extra_schema.json"
|
||||
path.write_text(json.dumps(EXTRA_SCHEMA), encoding="utf-8")
|
||||
return str(path)
|
||||
|
||||
|
||||
class TestVisibleAlignScale:
|
||||
"""visible / align / scale all resolve to "change nothing" until asked.
|
||||
|
||||
That is the same invariant the font fields keep: a caller that honours
|
||||
these must still render an untouched config exactly as it did before
|
||||
they existed, so the neutral values are True / None / 1.0 rather than
|
||||
whatever the schema happens to declare.
|
||||
"""
|
||||
|
||||
def _style(self, config, mode=None, schema=None):
|
||||
defaults = defaults_from_schema_file(schema) if schema else {}
|
||||
return ElementStyleResolver(config, defaults, mode=mode).style(
|
||||
"score_text", classic_font="PressStart2P-Regular.ttf",
|
||||
classic_size=10, classic_color=(255, 255, 255))
|
||||
|
||||
def test_an_untouched_config_is_neutral(self, extra_schema_path):
|
||||
st = self._style({}, schema=extra_schema_path)
|
||||
assert (st.visible, st.align, st.scale) == (True, None, 1.0)
|
||||
|
||||
def test_a_schema_default_is_not_a_choice(self, extra_schema_path):
|
||||
"""align defaults to 'center' in the schema, and the save flow writes
|
||||
that into config -- which must not read as the user asking for it."""
|
||||
config = {"customization": {"score_text": {"align": "center",
|
||||
"visible": True}}}
|
||||
st = self._style(config, schema=extra_schema_path)
|
||||
assert st.align is None, "the plugin keeps its own alignment"
|
||||
assert st.visible is True
|
||||
|
||||
def test_hiding_an_element(self, extra_schema_path):
|
||||
config = {"customization": {"score_text": {"visible": False}}}
|
||||
assert self._style(config, schema=extra_schema_path).visible is False
|
||||
|
||||
def test_a_real_alignment_choice_comes_through(self, extra_schema_path):
|
||||
config = {"customization": {"score_text": {"align": "right"}}}
|
||||
assert self._style(config, schema=extra_schema_path).align == "right"
|
||||
|
||||
@pytest.mark.parametrize("written,expected", [
|
||||
("left", "left"), ("RIGHT", "right"), (" center ", "center"),
|
||||
("centre", "center"), ("middle", "center"),
|
||||
("sideways", None), ("", None), (5, None), (None, None),
|
||||
])
|
||||
def test_alignment_coercion(self, written, expected, extra_schema_path):
|
||||
config = {"customization": {"score_text": {"align": written}}}
|
||||
assert self._style(config, schema=extra_schema_path).align == expected
|
||||
|
||||
@pytest.mark.parametrize("written,expected", [
|
||||
(2, 2.0), (0.5, 0.5), ("1.5", 1.5),
|
||||
(0, 1.0), (-3, 1.0), # nonsense degrades, never inverts
|
||||
(999, 10.0), # clamped: the panel is 32px tall
|
||||
("huge", 1.0), (None, 1.0), (True, 1.0),
|
||||
])
|
||||
def test_scale_coercion(self, written, expected, extra_schema_path):
|
||||
config = {"customization": {"layout": {"score_text": {"scale": written}}}}
|
||||
assert self._style(config, schema=extra_schema_path).scale == expected
|
||||
|
||||
def test_scale_reads_from_the_layout_block(self, extra_schema_path):
|
||||
"""scale is geometry, so it sits with the offsets -- a logo has a
|
||||
scale and no font."""
|
||||
config = {"customization": {"layout": {"score_text": {"scale": 2}}}}
|
||||
assert self._style(config, schema=extra_schema_path).scale == 2.0
|
||||
|
||||
@pytest.mark.parametrize("field,value,attr,expected", [
|
||||
("visible", False, "visible", False),
|
||||
("align", "right", "align", "right"),
|
||||
])
|
||||
def test_a_mode_overrides_them(self, field, value, attr, expected,
|
||||
extra_schema_path):
|
||||
config = {"customization": {"modes": {
|
||||
"live": {"score_text": {field: value}}}}}
|
||||
st = self._style(config, mode="live", schema=extra_schema_path)
|
||||
assert getattr(st, attr) == expected
|
||||
|
||||
def test_a_mode_overrides_scale(self, extra_schema_path):
|
||||
config = {"customization": {
|
||||
"layout": {"score_text": {"scale": 2}},
|
||||
"modes": {"live": {"layout": {"score_text": {"scale": 3}}}}}}
|
||||
assert self._style(config, mode="live",
|
||||
schema=extra_schema_path).scale == 3.0
|
||||
|
||||
def test_an_unset_mode_field_inherits_the_base(self, extra_schema_path):
|
||||
config = {"customization": {
|
||||
"score_text": {"visible": False},
|
||||
"modes": {"live": {"score_text": {"align": "right"}}}}}
|
||||
st = self._style(config, mode="live", schema=extra_schema_path)
|
||||
assert st.visible is False and st.align == "right"
|
||||
|
||||
|
||||
class TestExtraFieldSchema:
|
||||
def _props(self):
|
||||
return expand_style_elements(
|
||||
EXTRA_SCHEMA)["properties"]["customization"]["properties"]
|
||||
|
||||
def test_only_declared_subfields_are_emitted(self):
|
||||
"""home_logo declares no font, so it gets no font control."""
|
||||
assert list(self._props()["home_logo"]["properties"]) == ["visible"]
|
||||
|
||||
def test_a_text_element_gets_the_text_fields(self):
|
||||
assert list(self._props()["score_text"]["properties"]) == [
|
||||
"font", "font_size", "text_color", "visible", "align"]
|
||||
|
||||
def test_scale_is_emitted_with_the_offsets(self):
|
||||
layout = self._props()["layout"]["properties"]
|
||||
assert list(layout["home_logo"]["properties"]) == [
|
||||
"x_offset", "y_offset", "scale"]
|
||||
assert list(layout["score_text"]["properties"]) == [
|
||||
"x_offset", "y_offset"], "scale is opt-in"
|
||||
|
||||
def test_declared_scale_bounds_are_kept(self):
|
||||
scale = self._props()["layout"]["properties"]["home_logo"]["properties"]["scale"]
|
||||
assert (scale["minimum"], scale["maximum"]) == (0.25, 4)
|
||||
|
||||
def test_align_offers_only_valid_choices(self):
|
||||
align = self._props()["score_text"]["properties"]["align"]
|
||||
assert align["enum"] == ["left", "center", "right"]
|
||||
|
||||
def test_the_mode_copies_are_nullable(self):
|
||||
live = self._props()["modes"]["properties"]["live"]["properties"]
|
||||
assert live["score_text"]["properties"]["visible"]["default"] is None
|
||||
assert "null" in live["score_text"]["properties"]["visible"]["type"]
|
||||
scale = live["layout"]["properties"]["home_logo"]["properties"]["scale"]
|
||||
assert scale["default"] is None and "null" in scale["type"]
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"""visible / align / scale, from the form the widget renders to the style the
|
||||
display resolves.
|
||||
|
||||
These three are the difference between "restyle the text" and "lay the card
|
||||
out", and each has a way of going quietly wrong in a form post:
|
||||
|
||||
- ``visible`` is a boolean, and an unchecked checkbox posts nothing at all,
|
||||
so the widget carries the value in a hidden field. The save path has to
|
||||
turn those strings into real booleans, or config.json grows "false"
|
||||
strings that are truthy everywhere they are read.
|
||||
- ``align`` is an enum, so an invalid value must not reach the renderer.
|
||||
- ``scale`` is a float living in the layout block rather than the element
|
||||
block, because a logo has a scale and no font.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean", "default": True},
|
||||
"customization": {
|
||||
"type": "object",
|
||||
"x-style-modes": ["live"],
|
||||
"x-style-elements": {
|
||||
"score_text": {
|
||||
"title": "Score",
|
||||
"font": {"default": "PressStart2P-Regular.ttf"},
|
||||
"size": {"default": 10, "min": 4, "max": 16},
|
||||
"color": {"default": [255, 255, 255]},
|
||||
"visible": True,
|
||||
"align": {"default": "center"},
|
||||
"offsets": True,
|
||||
},
|
||||
"home_logo": {
|
||||
"title": "Home Logo",
|
||||
"offsets": True,
|
||||
"visible": True,
|
||||
"scale": {"default": 1.0, "min": 0.25, "max": 4},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class _SaveOk:
|
||||
status = type("S", (), {"value": "success"})()
|
||||
message = None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def post(tmp_path):
|
||||
from src.plugin_system.schema_manager import SchemaManager
|
||||
from web_interface.blueprints import api_v3 as a
|
||||
|
||||
originals = {k: getattr(a.api_v3, k, None)
|
||||
for k in ("config_manager", "schema_manager", "plugin_manager")}
|
||||
|
||||
pdir = tmp_path / "plugins" / "demo"
|
||||
pdir.mkdir(parents=True)
|
||||
(pdir / "config_schema.json").write_text(json.dumps(SCHEMA), encoding="utf-8")
|
||||
|
||||
store = {"demo": {"enabled": True}}
|
||||
cm = MagicMock()
|
||||
cm.load_config.side_effect = lambda: json.loads(json.dumps(store))
|
||||
cm.get_config_path.return_value = str(tmp_path / "config.json")
|
||||
|
||||
def _save(cfg, **_kw):
|
||||
store.clear()
|
||||
store.update(cfg)
|
||||
return _SaveOk()
|
||||
|
||||
cm.save_config_atomic.side_effect = _save
|
||||
|
||||
a.api_v3.config_manager = cm
|
||||
a.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path / "plugins",
|
||||
project_root=tmp_path)
|
||||
pm = MagicMock()
|
||||
pm.plugins = {}
|
||||
pm.get_plugin.return_value = None
|
||||
a.api_v3.plugin_manager = pm
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
app.register_blueprint(a.api_v3, url_prefix="/api/v3")
|
||||
client = app.test_client()
|
||||
|
||||
def _post(extra):
|
||||
form = {
|
||||
"enabled": "true",
|
||||
"customization.score_text.font": "PressStart2P-Regular.ttf",
|
||||
"customization.score_text.font_size": "10",
|
||||
"customization.score_text.text_color.0": "255",
|
||||
"customization.score_text.text_color.1": "255",
|
||||
"customization.score_text.text_color.2": "255",
|
||||
"customization.score_text.visible": "true",
|
||||
"customization.score_text.align": "center",
|
||||
"customization.layout.score_text.x_offset": "0",
|
||||
"customization.layout.score_text.y_offset": "0",
|
||||
"customization.home_logo.visible": "true",
|
||||
"customization.layout.home_logo.x_offset": "0",
|
||||
"customization.layout.home_logo.y_offset": "0",
|
||||
"customization.layout.home_logo.scale": "1",
|
||||
}
|
||||
form.update(extra)
|
||||
resp = client.post("/api/v3/plugins/config?plugin_id=demo", data=form)
|
||||
return resp, store.get("demo", {})
|
||||
|
||||
def _style(cfg, element="score_text", mode=None):
|
||||
from src.element_style import (ElementStyleResolver,
|
||||
defaults_from_schema_file)
|
||||
defaults = defaults_from_schema_file(str(pdir / "config_schema.json"))
|
||||
return ElementStyleResolver(cfg, defaults, mode=mode).style(
|
||||
element, classic_font="PressStart2P-Regular.ttf", classic_size=10,
|
||||
classic_color=(255, 255, 255))
|
||||
|
||||
try:
|
||||
yield _post, _style
|
||||
finally:
|
||||
for k, v in originals.items():
|
||||
setattr(a.api_v3, k, v)
|
||||
|
||||
|
||||
class TestVisible:
|
||||
def test_a_checked_box_stores_a_real_boolean(self, post):
|
||||
_post, _ = post
|
||||
resp, cfg = _post({})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
stored = cfg["customization"]["score_text"]["visible"]
|
||||
assert stored is True, f"stored {stored!r}, not a bool"
|
||||
|
||||
def test_hiding_an_element_survives_the_round_trip(self, post):
|
||||
_post, _style = post
|
||||
resp, cfg = _post({"customization.score_text.visible": "false"})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert cfg["customization"]["score_text"]["visible"] is False
|
||||
assert _style(cfg).visible is False
|
||||
|
||||
def test_the_default_is_not_read_as_a_choice(self, post):
|
||||
"""The save flow writes the schema default in either way."""
|
||||
_post, _style = post
|
||||
_resp, cfg = _post({})
|
||||
assert _style(cfg).visible is True
|
||||
|
||||
|
||||
class TestAlign:
|
||||
def test_a_choice_reaches_the_display(self, post):
|
||||
_post, _style = post
|
||||
resp, cfg = _post({"customization.score_text.align": "right"})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert _style(cfg).align == "right"
|
||||
|
||||
def test_the_schema_default_resolves_to_no_preference(self, post):
|
||||
"""'center' is what the schema declares, so the plugin keeps doing
|
||||
whatever it already did rather than being told to centre."""
|
||||
_post, _style = post
|
||||
_resp, cfg = _post({})
|
||||
assert _style(cfg).align is None
|
||||
|
||||
def test_an_invalid_alignment_is_rejected_on_save(self, post):
|
||||
_post, _ = post
|
||||
resp, _cfg = _post({"customization.score_text.align": "sideways"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
class TestScale:
|
||||
def test_a_logo_scale_saves_into_the_layout_block(self, post):
|
||||
_post, _style = post
|
||||
resp, cfg = _post({"customization.layout.home_logo.scale": "2.5"})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert cfg["customization"]["layout"]["home_logo"]["scale"] == 2.5
|
||||
assert _style(cfg, "home_logo").scale == 2.5
|
||||
|
||||
def test_a_scale_outside_the_declared_range_is_rejected(self, post):
|
||||
_post, _ = post
|
||||
resp, _cfg = _post({"customization.layout.home_logo.scale": "99"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_an_element_without_a_declared_scale_stays_neutral(self, post):
|
||||
_post, _style = post
|
||||
_resp, cfg = _post({})
|
||||
assert _style(cfg, "score_text").scale == 1.0
|
||||
|
||||
|
||||
class TestPerMode:
|
||||
def test_a_mode_can_hide_what_the_base_shows(self, post):
|
||||
_post, _style = post
|
||||
resp, cfg = _post({"customization.modes.live.score_text.visible": "false",
|
||||
"customization.modes.live.score_text.align": "",
|
||||
"customization.modes.live.score_text.font": "",
|
||||
"customization.modes.live.score_text.font_size": ""})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert _style(cfg).visible is True
|
||||
assert _style(cfg, mode="live").visible is False
|
||||
|
||||
def test_a_blank_mode_boolean_means_inherit(self, post):
|
||||
_post, _style = post
|
||||
resp, cfg = _post({"customization.score_text.visible": "false",
|
||||
"customization.modes.live.score_text.visible": ""})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert cfg["customization"]["modes"]["live"]["score_text"]["visible"] is None
|
||||
assert _style(cfg, mode="live").visible is False, "inherits the base"
|
||||
Reference in New Issue
Block a user