Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7148c4184 | ||
|
|
353ad82c7e | ||
|
|
1a2775adfc | ||
|
|
02f78b884d | ||
|
|
7b5ea2b29e | ||
|
|
43ad44ee48 |
@@ -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.
|
||||
|
||||
@@ -163,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
|
||||
|
||||
@@ -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