2026-09-20 19:41:57 -04:00
|
|
|
"""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.
|
2026-09-23 09:40:52 -04:00
|
|
|
|
|
|
|
|
The party board additionally has render tests: they drive
|
|
|
|
|
frontend/tests/render-party-board.mjs, which imports the built bundle into a
|
|
|
|
|
jsdom window and reports the ordered plates the shadow DOM renders, so the
|
|
|
|
|
zero-config discovery contract is asserted against the bundle HACS ships.
|
2026-09-20 19:41:57 -04:00
|
|
|
"""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-09-23 09:40:52 -04:00
|
|
|
import json
|
|
|
|
|
import subprocess
|
2026-09-20 19:41:57 -04:00
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
REPO_ROOT = Path(__file__).parent.parent
|
2026-09-23 09:40:52 -04:00
|
|
|
FRONTEND_DIR = REPO_ROOT / "frontend"
|
|
|
|
|
FRONTEND_CARDS_DIR = FRONTEND_DIR / "src" / "cards"
|
2026-09-20 19:41:57 -04:00
|
|
|
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"
|
2026-09-23 09:40:52 -04:00
|
|
|
RENDER_HARNESS = FRONTEND_DIR / "tests" / "render-party-board.mjs"
|
2026-09-23 09:48:57 -04:00
|
|
|
STRATEGY_HARNESS = FRONTEND_DIR / "tests" / "render-strategy.mjs"
|
|
|
|
|
STRATEGY_SOURCE = FRONTEND_DIR / "src" / "strategy.ts"
|
2026-09-20 19:41:57 -04:00
|
|
|
|
|
|
|
|
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
|
2026-09-23 09:40:52 -04:00
|
|
|
assert "nestquest-quest-log-card" in bundle
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_party_board_discovers_children_from_the_household_roster() -> None:
|
|
|
|
|
"""Feature 20: with no child_order configured, the board discovers
|
|
|
|
|
the ordered child slugs from the household rollup's child_roster
|
|
|
|
|
attribute; explicit configuration still wins."""
|
|
|
|
|
source = _read(PARTY_BOARD)
|
|
|
|
|
assert "child_roster" in source
|
|
|
|
|
assert "sensor.nestquest_household_quests_due_today" in source
|
|
|
|
|
# Discovery only fills in for a MISSING child_order; a supplied
|
|
|
|
|
# order array is honoured verbatim before the discovered list is
|
|
|
|
|
# ever consulted.
|
|
|
|
|
assert "Array.isArray(order)" in source
|
|
|
|
|
assert "_discoveredChildSlugs()" in source
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Render tests (built bundle under jsdom) ---------------------------------
|
|
|
|
|
|
|
|
|
|
#: The seeded three-child household, in the API's sort order: Ada and
|
|
|
|
|
#: Bo share one daily quest (Bo is away today), Cory has none.
|
|
|
|
|
ROSTER = [
|
|
|
|
|
{"child_id": 1, "name": "Ada", "slug": "ada"},
|
|
|
|
|
{"child_id": 2, "name": "Bo", "slug": "bo"},
|
|
|
|
|
{"child_id": 3, "name": "Cory", "slug": "cory"},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _state(state: str, **attributes) -> dict:
|
|
|
|
|
return {"state": state, "attributes": attributes}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _three_child_states() -> dict:
|
|
|
|
|
"""The hass states a three-child household exposes.
|
|
|
|
|
|
|
|
|
|
Entity ids follow the documented naming contract
|
|
|
|
|
(design/ENTITIES-AND-SERVICES.md §1): the household rollup and the
|
|
|
|
|
per-child sensors slugged from the child names.
|
|
|
|
|
"""
|
|
|
|
|
return {
|
|
|
|
|
"sensor.nestquest_household_quests_due_today": _state(
|
|
|
|
|
"2",
|
|
|
|
|
children={"1": 1, "2": 1, "3": 0},
|
|
|
|
|
child_roster=ROSTER,
|
|
|
|
|
),
|
|
|
|
|
"sensor.nestquest_ada_quests_due_today": _state(
|
|
|
|
|
"1", child_name="Ada", present=True
|
|
|
|
|
),
|
|
|
|
|
"sensor.nestquest_ada_quests_completed_today": _state("0"),
|
|
|
|
|
"sensor.nestquest_ada_completion_pct_today": _state("0"),
|
|
|
|
|
"binary_sensor.nestquest_ada_present_today": _state("on"),
|
|
|
|
|
"sensor.nestquest_bo_quests_due_today": _state(
|
|
|
|
|
"1", child_name="Bo", present=False
|
|
|
|
|
),
|
|
|
|
|
"sensor.nestquest_bo_quests_completed_today": _state("0"),
|
|
|
|
|
"sensor.nestquest_bo_completion_pct_today": _state("0"),
|
|
|
|
|
"binary_sensor.nestquest_bo_present_today": _state("off"),
|
|
|
|
|
"sensor.nestquest_cory_quests_due_today": _state(
|
|
|
|
|
"0", child_name="Cory", present=True
|
|
|
|
|
),
|
|
|
|
|
"sensor.nestquest_cory_quests_completed_today": _state("0"),
|
|
|
|
|
"sensor.nestquest_cory_completion_pct_today": _state("100"),
|
|
|
|
|
"binary_sensor.nestquest_cory_present_today": _state("on"),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _render_party_board(config: dict, states: dict) -> dict:
|
|
|
|
|
"""Render the built bundle's party board and return its plates.
|
|
|
|
|
|
|
|
|
|
Drives frontend/tests/render-party-board.mjs: the harness imports
|
|
|
|
|
the committed bundle (the artifact HACS ships) into a jsdom window,
|
|
|
|
|
mounts the card with the given config and hass snapshot, and prints
|
|
|
|
|
the ordered plates its shadow DOM renders.
|
|
|
|
|
"""
|
|
|
|
|
assert BUNDLE_PATH.is_file(), "bundle missing: run cd frontend && npm run build"
|
|
|
|
|
assert RENDER_HARNESS.is_file()
|
|
|
|
|
spec = {
|
|
|
|
|
"bundle": str(BUNDLE_PATH),
|
|
|
|
|
"tag": "nestquest-party-board-card",
|
|
|
|
|
"config": config,
|
|
|
|
|
"hass": {"config": {"time_zone": "UTC"}, "states": states},
|
|
|
|
|
}
|
|
|
|
|
completed = subprocess.run(
|
|
|
|
|
["node", str(RENDER_HARNESS)],
|
|
|
|
|
input=json.dumps(spec),
|
|
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
|
|
|
|
timeout=120,
|
|
|
|
|
)
|
|
|
|
|
assert completed.returncode == 0, (
|
|
|
|
|
f"render harness failed: {completed.stderr}"
|
|
|
|
|
)
|
|
|
|
|
return json.loads(completed.stdout)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_party_board_renders_discovered_children_in_sort_order() -> None:
|
|
|
|
|
"""No child_order key: three plates render in the roster's sort
|
|
|
|
|
order (the coordinator snapshot's order), one per child."""
|
|
|
|
|
result = _render_party_board(
|
|
|
|
|
{"type": "custom:nestquest-party-board-card"},
|
|
|
|
|
_three_child_states(),
|
|
|
|
|
)
|
|
|
|
|
assert result["plates"] == [
|
|
|
|
|
{"name": "Ada", "kind": "present"},
|
|
|
|
|
{"name": "Bo", "kind": "away"},
|
|
|
|
|
{"name": "Cory", "kind": "present"},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_explicit_child_order_still_wins_over_discovery() -> None:
|
|
|
|
|
"""A supplied child_order keeps working: it overrides the household
|
|
|
|
|
roster's order and limits the plates to the configured slugs."""
|
|
|
|
|
result = _render_party_board(
|
|
|
|
|
{
|
|
|
|
|
"type": "custom:nestquest-party-board-card",
|
|
|
|
|
"child_order": ["bo", "ada"],
|
|
|
|
|
},
|
|
|
|
|
_three_child_states(),
|
|
|
|
|
)
|
|
|
|
|
assert [plate["name"] for plate in result["plates"]] == ["Bo", "Ada"]
|
2026-09-23 09:48:57 -04:00
|
|
|
assert "Cory" not in [plate["name"] for plate in result["plates"]]
|
|
|
|
|
|
|
|
|
|
# --- Dashboard strategy tests -------------------------------------------------
|
|
|
|
|
|
|
|
|
|
STRATEGY_NAME = "nestquest-party"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _generate_dashboard(config: dict, states: dict, url: str | None = None) -> dict:
|
|
|
|
|
"""Generate the strategy's dashboard from the built bundle.
|
|
|
|
|
|
|
|
|
|
Drives frontend/tests/render-strategy.mjs: the harness imports the
|
|
|
|
|
committed bundle into a jsdom window at the given dashboard URL and
|
|
|
|
|
prints the views ``window.customStrategies["nestquest-party"].
|
|
|
|
|
generate(config, hass)`` returns.
|
|
|
|
|
"""
|
|
|
|
|
assert BUNDLE_PATH.is_file(), "bundle missing: run cd frontend && npm run build"
|
|
|
|
|
assert STRATEGY_HARNESS.is_file()
|
|
|
|
|
spec = {
|
|
|
|
|
"bundle": str(BUNDLE_PATH),
|
|
|
|
|
"url": url or "http://homeassistant.local/nestquest/party",
|
|
|
|
|
"config": config,
|
|
|
|
|
"hass": {"config": {"time_zone": "UTC"}, "states": states},
|
|
|
|
|
}
|
|
|
|
|
completed = subprocess.run(
|
|
|
|
|
["node", str(STRATEGY_HARNESS)],
|
|
|
|
|
input=json.dumps(spec),
|
|
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
|
|
|
|
timeout=120,
|
|
|
|
|
)
|
|
|
|
|
assert completed.returncode == 0, (
|
|
|
|
|
f"strategy harness failed: {completed.stderr}"
|
|
|
|
|
)
|
|
|
|
|
return json.loads(completed.stdout)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_strategy_is_registered_on_the_bundle() -> None:
|
|
|
|
|
"""The strategy ships in the same bundle as the cards and registers
|
|
|
|
|
itself under window.customStrategies so a dashboard can use
|
|
|
|
|
strategy: { type: custom:nestquest-party }."""
|
|
|
|
|
source = _read(STRATEGY_SOURCE)
|
|
|
|
|
assert "customStrategies" in source
|
|
|
|
|
assert f'["{STRATEGY_NAME}"]' in source or f'"{STRATEGY_NAME}"' in source
|
|
|
|
|
bundle = _read(BUNDLE_PATH)
|
|
|
|
|
assert "nestquest-party" in bundle
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_strategy_generates_views_for_three_children() -> None:
|
|
|
|
|
"""For the seeded three-child household the strategy generates one
|
|
|
|
|
party-board view plus one quest-log view per child in roster order,
|
|
|
|
|
with COMPUTED paths: the board view at the dashboard root, each log
|
|
|
|
|
view at the child's slug so quest-log.ts's childSlugFromPath reads
|
|
|
|
|
the child from the URL's last segment."""
|
|
|
|
|
result = _generate_dashboard({}, _three_child_states())
|
|
|
|
|
views = result["views"]
|
|
|
|
|
assert len(views) == 4
|
|
|
|
|
assert [view["path"] for view in views] == [
|
|
|
|
|
"party",
|
|
|
|
|
"ada",
|
|
|
|
|
"bo",
|
|
|
|
|
"cory",
|
|
|
|
|
]
|
2026-09-23 09:59:57 -04:00
|
|
|
assert [view["title"] for view in views] == [
|
|
|
|
|
"The Party",
|
|
|
|
|
"Ada's Quest Log",
|
|
|
|
|
"Bo's Quest Log",
|
|
|
|
|
"Cory's Quest Log",
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_strategy_every_view_is_panel_type() -> None:
|
|
|
|
|
"""Every generated view carries ``type: "panel"``: Home Assistant
|
|
|
|
|
defaults an unspecified view to Masonry, which constrains the
|
|
|
|
|
1080px-tall NestQuest card to a narrow column (README's TouchHub
|
|
|
|
|
setup warns of exactly this); with a generated dashboard there is
|
|
|
|
|
no editor step left to set Panel by hand."""
|
|
|
|
|
result = _generate_dashboard({}, _three_child_states())
|
|
|
|
|
for view in result["views"]:
|
|
|
|
|
assert view["type"] == "panel"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_strategy_log_view_titles_use_the_roster_display_name() -> None:
|
|
|
|
|
"""Quest-log view titles come from the roster's ``name`` field (the
|
|
|
|
|
child's display name), not the lowercased entity-id slug; the slug
|
|
|
|
|
stays the view's path."""
|
|
|
|
|
result = _generate_dashboard(
|
|
|
|
|
{},
|
|
|
|
|
{
|
|
|
|
|
"sensor.nestquest_household_quests_due_today": _state(
|
|
|
|
|
"0",
|
|
|
|
|
child_roster=[
|
|
|
|
|
{"child_id": 7, "name": "Milo", "slug": "milo"},
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
views = result["views"]
|
|
|
|
|
assert len(views) == 2
|
|
|
|
|
assert views[1]["path"] == "milo"
|
|
|
|
|
assert views[1]["title"] == "Milo's Quest Log"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_strategy_passes_weather_entity_through_to_the_cards() -> None:
|
|
|
|
|
"""An explicit ``weather_entity`` in the strategy configuration is
|
|
|
|
|
passed through to the generated card configs (both the board and
|
|
|
|
|
the quest log render the weather dock when it is set)."""
|
|
|
|
|
result = _generate_dashboard(
|
|
|
|
|
{"weather_entity": "weather.home"},
|
|
|
|
|
_three_child_states(),
|
|
|
|
|
)
|
|
|
|
|
views = result["views"]
|
|
|
|
|
for view in views:
|
|
|
|
|
assert view["cards"][0]["weather_entity"] == "weather.home"
|
|
|
|
|
# And without it, no weather_entity key is emitted at all.
|
|
|
|
|
unset = _generate_dashboard({}, _three_child_states())
|
|
|
|
|
for view in unset["views"]:
|
|
|
|
|
assert "weather_entity" not in view["cards"][0]
|
2026-09-23 09:48:57 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_strategy_wires_navigation_between_the_views() -> None:
|
|
|
|
|
"""The board's quest_log_path and each log's board_path are computed
|
|
|
|
|
to the dashboard root the strategy renders at, so party-board.ts's
|
|
|
|
|
crest tap pushes <root>/<slug> and quest-log.ts's idle return goes
|
|
|
|
|
back to the board — neither path is configured by hand."""
|
|
|
|
|
dashboard_url = "http://homeassistant.local/nestquest"
|
|
|
|
|
root = "/nestquest"
|
|
|
|
|
result = _generate_dashboard(
|
|
|
|
|
{}, _three_child_states(), url=dashboard_url
|
|
|
|
|
)
|
|
|
|
|
views = result["views"]
|
|
|
|
|
board = views[0]["cards"][0]
|
|
|
|
|
assert board["type"] == "custom:nestquest-party-board-card"
|
|
|
|
|
assert board["quest_log_path"] == root
|
|
|
|
|
for slug, view in zip(("ada", "bo", "cory"), views[1:]):
|
|
|
|
|
assert view["cards"][0]["type"] == "custom:nestquest-quest-log-card"
|
|
|
|
|
assert view["cards"][0]["board_path"] == root
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_strategy_zero_children_still_generates_the_board() -> None:
|
|
|
|
|
"""With an empty roster (NestQuest not set up yet) the strategy
|
|
|
|
|
renders the board view alone — the board card shows its own
|
|
|
|
|
"not set up yet" notice for an empty roster — and never crashes."""
|
|
|
|
|
result = _generate_dashboard({}, {})
|
|
|
|
|
assert len(result["views"]) == 1
|
|
|
|
|
assert result["views"][0]["path"] == "party"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_strategy_config_overrides_still_apply() -> None:
|
|
|
|
|
"""Explicit strategy configuration wins where the design allows: an
|
|
|
|
|
explicit url_path pins the computed dashboard root."""
|
|
|
|
|
result = _generate_dashboard(
|
|
|
|
|
{"url_path": "/wallboard"},
|
|
|
|
|
_three_child_states(),
|
|
|
|
|
)
|
|
|
|
|
root = "/wallboard"
|
|
|
|
|
assert result["views"][0]["cards"][0]["quest_log_path"] == root
|
|
|
|
|
for view in result["views"][1:]:
|
|
|
|
|
assert view["cards"][0]["board_path"] == root
|