Compare commits

...
Author SHA1 Message Date
overseer e7148c4184 Set both panel views to Panel layout and document TouchHub Auto-Return 2026-09-20 20:18:01 -04:00
overseer 353ad82c7e Document the required dashboard and view URL paths 2026-09-20 20:14:01 -04:00
overseer 1a2775adfc Correct the kiosk account permissions and document both panel card views 2026-09-20 20:11:35 -04:00
overseer 02f78b884d Drop the unnecessary X-Frame-Options runbook step 2026-09-20 20:05:49 -04:00
overseer 7b5ea2b29e Document the TouchHub setup runbook 2026-09-20 19:44:30 -04:00
dev_agent 43ad44ee48 Panel card tests including the no-admin-controls assertion
Panel card tests including the no-admin-controls assertion
2026-09-20 19:41:57 -04:00
dev_agent 4cb07fbca4 Record 1920x1080 panel layout and TouchHub dock behaviour
Record 1920x1080 panel layout and TouchHub dock behaviour
2026-09-20 19:29:00 -04:00
dev_agent 030ba83b14 Subscribe panel cards to live NestQuest events (#130) 2026-09-20 19:17:52 -04:00
dev_agent a2c027deca Add the quest complete screen (1c)
Add the quest complete screen (1c)
2026-09-20 18:39:11 -04:00
dev_agent 2b45ebf035 Wire complete_quest with optimistic seal and parchment toast
Wire complete_quest with optimistic seal and parchment toast
2026-09-20 14:19:39 -04:00
dev_agent 028215fa38 Define and implement the panel's empty and error states
Define and implement the panel's empty and error states
2026-09-20 14:04:58 -04:00
8 changed files with 3155 additions and 528 deletions
+87
View File
@@ -155,6 +155,85 @@ Sensors the automations read: `sensor.nestquest_<child>_quests_due_today`
`..._quests_completed_today`, and
`binary_sensor.nestquest_<child>_present_today`.
## TouchHub setup
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.
1. **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`,
`update_quest_definition`, `set_quest_definition_active`,
`set_presence_pattern`, `create_presence_override`,
`delete_presence_override`, `export_history_csv`, and `manage_child`
(plus `regenerate` flows) — it does not make the account view-only. The
kiosk user *can* call `nestquest.complete_quest`, the one open NestQuest
service, to complete quests from the panel, and as a non-admin Home
Assistant user it may also reach other permitted Home Assistant entities
and services.
2. **Build a dashboard with only the NestQuest panel cards.** 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.
TouchHub loads the dashboard URL directly. The NestQuest cards are normal
Lovelace cards, not an iframe, so no Home Assistant framing setting is
needed.
## Developer Setup and Testing
This project uses [`uv`](https://github.com/astral-sh/uv) for fast Python package and dependency management.
@@ -186,6 +265,14 @@ npm install
npm run build
```
### Kids panel layout
The Lovelace panel cards are designed for the wall display's confirmed
resolution, 1920×1080 landscape, with no page scroll. TouchHub's bottom
dock overlays the NestQuest weather dock strip until it auto-hides, so
no tappable NestQuest control sits in that strip (the dock carries
`pointer-events: none`). Keep it that way when editing the cards.
### Running Tests
Run the test suite with `pytest`:
File diff suppressed because it is too large Load Diff
+1
View File
@@ -90,6 +90,7 @@ child_order: [declan, jordyn, chloe]
# kids' panel, view 2
type: custom:nestquest-quest-log-card
board_path: /nestquest/board
weather_entity: weather.home # optional; dock falls back to date/time
idle_return_seconds: 40 # must be shorter than TouchHub Auto-Return
confirm_timeout_seconds: 15
complete_screen_seconds: 12
+104
View File
@@ -0,0 +1,104 @@
# Panel empty & error states — design addendum
Closes the gap DESIGN-HANDOFF.md lists under "Open questions": *"Empty and error
states are unspecified by request (child with no quests, everyone away, integration
unconfigured, DB unreachable). Panel must not render a blank parchment — decide
before shipping the card."*
All decisions below reuse PANEL-SPEC.md's approved aesthetic — parchment ground,
double frame, dock, panel tokens, no text below 22px, no alpha-muted ink — and add
one shared component per card: a **notice panel** (radius 20px, `--nq-p-card-panel`,
2px `--nq-p-panel-border`, `--nq-p-panel-shadow`, centred icon + headline + body).
The frame, wordmark row, kicker and dock always render; only the content area
changes. **No state renders a blank parchment.**
## 1. What the data can say
The cards read `sensor.nestquest_<child>_quests_due_today` (plus siblings). One of
four things comes back, and the card must tell them apart:
| Backend condition | What the card sees | Card's interpretation |
|---|---|---|
| Resolving (DB fine) | numeric state + attributes (`child_name`, `present`, `instances`) | normal, all states below |
| Integration not configured | entity does not exist at all | **not set up** |
| Backend unreachable (DB locked, refresh failed) | state `unavailable` | **records unreachable** |
| Child deactivated / absent from snapshot | state `unknown` | **records unreachable** (same panel voice: the records do not answer) |
An away child (presence `present: false`, from the due-sensor attributes with the
presence binary sensor as fallback) is *not* an error: it is a first-class panel
state ("On travels"). Away children get no instances (materialiser skips them), so
away is detected from the presence flag, never from a zero quest count.
## 2. Quest log — state priority
`nestquest-quest-log-card` resolves exactly one state, in this order:
1. **No adventurer in the path** (direct navigation to a URL with no child slug —
misconfigured `board_path`/`quest_log_path`) → notice *"No adventurer chosen"*,
body *"Open The Party and tap your crest to open your quest log."*
2. **Not set up** (entity missing) → notice *"NestQuest is not set up yet"*, body
*"A parent needs to finish setting up NestQuest."*
3. **Records unreachable** (`unavailable`/`unknown`) → notice *"The records cannot
be reached"*, body *"The party's records are quiet right now. NestQuest will
return shortly."*
4. **Away** (present flag false) → away notice: dashed muted panel (the away-plate
gradient), tent icon, *"<Name> is on travels"*, *"The quest log unlocks when
they return."* and, when `next_present` is known, *"Returns <weekday>, <Mon D>"*.
Direct navigation is the one path that can reach this state (the board's away
plate is not tappable); the log answers it honestly instead of showing an empty
column set. Header renders with the crest in away colours and the sub line
`… · On travels`; the remaining-count block is hidden (it is meaningless while
away).
5. **No quests today** (present, due = 0) → all-clear notice, sun icon, *"No quests
today"*, body *"Nothing was posted for today, <Name>. Enjoy the day's rest!"* —
never three bare empty columns. The header and remaining block stay (0 quests
left, party progress), so the log still reads like the log.
6. **All quests done** (due > 0, remaining = 0) → the fallback celebration that
shows until the 1c quest-complete screen takes over: centred wax-seal mark (the
`--nq-p-seal` tokens at 78px, check glyph, −6°) with headline *"Quest complete"*
— the same phrase screen 1c uses — and body *"Every quest is claimed, <Name>.
The seal is set for today. Returning to The Party shortly."* The idle return
(§3.5) is what actually navigates back; 1c remains the designed takeover.
7. **Normal** → the three-window column layout of PANEL-SPEC §3.1 unchanged. A
window with no quests still renders its head alone, exactly as the spec says.
Notices sit in the columns' content area, vertically centred. Error notices (states
1–3) replace the header entirely — there is no honest name to headline when the
records do not answer — while states 4–6 keep the full header.
## 3. Party board — state priority
`nestquest-party-board-card` resolves per configured child, then at board level:
1. **No children configured** (`child_order` empty) → board notice *"NestQuest is
not set up yet"*, body *"A parent needs to finish setting up NestQuest before
the party can gather."*
2. **Every configured child unresolvable** → one board notice instead of a grid of
broken plates: *"NestQuest is not set up yet"* when the entities are missing,
*"The records cannot be reached"* when they answer `unavailable`/`unknown`.
3. **Partial** (some children resolve, some do not) → the board still renders the
resolving plates; an unresolvable child renders a muted, dashed plate in the
away geometry with a compass icon and the pill label **"Unknown"**, progress
line `—`, empty bar, **not tappable**. It must not claim "Home today" (0 of 0)
and must not lie "On travels": the records simply do not say.
4. **Normal** → the plates grid of PANEL-SPEC §2 unchanged (present + away plates).
The hint line ("Tap your crest…") is hidden in board-level notice states — there
is nothing to tap.
## 4. Shared rules
- **Never a blank parchment.** Every render path emits the frames, wordmark,
kicker, dock, and either content or a notice panel. The dock already degrades to
date + time when weather is unset (PANEL-SPEC §2.3); that behaviour is unchanged.
- **Notice panel type scale:** icon 46px, headline 40px/700 Cinzel
(`--nq-p-font-heading`, `--nq-p-ink`), body 24px/600 Nunito
(`--nq-p-ink-secondary`), `role="status"` so assistive tech announces it.
- **Tone.** The panel speaks tabletop (PANEL-SPEC vocabulary) but never lies: an
error says the records cannot be reached, not that everything is fine. Error
copy names the actor who can fix it (a parent) or promises the return of the
data — no stack traces, no entity IDs.
- **No interaction in error/away states.** No confirm dialog, no idle return
changes, no navigation attempts. Notices are static.
- Both cards implement the same resolution rules, so the board and the log never
disagree about whether the integration is up.
+288 -6
View File
@@ -1,4 +1,4 @@
import { LitElement, css, html, nothing, unsafeCSS } from "lit";
import { LitElement, css, html, nothing, unsafeCSS, type TemplateResult } from "lit";
import panelTokens from "../../../custom_components/nestquest/www/nestquest-panel-tokens.css?inline";
import { registerCustomCard, type CardConfig, type HassLike } from "../types";
@@ -8,6 +8,20 @@ type StateObject = {
attributes?: Record<string, unknown> | null;
};
type HassConnection = {
subscribeEvents: (
callback: (event: unknown) => void,
eventType: string
) => Promise<() => void>;
};
const NESTQUEST_EVENT_TYPES = [
"nestquest_quest_completed",
"nestquest_quest_uncompleted",
"nestquest_quest_missed",
"nestquest_child_day_complete",
];
type ChildPlate = {
slug: string;
name: string;
@@ -17,6 +31,14 @@ type ChildPlate = {
due: number;
pct: number;
returns: string | null;
/** Set when the child's sensors cannot resolve; the plate shows "Unknown". */
unresolved: "missing" | "stale" | null;
};
type BoardNotice = {
icon: TemplateResult;
headline: string;
body: string;
};
type DockWeather = {
@@ -476,6 +498,62 @@ const boardStyles = css`
color: var(--nq-p-ink-secondary);
}
.plate.unknown {
border: 2px dashed var(--nq-p-panel-border);
background: linear-gradient(#f2ecdd, #e6dcc6);
box-shadow: none;
}
.notice-wrap {
position: absolute;
top: 262px;
left: 110px;
right: 110px;
bottom: 190px;
display: flex;
align-items: center;
justify-content: center;
}
.notice {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
width: 100%;
max-width: 980px;
padding: 56px 64px;
border: 2px solid var(--nq-p-panel-border);
border-radius: var(--nq-p-radius-panel);
background: var(--nq-p-card-panel);
box-shadow: var(--nq-p-panel-shadow);
text-align: center;
}
.notice svg {
width: 46px;
height: 46px;
color: var(--nq-p-ink-secondary);
}
.notice-headline {
font-family: var(--nq-p-font-heading);
font-size: 40px;
font-weight: 700;
line-height: 1.15;
color: var(--nq-p-ink);
}
.notice-body {
margin: 0;
font-family: var(--nq-p-font-body);
font-size: 24px;
font-weight: 600;
line-height: 1.4;
color: var(--nq-p-ink-secondary);
}
.spacer {
flex: 1 1 0;
}
@@ -569,6 +647,58 @@ const ICON_TENT = html`<svg
<path d="M9 21l3-8 3 8"></path>
</svg>`;
const ICON_COMPASS = html`<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10"></circle>
<polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76"></polygon>
</svg>`;
const ICON_ALERT = html`<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"></path>
<path d="M12 9v4"></path>
<path d="M12 17h.01"></path>
</svg>`;
const ICON_CLOUD_OFF = html`<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z"></path>
<path d="m2 2 20 20"></path>
</svg>`;
const BOARD_NOTICE_NOT_SET_UP: BoardNotice = {
icon: ICON_ALERT,
headline: "NestQuest is not set up yet",
body: "A parent needs to finish setting up NestQuest before the party can gather.",
};
const BOARD_NOTICE_UNREACHABLE: BoardNotice = {
icon: ICON_CLOUD_OFF,
headline: "The records cannot be reached",
body: "The party's records are quiet right now. NestQuest will return shortly.",
};
const ICON_CLOUD_SUN = html`<svg
viewBox="0 0 24 24"
fill="none"
@@ -599,6 +729,10 @@ export class NestQuestPartyBoardCard extends LitElement {
_config?: CardConfig;
_now = new Date();
private _clockTimer?: number;
private _unsubs: Array<() => void> = [];
private _subscribed = false;
private _subGeneration = 0;
private _retryTimer?: number;
setConfig(config: CardConfig): void {
if (!config || typeof config !== "object") {
@@ -607,19 +741,101 @@ export class NestQuestPartyBoardCard extends LitElement {
this._config = config;
}
private _hassConnection(): HassConnection | null {
const connection = (this.hass as { connection?: unknown } | undefined)
?.connection;
if (
!connection ||
typeof connection !== "object" ||
typeof (connection as HassConnection).subscribeEvents !== "function"
) {
return null;
}
return connection as HassConnection;
}
private _subscribeLive(): void {
if (!this.isConnected) {
return;
}
if (this._subscribed) {
return;
}
const connection = this._hassConnection();
if (!connection) {
return;
}
this._subscribed = true;
const token = ++this._subGeneration;
const track = (unsubPromise: Promise<() => void>): void => {
unsubPromise
.then((unsub) => {
if (this._subscribed && token === this._subGeneration) {
this._unsubs.push(unsub);
} else {
unsub();
}
})
.catch(() => {
if (token === this._subGeneration) {
this._unsubscribeLive();
this._armSubscribeRetry();
}
});
};
for (const eventType of NESTQUEST_EVENT_TYPES) {
track(
connection.subscribeEvents(() => this.requestUpdate(), eventType)
);
}
}
private _armSubscribeRetry(): void {
if (this._retryTimer !== undefined) {
return;
}
this._retryTimer = window.setTimeout(() => {
this._retryTimer = undefined;
this._subscribeLive();
}, 1000);
}
private _clearSubscribeRetry(): void {
if (this._retryTimer !== undefined) {
window.clearTimeout(this._retryTimer);
this._retryTimer = undefined;
}
}
private _unsubscribeLive(): void {
this._clearSubscribeRetry();
this._subGeneration++;
for (const unsub of this._unsubs) {
unsub();
}
this._unsubs = [];
this._subscribed = false;
}
getCardSize(): number {
return 22;
}
connectedCallback(): void {
super.connectedCallback();
this._subscribeLive();
this._now = new Date();
this._clockTimer = window.setInterval(() => {
this._now = new Date();
}, 1000);
}
protected updated(): void {
this._subscribeLive();
}
disconnectedCallback(): void {
this._unsubscribeLive();
if (this._clockTimer !== undefined) {
window.clearInterval(this._clockTimer);
this._clockTimer = undefined;
@@ -628,6 +844,7 @@ export class NestQuestPartyBoardCard extends LitElement {
}
render() {
const notice = this._boardNotice();
return html`
<div class="board">
<div class="frame frame-outer"></div>
@@ -644,10 +861,14 @@ export class NestQuestPartyBoardCard extends LitElement {
<span class="rule"></span>
</div>
<p class="kicker">The Party · ${formatDay(this._now, this._timeZone())}</p>
<div class="plates">
${this._plates().map((plate) => this._renderPlate(plate))}
</div>
<p class="hint">Tap your crest to open your Quest Log</p>
${notice
? this._renderNotice(notice)
: html`
<div class="plates">
${this._plates().map((plate) => this._renderPlate(plate))}
</div>
<p class="hint">Tap your crest to open your Quest Log</p>
`}
<div class="spacer"></div>
${this._renderDock()}
</div>
@@ -687,6 +908,7 @@ export class NestQuestPartyBoardCard extends LitElement {
}
private _plate(slug: string): ChildPlate {
const unresolved = this._dueSensorResolution(slug);
const dueSensor = this._state(`sensor.nestquest_${slug}_quests_due_today`);
const doneSensor = this._state(
`sensor.nestquest_${slug}_quests_completed_today`
@@ -744,15 +966,75 @@ export class NestQuestPartyBoardCard extends LitElement {
slug,
name,
initial: (name.charAt(0) || "?").toUpperCase(),
present,
present: unresolved === null ? present : false,
completed,
due,
pct,
returns,
unresolved,
};
}
/** "missing" when the due sensor does not exist (integration not
* configured), "stale" when it answers unavailable/unknown (backend
* unreachable or the child is absent from the snapshot), null when
* it resolves. */
private _dueSensorResolution(slug: string): "missing" | "stale" | null {
const sensor = this._state(`sensor.nestquest_${slug}_quests_due_today`);
if (!sensor) {
return "missing";
}
const state = String(sensor.state ?? "").trim().toLowerCase();
if (!state || state === "unavailable" || state === "unknown") {
return "stale";
}
return null;
}
private _boardNotice(): BoardNotice | null {
const plates = this._plates();
if (plates.length === 0) {
return BOARD_NOTICE_NOT_SET_UP;
}
if (plates.every((plate) => plate.unresolved !== null)) {
return plates.some((plate) => plate.unresolved === "stale")
? BOARD_NOTICE_UNREACHABLE
: BOARD_NOTICE_NOT_SET_UP;
}
return null;
}
private _renderNotice(notice: BoardNotice) {
return html`
<div class="notice-wrap">
<div class="notice" role="status">
${notice.icon}
<span class="notice-headline">${notice.headline}</span>
<p class="notice-body">${notice.body}</p>
</div>
</div>
`;
}
private _renderPlate(plate: ChildPlate) {
if (plate.unresolved) {
return html`
<div class="plate unknown">
<span class="crest away">
<span class="crest-face">
<span class="initial">${plate.initial}</span>
</span>
</span>
<span class="name">${plate.name}</span>
<span class="pill away">
${ICON_COMPASS}
<span>Unknown</span>
</span>
<span class="progress-line">&mdash;</span>
<span class="bar"></span>
</div>
`;
}
if (plate.present) {
return html`
<button
File diff suppressed because it is too large Load Diff
+24
View File
@@ -93,6 +93,14 @@ def test_readme_documents_npm_build() -> None:
assert "nestquest-cards.js" in readme
def test_readme_documents_panel_resolution_and_touchhub_dock() -> None:
readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8")
assert "1920×1080" in readme
assert "TouchHub" in readme
assert "dock" in readme
assert "pointer-events: none" in readme
WOFF2_FILES = (
"nunito-latin-400-normal.woff2",
"nunito-latin-600-normal.woff2",
@@ -155,3 +163,19 @@ def test_frontend_and_www_have_no_cdn_font_references() -> None:
text = path.read_text(encoding="utf-8", errors="ignore")
for marker in CDN_MARKERS:
assert marker not in text, f"{path}: {marker}"
def test_readme_documents_the_touchhub_setup_runbook() -> None:
readme = " ".join(
(REPO_ROOT / "README.md").read_text(encoding="utf-8").split()
)
for required in ("TouchHub", "kiosk"):
assert required in readme, required
assert "not in the NestQuest admin allowlist" in readme
assert "contains only the NestQuest panel card" in readme
assert "nestquest-party-board-card" in readme
assert "nestquest-quest-log-card" in readme
assert "Panel" in readme
assert "Auto-Return" in readme
assert "use_x_frame_options" not in readme
assert "clickjacking" not in readme
+160
View File
@@ -0,0 +1,160 @@
"""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.
"""
from __future__ import annotations
from pathlib import Path
REPO_ROOT = Path(__file__).parent.parent
FRONTEND_CARDS_DIR = REPO_ROOT / "frontend" / "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"
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")
def _read(path: Path) -> str:
return path.read_text(encoding="utf-8")
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
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