Compare commits

...
Author SHA1 Message Date
overseerandClaude Opus 5.5 900af61012 Add admin rule occurrence-preview route
POST /api/v1/admin/quest-definitions/occurrences-preview returns the
next N dates a supplied schedule rule fires on, computed by core
recurrence.occurrences_between over a bounded 366-day window from a
start_date (default the host's today via one _local_now read). The
rule goes through the same AdminRuleRequest + ScheduleRule.from_dict
path as create/edit; a rejected rule, non-strict start_date or count
outside 1..50 is 422. Auth is the router's single require_admin.

Task: 5f843564-8399-4afc-b5f6-023dff146a8d

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 21:30:41 -04:00
dev_agent 4b39af4b67 Feature 22 follow-up: Editable definition assignees (#196) (#197)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-23 21:10:39 -04:00
dev_agent 42ea5710d7 Feature 22: Admin Definitions Read and skip_on_away (#193, #194) (#195)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-23 20:52:06 -04:00
dev_agent a808f89993 Feature 21: Admin Today Snapshot API (#190) (#191)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-23 20:01:25 -04:00
orchestrator-agent 22f1a6861d Merge pull request 'Reconcile dev from main (release 0.6.0 commits)' (#186) from main into dev 2026-09-23 16:16:23 -04:00
overseer 939556a38a Merge pull request 'Release: Re-imagined architecture - Features 15, 16, 18, 20 (2026-09-23)' (#185) from release/2026-09-23-reimagining into main
Reviewed-on: #185
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-09-23 14:55:38 -04:00
overseer 8676bdc6ad release: correct the 0.6.0 notes - no containerization artifacts ship in this release 2026-09-23 14:35:01 -04:00
overseer 0ed35ab85a release: advance version to 0.6.0 and add the re-imagined architecture release notes 2026-09-23 14:29:12 -04:00
overseer c57d950101 merge: origin/main into 0.6.0 release (bring the architecture doc; keep dev versions and notes) 2026-09-23 14:27:56 -04:00
dev_agent 70ee4ec111 Make the missed-sweep watermark claim safe across processes (#184)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-23 13:06:40 -04:00
dev_agent 415ababc8e Automatically prepare releases for completed features (#182)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-23 13:02:44 -04:00
dev_agent ee8ef6dcac Feature 20: Zero-Config Panel Dashboard (#180)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-23 12:27:35 -04:00
overseer 0972761994 Merge pull request 'Add the re-imagined architecture document' (#140) from docs/architecture-reimagining into main
Reviewed-on: #140
2026-09-21 16:09:26 -04:00
overseer 10f365ef9f Add the re-imagined architecture document 2026-09-21 13:10:57 -04:00
45 changed files with 6110 additions and 336 deletions
+132 -56
View File
@@ -159,9 +159,50 @@ Sensors the automations read: `sensor.nestquest_<child>_quests_due_today`
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.
is simply a Home Assistant client. The path below is zero-config: the
integration registers its panel dashboard itself on setup, so no YAML
and no hand-built views are involved.
1. **Create a dedicated kiosk user.** In *Settings → People → Users*, add
1. **Install the integration and add the config entry.** Install
NestQuest via HACS or manually (see Installation above) and restart
Home Assistant. Then, in *Settings → Devices & Services*, click
**Add Integration**, search for **NestQuest**, and submit the flow —
it has no fields; it just creates the single config entry. Then open
the entry, click **Configure**, and fill the panel-plane API
connection in the options flow:
- **NestQuest API base URL** — the full `http://host:port` URL of
your NestQuest API service (default `http://127.0.0.1:8000`).
- **Panel service token** — the token the panel presents to the API
service as its Bearer credential. It may stay empty until you have
one; a pasted token must be unpadded.
The form's remaining fields (panel idle timeout, coordinator update
interval, last-good snapshot staleness) have defaults that serve the
panel and need no change here.
2. **The panel dashboard appears automatically.** On setup the
integration registers one storage-mode Lovelace dashboard: a sidebar
entry titled **NestQuest** at the URL path `/nestquest`, whose stored
config is nothing but the **`custom:nestquest-party` strategy**
(`strategy: { type: custom:nestquest-party }`). The strategy
generates the views at render time from the household roster:
- **The Party** — the party-board view at `/nestquest/party`; the
dashboard root `/nestquest` shows it as the first view.
- one quest-log view per child at `/nestquest/<child-slug>`
("Ada's Quest Log", …), in roster order.
Nothing is hand-configured: no YAML, no manual views, no
`child_order`, no `quest_log_path`/`board_path` — the board and the
quest logs compute their navigation paths from the dashboard's own
URL — and every generated view is already in **Panel** layout (see
the two constraints below). If the Lovelace surfaces are unavailable
(for example when Lovelace runs in YAML mode), setup logs a warning
and skips the registration; create the dashboard manually with the
same strategy as described in the manual alternative below.
3. **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`,
@@ -174,66 +215,101 @@ is simply a Home Assistant client.
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):
4. **Open the dashboard and point TouchHub at it.** Open
`http://<home-assistant>/nestquest` in a browser first and check the
party board renders with your children's crests. Then, in the
TouchHub launcher, add the **Home Assistant Lovelace** app to the
dock and paste the dashboard URL from step 2 — the dashboard root,
`/nestquest` (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.
- **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.
5. **Log the kiosk session in.** Sign in on the device as the dedicated
kiosk user from step 3, 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.
### The two constraints
- **Panel layout.** Every view must be in **Panel** (single-card)
layout: Home Assistant's default Masonry layout constrains the
1080px-tall NestQuest card to a narrow column, while Panel renders the
card full-screen at 1920×1080. The zero-config path already satisfies
the requirement — the strategy generates every view as
`type: panel` — while the manual alternative below requires setting
the layout by hand on each view (Edit Dashboard → pencil on the view
→ View type: Panel).
- **TouchHub Auto-Return.** TouchHub's **Auto-Return** must be greater
than the quest log's `idle_return_seconds` (default 40s) — recommend
**60s or higher** — so the launcher does not reclaim the wall device
before the quest log's own idle return navigates back to the party
board.
### Manual alternative: build the dashboard by hand
If you prefer not to use the zero-config dashboard above — or its
registration was skipped — configure a Lovelace dashboard whose **URL
slug is `nestquest`** (Settings → Dashboards → Edit → URL slug)
yourself. Two options, in decreasing order of convenience:
- **Use the strategy.** Give the dashboard
```yaml
strategy:
type: custom:nestquest-party
```
and no views of your own — the strategy generates the party-board and
quest-log views (each already in Panel layout) from the household
roster at render time. Optional `url_path` and `weather_entity` keys
may sit beside `strategy:`.
- **Build the two views by hand.** 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 —
see the two constraints above), `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.
The dashboard contains only the NestQuest panel card for each view —
no other cards, no admin controls. Each view's layout MUST be set to
**Panel** — see the two constraints above for why; the strategy
generates it for you, hand-built views do not get it for free.
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.
## Developer Setup and Testing
This project uses [`uv`](https://github.com/astral-sh/uv) for fast Python package and dependency management.
+129 -21
View File
@@ -1,27 +1,135 @@
# NestQuest Release Notes
## Unreleased
## Version 0.6.0 — 2026-09-23
- **Storage layer extraction (task d0b691d8):** the SQLite storage layer
(schema, migrations, db, store, const, recurrence, DAOs) is bundled
under `custom_components/nestquest/core/`, with the integration's
original module names preserved as re-export shims.
- **Logger namespace change (breaking for logging configuration):** the
migrations runner now logs under
`custom_components.nestquest.core.migrations` instead of
`custom_components.nestquest.migrations`. Users who tuned the old
logger name (e.g. in `logger.yaml` or `configuration.yaml`) must
update the entry to keep that level, as the old name no longer
receives migration log output.
- **Additional logger namespace moves:** the children registry and the
admin allowlist business layers now log under
`custom_components.nestquest.core.children` and
`custom_components.nestquest.core.admin_allowlist` respectively
(moved with the engines/business-layer extraction, task 510c1f78).
The parent `custom_components.nestquest` logger still catches both,
so users who tune the parent rather than the leaf names are
unaffected; only users who pinned a leaf logger name by its old
spelling need to update.
### Scope
Sixth release of NestQuest, promoting the re-imagined architecture
(Features 15, 16, 18 and 20) and the foreground development-orchestration
tooling from `dev` onto 0.5.0. This is a major architectural change: the
Home Assistant integration becomes a thin client of a standalone NestQuest
API service that owns the database.
- **Feature 15 — Domain Core Extraction:** all domain code (schema,
migrations, database access, the DAOs, the pure recurrence/presence
engines, materialization, completion, the SSE-free event payload
builders, the panel snapshot builder, the children/definitions/allowlist
business layers, and an explicit settings object) now lives in a
Home-Assistant-free package bundled inside the integration at
`custom_components/nestquest/core/`. The SQLite schema and migration
versions are unchanged, so an existing `nestquest.db` opens without
migration. Covered by a no-Home-Assistant end-to-end smoke test.
(Tasks d0b691d8, 510c1f78, 4dd8f870, 628473ad.)
- **Feature 16 — NestQuest API Service:** a standalone FastAPI service
(`api/`) that imports `core/` and is the only writer of the database.
Panel plane (`/api/v1/panel/*`) is authenticated by a static service
token: household snapshot and an idempotent complete route. Admin plane
(`/api/v1/admin/*`): children, quest definitions, presence
schedule/override, uncomplete, regenerate, history query + CSV export,
and settings — authenticated by an Authentik OIDC JWT (issuer, audience,
JWKS signature and expiry) plus membership in `nestquest-admins`, with
the panel service token rejected outright. A server-sent-events stream
publishes the quest-completed, quest-uncompleted, quest-missed and
child-day-complete transitions; the missed sweep runs in the API with a
cross-process-safe watermark. `/health` and the OpenAPI document are
served. (Tasks 042dfc5c, ae7e90ae, 1860ef76, 4d7bae00, 44c93cfd,
e70e6bfb, 0f2afb29, e4845baa, b586a2f1, e4a9d31b, da0226b3, 2d2dda53,
2b3de7e5, 9047a97b, 9a118da9, 058c7b69, a232c4f5.)
- **Feature 18 — HA Integration as API Client:** the integration no longer
owns a database. It polls `GET /api/v1/panel/snapshot` and builds the
existing sensors (entity ids and per-child instance payload shape
unchanged), subscribes to the SSE stream and re-fires the four HA bus
events, proxies `nestquest.complete_quest` to the panel complete route,
and caches the last-good snapshot so a brief API outage does not blank
the panel. The HA admin services and the service permission gate are
removed; only the panel completion path remains. (Tasks 37d5e806,
6e70d166, b1ed9c45, 47c7c90e, d25b875f, d04f5bf3, ad67fc44.)
- **Feature 20 — Zero-Config Panel Dashboard:** the panel installs with no
hand-written card YAML and no manual dashboard creation. The cards
auto-discover children from a household entity attribute (ordered by the
API's `sort_order`), a Lovelace dashboard strategy generates the
party-board view and one quest-log view per child at render time with
computed navigation paths, and the integration registers the dashboard
on first setup. Navigation and the panel transitions match the design
mockups. (Tasks 1b6d91d2, c859af23, c2d4dd8e, 107b8fbe, 50067c1f,
fc69c940, 67775be9.)
- **Development orchestration tooling:** durable OpenCode controller and
role launchers, the foreground terminal orchestrator, the independent
review/approval gates, and the automatic release-preparation policy.
### Requirements
- Minimum Home Assistant version: **2024.6.0**.
- A running NestQuest API service, reachable at the base URL and panel
service token configured in the integration options. The API service
source ships in `api/`; there is no container image, compose file or
deployment runbook in this release, and Feature 17 — the deployment
(TLS reverse proxy, Authentik, network separation, backups) — is **not**
part of this release and must be completed before the integration is
functional. See `docs/ARCHITECTURE.md` for the target design.
### Breaking changes
- **The HA integration no longer owns a database.** It reads exclusively
from the NestQuest API. The local `nestquest.db` is no longer opened,
migrated or written by the integration (the file is left untouched on
disk). Without a reachable API the entities become unavailable after the
configured staleness threshold; the panel serves the last-good snapshot
in the meantime.
- **HA admin services are removed** (`uncomplete`, quest-definition
create/update/set-active, presence pattern/override, CSV export,
`manage_child`, `regenerate`) and the HA-side permission gate is gone.
Admin operations move to the API, and — once Feature 19 lands — the
admin PWA. Any automation that called an admin service must be
repointed at the API.
- **Logger namespaces moved** under `custom_components.nestquest.core.*`:
the migrations runner now logs under
`custom_components.nestquest.core.migrations` (previously
`custom_components.nestquest.migrations`), and the children registry and
admin allowlist log under `custom_components.nestquest.core.children` and
`custom_components.nestquest.core.admin_allowlist`. The parent
`custom_components.nestquest` logger still catches all three; only users
who pinned a leaf logger name by its old spelling must update.
- **The panel installs with no configuration.** `child_order`,
`quest_log_path` and `board_path` are no longer required (an explicit
value still wins). The integration registers a `custom:nestquest-party`
dashboard on setup; point TouchHub at it.
- **Schema is unchanged at version 7** — no migration runs on upgrade.
### Known Limitations
- **Feature 17 (API deployment and identity) is not in this release.** The
API service source ships in `api/`, but no container image, compose
file, TLS reverse proxy, Authentik application/provider/`nestquest-admins`
group, proven network separation of the panel routes, or backup/restore
is included. Feature 17's containerization groundwork was completed on
its own feature branch, which is **not** merged into `dev` and therefore
is not part of this release. Until that deployment is completed the
integration has nothing to talk to.
- **Feature 19 (admin PWA) is not in this release.** There is no admin UI
in 0.6.0; admin operations are reachable only through the API directly.
- Moving the household database from Home Assistant to the API box is a
manual file copy (the schema is unchanged).
- Single household; single API service.
### Rollback
Schema version is unchanged (7), so no migration is involved in either
direction. Nevertheless **back up `nestquest.db` (plus `-wal`/`-shm`)
before upgrading**: 0.6.0 never touches it, and a downgrade to 0.5.0
resumes using exactly that file.
**HACS installs:**
1. Downgrade via HACS (HACS > Integrations > NestQuest > Redownload > 0.5.0).
2. Restart Home Assistant.
**Manual installs:**
1. Replace `custom_components/nestquest/` with the 0.5.0 tree.
2. Restart Home Assistant.
**Repository operators:** git-revert the 0.6.0 release merge on `main`.
## Version 0.5.0 — 2026-09-18
+324 -18
View File
@@ -30,10 +30,10 @@ hand-rolled here), and the call goes to
``set_quest_definition_active``. The title policy, the window-name
and due-time policy, the assignee checks, the whole-set window
replacement and the never-hard-delete policy (deactivation is the only
removal path) all live in the core. Assignment is NOT settable
through the API: the core's edit path takes no assignees. The edit
route forwards only SUPPLIED fields (``exclude_unset``), like the
children edit.
removal path) all live in the core. The edit route forwards only
SUPPLIED fields (``exclude_unset``), like the children edit; a supplied
``assignee_child_ids`` list REPLACES the whole assignee set inside the
core edit's one transaction (and its one regeneration).
The presence routes follow the same pattern over
:mod:`nestquest_core.presence_management` — the ONE presence write
@@ -116,6 +116,22 @@ owns every rule so the two routes cannot drift:
a ``Content-Disposition`` attachment filename and the documented,
stable header row (Admin spec §5 event-row fields).
The snapshot route (task ef3f4877) is the admin-plane twin of the
panel's ``GET /api/v1/panel/snapshot`` for the admin PWA's Today tab —
the PWA is internet-facing and never holds the panel service token
(D-012), so it reads the same snapshot here:
- ``GET /snapshot`` threads ONE clock read (:func:`_local_now`) into
:func:`nestquest_core.snapshot.build_snapshot` and shapes each
child's instances through
:func:`nestquest_core.snapshot.instance_payload` with
``include_missed=True`` — admin payloads KEEP missed instances with
their ``missed`` state (D-009), where the panel omits them. The
body is the panel's documented shape (``today_iso``, ``cycle_day``,
``children`` with the per-child presence, rollup counts and
``instances``); the builder, the state derivation and the shaping
all live in the core.
The settings routes (task 2b3de7e5) follow the same pattern over
:mod:`nestquest_core.settings_store` — the API's OWN durable settings
store. The API now OWNS settings in the database (a validated JSON
@@ -156,6 +172,33 @@ integration's rollover listener shares:
read-only-over-the-domain-tables guarantee all live in the core; the
response reports how many transitions were published.
The occurrence-preview route (task 5f843564) lets the admin PWA's
Definitions edit sheet show the next dates a rule fires on WITHOUT
reimplementing the recurrence rules client-side:
- ``POST /quest-definitions/occurrences-preview`` takes the SAME
``rule`` object create/edit take (:class:`AdminRuleRequest`, built
through the SAME :func:`_rule_from_request` / core ``from_dict``
validation, so a rejected rule is 422 exactly as on create), an
optional ``start_date`` (default: the API host's local ``today``,
ONE :func:`_local_now` clock read) and an optional ``count`` (1..50,
default 10). The dates come from ONE
:func:`nestquest_core.recurrence.occurrences_between` call — the
same engine the materializer walks — so the preview can only agree
with what would be generated. The scan is BOUNDED server-side: the
window is ``[start_date, start_date + 366 days]`` inclusive,
whatever the caller sends, so every rule shape (a yearly rule
included) shows at least its next occurrence, and a caller can never
ask for an unbounded walk. Any strict ``start_date`` is accepted
rather than clamped or range-limited — the fixed window already
bounds the work — EXCEPT one whose window would reach 9999-12-31,
which is 422 (the core's day walk cannot step past the calendar's
last date). ``start_date`` strictness
(``YYYY-MM-DD``) is the core's own date validator; a non-strict or
unparseable value is 422. A ``count`` outside 1..50 is the body
model's 422 before any handler code runs. Nothing is read or
written: the route touches no database.
Error mapping (every children, quest-definition and presence route,
deliberately narrow):
@@ -166,8 +209,9 @@ deliberately narrow):
set_active on an unknown id) is mapped to 404.
- A core ``ValueError`` reporting a NON-EXISTENT DEFINITION (edit or
set_active on an unknown id — the core prefixes that error with
``definition_id:``) is mapped to 404. Create's rejected-assignee
error names a child from the BODY, not a path id, so it maps to 422.
``definition_id:``) is mapped to 404. Create's and edit's
rejected-assignee error names a child from the BODY, not a path id,
so it maps to 422.
- Every OTHER core ``ValueError`` — an empty display name, an explicit
``null`` colour/avatar_ref (clearing is not supported), a reorder
list that is not a complete permutation of the children (missing,
@@ -235,7 +279,13 @@ import datetime
from typing import Annotated, Literal, NoReturn
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel, field_validator, model_validator
from pydantic import (
BaseModel,
StrictBool,
StrictInt,
field_validator,
model_validator,
)
from api import transitions as api_transitions
from api.auth import require_admin
@@ -254,6 +304,7 @@ from api.nestquest_core import (
core_recurrence,
core_settings,
core_settings_store,
core_snapshot,
core_sweep,
)
@@ -346,11 +397,18 @@ class AdminQuestDefinitionResponse(BaseModel):
description: str | None
icon: str | None
is_active: bool
skip_on_away: bool
rule: AdminRuleResponse
assignees: list[AdminAssigneeResponse]
windows: list[AdminDefinitionWindowResponse]
class AdminQuestDefinitionListResponse(BaseModel):
"""The definitions list: every definition, active AND inactive, by id."""
definitions: list[AdminQuestDefinitionResponse]
class AdminChildCreateRequest(BaseModel):
"""The body of ``POST /api/v1/admin/children``.
@@ -421,24 +479,67 @@ class AdminRuleRequest(BaseModel):
end_date: str | None = None
#: Upper bound on ``count`` for the occurrence-preview route.
_PREVIEW_MAX_COUNT = 50
#: Days after ``start_date`` the occurrence-preview window spans
#: (inclusive), so a yearly rule always shows its next occurrence.
_PREVIEW_WINDOW_DAYS = 366
class AdminOccurrencesPreviewRequest(BaseModel):
"""The body of ``POST /api/v1/admin/quest-definitions/occurrences-preview``.
``rule`` is the SAME object create/edit take. ``start_date`` is an
optional strict ``YYYY-MM-DD`` (validated by the core; default the
API host's today). ``count`` is a strict JSON integer in
1..``_PREVIEW_MAX_COUNT`` — anything else is 422 before any handler
code runs.
"""
rule: AdminRuleRequest
start_date: str | None = None
count: StrictInt = 10
@field_validator("count")
@classmethod
def _count_in_range(cls, value: int) -> int:
"""Reject a count outside 1.._PREVIEW_MAX_COUNT."""
if not 1 <= value <= _PREVIEW_MAX_COUNT:
raise ValueError(
f"count must be between 1 and {_PREVIEW_MAX_COUNT}"
)
return value
class AdminOccurrencesPreviewResponse(BaseModel):
"""The next occurrence dates (``YYYY-MM-DD``, ascending)."""
dates: list[str]
class AdminQuestDefinitionCreateRequest(BaseModel):
"""The body of ``POST /api/v1/admin/quest-definitions``.
``title``, ``rule``, ``assignee_child_ids`` and ``windows`` are
required (the core rejects an empty title, a non-list or empty
assignee/window list, and unknown window names); ``description``
and ``icon`` are optional and default to NULL through the core.
``windows`` entries are a window name (``morning``) or a two-item
and ``icon`` are optional and default to NULL through the core, and
``skip_on_away`` is optional and defaults to true (no instance on a
date the child is absent). ``windows`` entries are a window name (``morning``) or a two-item
``[window, due_time]`` pair whose due time is a strict 24-hour
HH:MM (or ``null`` for none) — both forms the core normalizes.
Assignee ids are strict JSON integers: a boolean, float or numeric
string is 422, never coerced to a child id.
"""
title: str
rule: AdminRuleRequest
assignee_child_ids: list[int]
assignee_child_ids: list[StrictInt]
windows: list[str | tuple[str, str | None]]
description: str | None = None
icon: str | None = None
skip_on_away: StrictBool = True
class AdminQuestDefinitionEditRequest(BaseModel):
@@ -450,10 +551,13 @@ class AdminQuestDefinitionEditRequest(BaseModel):
field stays unchanged. An explicit ``null`` ``description``/
``icon`` IS passed and CLEARS the stored value (the core supports
clearing here, unlike the children edit); an explicit ``null``
``rule`` or ``windows`` is passed and rejected by the core. A
supplied ``windows`` list REPLACES the whole window set, and
``assignees`` are deliberately absent — assignment is not settable
through this route.
``rule``, ``windows`` or ``assignee_child_ids`` is passed and
rejected by the core. A supplied ``windows`` list REPLACES the
whole window set, and a supplied ``assignee_child_ids`` list
REPLACES the whole assignee set (non-empty, every id an existing
active child — create's contract; a rejection changes nothing). An
omitted (or ``null``) ``skip_on_away`` keeps the stored value.
Assignee ids are strict JSON integers, as on create.
"""
title: str | None = None
@@ -461,6 +565,8 @@ class AdminQuestDefinitionEditRequest(BaseModel):
icon: str | None = None
rule: AdminRuleRequest | None = None
windows: list[str | tuple[str, str | None]] | None = None
skip_on_away: StrictBool | None = None
assignee_child_ids: list[StrictInt] | None = None
class AdminQuestDefinitionActiveRequest(BaseModel):
@@ -683,6 +789,59 @@ class AdminMissedSweepResponse(BaseModel):
fired: int
class AdminInstancePayload(BaseModel):
"""One instance in the admin snapshot payload.
Mirrors :func:`nestquest_core.snapshot.instance_payload` with
``include_missed=True``: ``state`` is ``open`` | ``completed`` |
``missed`` (admin payloads keep missed instances, D-009),
``on_time`` passes the completion event's flag through verbatim
(``None`` unless completed), and ``completed_at`` is the event's
UTC timestamp when completed.
"""
id: int
definition_id: int
child_id: int
title: str
icon: str | None
window: str
due_time: str | None
state: str
overdue: bool
completed_at: str | None
on_time: bool | None
class AdminChildSnapshotPayload(BaseModel):
"""One child's day: presence, rollup counts, and today's instances."""
child_id: int
child_name: str
present: bool
#: ISO date of the child's next present day while they are away;
#: ``None`` when the child is present today.
next_present: str | None
due_today: int
completed_today: int
remaining_today: int
completion_pct: int
instances: list[AdminInstancePayload]
class AdminSnapshotResponse(BaseModel):
"""Today's whole household snapshot for the admin Today tab.
The panel snapshot's shape (``today_iso``, ``cycle_day``,
``children`` in the household's sort order), with missed instances
kept in each child's ``instances``.
"""
today_iso: str
cycle_day: int
children: list[AdminChildSnapshotPayload]
#: 404 detail for a child id the core layer reports as non-existent.
_CHILD_NOT_FOUND_DETAIL = "Child not found"
@@ -845,6 +1004,40 @@ def _raise_sweep_error(error: ValueError) -> NoReturn:
raise HTTPException(status_code=422, detail=str(error)) from error
def _raise_preview_error(error: ValueError) -> NoReturn:
"""Map a core recurrence ``ValueError`` from the preview route to 422.
The preview route has no path ids, so it has no 404 case: a
rejected rule (:class:`~nestquest_core.recurrence.RuleValidationError`)
or a non-strict ``start_date`` is 422 with the core's message as
the detail. Only ``ValueError`` is caught, so an unexpected
failure re-raises rather than becoming a 4xx.
"""
raise HTTPException(status_code=422, detail=str(error)) from error
def _preview_window_end(start_date: str) -> str:
"""Return the preview window's inclusive end for ``start_date``.
``start_date`` + :data:`_PREVIEW_WINDOW_DAYS`. Parsing here only
locates the window; the STRICT ``YYYY-MM-DD`` check stays the
core's — :func:`~nestquest_core.recurrence.occurrences_between`
re-validates the same string and rejects any lenient form this
parse accepted. Raises ``ValueError`` for an unparseable value and
for a start whose window would reach the calendar's last date (the
core's day walk steps one day PAST its end, which overflows there).
"""
start = datetime.date.fromisoformat(start_date)
if datetime.date.max - start <= datetime.timedelta(
days=_PREVIEW_WINDOW_DAYS
):
raise ValueError(
f"start_date {start_date!r} is too close to the end of the "
"calendar for a preview window"
)
return (start + datetime.timedelta(days=_PREVIEW_WINDOW_DAYS)).isoformat()
def _local_now() -> datetime.datetime:
"""Return the API host's local time as ONE timezone-aware clock read.
@@ -887,6 +1080,7 @@ def _quest_definition_response(
description=bundle.definition.description,
icon=bundle.definition.icon,
is_active=bundle.definition.is_active,
skip_on_away=bundle.definition.skip_on_away,
rule=_rule_response(bundle.rule),
assignees=[
AdminAssigneeResponse(id=child.id, display_name=child.display_name)
@@ -1098,6 +1292,32 @@ async def admin_reorder_children(
return {"status": "ok"}
@router.get(
"/quest-definitions",
summary="List quest definitions",
response_model=AdminQuestDefinitionListResponse,
)
async def admin_list_quest_definitions(
request: Request,
) -> AdminQuestDefinitionListResponse:
"""Return every quest definition in rising id order.
Requires a valid admin JWT (the router's shared
:func:`~api.auth.require_admin` dependency — the ONE check; this
handler performs NO auth of its own). Thin adapter: one
:func:`nestquest_core.quest_definitions.list_all_definitions` call,
active AND inactive definitions included (the admin screen manages
both), each serialized exactly as the create/edit responses are.
"""
state: DatabaseState = request.app.state.db
bundles = await core_quest_definitions.list_all_definitions(
state.database
)
return AdminQuestDefinitionListResponse(
definitions=[_quest_definition_response(bundle) for bundle in bundles]
)
@router.post(
"/quest-definitions",
summary="Create a quest definition",
@@ -1132,12 +1352,49 @@ async def admin_create_quest_definition(
body.windows,
description=body.description,
icon=body.icon,
skip_on_away=body.skip_on_away,
)
except ValueError as error:
_raise_quest_definition_error(error)
return _quest_definition_response(bundle)
@router.post(
"/quest-definitions/occurrences-preview",
summary="Preview the next occurrence dates of a schedule rule",
response_model=AdminOccurrencesPreviewResponse,
)
async def admin_preview_occurrences(
body: AdminOccurrencesPreviewRequest,
) -> AdminOccurrencesPreviewResponse:
"""Return the first ``count`` dates the supplied rule fires on.
Requires a valid admin JWT (the router's shared
:func:`~api.auth.require_admin` dependency — the ONE check; this
handler performs NO auth of its own). Thin adapter: the ``rule``
becomes ONE :class:`~nestquest_core.recurrence.ScheduleRule`
(:func:`_rule_from_request`, the create/edit path), and the dates
come from ONE :func:`nestquest_core.recurrence.occurrences_between`
call over the bounded window ``[start_date, start_date + 366
days]`` — ``start_date`` defaulting to ONE :func:`_local_now` clock
read — truncated to ``count``. Every core ``ValueError`` is 422
(:func:`_raise_preview_error`). No database access.
"""
start_date = (
body.start_date
if body.start_date is not None
else _local_now().date().isoformat()
)
try:
rule = _rule_from_request(body.rule)
dates = core_recurrence.occurrences_between(
rule, start_date, _preview_window_end(start_date)
)
except ValueError as error:
_raise_preview_error(error)
return AdminOccurrencesPreviewResponse(dates=dates[: body.count])
@router.patch(
"/quest-definitions/{definition_id}",
summary="Edit a quest definition",
@@ -1157,10 +1414,12 @@ async def admin_edit_quest_definition(
field is never passed to
:func:`nestquest_core.quest_definitions.edit_quest_definition` and
stays unchanged; a supplied ``windows`` list replaces the WHOLE
window set in the core, and a supplied ``rule`` is rebuilt into a
ScheduleRule (:func:`_rule_from_request`, validated by the model).
An explicit ``null`` ``rule``/``windows`` reaches the core as None
and is rejected there (422), while an explicit ``null``
window set in the core, a supplied ``assignee_child_ids`` list
replaces the WHOLE assignee set in the same core transaction, and a
supplied ``rule`` is rebuilt into a ScheduleRule
(:func:`_rule_from_request`, validated by the model). An explicit
``null`` ``rule``/``windows``/``assignee_child_ids`` reaches the
core as None and is rejected there (422), while an explicit ``null``
``description``/``icon`` clears the stored value — the core edit
path supports clearing, unlike the children edit. An unknown
definition id is mapped to 404 and any other core ``ValueError``
@@ -1729,4 +1988,51 @@ async def admin_run_missed_sweep(
return AdminMissedSweepResponse(fired=len(events))
@router.get(
"/snapshot",
summary="Today's household snapshot",
response_model=AdminSnapshotResponse,
)
async def admin_snapshot(request: Request) -> AdminSnapshotResponse:
"""Return today's full household snapshot for the admin Today tab.
Requires a valid admin JWT (the router's shared
:func:`~api.auth.require_admin` dependency — the ONE check; this
handler performs NO auth of its own). Thin adapter: ONE clock read
(:func:`_local_now`) goes into
:func:`nestquest_core.snapshot.build_snapshot` against the live
database, and each child's instances are shaped with
``instance_payload(include_missed=True)`` — missed instances are
KEPT in the admin payload (D-009) — into
:class:`AdminSnapshotResponse`.
"""
state: DatabaseState = request.app.state.db
snapshot = await core_snapshot.build_snapshot(
state.database, core_settings.NestQuestSettings(), _local_now()
)
return AdminSnapshotResponse(
today_iso=snapshot.today_iso,
cycle_day=snapshot.cycle_day,
children=[
AdminChildSnapshotPayload(
child_id=child.child_id,
child_name=child.child_name,
present=child.present,
next_present=child.next_present,
due_today=child.due_today,
completed_today=child.completed_today,
remaining_today=child.remaining_today,
completion_pct=child.completion_pct,
instances=[
AdminInstancePayload.model_validate(instance)
for instance in core_snapshot.instance_payload(
child.instances, include_missed=True
)
],
)
for child in snapshot.children
],
)
__all__ = ["router"]
+10 -1
View File
@@ -5,7 +5,8 @@ SQLite file, the migrations, and every business rule. Setup builds the
entry's ONE shared API client, the coordinator that polls the panel
snapshot through it, and the SSE subscription that re-fires the API's
transition frames on the HA bus, registers the single
``nestquest.complete_quest`` proxy service, and forwards the sensor and
``nestquest.complete_quest`` proxy service, registers the zero-config
panel dashboard (Feature 20), and forwards the sensor and
binary_sensor platforms.
"""
from __future__ import annotations
@@ -29,6 +30,7 @@ from .const import (
PLATFORMS,
)
from .coordinator import NestQuestCoordinator, coordinator_client_from_entry
from .dashboard import async_register_dashboard
from .frontend import async_register_frontend
from .services import async_deregister_services, async_register_services
from .sse import NestQuestEventStream
@@ -87,6 +89,13 @@ async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up NestQuest from a config entry."""
await async_register_frontend(hass)
# The zero-config panel dashboard (Feature 20): register the
# storage-mode strategy dashboard if no dashboard under the stable
# url_path exists yet. Unavailability of the Lovelace dashboards
# collection (YAML mode, lovelace not loaded) logs a warning and
# NEVER fails setup; an existing dashboard — ours or the user's —
# is left untouched, so a reload cannot duplicate it.
await async_register_dashboard(hass)
hass.data.setdefault(DOMAIN, {})
async def _async_update_listener(
+12
View File
@@ -43,6 +43,18 @@ DOMAIN_SERVICES: tuple[str, ...] = (
SQLITE_DB_FILENAME = "nestquest.db"
# Zero-config panel dashboard (Feature 20): ONE storage-mode Lovelace
# dashboard registered on setup whose config carries the bundle's
# ``custom:nestquest-party`` strategy (frontend/src/strategy.ts) — the
# views are generated at render time from the household roster. The
# url_path IS the domain: the stable key idempotency is keyed on (a
# reload or a second entry never duplicates it, and a dashboard already
# registered under it — ours or the user's — is left untouched).
DASHBOARD_URL_PATH = DOMAIN
DASHBOARD_TITLE = "NestQuest"
DASHBOARD_ICON = "mdi:party-popper"
DASHBOARD_STRATEGY_TYPE = "custom:nestquest-party"
# Configuration keys and default values
CONF_HORIZON_DAYS = "horizon_days"
DEFAULT_HORIZON_DAYS = 14
+35 -1
View File
@@ -41,4 +41,38 @@ class MetaStateDao:
f"INSERT INTO {_TABLE} (key, value) VALUES (?, ?) "
"ON CONFLICT (key) DO UPDATE SET value = excluded.value",
(key, value),
)
)
async def claim(self, key: str, value: str) -> bool:
"""Atomically set ``key`` to ``value`` unless it already is.
The single upsert statement carries a ``WHERE`` guard on the
conflict path (``t.value IS NOT excluded.value``), so the
read-compare-write is ONE SQLite statement — atomic at the
DATABASE level, not just within one process: two connections
(two processes sharing one SQLite file) cannot both observe
"not yet set" and both write, because the second writer's
statement re-evaluates the guard against the first writer's
committed value and matches zero rows.
Returns True only when THIS call changed the row: the first
claimant wins and every later claimant of the same value
returns False. A DIFFERENT value always wins (an advance, e.g.
the sweep watermark moving to a new date) and re-arms the
same-value loser check for the new value.
Runs under the shared connection lock and inside one
transaction, like :meth:`set`; ``cursor.rowcount`` (1 for a
matched write, 0 for a guarded no-op) is captured by the
wrapper on the executor thread, so no sqlite3 object crosses
back to the event loop.
"""
async with _connection_lock(self._database):
async with self._database.transaction():
result = await self._database.execute(
f"INSERT INTO {_TABLE} (key, value) VALUES (?, ?) "
"ON CONFLICT (key) DO UPDATE SET value = excluded.value "
f"WHERE {_TABLE}.value IS NOT excluded.value",
(key, value),
)
return result.rowcount == 1
+118 -13
View File
@@ -105,6 +105,19 @@ def _validate_window(window: str) -> None:
)
def _validate_skip_on_away(value: object) -> int:
"""Return ``value`` as 0/1, raising ValueError for anything else.
The fresh-schema CHECK constraint enforces this in SQLite, but a
migrated database gained the column through ``ADD COLUMN`` without
it, so every DAO write path validates here instead. Accepts a bool
or the ints 0/1; floats, strings and other ints are rejected.
"""
if not isinstance(value, int) or value not in (0, 1):
raise ValueError(f"skip_on_away must be 0 or 1, got {value!r}")
return int(value)
@dataclass(frozen=True)
class ScheduleRuleRecord:
"""One row of ``schedule_rules``."""
@@ -152,6 +165,7 @@ class QuestDefinitionRecord:
due_time: str | None
is_active: bool
created_at: str
skip_on_away: bool
@dataclass(frozen=True)
@@ -187,7 +201,7 @@ _RULE_COLUMNS = (
)
_DEFINITION_COLUMNS = (
"id, title, description, icon, schedule_rule_id, due_time, "
"is_active, created_at"
"is_active, created_at, skip_on_away"
)
_WINDOW_COLUMNS = "definition_id, window, due_time"
@@ -216,6 +230,7 @@ def _definition_from_row(row: tuple) -> QuestDefinitionRecord:
due_time=row[5],
is_active=bool(row[6]),
created_at=row[7],
skip_on_away=bool(row[8]),
)
@@ -632,6 +647,7 @@ class QuestDefinitionsDao:
due_time: str | None = None,
is_active: bool = True,
assignee_child_ids: list[int] | None = None,
skip_on_away: bool = True,
) -> QuestDefinitionRecord:
"""Insert one definition and return the record as stored.
@@ -644,6 +660,7 @@ class QuestDefinitionsDao:
empty list or None creates an unassigned definition, which
callers may fill via :meth:`add_assignee`.
"""
skip_on_away_value = _validate_skip_on_away(skip_on_away)
async with _connection_lock(self._database):
async with self._database.transaction():
await self._validate_rule_exists(schedule_rule_id)
@@ -652,7 +669,8 @@ class QuestDefinitionsDao:
result = await self._database.execute(
"INSERT INTO quest_definitions (title, description, "
"icon, schedule_rule_id, due_time, is_active, "
"created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
"created_at, skip_on_away) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
title,
description,
@@ -661,6 +679,7 @@ class QuestDefinitionsDao:
due_time,
int(is_active),
created_at,
skip_on_away_value,
),
)
definition_id = result.lastrowid
@@ -684,6 +703,7 @@ class QuestDefinitionsDao:
*,
description: str | None = None,
icon: str | None = None,
skip_on_away: bool = True,
) -> QuestDefinitionSnapshot:
"""Insert a rule, definition, assignees and windows atomically.
@@ -713,6 +733,7 @@ class QuestDefinitionsDao:
_validate_date(schedule_rule.start_date, "start_date")
if schedule_rule.end_date is not None:
_validate_date(schedule_rule.end_date, "end_date")
skip_on_away_value = _validate_skip_on_away(skip_on_away)
async with _connection_lock(self._database):
async with self._database.transaction():
for child_id in assignee_child_ids:
@@ -721,8 +742,18 @@ class QuestDefinitionsDao:
result = await self._database.execute(
"INSERT INTO quest_definitions (title, description, "
"icon, schedule_rule_id, due_time, is_active, "
"created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
(title, description, icon, rule_id, None, 1, created_at),
"created_at, skip_on_away) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
title,
description,
icon,
rule_id,
None,
1,
created_at,
skip_on_away_value,
),
)
definition_id = result.lastrowid
for child_id in assignee_child_ids:
@@ -764,8 +795,10 @@ class QuestDefinitionsDao:
icon: str | None | object = _UNSET,
rule: ScheduleRuleStorage | object = _UNSET,
windows: list[tuple[str, str | None]] | object = _UNSET,
skip_on_away: bool | object = _UNSET,
assignee_child_ids: list[int] | object = _UNSET,
) -> QuestDefinitionSnapshot:
"""Edit a definition's metadata, rule and windows atomically.
"""Edit a definition's metadata, rule, windows and assignees atomically.
Mirrors :meth:`create_with_rule_and_windows`: everything runs
inside ONE transaction under the connection lock, and the
@@ -774,9 +807,10 @@ class QuestDefinitionsDao:
``_UNSET`` meaning "leave this field alone"; ``None`` writes SQL
NULL so optional metadata (description, icon) can be cleared.
- ``title``/``description``/``icon`` update the definition row
in place; the definition-level ``due_time`` column is never
written (per-window due times supersede it, D-008).
- ``title``/``description``/``icon``/``skip_on_away`` update the
definition row in place; the definition-level ``due_time``
column is never written (per-window due times supersede it,
D-008).
- ``rule`` (a pre-validated :class:`ScheduleRuleStorage`)
overwrites the referenced ``schedule_rules`` row in place: the
definition keeps its ``schedule_rule_id``, so no new or
@@ -785,13 +819,19 @@ class QuestDefinitionsDao:
are deleted and the given set re-inserted, so missing windows
are added, changed due times updated and removed windows
dropped.
- ``assignee_child_ids`` REPLACES the whole assignee set: every
id is validated as existing-and-active BEFORE any write (the
same check :meth:`add_assignee` applies), then links for
children no longer listed are removed and links for newly
listed children inserted; unchanged links are left alone.
Assignment is deliberately not settable here (multi-assignee is
managed by :meth:`add_assignee`/:meth:`remove_assignee`), and
activation is untouched. Raises ValueError when the definition
does not exist. Edits change future instances only: no
Activation is untouched. Raises ValueError when the definition
does not exist or an assignee is rejected; either way the
transaction rolls back and nothing is written. Edits change future instances only: no
``quest_instances`` or ``completion_events`` row is touched.
"""
if skip_on_away is not _UNSET:
skip_on_away = _validate_skip_on_away(skip_on_away)
async with _connection_lock(self._database):
async with self._database.transaction():
definition = await self.get(definition_id)
@@ -799,6 +839,9 @@ class QuestDefinitionsDao:
raise ValueError(
f"quest definition {definition_id} does not exist"
)
if assignee_child_ids is not _UNSET:
for child_id in assignee_child_ids:
await self._validate_assignable_child(child_id)
assignments: list[str] = []
parameters: list[object] = []
for column, value in (
@@ -809,6 +852,9 @@ class QuestDefinitionsDao:
if value is not _UNSET:
assignments.append(f"{column} = ?")
parameters.append(value)
if skip_on_away is not _UNSET:
assignments.append("skip_on_away = ?")
parameters.append(skip_on_away)
if assignments:
parameters.append(definition_id)
await self._database.execute(
@@ -844,6 +890,10 @@ class QuestDefinitionsDao:
"VALUES (?, ?, ?)",
(definition_id, window, due_time),
)
if assignee_child_ids is not _UNSET:
await self._replace_assignees(
definition_id, assignee_child_ids
)
updated = await self.get(definition_id)
assignees = await self.list_assignees(definition_id)
window_records = await self.list_windows(definition_id)
@@ -859,6 +909,36 @@ class QuestDefinitionsDao:
windows=window_records,
)
async def _replace_assignees(
self, definition_id: int, child_ids: list[int]
) -> None:
"""Make the definition's assignee links exactly ``child_ids``.
Removes the links for children not in ``child_ids`` and inserts
the missing ones. MUST be called inside the connection lock and
an open transaction, after the ids were validated as assignable.
"""
rows = await self._database.fetch_all(
"SELECT child_id FROM quest_definition_assignees "
"WHERE definition_id = ?",
(definition_id,),
)
current = {row[0] for row in rows}
wanted = set(child_ids)
for child_id in sorted(current - wanted):
await self._database.execute(
"DELETE FROM quest_definition_assignees "
"WHERE definition_id = ? AND child_id = ?",
(definition_id, child_id),
)
for child_id in child_ids:
if child_id not in current:
await self._database.execute(
"INSERT INTO quest_definition_assignees "
"(definition_id, child_id) VALUES (?, ?)",
(definition_id, child_id),
)
async def _validate_rule_exists(self, schedule_rule_id: int) -> None:
"""Raise ValueError unless the rule exists.
@@ -907,6 +987,13 @@ class QuestDefinitionsDao:
)
return [_definition_from_row(row) for row in rows]
async def list_all(self) -> list[QuestDefinitionRecord]:
"""Return every definition, active AND inactive, oldest first."""
rows = await self._database.fetch_all(
f"SELECT {_DEFINITION_COLUMNS} FROM quest_definitions ORDER BY id"
)
return [_definition_from_row(row) for row in rows]
async def _snapshots_for(
self, definitions: list[QuestDefinitionRecord]
) -> list[QuestDefinitionSnapshot]:
@@ -951,6 +1038,18 @@ class QuestDefinitionsDao:
definitions = await self.list_active()
return await self._snapshots_for(definitions)
async def list_snapshots_all(self) -> list[QuestDefinitionSnapshot]:
"""Return every definition (active AND inactive) with its rule,
assignees and windows, all read inside one locked transaction.
Mirrors :meth:`list_snapshots_active` over :meth:`list_all`
(oldest first), with the same coherence guarantee.
"""
async with _connection_lock(self._database):
async with self._database.transaction():
definitions = await self.list_all()
return await self._snapshots_for(definitions)
async def read_snapshots_active(self) -> list[QuestDefinitionSnapshot]:
"""Return every active definition's snapshot WITHOUT locking.
@@ -990,13 +1089,16 @@ class QuestDefinitionsDao:
description: str | None | object = _UNSET,
icon: str | None | object = _UNSET,
due_time: str | None | object = _UNSET,
skip_on_away: bool | None = None,
) -> int:
"""Update the given fields; returns rows updated (0 if absent).
Arguments default to the module sentinel ``_UNSET`` meaning
"leave this column alone"; passing ``None`` explicitly writes
SQL NULL, so callers can remove optional metadata
(description, icon, due_time). Assignment and activation are
(description, icon, due_time). ``skip_on_away`` is NOT NULL,
so its ``None`` default leaves it unchanged; a supplied value
must be 0/1 (a bool). Assignment and activation are
deliberately NOT settable here: they have their own explicit
operations (:meth:`add_assignee`, :meth:`remove_assignee`,
:meth:`set_active`) so they stay separately loggable and
@@ -1013,6 +1115,9 @@ class QuestDefinitionsDao:
if value is not _UNSET:
assignments.append(f"{column} = ?")
parameters.append(value)
if skip_on_away is not None:
assignments.append("skip_on_away = ?")
parameters.append(_validate_skip_on_away(skip_on_away))
if not assignments:
return 0
parameters.append(definition_id)
@@ -18,7 +18,8 @@ The walk is deterministic and idempotent:
:class:`~.presence.PresenceEngine` from that same coherent read.
- For each date in the range, each definition whose decoded rule
:func:`~.recurrence.occurs_on` on that date, each assignee who is
``is_present`` on that date and each declared window, one instance is
``is_present`` on that date (or every assignee, when the definition's
``skip_on_away`` is off) and each declared window, one instance is
written through :class:`~.dao_instances.QuestInstancesDao.upsert_if_valid`
with a ``generated_at`` stamp shared by the whole batch (one coherent
time per run).
@@ -208,7 +209,8 @@ async def materialize(
definitions = [
(snapshot.definition.id, _decode_rule(snapshot),
snapshot.assignees, snapshot.windows)
snapshot.assignees, snapshot.windows,
snapshot.definition.skip_on_away)
for snapshot in snapshots
]
@@ -229,13 +231,15 @@ async def materialize(
cursor = start
while cursor <= end:
iso = cursor.isoformat()
for definition_id, rule, assignees, windows in definitions:
for (
definition_id, rule, assignees, windows, skip_on_away
) in definitions:
if not occurs_on(rule, cursor):
continue
for child in assignees:
if child_filter is not None and child.id not in child_filter:
continue
if not engine.is_present(child.id, cursor):
if skip_on_away and not engine.is_present(child.id, cursor):
continue
for window in windows:
written = await instances.upsert_if_valid(
+29 -2
View File
@@ -30,8 +30,9 @@ widened (definition_id, child_id, due_date, window) key, and migration
6 rebuilds ``completion_events`` with the D-008 ``actor_child_id``
column. Migration 7 adds the generic ``nestquest_meta_state``
key/value table for integration-owned bookkeeping (Feature 11's
missed-sweep watermark). Each callable migration runs inside its own
transaction
missed-sweep watermark). Migration 8 adds the ``skip_on_away``
column to ``quest_definitions`` (Feature 22), defaulting to 1. Each
callable migration runs inside its own transaction
together with its version stamp, so a crash mid-step rolls the
statements and the stamp back together.
@@ -400,6 +401,28 @@ async def _add_actor_child_id(
await database.execute("PRAGMA foreign_keys = ON")
async def _add_skip_on_away(
database: NestQuestDatabase, target_version: int
) -> None:
"""Add the ``skip_on_away`` column to ``quest_definitions``.
Strictly additive: ``ALTER TABLE ... ADD COLUMN`` with a NOT NULL
DEFAULT 1, so existing rows read as 1 (skip absent dates — the
behaviour before the flag existed). Fresh databases already carry
the column from the canonical DDL, so the ALTER is guarded and the
step only stamps there.
"""
async with database.transaction():
if not await _table_has_column(
database, "quest_definitions", "skip_on_away"
):
await database.execute(
"ALTER TABLE quest_definitions ADD COLUMN "
"skip_on_away INTEGER NOT NULL DEFAULT 1"
)
await stamp_schema_version(database, target_version)
#: The ordered migration list. Append-only: never edit an applied
#: entry, add the next one instead. (Migration 1's content was
#: rewritten pre-release per D-007 — no version 1 database shipped.)
@@ -423,6 +446,10 @@ MIGRATIONS: Sequence[MigrationStep] = [
# fresh databases (which already made it in migration 1) and
# pre-v7 databases. Strictly additive; no existing table changes.
list(SCHEMA_V7_META_STATE_DDL),
# Migration 8 (Feature 22): the skip_on_away flag on
# quest_definitions — an additive column defaulting to 1, so every
# pre-existing definition keeps today's skip-when-absent behaviour.
_add_skip_on_away,
]
@@ -4,8 +4,9 @@ Sits between the Feature 09 service gate and the typed
:class:`~.dao_rules.QuestDefinitionsDao`: callers get validation and the
all-or-nothing create/edit here, and never touch the DAO SQL directly.
:func:`create_quest_definition` is the create path,
:func:`edit_quest_definition` the edit path, :func:`assign_child` and
:func:`unassign_child` the assignment paths, and
:func:`edit_quest_definition` the edit path (which may also replace the
whole assignee set), :func:`assign_child` and
:func:`unassign_child` the single-child assignment paths, and
:func:`set_quest_definition_active` the deactivate/reactivate path.
There is no permission check here (that is Feature 09).
@@ -191,6 +192,17 @@ def _validate_definition_id(value: object) -> int:
return value
def _validate_skip_on_away(value: object) -> bool:
"""Reject a non-bool ``skip_on_away`` before any write.
The DAO's ``int()`` would otherwise silently coerce strings and
numerics ("0", 1) into a state the caller never asked for.
"""
if not isinstance(value, bool):
raise ValueError(f"skip_on_away must be a real bool, got {value!r}")
return value
def _validate_child_id(value: object) -> int:
"""Reject non-int child ids (bools included) before any lookup.
@@ -212,6 +224,7 @@ async def create_quest_definition(
*,
description: str | None = None,
icon: str | None = None,
skip_on_away: bool = True,
) -> CreatedQuestDefinition:
"""Create a quest definition (rule + assignees + windows) atomically.
@@ -231,6 +244,7 @@ async def create_quest_definition(
window_specs = _normalize_windows(windows)
description_value = _validate_text(description, "description")
icon_value = _validate_text(icon, "icon")
skip_on_away_value = _validate_skip_on_away(skip_on_away)
storage = schedule_rule_to_storage(rule)
dao = QuestDefinitionsDao(database)
@@ -243,6 +257,7 @@ async def create_quest_definition(
window_specs,
description=description_value,
icon=icon_value,
skip_on_away=skip_on_away_value,
)
except ValueError as error:
# The DAO's assignee check names only the child; re-raise so the
@@ -271,10 +286,12 @@ async def edit_quest_definition(
icon: str | None | object = _UNSET,
rule: ScheduleRule | object = _UNSET,
windows: list[str | tuple[str, str | None]] | object = _UNSET,
skip_on_away: bool | None = None,
assignee_child_ids: list[int] | object = _UNSET,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> CreatedQuestDefinition:
"""Edit a quest definition's metadata, rule and windows atomically.
"""Edit a quest definition's metadata, rule, windows and assignees atomically.
Arguments default to the sentinel ``_UNSET`` meaning "leave this
field alone". A provided ``title`` is validated like create's
@@ -285,9 +302,16 @@ async def edit_quest_definition(
``schedule_rules`` row is updated, never orphaned or duplicated. A
provided ``windows`` list REPLACES the whole window set (each entry
a ``const.QUEST_WINDOWS`` name, optionally with a strict HH:MM due
time), matching create's non-empty contract. Assignment is NOT
settable here. Raises ValueError when the definition does not
exist or any argument is rejected.
time), matching create's non-empty contract. A provided
``skip_on_away`` (a real bool) replaces the stored flag; ``None``
leaves it unchanged. A provided ``assignee_child_ids`` REPLACES the
whole assignee set under create's contract (a non-empty list of
distinct plain ints, each an existing ACTIVE child — the DAO checks
that inside the edit's transaction): newly listed children are
assigned and unlisted ones unassigned. Raises ValueError when the
definition does not exist or any argument is rejected; a rejection
writes nothing, so the stored assignees (and every other field)
stay unchanged.
Returns the updated definition together with its decoded rule,
assignees and windows — all read back inside the DAO's transaction,
@@ -331,6 +355,14 @@ async def edit_quest_definition(
if windows is not _UNSET:
window_specs = _normalize_windows(windows)
assignee_ids: list[int] | object = _UNSET
if assignee_child_ids is not _UNSET:
assignee_ids = _validate_assignee_ids(assignee_child_ids)
skip_on_away_value: bool | object = _UNSET
if skip_on_away is not None:
skip_on_away_value = _validate_skip_on_away(skip_on_away)
dao = QuestDefinitionsDao(database)
try:
snapshot = await dao.edit_definition(
@@ -340,14 +372,18 @@ async def edit_quest_definition(
icon=icon_value,
rule=rule_storage,
windows=window_specs,
skip_on_away=skip_on_away_value,
assignee_child_ids=assignee_ids,
)
except ValueError as error:
# The DAO's missing-definition error names only the id; re-raise
# so the public contract names the field the caller passed (matching
# assign/unassign/deactivate).
# The DAO's missing-definition and assignee errors name only the
# id; re-raise so the public contract names the field the caller
# passed (matching create/assign/unassign/deactivate).
message = str(error)
if message.startswith("quest definition "):
raise ValueError(f"definition_id: {message}") from error
if message.startswith("child "):
raise ValueError(f"assignee_child_ids: {message}") from error
raise
decoded_rule = schedule_rule_from_storage(
@@ -558,6 +594,21 @@ async def list_active_definitions(
return [_bundle_snapshot(snapshot) for snapshot in snapshots]
async def list_all_definitions(
database: NestQuestDatabase,
) -> list[CreatedQuestDefinition]:
"""Return EVERY definition, active AND inactive (rich view).
Mirrors :func:`list_active_definitions` without the activity
filter: ordered by rising definition id, each entry the same
:class:`CreatedQuestDefinition` snapshot, read by the DAO inside one
locked transaction.
"""
dao = QuestDefinitionsDao(database)
snapshots = await dao.list_snapshots_all()
return [_bundle_snapshot(snapshot) for snapshot in snapshots]
async def list_definitions_for_child(
database: NestQuestDatabase,
child_id: int,
+2 -1
View File
@@ -257,7 +257,8 @@ QUEST_DEFINITIONS_TABLE_SQL = """(
schedule_rule_id INTEGER NOT NULL REFERENCES schedule_rules(id),
due_time TEXT,
is_active INTEGER NOT NULL DEFAULT 1 CHECK (typeof(is_active) = 'integer' AND is_active IN (0, 1)),
created_at TEXT NOT NULL
created_at TEXT NOT NULL,
skip_on_away INTEGER NOT NULL DEFAULT 1 CHECK (skip_on_away IN (0, 1))
)"""
SCHEMA_V1_QUEST_DEFINITIONS_DDL: list[str] = [
+42 -15
View File
@@ -26,12 +26,17 @@ later completed simply gains its completion event; history keeps both
rows (append-only), and the sweep's later runs skip it via the
no-completion-event rule.
The whole watermark read → query → build → write span runs inside the
The whole watermark read → query → build span runs inside the
per-database sweep lock (one lock per connection wrapper and running
loop, the settings store's pattern): an overlapping startup sweep and
a rollover sweep queue instead of interleaving, and the loser re-reads
the watermark INSIDE the lock — sees the winner's date — and announces
nothing twice.
a rollover sweep of the SAME process queue instead of interleaving.
The announce decision itself is the DATABASE-level claim
(:meth:`MetaStateDao.claim`), so two PROCESSES sharing one SQLite file
(the integration's rollover sweep and the API service's scheduler)
cannot both announce the same missed batch either: both may read the
old watermark and build the events, but the single conditional upsert
lets exactly one of them win the claim — the loser returns [] and
announces nothing.
The caller supplies ``today`` as a plain :class:`datetime.date`: the
household-local calendar date, read from ``hass.config.time_zone`` by
@@ -60,11 +65,11 @@ LOGGER = logging.getLogger(__name__)
SWEEP_WATERMARK_KEY = "missed_sweep_last_run_date"
#: One asyncio.Lock per (connection wrapper, running loop), mirroring
#: the settings store's pattern: the whole watermark read → query →
#: build → write span is one critical section, so an overlapping
#: startup sweep and rollover sweep queue instead of double-announcing.
#: The loser re-reads the watermark INSIDE the lock, sees the winner's
#: date, and fires nothing.
#: the settings store's pattern: the watermark read → query → build
#: span is one critical section, so an overlapping startup sweep and
#: rollover sweep of the SAME process queue instead of interleaving.
#: Same-process callers are fully serialised by it; the cross-process
#: case is closed by the database-level claim, not by this lock.
_SWEEP_LOCKS: dict[tuple[int, int], asyncio.Lock] = {}
@@ -99,15 +104,24 @@ async def run_missed_sweep(
the caller decides how to publish them and announces nothing on
its own.
The whole watermark read → query → build → write span runs inside
the per-database sweep lock: an overlapping startup sweep and a
rollover sweep queue instead of interleaving, and the loser
re-reads the watermark inside the lock — sees the winner's date —
and announces nothing twice. Every event in one run shares one
The watermark read → query → build span runs inside the
per-database sweep lock, so overlapping callers of ONE process
queue instead of interleaving. The announce decision is the
DATABASE-level claim (:meth:`MetaStateDao.claim` — a single
conditional upsert on the watermark, atomic across processes):
every caller builds the events optimistically, then only the
caller whose claim actually advanced the watermark returns them;
a loser (another process already announced for ``today``) returns
[] and announces nothing twice. Every event in one run shares one
``occurred_at`` stamp (one run, one announcement moment), and the
payload comes from the shared
:func:`~.events._instance_payload` builder so the shape can never
drift from the documented §3 contract.
Failure ordering: the watermark is advanced ONLY by the claim,
AFTER the events are fully built — a failure before the claim
leaves the watermark unadvanced, so a later run re-sweeps the same
window and nothing is lost.
"""
today_iso = today.isoformat()
dao = MetaStateDao(database)
@@ -134,10 +148,23 @@ async def run_missed_sweep(
),
)
)
await dao.set(SWEEP_WATERMARK_KEY, today_iso)
# The database-level claim gates the announcement: only the
# caller that actually advanced the watermark may publish the
# built events, so two processes racing one SQLite file cannot
# both announce the same missed batch. A same-value loser's
# claim matches zero rows and this run announces nothing.
claimed = await dao.claim(SWEEP_WATERMARK_KEY, today_iso)
if not claimed:
LOGGER.info(
"NestQuest missed sweep for %s lost the watermark claim; "
"announcing nothing",
today_iso,
)
return []
LOGGER.info(
"NestQuest missed sweep for %s built %d event(s)",
today_iso,
len(events),
)
return events
+215
View File
@@ -0,0 +1,215 @@
"""Zero-config panel dashboard registration for NestQuest (Feature 20).
On setup the integration registers ONE storage-mode Lovelace dashboard
whose CONFIG uses the bundle's ``custom:nestquest-party`` strategy (see
``frontend/src/strategy.ts``): the views themselves are GENERATED at
render time from the household roster, so the stored dashboard config
carries nothing but the strategy reference.
Registration mirrors the REAL two-step sequence the lovelace
integration itself uses when it creates the onboarding map dashboard
(``homeassistant/components/lovelace/__init__.py``,
``_create_map_dashboard``):
1. create the dashboard METADATA through the ``DashboardsCollection``
(``hass.data["lovelace"]["dashboards_collection"]``) — url_path,
title, icon, sidebar visibility. The strategy reference is NOT part
of the create payload: the collection's create schema allows only
the metadata fields, so a ``strategy`` key there fails the whole
create (this was the silent no-op the first implementation shipped).
2. save the dashboard CONFIG — ``{"strategy": ...}`` — through the
per-url_path CONTENT manager that the collection's change listener
registers under ``hass.data["lovelace"]["dashboards"][url_path]``
(a ``LovelaceStorage``), exactly the second step HA performs.
Registration is IDEMPOTENT — a dashboard already under the stable
``url_path`` (ours or a user's) skips BOTH steps and its stored config
is never overwritten — and the Lovelace surfaces being unavailable
(YAML mode, lovelace not loaded) is a logged warning, never a setup
failure.
"""
from __future__ import annotations
import asyncio
from homeassistant.core import HomeAssistant
from .const import (
DASHBOARD_ICON,
DASHBOARD_STRATEGY_TYPE,
DASHBOARD_TITLE,
DASHBOARD_URL_PATH,
LOGGER,
)
_LOGGER = LOGGER
# Single-flight guard for the check-then-create below: HA can set up
# several config entries CONCURRENTLY, and two overlapping setups would
# both pass the exists-check and race on the url_path create. The lock
# is per hass instance (kept as an attribute on the hass object, since
# hass.data is the integrations' shared registry the unload path
# empties) rather than one module-level asyncio.Lock — a bare
# module-level lock binds to whichever event loop first awaits it and
# raises RuntimeError on any other loop, while the race it must prevent
# only exists between setups running on the SAME hass anyway.
_REGISTRATION_LOCK_ATTR = "_nestquest_dashboard_registration_lock"
def _registration_lock(hass: HomeAssistant) -> asyncio.Lock:
"""Return the per-hass registration lock, creating it on first use."""
lock = getattr(hass, _REGISTRATION_LOCK_ATTR, None)
if lock is None:
lock = asyncio.Lock()
setattr(hass, _REGISTRATION_LOCK_ATTR, lock)
return lock
def _lovelace_data(hass: HomeAssistant) -> dict | None:
"""Return ``hass.data["lovelace"]`` when it is the real dict shape.
The lovelace integration stores a plain DICT of surfaces (``mode``,
``dashboards``, ``dashboards_collection``, ``resources``,
``yaml_dashboards``) under the ``lovelace`` key; anything else
(missing key, unexpected object) means the surfaces below cannot be
reached and registration must warn and continue.
"""
lovelace_data = hass.data.get("lovelace")
if not isinstance(lovelace_data, dict):
return None
return lovelace_data
def _dashboards_collection(hass: HomeAssistant):
"""Return the Lovelace dashboards METADATA collection, or None.
The collection lives at ``hass.data["lovelace"]
["dashboards_collection"]`` — NOT under ``["dashboards"]``, which
maps url_path to the per-dashboard CONTENT managers
(``LovelaceStorage``/``LovelaceYAML``). A missing key (lovelace
not loaded) and a missing/None entry (YAML mode never sets the
collection) both mean "cannot create storage dashboards here" —
callers log and continue. The presence of the surface is verified
by capability (``async_create_item`` callable), not by type, so no
lovelace import is needed.
"""
lovelace_data = _lovelace_data(hass)
if lovelace_data is None:
return None
collection = lovelace_data.get("dashboards_collection")
if collection is None or not callable(
getattr(collection, "async_create_item", None)
):
return None
return collection
def _dashboard_store(hass: HomeAssistant, url_path: str):
"""Return a url_path's dashboard CONTENT manager, or None.
After a metadata create, the collection's change listener registers
a ``LovelaceStorage`` for the new dashboard under
``hass.data["lovelace"]["dashboards"][url_path]``; the dashboard
CONFIG (the strategy reference) is saved through THAT manager —
the second step of HA's own dashboard creation.
"""
lovelace_data = _lovelace_data(hass)
if lovelace_data is None:
return None
dashboards = lovelace_data.get("dashboards")
if not isinstance(dashboards, dict):
return None
store = dashboards.get(url_path)
if store is None or not callable(getattr(store, "async_save", None)):
return None
return store
def _item_url_path(item) -> str | None:
"""Read a dashboard item's ``url_path`` through either access shape.
The collection hands back storage items as dicts while builtin
dashboards come back as config objects; the url_path is reachable
through ``item["url_path"]`` or ``item.url_path`` respectively.
"""
url_path = getattr(item, "url_path", None)
if isinstance(url_path, str):
return url_path
get_url_path = getattr(item, "get", None)
if callable(get_url_path):
value = get_url_path("url_path")
if isinstance(value, str):
return value
return None
def _existing_dashboard(items) -> bool:
"""Whether a dashboard under the stable url_path already exists."""
return any(_item_url_path(item) == DASHBOARD_URL_PATH for item in items)
async def async_register_dashboard(hass: HomeAssistant) -> bool:
"""Register the NestQuest panel dashboard exactly once.
Returns True when a dashboard under the stable ``url_path`` exists
after the call (pre-existing or created here), False when the
Lovelace surfaces were unavailable or refused. A dashboard already
registered under the stable ``url_path`` — whether ours from a
previous setup or a user's own — skips BOTH steps and is left
COMPLETELY alone: its stored config is never overwritten, it is
never deleted, never a duplicate. The strategy config is saved
ONLY for the dashboard created in THIS call, whose fresh content
store is empty, so the save cannot clobber anything.
"""
collection = _dashboards_collection(hass)
if collection is None:
_LOGGER.warning(
"Lovelace dashboards collection unavailable; the NestQuest "
"panel dashboard was not registered. Enable the Lovelace "
"integration in storage mode or create a dashboard with "
"strategy %s manually.",
DASHBOARD_STRATEGY_TYPE,
)
return False
try:
# Single-flight: two concurrent entry setups must not both pass
# the exists-check and race on the url_path create.
async with _registration_lock(hass):
# async_items() is a SYNC @callback on the real collection
# (helpers/collection.py ObservableCollection) — not awaited.
if _existing_dashboard(collection.async_items()):
return True
await collection.async_create_item(
{
"url_path": DASHBOARD_URL_PATH,
"title": DASHBOARD_TITLE,
"icon": DASHBOARD_ICON,
"show_in_sidebar": True,
"allow_single_word": True,
}
)
# Step two (mirroring HA's _create_map_dashboard): the
# strategy CONFIG goes through the fresh dashboard's
# content manager, not through the metadata collection.
store = _dashboard_store(hass, DASHBOARD_URL_PATH)
if store is None:
_LOGGER.warning(
"The NestQuest panel dashboard was registered but its "
"Lovelace content manager never appeared; add the "
"strategy %s to the dashboard manually.",
DASHBOARD_STRATEGY_TYPE,
)
return False
await store.async_save(
{"strategy": {"type": DASHBOARD_STRATEGY_TYPE}}
)
except Exception:
_LOGGER.warning(
"Registering the NestQuest panel dashboard in the Lovelace "
"dashboards collection failed; set it up manually with "
"strategy %s.",
DASHBOARD_STRATEGY_TYPE,
exc_info=True,
)
return False
return True
+3 -2
View File
@@ -7,12 +7,13 @@
"config_flow": true,
"dependencies": [
"frontend",
"http"
"http",
"lovelace"
],
"documentation": "https://github.com/Maniacs-Mansion/NestQuest",
"integration_type": "hub",
"iot_class": "local_polling",
"issue_tracker": "https://github.com/Maniacs-Mansion/NestQuest/issues",
"requirements": [],
"version": "0.5.0"
"version": "0.6.0"
}
+63 -3
View File
@@ -32,6 +32,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import slugify
from .const import DOMAIN
from .coordinator import (
@@ -41,13 +42,21 @@ from .coordinator import (
)
from .core.snapshot import instance_payload
#: Home Assistant's state machine refuses states longer than 255
#: HA's state machine refuses states longer than 255
#: characters (its hard recorder/state limit). These sensors' states
#: are numeric, but the guard is applied uniformly: a payload-bearing
#: sensor can never crash the state write, and later string-state
#: sensors reuse the same helper.
MAX_STATE_LENGTH = 255
#: Entity-id length cap the roster applies to HA's slugify: HA's
#: slugifier itself does NOT truncate, but the entity registry derives
#: entity ids as ``<domain>.<slug>`` and truncates the full entity_id at
#: MAX_LENGTH_STATE_ENTITY_ID (64), so for this module's ``sensor.``
#: entities the slugged object id alone may not exceed
#: 64 - len("sensor.").
_MAX_SLUG_LENGTH = 64 - len("sensor.")
def _state_within_limit(state: Any) -> Any:
"""Return ``state`` unchanged, refusing strings over HA's limit.
@@ -98,6 +107,51 @@ def _next_open_quest(
)
def _slugify(text: str) -> str:
"""Slugify a display name the way Home Assistant derives entity ids.
Runs ``homeassistant.util.slugify`` — the real slugifier HA runs
entity names through, which transliterates every script to ASCII
through unidecode (so non-Latin names survive: ``京子`` slugs to
``jing_zi``) before folding to lowercase and turning every run of
non-alphanumerics into one underscore, answering HA's ``unknown``
sentinel when a name slugs to nothing — then caps the result at
the object-id length the entity registry enforces (the registry
truncates the full ``sensor.`` + slug entity_id at 64 characters,
so the slug alone is capped like this; HA's slugify does not
truncate by itself).
"""
return slugify(text)[:_MAX_SLUG_LENGTH]
def _child_roster(children: Iterable[ChildDaySnapshot]) -> list[dict[str, Any]]:
"""The ordered child roster the panel cards discover children from.
One entry per ACTIVE child in the snapshot's order (the API's
sort_order, then id): ``child_id``, ``name``, and the ``slug`` the
entity-id derivation produces for the child's name — the piece the
cards need to build ``sensor.nestquest_<slug>_...`` ids. Duplicate
display names get the registry's ``_2``/``_3`` suffix in snapshot
order, matching how HA resolves the collision at registration. The
rollups' ``children`` attribute (per-child counts) is unchanged and
carries a different shape on purpose.
"""
slugs: dict[str, int] = {}
roster: list[dict[str, Any]] = []
for child in children:
base = _slugify(child.child_name)
taken = slugs.get(base, 0) + 1
slugs[base] = taken
roster.append(
{
"child_id": child.child_id,
"name": child.child_name,
"slug": base if taken == 1 else f"{base}_{taken}",
}
)
return roster
class _NestQuestChildDaySensor(CoordinatorEntity, SensorEntity):
"""Base for the per-child day sensors.
@@ -356,7 +410,12 @@ class _NestQuestHouseholdSensor(CoordinatorEntity, SensorEntity):
class NestQuestHouseholdDueTodaySensor(_NestQuestHouseholdSensor):
"""Total quests due today across active children."""
"""Total quests due today across active children.
Also publishes ``child_roster`` (see :func:`_child_roster`): the
ordered child identity list the panel cards read to discover the
board's children when the card config carries no ``child_order``.
"""
_attr_name = "NestQuest household quests due today"
_attr_state_class = SensorStateClass.MEASUREMENT
@@ -380,7 +439,8 @@ class NestQuestHouseholdDueTodaySensor(_NestQuestHouseholdSensor):
"children": {
str(child.child_id): child.due_today
for child in data.children
}
},
"child_roster": _child_roster(data.children),
}
File diff suppressed because it is too large Load Diff
+203
View File
@@ -0,0 +1,203 @@
# NestQuest Architecture
Status: approved direction (2026-09-21). This document supersedes the
single-process design implied by Features 01–14, in which the Home Assistant
integration owned the database and the parent admin surface was planned as a
Lovelace card inside Home Assistant.
## 1. Why this changed
The original design put everything inside the Home Assistant integration: the
SQLite database, the business logic, the kid panel cards, and (planned) the
parent admin card. Three problems surfaced:
1. **The panel needed hand configuration.** Standing up the kid panel required
hand-writing card YAML (`child_order`, `quest_log_path`, `board_path`) and
hand-creating a Lovelace dashboard. A fresh install rendered nothing.
2. **The admin surface fought Home Assistant.** The admin design
(`design/ADMIN-SPEC.md`) is drawn at 390×844 — it is a phone app. Trapping it
inside a Lovelace card meant fighting HA's UI constraints for a mobile-first
experience.
3. **Data was locked to one process.** A standalone admin web app cannot reach a
SQLite file inside Home Assistant's config directory.
The re-imagined design keeps the panel in Home Assistant (TouchHub loads an HA
dashboard), moves data ownership to a standalone API service, and promotes the
admin surface to a standalone mobile-first PWA.
## 2. Target architecture
```
DAKboard TouchHub
│ (loads an HA dashboard URL)
▼
HA box API box
┌───────────────────────────────┐ ┌───────────────────────────────────┐
│ Lovelace panel dashboard │ │ reverse proxy (TLS) │
│ ├ nestquest-party-board-card │ service │ ├ /api/v1/panel/* LAN-only │
│ └ nestquest-quest-log-card │ token │ └ /api/v1/admin/* public │
│ │ ─────────▶ │ FastAPI (imports core/) │
│ NestQuest integration │ │ └ SQLite (owned here) │
│ (thin client, no database) │ ◀───────── │ Authentik (OIDC) │
│ sensors + HA bus events │ SSE └───────────────────────────────────┘
└───────────────────────────────┘ ▲
PWA ───────────┘
https://nestquest.cubecraftlabs.com
```
One owner of the data (the API). Two clients (the HA integration, the PWA).
## 3. Data ownership
**The API service is the only writer.** The HA integration never opens the
database; it reads a snapshot and posts completions. This removes the
two-writer problem entirely.
- **Storage:** SQLite on the API box. Single household, trivial concurrency,
and the existing schema, migrations, and DAO tests are already SQLite. Postgres
buys nothing at this scale.
- **Schema:** unchanged. The cutover from the current HA-hosted database is a
file copy — same schema, same migration version.
- **Backups:** continuous replication (e.g. Litestream) plus a proven restore.
A configured-but-untested backup is not a backup.
## 4. Authentication: two planes
The kid panel and the admin PWA have opposite trust levels, so they use
separate credentials and separate network exposure.
### Panel plane (machine)
- **Credential:** a static service token.
- **Exposure:** `/api/v1/panel/*` is reachable **only from the LAN**.
- **Grants:** read the snapshot, and the single open write — complete a quest as
`actor: panel` with the tapped `actor_child_id`.
- **Refused:** admin routes reject the service token outright, not merely as a
non-admin. The internet cannot reach these routes at all.
This preserves the original fail-closed boundary: the kiosk can complete a
quest and nothing else.
### Admin plane (human)
- **Credential:** an Authentik OIDC JWT (authorization code + PKCE).
- **Exposure:** `/api/v1/admin/*` is internet-facing behind Authentik.
- **Grants:** everything a parent can do — definitions, assignment, presence,
children, un-complete, history, export, settings, regenerate.
- **Gate:** the API validates the JWT (issuer, audience, JWKS signature, expiry)
and requires membership in the `nestquest-admins` group. The Authentik group
is the single source of truth for "who is a parent", replacing the
integration-side admin allowlist.
## 5. Components
### `core/` — domain library
The extracted domain layer: schema, migrations, database access, DAOs, the pure
recurrence and presence engines, materialization, completion, event payload
construction, and the children/definitions/allowlist business layers. **No
Home Assistant imports.** Consumed by both the API and (during transition) the
integration. This is where the ~1500 existing tests live after extraction.
### `api/` — NestQuest API
A FastAPI service importing `core/`. Owns the database. Serves the panel and
admin planes, validates Authentik tokens, and publishes a transition event
stream (SSE) that the integration consumes. No business logic in route handlers.
### `custom_components/nestquest/` — HA integration (client)
A thin client. Polls the snapshot and builds the existing sensors; subscribes to
the SSE stream and re-fires the four HA bus events; proxies the panel completion.
Holds a last-good snapshot cache so a brief API outage does not blank the panel.
Owns no database and no business logic.
### `panel/` — Lovelace cards and dashboard strategy
The two kid-facing Lit cards plus a dashboard strategy that generates the panel
views at render time. Cards auto-discover children, so no `child_order` is
required; the strategy computes navigation paths, so `quest_log_path` and
`board_path` are not required.
### `admin/` — Admin PWA
A React progressive web app implementing `design/ADMIN-SPEC.md`, authenticated
via Authentik OIDC, installable on a phone. The only admin surface.
## 6. Zero-configuration panel
A fresh install should show the panel with no YAML:
1. **Auto-discovery.** The party-board card reads an ordered child list from a
household entity attribute instead of a configured `child_order`.
2. **Dashboard strategy.** A Lovelace strategy generates a party-board view and
one quest-log view per child, wiring navigation between them, so paths are
computed rather than configured. Explicit configuration still wins if a user
supplies it.
3. **Auto-registration.** The integration registers the dashboard on first
setup, so there is no manual dashboard step.
## 7. Home Assistant surface preserved
The re-imagining must not break what already works:
- **Sensors:** the per-child and household sensor entity_ids and the per-child
instance attribute payload shape are unchanged. The panel cards and the four
blueprints depend on them.
- **Events:** the four HA bus events (`nestquest_quest_completed`,
`nestquest_quest_uncompleted`, `nestquest_quest_missed`,
`nestquest_child_day_complete`) keep firing with their documented payloads.
They are now driven by the API's transition stream rather than by local
service handlers.
- **Blueprints:** the morning summary, afternoon reminder, end-of-day report, and
day-complete celebration continue to work with no change.
## 8. Events without an HA token on the API box
The API publishes transitions on an SSE stream; the integration subscribes and
re-fires the HA bus events locally. This deliberately avoids storing a Home
Assistant long-lived token on the API box, which would grant full HA access.
## 9. Migration phases
Mapped to the Maestro features:
| Phase | Feature | Outcome |
|---|---|---|
| 1 | 15 — Domain Core Extraction | `core/` runs with no HA imports; one implementation of the rules |
| 2 | 16 — NestQuest API Service | Panel and admin planes served with two-plane auth |
| 3 | 17 — API Deployment and Identity | TLS, Authentik, backups, network separation |
| 4 | 18 — HA Integration as API Client | Integration becomes a client; entity/event surface preserved |
| 5 | 19 — Admin PWA | Mobile-first React PWA replaces the admin card |
| 6 | 20 — Zero-Config Panel Dashboard | Panel installs itself; navigation and animations match the mockups |
Cutover: copy the existing SQLite file to the API box (same schema).
## 10. Locked decisions
- **API on its own box** — separate from Home Assistant.
- **Authentik** (`https://auth.cubecraftlabs.com`) for admin identity; a
`nestquest-admins` group is the admin source of truth.
- **Single household** — no tenant column, no multi-household auth.
- **React** for the admin PWA.
- **Drop the HA admin services** — the PWA is the only admin surface. The panel
completion path stays.
- **SQLite on the API box** initially; Postgres only if multi-household ever
becomes real.
## 11. Preserved vs. changed
**Preserved:** the tested Python domain layer, the panel card designs and
tokens, the sensor and event contract, the completion model, the append-only
event log, the panel's fail-closed "complete only" boundary.
**Changed:** data ownership moves to the API; the integration becomes a client;
the admin card becomes a PWA; panel cards become config-free with a
strategy-generated dashboard.
**New:** the API service, Authentik-gated admin auth, the PWA, and a
last-good-snapshot cache in the integration.
**Cost:** two new deployables (the API and the PWA) and one new box. Accepted
knowingly in exchange for a self-installing panel, an admin surface that is not
fighting HA's UI, and a data layer with real auth in front of it.
+522
View File
@@ -9,10 +9,197 @@
"lit": "^3.3.0"
},
"devDependencies": {
"jsdom": "^30.1.1",
"typescript": "^5.9.2",
"vite": "^7.1.5"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.0.1.tgz",
"integrity": "sha512-C9duntabagkBZ1LebM7FKmphR4Q1pBclLxVbZETQV0akkFjV0ooFxo8FlvAQyKj9F6l8rEnmidgcTlpyxFYizg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@csstools/css-calc": "^3.4.0",
"@csstools/css-color-parser": "^4.2.3",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.1",
"lru-cache": "^11.5.3"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "9.2.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.1.tgz",
"integrity": "sha512-NT4s3yZLjovPpliRpTvdsdzyPjqRqiCZj9MxnarBihbaY5MbAG7DWAJcrLlhmC7xKTNCXsTELcqB8YsqpLnUFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"bidi-js": "^1.1.0",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.5.3"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}
},
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"css-tree": "^3.0.0"
},
"bin": {
"specificity": "bin/cli.js"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz",
"integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@csstools/css-calc": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.0.tgz",
"integrity": "sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.3.tgz",
"integrity": "sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^6.1.1",
"@csstools/css-calc": "^3.4.0"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz",
"integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"peerDependencies": {
"css-tree": "^3.2.1"
},
"peerDependenciesMeta": {
"css-tree": {
"optional": true
}
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.1.tgz",
"integrity": "sha512-bPlN9S9O1A0euCpEWE4qnvB5YDuyYVsUTrxSgmAM1Is0j4tICHoVyOVAXfWMP/kS9ZrjvyIXWV2PmomiAXXqOw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
@@ -455,6 +642,24 @@
"node": ">=18"
}
},
"node_modules/@exodus/bytes": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz",
"integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@noble/hashes": "^1.8.0 || ^2.0.0"
},
"peerDependenciesMeta": {
"@noble/hashes": {
"optional": true
}
}
},
"node_modules/@lit-labs/ssr-dom-shim": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.6.0.tgz",
@@ -892,6 +1097,79 @@
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT"
},
"node_modules/bidi-js": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
"integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
"dev": true,
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.27.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/data-urls/node_modules/whatwg-url": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.11.0",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
"license": "MIT"
},
"node_modules/entities": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
"integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/esbuild": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
@@ -967,6 +1245,66 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/html-encoding-sniffer": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz",
"integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.15.1"
},
"engines": {
"node": "^22.13.0 || >=24.0.0"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"license": "MIT"
},
"node_modules/jsdom": {
"version": "30.1.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.1.tgz",
"integrity": "sha512-FahmoPK5vbPc+jxV1iErMHmAZypCZ942NHF4+qqaWAuvaKKTBZxawnmAtrbGWLU7MtlxfqIP0qw6aSI+aWGtLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^7.0.0",
"@asamuzakjp/dom-selector": "^9.2.1",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.13",
"@exodus/bytes": "^1.15.1",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^7.0.0",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.5.2",
"parse5": "^8.0.1",
"saxes": "^6.0.0",
"tough-cookie": "^6.0.2",
"undici": "^8.10.2",
"w3c-xmlserializer": "^6.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^17.1.1",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
"peerDependencies": {
"canvas": "^3.2.3"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/lit": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/lit/-/lit-3.3.3.tgz",
@@ -998,6 +1336,23 @@
"@types/trusted-types": "^2.0.2"
}
},
"node_modules/lru-cache": {
"version": "11.5.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/nanoid": {
"version": "3.3.19",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
@@ -1017,6 +1372,19 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^8.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1066,6 +1434,26 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/rollup": {
"version": "4.63.4",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.4.tgz",
@@ -1112,6 +1500,19 @@
"fsevents": "~2.3.2"
}
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1139,6 +1540,52 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tldts": {
"version": "7.4.14",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.14.tgz",
"integrity": "sha512-EahQoi+Q5oqmG3yxRHx+bwn36OaLbtw6jnTFcGjc8fcmktzTgk45xRnzAx8Ch87PFEiqRhoc3FWBmY+LBrvEGQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^7.4.14"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.4.14",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.14.tgz",
"integrity": "sha512-KYkjfJHnIC5t+Gy7hPrMHgJmWc/N9FfmS+fggRPSlpFckidpB9HD6OzPsSTkfDH+MpZgcu2tJPGgLfHl979N1Q==",
"dev": true,
"license": "MIT"
},
"node_modules/tough-cookie": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
"integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^7.0.5"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -1153,6 +1600,16 @@
"node": ">=14.17"
}
},
"node_modules/undici": {
"version": "8.11.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.11.0.tgz",
"integrity": "sha512-AdzuGcAkzhbha3GgCSoUBx2P0quwym6qdFNCQz/fDGZc8w9XJ741wHUbt2kTmfPz+ZIOMkySFkiY7RXmiDDC8w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/vite": {
"version": "7.3.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
@@ -1227,6 +1684,71 @@
"optional": true
}
}
},
"node_modules/w3c-xmlserializer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz",
"integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}
},
"node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-url": {
"version": "17.1.2",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.2.tgz",
"integrity": "sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.15.1",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^22.14.0 || >=24.0.0"
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
}
}
}
+1
View File
@@ -9,6 +9,7 @@
"lit": "^3.3.0"
},
"devDependencies": {
"jsdom": "^30.1.1",
"typescript": "^5.9.2",
"vite": "^7.1.5"
}
+36 -7
View File
@@ -143,6 +143,16 @@ function asNumber(value: unknown, fallback: number): number {
return Number.isFinite(parsed) ? parsed : fallback;
}
/** The slug a discovered roster entry carries, or "" when the entry is
* not an object with a non-empty slug string. */
function asRosterSlug(entry: unknown): string {
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
return "";
}
const slug = (entry as Record<string, unknown>).slug;
return typeof slug === "string" ? slug.trim() : "";
}
function clampPercent(value: number): number {
if (!Number.isFinite(value)) {
return 0;
@@ -892,15 +902,34 @@ export class NestQuestPartyBoardCard extends LitElement {
return [];
}
const order = this._config.child_order;
if (!Array.isArray(order)) {
if (Array.isArray(order)) {
return order
.filter(
(entry): entry is string =>
typeof entry === "string" && entry.trim().length > 0
)
.map((entry) => entry.trim());
}
return this._discoveredChildSlugs();
}
/** Zero-config discovery (Feature 20): the ordered child roster the
* integration publishes on the household rollup
* (custom_components/nestquest/sensor.py — ``child_roster``, the
* snapshot's sort_order, then id), each entry carrying the slug the
* per-child entity ids are built from. Consulted only when the
* config has no child_order — explicit configuration wins. */
private _discoveredChildSlugs(): string[] {
const household = this._state(
"sensor.nestquest_household_quests_due_today"
);
const roster = household?.attributes?.child_roster;
if (!Array.isArray(roster)) {
return [];
}
return order
.filter(
(entry): entry is string =>
typeof entry === "string" && entry.trim().length > 0
)
.map((entry) => entry.trim());
return roster
.map((entry) => asRosterSlug(entry))
.filter((slug) => slug.length > 0);
}
private _plates(): ChildPlate[] {
+1
View File
@@ -1,6 +1,7 @@
import "./cards/party-board";
import "./cards/quest-log";
import "./cards/admin";
import "./strategy";
const FONTS_HREF = "/nestquest-static/nestquest-fonts.css";
if (!document.querySelector('link[data-nq-fonts=""]')) {
+161
View File
@@ -0,0 +1,161 @@
import type { HassLike } from "./types";
type StateObject = {
state: unknown;
attributes?: Record<string, unknown> | null;
};
type RosterEntry = {
name: string;
slug: string;
};
type View = {
path?: string;
title?: string;
type: string;
cards: Record<string, unknown>[];
};
type LovelaceConfig = {
views: View[];
};
type StrategyConfig = Record<string, unknown>;
type StrategyClass = {
generate: (
config: StrategyConfig,
hass: HassLike | undefined
) => Promise<LovelaceConfig>;
};
declare global {
interface Window {
customStrategies?: Record<string, StrategyClass>;
}
}
function asString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
/** The slug a discovered roster entry carries, or "" when the entry is
* not an object with a non-empty slug string — the same shape check
* the party board applies to the same attribute. */
function asRosterSlug(entry: unknown): string {
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
return "";
}
const slug = (entry as Record<string, unknown>).slug;
return typeof slug === "string" ? slug.trim() : "";
}
/** The dashboard's URL path segment, used to COMPUTE the card paths the
* navigation needs. A dashboard lives at a single top-level segment
* (/<url_path>), so the first pathname segment is the dashboard root —
* this is where the strategy renders, and navigating back to it shows
* the first view (the party board). An explicit ``url_path`` in the
* strategy configuration wins for setups that need to pin it. */
function dashboardUrlPath(config: StrategyConfig): string {
const configured = asString(config.url_path).replace(/^\/+|\/+$/g, "");
if (configured) {
return configured;
}
const segment = window.location.pathname.split("/").filter(Boolean)[0];
return segment ?? "";
}
/** The display name a roster entry carries, or "" when it is not a
* non-empty string — the roster's ``name`` field (sensor.py
* ``_child_roster``), used for the quest-log view titles. */
function asRosterName(entry: unknown): string {
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
return "";
}
const name = (entry as Record<string, unknown>).name;
return typeof name === "string" ? name.trim() : "";
}
/** The ordered child roster the integration publishes on the household
* rollup (custom_components/nestquest/sensor.py — ``child_roster``,
* the snapshot's sort_order, then id). The same source the party
* board's plate discovery reads, consumed here at render time so the
* quest-log views exist for exactly the active children. */
function rosterEntries(hass: HassLike | undefined): RosterEntry[] {
const states = hass?.states as Record<string, StateObject> | undefined;
const household = states?.["sensor.nestquest_household_quests_due_today"];
const roster = household?.attributes?.child_roster;
if (!Array.isArray(roster)) {
return [];
}
return roster
.map((entry) => ({ slug: asRosterSlug(entry), name: asRosterName(entry) }))
.filter((entry) => entry.slug.length > 0);
}
/**
* Zero-config NestQuest dashboard strategy (Feature 20).
*
* A dashboard configured with ``strategy: { type: custom:nestquest-party }``
* gets its views GENERATED at render time from the household roster: one
* party-board view (the dashboard root) and one quest-log view per child,
* ordered by the roster's sort_order. The paths the cards navigate by are
* COMPUTED, not configured:
*
* - the board's ``quest_log_path`` is the dashboard root; tapping a crest
* pushes ``<root>/<slug>`` (party-board.ts ``_openQuestLog``);
* - each log view's ``path`` IS the child's slug, so the log resolves its
* child from the last pathname segment (quest-log.ts
* ``childSlugFromPath``) and its ``board_path`` back to the dashboard
* root (quest-log.ts ``_returnToBoard``).
*
* With zero children the board view still renders — the board card shows
* its own "not set up yet" notice for an empty roster, so no log views are
* needed. ``url_path`` and ``weather_entity`` may still be set explicitly
* in the strategy configuration; everything else is derived.
*/
export class NestQuestPartyStrategy {
static async generate(
config: StrategyConfig,
hass: HassLike | undefined
): Promise<LovelaceConfig> {
const root = `/${dashboardUrlPath(config)}`;
const weatherEntity = asString(config.weather_entity);
const boardCard: Record<string, unknown> = {
type: "custom:nestquest-party-board-card",
quest_log_path: root,
};
if (weatherEntity) {
boardCard.weather_entity = weatherEntity;
}
const logCard: Record<string, unknown> = {
type: "custom:nestquest-quest-log-card",
board_path: root,
};
if (weatherEntity) {
logCard.weather_entity = weatherEntity;
}
return {
views: [
{
path: "party",
title: "The Party",
type: "panel",
cards: [boardCard],
},
...rosterEntries(hass).map<View>((entry) => ({
path: entry.slug,
title: entry.name
? `${entry.name}'s Quest Log`
: `${entry.slug}'s Quest Log`,
type: "panel",
cards: [{ ...logCard }],
})),
],
};
}
}
window.customStrategies = window.customStrategies ?? {};
window.customStrategies["nestquest-party"] = NestQuestPartyStrategy;
+80
View File
@@ -0,0 +1,80 @@
/**
* Render the built party-board card under jsdom and print its plates.
*
* Usage: node render-party-board.mjs < spec.json
*
* The spec JSON carries {bundle, tag, config, hass, url?, click_plate?};
* stdout receives one JSON object {plates, tappable, location, html} where
* plates is the ordered plate list the shadow DOM renders (name +
* present/away/unknown kind), tappable the plate names rendered as tappable
* <button> plates, and location the window pathname after the optional plate
* tap. The Python panel tests drive this harness so the assertions run
* against the exact bundle HACS ships.
*/
import { readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
import { JSDOM } from "jsdom";
const spec = JSON.parse(readFileSync(0, "utf8"));
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: spec.url ?? "http://homeassistant.local/nestquest/board",
pretendToBeVisual: true,
});
// Expose the jsdom globals the Lit bundle expects. Node's own intrinsics
// (Object, Array, ...) are kept — only what globalThis lacks is copied.
for (const key of Object.getOwnPropertyNames(dom.window)) {
if (!(key in globalThis)) {
try {
globalThis[key] = dom.window[key];
} catch {
// jsdom getters may throw on access; the card does not need them.
}
}
}
globalThis.window = dom.window;
// The card navigates by dispatching new Event("location-changed") on this
// window; jsdom's EventTarget rejects Node's own global Event class, so the
// jsdom Event must shadow it for the dispatched event to be delivered.
globalThis.Event = dom.window.Event;
await import(pathToFileURL(spec.bundle).href);
const element = document.createElement(spec.tag);
element.setConfig(spec.config);
element.hass = spec.hass;
document.body.appendChild(element);
await element.updateComplete;
const plateNodes = [
...element.shadowRoot.querySelectorAll(".plates .plate"),
];
const plates = plateNodes.map((plate) => ({
name: plate.querySelector(".name")?.textContent?.trim() ?? "",
kind: plate.classList.contains("unknown")
? "unknown"
: plate.classList.contains("away")
? "away"
: "present",
}));
const tappable = plateNodes
.filter((plate) => plate.tagName === "BUTTON")
.map((plate) => plate.querySelector(".name")?.textContent?.trim() ?? "");
if (spec.click_plate !== undefined) {
const plate = plateNodes.find(
(candidate) =>
candidate.querySelector(".name")?.textContent?.trim() === spec.click_plate
);
plate?.click();
}
const payload = JSON.stringify({
plates,
tappable,
location: window.location.pathname,
html: element.shadowRoot.innerHTML,
});
await new Promise((resolve) => process.stdout.write(payload, () => resolve()));
process.exit(0);
+73
View File
@@ -0,0 +1,73 @@
/**
* Render the built quest-log card under jsdom and report its location.
*
* Usage: node render-quest-log.mjs < spec.json
*
* The spec JSON carries {bundle, url, config, hass, idle_ms?, probe_ms?};
* the card is mounted at the given URL (it resolves its child from the
* pathname's last segment), after the optional probe wait one snapshot of
* the render is taken mid-wait, then after the optional idle wait stdout
* receives one JSON object {location, headline, countdown, html, probe} —
* the final window pathname, the rendered title, the complete-screen
* countdown text, the rendered shadow DOM, and the probe snapshot when
* probe_ms was given. The Python panel tests drive this harness so the
* assertions run against the exact bundle HACS ships.
*/
import { readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
import { JSDOM } from "jsdom";
const spec = JSON.parse(readFileSync(0, "utf8"));
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: spec.url ?? "http://homeassistant.local/nestquest/ada",
pretendToBeVisual: true,
});
// Expose the jsdom globals the Lit bundle expects. Node's own intrinsics
// (Object, Array, ...) are kept — only what globalThis lacks is copied.
for (const key of Object.getOwnPropertyNames(dom.window)) {
if (!(key in globalThis)) {
try {
globalThis[key] = dom.window[key];
} catch {
// jsdom getters may throw on access; the card does not need them.
}
}
}
globalThis.window = dom.window;
// The card navigates by dispatching new Event("location-changed") on this
// window; jsdom's EventTarget rejects Node's own global Event class, so the
// jsdom Event must shadow it for the return navigation to be delivered.
globalThis.Event = dom.window.Event;
await import(pathToFileURL(spec.bundle).href);
const element = document.createElement("nestquest-quest-log-card");
element.setConfig(spec.config);
element.hass = spec.hass;
document.body.appendChild(element);
await element.updateComplete;
const snapshot = () => ({
location: window.location.pathname,
headline: element.shadowRoot.querySelector(".title")?.textContent?.trim() ?? "",
countdown:
element.shadowRoot.querySelector(".complete-countdown")?.textContent?.trim() ??
"",
html: element.shadowRoot.innerHTML,
});
let probe = null;
if (spec.probe_ms !== undefined) {
await new Promise((resolve) => setTimeout(resolve, spec.probe_ms));
probe = snapshot();
}
if (spec.idle_ms !== undefined) {
await new Promise((resolve) => setTimeout(resolve, spec.idle_ms));
}
const payload = JSON.stringify({ ...snapshot(), probe });
await new Promise((resolve) => process.stdout.write(payload, () => resolve()));
process.exit(0);
+48
View File
@@ -0,0 +1,48 @@
/**
* Import the built bundle into jsdom and generate the dashboard strategy's
* views.
*
* Usage: node render-strategy.mjs < spec.json
*
* The spec JSON carries {bundle, url, config, hass}; stdout receives one
* JSON object {views} — the strategy output's views array with the paths
* and card configs the generated dashboard renders. The Python panel
* tests drive this harness so the assertions run against the exact bundle
* HACS ships.
*/
import { readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
import { JSDOM } from "jsdom";
const spec = JSON.parse(readFileSync(0, "utf8"));
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: spec.url ?? "http://homeassistant.local/nestquest/party",
pretendToBeVisual: true,
});
// Expose the jsdom globals the Lit bundle expects. Node's own intrinsics
// (Object, Array, ...) are kept — only what globalThis lacks is copied.
for (const key of Object.getOwnPropertyNames(dom.window)) {
if (!(key in globalThis)) {
try {
globalThis[key] = dom.window[key];
} catch {
// jsdom getters may throw on access; the strategy does not need them.
}
}
}
globalThis.window = dom.window;
await import(pathToFileURL(spec.bundle).href);
const strategy = window.customStrategies?.["nestquest-party"];
if (!strategy) {
console.error("nestquest-party strategy is not registered on the bundle");
process.exit(1);
}
const lovelace = await strategy.generate(spec.config, spec.hass);
const payload = JSON.stringify({ views: lovelace.views });
await new Promise((resolve) => process.stdout.write(payload, () => resolve()));
process.exit(0);
+30
View File
@@ -51,6 +51,36 @@ class CyclePromptTests(unittest.TestCase):
self.assertNotIn("The persistent user service runs", output)
self.assertNotIn("---\n\n---\n\n# 27", output)
def test_task_complete_feature_triggers_one_guarded_promotion(self):
output = RENDER(TEMPLATE, PROFILE)
self.assertIn(
"feature becomes task-complete and its reviewed merge into `dev` is verified",
output,
)
self.assertIn("Every required feature task is `done`", output)
self.assertIn("No task review, remediation, approval, or merge remains pending", output)
self.assertIn("feature acceptance criteria and integrated checks pass", output)
self.assertIn("Independent approval applies to the exact feature head", output)
self.assertIn("approver's merge of that head into `dev` is verified", output)
self.assertIn("reflects the completed feature and exact repository references", output)
self.assertIn("Task completion inside an unmerged feature branch does not trigger", output)
self.assertIn("Do not create a duplicate promotion", output)
self.assertIn("Allow only one active promotion pull request to `main`", output)
self.assertIn("if it was closed without merge, recalculate", output)
self.assertIn("sole active orchestrator for the project", output)
self.assertIn("including controller exclusivity, reconciliation", output)
self.assertIn("RELEASE_PREPARATION_POLICY = EXCLUDED", output)
self.assertIn("Active promotion branch and pull request", output)
self.assertIn("An active promotion that has not reached `RELEASE_READY`", output)
self.assertIn("RELEASE_PREPARATION_POLICY:\n", output)
self.assertIn("A ready promotion is a local checkpoint", output)
self.assertIn("continue that work while the ready promotion waits", output)
self.assertLess(
output.index("Begin an automatically triggered, requested, or scheduled release"),
output.index("Advance to the next eligible feature"),
)
self.assertIn("Only Joshua may merge into `main`", output)
if __name__ == "__main__":
unittest.main()
@@ -1,4 +1,5 @@
from pathlib import Path
import fcntl
import os
import subprocess
import tempfile
@@ -47,6 +48,7 @@ class ForegroundLauncherTests(unittest.TestCase):
"NQ_PROJECT_ROOT": str(project),
"OPENCODE_BIN": str(opencode),
"SYSTEMCTL_BIN": str(systemctl),
"NQ_STATE_DIR": str(root / "state"),
})
printed = subprocess.run([str(SCRIPT), "--print-prompt"], env=env,
text=True, capture_output=True)
@@ -67,6 +69,16 @@ class ForegroundLauncherTests(unittest.TestCase):
["--check-interactive", "--project", str(project)])
output.unlink()
lock_path = root / "state/controller.lock"
with lock_path.open("w") as held_lock:
fcntl.flock(held_lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
locked = subprocess.run([str(SCRIPT)], env=env, text=True,
capture_output=True)
self.assertEqual(locked.returncode, 75)
self.assertIn("Another NestQuest orchestrator or controller",
locked.stderr)
self.assertFalse(output.exists())
open_marker.touch()
refused = subprocess.run([str(SCRIPT)], env=env, text=True,
capture_output=True)
+2 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "nestquest"
version = "0.5.0"
version = "0.6.0"
description = "NestQuest Home Assistant Integration"
readme = "README.md"
requires-python = ">=3.11"
@@ -21,6 +21,7 @@ dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.24",
"pyyaml>=6",
"unidecode>=1.4.0",
"uvicorn>=0.30",
"voluptuous>=0.14",
]
+12
View File
@@ -11,6 +11,7 @@ control_home=${NQ_CONTROL_HOME:-/home/overseer/.local/share/nestquest-controller
project=${NQ_PROJECT_ROOT:-/mnt/ai-storage/projects/NestQuest}
opencode_bin=${OPENCODE_BIN:-/home/overseer/.opencode/bin/opencode}
systemctl_bin=${SYSTEMCTL_BIN:-systemctl}
state_dir=${NQ_STATE_DIR:-/home/overseer/.local/state/nestquest-controller}
renderer="$control_home/scripts/render-cycle-prompt"
profile="$control_home/templates/nestquest.profile.json"
@@ -41,6 +42,17 @@ fi
# Foreground mode owns orchestration. A dormant service must never race it.
"$systemctl_bin" --user stop nestquest-controller.service
# Share the controller's lock and hold it across the final exec. This closes
# the launch-time race between two foreground sessions as well as the race with
# the background controller.
mkdir -p "$state_dir"
chmod 700 "$state_dir"
exec 9>"$state_dir/controller.lock"
if ! flock -n 9; then
echo "Another NestQuest orchestrator or controller is running." >&2
exit 75
fi
session_status=$("$control_home/scripts/nq-controller" --check-interactive --project "$project")
if [[ "$session_status" == "interactive NestQuest session open" ]]; then
echo "Another interactive NestQuest OpenCode session is open." >&2
+79 -21
View File
@@ -150,6 +150,15 @@ Possible scopes:
- `PROJECT_QUEUE`
- `RELEASE_PREPARATION`
Also set `RELEASE_PREPARATION_POLICY` at startup:
- `AUTO` by default.
- `EXCLUDED` only when the user's launch request explicitly says not to prepare
a release or `{{INTEGRATION_BRANCH}}`-to-`{{RELEASE_BRANCH}}` promotion.
Record both `MISSION_SCOPE` and `RELEASE_PREPARATION_POLICY` in durable run
state. Do not infer exclusion merely from `SINGLE_TASK` or `SINGLE_FEATURE`.
If the user explicitly names one task or feature, respect that scope.
If the user launches the orchestrator for general/autonomous {{PROJECT_NAME}} development without limiting it to one feature, use:
@@ -171,6 +180,7 @@ Maintain a durable orchestration checkpoint using {{WORK_TRACKER}} wherever its
Track at minimum:
- `MISSION_SCOPE`
- `RELEASE_PREPARATION_POLICY`
- Current feature
- Current controller phase
- Active development agents
@@ -185,6 +195,9 @@ Track at minimum:
- Blocked items
- Tests/checks status
- Last verified repository state
- Active promotion branch and pull request
- Triggering feature and exact `{{INTEGRATION_BRANCH}}` commit for each automatic promotion
- Completed features pending the next promotion
- `NEXT_ACTION`
After every material state transition:
@@ -254,8 +267,8 @@ Choose the highest-priority actionable transition in this order:
8. Remediate feature-level review findings.
9. Obtain feature approval.
10. Merge an approved feature into `{{INTEGRATION_BRANCH}}`.
11. Advance to the next eligible feature when mission scope permits.
12. Begin a requested/scheduled release when appropriate.
11. Begin an automatically triggered, requested, or scheduled release when appropriate. The automatic post-feature trigger is in scope for both `SINGLE_FEATURE` and `PROJECT_QUEUE` unless `RELEASE_PREPARATION_POLICY = EXCLUDED`.
12. Advance to the next eligible feature when mission scope permits.
13. Continue unrelated work around locally blocked items.
14. Escalate only when the defined escalation rules require it.
@@ -588,9 +601,10 @@ Immediately transition to `FEATURE_INTEGRATION`.
A merged feature is a transition.
It is terminal ONLY when:
`MISSION_SCOPE = SINGLE_FEATURE`
It is terminal only when `MISSION_SCOPE = SINGLE_FEATURE` and no authorized
post-feature release transition is pending. Preparing the automatic promotion
for that completed feature is part of `SINGLE_FEATURE` scope when
`RELEASE_PREPARATION_POLICY = AUTO`.
Otherwise select the next eligible work.
@@ -637,13 +651,19 @@ The orchestrator itself does not perform the merge.
When `MISSION_SCOPE = PROJECT_QUEUE` and a feature is successfully merged into `{{INTEGRATION_BRANCH}}`:
1. Reconcile {{WORK_TRACKER}}.
2. Check for a requested/scheduled release.
2. Evaluate the automatic, requested, and scheduled release triggers.
3. Identify remaining incomplete features.
4. Determine which features are eligible based on dependencies and priority.
5. Select the next eligible feature.
6. Initialize it.
7. Continue the development loop.
If an automatic promotion becomes `RELEASE_READY` while another eligible
feature remains, keep that promotion waiting for {{RELEASE_OWNER_NAME}} and
continue the project queue. A ready promotion is a local checkpoint in this
case, not a global terminal condition. Features completed while it remains open
are recorded as pending for the next promotion under Section 18.
Do not return to the user simply because one feature completed.
If several features are available, choose using:
@@ -658,27 +678,52 @@ If several features are available, choose using:
# 18. RELEASE RULES
Completed features accumulate in `{{INTEGRATION_BRANCH}}`.
Completed features accumulate in `{{INTEGRATION_BRANCH}}`. With no active
promotion, each verified feature completion starts release preparation. While
one promotion is active, later completed features accumulate for the next
promotion.
Prepare a release only when:
Prepare a release when any of these conditions is true:
- {{RELEASE_OWNER_NAME}} requests it; OR
- {{WORK_TRACKER}} schedules it.
- {{WORK_TRACKER}} schedules it; OR
- `RELEASE_PREPARATION_POLICY = AUTO`, and a feature becomes task-complete and its reviewed merge into `{{INTEGRATION_BRANCH}}` is verified.
For the automatic trigger, a feature is task-complete only when:
- Every required feature task is `done`, or deliberately `canceled` with recorded evidence.
- No task review, remediation, approval, or merge remains pending.
- The feature acceptance criteria and integrated checks pass.
- Independent approval applies to the exact feature head.
- The approver's merge of that head into `{{INTEGRATION_BRANCH}}` is verified.
- {{WORK_TRACKER}} reflects the completed feature and exact repository references.
Task completion inside an unmerged feature branch does not trigger a release.
The verified feature-to-`{{INTEGRATION_BRANCH}}` merge is the triggering transition.
Before creating a release branch or pull request:
1. Verify this session is the sole active orchestrator for the project. `{{TERMINAL_LAUNCHER}}` enforces that exclusivity by holding the shared controller lock for the full foreground session. If that lock is not held by this session, do not create a release branch or pull request; escalate instead.
2. Reconcile open release records, release branches, and promotion pull requests.
3. Do not create a duplicate promotion for an already-covered `{{INTEGRATION_BRANCH}}` commit.
4. Allow only one active promotion pull request to `{{RELEASE_BRANCH}}`. If an earlier promotion is still open and does not contain the newly completed feature, record the feature as pending for the next automatic promotion. Resume it after the active promotion is merged or closed; if it was closed without merge, recalculate the next promotion from the current validated `{{INTEGRATION_BRANCH}}` head.
5. Record the triggering feature and exact `{{INTEGRATION_BRANCH}}` commit so the automatic trigger runs once for that merge.
Release process:
1. Reconcile release scope.
2. Confirm included features are complete.
3. Create release branch from exact validated `{{INTEGRATION_BRANCH}}`.
4. Run complete release checks.
5. Open release PR → `{{RELEASE_BRANCH}}`.
6. Obtain fresh independent {{REVIEWER_VENDOR}} review.
7. Remediate findings through development agents.
8. Incorporate every release correction back into `{{INTEGRATION_BRANCH}}`.
9. Revalidate.
10. Assign final release PR to `{{HOST_ACCOUNT}}`.
11. Update {{WORK_TRACKER}}.
12. Stop before {{RELEASE_BRANCH}} merge.
1. Enforce every pre-creation requirement above, including controller exclusivity, reconciliation, duplicate and active-promotion checks, and trigger recording.
2. Reconcile release scope.
3. Confirm included features are complete.
4. Create release branch from exact validated `{{INTEGRATION_BRANCH}}`.
5. Run complete release checks.
6. Open release PR → `{{RELEASE_BRANCH}}`.
7. Obtain fresh independent {{REVIEWER_VENDOR}} review.
8. Remediate findings through development agents.
9. Incorporate every release correction back into `{{INTEGRATION_BRANCH}}`.
10. Revalidate.
11. Assign final release PR to `{{HOST_ACCOUNT}}`.
12. Update {{WORK_TRACKER}}.
13. Stop before {{RELEASE_BRANCH}} merge.
Only {{RELEASE_OWNER_NAME}} may merge into `{{RELEASE_BRANCH}}`.
@@ -724,13 +769,23 @@ For `PROJECT_QUEUE`, this additionally requires:
- No eligible incomplete feature remains.
- No task/review/remediation/merge is pending.
- No release preparation, release review, or release remediation is pending.
- Every in-scope automatic trigger is either incorporated into the active
`RELEASE_READY` promotion or durably recorded for the next promotion.
- No development agent is active.
- No approver is active.
An active promotion that has not reached `RELEASE_READY` makes
`MISSION_COMPLETE` false.
## `RELEASE_READY`
Release PR has passed required gates and is assigned to `{{HOST_ACCOUNT}}`, awaiting {{RELEASE_OWNER_NAME}}'s {{RELEASE_BRANCH}} merge.
`RELEASE_READY` is globally terminal only when no other in-scope engineering
action is executable. In `PROJECT_QUEUE`, if another eligible incomplete
feature remains, continue that work while the ready promotion waits.
## `GLOBAL_ESCALATION`
All remaining in-scope work is blocked by decisions/access that require {{RELEASE_OWNER_NAME}}.
@@ -778,9 +833,12 @@ Output a machine-readable continuation checkpoint:
```text
ORCHESTRATOR_STATE: CONTINUE
MISSION_SCOPE:
RELEASE_PREPARATION_POLICY:
CONTROLLER_PHASE:
CURRENT_FEATURE:
ACTIVE_TASKS:
ACTIVE_PROMOTION:
PENDING_PROMOTION_FEATURES:
BLOCKED_ITEMS:
LAST_VERIFIED_STATE:
NEXT_ACTION:
+68
View File
@@ -24,16 +24,20 @@ from __future__ import annotations
import asyncio
import importlib.util
import inspect
import re
import sys
import tempfile
import unicodedata
import weakref
from dataclasses import dataclass
from html.entities import name2codepoint
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
from unidecode import unidecode as _unidecode
from tests.admin_jwt_harness import ADMIN_KEY, KID, AdminRunner, jwks_for
@@ -46,6 +50,7 @@ _HA_MODULES = (
"homeassistant.config_entries",
"homeassistant.data_entry_flow",
"homeassistant.exceptions",
"homeassistant.util",
"homeassistant.helpers",
"homeassistant.helpers.config_validation",
"homeassistant.helpers.entity",
@@ -94,6 +99,7 @@ for _parent, _child in (
("homeassistant", "data_entry_flow"),
("homeassistant", "core"),
("homeassistant", "exceptions"),
("homeassistant", "util"),
("homeassistant", "helpers"),
("homeassistant.helpers", "config_validation"),
("homeassistant.helpers", "entity"),
@@ -218,6 +224,68 @@ _exceptions_mock.ConfigEntryNotReady = ConfigEntryNotReady
_exceptions_mock.Unauthorized = Unauthorized
# ---------------------------------------------------------------------------
# homeassistant.util.slugify: a faithful stand-in for the real slugifier.
#
# Real HA (2024.6) wraps python-slugify's frozen legacy pipeline and answers
# its "unknown" sentinel when a non-empty name slugs to nothing. The stub
# mirrors that pipeline step for step — quotes to dashes, NFKD-normalize,
# TRANSLITERATE through unidecode (the backend python-slugify's "auto" mode
# prefers, and a dev dependency here), decode HTML entities, NFKD again,
# lowercase, drop quotes, fold runs of non-[a-z0-9-] to one dash, collapse
# and strip dashes, then map dashes to the requested separator — and was
# verified byte-for-byte against real python-slugify across Latin, Cyrillic,
# CJK and Greek household-name shapes. Transliteration is the point: HA
# derives the per-child entity ids from a name like "京子" as
# "nestquest_jing_zi_…", so the roster's slugs must transliterate too,
# never dropping characters into "unknown". Deliberately NOT truncating:
# real slugify caps nothing; the entity registry truncates the full
# entity_id instead.
# ---------------------------------------------------------------------------
_HA_QUOTE_PATTERN = re.compile(r"[']+")
_HA_CHAR_ENTITY_PATTERN = re.compile(r"&(%s);" % "|".join(name2codepoint))
_HA_DECIMAL_PATTERN = re.compile(r"&#(\d+);")
_HA_HEX_PATTERN = re.compile(r"&#x([\da-fA-F]+);")
_HA_NUMBERS_PATTERN = re.compile(r"(?<=\d),(?=\d)")
_HA_DISALLOWED_CHARS_PATTERN = re.compile(r"[^-a-zA-Z0-9]+")
_HA_DUPLICATE_DASH_PATTERN = re.compile(r"-{2,}")
def _ha_slugify(text: str | None, *, separator: str = "_") -> str:
"""Faithful homeassistant.util.slugify stand-in (see section above)."""
if text == "" or text is None:
return ""
text = str(text)
text = _HA_QUOTE_PATTERN.sub("-", text)
text = unicodedata.normalize("NFKD", text)
text = _unidecode(text)
text = _HA_CHAR_ENTITY_PATTERN.sub(
lambda match: chr(name2codepoint[match.group(1)]), text
)
try:
text = _HA_DECIMAL_PATTERN.sub(
lambda match: chr(int(match.group(1))), text
)
except (ValueError, OverflowError):
pass
try:
text = _HA_HEX_PATTERN.sub(
lambda match: chr(int(match.group(1), 16)), text
)
except (ValueError, OverflowError):
pass
text = unicodedata.normalize("NFKD", text)
text = text.lower()
text = _HA_QUOTE_PATTERN.sub("", text)
text = _HA_NUMBERS_PATTERN.sub("", text)
text = _HA_DISALLOWED_CHARS_PATTERN.sub("-", text)
text = _HA_DUPLICATE_DASH_PATTERN.sub("-", text).strip("-")
return "unknown" if text == "" else text.replace("-", separator)
_ha_mock("homeassistant.util").slugify = _ha_slugify
@dataclass
class DeviceInfo:
"""Stand-in for homeassistant.helpers.entity.DeviceInfo.
+431
View File
@@ -0,0 +1,431 @@
"""Admin-plane occurrence-preview route tests (task 5f843564).
These tests exercise ``POST /api/v1/admin/quest-definitions/occurrences-preview``
against the ASGI app with httpx, reusing the shared local-RSA-key/
stubbed-JWKS harness (:mod:`tests.admin_jwt_harness`) so an admin JWT
genuinely authenticates with NO network access.
- Authentication runs through the router's ONE ``require_admin``
dependency: no credential is 401, the panel service token is 403
(refused as a credential type), a JWT without the admin group is
403, an admin JWT is 200.
- The returned dates are asserted EXACTLY for a weekly rule with an
interval and a start_date, a daily rule with an interval, a rule
with an end_date (the list stops at it), a month-end-clamped monthly
rule, and the default start (the pinned host clock's today); one
case also asserts parity with the core's own ``occurrences_between``
over the documented window.
- A rejected rule (the same core ``from_dict`` validation create/edit
use), a non-strict ``start_date`` and a ``count`` outside 1..50 are
422.
"""
from __future__ import annotations
import datetime
import importlib
from types import SimpleNamespace
from zoneinfo import ZoneInfo
import pytest
from api import routes_admin
from tests.admin_jwt_harness import (
ADMIN_KEY,
KID,
AdminRunner,
jwks_for,
make_token,
)
# The recurrence engine through the SAME core copy the app uses
# (nestquest_core.*, NOT the conftest-stubbed custom_components copy).
_recurrence = importlib.import_module("nestquest_core.recurrence")
PREVIEW_URL = "/api/v1/admin/quest-definitions/occurrences-preview"
#: The panel service token the harness app is configured with.
HARNESS_PANEL_TOKEN = "admin-test-panel-token"
#: The 403 detail for the panel service token on an admin route.
PANEL_TOKEN_REJECTION_DETAIL = (
"This credential type is not accepted for admin access"
)
#: The 403 detail for a valid JWT without the admin group.
FORBIDDEN_DETAIL = (
"Admin access requires membership in the nestquest-admins group"
)
#: The pinned host instant: Wednesday 2026-09-23, noon UTC.
PINNED_NOW = datetime.datetime(2026, 9, 23, 12, 0, 0, tzinfo=ZoneInfo("UTC"))
def _admin_headers() -> dict[str, str]:
"""The Authorization header of a valid nestquest-admins JWT."""
return {"Authorization": f"Bearer {make_token()}"}
def _weekly_rule() -> dict[str, object]:
"""Every OTHER week's Tuesday and Friday, anchored on Tue 2026-03-03."""
return {
"rule_type": "weekly",
"interval": 2,
"weekday_set": [1, 4],
"start_date": "2026-03-03",
}
@pytest.fixture
async def preview(temp_db_path: str, monkeypatch) -> SimpleNamespace:
"""An admin-plane app client with the host clock pinned (and counted)."""
clock_reads: list[datetime.datetime] = []
def _admin_now() -> datetime.datetime:
clock_reads.append(PINNED_NOW)
return PINNED_NOW
monkeypatch.setattr(routes_admin, "_local_now", _admin_now)
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner as client:
yield SimpleNamespace(client=client, clock_reads=clock_reads)
async def _post(client: object, body: dict[str, object]) -> object:
"""POST a preview body with an admin JWT."""
return await client.post(PREVIEW_URL, json=body, headers=_admin_headers())
# --- authentication: the ONE require_admin dependency ----------------------
async def test_preview_without_credential_is_401(
preview: SimpleNamespace,
) -> None:
"""No Authorization header is the uniform 401."""
response = await preview.client.post(
PREVIEW_URL, json={"rule": _weekly_rule()}
)
assert response.status_code == 401
async def test_preview_refuses_panel_service_token(
preview: SimpleNamespace,
) -> None:
"""The panel service token is refused as a credential type (403)."""
response = await preview.client.post(
PREVIEW_URL,
json={"rule": _weekly_rule()},
headers={"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"},
)
assert response.status_code == 403
assert response.json()["detail"] == PANEL_TOKEN_REJECTION_DETAIL
async def test_preview_refuses_non_admin_jwt(
preview: SimpleNamespace,
) -> None:
"""A valid JWT without the nestquest-admins group is 403."""
non_admin = make_token(groups=("some-other-group",))
response = await preview.client.post(
PREVIEW_URL,
json={"rule": _weekly_rule()},
headers={"Authorization": f"Bearer {non_admin}"},
)
assert response.status_code == 403
assert response.json()["detail"] == FORBIDDEN_DETAIL
async def test_preview_admin_jwt_is_200(preview: SimpleNamespace) -> None:
"""An admin JWT is 200 with the documented ``dates`` shape."""
response = await _post(
preview.client, {"rule": _weekly_rule(), "start_date": "2026-03-01"}
)
assert response.status_code == 200
assert set(response.json()) == {"dates"}
# --- the returned dates ----------------------------------------------------
async def test_weekly_interval_rule_from_before_its_start(
preview: SimpleNamespace,
) -> None:
"""Every other week's Tue/Fri, starting at the rule's own start_date.
The preview starts on Sun 2026-03-01, before the rule's anchor, so
the first date is the anchor itself; the off weeks (Mar 10-16,
Mar 24-30) are skipped.
"""
response = await _post(
preview.client,
{"rule": _weekly_rule(), "start_date": "2026-03-01", "count": 6},
)
assert response.status_code == 200
assert response.json()["dates"] == [
"2026-03-03",
"2026-03-06",
"2026-03-17",
"2026-03-20",
"2026-03-31",
"2026-04-03",
]
async def test_weekly_interval_rule_from_mid_cadence(
preview: SimpleNamespace,
) -> None:
"""A start inside an on-week keeps the rule's anchor cadence."""
response = await _post(
preview.client,
{"rule": _weekly_rule(), "start_date": "2026-03-18", "count": 4},
)
assert response.status_code == 200
assert response.json()["dates"] == [
"2026-03-20",
"2026-03-31",
"2026-04-03",
"2026-04-14",
]
async def test_daily_interval_rule(preview: SimpleNamespace) -> None:
"""Every third day from 2026-01-01, previewed from 2026-01-05."""
response = await _post(
preview.client,
{
"rule": {
"rule_type": "daily",
"interval": 3,
"start_date": "2026-01-01",
},
"start_date": "2026-01-05",
"count": 4,
},
)
assert response.status_code == 200
assert response.json()["dates"] == [
"2026-01-07",
"2026-01-10",
"2026-01-13",
"2026-01-16",
]
async def test_rule_with_end_date_stops_at_it(
preview: SimpleNamespace,
) -> None:
"""A daily rule ending 2026-05-04 yields only its four dates."""
response = await _post(
preview.client,
{
"rule": {
"rule_type": "daily",
"start_date": "2026-05-01",
"end_date": "2026-05-04",
},
"start_date": "2026-04-28",
"count": 10,
},
)
assert response.status_code == 200
assert response.json()["dates"] == [
"2026-05-01",
"2026-05-02",
"2026-05-03",
"2026-05-04",
]
async def test_monthly_day_rule_clamps_to_month_end(
preview: SimpleNamespace,
) -> None:
"""The core's month-end policy: the 31st fires on Feb 28th."""
response = await _post(
preview.client,
{
"rule": {
"rule_type": "monthly_day",
"day_of_month": 31,
"start_date": "2026-01-31",
},
"start_date": "2026-01-01",
"count": 3,
},
)
assert response.status_code == 200
assert response.json()["dates"] == [
"2026-01-31",
"2026-02-28",
"2026-03-31",
]
async def test_default_start_is_host_today_with_default_count(
preview: SimpleNamespace,
) -> None:
"""No start_date: ONE clock read's date; no count: ten dates."""
response = await _post(preview.client, {"rule": {"rule_type": "daily"}})
assert response.status_code == 200
expected = [
(datetime.date(2026, 9, 23) + datetime.timedelta(days=n)).isoformat()
for n in range(10)
]
assert response.json()["dates"] == expected
assert preview.clock_reads == [PINNED_NOW]
async def test_supplied_start_date_reads_no_clock(
preview: SimpleNamespace,
) -> None:
"""A supplied start_date is used as-is: the host clock is not read."""
response = await _post(
preview.client, {"rule": _weekly_rule(), "start_date": "2026-03-01"}
)
assert response.status_code == 200
assert preview.clock_reads == []
async def test_dates_match_core_occurrences_between_over_window(
preview: SimpleNamespace,
) -> None:
"""The route's dates ARE the core's walk over the 366-day window."""
rule_body = {
"rule_type": "monthly_weekday",
"nth_weekday": -1,
"nth_weekday_weekday": 4,
"start_date": "2026-01-01",
}
response = await _post(
preview.client,
{"rule": rule_body, "start_date": "2026-02-10", "count": 50},
)
assert response.status_code == 200
core_dates = _recurrence.occurrences_between(
_recurrence.ScheduleRule.from_dict(rule_body),
"2026-02-10",
"2027-02-11",
)
assert response.json()["dates"] == core_dates
assert len(core_dates) == 12
async def test_yearly_rule_shows_next_occurrence_within_window(
preview: SimpleNamespace,
) -> None:
"""The 366-day window always reaches a yearly rule's next date."""
response = await _post(
preview.client,
{
"rule": {
"rule_type": "yearly",
"month": 9,
"day_of_month": 22,
"start_date": "2020-09-22",
},
"start_date": "2026-09-23",
},
)
assert response.status_code == 200
assert response.json()["dates"] == ["2027-09-22"]
async def test_start_near_calendar_end_is_422(
preview: SimpleNamespace,
) -> None:
"""A window that would reach 9999-12-31 is rejected, not clamped."""
response = await _post(
preview.client,
{"rule": {"rule_type": "daily"}, "start_date": "9999-12-30"},
)
assert response.status_code == 422
assert "too close to the end of the calendar" in response.json()["detail"]
async def test_latest_accepted_start_date(preview: SimpleNamespace) -> None:
"""The last start whose window ends before 9999-12-31 is accepted."""
response = await _post(
preview.client,
{
"rule": {"rule_type": "daily"},
"start_date": "9998-12-29",
"count": 1,
},
)
assert response.status_code == 200
assert response.json()["dates"] == ["9998-12-29"]
# --- rejections: 422 -------------------------------------------------------
@pytest.mark.parametrize(
"rule",
[
{"rule_type": "weekly"}, # weekly without weekday_set
{"rule_type": "fortnightly"}, # unknown rule_type
{"rule_type": "daily", "interval": 0},
{"rule_type": "daily", "weekday_set": [1]}, # forbidden field
{"rule_type": "daily", "start_date": "2026-3-1"},
{
"rule_type": "daily",
"start_date": "2026-05-04",
"end_date": "2026-05-01",
},
],
)
async def test_malformed_rule_is_422(
preview: SimpleNamespace, rule: dict[str, object]
) -> None:
"""A rule the core's from_dict rejects is 422, as on create/edit."""
response = await _post(
preview.client, {"rule": rule, "start_date": "2026-03-01"}
)
assert response.status_code == 422
async def test_missing_rule_is_422(preview: SimpleNamespace) -> None:
"""The rule is required: the body model's 422."""
response = await _post(preview.client, {"start_date": "2026-03-01"})
assert response.status_code == 422
@pytest.mark.parametrize(
"start_date", ["2026-3-1", "20260301", "tomorrow", "2026-02-30", ""]
)
async def test_non_strict_start_date_is_422(
preview: SimpleNamespace, start_date: str
) -> None:
"""start_date must be a strict YYYY-MM-DD date (the core's check)."""
response = await _post(
preview.client, {"rule": _weekly_rule(), "start_date": start_date}
)
assert response.status_code == 422
@pytest.mark.parametrize("count", [0, -1, 51, 1000, True, "5", 2.5])
async def test_count_out_of_range_is_422(
preview: SimpleNamespace, count: object
) -> None:
"""count must be a strict JSON integer in 1..50."""
response = await _post(
preview.client,
{"rule": _weekly_rule(), "start_date": "2026-03-01", "count": count},
)
assert response.status_code == 422
@pytest.mark.parametrize("count", [1, 50])
async def test_count_bounds_are_inclusive(
preview: SimpleNamespace, count: int
) -> None:
"""count 1 and 50 are accepted and truncate the list exactly."""
response = await _post(
preview.client,
{
"rule": {"rule_type": "daily"},
"start_date": "2026-01-01",
"count": count,
},
)
assert response.status_code == 200
assert len(response.json()["dates"]) == count
+127 -3
View File
@@ -18,8 +18,9 @@ caveat in api/nestquest_core.py):
and windows exactly as posted.
- ``PATCH /api/v1/admin/quest-definitions/{id}``: an edit PERSISTS the
supplied fields and leaves omitted fields untouched (exclude_unset
semantics); a supplied ``windows`` list replaces the whole set;
assignment is not settable through the edit.
semantics); a supplied ``windows`` list replaces the whole set; an
omitted ``assignee_child_ids`` leaves the assignees untouched (the
supplied-list cases live in test_api_admin_questdef_assignees.py).
- ``PATCH /api/v1/admin/quest-definitions/{id}/active`` with
``is_active: false`` sets the flag FALSE and the definition STILL
EXISTS with its rule, assignees and windows (never hard-deleted);
@@ -215,6 +216,7 @@ async def test_create_persists_definition_with_assignees_and_windows(
"description",
"icon",
"is_active",
"skip_on_away",
"rule",
"assignees",
"windows",
@@ -223,6 +225,7 @@ async def test_create_persists_definition_with_assignees_and_windows(
assert payload["description"] == "Put everything back where it belongs."
assert payload["icon"] == "toy-box"
assert payload["is_active"] is True
assert payload["skip_on_away"] is True
assert payload["rule"] == _weekly_rule_dict()
assert {a["id"]: a["display_name"] for a in payload["assignees"]} == {
ada: "Ada",
@@ -395,7 +398,7 @@ async def test_edit_persists_supplied_fields_and_leaves_omitted_untouched(
)
assert stored.definition.icon == "toy-box"
assert stored.rule.to_dict() == _weekly_rule_dict()
# Assignment is not settable through the edit route: untouched.
# assignee_child_ids was omitted: the assignees are untouched.
assert sorted(child.id for child in stored.assignees) == [ada, bo]
# A second edit touching only the rule leaves the metadata and the
@@ -521,9 +524,130 @@ async def test_edit_rejections_are_422_and_change_nothing(
}
@pytest.mark.parametrize("coercible", [0, 1, "true", "false"])
async def test_create_rejects_coercible_skip_on_away_and_persists_nothing(
admin_questdefs: SimpleNamespace, coercible: object
) -> None:
"""A numeric or string skip_on_away is 422, not coerced to a bool."""
client = admin_questdefs.client
dao = _dao_rules.QuestDefinitionsDao(admin_questdefs.database)
ada = (await _create_children(client, ("Ada",)))[0]
existing = await client.post(
"/api/v1/admin/quest-definitions",
json={
"title": "Feed the cat",
"rule": _weekly_rule_body(),
"assignee_child_ids": [ada],
"windows": ["morning"],
"skip_on_away": False,
},
headers=_admin_headers(),
)
assert existing.status_code == 201
existing_id = existing.json()["id"]
response = await client.post(
"/api/v1/admin/quest-definitions",
json={
"title": "Tidy the den",
"rule": _weekly_rule_body(),
"assignee_child_ids": [ada],
"windows": ["morning"],
"skip_on_away": coercible,
},
headers=_admin_headers(),
)
assert response.status_code == 422
assert set(await _definitions_by_id(admin_questdefs.database)) == {
existing_id
}
assert (await dao.get(existing_id)).skip_on_away is False
@pytest.mark.parametrize("coercible", [0, 1, "true", "false"])
async def test_edit_rejects_coercible_skip_on_away_and_changes_nothing(
admin_questdefs: SimpleNamespace, coercible: object
) -> None:
"""A numeric or string skip_on_away edit is 422; the flag is kept."""
client = admin_questdefs.client
dao = _dao_rules.QuestDefinitionsDao(admin_questdefs.database)
ada = (await _create_children(client, ("Ada",)))[0]
definition_id = (await _create_definition(client, [ada])).json()["id"]
# Start from each stored value so a coerced write would be visible.
for stored in (True, False):
set_flag = await client.patch(
f"/api/v1/admin/quest-definitions/{definition_id}",
json={"skip_on_away": stored},
headers=_admin_headers(),
)
assert set_flag.status_code == 200
response = await client.patch(
f"/api/v1/admin/quest-definitions/{definition_id}",
json={"skip_on_away": coercible, "title": "Renamed"},
headers=_admin_headers(),
)
assert response.status_code == 422
stored_definition = await dao.get(definition_id)
assert stored_definition.skip_on_away is stored
assert stored_definition.title == "Tidy the den"
# --- set_active -------------------------------------------------------------
async def test_edit_toggles_skip_on_away_and_omission_keeps_it(
admin_questdefs: SimpleNamespace,
) -> None:
"""A supplied skip_on_away persists; an edit omitting it keeps it."""
client = admin_questdefs.client
dao = _dao_rules.QuestDefinitionsDao(admin_questdefs.database)
ada = (await _create_children(client, ("Ada",)))[0]
created = await _create_definition(client, [ada])
assert created.status_code == 201
definition_id = created.json()["id"]
assert created.json()["skip_on_away"] is True
toggled_off = await client.patch(
f"/api/v1/admin/quest-definitions/{definition_id}",
json={"skip_on_away": False},
headers=_admin_headers(),
)
assert toggled_off.status_code == 200
assert toggled_off.json()["skip_on_away"] is False
assert (await dao.get(definition_id)).skip_on_away is False
# An edit that omits the flag leaves the stored False untouched.
renamed = await client.patch(
f"/api/v1/admin/quest-definitions/{definition_id}",
json={"title": "Tidy the den properly"},
headers=_admin_headers(),
)
assert renamed.status_code == 200
assert renamed.json()["skip_on_away"] is False
stored = await dao.get(definition_id)
assert stored.title == "Tidy the den properly"
assert stored.skip_on_away is False
toggled_on = await client.patch(
f"/api/v1/admin/quest-definitions/{definition_id}",
json={"skip_on_away": True},
headers=_admin_headers(),
)
assert toggled_on.status_code == 200
assert toggled_on.json()["skip_on_away"] is True
assert (await dao.get(definition_id)).skip_on_away is True
# A non-boolean value is a malformed body: 422, nothing changes.
rejected = await client.patch(
f"/api/v1/admin/quest-definitions/{definition_id}",
json={"skip_on_away": "sometimes"},
headers=_admin_headers(),
)
assert rejected.status_code == 422
assert (await dao.get(definition_id)).skip_on_away is True
async def test_set_active_toggles_the_flag_and_the_definition_survives(
admin_questdefs: SimpleNamespace,
) -> None:
+431
View File
@@ -0,0 +1,431 @@
"""Admin-plane editable assignees on the definition edit route (task 1c8096f6).
``PATCH /api/v1/admin/quest-definitions/{definition_id}`` accepts an
optional ``assignee_child_ids`` list that REPLACES the definition's
assignee set inside the core edit's ONE transaction
(:func:`nestquest_core.quest_definitions.edit_quest_definition`):
- newly listed children are assigned and unlisted ones unassigned; the
response and the database both carry the resulting set;
- an empty list, a duplicate, an unknown child or an inactive child is
422 and NOTHING is written — the stored assignees (and any other
field supplied in the same body) are unchanged;
- a JSON boolean, float or numeric string id is 422 on create and edit
(never coerced to an int) and nothing is written;
- an omitted ``assignee_child_ids`` leaves the assignees unchanged;
- an assignment change regenerates the definition's future instances
through the existing core path;
- the route stays behind the router's ONE ``require_admin`` dependency
(absent credential 401, panel service token 403, non-admin JWT 403,
admin JWT 200).
Every case is proven against the DATABASE, read through the SAME core
copy the app uses (``nestquest_core.*``; see api/nestquest_core.py).
"""
from __future__ import annotations
import datetime
import importlib
from types import SimpleNamespace
import pytest
from tests.admin_jwt_harness import (
ADMIN_KEY,
KID,
AdminRunner,
jwks_for,
make_token,
)
_dao_rules = importlib.import_module("nestquest_core.dao_rules")
_dao_instances = importlib.import_module("nestquest_core.dao_instances")
_materialize = importlib.import_module("nestquest_core.materialize")
_quest_definitions = importlib.import_module(
"nestquest_core.quest_definitions"
)
#: The panel service token the harness app is configured with.
HARNESS_PANEL_TOKEN = "admin-test-panel-token"
def _admin_headers() -> dict[str, str]:
"""The Authorization header of a valid nestquest-admins JWT."""
return {"Authorization": f"Bearer {make_token()}"}
def _edit_path(definition_id: int) -> str:
"""The edit route for one definition."""
return f"/api/v1/admin/quest-definitions/{definition_id}"
@pytest.fixture
async def admin_assignees(temp_db_path: str) -> SimpleNamespace:
"""An admin-plane app client plus its live database."""
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner as client:
yield SimpleNamespace(
client=client,
database=runner.app.state.db.database,
)
async def _create_children(
client: object, names: tuple[str, ...]
) -> list[int]:
"""Create ACTIVE children through the admin children route."""
ids = []
for name in names:
created = await client.post(
"/api/v1/admin/children",
json={"display_name": name},
headers=_admin_headers(),
)
assert created.status_code == 201
ids.append(created.json()["id"])
return ids
async def _create_definition(client: object, assignee_ids: list[int]) -> int:
"""POST a daily morning definition for the given assignees."""
created = await client.post(
"/api/v1/admin/quest-definitions",
json={
"title": "Brush teeth",
"rule": {"rule_type": "daily"},
"assignee_child_ids": assignee_ids,
"windows": ["morning"],
},
headers=_admin_headers(),
)
assert created.status_code == 201
return created.json()["id"]
async def _stored_assignee_ids(
database: object, definition_id: int
) -> set[int]:
"""The definition's assignee ids as stored."""
dao = _dao_rules.QuestDefinitionsDao(database)
return {child.id for child in await dao.list_assignees(definition_id)}
def _response_assignee_ids(response: object) -> set[int]:
"""The assignee ids a definition payload reports."""
return {assignee["id"] for assignee in response.json()["assignees"]}
# --- add / remove / replace -------------------------------------------------
@pytest.mark.parametrize(
("initial", "requested"),
[
(("Ada",), ("Ada", "Bo")), # add a child
(("Ada", "Bo"), ("Ada",)), # remove a child
(("Ada", "Bo"), ("Cy",)), # replace the whole set
],
)
async def test_edit_replaces_the_assignee_set(
admin_assignees: SimpleNamespace,
initial: tuple[str, ...],
requested: tuple[str, ...],
) -> None:
"""A supplied list becomes exactly the stored and returned set."""
client = admin_assignees.client
names = ("Ada", "Bo", "Cy")
ids = dict(zip(names, await _create_children(client, names)))
definition_id = await _create_definition(
client, [ids[name] for name in initial]
)
wanted = {ids[name] for name in requested}
response = await client.patch(
_edit_path(definition_id),
json={"assignee_child_ids": [ids[name] for name in requested]},
headers=_admin_headers(),
)
assert response.status_code == 200
assert _response_assignee_ids(response) == wanted
assert {a["display_name"] for a in response.json()["assignees"]} == set(
requested
)
assert await _stored_assignee_ids(
admin_assignees.database, definition_id
) == wanted
# The rest of the definition is untouched by an assignee-only edit.
assert response.json()["title"] == "Brush teeth"
assert [w["window"] for w in response.json()["windows"]] == ["morning"]
async def test_edit_with_assignees_and_other_fields_applies_both(
admin_assignees: SimpleNamespace,
) -> None:
"""Assignees and metadata supplied together land in one edit."""
client = admin_assignees.client
ada, bo = await _create_children(client, ("Ada", "Bo"))
definition_id = await _create_definition(client, [ada])
response = await client.patch(
_edit_path(definition_id),
json={"title": "Floss", "assignee_child_ids": [bo]},
headers=_admin_headers(),
)
assert response.status_code == 200
assert response.json()["title"] == "Floss"
assert _response_assignee_ids(response) == {bo}
dao = _dao_rules.QuestDefinitionsDao(admin_assignees.database)
assert (await dao.get(definition_id)).title == "Floss"
assert await _stored_assignee_ids(
admin_assignees.database, definition_id
) == {bo}
# --- rejections leave the stored state unchanged ----------------------------
@pytest.mark.parametrize(
("case", "expected_detail"),
[
("empty", "assignee_child_ids must not be empty"),
("unknown", "assignee_child_ids: child 999 does not exist"),
("inactive", "assignee_child_ids: child {cy} is inactive"),
("duplicate", "assignee_child_ids must not contain duplicate"),
("null", "assignee_child_ids must be a list"),
("mixed_unknown", "assignee_child_ids: child 999 does not exist"),
("wrong_type", None), # the body model's own 422
],
)
async def test_rejected_assignee_list_is_422_and_changes_nothing(
admin_assignees: SimpleNamespace,
case: str,
expected_detail: str | None,
) -> None:
"""Empty/unknown/inactive/malformed lists are 422 with no mutation.
The same body also renames the definition, proving the whole edit
is all-or-nothing: neither the assignees nor the title change.
"""
client = admin_assignees.client
ada, bo, cy, di = await _create_children(
client, ("Ada", "Bo", "Cy", "Di")
)
definition_id = await _create_definition(client, [ada, bo])
deactivated = await client.patch(
f"/api/v1/admin/children/{cy}/active",
json={"is_active": False},
headers=_admin_headers(),
)
assert deactivated.status_code == 200
requested: object = {
"empty": [],
"unknown": [999],
"inactive": [ada, cy],
"duplicate": [ada, ada],
"null": None,
# A valid new child alongside an unknown one: di must NOT be
# assigned (all-or-nothing), and ada/bo must NOT be removed.
"mixed_unknown": [di, 999],
"wrong_type": ["Ada"],
}[case]
response = await client.patch(
_edit_path(definition_id),
json={"title": "Renamed", "assignee_child_ids": requested},
headers=_admin_headers(),
)
assert response.status_code == 422, case
if expected_detail is not None:
assert response.json()["detail"].startswith(
expected_detail.format(cy=cy)
), response.json()
assert await _stored_assignee_ids(
admin_assignees.database, definition_id
) == {ada, bo}
dao = _dao_rules.QuestDefinitionsDao(admin_assignees.database)
assert (await dao.get(definition_id)).title == "Brush teeth"
#: JSON ids the lax ``int`` parser would coerce (``true`` -> 1).
_COERCIBLE_IDS = [True, 1.0, "1"]
@pytest.mark.parametrize("coercible", _COERCIBLE_IDS)
async def test_edit_rejects_coercible_assignee_ids_and_changes_nothing(
admin_assignees: SimpleNamespace, coercible: object
) -> None:
"""A bool, float or numeric-string id edit is 422; nothing changes."""
client = admin_assignees.client
ada, bo = await _create_children(client, ("Ada", "Bo"))
# Assign bo only, so a coerced id 1 (ada) would be a visible change.
definition_id = await _create_definition(client, [bo])
assert ada == 1
response = await client.patch(
_edit_path(definition_id),
json={"title": "Renamed", "assignee_child_ids": [coercible]},
headers=_admin_headers(),
)
assert response.status_code == 422
assert await _stored_assignee_ids(
admin_assignees.database, definition_id
) == {bo}
dao = _dao_rules.QuestDefinitionsDao(admin_assignees.database)
assert (await dao.get(definition_id)).title == "Brush teeth"
@pytest.mark.parametrize("coercible", _COERCIBLE_IDS)
async def test_create_rejects_coercible_assignee_ids_and_persists_nothing(
admin_assignees: SimpleNamespace, coercible: object
) -> None:
"""A bool, float or numeric-string id create is 422; nothing is written."""
client = admin_assignees.client
ada, bo = await _create_children(client, ("Ada", "Bo"))
assert ada == 1
existing_id = await _create_definition(client, [bo])
response = await client.post(
"/api/v1/admin/quest-definitions",
json={
"title": "Tidy the den",
"rule": {"rule_type": "daily"},
"assignee_child_ids": [coercible],
"windows": ["morning"],
},
headers=_admin_headers(),
)
assert response.status_code == 422
bundles = await _quest_definitions.list_active_definitions(
admin_assignees.database
)
assert [bundle.definition.id for bundle in bundles] == [existing_id]
assert await _stored_assignee_ids(
admin_assignees.database, existing_id
) == {bo}
async def test_rejected_assignees_on_unknown_definition_is_404(
admin_assignees: SimpleNamespace,
) -> None:
"""An unknown definition id is still 404 when assignees are supplied."""
client = admin_assignees.client
(ada,) = await _create_children(client, ("Ada",))
response = await client.patch(
_edit_path(999),
json={"assignee_child_ids": [ada]},
headers=_admin_headers(),
)
assert response.status_code == 404
assert response.json()["detail"] == "Quest definition not found"
# --- omitted keeps ----------------------------------------------------------
async def test_omitted_assignees_are_unchanged(
admin_assignees: SimpleNamespace,
) -> None:
"""An edit without assignee_child_ids never touches the assignees."""
client = admin_assignees.client
ada, bo = await _create_children(client, ("Ada", "Bo"))
definition_id = await _create_definition(client, [ada, bo])
response = await client.patch(
_edit_path(definition_id),
json={"title": "Floss"},
headers=_admin_headers(),
)
assert response.status_code == 200
assert _response_assignee_ids(response) == {ada, bo}
assert await _stored_assignee_ids(
admin_assignees.database, definition_id
) == {ada, bo}
# --- regeneration -----------------------------------------------------------
async def test_assignment_change_regenerates_future_instances(
admin_assignees: SimpleNamespace,
) -> None:
"""Swapping the assignee moves the future open instances with it.
The regeneration lives in the core edit path the route calls: the
definition's open instances at or after today are deleted and the
horizon re-materialized for the NEW assignee set.
"""
client = admin_assignees.client
database = admin_assignees.database
ada, bo = await _create_children(client, ("Ada", "Bo"))
definition_id = await _create_definition(client, [ada])
today = datetime.date.today()
start = today.isoformat()
end = (today + datetime.timedelta(days=7)).isoformat()
await _materialize.materialize(database, start, end, today=today)
instances = _dao_instances.QuestInstancesDao(database)
assert await instances.list_by_date_range(ada, start, end)
assert await instances.list_by_date_range(bo, start, end) == []
response = await client.patch(
_edit_path(definition_id),
json={"assignee_child_ids": [bo]},
headers=_admin_headers(),
)
assert response.status_code == 200
assert await instances.list_by_date_range(ada, start, end) == []
regenerated = await instances.list_by_date_range(bo, start, end)
assert regenerated
assert {i.definition_id for i in regenerated} == {definition_id}
# --- authentication ---------------------------------------------------------
async def test_assignee_edit_auth_outcomes(
admin_assignees: SimpleNamespace,
) -> None:
"""401 absent, 403 panel token, 403 non-admin, 200 admin."""
client = admin_assignees.client
ada, bo = await _create_children(client, ("Ada", "Bo"))
definition_id = await _create_definition(client, [ada])
body = {"assignee_child_ids": [bo]}
absent = await client.patch(_edit_path(definition_id), json=body)
assert absent.status_code == 401
panel = await client.patch(
_edit_path(definition_id),
json=body,
headers={"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"},
)
assert panel.status_code == 403
non_admin_token = make_token(groups=("some-other-group",))
non_admin = await client.patch(
_edit_path(definition_id),
json=body,
headers={"Authorization": f"Bearer {non_admin_token}"},
)
assert non_admin.status_code == 403
# None of the refused calls reached the handler.
assert await _stored_assignee_ids(
admin_assignees.database, definition_id
) == {ada}
admin = await client.patch(
_edit_path(definition_id), json=body, headers=_admin_headers()
)
assert admin.status_code == 200
assert await _stored_assignee_ids(
admin_assignees.database, definition_id
) == {bo}
+299
View File
@@ -0,0 +1,299 @@
"""Admin-plane quest-definition LIST route tests (task d4667573).
``GET /api/v1/admin/quest-definitions`` against the ASGI app with httpx,
reusing the shared local-RSA-key/stubbed-JWKS harness
(:mod:`tests.admin_jwt_harness`) so an admin JWT genuinely authenticates
with NO network access.
- An admin JWT lists EVERY definition — active AND inactive — in rising
id order, each in the same documented shape as the create/edit
responses (id, title, description, icon, is_active, rule, assignees,
windows).
- An empty household lists nothing: 200 with an empty list.
- Authentication runs through the router's ONE ``require_admin``
dependency: an absent credential is 401, the panel service token is
refused outright (403, the credential-type detail), and a JWT without
the nestquest-admins group is 403.
"""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from tests.admin_jwt_harness import (
ADMIN_KEY,
KID,
AdminRunner,
jwks_for,
make_token,
)
#: The admin list route under test.
LIST_PATH = "/api/v1/admin/quest-definitions"
#: The panel service token the harness app is configured with.
HARNESS_PANEL_TOKEN = "admin-test-panel-token"
#: The 403 detail for the panel service token on an admin route.
PANEL_TOKEN_REJECTION_DETAIL = (
"This credential type is not accepted for admin access"
)
#: The 403 detail for a valid JWT without the admin group.
FORBIDDEN_DETAIL = (
"Admin access requires membership in the nestquest-admins group"
)
def _admin_headers() -> dict[str, str]:
"""The Authorization header of a valid nestquest-admins JWT."""
return {"Authorization": f"Bearer {make_token()}"}
@pytest.fixture
async def admin_client(temp_db_path: str) -> SimpleNamespace:
"""An admin-plane app client over a fresh (empty) household."""
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner as client:
yield client
async def _seed_active_and_inactive(client: object) -> tuple[int, int, int]:
"""Seed one child, an ACTIVE definition and an INACTIVE one.
Returns ``(child_id, active_id, inactive_id)``. Seeded through the
admin routes so the rows are exactly what the admin plane writes.
"""
child = await client.post(
"/api/v1/admin/children",
json={"display_name": "Ada"},
headers=_admin_headers(),
)
assert child.status_code == 201
child_id = child.json()["id"]
active = await client.post(
LIST_PATH,
json={
"title": "Tidy the den",
"rule": {
"rule_type": "weekly",
"interval": 1,
"weekday_set": [0, 2],
"start_date": "2026-03-02",
},
"assignee_child_ids": [child_id],
"windows": ["morning", ["evening", "19:30"]],
"description": "Put everything back where it belongs.",
"icon": "toy-box",
},
headers=_admin_headers(),
)
assert active.status_code == 201
inactive = await client.post(
LIST_PATH,
json={
"title": "Water the plants",
"rule": {
"rule_type": "daily",
"interval": 1,
"start_date": "2026-03-01",
},
"assignee_child_ids": [child_id],
"windows": ["afternoon"],
},
headers=_admin_headers(),
)
assert inactive.status_code == 201
inactive_id = inactive.json()["id"]
deactivated = await client.patch(
f"{LIST_PATH}/{inactive_id}/active",
json={"is_active": False},
headers=_admin_headers(),
)
assert deactivated.status_code == 200
return child_id, active.json()["id"], inactive_id
async def test_admin_lists_active_and_inactive_definitions_with_full_shape(
admin_client: object,
) -> None:
"""Both definitions come back, by rising id, in the documented shape."""
child_id, active_id, inactive_id = await _seed_active_and_inactive(
admin_client
)
response = await admin_client.get(LIST_PATH, headers=_admin_headers())
assert response.status_code == 200
assert response.json() == {
"definitions": [
{
"id": active_id,
"title": "Tidy the den",
"description": "Put everything back where it belongs.",
"icon": "toy-box",
"is_active": True,
"skip_on_away": True,
"rule": {
"rule_type": "weekly",
"interval": 1,
"weekday_set": [0, 2],
"day_of_month": None,
"nth_weekday": None,
"nth_weekday_weekday": None,
"month": None,
"start_date": "2026-03-02",
"end_date": None,
},
"assignees": [{"id": child_id, "display_name": "Ada"}],
"windows": [
{"window": "morning", "due_time": None},
{"window": "evening", "due_time": "19:30"},
],
},
{
"id": inactive_id,
"title": "Water the plants",
"description": None,
"icon": None,
"is_active": False,
"skip_on_away": True,
"rule": {
"rule_type": "daily",
"interval": 1,
"weekday_set": None,
"day_of_month": None,
"nth_weekday": None,
"nth_weekday_weekday": None,
"month": None,
"start_date": "2026-03-01",
"end_date": None,
},
"assignees": [{"id": child_id, "display_name": "Ada"}],
"windows": [{"window": "afternoon", "due_time": None}],
},
]
}
async def test_list_items_match_the_create_response_shape(
admin_client: object,
) -> None:
"""Each listed item is exactly what the create route returned."""
child = await admin_client.post(
"/api/v1/admin/children",
json={"display_name": "Ben"},
headers=_admin_headers(),
)
created = await admin_client.post(
LIST_PATH,
json={
"title": "Feed the cat",
"rule": {
"rule_type": "daily",
"interval": 1,
"start_date": "2026-03-01",
},
"assignee_child_ids": [child.json()["id"]],
"windows": ["morning"],
},
headers=_admin_headers(),
)
assert created.status_code == 201
response = await admin_client.get(LIST_PATH, headers=_admin_headers())
assert response.status_code == 200
assert response.json() == {"definitions": [created.json()]}
async def test_skip_on_away_round_trips_true_and_false_through_list(
admin_client: object,
) -> None:
"""skip_on_away posted true, false, or omitted lists back as stored."""
child = await admin_client.post(
"/api/v1/admin/children",
json={"display_name": "Cleo"},
headers=_admin_headers(),
)
child_id = child.json()["id"]
async def create(title: str, extra: dict[str, object]) -> dict:
response = await admin_client.post(
LIST_PATH,
json={
"title": title,
"rule": {
"rule_type": "daily",
"interval": 1,
"start_date": "2026-03-01",
},
"assignee_child_ids": [child_id],
"windows": ["morning"],
**extra,
},
headers=_admin_headers(),
)
assert response.status_code == 201
return response.json()
skipping = await create("Skip when away", {"skip_on_away": True})
keeping = await create("Keep when away", {"skip_on_away": False})
defaulted = await create("Default", {})
assert skipping["skip_on_away"] is True
assert keeping["skip_on_away"] is False
assert defaulted["skip_on_away"] is True
response = await admin_client.get(LIST_PATH, headers=_admin_headers())
assert response.status_code == 200
listed = {item["id"]: item for item in response.json()["definitions"]}
assert listed[skipping["id"]]["skip_on_away"] is True
assert listed[keeping["id"]]["skip_on_away"] is False
assert listed[defaulted["id"]]["skip_on_away"] is True
assert response.json() == {"definitions": [skipping, keeping, defaulted]}
async def test_empty_household_lists_no_definitions(
admin_client: object,
) -> None:
"""With no definitions stored the list is 200 and empty."""
response = await admin_client.get(LIST_PATH, headers=_admin_headers())
assert response.status_code == 200
assert response.json() == {"definitions": []}
async def test_list_without_credentials_is_401(admin_client: object) -> None:
"""An absent credential is the uniform 401."""
response = await admin_client.get(LIST_PATH)
assert response.status_code == 401
async def test_list_with_panel_service_token_is_403(
admin_client: object,
) -> None:
"""The panel service token is refused outright, never a JWT."""
response = await admin_client.get(
LIST_PATH,
headers={"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"},
)
assert response.status_code == 403
assert response.json()["detail"] == PANEL_TOKEN_REJECTION_DETAIL
async def test_list_with_non_admin_jwt_is_403(admin_client: object) -> None:
"""A valid JWT without the nestquest-admins group is 403."""
non_admin = make_token(groups=("some-other-group",))
response = await admin_client.get(
LIST_PATH, headers={"Authorization": f"Bearer {non_admin}"}
)
assert response.status_code == 403
assert response.json()["detail"] == FORBIDDEN_DETAIL
+328
View File
@@ -0,0 +1,328 @@
"""Admin-plane snapshot route tests (task ef3f4877).
These tests exercise ``GET /api/v1/admin/snapshot`` against the ASGI
app with httpx, reusing the shared local-RSA-key/stubbed-JWKS harness
(:mod:`tests.admin_jwt_harness`) so an admin JWT genuinely
authenticates with NO network access. The household is seeded with
:func:`tests.test_api_panel._seed_household` through the SAME core
copy the app uses (``nestquest_core.*``), and both routes' single
clock read (``_local_now``) is pinned to noon so nothing straddles
midnight.
Proven per the done-condition:
- An admin JWT returns 200 with the documented shape: ``today_iso``,
``cycle_day`` and, per child, the presence fields, the rollup counts
and ``instances`` in the core ``instance_payload`` shape.
- A MISSED instance is KEPT in the admin payload (D-009) and still
OMITTED from the panel payload. The core builder lists only
instances due on ``now.date()``, and ``derive_state`` calls an
instance missed only when its due date is BEFORE that day — so a
real snapshot never carries a missed view. The test therefore
wraps the real builder to add the seeded past-due instance as a
view whose state the core's own ``derive_state`` computes
(``missed``), and reads it through both routes.
- Auth runs through the router's ONE ``require_admin`` dependency: no
credential is 401, the panel service token is refused outright
(403, the credential-type detail), and a non-admin JWT is 403.
- The handler performs exactly ONE clock read per request.
"""
from __future__ import annotations
import dataclasses
import datetime
import importlib
from types import SimpleNamespace
from zoneinfo import ZoneInfo
import pytest
from api import routes_admin, routes_panel
from api.nestquest_core import core_snapshot
from tests.admin_jwt_harness import (
ADMIN_KEY,
KID,
AdminRunner,
jwks_for,
make_token,
)
from tests.test_api_panel import _seed_household
# The completion layer through the SAME core copy the app uses — the
# missed test derives its view's state with the core's own rule.
_completion = importlib.import_module("nestquest_core.completion")
#: The panel service token the harness app is configured with.
HARNESS_PANEL_TOKEN = "admin-test-panel-token"
#: The 403 detail for the panel service token on an admin route.
PANEL_TOKEN_REJECTION_DETAIL = (
"This credential type is not accepted for admin access"
)
#: The 403 detail for a valid JWT without the admin group.
FORBIDDEN_DETAIL = (
"Admin access requires membership in the nestquest-admins group"
)
def _admin_headers() -> dict[str, str]:
"""The Authorization header of a valid nestquest-admins JWT."""
return {"Authorization": f"Bearer {make_token()}"}
def _panel_headers() -> dict[str, str]:
"""The Authorization header carrying the harness panel token."""
return {"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"}
@pytest.fixture
async def snapshot_household(
temp_db_path: str, monkeypatch
) -> SimpleNamespace:
"""A seeded household served by the admin-plane app, clock pinned.
``today`` comes from one host clock read; the pinned instant is
12:00 UTC that day (past Ada's 10:00 due, before Bo's 17:00). Both
the admin and the panel route's ``_local_now`` return it, and the
admin one counts its calls.
"""
today = datetime.datetime.now().astimezone().date()
pinned_now = datetime.datetime(
today.year, today.month, today.day, 12, 0, 0, tzinfo=ZoneInfo("UTC")
)
seed = await _seed_household(temp_db_path, today, pinned_now)
clock_reads: list[datetime.datetime] = []
def _admin_now() -> datetime.datetime:
clock_reads.append(pinned_now)
return pinned_now
monkeypatch.setattr(routes_admin, "_local_now", _admin_now)
monkeypatch.setattr(routes_panel, "_local_now", lambda: pinned_now)
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner as client:
yield SimpleNamespace(
client=client,
seed=seed,
today=today,
pinned_now=pinned_now,
pinned_utc_iso=pinned_now.astimezone(
datetime.timezone.utc
).isoformat(),
clock_reads=clock_reads,
)
# --- authentication: the ONE require_admin dependency ----------------------
async def test_snapshot_without_credential_is_401(
snapshot_household: SimpleNamespace,
) -> None:
"""No Authorization header is the uniform 401."""
response = await snapshot_household.client.get("/api/v1/admin/snapshot")
assert response.status_code == 401
async def test_snapshot_refuses_panel_service_token_outright(
snapshot_household: SimpleNamespace,
) -> None:
"""The panel service token is refused as a credential type (403).
403 with the credential-type detail — not the uniform 401 and not
the non-admin group detail — proves the token was refused outright,
never tried as a JWT.
"""
response = await snapshot_household.client.get(
"/api/v1/admin/snapshot", headers=_panel_headers()
)
assert response.status_code == 403
assert response.json()["detail"] == PANEL_TOKEN_REJECTION_DETAIL
async def test_snapshot_refuses_non_admin_jwt(
snapshot_household: SimpleNamespace,
) -> None:
"""A valid JWT without the nestquest-admins group is 403."""
non_admin = make_token(groups=("some-other-group",))
response = await snapshot_household.client.get(
"/api/v1/admin/snapshot",
headers={"Authorization": f"Bearer {non_admin}"},
)
assert response.status_code == 403
assert response.json()["detail"] == FORBIDDEN_DETAIL
# --- the payload -----------------------------------------------------------
async def test_admin_jwt_returns_documented_snapshot(
snapshot_household: SimpleNamespace,
) -> None:
"""An admin JWT returns 200 with the documented snapshot shape.
Every field is asserted as an explicit literal, with ONE clock
read for the request. With no missed view in the real snapshot,
the admin body equals the panel body for the same household.
"""
client = snapshot_household.client
seed = snapshot_household.seed
response = await client.get(
"/api/v1/admin/snapshot", headers=_admin_headers()
)
assert response.status_code == 200
assert len(snapshot_household.clock_reads) == 1
body = response.json()
assert body == {
"today_iso": snapshot_household.today.isoformat(),
# Ada's 2-week cycle anchored yesterday => today is cycle day 2.
"cycle_day": 2,
"children": [
{
"child_id": seed.ada.id,
"child_name": "Ada",
"present": False,
"next_present": seed.returns_date.isoformat(),
"due_today": 1,
"completed_today": 0,
"remaining_today": 1,
"completion_pct": 0,
"instances": [
{
"id": seed.pack_bag_instance.id,
"definition_id": seed.pack_bag_instance.definition_id,
"child_id": seed.ada.id,
"title": "Pack bag",
"icon": None,
"window": "morning",
"due_time": "10:00",
"state": "open",
"overdue": True,
"completed_at": None,
"on_time": None,
}
],
},
{
"child_id": seed.bo.id,
"child_name": "Bo",
"present": True,
"next_present": None,
"due_today": 1,
"completed_today": 1,
"remaining_today": 0,
"completion_pct": 100,
"instances": [
{
"id": seed.brush_teeth_instance.id,
"definition_id": (
seed.brush_teeth_instance.definition_id
),
"child_id": seed.bo.id,
"title": "Brush teeth",
"icon": None,
"window": "morning",
"due_time": "17:00",
"state": "completed",
"overdue": False,
"completed_at": snapshot_household.pinned_utc_iso,
"on_time": True,
}
],
},
],
}
panel = await client.get(
"/api/v1/panel/snapshot", headers=_panel_headers()
)
assert panel.status_code == 200
assert panel.json() == body
async def test_admin_keeps_missed_instance_panel_omits_it(
snapshot_household: SimpleNamespace, monkeypatch
) -> None:
"""A missed view is KEPT by the admin route, OMITTED by the panel's.
The real builder is wrapped to add the seeded past-due Stale chore
instance to Ada's views, its state derived by the core's own
``derive_state`` (no events, due yesterday => ``missed``). Both
routes read the SAME wrapped snapshot, so the only difference is
each route's ``include_missed`` flag.
"""
client = snapshot_household.client
seed = snapshot_household.seed
stale = seed.stale_instance
missed_state = _completion.derive_state(
stale, None, snapshot_household.today
)
assert missed_state == "missed"
missed_view = core_snapshot.QuestInstanceView(
instance_id=stale.id,
definition_id=stale.definition_id,
child_id=stale.child_id,
title="Stale chore",
icon=None,
window=stale.window,
due_date=stale.due_date,
due_time=stale.due_time,
state=missed_state,
overdue=False,
completed_at=None,
was_on_time=None,
)
real_build_snapshot = core_snapshot.build_snapshot
async def _with_missed_view(database, settings, now):
snapshot = await real_build_snapshot(database, settings, now)
return dataclasses.replace(
snapshot,
children=tuple(
dataclasses.replace(
child, instances=(*child.instances, missed_view)
)
if child.child_id == seed.ada.id
else child
for child in snapshot.children
),
)
monkeypatch.setattr(core_snapshot, "build_snapshot", _with_missed_view)
admin = await client.get(
"/api/v1/admin/snapshot", headers=_admin_headers()
)
assert admin.status_code == 200
admin_ada = admin.json()["children"][0]
assert admin_ada["child_id"] == seed.ada.id
assert [instance["id"] for instance in admin_ada["instances"]] == [
seed.pack_bag_instance.id,
stale.id,
]
assert admin_ada["instances"][1] == {
"id": stale.id,
"definition_id": stale.definition_id,
"child_id": seed.ada.id,
"title": "Stale chore",
"icon": None,
"window": "morning",
"due_time": "09:00",
"state": "missed",
"overdue": False,
"completed_at": None,
"on_time": None,
}
panel = await client.get(
"/api/v1/panel/snapshot", headers=_panel_headers()
)
assert panel.status_code == 200
panel_ada = panel.json()["children"][0]
assert [instance["id"] for instance in panel_ada["instances"]] == [
seed.pack_bag_instance.id
], "the panel payload must still omit missed instances"
+295
View File
@@ -0,0 +1,295 @@
"""Cross-process safety of the missed-sweep watermark claim.
Feature 11's sweep guards its announcement with the watermark; the
original guard was a read-then-set inside a per-PROCESS asyncio lock,
so two PROCESSES sharing one SQLite file (the HA integration's
rollover sweep and the API service's scheduler) could both read the
old watermark and both announce the same missed batch. These tests
pin the fix:
- :meth:`MetaStateDao.claim` is the atomic, database-level primitive:
one conditional upsert whose ``rowcount`` says whether THIS caller
changed the row. The first claimant of a value wins; a second
claimant of the same value — even on an INDEPENDENT connection to
the same file, the shape two processes produce — loses.
- :func:`run_missed_sweep` gates the announcement on that claim: two
independent ``NestQuestDatabase`` wrappers (two processes' shape)
racing one seeded database yields exactly one announcer.
- The documented sweep semantics are unchanged: a same-night rerun
announces nothing, and a run after days of downtime announces the
accumulated ``[watermark, today)`` window exactly once.
Everything here runs against the bundled HA-free core copy
(``custom_components.nestquest.core``) with a plain
``asyncio.to_thread`` executor — no Home Assistant, no API server.
"""
from __future__ import annotations
import asyncio
import datetime
from types import SimpleNamespace
import pytest
from custom_components.nestquest.core.dao_instances import (
QuestInstancesDao,
)
from custom_components.nestquest.core.dao_meta import MetaStateDao
from custom_components.nestquest.core.db import NestQuestDatabase
from custom_components.nestquest.core.migrations import apply_migrations
from custom_components.nestquest.core.children import create_child
from custom_components.nestquest.core.quest_definitions import (
create_quest_definition,
)
from custom_components.nestquest.core.materialize import materialize
from custom_components.nestquest.core.recurrence import ScheduleRule
from custom_components.nestquest.core.sweep import (
SWEEP_WATERMARK_KEY,
run_missed_sweep,
)
#: One missed event per unswept past-due instance — the sweep's only
#: event type.
EVENT_QUEST_MISSED = "nestquest_quest_missed"
async def _executor(fn, *args, **kwargs):
"""Run a synchronous callable on a worker thread and await it.
The :class:`NestQuestDatabase` executor contract (the shape of
``hass.async_add_executor_job``), so sqlite3 calls never block the
event loop.
"""
return await asyncio.to_thread(fn, *args, **kwargs)
async def _open_database(db_path: str) -> NestQuestDatabase:
"""Open (without migrating) one independent connection wrapper."""
database = NestQuestDatabase(_executor)
await database.open(db_path)
return database
async def _seed_missed_household(
db_path: str, today: datetime.date
) -> SimpleNamespace:
"""Seed a household with two past-due open instances on ``db_path``.
One child, two quests whose ONLY instances are due YESTERDAY
(materialized with ``today`` pinned to yesterday, ``end_date``
closing the rules so the later today-walk cannot duplicate them).
Seeding uses its OWN connection, opened and closed here, so the
connections the tests race with start fresh.
"""
database = await _open_database(db_path)
try:
await apply_migrations(database)
today_iso = today.isoformat()
yesterday = today - datetime.timedelta(days=1)
yesterday_iso = yesterday.isoformat()
rule = ScheduleRule.from_dict(
{
"rule_type": "daily",
"start_date": yesterday_iso,
"end_date": yesterday_iso,
}
)
ada = await create_child(database, "Ada", sort_order=0)
await create_quest_definition(
database, "Stale chore", rule, [ada.id], [("morning", "09:00")]
)
await create_quest_definition(
database, "Old chore", rule, [ada.id], [("evening", "18:30")]
)
await materialize(database, yesterday_iso, yesterday_iso, today=yesterday)
# A quest due TODAY (created after the yesterday walk so it has
# no past-due instance): the post-downtime catch-up run's
# target — inside [watermark=today, later_today) but not
# inside the day-one sweep's window.
await create_quest_definition(
database,
"Recoverable chore",
ScheduleRule.from_dict(
{"rule_type": "daily", "start_date": today_iso}
),
[ada.id],
[("afternoon", "17:00")],
)
await materialize(database, today_iso, today_iso, today=today)
instances = QuestInstancesDao(database)
rows = await instances.list_by_child_and_date(ada.id, yesterday_iso)
assert len(rows) == 2
today_rows = await instances.list_by_child_and_date(
ada.id, today_iso
)
assert len(today_rows) == 1
return SimpleNamespace(
child=ada,
instances=rows,
recoverable_instance=today_rows[0],
yesterday_iso=yesterday_iso,
today_iso=today_iso,
)
finally:
await database.close()
# --- the claim primitive: atomic at the database level ------------------
async def test_claim_first_caller_wins_and_second_same_value_loses(
temp_db_path: str,
) -> None:
"""claim() is a conditional upsert: first True, same-value second False.
A different value always wins (an advance re-arms the check), and
the stored value follows the winner.
"""
database = await _open_database(temp_db_path)
try:
await apply_migrations(database)
dao = MetaStateDao(database)
assert await dao.get(SWEEP_WATERMARK_KEY) is None
assert await dao.claim(SWEEP_WATERMARK_KEY, "2026-09-01") is True
# Same value again: the row is already there — nothing changed.
assert await dao.claim(SWEEP_WATERMARK_KEY, "2026-09-01") is False
assert await dao.get(SWEEP_WATERMARK_KEY) == "2026-09-01"
# A DIFFERENT value is an advance, not a re-claim: it wins.
assert await dao.claim(SWEEP_WATERMARK_KEY, "2026-09-02") is True
assert await dao.get(SWEEP_WATERMARK_KEY) == "2026-09-02"
assert await dao.claim(SWEEP_WATERMARK_KEY, "2026-09-02") is False
finally:
await database.close()
async def test_claim_is_atomic_across_independent_connections(
temp_db_path: str,
) -> None:
"""Two connections to one file cannot both claim the same value.
This is the two-process shape: independent sqlite3 connections (one
per ``NestQuestDatabase`` wrapper), racing the SAME key on the SAME
event loop with no shared lock. The single conditional upsert is
serialized by SQLite itself, so exactly one caller's rowcount is 1.
"""
first = await _open_database(temp_db_path)
second = await _open_database(temp_db_path)
try:
await apply_migrations(first)
won = await asyncio.gather(
MetaStateDao(first).claim(SWEEP_WATERMARK_KEY, "2026-09-01"),
MetaStateDao(second).claim(SWEEP_WATERMARK_KEY, "2026-09-01"),
)
assert sorted(won) == [False, True]
# One stored row, holding the winner's value.
assert await MetaStateDao(first).get(SWEEP_WATERMARK_KEY) == (
"2026-09-01"
)
assert await MetaStateDao(second).get(SWEEP_WATERMARK_KEY) == (
"2026-09-01"
)
finally:
await first.close()
await second.close()
# --- the sweep: the announcement is gated by the atomic claim -----------
async def test_two_process_shape_race_exactly_one_announces(
temp_db_path: str,
) -> None:
"""Two independent connections racing the sweep: ONE announcer.
Each wrapper carries its own connection AND its own per-process
sweep lock, so nothing in-process serialises the two runs — the
only guard left is the database-level claim. Both read the unset
watermark and both build the events, but exactly one claim wins:
one run returns the missed events, the loser returns [].
"""
today = datetime.datetime.now().astimezone().date()
seed = await _seed_missed_household(temp_db_path, today)
first = await _open_database(temp_db_path)
second = await _open_database(temp_db_path)
try:
results = await asyncio.gather(
run_missed_sweep(first, today=today),
run_missed_sweep(second, today=today),
)
announcers = [r for r in results if r]
assert len(announcers) == 1
event_type, payload = announcers[0][0]
assert event_type == EVENT_QUEST_MISSED
assert payload["due_date"] == seed.yesterday_iso
# Both instances of the seeded window, one event each.
assert len(announcers[0]) == 2
losers = [r for r in results if not r]
assert losers == [[]]
# The watermark holds the run's date for whoever looks next.
assert (
await MetaStateDao(first).get(SWEEP_WATERMARK_KEY)
== seed.today_iso
)
finally:
await first.close()
await second.close()
async def test_same_night_rerun_announces_nothing(temp_db_path: str) -> None:
"""A second sweep for the same date is an empty no-op (the watermark)."""
today = datetime.datetime.now().astimezone().date()
await _seed_missed_household(temp_db_path, today)
database = await _open_database(temp_db_path)
try:
first = await run_missed_sweep(database, today=today)
assert len(first) == 2
second = await run_missed_sweep(database, today=today)
assert second == []
finally:
await database.close()
async def test_post_downtime_run_announces_accumulated_window_once(
temp_db_path: str,
) -> None:
"""After days of downtime the catch-up run sweeps the window once.
The first run sweeps yesterday's instances (watermark := today);
``today`` then jumps two days and the catch-up run — on a
DIFFERENT connection, the two-process shape — announces the
accumulated ``[watermark, later_today)`` window exactly once, and
a rerun on the original connection is silent again.
"""
today = datetime.datetime.now().astimezone().date()
later_today = today + datetime.timedelta(days=2)
seed = await _seed_missed_household(temp_db_path, today)
first = await _open_database(temp_db_path)
second = await _open_database(temp_db_path)
try:
# Day one: yesterday's two instances fire once.
day_one = await run_missed_sweep(first, today=today)
assert len(day_one) == 2
assert all(
payload["due_date"] == seed.yesterday_iso
for _, payload in day_one
)
# The downtime catch-up: the window is [watermark, later_today).
catchup = await run_missed_sweep(second, today=later_today)
assert len(catchup) == 1
event_type, payload = catchup[0]
assert event_type == EVENT_QUEST_MISSED
assert payload["due_date"] == seed.today_iso
assert payload["instance_id"] == seed.recoverable_instance.id
assert payload["child_id"] == seed.child.id
# A further rerun announces nothing.
assert await run_missed_sweep(first, today=later_today) == []
assert (
await MetaStateDao(first).get(SWEEP_WATERMARK_KEY)
== later_today.isoformat()
)
finally:
await first.close()
await second.close()
+67
View File
@@ -847,6 +847,7 @@ def test_definition_update_cannot_change_assignee_or_active(
"description",
"icon",
"due_time",
"skip_on_away",
}
assert await definitions.update(
definition.id, title="A2"
@@ -862,6 +863,72 @@ def test_definition_update_cannot_change_assignee_or_active(
_with_db(tmp_path, "definition-update-shape.db")(_body)
def test_definition_update_toggles_skip_on_away_and_omission_keeps_it(
tmp_path,
) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
definition = await definitions.create("A", rule.id, NOW)
assert definition.skip_on_away is True
assert await definitions.update(
definition.id, skip_on_away=False
) == 1
assert (await definitions.get(definition.id)).skip_on_away is False
# Omitting the flag (None) leaves the stored False untouched.
assert await definitions.update(definition.id, title="A2") == 1
kept = await definitions.get(definition.id)
assert kept.title == "A2"
assert kept.skip_on_away is False
assert await definitions.update(definition.id) == 0
assert await definitions.update(
definition.id, skip_on_away=True
) == 1
assert (await definitions.get(definition.id)).skip_on_away is True
_with_db(tmp_path, "definition-update-skip-on-away.db")(_body)
@pytest.mark.parametrize("bad", [2, -1, 0.0, 1.5, "1", "true", b"\x01"])
def test_skip_on_away_rejected_unless_zero_or_one_at_every_write(
tmp_path, bad
) -> None:
"""Every DAO entry point taking the flag rejects non-0/1 values.
This is the only guard on migrated databases, whose column was
added without the fresh-schema CHECK constraint.
"""
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
definition = await definitions.create("A", rule.id, NOW)
with pytest.raises(ValueError, match="skip_on_away must be 0 or 1"):
await definitions.create("B", rule.id, NOW, skip_on_away=bad)
with pytest.raises(ValueError, match="skip_on_away must be 0 or 1"):
await definitions.create_with_rule_and_windows(
"C",
schedule_rule_to_storage(
ScheduleRule(
rule_type=RuleType.DAILY, start_date="2026-09-14"
)
),
NOW,
[child.id],
[("morning", None)],
skip_on_away=bad,
)
with pytest.raises(ValueError, match="skip_on_away must be 0 or 1"):
await definitions.edit_definition(
definition.id, title="A2", skip_on_away=bad
)
with pytest.raises(ValueError, match="skip_on_away must be 0 or 1"):
await definitions.update(
definition.id, title="A2", skip_on_away=bad
)
stored = await definitions.list_all()
assert [(d.title, d.skip_on_away) for d in stored] == [("A", True)]
_with_db(tmp_path, "definition-skip-on-away-invalid.db")(_body)
def test_definition_update_no_fields_returns_zero(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
+246
View File
@@ -12,12 +12,100 @@ from custom_components.nestquest.const import (
DOMAIN as DOMAIN_CONST,
SERVICE_COMPLETE_QUEST,
)
from custom_components.nestquest.dashboard import (
DASHBOARD_STRATEGY_TYPE,
DASHBOARD_URL_PATH,
async_register_dashboard,
)
def _wire(entry, registry):
return wire_entry_to_registry(entry, registry)
class FakeDashboardStore:
"""Stand-in for a per-dashboard CONTENT manager (LovelaceStorage).
In the real API the dashboard CONFIG (the strategy reference) is
saved through THIS manager's ``async_save``, NOT through the
metadata collection — so every strategy-save assertion reads here.
"""
def __init__(self) -> None:
self.saved: list[dict] = []
self.config: dict | None = None
async def async_save(self, config: dict) -> None:
self.saved.append(dict(config))
self.config = dict(config)
class FakeDashboardsCollection:
"""Stand-in mirroring the REAL Lovelace dashboards API shapes.
Faithful to ``homeassistant/components/lovelace`` +
``helpers/collection.py``: ``async_items()`` is a SYNC ``@callback``
(ObservableCollection.async_items returns list(data.values())),
``async_create_item`` IS async, and — mirroring lovelace's
``storage_dashboard_changed`` listener — a successful create
registers a fresh CONTENT manager under
``hass.data["lovelace"]["dashboards"][url_path]``, which is where
the dashboard config is then saved. Records ``async_create_item``
calls so tests can assert exactly what the setup path registered —
and that a reload registers nothing more.
"""
def __init__(self, hass, items=None) -> None:
self.hass = hass
self.items = list(items or [])
self.created: list[dict] = []
#: url_path -> content manager, mirroring the real listener's
# registrations (pre-existing items included).
self.stores: dict[str, FakeDashboardStore] = {}
def async_items(self):
"""SYNC, like the real ``ObservableCollection.async_items``."""
return list(self.items)
async def async_create_item(self, item_config: dict) -> None:
# The real collection awaits validation and change notification
# around the item insertion; yield first so two concurrent
# callers without a single-flight guard would BOTH get here.
await asyncio.sleep(0)
self.created.append(dict(item_config))
self.items.append(dict(item_config))
self.stores[item_config["url_path"]] = FakeDashboardStore()
self.hass.data["lovelace"]["dashboards"][item_config["url_path"]] = (
self.stores[item_config["url_path"]]
)
def _install_dashboard_collection(
hass, items=None
) -> FakeDashboardsCollection:
"""Install the REAL-API-shaped lovelace surfaces on the hass stub.
Mirrors what lovelace's ``async_setup`` leaves behind in storage
mode: ``hass.data["lovelace"]`` is a DICT with the metadata
collection under ``dashboards_collection`` and the per-url_path
CONTENT managers under ``dashboards``. Any pre-existing item gets
a content manager, exactly as lovelace's change listener would
have registered one when the dashboard was created.
"""
collection = FakeDashboardsCollection(hass, items)
hass.data["lovelace"] = {
"mode": "storage",
"dashboards": {},
"dashboards_collection": collection,
}
for item in collection.items:
url_path = item.get("url_path")
store = FakeDashboardStore()
collection.stores[url_path] = store
hass.data["lovelace"]["dashboards"][url_path] = store
return collection
async def test_setup_stores_runtime_data_in_hass_data(hass, make_entry) -> None:
entry = _wire(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
@@ -271,3 +359,161 @@ async def test_unload_shuts_coordinator_even_when_listener_removal_raises() -> N
assert entry.runtime_data is None
assert DOMAIN_CONST not in hass.data
assert hass.time_change.size == 0
async def test_setup_creates_the_panel_dashboard(hass, make_entry) -> None:
"""A fresh install gets the storage-mode strategy dashboard via the
REAL two-step Lovelace sequence: the METADATA (url_path, title,
icon, sidebar, single-word allowance — nothing else: the real
create schema rejects unknown keys like ``strategy``) through the
collection, then the dashboard CONFIG (the strategy reference)
through the fresh dashboard's content manager."""
collection = _install_dashboard_collection(hass)
entry = _wire(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
# Step 1: the metadata create through the collection.
assert len(collection.created) == 1
created = collection.created[0]
assert created["url_path"] == DASHBOARD_URL_PATH
assert created["title"]
assert created["show_in_sidebar"] is True
assert created["allow_single_word"] is True
assert "icon" in created
# The create payload carries METADATA only: a ``strategy`` key is
# rejected by the real collection's create schema (the silent
# no-op the first implementation shipped).
assert "strategy" not in created
assert "mode" not in created
# Step 2: the strategy CONFIG saved through the content manager
# the create registered, with the custom strategy type.
store = hass.data["lovelace"]["dashboards"][DASHBOARD_URL_PATH]
assert store is collection.stores[DASHBOARD_URL_PATH]
assert store.saved == [{"strategy": {"type": DASHBOARD_STRATEGY_TYPE}}]
assert store.config == {"strategy": {"type": DASHBOARD_STRATEGY_TYPE}}
async def test_setup_reload_does_not_duplicate_the_dashboard(
hass, make_entry
) -> None:
"""A reload (or a second entry) must not create a second dashboard:
the one under the stable url_path is reused as-is — BOTH steps are
skipped, so the strategy config is not re-saved either."""
collection = _install_dashboard_collection(hass)
entry = _wire(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
assert len(collection.created) == 1
store = hass.data["lovelace"]["dashboards"][DASHBOARD_URL_PATH]
assert len(store.saved) == 1
# A reload: setup again on the same hass (the reload cycle) —
# the existing dashboard must be left alone.
await async_unload_entry(hass, entry)
assert await async_setup_entry(hass, entry) is True
assert len(collection.created) == 1
assert len(store.saved) == 1
# And a second entry on the same install.
other = _wire(make_entry(entry_id="other"), hass.registry)
assert await async_setup_entry(hass, other) is True
assert len(collection.created) == 1
assert len(store.saved) == 1
async def test_setup_leaves_an_existing_user_dashboard_alone(
hass, make_entry
) -> None:
"""A dashboard already occupying the stable url_path — the user's
own — is never overwritten, deleted or duplicated: no metadata
create AND no strategy save against its content manager."""
users_dashboard = {"url_path": DASHBOARD_URL_PATH, "title": "My Board"}
collection = _install_dashboard_collection(hass, items=[users_dashboard])
entry = _wire(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
assert collection.created == []
assert collection.items[0] is users_dashboard
users_store = hass.data["lovelace"]["dashboards"][DASHBOARD_URL_PATH]
assert users_store.saved == []
assert users_store.config is None
async def test_setup_succeeds_without_the_dashboards_collection(
hass, make_entry, caplog
) -> None:
"""YAML mode / lovelace not loaded: log a warning, continue setup."""
entry = _wire(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
assert entry.entry_id in hass.data[DOMAIN]
assert any(
"dashboard" in record.message.lower()
and record.levelname == "WARNING"
for record in caplog.records
)
async def test_setup_survives_a_failing_dashboards_collection(
hass, make_entry
) -> None:
"""A collection that refuses the create (permissions, admin-only)
logs a warning and setup still succeeds — entities come up."""
class _RefusingCollection(FakeDashboardsCollection):
async def async_create_item(self, item_config):
raise PermissionError("not an admin")
collection = _RefusingCollection(hass)
hass.data["lovelace"] = {
"mode": "storage",
"dashboards": {},
"dashboards_collection": collection,
}
entry = _wire(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
assert collection.created == []
async def test_setup_survives_a_failing_content_manager_save(
hass, make_entry
) -> None:
"""A content manager that refuses the config save after the
metadata create succeeded (storage write failure) logs a warning
and setup still succeeds — the dashboard exists, the strategy
simply was not persisted through it."""
class _RefusingStore(FakeDashboardStore):
async def async_save(self, config):
raise OSError("disk full")
class _StorelessCreateCollection(FakeDashboardsCollection):
async def async_create_item(self, item_config):
await asyncio.sleep(0)
self.created.append(dict(item_config))
self.items.append(dict(item_config))
self.stores[item_config["url_path"]] = _RefusingStore()
self.hass.data["lovelace"]["dashboards"][
item_config["url_path"]
] = self.stores[item_config["url_path"]]
collection = _StorelessCreateCollection(hass)
hass.data["lovelace"] = {
"mode": "storage",
"dashboards": {},
"dashboards_collection": collection,
}
entry = _wire(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
assert len(collection.created) == 1
store = hass.data["lovelace"]["dashboards"][DASHBOARD_URL_PATH]
assert store.saved == []
async def test_concurrent_registrations_create_the_dashboard_once(hass) -> None:
"""Two CONCURRENT registrations (the race two entry setups run)
must produce exactly ONE dashboard: the single-flight guard makes
the second caller's exists-check run after the first caller's
create completed, so it skips both steps. The fake's create
yields before recording, so without the guard both callers would
pass the exists-check and the dashboard would be created twice."""
collection = _install_dashboard_collection(hass)
results = await asyncio.gather(
async_register_dashboard(hass),
async_register_dashboard(hass),
)
assert results == [True, True]
assert len(collection.created) == 1
assert len(collection.stores[DASHBOARD_URL_PATH].saved) == 1
+127
View File
@@ -657,6 +657,133 @@ def test_materialize_honours_overrides(tmp_path) -> None:
_with_db(tmp_path, "materialize-override.db")(_body)
async def _absent_midweek_setup(database, skip_on_away: bool):
"""One child absent on days 2..4 of a future week, one daily quest.
Returns ``(child_id, definition_id, start, end_iso, absent_dates)``.
"""
from custom_components.nestquest.core.dao_presence import (
PresenceOverridesDao,
)
child = await ChildrenDao(database).create("Ada", NOW)
start = _future_monday()
end_iso = (start + datetime.timedelta(days=6)).isoformat()
absent = {
(start + datetime.timedelta(days=offset)).isoformat()
for offset in (2, 3, 4)
}
await PresenceOverridesDao(database).create(
child.id, min(absent), max(absent), False, note="away"
)
created = await create_quest_definition(
database,
"Daily chore",
ScheduleRule(rule_type=RuleType.DAILY, start_date=start.isoformat()),
[child.id],
["morning"],
skip_on_away=skip_on_away,
)
assert created.definition.skip_on_away is skip_on_away
return child.id, created.definition.id, start, end_iso, absent
def test_materialize_skip_on_away_true_skips_absent_dates(tmp_path) -> None:
async def _body(database):
child_id, _, start, end_iso, absent = await _absent_midweek_setup(
database, skip_on_away=True
)
count = await materialize(database, start.isoformat(), end_iso)
records = await QuestInstancesDao(database).list_by_date_range(
child_id, start.isoformat(), end_iso
)
due_dates = {record.due_date for record in records}
assert count == 4
assert due_dates.isdisjoint(absent)
assert len(due_dates) == 4
_with_db(tmp_path, "skip-on-away-true.db")(_body)
def test_materialize_skip_on_away_false_generates_absent_dates(
tmp_path,
) -> None:
async def _body(database):
child_id, _, start, end_iso, absent = await _absent_midweek_setup(
database, skip_on_away=False
)
count = await materialize(database, start.isoformat(), end_iso)
records = await QuestInstancesDao(database).list_by_date_range(
child_id, start.isoformat(), end_iso
)
due_dates = {record.due_date for record in records}
assert count == 7
assert absent <= due_dates
assert len(due_dates) == 7
_with_db(tmp_path, "skip-on-away-false.db")(_body)
def test_edit_skip_on_away_regenerates_absent_dates(tmp_path) -> None:
"""Toggling the flag through the edit path rebuilds future instances."""
async def _body(database):
child_id, definition_id, start, end_iso, absent = (
await _absent_midweek_setup(database, skip_on_away=True)
)
await materialize(database, start.isoformat(), end_iso)
dao = QuestInstancesDao(database)
async def due_dates() -> set[str]:
records = await dao.list_by_date_range(
child_id, start.isoformat(), end_iso
)
return {record.due_date for record in records}
assert (await due_dates()).isdisjoint(absent)
edited = await edit_quest_definition(
database, definition_id, skip_on_away=False
)
assert edited.definition.skip_on_away is False
assert absent <= await due_dates()
# None leaves the stored flag unchanged.
unchanged = await edit_quest_definition(
database, definition_id, title="Daily chore (renamed)"
)
assert unchanged.definition.skip_on_away is False
edited = await edit_quest_definition(
database, definition_id, skip_on_away=True
)
assert edited.definition.skip_on_away is True
assert (await due_dates()).isdisjoint(absent)
_with_db(tmp_path, "skip-on-away-edit.db")(_body)
def test_skip_on_away_rejects_non_bool(tmp_path) -> None:
async def _body(database):
child = await ChildrenDao(database).create("Ada", NOW)
rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-01-01")
with pytest.raises(ValueError, match="skip_on_away must be a real bool"):
await create_quest_definition(
database, "Chore", rule, [child.id], ["morning"],
skip_on_away=1,
)
created = await create_quest_definition(
database, "Chore", rule, [child.id], ["morning"]
)
with pytest.raises(ValueError, match="skip_on_away must be a real bool"):
await edit_quest_definition(
database, created.definition.id, skip_on_away="false"
)
stored = await QuestDefinitionsDao(database).get(created.definition.id)
assert stored.skip_on_away is True
_with_db(tmp_path, "skip-on-away-validation.db")(_body)
def test_materialize_rejects_malformed_or_inverted_bounds(tmp_path) -> None:
async def _body(database):
start = (_future_monday()).isoformat()
+93 -2
View File
@@ -153,7 +153,9 @@ def test_migration_list_shape() -> None:
migration 3 brings pre-D-008 definitions to the multi-assignee
model, migration 4 adds the windows table, migration 5 rebuilds
quest_instances onto the widened (definition, child, date, window)
key, and migration 6 adds actor_child_id to completion_events.
key, migration 6 adds actor_child_id to completion_events,
migration 7 adds the meta_state table and migration 8 adds
skip_on_away to quest_definitions.
"""
assert MIGRATIONS[0] == SCHEMA_V1_STATEMENTS
assert callable(MIGRATIONS[1])
@@ -162,7 +164,8 @@ def test_migration_list_shape() -> None:
assert callable(MIGRATIONS[4])
assert callable(MIGRATIONS[5])
assert MIGRATIONS[6] == SCHEMA_V7_META_STATE_DDL
assert len(MIGRATIONS) == 7
assert callable(MIGRATIONS[7])
assert len(MIGRATIONS) == 8
# ---------------------------------------------------------------------------
@@ -564,6 +567,13 @@ def test_v2_definitions_rebuilt_to_multi_assignee(tmp_path) -> None:
database.fetch_all("PRAGMA table_info(quest_definitions)")
)
assert "child_id" not in {c[1] for c in columns}
# The rebuilt row reads the pre-flag behaviour: skip on away.
skip = _run(
database.fetch_one(
"SELECT skip_on_away FROM quest_definitions WHERE id = 1"
)
)
assert skip == (1,)
# The single assignee moved into the assignees table.
assignees = _run(
database.fetch_all(
@@ -637,6 +647,87 @@ def test_v2_definitions_rebuilt_to_multi_assignee(tmp_path) -> None:
_run(database.close())
# ---------------------------------------------------------------------------
# Migration 8: skip_on_away on quest_definitions (Feature 22)
# ---------------------------------------------------------------------------
def _seed_v7_database(database) -> None:
"""Build a genuine version-7 database holding one definition.
Migrations 1..7 run first; the fresh v1 DDL already carries
``skip_on_away`` (canonical shape), so the column is dropped to
reproduce the exact pre-migration-8 table a shipped v7 file holds,
and a definition row is written through that old column list.
"""
assert _migrate(database, MIGRATIONS[:7]) == 7
_run(
database.execute(
"ALTER TABLE quest_definitions DROP COLUMN skip_on_away"
)
)
columns = _run(database.fetch_all("PRAGMA table_info(quest_definitions)"))
assert "skip_on_away" not in {c[1] for c in columns}
_run(
database.execute(
"INSERT INTO schedule_rules (id, rule_type, interval, "
"start_date) VALUES (1, 'daily', 1, '2026-09-01')"
)
)
_run(
database.execute(
"INSERT INTO quest_definitions (id, title, schedule_rule_id, "
"is_active, created_at) VALUES (1, 'Dishes', 1, 1, "
"'2026-09-01T00:00:00+00:00')"
)
)
def test_v7_definitions_gain_skip_on_away_defaulting_true(tmp_path) -> None:
from custom_components.nestquest.core.dao_rules import (
QuestDefinitionsDao,
)
database = _open_db(tmp_path / "v7-upgrade.db")
try:
_seed_v7_database(database)
before = _version(database)
assert before == 7
final = _migrate(database)
assert final == before + 1
assert _version(database) == before + 1
columns = {
c[1]: c
for c in _run(
database.fetch_all("PRAGMA table_info(quest_definitions)")
)
}
assert "skip_on_away" in columns
# (cid, name, type, notnull, dflt_value, pk)
assert columns["skip_on_away"][2:5] == ("INTEGER", 1, "1")
record = _run(QuestDefinitionsDao(database).get(1))
assert record is not None
assert record.title == "Dishes"
assert record.skip_on_away is True
finally:
_run(database.close())
def test_skip_on_away_migration_is_noop_on_fresh_schema(tmp_path) -> None:
"""A fresh file already has the column; step 8 only stamps."""
database = _open_db(tmp_path / "fresh-v8.db")
try:
assert _migrate(database) == len(MIGRATIONS)
columns = _run(
database.fetch_all("PRAGMA table_info(quest_definitions)")
)
assert [c[1] for c in columns].count("skip_on_away") == 1
finally:
_run(database.close())
# ---------------------------------------------------------------------------
# Idempotence: second start is a no-op
# ---------------------------------------------------------------------------
+730 -2
View File
@@ -5,16 +5,36 @@ 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.
The cards additionally have render tests: they drive the jsdom harnesses in
frontend/tests — render-party-board.mjs imports the built bundle into a
jsdom window and reports the ordered plates the shadow DOM renders plus
where a crest tap navigates, and render-quest-log.mjs reports where the
quest log's idle return navigates — so the zero-config discovery and
navigation contracts are asserted against the bundle HACS ships.
The motion contract (PANEL-SPEC §3.3/§3.5/§4/§5 entrances and timings) and
the §1/§6 hard rules are asserted on the card sources in the motion
section below, and the quest-complete screen's countdown return is driven
end-to-end through the quest-log harness.
"""
from __future__ import annotations
import json
import re
import subprocess
from pathlib import Path
REPO_ROOT = Path(__file__).parent.parent
FRONTEND_CARDS_DIR = REPO_ROOT / "frontend" / "src" / "cards"
FRONTEND_DIR = REPO_ROOT / "frontend"
FRONTEND_CARDS_DIR = FRONTEND_DIR / "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"
RENDER_HARNESS = FRONTEND_DIR / "tests" / "render-party-board.mjs"
QUEST_LOG_HARNESS = FRONTEND_DIR / "tests" / "render-quest-log.mjs"
STRATEGY_HARNESS = FRONTEND_DIR / "tests" / "render-strategy.mjs"
STRATEGY_SOURCE = FRONTEND_DIR / "src" / "strategy.ts"
PANEL_SOURCES = (PARTY_BOARD, QUEST_LOG)
@@ -38,11 +58,106 @@ ADMIN_ONLY_SERVICES = (
WINDOW_KEYS = ("morning", "afternoon", "evening")
WINDOW_NAMES = ("Morning", "Afternoon", "Evening")
# The approved token pair: the bundle must serve the design copy
# verbatim. The values below are what PANEL-SPEC times the panel with
# (§3.3 seal entrance, §4 dialog entrance, §3.2 press feedback) and the
# ink set §1 pins at >=4.5:1 on parchment.
DESIGN_TOKENS = REPO_ROOT / "design" / "tokens" / "nestquest-panel-tokens.css"
REWARD_TOKENS = (
REPO_ROOT
/ "custom_components"
/ "nestquest"
/ "www"
/ "nestquest-panel-tokens.css"
)
MOTION_TOKENS = {
"--nq-ease-out": "cubic-bezier(0, 0, .2, 1)",
"--nq-dur-micro": "120ms",
"--nq-dur-base": "200ms",
"--nq-dur-modal": "350ms",
}
PANEL_INK_TOKENS = {
"--nq-p-ink": "#2b1f14",
"--nq-p-ink-secondary": "#5c452a",
"--nq-p-ink-muted": "#6f6455",
"--nq-p-ink-away": "#4d4433",
"--nq-p-ink-late": "#8f1526",
"--nq-p-dock-ink": "#f7efdb",
"--nq-p-dock-ink-secondary": "#d8c9a6",
}
# Text colors the panel styles may use beyond the token inks: the
# spec's own literals (§2.1 pill, §3.3 seal check, §4 dialog body),
# glyphs on the brand gradient, gradient text fills (§2 wordmark, §5
# title), and the plate button inheriting its plate's ink.
APPROVED_NON_TOKEN_INKS = {
"inherit",
"transparent",
"#ffffff",
"rgba(255, 235, 235, 0.95)",
"#3f2f1c",
"#146b36",
"#157a3c",
}
#: Each card's styles live in one css template literal, keyed by the
#: constant that holds it.
STYLE_CONSTANTS = {
PARTY_BOARD: "boardStyles",
QUEST_LOG: "logStyles",
}
def _read(path: Path) -> str:
return path.read_text(encoding="utf-8")
def _style_literal(source: str, constant: str) -> str:
"""A card's css template literal — the styles between the backticks."""
marker = f"const {constant} = css`"
start = source.index(marker) + len(marker)
return source[start : source.index("`;", start)]
def _keyframes_block(style: str, name: str) -> str:
"""A @keyframes rule's text, from its name through its to-block."""
start = style.index(f"@keyframes {name}")
return style[start : style.index("}", style.index("to {", start))]
def _css_rules(style: str) -> list[tuple[str, str]]:
"""(selector, declarations) pairs for every rule in a card's styles.
Lit interpolations are blanked first so the clip-path/unsafeCSS
braces cannot confuse the parse; @keyframes' from/to count as rules
but carry no font-size, so the size checks skip them.
"""
flattened = re.sub(r"\$\{[^}]*\}", " ", style)
return re.findall(r"([^{}]+)\{([^{}]*)\}", flattened)
def _size_tokens(tokens: str) -> dict[str, int]:
"""Every px length token declared in the panel tokens."""
return {
name: int(px)
for name, px in re.findall(r"(--[a-z0-9-]+):\s*(\d+)px", tokens)
}
def _font_size_px(declaration: str, sizes: dict[str, int]) -> int | None:
"""A font-size value's pixel amount; None when it is neither a px
length nor one of the panel size tokens."""
declaration = declaration.strip()
if declaration.endswith("px"):
return round(float(declaration[:-2]))
match = re.fullmatch(r"var\((--[a-z0-9-]+)\)", declaration)
if match and match.group(1) in sizes:
return sizes[match.group(1)]
return None
def test_party_board_renders_present_and_away_plates() -> None:
source = _read(PARTY_BOARD)
assert "nestquest-party-board-card" in source
@@ -146,6 +261,198 @@ def test_quest_complete_trigger() -> None:
assert "complete_screen_seconds" in source
# --- Panel motion and hard rules (PANEL-SPEC §1, §3.3, §3.5, §4–§6) -----------
def test_panel_serves_the_exact_design_token_copy() -> None:
"""The bundle's served tokens are byte-for-byte the approved design
copy. The motion tokens time every panel entrance and the ink set is
the documented >=4.5:1 set — the cards only reference the tokens, so
a drifted value fails here before it can reach the panel."""
assert _read(REWARD_TOKENS) == _read(DESIGN_TOKENS)
tokens = _read(REWARD_TOKENS)
for declared in (MOTION_TOKENS, PANEL_INK_TOKENS):
for name, value in declared.items():
pattern = rf"^\s*{re.escape(name)}:\s*{re.escape(value)};"
assert re.search(pattern, tokens, re.MULTILINE), name
# The 22px meta floor is the declared panel minimum (§1).
assert re.search(
r"^\s*--nq-p-size-body:\s*22px;.*PANEL MINIMUM", tokens, re.MULTILINE
)
def test_quest_log_entrances_use_the_design_timing_and_easing() -> None:
"""PANEL-SPEC §3.3/§4: the seal enters with fade + scale(1.15 → 1)
over --nq-dur-base and the confirm dialog with fade + scale(.96 → 1)
over --nq-dur-modal, both --nq-ease-out; press feedback is scale(.98)
over --nq-dur-micro."""
style = _style_literal(_read(QUEST_LOG), STYLE_CONSTANTS[QUEST_LOG])
assert "animation: dialog-in var(--nq-dur-modal) var(--nq-ease-out);" in style
dialog = _keyframes_block(style, "dialog-in")
assert "opacity: 0;" in dialog and "transform: scale(0.96);" in dialog
assert "opacity: 1;" in dialog and "transform: scale(1);" in dialog
assert "animation: seal-in var(--nq-dur-base) var(--nq-ease-out);" in style
seal = _keyframes_block(style, "seal-in")
assert "opacity: 0;" in seal
# The per-card rotation is preserved through the stamp animation.
assert "transform: scale(1.15) rotate(var(--seal-rot, 0deg));" in seal
assert "transform: scale(1) rotate(var(--seal-rot, 0deg));" in seal
assert "animation: toast-in var(--nq-dur-base) var(--nq-ease-out);" in style
for selector in (
".quest.tappable:active",
"button.complete:active",
"button.confirm-button:active",
):
start = style.index(selector)
block = style[start : style.index("}", start)]
assert "transform: scale(0.98);" in block, selector
assert (
style.count("transition: transform var(--nq-dur-micro) var(--nq-ease-out);")
== 3
) # quest card, Complete button, confirm buttons
board_style = _style_literal(_read(PARTY_BOARD), STYLE_CONSTANTS[PARTY_BOARD])
start = board_style.index("button.plate:active")
block = board_style[start : board_style.index("}", start)]
assert "transform: scale(0.98);" in block
assert (
"transition: transform var(--nq-dur-micro) var(--nq-ease-out);" in board_style
)
def test_quest_complete_screen_times_the_12_second_return() -> None:
"""PANEL-SPEC §5: the 1c screen auto-returns to the board 12 s after
the day completes, driven by the per-second countdown line."""
source = _read(QUEST_LOG)
assert "asNumber(this._config?.complete_screen_seconds, 12)" in source
assert "this._countdown = this._completeSeconds();" in source
assert "}, 1000);" in source
assert "Returning to The Party in ${remaining} seconds" in source
# Zero reaches the idle-return navigation itself.
assert "this._returnToBoard();" in source
def test_idle_return_uses_the_40_second_design_timing() -> None:
"""PANEL-SPEC §3.5: 40 s with no touch returns to the party board;
any pointer or touch event re-arms the timer. The config knob only
shortens the default (tests do; the strategy never sets it)."""
source = _read(QUEST_LOG)
assert "asNumber(this._config?.idle_return_seconds, 40)" in source
for event in ("pointerdown", "touchstart"):
assert (
f'window.addEventListener("{event}", this._onActivity, true)' in source
), event
def test_panel_hard_rules_hold_in_the_card_sources() -> None:
"""PANEL-SPEC §1/§6 hard rules on both panel card sources: no hover
states (touch-only surface), no animation loops (entrances only,
each timed on a design duration token with --nq-ease-out), an inert
dock strip and pointer-none frames, the whole plate as one tap
target with the away plate control-free, and both the quest card
and its button opening the confirm dialog. The away plate's
navigation contract is asserted against the bundle by
test_away_plate_does_not_navigate."""
board = _read(PARTY_BOARD)
log = _read(QUEST_LOG)
for source in (board, log):
assert ":hover" not in source
assert "infinite" not in source
for animation in re.findall(r"animation:[^;]+;", source):
assert re.fullmatch(
r"animation: [a-z-]+ var\(--nq-dur-(?:micro|base|modal)\) "
r"var\(--nq-ease-out\);",
" ".join(animation.split()),
), animation
for transition in re.findall(r"transition:[^;]+;", source):
assert transition in (
"transition: transform var(--nq-dur-micro) var(--nq-ease-out);",
), transition
dock = re.search(r"\n \.dock \{([^}]*)\}", source)
assert dock is not None
assert "pointer-events: none" in dock.group(1)
assert "user-select: none" in dock.group(1)
frame = re.search(r"\n \.frame \{([^}]*)\}", source)
assert frame is not None
assert "pointer-events: none" in frame.group(1)
# Nothing in either dock's markup can be tapped: no handlers, no
# button, no focusable or role-bearing element.
board_dock = board[board.index("private _renderDock") :]
log_dock = log[
log.index("private _renderDock") : log.index("private _returnToBoard")
]
for markup in (board_dock, log_dock):
for tappable in ("@click", "@keydown", "<button", 'role="button"', "tabindex"):
assert tappable not in markup, tappable
# The whole plate is the tap target: the button wraps crest, name,
# pill, progress line and bar, and it is the board's only button.
plate = board[board.index('class="plate"') : board.index('class="plate away"')]
for part in (
'class="crest"',
'class="name"',
'class="pill"',
'class="progress-line"',
'class="bar"',
):
assert part in plate, part
assert board.count("<button") == 1
# The away plate is a plain div — nothing tappable, never navigates.
away = board[
board.index('class="plate away"') : board.index("private _openQuestLog")
]
assert "<button" not in away
assert "@click" not in away
# On the log, the whole quest card AND its button open the confirm
# dialog (§3.2); the sealed card renders no control at all.
quest = log[log.index("private _renderQuest") : log.index("private _renderRollup")]
assert quest.count("() => this._openConfirm(instance.id)") == 2
sealed_template = quest[
quest.index('class="quest sealed"') : quest.index('class="quest tappable"')
]
assert "@click" not in sealed_template
def test_panel_text_sizes_respect_the_22px_floor() -> None:
"""PANEL-SPEC §1: no body text below 22px. Every font-size in the
panel styles resolves against the panel size tokens or an explicit
px value, and the only text under the floor is the spec's own
tracked uppercase Cinzel labels (§3 header sub, §3.1 range, §3.4
roll-up label, §4 kicker, §5 stat labels) and the d20 mark numerals
the spec sizes by hand (§2, §3) — never a Nunito body line."""
sizes = _size_tokens(_read(REWARD_TOKENS))
for path in (PARTY_BOARD, QUEST_LOG):
style = _style_literal(_read(path), STYLE_CONSTANTS[path])
for selector, body in _css_rules(style):
declared = re.search(r"font-size:\s*([^;]+);", body)
if declared is None:
continue
px = _font_size_px(declared.group(1), sizes)
assert px is not None, (
f"{path.name}: {selector.strip()} {declared.group(1)}"
)
if px >= 22:
continue
assert (
"text-transform: uppercase" in body or ".d20-numeral" in selector
), f"{path.name}: {selector.strip()} is {px}px below the panel floor"
def test_panel_text_inks_come_from_the_token_set() -> None:
"""PANEL-SPEC §1: every ink is a token from the approved >=4.5:1 set
or one of the spec's own literals — no alpha-muted text is ever
introduced."""
declared = set(
re.findall(r"^\s*(--[a-z0-9-]+):", _read(REWARD_TOKENS), re.MULTILINE)
)
for path in (PARTY_BOARD, QUEST_LOG):
style = _style_literal(_read(path), STYLE_CONSTANTS[path])
for value in re.findall(r"(?<![-\w])color:\s*([^;]+);", style):
value = " ".join(value.split())
if value.startswith("var("):
assert value[4:-1] in declared, f"{path.name}: {value}"
else:
assert value in APPROVED_NON_TOKEN_INKS, f"{path.name}: {value}"
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.
@@ -157,4 +464,425 @@ def test_panel_cards_reference_no_admin_only_services() -> None:
assert BUNDLE_PATH.is_file()
bundle = _read(BUNDLE_PATH)
assert "nestquest-party-board-card" in bundle
assert "nestquest-quest-log-card" in bundle
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,
url: str | None = None,
click_plate: str | None = None,
) -> 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, optionally
taps the named plate, and prints the ordered plates its shadow DOM
renders, which of them are tappable buttons, and the pathname after
the optional tap.
"""
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},
}
if url is not None:
spec["url"] = url
if click_plate is not None:
spec["click_plate"] = click_plate
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"]
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",
]
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]
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
# --- Navigation tests (strategy-computed paths under jsdom) -------------------
def _render_quest_log(
config: dict,
states: dict,
url: str,
idle_ms: int | None = None,
probe_ms: int | None = None,
) -> dict:
"""Render the built bundle's quest log and report its location.
Drives frontend/tests/render-quest-log.mjs: the harness imports the
committed bundle (the artifact HACS ships) into a jsdom window at
``url`` — the card resolves its child from the pathname's last
segment — waits the optional probe window and captures one snapshot
of the render mid-wait, then the optional idle window, and prints
the final pathname and rendered title (plus the probe snapshot when
``probe_ms`` was given).
"""
assert BUNDLE_PATH.is_file(), "bundle missing: run cd frontend && npm run build"
assert QUEST_LOG_HARNESS.is_file()
spec = {
"bundle": str(BUNDLE_PATH),
"url": url,
"config": config,
"hass": {"config": {"time_zone": "UTC"}, "states": states},
}
if idle_ms is not None:
spec["idle_ms"] = idle_ms
if probe_ms is not None:
spec["probe_ms"] = probe_ms
completed = subprocess.run(
["node", str(QUEST_LOG_HARNESS)],
input=json.dumps(spec),
capture_output=True,
text=True,
timeout=120,
)
assert completed.returncode == 0, (
f"quest-log harness failed: {completed.stderr}"
)
return json.loads(completed.stdout)
def test_present_crest_tap_navigates_to_the_child_quest_log() -> None:
"""Tapping a present crest pushes <dashboard root>/<slug>: the exact
URL the strategy computed for that child's quest log view (the log
then resolves its child from the path's last segment)."""
generated = _generate_dashboard(
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
)
board_config = generated["views"][0]["cards"][0]
result = _render_party_board(
board_config,
_three_child_states(),
url="http://homeassistant.local/nestquest",
click_plate="Ada",
)
# Only the present adventurers' plates are tappable buttons.
assert result["tappable"] == ["Ada", "Cory"]
assert result["location"] == "/nestquest/ada"
def test_away_plate_does_not_navigate() -> None:
"""Bo is away today: his plate is not a button, and tapping it leaves
the URL at the dashboard root — away adventurers cannot open a log."""
generated = _generate_dashboard(
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
)
board_config = generated["views"][0]["cards"][0]
result = _render_party_board(
board_config,
_three_child_states(),
url="http://homeassistant.local/nestquest",
click_plate="Bo",
)
assert "Bo" not in result["tappable"]
assert result["location"] == "/nestquest"
def test_quest_log_idle_return_navigates_to_the_board() -> None:
"""After the idle window the quest log returns to the board using the
strategy-computed board_path (the dashboard root) — and it resolved
Ada from the URL's last segment first. ``idle_return_seconds`` is
set by hand because the card's 40-second default is too slow for a
test; the strategy never generates that knob."""
generated = _generate_dashboard(
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
)
log_config = {**generated["views"][1]["cards"][0], "idle_return_seconds": 1}
result = _render_quest_log(
log_config,
_three_child_states(),
url="http://homeassistant.local/nestquest/ada",
idle_ms=2000,
)
assert result["headline"] == "Ada's Quest Log"
assert result["location"] == "/nestquest"
def test_quest_complete_screen_returns_to_the_board_after_the_countdown() -> None:
"""The 1c screen renders with its countdown line and auto-returns to
the strategy-computed board path when it reaches zero. The design
shows it for 12 s; the test shortens complete_screen_seconds to 1
the same way the idle test shortens idle_return_seconds."""
generated = _generate_dashboard(
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
)
log_config = {
**generated["views"][1]["cards"][0],
"complete_screen_seconds": 1,
}
states = {
**_three_child_states(),
"binary_sensor.nestquest_ada_all_done": _state("on"),
}
result = _render_quest_log(
log_config,
states,
url="http://homeassistant.local/nestquest/ada",
probe_ms=400,
idle_ms=2400,
)
# Mid-wait the complete screen is up with its countdown line…
assert result["probe"] is not None
assert result["probe"]["location"] == "/nestquest/ada"
assert "Quest complete" in result["probe"]["html"]
assert result["probe"]["countdown"] == "Returning to The Party in 1 seconds"
# …and at zero the card has returned to the party board.
assert result["location"] == "/nestquest"
+25
View File
@@ -322,6 +322,7 @@ def test_quest_definitions_columns_types_and_constraints(tmp_path) -> None:
("due_time", "TEXT", 0, None, 0),
("is_active", "INTEGER", 1, "1", 0),
("created_at", "TEXT", 1, None, 0),
("skip_on_away", "INTEGER", 1, "1", 0),
]
finally:
_run(database.close())
@@ -633,6 +634,30 @@ def test_schedule_rules_rule_type_check_enforced(tmp_path) -> None:
_run(database.close())
@pytest.mark.parametrize("bad_flag", [2, -1])
def test_quest_definitions_skip_on_away_check_enforced(
tmp_path, bad_flag
) -> None:
database = _open_db(tmp_path / f"skip-on-away-{bad_flag}.db")
try:
_apply(database)
_child(database)
_insert_rule(database)
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
_insert_definition(database, skip_on_away=bad_flag)
definition_id = _insert_definition(database, skip_on_away=0)
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
_run(
database.execute(
"UPDATE quest_definitions SET skip_on_away = ? "
"WHERE id = ?",
(bad_flag, definition_id),
)
)
finally:
_run(database.close())
def test_schedule_rules_interval_zero_fails(tmp_path) -> None:
database = _open_db(tmp_path / "interval-zero.db")
try:
+164
View File
@@ -12,6 +12,7 @@ from __future__ import annotations
import datetime
import pytest
from homeassistant.util import slugify
from conftest import StubSnapshotClient, set_coordinator_client, wire_entry_to_registry
@@ -19,6 +20,7 @@ from custom_components.nestquest import async_setup_entry, async_unload_entry
from custom_components.nestquest.const import DOMAIN
from custom_components.nestquest.sensor import (
MAX_STATE_LENGTH,
_slugify,
_state_within_limit,
)
@@ -539,3 +541,165 @@ async def test_household_rollups_sum_the_active_children(
assert cycle_day_entity.name == "NestQuest cycle day"
assert cycle_day_entity.native_value == 4
assert cycle_day_entity.extra_state_attributes["today"] == _today_iso()
async def test_household_due_sensor_publishes_the_ordered_child_roster(
hass, make_entry
) -> None:
"""Feature 20: the rollup carries the ordered child roster beside
the unchanged per-child ``children`` counts, so the panel cards can
discover the board without a configured child_order. The order is
the coordinator snapshot's (the API's sort_order, then id)."""
entry, _coordinator, _client = await _setup_entry(
hass, make_entry, _seeded_payload()
)
attributes = hass.entities[
"nestquest_household_quests_due_today"
].extra_state_attributes
assert attributes["child_roster"] == [
{"child_id": ADA, "name": "Ada", "slug": "ada"},
{"child_id": BO, "name": "Bo", "slug": "bo"},
{"child_id": CORY, "name": "Cory", "slug": "cory"},
]
# The pre-existing per-child counts attribute is untouched.
assert attributes["children"] == {
str(ADA): 1,
str(BO): 1,
str(CORY): 0,
}
async def test_household_roster_tracks_the_snapshot_on_refresh(
hass, make_entry
) -> None:
"""The roster re-derives from every snapshot: a reorder and a
rename in the API payload show up (with the re-derived slug) on the
next refresh, like the sensor names already do."""
reordered = _payload(
[
_child(CORY, "Cory"),
_child(ADA, "Ada", [_instance(7, ADA)]),
_child(BO, "Mirren", [_instance(8, BO)]),
]
)
entry, coordinator, _client = await _setup_entry(
hass, make_entry, _seeded_payload(), reordered
)
roster_entity = hass.entities["nestquest_household_quests_due_today"]
assert [entry["slug"] for entry in roster_entity.extra_state_attributes["child_roster"]] == [
"ada",
"bo",
"cory",
]
await coordinator.async_refresh()
assert roster_entity.extra_state_attributes["child_roster"] == [
{"child_id": CORY, "name": "Cory", "slug": "cory"},
{"child_id": ADA, "name": "Ada", "slug": "ada"},
{"child_id": BO, "name": "Mirren", "slug": "mirren"},
]
async def test_duplicate_display_names_get_registry_suffix_slugs(
hass, make_entry
) -> None:
"""Two children may share a nickname (the children layer allows
it); the roster's slugs then mirror the registry's collision
suffixing (_2, _3, ...) in snapshot order, so both plates resolve."""
payload = _payload(
[
_child(ADA, "Ada", [_instance(7, ADA)]),
_child(BO, "Ada"),
]
)
entry, _coordinator, _client = await _setup_entry(hass, make_entry, payload)
assert hass.entities[
"nestquest_household_quests_due_today"
].extra_state_attributes["child_roster"] == [
{"child_id": ADA, "name": "Ada", "slug": "ada"},
{"child_id": BO, "name": "Ada", "slug": "ada_2"},
]
async def test_empty_household_publishes_an_empty_roster(
hass, make_entry
) -> None:
"""An empty household is a valid 0: the roster is an empty list,
and the card's discovery of it renders the not-set-up notice."""
entry, _coordinator, _client = await _setup_entry(
hass, make_entry, _payload([])
)
attributes = hass.entities[
"nestquest_household_quests_due_today"
].extra_state_attributes
assert attributes["child_roster"] == []
assert attributes["children"] == {}
def test_slugify_mirrors_home_assistant_entity_id_derivation() -> None:
"""The roster slugs match homeassistant.util.slugify — the real
slugifier HA derives entity ids from, composed with the entity-id
length cap the entity registry applies to ``sensor.`` ids."""
assert _slugify("Ada") == "ada"
assert _slugify("Mary Jane") == "mary_jane"
assert _slugify("Mary-Jane") == "mary_jane"
assert _slugify("O'Brien") == "o_brien"
assert _slugify("Émilie") == "emilie"
assert _slugify("Åsa") == "asa"
assert _slugify(" Bo ") == "bo"
# HA's wrapper answers "unknown" when a name slugs to nothing.
assert _slugify(" ") == "unknown"
assert _slugify("?!?") == "unknown"
# The registry caps the entity_id at 64 characters, so the slugged
# object id alone is capped at 64 - len("sensor.").
assert _slugify("a" * 80) == "a" * (64 - len("sensor."))
def test_slugify_transliterates_non_latin_names_like_home_assistant() -> None:
"""HA's slugifier transliterates every script through unidecode,
so a non-Latin child name slugs to its romanization — never to the
``unknown`` sentinel — and the roster publishes the same slug HA's
entity-id derivation produces for that name."""
assert _slugify("Дана") == "dana"
assert _slugify("京子") == "jing_zi"
assert _slugify("Zoë") == "zoe"
async def test_non_latin_child_names_resolve_auto_discovery(
hass, make_entry
) -> None:
"""A non-Latin child name slugs through the real slugifier, so the
roster slug IS the object id of the per-child sensor's entity_id
and the cards' auto-discovery resolves that child.
HA derives the per-child entity id from the entity NAME ("NestQuest
京子 quests due today") with the same slugifier the roster uses, so
the two must agree exactly; the reviewed mirror dropped non-Latin
characters (slugging "京子" to "unknown"), so the cards built
"sensor.nestquest_unknown_…" ids that no entity had."""
payload = _payload(
[
_child(ADA, "Дана", [_instance(7, ADA)]),
_child(BO, "京子", [_instance(8, BO)]),
]
)
entry, _coordinator, _client = await _setup_entry(hass, make_entry, payload)
roster = hass.entities[
"nestquest_household_quests_due_today"
].extra_state_attributes["child_roster"]
assert roster == [
{"child_id": ADA, "name": "Дана", "slug": "dana"},
{"child_id": BO, "name": "京子", "slug": "jing_zi"},
]
for roster_entry in roster:
due_entity = _sensor(hass, roster_entry["child_id"], "quests_due_today")
assert due_entity.name == (
f"NestQuest {roster_entry['name']} quests due today"
)
# The entity id HA's registry derives for this entity (from the
# same slugifier) embeds exactly the slug the roster publishes:
# the card builds sensor.nestquest_<slug>_quests_due_today.
assert slugify(due_entity.name) == (
f"nestquest_{roster_entry['slug']}_quests_due_today"
)
Generated
+12 -1
View File
@@ -373,7 +373,7 @@ wheels = [
[[package]]
name = "nestquest"
version = "0.5.0"
version = "0.6.0"
source = { virtual = "." }
[package.dev-dependencies]
@@ -385,6 +385,7 @@ dev = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pyyaml" },
{ name = "unidecode" },
{ name = "uvicorn" },
{ name = "voluptuous" },
]
@@ -400,6 +401,7 @@ dev = [
{ name = "pytest", specifier = ">=8.0.0" },
{ name = "pytest-asyncio", specifier = ">=0.24" },
{ name = "pyyaml", specifier = ">=6" },
{ name = "unidecode", specifier = ">=1.4.0" },
{ name = "uvicorn", specifier = ">=0.30" },
{ name = "voluptuous", specifier = ">=0.14" },
]
@@ -689,6 +691,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
]
[[package]]
name = "unidecode"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/7d/a8a765761bbc0c836e397a2e48d498305a865b70a8600fd7a942e85dcf63/Unidecode-1.4.0.tar.gz", hash = "sha256:ce35985008338b676573023acc382d62c264f307c8f7963733405add37ea2b23", size = 200149, upload-time = "2025-04-24T08:45:03.798Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/b7/559f59d57d18b44c6d1250d2eeaa676e028b9c527431f5d0736478a73ba1/Unidecode-1.4.0-py3-none-any.whl", hash = "sha256:c3c7606c27503ad8d501270406e345ddb480a7b5f38827eafe4fa82a137f0021", size = 235837, upload-time = "2025-04-24T08:45:01.609Z" },
]
[[package]]
name = "uvicorn"
version = "0.53.0"