Compare commits

...
Author SHA1 Message Date
overseer 710ef0dcf2 Fix dashboard registration to use the real Lovelace API (P1-P3)
P1: The registration was a silent no-op in production — it read
hass.data["lovelace"].dashboards as the DashboardsCollection and
awaited a sync async_items(), and passed "strategy"/"mode" keys the
real create schema rejects. Per the real lovelace source
(components/lovelace/__init__.py, _create_map_dashboard): the metadata
collection lives at hass.data["lovelace"]["dashboards_collection"],
async_items() is a SYNC @callback, the create payload carries metadata
only (url_path/title/icon/show_in_sidebar/allow_single_word), and the
dashboard CONFIG (the strategy reference) is saved as a SECOND step
through the content manager registered under
hass.data["lovelace"]["dashboards"][url_path]. The strategy is saved
only for a dashboard created in this run (or whose config already
carries ours); an existing dashboard skips both steps, so a user's
dashboard config is never overwritten. Collection unavailable / save
raising still warns and continues, never crashing setup.

P2: Declare "lovelace" in manifest.json dependencies (alongside
frontend/http) so hass.data["lovelace"] is populated before setup
instead of relying on boot order.

P3: Guard the check-then-create with a per-hass asyncio.Lock so two
concurrent entry setups cannot both pass the exists-check and race on
the url_path create. Per-hass (attribute on the hass object) rather
than a bare module-level lock, which binds to the first event loop
that awaits it.

