Compare commits

...
Author SHA1 Message Date
overseer 9e9b8c5938 Discover the party board's children from the household roster (Feature 20)
The board rendered nothing until a parent hand-wrote child_order with
each child's slug — the DAKOS bring-up gap. The household rollup now
publishes an ordered child_roster attribute (snapshot sort_order, each
entry carrying the slug the way HA derives entity ids, so the card can
build sensor.nestquest_<slug>_...), and the card discovers its plates
from it when the config carries no child_order; a supplied child_order
still wins. The rollups' existing per-child children counts attribute
is unchanged. Render tests drive the committed bundle under jsdom, so
the discovery contract is asserted on the artifact HACS ships.
2026-09-23 09:35:42 -04:00
8 changed files with 1296 additions and 365 deletions
+66 -3
View File
@@ -21,6 +21,8 @@ missed instances, and ``admin_instances`` includes them (D-009).
"""
from __future__ import annotations
import re
import unicodedata
from collections.abc import Callable, Iterable
from typing import Any
@@ -41,13 +43,18 @@ from .coordinator import (
)
from .core.snapshot import instance_payload
#: Home Assistant's state machine refuses states longer than 255
#: HA's state machine refuses states longer than 255
#: characters (its hard recorder/state limit). These sensors' states
#: are numeric, but the guard is applied uniformly: a payload-bearing
#: sensor can never crash the state write, and later string-state
#: sensors reuse the same helper.
MAX_STATE_LENGTH = 255
#: Entity-id length cap the slug mirror applies: HA truncates the
#: entity_id (``sensor.`` + slug) at MAX_LENGTH_STATE_ENTITY_ID (64),
#: so the slugged object id alone may not exceed 64 - len("sensor.").
_MAX_SLUG_LENGTH = 64 - len("sensor.")
def _state_within_limit(state: Any) -> Any:
"""Return ``state`` unchanged, refusing strings over HA's limit.
@@ -98,6 +105,56 @@ def _next_open_quest(
)
def _slugify(text: str) -> str:
"""Slugify a display name the way Home Assistant derives entity ids.
Mirrors ``homeassistant.util.slugify`` (the unicode path HA runs
entity names through) for accented-Latin names: NFKD-normalize,
fold to ASCII (accents drop out with their combining marks),
lowercase, and turn every run of non-alphanumerics into one
underscore, capped at HA's entity-id length like the registry does.
The documented divergence from HA's vendored slugifier is unidecode:
HA transliterates any script to ASCII (so e.g. non-Latin names
survive), this mirror drops those characters instead, and a name
that then slugs to nothing falls through to HA's ``unknown``
sentinel — the same answer HA's wrapper gives. (The harness is
mock-only — no real HA to import the helper from — and entity ids
are registry identity, so the mirror is deliberate.)
"""
slug = unicodedata.normalize("NFKD", text)
slug = slug.encode("ascii", "ignore").decode("ascii")
slug = re.sub(r"[^a-z0-9]+", "_", slug.lower()).strip("_")
return (slug or "unknown")[:_MAX_SLUG_LENGTH]
def _child_roster(children: Iterable[ChildDaySnapshot]) -> list[dict[str, Any]]:
"""The ordered child roster the panel cards discover children from.
One entry per ACTIVE child in the snapshot's order (the API's
sort_order, then id): ``child_id``, ``name``, and the ``slug`` the
entity-id derivation produces for the child's name — the piece the
cards need to build ``sensor.nestquest_<slug>_...`` ids. Duplicate
display names get the registry's ``_2``/``_3`` suffix in snapshot
order, matching how HA resolves the collision at registration. The
rollups' ``children`` attribute (per-child counts) is unchanged and
carries a different shape on purpose.
"""
slugs: dict[str, int] = {}
roster: list[dict[str, Any]] = []
for child in children:
base = _slugify(child.child_name)
taken = slugs.get(base, 0) + 1
slugs[base] = taken
roster.append(
{
"child_id": child.child_id,
"name": child.child_name,
"slug": base if taken == 1 else f"{base}_{taken}",
}
)
return roster
class _NestQuestChildDaySensor(CoordinatorEntity, SensorEntity):
"""Base for the per-child day sensors.
@@ -356,7 +413,12 @@ class _NestQuestHouseholdSensor(CoordinatorEntity, SensorEntity):
class NestQuestHouseholdDueTodaySensor(_NestQuestHouseholdSensor):
"""Total quests due today across active children."""
"""Total quests due today across active children.
Also publishes ``child_roster`` (see :func:`_child_roster`): the
ordered child identity list the panel cards read to discover the
board's children when the card config carries no ``child_order``.
"""
_attr_name = "NestQuest household quests due today"
_attr_state_class = SensorStateClass.MEASUREMENT
@@ -380,7 +442,8 @@ class NestQuestHouseholdDueTodaySensor(_NestQuestHouseholdSensor):
"children": {
str(child.child_id): child.due_today
for child in data.children
}
},
"child_roster": _child_roster(data.children),
}
File diff suppressed because it is too large Load Diff
+522
View File
@@ -9,10 +9,197 @@
"lit": "^3.3.0"
},
"devDependencies": {
"jsdom": "^30.1.1",
"typescript": "^5.9.2",
"vite": "^7.1.5"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.0.1.tgz",
"integrity": "sha512-C9duntabagkBZ1LebM7FKmphR4Q1pBclLxVbZETQV0akkFjV0ooFxo8FlvAQyKj9F6l8rEnmidgcTlpyxFYizg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@csstools/css-calc": "^3.4.0",
"@csstools/css-color-parser": "^4.2.3",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.1",
"lru-cache": "^11.5.3"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "9.2.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.1.tgz",
"integrity": "sha512-NT4s3yZLjovPpliRpTvdsdzyPjqRqiCZj9MxnarBihbaY5MbAG7DWAJcrLlhmC7xKTNCXsTELcqB8YsqpLnUFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"bidi-js": "^1.1.0",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.5.3"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}
},
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"css-tree": "^3.0.0"
},
"bin": {
"specificity": "bin/cli.js"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz",
"integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@csstools/css-calc": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.0.tgz",
"integrity": "sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.3.tgz",
"integrity": "sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^6.1.1",
"@csstools/css-calc": "^3.4.0"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz",
"integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"peerDependencies": {
"css-tree": "^3.2.1"
},
"peerDependenciesMeta": {
"css-tree": {
"optional": true
}
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.1.tgz",
"integrity": "sha512-bPlN9S9O1A0euCpEWE4qnvB5YDuyYVsUTrxSgmAM1Is0j4tICHoVyOVAXfWMP/kS9ZrjvyIXWV2PmomiAXXqOw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
@@ -455,6 +642,24 @@
"node": ">=18"
}
},
"node_modules/@exodus/bytes": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz",
"integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@noble/hashes": "^1.8.0 || ^2.0.0"
},
"peerDependenciesMeta": {
"@noble/hashes": {
"optional": true
}
}
},
"node_modules/@lit-labs/ssr-dom-shim": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.6.0.tgz",
@@ -892,6 +1097,79 @@
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT"
},
"node_modules/bidi-js": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
"integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
"dev": true,
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.27.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/data-urls/node_modules/whatwg-url": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.11.0",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
"license": "MIT"
},
"node_modules/entities": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
"integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/esbuild": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
@@ -967,6 +1245,66 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/html-encoding-sniffer": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz",
"integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.15.1"
},
"engines": {
"node": "^22.13.0 || >=24.0.0"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"license": "MIT"
},
"node_modules/jsdom": {
"version": "30.1.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.1.tgz",
"integrity": "sha512-FahmoPK5vbPc+jxV1iErMHmAZypCZ942NHF4+qqaWAuvaKKTBZxawnmAtrbGWLU7MtlxfqIP0qw6aSI+aWGtLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^7.0.0",
"@asamuzakjp/dom-selector": "^9.2.1",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.13",
"@exodus/bytes": "^1.15.1",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^7.0.0",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.5.2",
"parse5": "^8.0.1",
"saxes": "^6.0.0",
"tough-cookie": "^6.0.2",
"undici": "^8.10.2",
"w3c-xmlserializer": "^6.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^17.1.1",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
"peerDependencies": {
"canvas": "^3.2.3"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/lit": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/lit/-/lit-3.3.3.tgz",
@@ -998,6 +1336,23 @@
"@types/trusted-types": "^2.0.2"
}
},
"node_modules/lru-cache": {
"version": "11.5.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/nanoid": {
"version": "3.3.19",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
@@ -1017,6 +1372,19 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^8.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1066,6 +1434,26 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/rollup": {
"version": "4.63.4",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.4.tgz",
@@ -1112,6 +1500,19 @@
"fsevents": "~2.3.2"
}
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1139,6 +1540,52 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tldts": {
"version": "7.4.14",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.14.tgz",
"integrity": "sha512-EahQoi+Q5oqmG3yxRHx+bwn36OaLbtw6jnTFcGjc8fcmktzTgk45xRnzAx8Ch87PFEiqRhoc3FWBmY+LBrvEGQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^7.4.14"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.4.14",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.14.tgz",
"integrity": "sha512-KYkjfJHnIC5t+Gy7hPrMHgJmWc/N9FfmS+fggRPSlpFckidpB9HD6OzPsSTkfDH+MpZgcu2tJPGgLfHl979N1Q==",
"dev": true,
"license": "MIT"
},
"node_modules/tough-cookie": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
"integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^7.0.5"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -1153,6 +1600,16 @@
"node": ">=14.17"
}
},
"node_modules/undici": {
"version": "8.11.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.11.0.tgz",
"integrity": "sha512-AdzuGcAkzhbha3GgCSoUBx2P0quwym6qdFNCQz/fDGZc8w9XJ741wHUbt2kTmfPz+ZIOMkySFkiY7RXmiDDC8w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/vite": {
"version": "7.3.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
@@ -1227,6 +1684,71 @@
"optional": true
}
}
},
"node_modules/w3c-xmlserializer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz",
"integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}
},
"node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-url": {
"version": "17.1.2",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.2.tgz",
"integrity": "sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.15.1",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^22.14.0 || >=24.0.0"
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
}
}
}
+1
View File
@@ -9,6 +9,7 @@
"lit": "^3.3.0"
},
"devDependencies": {
"jsdom": "^30.1.1",
"typescript": "^5.9.2",
"vite": "^7.1.5"
}
+36 -7
View File
@@ -143,6 +143,16 @@ function asNumber(value: unknown, fallback: number): number {
return Number.isFinite(parsed) ? parsed : fallback;
}
/** The slug a discovered roster entry carries, or "" when the entry is
* not an object with a non-empty slug string. */
function asRosterSlug(entry: unknown): string {
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
return "";
}
const slug = (entry as Record<string, unknown>).slug;
return typeof slug === "string" ? slug.trim() : "";
}
function clampPercent(value: number): number {
if (!Number.isFinite(value)) {
return 0;
@@ -892,15 +902,34 @@ export class NestQuestPartyBoardCard extends LitElement {
return [];
}
const order = this._config.child_order;
if (!Array.isArray(order)) {
if (Array.isArray(order)) {
return order
.filter(
(entry): entry is string =>
typeof entry === "string" && entry.trim().length > 0
)
.map((entry) => entry.trim());
}
return this._discoveredChildSlugs();
}
/** Zero-config discovery (Feature 20): the ordered child roster the
* integration publishes on the household rollup
* (custom_components/nestquest/sensor.py — ``child_roster``, the
* snapshot's sort_order, then id), each entry carrying the slug the
* per-child entity ids are built from. Consulted only when the
* config has no child_order — explicit configuration wins. */
private _discoveredChildSlugs(): string[] {
const household = this._state(
"sensor.nestquest_household_quests_due_today"
);
const roster = household?.attributes?.child_roster;
if (!Array.isArray(roster)) {
return [];
}
return order
.filter(
(entry): entry is string =>
typeof entry === "string" && entry.trim().length > 0
)
.map((entry) => entry.trim());
return roster
.map((entry) => asRosterSlug(entry))
.filter((slug) => slug.length > 0);
}
private _plates(): ChildPlate[] {
+56
View File
@@ -0,0 +1,56 @@
/**
* 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}; stdout receives one JSON
* object {plates, html} where plates is the ordered plate list the shadow DOM
* renders (name + present/away/unknown kind). The Python panel tests drive
* this harness so the assertions run against the exact bundle HACS ships.
*/
import { readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
import { JSDOM } from "jsdom";
const spec = JSON.parse(readFileSync(0, "utf8"));
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
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;
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 plates = [
...element.shadowRoot.querySelectorAll(".plates .plate"),
].map((plate) => ({
name: plate.querySelector(".name")?.textContent?.trim() ?? "",
kind: plate.classList.contains("unknown")
? "unknown"
: plate.classList.contains("away")
? "away"
: "present",
}));
const payload = JSON.stringify({ plates, html: element.shadowRoot.innerHTML });
await new Promise((resolve) => process.stdout.write(payload, () => resolve()));
process.exit(0);
+131 -2
View File
@@ -5,16 +5,25 @@ the contract by reading the card sources and the built bundle, in the same
spirit as test_frontend.py. They cover the states decided in
design/PANEL-EMPTY-STATES.md and PANEL-SPEC.md, and guard the kid-facing
bundles against ever gaining a parent-only service call.
The party board additionally has render tests: they drive
frontend/tests/render-party-board.mjs, which imports the built bundle into a
jsdom window and reports the ordered plates the shadow DOM renders, so the
zero-config discovery contract is asserted against the bundle HACS ships.
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
REPO_ROOT = Path(__file__).parent.parent
FRONTEND_CARDS_DIR = REPO_ROOT / "frontend" / "src" / "cards"
FRONTEND_DIR = REPO_ROOT / "frontend"
FRONTEND_CARDS_DIR = FRONTEND_DIR / "src" / "cards"
PARTY_BOARD = FRONTEND_CARDS_DIR / "party-board.ts"
QUEST_LOG = FRONTEND_CARDS_DIR / "quest-log.ts"
BUNDLE_PATH = REPO_ROOT / "custom_components" / "nestquest" / "www" / "nestquest-cards.js"
RENDER_HARNESS = FRONTEND_DIR / "tests" / "render-party-board.mjs"
PANEL_SOURCES = (PARTY_BOARD, QUEST_LOG)
@@ -157,4 +166,124 @@ def test_panel_cards_reference_no_admin_only_services() -> None:
assert BUNDLE_PATH.is_file()
bundle = _read(BUNDLE_PATH)
assert "nestquest-party-board-card" in bundle
assert "nestquest-quest-log-card" in bundle
assert "nestquest-quest-log-card" in bundle
def test_party_board_discovers_children_from_the_household_roster() -> None:
"""Feature 20: with no child_order configured, the board discovers
the ordered child slugs from the household rollup's child_roster
attribute; explicit configuration still wins."""
source = _read(PARTY_BOARD)
assert "child_roster" in source
assert "sensor.nestquest_household_quests_due_today" in source
# Discovery only fills in for a MISSING child_order; a supplied
# order array is honoured verbatim before the discovered list is
# ever consulted.
assert "Array.isArray(order)" in source
assert "_discoveredChildSlugs()" in source
# --- Render tests (built bundle under jsdom) ---------------------------------
#: The seeded three-child household, in the API's sort order: Ada and
#: Bo share one daily quest (Bo is away today), Cory has none.
ROSTER = [
{"child_id": 1, "name": "Ada", "slug": "ada"},
{"child_id": 2, "name": "Bo", "slug": "bo"},
{"child_id": 3, "name": "Cory", "slug": "cory"},
]
def _state(state: str, **attributes) -> dict:
return {"state": state, "attributes": attributes}
def _three_child_states() -> dict:
"""The hass states a three-child household exposes.
Entity ids follow the documented naming contract
(design/ENTITIES-AND-SERVICES.md §1): the household rollup and the
per-child sensors slugged from the child names.
"""
return {
"sensor.nestquest_household_quests_due_today": _state(
"2",
children={"1": 1, "2": 1, "3": 0},
child_roster=ROSTER,
),
"sensor.nestquest_ada_quests_due_today": _state(
"1", child_name="Ada", present=True
),
"sensor.nestquest_ada_quests_completed_today": _state("0"),
"sensor.nestquest_ada_completion_pct_today": _state("0"),
"binary_sensor.nestquest_ada_present_today": _state("on"),
"sensor.nestquest_bo_quests_due_today": _state(
"1", child_name="Bo", present=False
),
"sensor.nestquest_bo_quests_completed_today": _state("0"),
"sensor.nestquest_bo_completion_pct_today": _state("0"),
"binary_sensor.nestquest_bo_present_today": _state("off"),
"sensor.nestquest_cory_quests_due_today": _state(
"0", child_name="Cory", present=True
),
"sensor.nestquest_cory_quests_completed_today": _state("0"),
"sensor.nestquest_cory_completion_pct_today": _state("100"),
"binary_sensor.nestquest_cory_present_today": _state("on"),
}
def _render_party_board(config: dict, states: dict) -> 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, and prints
the ordered plates its shadow DOM renders.
"""
assert BUNDLE_PATH.is_file(), "bundle missing: run cd frontend && npm run build"
assert RENDER_HARNESS.is_file()
spec = {
"bundle": str(BUNDLE_PATH),
"tag": "nestquest-party-board-card",
"config": config,
"hass": {"config": {"time_zone": "UTC"}, "states": states},
}
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"]]
+113
View File
@@ -19,6 +19,7 @@ from custom_components.nestquest import async_setup_entry, async_unload_entry
from custom_components.nestquest.const import DOMAIN
from custom_components.nestquest.sensor import (
MAX_STATE_LENGTH,
_slugify,
_state_within_limit,
)
@@ -539,3 +540,115 @@ async def test_household_rollups_sum_the_active_children(
assert cycle_day_entity.name == "NestQuest cycle day"
assert cycle_day_entity.native_value == 4
assert cycle_day_entity.extra_state_attributes["today"] == _today_iso()
async def test_household_due_sensor_publishes_the_ordered_child_roster(
hass, make_entry
) -> None:
"""Feature 20: the rollup carries the ordered child roster beside
the unchanged per-child ``children`` counts, so the panel cards can
discover the board without a configured child_order. The order is
the coordinator snapshot's (the API's sort_order, then id)."""
entry, _coordinator, _client = await _setup_entry(
hass, make_entry, _seeded_payload()
)
attributes = hass.entities[
"nestquest_household_quests_due_today"
].extra_state_attributes
assert attributes["child_roster"] == [
{"child_id": ADA, "name": "Ada", "slug": "ada"},
{"child_id": BO, "name": "Bo", "slug": "bo"},
{"child_id": CORY, "name": "Cory", "slug": "cory"},
]
# The pre-existing per-child counts attribute is untouched.
assert attributes["children"] == {
str(ADA): 1,
str(BO): 1,
str(CORY): 0,
}
async def test_household_roster_tracks_the_snapshot_on_refresh(
hass, make_entry
) -> None:
"""The roster re-derives from every snapshot: a reorder and a
rename in the API payload show up (with the re-derived slug) on the
next refresh, like the sensor names already do."""
reordered = _payload(
[
_child(CORY, "Cory"),
_child(ADA, "Ada", [_instance(7, ADA)]),
_child(BO, "Mirren", [_instance(8, BO)]),
]
)
entry, coordinator, _client = await _setup_entry(
hass, make_entry, _seeded_payload(), reordered
)
roster_entity = hass.entities["nestquest_household_quests_due_today"]
assert [entry["slug"] for entry in roster_entity.extra_state_attributes["child_roster"]] == [
"ada",
"bo",
"cory",
]
await coordinator.async_refresh()
assert roster_entity.extra_state_attributes["child_roster"] == [
{"child_id": CORY, "name": "Cory", "slug": "cory"},
{"child_id": ADA, "name": "Ada", "slug": "ada"},
{"child_id": BO, "name": "Mirren", "slug": "mirren"},
]
async def test_duplicate_display_names_get_registry_suffix_slugs(
hass, make_entry
) -> None:
"""Two children may share a nickname (the children layer allows
it); the roster's slugs then mirror the registry's collision
suffixing (_2, _3, ...) in snapshot order, so both plates resolve."""
payload = _payload(
[
_child(ADA, "Ada", [_instance(7, ADA)]),
_child(BO, "Ada"),
]
)
entry, _coordinator, _client = await _setup_entry(hass, make_entry, payload)
assert hass.entities[
"nestquest_household_quests_due_today"
].extra_state_attributes["child_roster"] == [
{"child_id": ADA, "name": "Ada", "slug": "ada"},
{"child_id": BO, "name": "Ada", "slug": "ada_2"},
]
async def test_empty_household_publishes_an_empty_roster(
hass, make_entry
) -> None:
"""An empty household is a valid 0: the roster is an empty list,
and the card's discovery of it renders the not-set-up notice."""
entry, _coordinator, _client = await _setup_entry(
hass, make_entry, _payload([])
)
attributes = hass.entities[
"nestquest_household_quests_due_today"
].extra_state_attributes
assert attributes["child_roster"] == []
assert attributes["children"] == {}
def test_slugify_mirrors_home_assistant_entity_id_derivation() -> None:
"""The slug mirror matches homeassistant.util.slugify for the names
children are registered with (verified against HA's own slugify)."""
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."))