75ca487be1
Adds a comfortable/compact/dense/row toggle to every card grid in the
app. Each surface (LED devices, targets, automations, scenes, sources,
streams, dashboard subsections, etc.) remembers its mode independently.
Persistence mirrors dashboard-layout: localStorage cache for first paint,
debounced PUT to /api/v1/preferences/card-modes (new endpoint) for
cross-browser sync. Surface registry is open — any non-empty key
accepted server-side; modes validated against {comfortable, compact,
dense, row}.
CSS is token-driven: grid min-width and gap come from --card-grid-min /
--card-grid-gap / --card-grid-min-narrow / --card-grid-gap-narrow /
--templates-grid-min / --templates-grid-gap defined on :root, overridden
per [data-card-mode]. Dense/row also hide .mod-leds, collapse secondary
button labels, and tighten .mod-metrics; row collapses the grid to one
full-width column. Coexists with the existing per-section [data-density]
on the dashboard tab — different attribute, additive concern.
Toggle UI auto-mounts into every CardSection header (18+ surfaces) plus
the six dashboard subsections via post-render mount; teardown tracking
keeps the listener Set bounded across re-renders.
i18n: card_mode.{tooltip,comfortable,compact,dense,row} in en/ru/zh.
Tests: 9 new cases in tests/test_preferences_card_modes_api.py covering
defaults, round-trip, validation, open-registry keys, row mode, delete.
120 lines
3.8 KiB
Python
120 lines
3.8 KiB
Python
"""Tests for /api/v1/preferences/card-modes endpoints."""
|
|
|
|
import pytest
|
|
|
|
from ledgrab.config import get_config
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def client():
|
|
"""TestClient with auth header read at fixture-build time.
|
|
|
|
Mirrors test_preferences_api.py — the auth key is resolved here so
|
|
any singleton mutation during pytest collection cannot leave us with
|
|
a stale Bearer header.
|
|
"""
|
|
from fastapi.testclient import TestClient
|
|
|
|
from ledgrab.main import app
|
|
|
|
api_key = next(iter(get_config().auth.api_keys.values()), "")
|
|
with TestClient(app, raise_server_exceptions=False) as c:
|
|
if api_key:
|
|
c.headers["Authorization"] = f"Bearer {api_key}"
|
|
yield c
|
|
|
|
|
|
def _prefs(surfaces: dict[str, str] | None = None) -> dict:
|
|
return {"version": 1, "surfaces": surfaces or {}}
|
|
|
|
|
|
def test_get_default_empty(client):
|
|
"""When nothing is saved, GET returns {}."""
|
|
client.delete("/api/v1/preferences/card-modes")
|
|
resp = client.get("/api/v1/preferences/card-modes")
|
|
assert resp.status_code == 200
|
|
assert resp.json() == {}
|
|
|
|
|
|
def test_put_then_get_round_trip(client):
|
|
"""PUT a prefs object, GET it back verbatim."""
|
|
body = _prefs({"led-devices": "dense", "led-targets": "comfortable"})
|
|
put = client.put("/api/v1/preferences/card-modes", json=body)
|
|
assert put.status_code == 200
|
|
assert put.json() == {"ok": True}
|
|
|
|
got = client.get("/api/v1/preferences/card-modes")
|
|
assert got.status_code == 200
|
|
assert got.json() == body
|
|
|
|
|
|
def test_put_rejects_missing_version(client):
|
|
"""Body without numeric version is rejected with 422."""
|
|
resp = client.put(
|
|
"/api/v1/preferences/card-modes",
|
|
json={"surfaces": {"led-devices": "dense"}},
|
|
)
|
|
assert resp.status_code == 422
|
|
|
|
|
|
def test_put_rejects_invalid_mode(client):
|
|
"""A mode value outside the allowed set is rejected with 422."""
|
|
resp = client.put(
|
|
"/api/v1/preferences/card-modes",
|
|
json=_prefs({"led-devices": "extreme"}),
|
|
)
|
|
assert resp.status_code == 422
|
|
|
|
|
|
def test_put_rejects_non_dict_surfaces(client):
|
|
"""`surfaces` must be an object, not an array or string."""
|
|
resp = client.put(
|
|
"/api/v1/preferences/card-modes",
|
|
json={"version": 1, "surfaces": ["led-devices", "dense"]},
|
|
)
|
|
assert resp.status_code == 422
|
|
|
|
|
|
def test_put_accepts_empty_surfaces(client):
|
|
"""An empty surfaces map is a valid (no-override) state."""
|
|
resp = client.put("/api/v1/preferences/card-modes", json=_prefs({}))
|
|
assert resp.status_code == 200
|
|
|
|
|
|
def test_put_accepts_unknown_surface_keys(client):
|
|
"""Surface keys are an open registry — any non-empty string is OK."""
|
|
body = _prefs(
|
|
{
|
|
"led-devices": "compact",
|
|
"automations": "dense",
|
|
"weather-sources": "comfortable",
|
|
"future-surface-v2": "compact",
|
|
}
|
|
)
|
|
resp = client.put("/api/v1/preferences/card-modes", json=body)
|
|
assert resp.status_code == 200
|
|
got = client.get("/api/v1/preferences/card-modes").json()
|
|
assert got["surfaces"]["future-surface-v2"] == "compact"
|
|
|
|
|
|
def test_put_accepts_row_mode(client):
|
|
"""`row` is a valid mode (added alongside the original three)."""
|
|
body = _prefs({"led-devices": "row"})
|
|
resp = client.put("/api/v1/preferences/card-modes", json=body)
|
|
assert resp.status_code == 200
|
|
got = client.get("/api/v1/preferences/card-modes").json()
|
|
assert got["surfaces"]["led-devices"] == "row"
|
|
|
|
|
|
def test_delete_clears(client):
|
|
"""DELETE wipes saved prefs so the next GET returns empty."""
|
|
client.put(
|
|
"/api/v1/preferences/card-modes",
|
|
json=_prefs({"led-devices": "dense"}),
|
|
)
|
|
deleted = client.delete("/api/v1/preferences/card-modes")
|
|
assert deleted.status_code == 200
|
|
after = client.get("/api/v1/preferences/card-modes")
|
|
assert after.status_code == 200
|
|
assert after.json() == {}
|