Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ff41c02bd | ||
|
|
5339b64751 | ||
|
|
881f48de43 | ||
|
|
6d5a730cc7 |
@@ -159,9 +159,50 @@ Sensors the automations read: `sensor.nestquest_<child>_quests_due_today`
|
||||
|
||||
Run these steps in order to display the NestQuest panel on a TouchHub
|
||||
wall device. The panel is an HA Lovelace dashboard, so the kiosk browser
|
||||
is simply a Home Assistant client.
|
||||
is simply a Home Assistant client. The path below is zero-config: the
|
||||
integration registers its panel dashboard itself on setup, so no YAML
|
||||
and no hand-built views are involved.
|
||||
|
||||
1. **Create a dedicated kiosk user.** In *Settings → People → Users*, add
|
||||
1. **Install the integration and add the config entry.** Install
|
||||
NestQuest via HACS or manually (see Installation above) and restart
|
||||
Home Assistant. Then, in *Settings → Devices & Services*, click
|
||||
**Add Integration**, search for **NestQuest**, and submit the flow —
|
||||
it has no fields; it just creates the single config entry. Then open
|
||||
the entry, click **Configure**, and fill the panel-plane API
|
||||
connection in the options flow:
|
||||
|
||||
- **NestQuest API base URL** — the full `http://host:port` URL of
|
||||
your NestQuest API service (default `http://127.0.0.1:8000`).
|
||||
- **Panel service token** — the token the panel presents to the API
|
||||
service as its Bearer credential. It may stay empty until you have
|
||||
one; a pasted token must be unpadded.
|
||||
|
||||
The form's remaining fields (panel idle timeout, coordinator update
|
||||
interval, last-good snapshot staleness) have defaults that serve the
|
||||
panel and need no change here.
|
||||
|
||||
2. **The panel dashboard appears automatically.** On setup the
|
||||
integration registers one storage-mode Lovelace dashboard: a sidebar
|
||||
entry titled **NestQuest** at the URL path `/nestquest`, whose stored
|
||||
config is nothing but the **`custom:nestquest-party` strategy**
|
||||
(`strategy: { type: custom:nestquest-party }`). The strategy
|
||||
generates the views at render time from the household roster:
|
||||
|
||||
- **The Party** — the party-board view at `/nestquest/party`; the
|
||||
dashboard root `/nestquest` shows it as the first view.
|
||||
- one quest-log view per child at `/nestquest/<child-slug>`
|
||||
("Ada's Quest Log", …), in roster order.
|
||||
|
||||
Nothing is hand-configured: no YAML, no manual views, no
|
||||
`child_order`, no `quest_log_path`/`board_path` — the board and the
|
||||
quest logs compute their navigation paths from the dashboard's own
|
||||
URL — and every generated view is already in **Panel** layout (see
|
||||
the two constraints below). If the Lovelace surfaces are unavailable
|
||||
(for example when Lovelace runs in YAML mode), setup logs a warning
|
||||
and skips the registration; create the dashboard manually with the
|
||||
same strategy as described in the manual alternative below.
|
||||
|
||||
3. **Create a dedicated kiosk user.** In *Settings → People → Users*, add
|
||||
a new user for the wall device. Make it **non-admin**, and ensure it is
|
||||
not in the NestQuest admin allowlist. The allowlist blocks the NestQuest
|
||||
**admin** operations — `uncomplete_quest`, `create_quest_definition`,
|
||||
@@ -174,81 +215,101 @@ is simply a Home Assistant client.
|
||||
Assistant user it may also reach other permitted Home Assistant entities
|
||||
and services.
|
||||
|
||||
2. **Build a dashboard with only the NestQuest panel cards.** The
|
||||
recommended path is the **zero-config dashboard strategy**: create a
|
||||
Lovelace dashboard whose URL slug is `nestquest` with
|
||||
4. **Open the dashboard and point TouchHub at it.** Open
|
||||
`http://<home-assistant>/nestquest` in a browser first and check the
|
||||
party board renders with your children's crests. Then, in the
|
||||
TouchHub launcher, add the **Home Assistant Lovelace** app to the
|
||||
dock and paste the dashboard URL from step 2 — the dashboard root,
|
||||
`/nestquest` (TouchHub loads that URL directly as the kiosk landing
|
||||
page). Set TouchHub **Auto-Return** to a value GREATER than the
|
||||
quest log's `idle_return_seconds` (default 40s) — recommend **60s or
|
||||
higher**. If Auto-Return is at or below 40s, TouchHub returns to its
|
||||
launcher before the quest log's idle return can navigate back to the
|
||||
party board.
|
||||
|
||||
```yaml
|
||||
strategy:
|
||||
type: custom:nestquest-party
|
||||
```
|
||||
|
||||
and no views of your own — the strategy generates the party-board and
|
||||
quest-log views (each already in Panel layout) from the household
|
||||
roster at render time. Optional `url_path` and `weather_entity` keys
|
||||
may sit beside `strategy:`. The manual instructions below describe
|
||||
building the two views by hand if you prefer not to use the strategy.
|
||||
|
||||
**Manual alternative:** create a Lovelace dashboard whose **URL slug
|
||||
is `nestquest`** (Settings →
|
||||
Dashboards → Edit → URL slug). A functional panel needs **two** views
|
||||
in that dashboard, each with its own card config from
|
||||
`design/ENTITIES-AND-SERVICES.md` §4. Give the views these exact URL
|
||||
paths (the path segment after the dashboard slug):
|
||||
|
||||
- **View 1 path `board`** (URL `/nestquest/board`) — party board
|
||||
(`type: custom:nestquest-party-board-card`): the household quest
|
||||
board. Set `weather_entity` (optional; the dock falls back to
|
||||
date/time), `quest_log_path: /nestquest/log` (the view the crest tap
|
||||
navigates to), and `child_order: [slug1, slug2, slug3]` listing each
|
||||
child's slug in display order.
|
||||
|
||||
- **View 2 path `log`** (URL `/nestquest/log`) — quest log
|
||||
(`type: custom:nestquest-quest-log-card`): the per-child quest screen
|
||||
the party board hops to. Set `board_path: /nestquest/board` (the
|
||||
party-board view to return to), `weather_entity`,
|
||||
`idle_return_seconds` (must be shorter than TouchHub Auto-Return),
|
||||
`confirm_timeout_seconds`, and `complete_screen_seconds`. The quest
|
||||
log reads the selected child's slug from the view's URL path, so the
|
||||
board → log hop is a plain Lovelace navigation.
|
||||
|
||||
Each view's layout MUST be set to **Panel** (Edit Dashboard → pencil on the
|
||||
view → View type: Panel). Home Assistant's default Masonry layout constrains
|
||||
the 1080px-tall NestQuest card to a narrow column; Panel (single-card)
|
||||
layout is what renders the card full-screen at 1920×1080.
|
||||
|
||||
The dashboard contains only the NestQuest panel card for each view —
|
||||
no other cards, no admin controls.
|
||||
|
||||
The `quest_log_path` and `board_path` values must match the dashboard
|
||||
slug and view paths above: `/<slug>/<view>`. If you chose `nestquest`
|
||||
as the dashboard slug with view paths `board` and `log`, the card
|
||||
configs are already correct. If you pick different names, substitute
|
||||
your actual paths in **both** card configs — e.g. a dashboard slug
|
||||
`chores` with view paths `main` and `history` needs
|
||||
`quest_log_path: /chores/history` and `board_path: /chores/main`.
|
||||
Navigation between the two views fails (the crest tap or back button
|
||||
lands on a 404) if the card config paths do not match the dashboard
|
||||
slug and view paths exactly.
|
||||
|
||||
See `design/ENTITIES-AND-SERVICES.md` §4 for the full card YAML.
|
||||
|
||||
3. **Point TouchHub at it.** In the TouchHub launcher, add the **Home
|
||||
Assistant Lovelace** app to the dock and paste the dashboard URL from
|
||||
step 2 — the party-board view URL, e.g. `/nestquest/board` (TouchHub
|
||||
loads that URL directly as the kiosk landing page). Set TouchHub
|
||||
**Auto-Return** to a value GREATER than the quest log's
|
||||
`idle_return_seconds` (default 40s) — recommend **60s or higher**. If
|
||||
Auto-Return is at or below 40s, TouchHub returns to its launcher before
|
||||
the quest log's idle return can navigate back to the party board.
|
||||
|
||||
4. **Log the kiosk session in.** Sign in on the device as the dedicated
|
||||
kiosk user from step 1, and leave that session logged in.
|
||||
5. **Log the kiosk session in.** Sign in on the device as the dedicated
|
||||
kiosk user from step 3, and leave that session logged in.
|
||||
|
||||
TouchHub loads the dashboard URL directly. The NestQuest cards are normal
|
||||
Lovelace cards, not an iframe, so no Home Assistant framing setting is
|
||||
needed.
|
||||
|
||||
### The two constraints
|
||||
|
||||
- **Panel layout.** Every view must be in **Panel** (single-card)
|
||||
layout: Home Assistant's default Masonry layout constrains the
|
||||
1080px-tall NestQuest card to a narrow column, while Panel renders the
|
||||
card full-screen at 1920×1080. The zero-config path already satisfies
|
||||
the requirement — the strategy generates every view as
|
||||
`type: panel` — while the manual alternative below requires setting
|
||||
the layout by hand on each view (Edit Dashboard → pencil on the view
|
||||
→ View type: Panel).
|
||||
|
||||
- **TouchHub Auto-Return.** TouchHub's **Auto-Return** must be greater
|
||||
than the quest log's `idle_return_seconds` (default 40s) — recommend
|
||||
**60s or higher** — so the launcher does not reclaim the wall device
|
||||
before the quest log's own idle return navigates back to the party
|
||||
board.
|
||||
|
||||
### Manual alternative: build the dashboard by hand
|
||||
|
||||
If you prefer not to use the zero-config dashboard above — or its
|
||||
registration was skipped — configure a Lovelace dashboard whose **URL
|
||||
slug is `nestquest`** (Settings → Dashboards → Edit → URL slug)
|
||||
yourself. Two options, in decreasing order of convenience:
|
||||
|
||||
- **Use the strategy.** Give the dashboard
|
||||
|
||||
```yaml
|
||||
strategy:
|
||||
type: custom:nestquest-party
|
||||
```
|
||||
|
||||
and no views of your own — the strategy generates the party-board and
|
||||
quest-log views (each already in Panel layout) from the household
|
||||
roster at render time. Optional `url_path` and `weather_entity` keys
|
||||
may sit beside `strategy:`.
|
||||
|
||||
- **Build the two views by hand.** A functional panel needs **two**
|
||||
views in that dashboard, each with its own card config from
|
||||
`design/ENTITIES-AND-SERVICES.md` §4. Give the views these exact URL
|
||||
paths (the path segment after the dashboard slug):
|
||||
|
||||
- **View 1 path `board`** (URL `/nestquest/board`) — party board
|
||||
(`type: custom:nestquest-party-board-card`): the household quest
|
||||
board. Set `weather_entity` (optional; the dock falls back to
|
||||
date/time), `quest_log_path: /nestquest/log` (the view the crest tap
|
||||
navigates to), and `child_order: [slug1, slug2, slug3]` listing each
|
||||
child's slug in display order.
|
||||
|
||||
- **View 2 path `log`** (URL `/nestquest/log`) — quest log
|
||||
(`type: custom:nestquest-quest-log-card`): the per-child quest screen
|
||||
the party board hops to. Set `board_path: /nestquest/board` (the
|
||||
party-board view to return to), `weather_entity`,
|
||||
`idle_return_seconds` (must be shorter than TouchHub Auto-Return —
|
||||
see the two constraints above), `confirm_timeout_seconds`, and
|
||||
`complete_screen_seconds`. The quest log reads the selected child's
|
||||
slug from the view's URL path, so the board → log hop is a plain
|
||||
Lovelace navigation.
|
||||
|
||||
The dashboard contains only the NestQuest panel card for each view —
|
||||
no other cards, no admin controls. Each view's layout MUST be set to
|
||||
**Panel** — see the two constraints above for why; the strategy
|
||||
generates it for you, hand-built views do not get it for free.
|
||||
|
||||
The `quest_log_path` and `board_path` values must match the dashboard
|
||||
slug and view paths above: `/<slug>/<view>`. If you chose `nestquest`
|
||||
as the dashboard slug with view paths `board` and `log`, the card
|
||||
configs are already correct. If you pick different names, substitute
|
||||
your actual paths in **both** card configs — e.g. a dashboard slug
|
||||
`chores` with view paths `main` and `history` needs
|
||||
`quest_log_path: /chores/history` and `board_path: /chores/main`.
|
||||
Navigation between the two views fails (the crest tap or back button
|
||||
lands on a 404) if the card config paths do not match the dashboard
|
||||
slug and view paths exactly.
|
||||
|
||||
See `design/ENTITIES-AND-SERVICES.md` §4 for the full card YAML.
|
||||
|
||||
## Developer Setup and Testing
|
||||
|
||||
This project uses [`uv`](https://github.com/astral-sh/uv) for fast Python package and dependency management.
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
*
|
||||
* Usage: node render-party-board.mjs < spec.json
|
||||
*
|
||||
* The spec JSON carries {bundle, tag, config, hass}; stdout receives one JSON
|
||||
* object {plates, html} where plates is the ordered plate list the shadow DOM
|
||||
* renders (name + present/away/unknown kind). The Python panel tests drive
|
||||
* this harness so the assertions run against the exact bundle HACS ships.
|
||||
* The spec JSON carries {bundle, tag, config, hass, url?, click_plate?};
|
||||
* stdout receives one JSON object {plates, tappable, location, html} where
|
||||
* plates is the ordered plate list the shadow DOM renders (name +
|
||||
* present/away/unknown kind), tappable the plate names rendered as tappable
|
||||
* <button> plates, and location the window pathname after the optional plate
|
||||
* tap. 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";
|
||||
@@ -15,7 +18,7 @@ import { JSDOM } from "jsdom";
|
||||
const spec = JSON.parse(readFileSync(0, "utf8"));
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
||||
url: "http://homeassistant.local/nestquest/board",
|
||||
url: spec.url ?? "http://homeassistant.local/nestquest/board",
|
||||
pretendToBeVisual: true,
|
||||
});
|
||||
|
||||
@@ -31,6 +34,10 @@ for (const key of Object.getOwnPropertyNames(dom.window)) {
|
||||
}
|
||||
}
|
||||
globalThis.window = dom.window;
|
||||
// The card navigates by dispatching new Event("location-changed") on this
|
||||
// window; jsdom's EventTarget rejects Node's own global Event class, so the
|
||||
// jsdom Event must shadow it for the dispatched event to be delivered.
|
||||
globalThis.Event = dom.window.Event;
|
||||
|
||||
await import(pathToFileURL(spec.bundle).href);
|
||||
|
||||
@@ -40,9 +47,10 @@ element.hass = spec.hass;
|
||||
document.body.appendChild(element);
|
||||
await element.updateComplete;
|
||||
|
||||
const plates = [
|
||||
const plateNodes = [
|
||||
...element.shadowRoot.querySelectorAll(".plates .plate"),
|
||||
].map((plate) => ({
|
||||
];
|
||||
const plates = plateNodes.map((plate) => ({
|
||||
name: plate.querySelector(".name")?.textContent?.trim() ?? "",
|
||||
kind: plate.classList.contains("unknown")
|
||||
? "unknown"
|
||||
@@ -50,7 +58,23 @@ const plates = [
|
||||
? "away"
|
||||
: "present",
|
||||
}));
|
||||
const tappable = plateNodes
|
||||
.filter((plate) => plate.tagName === "BUTTON")
|
||||
.map((plate) => plate.querySelector(".name")?.textContent?.trim() ?? "");
|
||||
|
||||
const payload = JSON.stringify({ plates, html: element.shadowRoot.innerHTML });
|
||||
if (spec.click_plate !== undefined) {
|
||||
const plate = plateNodes.find(
|
||||
(candidate) =>
|
||||
candidate.querySelector(".name")?.textContent?.trim() === spec.click_plate
|
||||
);
|
||||
plate?.click();
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({
|
||||
plates,
|
||||
tappable,
|
||||
location: window.location.pathname,
|
||||
html: element.shadowRoot.innerHTML,
|
||||
});
|
||||
await new Promise((resolve) => process.stdout.write(payload, () => resolve()));
|
||||
process.exit(0);
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Render the built quest-log card under jsdom and report its location.
|
||||
*
|
||||
* Usage: node render-quest-log.mjs < spec.json
|
||||
*
|
||||
* 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";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
const spec = JSON.parse(readFileSync(0, "utf8"));
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
||||
url: spec.url ?? "http://homeassistant.local/nestquest/ada",
|
||||
pretendToBeVisual: true,
|
||||
});
|
||||
|
||||
// Expose the jsdom globals the Lit bundle expects. Node's own intrinsics
|
||||
// (Object, Array, ...) are kept — only what globalThis lacks is copied.
|
||||
for (const key of Object.getOwnPropertyNames(dom.window)) {
|
||||
if (!(key in globalThis)) {
|
||||
try {
|
||||
globalThis[key] = dom.window[key];
|
||||
} catch {
|
||||
// jsdom getters may throw on access; the card does not need them.
|
||||
}
|
||||
}
|
||||
}
|
||||
globalThis.window = dom.window;
|
||||
// The card navigates by dispatching new Event("location-changed") on this
|
||||
// window; jsdom's EventTarget rejects Node's own global Event class, so the
|
||||
// jsdom Event must shadow it for the return navigation to be delivered.
|
||||
globalThis.Event = dom.window.Event;
|
||||
|
||||
await import(pathToFileURL(spec.bundle).href);
|
||||
|
||||
const element = document.createElement("nestquest-quest-log-card");
|
||||
element.setConfig(spec.config);
|
||||
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({ ...snapshot(), probe });
|
||||
await new Promise((resolve) => process.stdout.write(payload, () => resolve()));
|
||||
process.exit(0);
|
||||
+447
-7
@@ -6,14 +6,22 @@ spirit as test_frontend.py. They cover the states decided in
|
||||
design/PANEL-EMPTY-STATES.md and PANEL-SPEC.md, and guard the kid-facing
|
||||
bundles against ever gaining a parent-only service call.
|
||||
|
||||
The party board additionally has render tests: they drive
|
||||
frontend/tests/render-party-board.mjs, which imports the built bundle into a
|
||||
jsdom window and reports the ordered plates the shadow DOM renders, so the
|
||||
zero-config discovery contract is asserted against the bundle HACS ships.
|
||||
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 re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
@@ -24,6 +32,7 @@ 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"
|
||||
|
||||
@@ -49,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
|
||||
@@ -157,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", "<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:
|
||||
# Only the kid panel sources are scanned: the bundle also contains the
|
||||
# sibling admin card, so its parent-only service names are expected there.
|
||||
@@ -234,13 +530,20 @@ def _three_child_states() -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _render_party_board(config: dict, states: dict) -> dict:
|
||||
def _render_party_board(
|
||||
config: dict,
|
||||
states: dict,
|
||||
url: str | None = None,
|
||||
click_plate: 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, and prints
|
||||
the ordered plates its shadow DOM renders.
|
||||
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.
|
||||
"""
|
||||
assert BUNDLE_PATH.is_file(), "bundle missing: run cd frontend && npm run build"
|
||||
assert RENDER_HARNESS.is_file()
|
||||
@@ -250,6 +553,10 @@ def _render_party_board(config: dict, states: dict) -> dict:
|
||||
"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
|
||||
completed = subprocess.run(
|
||||
["node", str(RENDER_HARNESS)],
|
||||
input=json.dumps(spec),
|
||||
@@ -446,3 +753,136 @@ def test_strategy_config_overrides_still_apply() -> None:
|
||||
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,
|
||||
) -> 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).
|
||||
"""
|
||||
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
|
||||
completed = subprocess.run(
|
||||
["node", str(QUEST_LOG_HARNESS)],
|
||||
input=json.dumps(spec),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
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_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