Feature 20: Zero-Config Panel Dashboard (#180)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com> Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
This commit was merged in pull request #180.
This commit is contained in:
@@ -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
|
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
|
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
|
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
|
not in the NestQuest admin allowlist. The allowlist blocks the NestQuest
|
||||||
**admin** operations — `uncomplete_quest`, `create_quest_definition`,
|
**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
|
Assistant user it may also reach other permitted Home Assistant entities
|
||||||
and services.
|
and services.
|
||||||
|
|
||||||
2. **Build a dashboard with only the NestQuest panel cards.** Create a
|
4. **Open the dashboard and point TouchHub at it.** Open
|
||||||
Lovelace dashboard whose **URL slug is `nestquest`** (Settings →
|
`http://<home-assistant>/nestquest` in a browser first and check the
|
||||||
Dashboards → Edit → URL slug). A functional panel needs **two** views
|
party board renders with your children's crests. Then, in the
|
||||||
in that dashboard, each with its own card config from
|
TouchHub launcher, add the **Home Assistant Lovelace** app to the
|
||||||
`design/ENTITIES-AND-SERVICES.md` §4. Give the views these exact URL
|
dock and paste the dashboard URL from step 2 — the dashboard root,
|
||||||
paths (the path segment after the dashboard slug):
|
`/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
|
5. **Log the kiosk session in.** Sign in on the device as the dedicated
|
||||||
(`type: custom:nestquest-party-board-card`): the household quest
|
kiosk user from step 3, and leave that session logged in.
|
||||||
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.
|
|
||||||
|
|
||||||
TouchHub loads the dashboard URL directly. The NestQuest cards are normal
|
TouchHub loads the dashboard URL directly. The NestQuest cards are normal
|
||||||
Lovelace cards, not an iframe, so no Home Assistant framing setting is
|
Lovelace cards, not an iframe, so no Home Assistant framing setting is
|
||||||
needed.
|
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
|
## Developer Setup and Testing
|
||||||
|
|
||||||
This project uses [`uv`](https://github.com/astral-sh/uv) for fast Python package and dependency management.
|
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
|
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
|
snapshot through it, and the SSE subscription that re-fires the API's
|
||||||
transition frames on the HA bus, registers the single
|
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.
|
binary_sensor platforms.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -29,6 +30,7 @@ from .const import (
|
|||||||
PLATFORMS,
|
PLATFORMS,
|
||||||
)
|
)
|
||||||
from .coordinator import NestQuestCoordinator, coordinator_client_from_entry
|
from .coordinator import NestQuestCoordinator, coordinator_client_from_entry
|
||||||
|
from .dashboard import async_register_dashboard
|
||||||
from .frontend import async_register_frontend
|
from .frontend import async_register_frontend
|
||||||
from .services import async_deregister_services, async_register_services
|
from .services import async_deregister_services, async_register_services
|
||||||
from .sse import NestQuestEventStream
|
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:
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Set up NestQuest from a config entry."""
|
"""Set up NestQuest from a config entry."""
|
||||||
await async_register_frontend(hass)
|
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, {})
|
hass.data.setdefault(DOMAIN, {})
|
||||||
|
|
||||||
async def _async_update_listener(
|
async def _async_update_listener(
|
||||||
|
|||||||
@@ -43,6 +43,18 @@ DOMAIN_SERVICES: tuple[str, ...] = (
|
|||||||
|
|
||||||
SQLITE_DB_FILENAME = "nestquest.db"
|
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
|
# Configuration keys and default values
|
||||||
CONF_HORIZON_DAYS = "horizon_days"
|
CONF_HORIZON_DAYS = "horizon_days"
|
||||||
DEFAULT_HORIZON_DAYS = 14
|
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,
|
"config_flow": true,
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"frontend",
|
"frontend",
|
||||||
"http"
|
"http",
|
||||||
|
"lovelace"
|
||||||
],
|
],
|
||||||
"documentation": "https://github.com/Maniacs-Mansion/NestQuest",
|
"documentation": "https://github.com/Maniacs-Mansion/NestQuest",
|
||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ from homeassistant.config_entries import ConfigEntry
|
|||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.entity import DeviceInfo
|
from homeassistant.helpers.entity import DeviceInfo
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
from homeassistant.util import slugify
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
from .coordinator import (
|
from .coordinator import (
|
||||||
@@ -41,13 +42,21 @@ from .coordinator import (
|
|||||||
)
|
)
|
||||||
from .core.snapshot import instance_payload
|
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
|
#: characters (its hard recorder/state limit). These sensors' states
|
||||||
#: are numeric, but the guard is applied uniformly: a payload-bearing
|
#: are numeric, but the guard is applied uniformly: a payload-bearing
|
||||||
#: sensor can never crash the state write, and later string-state
|
#: sensor can never crash the state write, and later string-state
|
||||||
#: sensors reuse the same helper.
|
#: sensors reuse the same helper.
|
||||||
MAX_STATE_LENGTH = 255
|
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:
|
def _state_within_limit(state: Any) -> Any:
|
||||||
"""Return ``state`` unchanged, refusing strings over HA's limit.
|
"""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):
|
class _NestQuestChildDaySensor(CoordinatorEntity, SensorEntity):
|
||||||
"""Base for the per-child day sensors.
|
"""Base for the per-child day sensors.
|
||||||
|
|
||||||
@@ -356,7 +410,12 @@ class _NestQuestHouseholdSensor(CoordinatorEntity, SensorEntity):
|
|||||||
|
|
||||||
|
|
||||||
class NestQuestHouseholdDueTodaySensor(_NestQuestHouseholdSensor):
|
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_name = "NestQuest household quests due today"
|
||||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||||
@@ -380,7 +439,8 @@ class NestQuestHouseholdDueTodaySensor(_NestQuestHouseholdSensor):
|
|||||||
"children": {
|
"children": {
|
||||||
str(child.child_id): child.due_today
|
str(child.child_id): child.due_today
|
||||||
for child in data.children
|
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"
|
"lit": "^3.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"jsdom": "^30.1.1",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^5.9.2",
|
||||||
"vite": "^7.1.5"
|
"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": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.28.2",
|
"version": "0.28.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
||||||
@@ -455,6 +642,24 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/@lit-labs/ssr-dom-shim": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.6.0.tgz",
|
"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==",
|
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.28.2",
|
"version": "0.28.2",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
"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": "^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": {
|
"node_modules/lit": {
|
||||||
"version": "3.3.3",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/lit/-/lit-3.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/lit/-/lit-3.3.3.tgz",
|
||||||
@@ -998,6 +1336,23 @@
|
|||||||
"@types/trusted-types": "^2.0.2"
|
"@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": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.19",
|
"version": "3.3.19",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
|
"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": "^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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -1066,6 +1434,26 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"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": {
|
"node_modules/rollup": {
|
||||||
"version": "4.63.4",
|
"version": "4.63.4",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.4.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.4.tgz",
|
||||||
@@ -1112,6 +1500,19 @@
|
|||||||
"fsevents": "~2.3.2"
|
"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": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"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"
|
"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": {
|
"node_modules/typescript": {
|
||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
@@ -1153,6 +1600,16 @@
|
|||||||
"node": ">=14.17"
|
"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": {
|
"node_modules/vite": {
|
||||||
"version": "7.3.6",
|
"version": "7.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
|
||||||
@@ -1227,6 +1684,71 @@
|
|||||||
"optional": true
|
"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"
|
"lit": "^3.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"jsdom": "^30.1.1",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^5.9.2",
|
||||||
"vite": "^7.1.5"
|
"vite": "^7.1.5"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,6 +143,16 @@ function asNumber(value: unknown, fallback: number): number {
|
|||||||
return Number.isFinite(parsed) ? parsed : fallback;
|
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 {
|
function clampPercent(value: number): number {
|
||||||
if (!Number.isFinite(value)) {
|
if (!Number.isFinite(value)) {
|
||||||
return 0;
|
return 0;
|
||||||
@@ -892,15 +902,34 @@ export class NestQuestPartyBoardCard extends LitElement {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const order = this._config.child_order;
|
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 [];
|
||||||
}
|
}
|
||||||
return order
|
return roster
|
||||||
.filter(
|
.map((entry) => asRosterSlug(entry))
|
||||||
(entry): entry is string =>
|
.filter((slug) => slug.length > 0);
|
||||||
typeof entry === "string" && entry.trim().length > 0
|
|
||||||
)
|
|
||||||
.map((entry) => entry.trim());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _plates(): ChildPlate[] {
|
private _plates(): ChildPlate[] {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import "./cards/party-board";
|
import "./cards/party-board";
|
||||||
import "./cards/quest-log";
|
import "./cards/quest-log";
|
||||||
import "./cards/admin";
|
import "./cards/admin";
|
||||||
|
import "./strategy";
|
||||||
|
|
||||||
const FONTS_HREF = "/nestquest-static/nestquest-fonts.css";
|
const FONTS_HREF = "/nestquest-static/nestquest-fonts.css";
|
||||||
if (!document.querySelector('link[data-nq-fonts=""]')) {
|
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);
|
||||||
@@ -21,6 +21,7 @@ dev = [
|
|||||||
"pytest>=8.0.0",
|
"pytest>=8.0.0",
|
||||||
"pytest-asyncio>=0.24",
|
"pytest-asyncio>=0.24",
|
||||||
"pyyaml>=6",
|
"pyyaml>=6",
|
||||||
|
"unidecode>=1.4.0",
|
||||||
"uvicorn>=0.30",
|
"uvicorn>=0.30",
|
||||||
"voluptuous>=0.14",
|
"voluptuous>=0.14",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -24,16 +24,20 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import inspect
|
import inspect
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import unicodedata
|
||||||
import weakref
|
import weakref
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from html.entities import name2codepoint
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
from unidecode import unidecode as _unidecode
|
||||||
|
|
||||||
from tests.admin_jwt_harness import ADMIN_KEY, KID, AdminRunner, jwks_for
|
from tests.admin_jwt_harness import ADMIN_KEY, KID, AdminRunner, jwks_for
|
||||||
|
|
||||||
@@ -46,6 +50,7 @@ _HA_MODULES = (
|
|||||||
"homeassistant.config_entries",
|
"homeassistant.config_entries",
|
||||||
"homeassistant.data_entry_flow",
|
"homeassistant.data_entry_flow",
|
||||||
"homeassistant.exceptions",
|
"homeassistant.exceptions",
|
||||||
|
"homeassistant.util",
|
||||||
"homeassistant.helpers",
|
"homeassistant.helpers",
|
||||||
"homeassistant.helpers.config_validation",
|
"homeassistant.helpers.config_validation",
|
||||||
"homeassistant.helpers.entity",
|
"homeassistant.helpers.entity",
|
||||||
@@ -94,6 +99,7 @@ for _parent, _child in (
|
|||||||
("homeassistant", "data_entry_flow"),
|
("homeassistant", "data_entry_flow"),
|
||||||
("homeassistant", "core"),
|
("homeassistant", "core"),
|
||||||
("homeassistant", "exceptions"),
|
("homeassistant", "exceptions"),
|
||||||
|
("homeassistant", "util"),
|
||||||
("homeassistant", "helpers"),
|
("homeassistant", "helpers"),
|
||||||
("homeassistant.helpers", "config_validation"),
|
("homeassistant.helpers", "config_validation"),
|
||||||
("homeassistant.helpers", "entity"),
|
("homeassistant.helpers", "entity"),
|
||||||
@@ -218,6 +224,68 @@ _exceptions_mock.ConfigEntryNotReady = ConfigEntryNotReady
|
|||||||
_exceptions_mock.Unauthorized = Unauthorized
|
_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
|
@dataclass
|
||||||
class DeviceInfo:
|
class DeviceInfo:
|
||||||
"""Stand-in for homeassistant.helpers.entity.DeviceInfo.
|
"""Stand-in for homeassistant.helpers.entity.DeviceInfo.
|
||||||
|
|||||||
@@ -12,12 +12,100 @@ from custom_components.nestquest.const import (
|
|||||||
DOMAIN as DOMAIN_CONST,
|
DOMAIN as DOMAIN_CONST,
|
||||||
SERVICE_COMPLETE_QUEST,
|
SERVICE_COMPLETE_QUEST,
|
||||||
)
|
)
|
||||||
|
from custom_components.nestquest.dashboard import (
|
||||||
|
DASHBOARD_STRATEGY_TYPE,
|
||||||
|
DASHBOARD_URL_PATH,
|
||||||
|
async_register_dashboard,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _wire(entry, registry):
|
def _wire(entry, registry):
|
||||||
return wire_entry_to_registry(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:
|
async def test_setup_stores_runtime_data_in_hass_data(hass, make_entry) -> None:
|
||||||
entry = _wire(make_entry(), hass.registry)
|
entry = _wire(make_entry(), hass.registry)
|
||||||
assert await async_setup_entry(hass, entry) is True
|
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 entry.runtime_data is None
|
||||||
assert DOMAIN_CONST not in hass.data
|
assert DOMAIN_CONST not in hass.data
|
||||||
assert hass.time_change.size == 0
|
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
|
||||||
|
|||||||
+729
-1
@@ -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
|
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
|
design/PANEL-EMPTY-STATES.md and PANEL-SPEC.md, and guard the kid-facing
|
||||||
bundles against ever gaining a parent-only service call.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).parent.parent
|
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"
|
PARTY_BOARD = FRONTEND_CARDS_DIR / "party-board.ts"
|
||||||
QUEST_LOG = FRONTEND_CARDS_DIR / "quest-log.ts"
|
QUEST_LOG = FRONTEND_CARDS_DIR / "quest-log.ts"
|
||||||
BUNDLE_PATH = REPO_ROOT / "custom_components" / "nestquest" / "www" / "nestquest-cards.js"
|
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)
|
PANEL_SOURCES = (PARTY_BOARD, QUEST_LOG)
|
||||||
|
|
||||||
@@ -38,11 +58,106 @@ ADMIN_ONLY_SERVICES = (
|
|||||||
WINDOW_KEYS = ("morning", "afternoon", "evening")
|
WINDOW_KEYS = ("morning", "afternoon", "evening")
|
||||||
WINDOW_NAMES = ("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:
|
def _read(path: Path) -> str:
|
||||||
return path.read_text(encoding="utf-8")
|
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:
|
def test_party_board_renders_present_and_away_plates() -> None:
|
||||||
source = _read(PARTY_BOARD)
|
source = _read(PARTY_BOARD)
|
||||||
assert "nestquest-party-board-card" in source
|
assert "nestquest-party-board-card" in source
|
||||||
@@ -146,6 +261,198 @@ def test_quest_complete_trigger() -> None:
|
|||||||
assert "complete_screen_seconds" in source
|
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:
|
def test_panel_cards_reference_no_admin_only_services() -> None:
|
||||||
# Only the kid panel sources are scanned: the bundle also contains the
|
# Only the kid panel sources are scanned: the bundle also contains the
|
||||||
# sibling admin card, so its parent-only service names are expected there.
|
# sibling admin card, so its parent-only service names are expected there.
|
||||||
@@ -158,3 +465,424 @@ def test_panel_cards_reference_no_admin_only_services() -> None:
|
|||||||
bundle = _read(BUNDLE_PATH)
|
bundle = _read(BUNDLE_PATH)
|
||||||
assert "nestquest-party-board-card" in bundle
|
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 datetime
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from homeassistant.util import slugify
|
||||||
|
|
||||||
from conftest import StubSnapshotClient, set_coordinator_client, wire_entry_to_registry
|
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.const import DOMAIN
|
||||||
from custom_components.nestquest.sensor import (
|
from custom_components.nestquest.sensor import (
|
||||||
MAX_STATE_LENGTH,
|
MAX_STATE_LENGTH,
|
||||||
|
_slugify,
|
||||||
_state_within_limit,
|
_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.name == "NestQuest cycle day"
|
||||||
assert cycle_day_entity.native_value == 4
|
assert cycle_day_entity.native_value == 4
|
||||||
assert cycle_day_entity.extra_state_attributes["today"] == _today_iso()
|
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" },
|
||||||
{ name = "pytest-asyncio" },
|
{ name = "pytest-asyncio" },
|
||||||
{ name = "pyyaml" },
|
{ name = "pyyaml" },
|
||||||
|
{ name = "unidecode" },
|
||||||
{ name = "uvicorn" },
|
{ name = "uvicorn" },
|
||||||
{ name = "voluptuous" },
|
{ name = "voluptuous" },
|
||||||
]
|
]
|
||||||
@@ -400,6 +401,7 @@ dev = [
|
|||||||
{ name = "pytest", specifier = ">=8.0.0" },
|
{ name = "pytest", specifier = ">=8.0.0" },
|
||||||
{ name = "pytest-asyncio", specifier = ">=0.24" },
|
{ name = "pytest-asyncio", specifier = ">=0.24" },
|
||||||
{ name = "pyyaml", specifier = ">=6" },
|
{ name = "pyyaml", specifier = ">=6" },
|
||||||
|
{ name = "unidecode", specifier = ">=1.4.0" },
|
||||||
{ name = "uvicorn", specifier = ">=0.30" },
|
{ name = "uvicorn", specifier = ">=0.30" },
|
||||||
{ name = "voluptuous", specifier = ">=0.14" },
|
{ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "uvicorn"
|
name = "uvicorn"
|
||||||
version = "0.53.0"
|
version = "0.53.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user