diff --git a/tests/test_panel_cards.py b/tests/test_panel_cards.py new file mode 100644 index 0000000..bb0daad --- /dev/null +++ b/tests/test_panel_cards.py @@ -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 \ No newline at end of file