From f91f1b02e37b17c92052e9eaea31cae51dad47d1 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:30:28 -0400 Subject: [PATCH] fix(web): render the config form from the schema the save route validates The form read config_schema.json with a raw json.load while api_v3.save_plugin_config went through SchemaManager. Those are not the same schema: SchemaManager applies expand_style_elements, which turns a compact customization.x-style-elements declaration into the per-element blocks the form knows how to render. Without it, that customization object has an x-style-elements key and no "properties", so the template's object branch matched nothing and the section rendered as empty space -- while saving still validated against the expanded shape. of-the-day ships the compact form, so its customization section has been invisible in the web UI. pages_v3 gains a schema_manager the way it already has config_manager and plugin_manager. use_cache=False matches the save route, so an edited schema is not served stale during plugin development. The raw read stays as a fallback for callers that register this blueprint without one. Checked before making the change: load_schema does nothing here except read, validate and expand -- inject_skin_selector is a separate method it does not call -- so this is not a behaviour change for schemas without the declaration. The test pair renders the same compact schema with and without a SchemaManager, so it documents exactly what was broken as well as what is fixed. Co-Authored-By: Claude Opus 5 --- .../test_plugin_config_schema_expansion.py | 182 ++++++++++++++++++ web_interface/app.py | 1 + web_interface/blueprints/pages_v3.py | 38 +++- 3 files changed, 215 insertions(+), 6 deletions(-) create mode 100644 test/web_interface/test_plugin_config_schema_expansion.py diff --git a/test/web_interface/test_plugin_config_schema_expansion.py b/test/web_interface/test_plugin_config_schema_expansion.py new file mode 100644 index 00000000..42af00b9 --- /dev/null +++ b/test/web_interface/test_plugin_config_schema_expansion.py @@ -0,0 +1,182 @@ +"""The config form must render the same schema the save route validates. + +The form used to read config_schema.json with a raw json.load while +api_v3.save_plugin_config went through SchemaManager. That is not a stylistic +difference: SchemaManager applies expand_style_elements, which turns a compact +``customization.x-style-elements`` declaration into the per-element blocks the +form knows how to render. Without it the customization object has an +x-style-elements key and no ``properties``, so the template's object branch +matched nothing and the whole section rendered as empty space -- while saving +still validated against the expanded shape. + +of-the-day ships the compact form, so this was live. +""" + +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)) + +COMPACT_SCHEMA = { + "type": "object", + "properties": { + "enabled": {"type": "boolean", "default": False}, + "customization": { + "type": "object", + "title": "Display Customization", + "x-style-modes": ["live", "recent"], + "x-style-elements": { + "title_text": { + "title": "Title", + "font": {"default": "PressStart2P-Regular.ttf"}, + "size": {"default": 8, "min": 4, "max": 16}, + "color": {"default": [255, 255, 255]}, + "offsets": True, + }, + }, + }, + }, +} + +MANUAL_SCHEMA = { + "type": "object", + "properties": { + "enabled": {"type": "boolean", "default": False}, + "customization": { + "type": "object", + "title": "Display Customization", + "properties": { + "title_text": { + "type": "object", + "title": "Title", + "properties": { + "font_size": {"type": "integer", "default": 8}, + }, + }, + }, + }, + }, +} + + +@pytest.fixture +def render(tmp_path): + """Render a plugin's config partial, with or without a SchemaManager.""" + from web_interface.blueprints import pages_v3 as pv + from src.plugin_system.schema_manager import SchemaManager + + orig_pm = getattr(pv.pages_v3, "plugin_manager", None) + orig_cm = getattr(pv.pages_v3, "config_manager", None) + orig_sm = getattr(pv.pages_v3, "schema_manager", None) + + def _render(schema, with_schema_manager=True, plugin_id="demo"): + pdir = tmp_path / "plugins" / plugin_id + pdir.mkdir(parents=True, exist_ok=True) + (pdir / "config_schema.json").write_text(json.dumps(schema), + encoding="utf-8") + (pdir / "manifest.json").write_text( + json.dumps({"id": plugin_id, "name": plugin_id}), encoding="utf-8") + + pm = MagicMock() + pm.plugins_dir = str(tmp_path / "plugins") + pm.get_plugin_info.return_value = {"id": plugin_id, "name": plugin_id} + pm.get_plugin.return_value = None + pv.pages_v3.plugin_manager = pm + + cm = MagicMock() + cm.load_config.return_value = {plugin_id: {"enabled": True}} + pv.pages_v3.config_manager = cm + + pv.pages_v3.schema_manager = ( + SchemaManager(plugins_dir=tmp_path / "plugins", + project_root=tmp_path) + if with_schema_manager else None) + + base = PROJECT_ROOT / "web_interface" + app = Flask(__name__, + template_folder=str(base / "templates"), + static_folder=str(base / "static")) + app.config["TESTING"] = True + app.register_blueprint(pv.pages_v3, url_prefix="") + resp = app.test_client().get(f"/partials/plugin-config/{plugin_id}") + assert resp.status_code == 200, resp.status_code + return resp.get_data(as_text=True) + + try: + yield _render + finally: + pv.pages_v3.plugin_manager = orig_pm + pv.pages_v3.config_manager = orig_cm + pv.pages_v3.schema_manager = orig_sm + + +class TestCompactDeclarationRenders: + def test_the_element_fields_appear(self, render): + """This is the regression: the section used to render empty.""" + body = render(COMPACT_SCHEMA) + assert 'name="customization.title_text.font_size"' in body + # The font field is widget-rendered, so its