mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 22:35:08 +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>
248 lines
9.5 KiB
Python
248 lines
9.5 KiB
Python
"""A save must only switch off checkboxes the caller actually saw.
|
|
|
|
An HTML checkbox posts nothing when unchecked, so the save route walks the
|
|
schema and forces every boolean missing from the form to ``False``. That is
|
|
right for the rendered form and wrong for everything else: a caller that posts
|
|
four fields -- a script, the MQTT bridge, a curl against the documented
|
|
endpoint -- never rendered a checkbox, and reading its silence as "all off"
|
|
turns a one-field save into a mass disable.
|
|
|
|
That is not hypothetical. Posting four ``customization.*`` keys to a live
|
|
device switched off ``nfl.enabled``, ``ncaa_fb.enabled`` and every display-mode
|
|
toggle in one request.
|
|
|
|
The fix keeps both halves working, so both are pinned here:
|
|
|
|
* the rendered form reports the sections it drew (``__rendered_section``), and
|
|
inside those, an absent checkbox still means unchecked -- including a section
|
|
whose only fields are checkboxes that are all off, which no heuristic could
|
|
recover;
|
|
* a post with no such marker only touches objects it actually posted a field
|
|
from.
|
|
"""
|
|
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
from flask import Flask
|
|
from werkzeug.datastructures import MultiDict
|
|
|
|
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
|
sys.path.insert(0, str(PROJECT_ROOT))
|
|
|
|
SCHEMA = {
|
|
"type": "object",
|
|
"properties": {
|
|
"enabled": {"type": "boolean", "default": True},
|
|
"nfl": {
|
|
"type": "object",
|
|
"properties": {
|
|
"enabled": {"type": "boolean", "default": True},
|
|
"live_priority": {"type": "boolean", "default": True},
|
|
"update_interval": {"type": "integer", "default": 3600},
|
|
# A section with nothing but checkboxes: when they are all
|
|
# unchecked it posts no keys at all.
|
|
"display_modes": {
|
|
"type": "object",
|
|
"properties": {
|
|
"show_live": {"type": "boolean", "default": True},
|
|
"show_recent": {"type": "boolean", "default": True},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"customization": {
|
|
"type": "object",
|
|
"properties": {
|
|
"score_text": {
|
|
"type": "object",
|
|
"properties": {
|
|
"visible": {"type": "boolean", "default": True},
|
|
"text_color": {
|
|
"type": "array",
|
|
"items": {"type": "integer"},
|
|
"minItems": 3, "maxItems": 3,
|
|
"default": [255, 255, 255],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
STORED = {
|
|
"enabled": True,
|
|
"nfl": {
|
|
"enabled": True,
|
|
"live_priority": True,
|
|
"update_interval": 3600,
|
|
"display_modes": {"show_live": True, "show_recent": True},
|
|
},
|
|
"customization": {"score_text": {"visible": True,
|
|
"text_color": [255, 255, 255]}},
|
|
}
|
|
|
|
|
|
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": json.loads(json.dumps(STORED))}
|
|
|
|
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):
|
|
# A list of pairs, not a dict: __rendered_section repeats, and that
|
|
# repetition is the whole point of the marker.
|
|
data = MultiDict(form) if isinstance(form, list) else form
|
|
resp = client.post("/api/v3/plugins/config?plugin_id=demo", data=data)
|
|
return resp, store.get("demo", {})
|
|
|
|
try:
|
|
yield _post
|
|
finally:
|
|
for k, v in originals.items():
|
|
setattr(a.api_v3, k, v)
|
|
|
|
|
|
# What the style editor posts when someone changes one colour and nothing else.
|
|
PARTIAL = [
|
|
("customization.score_text.text_color.0", "255"),
|
|
("customization.score_text.text_color.1", "0"),
|
|
("customization.score_text.text_color.2", "255"),
|
|
]
|
|
|
|
|
|
class TestAPartialPost:
|
|
def test_it_does_not_disable_another_section(self, post):
|
|
"""The live-device regression, in one assertion."""
|
|
_post = post
|
|
resp, cfg = _post(list(PARTIAL))
|
|
assert resp.status_code == 200, resp.get_json()
|
|
assert cfg["nfl"]["enabled"] is True
|
|
assert cfg["nfl"]["live_priority"] is True
|
|
assert cfg["nfl"]["display_modes"] == {"show_live": True,
|
|
"show_recent": True}
|
|
|
|
def test_it_still_applies_what_was_posted(self, post):
|
|
_post = post
|
|
_resp, cfg = _post(list(PARTIAL))
|
|
assert cfg["customization"]["score_text"]["text_color"] == [255, 0, 255]
|
|
|
|
def test_it_leaves_a_sibling_checkbox_it_never_mentioned(self, post):
|
|
"""`visible` lives beside the colour, but the post carried no field
|
|
from that object -- only from the colour array inside it."""
|
|
_post = post
|
|
_resp, cfg = _post(list(PARTIAL))
|
|
assert cfg["customization"]["score_text"]["visible"] is True
|
|
|
|
def test_it_does_clear_a_box_in_an_object_it_posted_to(self, post):
|
|
"""Evidence, not guesswork: a field from `display_modes` was posted,
|
|
so the checkbox missing from that same object really is unchecked."""
|
|
_post = post
|
|
_resp, cfg = _post([("nfl.display_modes.show_live", "true")])
|
|
assert cfg["nfl"]["display_modes"] == {"show_live": True,
|
|
"show_recent": False}
|
|
assert cfg["nfl"]["enabled"] is True
|
|
|
|
|
|
class TestTheRenderedForm:
|
|
"""With section markers the old behaviour must be exactly preserved."""
|
|
|
|
def test_an_unchecked_box_is_still_cleared(self, post):
|
|
_post = post
|
|
resp, cfg = _post([("__rendered_section", "nfl"),
|
|
("__rendered_section", "customization"),
|
|
("enabled", "true"),
|
|
("nfl.update_interval", "3600"),
|
|
("nfl.display_modes.show_live", "true")])
|
|
assert resp.status_code == 200, resp.get_json()
|
|
assert cfg["nfl"]["enabled"] is False
|
|
assert cfg["nfl"]["live_priority"] is False
|
|
assert cfg["nfl"]["display_modes"]["show_recent"] is False
|
|
|
|
def test_a_section_of_only_checkboxes_all_unchecked_is_cleared(self, post):
|
|
"""It posts no keys of its own, so only the marker can vouch for it."""
|
|
_post = post
|
|
_resp, cfg = _post([("__rendered_section", "nfl"),
|
|
("nfl.update_interval", "3600")])
|
|
assert cfg["nfl"]["display_modes"] == {"show_live": False,
|
|
"show_recent": False}
|
|
|
|
def test_a_section_the_form_did_not_draw_is_untouched(self, post):
|
|
_post = post
|
|
_resp, cfg = _post([("__rendered_section", "nfl"),
|
|
("nfl.update_interval", "3600")])
|
|
assert cfg["customization"]["score_text"]["visible"] is True
|
|
|
|
def test_the_marker_is_not_written_into_the_config(self, post):
|
|
"""It describes the submission; it is not a config path. Unknown form
|
|
keys are otherwise stored verbatim."""
|
|
_post = post
|
|
_resp, cfg = _post([("__rendered_section", "nfl"),
|
|
("nfl.update_interval", "3600")])
|
|
assert "__rendered_section" not in cfg
|
|
assert not [k for k in cfg if k.startswith("__")]
|
|
|
|
|
|
class TestTheFormEmitsTheMarker:
|
|
"""The fix only works if the rendered form actually reports its sections."""
|
|
|
|
def test_every_top_level_section_is_reported(self, tmp_path):
|
|
import re
|
|
|
|
from jinja2 import Environment, FileSystemLoader
|
|
|
|
templates = PROJECT_ROOT / "web_interface" / "templates"
|
|
env = Environment(loader=FileSystemLoader(str(templates)))
|
|
source = (templates / "v3" / "partials" / "plugin_config.html").read_text(
|
|
encoding="utf-8")
|
|
assert '__rendered_section' in source, (
|
|
"the form must tell the save route which sections it drew")
|
|
# It has to cover the advanced tier too, or collapsing a section into
|
|
# Advanced Settings would quietly stop its checkboxes clearing.
|
|
marker = re.search(r"\{% for key in ([^%]+) %\}\s*"
|
|
r"<input type=\"hidden\" name=\"__rendered_section\"",
|
|
source)
|
|
assert marker, "marker loop not found"
|
|
assert "tiers.basic" in marker.group(1)
|
|
assert "tiers.advanced" in marker.group(1)
|