Compare commits

..
6 changed files with 442 additions and 910 deletions
-8
View File
@@ -186,14 +186,6 @@ 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
-100
View File
@@ -8,20 +8,6 @@ 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;
@@ -729,10 +715,6 @@ 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") {
@@ -741,101 +723,19 @@ 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;
-97
View File
@@ -13,20 +13,6 @@ 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 PanelInstance = {
id: number;
child_id: number | null;
@@ -1375,10 +1361,6 @@ export class NestQuestQuestLogCard extends LitElement {
private _confirmTimer?: number;
private _toastTimer?: number;
private _completeTimer?: number;
private _unsubs: Array<() => void> = [];
private _subscribed = false;
private _subGeneration = 0;
private _retryTimer?: number;
setConfig(config: QuestLogCardConfig): void {
if (!config || typeof config !== "object") {
@@ -1387,89 +1369,12 @@ export class NestQuestQuestLogCard 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();
@@ -1484,7 +1389,6 @@ export class NestQuestQuestLogCard extends LitElement {
}
protected updated(): void {
this._subscribeLive();
if (this._logView().kind === "complete-day") {
this._clearIdle();
this._armCompleteTimer();
@@ -1498,7 +1402,6 @@ export class NestQuestQuestLogCard extends LitElement {
}
disconnectedCallback(): void {
this._unsubscribeLive();
this._stopClock();
this._clearIdle();
this._clearConfirmTimer();
-8
View File
@@ -93,14 +93,6 @@ 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
@@ -1,160 +0,0 @@
"""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