Merge remote-tracking branch 'origin/main' into claude/frame-timing-harness

This commit is contained in:
Chuck
2026-09-24 17:36:48 -04:00
29 changed files with 1318 additions and 1152 deletions
+90
View File
@@ -0,0 +1,90 @@
"""GET /api/v3/health: the plugin count is real, and a failed check is logged.
The plugin check counted ``plugin_manager.get_available_plugins()``, which
PluginManager does not have; a hasattr guard turned that into a permanent 0.
Each check that fails answers "see logs for details", so it has to log.
"""
import logging
import sys
from pathlib import Path
from types import SimpleNamespace
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
URL = "/api/v3/health"
@pytest.fixture(autouse=True)
def _no_systemctl(monkeypatch):
monkeypatch.setattr("web_interface.blueprints.api_v3.misc._get_display_service_status",
lambda: {"active": True})
def _checks(client):
response = client.get(URL)
assert response.status_code == 200, response.get_json()
return response.get_json()["data"]["checks"]
def test_plugin_count_is_the_number_of_discovered_plugins(api_v3_client, api_v3_module):
api_v3_module.api_v3.plugin_manager.plugin_manifests = {
"clock": {"id": "clock"}, "weather": {"id": "weather"}, "stocks": {"id": "stocks"},
}
check = _checks(api_v3_client)["plugin_system"]
assert check == {"status": "operational", "plugin_count": 3}
def test_plugin_count_discovers_when_nothing_is_discovered_yet(api_v3_client, api_v3_module):
pm = api_v3_module.api_v3.plugin_manager
pm.plugin_manifests = {}
def discover():
pm.plugin_manifests = {"clock": {"id": "clock"}}
pm.discover_plugins.side_effect = discover
assert _checks(api_v3_client)["plugin_system"]["plugin_count"] == 1
def test_a_failed_config_check_is_logged(api_v3_client, api_v3_module, caplog):
api_v3_module.api_v3.config_manager.load_config.side_effect = OSError("disk gone")
with caplog.at_level(logging.WARNING):
check = _checks(api_v3_client)["config_file"]
assert check["error"] == "see logs for details"
logged = [r for r in caplog.records if "config file" in r.getMessage()]
assert logged and logged[0].exc_info and "disk gone" in str(logged[0].exc_info[1])
def test_a_failed_plugin_check_is_logged(api_v3_client, api_v3_module, caplog, monkeypatch):
def boom():
raise RuntimeError("manifests unreadable")
monkeypatch.setattr("web_interface.blueprints.api_v3.misc._discovered_plugin_manifests", boom)
with caplog.at_level(logging.WARNING):
check = _checks(api_v3_client)["plugin_system"]
assert check["status"] == "error"
logged = [r for r in caplog.records if "count plugins" in r.getMessage()]
assert logged and logged[0].exc_info
def test_a_failed_hardware_check_is_logged(api_v3_client, api_v3_module, caplog, monkeypatch):
def getmtime(_path):
raise PermissionError("denied")
fake_os = SimpleNamespace(path=SimpleNamespace(exists=lambda _p: True, getmtime=getmtime))
monkeypatch.setattr("web_interface.blueprints.api_v3.misc.os", fake_os)
with caplog.at_level(logging.WARNING):
check = _checks(api_v3_client)["hardware"]
assert check["status"] == "unknown"
logged = [r for r in caplog.records if "snapshot" in r.getMessage()]
assert logged and logged[0].exc_info
+68
View File
@@ -0,0 +1,68 @@
"""GET /plugins/health/<id> and /plugins/metrics/<id> read the display
service's latest state, not the web process's first snapshot.
The display service writes health and metrics to the shared cache; the web
process only reads them. Its tracker and monitor keep what they read first in
memory, so without ``force_reload`` the per-plugin routes kept answering with
that first read while the list routes (which pass it) moved on.
"""
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
from src.plugin_system.plugin_health import PluginHealthTracker # noqa: E402
from src.plugin_system.resource_monitor import PluginResourceMonitor # noqa: E402
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
class SharedCache:
"""The on-disk cache both processes see, reduced to a dict."""
def __init__(self):
self.entries = {}
def get(self, key, max_age=None, memory_ttl=None):
return self.entries.get(key)
def set(self, key, value, *args, **kwargs):
self.entries[key] = value
@pytest.fixture
def shared_cache(api_v3_module):
cache = SharedCache()
pm = api_v3_module.api_v3.plugin_manager
pm.health_tracker = PluginHealthTracker(cache)
pm.resource_monitor = PluginResourceMonitor(cache)
return cache
def test_health_reflects_failures_recorded_after_the_first_read(api_v3_client, shared_cache):
first = api_v3_client.get("/api/v3/plugins/health/weather").get_json()["data"]
assert first["total_failures"] == 0
display_side = PluginHealthTracker(shared_cache)
display_side.record_failure("weather", RuntimeError("api down"))
display_side.record_failure("weather", RuntimeError("api down"))
later = api_v3_client.get("/api/v3/plugins/health/weather").get_json()["data"]
assert later["total_failures"] == 2
def test_metrics_reflect_calls_recorded_after_the_first_read(api_v3_client, shared_cache):
first = api_v3_client.get("/api/v3/plugins/metrics/weather").get_json()["data"]
assert first["call_count"] == 0
shared_cache.set("plugin_metrics:weather", {
"memory_mb": 12.5, "cpu_percent": 3.0, "execution_time": 0.2,
"call_count": 40, "total_execution_time": 8.0,
"max_execution_time": 0.5, "min_execution_time": 0.1,
"last_update_time": 1000.0,
})
later = api_v3_client.get("/api/v3/plugins/metrics/weather").get_json()["data"]
assert later["call_count"] == 40
+8 -4
View File
@@ -66,12 +66,16 @@ class TestRefreshPluginStore:
assert response.status_code == 200
@pytest.mark.parametrize("key", ["fetch_commit_info", "fetch_latest_versions"])
def test_either_commit_info_key_extends_the_message(
def test_commit_info_flag_claims_no_refresh_it_does_not_do(
self, api_v3_client, api_v3_module, key):
# fetch_latest_versions is the older spelling; both must work.
api_v3_module.api_v3.plugin_store_manager.fetch_registry.return_value = {"plugins": []}
# The route only re-downloads the registry. It used to append "(with
# refreshed commit metadata from GitHub)" for either flag without
# fetching any.
store = api_v3_module.api_v3.plugin_store_manager
store.fetch_registry.return_value = {"plugins": [{"id": "a"}]}
response = api_v3_client.post(self.URL, json={key: True})
assert "commit metadata" in response.get_json()["message"]
assert response.get_json()["message"] == "Plugin store refreshed"
store.fetch_registry.assert_called_once_with(force_refresh=True)
def test_message_stays_plain_without_the_flag(self, api_v3_client, api_v3_module):
api_v3_module.api_v3.plugin_store_manager.fetch_registry.return_value = {"plugins": []}
+55
View File
@@ -0,0 +1,55 @@
"""POST /config/main refuses a malformed Vegas plugin order or exclusion list.
Both were parsed with ``except JSONDecodeError: ... = []``, so a bad value
cleared the saved list and answered 200. They now fail the save with a 400,
as plugin_rotation_order already did.
"""
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
URL = "/api/v3/config/main"
@pytest.fixture
def saved(api_v3_module):
config = {"display": {"vegas_scroll": {"plugin_order": ["clock", "weather"],
"excluded_plugins": ["stocks"]}}}
cm = api_v3_module.api_v3.config_manager
cm.load_config.return_value = config
cm.save_config_atomic.return_value.status.value = 'success'
return cm
@pytest.mark.parametrize("field", ["vegas_plugin_order", "vegas_excluded_plugins"])
@pytest.mark.parametrize("value", ["[not json", '{"a": 1}', "[1, 2]", 7])
def test_malformed_list_is_refused_and_nothing_is_saved(api_v3_client, saved, field, value):
response = api_v3_client.post(URL, json={field: value})
assert response.status_code == 400, response.get_json()
assert field in response.get_json()["message"]
saved.save_config_atomic.assert_not_called()
saved.save_config.assert_not_called()
@pytest.mark.parametrize("value", ['["weather", "clock"]', ["weather", "clock"]])
def test_json_text_or_array_is_stored(api_v3_client, saved, value):
response = api_v3_client.post(URL, json={"vegas_plugin_order": value})
assert response.status_code == 200, response.get_json()
stored = saved.save_config_atomic.call_args.args[0]
assert stored["display"]["vegas_scroll"]["plugin_order"] == ["weather", "clock"]
assert stored["display"]["vegas_scroll"]["excluded_plugins"] == ["stocks"]
def test_rotation_order_keeps_its_messages(api_v3_client, saved):
response = api_v3_client.post(URL, json={"plugin_rotation_order": "[oops"})
assert response.status_code == 400
assert response.get_json()["message"] == "plugin_rotation_order must be valid JSON"
+79
View File
@@ -0,0 +1,79 @@
"""GET /api/v3/display/current passes the snapshot PNG through untouched.
It used to PIL-decode the snapshot and re-encode it, which cost CPU on the Pi
for no change in the picture, and it swallowed any read failure with
``except Exception: pass``. It now sends the file's own bytes, the payload the
/stream/display SSE stream sends, and logs a failed read.
"""
import base64
import io
import logging
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from flask import Flask
from PIL import Image, PngImagePlugin
sys.path.insert(0, str(Path(__file__).parent.parent))
from web_interface import display_preview # noqa: E402
@pytest.fixture
def client(monkeypatch):
from web_interface.blueprints.api_v3 import api_v3
monkeypatch.setattr(api_v3, 'config_manager', MagicMock(), raising=False)
api_v3.config_manager.load_config.return_value = {}
app = Flask(__name__)
app.config['TESTING'] = True
app.register_blueprint(api_v3, url_prefix='/api/v3')
with app.test_client() as test_client:
yield test_client
@pytest.fixture
def snapshot(tmp_path, monkeypatch):
"""A snapshot PNG carrying a text chunk, which a PIL re-encode drops."""
info = PngImagePlugin.PngInfo()
info.add_text('written-by', 'display_manager')
buffer = io.BytesIO()
Image.new('RGB', (4, 2), (255, 0, 0)).save(buffer, format='PNG', pnginfo=info)
path = tmp_path / 'led_matrix_preview.png'
path.write_bytes(buffer.getvalue())
monkeypatch.setattr(display_preview, 'SNAPSHOT_PATH', str(path))
return path
def _image(client):
response = client.get('/api/v3/display/current')
assert response.status_code == 200
return response.get_json()['data']
def test_the_snapshot_bytes_are_sent_as_they_are(client, snapshot):
data = _image(client)
assert base64.b64decode(data['image']) == snapshot.read_bytes()
def test_route_and_stream_send_the_same_payload_keys(client, snapshot):
data = _image(client)
assert set(data) == set(display_preview.preview_payload(1, 1, None))
def test_no_snapshot_is_a_null_image_without_a_warning(client, tmp_path, monkeypatch, caplog):
monkeypatch.setattr(display_preview, 'SNAPSHOT_PATH', str(tmp_path / 'missing.png'))
with caplog.at_level(logging.WARNING):
assert _image(client)['image'] is None
assert not [r for r in caplog.records if 'snapshot' in r.getMessage()]
def test_an_unreadable_snapshot_is_logged(client, snapshot, monkeypatch, caplog):
def denied(_path=None):
raise PermissionError('denied')
monkeypatch.setattr(display_preview, 'read_snapshot_base64', denied)
with caplog.at_level(logging.WARNING):
assert _image(client)['image'] is None
assert [r for r in caplog.records if 'snapshot' in r.getMessage() and r.exc_info]
+23 -3
View File
@@ -31,8 +31,12 @@ import pytest
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
from web_interface import system_metrics # noqa: E402
from web_interface.system_metrics import collect_system_metrics # noqa: E402
MB = 1024 * 1024
GB = 1024 * MB
@pytest.fixture
def metrics(monkeypatch):
@@ -45,10 +49,11 @@ def metrics(monkeypatch):
lambda _p: (_ for _ in ()).throw(OSError("no such mount")))
else:
monkeypatch.setattr(psutil, "disk_usage",
lambda _p: SimpleNamespace(percent=disk_percent))
lambda _p: SimpleNamespace(percent=disk_percent,
total=32 * GB, used=4 * GB))
monkeypatch.setattr(psutil, "virtual_memory",
lambda: SimpleNamespace(percent=used_percent,
available=available_bytes))
lambda: SimpleNamespace(percent=used_percent, total=1024 * MB,
used=600 * MB, available=available_bytes))
return collect_system_metrics()
return _collect
@@ -101,3 +106,18 @@ class TestEveryPredictiveFieldIsPresent:
"memory_available_mb", "disk_used_percent"):
assert field in m
assert m["disk_used_percent"] is None
assert all(m[key] is None for key in m if key != "cpu_temp")
class TestUnavailableIsNull:
"""One answer for "could not be read": None, never 0."""
def test_unreadable_temperature_is_null_not_zero_degrees(self, metrics, monkeypatch):
monkeypatch.setattr(system_metrics, "_THERMAL_ZONE", "/nonexistent/thermal/temp")
assert metrics()["cpu_temp"] is None
def test_readable_temperature_is_degrees_c(self, metrics, monkeypatch, tmp_path):
zone = tmp_path / "temp"
zone.write_text("48312\n")
monkeypatch.setattr(system_metrics, "_THERMAL_ZONE", str(zone))
assert metrics()["cpu_temp"] == 48.3
+65
View File
@@ -0,0 +1,65 @@
"""/partials/<name> dispatch and the plugin web_ui page's directory lookup."""
import logging
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from flask import Flask
sys.path.insert(0, str(Path(__file__).parent.parent))
from web_interface.blueprints import pages_v3 as module # noqa: E402
@pytest.fixture
def client(tmp_path, monkeypatch):
plugin_manager = MagicMock()
plugin_manager.plugins_dir = tmp_path
monkeypatch.setattr(module.pages_v3, "plugin_manager", plugin_manager, raising=False)
monkeypatch.setattr(module.pages_v3, "config_manager",
MagicMock(load_config=lambda: {}), raising=False)
app = Flask(__name__, template_folder=str(
Path(module.__file__).resolve().parents[1] / "templates"))
app.register_blueprint(module.pages_v3)
return app.test_client()
def test_every_tab_the_old_if_chain_served_is_still_served():
assert set(module._PARTIAL_LOADERS) == {
"overview", "general", "display", "durations", "schedule", "plugins",
"fonts", "logs", "raw-json", "backup-restore", "wifi", "cache",
"operation-history", "tools",
}
def test_an_unknown_partial_is_a_404(client):
response = client.get("/partials/nope")
assert response.status_code == 404
def test_a_failing_loader_is_one_500_that_names_the_partial(client, monkeypatch, caplog):
def boom():
raise RuntimeError("template exploded")
monkeypatch.setitem(module._PARTIAL_LOADERS, "logs", boom)
with caplog.at_level(logging.ERROR):
response = client.get("/partials/logs")
assert response.status_code == 500
assert response.get_data(as_text=True) == "Error loading partial"
errors = [r for r in caplog.records if r.levelno >= logging.ERROR]
assert len(errors) == 1
assert errors[0].getMessage() == "Error loading partial logs"
def test_web_ui_page_uses_the_ledmatrix_prefix_fallback(client, tmp_path):
web_ui = tmp_path / "ledmatrix-radar" / "web_ui"
web_ui.mkdir(parents=True)
(web_ui / "panel.html").write_text("<p>radar panel</p>", encoding="utf-8")
response = client.get("/plugin-ui/radar/web-ui/panel.html")
assert response.status_code == 200
assert "radar panel" in response.get_data(as_text=True)
+22 -1
View File
@@ -51,7 +51,7 @@ def _memory(total_mb, used_mb, available_mb):
def _get_status(client, memory):
# The endpoint caches for 10s; bypass so each case is measured fresh.
with patch("web_interface.cache.get_cached", return_value=None), \
with patch("web_interface.blueprints.api_v3.system.get_cached", return_value=None), \
patch("psutil.virtual_memory", return_value=memory), \
patch("psutil.cpu_percent", return_value=5.0), \
patch("psutil.boot_time", return_value=0.0):
@@ -90,3 +90,24 @@ def test_a_nearly_exhausted_board_reports_a_small_number(client):
# to fork. The readout has to surface that rather than round it away.
data = _get_status(client, _memory(total_mb=905, used_mb=800, available_mb=73))
assert data["memory_available_mb"] == pytest.approx(73, abs=0.5)
def test_status_and_live_stream_give_the_same_answer(client, monkeypatch):
"""/system/status is built on collect_system_metrics(), so a metric has one
value, and "could not be read" is null in both."""
from web_interface import system_metrics
monkeypatch.setattr(system_metrics, "_THERMAL_ZONE", "/nonexistent/thermal/temp")
memory = _memory(total_mb=905, used_mb=620, available_mb=284)
with patch("psutil.virtual_memory", return_value=memory), \
patch("psutil.cpu_percent", return_value=5.0), \
patch("psutil.boot_time", return_value=0.0), \
patch("psutil.disk_usage", side_effect=OSError("no such mount")):
streamed = system_metrics.collect_system_metrics()
with patch("web_interface.blueprints.api_v3.system.get_cached", return_value=None):
status = json.loads(client.get("/api/v3/system/status").data)["data"]
assert status["cpu_temp"] is None
assert status["disk_used_percent"] is None
for key in system_metrics.METRIC_KEYS:
if key != "uptime_seconds": # the two reads are a moment apart
assert status[key] == streamed[key], key
@@ -0,0 +1,210 @@
"""POST /plugins/config and POST /plugins/config/reset over a real
ConfigManager and SchemaManager.
The save path reshapes what the browser posts before validating it, and these
tests pin the reshaping that validation depends on: repeats in a uniqueItems
list are dropped, and a list the form posted as numbered fields becomes a list
again.
"""
import json
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from src.config_manager import ConfigManager # noqa: E402
from src.plugin_system.schema_manager import SchemaManager # noqa: E402
from test._api_v3_test_helpers import api_v3_module, build_app # noqa: E402,F401
PLUGIN_ID = "stocks"
SCHEMA = {
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": True},
"stock_symbols": {
"type": "array",
"uniqueItems": True,
"items": {"type": "string"},
"default": ["AAPL"],
},
"refresh_seconds": {"type": "integer", "default": 60},
"api_key": {"type": "string", "x-secret": True, "default": ""},
},
}
# The shape of the news plugin's schema: a list of objects nested one level
# down. A form posts it as feeds.custom_feeds.0.name, feeds.custom_feeds.0.url,
# which lands as a dict keyed "0", "1", ...
NEWS_ID = "news"
NEWS_SCHEMA = {
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": True},
"feeds": {
"type": "object",
"properties": {
"enabled_feeds": {
"type": "array", "items": {"type": "string"}, "default": [],
},
"custom_feeds": {
"type": "array",
"default": [],
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"url": {"type": "string"},
"enabled": {"type": "boolean", "default": True},
},
},
},
},
},
},
}
@pytest.fixture
def env(tmp_path, api_v3_module):
"""Real config and schema managers under tmp_path, on the blueprint."""
plugins_dir = tmp_path / "plugins"
for plugin_id, schema in ((PLUGIN_ID, SCHEMA), (NEWS_ID, NEWS_SCHEMA)):
plugin_dir = plugins_dir / plugin_id
plugin_dir.mkdir(parents=True)
(plugin_dir / "config_schema.json").write_text(json.dumps(schema))
(plugin_dir / "manifest.json").write_text(json.dumps(
{"id": plugin_id, "name": plugin_id, "version": "1.0.0"}))
config_file = tmp_path / "config.json"
config_file.write_text(json.dumps(
{PLUGIN_ID: {"enabled": True, "stock_symbols": ["AAPL", "FNMA"]}}))
config_manager = ConfigManager(
config_path=str(config_file),
secrets_path=str(tmp_path / "config_secrets.json"))
config_manager.template_path = str(tmp_path / "no-template.json")
api = api_v3_module.api_v3
api.config_manager = config_manager
api.schema_manager = SchemaManager(plugins_dir=plugins_dir, project_root=tmp_path)
api.plugin_manager.plugin_manifests = {PLUGIN_ID: {"id": PLUGIN_ID},
NEWS_ID: {"id": NEWS_ID}}
api.plugin_manager.get_plugin.return_value = None
class Env:
client = build_app(api).test_client()
plugin_manager = api.plugin_manager
@staticmethod
def stored(plugin_id=PLUGIN_ID):
return json.loads(config_file.read_text())[plugin_id]
Env.config_manager = config_manager
return Env
class TestUniqueItemsRepeats:
"""A repeat in a uniqueItems list is dropped, not a failed save."""
def test_form_post_repeating_a_saved_symbol_saves(self, env):
response = env.client.post(
f"/api/v3/plugins/config?plugin_id={PLUGIN_ID}",
data={"stock_symbols": "AAPL, FNMA, FNMA"})
assert response.status_code == 200, response.get_json()
assert env.stored()["stock_symbols"] == ["AAPL", "FNMA"]
def test_json_post_keeps_first_occurrence_order(self, env):
response = env.client.post("/api/v3/plugins/config", json={
"plugin_id": PLUGIN_ID,
"config": {"stock_symbols": ["TSLA", "AAPL", "TSLA", "FNMA"]},
})
assert response.status_code == 200, response.get_json()
assert env.stored()["stock_symbols"] == ["TSLA", "AAPL", "FNMA"]
class TestNumberedFieldsBecomeLists:
"""The news plugin's custom feeds, posted one field per row."""
FEEDS = [{"name": "Local", "url": "https://example.com/local.xml", "enabled": True},
{"name": "Tech", "url": "https://example.com/tech.xml", "enabled": False}]
def test_form_post(self, env):
response = env.client.post(f"/api/v3/plugins/config?plugin_id={NEWS_ID}", data={
"feeds.custom_feeds.0.name": "Local",
"feeds.custom_feeds.0.url": "https://example.com/local.xml",
"feeds.custom_feeds.0.enabled": "on",
"feeds.custom_feeds.1.name": "Tech",
"feeds.custom_feeds.1.url": "https://example.com/tech.xml",
})
assert response.status_code == 200, response.get_json()
assert env.stored(NEWS_ID)["feeds"]["custom_feeds"] == self.FEEDS
def test_json_post(self, env):
response = env.client.post("/api/v3/plugins/config", json={
"plugin_id": NEWS_ID,
"config": {"feeds": {"custom_feeds": {"1": self.FEEDS[1], "0": self.FEEDS[0]}}},
})
assert response.status_code == 200, response.get_json()
assert env.stored(NEWS_ID)["feeds"]["custom_feeds"] == self.FEEDS
class TestReset:
"""POST /plugins/config/reset saves the way every other plugin save does."""
def test_reset_saves_atomically_with_a_backup(self, env, monkeypatch):
cm = env.config_manager
calls = []
real_atomic = cm.save_config_atomic
def spy(config, create_backup=True, **kwargs):
calls.append(create_backup)
return real_atomic(config, create_backup=create_backup, **kwargs)
def no_plain_save(_config):
raise AssertionError("reset bypassed the atomic save")
monkeypatch.setattr(cm, "save_config_atomic", spy)
monkeypatch.setattr(cm, "save_config", no_plain_save)
response = env.client.post("/api/v3/plugins/config/reset",
json={"plugin_id": PLUGIN_ID})
assert response.status_code == 200, response.get_json()
assert calls == [True]
assert env.stored()["stock_symbols"] == ["AAPL"]
def test_reset_notifies_the_plugin_with_its_prepared_config(self, env):
plugin = MagicMock()
env.plugin_manager.get_plugin.return_value = plugin
env.plugin_manager.prepare_plugin_config.side_effect = (
lambda _pid, raw: {**raw, "prepared": True})
response = env.client.post("/api/v3/plugins/config/reset",
json={"plugin_id": PLUGIN_ID})
assert response.status_code == 200, response.get_json()
handed_over = plugin.on_config_change.call_args.args[0]
assert handed_over["prepared"] is True
assert handed_over["stock_symbols"] == ["AAPL"]
def test_a_failed_save_is_reported(self, env, monkeypatch):
failed = MagicMock(message="disk full")
failed.status.value = "failed"
monkeypatch.setattr(env.config_manager, "save_config_atomic",
MagicMock(return_value=failed))
response = env.client.post("/api/v3/plugins/config/reset",
json={"plugin_id": PLUGIN_ID})
assert response.status_code == 500
assert "disk full" in response.get_json()["message"]
+58
View File
@@ -0,0 +1,58 @@
"""coerce_array_shapes: position-keyed dicts back into lists before validation."""
from src.web_interface.config_arrays import coerce_array_shapes
COLOR = {"type": "array", "items": {"type": "integer"},
"minItems": 3, "maxItems": 3, "default": [255, 255, 255]}
def test_position_keys_become_a_list_in_numeric_order():
config = {"tags": {"10": "k", "2": "c", "0": "a"}}
coerce_array_shapes(config, {"tags": {"type": "array"}})
assert config["tags"] == ["a", "c", "k"]
def test_an_empty_dict_becomes_an_empty_list():
config = {"tags": {}}
coerce_array_shapes(config, {"tags": {"type": "array"}})
assert config["tags"] == []
def test_a_dict_with_other_keys_is_left_for_validation_to_report():
config = {"tags": {"0": "a", "name": "b"}}
coerce_array_shapes(config, {"tags": {"type": "array"}})
assert config["tags"] == {"0": "a", "name": "b"}
def test_element_types_are_left_to_normalization():
config = {"color": {"0": "1", "1": "2", "2": "3"}}
coerce_array_shapes(config, {"color": COLOR})
assert config["color"] == ["1", "2", "3"]
def test_nested_objects_and_array_items_are_walked():
schema = {"feeds": {"type": "object", "properties": {
"custom_feeds": {"type": "array", "items": {"type": "object", "properties": {
"tags": {"type": "array"},
}}},
}}}
config = {"feeds": {"custom_feeds": {"0": {"tags": {"0": "news"}}}}}
coerce_array_shapes(config, schema)
assert config == {"feeds": {"custom_feeds": [{"tags": ["news"]}]}}
def test_a_short_form_list_takes_the_default_only_when_asked():
config = {"color": ["10", "20"]}
coerce_array_shapes(config, {"color": COLOR})
assert config["color"] == ["10", "20"]
coerce_array_shapes(config, {"color": COLOR}, short_lists_take_default=True)
assert config["color"] == [255, 255, 255]
assert config["color"] is not COLOR["default"]
def test_a_default_too_short_itself_is_not_used():
schema = {"color": dict(COLOR, default=[0])}
config = {"color": ["10"]}
coerce_array_shapes(config, schema, short_lists_take_default=True)
assert config["color"] == ["10"]
@@ -1,11 +1,9 @@
"""Tests for dedup_unique_arrays used by save_plugin_config.
"""Tests for dedup_unique_arrays, which the plugin-config save path
(_prepare_plugin_config_for_save in api_v3/plugins.py) runs before validation.
Validates that arrays with uniqueItems: true in the JSON schema have
duplicates removed before validation, preventing spurious validation
failures when form merging introduces duplicate entries.
Tests import the production function from src.web_interface.validators
to ensure they exercise the real code path.
Arrays with uniqueItems: true in the JSON schema lose their duplicates, so a
repeat introduced by form merging does not fail validation.
test_api_v3_plugin_config_save.py checks the same through the endpoint.
"""