Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4942917fa | ||
|
|
290a77441c | ||
|
|
8801cdb0aa | ||
|
|
4cb07fbca4 | ||
|
|
030ba83b14 | ||
|
|
a2c027deca |
@@ -186,6 +186,14 @@ npm install
|
|||||||
npm run build
|
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
|
### Running Tests
|
||||||
|
|
||||||
Run the test suite with `pytest`:
|
Run the test suite with `pytest`:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,20 @@ type StateObject = {
|
|||||||
attributes?: Record<string, unknown> | null;
|
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 = {
|
type ChildPlate = {
|
||||||
slug: string;
|
slug: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -715,6 +729,10 @@ export class NestQuestPartyBoardCard extends LitElement {
|
|||||||
_config?: CardConfig;
|
_config?: CardConfig;
|
||||||
_now = new Date();
|
_now = new Date();
|
||||||
private _clockTimer?: number;
|
private _clockTimer?: number;
|
||||||
|
private _unsubs: Array<() => void> = [];
|
||||||
|
private _subscribed = false;
|
||||||
|
private _subGeneration = 0;
|
||||||
|
private _retryTimer?: number;
|
||||||
|
|
||||||
setConfig(config: CardConfig): void {
|
setConfig(config: CardConfig): void {
|
||||||
if (!config || typeof config !== "object") {
|
if (!config || typeof config !== "object") {
|
||||||
@@ -723,19 +741,101 @@ export class NestQuestPartyBoardCard extends LitElement {
|
|||||||
this._config = config;
|
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 {
|
getCardSize(): number {
|
||||||
return 22;
|
return 22;
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback(): void {
|
connectedCallback(): void {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
|
this._subscribeLive();
|
||||||
this._now = new Date();
|
this._now = new Date();
|
||||||
this._clockTimer = window.setInterval(() => {
|
this._clockTimer = window.setInterval(() => {
|
||||||
this._now = new Date();
|
this._now = new Date();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected updated(): void {
|
||||||
|
this._subscribeLive();
|
||||||
|
}
|
||||||
|
|
||||||
disconnectedCallback(): void {
|
disconnectedCallback(): void {
|
||||||
|
this._unsubscribeLive();
|
||||||
if (this._clockTimer !== undefined) {
|
if (this._clockTimer !== undefined) {
|
||||||
window.clearInterval(this._clockTimer);
|
window.clearInterval(this._clockTimer);
|
||||||
this._clockTimer = undefined;
|
this._clockTimer = undefined;
|
||||||
|
|||||||
@@ -13,6 +13,20 @@ type StateObject = {
|
|||||||
attributes?: Record<string, unknown> | null;
|
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 = {
|
type PanelInstance = {
|
||||||
id: number;
|
id: number;
|
||||||
child_id: number | null;
|
child_id: number | null;
|
||||||
@@ -1361,6 +1375,10 @@ export class NestQuestQuestLogCard extends LitElement {
|
|||||||
private _confirmTimer?: number;
|
private _confirmTimer?: number;
|
||||||
private _toastTimer?: number;
|
private _toastTimer?: number;
|
||||||
private _completeTimer?: number;
|
private _completeTimer?: number;
|
||||||
|
private _unsubs: Array<() => void> = [];
|
||||||
|
private _subscribed = false;
|
||||||
|
private _subGeneration = 0;
|
||||||
|
private _retryTimer?: number;
|
||||||
|
|
||||||
setConfig(config: QuestLogCardConfig): void {
|
setConfig(config: QuestLogCardConfig): void {
|
||||||
if (!config || typeof config !== "object") {
|
if (!config || typeof config !== "object") {
|
||||||
@@ -1369,12 +1387,89 @@ export class NestQuestQuestLogCard extends LitElement {
|
|||||||
this._config = config;
|
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 {
|
getCardSize(): number {
|
||||||
return 22;
|
return 22;
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback(): void {
|
connectedCallback(): void {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
|
this._subscribeLive();
|
||||||
this._now = new Date();
|
this._now = new Date();
|
||||||
this._clockTimer = window.setInterval(() => {
|
this._clockTimer = window.setInterval(() => {
|
||||||
this._now = new Date();
|
this._now = new Date();
|
||||||
@@ -1389,6 +1484,7 @@ export class NestQuestQuestLogCard extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected updated(): void {
|
protected updated(): void {
|
||||||
|
this._subscribeLive();
|
||||||
if (this._logView().kind === "complete-day") {
|
if (this._logView().kind === "complete-day") {
|
||||||
this._clearIdle();
|
this._clearIdle();
|
||||||
this._armCompleteTimer();
|
this._armCompleteTimer();
|
||||||
@@ -1402,6 +1498,7 @@ export class NestQuestQuestLogCard extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback(): void {
|
disconnectedCallback(): void {
|
||||||
|
this._unsubscribeLive();
|
||||||
this._stopClock();
|
this._stopClock();
|
||||||
this._clearIdle();
|
this._clearIdle();
|
||||||
this._clearConfirmTimer();
|
this._clearConfirmTimer();
|
||||||
|
|||||||
@@ -93,6 +93,14 @@ def test_readme_documents_npm_build() -> None:
|
|||||||
assert "nestquest-cards.js" in readme
|
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 = (
|
WOFF2_FILES = (
|
||||||
"nunito-latin-400-normal.woff2",
|
"nunito-latin-400-normal.woff2",
|
||||||
"nunito-latin-600-normal.woff2",
|
"nunito-latin-600-normal.woff2",
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user