Files
LEDMatrix/test/web_interface/test_style_editor_save_roundtrip.py
T
ChuckandClaude Opus 5.5 7ab6fb1aff refactor(web): read plugins through a PluginCatalog; only the display runs them (#688)
The web process built its own PluginManager and loaded plugins into itself:
store installs and updates loaded or reloaded a web-side copy, and config
saves and enable/disable called on_config_change, on_enable and on_disable
on it. None of that reached the panel, and /plugins/installed reported
runtime state from those copies.

- Add PluginCatalog (src/plugin_system/plugin_catalog.py): manifests,
  directories, display modes, installed version, schema and config reads,
  with no way to run a plugin. app.py and both blueprints use it; the
  plugin_manager blueprint attribute is gone.
- Remove every lifecycle call from the web routes. Config changes already
  reach the display through ConfigService (on_config_change) and the
  enabled-set reconcile.
- Health and metrics readers move to api_v3.health_tracker /
  resource_monitor. /plugins/installed reports loaded/state/error_info as
  null (the display does not publish them) and enabled by the display's
  rule.
- Store install, update and uninstall answer restart_required when the
  running display will not pick the change up by itself
  (display_restart_required). The restart banner follows the flag via
  window.noteRestartRequired instead of the /config/main URL heuristic;
  /config/main now sends restart_required: true.
- The one remaining in-process import of plugin code (Starlark helper
  modules, oauth_flow action scripts) goes through
  _import_plugin_code_in_web_process() until a web-entry contract.
- /plugins/installed reports vegas_participation (from #682) from the
  user's setting or the manifest, with vegas_participation_source; when
  only the plugin's code decides it, null with source 'runtime', since the
  web process no longer has plugin instances to ask.
- Check & Update All keeps its restart flags when the final list refresh
  fails, and asks for a restart when an enabled plugin's first request got
  no answer and the re-sent one found it up to date.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 10:39:44 -04:00

221 lines
8.7 KiB
Python

"""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_catalog")}
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()
a.api_v3.plugin_catalog = 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))