Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d73efc8876 | ||
|
|
46f45aa4e5 |
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -7,8 +7,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": [
|
||||
"frontend",
|
||||
"http",
|
||||
"lovelace"
|
||||
"http"
|
||||
],
|
||||
"documentation": "https://github.com/Maniacs-Mansion/NestQuest",
|
||||
"integration_type": "hub",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,59 +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?}; the card is
|
||||
* mounted at the given URL (it resolves its child from the pathname's last
|
||||
* segment), and after the optional idle wait stdout receives one JSON object
|
||||
* {location, headline, html} — the window pathname and the rendered title.
|
||||
* 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;
|
||||
|
||||
if (spec.idle_ms !== undefined) {
|
||||
await new Promise((resolve) => setTimeout(resolve, spec.idle_ms));
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({
|
||||
location: window.location.pathname,
|
||||
headline: element.shadowRoot.querySelector(".title")?.textContent?.trim() ?? "",
|
||||
html: element.shadowRoot.innerHTML,
|
||||
});
|
||||
await new Promise((resolve) => process.stdout.write(payload, () => resolve()));
|
||||
process.exit(0);
|
||||
@@ -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
-117
@@ -6,12 +6,10 @@ 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 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
|
||||
|
||||
@@ -26,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"
|
||||
|
||||
@@ -237,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()
|
||||
@@ -260,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),
|
||||
@@ -460,99 +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,
|
||||
) -> 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 idle window, and prints the pathname
|
||||
and the rendered title.
|
||||
"""
|
||||
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
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user