Compare commits

..
Author SHA1 Message Date
overseer d73efc8876 Fix strategy review findings: panel views, display-name titles, weather pass-through test
- P1: every generated view now emits type: "panel" — HA defaults an
  unspecified view to Masonry, which constrains the 1080px-tall card to
  a narrow column (the exact failure the README warns about), and a
  generated dashboard leaves no editor step to set Panel by hand. Added
  a regression assertion that each view's type is "panel".
- P3: quest-log view titles use the roster's display name ("Ada's Quest
  Log") instead of the raw slug; the slug stays the view path.
- P3: added a focused test that weather_entity from the strategy config
  is passed through to every generated card config.
- P3: dropped the vestigial new (): unknown constructor from
  StrategyClass — HA calls generate() statically.
- P2: README's TouchHub setup now documents the zero-config dashboard
  strategy as the primary path, with the manual steps kept as the
  alternative (full runbook rewrite is task fc69c940).
- Rebuilt the bundle.
2026-09-23 09:59:57 -04:00
overseer 46f45aa4e5 Generate the zero-config panel dashboard from the household roster (Feature 20)
The board and quest-log cards navigate by URL path, but the paths had
to be configured by hand per view. Add a custom dashboard strategy
(window.customStrategies['nestquest-party'], bundled with the cards) so
a dashboard with strategy { type: custom:nestquest-party } gets its
views GENERATED at render time: a party-board view at the dashboard
root and one quest-log view per child in the household roster's
sort_order. The card paths are computed — the board's quest_log_path is
the root, each log's path is the child slug (so childSlugFromPath
resolves it) and its board_path points back — making quest_log_path and
board_path derived rather than configured. Zero children still render
the board alone (its own not-set-up notice covers the empty roster),
and an explicit url_path in the strategy config pins the root.
2026-09-23 09:48:57 -04:00
8 changed files with 17 additions and 1037 deletions
+1 -10
View File
@@ -5,8 +5,7 @@ SQLite file, the migrations, and every business rule. Setup builds the
entry's ONE shared API client, the coordinator that polls the panel
snapshot through it, and the SSE subscription that re-fires the API's
transition frames on the HA bus, registers the single
``nestquest.complete_quest`` proxy service, registers the zero-config
panel dashboard (Feature 20), and forwards the sensor and
``nestquest.complete_quest`` proxy service, and forwards the sensor and
binary_sensor platforms.
"""
from __future__ import annotations
@@ -30,7 +29,6 @@ from .const import (
PLATFORMS,
)
from .coordinator import NestQuestCoordinator, coordinator_client_from_entry
from .dashboard import async_register_dashboard
from .frontend import async_register_frontend
from .services import async_deregister_services, async_register_services
from .sse import NestQuestEventStream
@@ -89,13 +87,6 @@ async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up NestQuest from a config entry."""
await async_register_frontend(hass)
# The zero-config panel dashboard (Feature 20): register the
# storage-mode strategy dashboard if no dashboard under the stable
# url_path exists yet. Unavailability of the Lovelace dashboards
# collection (YAML mode, lovelace not loaded) logs a warning and
# NEVER fails setup; an existing dashboard — ours or the user's —
# is left untouched, so a reload cannot duplicate it.
await async_register_dashboard(hass)
hass.data.setdefault(DOMAIN, {})
async def _async_update_listener(
-12
View File
@@ -43,18 +43,6 @@ DOMAIN_SERVICES: tuple[str, ...] = (
SQLITE_DB_FILENAME = "nestquest.db"
# Zero-config panel dashboard (Feature 20): ONE storage-mode Lovelace
# dashboard registered on setup whose config carries the bundle's
# ``custom:nestquest-party`` strategy (frontend/src/strategy.ts) — the
# views are generated at render time from the household roster. The
# url_path IS the domain: the stable key idempotency is keyed on (a
# reload or a second entry never duplicates it, and a dashboard already
# registered under it — ours or the user's — is left untouched).
DASHBOARD_URL_PATH = DOMAIN
DASHBOARD_TITLE = "NestQuest"
DASHBOARD_ICON = "mdi:party-popper"
DASHBOARD_STRATEGY_TYPE = "custom:nestquest-party"
# Configuration keys and default values
CONF_HORIZON_DAYS = "horizon_days"
DEFAULT_HORIZON_DAYS = 14
-215
View File
@@ -1,215 +0,0 @@
"""Zero-config panel dashboard registration for NestQuest (Feature 20).
On setup the integration registers ONE storage-mode Lovelace dashboard
whose CONFIG uses the bundle's ``custom:nestquest-party`` strategy (see
``frontend/src/strategy.ts``): the views themselves are GENERATED at
render time from the household roster, so the stored dashboard config
carries nothing but the strategy reference.
Registration mirrors the REAL two-step sequence the lovelace
integration itself uses when it creates the onboarding map dashboard
(``homeassistant/components/lovelace/__init__.py``,
``_create_map_dashboard``):
1. create the dashboard METADATA through the ``DashboardsCollection``
(``hass.data["lovelace"]["dashboards_collection"]``) — url_path,
title, icon, sidebar visibility. The strategy reference is NOT part
of the create payload: the collection's create schema allows only
the metadata fields, so a ``strategy`` key there fails the whole
create (this was the silent no-op the first implementation shipped).
2. save the dashboard CONFIG — ``{"strategy": ...}`` — through the
per-url_path CONTENT manager that the collection's change listener
registers under ``hass.data["lovelace"]["dashboards"][url_path]``
(a ``LovelaceStorage``), exactly the second step HA performs.
Registration is IDEMPOTENT — a dashboard already under the stable
``url_path`` (ours or a user's) skips BOTH steps and its stored config
is never overwritten — and the Lovelace surfaces being unavailable
(YAML mode, lovelace not loaded) is a logged warning, never a setup
failure.
"""
from __future__ import annotations
import asyncio
from homeassistant.core import HomeAssistant
from .const import (
DASHBOARD_ICON,
DASHBOARD_STRATEGY_TYPE,
DASHBOARD_TITLE,
DASHBOARD_URL_PATH,
LOGGER,
)
_LOGGER = LOGGER
# Single-flight guard for the check-then-create below: HA can set up
# several config entries CONCURRENTLY, and two overlapping setups would
# both pass the exists-check and race on the url_path create. The lock
# is per hass instance (kept as an attribute on the hass object, since
# hass.data is the integrations' shared registry the unload path
# empties) rather than one module-level asyncio.Lock — a bare
# module-level lock binds to whichever event loop first awaits it and
# raises RuntimeError on any other loop, while the race it must prevent
# only exists between setups running on the SAME hass anyway.
_REGISTRATION_LOCK_ATTR = "_nestquest_dashboard_registration_lock"
def _registration_lock(hass: HomeAssistant) -> asyncio.Lock:
"""Return the per-hass registration lock, creating it on first use."""
lock = getattr(hass, _REGISTRATION_LOCK_ATTR, None)
if lock is None:
lock = asyncio.Lock()
setattr(hass, _REGISTRATION_LOCK_ATTR, lock)
return lock
def _lovelace_data(hass: HomeAssistant) -> dict | None:
"""Return ``hass.data["lovelace"]`` when it is the real dict shape.
The lovelace integration stores a plain DICT of surfaces (``mode``,
``dashboards``, ``dashboards_collection``, ``resources``,
``yaml_dashboards``) under the ``lovelace`` key; anything else
(missing key, unexpected object) means the surfaces below cannot be
reached and registration must warn and continue.
"""
lovelace_data = hass.data.get("lovelace")
if not isinstance(lovelace_data, dict):
return None
return lovelace_data
def _dashboards_collection(hass: HomeAssistant):
"""Return the Lovelace dashboards METADATA collection, or None.
The collection lives at ``hass.data["lovelace"]
["dashboards_collection"]`` — NOT under ``["dashboards"]``, which
maps url_path to the per-dashboard CONTENT managers
(``LovelaceStorage``/``LovelaceYAML``). A missing key (lovelace
not loaded) and a missing/None entry (YAML mode never sets the
collection) both mean "cannot create storage dashboards here" —
callers log and continue. The presence of the surface is verified
by capability (``async_create_item`` callable), not by type, so no
lovelace import is needed.
"""
lovelace_data = _lovelace_data(hass)
if lovelace_data is None:
return None
collection = lovelace_data.get("dashboards_collection")
if collection is None or not callable(
getattr(collection, "async_create_item", None)
):
return None
return collection
def _dashboard_store(hass: HomeAssistant, url_path: str):
"""Return a url_path's dashboard CONTENT manager, or None.
After a metadata create, the collection's change listener registers
a ``LovelaceStorage`` for the new dashboard under
``hass.data["lovelace"]["dashboards"][url_path]``; the dashboard
CONFIG (the strategy reference) is saved through THAT manager —
the second step of HA's own dashboard creation.
"""
lovelace_data = _lovelace_data(hass)
if lovelace_data is None:
return None
dashboards = lovelace_data.get("dashboards")
if not isinstance(dashboards, dict):
return None
store = dashboards.get(url_path)
if store is None or not callable(getattr(store, "async_save", None)):
return None
return store
def _item_url_path(item) -> str | None:
"""Read a dashboard item's ``url_path`` through either access shape.
The collection hands back storage items as dicts while builtin
dashboards come back as config objects; the url_path is reachable
through ``item["url_path"]`` or ``item.url_path`` respectively.
"""
url_path = getattr(item, "url_path", None)
if isinstance(url_path, str):
return url_path
get_url_path = getattr(item, "get", None)
if callable(get_url_path):
value = get_url_path("url_path")
if isinstance(value, str):
return value
return None
def _existing_dashboard(items) -> bool:
"""Whether a dashboard under the stable url_path already exists."""
return any(_item_url_path(item) == DASHBOARD_URL_PATH for item in items)
async def async_register_dashboard(hass: HomeAssistant) -> bool:
"""Register the NestQuest panel dashboard exactly once.
Returns True when a dashboard under the stable ``url_path`` exists
after the call (pre-existing or created here), False when the
Lovelace surfaces were unavailable or refused. A dashboard already
registered under the stable ``url_path`` — whether ours from a
previous setup or a user's own — skips BOTH steps and is left
COMPLETELY alone: its stored config is never overwritten, it is
never deleted, never a duplicate. The strategy config is saved
ONLY for the dashboard created in THIS call, whose fresh content
store is empty, so the save cannot clobber anything.
"""
collection = _dashboards_collection(hass)
if collection is None:
_LOGGER.warning(
"Lovelace dashboards collection unavailable; the NestQuest "
"panel dashboard was not registered. Enable the Lovelace "
"integration in storage mode or create a dashboard with "
"strategy %s manually.",
DASHBOARD_STRATEGY_TYPE,
)
return False
try:
# Single-flight: two concurrent entry setups must not both pass
# the exists-check and race on the url_path create.
async with _registration_lock(hass):
# async_items() is a SYNC @callback on the real collection
# (helpers/collection.py ObservableCollection) — not awaited.
if _existing_dashboard(collection.async_items()):
return True
await collection.async_create_item(
{
"url_path": DASHBOARD_URL_PATH,
"title": DASHBOARD_TITLE,
"icon": DASHBOARD_ICON,
"show_in_sidebar": True,
"allow_single_word": True,
}
)
# Step two (mirroring HA's _create_map_dashboard): the
# strategy CONFIG goes through the fresh dashboard's
# content manager, not through the metadata collection.
store = _dashboard_store(hass, DASHBOARD_URL_PATH)
if store is None:
_LOGGER.warning(
"The NestQuest panel dashboard was registered but its "
"Lovelace content manager never appeared; add the "
"strategy %s to the dashboard manually.",
DASHBOARD_STRATEGY_TYPE,
)
return False
await store.async_save(
{"strategy": {"type": DASHBOARD_STRATEGY_TYPE}}
)
except Exception:
_LOGGER.warning(
"Registering the NestQuest panel dashboard in the Lovelace "
"dashboards collection failed; set it up manually with "
"strategy %s.",
DASHBOARD_STRATEGY_TYPE,
exc_info=True,
)
return False
return True
+1 -2
View File
@@ -7,8 +7,7 @@
"config_flow": true,
"dependencies": [
"frontend",
"http",
"lovelace"
"http"
],
"documentation": "https://github.com/Maniacs-Mansion/NestQuest",
"integration_type": "hub",
+8 -32
View File
@@ -3,13 +3,10 @@
*
* Usage: node render-party-board.mjs < spec.json
*
* The spec JSON carries {bundle, tag, config, hass, url?, click_plate?};
* stdout receives one JSON object {plates, tappable, location, html} where
* plates is the ordered plate list the shadow DOM renders (name +
* present/away/unknown kind), tappable the plate names rendered as tappable
* <button> plates, and location the window pathname after the optional plate
* tap. The Python panel tests drive this harness so the assertions run
* against the exact bundle HACS ships.
* The spec JSON carries {bundle, tag, config, hass}; stdout receives one JSON
* object {plates, html} where plates is the ordered plate list the shadow DOM
* renders (name + present/away/unknown kind). The Python panel tests drive
* this harness so the assertions run against the exact bundle HACS ships.
*/
import { readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
@@ -18,7 +15,7 @@ import { JSDOM } from "jsdom";
const spec = JSON.parse(readFileSync(0, "utf8"));
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: spec.url ?? "http://homeassistant.local/nestquest/board",
url: "http://homeassistant.local/nestquest/board",
pretendToBeVisual: true,
});
@@ -34,10 +31,6 @@ for (const key of Object.getOwnPropertyNames(dom.window)) {
}
}
globalThis.window = dom.window;
// The card navigates by dispatching new Event("location-changed") on this
// window; jsdom's EventTarget rejects Node's own global Event class, so the
// jsdom Event must shadow it for the dispatched event to be delivered.
globalThis.Event = dom.window.Event;
await import(pathToFileURL(spec.bundle).href);
@@ -47,10 +40,9 @@ element.hass = spec.hass;
document.body.appendChild(element);
await element.updateComplete;
const plateNodes = [
const plates = [
...element.shadowRoot.querySelectorAll(".plates .plate"),
];
const plates = plateNodes.map((plate) => ({
].map((plate) => ({
name: plate.querySelector(".name")?.textContent?.trim() ?? "",
kind: plate.classList.contains("unknown")
? "unknown"
@@ -58,23 +50,7 @@ const plates = plateNodes.map((plate) => ({
? "away"
: "present",
}));
const tappable = plateNodes
.filter((plate) => plate.tagName === "BUTTON")
.map((plate) => plate.querySelector(".name")?.textContent?.trim() ?? "");
if (spec.click_plate !== undefined) {
const plate = plateNodes.find(
(candidate) =>
candidate.querySelector(".name")?.textContent?.trim() === spec.click_plate
);
plate?.click();
}
const payload = JSON.stringify({
plates,
tappable,
location: window.location.pathname,
html: element.shadowRoot.innerHTML,
});
const payload = JSON.stringify({ plates, html: element.shadowRoot.innerHTML });
await new Promise((resolve) => process.stdout.write(payload, () => resolve()));
process.exit(0);
-73
View File
@@ -1,73 +0,0 @@
/**
* Render the built quest-log card under jsdom and report its location.
*
* Usage: node render-quest-log.mjs < spec.json
*
* The spec JSON carries {bundle, url, config, hass, idle_ms?, probe_ms?};
* the card is mounted at the given URL (it resolves its child from the
* pathname's last segment), after the optional probe wait one snapshot of
* the render is taken mid-wait, then after the optional idle wait stdout
* receives one JSON object {location, headline, countdown, html, probe} —
* the final window pathname, the rendered title, the complete-screen
* countdown text, the rendered shadow DOM, and the probe snapshot when
* probe_ms was given. The Python panel tests drive this harness so the
* assertions run against the exact bundle HACS ships.
*/
import { readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
import { JSDOM } from "jsdom";
const spec = JSON.parse(readFileSync(0, "utf8"));
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: spec.url ?? "http://homeassistant.local/nestquest/ada",
pretendToBeVisual: true,
});
// Expose the jsdom globals the Lit bundle expects. Node's own intrinsics
// (Object, Array, ...) are kept — only what globalThis lacks is copied.
for (const key of Object.getOwnPropertyNames(dom.window)) {
if (!(key in globalThis)) {
try {
globalThis[key] = dom.window[key];
} catch {
// jsdom getters may throw on access; the card does not need them.
}
}
}
globalThis.window = dom.window;
// The card navigates by dispatching new Event("location-changed") on this
// window; jsdom's EventTarget rejects Node's own global Event class, so the
// jsdom Event must shadow it for the return navigation to be delivered.
globalThis.Event = dom.window.Event;
await import(pathToFileURL(spec.bundle).href);
const element = document.createElement("nestquest-quest-log-card");
element.setConfig(spec.config);
element.hass = spec.hass;
document.body.appendChild(element);
await element.updateComplete;
const snapshot = () => ({
location: window.location.pathname,
headline: element.shadowRoot.querySelector(".title")?.textContent?.trim() ?? "",
countdown:
element.shadowRoot.querySelector(".complete-countdown")?.textContent?.trim() ??
"",
html: element.shadowRoot.innerHTML,
});
let probe = null;
if (spec.probe_ms !== undefined) {
await new Promise((resolve) => setTimeout(resolve, spec.probe_ms));
probe = snapshot();
}
if (spec.idle_ms !== undefined) {
await new Promise((resolve) => setTimeout(resolve, spec.idle_ms));
}
const payload = JSON.stringify({ ...snapshot(), probe });
await new Promise((resolve) => process.stdout.write(payload, () => resolve()));
process.exit(0);
-246
View File
@@ -12,100 +12,12 @@ from custom_components.nestquest.const import (
DOMAIN as DOMAIN_CONST,
SERVICE_COMPLETE_QUEST,
)
from custom_components.nestquest.dashboard import (
DASHBOARD_STRATEGY_TYPE,
DASHBOARD_URL_PATH,
async_register_dashboard,
)
def _wire(entry, registry):
return wire_entry_to_registry(entry, registry)
class FakeDashboardStore:
"""Stand-in for a per-dashboard CONTENT manager (LovelaceStorage).
In the real API the dashboard CONFIG (the strategy reference) is
saved through THIS manager's ``async_save``, NOT through the
metadata collection — so every strategy-save assertion reads here.
"""
def __init__(self) -> None:
self.saved: list[dict] = []
self.config: dict | None = None
async def async_save(self, config: dict) -> None:
self.saved.append(dict(config))
self.config = dict(config)
class FakeDashboardsCollection:
"""Stand-in mirroring the REAL Lovelace dashboards API shapes.
Faithful to ``homeassistant/components/lovelace`` +
``helpers/collection.py``: ``async_items()`` is a SYNC ``@callback``
(ObservableCollection.async_items returns list(data.values())),
``async_create_item`` IS async, and — mirroring lovelace's
``storage_dashboard_changed`` listener — a successful create
registers a fresh CONTENT manager under
``hass.data["lovelace"]["dashboards"][url_path]``, which is where
the dashboard config is then saved. Records ``async_create_item``
calls so tests can assert exactly what the setup path registered —
and that a reload registers nothing more.
"""
def __init__(self, hass, items=None) -> None:
self.hass = hass
self.items = list(items or [])
self.created: list[dict] = []
#: url_path -> content manager, mirroring the real listener's
# registrations (pre-existing items included).
self.stores: dict[str, FakeDashboardStore] = {}
def async_items(self):
"""SYNC, like the real ``ObservableCollection.async_items``."""
return list(self.items)
async def async_create_item(self, item_config: dict) -> None:
# The real collection awaits validation and change notification
# around the item insertion; yield first so two concurrent
# callers without a single-flight guard would BOTH get here.
await asyncio.sleep(0)
self.created.append(dict(item_config))
self.items.append(dict(item_config))
self.stores[item_config["url_path"]] = FakeDashboardStore()
self.hass.data["lovelace"]["dashboards"][item_config["url_path"]] = (
self.stores[item_config["url_path"]]
)
def _install_dashboard_collection(
hass, items=None
) -> FakeDashboardsCollection:
"""Install the REAL-API-shaped lovelace surfaces on the hass stub.
Mirrors what lovelace's ``async_setup`` leaves behind in storage
mode: ``hass.data["lovelace"]`` is a DICT with the metadata
collection under ``dashboards_collection`` and the per-url_path
CONTENT managers under ``dashboards``. Any pre-existing item gets
a content manager, exactly as lovelace's change listener would
have registered one when the dashboard was created.
"""
collection = FakeDashboardsCollection(hass, items)
hass.data["lovelace"] = {
"mode": "storage",
"dashboards": {},
"dashboards_collection": collection,
}
for item in collection.items:
url_path = item.get("url_path")
store = FakeDashboardStore()
collection.stores[url_path] = store
hass.data["lovelace"]["dashboards"][url_path] = store
return collection
async def test_setup_stores_runtime_data_in_hass_data(hass, make_entry) -> None:
entry = _wire(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
@@ -359,161 +271,3 @@ async def test_unload_shuts_coordinator_even_when_listener_removal_raises() -> N
assert entry.runtime_data is None
assert DOMAIN_CONST not in hass.data
assert hass.time_change.size == 0
async def test_setup_creates_the_panel_dashboard(hass, make_entry) -> None:
"""A fresh install gets the storage-mode strategy dashboard via the
REAL two-step Lovelace sequence: the METADATA (url_path, title,
icon, sidebar, single-word allowance — nothing else: the real
create schema rejects unknown keys like ``strategy``) through the
collection, then the dashboard CONFIG (the strategy reference)
through the fresh dashboard's content manager."""
collection = _install_dashboard_collection(hass)
entry = _wire(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
# Step 1: the metadata create through the collection.
assert len(collection.created) == 1
created = collection.created[0]
assert created["url_path"] == DASHBOARD_URL_PATH
assert created["title"]
assert created["show_in_sidebar"] is True
assert created["allow_single_word"] is True
assert "icon" in created
# The create payload carries METADATA only: a ``strategy`` key is
# rejected by the real collection's create schema (the silent
# no-op the first implementation shipped).
assert "strategy" not in created
assert "mode" not in created
# Step 2: the strategy CONFIG saved through the content manager
# the create registered, with the custom strategy type.
store = hass.data["lovelace"]["dashboards"][DASHBOARD_URL_PATH]
assert store is collection.stores[DASHBOARD_URL_PATH]
assert store.saved == [{"strategy": {"type": DASHBOARD_STRATEGY_TYPE}}]
assert store.config == {"strategy": {"type": DASHBOARD_STRATEGY_TYPE}}
async def test_setup_reload_does_not_duplicate_the_dashboard(
hass, make_entry
) -> None:
"""A reload (or a second entry) must not create a second dashboard:
the one under the stable url_path is reused as-is — BOTH steps are
skipped, so the strategy config is not re-saved either."""
collection = _install_dashboard_collection(hass)
entry = _wire(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
assert len(collection.created) == 1
store = hass.data["lovelace"]["dashboards"][DASHBOARD_URL_PATH]
assert len(store.saved) == 1
# A reload: setup again on the same hass (the reload cycle) —
# the existing dashboard must be left alone.
await async_unload_entry(hass, entry)
assert await async_setup_entry(hass, entry) is True
assert len(collection.created) == 1
assert len(store.saved) == 1
# And a second entry on the same install.
other = _wire(make_entry(entry_id="other"), hass.registry)
assert await async_setup_entry(hass, other) is True
assert len(collection.created) == 1
assert len(store.saved) == 1
async def test_setup_leaves_an_existing_user_dashboard_alone(
hass, make_entry
) -> None:
"""A dashboard already occupying the stable url_path — the user's
own — is never overwritten, deleted or duplicated: no metadata
create AND no strategy save against its content manager."""
users_dashboard = {"url_path": DASHBOARD_URL_PATH, "title": "My Board"}
collection = _install_dashboard_collection(hass, items=[users_dashboard])
entry = _wire(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
assert collection.created == []
assert collection.items[0] is users_dashboard
users_store = hass.data["lovelace"]["dashboards"][DASHBOARD_URL_PATH]
assert users_store.saved == []
assert users_store.config is None
async def test_setup_succeeds_without_the_dashboards_collection(
hass, make_entry, caplog
) -> None:
"""YAML mode / lovelace not loaded: log a warning, continue setup."""
entry = _wire(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
assert entry.entry_id in hass.data[DOMAIN]
assert any(
"dashboard" in record.message.lower()
and record.levelname == "WARNING"
for record in caplog.records
)
async def test_setup_survives_a_failing_dashboards_collection(
hass, make_entry
) -> None:
"""A collection that refuses the create (permissions, admin-only)
logs a warning and setup still succeeds — entities come up."""
class _RefusingCollection(FakeDashboardsCollection):
async def async_create_item(self, item_config):
raise PermissionError("not an admin")
collection = _RefusingCollection(hass)
hass.data["lovelace"] = {
"mode": "storage",
"dashboards": {},
"dashboards_collection": collection,
}
entry = _wire(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
assert collection.created == []
async def test_setup_survives_a_failing_content_manager_save(
hass, make_entry
) -> None:
"""A content manager that refuses the config save after the
metadata create succeeded (storage write failure) logs a warning
and setup still succeeds — the dashboard exists, the strategy
simply was not persisted through it."""
class _RefusingStore(FakeDashboardStore):
async def async_save(self, config):
raise OSError("disk full")
class _StorelessCreateCollection(FakeDashboardsCollection):
async def async_create_item(self, item_config):
await asyncio.sleep(0)
self.created.append(dict(item_config))
self.items.append(dict(item_config))
self.stores[item_config["url_path"]] = _RefusingStore()
self.hass.data["lovelace"]["dashboards"][
item_config["url_path"]
] = self.stores[item_config["url_path"]]
collection = _StorelessCreateCollection(hass)
hass.data["lovelace"] = {
"mode": "storage",
"dashboards": {},
"dashboards_collection": collection,
}
entry = _wire(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
assert len(collection.created) == 1
store = hass.data["lovelace"]["dashboards"][DASHBOARD_URL_PATH]
assert store.saved == []
async def test_concurrent_registrations_create_the_dashboard_once(hass) -> None:
"""Two CONCURRENT registrations (the race two entry setups run)
must produce exactly ONE dashboard: the single-flight guard makes
the second caller's exists-check run after the first caller's
create completed, so it skips both steps. The fake's create
yields before recording, so without the guard both callers would
pass the exists-check and the dashboard would be created twice."""
collection = _install_dashboard_collection(hass)
results = await asyncio.gather(
async_register_dashboard(hass),
async_register_dashboard(hass),
)
assert results == [True, True]
assert len(collection.created) == 1
assert len(collection.stores[DASHBOARD_URL_PATH].saved) == 1
+7 -447
View File
@@ -6,22 +6,14 @@ spirit as test_frontend.py. They cover the states decided in
design/PANEL-EMPTY-STATES.md and PANEL-SPEC.md, and guard the kid-facing
bundles against ever gaining a parent-only service call.
The cards additionally have render tests: they drive the jsdom harnesses in
frontend/tests — render-party-board.mjs imports the built bundle into a
jsdom window and reports the ordered plates the shadow DOM renders plus
where a crest tap navigates, and render-quest-log.mjs reports where the
quest log's idle return navigates — so the zero-config discovery and
navigation contracts are asserted against the bundle HACS ships.
The motion contract (PANEL-SPEC §3.3/§3.5/§4/§5 entrances and timings) and
the §1/§6 hard rules are asserted on the card sources in the motion
section below, and the quest-complete screen's countdown return is driven
end-to-end through the quest-log harness.
The party board additionally has render tests: they drive
frontend/tests/render-party-board.mjs, which imports the built bundle into a
jsdom window and reports the ordered plates the shadow DOM renders, so the
zero-config discovery contract is asserted against the bundle HACS ships.
"""
from __future__ import annotations
import json
import re
import subprocess
from pathlib import Path
@@ -32,7 +24,6 @@ PARTY_BOARD = FRONTEND_CARDS_DIR / "party-board.ts"
QUEST_LOG = FRONTEND_CARDS_DIR / "quest-log.ts"
BUNDLE_PATH = REPO_ROOT / "custom_components" / "nestquest" / "www" / "nestquest-cards.js"
RENDER_HARNESS = FRONTEND_DIR / "tests" / "render-party-board.mjs"
QUEST_LOG_HARNESS = FRONTEND_DIR / "tests" / "render-quest-log.mjs"
STRATEGY_HARNESS = FRONTEND_DIR / "tests" / "render-strategy.mjs"
STRATEGY_SOURCE = FRONTEND_DIR / "src" / "strategy.ts"
@@ -58,106 +49,11 @@ ADMIN_ONLY_SERVICES = (
WINDOW_KEYS = ("morning", "afternoon", "evening")
WINDOW_NAMES = ("Morning", "Afternoon", "Evening")
# The approved token pair: the bundle must serve the design copy
# verbatim. The values below are what PANEL-SPEC times the panel with
# (§3.3 seal entrance, §4 dialog entrance, §3.2 press feedback) and the
# ink set §1 pins at >=4.5:1 on parchment.
DESIGN_TOKENS = REPO_ROOT / "design" / "tokens" / "nestquest-panel-tokens.css"
REWARD_TOKENS = (
REPO_ROOT
/ "custom_components"
/ "nestquest"
/ "www"
/ "nestquest-panel-tokens.css"
)
MOTION_TOKENS = {
"--nq-ease-out": "cubic-bezier(0, 0, .2, 1)",
"--nq-dur-micro": "120ms",
"--nq-dur-base": "200ms",
"--nq-dur-modal": "350ms",
}
PANEL_INK_TOKENS = {
"--nq-p-ink": "#2b1f14",
"--nq-p-ink-secondary": "#5c452a",
"--nq-p-ink-muted": "#6f6455",
"--nq-p-ink-away": "#4d4433",
"--nq-p-ink-late": "#8f1526",
"--nq-p-dock-ink": "#f7efdb",
"--nq-p-dock-ink-secondary": "#d8c9a6",
}
# Text colors the panel styles may use beyond the token inks: the
# spec's own literals (§2.1 pill, §3.3 seal check, §4 dialog body),
# glyphs on the brand gradient, gradient text fills (§2 wordmark, §5
# title), and the plate button inheriting its plate's ink.
APPROVED_NON_TOKEN_INKS = {
"inherit",
"transparent",
"#ffffff",
"rgba(255, 235, 235, 0.95)",
"#3f2f1c",
"#146b36",
"#157a3c",
}
#: Each card's styles live in one css template literal, keyed by the
#: constant that holds it.
STYLE_CONSTANTS = {
PARTY_BOARD: "boardStyles",
QUEST_LOG: "logStyles",
}
def _read(path: Path) -> str:
return path.read_text(encoding="utf-8")
def _style_literal(source: str, constant: str) -> str:
"""A card's css template literal — the styles between the backticks."""
marker = f"const {constant} = css`"
start = source.index(marker) + len(marker)
return source[start : source.index("`;", start)]
def _keyframes_block(style: str, name: str) -> str:
"""A @keyframes rule's text, from its name through its to-block."""
start = style.index(f"@keyframes {name}")
return style[start : style.index("}", style.index("to {", start))]
def _css_rules(style: str) -> list[tuple[str, str]]:
"""(selector, declarations) pairs for every rule in a card's styles.
Lit interpolations are blanked first so the clip-path/unsafeCSS
braces cannot confuse the parse; @keyframes' from/to count as rules
but carry no font-size, so the size checks skip them.
"""
flattened = re.sub(r"\$\{[^}]*\}", " ", style)
return re.findall(r"([^{}]+)\{([^{}]*)\}", flattened)
def _size_tokens(tokens: str) -> dict[str, int]:
"""Every px length token declared in the panel tokens."""
return {
name: int(px)
for name, px in re.findall(r"(--[a-z0-9-]+):\s*(\d+)px", tokens)
}
def _font_size_px(declaration: str, sizes: dict[str, int]) -> int | None:
"""A font-size value's pixel amount; None when it is neither a px
length nor one of the panel size tokens."""
declaration = declaration.strip()
if declaration.endswith("px"):
return round(float(declaration[:-2]))
match = re.fullmatch(r"var\((--[a-z0-9-]+)\)", declaration)
if match and match.group(1) in sizes:
return sizes[match.group(1)]
return None
def test_party_board_renders_present_and_away_plates() -> None:
source = _read(PARTY_BOARD)
assert "nestquest-party-board-card" in source
@@ -261,198 +157,6 @@ def test_quest_complete_trigger() -> None:
assert "complete_screen_seconds" in source
# --- Panel motion and hard rules (PANEL-SPEC §1, §3.3, §3.5, §4–§6) -----------
def test_panel_serves_the_exact_design_token_copy() -> None:
"""The bundle's served tokens are byte-for-byte the approved design
copy. The motion tokens time every panel entrance and the ink set is
the documented >=4.5:1 set — the cards only reference the tokens, so
a drifted value fails here before it can reach the panel."""
assert _read(REWARD_TOKENS) == _read(DESIGN_TOKENS)
tokens = _read(REWARD_TOKENS)
for declared in (MOTION_TOKENS, PANEL_INK_TOKENS):
for name, value in declared.items():
pattern = rf"^\s*{re.escape(name)}:\s*{re.escape(value)};"
assert re.search(pattern, tokens, re.MULTILINE), name
# The 22px meta floor is the declared panel minimum (§1).
assert re.search(
r"^\s*--nq-p-size-body:\s*22px;.*PANEL MINIMUM", tokens, re.MULTILINE
)
def test_quest_log_entrances_use_the_design_timing_and_easing() -> None:
"""PANEL-SPEC §3.3/§4: the seal enters with fade + scale(1.15 → 1)
over --nq-dur-base and the confirm dialog with fade + scale(.96 → 1)
over --nq-dur-modal, both --nq-ease-out; press feedback is scale(.98)
over --nq-dur-micro."""
style = _style_literal(_read(QUEST_LOG), STYLE_CONSTANTS[QUEST_LOG])
assert "animation: dialog-in var(--nq-dur-modal) var(--nq-ease-out);" in style
dialog = _keyframes_block(style, "dialog-in")
assert "opacity: 0;" in dialog and "transform: scale(0.96);" in dialog
assert "opacity: 1;" in dialog and "transform: scale(1);" in dialog
assert "animation: seal-in var(--nq-dur-base) var(--nq-ease-out);" in style
seal = _keyframes_block(style, "seal-in")
assert "opacity: 0;" in seal
# The per-card rotation is preserved through the stamp animation.
assert "transform: scale(1.15) rotate(var(--seal-rot, 0deg));" in seal
assert "transform: scale(1) rotate(var(--seal-rot, 0deg));" in seal
assert "animation: toast-in var(--nq-dur-base) var(--nq-ease-out);" in style
for selector in (
".quest.tappable:active",
"button.complete:active",
"button.confirm-button:active",
):
start = style.index(selector)
block = style[start : style.index("}", start)]
assert "transform: scale(0.98);" in block, selector
assert (
style.count("transition: transform var(--nq-dur-micro) var(--nq-ease-out);")
== 3
) # quest card, Complete button, confirm buttons
board_style = _style_literal(_read(PARTY_BOARD), STYLE_CONSTANTS[PARTY_BOARD])
start = board_style.index("button.plate:active")
block = board_style[start : board_style.index("}", start)]
assert "transform: scale(0.98);" in block
assert (
"transition: transform var(--nq-dur-micro) var(--nq-ease-out);" in board_style
)
def test_quest_complete_screen_times_the_12_second_return() -> None:
"""PANEL-SPEC §5: the 1c screen auto-returns to the board 12 s after
the day completes, driven by the per-second countdown line."""
source = _read(QUEST_LOG)
assert "asNumber(this._config?.complete_screen_seconds, 12)" in source
assert "this._countdown = this._completeSeconds();" in source
assert "}, 1000);" in source
assert "Returning to The Party in ${remaining} seconds" in source
# Zero reaches the idle-return navigation itself.
assert "this._returnToBoard();" in source
def test_idle_return_uses_the_40_second_design_timing() -> None:
"""PANEL-SPEC §3.5: 40 s with no touch returns to the party board;
any pointer or touch event re-arms the timer. The config knob only
shortens the default (tests do; the strategy never sets it)."""
source = _read(QUEST_LOG)
assert "asNumber(this._config?.idle_return_seconds, 40)" in source
for event in ("pointerdown", "touchstart"):
assert (
f'window.addEventListener("{event}", this._onActivity, true)' in source
), event
def test_panel_hard_rules_hold_in_the_card_sources() -> None:
"""PANEL-SPEC §1/§6 hard rules on both panel card sources: no hover
states (touch-only surface), no animation loops (entrances only,
each timed on a design duration token with --nq-ease-out), an inert
dock strip and pointer-none frames, the whole plate as one tap
target with the away plate control-free, and both the quest card
and its button opening the confirm dialog. The away plate's
navigation contract is asserted against the bundle by
test_away_plate_does_not_navigate."""
board = _read(PARTY_BOARD)
log = _read(QUEST_LOG)
for source in (board, log):
assert ":hover" not in source
assert "infinite" not in source
for animation in re.findall(r"animation:[^;]+;", source):
assert re.fullmatch(
r"animation: [a-z-]+ var\(--nq-dur-(?:micro|base|modal)\) "
r"var\(--nq-ease-out\);",
" ".join(animation.split()),
), animation
for transition in re.findall(r"transition:[^;]+;", source):
assert transition in (
"transition: transform var(--nq-dur-micro) var(--nq-ease-out);",
), transition
dock = re.search(r"\n \.dock \{([^}]*)\}", source)
assert dock is not None
assert "pointer-events: none" in dock.group(1)
assert "user-select: none" in dock.group(1)
frame = re.search(r"\n \.frame \{([^}]*)\}", source)
assert frame is not None
assert "pointer-events: none" in frame.group(1)
# Nothing in either dock's markup can be tapped: no handlers, no
# button, no focusable or role-bearing element.
board_dock = board[board.index("private _renderDock") :]
log_dock = log[
log.index("private _renderDock") : log.index("private _returnToBoard")
]
for markup in (board_dock, log_dock):
for tappable in ("@click", "@keydown", "<button", 'role="button"', "tabindex"):
assert tappable not in markup, tappable
# The whole plate is the tap target: the button wraps crest, name,
# pill, progress line and bar, and it is the board's only button.
plate = board[board.index('class="plate"') : board.index('class="plate away"')]
for part in (
'class="crest"',
'class="name"',
'class="pill"',
'class="progress-line"',
'class="bar"',
):
assert part in plate, part
assert board.count("<button") == 1
# The away plate is a plain div — nothing tappable, never navigates.
away = board[
board.index('class="plate away"') : board.index("private _openQuestLog")
]
assert "<button" not in away
assert "@click" not in away
# On the log, the whole quest card AND its button open the confirm
# dialog (§3.2); the sealed card renders no control at all.
quest = log[log.index("private _renderQuest") : log.index("private _renderRollup")]
assert quest.count("() => this._openConfirm(instance.id)") == 2
sealed_template = quest[
quest.index('class="quest sealed"') : quest.index('class="quest tappable"')
]
assert "@click" not in sealed_template
def test_panel_text_sizes_respect_the_22px_floor() -> None:
"""PANEL-SPEC §1: no body text below 22px. Every font-size in the
panel styles resolves against the panel size tokens or an explicit
px value, and the only text under the floor is the spec's own
tracked uppercase Cinzel labels (§3 header sub, §3.1 range, §3.4
roll-up label, §4 kicker, §5 stat labels) and the d20 mark numerals
the spec sizes by hand (§2, §3) — never a Nunito body line."""
sizes = _size_tokens(_read(REWARD_TOKENS))
for path in (PARTY_BOARD, QUEST_LOG):
style = _style_literal(_read(path), STYLE_CONSTANTS[path])
for selector, body in _css_rules(style):
declared = re.search(r"font-size:\s*([^;]+);", body)
if declared is None:
continue
px = _font_size_px(declared.group(1), sizes)
assert px is not None, (
f"{path.name}: {selector.strip()} {declared.group(1)}"
)
if px >= 22:
continue
assert (
"text-transform: uppercase" in body or ".d20-numeral" in selector
), f"{path.name}: {selector.strip()} is {px}px below the panel floor"
def test_panel_text_inks_come_from_the_token_set() -> None:
"""PANEL-SPEC §1: every ink is a token from the approved >=4.5:1 set
or one of the spec's own literals — no alpha-muted text is ever
introduced."""
declared = set(
re.findall(r"^\s*(--[a-z0-9-]+):", _read(REWARD_TOKENS), re.MULTILINE)
)
for path in (PARTY_BOARD, QUEST_LOG):
style = _style_literal(_read(path), STYLE_CONSTANTS[path])
for value in re.findall(r"(?<![-\w])color:\s*([^;]+);", style):
value = " ".join(value.split())
if value.startswith("var("):
assert value[4:-1] in declared, f"{path.name}: {value}"
else:
assert value in APPROVED_NON_TOKEN_INKS, f"{path.name}: {value}"
def test_panel_cards_reference_no_admin_only_services() -> None:
# Only the kid panel sources are scanned: the bundle also contains the
# sibling admin card, so its parent-only service names are expected there.
@@ -530,20 +234,13 @@ def _three_child_states() -> dict:
}
def _render_party_board(
config: dict,
states: dict,
url: str | None = None,
click_plate: str | None = None,
) -> dict:
def _render_party_board(config: dict, states: dict) -> dict:
"""Render the built bundle's party board and return its plates.
Drives frontend/tests/render-party-board.mjs: the harness imports
the committed bundle (the artifact HACS ships) into a jsdom window,
mounts the card with the given config and hass snapshot, optionally
taps the named plate, and prints the ordered plates its shadow DOM
renders, which of them are tappable buttons, and the pathname after
the optional tap.
mounts the card with the given config and hass snapshot, and prints
the ordered plates its shadow DOM renders.
"""
assert BUNDLE_PATH.is_file(), "bundle missing: run cd frontend && npm run build"
assert RENDER_HARNESS.is_file()
@@ -553,10 +250,6 @@ def _render_party_board(
"config": config,
"hass": {"config": {"time_zone": "UTC"}, "states": states},
}
if url is not None:
spec["url"] = url
if click_plate is not None:
spec["click_plate"] = click_plate
completed = subprocess.run(
["node", str(RENDER_HARNESS)],
input=json.dumps(spec),
@@ -753,136 +446,3 @@ def test_strategy_config_overrides_still_apply() -> None:
assert result["views"][0]["cards"][0]["quest_log_path"] == root
for view in result["views"][1:]:
assert view["cards"][0]["board_path"] == root
# --- Navigation tests (strategy-computed paths under jsdom) -------------------
def _render_quest_log(
config: dict,
states: dict,
url: str,
idle_ms: int | None = None,
probe_ms: int | None = None,
) -> dict:
"""Render the built bundle's quest log and report its location.
Drives frontend/tests/render-quest-log.mjs: the harness imports the
committed bundle (the artifact HACS ships) into a jsdom window at
``url`` — the card resolves its child from the pathname's last
segment — waits the optional probe window and captures one snapshot
of the render mid-wait, then the optional idle window, and prints
the final pathname and rendered title (plus the probe snapshot when
``probe_ms`` was given).
"""
assert BUNDLE_PATH.is_file(), "bundle missing: run cd frontend && npm run build"
assert QUEST_LOG_HARNESS.is_file()
spec = {
"bundle": str(BUNDLE_PATH),
"url": url,
"config": config,
"hass": {"config": {"time_zone": "UTC"}, "states": states},
}
if idle_ms is not None:
spec["idle_ms"] = idle_ms
if probe_ms is not None:
spec["probe_ms"] = probe_ms
completed = subprocess.run(
["node", str(QUEST_LOG_HARNESS)],
input=json.dumps(spec),
capture_output=True,
text=True,
timeout=120,
)
assert completed.returncode == 0, (
f"quest-log harness failed: {completed.stderr}"
)
return json.loads(completed.stdout)
def test_present_crest_tap_navigates_to_the_child_quest_log() -> None:
"""Tapping a present crest pushes <dashboard root>/<slug>: the exact
URL the strategy computed for that child's quest log view (the log
then resolves its child from the path's last segment)."""
generated = _generate_dashboard(
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
)
board_config = generated["views"][0]["cards"][0]
result = _render_party_board(
board_config,
_three_child_states(),
url="http://homeassistant.local/nestquest",
click_plate="Ada",
)
# Only the present adventurers' plates are tappable buttons.
assert result["tappable"] == ["Ada", "Cory"]
assert result["location"] == "/nestquest/ada"
def test_away_plate_does_not_navigate() -> None:
"""Bo is away today: his plate is not a button, and tapping it leaves
the URL at the dashboard root — away adventurers cannot open a log."""
generated = _generate_dashboard(
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
)
board_config = generated["views"][0]["cards"][0]
result = _render_party_board(
board_config,
_three_child_states(),
url="http://homeassistant.local/nestquest",
click_plate="Bo",
)
assert "Bo" not in result["tappable"]
assert result["location"] == "/nestquest"
def test_quest_log_idle_return_navigates_to_the_board() -> None:
"""After the idle window the quest log returns to the board using the
strategy-computed board_path (the dashboard root) — and it resolved
Ada from the URL's last segment first. ``idle_return_seconds`` is
set by hand because the card's 40-second default is too slow for a
test; the strategy never generates that knob."""
generated = _generate_dashboard(
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
)
log_config = {**generated["views"][1]["cards"][0], "idle_return_seconds": 1}
result = _render_quest_log(
log_config,
_three_child_states(),
url="http://homeassistant.local/nestquest/ada",
idle_ms=2000,
)
assert result["headline"] == "Ada's Quest Log"
assert result["location"] == "/nestquest"
def test_quest_complete_screen_returns_to_the_board_after_the_countdown() -> None:
"""The 1c screen renders with its countdown line and auto-returns to
the strategy-computed board path when it reaches zero. The design
shows it for 12 s; the test shortens complete_screen_seconds to 1
the same way the idle test shortens idle_return_seconds."""
generated = _generate_dashboard(
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
)
log_config = {
**generated["views"][1]["cards"][0],
"complete_screen_seconds": 1,
}
states = {
**_three_child_states(),
"binary_sensor.nestquest_ada_all_done": _state("on"),
}
result = _render_quest_log(
log_config,
states,
url="http://homeassistant.local/nestquest/ada",
probe_ms=400,
idle_ms=2400,
)
# Mid-wait the complete screen is up with its countdown line…
assert result["probe"] is not None
assert result["probe"]["location"] == "/nestquest/ada"
assert "Quest complete" in result["probe"]["html"]
assert result["probe"]["countdown"] == "Returning to The Party in 1 seconds"
# …and at zero the card has returned to the party board.
assert result["location"] == "/nestquest"