Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5278f130bd | ||
|
|
6d87b1e595 | ||
|
|
2180b2fd49 |
@@ -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
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user