feat(web): style-editor widget -- a row per element instead of 65 accordions

Rendered element by element, a realistic scoreboard's customization block
is 65 nested sections, and reaching one per-mode font size takes five
levels of expanding. The widget collapses that to one compact row per
element -- font, size, colour, X, Y -- with a tab per declared mode.

It emits ordinary inputs under the same dotted names the generic renderer
would produce, so the save/validate/merge pipeline is untouched: no hidden
JSON blob and no new server-side parsing. It is driven entirely by the
schema block it is handed, so fields added to the schema later appear
without editing the widget. If it fails to load or throws, the generic
nested rendering it replaces is left in place.

Fixing two things the save path got wrong for nullable fields, found by
posting what the widget actually emits:

- The indexed-array recombiner (text_color.0/.1/.2 -> one list) compared
  the declared type to the string 'array', so a per-mode colour, typed
  ["array", "null"], was never reassembled and failed validation on save.
  _parse_form_value_with_schema had the same comparison.
- A blank nullable field became [] rather than None, which then failed the
  minItems the colour array declares. Null is the inherit sentinel, so it
  has to survive.

And two things the widget itself got wrong, found by looking at it:

- An unset base control fell back to the select's first option, so an
  untouched scoreboard claimed every element used 10x20.bdf -- and the
  size box then locked itself to that bitmap font's fixed size. Base
  controls now show the schema default; mode controls stay blank, because
  blank there means inherit.
- Elements arrived alphabetised (Detail and Odds above Score). Flask's
  JSON provider sorts keys, so declaration order has to be stated
  explicitly; expand_style_elements now emits x-propertyOrder, which the
  generic renderer already honoured too.

