Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc97328b7b | ||
|
|
ee8ef6dcac |
@@ -159,9 +159,50 @@ Sensors the automations read: `sensor.nestquest_<child>_quests_due_today`
|
||||
|
||||
Run these steps in order to display the NestQuest panel on a TouchHub
|
||||
wall device. The panel is an HA Lovelace dashboard, so the kiosk browser
|
||||
is simply a Home Assistant client.
|
||||
is simply a Home Assistant client. The path below is zero-config: the
|
||||
integration registers its panel dashboard itself on setup, so no YAML
|
||||
and no hand-built views are involved.
|
||||
|
||||
1. **Create a dedicated kiosk user.** In *Settings → People → Users*, add
|
||||
1. **Install the integration and add the config entry.** Install
|
||||
NestQuest via HACS or manually (see Installation above) and restart
|
||||
Home Assistant. Then, in *Settings → Devices & Services*, click
|
||||
**Add Integration**, search for **NestQuest**, and submit the flow —
|
||||
it has no fields; it just creates the single config entry. Then open
|
||||
the entry, click **Configure**, and fill the panel-plane API
|
||||
connection in the options flow:
|
||||
|
||||
- **NestQuest API base URL** — the full `http://host:port` URL of
|
||||
your NestQuest API service (default `http://127.0.0.1:8000`).
|
||||
- **Panel service token** — the token the panel presents to the API
|
||||
service as its Bearer credential. It may stay empty until you have
|
||||
one; a pasted token must be unpadded.
|
||||
|
||||
The form's remaining fields (panel idle timeout, coordinator update
|
||||
interval, last-good snapshot staleness) have defaults that serve the
|
||||
panel and need no change here.
|
||||
|
||||
2. **The panel dashboard appears automatically.** On setup the
|
||||
integration registers one storage-mode Lovelace dashboard: a sidebar
|
||||
entry titled **NestQuest** at the URL path `/nestquest`, whose stored
|
||||
config is nothing but the **`custom:nestquest-party` strategy**
|
||||
(`strategy: { type: custom:nestquest-party }`). The strategy
|
||||
generates the views at render time from the household roster:
|
||||
|
||||
- **The Party** — the party-board view at `/nestquest/party`; the
|
||||
dashboard root `/nestquest` shows it as the first view.
|
||||
- one quest-log view per child at `/nestquest/<child-slug>`
|
||||
("Ada's Quest Log", …), in roster order.
|
||||
|
||||
Nothing is hand-configured: no YAML, no manual views, no
|
||||
`child_order`, no `quest_log_path`/`board_path` — the board and the
|
||||
quest logs compute their navigation paths from the dashboard's own
|
||||
URL — and every generated view is already in **Panel** layout (see
|
||||
the two constraints below). If the Lovelace surfaces are unavailable
|
||||
(for example when Lovelace runs in YAML mode), setup logs a warning
|
||||
and skips the registration; create the dashboard manually with the
|
||||
same strategy as described in the manual alternative below.
|
||||
|
||||
3. **Create a dedicated kiosk user.** In *Settings → People → Users*, add
|
||||
a new user for the wall device. Make it **non-admin**, and ensure it is
|
||||
not in the NestQuest admin allowlist. The allowlist blocks the NestQuest
|
||||
**admin** operations — `uncomplete_quest`, `create_quest_definition`,
|
||||
@@ -174,66 +215,101 @@ 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 →
|
||||
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
|
||||
paths (the path segment after the dashboard slug):
|
||||
4. **Open the dashboard and point TouchHub at it.** Open
|
||||
`http://<home-assistant>/nestquest` in a browser first and check the
|
||||
party board renders with your children's crests. Then, in the
|
||||
TouchHub launcher, add the **Home Assistant Lovelace** app to the
|
||||
dock and paste the dashboard URL from step 2 — the dashboard root,
|
||||
`/nestquest` (TouchHub loads that URL directly as the kiosk landing
|
||||
page). Set TouchHub **Auto-Return** to a value GREATER than the
|
||||
quest log's `idle_return_seconds` (default 40s) — recommend **60s or
|
||||
higher**. If Auto-Return is at or below 40s, TouchHub returns to its
|
||||
launcher before the quest log's idle return can navigate back to the
|
||||
party board.
|
||||
|
||||
- **View 1 path `board`** (URL `/nestquest/board`) — party board
|
||||
(`type: custom:nestquest-party-board-card`): the household quest
|
||||
board. Set `weather_entity` (optional; the dock falls back to
|
||||
date/time), `quest_log_path: /nestquest/log` (the view the crest tap
|
||||
navigates to), and `child_order: [slug1, slug2, slug3]` listing each
|
||||
child's slug in display order.
|
||||
|
||||
- **View 2 path `log`** (URL `/nestquest/log`) — quest log
|
||||
(`type: custom:nestquest-quest-log-card`): the per-child quest screen
|
||||
the party board hops to. Set `board_path: /nestquest/board` (the
|
||||
party-board view to return to), `weather_entity`,
|
||||
`idle_return_seconds` (must be shorter than TouchHub Auto-Return),
|
||||
`confirm_timeout_seconds`, and `complete_screen_seconds`. The quest
|
||||
log reads the selected child's slug from the view's URL path, so the
|
||||
board → log hop is a plain Lovelace navigation.
|
||||
|
||||
Each view's layout MUST be set to **Panel** (Edit Dashboard → pencil on the
|
||||
view → View type: Panel). Home Assistant's default Masonry layout constrains
|
||||
the 1080px-tall NestQuest card to a narrow column; Panel (single-card)
|
||||
layout is what renders the card full-screen at 1920×1080.
|
||||
|
||||
The dashboard contains only the NestQuest panel card for each view —
|
||||
no other cards, no admin controls.
|
||||
|
||||
The `quest_log_path` and `board_path` values must match the dashboard
|
||||
slug and view paths above: `/<slug>/<view>`. If you chose `nestquest`
|
||||
as the dashboard slug with view paths `board` and `log`, the card
|
||||
configs are already correct. If you pick different names, substitute
|
||||
your actual paths in **both** card configs — e.g. a dashboard slug
|
||||
`chores` with view paths `main` and `history` needs
|
||||
`quest_log_path: /chores/history` and `board_path: /chores/main`.
|
||||
Navigation between the two views fails (the crest tap or back button
|
||||
lands on a 404) if the card config paths do not match the dashboard
|
||||
slug and view paths exactly.
|
||||
|
||||
See `design/ENTITIES-AND-SERVICES.md` §4 for the full card YAML.
|
||||
|
||||
3. **Point TouchHub at it.** In the TouchHub launcher, add the **Home
|
||||
Assistant Lovelace** app to the dock and paste the dashboard URL from
|
||||
step 2 — the party-board view URL, e.g. `/nestquest/board` (TouchHub
|
||||
loads that URL directly as the kiosk landing page). Set TouchHub
|
||||
**Auto-Return** to a value GREATER than the quest log's
|
||||
`idle_return_seconds` (default 40s) — recommend **60s or higher**. If
|
||||
Auto-Return is at or below 40s, TouchHub returns to its launcher before
|
||||
the quest log's idle return can navigate back to the party board.
|
||||
|
||||
4. **Log the kiosk session in.** Sign in on the device as the dedicated
|
||||
kiosk user from step 1, and leave that session logged in.
|
||||
5. **Log the kiosk session in.** Sign in on the device as the dedicated
|
||||
kiosk user from step 3, and leave that session logged in.
|
||||
|
||||
TouchHub loads the dashboard URL directly. The NestQuest cards are normal
|
||||
Lovelace cards, not an iframe, so no Home Assistant framing setting is
|
||||
needed.
|
||||
|
||||
### The two constraints
|
||||
|
||||
- **Panel layout.** Every view must be in **Panel** (single-card)
|
||||
layout: Home Assistant's default Masonry layout constrains the
|
||||
1080px-tall NestQuest card to a narrow column, while Panel renders the
|
||||
card full-screen at 1920×1080. The zero-config path already satisfies
|
||||
the requirement — the strategy generates every view as
|
||||
`type: panel` — while the manual alternative below requires setting
|
||||
the layout by hand on each view (Edit Dashboard → pencil on the view
|
||||
→ View type: Panel).
|
||||
|
||||
- **TouchHub Auto-Return.** TouchHub's **Auto-Return** must be greater
|
||||
than the quest log's `idle_return_seconds` (default 40s) — recommend
|
||||
**60s or higher** — so the launcher does not reclaim the wall device
|
||||
before the quest log's own idle return navigates back to the party
|
||||
board.
|
||||
|
||||
### Manual alternative: build the dashboard by hand
|
||||
|
||||
If you prefer not to use the zero-config dashboard above — or its
|
||||
registration was skipped — configure a Lovelace dashboard whose **URL
|
||||
slug is `nestquest`** (Settings → Dashboards → Edit → URL slug)
|
||||
yourself. Two options, in decreasing order of convenience:
|
||||
|
||||
- **Use the strategy.** Give the dashboard
|
||||
|
||||
```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:`.
|
||||
|
||||
- **Build the two views by hand.** 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
|
||||
paths (the path segment after the dashboard slug):
|
||||
|
||||
- **View 1 path `board`** (URL `/nestquest/board`) — party board
|
||||
(`type: custom:nestquest-party-board-card`): the household quest
|
||||
board. Set `weather_entity` (optional; the dock falls back to
|
||||
date/time), `quest_log_path: /nestquest/log` (the view the crest tap
|
||||
navigates to), and `child_order: [slug1, slug2, slug3]` listing each
|
||||
child's slug in display order.
|
||||
|
||||
- **View 2 path `log`** (URL `/nestquest/log`) — quest log
|
||||
(`type: custom:nestquest-quest-log-card`): the per-child quest screen
|
||||
the party board hops to. Set `board_path: /nestquest/board` (the
|
||||
party-board view to return to), `weather_entity`,
|
||||
`idle_return_seconds` (must be shorter than TouchHub Auto-Return —
|
||||
see the two constraints above), `confirm_timeout_seconds`, and
|
||||
`complete_screen_seconds`. The quest log reads the selected child's
|
||||
slug from the view's URL path, so the board → log hop is a plain
|
||||
Lovelace navigation.
|
||||
|
||||
The dashboard contains only the NestQuest panel card for each view —
|
||||
no other cards, no admin controls. Each view's layout MUST be set to
|
||||
**Panel** — see the two constraints above for why; the strategy
|
||||
generates it for you, hand-built views do not get it for free.
|
||||
|
||||
The `quest_log_path` and `board_path` values must match the dashboard
|
||||
slug and view paths above: `/<slug>/<view>`. If you chose `nestquest`
|
||||
as the dashboard slug with view paths `board` and `log`, the card
|
||||
configs are already correct. If you pick different names, substitute
|
||||
your actual paths in **both** card configs — e.g. a dashboard slug
|
||||
`chores` with view paths `main` and `history` needs
|
||||
`quest_log_path: /chores/history` and `board_path: /chores/main`.
|
||||
Navigation between the two views fails (the crest tap or back button
|
||||
lands on a 404) if the card config paths do not match the dashboard
|
||||
slug and view paths exactly.
|
||||
|
||||
See `design/ENTITIES-AND-SERVICES.md` §4 for the full card YAML.
|
||||
|
||||
## Developer Setup and Testing
|
||||
|
||||
This project uses [`uv`](https://github.com/astral-sh/uv) for fast Python package and dependency management.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -7,7 +7,8 @@
|
||||
"config_flow": true,
|
||||
"dependencies": [
|
||||
"frontend",
|
||||
"http"
|
||||
"http",
|
||||
"lovelace"
|
||||
],
|
||||
"documentation": "https://github.com/Maniacs-Mansion/NestQuest",
|
||||
"integration_type": "hub",
|
||||
|
||||
@@ -32,6 +32,7 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util import slugify
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import (
|
||||
@@ -41,13 +42,21 @@ from .coordinator import (
|
||||
)
|
||||
from .core.snapshot import instance_payload
|
||||
|
||||
#: Home Assistant's state machine refuses states longer than 255
|
||||
#: HA's state machine refuses states longer than 255
|
||||
#: characters (its hard recorder/state limit). These sensors' states
|
||||
#: are numeric, but the guard is applied uniformly: a payload-bearing
|
||||
#: sensor can never crash the state write, and later string-state
|
||||
#: sensors reuse the same helper.
|
||||
MAX_STATE_LENGTH = 255
|
||||
|
||||
#: Entity-id length cap the roster applies to HA's slugify: HA's
|
||||
#: slugifier itself does NOT truncate, but the entity registry derives
|
||||
#: entity ids as ``<domain>.<slug>`` and truncates the full entity_id at
|
||||
#: MAX_LENGTH_STATE_ENTITY_ID (64), so for this module's ``sensor.``
|
||||
#: entities the slugged object id alone may not exceed
|
||||
#: 64 - len("sensor.").
|
||||
_MAX_SLUG_LENGTH = 64 - len("sensor.")
|
||||
|
||||
|
||||
def _state_within_limit(state: Any) -> Any:
|
||||
"""Return ``state`` unchanged, refusing strings over HA's limit.
|
||||
@@ -98,6 +107,51 @@ def _next_open_quest(
|
||||
)
|
||||
|
||||
|
||||
def _slugify(text: str) -> str:
|
||||
"""Slugify a display name the way Home Assistant derives entity ids.
|
||||
|
||||
Runs ``homeassistant.util.slugify`` — the real slugifier HA runs
|
||||
entity names through, which transliterates every script to ASCII
|
||||
through unidecode (so non-Latin names survive: ``京子`` slugs to
|
||||
``jing_zi``) before folding to lowercase and turning every run of
|
||||
non-alphanumerics into one underscore, answering HA's ``unknown``
|
||||
sentinel when a name slugs to nothing — then caps the result at
|
||||
the object-id length the entity registry enforces (the registry
|
||||
truncates the full ``sensor.`` + slug entity_id at 64 characters,
|
||||
so the slug alone is capped like this; HA's slugify does not
|
||||
truncate by itself).
|
||||
"""
|
||||
return slugify(text)[:_MAX_SLUG_LENGTH]
|
||||
|
||||
|
||||
def _child_roster(children: Iterable[ChildDaySnapshot]) -> list[dict[str, Any]]:
|
||||
"""The ordered child roster the panel cards discover children from.
|
||||
|
||||
One entry per ACTIVE child in the snapshot's order (the API's
|
||||
sort_order, then id): ``child_id``, ``name``, and the ``slug`` the
|
||||
entity-id derivation produces for the child's name — the piece the
|
||||
cards need to build ``sensor.nestquest_<slug>_...`` ids. Duplicate
|
||||
display names get the registry's ``_2``/``_3`` suffix in snapshot
|
||||
order, matching how HA resolves the collision at registration. The
|
||||
rollups' ``children`` attribute (per-child counts) is unchanged and
|
||||
carries a different shape on purpose.
|
||||
"""
|
||||
slugs: dict[str, int] = {}
|
||||
roster: list[dict[str, Any]] = []
|
||||
for child in children:
|
||||
base = _slugify(child.child_name)
|
||||
taken = slugs.get(base, 0) + 1
|
||||
slugs[base] = taken
|
||||
roster.append(
|
||||
{
|
||||
"child_id": child.child_id,
|
||||
"name": child.child_name,
|
||||
"slug": base if taken == 1 else f"{base}_{taken}",
|
||||
}
|
||||
)
|
||||
return roster
|
||||
|
||||
|
||||
class _NestQuestChildDaySensor(CoordinatorEntity, SensorEntity):
|
||||
"""Base for the per-child day sensors.
|
||||
|
||||
@@ -356,7 +410,12 @@ class _NestQuestHouseholdSensor(CoordinatorEntity, SensorEntity):
|
||||
|
||||
|
||||
class NestQuestHouseholdDueTodaySensor(_NestQuestHouseholdSensor):
|
||||
"""Total quests due today across active children."""
|
||||
"""Total quests due today across active children.
|
||||
|
||||
Also publishes ``child_roster`` (see :func:`_child_roster`): the
|
||||
ordered child identity list the panel cards read to discover the
|
||||
board's children when the card config carries no ``child_order``.
|
||||
"""
|
||||
|
||||
_attr_name = "NestQuest household quests due today"
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
@@ -380,7 +439,8 @@ class NestQuestHouseholdDueTodaySensor(_NestQuestHouseholdSensor):
|
||||
"children": {
|
||||
str(child.child_id): child.due_today
|
||||
for child in data.children
|
||||
}
|
||||
},
|
||||
"child_roster": _child_roster(data.children),
|
||||
}
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Generated
+522
@@ -9,10 +9,197 @@
|
||||
"lit": "^3.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jsdom": "^30.1.1",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.0.1.tgz",
|
||||
"integrity": "sha512-C9duntabagkBZ1LebM7FKmphR4Q1pBclLxVbZETQV0akkFjV0ooFxo8FlvAQyKj9F6l8rEnmidgcTlpyxFYizg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/css-calc": "^3.4.0",
|
||||
"@csstools/css-color-parser": "^4.2.3",
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.1",
|
||||
"lru-cache": "^11.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/dom-selector": {
|
||||
"version": "9.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.1.tgz",
|
||||
"integrity": "sha512-NT4s3yZLjovPpliRpTvdsdzyPjqRqiCZj9MxnarBihbaY5MbAG7DWAJcrLlhmC7xKTNCXsTELcqB8YsqpLnUFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bidi-js": "^1.1.0",
|
||||
"css-tree": "^3.2.1",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"lru-cache": "^11.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bramus/specificity": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
|
||||
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-tree": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"specificity": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz",
|
||||
"integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-calc": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.0.tgz",
|
||||
"integrity": "sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-color-parser": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.3.tgz",
|
||||
"integrity": "sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/color-helpers": "^6.1.1",
|
||||
"@csstools/css-calc": "^3.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-parser-algorithms": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
|
||||
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz",
|
||||
"integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"peerDependencies": {
|
||||
"css-tree": "^3.2.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"css-tree": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-tokenizer": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.1.tgz",
|
||||
"integrity": "sha512-bPlN9S9O1A0euCpEWE4qnvB5YDuyYVsUTrxSgmAM1Is0j4tICHoVyOVAXfWMP/kS9ZrjvyIXWV2PmomiAXXqOw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
||||
@@ -455,6 +642,24 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@exodus/bytes": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz",
|
||||
"integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@noble/hashes": "^1.8.0 || ^2.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@noble/hashes": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@lit-labs/ssr-dom-shim": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.6.0.tgz",
|
||||
@@ -892,6 +1097,79 @@
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bidi-js": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
|
||||
"integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"require-from-string": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/css-tree": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
||||
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mdn-data": "2.27.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"whatwg-url": "^16.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls/node_modules/whatwg-url": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
|
||||
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.11.0",
|
||||
"tr46": "^6.0.0",
|
||||
"webidl-conversions": "^8.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
|
||||
"integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
||||
@@ -967,6 +1245,66 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-encoding-sniffer": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz",
|
||||
"integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.15.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.13.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-potential-custom-element-name": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "30.1.1",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.1.tgz",
|
||||
"integrity": "sha512-FahmoPK5vbPc+jxV1iErMHmAZypCZ942NHF4+qqaWAuvaKKTBZxawnmAtrbGWLU7MtlxfqIP0qw6aSI+aWGtLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/css-color": "^7.0.0",
|
||||
"@asamuzakjp/dom-selector": "^9.2.1",
|
||||
"@bramus/specificity": "^2.4.2",
|
||||
"@csstools/css-syntax-patches-for-csstree": "^1.1.13",
|
||||
"@exodus/bytes": "^1.15.1",
|
||||
"css-tree": "^3.2.1",
|
||||
"data-urls": "^7.0.0",
|
||||
"decimal.js": "^10.6.0",
|
||||
"html-encoding-sniffer": "^7.0.0",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"lru-cache": "^11.5.2",
|
||||
"parse5": "^8.0.1",
|
||||
"saxes": "^6.0.0",
|
||||
"tough-cookie": "^6.0.2",
|
||||
"undici": "^8.10.2",
|
||||
"w3c-xmlserializer": "^6.0.0",
|
||||
"webidl-conversions": "^8.0.1",
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"whatwg-url": "^17.1.1",
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"canvas": "^3.2.3"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"canvas": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/lit": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/lit/-/lit-3.3.3.tgz",
|
||||
@@ -998,6 +1336,23 @@
|
||||
"@types/trusted-types": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "11.5.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
|
||||
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/mdn-data": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
|
||||
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
|
||||
"dev": true,
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.19",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
|
||||
@@ -1017,6 +1372,19 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
|
||||
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -1066,6 +1434,26 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.63.4",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.4.tgz",
|
||||
@@ -1112,6 +1500,19 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"xmlchars": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=v12.22.7"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -1139,6 +1540,52 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts": {
|
||||
"version": "7.4.14",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.14.tgz",
|
||||
"integrity": "sha512-EahQoi+Q5oqmG3yxRHx+bwn36OaLbtw6jnTFcGjc8fcmktzTgk45xRnzAx8Ch87PFEiqRhoc3FWBmY+LBrvEGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tldts-core": "^7.4.14"
|
||||
},
|
||||
"bin": {
|
||||
"tldts": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts-core": {
|
||||
"version": "7.4.14",
|
||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.14.tgz",
|
||||
"integrity": "sha512-KYkjfJHnIC5t+Gy7hPrMHgJmWc/N9FfmS+fggRPSlpFckidpB9HD6OzPsSTkfDH+MpZgcu2tJPGgLfHl979N1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
|
||||
"integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tldts": "^7.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
|
||||
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"punycode": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
@@ -1153,6 +1600,16 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "8.11.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-8.11.0.tgz",
|
||||
"integrity": "sha512-AdzuGcAkzhbha3GgCSoUBx2P0quwym6qdFNCQz/fDGZc8w9XJ741wHUbt2kTmfPz+ZIOMkySFkiY7RXmiDDC8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.6",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
|
||||
@@ -1227,6 +1684,71 @@
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz",
|
||||
"integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
||||
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
|
||||
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "17.1.2",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.2.tgz",
|
||||
"integrity": "sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.15.1",
|
||||
"tr46": "^6.0.0",
|
||||
"webidl-conversions": "^8.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.14.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlchars": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"lit": "^3.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jsdom": "^30.1.1",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.1.5"
|
||||
}
|
||||
|
||||
@@ -143,6 +143,16 @@ function asNumber(value: unknown, fallback: number): number {
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
/** The slug a discovered roster entry carries, or "" when the entry is
|
||||
* not an object with a non-empty slug string. */
|
||||
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() : "";
|
||||
}
|
||||
|
||||
function clampPercent(value: number): number {
|
||||
if (!Number.isFinite(value)) {
|
||||
return 0;
|
||||
@@ -892,15 +902,34 @@ export class NestQuestPartyBoardCard extends LitElement {
|
||||
return [];
|
||||
}
|
||||
const order = this._config.child_order;
|
||||
if (!Array.isArray(order)) {
|
||||
if (Array.isArray(order)) {
|
||||
return order
|
||||
.filter(
|
||||
(entry): entry is string =>
|
||||
typeof entry === "string" && entry.trim().length > 0
|
||||
)
|
||||
.map((entry) => entry.trim());
|
||||
}
|
||||
return this._discoveredChildSlugs();
|
||||
}
|
||||
|
||||
/** Zero-config discovery (Feature 20): 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), each entry carrying the slug the
|
||||
* per-child entity ids are built from. Consulted only when the
|
||||
* config has no child_order — explicit configuration wins. */
|
||||
private _discoveredChildSlugs(): string[] {
|
||||
const household = this._state(
|
||||
"sensor.nestquest_household_quests_due_today"
|
||||
);
|
||||
const roster = household?.attributes?.child_roster;
|
||||
if (!Array.isArray(roster)) {
|
||||
return [];
|
||||
}
|
||||
return order
|
||||
.filter(
|
||||
(entry): entry is string =>
|
||||
typeof entry === "string" && entry.trim().length > 0
|
||||
)
|
||||
.map((entry) => entry.trim());
|
||||
return roster
|
||||
.map((entry) => asRosterSlug(entry))
|
||||
.filter((slug) => slug.length > 0);
|
||||
}
|
||||
|
||||
private _plates(): ChildPlate[] {
|
||||
|
||||
@@ -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=""]')) {
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Render the built party-board card under jsdom and print its plates.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
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/board",
|
||||
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 dispatched event to be delivered.
|
||||
globalThis.Event = dom.window.Event;
|
||||
|
||||
await import(pathToFileURL(spec.bundle).href);
|
||||
|
||||
const element = document.createElement(spec.tag);
|
||||
element.setConfig(spec.config);
|
||||
element.hass = spec.hass;
|
||||
document.body.appendChild(element);
|
||||
await element.updateComplete;
|
||||
|
||||
const plateNodes = [
|
||||
...element.shadowRoot.querySelectorAll(".plates .plate"),
|
||||
];
|
||||
const plates = plateNodes.map((plate) => ({
|
||||
name: plate.querySelector(".name")?.textContent?.trim() ?? "",
|
||||
kind: plate.classList.contains("unknown")
|
||||
? "unknown"
|
||||
: plate.classList.contains("away")
|
||||
? "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,
|
||||
});
|
||||
await new Promise((resolve) => process.stdout.write(payload, () => resolve()));
|
||||
process.exit(0);
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 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);
|
||||
@@ -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);
|
||||
@@ -51,6 +51,30 @@ class CyclePromptTests(unittest.TestCase):
|
||||
self.assertNotIn("The persistent user service runs", output)
|
||||
self.assertNotIn("---\n\n---\n\n# 27", output)
|
||||
|
||||
def test_task_complete_feature_triggers_one_guarded_promotion(self):
|
||||
output = RENDER(TEMPLATE, PROFILE)
|
||||
self.assertIn(
|
||||
"feature becomes task-complete and its reviewed merge into `dev` is verified",
|
||||
output,
|
||||
)
|
||||
self.assertIn("Task completion inside an unmerged feature branch does not trigger", output)
|
||||
self.assertIn("Do not create a duplicate promotion", output)
|
||||
self.assertIn("Allow only one active promotion pull request to `main`", output)
|
||||
self.assertIn("if it was closed without merge, recalculate", output)
|
||||
self.assertIn("sole active orchestrator for the project", output)
|
||||
self.assertIn("including controller exclusivity, reconciliation", output)
|
||||
self.assertIn("RELEASE_PREPARATION_POLICY = EXCLUDED", output)
|
||||
self.assertIn("Active promotion branch and pull request", output)
|
||||
self.assertIn("An active promotion that has not reached `RELEASE_READY`", output)
|
||||
self.assertIn("RELEASE_PREPARATION_POLICY:\n", output)
|
||||
self.assertIn("A ready promotion is a local checkpoint", output)
|
||||
self.assertIn("continue that work while the ready promotion waits", output)
|
||||
self.assertLess(
|
||||
output.index("Begin an automatically triggered, requested, or scheduled release"),
|
||||
output.index("Advance to the next eligible feature"),
|
||||
)
|
||||
self.assertIn("Only Joshua may merge into `main`", output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -21,6 +21,7 @@ dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.24",
|
||||
"pyyaml>=6",
|
||||
"unidecode>=1.4.0",
|
||||
"uvicorn>=0.30",
|
||||
"voluptuous>=0.14",
|
||||
]
|
||||
|
||||
@@ -150,6 +150,15 @@ Possible scopes:
|
||||
- `PROJECT_QUEUE`
|
||||
- `RELEASE_PREPARATION`
|
||||
|
||||
Also set `RELEASE_PREPARATION_POLICY` at startup:
|
||||
|
||||
- `AUTO` by default.
|
||||
- `EXCLUDED` only when the user's launch request explicitly says not to prepare
|
||||
a release or `{{INTEGRATION_BRANCH}}`-to-`{{RELEASE_BRANCH}}` promotion.
|
||||
|
||||
Record both `MISSION_SCOPE` and `RELEASE_PREPARATION_POLICY` in durable run
|
||||
state. Do not infer exclusion merely from `SINGLE_TASK` or `SINGLE_FEATURE`.
|
||||
|
||||
If the user explicitly names one task or feature, respect that scope.
|
||||
|
||||
If the user launches the orchestrator for general/autonomous {{PROJECT_NAME}} development without limiting it to one feature, use:
|
||||
@@ -171,6 +180,7 @@ Maintain a durable orchestration checkpoint using {{WORK_TRACKER}} wherever its
|
||||
Track at minimum:
|
||||
|
||||
- `MISSION_SCOPE`
|
||||
- `RELEASE_PREPARATION_POLICY`
|
||||
- Current feature
|
||||
- Current controller phase
|
||||
- Active development agents
|
||||
@@ -185,6 +195,9 @@ Track at minimum:
|
||||
- Blocked items
|
||||
- Tests/checks status
|
||||
- Last verified repository state
|
||||
- Active promotion branch and pull request
|
||||
- Triggering feature and exact `{{INTEGRATION_BRANCH}}` commit for each automatic promotion
|
||||
- Completed features pending the next promotion
|
||||
- `NEXT_ACTION`
|
||||
|
||||
After every material state transition:
|
||||
@@ -254,8 +267,8 @@ Choose the highest-priority actionable transition in this order:
|
||||
8. Remediate feature-level review findings.
|
||||
9. Obtain feature approval.
|
||||
10. Merge an approved feature into `{{INTEGRATION_BRANCH}}`.
|
||||
11. Advance to the next eligible feature when mission scope permits.
|
||||
12. Begin a requested/scheduled release when appropriate.
|
||||
11. Begin an automatically triggered, requested, or scheduled release when appropriate. The automatic post-feature trigger is in scope for both `SINGLE_FEATURE` and `PROJECT_QUEUE` unless `RELEASE_PREPARATION_POLICY = EXCLUDED`.
|
||||
12. Advance to the next eligible feature when mission scope permits.
|
||||
13. Continue unrelated work around locally blocked items.
|
||||
14. Escalate only when the defined escalation rules require it.
|
||||
|
||||
@@ -588,9 +601,10 @@ Immediately transition to `FEATURE_INTEGRATION`.
|
||||
|
||||
A merged feature is a transition.
|
||||
|
||||
It is terminal ONLY when:
|
||||
|
||||
`MISSION_SCOPE = SINGLE_FEATURE`
|
||||
It is terminal only when `MISSION_SCOPE = SINGLE_FEATURE` and no authorized
|
||||
post-feature release transition is pending. Preparing the automatic promotion
|
||||
for that completed feature is part of `SINGLE_FEATURE` scope when
|
||||
`RELEASE_PREPARATION_POLICY = AUTO`.
|
||||
|
||||
Otherwise select the next eligible work.
|
||||
|
||||
@@ -637,13 +651,19 @@ The orchestrator itself does not perform the merge.
|
||||
When `MISSION_SCOPE = PROJECT_QUEUE` and a feature is successfully merged into `{{INTEGRATION_BRANCH}}`:
|
||||
|
||||
1. Reconcile {{WORK_TRACKER}}.
|
||||
2. Check for a requested/scheduled release.
|
||||
2. Evaluate the automatic, requested, and scheduled release triggers.
|
||||
3. Identify remaining incomplete features.
|
||||
4. Determine which features are eligible based on dependencies and priority.
|
||||
5. Select the next eligible feature.
|
||||
6. Initialize it.
|
||||
7. Continue the development loop.
|
||||
|
||||
If an automatic promotion becomes `RELEASE_READY` while another eligible
|
||||
feature remains, keep that promotion waiting for {{RELEASE_OWNER_NAME}} and
|
||||
continue the project queue. A ready promotion is a local checkpoint in this
|
||||
case, not a global terminal condition. Features completed while it remains open
|
||||
are recorded as pending for the next promotion under Section 18.
|
||||
|
||||
Do not return to the user simply because one feature completed.
|
||||
|
||||
If several features are available, choose using:
|
||||
@@ -658,27 +678,52 @@ If several features are available, choose using:
|
||||
|
||||
# 18. RELEASE RULES
|
||||
|
||||
Completed features accumulate in `{{INTEGRATION_BRANCH}}`.
|
||||
Completed features accumulate in `{{INTEGRATION_BRANCH}}`. With no active
|
||||
promotion, each verified feature completion starts release preparation. While
|
||||
one promotion is active, later completed features accumulate for the next
|
||||
promotion.
|
||||
|
||||
Prepare a release only when:
|
||||
Prepare a release when any of these conditions is true:
|
||||
|
||||
- {{RELEASE_OWNER_NAME}} requests it; OR
|
||||
- {{WORK_TRACKER}} schedules it.
|
||||
- {{WORK_TRACKER}} schedules it; OR
|
||||
- `RELEASE_PREPARATION_POLICY = AUTO`, and a feature becomes task-complete and its reviewed merge into `{{INTEGRATION_BRANCH}}` is verified.
|
||||
|
||||
For the automatic trigger, a feature is task-complete only when:
|
||||
|
||||
- Every required feature task is `done`, or deliberately `canceled` with recorded evidence.
|
||||
- No task review, remediation, approval, or merge remains pending.
|
||||
- The feature acceptance criteria and integrated checks pass.
|
||||
- Independent approval applies to the exact feature head.
|
||||
- The approver's merge of that head into `{{INTEGRATION_BRANCH}}` is verified.
|
||||
- {{WORK_TRACKER}} reflects the completed feature and exact repository references.
|
||||
|
||||
Task completion inside an unmerged feature branch does not trigger a release.
|
||||
The verified feature-to-`{{INTEGRATION_BRANCH}}` merge is the triggering transition.
|
||||
|
||||
Before creating a release branch or pull request:
|
||||
|
||||
1. Verify this session is the sole active orchestrator for the project. `{{TERMINAL_LAUNCHER}}` must enforce that exclusivity. If sole ownership cannot be verified, do not create a release branch or pull request; escalate instead.
|
||||
2. Reconcile open release records, release branches, and promotion pull requests.
|
||||
3. Do not create a duplicate promotion for an already-covered `{{INTEGRATION_BRANCH}}` commit.
|
||||
4. Allow only one active promotion pull request to `{{RELEASE_BRANCH}}`. If an earlier promotion is still open and does not contain the newly completed feature, record the feature as pending for the next automatic promotion. Resume it after the active promotion is merged or closed; if it was closed without merge, recalculate the next promotion from the current validated `{{INTEGRATION_BRANCH}}` head.
|
||||
5. Record the triggering feature and exact `{{INTEGRATION_BRANCH}}` commit so the automatic trigger runs once for that merge.
|
||||
|
||||
Release process:
|
||||
|
||||
1. Reconcile release scope.
|
||||
2. Confirm included features are complete.
|
||||
3. Create release branch from exact validated `{{INTEGRATION_BRANCH}}`.
|
||||
4. Run complete release checks.
|
||||
5. Open release PR → `{{RELEASE_BRANCH}}`.
|
||||
6. Obtain fresh independent {{REVIEWER_VENDOR}} review.
|
||||
7. Remediate findings through development agents.
|
||||
8. Incorporate every release correction back into `{{INTEGRATION_BRANCH}}`.
|
||||
9. Revalidate.
|
||||
10. Assign final release PR to `{{HOST_ACCOUNT}}`.
|
||||
11. Update {{WORK_TRACKER}}.
|
||||
12. Stop before {{RELEASE_BRANCH}} merge.
|
||||
1. Enforce every pre-creation requirement above, including controller exclusivity, reconciliation, duplicate and active-promotion checks, and trigger recording.
|
||||
2. Reconcile release scope.
|
||||
3. Confirm included features are complete.
|
||||
4. Create release branch from exact validated `{{INTEGRATION_BRANCH}}`.
|
||||
5. Run complete release checks.
|
||||
6. Open release PR → `{{RELEASE_BRANCH}}`.
|
||||
7. Obtain fresh independent {{REVIEWER_VENDOR}} review.
|
||||
8. Remediate findings through development agents.
|
||||
9. Incorporate every release correction back into `{{INTEGRATION_BRANCH}}`.
|
||||
10. Revalidate.
|
||||
11. Assign final release PR to `{{HOST_ACCOUNT}}`.
|
||||
12. Update {{WORK_TRACKER}}.
|
||||
13. Stop before {{RELEASE_BRANCH}} merge.
|
||||
|
||||
Only {{RELEASE_OWNER_NAME}} may merge into `{{RELEASE_BRANCH}}`.
|
||||
|
||||
@@ -724,13 +769,23 @@ For `PROJECT_QUEUE`, this additionally requires:
|
||||
|
||||
- No eligible incomplete feature remains.
|
||||
- No task/review/remediation/merge is pending.
|
||||
- No release preparation, release review, or release remediation is pending.
|
||||
- Every in-scope automatic trigger is either incorporated into the active
|
||||
`RELEASE_READY` promotion or durably recorded for the next promotion.
|
||||
- No development agent is active.
|
||||
- No approver is active.
|
||||
|
||||
An active promotion that has not reached `RELEASE_READY` makes
|
||||
`MISSION_COMPLETE` false.
|
||||
|
||||
## `RELEASE_READY`
|
||||
|
||||
Release PR has passed required gates and is assigned to `{{HOST_ACCOUNT}}`, awaiting {{RELEASE_OWNER_NAME}}'s {{RELEASE_BRANCH}} merge.
|
||||
|
||||
`RELEASE_READY` is globally terminal only when no other in-scope engineering
|
||||
action is executable. In `PROJECT_QUEUE`, if another eligible incomplete
|
||||
feature remains, continue that work while the ready promotion waits.
|
||||
|
||||
## `GLOBAL_ESCALATION`
|
||||
|
||||
All remaining in-scope work is blocked by decisions/access that require {{RELEASE_OWNER_NAME}}.
|
||||
@@ -778,9 +833,12 @@ Output a machine-readable continuation checkpoint:
|
||||
```text
|
||||
ORCHESTRATOR_STATE: CONTINUE
|
||||
MISSION_SCOPE:
|
||||
RELEASE_PREPARATION_POLICY:
|
||||
CONTROLLER_PHASE:
|
||||
CURRENT_FEATURE:
|
||||
ACTIVE_TASKS:
|
||||
ACTIVE_PROMOTION:
|
||||
PENDING_PROMOTION_FEATURES:
|
||||
BLOCKED_ITEMS:
|
||||
LAST_VERIFIED_STATE:
|
||||
NEXT_ACTION:
|
||||
|
||||
@@ -24,16 +24,20 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import inspect
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import unicodedata
|
||||
import weakref
|
||||
from dataclasses import dataclass
|
||||
from html.entities import name2codepoint
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from unidecode import unidecode as _unidecode
|
||||
|
||||
from tests.admin_jwt_harness import ADMIN_KEY, KID, AdminRunner, jwks_for
|
||||
|
||||
@@ -46,6 +50,7 @@ _HA_MODULES = (
|
||||
"homeassistant.config_entries",
|
||||
"homeassistant.data_entry_flow",
|
||||
"homeassistant.exceptions",
|
||||
"homeassistant.util",
|
||||
"homeassistant.helpers",
|
||||
"homeassistant.helpers.config_validation",
|
||||
"homeassistant.helpers.entity",
|
||||
@@ -94,6 +99,7 @@ for _parent, _child in (
|
||||
("homeassistant", "data_entry_flow"),
|
||||
("homeassistant", "core"),
|
||||
("homeassistant", "exceptions"),
|
||||
("homeassistant", "util"),
|
||||
("homeassistant", "helpers"),
|
||||
("homeassistant.helpers", "config_validation"),
|
||||
("homeassistant.helpers", "entity"),
|
||||
@@ -218,6 +224,68 @@ _exceptions_mock.ConfigEntryNotReady = ConfigEntryNotReady
|
||||
_exceptions_mock.Unauthorized = Unauthorized
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# homeassistant.util.slugify: a faithful stand-in for the real slugifier.
|
||||
#
|
||||
# Real HA (2024.6) wraps python-slugify's frozen legacy pipeline and answers
|
||||
# its "unknown" sentinel when a non-empty name slugs to nothing. The stub
|
||||
# mirrors that pipeline step for step — quotes to dashes, NFKD-normalize,
|
||||
# TRANSLITERATE through unidecode (the backend python-slugify's "auto" mode
|
||||
# prefers, and a dev dependency here), decode HTML entities, NFKD again,
|
||||
# lowercase, drop quotes, fold runs of non-[a-z0-9-] to one dash, collapse
|
||||
# and strip dashes, then map dashes to the requested separator — and was
|
||||
# verified byte-for-byte against real python-slugify across Latin, Cyrillic,
|
||||
# CJK and Greek household-name shapes. Transliteration is the point: HA
|
||||
# derives the per-child entity ids from a name like "京子" as
|
||||
# "nestquest_jing_zi_…", so the roster's slugs must transliterate too,
|
||||
# never dropping characters into "unknown". Deliberately NOT truncating:
|
||||
# real slugify caps nothing; the entity registry truncates the full
|
||||
# entity_id instead.
|
||||
# ---------------------------------------------------------------------------
|
||||
_HA_QUOTE_PATTERN = re.compile(r"[']+")
|
||||
_HA_CHAR_ENTITY_PATTERN = re.compile(r"&(%s);" % "|".join(name2codepoint))
|
||||
_HA_DECIMAL_PATTERN = re.compile(r"&#(\d+);")
|
||||
_HA_HEX_PATTERN = re.compile(r"&#x([\da-fA-F]+);")
|
||||
_HA_NUMBERS_PATTERN = re.compile(r"(?<=\d),(?=\d)")
|
||||
_HA_DISALLOWED_CHARS_PATTERN = re.compile(r"[^-a-zA-Z0-9]+")
|
||||
_HA_DUPLICATE_DASH_PATTERN = re.compile(r"-{2,}")
|
||||
|
||||
|
||||
def _ha_slugify(text: str | None, *, separator: str = "_") -> str:
|
||||
"""Faithful homeassistant.util.slugify stand-in (see section above)."""
|
||||
if text == "" or text is None:
|
||||
return ""
|
||||
text = str(text)
|
||||
text = _HA_QUOTE_PATTERN.sub("-", text)
|
||||
text = unicodedata.normalize("NFKD", text)
|
||||
text = _unidecode(text)
|
||||
text = _HA_CHAR_ENTITY_PATTERN.sub(
|
||||
lambda match: chr(name2codepoint[match.group(1)]), text
|
||||
)
|
||||
try:
|
||||
text = _HA_DECIMAL_PATTERN.sub(
|
||||
lambda match: chr(int(match.group(1))), text
|
||||
)
|
||||
except (ValueError, OverflowError):
|
||||
pass
|
||||
try:
|
||||
text = _HA_HEX_PATTERN.sub(
|
||||
lambda match: chr(int(match.group(1), 16)), text
|
||||
)
|
||||
except (ValueError, OverflowError):
|
||||
pass
|
||||
text = unicodedata.normalize("NFKD", text)
|
||||
text = text.lower()
|
||||
text = _HA_QUOTE_PATTERN.sub("", text)
|
||||
text = _HA_NUMBERS_PATTERN.sub("", text)
|
||||
text = _HA_DISALLOWED_CHARS_PATTERN.sub("-", text)
|
||||
text = _HA_DUPLICATE_DASH_PATTERN.sub("-", text).strip("-")
|
||||
return "unknown" if text == "" else text.replace("-", separator)
|
||||
|
||||
|
||||
_ha_mock("homeassistant.util").slugify = _ha_slugify
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceInfo:
|
||||
"""Stand-in for homeassistant.helpers.entity.DeviceInfo.
|
||||
|
||||
@@ -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
|
||||
|
||||
+730
-2
@@ -5,16 +5,36 @@ the contract by reading the card sources and the built bundle, in the same
|
||||
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.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
FRONTEND_CARDS_DIR = REPO_ROOT / "frontend" / "src" / "cards"
|
||||
FRONTEND_DIR = REPO_ROOT / "frontend"
|
||||
FRONTEND_CARDS_DIR = FRONTEND_DIR / "src" / "cards"
|
||||
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"
|
||||
|
||||
PANEL_SOURCES = (PARTY_BOARD, QUEST_LOG)
|
||||
|
||||
@@ -38,11 +58,106 @@ 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
|
||||
@@ -146,6 +261,198 @@ 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.
|
||||
@@ -157,4 +464,425 @@ def test_panel_cards_reference_no_admin_only_services() -> None:
|
||||
assert BUNDLE_PATH.is_file()
|
||||
bundle = _read(BUNDLE_PATH)
|
||||
assert "nestquest-party-board-card" in bundle
|
||||
assert "nestquest-quest-log-card" in bundle
|
||||
assert "nestquest-quest-log-card" in bundle
|
||||
|
||||
|
||||
def test_party_board_discovers_children_from_the_household_roster() -> None:
|
||||
"""Feature 20: with no child_order configured, the board discovers
|
||||
the ordered child slugs from the household rollup's child_roster
|
||||
attribute; explicit configuration still wins."""
|
||||
source = _read(PARTY_BOARD)
|
||||
assert "child_roster" in source
|
||||
assert "sensor.nestquest_household_quests_due_today" in source
|
||||
# Discovery only fills in for a MISSING child_order; a supplied
|
||||
# order array is honoured verbatim before the discovered list is
|
||||
# ever consulted.
|
||||
assert "Array.isArray(order)" in source
|
||||
assert "_discoveredChildSlugs()" in source
|
||||
|
||||
|
||||
# --- Render tests (built bundle under jsdom) ---------------------------------
|
||||
|
||||
#: The seeded three-child household, in the API's sort order: Ada and
|
||||
#: Bo share one daily quest (Bo is away today), Cory has none.
|
||||
ROSTER = [
|
||||
{"child_id": 1, "name": "Ada", "slug": "ada"},
|
||||
{"child_id": 2, "name": "Bo", "slug": "bo"},
|
||||
{"child_id": 3, "name": "Cory", "slug": "cory"},
|
||||
]
|
||||
|
||||
|
||||
def _state(state: str, **attributes) -> dict:
|
||||
return {"state": state, "attributes": attributes}
|
||||
|
||||
|
||||
def _three_child_states() -> dict:
|
||||
"""The hass states a three-child household exposes.
|
||||
|
||||
Entity ids follow the documented naming contract
|
||||
(design/ENTITIES-AND-SERVICES.md §1): the household rollup and the
|
||||
per-child sensors slugged from the child names.
|
||||
"""
|
||||
return {
|
||||
"sensor.nestquest_household_quests_due_today": _state(
|
||||
"2",
|
||||
children={"1": 1, "2": 1, "3": 0},
|
||||
child_roster=ROSTER,
|
||||
),
|
||||
"sensor.nestquest_ada_quests_due_today": _state(
|
||||
"1", child_name="Ada", present=True
|
||||
),
|
||||
"sensor.nestquest_ada_quests_completed_today": _state("0"),
|
||||
"sensor.nestquest_ada_completion_pct_today": _state("0"),
|
||||
"binary_sensor.nestquest_ada_present_today": _state("on"),
|
||||
"sensor.nestquest_bo_quests_due_today": _state(
|
||||
"1", child_name="Bo", present=False
|
||||
),
|
||||
"sensor.nestquest_bo_quests_completed_today": _state("0"),
|
||||
"sensor.nestquest_bo_completion_pct_today": _state("0"),
|
||||
"binary_sensor.nestquest_bo_present_today": _state("off"),
|
||||
"sensor.nestquest_cory_quests_due_today": _state(
|
||||
"0", child_name="Cory", present=True
|
||||
),
|
||||
"sensor.nestquest_cory_quests_completed_today": _state("0"),
|
||||
"sensor.nestquest_cory_completion_pct_today": _state("100"),
|
||||
"binary_sensor.nestquest_cory_present_today": _state("on"),
|
||||
}
|
||||
|
||||
|
||||
def _render_party_board(
|
||||
config: dict,
|
||||
states: dict,
|
||||
url: str | None = None,
|
||||
click_plate: str | None = None,
|
||||
) -> 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.
|
||||
"""
|
||||
assert BUNDLE_PATH.is_file(), "bundle missing: run cd frontend && npm run build"
|
||||
assert RENDER_HARNESS.is_file()
|
||||
spec = {
|
||||
"bundle": str(BUNDLE_PATH),
|
||||
"tag": "nestquest-party-board-card",
|
||||
"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),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
assert completed.returncode == 0, (
|
||||
f"render harness failed: {completed.stderr}"
|
||||
)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_party_board_renders_discovered_children_in_sort_order() -> None:
|
||||
"""No child_order key: three plates render in the roster's sort
|
||||
order (the coordinator snapshot's order), one per child."""
|
||||
result = _render_party_board(
|
||||
{"type": "custom:nestquest-party-board-card"},
|
||||
_three_child_states(),
|
||||
)
|
||||
assert result["plates"] == [
|
||||
{"name": "Ada", "kind": "present"},
|
||||
{"name": "Bo", "kind": "away"},
|
||||
{"name": "Cory", "kind": "present"},
|
||||
]
|
||||
|
||||
|
||||
def test_explicit_child_order_still_wins_over_discovery() -> None:
|
||||
"""A supplied child_order keeps working: it overrides the household
|
||||
roster's order and limits the plates to the configured slugs."""
|
||||
result = _render_party_board(
|
||||
{
|
||||
"type": "custom:nestquest-party-board-card",
|
||||
"child_order": ["bo", "ada"],
|
||||
},
|
||||
_three_child_states(),
|
||||
)
|
||||
assert [plate["name"] for plate in result["plates"]] == ["Bo", "Ada"]
|
||||
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
|
||||
|
||||
|
||||
# --- 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"
|
||||
|
||||
@@ -12,6 +12,7 @@ from __future__ import annotations
|
||||
import datetime
|
||||
|
||||
import pytest
|
||||
from homeassistant.util import slugify
|
||||
|
||||
from conftest import StubSnapshotClient, set_coordinator_client, wire_entry_to_registry
|
||||
|
||||
@@ -19,6 +20,7 @@ from custom_components.nestquest import async_setup_entry, async_unload_entry
|
||||
from custom_components.nestquest.const import DOMAIN
|
||||
from custom_components.nestquest.sensor import (
|
||||
MAX_STATE_LENGTH,
|
||||
_slugify,
|
||||
_state_within_limit,
|
||||
)
|
||||
|
||||
@@ -539,3 +541,165 @@ async def test_household_rollups_sum_the_active_children(
|
||||
assert cycle_day_entity.name == "NestQuest cycle day"
|
||||
assert cycle_day_entity.native_value == 4
|
||||
assert cycle_day_entity.extra_state_attributes["today"] == _today_iso()
|
||||
|
||||
|
||||
async def test_household_due_sensor_publishes_the_ordered_child_roster(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""Feature 20: the rollup carries the ordered child roster beside
|
||||
the unchanged per-child ``children`` counts, so the panel cards can
|
||||
discover the board without a configured child_order. The order is
|
||||
the coordinator snapshot's (the API's sort_order, then id)."""
|
||||
entry, _coordinator, _client = await _setup_entry(
|
||||
hass, make_entry, _seeded_payload()
|
||||
)
|
||||
attributes = hass.entities[
|
||||
"nestquest_household_quests_due_today"
|
||||
].extra_state_attributes
|
||||
assert attributes["child_roster"] == [
|
||||
{"child_id": ADA, "name": "Ada", "slug": "ada"},
|
||||
{"child_id": BO, "name": "Bo", "slug": "bo"},
|
||||
{"child_id": CORY, "name": "Cory", "slug": "cory"},
|
||||
]
|
||||
# The pre-existing per-child counts attribute is untouched.
|
||||
assert attributes["children"] == {
|
||||
str(ADA): 1,
|
||||
str(BO): 1,
|
||||
str(CORY): 0,
|
||||
}
|
||||
|
||||
|
||||
async def test_household_roster_tracks_the_snapshot_on_refresh(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""The roster re-derives from every snapshot: a reorder and a
|
||||
rename in the API payload show up (with the re-derived slug) on the
|
||||
next refresh, like the sensor names already do."""
|
||||
reordered = _payload(
|
||||
[
|
||||
_child(CORY, "Cory"),
|
||||
_child(ADA, "Ada", [_instance(7, ADA)]),
|
||||
_child(BO, "Mirren", [_instance(8, BO)]),
|
||||
]
|
||||
)
|
||||
entry, coordinator, _client = await _setup_entry(
|
||||
hass, make_entry, _seeded_payload(), reordered
|
||||
)
|
||||
roster_entity = hass.entities["nestquest_household_quests_due_today"]
|
||||
assert [entry["slug"] for entry in roster_entity.extra_state_attributes["child_roster"]] == [
|
||||
"ada",
|
||||
"bo",
|
||||
"cory",
|
||||
]
|
||||
|
||||
await coordinator.async_refresh()
|
||||
|
||||
assert roster_entity.extra_state_attributes["child_roster"] == [
|
||||
{"child_id": CORY, "name": "Cory", "slug": "cory"},
|
||||
{"child_id": ADA, "name": "Ada", "slug": "ada"},
|
||||
{"child_id": BO, "name": "Mirren", "slug": "mirren"},
|
||||
]
|
||||
|
||||
|
||||
async def test_duplicate_display_names_get_registry_suffix_slugs(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""Two children may share a nickname (the children layer allows
|
||||
it); the roster's slugs then mirror the registry's collision
|
||||
suffixing (_2, _3, ...) in snapshot order, so both plates resolve."""
|
||||
payload = _payload(
|
||||
[
|
||||
_child(ADA, "Ada", [_instance(7, ADA)]),
|
||||
_child(BO, "Ada"),
|
||||
]
|
||||
)
|
||||
entry, _coordinator, _client = await _setup_entry(hass, make_entry, payload)
|
||||
assert hass.entities[
|
||||
"nestquest_household_quests_due_today"
|
||||
].extra_state_attributes["child_roster"] == [
|
||||
{"child_id": ADA, "name": "Ada", "slug": "ada"},
|
||||
{"child_id": BO, "name": "Ada", "slug": "ada_2"},
|
||||
]
|
||||
|
||||
|
||||
async def test_empty_household_publishes_an_empty_roster(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""An empty household is a valid 0: the roster is an empty list,
|
||||
and the card's discovery of it renders the not-set-up notice."""
|
||||
entry, _coordinator, _client = await _setup_entry(
|
||||
hass, make_entry, _payload([])
|
||||
)
|
||||
attributes = hass.entities[
|
||||
"nestquest_household_quests_due_today"
|
||||
].extra_state_attributes
|
||||
assert attributes["child_roster"] == []
|
||||
assert attributes["children"] == {}
|
||||
|
||||
|
||||
def test_slugify_mirrors_home_assistant_entity_id_derivation() -> None:
|
||||
"""The roster slugs match homeassistant.util.slugify — the real
|
||||
slugifier HA derives entity ids from, composed with the entity-id
|
||||
length cap the entity registry applies to ``sensor.`` ids."""
|
||||
assert _slugify("Ada") == "ada"
|
||||
assert _slugify("Mary Jane") == "mary_jane"
|
||||
assert _slugify("Mary-Jane") == "mary_jane"
|
||||
assert _slugify("O'Brien") == "o_brien"
|
||||
assert _slugify("Émilie") == "emilie"
|
||||
assert _slugify("Åsa") == "asa"
|
||||
assert _slugify(" Bo ") == "bo"
|
||||
# HA's wrapper answers "unknown" when a name slugs to nothing.
|
||||
assert _slugify(" ") == "unknown"
|
||||
assert _slugify("?!?") == "unknown"
|
||||
# The registry caps the entity_id at 64 characters, so the slugged
|
||||
# object id alone is capped at 64 - len("sensor.").
|
||||
assert _slugify("a" * 80) == "a" * (64 - len("sensor."))
|
||||
|
||||
|
||||
def test_slugify_transliterates_non_latin_names_like_home_assistant() -> None:
|
||||
"""HA's slugifier transliterates every script through unidecode,
|
||||
so a non-Latin child name slugs to its romanization — never to the
|
||||
``unknown`` sentinel — and the roster publishes the same slug HA's
|
||||
entity-id derivation produces for that name."""
|
||||
assert _slugify("Дана") == "dana"
|
||||
assert _slugify("京子") == "jing_zi"
|
||||
assert _slugify("Zoë") == "zoe"
|
||||
|
||||
|
||||
async def test_non_latin_child_names_resolve_auto_discovery(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""A non-Latin child name slugs through the real slugifier, so the
|
||||
roster slug IS the object id of the per-child sensor's entity_id
|
||||
and the cards' auto-discovery resolves that child.
|
||||
|
||||
HA derives the per-child entity id from the entity NAME ("NestQuest
|
||||
京子 quests due today") with the same slugifier the roster uses, so
|
||||
the two must agree exactly; the reviewed mirror dropped non-Latin
|
||||
characters (slugging "京子" to "unknown"), so the cards built
|
||||
"sensor.nestquest_unknown_…" ids that no entity had."""
|
||||
payload = _payload(
|
||||
[
|
||||
_child(ADA, "Дана", [_instance(7, ADA)]),
|
||||
_child(BO, "京子", [_instance(8, BO)]),
|
||||
]
|
||||
)
|
||||
entry, _coordinator, _client = await _setup_entry(hass, make_entry, payload)
|
||||
roster = hass.entities[
|
||||
"nestquest_household_quests_due_today"
|
||||
].extra_state_attributes["child_roster"]
|
||||
assert roster == [
|
||||
{"child_id": ADA, "name": "Дана", "slug": "dana"},
|
||||
{"child_id": BO, "name": "京子", "slug": "jing_zi"},
|
||||
]
|
||||
for roster_entry in roster:
|
||||
due_entity = _sensor(hass, roster_entry["child_id"], "quests_due_today")
|
||||
assert due_entity.name == (
|
||||
f"NestQuest {roster_entry['name']} quests due today"
|
||||
)
|
||||
# The entity id HA's registry derives for this entity (from the
|
||||
# same slugifier) embeds exactly the slug the roster publishes:
|
||||
# the card builds sensor.nestquest_<slug>_quests_due_today.
|
||||
assert slugify(due_entity.name) == (
|
||||
f"nestquest_{roster_entry['slug']}_quests_due_today"
|
||||
)
|
||||
|
||||
@@ -385,6 +385,7 @@ dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "unidecode" },
|
||||
{ name = "uvicorn" },
|
||||
{ name = "voluptuous" },
|
||||
]
|
||||
@@ -400,6 +401,7 @@ dev = [
|
||||
{ name = "pytest", specifier = ">=8.0.0" },
|
||||
{ name = "pytest-asyncio", specifier = ">=0.24" },
|
||||
{ name = "pyyaml", specifier = ">=6" },
|
||||
{ name = "unidecode", specifier = ">=1.4.0" },
|
||||
{ name = "uvicorn", specifier = ">=0.30" },
|
||||
{ name = "voluptuous", specifier = ">=0.14" },
|
||||
]
|
||||
@@ -689,6 +691,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unidecode"
|
||||
version = "1.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/7d/a8a765761bbc0c836e397a2e48d498305a865b70a8600fd7a942e85dcf63/Unidecode-1.4.0.tar.gz", hash = "sha256:ce35985008338b676573023acc382d62c264f307c8f7963733405add37ea2b23", size = 200149, upload-time = "2025-04-24T08:45:03.798Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/b7/559f59d57d18b44c6d1250d2eeaa676e028b9c527431f5d0736478a73ba1/Unidecode-1.4.0-py3-none-any.whl", hash = "sha256:c3c7606c27503ad8d501270406e345ddb480a7b5f38827eafe4fa82a137f0021", size = 235837, upload-time = "2025-04-24T08:45:01.609Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.53.0"
|
||||
|
||||
Reference in New Issue
Block a user