From 5339b647510cb50ba388c981ae676be9e68db27b Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Wed, 23 Sep 2026 11:32:49 -0400 Subject: [PATCH] Panel transitions: assert the motion contract and hard rules (#178) --- frontend/tests/render-quest-log.mjs | 36 ++- tests/test_panel_cards.py | 334 +++++++++++++++++++++++++++- 2 files changed, 357 insertions(+), 13 deletions(-) diff --git a/frontend/tests/render-quest-log.mjs b/frontend/tests/render-quest-log.mjs index bd039c6..f0ccdc9 100644 --- a/frontend/tests/render-quest-log.mjs +++ b/frontend/tests/render-quest-log.mjs @@ -3,12 +3,15 @@ * * Usage: node render-quest-log.mjs < spec.json * - * The spec JSON carries {bundle, url, config, hass, idle_ms?}; the card is - * mounted at the given URL (it resolves its child from the pathname's last - * segment), and after the optional idle wait stdout receives one JSON object - * {location, headline, html} — the window pathname and the rendered title. - * The Python panel tests drive this harness so the assertions run against - * the exact bundle HACS ships. + * The spec JSON carries {bundle, url, config, hass, idle_ms?, probe_ms?}; + * the card is mounted at the given URL (it resolves its child from the + * pathname's last segment), after the optional probe wait one snapshot of + * the render is taken mid-wait, then after the optional idle wait stdout + * receives one JSON object {location, headline, countdown, html, probe} — + * the final window pathname, the rendered title, the complete-screen + * countdown text, the rendered shadow DOM, and the probe snapshot when + * probe_ms was given. The Python panel tests drive this harness so the + * assertions run against the exact bundle HACS ships. */ import { readFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; @@ -46,14 +49,25 @@ element.hass = spec.hass; document.body.appendChild(element); await element.updateComplete; +const snapshot = () => ({ + location: window.location.pathname, + headline: element.shadowRoot.querySelector(".title")?.textContent?.trim() ?? "", + countdown: + element.shadowRoot.querySelector(".complete-countdown")?.textContent?.trim() ?? + "", + html: element.shadowRoot.innerHTML, +}); + +let probe = null; +if (spec.probe_ms !== undefined) { + await new Promise((resolve) => setTimeout(resolve, spec.probe_ms)); + probe = snapshot(); +} + if (spec.idle_ms !== undefined) { await new Promise((resolve) => setTimeout(resolve, spec.idle_ms)); } -const payload = JSON.stringify({ - location: window.location.pathname, - headline: element.shadowRoot.querySelector(".title")?.textContent?.trim() ?? "", - html: element.shadowRoot.innerHTML, -}); +const payload = JSON.stringify({ ...snapshot(), probe }); await new Promise((resolve) => process.stdout.write(payload, () => resolve())); process.exit(0); \ No newline at end of file diff --git a/tests/test_panel_cards.py b/tests/test_panel_cards.py index 95f2ba0..2b01162 100644 --- a/tests/test_panel_cards.py +++ b/tests/test_panel_cards.py @@ -12,10 +12,16 @@ 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 re import subprocess from pathlib import Path @@ -52,11 +58,106 @@ ADMIN_ONLY_SERVICES = ( 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 @@ -160,6 +261,198 @@ def test_quest_complete_trigger() -> None: 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", + ): + 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);") + == 3 + ) # quest card, Complete button, confirm buttons + 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_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"(? 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. @@ -470,14 +763,17 @@ def _render_quest_log( states: dict, url: str, idle_ms: int | None = None, + probe_ms: int | 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 idle window, and prints the pathname - and the rendered title. + 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). """ assert BUNDLE_PATH.is_file(), "bundle missing: run cd frontend && npm run build" assert QUEST_LOG_HARNESS.is_file() @@ -489,6 +785,8 @@ def _render_quest_log( } if idle_ms is not None: spec["idle_ms"] = idle_ms + if probe_ms is not None: + spec["probe_ms"] = probe_ms completed = subprocess.run( ["node", str(QUEST_LOG_HARNESS)], input=json.dumps(spec), @@ -556,3 +854,35 @@ def test_quest_log_idle_return_navigates_to_the_board() -> None: ) assert result["headline"] == "Ada's Quest Log" assert result["location"] == "/nestquest" + + +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"