"""Source-level tests for the kid panel cards. The panel cards are Lit/TypeScript modules bundled by Vite; these tests assert the contract by reading the card sources and the built bundle, in the same spirit as test_frontend.py. They cover the states decided in design/PANEL-EMPTY-STATES.md and PANEL-SPEC.md, and guard the kid-facing bundles against ever gaining a parent-only service call. The cards additionally have render tests: they drive the jsdom harnesses in frontend/tests — render-party-board.mjs imports the built bundle into a jsdom window and reports the ordered plates the shadow DOM renders plus where a crest tap navigates, and render-quest-log.mjs reports where the quest log's idle return navigates — so the zero-config discovery and navigation contracts are asserted against the bundle HACS ships. The motion contract (PANEL-SPEC §3.3/§3.5/§4/§5 entrances and timings) and the §1/§6 hard rules are asserted on the card sources in the motion section below, and the quest-complete screen's countdown return is driven end-to-end through the quest-log harness. """ from __future__ import annotations import json import os import re import subprocess from pathlib import Path REPO_ROOT = Path(__file__).parent.parent FRONTEND_DIR = REPO_ROOT / "frontend" FRONTEND_CARDS_DIR = FRONTEND_DIR / "src" / "cards" PARTY_BOARD = FRONTEND_CARDS_DIR / "party-board.ts" QUEST_LOG = FRONTEND_CARDS_DIR / "quest-log.ts" BUNDLE_PATH = REPO_ROOT / "custom_components" / "nestquest" / "www" / "nestquest-cards.js" RENDER_HARNESS = FRONTEND_DIR / "tests" / "render-party-board.mjs" QUEST_LOG_HARNESS = FRONTEND_DIR / "tests" / "render-quest-log.mjs" STRATEGY_HARNESS = FRONTEND_DIR / "tests" / "render-strategy.mjs" STRATEGY_SOURCE = FRONTEND_DIR / "src" / "strategy.ts" PANEL_SOURCES = (PARTY_BOARD, QUEST_LOG) # Parent-only NestQuest services. The party board and quest log are the # kid-facing panel: they may complete a quest for the tapped child, and nothing # else. Any of these names appearing in a panel source or bundle means an admin # control has leaked into the kid panel. ADMIN_ONLY_SERVICES = ( "uncomplete_quest", "create_quest_definition", "update_quest_definition", "set_quest_definition_active", "set_presence_pattern", "create_presence_override", "delete_presence_override", "export_history_csv", "manage_child", "regenerate", ) WINDOW_KEYS = ("morning", "afternoon", "evening") WINDOW_NAMES = ("Morning", "Afternoon", "Evening") # The approved token pair: the bundle must serve the design copy # verbatim. The values below are what PANEL-SPEC times the panel with # (§3.3 seal entrance, §4 dialog entrance, §3.2 press feedback) and the # ink set §1 pins at >=4.5:1 on parchment. DESIGN_TOKENS = REPO_ROOT / "design" / "tokens" / "nestquest-panel-tokens.css" REWARD_TOKENS = ( REPO_ROOT / "custom_components" / "nestquest" / "www" / "nestquest-panel-tokens.css" ) MOTION_TOKENS = { "--nq-ease-out": "cubic-bezier(0, 0, .2, 1)", "--nq-dur-micro": "120ms", "--nq-dur-base": "200ms", "--nq-dur-modal": "350ms", } PANEL_INK_TOKENS = { "--nq-p-ink": "#2b1f14", "--nq-p-ink-secondary": "#5c452a", "--nq-p-ink-muted": "#6f6455", "--nq-p-ink-away": "#4d4433", "--nq-p-ink-late": "#8f1526", "--nq-p-dock-ink": "#f7efdb", "--nq-p-dock-ink-secondary": "#d8c9a6", } # Text colors the panel styles may use beyond the token inks: the # spec's own literals (§2.1 pill, §3.3 seal check, §4 dialog body), # glyphs on the brand gradient, gradient text fills (§2 wordmark, §5 # title), and the plate button inheriting its plate's ink. APPROVED_NON_TOKEN_INKS = { "inherit", "transparent", "#ffffff", "rgba(255, 235, 235, 0.95)", "#3f2f1c", "#146b36", "#157a3c", } #: Each card's styles live in one css template literal, keyed by the #: constant that holds it. STYLE_CONSTANTS = { PARTY_BOARD: "boardStyles", QUEST_LOG: "logStyles", } def _read(path: Path) -> str: return path.read_text(encoding="utf-8") def _style_literal(source: str, constant: str) -> str: """A card's css template literal — the styles between the backticks.""" marker = f"const {constant} = css`" start = source.index(marker) + len(marker) return source[start : source.index("`;", start)] def _keyframes_block(style: str, name: str) -> str: """A @keyframes rule's text, from its name through its to-block.""" start = style.index(f"@keyframes {name}") return style[start : style.index("}", style.index("to {", start))] def _css_rules(style: str) -> list[tuple[str, str]]: """(selector, declarations) pairs for every rule in a card's styles. Lit interpolations are blanked first so the clip-path/unsafeCSS braces cannot confuse the parse; @keyframes' from/to count as rules but carry no font-size, so the size checks skip them. """ flattened = re.sub(r"\$\{[^}]*\}", " ", style) return re.findall(r"([^{}]+)\{([^{}]*)\}", flattened) def _size_tokens(tokens: str) -> dict[str, int]: """Every px length token declared in the panel tokens.""" return { name: int(px) for name, px in re.findall(r"(--[a-z0-9-]+):\s*(\d+)px", tokens) } def _font_size_px(declaration: str, sizes: dict[str, int]) -> int | None: """A font-size value's pixel amount; None when it is neither a px length nor one of the panel size tokens.""" declaration = declaration.strip() if declaration.endswith("px"): return round(float(declaration[:-2])) match = re.fullmatch(r"var\((--[a-z0-9-]+)\)", declaration) if match and match.group(1) in sizes: return sizes[match.group(1)] return None def test_party_board_renders_present_and_away_plates() -> None: source = _read(PARTY_BOARD) assert "nestquest-party-board-card" in source # Present adventurers get the tappable crest plate. assert "button.plate" in source assert 'class="plate"' in source assert "Home today" in source assert "aria-label=\"Open ${plate.name}'s Quest Log\"" in source # Away adventurers get the dashed On travels plate, not a tappable one. assert ".plate.away" in source assert 'class="plate away"' in source assert "On travels" in source assert "next_present" in source assert "Returns ${formatDay(returnsDate" in source def test_quest_log_renders_three_window_columns() -> None: source = _read(QUEST_LOG) for key, name in zip(WINDOW_KEYS, WINDOW_NAMES): assert f'key: "{key}"' in source, key assert f'name: "{name}"' in source, name assert "grid-template-columns: repeat(3, 1fr)" in source assert 'class="column-head"' in source assert 'class="column-names"' in source assert 'class="column-range"' in source def test_empty_window_still_renders_its_column_head() -> None: """A window with no quests renders the head alone (PANEL-SPEC §3.1).""" source = _read(QUEST_LOG) column = source[source.index("private _renderColumn(windowDef: WindowDef)") :] column = column[: column.index("private _renderQuest(")] # The head is emitted unconditionally, before the stack is built. assert 'class="column-head"' in column assert "sealed.length}/${instances.length}" in column # There is no early return that would drop the head on an empty window. assert "instances.length === 0" not in column assert "return nothing" not in column def test_complete_quest_is_optimistic_with_rollback() -> None: source = _read(QUEST_LOG) assert "_optimistic" in source assert "new Date().toISOString()" in source assert ".set(instance.id, stampedAt)" in source # The overlay is applied to the rendered instance before the service answers. assert 'instance.state = "completed"' in source # A rejected call reverts the overlay and raises the parchment toast. assert "reverted.delete(instance.id)" in source assert "NestQuest could not set the seal just now. Please try again." in source assert "_showToast(" in source assert "await this._callCompleteQuest(instance)" in source assert '"nestquest", "complete_quest"' in source def test_idle_return_to_board() -> None: source = _read(QUEST_LOG) assert "idle_return_seconds" in source assert "_armIdle" in source assert "_clearIdle" in source assert "this._idleSeconds() * 1000" in source assert "board_path" in source assert "_returnToBoard" in source assert 'window.dispatchEvent(new Event("location-changed"))' in source def test_decided_empty_and_error_states_are_present() -> None: log = _read(QUEST_LOG) board = _read(PARTY_BOARD) for kind in ( "no-adventurer", "not-set-up", "unreachable", "away", "empty-day", "complete-day", ): assert f'kind: "{kind}"' in log, kind assert "PANEL-EMPTY-STATES" in log assert "No adventurer chosen" in log assert "NestQuest is not set up yet" in log assert "The records cannot be reached" in log assert "No quests today" in log assert 'role="status"' in log # The board distinguishes the same setup/unreachable cases at board level. assert "NestQuest is not set up yet" in board assert "The records cannot be reached" in board assert "BOARD_NOTICE_NOT_SET_UP" in board assert "BOARD_NOTICE_UNREACHABLE" in board def test_quest_complete_trigger() -> None: source = _read(QUEST_LOG) assert "binary_sensor.nestquest_${slug}_all_done" in source assert "nestquest_child_day_complete" in source assert "NESTQUEST_EVENT_TYPES" in source assert 'class="complete-screen"' in source assert "Quest complete" in source assert "Returning to The Party in" in source assert "_armCompleteTimer" 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", "button.return: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);") == 4 ) # quest card, Complete button, confirm buttons, header return control 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", " 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_party_board_hint_clears_the_dock() -> None: """PANEL-SPEC §2/§2.3: the hint line reads fully above the dock at 1920x1080. The hint's line box (top + font-size x line-height) must end above the dock's top edge (1080 - bottom inset - dock height) with a visible gap, at the 27px/600 design size. The hint is a

