mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
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>
221 lines
8.7 KiB
Python
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))
|