Files
LEDMatrix/test/test_legacy_boolean_config.py
ChuckandClaude Opus 5 19686ab761 fix(web): plugin settings form shows schema defaults for unsaved keys (#597)
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>
2026-09-18 11:09:25 -04:00

404 lines
16 KiB
Python

"""A boolean the schema has since turned into an ``{enabled, ...}`` object.
The news plugin's ``global.dynamic_duration`` went from ``true`` to an object
with ``enabled``, ``min_duration_seconds``, ... An install that has not saved
the news settings since still holds ``true``, and on every start the display
service logged::
Plugin news config does not match its schema (loading anyway):
Field 'global.dynamic_duration': Expected type object, got bool
and flagged news degraded. The settings form already reads such a boolean as
``{"enabled": <bool>}`` (``render_nested_section`` in plugin_config.html); the
loader did not. Both now follow ``legacy_bool_as_object``, and the parity tests
below render the real macro against it so the two cannot drift.
"""
import json
import sys
from html.parser import HTMLParser
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
PROJECT_ROOT = Path(__file__).parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from src.plugin_system.plugin_manager import PluginManager
from src.plugin_system.schema_manager import (
legacy_bool_as_object,
normalize_legacy_booleans,
plugin_config_defaults,
prepare_plugin_config,
)
DYNAMIC_DURATION = {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": True},
"min_duration_seconds": {"type": "integer", "default": 30,
"minimum": 10, "maximum": 300},
"max_duration_seconds": {"type": "integer", "default": 300,
"minimum": 30, "maximum": 600},
"buffer_ratio": {"type": "number", "default": 0.1,
"minimum": 0.01, "maximum": 1.0},
},
"additionalProperties": False,
}
# The shape of news' schema, cut down to what the tests need.
NEWS_SCHEMA = {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": True},
"global": {
"type": "object",
"properties": {
"font_size": {"type": "integer", "default": 12},
"dynamic_duration": DYNAMIC_DURATION,
"display": {
"type": "object",
"properties": {
"scroll_speed": {"type": "number", "default": 1.0},
},
"additionalProperties": False,
},
"headline_paging": {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": False},
"page_hold_seconds": {"type": "number", "default": 2.0},
},
"additionalProperties": False,
},
},
"additionalProperties": False,
},
"feeds": {
"type": "object",
"properties": {
"custom_feeds": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"enabled": {"type": "boolean", "default": True},
"schedule": {
"type": "object",
"properties": {"enabled": {"type": "boolean"}},
},
},
},
},
},
},
},
"additionalProperties": False,
}
# --------------------------------------------------------------------------
# The helper
# --------------------------------------------------------------------------
class TestLegacyBoolAsObject:
def test_true_under_an_enabled_object_becomes_its_switch(self):
assert legacy_bool_as_object(True, DYNAMIC_DURATION) == {"enabled": True}
def test_false_stays_off(self):
"""Upgrading must never turn a feature the user switched off back on."""
assert legacy_bool_as_object(False, DYNAMIC_DURATION) == {"enabled": False}
def test_nullable_object_type_list_counts_as_object(self):
prop = dict(DYNAMIC_DURATION, type=["object", "null"])
assert legacy_bool_as_object(True, prop) == {"enabled": True}
@pytest.mark.parametrize("value", [1, 0, "true", "false", None, [], 2.5])
def test_a_value_that_is_not_a_real_bool_is_untouched(self, value):
assert legacy_bool_as_object(value, DYNAMIC_DURATION) is value
def test_an_object_without_enabled_is_untouched(self):
prop = {"type": "object", "properties": {"speed": {"type": "number"}}}
assert legacy_bool_as_object(True, prop) is True
def test_a_boolean_or_object_union_is_untouched(self):
"""The form draws ``["boolean", "object"]`` as a checkbox, and the
schema accepts the boolean, so there is nothing to upgrade."""
prop = dict(DYNAMIC_DURATION, type=["boolean", "object"])
assert legacy_bool_as_object(True, prop) is True
def test_a_non_object_schema_is_untouched(self):
assert legacy_bool_as_object(True, {"type": "boolean"}) is True
assert legacy_bool_as_object(True, None) is True
class TestNormalizeLegacyBooleans:
def test_top_level(self):
schema = {"type": "object",
"properties": {"dynamic_duration": DYNAMIC_DURATION}}
out = normalize_legacy_booleans({"dynamic_duration": True}, schema)
assert out == {"dynamic_duration": {"enabled": True}}
def test_nested(self):
cfg = {"global": {"font_size": 12, "dynamic_duration": True}}
out = normalize_legacy_booleans(cfg, NEWS_SCHEMA)
assert out == {"global": {"font_size": 12,
"dynamic_duration": {"enabled": True}}}
def test_several_depths_report_their_paths(self):
schema = {"type": "object", "properties": {
"dynamic_duration": DYNAMIC_DURATION,
"outer": {"type": "object", "properties": {
"inner": {"type": "object", "properties": {
"mode": DYNAMIC_DURATION}}}},
}}
cfg = {"dynamic_duration": False, "outer": {"inner": {"mode": True}}}
changed = []
out = normalize_legacy_booleans(cfg, schema, changed)
assert out == {"dynamic_duration": {"enabled": False},
"outer": {"inner": {"mode": {"enabled": True}}}}
assert changed == ["dynamic_duration", "outer.inner.mode"]
def test_array_items_are_left_alone_as_the_form_does(self):
"""The form hands arrays to widgets and never upgrades inside them."""
cfg = {"feeds": {"custom_feeds": [{"name": "a", "schedule": True}]}}
out = normalize_legacy_booleans(cfg, NEWS_SCHEMA)
assert out is cfg
def test_already_an_object_is_untouched(self):
cfg = {"global": {"dynamic_duration": {"enabled": False,
"min_duration_seconds": 45}}}
assert normalize_legacy_booleans(cfg, NEWS_SCHEMA) is cfg
def test_other_mismatches_are_untouched(self):
cfg = {"global": {"font_size": True, "display": True,
"dynamic_duration": "yes"}}
changed = []
assert normalize_legacy_booleans(cfg, NEWS_SCHEMA, changed) is cfg
assert changed == []
def test_keys_the_schema_does_not_declare_are_untouched(self):
cfg = {"global": {"mystery": True}, "vegas_width_pct": 50}
assert normalize_legacy_booleans(cfg, NEWS_SCHEMA) is cfg
def test_the_stored_config_is_not_mutated(self):
"""load_config() may hand back a cached dict; it must stay as stored."""
cfg = {"enabled": True, "global": {"dynamic_duration": True}}
before = json.loads(json.dumps(cfg))
out = normalize_legacy_booleans(cfg, NEWS_SCHEMA)
assert cfg == before
assert out is not cfg and out["global"] is not cfg["global"]
@pytest.mark.parametrize("config,schema", [
(None, NEWS_SCHEMA), ([], NEWS_SCHEMA), ({"a": True}, None),
({"a": True}, {"type": "object"}),
])
def test_tolerates_odd_inputs(self, config, schema):
assert normalize_legacy_booleans(config, schema) is config
# --------------------------------------------------------------------------
# The load path
# --------------------------------------------------------------------------
PLUGIN_ID = "news-shaped"
@pytest.fixture
def load(tmp_path):
"""Run the real ``PluginManager.load_plugin`` for a plugin with
``NEWS_SCHEMA`` and the given stored section; the plugin class is stubbed.
Returns ``(loaded, config the plugin received, logger, health_tracker)``.
"""
plugins_dir = tmp_path / "plugins"
plugin_dir = plugins_dir / PLUGIN_ID
plugin_dir.mkdir(parents=True)
manifest = {"id": PLUGIN_ID, "name": "News-shaped", "version": "1.0.0",
"entry_point": "manager.py", "class_name": "Plugin"}
(plugin_dir / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
(plugin_dir / "config_schema.json").write_text(json.dumps(NEWS_SCHEMA),
encoding="utf-8")
def _load(stored_section):
config_manager = MagicMock()
config_manager.load_config.return_value = {
PLUGIN_ID: json.loads(json.dumps(stored_section))}
with patch('src.common.permission_utils.ensure_directory_permissions'):
manager = PluginManager(plugins_dir=str(plugins_dir),
config_manager=config_manager,
display_manager=MagicMock(),
cache_manager=MagicMock())
manager.logger = MagicMock()
manager.health_tracker = MagicMock()
manager.plugin_manifests[PLUGIN_ID] = manifest
manager.plugin_loader.find_plugin_directory = MagicMock(return_value=plugin_dir)
received = {}
def fake_load_plugin(**kwargs):
received.update(kwargs["config"])
instance = MagicMock()
instance.validate_config.return_value = True
return instance, MagicMock()
manager.plugin_loader.load_plugin = MagicMock(side_effect=fake_load_plugin)
loaded = manager.load_plugin(PLUGIN_ID)
return loaded, received, manager.logger, manager.health_tracker
return _load
def _schema_warnings(logger):
return [c for c in logger.warning.call_args_list
if "does not match its schema" in str(c.args[0])]
def _degraded_reason(health_tracker):
calls = [c.args for c in health_tracker.set_degraded.call_args_list
if c.args[0] == PLUGIN_ID]
assert calls, "schema validation never ran"
return calls[-1][1]
class TestLoadPath:
def test_news_shaped_legacy_config_loads_without_warning_or_degraded(self, load):
stored = {"enabled": True,
"global": {"font_size": 12, "dynamic_duration": True,
"headline_paging": {"enabled": False}}}
loaded, received, logger, tracker = load(stored)
assert loaded is True
assert _schema_warnings(logger) == []
assert _degraded_reason(tracker) is None
# The plugin gets the object, with the schema defaults filled in.
assert received["global"]["dynamic_duration"] == {
"enabled": True, "min_duration_seconds": 30,
"max_duration_seconds": 300, "buffer_ratio": 0.1}
# And the upgrade is logged once, below warning level.
assert any("global.dynamic_duration" in str(c.args)
for c in logger.info.call_args_list)
def test_a_legacy_false_loads_switched_off(self, load):
loaded, received, logger, tracker = load(
{"enabled": True, "global": {"dynamic_duration": False}})
assert loaded is True
assert received["global"]["dynamic_duration"]["enabled"] is False
assert _schema_warnings(logger) == []
assert _degraded_reason(tracker) is None
def test_a_real_mismatch_still_warns_and_degrades(self, load):
loaded, received, logger, tracker = load(
{"enabled": True, "global": {"dynamic_duration": True,
"font_size": "big"}})
assert loaded is True # warn-only, as before
warnings = _schema_warnings(logger)
assert len(warnings) == 1
assert "font_size" in str(warnings[0].args)
assert "dynamic_duration" not in str(warnings[0].args)
reason = _degraded_reason(tracker)
assert reason and "font_size" in reason
def test_a_boolean_under_an_object_without_enabled_still_warns(self, load):
loaded, received, logger, tracker = load(
{"enabled": True, "global": {"display": True}})
assert loaded is True
assert received["global"]["display"] is True
warnings = _schema_warnings(logger)
assert len(warnings) == 1 and "global.display" in str(warnings[0].args)
assert _degraded_reason(tracker)
# --------------------------------------------------------------------------
# Parity with the settings form
# --------------------------------------------------------------------------
PARITY_SCHEMA = {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": True},
"global": {"type": "object", "properties": {
"dynamic_duration": DYNAMIC_DURATION,
"outer": {"type": "object", "properties": {
"inner": {"type": "object", "properties": {
"enabled": {"type": "boolean", "default": True},
"level": {"type": "integer", "default": 1},
}},
}},
"union": {"type": ["boolean", "object"], "properties": {
"enabled": {"type": "boolean"}}},
}},
},
}
class _Checkboxes(HTMLParser):
def __init__(self):
super().__init__()
self.checked = {}
def handle_starttag(self, tag, attrs):
a = dict(attrs)
if tag == "input" and a.get("type") == "checkbox" and a.get("name"):
self.checked[a["name"]] = "checked" in a
def _form_checkboxes(stored):
from jinja2 import Environment, FileSystemLoader, select_autoescape
env = Environment(
loader=FileSystemLoader(str(PROJECT_ROOT / "web_interface" / "templates")),
autoescape=select_autoescape(["html"]),
)
plugin = {"id": "demo", "name": "Demo", "description": "", "enabled": True,
"author": "me", "version": "1.0.0"}
html = env.get_template("v3/partials/plugin_config.html").render(
plugin=plugin, schema=PARITY_SCHEMA, config=json.loads(json.dumps(stored)))
parser = _Checkboxes()
parser.feed(html)
return parser.checked
def _loader_enabled(prepared, *path):
node = prepared
for key in path:
node = node.get(key) if isinstance(node, dict) else None
if not isinstance(node, dict):
# A value the loader cannot read as the object (1, "true") fails
# validation; the form treats it as missing and draws the schema
# default, as it does for every other missing field.
return True
return node.get("enabled") is True
@pytest.mark.parametrize("value", [True, False, 1, "true", None])
def test_form_and_loader_agree_on_what_a_stored_value_means(value):
"""The form draws the object's ``enabled`` checkbox ticked exactly when the
plugin runs with it on -- legacy booleans read as objects, then schema
defaults filled in (prepare_plugin_config)."""
stored = {"enabled": True,
"global": {"dynamic_duration": value, "outer": {"inner": value}}}
prepared = prepare_plugin_config(stored, PARITY_SCHEMA,
plugin_config_defaults(PARITY_SCHEMA))
boxes = _form_checkboxes(stored)
for path in (("global", "dynamic_duration"), ("global", "outer", "inner")):
name = ".".join(path) + ".enabled"
assert name in boxes, f"form drew no {name} checkbox"
assert boxes[name] is _loader_enabled(prepared, *path), name
@pytest.mark.parametrize("value", [True, False])
def test_form_and_loader_agree_a_boolean_union_stays_a_boolean(value):
stored = {"enabled": True, "global": {"union": value}}
normalized = normalize_legacy_booleans(stored, PARITY_SCHEMA)
boxes = _form_checkboxes(stored)
assert normalized is stored
assert boxes.get("global.union") is value
assert "global.union.enabled" not in boxes