Compare commits

...
Author SHA1 Message Date
overseer a4942917fa Include regenerate in the kid-panel service denylist 2026-09-20 19:39:52 -04:00
overseer 290a77441c Scan only kid panel sources for admin-only services 2026-09-20 19:36:58 -04:00
overseer 8801cdb0aa Add source-level tests for the panel cards and no-admin-controls guard 2026-09-20 19:32:56 -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
7 changed files with 1581 additions and 330 deletions
+8
View File
@@ -186,6 +186,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
+100
View File
@@ -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;
@@ -715,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") {
@@ -723,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;
+547 -13
View File
@@ -4,11 +4,29 @@ import panelTokens from "../../../custom_components/nestquest/www/nestquest-pane
import { registerCustomCard, type CardConfig, type HassLike } from "../types";
type QuestLogCardConfig = CardConfig & {
weather_entity?: string;
};
type StateObject = {
state: unknown;
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 PanelInstance = {
id: number;
child_id: number | null;
@@ -19,6 +37,7 @@ type PanelInstance = {
state: string;
overdue: boolean;
completed_at: string | null;
on_time: boolean | null;
};
type WindowDef = {
@@ -109,6 +128,14 @@ function formatInstant(value: string, timeZone: string | undefined): string {
}).format(parsed);
}
function formatTime(date: Date, timeZone: string | undefined): string {
return zonedFormatter(timeZone, {
hour: "numeric",
minute: "2-digit",
hour12: true,
}).format(date);
}
function formatWallClock(value: string | null): string | null {
if (!value) {
return null;
@@ -227,9 +254,65 @@ function toPanelInstance(value: unknown): PanelInstance | null {
state,
overdue: row.overdue === true,
completed_at: asString(row.completed_at) || null,
on_time: row.on_time === true ? true : row.on_time === false ? false : null,
};
}
type DockWeather = {
condition: string;
temperature: number | null;
high: number | null;
low: number | null;
};
const CONDITION_LABELS: Record<string, string> = {
"clear-night": "Clear",
cloudy: "Cloudy",
exceptional: "Clear",
fog: "Foggy",
hail: "Hail",
lightning: "Storms",
"lightning-rainy": "Storms",
partlycloudy: "Partly cloudy",
pouring: "Heavy rain",
rainy: "Rain",
snowy: "Snow",
"snowy-rainy": "Sleet",
sunny: "Sunny",
windy: "Windy",
"windy-variant": "Windy",
};
const CONDITION_PHRASES: Record<string, string> = {
"clear-night": "Clear night skies",
cloudy: "Grey skies today",
exceptional: "A striking day",
fog: "Mist on the road",
hail: "Ice from the sky",
lightning: "Storms may roll in",
"lightning-rainy": "Storms may roll in",
partlycloudy: "Sun between clouds",
pouring: "Heavy rain outside",
rainy: "Rain on the walls",
snowy: "Snow on the peaks",
"snowy-rainy": "Sleet may fall",
sunny: "Clear skies today",
windy: "A blustery day",
"windy-variant": "A blustery day",
};
function conditionLabel(condition: string): string {
const known = CONDITION_LABELS[condition];
if (known) {
return known;
}
return condition.charAt(0).toUpperCase() + condition.slice(1);
}
function conditionPhrase(condition: string, label: string): string {
return CONDITION_PHRASES[condition] ?? label;
}
const SHIELD_CLIP = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)";
const HEXAGON_CLIP =
"polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)";
@@ -919,6 +1002,170 @@ const logStyles = css`
transform: translate(-50%, 0);
}
}
.complete-screen {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
padding: 96px 62px 130px;
text-align: center;
}
.complete-seal {
display: inline-flex;
align-items: center;
justify-content: center;
width: 168px;
height: 168px;
border-radius: var(--nq-p-radius-pill);
background: var(--nq-p-seal);
box-shadow: 0 10px 24px rgba(60, 10, 20, 0.42);
color: rgba(255, 235, 235, 0.95);
transform: rotate(-6deg);
}
.complete-seal svg {
width: 86px;
height: 86px;
}
.complete-title {
margin: 28px 0 0;
font-family: var(--nq-p-font-display);
font-size: 86px;
font-weight: 900;
line-height: 1.1;
background: var(--nq-brand-gradient-h);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
-webkit-text-fill-color: transparent;
}
.complete-sub {
margin: 16px 0 0;
max-width: 1000px;
font-family: var(--nq-p-font-body);
font-size: 34px;
font-weight: 600;
line-height: 1.35;
color: var(--nq-p-ink-secondary);
text-wrap: pretty;
}
.complete-countdown {
margin: 12px 0 0;
font-family: var(--nq-p-font-body);
font-size: 25px;
font-weight: 600;
line-height: 1.2;
color: var(--nq-p-ink-secondary);
}
.complete-stats {
position: absolute;
top: 540px;
left: 200px;
right: 200px;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 26px;
}
.complete-stat {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 28px 16px;
border: 2px solid var(--nq-p-panel-border);
border-radius: 16px;
background: var(--nq-p-card-panel);
box-shadow: var(--nq-p-panel-shadow);
}
.complete-stat .numeral {
font-family: var(--nq-p-font-heading);
font-size: 58px;
font-weight: 900;
line-height: 1;
color: var(--nq-p-ink);
}
.complete-stat .label {
font-family: var(--nq-p-font-heading);
font-size: 17px;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
line-height: 1.2;
color: var(--nq-p-ink-secondary);
}
.dock {
position: absolute;
left: 46px;
right: 46px;
bottom: 46px;
height: var(--nq-p-dock-height);
display: flex;
align-items: center;
justify-content: center;
gap: 24px;
padding: 0 40px;
border-radius: 14px;
background: var(--nq-p-dock-bg);
pointer-events: none;
user-select: none;
color: var(--nq-p-dock-ink);
}
.dock svg {
flex: none;
width: 40px;
height: 40px;
color: var(--nq-p-dock-ink);
}
.dock .divider {
flex: none;
width: 1px;
height: 40px;
background: var(--nq-p-dock-divider);
}
.dock .temp {
font-family: var(--nq-p-font-body);
font-size: 38px;
font-weight: 800;
line-height: 1;
}
.dock .condition {
font-family: var(--nq-p-font-body);
font-size: 26px;
font-weight: 600;
line-height: 1.2;
color: var(--nq-p-dock-ink-secondary);
}
.dock .date {
font-family: var(--nq-p-font-body);
font-size: 26px;
font-weight: 600;
line-height: 1.2;
color: var(--nq-p-dock-ink-secondary);
}
.dock .clock {
font-family: var(--nq-p-font-body);
font-size: 38px;
font-weight: 800;
line-height: 1;
}
`;
const ICON_CHECK = html`<svg
@@ -1064,6 +1311,19 @@ const ICON_CLOUD_OFF = html`<svg
<path d="m2 2 20 20"></path>
</svg>`;
const ICON_CLOUD_MOON = 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="M13 16a3 3 0 1 1 0 6H7a5 5 0 1 1 4.9-6Z"></path>
<path d="M10.1 9A6 6 0 0 1 16 4a4.5 4.5 0 0 0 8.9 4.2v.1a6 6 0 0 1-5.2 5.7"></path>
</svg>`;
const LOG_NOTICE_NO_ADVENTURER: LogNotice = {
icon: ICON_COMPASS,
headline: "No adventurer chosen",
@@ -1096,36 +1356,120 @@ export class NestQuestQuestLogCard extends LitElement {
_confirm: { state: true },
_optimistic: { state: true },
_toast: { state: true },
_countdown: { state: true },
};
static styles = [unsafeCSS(panelTokens), logStyles];
hass?: HassLike;
_config?: CardConfig;
_config?: QuestLogCardConfig;
_now = new Date();
_confirm: number | null = null;
/** instanceId → the completion instant stamped before the service has
* answered; the seal shows immediately and reverts on failure. */
_optimistic = new Map<number, string>();
_toast: string | null = null;
_countdown: number | null = null;
private _clockTimer?: number;
private _idleTimer?: number;
private _confirmTimer?: number;
private _toastTimer?: number;
private _completeTimer?: number;
private _unsubs: Array<() => void> = [];
private _subscribed = false;
private _subGeneration = 0;
private _retryTimer?: number;
setConfig(config: CardConfig): void {
setConfig(config: QuestLogCardConfig): void {
if (!config || typeof config !== "object") {
throw new Error("Invalid configuration");
}
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();
@@ -1134,13 +1478,32 @@ export class NestQuestQuestLogCard extends LitElement {
window.addEventListener("touchstart", this._onActivity, true);
window.addEventListener("keydown", this._onActivity, true);
this._armIdle();
if (this._logView().kind === "complete-day") {
this._armCompleteTimer();
}
}
protected updated(): void {
this._subscribeLive();
if (this._logView().kind === "complete-day") {
this._clearIdle();
this._armCompleteTimer();
} else {
this._clearCompleteTimer();
this._countdown = null;
if (this._idleTimer === undefined) {
this._armIdle();
}
}
}
disconnectedCallback(): void {
this._unsubscribeLive();
this._stopClock();
this._clearIdle();
this._clearConfirmTimer();
this._clearToastTimer();
this._clearCompleteTimer();
window.removeEventListener("pointerdown", this._onActivity, true);
window.removeEventListener("touchstart", this._onActivity, true);
window.removeEventListener("keydown", this._onActivity, true);
@@ -1206,20 +1569,39 @@ export class NestQuestQuestLogCard extends LitElement {
</div>
</div>
`;
case "complete-day":
case "complete-day": {
const stats = this._completeStats();
const remaining = this._countdown ?? this._completeSeconds();
return html`
${this._renderHeader(view)}
<div class="notice-wrap">
<div class="notice" role="status">
<span class="notice-seal">${ICON_CHECK}</span>
<span class="notice-headline">Quest complete</span>
<p class="notice-body">
${`Every quest is claimed, ${view.name}. The seal is set for today.`}
</p>
<p class="notice-body">Returning to The Party shortly.</p>
<div class="complete-screen" role="status">
<span class="complete-seal" aria-hidden="true">${ICON_CHECK}</span>
<h1 class="complete-title">Quest complete</h1>
<p class="complete-sub">${this._completeSub(view.name)}</p>
<p class="complete-countdown">
Returning to The Party in ${remaining} seconds
</p>
<div class="complete-stats">
<div class="complete-stat">
<span class="numeral">${stats.claimed}</span>
<span class="label">Quests claimed</span>
</div>
<div class="complete-stat">
<span class="numeral">${stats.onTime}</span>
<span class="label">On time</span>
</div>
<div class="complete-stat">
<span class="numeral">${stats.late}</span>
<span class="label">Late</span>
</div>
<div class="complete-stat">
<span class="numeral">${stats.party}</span>
<span class="label">The Party</span>
</div>
</div>
</div>
${this._renderDock()}
`;
}
default:
return html`
${this._renderHeader(view)}
@@ -1248,6 +1630,10 @@ export class NestQuestQuestLogCard extends LitElement {
if (!this._childPresent(slug)) {
return { kind: "away", name, returns: this._awayReturns(slug) };
}
const allDone = this._state(`binary_sensor.nestquest_${slug}_all_done`);
if (allDone && String(allDone.state ?? "").trim().toLowerCase() === "on") {
return { kind: "complete-day", name };
}
const dueSensor = this._state(`sensor.nestquest_${slug}_quests_due_today`);
const due = clampCount(asNumber(dueSensor?.state, 0));
if (due === 0) {
@@ -1342,6 +1728,11 @@ export class NestQuestQuestLogCard extends LitElement {
return seconds > 0 ? seconds : 15;
}
private _completeSeconds(): number {
const seconds = asNumber(this._config?.complete_screen_seconds, 12);
return seconds > 0 ? seconds : 12;
}
private _childName(slug: string): string | null {
if (!slug) {
return null;
@@ -1435,6 +1826,41 @@ export class NestQuestQuestLogCard extends LitElement {
return { completed, due };
}
private _completeStats(): {
claimed: number;
onTime: number;
late: number;
party: string;
} {
const claimed = this._childCounts(this._childSlug()).completed;
const onTime = this._instances().filter(
(instance) => instance.on_time === true
).length;
const late = clampCount(claimed - onTime);
const party = this._partyCounts();
return {
claimed,
onTime,
late,
party: `${party.completed}/${party.due}`,
};
}
private _completeSub(name: string): string {
const date = formatDay(this._now, this._timeZone());
const { claimed, onTime, late } = this._completeStats();
if (claimed === 0) {
return `${name} sealed the day on ${date}.`;
}
if (late === 0) {
return `${name} claimed every quest on ${date} — all on time.`;
}
if (onTime === 0) {
return `${name} claimed every quest on ${date} — all late.`;
}
return `${name} claimed every quest on ${date} — ${onTime} on time, ${late} late.`;
}
private _otherChildren(): OtherChild[] {
const states = this.hass?.states as
| Record<string, StateObject>
@@ -1897,6 +2323,112 @@ export class NestQuestQuestLogCard extends LitElement {
}
}
private _armCompleteTimer(): void {
if (this._completeTimer !== undefined) {
return;
}
if (this._countdown === null) {
this._countdown = this._completeSeconds();
}
if (this._countdown <= 0) {
this._returnToBoard();
return;
}
this._completeTimer = window.setInterval(() => {
const remaining = (this._countdown ?? 1) - 1;
if (remaining <= 0) {
this._countdown = 0;
this._clearCompleteTimer();
this._returnToBoard();
return;
}
this._countdown = remaining;
}, 1000);
}
private _clearCompleteTimer(): void {
if (this._completeTimer !== undefined) {
window.clearInterval(this._completeTimer);
this._completeTimer = undefined;
}
}
private _optionalNumber(value: unknown): number | null {
if (value === null || value === undefined || value === "") {
return null;
}
const parsed = typeof value === "number" ? value : Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
private _dockWeather(): DockWeather | null {
const entityId = asString(this._config?.weather_entity);
if (!entityId) {
return null;
}
const stateObj = this._state(entityId);
if (!stateObj) {
return null;
}
const condition = String(stateObj.state ?? "").trim();
if (!condition || condition === "unavailable" || condition === "unknown") {
return null;
}
const attrs = stateObj.attributes ?? {};
let high: number | null = null;
let low: number | null = null;
const forecast = attrs.forecast;
if (Array.isArray(forecast) && forecast.length > 0) {
const entry = forecast[0];
if (entry && typeof entry === "object") {
const typed = entry as Record<string, unknown>;
high = this._optionalNumber(typed.temperature);
low = this._optionalNumber(typed.templow);
}
}
return {
condition,
temperature: this._optionalNumber(attrs.temperature),
high,
low,
};
}
private _renderDock() {
const weather = this._dockWeather();
if (!weather) {
return html`
<div class="dock">
<span class="date">${formatDay(this._now, this._timeZone())}</span>
<span class="divider"></span>
<span class="clock">${formatTime(this._now, this._timeZone())}</span>
</div>
`;
}
const label = conditionLabel(weather.condition);
const phrase = conditionPhrase(weather.condition, label);
const temperature =
weather.temperature === null
? nothing
: html`<span class="temp">${Math.round(weather.temperature)}°</span>`;
const hiLo =
weather.high === null || weather.low === null
? null
: `${Math.round(weather.high)}° / ${Math.round(weather.low)}°`;
return html`
<div class="dock">
${ICON_CLOUD_MOON}
${temperature}
<span class="divider"></span>
<span class="condition">
${hiLo === null ? label : `${label} · ${hiLo}`}
</span>
<span class="divider"></span>
<span class="condition">${phrase}</span>
</div>
`;
}
private _returnToBoard(): void {
const path = this._boardPath();
if (!path) {
@@ -1914,7 +2446,9 @@ export class NestQuestQuestLogCard extends LitElement {
}
private _onActivity = (): void => {
this._armIdle();
if (this._logView().kind !== "complete-day") {
this._armIdle();
}
if (this._confirm !== null) {
this._armConfirmTimer();
}
+8
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",
+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