Feature 20: Cards auto-discover children from the household roster (#174)

This commit was merged in pull request #174.
This commit is contained in:
2026-09-23 09:40:52 -04:00
parent 6059a67122
commit 316503cf73
8 changed files with 1296 additions and 365 deletions
+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."))