mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
The server-rendered plugin settings partial rendered straight from the saved config, so an option added in a plugin update (geochron 1.2.0's show_date / show_date_line, default true) drew as an unchecked box, and the save route's missing-checkbox handling then stored it as false. Enum dropdowns likewise showed their first option instead of the default. - _load_plugin_config_partial runs the stored section through prepare_plugin_config (as GET /plugins/config does) before masking secrets, so a secret's schema default is masked too. - render_field falls back to the field's own default, covering children of objects that declare a default of their own (where the defaults extraction stops). - The legacy-boolean parity test now compares against the config the plugin actually runs with (defaults included). Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
199 lines
7.1 KiB
Python
199 lines
7.1 KiB
Python
"""The server-rendered plugin form must show schema defaults for unsaved keys.
|
|
|
|
A plugin update that adds a boolean option with ``"default": true`` (geochron
|
|
1.2.0's ``show_date`` / ``show_date_line``) leaves every existing install with
|
|
a saved config that lacks the key. The partial rendered it from the raw config,
|
|
so the box came up unchecked -- and the save route treats a drawn but unposted
|
|
checkbox as false, so the first save turned the option off for good.
|
|
|
|
These drive the real partial route (pages_v3) and post what a browser would
|
|
submit from its HTML back through the real save route (api_v3).
|
|
"""
|
|
|
|
import json
|
|
import sys
|
|
from html.parser import HTMLParser
|
|
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},
|
|
"show_date": {"type": "boolean", "default": True},
|
|
"show_date_line": {"type": "boolean", "default": True},
|
|
"show_seconds": {"type": "boolean", "default": False},
|
|
"style": {"type": "string", "enum": ["flat", "globe", "night"],
|
|
"default": "globe"},
|
|
"brightness": {"type": "integer", "default": 70,
|
|
"minimum": 0, "maximum": 100},
|
|
"label": {"type": "string", "default": "UTC"},
|
|
# An object with its own default: extract_schema_defaults stops here,
|
|
# so the route merge leaves the children missing.
|
|
"overlay": {"type": "object", "default": {}, "properties": {
|
|
"show_sun": {"type": "boolean", "default": True},
|
|
"mode": {"type": "string", "enum": ["dot", "ring"],
|
|
"default": "ring"},
|
|
}, "additionalProperties": False},
|
|
# An object without one: the route merge fills the children in.
|
|
"grid": {"type": "object", "properties": {
|
|
"show_lines": {"type": "boolean", "default": True},
|
|
}, "additionalProperties": False},
|
|
"api_key": {"type": "string", "x-secret": True,
|
|
"default": "not-a-real-secret"},
|
|
},
|
|
"required": ["enabled"],
|
|
"additionalProperties": False,
|
|
}
|
|
|
|
# Saved before any of the options above existed.
|
|
STORED = {"enabled": True, "show_seconds": True}
|
|
|
|
|
|
class _FormFields(HTMLParser):
|
|
"""Collect what a browser would submit from the rendered form."""
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.pairs = []
|
|
self._select = None
|
|
|
|
def handle_starttag(self, tag, attrs):
|
|
a = dict(attrs)
|
|
if tag == "select":
|
|
self._select = a.get("name")
|
|
return
|
|
if tag == "option" and self._select and "selected" in a:
|
|
self.pairs.append((self._select, a.get("value", "")))
|
|
return
|
|
if tag != "input" or not a.get("name") or "disabled" in a:
|
|
return
|
|
if a.get("type") in ("checkbox", "radio") and "checked" not in a:
|
|
return
|
|
if a.get("type") in ("button", "submit", "file"):
|
|
return
|
|
self.pairs.append((a["name"], a.get("value", "")))
|
|
|
|
def handle_endtag(self, tag):
|
|
if tag == "select":
|
|
self._select = None
|
|
|
|
|
|
@pytest.fixture
|
|
def app_client(tmp_path):
|
|
from src.plugin_system.schema_manager import SchemaManager
|
|
from web_interface.blueprints import api_v3 as api
|
|
from web_interface.blueprints import pages_v3 as pages
|
|
|
|
plugins_dir = tmp_path / "plugin-repos"
|
|
pdir = plugins_dir / "demo"
|
|
pdir.mkdir(parents=True)
|
|
(pdir / "config_schema.json").write_text(json.dumps(SCHEMA), encoding="utf-8")
|
|
(pdir / "manifest.json").write_text(
|
|
json.dumps({"id": "demo", "name": "Demo", "version": "1.0.0"}),
|
|
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_raw_file_content.return_value = {}
|
|
cm.get_config_path.return_value = str(tmp_path / "config.json")
|
|
|
|
def _save(cfg, **_kw):
|
|
store.clear()
|
|
store.update(cfg)
|
|
return type("R", (), {"status": type("S", (), {"value": "success"})(),
|
|
"message": None})()
|
|
|
|
cm.save_config_atomic.side_effect = _save
|
|
|
|
pm = MagicMock()
|
|
pm.plugins = {}
|
|
pm.plugins_dir = plugins_dir
|
|
pm.get_plugin.return_value = None
|
|
pm.get_plugin_info.return_value = {"name": "Demo", "version": "1.0.0"}
|
|
sm = SchemaManager(plugins_dir=plugins_dir, project_root=tmp_path)
|
|
|
|
names = ("config_manager", "schema_manager", "plugin_manager")
|
|
originals = {(bp, k): getattr(bp, k, None)
|
|
for bp in (api.api_v3, pages.pages_v3) for k in names}
|
|
for bp in (api.api_v3, pages.pages_v3):
|
|
bp.config_manager = cm
|
|
bp.schema_manager = sm
|
|
bp.plugin_manager = pm
|
|
|
|
base = Path(pages.__file__).resolve().parent.parent
|
|
app = Flask(__name__, template_folder=str(base / "templates"),
|
|
static_folder=str(base / "static"))
|
|
app.config["TESTING"] = True
|
|
app.register_blueprint(pages.pages_v3, url_prefix="/v3")
|
|
app.register_blueprint(api.api_v3, url_prefix="/api/v3")
|
|
|
|
try:
|
|
yield app.test_client(), store
|
|
finally:
|
|
for (bp, k), v in originals.items():
|
|
setattr(bp, k, v)
|
|
|
|
|
|
def _render(client):
|
|
resp = client.get("/v3/partials/plugin-config/demo")
|
|
assert resp.status_code == 200
|
|
return resp.get_data(as_text=True)
|
|
|
|
|
|
def _fields(html):
|
|
parser = _FormFields()
|
|
parser.feed(html)
|
|
return dict(parser.pairs)
|
|
|
|
|
|
def test_missing_booleans_render_their_schema_default(app_client):
|
|
client, _ = app_client
|
|
fields = _fields(_render(client))
|
|
# Checked, so a browser posts them.
|
|
assert fields.get("show_date") == "true"
|
|
assert fields.get("show_date_line") == "true"
|
|
assert fields.get("grid.show_lines") == "true"
|
|
assert fields.get("overlay.show_sun") == "true"
|
|
# A saved value still wins over the default.
|
|
assert fields.get("show_seconds") == "true"
|
|
|
|
|
|
def test_missing_non_boolean_fields_render_their_schema_default(app_client):
|
|
client, _ = app_client
|
|
fields = _fields(_render(client))
|
|
assert fields.get("style") == "globe" # not the first option
|
|
assert fields.get("overlay.mode") == "ring"
|
|
assert fields.get("brightness") == "70"
|
|
assert fields.get("label") == "UTC"
|
|
|
|
|
|
def test_a_secret_default_is_still_masked(app_client):
|
|
client, _ = app_client
|
|
assert "not-a-real-secret" not in _render(client)
|
|
|
|
|
|
def test_saving_the_rendered_form_keeps_default_true_booleans_on(app_client):
|
|
client, store = app_client
|
|
pairs = list(_fields(_render(client)).items())
|
|
resp = client.post("/api/v3/plugins/config?plugin_id=demo",
|
|
data=MultiDict(pairs))
|
|
assert resp.status_code == 200, resp.get_json()
|
|
|
|
saved = store["demo"]
|
|
assert saved["show_date"] is True
|
|
assert saved["show_date_line"] is True
|
|
assert saved["grid"]["show_lines"] is True
|
|
assert saved["overlay"]["show_sun"] is True
|
|
assert saved["overlay"]["mode"] == "ring"
|
|
assert saved["style"] == "globe"
|
|
assert saved["show_seconds"] is True
|