Assert the panel motion contract and hard rules, not just build them
The confirm dialog (fade + scale .96->1 over --nq-dur-modal/--nq-ease-out), the seal (fade + scale 1.15->1 over --nq-dur-base), the quest-complete 12 s countdown return and the 40 s idle return already matched PANEL-SPEC, but nothing pinned them: a token or keyframe edit could silently drift the panel off the approved mockup timing. tests/test_panel_cards.py now asserts the entrance timing and easing, the byte-identical served token copy, and the section 1/6 hard rules (22px body floor, token-only inks, no hover states, no animation loops, inert dock strip, whole-plate tap target, control-free away plate), and the quest-log harness drives the 1c countdown return end-to-end against the bundle HACS ships.
This commit is contained in:
@@ -3,12 +3,15 @@
|
|||||||
*
|
*
|
||||||
* Usage: node render-quest-log.mjs < spec.json
|
* Usage: node render-quest-log.mjs < spec.json
|
||||||
*
|
*
|
||||||
* The spec JSON carries {bundle, url, config, hass, idle_ms?}; the card is
|
* The spec JSON carries {bundle, url, config, hass, idle_ms?, probe_ms?};
|
||||||
* mounted at the given URL (it resolves its child from the pathname's last
|
* the card is mounted at the given URL (it resolves its child from the
|
||||||
* segment), and after the optional idle wait stdout receives one JSON object
|
* pathname's last segment), after the optional probe wait one snapshot of
|
||||||
* {location, headline, html} — the window pathname and the rendered title.
|
* the render is taken mid-wait, then after the optional idle wait stdout
|
||||||
* The Python panel tests drive this harness so the assertions run against
|
* receives one JSON object {location, headline, countdown, html, probe} —
|
||||||
* the exact bundle HACS ships.
|
* 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 { readFileSync } from "node:fs";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
@@ -46,14 +49,25 @@ element.hass = spec.hass;
|
|||||||
document.body.appendChild(element);
|
document.body.appendChild(element);
|
||||||
await element.updateComplete;
|
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) {
|
if (spec.idle_ms !== undefined) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, spec.idle_ms));
|
await new Promise((resolve) => setTimeout(resolve, spec.idle_ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = JSON.stringify({
|
const payload = JSON.stringify({ ...snapshot(), probe });
|
||||||
location: window.location.pathname,
|
|
||||||
headline: element.shadowRoot.querySelector(".title")?.textContent?.trim() ?? "",
|
|
||||||
html: element.shadowRoot.innerHTML,
|
|
||||||
});
|
|
||||||
await new Promise((resolve) => process.stdout.write(payload, () => resolve()));
|
await new Promise((resolve) => process.stdout.write(payload, () => resolve()));
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
+332
-2
@@ -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
|
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
|
quest log's idle return navigates — so the zero-config discovery and
|
||||||
navigation contracts are asserted against the bundle HACS ships.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -52,11 +58,106 @@ ADMIN_ONLY_SERVICES = (
|
|||||||
WINDOW_KEYS = ("morning", "afternoon", "evening")
|
WINDOW_KEYS = ("morning", "afternoon", "evening")
|
||||||
WINDOW_NAMES = ("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:
|
def _read(path: Path) -> str:
|
||||||
return path.read_text(encoding="utf-8")
|
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:
|
def test_party_board_renders_present_and_away_plates() -> None:
|
||||||
source = _read(PARTY_BOARD)
|
source = _read(PARTY_BOARD)
|
||||||
assert "nestquest-party-board-card" in source
|
assert "nestquest-party-board-card" in source
|
||||||
@@ -160,6 +261,198 @@ def test_quest_complete_trigger() -> None:
|
|||||||
assert "complete_screen_seconds" 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",
|
||||||
|
):
|
||||||
|
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", "<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_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:
|
def test_panel_cards_reference_no_admin_only_services() -> None:
|
||||||
# Only the kid panel sources are scanned: the bundle also contains the
|
# Only the kid panel sources are scanned: the bundle also contains the
|
||||||
# sibling admin card, so its parent-only service names are expected there.
|
# sibling admin card, so its parent-only service names are expected there.
|
||||||
@@ -470,14 +763,17 @@ def _render_quest_log(
|
|||||||
states: dict,
|
states: dict,
|
||||||
url: str,
|
url: str,
|
||||||
idle_ms: int | None = None,
|
idle_ms: int | None = None,
|
||||||
|
probe_ms: int | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Render the built bundle's quest log and report its location.
|
"""Render the built bundle's quest log and report its location.
|
||||||
|
|
||||||
Drives frontend/tests/render-quest-log.mjs: the harness imports the
|
Drives frontend/tests/render-quest-log.mjs: the harness imports the
|
||||||
committed bundle (the artifact HACS ships) into a jsdom window at
|
committed bundle (the artifact HACS ships) into a jsdom window at
|
||||||
``url`` — the card resolves its child from the pathname's last
|
``url`` — the card resolves its child from the pathname's last
|
||||||
segment — waits the optional idle window, and prints the pathname
|
segment — waits the optional probe window and captures one snapshot
|
||||||
and the rendered title.
|
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 BUNDLE_PATH.is_file(), "bundle missing: run cd frontend && npm run build"
|
||||||
assert QUEST_LOG_HARNESS.is_file()
|
assert QUEST_LOG_HARNESS.is_file()
|
||||||
@@ -489,6 +785,8 @@ def _render_quest_log(
|
|||||||
}
|
}
|
||||||
if idle_ms is not None:
|
if idle_ms is not None:
|
||||||
spec["idle_ms"] = idle_ms
|
spec["idle_ms"] = idle_ms
|
||||||
|
if probe_ms is not None:
|
||||||
|
spec["probe_ms"] = probe_ms
|
||||||
completed = subprocess.run(
|
completed = subprocess.run(
|
||||||
["node", str(QUEST_LOG_HARNESS)],
|
["node", str(QUEST_LOG_HARNESS)],
|
||||||
input=json.dumps(spec),
|
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["headline"] == "Ada's Quest Log"
|
||||||
assert result["location"] == "/nestquest"
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user