56853b7123
Open-registry section/perf-cell schema persisted server-side under
db.get_setting('dashboard_layout'); localStorage cache for instant
first-paint, server sync after auth. 5 built-in presets
(Studio/Operator/Showrunner/Diagnostics/TV); JSON export/import.
Slide-in Customize panel toggles section + perf-cell visibility,
reorders via hand-rolled HTML5 drag (with up/down buttons for
keyboard/TV-remote use), changes density per section, and exposes
global Width / Animations / Perf-mode / Window with per-cell Inherit
overrides.
Window setting now drives the actual sparkline slice (30s/1m/2m/5m at
configurable poll interval) instead of always rendering 120 fixed
samples. Perf-grid edits re-render in place — sparklines repaint from
persistent module-level history, value labels replay from cached
last-fetch payload, so there is no flicker frame and no zero-data
window between layout change and next poll. initPerfCharts now fires
an immediate fetch on init so reload no longer shows "—" until the
first interval tick.
Reset confirmation uses the project's themed showConfirm modal
instead of the browser dialog. Reserved registry keys (audio-meters,
alerts, led-preview, source-thumbs, pinned, flow) are forward-
compatible so v1.1 cards slot in without a schema bump.
Backend exposes GET/PUT/DELETE /api/v1/preferences/dashboard-layout
treating the body as opaque JSON with a numeric version gate; covered
by 6 round-trip / validation / unknown-field tests.
146 lines
4.4 KiB
Python
146 lines
4.4 KiB
Python
"""Tests for /api/v1/preferences/dashboard-layout endpoints."""
|
|
|
|
import pytest
|
|
|
|
from ledgrab.config import get_config
|
|
|
|
_config = get_config()
|
|
_api_key = next(iter(_config.auth.api_keys.values()), "")
|
|
AUTH_HEADERS = {"Authorization": f"Bearer {_api_key}"} if _api_key else {}
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def client():
|
|
from fastapi.testclient import TestClient
|
|
|
|
from ledgrab.main import app
|
|
|
|
with TestClient(app, raise_server_exceptions=False) as c:
|
|
yield c
|
|
|
|
|
|
def _minimal_layout() -> dict:
|
|
return {
|
|
"version": 1,
|
|
"sections": [
|
|
{
|
|
"key": "perf",
|
|
"visible": True,
|
|
"collapsedDefault": False,
|
|
"density": "comfortable",
|
|
"options": {},
|
|
},
|
|
],
|
|
"perfCells": [
|
|
{
|
|
"key": "cpu",
|
|
"visible": True,
|
|
"mode": "inherit",
|
|
"span": 1,
|
|
"window": 120,
|
|
"yScale": "auto",
|
|
"precision": 1,
|
|
"showSubtitle": True,
|
|
"showRefLine": True,
|
|
},
|
|
],
|
|
"global": {
|
|
"width": "full",
|
|
"accent": "target",
|
|
"animations": "full",
|
|
"emptyState": "hide",
|
|
"toolbarPosition": "top",
|
|
"autoCollapseRunningEmpty": False,
|
|
"showTutorial": True,
|
|
"perfMode": "both",
|
|
"pollMs": 1000,
|
|
},
|
|
}
|
|
|
|
|
|
def test_get_dashboard_layout_default_empty(client):
|
|
"""When no layout has been saved, GET returns an empty object."""
|
|
# Clear first so this test is order-independent.
|
|
client.delete("/api/v1/preferences/dashboard-layout", headers=AUTH_HEADERS)
|
|
resp = client.get("/api/v1/preferences/dashboard-layout", headers=AUTH_HEADERS)
|
|
assert resp.status_code == 200
|
|
assert resp.json() == {}
|
|
|
|
|
|
def test_put_then_get_dashboard_layout(client):
|
|
"""PUT a layout, GET it back unchanged."""
|
|
layout = _minimal_layout()
|
|
put = client.put(
|
|
"/api/v1/preferences/dashboard-layout",
|
|
json=layout,
|
|
headers=AUTH_HEADERS,
|
|
)
|
|
assert put.status_code == 200
|
|
assert put.json() == {"ok": True}
|
|
|
|
got = client.get("/api/v1/preferences/dashboard-layout", headers=AUTH_HEADERS)
|
|
assert got.status_code == 200
|
|
body = got.json()
|
|
assert body["version"] == 1
|
|
assert body["sections"][0]["key"] == "perf"
|
|
assert body["perfCells"][0]["key"] == "cpu"
|
|
assert body["global"]["perfMode"] == "both"
|
|
|
|
|
|
def test_put_rejects_missing_version(client):
|
|
"""Body without numeric version field is rejected with 422."""
|
|
bad = {"sections": []}
|
|
resp = client.put(
|
|
"/api/v1/preferences/dashboard-layout",
|
|
json=bad,
|
|
headers=AUTH_HEADERS,
|
|
)
|
|
assert resp.status_code == 422
|
|
|
|
|
|
def test_put_rejects_non_object(client):
|
|
"""Bare arrays / strings / numbers are rejected by FastAPI body validation."""
|
|
resp = client.put(
|
|
"/api/v1/preferences/dashboard-layout",
|
|
json=["not", "an", "object"],
|
|
headers=AUTH_HEADERS,
|
|
)
|
|
assert resp.status_code in (400, 422)
|
|
|
|
|
|
def test_delete_clears_layout(client):
|
|
"""DELETE wipes the saved layout so subsequent GET returns empty."""
|
|
client.put(
|
|
"/api/v1/preferences/dashboard-layout",
|
|
json=_minimal_layout(),
|
|
headers=AUTH_HEADERS,
|
|
)
|
|
deleted = client.delete(
|
|
"/api/v1/preferences/dashboard-layout",
|
|
headers=AUTH_HEADERS,
|
|
)
|
|
assert deleted.status_code == 200
|
|
after = client.get("/api/v1/preferences/dashboard-layout", headers=AUTH_HEADERS)
|
|
assert after.status_code == 200
|
|
assert after.json() == {}
|
|
|
|
|
|
def test_layout_round_trip_preserves_unknown_fields(client):
|
|
"""Frontend may add new keys (e.g. v1.1 sections) — backend must
|
|
pass them through verbatim, not strip them."""
|
|
layout = _minimal_layout()
|
|
layout["futureField"] = {"foo": "bar"}
|
|
layout["sections"].append(
|
|
{
|
|
"key": "audio-meters",
|
|
"visible": True,
|
|
"collapsedDefault": False,
|
|
"density": "comfortable",
|
|
"options": {"sensitivity": 0.7},
|
|
}
|
|
)
|
|
client.put("/api/v1/preferences/dashboard-layout", json=layout, headers=AUTH_HEADERS)
|
|
got = client.get("/api/v1/preferences/dashboard-layout", headers=AUTH_HEADERS).json()
|
|
assert got["futureField"] == {"foo": "bar"}
|
|
assert any(s["key"] == "audio-meters" for s in got["sections"])
|