Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com> Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
1482 lines
58 KiB
Python
1482 lines
58 KiB
Python
"""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", "<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_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 <p>,
|
|
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"(?<![-\w]){prop}:\s*([^;]+);", body)
|
|
assert declared is not None, prop
|
|
value = _font_size_px(declared.group(1), sizes)
|
|
assert value is not None, f"{prop}: {declared.group(1)}"
|
|
return value
|
|
|
|
board, hint, dock = rules[".board"], rules[".hint"], rules[".dock"]
|
|
assert "margin: 0;" in hint
|
|
line_height = float(re.search(r"line-height:\s*([\d.]+);", hint).group(1))
|
|
hint_bottom = px(hint, "top") + px(hint, "font-size") * line_height
|
|
dock_top = px(board, "height") - px(dock, "bottom") - px(dock, "height")
|
|
assert px(dock, "height") == 84
|
|
assert px(dock, "bottom") == 46
|
|
assert hint_bottom + 12 <= dock_top, (hint_bottom, dock_top)
|
|
assert px(hint, "font-size") == 27
|
|
assert "font-weight: 600;" in hint
|
|
|
|
|
|
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:
|
|
# 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'<div class="wordmark-row">(.*?)</div>', 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 '<svg class="monogram" viewBox="0 0 100 100" aria-hidden="true"' in row_html
|
|
assert "var(--nq-brand-blue)" in row_html
|
|
assert "var(--nq-brand-purple)" in row_html
|
|
assert ">N</text>" in row_html
|
|
assert ">NestQuest</span>" in row_html
|
|
assert "d20" not in row_html
|
|
assert "<img" not in row_html
|
|
|
|
bundle = _read(BUNDLE_PATH)
|
|
assert "d20-numeral\">20<" not in bundle
|
|
assert "data:image/png" not in bundle
|
|
assert not re.search(r"https?://", bundle)
|
|
|
|
|
|
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,
|
|
tap_complete: int | None = None,
|
|
tap_return: bool = False,
|
|
now: str | None = None,
|
|
advance_to: list[str] | None = None,
|
|
tz: str | 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). ``now`` pins the card's clock to an ISO
|
|
instant, ``advance_to`` moves it through further instants firing the
|
|
card's clock tick (one snapshot each, under ``ticks``), 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 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
|
|
if tap_complete is not None:
|
|
spec["tap_complete"] = tap_complete
|
|
if tap_return:
|
|
spec["tap_return"] = True
|
|
if now is not None:
|
|
spec["now"] = now
|
|
if advance_to is not None:
|
|
spec["advance_to"] = advance_to
|
|
completed = subprocess.run(
|
|
["node", str(QUEST_LOG_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"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_log_return_control_navigates_to_the_board() -> None:
|
|
"""PANEL-SPEC §3: the Quest Log header carries an explicit return
|
|
control labelled for a child. Tapping it takes the same path as the
|
|
idle return — the strategy-computed board_path (the dashboard root) —
|
|
without waiting out the idle window."""
|
|
generated = _generate_dashboard(
|
|
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
|
|
)
|
|
result = _render_quest_log(
|
|
generated["views"][1]["cards"][0],
|
|
_three_child_states(),
|
|
url="http://homeassistant.local/nestquest/ada",
|
|
tap_return=True,
|
|
)
|
|
assert result["headline"] == "Ada's Quest Log"
|
|
assert result["returned"] == {"rendered": True, "label": "The Party"}
|
|
assert result["location"] == "/nestquest"
|
|
|
|
|
|
def test_quest_log_return_control_follows_the_panel_hard_rules() -> None:
|
|
"""The return control is a 72px button with :active feedback only,
|
|
lives in the header (never the dock), and reuses the idle return's
|
|
_returnToBoard navigation rather than a second mechanism."""
|
|
log = _read(QUEST_LOG)
|
|
style = _style_literal(log, STYLE_CONSTANTS[QUEST_LOG])
|
|
rules = {selector.strip(): body for selector, body in _css_rules(style)}
|
|
assert "height: var(--nq-p-button-height);" in rules["button.return"]
|
|
assert "transform: scale(0.98);" in rules["button.return:active"]
|
|
header = log[log.index("private _renderHeader") : log.index("private _renderColumn")]
|
|
assert 'class="return"' in header
|
|
assert "@click=${() => this._returnToBoard()}" in header
|
|
assert log.count("this._returnToBoard()") == 4
|
|
|
|
|
|
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"
|
|
|
|
|
|
def test_tapping_complete_confirms_and_calls_the_service() -> None:
|
|
"""Tapping an open quest's Complete opens the confirm dialog, and the
|
|
dialog's Complete calls nestquest.complete_quest with the panel actor.
|
|
With ``useDefineForClassFields`` on (the ES2022 default) the card's
|
|
reactive fields were emitted as native class fields that shadowed
|
|
Lit's accessors: ``_confirm`` changed but no re-render was scheduled,
|
|
so the dialog never opened and the tap did nothing."""
|
|
generated = _generate_dashboard(
|
|
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
|
|
)
|
|
open_quest = {
|
|
"id": 42,
|
|
"definition_id": 7,
|
|
"child_id": 1,
|
|
"title": "Brush Teeth",
|
|
"icon": None,
|
|
"window": "morning",
|
|
"due_time": "08:15",
|
|
"state": "open",
|
|
"overdue": True,
|
|
"completed_at": None,
|
|
"on_time": None,
|
|
}
|
|
states = {
|
|
**_three_child_states(),
|
|
"sensor.nestquest_ada_quests_due_today": _state(
|
|
"1",
|
|
child_name="Ada",
|
|
child_id=1,
|
|
present=True,
|
|
instances=[open_quest],
|
|
),
|
|
}
|
|
result = _render_quest_log(
|
|
generated["views"][1]["cards"][0],
|
|
states,
|
|
url="http://homeassistant.local/nestquest/ada",
|
|
tap_complete=open_quest["id"],
|
|
)
|
|
assert result["tap"]["dialog_opened"] is True
|
|
assert result["tap"]["service_calls"] == [
|
|
{
|
|
"domain": "nestquest",
|
|
"service": "complete_quest",
|
|
"data": {
|
|
"instance_id": open_quest["id"],
|
|
"actor": "panel",
|
|
"actor_child_id": open_quest["child_id"],
|
|
},
|
|
}
|
|
]
|
|
|
|
|
|
def test_card_build_keeps_lit_reactive_accessors() -> None:
|
|
"""The frontend build must not emit class fields that shadow Lit's
|
|
reactive property accessors (Lit's documented TypeScript setting)."""
|
|
tsconfig = json.loads((FRONTEND_DIR / "tsconfig.json").read_text())
|
|
assert tsconfig["compilerOptions"]["useDefineForClassFields"] is False
|
|
|
|
|
|
def test_quest_log_crest_keeps_its_shield_classes() -> None:
|
|
"""PANEL-SPEC §3 header crest: a present child's crest renders as the
|
|
styled shield, not a bare span. Lit removes an attribute outright when
|
|
any interpolated part of it is ``nothing``, so a class written as
|
|
``crest${away ? " away" : nothing}`` lost ``crest`` for every present
|
|
child and the header showed an unstyled sliver. The rendered markup
|
|
must carry ``class="crest"`` (present) and ``class="crest away"``
|
|
(away), each wrapping the face and initial."""
|
|
generated = _generate_dashboard(
|
|
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
|
|
)
|
|
log_config = generated["views"][1]["cards"][0]
|
|
for slug, crest_class in (("ada", "crest"), ("bo", "crest away")):
|
|
result = _render_quest_log(
|
|
log_config,
|
|
_three_child_states(),
|
|
url=f"http://homeassistant.local/nestquest/{slug}",
|
|
)
|
|
header = result["html"][result["html"].index('<header class="header">') :]
|
|
crest = re.match(
|
|
r'<header class="header">\s*(?:<!--[^>]*-->\s*)*'
|
|
r'(?:<button class="return"[^>]*>.*?</button>\s*(?:<!--[^>]*-->\s*)*)?'
|
|
r'<span ([^>]*)>\s*'
|
|
r'<span class="crest-face">\s*<span class="initial">',
|
|
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'<span class="quest-title">.*?</span>'
|
|
r"\s*(?:<!--[^>]*-->\s*)*<span([^>]*)>",
|
|
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'<span class="quest-title">.*?</span>'
|
|
r"\s*(?:<!--[^>]*-->\s*)*<span([^>]*)>(.*?)</span>",
|
|
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'<p class="kicker">The Party · (.*?)</p>.*?'
|
|
r'<span class="date">(.*?)</span>.*?<span class="clock">(.*?)</span>',
|
|
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'<p class="sub">\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('<div class="quest sealed"')
|
|
sealed_block = source[sealed_start : source.index("</div>", sealed_start)]
|
|
assert '<span class="tile" aria-hidden="true">${ICON_STAR}</span>' 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:]
|