Feature 20: Cards auto-discover children from the household roster (#174)
This commit was merged in pull request #174.
This commit is contained in:
+131
-2
@@ -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"]]
|
||||
@@ -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."))
|
||||
|
||||
Reference in New Issue
Block a user