Size is disabled and shown as fixed for a bitmap font, using the
scalable/native_size the font catalog now reports.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-10 15:47:39 -04:00
co-authored by Claude Opus 5
parent f91f1b02e3
commit 90d6d91c27
7 changed files with 851 additions and 4 deletions
@@ -0,0 +1,222 @@
"""The style editor's form data must survive the real save route.
The widget posts ordinary dotted field names so the existing pipeline needs no
new parsing -- but per-mode override fields are typed ``["integer", "null"]``
and ``["array", "null"]``, and two places in that pipeline compared the
declared type to a bare string:
- the indexed-array recombiner (``text_color.0/.1/.2`` -> one list) skipped
anything whose type was not exactly ``'array'``, so a per-mode colour was
never reassembled;
- ``_parse_form_value_with_schema`` did the same, and turned a blank nullable
field into ``[]`` rather than ``None`` -- which then failed the ``minItems``
the colour array declares.
Both would have surfaced as "saving a scoreboard's per-mode colour fails
validation", with nothing in the widget to suggest why. This posts what the
widget really emits and asserts on what lands in config, then on what the
resolver makes of it.
"""
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", "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 _SaveOk:
status = type("S", (), {"value": "success"})()
message = None
@pytest.fixture
def post(tmp_path):
"""POST form data to the real save route; yields the stored config."""
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(form):
resp = client.post("/api/v3/plugins/config?plugin_id=demo", data=form)
return resp, store.get("demo", {})
try:
yield _post, pdir
finally:
for k, v in originals.items():
setattr(a.api_v3, k, v)
BASE_FORM = {
"enabled": "true",
"customization.score_text.font": "PressStart2P-Regular.ttf",
"customization.score_text.font_size": "12",
"customization.score_text.text_color.0": "255",
"customization.score_text.text_color.1": "200",
"customization.score_text.text_color.2": "0",
"customization.layout.score_text.x_offset": "0",
"customization.layout.score_text.y_offset": "0",
}
# A mode the user left alone: every field blank. Blank colours are simply
# absent, because the widget disables those inputs rather than posting "".
BLANK_MODE = {
"customization.modes.recent.score_text.font": "",
"customization.modes.recent.score_text.font_size": "",
"customization.modes.recent.layout.score_text.x_offset": "",
"customization.modes.recent.layout.score_text.y_offset": "",
}
class TestSaveRoundTrip:
def test_the_base_element_saves(self, post):
_post, _ = post
resp, cfg = _post(dict(BASE_FORM))
assert resp.status_code == 200, resp.get_json()
assert cfg["customization"]["score_text"] == {
"font": "PressStart2P-Regular.ttf",
"font_size": 12,
"text_color": [255, 200, 0],
}
def test_a_blank_mode_saves_as_null_not_zero(self, post):
"""Null is the inherit sentinel; 0 or [] would pin the mode."""
_post, _ = post
resp, cfg = _post(dict(BASE_FORM, **BLANK_MODE))
assert resp.status_code == 200, resp.get_json()
recent = cfg["customization"]["modes"]["recent"]
assert recent["score_text"] == {"font": None, "font_size": None,
"text_color": None}
assert recent["layout"]["score_text"] == {"x_offset": None,
"y_offset": None}
def test_a_partly_set_mode_keeps_the_rest_inheriting(self, post):
_post, _ = post
resp, cfg = _post(dict(
BASE_FORM,
**{"customization.modes.live.score_text.font": "",
"customization.modes.live.score_text.font_size": "16",
"customization.modes.live.layout.score_text.x_offset": "",
"customization.modes.live.layout.score_text.y_offset": "-3"}))
assert resp.status_code == 200, resp.get_json()
live = cfg["customization"]["modes"]["live"]
assert live["score_text"]["font_size"] == 16
assert live["score_text"]["font"] is None
assert live["score_text"]["text_color"] is None
assert live["layout"]["score_text"] == {"x_offset": None,
"y_offset": -3}
def test_a_per_mode_colour_is_reassembled_into_a_list(self, post):
"""The recombiner used to skip this: its type is ["array", "null"],
not "array", so the three indexed inputs were never joined."""
_post, _ = post
resp, cfg = _post(dict(
BASE_FORM,
**{"customization.modes.live.score_text.text_color.0": "0",
"customization.modes.live.score_text.text_color.1": "255",
"customization.modes.live.score_text.text_color.2": "0"}))
assert resp.status_code == 200, resp.get_json()
assert (cfg["customization"]["modes"]["live"]["score_text"]["text_color"]
== [0, 255, 0])
def test_all_blank_colour_channels_mean_inherit(self, post):
"""A hand-written form (or an older widget) can still post three
empty strings; they must not become [] and fail minItems."""
_post, _ = post
resp, cfg = _post(dict(
BASE_FORM,
**{"customization.modes.live.score_text.text_color.0": "",
"customization.modes.live.score_text.text_color.1": "",
"customization.modes.live.score_text.text_color.2": ""}))
assert resp.status_code == 200, resp.get_json()
assert (cfg["customization"]["modes"]["live"]["score_text"]["text_color"]
is None)
class TestWhatTheDisplayThenRenders:
"""The point of the round trip: what the resolver makes of what was saved."""
def _styles(self, cfg, pdir):
from src.element_style import (ElementStyleResolver,
defaults_from_schema_file)
defaults = defaults_from_schema_file(str(pdir / "config_schema.json"))
out = {}
for mode in (None, "live", "recent"):
r = ElementStyleResolver(cfg, defaults, mode=mode)
style = r.style("score_text",
classic_font="PressStart2P-Regular.ttf",
classic_size=10, classic_color=(255, 255, 255))
out[mode] = (style.font_size, style.color, r.offset("score_text"))
return out
def test_a_mode_override_reaches_the_display_and_the_rest_inherits(self, post):
_post, pdir = post
_resp, cfg = _post(dict(
BASE_FORM, **BLANK_MODE,
**{"customization.modes.live.score_text.font": "",
"customization.modes.live.score_text.font_size": "16",
"customization.modes.live.layout.score_text.x_offset": "",
"customization.modes.live.layout.score_text.y_offset": "-3"}))
styles = self._styles(cfg, pdir)
assert styles[None] == (12, (255, 200, 0), (0, 0))
# live overrides size and y, and inherits the colour it never set
assert styles["live"] == (16, (255, 200, 0), (0, -3))
assert styles["recent"] == (12, (255, 200, 0), (0, 0))