, so its user-agent 1em top margin must be reset or it shifts the box down by a whole font-size.""" style = _style_literal(_read(PARTY_BOARD), STYLE_CONSTANTS[PARTY_BOARD]) rules = {selector.strip(): body for selector, body in _css_rules(style)} sizes = _size_tokens(_read(REWARD_TOKENS)) def px(body: str, prop: str) -> float: declared = re.search(rf"(? 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"(? None: # Only the kid panel sources are scanned: the bundle also contains the # sibling admin card, so its parent-only service names are expected there. for path in PANEL_SOURCES: text = _read(path) for service in ADMIN_ONLY_SERVICES: assert service not in text, f"{path}: {service}" # The bundle is still built and carries both panel card custom elements. assert BUNDLE_PATH.is_file() bundle = _read(BUNDLE_PATH) assert "nestquest-party-board-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, now: str | None = None, tz: 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. ``now`` pins the card's clock to an ISO instant and ``tz`` sets the node process's ``TZ`` — the panel browser's local zone. """ 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 if now is not None: spec["now"] = now completed = subprocess.run( ["node", str(RENDER_HARNESS)], input=json.dumps(spec), capture_output=True, text=True, timeout=120, env={**os.environ, "TZ": tz} if tz is not None else None, ) assert completed.returncode == 0, ( f"render harness failed: {completed.stderr}" ) return json.loads(completed.stdout) def test_party_board_wordmark_row_is_the_monogram_lockup() -> None: """PANEL-SPEC §2 item 1: the wordmark row is rule · compass-rose monogram · NestQuest · rule. The monogram is an inline SVG filled with the brand gradient — the old d20 hexagon pair and its "20" numerals are gone, and nothing is fetched from a remote URL or embedded as a raster logo.""" source = _read(PARTY_BOARD) assert "d20" not in _style_literal(source, STYLE_CONSTANTS[PARTY_BOARD]) assert 'class="d20' not in source assert "HEXAGON_CLIP" not in source html = _render_party_board( {"type": "custom:nestquest-party-board-card"}, _three_child_states(), )["html"] row = re.search(r'

(.*?)
', html, re.DOTALL) assert row is not None row_html = row.group(1) classes = re.findall(r'<(?:span|svg) class="([^"]+)"', row_html) assert classes == ["rule", "monogram", "wordmark", "rule"] assert ']*)>\s*' r'\s*', header, re.DOTALL, ) assert crest is not None, header[:600] assert f'class="{crest_class}"' in crest.group(1), crest.group(1) def test_quest_log_meta_line_keeps_its_meta_class() -> None: """An open quest's meta line is styled by ``.quest .meta`` (and ``.quest .meta.late`` when overdue). Written as ``meta ${late ? "late" : nothing}``, Lit dropped the whole class attribute for every quest that was not late, leaving the due line unstyled. The rendered markup must carry ``class="meta"`` on time and ``class="meta late"`` overdue — overdue as the API snapshot says.""" generated = _generate_dashboard( {}, _three_child_states(), url="http://homeassistant.local/nestquest" ) def quest(instance_id: int, title: str, overdue: bool) -> dict: return { "id": instance_id, "definition_id": instance_id, "child_id": 1, "title": title, "icon": None, "window": "morning", "due_time": "08:15", "state": "open", "overdue": overdue, "completed_at": None, "on_time": None, } states = { **_three_child_states(), "sensor.nestquest_ada_quests_due_today": _state( "2", child_name="Ada", child_id=1, present=True, instances=[ quest(41, "Make Bed", overdue=False), quest(42, "Brush Teeth", overdue=True), ], ), } result = _render_quest_log( generated["views"][1]["cards"][0], states, url="http://homeassistant.local/nestquest/ada", ) metas = {} for card in result["html"].split('data-instance-id="')[1:]: meta = re.search( r'.*?' r"\s*(?:\s*)*]*)>", card, re.S, ) assert meta is not None, card[:800] metas[card[: card.index('"')]] = meta.group(1) assert set(metas) == {"41", "42"}, metas assert metas["41"].strip() == 'class="meta"', metas["41"] assert metas["42"].strip() == 'class="meta late"', metas["42"] def _quest_metas(html: str) -> dict[str, tuple[str, str]]: """Map each rendered open quest's instance id to its meta line's attributes and text.""" metas = {} for card in html.split('data-instance-id="')[1:]: meta = re.search( r'.*?' r"\s*(?:\s*)*]*)>(.*?)", card, re.S, ) assert meta is not None, card[:800] text = " ".join(re.sub(r"", "", meta.group(2)).split()) metas[card[: card.index('"')]] = (meta.group(1).strip(), text) return metas def test_quest_log_overdue_follows_the_api_snapshot_not_the_panel_clock() -> None: """The stored application timezone is the single source of local-time truth: the API computes each instance's ``overdue`` in that zone and the card renders exactly that, whatever the panel browser's own clock or zone says. Here the browser runs in Pacific/Auckland and its clock is pinned (and later ticked) to instants where every due time has, or has not yet, arrived on its local wall clock — the card must still show the snapshot's state: the ``overdue: true`` 08:15 quest reads OVERDUE even though the browser reads long before 08:15, and the ``overdue: false`` 07:00 quest stays due even though the browser reads long after 07:00.""" generated = _generate_dashboard( {}, _three_child_states(), url="http://homeassistant.local/nestquest" ) def quest(instance_id: int, title: str, due_time: str, overdue: bool) -> dict: return { "id": instance_id, "definition_id": instance_id, "child_id": 1, "title": title, "icon": None, "window": "morning", "due_time": due_time, "state": "open", "overdue": overdue, "completed_at": None, "on_time": None, } states = { **_three_child_states(), "sensor.nestquest_ada_quests_due_today": _state( "2", child_name="Ada", child_id=1, present=True, instances=[ quest(42, "Brush Teeth", "08:15", overdue=True), quest(43, "Feed Fish", "07:00", overdue=False), ], ), } # 2026-09-29T17:00Z is 06:00 NZDT on the 30th: before both due times. # 2026-09-29T23:00Z is 12:00 NZDT: after both. result = _render_quest_log( generated["views"][1]["cards"][0], states, url="http://homeassistant.local/nestquest/ada", now="2026-09-29T17:00:00Z", advance_to=["2026-09-29T23:00:00Z"], tz="Pacific/Auckland", ) overdue = ('class="meta late"', "Overdue · due 8:15 AM") due_by = ('class="meta"', "Due by 7:00 AM") before, (after,) = ( _quest_metas(result["html"]), [_quest_metas(tick["html"]) for tick in result["ticks"]], ) assert before == {"42": overdue, "43": due_by}, before assert after == {"42": overdue, "43": due_by}, after def test_panel_date_and_clock_follow_the_stored_application_timezone() -> None: """The stored application timezone is the single source of local-time truth for the panel's visible date and clock too: the household rollup publishes it (``timezone`` — the API's effective zone) and both cards format in it — the party board's kicker and dock date and clock, the quest log's header date — even though Home Assistant's zone and the panel browser's zone are both UTC. At 2026-09-29T23:30Z it is still Tuesday 11:30 PM in UTC but already Wednesday 12:30 PM in Pacific/Auckland (NZDT).""" generated = _generate_dashboard( {}, _three_child_states(), url="http://homeassistant.local/nestquest" ) states = _three_child_states() states["sensor.nestquest_household_quests_due_today"]["attributes"][ "timezone" ] = "Pacific/Auckland" now = "2026-09-29T23:30:00Z" def text(html: str) -> str: return re.sub(r"", "", html).replace("\u202f", " ") def board_date_and_clock(html: str) -> tuple[str, str, str]: found = re.search( r'

The Party · (.*?)

.*?' r'(.*?).*?(.*?)', text(html), re.DOTALL, ) assert found is not None, html return found.group(1), found.group(2), found.group(3) board = _render_party_board( generated["views"][0]["cards"][0], states, now=now, tz="UTC" )["html"] assert board_date_and_clock(board) == ( "Wednesday, Sep 30", "Wednesday, Sep 30", "12:30 PM", ) log = _render_quest_log( generated["views"][1]["cards"][0], states, url="http://homeassistant.local/nestquest/ada", now=now, tz="UTC", )["html"] sub = re.search(r'

\s*(.*?) ·', text(log), re.DOTALL) assert sub is not None, log assert sub.group(1) == "Wednesday, Sep 30" # With no zone stored the API publishes its host's own zone, so the # rollup always carries one; only a rollup without it (an API older # than the field) falls back to Home Assistant's zone (UTC here). del states["sensor.nestquest_household_quests_due_today"]["attributes"][ "timezone" ] fallback = _render_party_board( generated["views"][0]["cards"][0], states, now=now, tz="UTC" )["html"] assert board_date_and_clock(fallback) == ( "Tuesday, Sep 29", "Tuesday, Sep 29", "11:30 PM", ) def test_quest_log_crest_matches_the_spec_geometry() -> None: """PANEL-SPEC §3 header crest: 96x110 shield, 4px ring of rgba(255,255,255,.22) around the --nq-p-crest face, 44px/700 display initial in full-opacity white; away swaps only the face gradient. jsdom cannot lay out, so this pins the stylesheet the rendered test above shows is applied.""" source = QUEST_LOG.read_text(encoding="utf-8") def rule(selector: str) -> str: found = re.search( r"\n " + re.escape(selector) + r" \{(.*?)\n \}", source, re.DOTALL ) assert found is not None, selector return found.group(1) crest = rule(".crest") for decl in ( "width: 96px;", "height: 110px;", "padding: 4px;", "clip-path: ${unsafeCSS(SHIELD_CLIP)};", "background: rgba(255, 255, 255, 0.22);", ): assert decl in crest, decl face = rule(".crest-face") assert "background: var(--nq-p-crest);" in face assert "clip-path: ${unsafeCSS(SHIELD_CLIP)};" in face initial = rule(".crest .initial") for decl in ( "font-family: var(--nq-p-font-display);", "font-size: 44px;", "font-weight: 700;", "color: #ffffff;", ): assert decl in initial, decl assert "opacity" not in initial away = rule(".crest.away .crest-face") assert away.strip() == "background: var(--nq-p-crest-away);" # --- Quest card icons (definition icon on the open tile) --------------------- def _quest_row(instance_id: int, icon: str | None, state: str = "open") -> dict: """One ``instances`` attribute row for Ada's quests-due sensor.""" row = { "id": instance_id, "child_id": 1, "title": f"Quest {instance_id}", "icon": icon, "window": "morning", "due_time": "08:00", "state": state, "overdue": False, "completed_at": None, "on_time": None, } if state == "completed": row["completed_at"] = "2026-09-26T07:30:00+00:00" row["on_time"] = True return row def _quest_log_tiles(rows: list[dict]) -> dict[int, dict]: """Render Ada's quest log with ``rows`` and key its tiles by instance id.""" states = _three_child_states() states["sensor.nestquest_ada_quests_due_today"] = _state( str(len(rows)), child_name="Ada", present=True, instances=rows ) result = _render_quest_log( {"type": "custom:nestquest-quest-log-card"}, states, url="http://homeassistant.local/nestquest/ada", ) return {tile["id"]: tile for tile in result["tiles"]} def test_open_quest_tile_renders_the_definition_icon() -> None: """The open card's tile shows the definition's namespaced icon, drawn from the bundled registry: Lucide as a stroked svg, Font Awesome as a filled svg, an emoji as its text, and a legacy bare name as Lucide.""" tiles = _quest_log_tiles( [ _quest_row(1, "lucide:bed"), _quest_row(2, "fa:broom"), _quest_row(3, "emoji:🦊"), _quest_row(4, "bath"), ] ) assert tiles[1]["icon"] == "lucide:bed" assert tiles[1]["fill"] == "none" assert tiles[1]["paths"] > 0 assert tiles[2]["icon"] == "fa:broom" assert tiles[2]["fill"] == "currentColor" assert tiles[2]["paths"] > 0 assert tiles[3]["icon"] == "emoji:🦊" assert tiles[3]["text"] == "🦊" assert tiles[4]["icon"] == "lucide:bath" assert tiles[4]["fill"] == "none" for tile in tiles.values(): assert not tile["sealed"] assert not tile["star"] def test_open_quest_tile_falls_back_to_the_star() -> None: """An unknown name, an unknown namespace, or no icon at all keeps the open card's current star glyph.""" tiles = _quest_log_tiles( [ _quest_row(1, "lucide:not-a-real-icon"), _quest_row(2, "fa:not-a-real-icon"), _quest_row(3, "mdi:bed"), _quest_row(4, ""), _quest_row(5, None), ] ) assert set(tiles) == {1, 2, 3, 4, 5} for tile in tiles.values(): assert tile["star"] assert tile["icon"] is None def test_open_quest_tile_renders_any_free_solid_icon() -> None: """A Font Awesome Free Solid name outside the curated quick-pick list still renders its glyph from the shipped bundle; an unknown ``fa:`` name keeps the star.""" tiles = _quest_log_tiles( [ _quest_row(1, "fa:rocket"), _quest_row(2, "fa:mug-hot"), _quest_row(3, "fa:definitely-not-an-icon"), ] ) for instance_id, key in ((1, "fa:rocket"), (2, "fa:mug-hot")): assert tiles[instance_id]["icon"] == key assert tiles[instance_id]["fill"] == "currentColor" assert tiles[instance_id]["paths"] > 0 assert not tiles[instance_id]["star"] assert tiles[3]["star"] assert tiles[3]["icon"] is None def test_sealed_quest_tile_keeps_the_star() -> None: """A sealed card keeps its star tile (and its wax seal) whatever the definition's icon; only the open card shows the quest's icon.""" tiles = _quest_log_tiles( [ _quest_row(1, "lucide:bed", state="completed"), _quest_row(2, "fa:broom", state="completed"), _quest_row(3, "emoji:🦊", state="completed"), ] ) for tile in tiles.values(): assert tile["sealed"] assert tile["star"] assert tile["icon"] is None assert tile["text"] == "" def test_only_the_open_tile_calls_the_icon_resolver() -> None: source = _read(QUEST_LOG) sealed_start = source.index('

", sealed_start)] assert '' in sealed_block assert "renderQuestIcon" not in sealed_block assert ( source.count("renderQuestIcon(instance.icon, ICON_STAR)") == 1 ), "only the open card's tile resolves the definition icon" open_start = source.index('class="quest tappable"') assert "renderQuestIcon(instance.icon, ICON_STAR)" in source[open_start:]