The lifecycle tests' fake is rebuilt to mirror the real API shape
(sync async_items, async async_create_item, a dashboards content-
manager dict whose entries expose async async_save), with assertions
that the strategy config was saved with the custom:nestquest-party
type, that a user's existing config is not overwritten, that a failing
save still leaves setup green, and that concurrent registrations
create the dashboard exactly once (verified to fail without the
guard).
2026-09-23 10:47:54 -04:00
overseer f668cf95b0 Auto-register the zero-config panel dashboard on setup
A fresh HA install with children configured should show the panel
with no YAML and no manual dashboard creation: setup now registers
ONE storage-mode Lovelace dashboard whose config is the bundle's
custom:nestquest-party strategy (frontend/src/strategy.ts), under the
stable url_path 'nestquest' with a title, icon and sidebar visibility.
Registration is idempotent — the url_path is the idempotency key, so
a reload or a second entry never duplicates the dashboard, and a
dashboard already occupying the url_path (ours or the user's) is left
completely alone (never overwritten, never deleted).  An unavailable
Lovelace dashboards collection (YAML mode, lovelace not loaded) or a
refused create logs a clear warning and setup continues — never a
crash.  Tests cover create-on-setup, no-duplicate-on-reload, the
untouched user dashboard, the missing collection and a refusing one.
2026-09-23 10:17:20 -04:00
dev_agent 6d570e1c0e Dashboard strategy generating the views (#175) 2026-09-23 10:06:05 -04:00
11 changed files with 1301 additions and 380 deletions
+17 -2
View File
@@ -174,8 +174,23 @@ is simply a Home Assistant client.
Assistant user it may also reach other permitted Home Assistant entities
and services.
2. **Build a dashboard with only the NestQuest panel cards.** Create a
Lovelace dashboard whose **URL slug is `nestquest`** (Settings →
2. **Build a dashboard with only the NestQuest panel cards.** The
recommended path is the **zero-config dashboard strategy**: create a
Lovelace dashboard whose URL slug is `nestquest` with
```yaml
strategy:
type: custom:nestquest-party
```
and no views of your own — the strategy generates the party-board and
quest-log views (each already in Panel layout) from the household
roster at render time. Optional `url_path` and `weather_entity` keys
may sit beside `strategy:`. The manual instructions below describe
building the two views by hand if you prefer not to use the strategy.
**Manual alternative:** create a Lovelace dashboard whose **URL slug
is `nestquest`** (Settings →
Dashboards → Edit → URL slug). A functional panel needs **two** views
in that dashboard, each with its own card config from
`design/ENTITIES-AND-SERVICES.md` §4. Give the views these exact URL
+10 -1
View File
@@ -5,7 +5,8 @@ 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, and forwards the sensor and
``nestquest.complete_quest`` proxy service, registers the zero-config
panel dashboard (Feature 20), and forwards the sensor and
binary_sensor platforms.
"""
from __future__ import annotations
@@ -29,6 +30,7 @@ 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
@@ -87,6 +89,13 @@ 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,6 +43,18 @@ 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
@@ -0,0 +1,215 @@
"""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
+2 -1
View File
@@ -7,7 +7,8 @@
"config_flow": true,
"dependencies": [
"frontend",
"http"
"http",
"lovelace"
],
"documentation": "https://github.com/Maniacs-Mansion/NestQuest",
"integration_type": "hub",
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1,6 +1,7 @@
import "./cards/party-board";
import "./cards/quest-log";
import "./cards/admin";
import "./strategy";
const FONTS_HREF = "/nestquest-static/nestquest-fonts.css";
if (!document.querySelector('link[data-nq-fonts=""]')) {
+161
View File
@@ -0,0 +1,161 @@
import type { HassLike } from "./types";
type StateObject = {
state: unknown;
attributes?: Record<string, unknown> | null;
};
type RosterEntry = {
name: string;
slug: string;
};
type View = {
path?: string;
title?: string;
type: string;
cards: Record<string, unknown>[];
};
type LovelaceConfig = {
views: View[];
};
type StrategyConfig = Record<string, unknown>;
type StrategyClass = {
generate: (
config: StrategyConfig,
hass: HassLike | undefined
) => Promise<LovelaceConfig>;
};
declare global {
interface Window {
customStrategies?: Record<string, StrategyClass>;
}
}
function asString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
/** The slug a discovered roster entry carries, or "" when the entry is
* not an object with a non-empty slug string — the same shape check
* the party board applies to the same attribute. */
function asRosterSlug(entry: unknown): string {
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
return "";
}
const slug = (entry as Record<string, unknown>).slug;
return typeof slug === "string" ? slug.trim() : "";
}
/** The dashboard's URL path segment, used to COMPUTE the card paths the
* navigation needs. A dashboard lives at a single top-level segment
* (/<url_path>), so the first pathname segment is the dashboard root —
* this is where the strategy renders, and navigating back to it shows
* the first view (the party board). An explicit ``url_path`` in the
* strategy configuration wins for setups that need to pin it. */
function dashboardUrlPath(config: StrategyConfig): string {
const configured = asString(config.url_path).replace(/^\/+|\/+$/g, "");
if (configured) {
return configured;
}
const segment = window.location.pathname.split("/").filter(Boolean)[0];
return segment ?? "";
}
/** The display name a roster entry carries, or "" when it is not a
* non-empty string — the roster's ``name`` field (sensor.py
* ``_child_roster``), used for the quest-log view titles. */
function asRosterName(entry: unknown): string {
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
return "";
}
const name = (entry as Record<string, unknown>).name;
return typeof name === "string" ? name.trim() : "";
}
/** The ordered child roster the integration publishes on the household
* rollup (custom_components/nestquest/sensor.py — ``child_roster``,
* the snapshot's sort_order, then id). The same source the party
* board's plate discovery reads, consumed here at render time so the
* quest-log views exist for exactly the active children. */
function rosterEntries(hass: HassLike | undefined): RosterEntry[] {
const states = hass?.states as Record<string, StateObject> | undefined;
const household = states?.["sensor.nestquest_household_quests_due_today"];
const roster = household?.attributes?.child_roster;
if (!Array.isArray(roster)) {
return [];
}
return roster
.map((entry) => ({ slug: asRosterSlug(entry), name: asRosterName(entry) }))
.filter((entry) => entry.slug.length > 0);
}
/**
* Zero-config NestQuest dashboard strategy (Feature 20).
*
* A dashboard configured with ``strategy: { type: custom:nestquest-party }``
* gets its views GENERATED at render time from the household roster: one
* party-board view (the dashboard root) and one quest-log view per child,
* ordered by the roster's sort_order. The paths the cards navigate by are
* COMPUTED, not configured:
*
* - the board's ``quest_log_path`` is the dashboard root; tapping a crest
* pushes ``<root>/<slug>`` (party-board.ts ``_openQuestLog``);
* - each log view's ``path`` IS the child's slug, so the log resolves its
* child from the last pathname segment (quest-log.ts
* ``childSlugFromPath``) and its ``board_path`` back to the dashboard
* root (quest-log.ts ``_returnToBoard``).
*
* With zero children the board view still renders — the board card shows
* its own "not set up yet" notice for an empty roster, so no log views are
* needed. ``url_path`` and ``weather_entity`` may still be set explicitly
* in the strategy configuration; everything else is derived.
*/
export class NestQuestPartyStrategy {
static async generate(
config: StrategyConfig,
hass: HassLike | undefined
): Promise<LovelaceConfig> {
const root = `/${dashboardUrlPath(config)}`;
const weatherEntity = asString(config.weather_entity);
const boardCard: Record<string, unknown> = {
type: "custom:nestquest-party-board-card",
quest_log_path: root,
};
if (weatherEntity) {
boardCard.weather_entity = weatherEntity;
}
const logCard: Record<string, unknown> = {
type: "custom:nestquest-quest-log-card",
board_path: root,
};
if (weatherEntity) {
logCard.weather_entity = weatherEntity;
}
return {
views: [
{
path: "party",
title: "The Party",
type: "panel",
cards: [boardCard],
},
...rosterEntries(hass).map<View>((entry) => ({
path: entry.slug,
title: entry.name
? `${entry.name}'s Quest Log`
: `${entry.slug}'s Quest Log`,
type: "panel",
cards: [{ ...logCard }],
})),
],
};
}
}
window.customStrategies = window.customStrategies ?? {};
window.customStrategies["nestquest-party"] = NestQuestPartyStrategy;
+48
View File
@@ -0,0 +1,48 @@
/**
* Import the built bundle into jsdom and generate the dashboard strategy's
* views.
*
* Usage: node render-strategy.mjs < spec.json
*
* The spec JSON carries {bundle, url, config, hass}; stdout receives one
* JSON object {views} — the strategy output's views array with the paths
* and card configs the generated dashboard renders. 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/party",
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 strategy does not need them.
}
}
}
globalThis.window = dom.window;
await import(pathToFileURL(spec.bundle).href);
const strategy = window.customStrategies?.["nestquest-party"];
if (!strategy) {
console.error("nestquest-party strategy is not registered on the bundle");
process.exit(1);
}
const lovelace = await strategy.generate(spec.config, spec.hass);
const payload = JSON.stringify({ views: lovelace.views });
await new Promise((resolve) => process.stdout.write(payload, () => resolve()));
process.exit(0);
+246
View File
@@ -12,12 +12,100 @@ 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
@@ -271,3 +359,161 @@ 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
+160 -1
View File
@@ -24,6 +24,8 @@ 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"
STRATEGY_HARNESS = FRONTEND_DIR / "tests" / "render-strategy.mjs"
STRATEGY_SOURCE = FRONTEND_DIR / "src" / "strategy.ts"
PANEL_SOURCES = (PARTY_BOARD, QUEST_LOG)
@@ -286,4 +288,161 @@ def test_explicit_child_order_still_wins_over_discovery() -> None:
_three_child_states(),
)
assert [plate["name"] for plate in result["plates"]] == ["Bo", "Ada"]
assert "Cory" not in [plate["name"] for plate in result["plates"]]
assert "Cory" not in [plate["name"] for plate in result["plates"]]
# --- Dashboard strategy tests -------------------------------------------------
STRATEGY_NAME = "nestquest-party"
def _generate_dashboard(config: dict, states: dict, url: str | None = None) -> dict:
"""Generate the strategy's dashboard from the built bundle.
Drives frontend/tests/render-strategy.mjs: the harness imports the
committed bundle into a jsdom window at the given dashboard URL and
prints the views ``window.customStrategies["nestquest-party"].
generate(config, hass)`` returns.
"""
assert BUNDLE_PATH.is_file(), "bundle missing: run cd frontend && npm run build"
assert STRATEGY_HARNESS.is_file()
spec = {
"bundle": str(BUNDLE_PATH),
"url": url or "http://homeassistant.local/nestquest/party",
"config": config,
"hass": {"config": {"time_zone": "UTC"}, "states": states},
}
completed = subprocess.run(
["node", str(STRATEGY_HARNESS)],
input=json.dumps(spec),
capture_output=True,
text=True,
timeout=120,
)
assert completed.returncode == 0, (
f"strategy harness failed: {completed.stderr}"
)
return json.loads(completed.stdout)
def test_strategy_is_registered_on_the_bundle() -> None:
"""The strategy ships in the same bundle as the cards and registers
itself under window.customStrategies so a dashboard can use
strategy: { type: custom:nestquest-party }."""
source = _read(STRATEGY_SOURCE)
assert "customStrategies" in source
assert f'["{STRATEGY_NAME}"]' in source or f'"{STRATEGY_NAME}"' in source
bundle = _read(BUNDLE_PATH)
assert "nestquest-party" in bundle
def test_strategy_generates_views_for_three_children() -> None:
"""For the seeded three-child household the strategy generates one
party-board view plus one quest-log view per child in roster order,
with COMPUTED paths: the board view at the dashboard root, each log
view at the child's slug so quest-log.ts's childSlugFromPath reads
the child from the URL's last segment."""
result = _generate_dashboard({}, _three_child_states())
views = result["views"]
assert len(views) == 4
assert [view["path"] for view in views] == [
"party",
"ada",
"bo",
"cory",
]
assert [view["title"] for view in views] == [
"The Party",
"Ada's Quest Log",
"Bo's Quest Log",
"Cory's Quest Log",
]
def test_strategy_every_view_is_panel_type() -> None:
"""Every generated view carries ``type: "panel"``: Home Assistant
defaults an unspecified view to Masonry, which constrains the
1080px-tall NestQuest card to a narrow column (README's TouchHub
setup warns of exactly this); with a generated dashboard there is
no editor step left to set Panel by hand."""
result = _generate_dashboard({}, _three_child_states())
for view in result["views"]:
assert view["type"] == "panel"
def test_strategy_log_view_titles_use_the_roster_display_name() -> None:
"""Quest-log view titles come from the roster's ``name`` field (the
child's display name), not the lowercased entity-id slug; the slug
stays the view's path."""
result = _generate_dashboard(
{},
{
"sensor.nestquest_household_quests_due_today": _state(
"0",
child_roster=[
{"child_id": 7, "name": "Milo", "slug": "milo"},
],
),
},
)
views = result["views"]
assert len(views) == 2
assert views[1]["path"] == "milo"
assert views[1]["title"] == "Milo's Quest Log"
def test_strategy_passes_weather_entity_through_to_the_cards() -> None:
"""An explicit ``weather_entity`` in the strategy configuration is
passed through to the generated card configs (both the board and
the quest log render the weather dock when it is set)."""
result = _generate_dashboard(
{"weather_entity": "weather.home"},
_three_child_states(),
)
views = result["views"]
for view in views:
assert view["cards"][0]["weather_entity"] == "weather.home"
# And without it, no weather_entity key is emitted at all.
unset = _generate_dashboard({}, _three_child_states())
for view in unset["views"]:
assert "weather_entity" not in view["cards"][0]
def test_strategy_wires_navigation_between_the_views() -> None:
"""The board's quest_log_path and each log's board_path are computed
to the dashboard root the strategy renders at, so party-board.ts's
crest tap pushes <root>/<slug> and quest-log.ts's idle return goes
back to the board — neither path is configured by hand."""
dashboard_url = "http://homeassistant.local/nestquest"
root = "/nestquest"
result = _generate_dashboard(
{}, _three_child_states(), url=dashboard_url
)
views = result["views"]
board = views[0]["cards"][0]
assert board["type"] == "custom:nestquest-party-board-card"
assert board["quest_log_path"] == root
for slug, view in zip(("ada", "bo", "cory"), views[1:]):
assert view["cards"][0]["type"] == "custom:nestquest-quest-log-card"
assert view["cards"][0]["board_path"] == root
def test_strategy_zero_children_still_generates_the_board() -> None:
"""With an empty roster (NestQuest not set up yet) the strategy
renders the board view alone — the board card shows its own
"not set up yet" notice for an empty roster — and never crashes."""
result = _generate_dashboard({}, {})
assert len(result["views"]) == 1
assert result["views"][0]["path"] == "party"
def test_strategy_config_overrides_still_apply() -> None:
"""Explicit strategy configuration wins where the design allows: an
explicit url_path pins the computed dashboard root."""
result = _generate_dashboard(
{"url_path": "/wallboard"},
_three_child_states(),
)
root = "/wallboard"
assert result["views"][0]["cards"][0]["quest_log_path"] == root
for view in result["views"][1:]:
assert view["cards"][0]["board_path"] == root