Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be94236111 | ||
|
|
67f333c98e | ||
|
|
a808f89993 | ||
|
|
22f1a6861d | ||
|
|
939556a38a | ||
|
|
8676bdc6ad | ||
|
|
0ed35ab85a | ||
|
|
c57d950101 | ||
|
|
70ee4ec111 | ||
|
|
415ababc8e | ||
|
|
ee8ef6dcac | ||
|
|
6059a67122 | ||
|
|
0972761994 | ||
|
|
10f365ef9f |
@@ -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
@@ -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
|
||||
|
||||
|
||||
+160
-4
@@ -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
|
||||
@@ -235,7 +251,7 @@ 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, field_validator, model_validator
|
||||
|
||||
from api import transitions as api_transitions
|
||||
from api.auth import require_admin
|
||||
@@ -254,6 +270,7 @@ from api.nestquest_core import (
|
||||
core_recurrence,
|
||||
core_settings,
|
||||
core_settings_store,
|
||||
core_snapshot,
|
||||
core_sweep,
|
||||
)
|
||||
|
||||
@@ -346,11 +363,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``.
|
||||
|
||||
@@ -427,8 +451,9 @@ class AdminQuestDefinitionCreateRequest(BaseModel):
|
||||
``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.
|
||||
"""
|
||||
@@ -439,6 +464,7 @@ class AdminQuestDefinitionCreateRequest(BaseModel):
|
||||
windows: list[str | tuple[str, str | None]]
|
||||
description: str | None = None
|
||||
icon: str | None = None
|
||||
skip_on_away: StrictBool = True
|
||||
|
||||
|
||||
class AdminQuestDefinitionEditRequest(BaseModel):
|
||||
@@ -453,7 +479,8 @@ class AdminQuestDefinitionEditRequest(BaseModel):
|
||||
``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.
|
||||
through this route. An omitted (or ``null``) ``skip_on_away``
|
||||
keeps the stored value.
|
||||
"""
|
||||
|
||||
title: str | None = None
|
||||
@@ -461,6 +488,7 @@ 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
|
||||
|
||||
|
||||
class AdminQuestDefinitionActiveRequest(BaseModel):
|
||||
@@ -683,6 +711,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"
|
||||
|
||||
@@ -887,6 +968,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 +1180,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,6 +1240,7 @@ 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)
|
||||
@@ -1729,4 +1838,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"]
|
||||
|
||||
@@ -1,375 +1,78 @@
|
||||
"""The NestQuest integration."""
|
||||
"""The NestQuest integration (Feature 18: a thin client of the NestQuest API service).
|
||||
|
||||
The integration owns NO database: the API service (Feature 16) owns the
|
||||
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, registers the zero-config
|
||||
panel dashboard (Feature 20), and forwards the sensor and
|
||||
binary_sensor platforms.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import inspect
|
||||
import sqlite3
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.event import async_track_time_change
|
||||
|
||||
from .const import (
|
||||
CONF_ADMIN_USER_IDS,
|
||||
CONF_SNAPSHOT_STALENESS,
|
||||
CONF_UPDATE_INTERVAL,
|
||||
DEFAULT_SNAPSHOT_STALENESS,
|
||||
DEFAULT_UPDATE_INTERVAL,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
PLATFORMS,
|
||||
)
|
||||
from .core.settings import NestQuestSettings
|
||||
from .coordinator import NestQuestCoordinator, coordinator_client_from_entry
|
||||
from .dashboard import async_register_dashboard
|
||||
from .frontend import async_register_frontend
|
||||
from .coordinator import NestQuestCoordinator
|
||||
from .db import NestQuestDatabase, make_database
|
||||
from .materialize import materialize as _materialize_run
|
||||
from .migrations import apply_migrations
|
||||
from .services import async_deregister_services, async_register_services
|
||||
from .store import async_get_db_path
|
||||
from .admin_allowlist import seed_setup_admin
|
||||
from .sweep import run_missed_sweep
|
||||
from .sse import NestQuestEventStream
|
||||
|
||||
_LOGGER = LOGGER
|
||||
|
||||
#: SQLite reports "file is not a database" for corruption in every
|
||||
#: sqlite3 version this integration targets; the version-specific
|
||||
#: "encrypted or malformed" spelling (3.41+) maps to the same failure.
|
||||
_CORRUPTION_MARKERS = (
|
||||
"file is not a database",
|
||||
"database disk image is malformed",
|
||||
"encrypted or malformed",
|
||||
"is not a database",
|
||||
)
|
||||
|
||||
#: Primary sqlite error codes that mean "the file is corrupt" (SQLite
|
||||
#: result codes, https://www.sqlite.org/c3ref/c_abort.html). Extended
|
||||
#: codes carry the primary code in their low byte, so the match is on
|
||||
#: errcode & 0xff.
|
||||
_CORRUPT_PRIMARY_CODES = frozenset({11, 26}) # SQLITE_CORRUPT, SQLITE_NOTADB
|
||||
|
||||
|
||||
def _is_corruption_error(error: BaseException) -> bool:
|
||||
"""Return True when ``error`` indicates a corrupt database file.
|
||||
|
||||
Classification order: sqlite_errorcode (Python 3.11+) is primary
|
||||
and locale-independent — SQLITE_CORRUPT and SQLITE_NOTADB, matched
|
||||
on the primary byte so extended codes (SQLITE_CORRUPT_VTAB etc.)
|
||||
classify correctly too. Message matching is the fallback for
|
||||
older Pythons: the corruption spellings are the documented English
|
||||
texts; a non-English locale could evade them, but the code check
|
||||
already covers every locale on supported Pythons.
|
||||
"""
|
||||
if not isinstance(error, sqlite3.DatabaseError):
|
||||
return False
|
||||
errorcode = getattr(error, "sqlite_errorcode", None)
|
||||
if errorcode is not None and (errorcode & 0xFF) in _CORRUPT_PRIMARY_CODES:
|
||||
return True
|
||||
message = str(error).lower()
|
||||
return any(marker in message for marker in _CORRUPTION_MARKERS)
|
||||
|
||||
|
||||
async def _preflight_existing_file(
|
||||
hass: HomeAssistant, db_path: Path
|
||||
) -> None:
|
||||
"""Detect corruption in an EXISTING file before it is opened read-write.
|
||||
|
||||
Opening read-write persists WAL mode — for a rollback-journal file
|
||||
that rewrites the header byte 18/19 and can create -wal/-shm
|
||||
sidecars — which would modify a corrupt file before detection and
|
||||
break the leave-untouched guarantee. This preflight therefore
|
||||
validates the WHOLE file with a READ-ONLY connection (mode=ro +
|
||||
immutable=1 URI, PRAGMA integrity_check, run on the executor like
|
||||
every other DB call): corruption anywhere fails there, before
|
||||
anything is written. ``immutable=1`` is SQLite's documented
|
||||
guarantee that the connection performs NO writes and creates NO
|
||||
sidecars — even a WAL-header file gets its -wal/-shm reads served
|
||||
without creating the shared-memory file. Missing/empty files skip
|
||||
the preflight (they are created fresh by the real open). All
|
||||
filesystem stat/URI work runs inside the executor job with the
|
||||
probe so the event loop never touches disk.
|
||||
"""
|
||||
def _probe() -> None:
|
||||
# The file-state check and URI construction live inside the
|
||||
# executor job: exists/stat/resolve are disk I/O.
|
||||
if not db_path.exists():
|
||||
return
|
||||
# A zero-byte file is neither missing nor a valid database: it
|
||||
# is a corrupt (truncated-to-nothing) existing file, likely an
|
||||
# interrupted first write. The leave-untouched contract applies
|
||||
# to it too: refuse rather than silently initialize over it.
|
||||
if db_path.stat().st_size == 0:
|
||||
raise sqlite3.DatabaseError(
|
||||
"existing database file is empty (zero bytes): file is "
|
||||
"not a database"
|
||||
)
|
||||
# Properly escape the path: '#'/'?'/'%' in a directory or file
|
||||
# name would otherwise be parsed as URI fragments/parameters/
|
||||
# escapes. immutable=1: SQLite guarantees no writes and no
|
||||
# sidecar creation from this connection.
|
||||
uri = db_path.resolve().as_uri() + "?mode=ro&immutable=1"
|
||||
conn = sqlite3.connect(uri, uri=True)
|
||||
try:
|
||||
row = conn.execute("PRAGMA integrity_check").fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
verdict = row[0] if row else "missing"
|
||||
if verdict != "ok":
|
||||
# A non-ok integrity verdict IS corruption: raise with the
|
||||
# verdict text (which matches the corruption markers) and
|
||||
# the NOTADB code so classification is code-based.
|
||||
raise sqlite3.DatabaseError(
|
||||
f"integrity_check verdict {verdict!r} on read-only "
|
||||
f"probe: file is not a database"
|
||||
)
|
||||
|
||||
try:
|
||||
# Every DB-touching call goes through the executor (the mock
|
||||
# harness's async_add_executor_job is awaitable too).
|
||||
await hass.async_add_executor_job(_probe)
|
||||
except sqlite3.DatabaseError as err:
|
||||
# Non-ok integrity verdicts are corruption, full stop: there is
|
||||
# no benign reading of a failed whole-file integrity check, so
|
||||
# ANY DatabaseError out of the probe that carries a corruption
|
||||
# marker or code propagates as corruption. Only exotic probe
|
||||
# failures that classify as non-corruption (e.g. a transient
|
||||
# 'database is locked' on the read) are downgraded to a debug
|
||||
# log, letting the real open path surface them.
|
||||
if _is_corruption_error(err):
|
||||
raise
|
||||
raise sqlite3.DatabaseError(
|
||||
f"read-only corruption probe of {db_path} failed: {err}"
|
||||
) from err
|
||||
|
||||
async def _async_open_database(
|
||||
hass: HomeAssistant, db_path: Path
|
||||
) -> NestQuestDatabase:
|
||||
"""Open the NestQuest database, mapping failure modes for setup.
|
||||
|
||||
Three startup cases (Feature 02 done-condition):
|
||||
|
||||
- file missing: SQLite creates it fresh on open — no special path,
|
||||
migrations stamp version 1 on the next step.
|
||||
- file present and valid: opens normally.
|
||||
- file present but corrupt: log an ERROR with the path, close
|
||||
anything opened, and raise ConfigEntryNotReady so HA retries —
|
||||
the file is left byte-for-byte untouched so the owner can
|
||||
restore a backup. No code path here deletes or overwrites an
|
||||
existing database file.
|
||||
|
||||
Corruption can surface at EITHER stage: a truncated header fails
|
||||
in the read-only preflight (before the file is ever opened
|
||||
read-write), while damaged later pages fail during migrations —
|
||||
the preflight guarantees no WAL sidecar or header write happens
|
||||
on a corrupt file before it is detected. Both are classified the
|
||||
same way.
|
||||
"""
|
||||
try:
|
||||
await _preflight_existing_file(hass, db_path)
|
||||
database = await make_database(hass).open(db_path)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except sqlite3.DatabaseError as err:
|
||||
if _is_corruption_error(err):
|
||||
_LOGGER.error(
|
||||
"NestQuest database at %s is corrupt: %s. Refusing to "
|
||||
"start with a corrupt database; the file was left "
|
||||
"untouched so a backup can be restored. Home Assistant "
|
||||
"will retry setup.",
|
||||
db_path,
|
||||
err,
|
||||
)
|
||||
raise ConfigEntryNotReady(
|
||||
f"NestQuest database corrupt: {db_path}"
|
||||
) from err
|
||||
# open() failed for a non-corruption reason (locked file,
|
||||
# permission problem): retryable, but nothing was opened.
|
||||
_LOGGER.error(
|
||||
"NestQuest database at %s failed to open: %s. Home "
|
||||
"Assistant will retry setup.",
|
||||
db_path,
|
||||
err,
|
||||
)
|
||||
raise ConfigEntryNotReady(
|
||||
f"NestQuest database unavailable: {db_path}"
|
||||
) from err
|
||||
try:
|
||||
await apply_migrations(database)
|
||||
except (sqlite3.DatabaseError, RuntimeError) as err:
|
||||
await database.close()
|
||||
if _is_corruption_error(err):
|
||||
_LOGGER.error(
|
||||
"NestQuest database at %s is corrupt: %s. Refusing to "
|
||||
"start with a corrupt database; the file was left "
|
||||
"untouched so a backup can be restored. Home Assistant "
|
||||
"will retry setup.",
|
||||
db_path,
|
||||
err,
|
||||
)
|
||||
raise ConfigEntryNotReady(
|
||||
f"NestQuest database corrupt: {db_path}"
|
||||
) from err
|
||||
# A non-corruption failure (e.g. a locked file, a future schema
|
||||
# version) is also a retryable setup problem, but without the
|
||||
# corruption framing.
|
||||
_LOGGER.error(
|
||||
"NestQuest database at %s failed to open: %s. Home "
|
||||
"Assistant will retry setup.",
|
||||
db_path,
|
||||
err,
|
||||
)
|
||||
raise ConfigEntryNotReady(
|
||||
f"NestQuest database unavailable: {db_path}"
|
||||
) from err
|
||||
except asyncio.CancelledError:
|
||||
await database.close()
|
||||
raise
|
||||
return database
|
||||
|
||||
|
||||
@dataclass
|
||||
class NestQuestRuntimeData:
|
||||
"""Runtime data stored on a NestQuest config entry."""
|
||||
|
||||
entry_id: str
|
||||
settings: NestQuestSettings
|
||||
database: NestQuestDatabase
|
||||
remove_update_listener: Callable[[], Any]
|
||||
remove_time_change_listener: Callable[[], Any]
|
||||
coordinator: Any = None
|
||||
|
||||
|
||||
async def _async_owner_user_ids(hass: HomeAssistant) -> list[str]:
|
||||
"""Return every Home Assistant owner-account user id.
|
||||
|
||||
The last-resort admin seed: when no persisted allowlist copy and no
|
||||
flow-context user survive to first setup, the HA owner accounts are
|
||||
seeded so the household is never left with zero admins (an empty
|
||||
allowlist fails closed, which would lock the household out of its
|
||||
own integration). Owner accounts can already do everything in HA,
|
||||
so granting NestQuest admin is not a privilege escalation.
|
||||
"""
|
||||
auth = getattr(hass, "auth", None)
|
||||
if auth is None:
|
||||
return []
|
||||
users = await auth.async_get_users()
|
||||
owners = [
|
||||
user.id
|
||||
for user in users
|
||||
if getattr(user, "is_owner", False) and getattr(user, "id", None)
|
||||
]
|
||||
return owners
|
||||
|
||||
|
||||
async def _run_horizon_materialization(
|
||||
hass: HomeAssistant,
|
||||
database: NestQuestDatabase,
|
||||
settings: NestQuestSettings,
|
||||
) -> None:
|
||||
"""Materialize the rolling horizon ``[today, today + horizon_days]``.
|
||||
|
||||
"today" is computed in HA local time (``hass.config.time_zone``) rather
|
||||
than the system clock, so the horizon tracks the household's own day.
|
||||
The walk itself runs through :func:`~.materialize.materialize`, whose DB
|
||||
access is executor-only.
|
||||
|
||||
This is the ONE generation path: the startup backfill, the daily rollover
|
||||
listener and the ``regenerate`` service all funnel through it, so there is
|
||||
no duplicated materialization logic. ``settings`` (REQUIRED — there is no
|
||||
silent default; every caller already passes the entry's validated
|
||||
:class:`NestQuestSettings`) carries the configured ``horizon_days``. The
|
||||
window itself comes from :meth:`settings.horizon_window` so the helper is
|
||||
real and shared (not recomputed inline here).
|
||||
"""
|
||||
time_zone = ZoneInfo(hass.config.time_zone)
|
||||
today = datetime.datetime.now(time_zone).date()
|
||||
start, end = settings.horizon_window(today)
|
||||
await _materialize_run(database, start.isoformat(), end.isoformat(), today=today)
|
||||
|
||||
|
||||
def _register_day_rollover_listener(
|
||||
hass: HomeAssistant,
|
||||
database: NestQuestDatabase,
|
||||
settings: NestQuestSettings,
|
||||
) -> Callable[[], Any]:
|
||||
"""Register the daily rollover listener and return its remover.
|
||||
|
||||
Fires once per day at the configured local ``day_rollover_time``:
|
||||
first re-materializes the rolling horizon
|
||||
``[today, today + horizon_days]`` through the shared
|
||||
:func:`_run_horizon_materialization` path (the entry's configured,
|
||||
validated ``horizon_days`` carried on ``settings``), then runs the
|
||||
Feature 11 missed sweep — announcing yesterday's still-open quests
|
||||
as missed AFTER today's instances exist, watermark-guarded so a
|
||||
re-fire of the listener the same night announces nothing twice.
|
||||
"""
|
||||
hour, minute = settings.day_rollover_hour_minute
|
||||
|
||||
async def _run_materialization(_now: datetime.datetime) -> None:
|
||||
await _run_horizon_materialization(hass, database, settings)
|
||||
await run_missed_sweep(hass, database)
|
||||
|
||||
return async_track_time_change(
|
||||
hass, _run_materialization, hour=hour, minute=minute
|
||||
)
|
||||
event_stream: NestQuestEventStream | None = None
|
||||
|
||||
|
||||
def _find_live_runtime_data(hass: HomeAssistant) -> NestQuestRuntimeData | None:
|
||||
"""Return a live runtime record with a connected database, or None.
|
||||
"""Return a live runtime record with a coordinator, or None.
|
||||
|
||||
The domain-global services (e.g. ``regenerate``) have no database of
|
||||
their own; they resolve whichever config entry's runtime data is
|
||||
currently live at call time, so none of them can hold a stale handle
|
||||
to a closed connection after another entry unloads. The record also
|
||||
carries that entry's validated ``settings`` (e.g. the configured
|
||||
horizon) so service handlers read horizon days off it rather than
|
||||
re-reading HA config.
|
||||
The domain-global service (the panel completion proxy) has no
|
||||
database of its own; it resolves whichever config entry's runtime
|
||||
data is currently live at call time, so it cannot hold a stale
|
||||
handle to an unloaded entry's coordinator.
|
||||
"""
|
||||
for runtime_data in hass.data.get(DOMAIN, {}).values():
|
||||
database = getattr(runtime_data, "database", None)
|
||||
if database is not None and database.connected:
|
||||
if getattr(runtime_data, "coordinator", None) is not None:
|
||||
return runtime_data
|
||||
return None
|
||||
|
||||
|
||||
def _make_regenerate_handler(hass: HomeAssistant):
|
||||
"""Return the Feature 07 ``regenerate`` handler closed over ``hass``."""
|
||||
|
||||
async def _regenerate(_call: Any) -> None:
|
||||
runtime_data = _find_live_runtime_data(hass)
|
||||
if runtime_data is None:
|
||||
_LOGGER.warning(
|
||||
"NestQuest regenerate requested but no config entry has a "
|
||||
"live database; ignoring"
|
||||
)
|
||||
return
|
||||
await _run_horizon_materialization(
|
||||
hass, runtime_data.database, runtime_data.settings
|
||||
)
|
||||
|
||||
return _regenerate
|
||||
|
||||
|
||||
def _register_services(hass: HomeAssistant) -> None:
|
||||
"""Register domain-global NestQuest services once.
|
||||
"""Register the domain-global NestQuest service once.
|
||||
|
||||
Services are DOMAIN-global — registered exactly once (guarded by
|
||||
``has_service`` on regenerate) and NOT bound to any config entry —
|
||||
so each handler resolves the currently-live database at call time
|
||||
The service is DOMAIN-global — registered exactly once (guarded by
|
||||
``has_service``) and NOT bound to any config entry — so the panel
|
||||
completion proxy resolves the currently-live runtime at call time
|
||||
and a name collision or duplicate setup is impossible. Removal is
|
||||
handled by the unload path only once the LAST entry is removed
|
||||
(see :func:`_deregister_services`).
|
||||
"""
|
||||
async_register_services(
|
||||
hass,
|
||||
find_runtime=_find_live_runtime_data,
|
||||
regenerate_handler=_make_regenerate_handler(hass),
|
||||
)
|
||||
async_register_services(hass, find_runtime=_find_live_runtime_data)
|
||||
|
||||
|
||||
def _deregister_services(hass: HomeAssistant) -> None:
|
||||
@@ -386,48 +89,14 @@ 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, {})
|
||||
existing = hass.data[DOMAIN].get(entry.entry_id)
|
||||
if existing is not None:
|
||||
db = getattr(existing, "database", None)
|
||||
if db is None or not db.connected:
|
||||
# Reopen through the SAME safety path as first setup: a
|
||||
# record without a live connection (a previous setup's
|
||||
# close-on-failure, or a hand-replaced file) must get the
|
||||
# missing/valid/corrupt classification too, never a raw
|
||||
# sqlite3 error and never skipped migrations — and the
|
||||
# same allowlist seeding, so a reopened fresh file is
|
||||
# never ownerless.
|
||||
db = await _async_open_database(
|
||||
hass, await async_get_db_path(hass)
|
||||
)
|
||||
try:
|
||||
stored_admin_ids = (
|
||||
entry.options.get(CONF_ADMIN_USER_IDS)
|
||||
or entry.data.get(CONF_ADMIN_USER_IDS)
|
||||
)
|
||||
context = getattr(entry, "context", None)
|
||||
context_user_id = (
|
||||
context.get("user_id")
|
||||
if isinstance(context, dict)
|
||||
else None
|
||||
)
|
||||
await seed_setup_admin(
|
||||
db,
|
||||
stored_admin_ids=stored_admin_ids,
|
||||
context_user_id=context_user_id,
|
||||
owner_ids=await _async_owner_user_ids(hass),
|
||||
)
|
||||
except BaseException:
|
||||
# A seeding failure must not leak the just-opened
|
||||
# connection: close it before the error propagates, so
|
||||
# HA's setup retry starts from a clean handle (the
|
||||
# first-setup path closes symmetrically).
|
||||
await db.close()
|
||||
raise
|
||||
existing.database = db
|
||||
entry.runtime_data = existing
|
||||
return True
|
||||
|
||||
async def _async_update_listener(
|
||||
listener_hass: HomeAssistant, listener_entry: ConfigEntry
|
||||
@@ -435,109 +104,94 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Reload the entry when its options change."""
|
||||
await listener_hass.config_entries.async_reload(listener_entry.entry_id)
|
||||
|
||||
database = await _async_open_database(hass, await async_get_db_path(hass))
|
||||
remove_update_listener: Callable[[], Any] | None = None
|
||||
remove_time_change_listener: Callable[[], Any] | None = None
|
||||
coordinator: Any = None
|
||||
event_stream: NestQuestEventStream | None = None
|
||||
try:
|
||||
# Seed the admin allowlist on first setup so the owner is never
|
||||
# locked out: an empty database takes the persisted admin copy
|
||||
# — the options flow's saved list first (it is the current
|
||||
# one), else the config flow's user from entry data — and only
|
||||
# then the HA user still present on the entry context. No-op
|
||||
# on a non-empty list (fail-closed narrowing is deliberate).
|
||||
stored_admin_ids = (
|
||||
entry.options.get(CONF_ADMIN_USER_IDS)
|
||||
or entry.data.get(CONF_ADMIN_USER_IDS)
|
||||
)
|
||||
context = getattr(entry, "context", None)
|
||||
context_user_id = (
|
||||
context.get("user_id")
|
||||
if isinstance(context, dict)
|
||||
else None
|
||||
)
|
||||
owner_ids = await _async_owner_user_ids(hass)
|
||||
await seed_setup_admin(
|
||||
database,
|
||||
stored_admin_ids=stored_admin_ids,
|
||||
context_user_id=context_user_id,
|
||||
owner_ids=owner_ids,
|
||||
)
|
||||
# Build the explicit settings object from entry.options ONCE: the
|
||||
# horizon materialization and the daily-rollover listener both
|
||||
# consume it, so core never reads HA config itself. A malformed
|
||||
# stored option falls back PER FIELD (via from_options_resilient)
|
||||
# so a single bad sibling option cannot reset the user's
|
||||
# horizon_days or day_rollover_time — the two fields core
|
||||
# consumes — to their defaults.
|
||||
settings = NestQuestSettings.from_options_resilient(entry.options)
|
||||
remove_update_listener = entry.add_update_listener(_async_update_listener)
|
||||
remove_time_change_listener = _register_day_rollover_listener(
|
||||
hass, database, settings
|
||||
)
|
||||
# Backfill any days the daily listener missed while the integration
|
||||
# was off (or freshly installed): run the one idempotent walk over
|
||||
# [today, today + horizon_days] once at setup, using the entry's
|
||||
# configured horizon. This runs through the SAME path as the daily
|
||||
# listener and regenerate service.
|
||||
await _run_horizon_materialization(hass, database, settings)
|
||||
# The missed sweep runs at startup too (Feature 11): if HA was
|
||||
# down at the rollover time, the previous night's still-open
|
||||
# quests are announced as missed now instead of waiting for
|
||||
# the next midnight. Watermark-guarded, so this is a no-op
|
||||
# when the night's sweep already ran.
|
||||
await run_missed_sweep(hass, database)
|
||||
_register_services(hass)
|
||||
# The shared Feature 10 coordinator: one refresh cycle every
|
||||
# entity reads from (CONF_UPDATE_INTERVAL seconds, default
|
||||
# five minutes). Created AFTER services so a failure below
|
||||
# unwinds them through the except path's listener handling.
|
||||
# ONE API client for the entry, shared by the coordinator's
|
||||
# snapshot polling, the SSE subscription, and the completion
|
||||
# proxy (the client is stateless — one session, one connection
|
||||
# pool). An entry with no panel token yet gets the
|
||||
# typed-error stub (unavailable entities, never a setup crash).
|
||||
api_client = coordinator_client_from_entry(hass, entry)
|
||||
# The shared coordinator: one refresh cycle every entity reads
|
||||
# from (CONF_UPDATE_INTERVAL seconds, default five minutes).
|
||||
# It polls the API service's panel snapshot; a failed poll
|
||||
# keeps the entities rendering the last-good snapshot within
|
||||
# CONF_SNAPSHOT_STALENESS, unavailable past it.
|
||||
coordinator = NestQuestCoordinator(
|
||||
hass,
|
||||
entry_id=entry.entry_id,
|
||||
database=database,
|
||||
settings=settings,
|
||||
api_client=api_client,
|
||||
update_interval_seconds=entry.options.get(
|
||||
CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL
|
||||
),
|
||||
staleness_seconds=entry.options.get(
|
||||
CONF_SNAPSHOT_STALENESS, DEFAULT_SNAPSHOT_STALENESS
|
||||
),
|
||||
)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
# The panel event subscription (the SSE task): a background
|
||||
# task consumes the API service's transition-event stream and
|
||||
# re-fires the four documented HA bus events. It reuses the
|
||||
# coordinator's client, so it only starts when that client
|
||||
# actually offers a stream — an unconfigured-token stub (or a
|
||||
# test snapshot client without ``stream_events``) leaves it
|
||||
# unstarted, and the typed-error path already covers
|
||||
# availability for those entries.
|
||||
if callable(getattr(api_client, "stream_events", None)):
|
||||
event_stream = NestQuestEventStream(hass, api_client)
|
||||
event_stream.start()
|
||||
runtime_data = NestQuestRuntimeData(
|
||||
entry_id=entry.entry_id,
|
||||
settings=settings,
|
||||
database=database,
|
||||
remove_update_listener=remove_update_listener,
|
||||
remove_time_change_listener=remove_time_change_listener,
|
||||
coordinator=coordinator,
|
||||
event_stream=event_stream,
|
||||
)
|
||||
# runtime_data is assigned BEFORE the platforms forward so each
|
||||
# platform's async_setup_entry reads its coordinator off the
|
||||
# entry (the HA runtime-data contract); a forward failure below
|
||||
# unwinds it through the except path.
|
||||
# The runtime record is registered BEFORE the first refresh:
|
||||
# the service proxy resolves the entry's runtime state through
|
||||
# hass.data, and it must find the live coordinator there —
|
||||
# never a half-set-up entry. The platforms forward below reads
|
||||
# the coordinator off the entry (the HA runtime-data contract);
|
||||
# a forward failure below unwinds it through the except path.
|
||||
hass.data[DOMAIN][entry.entry_id] = runtime_data
|
||||
entry.runtime_data = runtime_data
|
||||
# The first refresh polls the API snapshot; an API failure
|
||||
# (unconfigured token, API service down) does NOT crash setup:
|
||||
# the coordinator reports last_update_success=False, entities
|
||||
# come up unavailable, and HA's standard retry cycle applies
|
||||
# once the API is reachable. HA's real
|
||||
# ``async_config_entry_first_refresh`` wraps ANY failed update
|
||||
# pass in ConfigEntryNotReady — the original error survives
|
||||
# only as ``__cause__``, never as the raised exception — so the
|
||||
# catch keys on ConfigEntryNotReady, NOT on the typed API
|
||||
# error. Everything else (a platform-forward failure,
|
||||
# cancellation) still unwinds through the except block below.
|
||||
try:
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
except ConfigEntryNotReady:
|
||||
pass
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
except BaseException:
|
||||
# A failure anywhere after the database is open must not leak the
|
||||
# listeners already registered against that (now-closed) database:
|
||||
# unwind EVERY remover we acquired before the failure, keeping the
|
||||
# original error as the one raised. Removal is best-effort — a
|
||||
# failing remover must not mask the setup error that triggered it.
|
||||
for remove_listener in (
|
||||
remove_time_change_listener,
|
||||
remove_update_listener,
|
||||
):
|
||||
if remove_listener is None:
|
||||
continue
|
||||
# A failure anywhere after the listener was registered must not
|
||||
# leak it (or the SSE task): unwind EVERYTHING acquired before
|
||||
# the failure, keeping the original error as the one raised.
|
||||
# Removal is best-effort — a failing step must not mask the
|
||||
# setup error that triggered it.
|
||||
if event_stream is not None:
|
||||
try:
|
||||
result = remove_listener()
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
await event_stream.stop()
|
||||
except BaseException:
|
||||
pass
|
||||
if remove_update_listener is not None:
|
||||
try:
|
||||
remove_update_listener()
|
||||
except BaseException:
|
||||
pass
|
||||
# A coordinator created before the failure holds entity
|
||||
# listeners pointed at the database being closed below —
|
||||
# shut it down FIRST so no scheduled refresh can ever run
|
||||
# against a closed connection.
|
||||
# listeners pointed at the never-registered runtime — shut it
|
||||
# down so no scheduled refresh outlives the failed setup.
|
||||
if coordinator is not None:
|
||||
try:
|
||||
await coordinator.async_shutdown()
|
||||
@@ -545,13 +199,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
pass
|
||||
# A platform-forward failure reached this unwind AFTER
|
||||
# runtime_data was assigned: clear it so no later unload path
|
||||
# closes the already-closed database through the stale record.
|
||||
# re-invokes the already-consumed update-listener remover
|
||||
# through the stale record.
|
||||
if getattr(entry, "runtime_data", None) is not None:
|
||||
entry.runtime_data = None
|
||||
await database.close()
|
||||
# The runtime record was ALSO registered in hass.data before
|
||||
# the first refresh: remove it here on ANY failure, or a later
|
||||
# unload picks up the stale, already-torn-down record
|
||||
# (hass.data wins over entry.runtime_data there) and
|
||||
# re-invokes the already-consumed update-listener remover.
|
||||
hass.data.get(DOMAIN, {}).pop(entry.entry_id, None)
|
||||
raise
|
||||
assert remove_update_listener is not None
|
||||
assert remove_time_change_listener is not None
|
||||
# The runtime record was already registered before the first
|
||||
# refresh; re-assert the (unchanged) record so the return path
|
||||
# stays readable as one registration site.
|
||||
hass.data[DOMAIN][entry.entry_id] = runtime_data
|
||||
return True
|
||||
|
||||
@@ -566,58 +227,50 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
if runtime_data is None:
|
||||
runtime_data = getattr(entry, "runtime_data", None)
|
||||
# Platforms unload FIRST (mirroring HA conventions): entity
|
||||
# listeners must not outlive the database and coordinator the
|
||||
# teardown below closes.
|
||||
# listeners must not outlive the coordinator the teardown below
|
||||
# shuts down.
|
||||
platforms_unloaded = await hass.config_entries.async_unload_platforms(
|
||||
entry, PLATFORMS
|
||||
)
|
||||
if platforms_unloaded is False and runtime_data is not None:
|
||||
# Home Assistant KEPT one or more platforms loaded — their
|
||||
# entities still live and must keep their database. Restore
|
||||
# entities still live and must keep their coordinator. Restore
|
||||
# the runtime record popped above and report failure so HA
|
||||
# retries the unload later instead of leaving live entities
|
||||
# backed by a closed database.
|
||||
# backed by a torn-down coordinator.
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = runtime_data
|
||||
return False
|
||||
unload_error: BaseException | None = None
|
||||
if runtime_data is not None:
|
||||
remove_update_listener = getattr(runtime_data, "remove_update_listener", None)
|
||||
remove_time_change_listener = getattr(
|
||||
runtime_data, "remove_time_change_listener", None
|
||||
# The SSE subscription is stopped FIRST: its task must be gone
|
||||
# (no frame can fire) before anything else is torn down.
|
||||
event_stream = getattr(runtime_data, "event_stream", None)
|
||||
if event_stream is not None:
|
||||
stop_stream = getattr(event_stream, "stop", None)
|
||||
if stop_stream is not None:
|
||||
await stop_stream()
|
||||
remove_update_listener = getattr(
|
||||
runtime_data, "remove_update_listener", None
|
||||
)
|
||||
try:
|
||||
for remove_listener in (
|
||||
remove_update_listener,
|
||||
remove_time_change_listener,
|
||||
):
|
||||
if remove_listener is None:
|
||||
continue
|
||||
try:
|
||||
result = remove_listener()
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
except BaseException as err:
|
||||
# Cancel EVERY listener even when an earlier remover
|
||||
# raises: a skipped remover would leave a daily
|
||||
# callback firing against a closed database after
|
||||
# unload. The first error is retained and re-raised
|
||||
# only after both removers (and the DB close) ran.
|
||||
if unload_error is None:
|
||||
unload_error = err
|
||||
if remove_update_listener is not None:
|
||||
result = remove_update_listener()
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
except BaseException as err:
|
||||
# A failing remover must not skip the coordinator shutdown
|
||||
# below; the first error is retained and re-raised only
|
||||
# after the teardown ran.
|
||||
if unload_error is None:
|
||||
unload_error = err
|
||||
finally:
|
||||
# The coordinator's entity listeners must stop BEFORE the
|
||||
# database closes: a scheduled refresh starting during the
|
||||
# close await would touch a cleared connection. (The
|
||||
# platform unload above already removed the entities; this
|
||||
# clears the coordinator's own remaining listeners.)
|
||||
# The coordinator's entity listeners must stop here: a
|
||||
# scheduled refresh must never outlive the unload.
|
||||
coordinator = getattr(runtime_data, "coordinator", None)
|
||||
if coordinator is not None:
|
||||
shutdown = getattr(coordinator, "async_shutdown", None)
|
||||
if shutdown is not None:
|
||||
await shutdown()
|
||||
database = getattr(runtime_data, "database", None)
|
||||
if database is not None:
|
||||
await database.close()
|
||||
# Domain-global services are torn down only once the FINAL entry
|
||||
# unloads (``hass.data[DOMAIN]`` is now empty), never when a sibling
|
||||
# entry is still loaded.
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.admin_allowlist` for the integration package.
|
||||
|
||||
The admin allowlist business layer lives in
|
||||
:mod:`custom_components.nestquest.core.admin_allowlist` (extracted in
|
||||
task 510c1f78); this module re-exports it so existing
|
||||
``from .admin_allowlist import ...`` references inside the integration
|
||||
keep working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .core.admin_allowlist import * # noqa: F401,F403
|
||||
@@ -30,11 +30,22 @@ swallowed. There are NO retries and the client logs nothing: refresh
|
||||
cadence is the coordinator's concern (the next task), and the panel
|
||||
token is a secret that must never reach a log line or an error
|
||||
message — the error texts name the method, path, and status only.
|
||||
|
||||
The panel plane's SSE event stream (``GET /api/v1/panel/events``) is
|
||||
exposed by :meth:`NestQuestApiClient.stream_events` — an async
|
||||
generator yielding ``(event_type, payload)`` per Server-Sent Events
|
||||
frame (``event: <type>`` / ``data: <json>`` lines separated by a blank
|
||||
line). Opening the stream gets the same one-timeout, typed-error
|
||||
contract as every other call; reading it has NO body-wide timeout (a
|
||||
healthy stream is long-lived — the subscription manager owns
|
||||
reconnection and backoff).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@@ -51,6 +62,7 @@ if TYPE_CHECKING:
|
||||
__all__ = [
|
||||
"COMPLETE_INSTANCE_PATH",
|
||||
"DEFAULT_TIMEOUT_SECONDS",
|
||||
"EVENTS_PATH",
|
||||
"NestQuestApiError",
|
||||
"NestQuestApiClient",
|
||||
"SNAPSHOT_PATH",
|
||||
@@ -62,6 +74,10 @@ __all__ = [
|
||||
#: The panel plane's snapshot route (Feature 16).
|
||||
SNAPSHOT_PATH = "/api/v1/panel/snapshot"
|
||||
|
||||
#: The panel plane's SSE event-stream route (Feature 16): each
|
||||
#: transition arrives as an ``event: <type>`` / ``data: <json>`` frame.
|
||||
EVENTS_PATH = "/api/v1/panel/events"
|
||||
|
||||
#: The panel plane's completion route template (Feature 16); the
|
||||
#: ``{instance_id}`` placeholder is filled per call.
|
||||
COMPLETE_INSTANCE_PATH = "/api/v1/panel/instances/{instance_id}/complete"
|
||||
@@ -230,6 +246,121 @@ class NestQuestApiClient:
|
||||
"POST", path, json_body={"actor_child_id": actor_child_id}
|
||||
)
|
||||
|
||||
async def stream_events(
|
||||
self,
|
||||
) -> AsyncIterator[tuple[str, dict[str, Any]]]:
|
||||
"""Yield ``(event_type, payload)`` for every panel SSE frame.
|
||||
|
||||
Opens ``GET /api/v1/panel/events`` with the Bearer credential
|
||||
and parses the Server-Sent Events framing the API service
|
||||
emits: ``event: <type>`` and ``data: <json>`` lines closed by a
|
||||
blank line (one frame per transition event; a frame's ``data``
|
||||
may span several ``data:`` lines, joined with newlines — the
|
||||
standard SSE rule). Line splits across chunk boundaries are
|
||||
buffered, so frames are parsed correctly whatever chunking the
|
||||
transport produces.
|
||||
|
||||
Opening the stream carries the SAME one-timeout, typed-error
|
||||
contract as every other call: a non-2xx response raises
|
||||
:class:`NestQuestApiError` with its status and a transport
|
||||
failure (connection refused, DNS, the open timing out) raises
|
||||
one with ``status=None`` and the original error chained. The
|
||||
reading loop itself has NO timeout — a healthy stream is
|
||||
long-lived — but any failure raised from the transport
|
||||
mid-stream raises typed as well, so the consumer can reconnect.
|
||||
A frame whose ``data`` is not valid JSON is a protocol failure
|
||||
and raises typed, carrying the response status it arrived
|
||||
with. Cancellation is never converted: closing the generator
|
||||
(``aclose``, i.e. the consumer's task being cancelled) stops
|
||||
cleanly and closes the underlying response in every path.
|
||||
"""
|
||||
url = f"{self._base_url}{EVENTS_PATH}"
|
||||
headers = {"Authorization": f"Bearer {self._panel_token}"}
|
||||
# Opening the response gets ONE request timeout, exactly like
|
||||
# ``_request_json``; the read loop below runs WITHOUT one — a
|
||||
# body-wide timeout would kill a healthy long-lived stream.
|
||||
# The response context manager is entered manually (not via
|
||||
# ``async with``) because the block must span the yields below;
|
||||
# the ``finally`` closes it on EVERY exit path.
|
||||
try:
|
||||
async with asyncio.timeout(self._timeout_seconds):
|
||||
stream_cm = self._session.request(
|
||||
"GET", url, headers=headers, json=None
|
||||
)
|
||||
response = await stream_cm.__aenter__()
|
||||
except asyncio.CancelledError:
|
||||
# Cancellation is never converted (see ``_request_json``).
|
||||
raise
|
||||
except NestQuestApiError:
|
||||
raise
|
||||
except Exception as err:
|
||||
raise NestQuestApiError(
|
||||
f"NestQuest API GET {EVENTS_PATH} transport failure: {err}",
|
||||
status=None,
|
||||
) from err
|
||||
|
||||
status = response.status
|
||||
if not 200 <= status < 300:
|
||||
await self._close_stream(stream_cm)
|
||||
raise NestQuestApiError(
|
||||
f"NestQuest API GET {EVENTS_PATH} failed with HTTP {status}",
|
||||
status=status,
|
||||
)
|
||||
|
||||
try:
|
||||
event_type: str | None = None
|
||||
data_lines: list[str] = []
|
||||
buffer = ""
|
||||
async for chunk in response:
|
||||
if isinstance(chunk, (bytes, bytearray)):
|
||||
chunk = chunk.decode("utf-8")
|
||||
buffer += chunk
|
||||
while "\n" in buffer:
|
||||
line, buffer = buffer.split("\n", 1)
|
||||
if line.startswith("event:"):
|
||||
event_type = line[len("event:"):].strip()
|
||||
elif line.startswith("data:"):
|
||||
data_lines.append(line[len("data:"):].strip())
|
||||
elif not line and event_type is not None:
|
||||
# A blank line ends the frame. A trailing
|
||||
# incomplete frame (stream ends without its
|
||||
# closing blank line) is discarded, per the
|
||||
# SSE spec's treatment of incomplete events.
|
||||
try:
|
||||
payload = json.loads("\n".join(data_lines))
|
||||
except ValueError as err:
|
||||
raise NestQuestApiError(
|
||||
f"NestQuest API GET {EVENTS_PATH} "
|
||||
f"delivered a malformed SSE frame: {err}",
|
||||
status=status,
|
||||
) from err
|
||||
yield event_type, payload
|
||||
event_type = None
|
||||
data_lines = []
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except NestQuestApiError:
|
||||
raise
|
||||
except Exception as err:
|
||||
raise NestQuestApiError(
|
||||
f"NestQuest API GET {EVENTS_PATH} transport failure: {err}",
|
||||
status=None,
|
||||
) from err
|
||||
finally:
|
||||
await self._close_stream(stream_cm)
|
||||
|
||||
@staticmethod
|
||||
async def _close_stream(stream_cm: Any) -> None:
|
||||
"""Close an opened streaming response context manager.
|
||||
|
||||
Works for aiohttp's response context manager (whose
|
||||
``__aexit__`` is a coroutine) and for test stubs that return
|
||||
either a coroutine or a plain value.
|
||||
"""
|
||||
result = stream_cm.__aexit__(None, None, None)
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
|
||||
async def _request_json(
|
||||
self,
|
||||
method: str,
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.children` for the integration package.
|
||||
|
||||
The children registry business layer lives in
|
||||
:mod:`custom_components.nestquest.core.children` (extracted in
|
||||
task 510c1f78); this module re-exports it so existing
|
||||
``from .children import ...`` references inside the integration keep
|
||||
working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .core.children import * # noqa: F401,F403
|
||||
@@ -1,11 +0,0 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.completion` for the integration package.
|
||||
|
||||
The completion business layer lives in
|
||||
:mod:`custom_components.nestquest.core.completion` (extracted in
|
||||
task 510c1f78); this module re-exports it so existing
|
||||
``from .completion import ...`` references inside the integration keep
|
||||
working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .core.completion import * # noqa: F401,F403
|
||||
@@ -7,7 +7,7 @@ from homeassistant import config_entries
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
|
||||
from .const import CONF_ADMIN_USER_IDS, DOMAIN
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
@@ -30,11 +30,11 @@ class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
) -> FlowResult:
|
||||
"""Handle the user step of the config flow.
|
||||
|
||||
The HA user completing the flow is persisted into the entry
|
||||
data as the initial admin allowlist copy: real HA does not
|
||||
guarantee the flow context survives to setup, and entry data is
|
||||
the persisted record first setup seeds from — the owner is
|
||||
never locked out.
|
||||
The integration needs no configuration to start (the API
|
||||
connection is set in the options flow), so the user input is
|
||||
stored as-is. The former admin-allowlist copy is gone: with
|
||||
the database removed the allowlist lives in the API service's
|
||||
own store, not on the entry.
|
||||
"""
|
||||
await self.async_set_unique_id(DOMAIN, raise_on_progress=False)
|
||||
if self._async_in_progress() or self._async_current_entries():
|
||||
@@ -43,12 +43,4 @@ class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
if user_input is None:
|
||||
return self.async_show_form(step_id="user")
|
||||
|
||||
data = dict(user_input)
|
||||
flow_user_id = (
|
||||
self.context.get("user_id")
|
||||
if isinstance(self.context, dict)
|
||||
else None
|
||||
)
|
||||
if flow_user_id:
|
||||
data[CONF_ADMIN_USER_IDS] = [flow_user_id]
|
||||
return self.async_create_entry(title="NestQuest", data=data)
|
||||
return self.async_create_entry(title="NestQuest", data=dict(user_input))
|
||||
@@ -1,4 +1,4 @@
|
||||
"""The shared NestQuest DataUpdateCoordinator (Feature 10).
|
||||
"""The shared NestQuest DataUpdateCoordinator (Feature 10, Feature 18).
|
||||
|
||||
ONE refresh pass builds the whole entity snapshot: active children,
|
||||
today's instances with their derived states (open / done / missed via
|
||||
@@ -7,54 +7,81 @@ per-child rollup counts. Every Feature 10 entity reads
|
||||
``coordinator.data`` — no entity queries the database itself, so one
|
||||
refresh updates the whole board coherently.
|
||||
|
||||
"Today" and "now" are HA-local (``hass.config.time_zone``), never the
|
||||
host clock and never UTC, matching the materialization and completion
|
||||
layers.
|
||||
Since Feature 18 the snapshot no longer comes from the local database:
|
||||
the coordinator polls the NestQuest API service's panel plane —
|
||||
``GET /api/v1/panel/snapshot`` through
|
||||
:class:`~.api_client.NestQuestApiClient` on its update interval — and
|
||||
RECONSTRUCTS the same core dataclasses the local builder produced
|
||||
(:class:`~.core.snapshot.NestQuestSnapshot`,
|
||||
:class:`~.core.snapshot.ChildDaySnapshot`,
|
||||
:class:`~.core.snapshot.QuestInstanceView`). ``sensor.py`` and
|
||||
``binary_sensor.py`` read ``coordinator.data`` and
|
||||
:func:`~.core.snapshot.instance_payload` exactly as before, so every
|
||||
entity_id and attribute payload shape is unchanged: the panel cards and
|
||||
the four blueprints keep working untouched.
|
||||
|
||||
The snapshot assembly itself lives in the Home-Assistant-free
|
||||
:mod:`custom_components.nestquest.core.snapshot` module
|
||||
(:func:`~core.snapshot.build_snapshot`); this coordinator is now a thin
|
||||
HA wrapper that resolves the local timezone and reads the settings handed
|
||||
into its constructor, then delegates to that builder, passing the SAME
|
||||
HA-local ``now`` it resolved in one clock read. The API service's panel
|
||||
snapshot route shares the same builder, so both consumers render ONE
|
||||
snapshot shape.
|
||||
One loss is deliberate and documented (D-009 revisited): the panel
|
||||
payload OMITS missed instances, so a snapshot rebuilt from the API can
|
||||
no longer carry them — ``admin_instances`` (``include_missed=True``)
|
||||
now equals ``instances`` (they can hold only open and completed rows).
|
||||
The admin surface for missed quests is the PWA, which reads the API
|
||||
service directly; the integration's entities never needed missed rows
|
||||
for their states or counts (the counts arrive precomputed).
|
||||
|
||||
Presence is resolved through the SAME engine the materializer uses
|
||||
(:class:`~.presence.PresenceEngine`): schedules and overrides are read
|
||||
with the public DAO methods and the engine's anchor-date arithmetic
|
||||
answers ``is this child at this house today`` (D-004 — never ISO week
|
||||
parity).
|
||||
Failure handling: constructing the client fails fast (ValueError) when
|
||||
the entry has no panel token configured, and a fetch failure raises the
|
||||
typed :class:`~.api_client.NestQuestApiError`. Neither crashes
|
||||
integration setup: the constructor logs a clear message and hands back
|
||||
a client-shaped stub whose refresh raises the same typed error, and
|
||||
``_async_update_data`` converts every typed error into
|
||||
:class:`~homeassistant.helpers.update_coordinator.UpdateFailed` — the
|
||||
exception a HA ``DataUpdateCoordinator`` records as a failed pass
|
||||
(``last_update_success = False``, ``data = None``), so the entities
|
||||
come up unavailable. The typed error is never swallowed silently: it
|
||||
is logged with its message and chained into the UpdateFailed. (The
|
||||
conversion matters at setup: HA's real
|
||||
``async_config_entry_first_refresh`` wraps any failed pass in
|
||||
``ConfigEntryNotReady``, so a setup that keys on the original
|
||||
exception type would never match it.)
|
||||
|
||||
``cycle_day`` reports where today falls in the custody cycle of the
|
||||
first active child (display order) that HAS a schedule — 1-based day
|
||||
``N`` of ``cycle_length_weeks * 7``, computed from that schedule's
|
||||
anchor date. ``0`` means no active child has a schedule at all, so
|
||||
the admin header degrades to a harmless zero instead of guessing.
|
||||
Last-good cache and staleness: every successful pass refreshes a cache
|
||||
of the rebuilt snapshot stamped with the :func:`time.monotonic`
|
||||
instant of the success. A typed failure is checked against that stamp
|
||||
BEFORE the pass is declared failed: within the configured staleness
|
||||
threshold (``CONF_SNAPSHOT_STALENESS``, seconds, default three update
|
||||
intervals) the pass SUCCEEDS with the cached snapshot — the panel
|
||||
keeps rendering last-good data and the entities stay available — and
|
||||
the outage is logged as a warning. Entities go unavailable only once
|
||||
the cache is older than the threshold (or before the first success);
|
||||
a later successful fetch refreshes the cache and the entities recover
|
||||
on their own, without a config-entry reload.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
DataUpdateCoordinator,
|
||||
UpdateFailed,
|
||||
)
|
||||
|
||||
from .api_client import NestQuestApiClient, NestQuestApiError, resolve_api_config
|
||||
from .const import (
|
||||
CONF_UPDATE_INTERVAL,
|
||||
DEFAULT_SNAPSHOT_STALENESS,
|
||||
DEFAULT_UPDATE_INTERVAL,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
MIN_UPDATE_INTERVAL,
|
||||
)
|
||||
from .core.db import NestQuestDatabase
|
||||
from .core.settings import NestQuestSettings
|
||||
from .core.snapshot import (
|
||||
ChildDaySnapshot,
|
||||
NestQuestSnapshot,
|
||||
QuestInstanceView,
|
||||
build_snapshot,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -65,9 +92,131 @@ __all__ = [
|
||||
]
|
||||
|
||||
|
||||
def snapshot_from_api_payload(payload: dict[str, Any]) -> NestQuestSnapshot:
|
||||
"""Reconstruct the core snapshot dataclasses from the API's JSON.
|
||||
|
||||
The panel snapshot route (``api/routes_panel.py``) returns today's
|
||||
household as plain JSON — ``today_iso``, ``cycle_day``, and the
|
||||
per-child list with precomputed counts and the instances in the
|
||||
documented payload shape. This function maps that shape BACK onto
|
||||
the dataclasses ``sensor.py`` / ``binary_sensor.py`` read, field by
|
||||
field:
|
||||
|
||||
- the API's ``state`` ``"completed"`` maps back to the core view's
|
||||
``"done"`` (so :func:`~.core.snapshot.instance_payload` renders
|
||||
``"completed"`` again — the round-trip is identity for open and
|
||||
completed rows);
|
||||
- ``overdue``, ``on_time`` (→ ``was_on_time``), ``completed_at``,
|
||||
``window``, ``due_time``, ``icon`` and the per-child counts pass
|
||||
through verbatim;
|
||||
- ``present`` and ``next_present`` pass through verbatim (the API
|
||||
resolved them through the same presence engine server-side);
|
||||
- the instance list is rebuilt IN ORDER, so the panel payload the
|
||||
sensors emit matches the route's order byte for byte.
|
||||
|
||||
Because the route omits missed instances (D-009), a snapshot built
|
||||
here cannot contain any ``missed`` view — see the module docstring.
|
||||
"""
|
||||
children: list[ChildDaySnapshot] = []
|
||||
for child in payload.get("children", []):
|
||||
views = tuple(
|
||||
QuestInstanceView(
|
||||
instance_id=instance["id"],
|
||||
definition_id=instance["definition_id"],
|
||||
child_id=instance["child_id"],
|
||||
title=instance["title"],
|
||||
icon=instance.get("icon"),
|
||||
window=instance["window"],
|
||||
# The API payload does not carry due_date: every row
|
||||
# is TODAY's instance (the route queries
|
||||
# [today, today]), so the view's due_date is the
|
||||
# snapshot's today_iso — the same value the local
|
||||
# builder stamped on every today row.
|
||||
due_date=payload["today_iso"],
|
||||
due_time=instance.get("due_time"),
|
||||
state=(
|
||||
"done" if instance["state"] == "completed" else
|
||||
instance["state"]
|
||||
),
|
||||
overdue=instance["overdue"],
|
||||
completed_at=instance.get("completed_at"),
|
||||
was_on_time=instance.get("on_time"),
|
||||
)
|
||||
for instance in child.get("instances", [])
|
||||
)
|
||||
children.append(
|
||||
ChildDaySnapshot(
|
||||
child_id=child["child_id"],
|
||||
child_name=child["child_name"],
|
||||
instances=views,
|
||||
present=child["present"],
|
||||
due_today=child["due_today"],
|
||||
completed_today=child["completed_today"],
|
||||
remaining_today=child["remaining_today"],
|
||||
completion_pct=child["completion_pct"],
|
||||
next_present=child.get("next_present"),
|
||||
)
|
||||
)
|
||||
return NestQuestSnapshot(
|
||||
today_iso=payload["today_iso"],
|
||||
children=tuple(children),
|
||||
cycle_day=payload["cycle_day"],
|
||||
)
|
||||
|
||||
|
||||
class _UnconfiguredApiClient:
|
||||
"""Client-shaped stand-in used when the entry has no panel token.
|
||||
|
||||
Constructing a real :class:`~.api_client.NestQuestApiClient` with an
|
||||
empty token raises ValueError by design (fail fast on a client that
|
||||
could only ever 401). An entry that simply has not been configured
|
||||
yet must not crash integration setup, so the coordinator swaps in
|
||||
this stub: every refresh raises the SAME typed
|
||||
:class:`~.api_client.NestQuestApiError` a transport failure would,
|
||||
carrying a clear "API not configured" message;
|
||||
:meth:`NestQuestCoordinator._async_update_data` converts it to
|
||||
:class:`~homeassistant.helpers.update_coordinator.UpdateFailed`, so
|
||||
the coordinator marks the entities unavailable and the HA standard
|
||||
retry cycle applies. The error is logged, never silent.
|
||||
"""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
async def get_snapshot(self) -> dict[str, Any]:
|
||||
"""Always raise: there is no configured API to fetch from."""
|
||||
raise NestQuestApiError(
|
||||
"NestQuest API not configured: set the panel token in the "
|
||||
"integration options"
|
||||
)
|
||||
|
||||
|
||||
def _monotonic() -> float:
|
||||
"""The monotonic clock the staleness math reads.
|
||||
|
||||
A one-line indirection over :func:`time.monotonic` so the staleness
|
||||
tests can drive the clock deterministically (patch THIS function,
|
||||
never the stdlib clock). Monotonic is deliberate: wall-clock jumps
|
||||
(NTP corrections, suspends) must not shorten or extend the window.
|
||||
"""
|
||||
return time.monotonic()
|
||||
|
||||
|
||||
class NestQuestCoordinator(DataUpdateCoordinator):
|
||||
"""Shared refresh cycle for every NestQuest entity.
|
||||
|
||||
One refresh pass = one ``GET /api/v1/panel/snapshot`` through the
|
||||
API client, reconstructed into the core snapshot dataclasses every
|
||||
entity reads. A failed pass (unconfigured API, transport failure,
|
||||
non-2xx response) raises the typed error out of the client;
|
||||
``_async_update_data`` converts it to
|
||||
:class:`~homeassistant.helpers.update_coordinator.UpdateFailed`, the
|
||||
exception HA's coordinator machinery records as a failed pass: the
|
||||
coordinator's ``data`` keeps its last good value while
|
||||
``last_update_success`` goes False — HA marks the entities
|
||||
unavailable until a later pass succeeds. An unexpected
|
||||
non-typed error still propagates out of ``_async_update_data``
|
||||
and is logged (with its traceback) by HA's machinery, the same way.
|
||||
|
||||
Forced refreshes (the service paths pushing an immediate update
|
||||
after a completion) are SERIALIZED per entry: overlapping
|
||||
complete/uncomplete calls must not interleave their snapshot
|
||||
@@ -75,6 +224,15 @@ class NestQuestCoordinator(DataUpdateCoordinator):
|
||||
newer one and leave entities wrong until the next poll. Real
|
||||
HA's coordinator already serializes internally; the lock keeps
|
||||
the same guarantee on the stand-in and costs nothing upstream.
|
||||
|
||||
Last-good cache: a successful pass stamps the rebuilt snapshot
|
||||
with the :func:`~.coordinator._monotonic` instant of the success;
|
||||
a typed failure within the configured staleness threshold
|
||||
(``staleness_seconds``, from ``CONF_SNAPSHOT_STALENESS``) serves
|
||||
that cached snapshot instead of failing the pass, so a short API
|
||||
outage keeps the board up. Past the threshold — or before the
|
||||
first success — the pass fails as above, and a later successful
|
||||
pass refreshes the cache and recovers the entities on its own.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -82,9 +240,9 @@ class NestQuestCoordinator(DataUpdateCoordinator):
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
entry_id: str,
|
||||
database: NestQuestDatabase,
|
||||
settings: NestQuestSettings,
|
||||
api_client: NestQuestApiClient,
|
||||
update_interval_seconds: int | None = None,
|
||||
staleness_seconds: int | None = None,
|
||||
) -> None:
|
||||
interval = update_interval_seconds
|
||||
if (
|
||||
@@ -93,6 +251,19 @@ class NestQuestCoordinator(DataUpdateCoordinator):
|
||||
or interval < MIN_UPDATE_INTERVAL
|
||||
):
|
||||
interval = DEFAULT_UPDATE_INTERVAL
|
||||
staleness = staleness_seconds
|
||||
if (
|
||||
not isinstance(staleness, int)
|
||||
or isinstance(staleness, bool)
|
||||
or staleness < interval
|
||||
):
|
||||
# A missing or nonsensical threshold falls back to the
|
||||
# shipped default; a configured-but-too-short one (below
|
||||
# even one poll interval) falls back to whichever of
|
||||
# default/interval spans at least one failed poll, so the
|
||||
# cache is never dead on arrival. (The options flow
|
||||
# validates the same rule at the form.)
|
||||
staleness = max(DEFAULT_SNAPSHOT_STALENESS, interval)
|
||||
super().__init__(
|
||||
hass,
|
||||
LOGGER,
|
||||
@@ -100,9 +271,16 @@ class NestQuestCoordinator(DataUpdateCoordinator):
|
||||
update_interval=datetime.timedelta(seconds=interval),
|
||||
)
|
||||
self.entry_id = entry_id
|
||||
self.database = database
|
||||
self.settings = settings
|
||||
self.api_client = api_client
|
||||
self._refresh_lock = asyncio.Lock()
|
||||
#: The configured last-good window, in SECONDS (const.py:
|
||||
#: CONF_SNAPSHOT_STALENESS / DEFAULT_SNAPSHOT_STALENESS).
|
||||
self._staleness_seconds = staleness
|
||||
#: The last-good cache: the snapshot from the most recent
|
||||
#: SUCCESSFUL fetch and the :func:`time.monotonic` instant it
|
||||
#: landed. Both stay None until the first success.
|
||||
self._last_good_snapshot: NestQuestSnapshot | None = None
|
||||
self._last_good_monotonic: float | None = None
|
||||
|
||||
async def async_refresh(self) -> None:
|
||||
"""One refresh at a time; a queued refresh runs AFTER the
|
||||
@@ -111,12 +289,98 @@ class NestQuestCoordinator(DataUpdateCoordinator):
|
||||
async with self._refresh_lock:
|
||||
await super().async_refresh()
|
||||
|
||||
def _local_now(self) -> datetime.datetime:
|
||||
time_zone = ZoneInfo(self.hass.config.time_zone)
|
||||
return datetime.datetime.now(time_zone)
|
||||
|
||||
async def _async_update_data(self) -> NestQuestSnapshot:
|
||||
now = self._local_now()
|
||||
return await build_snapshot(
|
||||
self.database, self.settings, now
|
||||
"""One refresh pass: fetch the panel snapshot and rebuild it.
|
||||
|
||||
A successful pass rebuilds the snapshot and refreshes the
|
||||
last-good cache (the snapshot plus the
|
||||
:func:`time.monotonic` instant of the success).
|
||||
|
||||
A typed API failure (unconfigured token stub, transport
|
||||
failure, non-2xx response) is first checked against that
|
||||
stamp: WITHIN the configured staleness threshold (elapsed time
|
||||
since the last successful fetch, inclusive of the threshold
|
||||
itself) the pass SUCCEEDS with the cached snapshot — the panel
|
||||
keeps rendering last-good data, the entities stay available —
|
||||
and the outage is logged as a clear warning (never silent).
|
||||
With no cache yet, or past the threshold, the typed error is
|
||||
converted to
|
||||
:class:`~homeassistant.helpers.update_coordinator.UpdateFailed`
|
||||
— the exception HA's coordinator machinery records as a failed
|
||||
pass (``last_update_success = False``, ``data`` unchanged) so
|
||||
entities go unavailable and the setup-time first refresh
|
||||
arrives as ``ConfigEntryNotReady`` — with the typed error
|
||||
logged and chained. Anything else still propagates.
|
||||
"""
|
||||
try:
|
||||
payload = await self.api_client.get_snapshot()
|
||||
except NestQuestApiError as err:
|
||||
now = _monotonic()
|
||||
cached = self._last_good_snapshot
|
||||
fetched_at = self._last_good_monotonic
|
||||
if (
|
||||
cached is not None
|
||||
and fetched_at is not None
|
||||
and now - fetched_at <= self._staleness_seconds
|
||||
):
|
||||
LOGGER.warning(
|
||||
"NestQuest API refresh failed (%s); serving the "
|
||||
"last-good snapshot fetched %.0f s ago, within the "
|
||||
"%d s staleness threshold",
|
||||
err,
|
||||
now - fetched_at,
|
||||
self._staleness_seconds,
|
||||
)
|
||||
return cached
|
||||
LOGGER.error(
|
||||
"NestQuest coordinator refresh failed: %s", err
|
||||
)
|
||||
raise UpdateFailed(str(err)) from err
|
||||
snapshot = snapshot_from_api_payload(payload)
|
||||
self._last_good_snapshot = snapshot
|
||||
self._last_good_monotonic = _monotonic()
|
||||
return snapshot
|
||||
|
||||
|
||||
def coordinator_client_from_entry(
|
||||
hass: HomeAssistant, entry: Any, *, session_factory: Any = None
|
||||
) -> Any:
|
||||
"""Build the refresh client for a config entry, tolerating no token.
|
||||
|
||||
Reads ``(base_url, panel_token)`` off the entry and constructs the
|
||||
real client. An entry with no panel token configured (the shipped
|
||||
default is the empty string) makes the constructor raise ValueError
|
||||
by design; integration setup must not crash on an unconfigured
|
||||
entry, so this factory swaps in the typed-error stub instead and
|
||||
logs ONE clear message. The ValueError itself is never re-raised
|
||||
past this point — it is converted, with the reason logged.
|
||||
|
||||
``session_factory`` overrides where the HTTP session comes from
|
||||
(production: HA's shared aiohttp session via
|
||||
:func:`~.api_client.client_from_entry`); it exists so tests can
|
||||
inject a stub transport without importing aiohttp — the mock-only
|
||||
test harness has no ``homeassistant.helpers.aiohttp_client``.
|
||||
"""
|
||||
base_url, panel_token = resolve_api_config(entry)
|
||||
if not isinstance(panel_token, str) or not panel_token.strip():
|
||||
LOGGER.warning(
|
||||
"NestQuest API panel token is not configured for entry %s: "
|
||||
"entities will stay unavailable until a token is set in the "
|
||||
"integration options",
|
||||
entry.entry_id,
|
||||
)
|
||||
return _UnconfiguredApiClient()
|
||||
if session_factory is not None:
|
||||
return NestQuestApiClient(
|
||||
base_url, panel_token, session_factory(hass)
|
||||
)
|
||||
from custom_components.nestquest.api_client import client_from_entry
|
||||
|
||||
return client_from_entry(hass, entry)
|
||||
|
||||
|
||||
#: Test seam: entry_id → client, consulted by the conftest's client
|
||||
#: factory BEFORE the production path. Always empty in production
|
||||
#: (nothing in the integration writes to it); tests register scripted
|
||||
#: clients through it and clear it between tests.
|
||||
_COORDINATOR_CLIENT_OVERRIDES: dict[str, Any] = {}
|
||||
|
||||
@@ -20,44 +20,41 @@ TIME_PATTERN = re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d")
|
||||
PLATFORMS: list[str] = ["sensor", "binary_sensor"]
|
||||
|
||||
# Home Assistant bus events (design/ENTITIES-AND-SERVICES.md §3). The
|
||||
# panel and admin cards listen for these; Feature 10 fires completed,
|
||||
# uncompleted, and child_day_complete from the service paths, and
|
||||
# Feature 11's nightly sweep fires quest_missed.
|
||||
# panel and admin cards listen for these; since Feature 18 the
|
||||
# completed/uncompleted/day-complete transitions are published by the
|
||||
# API service on its SSE stream and re-fired on the bus by the
|
||||
# integration's subscription, and Feature 11's nightly sweep fires
|
||||
# quest_missed.
|
||||
EVENT_QUEST_COMPLETED = "nestquest_quest_completed"
|
||||
EVENT_QUEST_UNCOMPLETED = "nestquest_quest_uncompleted"
|
||||
EVENT_QUEST_MISSED = "nestquest_quest_missed"
|
||||
EVENT_CHILD_DAY_COMPLETE = "nestquest_child_day_complete"
|
||||
|
||||
#: Canonical domain-global service names (design/ENTITIES-AND-SERVICES.md §2
|
||||
#: plus the existing Feature 07 regenerate service).
|
||||
#: Canonical domain-global service name (design/ENTITIES-AND-SERVICES.md §2).
|
||||
#: Since Feature 18 only the panel completion service remains: the admin
|
||||
#: surface is the PWA and the API service's admin routes, so the former
|
||||
#: HA admin services (uncomplete, quest-definition and presence
|
||||
#: management, CSV export, child management, regenerate) are gone.
|
||||
SERVICE_COMPLETE_QUEST = "complete_quest"
|
||||
SERVICE_UNCOMPLETE_QUEST = "uncomplete_quest"
|
||||
SERVICE_CREATE_QUEST_DEFINITION = "create_quest_definition"
|
||||
SERVICE_UPDATE_QUEST_DEFINITION = "update_quest_definition"
|
||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE = "set_quest_definition_active"
|
||||
SERVICE_SET_PRESENCE_PATTERN = "set_presence_pattern"
|
||||
SERVICE_CREATE_PRESENCE_OVERRIDE = "create_presence_override"
|
||||
SERVICE_DELETE_PRESENCE_OVERRIDE = "delete_presence_override"
|
||||
SERVICE_EXPORT_HISTORY_CSV = "export_history_csv"
|
||||
SERVICE_MANAGE_CHILD = "manage_child"
|
||||
SERVICE_REGENERATE = "regenerate"
|
||||
|
||||
DOMAIN_SERVICES: tuple[str, ...] = (
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
SERVICE_UNCOMPLETE_QUEST,
|
||||
SERVICE_CREATE_QUEST_DEFINITION,
|
||||
SERVICE_UPDATE_QUEST_DEFINITION,
|
||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
||||
SERVICE_SET_PRESENCE_PATTERN,
|
||||
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
||||
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
||||
SERVICE_EXPORT_HISTORY_CSV,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
SERVICE_REGENERATE,
|
||||
)
|
||||
|
||||
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
|
||||
@@ -81,6 +78,21 @@ CONF_UPDATE_INTERVAL = "update_interval"
|
||||
DEFAULT_UPDATE_INTERVAL = 300
|
||||
MIN_UPDATE_INTERVAL = 30
|
||||
|
||||
# Last-good snapshot staleness (Feature 18), in SECONDS: how long the
|
||||
# coordinator keeps SERVING the last good snapshot after the API stops
|
||||
# answering. A failed poll whose elapsed time since the last successful
|
||||
# fetch is within the threshold returns the cached snapshot (the panel
|
||||
# keeps rendering last-good data, entities stay available); past it the
|
||||
# poll fails the HA way and the entities go unavailable. The default is
|
||||
# 3x the default update interval (900 s = 15 minutes): three consecutive
|
||||
# failed five-minute polls before the board is declared stale — enough
|
||||
# to ride out an API service restart, short enough not to render a
|
||||
# days-old board all day. Validation floor: the threshold must be at
|
||||
# least the configured update interval, or the cache could never span
|
||||
# even one failed poll.
|
||||
CONF_SNAPSHOT_STALENESS = "snapshot_staleness"
|
||||
DEFAULT_SNAPSHOT_STALENESS = 3 * DEFAULT_UPDATE_INTERVAL
|
||||
|
||||
# Notification automations (Feature 11). The notify target is a free-text
|
||||
# Home Assistant service name (e.g. "notify.mobile_app_dads_phone") the
|
||||
# household fills in — never a hard-coded personal target; the times are
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,6 +795,7 @@ 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,
|
||||
) -> QuestDefinitionSnapshot:
|
||||
"""Edit a definition's metadata, rule and windows atomically.
|
||||
|
||||
@@ -774,9 +806,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
|
||||
@@ -792,6 +825,8 @@ class QuestDefinitionsDao:
|
||||
does not exist. 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)
|
||||
@@ -809,6 +844,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(
|
||||
@@ -907,6 +945,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 +996,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 +1047,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 +1073,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(
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -191,6 +191,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 +223,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 +243,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 +256,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,6 +285,7 @@ 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,
|
||||
today: datetime.date | None = None,
|
||||
horizon_days: int | None = None,
|
||||
) -> CreatedQuestDefinition:
|
||||
@@ -285,7 +300,9 @@ 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
|
||||
time), matching create's non-empty contract. A provided
|
||||
``skip_on_away`` (a real bool) replaces the stored flag; ``None``
|
||||
leaves it unchanged. Assignment is NOT
|
||||
settable here. Raises ValueError when the definition does not
|
||||
exist or any argument is rejected.
|
||||
|
||||
@@ -331,6 +348,10 @@ async def edit_quest_definition(
|
||||
if windows is not _UNSET:
|
||||
window_specs = _normalize_windows(windows)
|
||||
|
||||
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,6 +361,7 @@ async def edit_quest_definition(
|
||||
icon=icon_value,
|
||||
rule=rule_storage,
|
||||
windows=window_specs,
|
||||
skip_on_away=skip_on_away_value,
|
||||
)
|
||||
except ValueError as error:
|
||||
# The DAO's missing-definition error names only the id; re-raise
|
||||
@@ -558,6 +580,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,
|
||||
|
||||
@@ -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] = [
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
"""Explicit admin-only versus open service registry (Feature 09).
|
||||
|
||||
One mapping, one file: every registered ``nestquest`` service name is
|
||||
either ``open`` or ``admin_only``. The permission decorator (later
|
||||
Feature 09 tasks) reads this table; it does not take per-handler
|
||||
policy arguments.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .const import (
|
||||
DOMAIN_SERVICES,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
||||
SERVICE_CREATE_QUEST_DEFINITION,
|
||||
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
||||
SERVICE_EXPORT_HISTORY_CSV,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
SERVICE_REGENERATE,
|
||||
SERVICE_SET_PRESENCE_PATTERN,
|
||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
||||
SERVICE_UNCOMPLETE_QUEST,
|
||||
SERVICE_UPDATE_QUEST_DEFINITION,
|
||||
)
|
||||
|
||||
OPEN = "open"
|
||||
ADMIN_ONLY = "admin_only"
|
||||
|
||||
SERVICE_POLICY: dict[str, str] = {
|
||||
SERVICE_COMPLETE_QUEST: OPEN,
|
||||
SERVICE_UNCOMPLETE_QUEST: ADMIN_ONLY,
|
||||
SERVICE_CREATE_QUEST_DEFINITION: ADMIN_ONLY,
|
||||
SERVICE_UPDATE_QUEST_DEFINITION: ADMIN_ONLY,
|
||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE: ADMIN_ONLY,
|
||||
SERVICE_SET_PRESENCE_PATTERN: ADMIN_ONLY,
|
||||
SERVICE_CREATE_PRESENCE_OVERRIDE: ADMIN_ONLY,
|
||||
SERVICE_DELETE_PRESENCE_OVERRIDE: ADMIN_ONLY,
|
||||
SERVICE_EXPORT_HISTORY_CSV: ADMIN_ONLY,
|
||||
SERVICE_MANAGE_CHILD: ADMIN_ONLY,
|
||||
SERVICE_REGENERATE: ADMIN_ONLY,
|
||||
}
|
||||
|
||||
if frozenset(SERVICE_POLICY) != frozenset(DOMAIN_SERVICES):
|
||||
raise RuntimeError(
|
||||
"SERVICE_POLICY must name exactly the registered nestquest services, "
|
||||
f"got {sorted(SERVICE_POLICY)} vs {list(DOMAIN_SERVICES)}"
|
||||
)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.dao_children` for the integration package.
|
||||
|
||||
The children/admin DAO lives in
|
||||
:mod:`custom_components.nestquest.core.dao_children` (extracted in
|
||||
task d0b691d8); this module re-exports it so existing
|
||||
``from .dao_children import ...`` references inside the integration keep
|
||||
working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .core.dao_children import * # noqa: F401,F403
|
||||
from .core.dao_children import _CONNECTION_LOCKS, _connection_lock # noqa: F401
|
||||
@@ -1,12 +0,0 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.dao_instances` for the integration package.
|
||||
|
||||
The instances/completion-events DAO lives in
|
||||
:mod:`custom_components.nestquest.core.dao_instances`
|
||||
(extracted in task d0b691d8); this module re-exports it so existing
|
||||
``from .dao_instances import ...`` references inside the integration keep
|
||||
working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .core.dao_instances import * # noqa: F401,F403
|
||||
from .core.dao_instances import _resolve_today # noqa: F401
|
||||
@@ -1,11 +0,0 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.dao_meta` for the integration package.
|
||||
|
||||
The meta-state DAO lives in
|
||||
:mod:`custom_components.nestquest.core.dao_meta` (extracted in task
|
||||
d0b691d8); this module re-exports it so existing
|
||||
``from .dao_meta import ...`` references inside the integration keep
|
||||
working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .core.dao_meta import * # noqa: F401,F403
|
||||
@@ -1,11 +0,0 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.dao_presence` for the integration package.
|
||||
|
||||
The presence DAOs live in
|
||||
:mod:`custom_components.nestquest.core.dao_presence` (extracted in
|
||||
task d0b691d8); this module re-exports them so existing
|
||||
``from .dao_presence import ...`` references inside the integration keep
|
||||
working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .core.dao_presence import * # noqa: F401,F403
|
||||
@@ -1,17 +0,0 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.dao_rules` for the integration package.
|
||||
|
||||
The schedule-rules/quest-definitions DAO lives in
|
||||
:mod:`custom_components.nestquest.core.dao_rules` (extracted in task
|
||||
d0b691d8); this module re-exports it so existing
|
||||
``from .dao_rules import ...`` references inside the integration keep
|
||||
working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .core.dao_rules import * # noqa: F401,F403
|
||||
from .core.dao_rules import ( # noqa: F401
|
||||
_UNSET,
|
||||
_validate_date,
|
||||
_validate_time,
|
||||
_validate_window,
|
||||
)
|
||||
@@ -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
|
||||
@@ -1,31 +0,0 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.db` for the integration package.
|
||||
|
||||
The async SQLite wrapper lives in :mod:`custom_components.nestquest.core.db` (extracted in task
|
||||
d0b691d8) and is Home-Assistant-free: :class:`NestQuestDatabase` takes
|
||||
an executor callable rather than a ``hass`` object. This module
|
||||
re-exports it and provides :func:`make_database`, a thin adapter that
|
||||
constructs a wrapper backed by ``hass.async_add_executor_job``, so the
|
||||
integration constructs the database the same way it always did.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .core.db import ExecutionResult, NestQuestDatabase
|
||||
|
||||
__all__ = ["ExecutionResult", "NestQuestDatabase", "make_database"]
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def make_database(hass: HomeAssistant) -> NestQuestDatabase:
|
||||
"""Construct a :class:`NestQuestDatabase` wired to ``hass``'s executor.
|
||||
|
||||
Mirrors the pre-extraction ``NestQuestDatabase(hass)`` call: the
|
||||
wrapper's executor is ``hass.async_add_executor_job``, so every
|
||||
sqlite3 call runs on the HA executor thread and the event loop
|
||||
never blocks.
|
||||
"""
|
||||
return NestQuestDatabase(hass.async_add_executor_job)
|
||||
@@ -1,79 +0,0 @@
|
||||
"""Re-export / HA wiring of :mod:`custom_components.nestquest.core.events` for the integration package.
|
||||
|
||||
The transition-event payload construction lives in
|
||||
:mod:`custom_components.nestquest.core.events` (extracted in
|
||||
task 510c1f78) and is HA-free: it builds and RETURNS the events. This
|
||||
shim keeps the integration's old ``fire_quest_completed`` /
|
||||
``fire_quest_uncompleted`` API (the complete/uncomplete service
|
||||
handlers call them with ``hass``), reads the HA-local timezone from
|
||||
``hass.config.time_zone`` to derive ``today``, asks the core builder
|
||||
for the events, and fires each on ``hass.bus`` with the documented
|
||||
payloads. The four blueprints keep working with identical payloads.
|
||||
|
||||
The completed event is fired BEFORE the day-complete rule evaluates:
|
||||
``fire_quest_completed`` asks
|
||||
:func:`build_quest_completed_event` for the completed event and fires
|
||||
it immediately, THEN reads the HA-local ``today``, asks
|
||||
:func:`build_child_day_complete_event` for the day-complete event, and
|
||||
fires it (if any) afterwards. This restores the pre-extraction
|
||||
ordering — a failure in the day-complete DB reads or the ZoneInfo
|
||||
lookup cannot suppress the completed event.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from .core.db import NestQuestDatabase
|
||||
from .core.events import (
|
||||
build_child_day_complete_event,
|
||||
build_quest_completed_event,
|
||||
build_quest_uncompleted_events,
|
||||
)
|
||||
|
||||
__all__ = ["fire_quest_completed", "fire_quest_uncompleted"]
|
||||
|
||||
|
||||
async def fire_quest_completed(
|
||||
hass,
|
||||
database: NestQuestDatabase,
|
||||
instance_id: int,
|
||||
*,
|
||||
was_on_time: bool | None,
|
||||
) -> None:
|
||||
"""Fire ``nestquest_quest_completed`` and, when the completion
|
||||
cleared the child's whole day, ``nestquest_child_day_complete``.
|
||||
|
||||
Thin HA wiring over the HA-free
|
||||
:func:`custom_components.nestquest.core.events.build_quest_completed_event`
|
||||
and
|
||||
:func:`build_child_day_complete_event`: the completed event is
|
||||
built and fired FIRST (so a failure in the day-complete DB reads
|
||||
or the HA-local ``today`` lookup cannot suppress it), then the
|
||||
HA-local ``today`` (the day-complete rule's input) is read from
|
||||
``hass.config.time_zone`` and the day-complete builder is asked
|
||||
whether the child's whole day cleared. Identical payloads and
|
||||
day-complete rule to the pre-extraction implementation.
|
||||
"""
|
||||
completed = await build_quest_completed_event(
|
||||
database,
|
||||
instance_id,
|
||||
was_on_time=was_on_time,
|
||||
)
|
||||
hass.bus.async_fire(*completed)
|
||||
|
||||
today = datetime.datetime.now(ZoneInfo(hass.config.time_zone)).date()
|
||||
day_complete = await build_child_day_complete_event(
|
||||
database, completed[1], today=today
|
||||
)
|
||||
if day_complete is not None:
|
||||
hass.bus.async_fire(*day_complete)
|
||||
|
||||
|
||||
async def fire_quest_uncompleted(
|
||||
hass, database: NestQuestDatabase, instance_id: int
|
||||
) -> None:
|
||||
"""Fire ``nestquest_quest_uncompleted`` for an actual reversal."""
|
||||
events = await build_quest_uncompleted_events(database, instance_id)
|
||||
for event_type, payload in events:
|
||||
hass.bus.async_fire(event_type, payload)
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.materialize` for the integration package.
|
||||
|
||||
The materialization walk lives in
|
||||
:mod:`custom_components.nestquest.core.materialize` (extracted in
|
||||
task 510c1f78); this module re-exports it so existing
|
||||
``from .materialize import ...`` references inside the integration keep
|
||||
working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .core.materialize import * # noqa: F401,F403
|
||||
@@ -1,11 +0,0 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.migrations` for the integration package.
|
||||
|
||||
The versioned migration runner lives in
|
||||
:mod:`custom_components.nestquest.core.migrations`
|
||||
(extracted in task d0b691d8); this module re-exports it so existing
|
||||
``from .migrations import ...`` references inside the integration keep
|
||||
working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .core.migrations import * # noqa: F401,F403
|
||||
@@ -1,4 +1,15 @@
|
||||
"""Options flow for NestQuest post-setup settings."""
|
||||
"""Options flow for NestQuest post-setup settings.
|
||||
|
||||
Since the DB removal (Feature 18) the integration is a thin client of
|
||||
the NestQuest API service: the settings that only served the local
|
||||
database (the generation horizon, the day rollover, the notification
|
||||
defaults, the admin allowlist) are gone — the API service owns those in
|
||||
its own settings store, and the automations take their behaviour from
|
||||
blueprint inputs. What remains is what the CLIENT itself consumes: the
|
||||
panel-plane API connection (base URL + token), the shared coordinator's
|
||||
refresh interval, the last-good staleness threshold, and the
|
||||
panel-facing idle timeout.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
@@ -6,66 +17,39 @@ from typing import Any
|
||||
import voluptuous as vol
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .const import (
|
||||
CONF_ADMIN_USER_IDS,
|
||||
CONF_AFTERNOON_REMINDER_ENABLED,
|
||||
CONF_AFTERNOON_REMINDER_TIME,
|
||||
CONF_API_BASE_URL,
|
||||
CONF_CELEBRATION_ENABLED,
|
||||
CONF_DAY_ROLLOVER_TIME,
|
||||
CONF_END_OF_DAY_REPORT_ENABLED,
|
||||
CONF_END_OF_DAY_REPORT_TIME,
|
||||
CONF_HORIZON_DAYS,
|
||||
CONF_MORNING_SUMMARY_ENABLED,
|
||||
CONF_MORNING_SUMMARY_TIME,
|
||||
CONF_NOTIFY_TARGET,
|
||||
CONF_PANEL_IDLE_TIMEOUT,
|
||||
CONF_PANEL_TOKEN,
|
||||
CONF_SNAPSHOT_STALENESS,
|
||||
CONF_UPDATE_INTERVAL,
|
||||
DEFAULT_API_BASE_URL,
|
||||
DEFAULT_AFTERNOON_REMINDER_TIME,
|
||||
DEFAULT_AUTOMATION_ENABLED,
|
||||
DEFAULT_DAY_ROLLOVER_TIME,
|
||||
DEFAULT_END_OF_DAY_REPORT_TIME,
|
||||
DEFAULT_HORIZON_DAYS,
|
||||
DEFAULT_MORNING_SUMMARY_TIME,
|
||||
DEFAULT_PANEL_IDLE_TIMEOUT,
|
||||
DEFAULT_PANEL_TOKEN,
|
||||
DEFAULT_SNAPSHOT_STALENESS,
|
||||
DEFAULT_UPDATE_INTERVAL,
|
||||
DOMAIN,
|
||||
MIN_UPDATE_INTERVAL,
|
||||
TIME_PATTERN,
|
||||
)
|
||||
from .admin_allowlist import list_admin_ids, set_admin_ids
|
||||
from .api_client import is_valid_base_url
|
||||
|
||||
MIN_HORIZON_DAYS = 1
|
||||
MIN_PANEL_IDLE_TIMEOUT = 30
|
||||
|
||||
|
||||
def _parse_time(value: str) -> bool:
|
||||
"""Return True when value is a valid 24-hour HH:MM string."""
|
||||
return TIME_PATTERN.fullmatch(value) is not None
|
||||
def _async_validate(
|
||||
user_input: dict[str, Any],
|
||||
current: dict[str, Any] | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Validate user input and return field-level errors instead of raising.
|
||||
|
||||
|
||||
def _async_validate(user_input: dict[str, Any]) -> dict[str, str]:
|
||||
"""Validate user input and return field-level errors instead of raising."""
|
||||
``current`` carries the stored values (entry options → entry data →
|
||||
defaults) so a raw submit that sets one field but omits a sibling it
|
||||
is validated against (the staleness threshold vs. the update
|
||||
interval) can still check the effective pair; raw submits omitting
|
||||
BOTH keys keep their stored values untouched.
|
||||
"""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
horizon_days = user_input.get(CONF_HORIZON_DAYS)
|
||||
if (
|
||||
isinstance(horizon_days, bool)
|
||||
or not isinstance(horizon_days, int)
|
||||
or horizon_days < MIN_HORIZON_DAYS
|
||||
):
|
||||
errors[CONF_HORIZON_DAYS] = "invalid"
|
||||
|
||||
day_rollover_time = user_input.get(CONF_DAY_ROLLOVER_TIME)
|
||||
if not isinstance(day_rollover_time, str) or not _parse_time(day_rollover_time):
|
||||
errors[CONF_DAY_ROLLOVER_TIME] = "invalid_time"
|
||||
|
||||
panel_idle_timeout = user_input.get(CONF_PANEL_IDLE_TIMEOUT)
|
||||
if (
|
||||
isinstance(panel_idle_timeout, bool)
|
||||
@@ -86,40 +70,32 @@ def _async_validate(user_input: dict[str, Any]) -> dict[str, str]:
|
||||
):
|
||||
errors[CONF_UPDATE_INTERVAL] = "invalid"
|
||||
|
||||
# The notification section (Feature 11) is OPTIONAL in raw submits
|
||||
# the same way: a missing key keeps the current value. The three
|
||||
# times are strict HH:MM; the notify target is a non-empty HA
|
||||
# service name; the toggles are real booleans.
|
||||
for time_key in (
|
||||
CONF_MORNING_SUMMARY_TIME,
|
||||
CONF_AFTERNOON_REMINDER_TIME,
|
||||
CONF_END_OF_DAY_REPORT_TIME,
|
||||
# The last-good staleness threshold (Feature 18) is OPTIONAL in raw
|
||||
# submits the same way: a missing key keeps the current value. It
|
||||
# must be a positive int, and at least the EFFECTIVE update interval
|
||||
# (the one this submit carries, else the stored or default one) — a
|
||||
# threshold shorter than the poll cadence could never serve even one
|
||||
# cached snapshot.
|
||||
staleness = user_input.get(CONF_SNAPSHOT_STALENESS)
|
||||
if staleness is not None and (
|
||||
isinstance(staleness, bool)
|
||||
or not isinstance(staleness, int)
|
||||
or staleness < 1
|
||||
):
|
||||
time_value = user_input.get(time_key)
|
||||
if time_value is not None and (
|
||||
not isinstance(time_value, str) or not _parse_time(time_value)
|
||||
errors[CONF_SNAPSHOT_STALENESS] = "invalid"
|
||||
elif staleness is not None and not errors.get(CONF_UPDATE_INTERVAL):
|
||||
stored_interval = (current or {}).get(CONF_UPDATE_INTERVAL)
|
||||
effective_interval = update_interval
|
||||
if effective_interval is None and (
|
||||
isinstance(stored_interval, int)
|
||||
and not isinstance(stored_interval, bool)
|
||||
and stored_interval >= MIN_UPDATE_INTERVAL
|
||||
):
|
||||
errors[time_key] = "invalid_time"
|
||||
notify_target = user_input.get(CONF_NOTIFY_TARGET)
|
||||
if notify_target is not None and not isinstance(notify_target, str):
|
||||
errors[CONF_NOTIFY_TARGET] = "invalid"
|
||||
elif isinstance(notify_target, str) and notify_target != (
|
||||
notify_target.strip()
|
||||
):
|
||||
# An EMPTY target is valid — it means "no notifications
|
||||
# configured yet" and is the shipped default; a value that
|
||||
# differs from its own trim (padded or whitespace-only) is
|
||||
# malformed and rejected.
|
||||
errors[CONF_NOTIFY_TARGET] = "invalid"
|
||||
for toggle_key in (
|
||||
CONF_MORNING_SUMMARY_ENABLED,
|
||||
CONF_AFTERNOON_REMINDER_ENABLED,
|
||||
CONF_END_OF_DAY_REPORT_ENABLED,
|
||||
CONF_CELEBRATION_ENABLED,
|
||||
):
|
||||
toggle_value = user_input.get(toggle_key)
|
||||
if toggle_value is not None and not isinstance(toggle_value, bool):
|
||||
errors[toggle_key] = "invalid"
|
||||
effective_interval = stored_interval
|
||||
if effective_interval is None:
|
||||
effective_interval = DEFAULT_UPDATE_INTERVAL
|
||||
if staleness < effective_interval:
|
||||
errors[CONF_SNAPSHOT_STALENESS] = "invalid_staleness"
|
||||
|
||||
# The panel-plane API connection (Feature 18) is OPTIONAL in raw
|
||||
# submits the same way: a missing key keeps the current value. The
|
||||
@@ -143,249 +119,78 @@ def _async_validate(user_input: dict[str, Any]) -> dict[str, str]:
|
||||
return errors
|
||||
|
||||
|
||||
def _strict_bool(value: object) -> bool:
|
||||
"""Require a real bool; voluptuous ``bool`` would coerce strings."""
|
||||
if not isinstance(value, bool):
|
||||
raise vol.Invalid(f"expected a boolean, got {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
def _build_schema(
|
||||
current: dict[str, Any],
|
||||
*,
|
||||
admin_choices: dict[str, str] | None = None,
|
||||
admin_default: list[str] | None = None,
|
||||
) -> vol.Schema:
|
||||
def _build_schema(current: dict[str, Any]) -> vol.Schema:
|
||||
"""Build the form schema, pre-filled with the current values.
|
||||
|
||||
The admin picker is a multi-select of EXISTING Home Assistant users
|
||||
(never a free-text field — the allowlist stores user IDs); its
|
||||
default is the current database allowlist, so an untouched submit
|
||||
re-saves exactly who is already allowed. The notification section
|
||||
(Feature 11) carries the household's notify target, the three
|
||||
automation times, and four enable toggles — defaults from
|
||||
:mod:`~.const`, never a hard-coded personal target.
|
||||
The API connection (Feature 18) carries the base URL the API client
|
||||
talks to and the panel service token it presents as the Bearer
|
||||
credential. Schema-validated submits always carry every field (the
|
||||
defaults pre-fill from the stored values); raw callers omitting one
|
||||
keep its stored value (see _async_validate).
|
||||
"""
|
||||
fields: dict[Any, Any] = {
|
||||
vol.Required(
|
||||
CONF_HORIZON_DAYS, default=current[CONF_HORIZON_DAYS]
|
||||
): int,
|
||||
vol.Required(
|
||||
CONF_DAY_ROLLOVER_TIME, default=current[CONF_DAY_ROLLOVER_TIME]
|
||||
): str,
|
||||
vol.Required(
|
||||
CONF_PANEL_IDLE_TIMEOUT, default=current[CONF_PANEL_IDLE_TIMEOUT]
|
||||
): int,
|
||||
vol.Required(
|
||||
CONF_UPDATE_INTERVAL, default=current[CONF_UPDATE_INTERVAL]
|
||||
): int,
|
||||
vol.Required(
|
||||
CONF_NOTIFY_TARGET, default=current[CONF_NOTIFY_TARGET]
|
||||
): str,
|
||||
vol.Required(
|
||||
CONF_MORNING_SUMMARY_TIME,
|
||||
default=current[CONF_MORNING_SUMMARY_TIME],
|
||||
): str,
|
||||
vol.Required(
|
||||
CONF_AFTERNOON_REMINDER_TIME,
|
||||
default=current[CONF_AFTERNOON_REMINDER_TIME],
|
||||
): str,
|
||||
vol.Required(
|
||||
CONF_END_OF_DAY_REPORT_TIME,
|
||||
default=current[CONF_END_OF_DAY_REPORT_TIME],
|
||||
): str,
|
||||
vol.Required(
|
||||
CONF_MORNING_SUMMARY_ENABLED,
|
||||
default=current[CONF_MORNING_SUMMARY_ENABLED],
|
||||
): _strict_bool,
|
||||
vol.Required(
|
||||
CONF_AFTERNOON_REMINDER_ENABLED,
|
||||
default=current[CONF_AFTERNOON_REMINDER_ENABLED],
|
||||
): _strict_bool,
|
||||
vol.Required(
|
||||
CONF_END_OF_DAY_REPORT_ENABLED,
|
||||
default=current[CONF_END_OF_DAY_REPORT_ENABLED],
|
||||
): _strict_bool,
|
||||
vol.Required(
|
||||
CONF_CELEBRATION_ENABLED,
|
||||
default=current[CONF_CELEBRATION_ENABLED],
|
||||
): _strict_bool,
|
||||
# Panel-plane API connection (Feature 18): the base URL the API
|
||||
# client talks to and the panel service token it presents as
|
||||
# the Bearer credential. Schema-validated submits always carry
|
||||
# both (the defaults pre-fill from the stored values); raw
|
||||
# callers omitting them keep their stored values (see
|
||||
# _async_validate).
|
||||
vol.Required(
|
||||
CONF_API_BASE_URL, default=current[CONF_API_BASE_URL]
|
||||
): str,
|
||||
vol.Required(
|
||||
CONF_PANEL_TOKEN, default=current[CONF_PANEL_TOKEN]
|
||||
): str,
|
||||
}
|
||||
if admin_choices is not None:
|
||||
# HA's supported multi-select validator: the frontend can
|
||||
# serialize the field as a real picker of the given choices.
|
||||
fields[
|
||||
vol.Optional(
|
||||
CONF_ADMIN_USER_IDS, default=list(admin_default or [])
|
||||
)
|
||||
] = cv.multi_select(admin_choices)
|
||||
return vol.Schema(fields)
|
||||
return vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_PANEL_IDLE_TIMEOUT,
|
||||
default=current[CONF_PANEL_IDLE_TIMEOUT],
|
||||
): int,
|
||||
vol.Required(
|
||||
CONF_UPDATE_INTERVAL, default=current[CONF_UPDATE_INTERVAL]
|
||||
): int,
|
||||
# The last-good staleness threshold (Feature 18): how long
|
||||
# the coordinator keeps serving the cached snapshot after
|
||||
# the API stops answering.
|
||||
vol.Required(
|
||||
CONF_SNAPSHOT_STALENESS,
|
||||
default=current[CONF_SNAPSHOT_STALENESS],
|
||||
): int,
|
||||
# Panel-plane API connection (Feature 18): the base URL the
|
||||
# API client talks to and the panel service token it
|
||||
# presents as the Bearer credential.
|
||||
vol.Required(
|
||||
CONF_API_BASE_URL, default=current[CONF_API_BASE_URL]
|
||||
): str,
|
||||
vol.Required(
|
||||
CONF_PANEL_TOKEN, default=current[CONF_PANEL_TOKEN]
|
||||
): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
|
||||
"""Handle the options flow for NestQuest."""
|
||||
|
||||
def _async_database(self):
|
||||
"""Return the runtime database for this entry, or None.
|
||||
|
||||
Real HA only opens the options flow for an existing entry, so
|
||||
the runtime record (and its database) exists; the None branch
|
||||
covers direct construction in tests and degraded states.
|
||||
"""
|
||||
hass_data = getattr(self.hass, "data", None)
|
||||
if not isinstance(hass_data, dict):
|
||||
return None
|
||||
record = hass_data.get(DOMAIN, {}).get(self.config_entry.entry_id)
|
||||
database = getattr(record, "database", None)
|
||||
return database if getattr(database, "connected", False) else None
|
||||
|
||||
async def _async_admin_choices(self) -> dict[str, str]:
|
||||
"""Map HA user id -> display name for the picker.
|
||||
|
||||
Real Home Assistant always provides ``hass.auth``; a missing
|
||||
one (direct construction in tests, degraded harness) degrades
|
||||
to an empty picker rather than crashing the form.
|
||||
"""
|
||||
auth = getattr(self.hass, "auth", None)
|
||||
if auth is None:
|
||||
return {}
|
||||
users = await auth.async_get_users()
|
||||
return {
|
||||
user.id: user.name
|
||||
for user in users
|
||||
if getattr(user, "id", None)
|
||||
}
|
||||
|
||||
async def _async_current_admin_ids(self) -> list[str]:
|
||||
"""Return the current allowlist, or [] without a database."""
|
||||
database = self._async_database()
|
||||
if database is None:
|
||||
return []
|
||||
return await list_admin_ids(database)
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Manage the NestQuest options."""
|
||||
admin_choices = await self._async_admin_choices()
|
||||
admin_default = await self._async_current_admin_ids()
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=_build_schema(
|
||||
self._current_values(),
|
||||
admin_choices=admin_choices,
|
||||
admin_default=admin_default,
|
||||
),
|
||||
data_schema=_build_schema(self._current_values()),
|
||||
)
|
||||
|
||||
errors = _async_validate(user_input)
|
||||
# The admin ids are validated only when the key is present:
|
||||
# schema-validated submits always carry it (vol.Optional's
|
||||
# default fills from the current allowlist), while raw callers
|
||||
# omitting the key keep the field untouched.
|
||||
admin_ids_present = CONF_ADMIN_USER_IDS in user_input
|
||||
admin_ids = user_input.get(CONF_ADMIN_USER_IDS)
|
||||
errors = _async_validate(user_input, self._current_values())
|
||||
if errors:
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=_build_schema(
|
||||
self._current_values(),
|
||||
admin_choices=admin_choices,
|
||||
admin_default=admin_default,
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
if admin_ids_present and (
|
||||
not isinstance(admin_ids, list)
|
||||
or any(not isinstance(value, str) for value in admin_ids)
|
||||
):
|
||||
errors[CONF_ADMIN_USER_IDS] = "invalid_admin"
|
||||
elif admin_ids_present and not admin_ids:
|
||||
# An empty allowlist fails closed: saving zero admins is
|
||||
# refused, never silently applied.
|
||||
errors[CONF_ADMIN_USER_IDS] = "no_admins"
|
||||
elif admin_ids_present and any(
|
||||
value not in admin_choices for value in admin_ids
|
||||
):
|
||||
# The schema enforces membership for real HA submits; this
|
||||
# backs the raw-input path up so an unknown id can never
|
||||
# reach the allowlist.
|
||||
errors[CONF_ADMIN_USER_IDS] = "invalid_admin"
|
||||
elif admin_ids_present and len(set(admin_ids)) != len(admin_ids):
|
||||
# set_admin_ids refuses duplicates with ValueError; a flow
|
||||
# submission must surface that as a field error instead of
|
||||
# an unhandled crash.
|
||||
errors[CONF_ADMIN_USER_IDS] = "invalid_admin"
|
||||
if errors:
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=_build_schema(
|
||||
self._current_values(),
|
||||
admin_choices=admin_choices,
|
||||
admin_default=admin_default,
|
||||
),
|
||||
data_schema=_build_schema(self._current_values()),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
# Apply the picker's selection to the allowlist when the
|
||||
# runtime database is reachable; the selection is always
|
||||
# stored on the entry as the disaster-recovery copy.
|
||||
database = self._async_database()
|
||||
if database is not None and admin_ids_present:
|
||||
await set_admin_ids(database, admin_ids)
|
||||
data = {
|
||||
CONF_HORIZON_DAYS: user_input[CONF_HORIZON_DAYS],
|
||||
CONF_DAY_ROLLOVER_TIME: user_input[CONF_DAY_ROLLOVER_TIME],
|
||||
CONF_PANEL_IDLE_TIMEOUT: user_input[CONF_PANEL_IDLE_TIMEOUT],
|
||||
CONF_UPDATE_INTERVAL: user_input.get(
|
||||
CONF_UPDATE_INTERVAL,
|
||||
self._current_values()[CONF_UPDATE_INTERVAL],
|
||||
),
|
||||
}
|
||||
# The notification section (Feature 11) stores the household's
|
||||
# choices the same fall-back-to-current way, so raw submits
|
||||
# that predate the section keep their stored values.
|
||||
# Every remaining key is stored the fall-back-to-current way, so
|
||||
# raw submits that predate a section keep that section's stored
|
||||
# values.
|
||||
current = self._current_values()
|
||||
for notification_key in (
|
||||
CONF_NOTIFY_TARGET,
|
||||
CONF_MORNING_SUMMARY_TIME,
|
||||
CONF_AFTERNOON_REMINDER_TIME,
|
||||
CONF_END_OF_DAY_REPORT_TIME,
|
||||
CONF_MORNING_SUMMARY_ENABLED,
|
||||
CONF_AFTERNOON_REMINDER_ENABLED,
|
||||
CONF_END_OF_DAY_REPORT_ENABLED,
|
||||
CONF_CELEBRATION_ENABLED,
|
||||
data: dict[str, Any] = {}
|
||||
for key in (
|
||||
CONF_PANEL_IDLE_TIMEOUT,
|
||||
CONF_UPDATE_INTERVAL,
|
||||
CONF_SNAPSHOT_STALENESS,
|
||||
CONF_API_BASE_URL,
|
||||
CONF_PANEL_TOKEN,
|
||||
):
|
||||
data[notification_key] = user_input.get(
|
||||
notification_key, current[notification_key]
|
||||
)
|
||||
# The API connection section (Feature 18) stores the base URL
|
||||
# and panel token the same fall-back-to-current way, so raw
|
||||
# submits that predate the section keep their stored values.
|
||||
for api_key in (CONF_API_BASE_URL, CONF_PANEL_TOKEN):
|
||||
data[api_key] = user_input.get(api_key, current[api_key])
|
||||
if admin_ids_present:
|
||||
data[CONF_ADMIN_USER_IDS] = admin_ids
|
||||
elif admin_default:
|
||||
# HA replaces entry.options wholesale, so an omitted picker
|
||||
# field must carry the CURRENT allowlist forward: dropping
|
||||
# it would leave only the stale config-flow copy in entry
|
||||
# data, and a later database loss would resurrect admins
|
||||
# the owner has since removed.
|
||||
data[CONF_ADMIN_USER_IDS] = list(admin_default)
|
||||
data[key] = user_input.get(key, current[key])
|
||||
return self.async_create_entry(title="NestQuest", data=data)
|
||||
|
||||
def _current_values(self) -> dict[str, Any]:
|
||||
@@ -394,18 +199,9 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
|
||||
entry_data = getattr(self.config_entry, "data", None) or {}
|
||||
values: dict[str, Any] = {}
|
||||
for key, default in (
|
||||
(CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS),
|
||||
(CONF_DAY_ROLLOVER_TIME, DEFAULT_DAY_ROLLOVER_TIME),
|
||||
(CONF_PANEL_IDLE_TIMEOUT, DEFAULT_PANEL_IDLE_TIMEOUT),
|
||||
(CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL),
|
||||
(CONF_NOTIFY_TARGET, ""),
|
||||
(CONF_MORNING_SUMMARY_TIME, DEFAULT_MORNING_SUMMARY_TIME),
|
||||
(CONF_AFTERNOON_REMINDER_TIME, DEFAULT_AFTERNOON_REMINDER_TIME),
|
||||
(CONF_END_OF_DAY_REPORT_TIME, DEFAULT_END_OF_DAY_REPORT_TIME),
|
||||
(CONF_MORNING_SUMMARY_ENABLED, DEFAULT_AUTOMATION_ENABLED),
|
||||
(CONF_AFTERNOON_REMINDER_ENABLED, DEFAULT_AUTOMATION_ENABLED),
|
||||
(CONF_END_OF_DAY_REPORT_ENABLED, DEFAULT_AUTOMATION_ENABLED),
|
||||
(CONF_CELEBRATION_ENABLED, DEFAULT_AUTOMATION_ENABLED),
|
||||
(CONF_SNAPSHOT_STALENESS, DEFAULT_SNAPSHOT_STALENESS),
|
||||
(CONF_API_BASE_URL, DEFAULT_API_BASE_URL),
|
||||
(CONF_PANEL_TOKEN, DEFAULT_PANEL_TOKEN),
|
||||
):
|
||||
@@ -415,4 +211,4 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
|
||||
values[key] = entry_data[key]
|
||||
else:
|
||||
values[key] = default
|
||||
return values
|
||||
return values
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
"""Permission gate for NestQuest services (Feature 09).
|
||||
|
||||
A wrapper factory, applied to EVERY registered service at registration
|
||||
time (no handler is exempt), reads ``call.context.user_id``, consults
|
||||
the :data:`~.service_policy.SERVICE_POLICY` registry and
|
||||
:func:`~.admin_allowlist.is_admin`, and raises
|
||||
:class:`homeassistant.exceptions.Unauthorized` for an admin-only
|
||||
operation called by a non-admin — including a call with NO user
|
||||
context, which fails closed. ``complete_quest`` (policy ``open``)
|
||||
proceeds regardless of the allowlist.
|
||||
|
||||
Logging (later Feature 09 tasks): every denied admin-only call logs a
|
||||
warning and every successful one logs a debug line, both carrying the
|
||||
service, the user id (or ``none``), and a UTC timestamp.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.exceptions import Unauthorized
|
||||
|
||||
from .admin_allowlist import is_admin
|
||||
from .service_policy import ADMIN_ONLY
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ServiceHandler = Callable[[Any], Awaitable[None]]
|
||||
|
||||
|
||||
def _context_user_id(call: Any) -> str | None:
|
||||
context = getattr(call, "context", None)
|
||||
user_id = getattr(context, "user_id", None) if context is not None else None
|
||||
if isinstance(user_id, str) and user_id:
|
||||
return user_id
|
||||
return None
|
||||
|
||||
|
||||
def _stamp() -> str:
|
||||
return datetime.datetime.now(datetime.timezone.utc).isoformat(
|
||||
timespec="seconds"
|
||||
)
|
||||
|
||||
|
||||
def permission_gate(
|
||||
service: str,
|
||||
policy: str,
|
||||
database_of: Callable[[Any], Any],
|
||||
) -> Callable[[ServiceHandler], ServiceHandler]:
|
||||
"""Wrap ``handler`` with the Feature 09 permission check.
|
||||
|
||||
``database_of`` resolves the live database from the call-time
|
||||
runtime (the same ``find_runtime`` the handler uses), so the gate
|
||||
never opens its own connection. The verdict is fail-closed:
|
||||
absent allowlist row, unknown id, no user context — all not admin.
|
||||
"""
|
||||
|
||||
def _wrap(handler: ServiceHandler) -> ServiceHandler:
|
||||
if policy != ADMIN_ONLY:
|
||||
return handler
|
||||
|
||||
async def _gated(call: Any) -> None:
|
||||
user_id = _context_user_id(call)
|
||||
allowed = user_id is not None and await is_admin(
|
||||
database_of(call), user_id
|
||||
)
|
||||
if not allowed:
|
||||
LOGGER.warning(
|
||||
"NestQuest denied %s: caller user_id=%s at %s",
|
||||
service,
|
||||
user_id if user_id is not None else "none",
|
||||
_stamp(),
|
||||
)
|
||||
raise Unauthorized(
|
||||
f"User '{user_id if user_id is not None else 'none'}' "
|
||||
f"is not allowed to call nestquest.{service}"
|
||||
)
|
||||
LOGGER.debug(
|
||||
"NestQuest allowed %s: caller user_id=%s at %s",
|
||||
service,
|
||||
user_id,
|
||||
_stamp(),
|
||||
)
|
||||
await handler(call)
|
||||
|
||||
return _gated
|
||||
|
||||
return _wrap
|
||||
@@ -1,11 +0,0 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.presence` for the integration package.
|
||||
|
||||
The presence and custody model lives in
|
||||
:mod:`custom_components.nestquest.core.presence` (extracted in
|
||||
task 510c1f78); this module re-exports it so existing
|
||||
``from .presence import ...`` references inside the integration keep
|
||||
working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .core.presence import * # noqa: F401,F403
|
||||
@@ -1,10 +0,0 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.presence_management` for the integration package.
|
||||
|
||||
The presence-write business layer lives in
|
||||
:mod:`custom_components.nestquest.core.presence_management` (added for
|
||||
task e4a9d31b); this module re-exports it so the integration's service
|
||||
handlers call the ONE implementation the API admin plane shares.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .core.presence_management import * # noqa: F401,F403
|
||||
@@ -1,11 +0,0 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.quest_definitions` for the integration package.
|
||||
|
||||
The quest definitions business layer lives in
|
||||
:mod:`custom_components.nestquest.core.quest_definitions` (extracted in
|
||||
task 510c1f78); this module re-exports it so existing
|
||||
``from .quest_definitions import ...`` references inside the integration
|
||||
keep working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .core.quest_definitions import * # noqa: F401,F403
|
||||
@@ -1,10 +0,0 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.recurrence` for the integration package.
|
||||
|
||||
The recurrence model lives in :mod:`custom_components.nestquest.core.recurrence`
|
||||
(extracted in task d0b691d8); this module re-exports it so existing
|
||||
``from .recurrence import ...`` references inside the integration keep
|
||||
working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .core.recurrence import * # noqa: F401,F403
|
||||
@@ -1,11 +0,0 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.schema` for the integration package.
|
||||
|
||||
The declarative schema DDL lives in
|
||||
:mod:`custom_components.nestquest.core.schema` (extracted in
|
||||
task d0b691d8); this module re-exports it so existing
|
||||
``from .schema import ...`` references inside the integration keep
|
||||
working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .core.schema import * # noqa: F401,F403
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.service_policy` for the integration package.
|
||||
|
||||
The service policy registry lives in
|
||||
:mod:`custom_components.nestquest.core.service_policy` (extracted in
|
||||
task 510c1f78); this module re-exports it so existing
|
||||
``from .service_policy import ...`` references inside the integration
|
||||
keep working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .core.service_policy import * # noqa: F401,F403
|
||||
@@ -1,61 +1,36 @@
|
||||
"""Domain-global NestQuest service schemas and handlers (Feature 09).
|
||||
"""The domain-global ``nestquest.complete_quest`` service (Feature 18).
|
||||
|
||||
Registration is domain-scoped and happens once (see
|
||||
:func:`async_register_services`); handlers resolve a live database at
|
||||
call time through the injected ``find_runtime`` callable. Permission
|
||||
checks are Feature 09 later tasks — this module only validates payload
|
||||
shape and forwards to the Feature 03/06/08 business layers (the
|
||||
presence writes included, through :mod:`.presence_management` — the
|
||||
ONE implementation the API admin plane shares).
|
||||
``export_history_csv`` is registered with a schema but raises until
|
||||
Feature 13. ``regenerate`` is supplied by the caller so
|
||||
the Feature 07 handler stays the one in ``__init__``.
|
||||
:func:`async_register_services`). Since Feature 18 the integration is
|
||||
a client of the NestQuest API service, so ``complete_quest`` proxies
|
||||
to the API's panel complete route through the entry's shared API
|
||||
client instead of writing any database. The former HA admin services
|
||||
(uncomplete, quest-definition and presence management, CSV export,
|
||||
child management, regenerate) are removed: the admin surface is the
|
||||
PWA and the API service's admin routes, so the only HA service left is
|
||||
the panel completion path.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from . import children as children_layer
|
||||
from . import completion as completion_layer
|
||||
from . import presence_management as presence_management_layer
|
||||
from . import quest_definitions as quest_definitions_layer
|
||||
from .api_client import NestQuestApiError
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
DOMAIN_SERVICES,
|
||||
QUEST_WINDOWS,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
||||
SERVICE_CREATE_QUEST_DEFINITION,
|
||||
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
||||
SERVICE_EXPORT_HISTORY_CSV,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
SERVICE_REGENERATE,
|
||||
SERVICE_SET_PRESENCE_PATTERN,
|
||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
||||
SERVICE_UNCOMPLETE_QUEST,
|
||||
SERVICE_UPDATE_QUEST_DEFINITION,
|
||||
)
|
||||
from .events import fire_quest_completed, fire_quest_uncompleted
|
||||
from .permissions import permission_gate
|
||||
from .recurrence import ScheduleRule
|
||||
from .service_policy import SERVICE_POLICY
|
||||
|
||||
FindRuntime = Callable[[HomeAssistant], Any]
|
||||
ServiceHandler = Callable[[Any], Awaitable[None]]
|
||||
|
||||
ACTOR_PANEL = "panel"
|
||||
ACTOR_USER = "user"
|
||||
MANAGE_CHILD_CREATE = "create"
|
||||
MANAGE_CHILD_EDIT = "edit"
|
||||
MANAGE_CHILD_SET_ACTIVE = "set_active"
|
||||
MANAGE_CHILD_REORDER = "reorder"
|
||||
|
||||
|
||||
def _strict_int(value: object) -> int:
|
||||
@@ -65,82 +40,6 @@ def _strict_int(value: object) -> int:
|
||||
return value
|
||||
|
||||
|
||||
def _strict_bool(value: object) -> bool:
|
||||
"""Reject ints and other truthy values; require a real bool."""
|
||||
if type(value) is not bool:
|
||||
raise vol.Invalid(f"expected bool, got {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
def _weekday_list(value: object) -> list[int]:
|
||||
if type(value) is not list:
|
||||
raise vol.Invalid(f"expected a list of weekday ints, got {value!r}")
|
||||
return [_strict_int(item) for item in value]
|
||||
|
||||
|
||||
def _presence_pattern(value: object) -> dict[int, list[int]]:
|
||||
if not isinstance(value, dict):
|
||||
raise vol.Invalid(f"pattern must be a mapping, got {value!r}")
|
||||
pattern: dict[int, list[int]] = {}
|
||||
for week, days in value.items():
|
||||
if isinstance(week, str):
|
||||
try:
|
||||
week_index = int(week)
|
||||
except ValueError as err:
|
||||
raise vol.Invalid(
|
||||
f"pattern week index must be an integer, got {week!r}"
|
||||
) from err
|
||||
if str(week_index) != week:
|
||||
raise vol.Invalid(
|
||||
f"pattern week index must be an integer, got {week!r}"
|
||||
)
|
||||
else:
|
||||
week_index = _strict_int(week)
|
||||
pattern[week_index] = _weekday_list(days)
|
||||
return pattern
|
||||
|
||||
|
||||
def _window_entry(value: object) -> str | tuple[str, str | None]:
|
||||
if isinstance(value, str):
|
||||
if value not in QUEST_WINDOWS:
|
||||
raise vol.Invalid(f"unknown window {value!r}")
|
||||
return value
|
||||
if isinstance(value, dict):
|
||||
parsed = vol.Schema(
|
||||
{
|
||||
vol.Required("window"): vol.In(QUEST_WINDOWS),
|
||||
vol.Optional("due_time"): str,
|
||||
}
|
||||
)(value)
|
||||
due_time = parsed.get("due_time")
|
||||
if due_time is None:
|
||||
return parsed["window"]
|
||||
return (parsed["window"], due_time)
|
||||
raise vol.Invalid(
|
||||
f"windows entry must be a window name or object, got {value!r}"
|
||||
)
|
||||
|
||||
|
||||
def _windows(value: object) -> list[str | tuple[str, str | None]]:
|
||||
if type(value) is not list:
|
||||
raise vol.Invalid(f"windows must be a list, got {value!r}")
|
||||
if not value:
|
||||
raise vol.Invalid("windows must not be empty")
|
||||
return [_window_entry(entry) for entry in value]
|
||||
|
||||
|
||||
def _int_list(value: object) -> list[int]:
|
||||
if type(value) is not list:
|
||||
raise vol.Invalid(f"expected a list of ints, got {value!r}")
|
||||
return [_strict_int(item) for item in value]
|
||||
|
||||
|
||||
def _rule_dict(value: object) -> dict:
|
||||
if not isinstance(value, dict):
|
||||
raise vol.Invalid(f"rule must be a dict, got {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
SCHEMA_COMPLETE_QUEST = vol.Schema(
|
||||
{
|
||||
vol.Required("instance_id"): _strict_int,
|
||||
@@ -150,102 +49,22 @@ SCHEMA_COMPLETE_QUEST = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_UNCOMPLETE_QUEST = SCHEMA_COMPLETE_QUEST
|
||||
|
||||
SCHEMA_CREATE_QUEST_DEFINITION = vol.Schema(
|
||||
{
|
||||
vol.Required("title"): str,
|
||||
vol.Required("rule"): _rule_dict,
|
||||
vol.Required("assignee_child_ids"): vol.All(
|
||||
_int_list, vol.Length(min=1)
|
||||
),
|
||||
vol.Required("windows"): _windows,
|
||||
vol.Optional("description"): str,
|
||||
vol.Optional("icon"): str,
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_UPDATE_QUEST_DEFINITION = vol.Schema(
|
||||
{
|
||||
vol.Required("definition_id"): _strict_int,
|
||||
vol.Optional("title"): str,
|
||||
vol.Optional("description"): vol.Any(str, None),
|
||||
vol.Optional("icon"): vol.Any(str, None),
|
||||
vol.Optional("rule"): _rule_dict,
|
||||
vol.Optional("windows"): _windows,
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_SET_QUEST_DEFINITION_ACTIVE = vol.Schema(
|
||||
{
|
||||
vol.Required("definition_id"): _strict_int,
|
||||
vol.Required("is_active"): _strict_bool,
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_SET_PRESENCE_PATTERN = vol.Schema(
|
||||
{
|
||||
vol.Required("child_id"): _strict_int,
|
||||
vol.Required("cycle_length_weeks"): _strict_int,
|
||||
vol.Required("anchor_date"): str,
|
||||
vol.Required("pattern"): _presence_pattern,
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_CREATE_PRESENCE_OVERRIDE = vol.Schema(
|
||||
{
|
||||
vol.Required("child_id"): _strict_int,
|
||||
vol.Required("start_date"): str,
|
||||
vol.Required("end_date"): str,
|
||||
vol.Required("is_present"): _strict_bool,
|
||||
vol.Optional("note"): str,
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_DELETE_PRESENCE_OVERRIDE = vol.Schema(
|
||||
{
|
||||
vol.Required("override_id"): _strict_int,
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_EXPORT_HISTORY_CSV = vol.Schema({})
|
||||
|
||||
SCHEMA_MANAGE_CHILD = vol.Schema(
|
||||
{
|
||||
vol.Required("action"): vol.In(
|
||||
[
|
||||
MANAGE_CHILD_CREATE,
|
||||
MANAGE_CHILD_EDIT,
|
||||
MANAGE_CHILD_SET_ACTIVE,
|
||||
MANAGE_CHILD_REORDER,
|
||||
]
|
||||
),
|
||||
vol.Optional("child_id"): _strict_int,
|
||||
vol.Optional("display_name"): str,
|
||||
vol.Optional("colour"): str,
|
||||
vol.Optional("avatar_ref"): str,
|
||||
vol.Optional("sort_order"): _strict_int,
|
||||
vol.Optional("is_active"): _strict_bool,
|
||||
vol.Optional("ordered_ids"): _int_list,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _require_runtime(hass: HomeAssistant, find_runtime: FindRuntime):
|
||||
runtime = find_runtime(hass)
|
||||
if runtime is None:
|
||||
raise HomeAssistantError(
|
||||
"NestQuest has no live database; cannot run this service"
|
||||
"NestQuest is not loaded; cannot run this service"
|
||||
)
|
||||
return runtime
|
||||
|
||||
|
||||
async def _refresh_entities(runtime: Any) -> None:
|
||||
"""Force an immediate coordinator refresh after a service mutation.
|
||||
"""Force an immediate coordinator refresh after the service call.
|
||||
|
||||
Only the SERVICE paths call this — the scheduled materialize and
|
||||
Only the SERVICE path calls this — the scheduled materialize and
|
||||
rollover internals are direct function calls and never widen this
|
||||
gate — so entities reflect a completion the moment the service
|
||||
gate — so the entities reflect a completion the moment the service
|
||||
call returns instead of waiting for the poll interval.
|
||||
"""
|
||||
coordinator = getattr(runtime, "coordinator", None)
|
||||
@@ -253,12 +72,6 @@ async def _refresh_entities(runtime: Any) -> None:
|
||||
await coordinator.async_refresh()
|
||||
|
||||
|
||||
def _generation_kwargs(hass: HomeAssistant, runtime: Any) -> dict[str, Any]:
|
||||
time_zone = ZoneInfo(hass.config.time_zone)
|
||||
today = datetime.datetime.now(time_zone).date()
|
||||
return {"today": today, "horizon_days": runtime.settings.horizon_days}
|
||||
|
||||
|
||||
def _actor_kwargs(call: Any) -> dict[str, Any]:
|
||||
actor = call.data["actor"]
|
||||
if actor == ACTOR_PANEL:
|
||||
@@ -301,223 +114,50 @@ def _complete_quest(
|
||||
async def _handler(call: Any) -> None:
|
||||
runtime = _require_runtime(hass, find_runtime)
|
||||
instance_id = call.data["instance_id"]
|
||||
# ``appended`` is decided under the same lock as the write, so
|
||||
# a concurrent duplicate call (a double tap) can not produce a
|
||||
# second transition event: the loser appends nothing.
|
||||
result = await completion_layer.complete_instance(
|
||||
runtime.database,
|
||||
instance_id,
|
||||
**_actor_kwargs(call),
|
||||
)
|
||||
if result.appended:
|
||||
await fire_quest_completed(
|
||||
hass,
|
||||
runtime.database,
|
||||
instance_id,
|
||||
was_on_time=result.was_on_time,
|
||||
)
|
||||
await _refresh_entities(runtime)
|
||||
|
||||
return _with_errors(_handler)
|
||||
|
||||
|
||||
def _uncomplete_quest(
|
||||
hass: HomeAssistant, find_runtime: FindRuntime
|
||||
) -> ServiceHandler:
|
||||
async def _handler(call: Any) -> None:
|
||||
runtime = _require_runtime(hass, find_runtime)
|
||||
instance_id = call.data["instance_id"]
|
||||
result = await completion_layer.uncomplete_instance(
|
||||
runtime.database,
|
||||
instance_id,
|
||||
**_actor_kwargs(call),
|
||||
)
|
||||
if result.appended:
|
||||
await fire_quest_uncompleted(
|
||||
hass, runtime.database, instance_id
|
||||
)
|
||||
await _refresh_entities(runtime)
|
||||
|
||||
return _with_errors(_handler)
|
||||
|
||||
|
||||
def _create_quest_definition(
|
||||
hass: HomeAssistant, find_runtime: FindRuntime
|
||||
) -> ServiceHandler:
|
||||
async def _handler(call: Any) -> None:
|
||||
database = _require_runtime(hass, find_runtime).database
|
||||
data = call.data
|
||||
await quest_definitions_layer.create_quest_definition(
|
||||
database,
|
||||
data["title"],
|
||||
ScheduleRule.from_dict(data["rule"]),
|
||||
data["assignee_child_ids"],
|
||||
data["windows"],
|
||||
description=data.get("description"),
|
||||
icon=data.get("icon"),
|
||||
)
|
||||
|
||||
return _with_errors(_handler)
|
||||
|
||||
|
||||
def _update_quest_definition(
|
||||
hass: HomeAssistant, find_runtime: FindRuntime
|
||||
) -> ServiceHandler:
|
||||
async def _handler(call: Any) -> None:
|
||||
runtime = _require_runtime(hass, find_runtime)
|
||||
data = call.data
|
||||
kwargs: dict[str, Any] = _generation_kwargs(hass, runtime)
|
||||
if "title" in data:
|
||||
kwargs["title"] = data["title"]
|
||||
if "description" in data:
|
||||
kwargs["description"] = data["description"]
|
||||
if "icon" in data:
|
||||
kwargs["icon"] = data["icon"]
|
||||
if "rule" in data:
|
||||
kwargs["rule"] = ScheduleRule.from_dict(data["rule"])
|
||||
if "windows" in data:
|
||||
kwargs["windows"] = data["windows"]
|
||||
await quest_definitions_layer.edit_quest_definition(
|
||||
runtime.database, data["definition_id"], **kwargs
|
||||
)
|
||||
|
||||
return _with_errors(_handler)
|
||||
|
||||
|
||||
def _set_quest_definition_active(
|
||||
hass: HomeAssistant, find_runtime: FindRuntime
|
||||
) -> ServiceHandler:
|
||||
async def _handler(call: Any) -> None:
|
||||
runtime = _require_runtime(hass, find_runtime)
|
||||
await quest_definitions_layer.set_quest_definition_active(
|
||||
runtime.database,
|
||||
call.data["definition_id"],
|
||||
call.data["is_active"],
|
||||
**_generation_kwargs(hass, runtime),
|
||||
)
|
||||
|
||||
return _with_errors(_handler)
|
||||
|
||||
|
||||
def _set_presence_pattern(
|
||||
hass: HomeAssistant, find_runtime: FindRuntime
|
||||
) -> ServiceHandler:
|
||||
async def _handler(call: Any) -> None:
|
||||
runtime = _require_runtime(hass, find_runtime)
|
||||
data = call.data
|
||||
await presence_management_layer.set_presence_schedule(
|
||||
runtime.database,
|
||||
data["child_id"],
|
||||
data["cycle_length_weeks"],
|
||||
data["anchor_date"],
|
||||
data["pattern"],
|
||||
**_generation_kwargs(hass, runtime),
|
||||
)
|
||||
|
||||
return _with_errors(_handler)
|
||||
|
||||
|
||||
def _create_presence_override(
|
||||
hass: HomeAssistant, find_runtime: FindRuntime
|
||||
) -> ServiceHandler:
|
||||
async def _handler(call: Any) -> None:
|
||||
runtime = _require_runtime(hass, find_runtime)
|
||||
data = call.data
|
||||
await presence_management_layer.create_presence_override(
|
||||
runtime.database,
|
||||
data["child_id"],
|
||||
data["start_date"],
|
||||
data["end_date"],
|
||||
data["is_present"],
|
||||
note=data.get("note"),
|
||||
**_generation_kwargs(hass, runtime),
|
||||
)
|
||||
|
||||
return _with_errors(_handler)
|
||||
|
||||
|
||||
def _delete_presence_override(
|
||||
hass: HomeAssistant, find_runtime: FindRuntime
|
||||
) -> ServiceHandler:
|
||||
async def _handler(call: Any) -> None:
|
||||
runtime = _require_runtime(hass, find_runtime)
|
||||
await presence_management_layer.delete_presence_override(
|
||||
runtime.database,
|
||||
call.data["override_id"],
|
||||
**_generation_kwargs(hass, runtime),
|
||||
)
|
||||
|
||||
return _with_errors(_handler)
|
||||
|
||||
|
||||
async def _export_history_csv(_call: Any) -> None:
|
||||
raise HomeAssistantError(
|
||||
"CSV history export is not implemented until Feature 13"
|
||||
)
|
||||
|
||||
|
||||
def _manage_child(
|
||||
hass: HomeAssistant, find_runtime: FindRuntime
|
||||
) -> ServiceHandler:
|
||||
async def _handler(call: Any) -> None:
|
||||
database = _require_runtime(hass, find_runtime).database
|
||||
data = call.data
|
||||
action = data["action"]
|
||||
if action == MANAGE_CHILD_CREATE:
|
||||
display_name = data.get("display_name")
|
||||
if display_name is None:
|
||||
raise HomeAssistantError(
|
||||
"display_name is required when action is create"
|
||||
)
|
||||
kwargs: dict[str, Any] = {}
|
||||
if "colour" in data:
|
||||
kwargs["colour"] = data["colour"]
|
||||
if "avatar_ref" in data:
|
||||
kwargs["avatar_ref"] = data["avatar_ref"]
|
||||
if "sort_order" in data:
|
||||
kwargs["sort_order"] = data["sort_order"]
|
||||
await children_layer.create_child(
|
||||
database, display_name, **kwargs
|
||||
)
|
||||
return
|
||||
if action == MANAGE_CHILD_EDIT:
|
||||
child_id = data.get("child_id")
|
||||
if child_id is None:
|
||||
raise HomeAssistantError(
|
||||
"child_id is required when action is edit"
|
||||
)
|
||||
kwargs = {}
|
||||
if "display_name" in data:
|
||||
kwargs["display_name"] = data["display_name"]
|
||||
if "colour" in data:
|
||||
kwargs["colour"] = data["colour"]
|
||||
if "avatar_ref" in data:
|
||||
kwargs["avatar_ref"] = data["avatar_ref"]
|
||||
if "sort_order" in data:
|
||||
kwargs["sort_order"] = data["sort_order"]
|
||||
await children_layer.edit_child(database, child_id, **kwargs)
|
||||
return
|
||||
if action == MANAGE_CHILD_SET_ACTIVE:
|
||||
child_id = data.get("child_id")
|
||||
is_active = data.get("is_active")
|
||||
if child_id is None:
|
||||
raise HomeAssistantError(
|
||||
"child_id is required when action is set_active"
|
||||
)
|
||||
if is_active is None:
|
||||
raise HomeAssistantError(
|
||||
"is_active is required when action is set_active"
|
||||
)
|
||||
await children_layer.set_child_active(
|
||||
database, child_id, is_active
|
||||
)
|
||||
return
|
||||
ordered_ids = data.get("ordered_ids")
|
||||
if ordered_ids is None:
|
||||
# The panel contract (decision 9): a tap completes as the
|
||||
# tapped child profile, the only actor shape the API's panel
|
||||
# route accepts. The payload schema is unchanged — the panel
|
||||
# cards call it with instance_id, actor=panel, actor_child_id.
|
||||
actor = _actor_kwargs(call)
|
||||
if actor["actor_source"] != ACTOR_PANEL:
|
||||
raise HomeAssistantError(
|
||||
"ordered_ids is required when action is reorder"
|
||||
"nestquest.complete_quest completes through the API's "
|
||||
"panel route as the tapped child profile; actor must "
|
||||
"be 'panel' with actor_child_id"
|
||||
)
|
||||
await children_layer.reorder_children(database, ordered_ids)
|
||||
# The entry's ONE shared API client (built at setup): the
|
||||
# coordinator's snapshot poll, the SSE subscription, and this
|
||||
# proxy all ride the same instance. An entry with no panel
|
||||
# token runs the unconfigured stub, which has no completion
|
||||
# surface — fail fast with a clear, token-free message.
|
||||
complete_instance = getattr(
|
||||
getattr(runtime.coordinator, "api_client", None),
|
||||
"complete_instance",
|
||||
None,
|
||||
)
|
||||
if complete_instance is None:
|
||||
raise HomeAssistantError(
|
||||
"NestQuest API is not configured; cannot complete a "
|
||||
"quest"
|
||||
)
|
||||
try:
|
||||
await complete_instance(instance_id, actor["actor_child_id"])
|
||||
except NestQuestApiError as err:
|
||||
# The typed API failure surfaces as a service error the
|
||||
# caller sees — never swallowed, never retried here. The
|
||||
# typed message names method, path, and status only; the
|
||||
# panel token never appears in it.
|
||||
raise HomeAssistantError(
|
||||
f"NestQuest quest completion failed: {err}"
|
||||
) from err
|
||||
# NO local completion write and NO local event: the API service
|
||||
# emits the transition on its SSE stream and the integration's
|
||||
# subscription re-fires it on the bus — firing it here too
|
||||
# would double-fire. The immediate refresh keeps the old
|
||||
# behaviour where the sensors reflect the completion the
|
||||
# moment the service call returns instead of waiting for the
|
||||
# poll interval.
|
||||
await _refresh_entities(runtime)
|
||||
|
||||
return _with_errors(_handler)
|
||||
|
||||
@@ -526,84 +166,25 @@ def async_register_services(
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
find_runtime: FindRuntime,
|
||||
regenerate_handler: ServiceHandler,
|
||||
) -> None:
|
||||
"""Register every canonical service once at domain scope.
|
||||
"""Register the panel completion service once at domain scope.
|
||||
|
||||
Every service — regenerate included — passes through the Feature
|
||||
09 permission gate before its handler runs; the gate reads
|
||||
:data:`~.service_policy.SERVICE_POLICY`, so no service can be
|
||||
registered exempt from the policy.
|
||||
The one remaining HA service is the panel completion path; every
|
||||
admin operation moved to the API service's admin routes, so there
|
||||
is no permission gate to apply here.
|
||||
"""
|
||||
if hass.services.has_service(DOMAIN, SERVICE_REGENERATE):
|
||||
if hass.services.has_service(DOMAIN, SERVICE_COMPLETE_QUEST):
|
||||
return
|
||||
gate = permission_gate
|
||||
specs: list[tuple[str, ServiceHandler, object]] = [
|
||||
(SERVICE_REGENERATE, regenerate_handler, None),
|
||||
(
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
_complete_quest(hass, find_runtime),
|
||||
SCHEMA_COMPLETE_QUEST,
|
||||
),
|
||||
(
|
||||
SERVICE_UNCOMPLETE_QUEST,
|
||||
_uncomplete_quest(hass, find_runtime),
|
||||
SCHEMA_UNCOMPLETE_QUEST,
|
||||
),
|
||||
(
|
||||
SERVICE_CREATE_QUEST_DEFINITION,
|
||||
_create_quest_definition(hass, find_runtime),
|
||||
SCHEMA_CREATE_QUEST_DEFINITION,
|
||||
),
|
||||
(
|
||||
SERVICE_UPDATE_QUEST_DEFINITION,
|
||||
_update_quest_definition(hass, find_runtime),
|
||||
SCHEMA_UPDATE_QUEST_DEFINITION,
|
||||
),
|
||||
(
|
||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
||||
_set_quest_definition_active(hass, find_runtime),
|
||||
SCHEMA_SET_QUEST_DEFINITION_ACTIVE,
|
||||
),
|
||||
(
|
||||
SERVICE_SET_PRESENCE_PATTERN,
|
||||
_set_presence_pattern(hass, find_runtime),
|
||||
SCHEMA_SET_PRESENCE_PATTERN,
|
||||
),
|
||||
(
|
||||
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
||||
_create_presence_override(hass, find_runtime),
|
||||
SCHEMA_CREATE_PRESENCE_OVERRIDE,
|
||||
),
|
||||
(
|
||||
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
||||
_delete_presence_override(hass, find_runtime),
|
||||
SCHEMA_DELETE_PRESENCE_OVERRIDE,
|
||||
),
|
||||
(
|
||||
SERVICE_EXPORT_HISTORY_CSV,
|
||||
_export_history_csv,
|
||||
SCHEMA_EXPORT_HISTORY_CSV,
|
||||
),
|
||||
(
|
||||
SERVICE_MANAGE_CHILD,
|
||||
_manage_child(hass, find_runtime),
|
||||
SCHEMA_MANAGE_CHILD,
|
||||
),
|
||||
]
|
||||
|
||||
def _database_of(_call: Any) -> Any:
|
||||
return _require_runtime(hass, find_runtime).database
|
||||
|
||||
for service, handler, schema in specs:
|
||||
gated = gate(
|
||||
service, SERVICE_POLICY[service], _database_of
|
||||
)(handler)
|
||||
hass.services.async_register(DOMAIN, service, gated, schema=schema)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
_complete_quest(hass, find_runtime),
|
||||
schema=SCHEMA_COMPLETE_QUEST,
|
||||
)
|
||||
|
||||
|
||||
def async_deregister_services(hass: HomeAssistant) -> None:
|
||||
"""Remove every canonical service when the last entry unloads."""
|
||||
for service in DOMAIN_SERVICES:
|
||||
if hass.services.has_service(DOMAIN, service):
|
||||
hass.services.async_remove(DOMAIN, service)
|
||||
hass.services.async_remove(DOMAIN, service)
|
||||
@@ -1,15 +1,8 @@
|
||||
# Service declarations for the NestQuest integration (Home Assistant
|
||||
# services.yaml metadata). Services are domain-scoped: nested under the
|
||||
# "nestquest" domain, not repeated per config entry.
|
||||
|
||||
regenerate:
|
||||
name: Regenerate quest instances
|
||||
description: >-
|
||||
Regenerate every future quest instance for the household over the rolling
|
||||
horizon, using the same idempotent materialization as the daily rollover
|
||||
and the startup backfill. Safe to call at any time: a re-run refreshes the
|
||||
same rows instead of duplicating them, and completed instances are never
|
||||
touched.
|
||||
# "nestquest" domain, not repeated per config entry. Since Feature 18
|
||||
# only the panel completion service remains — every admin operation
|
||||
# moved to the PWA and the API service's admin routes.
|
||||
|
||||
complete_quest:
|
||||
name: Complete quest
|
||||
@@ -43,284 +36,4 @@ complete_quest:
|
||||
description: Home Assistant user id when actor is user and no call context is present.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
|
||||
uncomplete_quest:
|
||||
name: Uncomplete quest
|
||||
description: Undo a quest instance completion.
|
||||
fields:
|
||||
instance_id:
|
||||
name: Instance ID
|
||||
description: The quest instance to uncomplete.
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
actor:
|
||||
name: Actor
|
||||
description: Who is undoing the completion (panel or user).
|
||||
required: true
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- panel
|
||||
- user
|
||||
actor_child_id:
|
||||
name: Actor child ID
|
||||
description: The tapped child profile. Required when actor is panel.
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
actor_user_id:
|
||||
name: Actor user ID
|
||||
description: Home Assistant user id when actor is user and no call context is present.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
|
||||
create_quest_definition:
|
||||
name: Create quest definition
|
||||
description: Create a quest definition with its schedule rule, assignees and windows.
|
||||
fields:
|
||||
title:
|
||||
name: Title
|
||||
description: Display title for the quest.
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
rule:
|
||||
name: Rule
|
||||
description: Recurrence rule as a mapping (rule_type plus shape fields).
|
||||
required: true
|
||||
selector:
|
||||
object:
|
||||
assignee_child_ids:
|
||||
name: Assignee child IDs
|
||||
description: Child ids this definition is assigned to.
|
||||
required: true
|
||||
selector:
|
||||
object:
|
||||
windows:
|
||||
name: Windows
|
||||
description: Day windows (morning, afternoon, evening), optionally with a due time.
|
||||
required: true
|
||||
selector:
|
||||
object:
|
||||
description:
|
||||
name: Description
|
||||
description: Optional longer description.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
icon:
|
||||
name: Icon
|
||||
description: Optional icon name.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
|
||||
update_quest_definition:
|
||||
name: Update quest definition
|
||||
description: Edit a quest definition's title, description, icon, rule or windows.
|
||||
fields:
|
||||
definition_id:
|
||||
name: Definition ID
|
||||
description: The quest definition to update.
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
title:
|
||||
name: Title
|
||||
description: Replacement display title.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
description:
|
||||
name: Description
|
||||
description: Replacement description, or null to clear.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
icon:
|
||||
name: Icon
|
||||
description: Replacement icon, or null to clear.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
rule:
|
||||
name: Rule
|
||||
description: Replacement recurrence rule as a mapping.
|
||||
required: false
|
||||
selector:
|
||||
object:
|
||||
windows:
|
||||
name: Windows
|
||||
description: Replacement day windows.
|
||||
required: false
|
||||
selector:
|
||||
object:
|
||||
|
||||
set_quest_definition_active:
|
||||
name: Set quest definition active
|
||||
description: Deactivate or reactivate a quest definition.
|
||||
fields:
|
||||
definition_id:
|
||||
name: Definition ID
|
||||
description: The quest definition to update.
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
is_active:
|
||||
name: Active
|
||||
description: Whether the definition should generate future instances.
|
||||
required: true
|
||||
selector:
|
||||
boolean:
|
||||
|
||||
set_presence_pattern:
|
||||
name: Set presence pattern
|
||||
description: Upsert a child's repeating presence schedule.
|
||||
fields:
|
||||
child_id:
|
||||
name: Child ID
|
||||
description: The child whose schedule is being set.
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
cycle_length_weeks:
|
||||
name: Cycle length weeks
|
||||
description: Number of weeks in the repeating cycle (1 to 4).
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
min: 1
|
||||
max: 4
|
||||
anchor_date:
|
||||
name: Anchor date
|
||||
description: Cycle anchor as a YYYY-MM-DD date.
|
||||
required: true
|
||||
selector:
|
||||
date:
|
||||
pattern:
|
||||
name: Pattern
|
||||
description: Mapping of week index to present weekday ints (Monday is 0).
|
||||
required: true
|
||||
selector:
|
||||
object:
|
||||
|
||||
create_presence_override:
|
||||
name: Create presence override
|
||||
description: Create a date-range presence override for a child.
|
||||
fields:
|
||||
child_id:
|
||||
name: Child ID
|
||||
description: The child the override applies to.
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
start_date:
|
||||
name: Start date
|
||||
description: Inclusive start date (YYYY-MM-DD).
|
||||
required: true
|
||||
selector:
|
||||
date:
|
||||
end_date:
|
||||
name: End date
|
||||
description: Inclusive end date (YYYY-MM-DD).
|
||||
required: true
|
||||
selector:
|
||||
date:
|
||||
is_present:
|
||||
name: Present
|
||||
description: Whether the child is present for this range.
|
||||
required: true
|
||||
selector:
|
||||
boolean:
|
||||
note:
|
||||
name: Note
|
||||
description: Optional note stored with the override.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
|
||||
delete_presence_override:
|
||||
name: Delete presence override
|
||||
description: Delete a presence override by id.
|
||||
fields:
|
||||
override_id:
|
||||
name: Override ID
|
||||
description: The override to delete.
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
|
||||
export_history_csv:
|
||||
name: Export history CSV
|
||||
description: Export completion history as CSV. Not implemented until Feature 13.
|
||||
|
||||
manage_child:
|
||||
name: Manage child
|
||||
description: Create, edit, activate or reorder child profiles.
|
||||
fields:
|
||||
action:
|
||||
name: Action
|
||||
description: create, edit, set_active, or reorder.
|
||||
required: true
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- create
|
||||
- edit
|
||||
- set_active
|
||||
- reorder
|
||||
child_id:
|
||||
name: Child ID
|
||||
description: Required for edit and set_active.
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
display_name:
|
||||
name: Display name
|
||||
description: Required for create; optional for edit.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
colour:
|
||||
name: Colour
|
||||
description: Optional colour string.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
avatar_ref:
|
||||
name: Avatar
|
||||
description: Optional avatar reference.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
sort_order:
|
||||
name: Sort order
|
||||
description: Optional panel sort order.
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
is_active:
|
||||
name: Active
|
||||
description: Required for set_active.
|
||||
required: false
|
||||
selector:
|
||||
boolean:
|
||||
ordered_ids:
|
||||
name: Ordered IDs
|
||||
description: Complete permutation of child ids. Required for reorder.
|
||||
required: false
|
||||
selector:
|
||||
object:
|
||||
text:
|
||||
@@ -0,0 +1,172 @@
|
||||
"""HA-bus subscription over the API service's panel SSE stream.
|
||||
|
||||
The NestQuest API service (Feature 16) publishes every quest
|
||||
transition on ``GET /api/v1/panel/events`` as Server-Sent Events
|
||||
frames. This module is the integration-side subscriber: ONE
|
||||
background task consumes :meth:`~.api_client.NestQuestApiClient
|
||||
.stream_events` and re-fires each frame on ``hass.bus`` with the SAME
|
||||
event type and payload fields the local transition path
|
||||
(:mod:`~.events`) emits — ``nestquest_quest_completed``,
|
||||
``nestquest_quest_uncompleted``, ``nestquest_quest_missed`` and
|
||||
``nestquest_child_day_complete`` — so the four blueprints fire
|
||||
identically whether a completion happens through the integration's own
|
||||
service or through the API service's panel route.
|
||||
|
||||
Failure handling: a failed stream (API down, network blip, non-2xx, a
|
||||
dropped connection) is retried FOREVER with a bounded exponential
|
||||
backoff — :data:`RECONNECT_MIN_SECONDS` after the first failure,
|
||||
doubling up to :data:`RECONNECT_MAX_SECONDS` — so a long API outage
|
||||
neither pins the event loop nor spams the log, and a frame received on
|
||||
a healthy stream resets the backoff. Nothing escapes the task: every
|
||||
failure is logged with its reason and the loop reconnects; a
|
||||
:class:`asyncio.CancelledError` (from :meth:`stop` or integration
|
||||
unload) is the only thing that ends it, and it stops cleanly — no
|
||||
frame fires after :meth:`stop` returns and the response is closed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from .const import LOGGER
|
||||
|
||||
__all__ = [
|
||||
"NestQuestEventStream",
|
||||
"RECONNECT_MAX_SECONDS",
|
||||
"RECONNECT_MIN_SECONDS",
|
||||
]
|
||||
|
||||
#: Delay before the FIRST reconnect attempt after a stream failure.
|
||||
#: One failed frame must not turn into a hot reconnect loop.
|
||||
RECONNECT_MIN_SECONDS = 5.0
|
||||
|
||||
#: Ceiling for the exponential reconnect backoff: a long API outage
|
||||
#: retries at a calm fixed cadence instead of growing without bound.
|
||||
RECONNECT_MAX_SECONDS = 60.0
|
||||
|
||||
|
||||
class NestQuestEventStream:
|
||||
"""Consume the panel SSE stream in the background, fire the HA bus.
|
||||
|
||||
ONE instance per config entry, built in integration setup with the
|
||||
SAME API client the coordinator polls (the client is stateless —
|
||||
one shared session, one connection pool). ``start()`` schedules
|
||||
the background task; ``stop()`` cancels and awaits it, so unload
|
||||
never leaks the task and no frame fires after teardown. The task
|
||||
itself is failure-proof: any exception from the stream (typed
|
||||
:class:`~.api_client.NestQuestApiError` or anything else) is
|
||||
logged and retried after the current backoff, never allowed to
|
||||
escape the task.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: Any,
|
||||
client: Any,
|
||||
*,
|
||||
reconnect_min_seconds: float = RECONNECT_MIN_SECONDS,
|
||||
reconnect_max_seconds: float = RECONNECT_MAX_SECONDS,
|
||||
) -> None:
|
||||
"""Store the hass bus target, the API client, and the backoff.
|
||||
|
||||
The client is anything exposing ``stream_events()`` (the real
|
||||
:class:`~.api_client.NestQuestApiClient` in production, a stub
|
||||
in tests). The backoff bounds must be positive and the minimum
|
||||
must not exceed the maximum — a misconfiguration should fail
|
||||
loudly at construction, not quietly reconnect at delay 0.
|
||||
"""
|
||||
if (
|
||||
not isinstance(reconnect_min_seconds, (int, float))
|
||||
or isinstance(reconnect_min_seconds, bool)
|
||||
or reconnect_min_seconds <= 0
|
||||
or reconnect_min_seconds > reconnect_max_seconds
|
||||
):
|
||||
raise ValueError(
|
||||
"reconnect_min_seconds must be a positive number no "
|
||||
f"greater than reconnect_max_seconds, got "
|
||||
f"{reconnect_min_seconds!r}"
|
||||
)
|
||||
if (
|
||||
not isinstance(reconnect_max_seconds, (int, float))
|
||||
or isinstance(reconnect_max_seconds, bool)
|
||||
or reconnect_max_seconds <= 0
|
||||
):
|
||||
raise ValueError(
|
||||
f"reconnect_max_seconds must be a positive number, got "
|
||||
f"{reconnect_max_seconds!r}"
|
||||
)
|
||||
self._hass = hass
|
||||
self._client = client
|
||||
self._min_backoff = float(reconnect_min_seconds)
|
||||
self._max_backoff = float(reconnect_max_seconds)
|
||||
self._backoff = float(reconnect_min_seconds)
|
||||
self._task: asyncio.Task | None = None
|
||||
|
||||
@property
|
||||
def task(self) -> asyncio.Task | None:
|
||||
"""The background task, or ``None`` when never started/stopped."""
|
||||
return self._task
|
||||
|
||||
def start(self) -> None:
|
||||
"""Schedule the subscription task (idempotent while it runs)."""
|
||||
if self._task is not None and not self._task.done():
|
||||
return
|
||||
self._backoff = self._min_backoff
|
||||
self._task = asyncio.create_task(
|
||||
self._run(), name="nestquest-sse-subscription"
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Cancel and await the subscription task.
|
||||
|
||||
Returns only after the task has actually finished, so unload
|
||||
can rely on: no further frame is fired after ``stop()``, and
|
||||
the underlying stream response has been closed. Safe to call
|
||||
when the task was never started or already stopped.
|
||||
"""
|
||||
task, self._task = self._task, None
|
||||
if task is None:
|
||||
return
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
# The task's own cancellation surfaces here — expected and
|
||||
# swallowed; a cancelling the CALLER would re-raise out of
|
||||
# the surrounding await on its own.
|
||||
pass
|
||||
|
||||
async def _run(self) -> None:
|
||||
"""Consume stream frames forever, reconnecting on failure."""
|
||||
while True:
|
||||
try:
|
||||
async for event_type, payload in self._client.stream_events():
|
||||
# A frame arrived on a healthy stream: the next
|
||||
# failure reconnects quickly again.
|
||||
self._backoff = self._min_backoff
|
||||
try:
|
||||
self._hass.bus.async_fire(event_type, dict(payload))
|
||||
except Exception as err:
|
||||
# Firing one frame must never tear down a
|
||||
# healthy connection: log and keep consuming.
|
||||
LOGGER.error(
|
||||
"NestQuest panel event %s could not be fired "
|
||||
"on the Home Assistant event bus: %s",
|
||||
event_type,
|
||||
err,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
# Unload / shutdown: stop cleanly, never converted.
|
||||
raise
|
||||
except Exception as err:
|
||||
delay = self._backoff
|
||||
self._backoff = min(
|
||||
self._backoff * 2, self._max_backoff
|
||||
)
|
||||
LOGGER.warning(
|
||||
"NestQuest panel event stream failed (%s); "
|
||||
"reconnecting in %.1f s",
|
||||
err,
|
||||
delay,
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
@@ -1,32 +0,0 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.store` for the integration package.
|
||||
|
||||
The path resolver lives in
|
||||
:mod:`custom_components.nestquest.core.store` (extracted in task
|
||||
d0b691d8) and is Home-Assistant-free:
|
||||
:func:`custom_components.nestquest.core.store.async_get_db_path`
|
||||
takes the full database path. This module re-exports it and provides
|
||||
the original ``async_get_db_path(hass)`` signature so the integration
|
||||
resolves the path from ``hass.config.path`` exactly as before.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .core.const import SQLITE_DB_FILENAME
|
||||
from .core.store import async_get_db_path as _core_async_get_db_path
|
||||
from .core.store import _ensure_dir
|
||||
|
||||
__all__ = ["async_get_db_path", "_ensure_dir"]
|
||||
|
||||
|
||||
async def async_get_db_path(hass: HomeAssistant) -> Path:
|
||||
"""Return the absolute path to the NestQuest SQLite database file.
|
||||
|
||||
Resolves the database file from ``hass.config.path`` and delegates to
|
||||
:func:`custom_components.nestquest.core.store.async_get_db_path`,
|
||||
which ensures the parent directory exists off the event loop via
|
||||
:func:`asyncio.to_thread`.
|
||||
"""
|
||||
return await _core_async_get_db_path(hass.config.path(SQLITE_DB_FILENAME))
|
||||
@@ -18,23 +18,19 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "NestQuest Settings",
|
||||
"description": "Adjust how NestQuest plans, rolls over days, idles its panel, and reaches the NestQuest API service.",
|
||||
"description": "Adjust how the NestQuest API client polls and reaches the NestQuest API service.",
|
||||
"data": {
|
||||
"horizon_days": "Generation horizon (days)",
|
||||
"day_rollover_time": "Day rollover time (HH:MM)",
|
||||
"panel_idle_timeout": "Panel idle timeout (seconds)",
|
||||
"snapshot_staleness": "Last-good snapshot staleness (seconds)",
|
||||
"api_base_url": "NestQuest API base URL (http://host:port)",
|
||||
"panel_token": "Panel service token",
|
||||
"admin_user_ids": "Admin users (parents)"
|
||||
"panel_token": "Panel service token"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"invalid": "Invalid value.",
|
||||
"invalid_time": "Time must use 24-hour HH:MM format.",
|
||||
"invalid_url": "Enter the API base URL as a full http:// or https:// URL with a host.",
|
||||
"no_admins": "Select at least one admin user; an empty allowlist would lock everyone out.",
|
||||
"invalid_admin": "Invalid admin selection."
|
||||
"invalid_staleness": "Last-good snapshot staleness must be at least the update interval."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
"""The nightly missed-quest sweep (Feature 11) — the Home Assistant shim.
|
||||
|
||||
The sweep's policy is the HA-free core's :mod:`~.core.sweep`: the
|
||||
watermark read → ``list_missed_unswept`` query → payload build →
|
||||
watermark span, all inside the per-database sweep lock, shared with
|
||||
the API plane. This module is the integration's thin adapter over it:
|
||||
at the configured day-rollover time (the same HA-local listener that
|
||||
re-materializes the rolling horizon) and once at setup it calls the
|
||||
core's :func:`~.core.sweep.run_missed_sweep` and fires each returned
|
||||
event on ``hass.bus`` — ONE ``nestquest_quest_missed`` per past-due
|
||||
open instance with the documented payload. The core never touches
|
||||
Home Assistant; the firing lives here.
|
||||
|
||||
Idempotency is the core's watermark: after a run, ``nestquest_meta_state``
|
||||
records the HA-local date the sweep ran, and the next run only sweeps
|
||||
instances due in ``[watermark, today)`` — a second run the same night
|
||||
fires nothing new, and a run after days of downtime sweeps exactly the
|
||||
accumulated window once. An instance swept as missed that is later
|
||||
completed simply gains its completion event; the sweep is read-only
|
||||
over the domain tables (``missed`` is derived state, never an
|
||||
event_type — Feature 08's contract).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from .core.sweep import (
|
||||
SWEEP_WATERMARK_KEY,
|
||||
run_missed_sweep as _run_core_missed_sweep,
|
||||
)
|
||||
from .db import NestQuestDatabase
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["SWEEP_WATERMARK_KEY", "run_missed_sweep"]
|
||||
|
||||
|
||||
async def run_missed_sweep(
|
||||
hass,
|
||||
database: NestQuestDatabase,
|
||||
*,
|
||||
today: datetime.date | None = None,
|
||||
) -> int:
|
||||
"""Fire one missed event per unswept past-due open instance.
|
||||
|
||||
``today`` optionally pins the HA-local date (threaded like the
|
||||
materialization and completion layers); when omitted it is read
|
||||
from ``hass.config.time_zone``. Returns the number of events
|
||||
fired. Fires nothing and returns 0 when the watermark already
|
||||
covers ``today`` (a same-night rerun) — the watermark read, the
|
||||
query, the payload build and the watermark write all live in the
|
||||
core's locked
|
||||
:func:`~.core.sweep.run_missed_sweep`; this shim only fires each
|
||||
returned event on the bus and counts them.
|
||||
"""
|
||||
today_date = (
|
||||
today
|
||||
if today is not None
|
||||
else datetime.datetime.now(ZoneInfo(hass.config.time_zone)).date()
|
||||
)
|
||||
events = await _run_core_missed_sweep(database, today=today_date)
|
||||
for event_type, payload in events:
|
||||
hass.bus.async_fire(event_type, payload)
|
||||
fired = len(events)
|
||||
LOGGER.info(
|
||||
"NestQuest missed sweep for %s fired %d event(s)",
|
||||
today_date.isoformat(),
|
||||
fired,
|
||||
)
|
||||
return fired
|
||||
@@ -18,23 +18,19 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "NestQuest Settings",
|
||||
"description": "Adjust how NestQuest plans, rolls over days, idles its panel, and reaches the NestQuest API service.",
|
||||
"description": "Adjust how the NestQuest API client polls and reaches the NestQuest API service.",
|
||||
"data": {
|
||||
"horizon_days": "Generation horizon (days)",
|
||||
"day_rollover_time": "Day rollover time (HH:MM)",
|
||||
"panel_idle_timeout": "Panel idle timeout (seconds)",
|
||||
"snapshot_staleness": "Last-good snapshot staleness (seconds)",
|
||||
"api_base_url": "NestQuest API base URL (http://host:port)",
|
||||
"panel_token": "Panel service token",
|
||||
"admin_user_ids": "Admin users (parents)"
|
||||
"panel_token": "Panel service token"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"invalid": "Invalid value.",
|
||||
"invalid_time": "Time must use 24-hour HH:MM format.",
|
||||
"invalid_url": "Enter the API base URL as a full http:// or https:// URL with a host.",
|
||||
"no_admins": "Select at least one admin user; an empty allowlist would lock everyone out.",
|
||||
"invalid_admin": "Invalid admin selection."
|
||||
"invalid_staleness": "Last-good snapshot staleness must be at least the update interval."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
Generated
+522
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"lit": "^3.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jsdom": "^30.1.1",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.1.5"
|
||||
}
|
||||
|
||||
@@ -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,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=""]')) {
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
+284
-43
@@ -22,19 +22,22 @@ pytest -q``); it is never meant to run against a real HA install.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
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
|
||||
|
||||
@@ -47,11 +50,13 @@ _HA_MODULES = (
|
||||
"homeassistant.config_entries",
|
||||
"homeassistant.data_entry_flow",
|
||||
"homeassistant.exceptions",
|
||||
"homeassistant.util",
|
||||
"homeassistant.helpers",
|
||||
"homeassistant.helpers.config_validation",
|
||||
"homeassistant.helpers.entity",
|
||||
"homeassistant.helpers.event",
|
||||
"homeassistant.helpers.update_coordinator",
|
||||
"homeassistant.helpers.aiohttp_client",
|
||||
"homeassistant.components",
|
||||
"homeassistant.components.sensor",
|
||||
"homeassistant.components.binary_sensor",
|
||||
@@ -94,11 +99,13 @@ for _parent, _child in (
|
||||
("homeassistant", "data_entry_flow"),
|
||||
("homeassistant", "core"),
|
||||
("homeassistant", "exceptions"),
|
||||
("homeassistant", "util"),
|
||||
("homeassistant", "helpers"),
|
||||
("homeassistant.helpers", "config_validation"),
|
||||
("homeassistant.helpers", "entity"),
|
||||
("homeassistant.helpers", "event"),
|
||||
("homeassistant.helpers", "update_coordinator"),
|
||||
("homeassistant.helpers", "aiohttp_client"),
|
||||
("homeassistant", "components"),
|
||||
("homeassistant.components", "sensor"),
|
||||
("homeassistant.components", "binary_sensor"),
|
||||
@@ -217,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.
|
||||
@@ -365,6 +434,15 @@ class BinarySensorEntity(Entity):
|
||||
return self._attr_device_class
|
||||
|
||||
|
||||
class UpdateFailed(HomeAssistantError):
|
||||
"""Stand-in mirroring homeassistant.helpers.update_coordinator.UpdateFailed.
|
||||
|
||||
The exception an update pass raises to report a failed fetch: the
|
||||
coordinator records ``last_update_success = False`` instead of
|
||||
propagating it out of the refresh.
|
||||
"""
|
||||
|
||||
|
||||
class DataUpdateCoordinator:
|
||||
"""Functional stand-in for HA's DataUpdateCoordinator.
|
||||
|
||||
@@ -373,8 +451,20 @@ class DataUpdateCoordinator:
|
||||
``async_add_listener``/``async_update_listeners`` for entity
|
||||
subscription, ``async_refresh``/``async_config_entry_first_refresh``
|
||||
driving the subclass's ``_async_update_data``, ``last_update_success``,
|
||||
and ``data``. Interval scheduling is NOT simulated (no real clock
|
||||
ticks in tests) — tests drive refreshes directly.
|
||||
``last_exception``, and ``data``. Interval scheduling is NOT
|
||||
simulated (no real clock ticks in tests) — tests drive refreshes
|
||||
directly.
|
||||
|
||||
The failure contract mirrors real HA exactly (it is the trap this
|
||||
harness once hid): an update failure NEVER propagates out of
|
||||
``async_refresh`` — the coordinator records
|
||||
``last_update_success = False``, keeps its last good ``data``, and
|
||||
still notifies listeners so entities re-render unavailable — and
|
||||
``async_config_entry_first_refresh`` wraps ANY failed pass in
|
||||
:class:`ConfigEntryNotReady`, with the original error only
|
||||
surviving as ``__cause__``. A stand-in that re-raised the raw
|
||||
update error would let setup code key on the original exception
|
||||
type — a clause real HA can never reach.
|
||||
"""
|
||||
|
||||
def __init__(self, hass, logger, *, name=None, update_interval=None):
|
||||
@@ -384,6 +474,7 @@ class DataUpdateCoordinator:
|
||||
self.update_interval = update_interval
|
||||
self.data = None
|
||||
self.last_update_success = False
|
||||
self.last_exception = None
|
||||
self._listeners = []
|
||||
|
||||
def async_add_listener(self, update_callback, context=None):
|
||||
@@ -403,18 +494,35 @@ class DataUpdateCoordinator:
|
||||
callback()
|
||||
|
||||
async def async_refresh(self):
|
||||
"""Run one update pass and notify listeners."""
|
||||
"""Run one update pass and notify listeners.
|
||||
|
||||
Mirrors HA: a failed update pass is RECORDED, never raised —
|
||||
``last_update_success`` goes False, the exception lands on
|
||||
``last_exception``, and listeners still fire; only cancellation
|
||||
propagates.
|
||||
"""
|
||||
try:
|
||||
self.data = await self._async_update_data()
|
||||
self.last_update_success = True
|
||||
except BaseException:
|
||||
except Exception as err:
|
||||
self.last_update_success = False
|
||||
raise
|
||||
self.last_exception = err
|
||||
self.async_update_listeners()
|
||||
|
||||
async def async_config_entry_first_refresh(self):
|
||||
"""First refresh at config-entry setup (alias of refresh)."""
|
||||
"""First refresh at config-entry setup, per HA's real contract.
|
||||
|
||||
HA's real ``async_config_entry_first_refresh`` wraps ANY failed
|
||||
update pass in ``ConfigEntryNotReady`` — the original error
|
||||
survives only as ``__cause__`` — so setup code that must
|
||||
tolerate a failed first refresh keys on ConfigEntryNotReady,
|
||||
never on the original exception type.
|
||||
"""
|
||||
await self.async_refresh()
|
||||
if not self.last_update_success:
|
||||
raise ConfigEntryNotReady(
|
||||
f"error fetching {self.name} data: {self.last_exception}"
|
||||
) from self.last_exception
|
||||
|
||||
async def async_shutdown(self):
|
||||
"""Release listeners on teardown."""
|
||||
@@ -424,6 +532,7 @@ class DataUpdateCoordinator:
|
||||
_update_coordinator_mock = _ha_mock("homeassistant.helpers.update_coordinator")
|
||||
_update_coordinator_mock.DataUpdateCoordinator = DataUpdateCoordinator
|
||||
_update_coordinator_mock.CoordinatorEntity = CoordinatorEntity
|
||||
_update_coordinator_mock.UpdateFailed = UpdateFailed
|
||||
|
||||
_entity_mock = _ha_mock("homeassistant.helpers.entity")
|
||||
_entity_mock.DeviceInfo = DeviceInfo
|
||||
@@ -700,6 +809,174 @@ def wire_entry_to_registry(entry, registry: ListenerRegistry):
|
||||
return entry
|
||||
|
||||
|
||||
class StubSnapshotClient:
|
||||
"""A no-network stand-in for the API client's snapshot surface.
|
||||
|
||||
Answers ``get_snapshot()`` from a scripted queue of outcomes — a
|
||||
payload dict or an exception per call, consumed in order — and
|
||||
records every call. When the queue is exhausted it re-serves the
|
||||
last outcome, so setup's first refresh and a test's explicit
|
||||
refreshes poll the same scripted state. Register it (or any
|
||||
scripted client) with :func:`set_coordinator_client` BEFORE setup.
|
||||
"""
|
||||
|
||||
def __init__(self, *outcomes) -> None:
|
||||
self.calls = 0
|
||||
self._outcomes = list(outcomes)
|
||||
self._last = outcomes[-1] if outcomes else None
|
||||
#: The SSE frames the stand-in "published" (the API service's
|
||||
# stream content), drained by :func:`refire_api_transitions`.
|
||||
self.published_frames = []
|
||||
|
||||
async def get_snapshot(self):
|
||||
self.calls += 1
|
||||
if not self._outcomes:
|
||||
# Re-serve the last outcome: setup's first refresh and the
|
||||
# test's explicit pass both poll the same scripted state.
|
||||
outcome = self._last
|
||||
else:
|
||||
outcome = self._outcomes.pop(0)
|
||||
self._last = outcome
|
||||
if isinstance(outcome, BaseException):
|
||||
raise outcome
|
||||
return outcome
|
||||
|
||||
|
||||
class ScriptedPanelClient(StubSnapshotClient):
|
||||
"""Snapshot + completion stand-in for the API service's panel plane.
|
||||
|
||||
Serves the scripted snapshot outcomes (like
|
||||
:class:`StubSnapshotClient`), records ``complete_instance`` calls
|
||||
the way the panel route receives them, optionally fails them the
|
||||
way the client types the failure, and carries the SSE frames the
|
||||
API "published" in ``published_frames`` for
|
||||
:func:`refire_api_transitions` to deliver. Everything the
|
||||
integration reads over the wire, no network.
|
||||
"""
|
||||
|
||||
def __init__(self, *outcomes) -> None:
|
||||
super().__init__(*outcomes)
|
||||
self.complete_calls: list[tuple[int, int]] = []
|
||||
self.published_frames: list[tuple[str, dict]] = []
|
||||
#: When set, the next ``complete_instance`` raises this instead
|
||||
# of succeeding (a typed NestQuestApiError in production).
|
||||
self.fail_complete_with: BaseException | None = None
|
||||
|
||||
async def complete_instance(
|
||||
self, instance_id: int, actor_child_id: int
|
||||
) -> dict:
|
||||
self.complete_calls.append((instance_id, actor_child_id))
|
||||
if self.fail_complete_with is not None:
|
||||
raise self.fail_complete_with
|
||||
return {"status": "done"}
|
||||
|
||||
|
||||
def set_coordinator_client(entry, client):
|
||||
"""Register a scripted client for ``entry``'s coordinator factory."""
|
||||
import custom_components.nestquest.coordinator as coordinator_module
|
||||
|
||||
coordinator_module._COORDINATOR_CLIENT_OVERRIDES[entry.entry_id] = client
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def coordinator_client_from_test_override():
|
||||
"""Resolve every entry's coordinator client through the test seam.
|
||||
|
||||
The integration's setup builds its API client through
|
||||
``coordinator_client_from_entry``; this fixture swaps that factory
|
||||
for a test one handing out the client registered with
|
||||
:func:`set_coordinator_client`, or — for an entry with no scripted
|
||||
client — a stub serving an EMPTY but valid household snapshot (the
|
||||
shape the API's panel route returns before any child exists; the
|
||||
real client would need aiohttp, which this mock-only harness does
|
||||
not provide).
|
||||
"""
|
||||
import datetime
|
||||
import unittest.mock as mock
|
||||
|
||||
import custom_components.nestquest as nq_mod
|
||||
import custom_components.nestquest.coordinator as coordinator_module
|
||||
|
||||
_COORDINATOR_CLIENT_OVERRIDES = (
|
||||
coordinator_module._COORDINATOR_CLIENT_OVERRIDES
|
||||
)
|
||||
|
||||
def _factory(hass, entry, **kwargs):
|
||||
override = _COORDINATOR_CLIENT_OVERRIDES.get(entry.entry_id)
|
||||
if override is not None:
|
||||
return override
|
||||
return StubSnapshotClient(
|
||||
{
|
||||
"today_iso": datetime.date.today().isoformat(),
|
||||
"cycle_day": 0,
|
||||
"children": [],
|
||||
}
|
||||
)
|
||||
|
||||
_COORDINATOR_CLIENT_OVERRIDES.clear()
|
||||
patcher = mock.patch.object(
|
||||
nq_mod, "coordinator_client_from_entry", _factory
|
||||
)
|
||||
patcher.start()
|
||||
yield
|
||||
patcher.stop()
|
||||
_COORDINATOR_CLIENT_OVERRIDES.clear()
|
||||
|
||||
|
||||
async def refire_api_transitions(hass, entry, client):
|
||||
"""Deliver the SSE frames the stand-in client has published so far
|
||||
through the integration's SSE subscription — the production event
|
||||
path.
|
||||
|
||||
Since Feature 18 the ``complete_quest`` service fires no local
|
||||
bus event: the API service builds and publishes the transition
|
||||
frames on its SSE stream, and the integration's subscription
|
||||
(:mod:`custom_components.nestquest.sse`) re-fires each frame on
|
||||
the bus. This helper streams the frames the stand-in client
|
||||
published (``client.published_frames``) through a real
|
||||
:class:`~custom_components.nestquest.sse.NestQuestEventStream`
|
||||
and waits (bounded) until the bus has re-fired every one of them.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
from custom_components.nestquest.sse import NestQuestEventStream
|
||||
|
||||
frames, client.published_frames = client.published_frames, []
|
||||
|
||||
class _ScriptedStream:
|
||||
"""A healthy stream: the frames, then it stays open."""
|
||||
|
||||
def __init__(self, frames) -> None:
|
||||
self._frames = frames
|
||||
|
||||
def stream_events(self):
|
||||
async def _gen():
|
||||
for frame in self._frames:
|
||||
yield frame
|
||||
await asyncio.Event().wait()
|
||||
|
||||
return _gen()
|
||||
|
||||
before = {etype: len(hass.bus.fired(etype)) for etype, _ in frames}
|
||||
expected = len(frames)
|
||||
manager = NestQuestEventStream(hass, _ScriptedStream(frames))
|
||||
manager.start()
|
||||
try:
|
||||
async with asyncio.timeout(2):
|
||||
while True:
|
||||
delivered = sum(
|
||||
len(hass.bus.fired(etype)) - before[etype]
|
||||
for etype, _ in frames
|
||||
)
|
||||
if delivered >= expected:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
finally:
|
||||
await manager.stop()
|
||||
return frames
|
||||
|
||||
|
||||
class _HassNamespace(SimpleNamespace):
|
||||
"""SimpleNamespace subclass so the hass stub can hold weak references."""
|
||||
|
||||
@@ -923,42 +1200,6 @@ def make_config_flow_entry():
|
||||
return _factory
|
||||
|
||||
|
||||
def freeze_nestquest_clock(
|
||||
monkeypatch, instant: datetime.datetime
|
||||
) -> tuple[datetime.datetime, list]:
|
||||
"""Freeze the integration's ``datetime`` clock to a fixed aware instant.
|
||||
|
||||
Only ``custom_components.nestquest``'s ``datetime`` binding is patched,
|
||||
so the generation path's ``datetime.datetime.now`` reads this instant
|
||||
(localized to whatever time zone it asks for) while every other module
|
||||
and the test harness keep the real clock. Returns ``(frozen, requested)``
|
||||
where ``frozen`` is the instant normalized to UTC and ``requested`` is
|
||||
the list of ``tzinfo`` objects the code under test passed to ``now`` —
|
||||
so a caller can prove the generation path actually requested the
|
||||
configured HA time zone rather than silently reading UTC.
|
||||
"""
|
||||
import custom_components.nestquest as nestquest
|
||||
|
||||
frozen = instant.astimezone(datetime.timezone.utc)
|
||||
requested: list = []
|
||||
|
||||
class _FrozenDatetime(datetime.datetime):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
requested.append(tz)
|
||||
if tz is None:
|
||||
return frozen.replace(tzinfo=None)
|
||||
return frozen.astimezone(tz)
|
||||
|
||||
fake_module = SimpleNamespace(
|
||||
datetime=_FrozenDatetime,
|
||||
timedelta=datetime.timedelta,
|
||||
date=datetime.date,
|
||||
)
|
||||
monkeypatch.setattr(nestquest, "datetime", fake_module)
|
||||
return frozen, requested
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin-plane API fixtures (shared by the admin-plane test modules).
|
||||
#
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
"""Tests for admin_allowlist.py: the Feature 03 allowlist business layer."""
|
||||
"""Tests for the Feature 03 admin allowlist business layer (core.admin_allowlist)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest import async_setup_entry, async_unload_entry
|
||||
from custom_components.nestquest.admin_allowlist import (
|
||||
from custom_components.nestquest.core.admin_allowlist import (
|
||||
list_admin_ids,
|
||||
seed_setup_admin,
|
||||
set_admin_ids,
|
||||
)
|
||||
from custom_components.nestquest.dao_children import AdminUsersDao
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.migrations import apply_migrations
|
||||
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||
from custom_components.nestquest.core.migrations import apply_migrations
|
||||
|
||||
|
||||
def _run(coro):
|
||||
@@ -193,165 +190,6 @@ def test_seed_rejects_malformed_candidates(tmp_path) -> None:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Setup wiring: async_setup_entry seeds automatically
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _setup_entry(hass, entry, registry):
|
||||
from tests.test_lifecycle import _wire
|
||||
|
||||
entry = _wire(entry, registry)
|
||||
await async_setup_entry(hass, entry)
|
||||
return entry
|
||||
|
||||
|
||||
async def test_setup_seeds_allowlist_from_entry_context(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
entry = await _setup_entry(
|
||||
hass,
|
||||
make_entry(context={"user_id": "ha-owner"}),
|
||||
hass.registry,
|
||||
)
|
||||
database = entry.runtime_data.database
|
||||
try:
|
||||
assert await list_admin_ids(database) == ["ha-owner"]
|
||||
finally:
|
||||
await database.close()
|
||||
assert await async_unload_entry(hass, entry) is True
|
||||
|
||||
|
||||
async def test_setup_seeds_allowlist_from_stored_entry_data(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""A lost database re-seeded from the entry's stored admin copy."""
|
||||
from custom_components.nestquest.const import CONF_ADMIN_USER_IDS
|
||||
|
||||
entry = await _setup_entry(
|
||||
hass,
|
||||
make_entry(data={CONF_ADMIN_USER_IDS: ["user-a", "user-b"]}),
|
||||
hass.registry,
|
||||
)
|
||||
database = entry.runtime_data.database
|
||||
try:
|
||||
assert await list_admin_ids(database) == ["user-a", "user-b"]
|
||||
finally:
|
||||
await database.close()
|
||||
assert await async_unload_entry(hass, entry) is True
|
||||
|
||||
|
||||
async def test_setup_without_context_leaves_allowlist_empty(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""Fail closed: no context user and no stored copy means an empty
|
||||
allowlist — nobody is admin until the owner configures it."""
|
||||
entry = await _setup_entry(hass, make_entry(), hass.registry)
|
||||
database = entry.runtime_data.database
|
||||
try:
|
||||
assert await list_admin_ids(database) == []
|
||||
finally:
|
||||
await database.close()
|
||||
assert await async_unload_entry(hass, entry) is True
|
||||
|
||||
|
||||
async def test_setup_does_not_reseed_narrowed_allowlist(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
from custom_components.nestquest import async_unload_entry
|
||||
from custom_components.nestquest.const import CONF_ADMIN_USER_IDS
|
||||
|
||||
entry = await _setup_entry(
|
||||
hass, make_entry(context={"user_id": "ha-owner"}), hass.registry
|
||||
)
|
||||
database = entry.runtime_data.database
|
||||
await set_admin_ids(database, ["user-parent"])
|
||||
await database.close()
|
||||
assert await async_unload_entry(hass, entry) is True
|
||||
|
||||
# Reload with the SAME context: the narrowed list survives.
|
||||
entry_reloaded = await _setup_entry(
|
||||
hass, make_entry(context={"user_id": "ha-owner"}), hass.registry
|
||||
)
|
||||
database_b = entry_reloaded.runtime_data.database
|
||||
try:
|
||||
assert await list_admin_ids(database_b) == ["user-parent"]
|
||||
finally:
|
||||
await database.close()
|
||||
assert await async_unload_entry(hass, entry_reloaded) is True
|
||||
|
||||
|
||||
async def test_setup_seed_prefers_options_over_entry_data(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""Seed precedence on a fresh database: the options flow's saved
|
||||
list (the current one) beats the config flow's original copy."""
|
||||
from custom_components.nestquest.const import CONF_ADMIN_USER_IDS
|
||||
|
||||
entry = await _setup_entry(
|
||||
hass,
|
||||
make_entry(
|
||||
options={CONF_ADMIN_USER_IDS: ["user-current"]},
|
||||
data={CONF_ADMIN_USER_IDS: ["ha-owner"]},
|
||||
context={"user_id": "ha-owner"},
|
||||
),
|
||||
hass.registry,
|
||||
)
|
||||
database = entry.runtime_data.database
|
||||
try:
|
||||
assert await list_admin_ids(database) == ["user-current"]
|
||||
finally:
|
||||
await database.close()
|
||||
assert await async_unload_entry(hass, entry) is True
|
||||
|
||||
|
||||
async def test_setup_owner_fallback_seeds_owner_accounts(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""Last-resort seed: with no persisted copy and no flow-context
|
||||
user, HA owner accounts are seeded so first setup is never
|
||||
ownerless (the real flow may lose the context; owners can already
|
||||
do everything in HA)."""
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
hass.auth.async_get_users = AsyncMock(
|
||||
return_value=[
|
||||
SimpleNamespace(id="owner-1", is_owner=True),
|
||||
SimpleNamespace(id="member-1", is_owner=False),
|
||||
SimpleNamespace(id="owner-2", is_owner=True),
|
||||
]
|
||||
)
|
||||
entry = await _setup_entry(hass, make_entry(), hass.registry)
|
||||
database = entry.runtime_data.database
|
||||
try:
|
||||
assert sorted(await list_admin_ids(database)) == ["owner-1", "owner-2"]
|
||||
finally:
|
||||
await database.close()
|
||||
assert await async_unload_entry(hass, entry) is True
|
||||
|
||||
|
||||
async def test_setup_owner_fallback_not_used_when_context_present(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""The context user (flow caller) wins over the owner fallback:
|
||||
a non-owner parent configuring the integration becomes the admin."""
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
hass.auth.async_get_users = AsyncMock(
|
||||
return_value=[SimpleNamespace(id="owner-1", is_owner=True)]
|
||||
)
|
||||
entry = await _setup_entry(
|
||||
hass,
|
||||
make_entry(context={"user_id": "parent-configured"}),
|
||||
hass.registry,
|
||||
)
|
||||
database = entry.runtime_data.database
|
||||
try:
|
||||
assert await list_admin_ids(database) == ["parent-configured"]
|
||||
finally:
|
||||
await database.close()
|
||||
assert await async_unload_entry(hass, entry) is True
|
||||
|
||||
|
||||
def test_seed_owner_ids_argument(tmp_path) -> None:
|
||||
async def _body(database):
|
||||
# Malformed owner lists are rejected like any other candidate —
|
||||
@@ -377,54 +215,13 @@ def test_seed_owner_ids_argument(tmp_path) -> None:
|
||||
_with_db(tmp_path, "seed-owner-arg.db")(_body)
|
||||
|
||||
|
||||
async def test_reopened_setup_failure_closes_database(
|
||||
hass, make_entry, monkeypatch
|
||||
) -> None:
|
||||
"""A seeding failure on the reopened-record path closes the just-
|
||||
opened connection before re-raising (no leaked handles across HA's
|
||||
setup retries)."""
|
||||
import pytest as pytest_module
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import custom_components.nestquest as nestquest_module
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
|
||||
# First: a successful setup to create the runtime record and file.
|
||||
entry = await _setup_entry(hass, make_entry(), hass.registry)
|
||||
await async_unload_entry(hass, entry)
|
||||
|
||||
# Now a re-setup whose seeding fails after the reopen.
|
||||
async def _failing_seed(database, **kwargs):
|
||||
raise RuntimeError("seed boom")
|
||||
|
||||
monkeypatch.setattr(
|
||||
nestquest_module, "seed_setup_admin", _failing_seed, raising=True
|
||||
)
|
||||
|
||||
close_calls: list[bool] = []
|
||||
original_close = NestQuestDatabase.close
|
||||
|
||||
async def _spy_close(self):
|
||||
close_calls.append(True)
|
||||
return await original_close(self)
|
||||
|
||||
monkeypatch.setattr(NestQuestDatabase, "close", _spy_close)
|
||||
|
||||
with pytest_module.raises(RuntimeError, match="seed boom"):
|
||||
await _setup_entry(hass, entry, hass.registry)
|
||||
# The reopen path opened AND closed a connection; the failure is
|
||||
# the last thing observed.
|
||||
assert close_calls[-1] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# is_admin: the fail-closed resolver (Feature 09's verdict function)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_is_admin_verdict_matrix(tmp_path) -> None:
|
||||
async def _body(database):
|
||||
from custom_components.nestquest.admin_allowlist import is_admin
|
||||
from custom_components.nestquest.core.admin_allowlist import is_admin
|
||||
|
||||
await set_admin_ids(database, ["user-owner"])
|
||||
assert await is_admin(database, "user-owner") is True
|
||||
@@ -447,7 +244,7 @@ def test_is_admin_verdict_matrix(tmp_path) -> None:
|
||||
def test_is_admin_empty_allowlist_fails_closed(tmp_path) -> None:
|
||||
"""An empty allowlist returns False for EVERYONE, not True."""
|
||||
async def _body(database):
|
||||
from custom_components.nestquest.admin_allowlist import is_admin
|
||||
from custom_components.nestquest.core.admin_allowlist import is_admin
|
||||
|
||||
for candidate in ("user-owner", "", None, 1):
|
||||
assert await is_admin(database, candidate) is False
|
||||
@@ -460,7 +257,7 @@ def test_is_admin_false_after_last_admin_removal_attempt(tmp_path) -> None:
|
||||
"""The refusal keeps the verdict sound: a refused removal leaves
|
||||
the admin in place, so is_admin still resolves True."""
|
||||
async def _body(database):
|
||||
from custom_components.nestquest.admin_allowlist import is_admin
|
||||
from custom_components.nestquest.core.admin_allowlist import is_admin
|
||||
|
||||
await set_admin_ids(database, ["user-owner"])
|
||||
with pytest.raises(ValueError, match="at least one admin"):
|
||||
|
||||
@@ -215,6 +215,7 @@ async def test_create_persists_definition_with_assignees_and_windows(
|
||||
"description",
|
||||
"icon",
|
||||
"is_active",
|
||||
"skip_on_away",
|
||||
"rule",
|
||||
"assignees",
|
||||
"windows",
|
||||
@@ -223,6 +224,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",
|
||||
@@ -521,9 +523,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:
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
+131
-120
@@ -1,50 +1,85 @@
|
||||
"""Tests for the per-child binary sensors (Feature 10, task 4)."""
|
||||
"""Tests for the per-child binary sensors (Feature 10, task 4).
|
||||
|
||||
The snapshot comes from the API service's panel route (the presence
|
||||
engine resolves server-side), so these tests script the route's
|
||||
payload through :class:`conftest.StubSnapshotClient` instead of
|
||||
seeding a local database.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
|
||||
from conftest import wire_entry_to_registry
|
||||
from conftest import StubSnapshotClient, set_coordinator_client, wire_entry_to_registry
|
||||
|
||||
from custom_components.nestquest import async_setup_entry
|
||||
from custom_components.nestquest.children import create_child, list_children
|
||||
from custom_components.nestquest.const import DOMAIN
|
||||
from custom_components.nestquest.dao_presence import PresenceSchedulesDao
|
||||
from custom_components.nestquest.materialize import materialize
|
||||
from custom_components.nestquest.quest_definitions import (
|
||||
create_quest_definition,
|
||||
)
|
||||
from custom_components.nestquest.recurrence import ScheduleRule
|
||||
from custom_components.nestquest.completion import complete_instance
|
||||
|
||||
SEED_HORIZON_DAYS = 3
|
||||
|
||||
|
||||
def _binary(hass, child_id: int, kind: str):
|
||||
return hass.entities[f"{DOMAIN}_child_{child_id}_{kind}"]
|
||||
|
||||
|
||||
async def _setup_entry(hass, make_entry):
|
||||
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
return entry
|
||||
def _child(
|
||||
child_id: int,
|
||||
name: str,
|
||||
*,
|
||||
present=True,
|
||||
next_present=None,
|
||||
instances=(),
|
||||
):
|
||||
completed = sum(1 for i in instances if i["state"] == "completed")
|
||||
due = len(instances)
|
||||
return {
|
||||
"child_id": child_id,
|
||||
"child_name": name,
|
||||
"present": present,
|
||||
"next_present": next_present,
|
||||
"due_today": due,
|
||||
"completed_today": completed,
|
||||
"remaining_today": due - completed,
|
||||
"completion_pct": 100 if due == 0 else round(completed / due * 100),
|
||||
"instances": list(instances),
|
||||
}
|
||||
|
||||
|
||||
async def _seed_two_children(database) -> tuple:
|
||||
ada = await create_child(database, "Ada")
|
||||
bo = await create_child(database, "Bo")
|
||||
today = datetime.date.today()
|
||||
await create_quest_definition(
|
||||
database,
|
||||
"Brush teeth",
|
||||
ScheduleRule.from_dict(
|
||||
{"rule_type": "daily", "start_date": today.isoformat()}
|
||||
def _instance(instance_id: int, child_id: int, *, state="open") -> dict:
|
||||
return {
|
||||
"id": instance_id,
|
||||
"definition_id": 3,
|
||||
"child_id": child_id,
|
||||
"title": "Brush teeth",
|
||||
"icon": "🦷",
|
||||
"window": "morning",
|
||||
"due_time": "08:00",
|
||||
"state": state,
|
||||
"overdue": False,
|
||||
"completed_at": None if state == "open" else "2026-09-23T07:55:00+00:00",
|
||||
"on_time": None if state == "open" else True,
|
||||
}
|
||||
|
||||
|
||||
def _payload(children) -> dict:
|
||||
return {
|
||||
"today_iso": "2026-09-23",
|
||||
"cycle_day": 0,
|
||||
"children": list(children),
|
||||
}
|
||||
|
||||
|
||||
ADA = 1
|
||||
BO = 2
|
||||
CORY = 3
|
||||
|
||||
|
||||
async def _setup_entry(hass, make_entry, *payloads):
|
||||
"""Set up the integration with a scripted snapshot client."""
|
||||
entry = wire_entry_to_registry(
|
||||
make_entry(
|
||||
data={"api_base_url": "http://api.test:8000", "panel_token": "tok"}
|
||||
),
|
||||
[ada.id, bo.id],
|
||||
["morning"],
|
||||
hass.registry,
|
||||
)
|
||||
end = (today + datetime.timedelta(days=SEED_HORIZON_DAYS)).isoformat()
|
||||
await materialize(database, today.isoformat(), end, today=today)
|
||||
return ada, bo
|
||||
set_coordinator_client(entry, StubSnapshotClient(*payloads))
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
return entry, entry.runtime_data.coordinator
|
||||
|
||||
|
||||
async def test_all_done_off_with_open_quests_and_zero_quests(
|
||||
@@ -52,88 +87,76 @@ async def test_all_done_off_with_open_quests_and_zero_quests(
|
||||
) -> None:
|
||||
"""all_done is OFF while quests are owed AND on a zero-quest day;
|
||||
a child with no quests never fires the celebration automation."""
|
||||
entry = await _setup_entry(hass, make_entry)
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
database = entry.runtime_data.database
|
||||
ada, _bo = await _seed_two_children(database)
|
||||
await create_child(database, "Cory") # zero quests
|
||||
await coordinator.async_refresh()
|
||||
_entry, coordinator = await _setup_entry(
|
||||
hass,
|
||||
make_entry,
|
||||
_payload(
|
||||
[
|
||||
_child(ADA, "Ada", instances=[_instance(7, ADA)]),
|
||||
_child(BO, "Bo", instances=[_instance(8, BO)]),
|
||||
_child(CORY, "Cory"),
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
assert _binary(hass, ada.id, "all_done").is_on is False
|
||||
cory = [
|
||||
child for child in coordinator.data.children
|
||||
if child.child_name == "Cory"
|
||||
][0]
|
||||
assert _binary(hass, cory.child_id, "all_done").is_on is False
|
||||
assert _binary(hass, ADA, "all_done").is_on is False
|
||||
assert _binary(hass, BO, "all_done").is_on is False
|
||||
assert _binary(hass, CORY, "all_done").is_on is False
|
||||
|
||||
|
||||
async def test_all_done_on_only_when_day_clear(hass, make_entry) -> None:
|
||||
entry = await _setup_entry(hass, make_entry)
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
database = entry.runtime_data.database
|
||||
ada, _bo = await _seed_two_children(database)
|
||||
await coordinator.async_refresh()
|
||||
snapshot_ada = [
|
||||
child for child in coordinator.data.children
|
||||
if child.child_id == ada.id
|
||||
][0]
|
||||
instance_id = snapshot_ada.instances[0].instance_id
|
||||
await complete_instance(
|
||||
database,
|
||||
instance_id,
|
||||
actor_source="panel",
|
||||
actor_child_id=ada.id,
|
||||
_entry, coordinator = await _setup_entry(
|
||||
hass,
|
||||
make_entry,
|
||||
_payload([_child(ADA, "Ada", instances=[_instance(7, ADA)])]),
|
||||
_payload(
|
||||
[
|
||||
_child(
|
||||
ADA,
|
||||
"Ada",
|
||||
instances=[_instance(7, ADA, state="completed")],
|
||||
)
|
||||
]
|
||||
),
|
||||
)
|
||||
await coordinator.async_refresh()
|
||||
assert _binary(hass, ada.id, "all_done").is_on is True
|
||||
assert _binary(hass, ADA, "all_done").is_on is True
|
||||
|
||||
|
||||
async def test_present_today_on_without_schedule_off_when_absent(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""No schedule means present every day; an all-absent pattern
|
||||
today reports OFF."""
|
||||
entry = await _setup_entry(hass, make_entry)
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
database = entry.runtime_data.database
|
||||
ada, _bo = await _seed_two_children(database)
|
||||
await create_child(database, "Cory")
|
||||
today = datetime.date.today()
|
||||
await coordinator.async_refresh()
|
||||
cory = [
|
||||
child for child in coordinator.data.children
|
||||
if child.child_name == "Cory"
|
||||
][0]
|
||||
await PresenceSchedulesDao(database).upsert_by_child(
|
||||
cory.child_id, 1, today.isoformat(), ""
|
||||
"""Presence is resolved by the API's presence engine: present=True
|
||||
renders ON, present=False renders OFF."""
|
||||
_entry, _coordinator = await _setup_entry(
|
||||
hass,
|
||||
make_entry,
|
||||
_payload([_child(ADA, "Ada"), _child(CORY, "Cory", present=False)]),
|
||||
)
|
||||
await coordinator.async_refresh()
|
||||
|
||||
assert _binary(hass, ada.id, "present_today").is_on is True
|
||||
assert _binary(hass, cory.child_id, "present_today").is_on is False
|
||||
assert _binary(hass, ADA, "present_today").is_on is True
|
||||
assert _binary(hass, CORY, "present_today").is_on is False
|
||||
|
||||
|
||||
async def test_binary_sensor_identity_and_attributes(hass, make_entry) -> None:
|
||||
entry = await _setup_entry(hass, make_entry)
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
database = entry.runtime_data.database
|
||||
ada, _bo = await _seed_two_children(database)
|
||||
await coordinator.async_refresh()
|
||||
_entry, _coordinator = await _setup_entry(
|
||||
hass, make_entry, _payload([_child(ADA, "Ada")])
|
||||
)
|
||||
|
||||
all_done = _binary(hass, ada.id, "all_done")
|
||||
present = _binary(hass, ada.id, "present_today")
|
||||
assert all_done.unique_id == f"nestquest_child_{ada.id}_all_done"
|
||||
assert present.unique_id == f"nestquest_child_{ada.id}_present_today"
|
||||
all_done = _binary(hass, ADA, "all_done")
|
||||
present = _binary(hass, ADA, "present_today")
|
||||
assert all_done.unique_id == f"nestquest_child_{ADA}_all_done"
|
||||
assert present.unique_id == f"nestquest_child_{ADA}_present_today"
|
||||
assert all_done.name == "NestQuest Ada all done"
|
||||
assert present.name == "NestQuest Ada present today"
|
||||
for entity in (all_done, present):
|
||||
assert entity.device_info.name == "NestQuest Ada"
|
||||
assert all_done.extra_state_attributes == {
|
||||
"child_id": ada.id,
|
||||
"child_id": ADA,
|
||||
"child_name": "Ada",
|
||||
}
|
||||
assert present.extra_state_attributes == {
|
||||
"child_id": ada.id,
|
||||
"child_id": ADA,
|
||||
"child_name": "Ada",
|
||||
"next_present": None,
|
||||
}
|
||||
@@ -145,40 +168,28 @@ async def test_present_today_carries_next_present_when_away(
|
||||
"""A child away today carries the ISO date of their next present
|
||||
day (the panel's away plate renders ``Returns <weekday>, <Mon D>``
|
||||
from it); an always-absent pattern carries ``None``."""
|
||||
entry = await _setup_entry(hass, make_entry)
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
database = entry.runtime_data.database
|
||||
ada, _bo = await _seed_two_children(database)
|
||||
await create_child(database, "Cory")
|
||||
today = datetime.date.today()
|
||||
pattern = ",".join(
|
||||
str(day) for day in range(7) if day != today.weekday()
|
||||
_entry, _coordinator = await _setup_entry(
|
||||
hass,
|
||||
make_entry,
|
||||
_payload(
|
||||
[
|
||||
_child(
|
||||
ADA,
|
||||
"Ada",
|
||||
present=False,
|
||||
next_present="2026-09-24",
|
||||
),
|
||||
_child(CORY, "Cory", present=False, next_present=None),
|
||||
]
|
||||
),
|
||||
)
|
||||
await PresenceSchedulesDao(database).upsert_by_child(
|
||||
ada.id, 1, today.isoformat(), pattern
|
||||
)
|
||||
await PresenceSchedulesDao(database).upsert_by_child(
|
||||
await _cory_id(database), 1, today.isoformat(), ""
|
||||
)
|
||||
await coordinator.async_refresh()
|
||||
|
||||
assert _binary(hass, ada.id, "present_today").is_on is False
|
||||
assert _binary(hass, ada.id, "present_today").extra_state_attributes[
|
||||
assert _binary(hass, ADA, "present_today").is_on is False
|
||||
assert _binary(hass, ADA, "present_today").extra_state_attributes[
|
||||
"next_present"
|
||||
] == (today + datetime.timedelta(days=1)).isoformat()
|
||||
] == "2026-09-24"
|
||||
|
||||
cory = [
|
||||
child for child in coordinator.data.children
|
||||
if child.child_name == "Cory"
|
||||
][0]
|
||||
assert _binary(hass, cory.child_id, "present_today").is_on is False
|
||||
assert _binary(hass, cory.child_id, "present_today").extra_state_attributes[
|
||||
assert _binary(hass, CORY, "present_today").is_on is False
|
||||
assert _binary(hass, CORY, "present_today").extra_state_attributes[
|
||||
"next_present"
|
||||
] is None
|
||||
|
||||
|
||||
async def _cory_id(database) -> int:
|
||||
for child in await list_children(database):
|
||||
if child.display_name == "Cory":
|
||||
return child.id
|
||||
raise AssertionError("Cory was not created")
|
||||
|
||||
@@ -8,16 +8,16 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.children import (
|
||||
from custom_components.nestquest.core.children import (
|
||||
create_child,
|
||||
edit_child,
|
||||
list_children,
|
||||
reorder_children,
|
||||
set_child_active,
|
||||
)
|
||||
from custom_components.nestquest.dao_children import ChildRecord, ChildrenDao
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.migrations import apply_migrations
|
||||
from custom_components.nestquest.core.dao_children import ChildRecord, ChildrenDao
|
||||
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||
from custom_components.nestquest.core.migrations import apply_migrations
|
||||
|
||||
|
||||
def _run(coro):
|
||||
@@ -739,12 +739,12 @@ def test_deactivating_child_preserves_definitions_instances_events(
|
||||
async def _body(database):
|
||||
from datetime import date, timedelta
|
||||
|
||||
from custom_components.nestquest.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.dao_instances import (
|
||||
from custom_components.nestquest.core.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.core.dao_instances import (
|
||||
CompletionEventsDao,
|
||||
QuestInstancesDao,
|
||||
)
|
||||
from custom_components.nestquest.dao_rules import (
|
||||
from custom_components.nestquest.core.dao_rules import (
|
||||
QuestDefinitionsDao,
|
||||
ScheduleRulesDao,
|
||||
)
|
||||
@@ -831,7 +831,7 @@ def test_no_code_path_deletes_a_child_row() -> None:
|
||||
|
||||
import inspect
|
||||
|
||||
from custom_components.nestquest.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.core.dao_children import ChildrenDao
|
||||
|
||||
methods = {
|
||||
name
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
"""The ``nestquest.complete_quest`` proxy to the API client (Feature 18).
|
||||
|
||||
The service is a thin proxy: a panel tap must reach the API's panel
|
||||
complete route through the entry's ONE shared API client (the same
|
||||
instance the coordinator polls and the SSE subscription reads), with
|
||||
exactly the payload contract the panel cards send (``instance_id``,
|
||||
``actor=panel``, ``actor_child_id``). The service performs NO local
|
||||
completion and fires NO local bus event — the API service emits the
|
||||
transition on its SSE stream and the integration's subscription
|
||||
re-fires it (firing it locally too would double-fire) — and it
|
||||
refreshes the coordinator after a successful call so the sensors
|
||||
reflect the completion immediately. Every failure (a typed
|
||||
:class:`NestQuestApiError`, an unconfigured client, a non-panel actor)
|
||||
surfaces as a :class:`HomeAssistantError`, token-free.
|
||||
|
||||
The scripted clients here stand in for the API service: recording and
|
||||
failing ``complete_instance`` implementations, no network, no aiohttp.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from conftest import ScriptedPanelClient, set_coordinator_client, wire_entry_to_registry
|
||||
|
||||
from custom_components.nestquest import async_setup_entry
|
||||
from custom_components.nestquest.api_client import NestQuestApiError
|
||||
from custom_components.nestquest.const import (
|
||||
CONF_PANEL_TOKEN,
|
||||
DOMAIN,
|
||||
EVENT_CHILD_DAY_COMPLETE,
|
||||
EVENT_QUEST_COMPLETED,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
)
|
||||
|
||||
|
||||
class _RecordingApiClient(ScriptedPanelClient):
|
||||
"""Snapshot-only stand-in that RECORDS completion calls.
|
||||
|
||||
``complete_instance`` deliberately performs NO local write: the
|
||||
API service is remote, and the assertions below must be able to
|
||||
prove the service handler itself completed nothing locally.
|
||||
"""
|
||||
|
||||
|
||||
class _FailingApiClient(_RecordingApiClient):
|
||||
"""A stand-in whose completion fails the way the client types it."""
|
||||
|
||||
def __init__(self, error: NestQuestApiError, *outcomes) -> None:
|
||||
super().__init__(*outcomes)
|
||||
self.fail_complete_with = error
|
||||
|
||||
|
||||
def _empty_snapshot() -> dict:
|
||||
return {
|
||||
"today_iso": "2026-09-23",
|
||||
"cycle_day": 0,
|
||||
"children": [],
|
||||
}
|
||||
|
||||
|
||||
async def _setup_entry(hass, make_entry, client):
|
||||
"""Wire, script, and set up an entry against the scripted client."""
|
||||
entry = wire_entry_to_registry(
|
||||
make_entry(data={CONF_PANEL_TOKEN: "secret-panel-token"}),
|
||||
hass.registry,
|
||||
)
|
||||
set_coordinator_client(entry, client)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
return entry
|
||||
|
||||
|
||||
def _record_refreshes(entry) -> list[bool]:
|
||||
"""Replace the coordinator's refresh with a recording stand-in."""
|
||||
refreshes: list[bool] = []
|
||||
|
||||
async def _fake_refresh():
|
||||
refreshes.append(True)
|
||||
|
||||
entry.runtime_data.coordinator.async_refresh = _fake_refresh
|
||||
return refreshes
|
||||
|
||||
|
||||
async def test_complete_quest_reaches_the_shared_api_client(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""A panel tap calls ``complete_instance`` on the entry's shared
|
||||
client with the tapped child, fires NO local event, and refreshes
|
||||
the coordinator immediately."""
|
||||
client = _RecordingApiClient(_empty_snapshot())
|
||||
entry = await _setup_entry(hass, make_entry, client)
|
||||
assert entry.runtime_data.coordinator.api_client is client, (
|
||||
"the service must proxy through the entry's ONE shared client"
|
||||
)
|
||||
refreshes = _record_refreshes(entry)
|
||||
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
{
|
||||
"instance_id": 7,
|
||||
"actor": "panel",
|
||||
"actor_child_id": 1,
|
||||
},
|
||||
)
|
||||
|
||||
assert client.complete_calls == [(7, 1)]
|
||||
# No local firing: the completed (and day-complete) events arrive
|
||||
# over the SSE stream, re-fired by the subscription — never from
|
||||
# this handler.
|
||||
assert hass.bus.fired(EVENT_QUEST_COMPLETED) == []
|
||||
assert hass.bus.fired(EVENT_CHILD_DAY_COMPLETE) == []
|
||||
# The old immediate-update behaviour: the sensors' coordinator
|
||||
# refresh runs before the service call returns.
|
||||
assert refreshes == [True]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("error", "fragment"),
|
||||
[
|
||||
(
|
||||
NestQuestApiError(
|
||||
"NestQuest API POST /api/v1/panel/instances/7/complete "
|
||||
"failed with HTTP 401",
|
||||
status=401,
|
||||
),
|
||||
"failed with HTTP 401",
|
||||
),
|
||||
(
|
||||
NestQuestApiError(
|
||||
"NestQuest API POST /api/v1/panel/instances/7/complete "
|
||||
"transport failure: connection refused",
|
||||
status=None,
|
||||
),
|
||||
"transport failure",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_api_failure_surfaces_as_a_service_error(
|
||||
hass, make_entry, error, fragment
|
||||
) -> None:
|
||||
"""A typed API failure (non-2xx or transport) raises
|
||||
``HomeAssistantError`` carrying the typed message — which never
|
||||
includes the panel token — with nothing fired locally, and no
|
||||
refresh for a mutation that never happened."""
|
||||
client = _FailingApiClient(error, _empty_snapshot())
|
||||
entry = await _setup_entry(hass, make_entry, client)
|
||||
refreshes = _record_refreshes(entry)
|
||||
|
||||
with pytest.raises(HomeAssistantError, match=fragment) as excinfo:
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
{
|
||||
"instance_id": 7,
|
||||
"actor": "panel",
|
||||
"actor_child_id": 1,
|
||||
},
|
||||
)
|
||||
# The typed error is chained, and the service error carries the
|
||||
# typed message only — never the panel token.
|
||||
assert excinfo.value.__cause__ is error
|
||||
assert "secret-panel-token" not in str(excinfo.value)
|
||||
assert client.complete_calls == [(7, 1)]
|
||||
assert hass.bus.fired(EVENT_QUEST_COMPLETED) == []
|
||||
assert hass.bus.fired(EVENT_CHILD_DAY_COMPLETE) == []
|
||||
assert refreshes == []
|
||||
|
||||
|
||||
async def test_unconfigured_api_client_surfaces_as_a_service_error(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""An entry with no completion surface (the unconfigured-token
|
||||
stub the coordinator swaps in) fails the service clearly instead
|
||||
of reaching an attribute error."""
|
||||
from custom_components.nestquest.coordinator import _UnconfiguredApiClient
|
||||
|
||||
await _setup_entry(hass, make_entry, _UnconfiguredApiClient())
|
||||
|
||||
with pytest.raises(
|
||||
HomeAssistantError, match="NestQuest API is not configured"
|
||||
):
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
{
|
||||
"instance_id": 7,
|
||||
"actor": "panel",
|
||||
"actor_child_id": 1,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def test_non_panel_actor_is_rejected_before_the_client(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""The API's panel route completes as the tapped child only: a
|
||||
user-actor call fails fast with a clear error and never reaches
|
||||
the client."""
|
||||
client = _RecordingApiClient(_empty_snapshot())
|
||||
await _setup_entry(hass, make_entry, client)
|
||||
|
||||
with pytest.raises(HomeAssistantError, match="actor must be 'panel'"):
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
{
|
||||
"instance_id": 7,
|
||||
"actor": "user",
|
||||
"actor_user_id": "admin-1",
|
||||
},
|
||||
context={"user_id": "admin-1"},
|
||||
)
|
||||
assert client.complete_calls == []
|
||||
@@ -8,27 +8,27 @@ import re
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.completion import (
|
||||
from custom_components.nestquest.core.completion import (
|
||||
append_event,
|
||||
complete_instance,
|
||||
instance_state,
|
||||
list_missed_for_child,
|
||||
uncomplete_instance,
|
||||
)
|
||||
from custom_components.nestquest.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.dao_instances import (
|
||||
from custom_components.nestquest.core.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.core.dao_instances import (
|
||||
EVENT_COMPLETED,
|
||||
EVENT_UNCOMPLETED,
|
||||
CompletionEventRecord,
|
||||
CompletionEventsDao,
|
||||
QuestInstancesDao,
|
||||
)
|
||||
from custom_components.nestquest.dao_rules import (
|
||||
from custom_components.nestquest.core.dao_rules import (
|
||||
QuestDefinitionsDao,
|
||||
ScheduleRulesDao,
|
||||
)
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.migrations import apply_migrations
|
||||
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||
from custom_components.nestquest.core.migrations import apply_migrations
|
||||
|
||||
|
||||
def _run(coro):
|
||||
@@ -311,7 +311,7 @@ def test_now_pins_occurred_at_utc(tmp_path) -> None:
|
||||
|
||||
|
||||
def test_module_never_exposes_update_or_delete() -> None:
|
||||
import custom_components.nestquest.completion as completion
|
||||
import custom_components.nestquest.core.completion as completion
|
||||
|
||||
public = {
|
||||
name
|
||||
|
||||
@@ -156,23 +156,13 @@ def test_no_hardcoded_domain_or_db_filename_in_new_modules() -> None:
|
||||
f"Found hard-coded database filename in {py_file.name}:{node.lineno}"
|
||||
)
|
||||
|
||||
async def test_flow_persists_flow_user_as_initial_admin(hass, make_flow) -> None:
|
||||
"""The HA user completing the flow lands in entry data as the
|
||||
initial admin copy: entry.context is not persisted by real HA, so
|
||||
the durable record must carry the owner."""
|
||||
from custom_components.nestquest.const import CONF_ADMIN_USER_IDS
|
||||
|
||||
flow = make_flow(hass)
|
||||
flow.context = {"user_id": "ha-owner"}
|
||||
result = await flow.async_step_user({})
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["data"] == {CONF_ADMIN_USER_IDS: ["ha-owner"]}
|
||||
|
||||
|
||||
async def test_flow_without_context_user_keeps_data_untouched(
|
||||
hass, make_flow
|
||||
) -> None:
|
||||
"""A submit with user input stores it as-is; no admin copy is
|
||||
invented (the allowlist lives in the API service's own store since
|
||||
the DB removal)."""
|
||||
flow = make_flow(hass)
|
||||
result = await flow.async_step_user({"horizon_days": 7})
|
||||
result = await flow.async_step_user({"update_interval": 60})
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["data"] == {"horizon_days": 7}
|
||||
assert result["data"] == {"update_interval": 60}
|
||||
|
||||
+684
-232
File diff suppressed because it is too large
Load Diff
@@ -14,9 +14,9 @@ Covers the contracts from task 4dd8f870 plus the review fixes:
|
||||
test feeds the scanner each bypass shape and asserts it flags them.
|
||||
- A settings object constructed with NON-default ``horizon_days`` and
|
||||
``day_rollover_time`` drives a materialization whose resulting window
|
||||
matches the non-default horizon — via BOTH the core ``materialize``
|
||||
path and the INTEGRATION path (``_run_horizon_materialization`` +
|
||||
``_register_day_rollover_listener``).
|
||||
matches the non-default horizon — via the core ``materialize`` path
|
||||
(the integration path it used to share is gone with the integration's
|
||||
database).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -29,7 +29,6 @@ from typing import Any
|
||||
import pytest
|
||||
|
||||
import custom_components.nestquest.core as _core_mod
|
||||
from conftest import freeze_nestquest_clock
|
||||
|
||||
from custom_components.nestquest.const import (
|
||||
CONF_AFTERNOON_REMINDER_ENABLED,
|
||||
@@ -741,7 +740,7 @@ def test_settings_module_has_no_ha_imports() -> None:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Materialization over a non-default horizon: core path + integration path.
|
||||
# Materialization over a non-default horizon (core path).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -828,85 +827,3 @@ async def test_materialize_over_non_default_horizon(hass, tmp_path) -> None:
|
||||
) == []
|
||||
finally:
|
||||
await database.close()
|
||||
|
||||
|
||||
async def test_integration_path_threads_settings(
|
||||
hass, tmp_path, monkeypatch
|
||||
) -> None:
|
||||
"""The integration path threads ``settings`` through horizon_window + rollover.
|
||||
|
||||
``_run_horizon_materialization`` calls
|
||||
``settings.horizon_window(today)`` (so the helper is real and shared,
|
||||
not recomputed inline), and ``_register_day_rollover_listener``
|
||||
registers at ``settings.day_rollover_hour_minute``. This runs the
|
||||
INTEGRATION path with ``horizon_days=3`` and
|
||||
``day_rollover_time='03:30'`` and asserts the resulting instance
|
||||
window matches [today, today+3] AND the registered hour/minute are 3/30.
|
||||
"""
|
||||
import custom_components.nestquest as nestquest
|
||||
from custom_components.nestquest.core.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.core.dao_instances import (
|
||||
QuestInstancesDao,
|
||||
)
|
||||
from custom_components.nestquest.core.quest_definitions import (
|
||||
create_quest_definition,
|
||||
)
|
||||
from custom_components.nestquest.core.recurrence import (
|
||||
RuleType,
|
||||
ScheduleRule,
|
||||
)
|
||||
|
||||
# Freeze the integration clock so "today" is deterministic (the
|
||||
# hass fixture's config.time_zone is UTC, so 2026-03-01 12:00 UTC
|
||||
# localizes to 2026-03-01).
|
||||
freeze_nestquest_clock(
|
||||
monkeypatch, datetime.datetime(2026, 3, 1, 12, 0, tzinfo=datetime.timezone.utc)
|
||||
)
|
||||
|
||||
database = await _prepare(tmp_path / "nq.db", hass.async_add_executor_job)
|
||||
try:
|
||||
settings = NestQuestSettings(horizon_days=3, day_rollover_time="03:30")
|
||||
today = datetime.date(2026, 3, 1)
|
||||
|
||||
NOW = "2026-03-01T12:00:00+00:00"
|
||||
child = await ChildrenDao(database).create("Ada", NOW)
|
||||
await create_quest_definition(
|
||||
database,
|
||||
"Daily chore",
|
||||
ScheduleRule(
|
||||
rule_type=RuleType.DAILY, start_date=today.isoformat()
|
||||
),
|
||||
[child.id],
|
||||
["morning"],
|
||||
)
|
||||
|
||||
# Integration path: the window comes from settings.horizon_window.
|
||||
await nestquest._run_horizon_materialization(hass, database, settings)
|
||||
|
||||
end = today + datetime.timedelta(days=3)
|
||||
records = await QuestInstancesDao(database).list_by_date_range(
|
||||
child.id, today.isoformat(), end.isoformat()
|
||||
)
|
||||
dates = sorted({r.due_date for r in records})
|
||||
assert dates == [
|
||||
(today + datetime.timedelta(days=i)).isoformat()
|
||||
for i in range(4)
|
||||
]
|
||||
beyond = (end + datetime.timedelta(days=1)).isoformat()
|
||||
far = (end + datetime.timedelta(days=10)).isoformat()
|
||||
assert await QuestInstancesDao(database).list_by_date_range(
|
||||
child.id, beyond, far
|
||||
) == []
|
||||
|
||||
# The rollover listener registers at the configured hour/minute.
|
||||
remove = nestquest._register_day_rollover_listener(
|
||||
hass, database, settings
|
||||
)
|
||||
try:
|
||||
reg = hass.time_change.registrations[-1]
|
||||
assert reg["hour"] == 3
|
||||
assert reg["minute"] == 30
|
||||
finally:
|
||||
remove()
|
||||
finally:
|
||||
await database.close()
|
||||
|
||||
+65
-25
@@ -10,17 +10,22 @@ child's ``next_present``, a due-timed quest's ``overdue`` under a
|
||||
pinned ``now``, the custody ``cycle_day`` (> 1), and the panel payload
|
||||
dict shape (``done`` -> ``completed``, ``on_time`` passthrough, and
|
||||
``missed`` omission) are all exercised as literals.
|
||||
|
||||
The database is a standalone core one (the integration owns no
|
||||
database since Feature 18's DB removal); the closing round-trip pins
|
||||
that the integration's coordinator reconstructs the SAME dataclasses
|
||||
from the route's payload.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from conftest import wire_entry_to_registry
|
||||
from conftest import set_coordinator_client, wire_entry_to_registry
|
||||
|
||||
from custom_components.nestquest import async_setup_entry
|
||||
from custom_components.nestquest.children import create_child
|
||||
from custom_components.nestquest.completion import complete_instance
|
||||
from custom_components.nestquest.core.children import create_child
|
||||
from custom_components.nestquest.core.completion import complete_instance
|
||||
from custom_components.nestquest.core.snapshot import (
|
||||
ChildDaySnapshot,
|
||||
NestQuestSnapshot,
|
||||
@@ -28,14 +33,17 @@ from custom_components.nestquest.core.snapshot import (
|
||||
build_snapshot,
|
||||
instance_payload,
|
||||
)
|
||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||
from custom_components.nestquest.dao_presence import (
|
||||
from custom_components.nestquest.core.dao_instances import QuestInstancesDao
|
||||
from custom_components.nestquest.core.dao_presence import (
|
||||
PresenceOverridesDao,
|
||||
PresenceSchedulesDao,
|
||||
)
|
||||
from custom_components.nestquest.materialize import materialize
|
||||
from custom_components.nestquest.quest_definitions import create_quest_definition
|
||||
from custom_components.nestquest.recurrence import ScheduleRule
|
||||
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||
from custom_components.nestquest.core.materialize import materialize
|
||||
from custom_components.nestquest.core.migrations import apply_migrations
|
||||
from custom_components.nestquest.core.quest_definitions import create_quest_definition
|
||||
from custom_components.nestquest.core.recurrence import ScheduleRule
|
||||
from custom_components.nestquest.core.settings import NestQuestSettings
|
||||
|
||||
|
||||
async def test_instance_payload_shape_and_missed_omission() -> None:
|
||||
@@ -105,7 +113,7 @@ async def test_instance_payload_shape_and_missed_omission() -> None:
|
||||
)
|
||||
|
||||
|
||||
async def test_build_snapshot_explicit_values(hass, make_entry, monkeypatch) -> None:
|
||||
async def test_build_snapshot_explicit_values(hass, make_entry, tmp_path) -> None:
|
||||
"""The builder produces the documented shape as literals, pinned in time.
|
||||
|
||||
Ada is AWAY today (a 2-week schedule whose absent week 0 covers today,
|
||||
@@ -117,15 +125,18 @@ async def test_build_snapshot_explicit_values(hass, make_entry, monkeypatch) ->
|
||||
scheduled active child is Ada, whose 2-week cycle anchored one day
|
||||
before today puts ``cycle_day`` at 2.
|
||||
"""
|
||||
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
database = entry.runtime_data.database
|
||||
settings = entry.runtime_data.settings
|
||||
# A standalone core database (the integration owns none): the
|
||||
# builder and its seed run against it through the executor.
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await database.open(tmp_path / "snapshot.db")
|
||||
await apply_migrations(database)
|
||||
settings = NestQuestSettings()
|
||||
|
||||
# Single clock read for the seed/snapshot anchor (no midnight skew).
|
||||
today = coordinator._local_now().date()
|
||||
# Single clock read for the seed/snapshot anchor (no midnight skew);
|
||||
# the coordinator no longer owns a clock read (the API serves the
|
||||
# snapshot), so the test resolves the HA-local zone directly.
|
||||
time_zone = ZoneInfo(hass.config.time_zone)
|
||||
today = datetime.datetime.now(time_zone).date()
|
||||
# Pin the build instant at 12:00 local: past Ada's 10:00 due time
|
||||
# (overdue) and before Bo's 17:00, and the completion moment for Bo.
|
||||
pinned_now = datetime.datetime(
|
||||
@@ -294,13 +305,42 @@ async def test_build_snapshot_explicit_values(hass, make_entry, monkeypatch) ->
|
||||
== "missed"
|
||||
)
|
||||
|
||||
# REQUIRED wrapper tie-in: the coordinator's _async_update_data is a
|
||||
# thin WRAPPER over build_snapshot, so pinning its clock to the SAME
|
||||
# pinned_now the builder was called with must reproduce the builder's
|
||||
# snapshot byte-for-byte. The dataclasses are frozen, so == is a full
|
||||
# deep compare — a wrapper that mis-passed today vs now, or stopped
|
||||
# calling the builder, would diverge here even though the literal
|
||||
# assertions above still pass.
|
||||
monkeypatch.setattr(coordinator, "_local_now", lambda: pinned_now)
|
||||
await coordinator.async_refresh()
|
||||
# REQUIRED wrapper tie-in: since Feature 18 the coordinator's
|
||||
# refresh polls the snapshot through its API client, so pinning the
|
||||
# client to serve the payload of the SAME snapshot built with
|
||||
# pinned_now must reproduce the builder's snapshot byte-for-byte.
|
||||
# The dataclasses are frozen, so == is a full deep compare — a
|
||||
# reconstruction that dropped or reshaped a field would diverge
|
||||
# here even though the literal assertions above still pass.
|
||||
class _PinnedPayloadClient:
|
||||
async def get_snapshot(self):
|
||||
return {
|
||||
"today_iso": built.today_iso,
|
||||
"cycle_day": built.cycle_day,
|
||||
"children": [
|
||||
{
|
||||
"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": instance_payload(
|
||||
child.instances, include_missed=False
|
||||
),
|
||||
}
|
||||
for child in built.children
|
||||
],
|
||||
}
|
||||
|
||||
entry = wire_entry_to_registry(
|
||||
make_entry(data={"api_base_url": "http://api.test:8000", "panel_token": "tok"}),
|
||||
hass.registry,
|
||||
)
|
||||
set_coordinator_client(entry, _PinnedPayloadClient())
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
assert coordinator.data == built
|
||||
await database.close()
|
||||
|
||||
@@ -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()
|
||||
@@ -6,15 +6,15 @@ import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.dao_children import (
|
||||
from custom_components.nestquest.core.dao_children import (
|
||||
AdminUserRecord,
|
||||
AdminUsersDao,
|
||||
ChildRecord,
|
||||
ChildrenDao,
|
||||
)
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.schema import SCHEMA_V1_STATEMENTS
|
||||
from custom_components.nestquest.migrations import apply_migrations
|
||||
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||
from custom_components.nestquest.core.schema import SCHEMA_V1_STATEMENTS
|
||||
from custom_components.nestquest.core.migrations import apply_migrations
|
||||
|
||||
|
||||
def _run(coro):
|
||||
@@ -504,9 +504,6 @@ def test_children_and_admin_sql_lives_only_in_dao_module() -> None:
|
||||
"tests/test_dao_rules.py", # rules/definitions guard, own scope
|
||||
"tests/test_dao_presence.py", # presence guard, own scope
|
||||
"tests/test_dao_instances.py", # instances guard, own scope
|
||||
"tests/test_startup_db.py", # row-count probe of the children
|
||||
# table only (proving a fresh setup loads); guard below covers
|
||||
# everything else
|
||||
}
|
||||
sql_pattern = re.compile(
|
||||
r"(FROM|INTO|UPDATE|DELETE\s+FROM|JOIN)\s+"
|
||||
@@ -558,32 +555,6 @@ def _strip_function_spans(text: str, names: set[str]) -> str:
|
||||
for number, line in enumerate(lines, start=1)
|
||||
if number not in excluded
|
||||
)
|
||||
|
||||
|
||||
def test_startup_db_test_file_uses_dao_elsewhere() -> None:
|
||||
"""Compensating self-scan for the startup-test exemption: outside
|
||||
the single sanctioned COUNT(*) probe, test_startup_db.py must go
|
||||
through the DAO for children/admin_users data access.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
startup_test = Path(__file__).parent / "test_startup_db.py"
|
||||
stripped = _strip_function_spans(
|
||||
startup_test.read_text(),
|
||||
{"test_setup_with_valid_file_preserves_rows"},
|
||||
)
|
||||
sql_pattern = re.compile(
|
||||
r"(SELECT\s[^\"']*?FROM|INSERT\s+INTO|UPDATE|DELETE\s+FROM|"
|
||||
r"FROM|JOIN)\s+[`'\"]*(\[)?(children|admin_users)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
assert sql_pattern.search(stripped) is None, (
|
||||
"test_startup_db.py must go through the DAO for these tables "
|
||||
"outside its sanctioned row-count probe"
|
||||
)
|
||||
|
||||
|
||||
def test_dao_matrix_test_file_raw_probes_are_only_constraint_probes() -> None:
|
||||
"""Compensating self-scan for the matrix-file exemption, structurally
|
||||
exact: parse the two sanctioned functions' ASTs and require that
|
||||
|
||||
@@ -7,16 +7,16 @@ import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.dao_instances import (
|
||||
from custom_components.nestquest.core.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.core.dao_instances import (
|
||||
CompletionEventRecord,
|
||||
CompletionEventsDao,
|
||||
QuestInstanceRecord,
|
||||
QuestInstancesDao,
|
||||
)
|
||||
from custom_components.nestquest.dao_rules import ScheduleRulesDao, QuestDefinitionsDao
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.migrations import apply_migrations
|
||||
from custom_components.nestquest.core.dao_rules import ScheduleRulesDao, QuestDefinitionsDao
|
||||
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||
from custom_components.nestquest.core.migrations import apply_migrations
|
||||
|
||||
|
||||
def _run(coro):
|
||||
|
||||
@@ -21,24 +21,24 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.dao_children import (
|
||||
from custom_components.nestquest.core.dao_children import (
|
||||
AdminUsersDao,
|
||||
ChildrenDao,
|
||||
)
|
||||
from custom_components.nestquest.dao_instances import (
|
||||
from custom_components.nestquest.core.dao_instances import (
|
||||
CompletionEventsDao,
|
||||
QuestInstancesDao,
|
||||
)
|
||||
from custom_components.nestquest.dao_presence import (
|
||||
from custom_components.nestquest.core.dao_presence import (
|
||||
PresenceOverridesDao,
|
||||
PresenceSchedulesDao,
|
||||
)
|
||||
from custom_components.nestquest.dao_rules import (
|
||||
from custom_components.nestquest.core.dao_rules import (
|
||||
ScheduleRulesDao,
|
||||
QuestDefinitionsDao,
|
||||
)
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.migrations import apply_migrations
|
||||
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||
from custom_components.nestquest.core.migrations import apply_migrations
|
||||
|
||||
|
||||
def _run(coro):
|
||||
|
||||
@@ -6,15 +6,15 @@ import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.dao_presence import (
|
||||
from custom_components.nestquest.core.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.core.dao_presence import (
|
||||
PresenceOverrideRecord,
|
||||
PresenceOverridesDao,
|
||||
PresenceScheduleRecord,
|
||||
PresenceSchedulesDao,
|
||||
)
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.migrations import apply_migrations
|
||||
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||
from custom_components.nestquest.core.migrations import apply_migrations
|
||||
|
||||
|
||||
def _run(coro):
|
||||
@@ -741,12 +741,12 @@ def test_override_create_racing_conflicts_serialize(tmp_path) -> None:
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
async def _main():
|
||||
from custom_components.nestquest.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.dao_presence import (
|
||||
from custom_components.nestquest.core.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.core.dao_presence import (
|
||||
PresenceOverridesDao,
|
||||
)
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.migrations import apply_migrations
|
||||
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||
from custom_components.nestquest.core.migrations import apply_migrations
|
||||
|
||||
armed = {"active": False, "gated": False}
|
||||
gate_open = asyncio_module.Event()
|
||||
|
||||
+73
-6
@@ -6,8 +6,8 @@ import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.dao_rules import (
|
||||
from custom_components.nestquest.core.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.core.dao_rules import (
|
||||
ScheduleRuleRecord,
|
||||
ScheduleRuleStorage,
|
||||
ScheduleRulesDao,
|
||||
@@ -17,9 +17,9 @@ from custom_components.nestquest.dao_rules import (
|
||||
schedule_rule_from_storage,
|
||||
schedule_rule_to_storage,
|
||||
)
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.migrations import apply_migrations
|
||||
from custom_components.nestquest.recurrence import (
|
||||
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||
from custom_components.nestquest.core.migrations import apply_migrations
|
||||
from custom_components.nestquest.core.recurrence import (
|
||||
RuleType,
|
||||
RuleValidationError,
|
||||
ScheduleRule,
|
||||
@@ -341,7 +341,7 @@ def test_rule_delete_concurrent_with_definition_create_is_safe(
|
||||
# delete task has been scheduled, so the create holds
|
||||
# the connection lock while the delete queues behind
|
||||
# it — then the create proceeds and wins.
|
||||
import custom_components.nestquest.dao_rules as dao_rules
|
||||
import custom_components.nestquest.core.dao_rules as dao_rules
|
||||
|
||||
original_validate = (
|
||||
dao_rules.QuestDefinitionsDao._validate_rule_exists
|
||||
@@ -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")
|
||||
|
||||
+1
-66
@@ -14,7 +14,7 @@ import pytest
|
||||
from conftest import executor_for, make_hass
|
||||
|
||||
import custom_components.nestquest.core.db as db_module
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||
|
||||
|
||||
def _run(coro):
|
||||
@@ -933,68 +933,3 @@ def test_execute_many_exposes_rowcount(tmp_path) -> None:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle wiring: opened once per entry, closed on unload
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_setup_entry_opens_database_in_runtime_data(hass, make_entry) -> None:
|
||||
from conftest import wire_entry_to_registry
|
||||
|
||||
from custom_components.nestquest import async_setup_entry, async_unload_entry
|
||||
|
||||
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
runtime = entry.runtime_data
|
||||
assert runtime.database is not None
|
||||
assert runtime.database.connected is True
|
||||
await async_unload_entry(hass, entry)
|
||||
|
||||
|
||||
async def test_unload_entry_closes_database(hass, make_entry) -> None:
|
||||
from conftest import wire_entry_to_registry
|
||||
|
||||
from custom_components.nestquest import async_setup_entry, async_unload_entry
|
||||
|
||||
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
||||
await async_setup_entry(hass, entry)
|
||||
database = entry.runtime_data.database
|
||||
assert database.connected is True
|
||||
assert await async_unload_entry(hass, entry) is True
|
||||
assert database.connected is False
|
||||
with pytest.raises(RuntimeError, match="closed"):
|
||||
await database.fetch_one("SELECT 1")
|
||||
|
||||
|
||||
async def test_second_setup_does_not_open_second_connection(hass, make_entry) -> None:
|
||||
from conftest import wire_entry_to_registry
|
||||
|
||||
from custom_components.nestquest import async_setup_entry, async_unload_entry
|
||||
|
||||
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
||||
await async_setup_entry(hass, entry)
|
||||
first = entry.runtime_data.database
|
||||
|
||||
await async_setup_entry(hass, entry)
|
||||
second = entry.runtime_data.database
|
||||
|
||||
assert first is second
|
||||
assert first.connected is True
|
||||
await async_unload_entry(hass, entry)
|
||||
|
||||
|
||||
async def test_setup_unload_cycles_reopen_fresh_connection(hass, make_entry) -> None:
|
||||
from conftest import wire_entry_to_registry
|
||||
|
||||
from custom_components.nestquest import async_setup_entry, async_unload_entry
|
||||
|
||||
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
||||
seen: list[NestQuestDatabase] = []
|
||||
for _ in range(3):
|
||||
await async_setup_entry(hass, entry)
|
||||
runtime = entry.runtime_data
|
||||
assert runtime.database.connected is True
|
||||
seen.append(runtime.database)
|
||||
await async_unload_entry(hass, entry)
|
||||
assert runtime.database.connected is False
|
||||
assert len(set(seen)) == 3
|
||||
assert hass.data == {}
|
||||
+121
-106
@@ -1,24 +1,16 @@
|
||||
"""Entity lifecycle across child add/deactivate/reactivate (Feature 10, task 8)."""
|
||||
"""Entity lifecycle across child add/deactivate/reactivate (Feature 10, task 8).
|
||||
|
||||
The household state the entities mirror comes from the API service's
|
||||
panel route, so a deactivate/reactivate/add is scripted as a payload
|
||||
change served by :class:`conftest.StubSnapshotClient`; the reload
|
||||
(unload + setup) re-polls it, exactly as production does.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
|
||||
from conftest import wire_entry_to_registry
|
||||
from conftest import StubSnapshotClient, set_coordinator_client, wire_entry_to_registry
|
||||
|
||||
from custom_components.nestquest import async_setup_entry, async_unload_entry
|
||||
from custom_components.nestquest.children import (
|
||||
create_child,
|
||||
list_children,
|
||||
set_child_active,
|
||||
)
|
||||
from custom_components.nestquest.const import DOMAIN
|
||||
from custom_components.nestquest.materialize import materialize
|
||||
from custom_components.nestquest.quest_definitions import (
|
||||
create_quest_definition,
|
||||
)
|
||||
from custom_components.nestquest.recurrence import ScheduleRule
|
||||
|
||||
SEED_HORIZON_DAYS = 3
|
||||
PER_CHILD_KINDS = (
|
||||
"quests_due_today",
|
||||
"quests_completed_today",
|
||||
@@ -34,6 +26,78 @@ HOUSEHOLD_UNIQUE_IDS = (
|
||||
"nestquest_cycle_day",
|
||||
)
|
||||
|
||||
ADA = 1
|
||||
BO = 2
|
||||
CORY_ID = 4
|
||||
|
||||
|
||||
def _child(child_id: int, name: str, *, present=True, instances=()):
|
||||
completed = sum(1 for i in instances if i["state"] == "completed")
|
||||
due = len(instances)
|
||||
return {
|
||||
"child_id": child_id,
|
||||
"child_name": name,
|
||||
"present": present,
|
||||
"next_present": None,
|
||||
"due_today": due,
|
||||
"completed_today": completed,
|
||||
"remaining_today": due - completed,
|
||||
"completion_pct": 100 if due == 0 else round(completed / due * 100),
|
||||
"instances": list(instances),
|
||||
}
|
||||
|
||||
|
||||
def _instance(instance_id: int, child_id: int, *, state="open") -> dict:
|
||||
return {
|
||||
"id": instance_id,
|
||||
"definition_id": 3,
|
||||
"child_id": child_id,
|
||||
"title": "Brush teeth",
|
||||
"icon": "🦷",
|
||||
"window": "morning",
|
||||
"due_time": "08:00",
|
||||
"state": state,
|
||||
"overdue": False,
|
||||
"completed_at": None if state == "open" else "2026-09-23T07:55:00+00:00",
|
||||
"on_time": None if state == "open" else True,
|
||||
}
|
||||
|
||||
|
||||
def _payload(children) -> dict:
|
||||
return {
|
||||
"today_iso": "2026-09-23",
|
||||
"cycle_day": 0,
|
||||
"children": list(children),
|
||||
}
|
||||
|
||||
|
||||
#: The full household (Ada + Bo, one daily quest each) and the states
|
||||
#: the lifecycle walks through: Bo deactivated, reactivated, completed.
|
||||
_BOTH = _payload(
|
||||
[
|
||||
_child(ADA, "Ada", instances=[_instance(7, ADA)]),
|
||||
_child(BO, "Bo", instances=[_instance(8, BO)]),
|
||||
]
|
||||
)
|
||||
_BO_GONE = _payload([_child(ADA, "Ada", instances=[_instance(7, ADA)])])
|
||||
_BO_DONE = _payload(
|
||||
[
|
||||
_child(ADA, "Ada", instances=[_instance(7, ADA)]),
|
||||
_child(BO, "Bo", instances=[_instance(8, BO, state="completed")]),
|
||||
]
|
||||
)
|
||||
_ADA_ONLY = _payload([_child(ADA, "Ada", instances=[_instance(7, ADA)])])
|
||||
_ADA_AND_CORY = _payload(
|
||||
[
|
||||
_child(ADA, "Ada", instances=[_instance(7, ADA)]),
|
||||
_child(CORY_ID, "Cory"),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _child_unique_ids(child_id: int) -> set[str]:
|
||||
return {f"nestquest_child_{child_id}_{kind}" for kind in PER_CHILD_KINDS}
|
||||
|
||||
|
||||
async def _reload(hass, entry) -> None:
|
||||
"""Unload then set up: the harness's restart/reload equivalent."""
|
||||
@@ -41,46 +105,36 @@ async def _reload(hass, entry) -> None:
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
|
||||
|
||||
def _child_unique_ids(child_id: int) -> set[str]:
|
||||
return {f"nestquest_child_{child_id}_{kind}" for kind in PER_CHILD_KINDS}
|
||||
|
||||
|
||||
async def _setup_and_seed(hass, make_entry):
|
||||
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
database = entry.runtime_data.database
|
||||
ada = await create_child(database, "Ada")
|
||||
bo = await create_child(database, "Bo")
|
||||
today = datetime.date.today()
|
||||
await create_quest_definition(
|
||||
database,
|
||||
"Brush teeth",
|
||||
ScheduleRule.from_dict(
|
||||
{"rule_type": "daily", "start_date": today.isoformat()}
|
||||
async def _setup_with_household(hass, make_entry, *payloads):
|
||||
"""Set up the entry with a scripted, ordered snapshot history."""
|
||||
entry = wire_entry_to_registry(
|
||||
make_entry(
|
||||
data={"api_base_url": "http://api.test:8000", "panel_token": "tok"}
|
||||
),
|
||||
[ada.id, bo.id],
|
||||
["morning"],
|
||||
hass.registry,
|
||||
)
|
||||
end = (today + datetime.timedelta(days=SEED_HORIZON_DAYS)).isoformat()
|
||||
await materialize(database, today.isoformat(), end, today=today)
|
||||
await entry.runtime_data.coordinator.async_refresh()
|
||||
return entry, ada, bo
|
||||
client = set_coordinator_client(entry, StubSnapshotClient(*payloads))
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
return entry, entry.runtime_data.coordinator, client
|
||||
|
||||
|
||||
async def test_deactivated_child_entities_removed_on_reload(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
entry, ada, bo = await _setup_and_seed(hass, make_entry)
|
||||
assert _child_unique_ids(ada.id) <= set(hass.entities)
|
||||
assert _child_unique_ids(bo.id) <= set(hass.entities)
|
||||
"""A child absent from the API's snapshot loses its entities on
|
||||
reload; the surviving child's entities are untouched."""
|
||||
entry, _coordinator, _client = await _setup_with_household(
|
||||
hass, make_entry, _BOTH, _BO_GONE
|
||||
)
|
||||
assert _child_unique_ids(ADA) <= set(hass.entities)
|
||||
assert _child_unique_ids(BO) <= set(hass.entities)
|
||||
|
||||
await set_child_active(entry.runtime_data.database, bo.id, False)
|
||||
await _reload(hass, entry)
|
||||
|
||||
assert _child_unique_ids(ada.id) <= set(hass.entities), (
|
||||
assert _child_unique_ids(ADA) <= set(hass.entities), (
|
||||
"the surviving child's entities must be untouched"
|
||||
)
|
||||
assert not (_child_unique_ids(bo.id) & set(hass.entities)), (
|
||||
assert not (_child_unique_ids(BO) & set(hass.entities)), (
|
||||
"the deactivated child's entities must be gone after reload"
|
||||
)
|
||||
|
||||
@@ -88,100 +142,61 @@ async def test_deactivated_child_entities_removed_on_reload(
|
||||
async def test_reactivation_restores_entities_with_original_unique_ids(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
entry, ada, bo = await _setup_and_seed(hass, make_entry)
|
||||
original = _child_unique_ids(bo.id)
|
||||
await set_child_active(entry.runtime_data.database, bo.id, False)
|
||||
original = _child_unique_ids(BO)
|
||||
entry, _coordinator, _client = await _setup_with_household(
|
||||
hass, make_entry, _BOTH, _BO_GONE, _BOTH
|
||||
)
|
||||
await _reload(hass, entry)
|
||||
assert not (original & set(hass.entities))
|
||||
|
||||
await set_child_active(entry.runtime_data.database, bo.id, True)
|
||||
await _reload(hass, entry)
|
||||
|
||||
assert original <= set(hass.entities), (
|
||||
"reactivation must restore the exact original unique_ids"
|
||||
)
|
||||
bo_due = hass.entities[f"nestquest_child_{bo.id}_quests_due_today"]
|
||||
bo_due = hass.entities[f"nestquest_child_{BO}_quests_due_today"]
|
||||
assert bo_due.native_value == 1, "restored entities carry live state"
|
||||
|
||||
|
||||
async def test_new_child_gets_entities_without_disturbing_existing(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
entry, ada, bo = await _setup_and_seed(hass, make_entry)
|
||||
before_ada = set(hass.entities) & _child_unique_ids(ada.id)
|
||||
"""A child ADDED to the API's snapshot gains its entities on
|
||||
reload; the existing children's entities are untouched."""
|
||||
entry, _coordinator, _client = await _setup_with_household(
|
||||
hass, make_entry, _ADA_ONLY, _ADA_AND_CORY
|
||||
)
|
||||
before_ada = set(hass.entities) & _child_unique_ids(ADA)
|
||||
|
||||
cory = await create_child(entry.runtime_data.database, "Cory")
|
||||
await _reload(hass, entry)
|
||||
|
||||
assert before_ada <= set(hass.entities), "existing entities untouched"
|
||||
assert _child_unique_ids(cory.id) <= set(hass.entities), (
|
||||
assert _child_unique_ids(CORY_ID) <= set(hass.entities), (
|
||||
"the new child's entities appear after reload"
|
||||
)
|
||||
assert hass.entities[f"nestquest_child_{cory.id}_quests_due_today"].native_value == 0
|
||||
assert hass.entities[
|
||||
f"nestquest_child_{CORY_ID}_quests_due_today"
|
||||
].native_value == 0
|
||||
|
||||
|
||||
async def test_restart_equivalent_restores_all_entity_states(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""Unload + setup (the restart equivalent) restores every active
|
||||
child's entities with correct states and the household rollups."""
|
||||
from custom_components.nestquest.completion import complete_instance
|
||||
|
||||
entry, ada, bo = await _setup_and_seed(hass, make_entry)
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
snapshot_ada = next(
|
||||
child
|
||||
for child in coordinator.data.children
|
||||
if child.child_id == ada.id
|
||||
child's entities with the API's current states and the household
|
||||
rollups."""
|
||||
entry, _coordinator, _client = await _setup_with_household(
|
||||
hass, make_entry, _BOTH, _BO_DONE
|
||||
)
|
||||
await complete_instance(
|
||||
entry.runtime_data.database,
|
||||
snapshot_ada.instances[0].instance_id,
|
||||
actor_source="panel",
|
||||
actor_child_id=ada.id,
|
||||
)
|
||||
await coordinator.async_refresh()
|
||||
assert hass.entities[
|
||||
f"nestquest_child_{ada.id}_quests_completed_today"
|
||||
].native_value == 1
|
||||
|
||||
await _reload(hass, entry)
|
||||
|
||||
assert hass.entities[
|
||||
f"nestquest_child_{ada.id}_quests_completed_today"
|
||||
].native_value == 1, "completed count survives the restart"
|
||||
assert hass.entities[
|
||||
f"nestquest_child_{bo.id}_quests_completed_today"
|
||||
f"nestquest_child_{ADA}_quests_completed_today"
|
||||
].native_value == 0
|
||||
assert hass.entities[
|
||||
f"nestquest_child_{BO}_quests_completed_today"
|
||||
].native_value == 1, "the API's recorded completion survives the restart"
|
||||
assert set(HOUSEHOLD_UNIQUE_IDS) <= set(hass.entities)
|
||||
assert hass.entities[
|
||||
"nestquest_household_quests_completed_today"
|
||||
].native_value == 1
|
||||
|
||||
|
||||
async def test_unload_shuts_coordinator_before_closing_database(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""The coordinator's listeners are cleared while the database is
|
||||
still connected: a scheduled refresh must never race the close."""
|
||||
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
database = entry.runtime_data.database
|
||||
seen_connected = []
|
||||
|
||||
async def _probe_shutdown():
|
||||
seen_connected.append(database.connected)
|
||||
|
||||
original_close = database.close
|
||||
|
||||
async def _recording_close():
|
||||
seen_connected.append("closing")
|
||||
await original_close()
|
||||
|
||||
coordinator.async_shutdown = _probe_shutdown
|
||||
database.close = _recording_close
|
||||
assert await async_unload_entry(hass, entry) is True
|
||||
assert seen_connected == [True, "closing"], (
|
||||
"coordinator shutdown must precede the database close"
|
||||
)
|
||||
|
||||
+217
-128
@@ -2,72 +2,156 @@
|
||||
|
||||
One fixture drives every assertion the done-condition names: correct
|
||||
values for every sensor and binary sensor (including the zero-quest
|
||||
child and the 255-character state limit), the three events firing
|
||||
exactly once with schema-valid payloads, the instances payload
|
||||
omitting missed instances while admin_instances includes them, and no
|
||||
entities for an inactive child. The per-task test files cover each
|
||||
behavior in depth; this file is the single pass that proves them all
|
||||
against ONE fixture, so a regression in any contract fails here too.
|
||||
child and the 255-character state limit), the completed/uncompleted/
|
||||
day-complete events re-fired exactly once from the API's SSE frames,
|
||||
the panel payload omitting missed instances while admin_instances
|
||||
equals it, and no entities for a child the API does not report. The
|
||||
per-task test files cover each behavior in depth; this file is the
|
||||
single pass that proves them all against ONE fixture, so a regression
|
||||
in any contract fails here too.
|
||||
|
||||
Since the DB removal the household state and every transition come
|
||||
from the API service, so the fixture scripts the panel route's
|
||||
payloads and completion surface (:class:`conftest.ScriptedPanelClient`)
|
||||
and delivers the transition frames through the SSE subscription — the
|
||||
production event path.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import re
|
||||
|
||||
from conftest import wire_entry_to_registry
|
||||
from conftest import ScriptedPanelClient, set_coordinator_client, wire_entry_to_registry
|
||||
|
||||
from custom_components.nestquest import async_setup_entry
|
||||
from custom_components.nestquest.children import (
|
||||
create_child,
|
||||
list_children,
|
||||
set_child_active,
|
||||
)
|
||||
from custom_components.nestquest.completion import derive_state
|
||||
from custom_components.nestquest.const import (
|
||||
CONF_ADMIN_USER_IDS,
|
||||
DOMAIN,
|
||||
EVENT_CHILD_DAY_COMPLETE,
|
||||
EVENT_QUEST_COMPLETED,
|
||||
EVENT_QUEST_UNCOMPLETED,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
SERVICE_UNCOMPLETE_QUEST,
|
||||
)
|
||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||
from custom_components.nestquest.materialize import materialize
|
||||
from custom_components.nestquest.quest_definitions import (
|
||||
create_quest_definition,
|
||||
)
|
||||
from custom_components.nestquest.recurrence import ScheduleRule
|
||||
|
||||
_TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$")
|
||||
|
||||
ADA = 1
|
||||
BO = 2
|
||||
CORY = 3
|
||||
|
||||
_OCCURRED = "2026-09-23T07:55:00+00:00"
|
||||
|
||||
|
||||
def _instance(instance_id: int, child_id: int, *, window="morning", state="open"):
|
||||
return {
|
||||
"id": instance_id,
|
||||
"definition_id": 3,
|
||||
"child_id": child_id,
|
||||
"title": "Brush teeth",
|
||||
"icon": "🦷",
|
||||
"window": window,
|
||||
"due_time": "08:00" if window == "morning" else "19:00",
|
||||
"state": state,
|
||||
"overdue": False,
|
||||
"completed_at": None if state == "open" else _OCCURRED,
|
||||
"on_time": None if state == "open" else True,
|
||||
}
|
||||
|
||||
|
||||
def _child(child_id: int, name: str, instances=()):
|
||||
completed = sum(1 for i in instances if i["state"] == "completed")
|
||||
due = len(instances)
|
||||
return {
|
||||
"child_id": child_id,
|
||||
"child_name": name,
|
||||
"present": True,
|
||||
"next_present": None,
|
||||
"due_today": due,
|
||||
"completed_today": completed,
|
||||
"remaining_today": due - completed,
|
||||
"completion_pct": 100 if due == 0 else round(completed / due * 100),
|
||||
"instances": list(instances),
|
||||
}
|
||||
|
||||
|
||||
def _payload(children, cycle_day=0) -> dict:
|
||||
return {
|
||||
"today_iso": "2026-09-23",
|
||||
"cycle_day": cycle_day,
|
||||
"children": list(children),
|
||||
}
|
||||
|
||||
|
||||
def _completed_frame(instance_id: int, child_id: int, child_name: str, window: str):
|
||||
"""The completed-event frame the API service publishes for one
|
||||
panel completion (the day-complete rule is the API's; its payload
|
||||
shape is pinned by tests/test_api_sse.py on the production
|
||||
plane)."""
|
||||
return (
|
||||
EVENT_QUEST_COMPLETED,
|
||||
{
|
||||
"child_id": child_id,
|
||||
"child_name": child_name,
|
||||
"instance_id": instance_id,
|
||||
"quest_title": "Brush teeth",
|
||||
"window": window,
|
||||
"due_date": "2026-09-23",
|
||||
"due_time": "08:00" if window == "morning" else "19:00",
|
||||
"occurred_at": _OCCURRED,
|
||||
"was_on_time": True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _day_complete_frame(child_id: int, child_name: str):
|
||||
"""The day-complete frame the API publishes once a child's day
|
||||
clears (after the LAST completion of the day)."""
|
||||
return (
|
||||
EVENT_CHILD_DAY_COMPLETE,
|
||||
{
|
||||
"child_id": child_id,
|
||||
"child_name": child_name,
|
||||
"quests_due": 2,
|
||||
"quests_completed": 2,
|
||||
"occurred_at": _OCCURRED,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _fixture(hass, make_entry):
|
||||
"""Two quest children, one zero-quest child, one inactive child."""
|
||||
"""Two quest children, one zero-quest child; the inactive child is
|
||||
simply ABSENT from the API's snapshot (the API only reports active
|
||||
children, so the integration can never create its entities)."""
|
||||
entry = wire_entry_to_registry(
|
||||
make_entry(data={CONF_ADMIN_USER_IDS: ["admin-1"]}), hass.registry
|
||||
)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
database = entry.runtime_data.database
|
||||
ada = await create_child(database, "Ada")
|
||||
bo = await create_child(database, "Bo")
|
||||
cory = await create_child(database, "Cory")
|
||||
ghost = await create_child(database, "Ghost")
|
||||
await set_child_active(database, ghost.id, False)
|
||||
today = datetime.date.today()
|
||||
await create_quest_definition(
|
||||
database,
|
||||
"Brush teeth",
|
||||
ScheduleRule.from_dict(
|
||||
{"rule_type": "daily", "start_date": today.isoformat()}
|
||||
make_entry(
|
||||
data={"api_base_url": "http://api.test:8000", "panel_token": "tok"}
|
||||
),
|
||||
[ada.id, bo.id],
|
||||
["morning", "evening"],
|
||||
hass.registry,
|
||||
)
|
||||
end = (today + datetime.timedelta(days=3)).isoformat()
|
||||
await materialize(database, today.isoformat(), end, today=today)
|
||||
await entry.runtime_data.coordinator.async_refresh()
|
||||
return entry, (ada, bo, cory, ghost)
|
||||
client = ScriptedPanelClient(
|
||||
_payload(
|
||||
[
|
||||
_child(ADA, "Ada", [_instance(7, ADA), _instance(8, ADA, window="evening")]),
|
||||
_child(BO, "Bo", [_instance(9, BO), _instance(10, BO, window="evening")]),
|
||||
_child(CORY, "Cory"),
|
||||
]
|
||||
),
|
||||
_payload(
|
||||
[
|
||||
_child(
|
||||
ADA,
|
||||
"Ada",
|
||||
[
|
||||
_instance(7, ADA, state="completed"),
|
||||
_instance(8, ADA, window="evening", state="completed"),
|
||||
],
|
||||
),
|
||||
_child(BO, "Bo", [_instance(9, BO), _instance(10, BO, window="evening")]),
|
||||
_child(CORY, "Cory"),
|
||||
]
|
||||
),
|
||||
)
|
||||
set_coordinator_client(entry, client)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
return entry, client
|
||||
|
||||
|
||||
def _sensor(hass, child_id, kind):
|
||||
@@ -75,11 +159,11 @@ def _sensor(hass, child_id, kind):
|
||||
|
||||
|
||||
async def test_entity_and_event_matrix(hass, make_entry) -> None:
|
||||
entry, (ada, bo, cory, ghost) = await _fixture(hass, make_entry)
|
||||
entry, client = await _fixture(hass, make_entry)
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
|
||||
# --- Every active child has the full entity set; the inactive
|
||||
# child has none.
|
||||
# --- Every reported child has the full entity set; the child the
|
||||
# API does not report has none.
|
||||
kinds = (
|
||||
"quests_due_today",
|
||||
"quests_completed_today",
|
||||
@@ -89,36 +173,37 @@ async def test_entity_and_event_matrix(hass, make_entry) -> None:
|
||||
"all_done",
|
||||
"present_today",
|
||||
)
|
||||
for child in (ada, bo, cory):
|
||||
ghost_id = 99
|
||||
for child in (ADA, BO, CORY):
|
||||
for kind in kinds:
|
||||
assert f"{DOMAIN}_child_{child.id}_{kind}" in hass.entities, kind
|
||||
assert f"{DOMAIN}_child_{child}_{kind}" in hass.entities, kind
|
||||
for kind in kinds:
|
||||
assert f"{DOMAIN}_child_{ghost.id}_{kind}" not in hass.entities, (
|
||||
"inactive children must have no entities"
|
||||
assert f"{DOMAIN}_child_{ghost_id}_{kind}" not in hass.entities, (
|
||||
"children absent from the API snapshot must have no entities"
|
||||
)
|
||||
|
||||
# --- Correct values against the fixture: Ada and Bo owe two
|
||||
# quests (morning + evening), Cory owes none and is still 100%.
|
||||
for child in (ada, bo):
|
||||
assert _sensor(hass, child.id, "quests_due_today").native_value == 2
|
||||
for child in (ADA, BO):
|
||||
assert _sensor(hass, child, "quests_due_today").native_value == 2
|
||||
assert (
|
||||
_sensor(hass, child.id, "quests_completed_today").native_value == 0
|
||||
_sensor(hass, child, "quests_completed_today").native_value == 0
|
||||
)
|
||||
assert (
|
||||
_sensor(hass, child.id, "quests_remaining_today").native_value == 2
|
||||
_sensor(hass, child, "quests_remaining_today").native_value == 2
|
||||
)
|
||||
assert (
|
||||
_sensor(hass, child.id, "completion_pct_today").native_value == 0
|
||||
_sensor(hass, child, "completion_pct_today").native_value == 0
|
||||
)
|
||||
assert _sensor(hass, child.id, "next_quest").native_value == (
|
||||
assert _sensor(hass, child, "next_quest").native_value == (
|
||||
"Brush teeth"
|
||||
)
|
||||
assert _sensor(hass, child.id, "all_done").is_on is False
|
||||
assert _sensor(hass, child.id, "present_today").is_on is True
|
||||
assert _sensor(hass, cory.id, "quests_due_today").native_value == 0
|
||||
assert _sensor(hass, cory.id, "completion_pct_today").native_value == 100
|
||||
assert _sensor(hass, cory.id, "all_done").is_on is False
|
||||
assert _sensor(hass, cory.id, "next_quest").native_value == "none"
|
||||
assert _sensor(hass, child, "all_done").is_on is False
|
||||
assert _sensor(hass, child, "present_today").is_on is True
|
||||
assert _sensor(hass, CORY, "quests_due_today").native_value == 0
|
||||
assert _sensor(hass, CORY, "completion_pct_today").native_value == 100
|
||||
assert _sensor(hass, CORY, "all_done").is_on is False
|
||||
assert _sensor(hass, CORY, "next_quest").native_value == "none"
|
||||
|
||||
# --- Household rollups equal the per-child sums.
|
||||
assert hass.entities[
|
||||
@@ -138,22 +223,31 @@ async def test_entity_and_event_matrix(hass, make_entry) -> None:
|
||||
), unique_id
|
||||
|
||||
# --- Completing both of Ada's quests fires the events exactly
|
||||
# once each and flips her entities immediately.
|
||||
instances = await QuestInstancesDao(
|
||||
entry.runtime_data.database
|
||||
).list_by_date_range(
|
||||
ada.id, datetime.date.today().isoformat(), datetime.date.today().isoformat()
|
||||
# once each and flips her entities immediately. The transition
|
||||
# events arrive over the API's SSE stream (the subscription
|
||||
# re-fires them), never from the service handler.
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
{"instance_id": 7, "actor": "panel", "actor_child_id": ADA},
|
||||
)
|
||||
for instance in instances:
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
{
|
||||
"instance_id": instance.id,
|
||||
"actor": "panel",
|
||||
"actor_child_id": ada.id,
|
||||
},
|
||||
)
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
{"instance_id": 8, "actor": "panel", "actor_child_id": ADA},
|
||||
)
|
||||
client.published_frames.extend(
|
||||
[
|
||||
_completed_frame(7, ADA, "Ada", "morning"),
|
||||
_completed_frame(8, ADA, "Ada", "evening"),
|
||||
# The API publishes the day-complete frame only when the
|
||||
# day clears — after the LAST completion of the day.
|
||||
_day_complete_frame(ADA, "Ada"),
|
||||
]
|
||||
)
|
||||
from conftest import refire_api_transitions
|
||||
|
||||
await refire_api_transitions(hass, entry, client)
|
||||
assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == 2
|
||||
for payload in hass.bus.fired(EVENT_QUEST_COMPLETED):
|
||||
assert _TIMESTAMP.match(payload["occurred_at"])
|
||||
@@ -162,53 +256,53 @@ async def test_entity_and_event_matrix(hass, make_entry) -> None:
|
||||
assert payload["quest_title"] == "Brush teeth"
|
||||
assert isinstance(payload["instance_id"], int)
|
||||
assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1
|
||||
assert _sensor(hass, ada.id, "all_done").is_on is True
|
||||
assert _sensor(hass, ada.id, "quests_remaining_today").native_value == 0
|
||||
# The next scripted snapshot reflects both completions.
|
||||
await coordinator.async_refresh()
|
||||
assert _sensor(hass, ADA, "all_done").is_on is True
|
||||
assert _sensor(hass, ADA, "quests_remaining_today").native_value == 0
|
||||
assert (
|
||||
_sensor(hass, ada.id, "completion_pct_today").native_value == 100
|
||||
_sensor(hass, ADA, "completion_pct_today").native_value == 100
|
||||
)
|
||||
|
||||
# --- Reversing one quest fires the uncompleted event exactly once
|
||||
# and restores the owed counts.
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_UNCOMPLETE_QUEST,
|
||||
{"instance_id": instances[0].id, "actor": "user"},
|
||||
context={"user_id": "admin-1"},
|
||||
# --- Reversing one quest fires the uncompleted event exactly once.
|
||||
# The HA ``uncomplete_quest`` service is gone (the API service's
|
||||
# admin route is the reversal path), so the reversal is the API's:
|
||||
# it publishes the uncompleted frame and the integration re-fires
|
||||
# it; the coordinator's next poll picks the restored counts up.
|
||||
client.published_frames.append(
|
||||
(
|
||||
EVENT_QUEST_UNCOMPLETED,
|
||||
{
|
||||
"child_id": ADA,
|
||||
"child_name": "Ada",
|
||||
"instance_id": 7,
|
||||
"quest_title": "Brush teeth",
|
||||
"window": "morning",
|
||||
"due_date": "2026-09-23",
|
||||
"due_time": "08:00",
|
||||
"occurred_at": _OCCURRED,
|
||||
},
|
||||
)
|
||||
)
|
||||
await refire_api_transitions(hass, entry, client)
|
||||
assert len(hass.bus.fired(EVENT_QUEST_UNCOMPLETED)) == 1
|
||||
assert _sensor(hass, ada.id, "all_done").is_on is False
|
||||
assert _sensor(hass, ada.id, "quests_remaining_today").native_value == 1
|
||||
|
||||
# --- The instances payload omits missed instances while
|
||||
# admin_instances includes them (D-009): pin the derive clock
|
||||
# forward in the TEST ONLY so today's open instances read missed.
|
||||
original_refresh = coordinator._async_update_data
|
||||
|
||||
async def _future_dated_refresh():
|
||||
import custom_components.nestquest.core.snapshot as snapshot_module
|
||||
|
||||
real_derive = snapshot_module.derive_state
|
||||
|
||||
def _derive(instance, latest, today):
|
||||
return real_derive(instance, latest, today + datetime.timedelta(days=1))
|
||||
|
||||
snapshot_module.derive_state = _derive
|
||||
try:
|
||||
return await original_refresh()
|
||||
finally:
|
||||
snapshot_module.derive_state = real_derive
|
||||
|
||||
coordinator._async_update_data = _future_dated_refresh
|
||||
# --- The panel payload omits missed instances (D-009) — the API
|
||||
# panel route omits missed rows ENTIRELY, so admin_instances
|
||||
# cannot carry them either (the admin surface for missed quests is
|
||||
# the PWA, reading the API directly). A snapshot built from the
|
||||
# payload can only ever hold open and completed rows: pin that
|
||||
# the payload round-trips verbatim.
|
||||
await coordinator.async_refresh()
|
||||
attributes = _sensor(hass, bo.id, "quests_due_today").extra_state_attributes
|
||||
assert attributes["instances"] == [], "panel payload omits missed"
|
||||
assert len(attributes["admin_instances"]) == 2
|
||||
assert all(
|
||||
entry["state"] == "missed" for entry in attributes["admin_instances"]
|
||||
attributes = _sensor(hass, BO, "quests_due_today").extra_state_attributes
|
||||
assert attributes["instances"] == [
|
||||
dict(_instance(9, BO)),
|
||||
dict(_instance(10, BO, window="evening")),
|
||||
], "the panel payload round-trips the API's open rows verbatim"
|
||||
assert attributes["admin_instances"] == attributes["instances"], (
|
||||
"the API payload omits missed rows, so the admin payload "
|
||||
"carries none either (the PWA is the admin surface now)"
|
||||
)
|
||||
coordinator._async_update_data = original_refresh
|
||||
await coordinator.async_refresh()
|
||||
|
||||
|
||||
async def test_payload_schema_matches_the_documented_contract(
|
||||
@@ -216,21 +310,16 @@ async def test_payload_schema_matches_the_documented_contract(
|
||||
) -> None:
|
||||
"""Every fired payload carries the documented fields with the
|
||||
documented types."""
|
||||
entry, (ada, _bo, _cory, _ghost) = await _fixture(hass, make_entry)
|
||||
instances = await QuestInstancesDao(
|
||||
entry.runtime_data.database
|
||||
).list_by_date_range(
|
||||
ada.id, datetime.date.today().isoformat(), datetime.date.today().isoformat()
|
||||
)
|
||||
entry, client = await _fixture(hass, make_entry)
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
{
|
||||
"instance_id": instances[0].id,
|
||||
"actor": "panel",
|
||||
"actor_child_id": ada.id,
|
||||
},
|
||||
{"instance_id": 7, "actor": "panel", "actor_child_id": ADA},
|
||||
)
|
||||
client.published_frames.append(_completed_frame(7, ADA, "Ada", "morning"))
|
||||
from conftest import refire_api_transitions
|
||||
|
||||
await refire_api_transitions(hass, entry, client)
|
||||
payload = hass.bus.fired(EVENT_QUEST_COMPLETED)[0]
|
||||
for key, check in (
|
||||
("child_id", lambda v: isinstance(v, int)),
|
||||
|
||||
+236
-658
File diff suppressed because it is too large
Load Diff
+272
-502
@@ -9,27 +9,103 @@ from conftest import make_config_entry, make_hass, wire_entry_to_registry
|
||||
|
||||
from custom_components.nestquest import DOMAIN, async_setup_entry, async_unload_entry
|
||||
from custom_components.nestquest.const import (
|
||||
CONF_ADMIN_USER_IDS,
|
||||
CONF_DAY_ROLLOVER_TIME,
|
||||
CONF_HORIZON_DAYS,
|
||||
CONF_MORNING_SUMMARY_TIME,
|
||||
DEFAULT_HORIZON_DAYS,
|
||||
SERVICE_REGENERATE,
|
||||
DOMAIN as DOMAIN_CONST,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
)
|
||||
from custom_components.nestquest.dashboard import (
|
||||
DASHBOARD_STRATEGY_TYPE,
|
||||
DASHBOARD_URL_PATH,
|
||||
async_register_dashboard,
|
||||
)
|
||||
|
||||
ADMIN_ID = "admin-1"
|
||||
ADMIN_CTX = {"user_id": ADMIN_ID}
|
||||
|
||||
|
||||
def _admin_data():
|
||||
return {CONF_ADMIN_USER_IDS: [ADMIN_ID]}
|
||||
|
||||
|
||||
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
|
||||
@@ -37,15 +113,25 @@ async def test_setup_stores_runtime_data_in_hass_data(hass, make_entry) -> None:
|
||||
assert hass.data[DOMAIN][entry.entry_id] is entry.runtime_data
|
||||
|
||||
|
||||
async def test_setup_runtime_data_has_entry_id_and_settings(hass, make_entry) -> None:
|
||||
entry = _wire(make_entry(options={"horizon_days": 7}), hass.registry)
|
||||
async def test_setup_runtime_data_has_entry_id_and_coordinator(hass, make_entry) -> None:
|
||||
entry = _wire(make_entry(), hass.registry)
|
||||
await async_setup_entry(hass, entry)
|
||||
runtime = entry.runtime_data
|
||||
assert runtime.entry_id == entry.entry_id
|
||||
assert runtime.settings.horizon_days == 7
|
||||
assert runtime.coordinator is not None
|
||||
assert callable(runtime.remove_update_listener)
|
||||
|
||||
|
||||
async def test_setup_registers_no_time_change_listener(hass, make_entry) -> None:
|
||||
"""The integration owns no database any more: the daily
|
||||
day-rollover/materialization listener is gone (the API service
|
||||
owns the horizon), so setup registers NOTHING against the
|
||||
time-change listener bookkeeping."""
|
||||
entry = _wire(make_entry(), hass.registry)
|
||||
await async_setup_entry(hass, entry)
|
||||
assert hass.time_change.size == 0
|
||||
|
||||
|
||||
async def test_unload_returns_true_and_removes_hass_data(hass, make_entry) -> None:
|
||||
registry = hass.registry
|
||||
entry = _wire(make_entry(), registry)
|
||||
@@ -120,7 +206,7 @@ async def test_options_update_triggers_reload_once(hass, make_entry) -> None:
|
||||
registry = hass.registry
|
||||
entry = _wire(make_entry(), registry)
|
||||
await async_setup_entry(hass, entry)
|
||||
entry.options = {"horizon_days": 30}
|
||||
entry.options = {"update_interval": 60}
|
||||
await registry.async_dispatch_options_update(entry)
|
||||
assert hass.config_entries.async_reload.call_count == 1
|
||||
assert registry.reloaded == [entry.entry_id]
|
||||
@@ -198,6 +284,16 @@ async def test_setup_entry_uses_domain_constant(hass, make_entry) -> None:
|
||||
assert set(hass.data) == {DOMAIN_CONST}
|
||||
|
||||
|
||||
async def test_setup_registers_the_completion_service(hass, make_entry) -> None:
|
||||
"""The one domain-global service registers with setup and deregisters
|
||||
on the last unload."""
|
||||
entry = _wire(make_entry(), hass.registry)
|
||||
await async_setup_entry(hass, entry)
|
||||
assert hass.services.has_service(DOMAIN, SERVICE_COMPLETE_QUEST)
|
||||
await async_unload_entry(hass, entry)
|
||||
assert not hass.services.has_service(DOMAIN, SERVICE_COMPLETE_QUEST)
|
||||
|
||||
|
||||
async def test_setup_awaitable_listener_removal_supported() -> None:
|
||||
"""A coroutine-returning remover is awaited during unload."""
|
||||
removed = []
|
||||
@@ -216,9 +312,9 @@ async def test_setup_awaitable_listener_removal_supported() -> None:
|
||||
assert entry.runtime_data is None
|
||||
|
||||
|
||||
async def test_setup_closes_database_when_listener_registration_fails() -> None:
|
||||
"""A listener-registration failure must not leak the opened database."""
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
async def test_setup_unwinds_when_listener_registration_fails() -> None:
|
||||
"""A listener-registration failure must not leak partial setup state."""
|
||||
from custom_components.nestquest.const import DOMAIN as DOMAIN_CONST
|
||||
|
||||
class _Boom(Exception):
|
||||
pass
|
||||
@@ -228,13 +324,16 @@ async def test_setup_closes_database_when_listener_registration_fails() -> None:
|
||||
hass, _registry = make_hass()
|
||||
with pytest.raises(_Boom):
|
||||
await async_setup_entry(hass, entry)
|
||||
assert hass.data.get(DOMAIN, {}) == {}
|
||||
assert hass.data.get(DOMAIN_CONST, {}) == {}
|
||||
assert not hasattr(entry, "runtime_data")
|
||||
assert not hass.services.has_service(DOMAIN_CONST, SERVICE_COMPLETE_QUEST)
|
||||
|
||||
|
||||
async def test_unload_closes_database_even_when_listener_removal_raises() -> None:
|
||||
"""Unload removes the listener, closes the DB in a finally, then raises."""
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
async def test_unload_shuts_coordinator_even_when_listener_removal_raises() -> None:
|
||||
"""A raising update-listener remover must not skip the coordinator
|
||||
shutdown: the retained error is re-raised only after the teardown
|
||||
ran and the runtime record was cleared."""
|
||||
from custom_components.nestquest.const import DOMAIN as DOMAIN_CONST
|
||||
|
||||
class _RemoveBoom(Exception):
|
||||
pass
|
||||
@@ -246,504 +345,175 @@ async def test_unload_closes_database_even_when_listener_removal_raises() -> Non
|
||||
entry.add_update_listener = lambda listener: _remover
|
||||
hass, _registry = make_hass()
|
||||
await async_setup_entry(hass, entry)
|
||||
database = entry.runtime_data.database
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
seen = []
|
||||
|
||||
async def _shutdown():
|
||||
seen.append("shutdown")
|
||||
|
||||
coordinator.async_shutdown = _shutdown
|
||||
|
||||
with pytest.raises(_RemoveBoom):
|
||||
await async_unload_entry(hass, entry)
|
||||
assert database.connected is False, "database leaked despite remover error"
|
||||
assert seen == ["shutdown"], "coordinator shutdown must have run"
|
||||
assert entry.runtime_data is None
|
||||
assert DOMAIN not in hass.data
|
||||
assert hass.time_change.size == 0, "time-change listener leaked"
|
||||
|
||||
|
||||
async def test_unload_cancels_time_listener_even_when_update_remover_raises() -> None:
|
||||
"""A raising update-listener remover must not skip the time-change cancel.
|
||||
|
||||
Both removers are independent: the update-listener remover raising must
|
||||
still cancel the daily time-change callback, whose action would otherwise
|
||||
fire after unload against a closed database.
|
||||
"""
|
||||
|
||||
class _RemoveBoom(Exception):
|
||||
pass
|
||||
|
||||
def _remover():
|
||||
raise _RemoveBoom()
|
||||
|
||||
entry = make_config_entry(entry_id="update-boom-time-cancel")
|
||||
entry.add_update_listener = lambda listener: _remover
|
||||
hass, _registry = make_hass()
|
||||
await async_setup_entry(hass, entry)
|
||||
assert hass.time_change.size == 1
|
||||
with pytest.raises(_RemoveBoom):
|
||||
await async_unload_entry(hass, entry)
|
||||
assert hass.time_change.size == 0, "time-change listener not cancelled"
|
||||
assert entry.runtime_data is None
|
||||
assert DOMAIN not in hass.data
|
||||
|
||||
|
||||
async def test_setup_registers_day_rollover_listener(hass, make_entry) -> None:
|
||||
"""Setup registers a daily rollover listener and stores its cancel fn."""
|
||||
entry = _wire(make_entry(), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
assert hass.time_change.size == 1
|
||||
registration = hass.time_change.registrations[0]
|
||||
assert registration["hour"] == 0
|
||||
assert registration["minute"] == 0
|
||||
assert callable(entry.runtime_data.remove_time_change_listener)
|
||||
|
||||
|
||||
async def test_setup_registers_day_rollover_at_configured_time(hass, make_entry) -> None:
|
||||
"""The listener fires at the entry option's rollover time, not the default."""
|
||||
entry = _wire(make_entry(options={CONF_DAY_ROLLOVER_TIME: "06:30"}), hass.registry)
|
||||
await async_setup_entry(hass, entry)
|
||||
registration = hass.time_change.registrations[0]
|
||||
assert registration["hour"] == 6
|
||||
assert registration["minute"] == 30
|
||||
|
||||
|
||||
async def test_unload_cancels_day_rollover_listener(hass, make_entry) -> None:
|
||||
"""Unload removes the time-change listener alongside the update listener."""
|
||||
entry = _wire(make_entry(), hass.registry)
|
||||
await async_setup_entry(hass, entry)
|
||||
assert hass.time_change.size == 1
|
||||
assert await async_unload_entry(hass, entry) is True
|
||||
assert DOMAIN_CONST not in hass.data
|
||||
assert hass.time_change.size == 0
|
||||
|
||||
|
||||
async def test_rollover_time_change_reregisters_listener_at_new_time(
|
||||
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 rollover-time options change re-registers the listener at the new time."""
|
||||
entry = _wire(
|
||||
make_entry(options={CONF_DAY_ROLLOVER_TIME: "00:00"}), hass.registry
|
||||
)
|
||||
await async_setup_entry(hass, entry)
|
||||
assert (hass.time_change.registrations[0]["hour"], hass.time_change.registrations[0]["minute"]) == (0, 0)
|
||||
|
||||
# The options flow writes the new rollover time, then HA reloads the
|
||||
# entry: unload tears down the old listener, setup re-registers it.
|
||||
entry.options = {CONF_DAY_ROLLOVER_TIME: "06:30"}
|
||||
"""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 hass.time_change.size == 0
|
||||
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
assert hass.time_change.size == 1
|
||||
registration = hass.time_change.registrations[0]
|
||||
assert (registration["hour"], registration["minute"]) == (6, 30)
|
||||
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_day_rollover_listener_materializes_ha_local_horizon(
|
||||
hass, make_entry, monkeypatch
|
||||
) -> None:
|
||||
"""Firing the listener materializes today..today+horizon in HA local time."""
|
||||
import datetime
|
||||
from unittest.mock import AsyncMock
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import custom_components.nestquest as nestquest
|
||||
|
||||
entry = _wire(make_entry(), hass.registry)
|
||||
await async_setup_entry(hass, entry)
|
||||
database = entry.runtime_data.database
|
||||
|
||||
fake = AsyncMock()
|
||||
monkeypatch.setattr(nestquest, "_materialize_run", fake)
|
||||
|
||||
time_zone = ZoneInfo(hass.config.time_zone)
|
||||
today = datetime.datetime.now(time_zone).date()
|
||||
expected_start = today.isoformat()
|
||||
expected_end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
|
||||
|
||||
await hass.time_change.fire()
|
||||
|
||||
fake.assert_awaited_once_with(
|
||||
database, expected_start, expected_end, today=today
|
||||
)
|
||||
|
||||
|
||||
async def test_day_rollover_uses_configured_horizon_days(hass, make_entry) -> None:
|
||||
"""The daily run materializes only the configured horizon window."""
|
||||
import datetime
|
||||
|
||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||
|
||||
entry = _wire(make_entry(options={CONF_HORIZON_DAYS: 5}), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
database = entry.runtime_data.database
|
||||
|
||||
# A definition created AFTER setup has no instances yet (create does not
|
||||
# materialize); firing the daily listener must generate only the horizon.
|
||||
child_id = await _seed_daily_child_and_definition(
|
||||
database, _local_today(hass)
|
||||
)
|
||||
await hass.time_change.fire()
|
||||
|
||||
today = _local_today(hass)
|
||||
end = (today + datetime.timedelta(days=5)).isoformat()
|
||||
records = await QuestInstancesDao(database).list_by_date_range(
|
||||
child_id, today.isoformat(), end
|
||||
)
|
||||
assert len(records) == 6
|
||||
|
||||
beyond = (today + datetime.timedelta(days=6)).isoformat()
|
||||
beyond_end = (
|
||||
today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
||||
).isoformat()
|
||||
assert await QuestInstancesDao(database).list_by_date_range(
|
||||
child_id, beyond, beyond_end
|
||||
) == []
|
||||
|
||||
|
||||
def test_day_rollover_time_change_tracker_rejects_local_kwarg() -> None:
|
||||
"""The fake async_track_time_change mirrors HA 2024.6 (no ``local`` kwarg).
|
||||
|
||||
Setup drives the local-time wrapper directly, so passing ``local=`` must
|
||||
raise TypeError — otherwise a regression in the production call would be
|
||||
silently masked by the fake.
|
||||
"""
|
||||
import inspect as _inspect
|
||||
|
||||
from homeassistant.helpers.event import async_track_time_change
|
||||
|
||||
params = _inspect.signature(async_track_time_change).parameters
|
||||
assert list(params) == ["hass", "action", "hour", "minute", "second"]
|
||||
with pytest.raises(TypeError):
|
||||
async_track_time_change(None, lambda _now: None, hour=0, minute=0, local=True)
|
||||
|
||||
|
||||
async def test_setup_applies_all_v1_tables(hass, make_entry) -> None:
|
||||
"""Setup applies the full v1 DDL: all four v1 tables exist afterwards."""
|
||||
entry = _wire(make_entry(), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
database = entry.runtime_data.database
|
||||
rows = await database.fetch_all(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' "
|
||||
"AND name IN ('children', 'admin_users', 'schedule_rules', "
|
||||
"'quest_definitions') ORDER BY name"
|
||||
)
|
||||
assert [row[0] for row in rows] == [
|
||||
"admin_users",
|
||||
"children",
|
||||
"quest_definitions",
|
||||
"schedule_rules",
|
||||
]
|
||||
await async_unload_entry(hass, entry)
|
||||
|
||||
|
||||
async def _seed_daily_child_and_definition(database, today) -> int:
|
||||
"""Create one always-present child + a daily definition starting today.
|
||||
|
||||
Returns the child's id. No presence schedule means the child is present
|
||||
every day, so the daily rule yields exactly one instance per horizon day.
|
||||
"""
|
||||
from custom_components.nestquest.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.quest_definitions import (
|
||||
create_quest_definition,
|
||||
)
|
||||
from custom_components.nestquest.recurrence import RuleType, ScheduleRule
|
||||
|
||||
NOW = "2026-09-14T12:00:00+00:00"
|
||||
child = await ChildrenDao(database).create("Ada", NOW)
|
||||
await create_quest_definition(
|
||||
database,
|
||||
"Daily chore",
|
||||
ScheduleRule(rule_type=RuleType.DAILY, start_date=today.isoformat()),
|
||||
[child.id],
|
||||
["morning"],
|
||||
)
|
||||
return child.id
|
||||
|
||||
|
||||
def _local_today(hass) -> "datetime.date":
|
||||
import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
return datetime.datetime.now(ZoneInfo(hass.config.time_zone)).date()
|
||||
|
||||
|
||||
async def test_setup_backfills_instances_over_horizon(hass, make_entry) -> None:
|
||||
"""Setup materializes once so a restart backfills any missed days."""
|
||||
import datetime
|
||||
|
||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.migrations import apply_migrations
|
||||
from custom_components.nestquest.store import async_get_db_path
|
||||
|
||||
# Seed the database BEFORE setup: a restart reopens an existing file that
|
||||
# already holds definitions. The startup backfill must pick them up.
|
||||
db_path = await async_get_db_path(hass)
|
||||
pre = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await pre.open(db_path)
|
||||
await apply_migrations(pre)
|
||||
child_id = await _seed_daily_child_and_definition(pre, _local_today(hass))
|
||||
await pre.close()
|
||||
|
||||
entry = _wire(make_entry(), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
|
||||
today = _local_today(hass)
|
||||
end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
|
||||
records = await QuestInstancesDao(entry.runtime_data.database).list_by_date_range(
|
||||
child_id, today.isoformat(), end
|
||||
)
|
||||
assert len(records) == DEFAULT_HORIZON_DAYS + 1
|
||||
|
||||
|
||||
async def test_setup_backfills_configured_horizon_days(hass, make_entry) -> None:
|
||||
"""Setup backfills only the configured (non-default) horizon window."""
|
||||
import datetime
|
||||
|
||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.migrations import apply_migrations
|
||||
from custom_components.nestquest.store import async_get_db_path
|
||||
|
||||
db_path = await async_get_db_path(hass)
|
||||
pre = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await pre.open(db_path)
|
||||
await apply_migrations(pre)
|
||||
child_id = await _seed_daily_child_and_definition(pre, _local_today(hass))
|
||||
await pre.close()
|
||||
|
||||
entry = _wire(
|
||||
make_entry(options={CONF_HORIZON_DAYS: 5}), hass.registry
|
||||
)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
|
||||
today = _local_today(hass)
|
||||
end = (today + datetime.timedelta(days=5)).isoformat()
|
||||
records = await QuestInstancesDao(
|
||||
entry.runtime_data.database
|
||||
).list_by_date_range(child_id, today.isoformat(), end)
|
||||
assert len(records) == 6
|
||||
|
||||
beyond = (today + datetime.timedelta(days=6)).isoformat()
|
||||
beyond_end = (
|
||||
today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
||||
).isoformat()
|
||||
assert await QuestInstancesDao(
|
||||
entry.runtime_data.database
|
||||
).list_by_date_range(child_id, beyond, beyond_end) == []
|
||||
|
||||
|
||||
async def test_setup_succeeds_with_fully_malformed_options(
|
||||
async def test_setup_leaves_an_existing_user_dashboard_alone(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""A fully malformed options dict falls back per field and setup succeeds.
|
||||
|
||||
``horizon_days=True``, ``day_rollover_time='99:99'`` and
|
||||
``morning_summary_time='25:00'`` are all invalid; the integration's
|
||||
``from_options_resilient`` falls each back to its default, so setup
|
||||
SUCCEEDS, the day-rollover listener registers at the default hour 0
|
||||
minute 0, and the startup backfill materializes the default horizon
|
||||
window.
|
||||
"""
|
||||
import datetime
|
||||
|
||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.migrations import apply_migrations
|
||||
from custom_components.nestquest.store import async_get_db_path
|
||||
|
||||
db_path = await async_get_db_path(hass)
|
||||
pre = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await pre.open(db_path)
|
||||
await apply_migrations(pre)
|
||||
child_id = await _seed_daily_child_and_definition(pre, _local_today(hass))
|
||||
await pre.close()
|
||||
|
||||
entry = _wire(
|
||||
make_entry(
|
||||
options={
|
||||
CONF_HORIZON_DAYS: True,
|
||||
CONF_DAY_ROLLOVER_TIME: "99:99",
|
||||
CONF_MORNING_SUMMARY_TIME: "25:00",
|
||||
}
|
||||
),
|
||||
hass.registry,
|
||||
)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
|
||||
# The day-rollover listener fell back to the default 00:00.
|
||||
registration = hass.time_change.registrations[0]
|
||||
assert registration["hour"] == 0
|
||||
assert registration["minute"] == 0
|
||||
|
||||
# The startup backfill used the default horizon window.
|
||||
today = _local_today(hass)
|
||||
end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
|
||||
records = await QuestInstancesDao(
|
||||
entry.runtime_data.database
|
||||
).list_by_date_range(child_id, today.isoformat(), end)
|
||||
assert len(records) == DEFAULT_HORIZON_DAYS + 1
|
||||
|
||||
|
||||
async def test_setup_keeps_horizon_when_sibling_option_is_bad(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""A malformed sibling option does not reset the configured horizon.
|
||||
|
||||
``horizon_days=5`` is valid while ``morning_summary_time='25:00'`` is
|
||||
invalid; ``from_options_resilient`` falls back ONLY the bad sibling,
|
||||
so the entry's resolved settings keep ``horizon_days == 5``.
|
||||
"""
|
||||
entry = _wire(
|
||||
make_entry(
|
||||
options={
|
||||
CONF_HORIZON_DAYS: 5,
|
||||
CONF_MORNING_SUMMARY_TIME: "25:00",
|
||||
}
|
||||
),
|
||||
hass.registry,
|
||||
)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
assert entry.runtime_data.settings.horizon_days == 5
|
||||
|
||||
|
||||
async def test_regenerate_service_registered_and_materializes(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""``nestquest.regenerate`` is registered and calling it regenerates."""
|
||||
import datetime
|
||||
|
||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||
|
||||
entry = _wire(make_entry(data=_admin_data()), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
||||
|
||||
# A definition created AFTER setup has no instances yet (create does not
|
||||
# materialize); the service must generate them on demand.
|
||||
database = entry.runtime_data.database
|
||||
child_id = await _seed_daily_child_and_definition(database, _local_today(hass))
|
||||
|
||||
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
||||
|
||||
today = _local_today(hass)
|
||||
end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
|
||||
records = await QuestInstancesDao(database).list_by_date_range(
|
||||
child_id, today.isoformat(), end
|
||||
)
|
||||
assert len(records) == DEFAULT_HORIZON_DAYS + 1
|
||||
|
||||
|
||||
async def test_regenerate_service_uses_configured_horizon_days(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""``nestquest.regenerate`` regenerates only the configured horizon."""
|
||||
import datetime
|
||||
|
||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||
|
||||
entry = _wire(
|
||||
make_entry(options={CONF_HORIZON_DAYS: 5}, data=_admin_data()),
|
||||
hass.registry,
|
||||
)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
|
||||
database = entry.runtime_data.database
|
||||
child_id = await _seed_daily_child_and_definition(database, _local_today(hass))
|
||||
|
||||
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
||||
|
||||
today = _local_today(hass)
|
||||
end = (today + datetime.timedelta(days=5)).isoformat()
|
||||
records = await QuestInstancesDao(database).list_by_date_range(
|
||||
child_id, today.isoformat(), end
|
||||
)
|
||||
assert len(records) == 6
|
||||
|
||||
beyond = (today + datetime.timedelta(days=6)).isoformat()
|
||||
beyond_end = (
|
||||
today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
||||
).isoformat()
|
||||
assert await QuestInstancesDao(database).list_by_date_range(
|
||||
child_id, beyond, beyond_end
|
||||
) == []
|
||||
|
||||
|
||||
async def test_unload_deregisters_regenerate_service(hass, make_entry) -> None:
|
||||
"""Unload removes the ``regenerate`` service so no stale entry leaks."""
|
||||
"""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 hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
||||
|
||||
assert await async_unload_entry(hass, entry) is True
|
||||
assert not hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
||||
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_regenerate_service_is_domain_scoped_across_entries(
|
||||
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:
|
||||
"""The ``regenerate`` service is domain-global, not per-entry.
|
||||
|
||||
With two loaded entries the service must be registered and functional;
|
||||
unloading one entry must keep it registered and functional for the
|
||||
remaining entry; unloading the final entry must remove it.
|
||||
"""
|
||||
import datetime
|
||||
|
||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||
|
||||
entry_a = _wire(
|
||||
make_entry(entry_id="entry_a", data=_admin_data()), hass.registry
|
||||
)
|
||||
entry_b = _wire(
|
||||
make_entry(entry_id="entry_b", data=_admin_data()), hass.registry
|
||||
)
|
||||
|
||||
assert await async_setup_entry(hass, entry_a) is True
|
||||
assert await async_setup_entry(hass, entry_b) is True
|
||||
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
||||
|
||||
# The domain-global handler resolves a live database from any entry.
|
||||
database_b = entry_b.runtime_data.database
|
||||
child_id = await _seed_daily_child_and_definition(
|
||||
database_b, _local_today(hass)
|
||||
)
|
||||
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
||||
today = _local_today(hass)
|
||||
end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
|
||||
records = await QuestInstancesDao(database_b).list_by_date_range(
|
||||
child_id, today.isoformat(), end
|
||||
)
|
||||
assert len(records) == DEFAULT_HORIZON_DAYS + 1
|
||||
|
||||
# Unloading one entry must not tear down the shared service.
|
||||
assert await async_unload_entry(hass, entry_a) is True
|
||||
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
||||
|
||||
# It still works for the remaining entry.
|
||||
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
||||
|
||||
# Unloading the final entry removes the domain-global service.
|
||||
assert await async_unload_entry(hass, entry_b) is True
|
||||
assert not hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
||||
|
||||
|
||||
async def test_setup_cleans_listeners_when_materialization_raises(
|
||||
hass, make_entry, monkeypatch
|
||||
) -> None:
|
||||
"""A startup-materialization failure rolls back every registered listener.
|
||||
|
||||
The daily and update listeners are installed before the startup
|
||||
materialization runs; when that walk raises, the database is closed but
|
||||
the remover callables must also be invoked so no listener survives
|
||||
firing against a closed database.
|
||||
"""
|
||||
import custom_components.nestquest as nestquest
|
||||
|
||||
async def _boom(*_args, **_kwargs):
|
||||
raise RuntimeError("materialization failed")
|
||||
|
||||
monkeypatch.setattr(nestquest, "_materialize_run", _boom)
|
||||
"""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)
|
||||
with pytest.raises(RuntimeError, match="materialization failed"):
|
||||
await async_setup_entry(hass, entry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
assert collection.created == []
|
||||
|
||||
assert hass.time_change.size == 0
|
||||
assert hass.registry.size == 0
|
||||
assert not hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
||||
assert not hasattr(entry, "runtime_data")
|
||||
|
||||
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
|
||||
|
||||
+143
-16
@@ -6,29 +6,29 @@ import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.core.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
|
||||
from custom_components.nestquest.dao_instances import (
|
||||
from custom_components.nestquest.core.dao_instances import (
|
||||
CompletionEventsDao,
|
||||
QuestInstancesDao,
|
||||
)
|
||||
from custom_components.nestquest.dao_presence import PresenceSchedulesDao
|
||||
from custom_components.nestquest.dao_rules import (
|
||||
from custom_components.nestquest.core.dao_presence import PresenceSchedulesDao
|
||||
from custom_components.nestquest.core.dao_rules import (
|
||||
QuestDefinitionsDao,
|
||||
schedule_rule_to_storage,
|
||||
)
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.materialize import (
|
||||
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||
from custom_components.nestquest.core.materialize import (
|
||||
materialize,
|
||||
regenerate_for_child,
|
||||
regenerate_for_definition,
|
||||
)
|
||||
from custom_components.nestquest.migrations import apply_migrations
|
||||
from custom_components.nestquest.quest_definitions import (
|
||||
from custom_components.nestquest.core.migrations import apply_migrations
|
||||
from custom_components.nestquest.core.quest_definitions import (
|
||||
create_quest_definition,
|
||||
edit_quest_definition,
|
||||
)
|
||||
from custom_components.nestquest.recurrence import (
|
||||
from custom_components.nestquest.core.recurrence import (
|
||||
RuleType,
|
||||
ScheduleRule,
|
||||
occurs_on,
|
||||
@@ -505,7 +505,7 @@ def test_materialize_skips_deactivated_child_mid_walk(tmp_path) -> None:
|
||||
|
||||
def test_materialize_uses_single_presence_snapshot_mid_walk(tmp_path) -> None:
|
||||
async def _body(database):
|
||||
from custom_components.nestquest.dao_presence import (
|
||||
from custom_components.nestquest.core.dao_presence import (
|
||||
PresenceOverridesDao,
|
||||
)
|
||||
|
||||
@@ -628,7 +628,7 @@ def test_materialize_does_not_overwrite_regenerated_due_time(tmp_path) -> None:
|
||||
|
||||
def test_materialize_honours_overrides(tmp_path) -> None:
|
||||
async def _body(database):
|
||||
from custom_components.nestquest.dao_presence import (
|
||||
from custom_components.nestquest.core.dao_presence import (
|
||||
PresenceOverridesDao,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
@@ -758,7 +885,7 @@ def test_materialize_clamps_past_start_to_today(tmp_path) -> None:
|
||||
|
||||
def test_materialize_skips_inactive_definitions(tmp_path) -> None:
|
||||
async def _body(database):
|
||||
from custom_components.nestquest.quest_definitions import (
|
||||
from custom_components.nestquest.core.quest_definitions import (
|
||||
set_quest_definition_active,
|
||||
)
|
||||
|
||||
@@ -906,7 +1033,7 @@ def test_regenerate_for_child_rebuilds_horizon_preserves_history(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
async def _body(database):
|
||||
from custom_components.nestquest.dao_presence import (
|
||||
from custom_components.nestquest.core.dao_presence import (
|
||||
PresenceOverridesDao,
|
||||
)
|
||||
|
||||
@@ -1091,7 +1218,7 @@ def test_regenerate_for_child_uses_single_anchor_across_midnight(
|
||||
def test_regenerate_for_child_is_child_scoped(tmp_path) -> None:
|
||||
"""Regenerating one child must never touch another child's rows."""
|
||||
async def _body(database):
|
||||
from custom_components.nestquest.dao_presence import (
|
||||
from custom_components.nestquest.core.dao_presence import (
|
||||
PresenceOverridesDao,
|
||||
)
|
||||
|
||||
@@ -1493,10 +1620,10 @@ def test_materialize_end_to_end_six_rule_types(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(materialize_module, "_now_stamp", _distinct_stamp)
|
||||
|
||||
async def _body(database):
|
||||
from custom_components.nestquest.dao_presence import (
|
||||
from custom_components.nestquest.core.dao_presence import (
|
||||
PresenceOverridesDao,
|
||||
)
|
||||
from custom_components.nestquest.presence import PresenceSchedule
|
||||
from custom_components.nestquest.core.presence import PresenceSchedule
|
||||
|
||||
children = ChildrenDao(database)
|
||||
schedules = PresenceSchedulesDao(database)
|
||||
|
||||
+97
-136
@@ -9,16 +9,16 @@ from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.schema import SCHEMA_V7_META_STATE_DDL
|
||||
from custom_components.nestquest.migrations import (
|
||||
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||
from custom_components.nestquest.core.schema import SCHEMA_V7_META_STATE_DDL
|
||||
from custom_components.nestquest.core.migrations import (
|
||||
MIGRATIONS,
|
||||
VERSION_TABLE,
|
||||
VERSION_TABLE_DDL,
|
||||
apply_migrations,
|
||||
read_schema_version,
|
||||
)
|
||||
from custom_components.nestquest.schema import (
|
||||
from custom_components.nestquest.core.schema import (
|
||||
SCHEMA_V1_STATEMENTS,
|
||||
SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL,
|
||||
)
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1064,133 +1155,3 @@ def test_partial_pending_migrations_resume_from_current_version(
|
||||
assert "step_b" in _tables(database)
|
||||
finally:
|
||||
_run(database.close())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Startup wiring: setup runs the migration runner, not raw DDL
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _setup_entry(hass, entry, registry) -> object:
|
||||
from tests.test_lifecycle import _wire # reuse the established wiring
|
||||
|
||||
entry = _wire(entry, registry)
|
||||
from custom_components.nestquest import async_setup_entry
|
||||
|
||||
await async_setup_entry(hass, entry)
|
||||
return entry
|
||||
|
||||
|
||||
async def test_setup_entry_migrates_fresh_database(hass, make_entry) -> None:
|
||||
import sqlite3 as sqlite3_mod
|
||||
|
||||
from custom_components.nestquest.migrations import read_schema_version
|
||||
from custom_components.nestquest.store import async_get_db_path
|
||||
|
||||
entry = await _setup_entry(hass, make_entry(), hass.registry)
|
||||
database = entry.runtime_data.database
|
||||
assert await read_schema_version(database) == len(MIGRATIONS)
|
||||
# The v1 tables exist because the runner applied migration 1.
|
||||
names = {
|
||||
row[0]
|
||||
for row in await database.fetch_all(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' "
|
||||
"AND name NOT LIKE 'sqlite_%'"
|
||||
)
|
||||
}
|
||||
assert "children" in names and "completion_events" in names
|
||||
# The version table exists with its singleton row stamped.
|
||||
columns = await database.fetch_all(f"PRAGMA table_info({VERSION_TABLE})")
|
||||
assert [(row[1], row[3]) for row in columns] == [("id", 1), ("version", 1)]
|
||||
db_path = await async_get_db_path(hass)
|
||||
conn = sqlite3_mod.connect(db_path)
|
||||
try:
|
||||
conn.execute(f"INSERT INTO {VERSION_TABLE} (id, version) VALUES (2, 1)")
|
||||
violated = False
|
||||
except sqlite3_mod.IntegrityError:
|
||||
violated = True
|
||||
finally:
|
||||
conn.close()
|
||||
assert violated, "singleton-row CHECK must be live in the setup-created DB"
|
||||
await database.close()
|
||||
|
||||
|
||||
async def test_options_reload_reopens_and_rechecks_version(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""A listener-triggered reload re-runs setup against the same file.
|
||||
|
||||
Unload then setup is the reload lifecycle: the old connection is
|
||||
closed, apply_migrations runs again on the persisted file, keeps
|
||||
version and rows intact, and the reopened connection is live.
|
||||
"""
|
||||
from custom_components.nestquest import async_unload_entry
|
||||
from custom_components.nestquest.migrations import read_schema_version
|
||||
|
||||
entry = await _setup_entry(hass, make_entry(), hass.registry)
|
||||
database_a = entry.runtime_data.database
|
||||
await database_a.execute(
|
||||
"INSERT INTO children (display_name, created_at) VALUES (?, ?)",
|
||||
("Ada", "2026-09-13T00:00:00+00:00"),
|
||||
)
|
||||
assert await async_unload_entry(hass, entry) is True
|
||||
assert not database_a.connected
|
||||
|
||||
entry_reloaded = await _setup_entry(hass, make_entry(), hass.registry)
|
||||
database_b = entry_reloaded.runtime_data.database
|
||||
assert database_b is not database_a
|
||||
try:
|
||||
assert await read_schema_version(database_b) == len(MIGRATIONS)
|
||||
row = await database_b.fetch_one("SELECT COUNT(*) FROM children")
|
||||
assert row == (1,)
|
||||
finally:
|
||||
await database_b.close()
|
||||
|
||||
|
||||
async def test_setup_entry_failure_closes_database(
|
||||
hass, make_entry, monkeypatch
|
||||
) -> None:
|
||||
"""A failing migration closes the opened connection before re-raising.
|
||||
|
||||
A migration failure surfaces as ConfigEntryNotReady (HA retries
|
||||
setup) with the original error preserved as __cause__. Spies on
|
||||
the real wrapper instance: records whether close() ran, rather
|
||||
than inferring from hass.data (a failed setup never stores a
|
||||
runtime record).
|
||||
"""
|
||||
import pytest as pytest_module
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
import custom_components.nestquest as nestquest_module
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.migrations import (
|
||||
MIGRATIONS as _MIGRATIONS,
|
||||
)
|
||||
|
||||
async def _failing(database, migrations=_MIGRATIONS):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr(
|
||||
nestquest_module, "apply_migrations", _failing, raising=True
|
||||
)
|
||||
|
||||
close_calls: list[bool] = []
|
||||
original_close = NestQuestDatabase.close
|
||||
|
||||
async def _spy_close(self):
|
||||
close_calls.append(True)
|
||||
return await original_close(self)
|
||||
|
||||
monkeypatch.setattr(NestQuestDatabase, "close", _spy_close)
|
||||
|
||||
entry = make_entry()
|
||||
with pytest_module.raises(ConfigEntryNotReady) as excinfo:
|
||||
await _setup_entry(hass, entry, hass.registry)
|
||||
assert isinstance(excinfo.value.__cause__, RuntimeError)
|
||||
assert "boom" in str(excinfo.value.__cause__)
|
||||
|
||||
assert close_calls == [True], (
|
||||
"failed setup must close the connection it opened"
|
||||
)
|
||||
record = hass.data["nestquest"].get(entry.entry_id)
|
||||
assert record is None, "failed setup must not store runtime data"
|
||||
+160
-502
@@ -14,31 +14,16 @@ from conftest import make_config_entry, make_hass, options_flow_base as _options
|
||||
from custom_components.nestquest import config_flow
|
||||
from custom_components.nestquest import options_flow
|
||||
from custom_components.nestquest.const import (
|
||||
CONF_ADMIN_USER_IDS,
|
||||
CONF_AFTERNOON_REMINDER_ENABLED,
|
||||
CONF_AFTERNOON_REMINDER_TIME,
|
||||
CONF_API_BASE_URL,
|
||||
CONF_CELEBRATION_ENABLED,
|
||||
CONF_DAY_ROLLOVER_TIME,
|
||||
CONF_END_OF_DAY_REPORT_ENABLED,
|
||||
CONF_END_OF_DAY_REPORT_TIME,
|
||||
CONF_HORIZON_DAYS,
|
||||
CONF_MORNING_SUMMARY_ENABLED,
|
||||
CONF_MORNING_SUMMARY_TIME,
|
||||
CONF_NOTIFY_TARGET,
|
||||
CONF_PANEL_IDLE_TIMEOUT,
|
||||
CONF_PANEL_TOKEN,
|
||||
CONF_SNAPSHOT_STALENESS,
|
||||
CONF_UPDATE_INTERVAL,
|
||||
DEFAULT_API_BASE_URL,
|
||||
DEFAULT_AFTERNOON_REMINDER_TIME,
|
||||
DEFAULT_AUTOMATION_ENABLED,
|
||||
DEFAULT_END_OF_DAY_REPORT_TIME,
|
||||
DEFAULT_MORNING_SUMMARY_TIME,
|
||||
DEFAULT_PANEL_TOKEN,
|
||||
DEFAULT_UPDATE_INTERVAL,
|
||||
DEFAULT_DAY_ROLLOVER_TIME,
|
||||
DEFAULT_HORIZON_DAYS,
|
||||
DEFAULT_PANEL_IDLE_TIMEOUT,
|
||||
DEFAULT_PANEL_TOKEN,
|
||||
DEFAULT_SNAPSHOT_STALENESS,
|
||||
DEFAULT_UPDATE_INTERVAL,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
@@ -47,26 +32,17 @@ STRINGS_PATH = PKG_DIR / "strings.json"
|
||||
TRANSLATIONS_EN_PATH = PKG_DIR / "translations" / "en.json"
|
||||
|
||||
VALID_INPUT = {
|
||||
CONF_HORIZON_DAYS: 7,
|
||||
CONF_DAY_ROLLOVER_TIME: "03:30",
|
||||
CONF_PANEL_IDLE_TIMEOUT: 60,
|
||||
}
|
||||
|
||||
#: What a schema-validated submit produces from a bare VALID_INPUT on a
|
||||
#: fresh entry: the Feature 10 refresh interval, the Feature 11
|
||||
#: notification section, and the Feature 18 API connection fill from
|
||||
#: fresh entry: the Feature 10 refresh interval, the Feature 18
|
||||
#: staleness threshold, and the Feature 18 API connection fill from
|
||||
#: their defaults.
|
||||
FULL_INPUT = {
|
||||
**VALID_INPUT,
|
||||
CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL,
|
||||
CONF_NOTIFY_TARGET: "",
|
||||
CONF_MORNING_SUMMARY_TIME: DEFAULT_MORNING_SUMMARY_TIME,
|
||||
CONF_AFTERNOON_REMINDER_TIME: DEFAULT_AFTERNOON_REMINDER_TIME,
|
||||
CONF_END_OF_DAY_REPORT_TIME: DEFAULT_END_OF_DAY_REPORT_TIME,
|
||||
CONF_MORNING_SUMMARY_ENABLED: DEFAULT_AUTOMATION_ENABLED,
|
||||
CONF_AFTERNOON_REMINDER_ENABLED: DEFAULT_AUTOMATION_ENABLED,
|
||||
CONF_END_OF_DAY_REPORT_ENABLED: DEFAULT_AUTOMATION_ENABLED,
|
||||
CONF_CELEBRATION_ENABLED: DEFAULT_AUTOMATION_ENABLED,
|
||||
CONF_SNAPSHOT_STALENESS: DEFAULT_SNAPSHOT_STALENESS,
|
||||
CONF_API_BASE_URL: DEFAULT_API_BASE_URL,
|
||||
CONF_PANEL_TOKEN: DEFAULT_PANEL_TOKEN,
|
||||
}
|
||||
@@ -84,48 +60,13 @@ def _make_entry(entry_id: str = "test_entry", options: dict | None = None, data:
|
||||
return make_config_entry(entry_id=entry_id, options=options, data=data)
|
||||
|
||||
|
||||
def _make_flow(entry, *, users=None, database=None) -> options_flow.NestQuestOptionsFlow:
|
||||
"""Create an options flow instance bound to a stubbed config entry.
|
||||
|
||||
``users`` populates a stubbed hass.auth for the admin picker;
|
||||
``database`` wires the runtime record so the flow can read and
|
||||
write the allowlist like real HA's options flow does.
|
||||
"""
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
def _make_flow(entry) -> options_flow.NestQuestOptionsFlow:
|
||||
"""Create an options flow instance bound to a stubbed config entry."""
|
||||
flow = options_flow.NestQuestOptionsFlow(config_entry=entry)
|
||||
flow.hass = SimpleNamespace()
|
||||
flow.hass.auth = SimpleNamespace(
|
||||
async_get_users=AsyncMock(return_value=users or [])
|
||||
)
|
||||
if database is not None:
|
||||
from custom_components.nestquest.const import DOMAIN
|
||||
|
||||
flow.hass.data = {
|
||||
DOMAIN: {entry.entry_id: SimpleNamespace(database=database)}
|
||||
}
|
||||
return flow
|
||||
|
||||
|
||||
def _user(user_id: str, name: str):
|
||||
return SimpleNamespace(id=user_id, name=name)
|
||||
|
||||
|
||||
def _open_allowlist_db(tmp_path, name):
|
||||
"""A real migrated temp database for the flow to read and write."""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.migrations import apply_migrations
|
||||
|
||||
hass = MagicMock()
|
||||
hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a)))
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
_run(database.open(tmp_path / name))
|
||||
_run(apply_migrations(database))
|
||||
return database
|
||||
|
||||
|
||||
def _schema_values(schema) -> dict:
|
||||
"""Extract the per-field default values from a real voluptuous schema."""
|
||||
return {
|
||||
@@ -152,11 +93,11 @@ def test_options_flow_get_options_flow_is_ha_callback() -> None:
|
||||
|
||||
def test_options_flow_constructs_through_2024_6_surface() -> None:
|
||||
"""The handler constructs against the mocked 2024.6 base and inherits its contract."""
|
||||
entry = _make_entry(options={CONF_HORIZON_DAYS: 21})
|
||||
entry = _make_entry(options={CONF_UPDATE_INTERVAL: 90})
|
||||
flow = options_flow.NestQuestOptionsFlow(config_entry=entry)
|
||||
assert isinstance(flow, _options_flow_base())
|
||||
assert flow.config_entry is entry
|
||||
assert flow.options == {CONF_HORIZON_DAYS: 21}
|
||||
assert flow.options == {CONF_UPDATE_INTERVAL: 90}
|
||||
|
||||
|
||||
def test_options_flow_submits_through_2024_6_surface() -> None:
|
||||
@@ -191,84 +132,66 @@ def test_options_flow_prefills_from_defaults() -> None:
|
||||
flow = _make_flow(_make_entry())
|
||||
result = _run(flow.async_step_init(None))
|
||||
values = _schema_values(result["data_schema"])
|
||||
assert values[CONF_HORIZON_DAYS] == DEFAULT_HORIZON_DAYS
|
||||
assert values[CONF_DAY_ROLLOVER_TIME] == DEFAULT_DAY_ROLLOVER_TIME
|
||||
assert values[CONF_PANEL_IDLE_TIMEOUT] == DEFAULT_PANEL_IDLE_TIMEOUT
|
||||
assert values[CONF_UPDATE_INTERVAL] == DEFAULT_UPDATE_INTERVAL
|
||||
assert values[CONF_SNAPSHOT_STALENESS] == DEFAULT_SNAPSHOT_STALENESS
|
||||
|
||||
|
||||
def test_options_flow_prefills_from_entry_options() -> None:
|
||||
"""Stored entry options win over entry data and defaults."""
|
||||
entry = _make_entry(
|
||||
options={
|
||||
CONF_HORIZON_DAYS: 21,
|
||||
CONF_DAY_ROLLOVER_TIME: "05:45",
|
||||
CONF_PANEL_IDLE_TIMEOUT: 120,
|
||||
CONF_UPDATE_INTERVAL: 90,
|
||||
CONF_SNAPSHOT_STALENESS: 450,
|
||||
},
|
||||
data={
|
||||
CONF_HORIZON_DAYS: 99,
|
||||
CONF_DAY_ROLLOVER_TIME: "23:59",
|
||||
CONF_PANEL_IDLE_TIMEOUT: 999,
|
||||
CONF_UPDATE_INTERVAL: 999,
|
||||
CONF_SNAPSHOT_STALENESS: 999,
|
||||
},
|
||||
)
|
||||
result = _run(_make_flow(entry).async_step_init(None))
|
||||
values = _schema_values(result["data_schema"])
|
||||
assert values[CONF_HORIZON_DAYS] == 21
|
||||
assert values[CONF_DAY_ROLLOVER_TIME] == "05:45"
|
||||
assert values[CONF_PANEL_IDLE_TIMEOUT] == 120
|
||||
assert values[CONF_UPDATE_INTERVAL] == 90
|
||||
assert values[CONF_SNAPSHOT_STALENESS] == 450
|
||||
|
||||
|
||||
def test_options_flow_falls_back_to_entry_data() -> None:
|
||||
"""Pre-upgrade entries without options fall back to entry.data."""
|
||||
entry = _make_entry(
|
||||
data={
|
||||
CONF_HORIZON_DAYS: 10,
|
||||
CONF_DAY_ROLLOVER_TIME: "01:15",
|
||||
CONF_PANEL_IDLE_TIMEOUT: 90,
|
||||
CONF_UPDATE_INTERVAL: 60,
|
||||
CONF_SNAPSHOT_STALENESS: 240,
|
||||
}
|
||||
)
|
||||
result = _run(_make_flow(entry).async_step_init(None))
|
||||
values = _schema_values(result["data_schema"])
|
||||
assert values[CONF_HORIZON_DAYS] == 10
|
||||
assert values[CONF_DAY_ROLLOVER_TIME] == "01:15"
|
||||
assert values[CONF_PANEL_IDLE_TIMEOUT] == 90
|
||||
assert values[CONF_UPDATE_INTERVAL] == 60
|
||||
assert values[CONF_SNAPSHOT_STALENESS] == 240
|
||||
|
||||
|
||||
def test_options_flow_schema_applies_stored_defaults() -> None:
|
||||
"""A real voluptuous validate({}) fills every field from the stored defaults.
|
||||
|
||||
Without a runtime database (and no auth in the bare harness), the
|
||||
admin picker's default is the empty selection and its choice set is
|
||||
empty — the three legacy settings are unaffected.
|
||||
"""
|
||||
"""A real voluptuous validate({}) fills every field from the stored defaults."""
|
||||
entry = _make_entry(
|
||||
options={
|
||||
CONF_HORIZON_DAYS: 21,
|
||||
CONF_DAY_ROLLOVER_TIME: "05:45",
|
||||
CONF_PANEL_IDLE_TIMEOUT: 120,
|
||||
CONF_UPDATE_INTERVAL: 90,
|
||||
CONF_NOTIFY_TARGET: "notify.mobile_app_test",
|
||||
CONF_MORNING_SUMMARY_TIME: "07:00",
|
||||
CONF_AFTERNOON_REMINDER_TIME: "14:00",
|
||||
CONF_END_OF_DAY_REPORT_TIME: "21:30",
|
||||
CONF_SNAPSHOT_STALENESS: 450,
|
||||
CONF_API_BASE_URL: "http://panel.lan:8443",
|
||||
CONF_PANEL_TOKEN: "stored-token",
|
||||
}
|
||||
)
|
||||
result = _run(_make_flow(entry).async_step_init(None))
|
||||
assert result["data_schema"]({}) == {
|
||||
CONF_HORIZON_DAYS: 21,
|
||||
CONF_DAY_ROLLOVER_TIME: "05:45",
|
||||
CONF_PANEL_IDLE_TIMEOUT: 120,
|
||||
CONF_UPDATE_INTERVAL: 90,
|
||||
CONF_NOTIFY_TARGET: "notify.mobile_app_test",
|
||||
CONF_MORNING_SUMMARY_TIME: "07:00",
|
||||
CONF_AFTERNOON_REMINDER_TIME: "14:00",
|
||||
CONF_END_OF_DAY_REPORT_TIME: "21:30",
|
||||
CONF_MORNING_SUMMARY_ENABLED: True,
|
||||
CONF_AFTERNOON_REMINDER_ENABLED: True,
|
||||
CONF_END_OF_DAY_REPORT_ENABLED: True,
|
||||
CONF_CELEBRATION_ENABLED: True,
|
||||
CONF_API_BASE_URL: DEFAULT_API_BASE_URL,
|
||||
CONF_PANEL_TOKEN: DEFAULT_PANEL_TOKEN,
|
||||
CONF_ADMIN_USER_IDS: [],
|
||||
CONF_SNAPSHOT_STALENESS: 450,
|
||||
CONF_API_BASE_URL: "http://panel.lan:8443",
|
||||
CONF_PANEL_TOKEN: "stored-token",
|
||||
}
|
||||
|
||||
|
||||
@@ -278,55 +201,14 @@ def test_options_flow_schema_coerces_field_types() -> None:
|
||||
result = _run(flow.async_step_init(None))
|
||||
schema = result["data_schema"]
|
||||
validators = {marker.schema: validator for marker, validator in schema.schema.items()}
|
||||
assert validators[CONF_HORIZON_DAYS] is int
|
||||
assert validators[CONF_DAY_ROLLOVER_TIME] is str
|
||||
assert validators[CONF_PANEL_IDLE_TIMEOUT] is int
|
||||
assert validators[CONF_UPDATE_INTERVAL] is int
|
||||
assert validators[CONF_SNAPSHOT_STALENESS] is int
|
||||
# The Feature 18 API connection fields are free-text strings.
|
||||
assert validators[CONF_API_BASE_URL] is str
|
||||
assert validators[CONF_PANEL_TOKEN] is str
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_horizon", [0, -5])
|
||||
def test_options_flow_rejects_non_positive_horizon(bad_horizon: int) -> None:
|
||||
"""Zero or negative horizon values yield a field-level error, no exception."""
|
||||
flow = _make_flow(_make_entry())
|
||||
result = _run(
|
||||
flow.async_step_init({**VALID_INPUT, CONF_HORIZON_DAYS: bad_horizon})
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {CONF_HORIZON_DAYS: "invalid"}
|
||||
|
||||
|
||||
def test_options_flow_rejects_malformed_time_not_a_time() -> None:
|
||||
"""Non-HH:MM strings are rejected with invalid_time."""
|
||||
flow = _make_flow(_make_entry())
|
||||
result = _run(
|
||||
flow.async_step_init({**VALID_INPUT, CONF_DAY_ROLLOVER_TIME: "noon"})
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {CONF_DAY_ROLLOVER_TIME: "invalid_time"}
|
||||
|
||||
|
||||
def test_options_flow_rejects_malformed_time_out_of_range() -> None:
|
||||
"""Out-of-range HH:MM values are rejected with invalid_time."""
|
||||
flow = _make_flow(_make_entry())
|
||||
result = _run(
|
||||
flow.async_step_init({**VALID_INPUT, CONF_DAY_ROLLOVER_TIME: "25:99"})
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {CONF_DAY_ROLLOVER_TIME: "invalid_time"}
|
||||
|
||||
|
||||
def test_options_flow_rejects_pasted_unicode_superscript_time() -> None:
|
||||
"""Pasted Unicode digits like '²²:²²' yield a field-level error, no exception."""
|
||||
flow = _make_flow(_make_entry())
|
||||
result = _run(
|
||||
flow.async_step_init({**VALID_INPUT, CONF_DAY_ROLLOVER_TIME: "²²:²²"})
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {CONF_DAY_ROLLOVER_TIME: "invalid_time"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_timeout", ["abc", 0, -10, 10])
|
||||
def test_options_flow_rejects_invalid_idle_timeout(bad_timeout) -> None:
|
||||
"""Non-numeric or too-low idle timeouts yield a field-level error."""
|
||||
@@ -338,10 +220,21 @@ def test_options_flow_rejects_invalid_idle_timeout(bad_timeout) -> None:
|
||||
assert result["errors"] == {CONF_PANEL_IDLE_TIMEOUT: "invalid"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_interval", ["abc", True, 0, -10, 29])
|
||||
def test_options_flow_rejects_invalid_update_interval(bad_interval) -> None:
|
||||
"""Non-numeric, boolean, and below-floor update intervals reject."""
|
||||
flow = _make_flow(_make_entry())
|
||||
result = _run(
|
||||
flow.async_step_init({**VALID_INPUT, CONF_UPDATE_INTERVAL: bad_interval})
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {CONF_UPDATE_INTERVAL: "invalid"}
|
||||
|
||||
|
||||
def test_options_flow_creates_entry_with_validated_keys() -> None:
|
||||
"""A valid submit creates an entry with exactly the validated keys
|
||||
(the legacy settings plus every later section's keys, filled from
|
||||
defaults where the raw submit omitted them)."""
|
||||
(the client-facing settings, filled from defaults where the raw
|
||||
submit omitted them)."""
|
||||
flow = _make_flow(_make_entry())
|
||||
result = _run(flow.async_step_init(dict(VALID_INPUT)))
|
||||
assert result["type"] == "create_entry"
|
||||
@@ -356,7 +249,7 @@ def test_options_flow_create_entry_data_lands_in_entry_options_and_reloads_once(
|
||||
entry = _make_entry()
|
||||
entry.add_update_listener = lambda listener: registry.add(entry.entry_id, listener)
|
||||
|
||||
# Simulate setup registering the merged lifecycle update listener.
|
||||
# Simulate setup registering the lifecycle update listener.
|
||||
from custom_components.nestquest import async_setup_entry
|
||||
|
||||
_run(async_setup_entry(hass, entry))
|
||||
@@ -375,14 +268,13 @@ def test_options_flow_create_entry_data_lands_in_entry_options_and_reloads_once(
|
||||
|
||||
def test_options_flow_validation_never_raises() -> None:
|
||||
"""Validation returns errors instead of raising for arbitrary junk input."""
|
||||
junk = {CONF_HORIZON_DAYS: None, CONF_DAY_ROLLOVER_TIME: None, CONF_PANEL_IDLE_TIMEOUT: None}
|
||||
junk = {CONF_PANEL_IDLE_TIMEOUT: None, CONF_UPDATE_INTERVAL: "abc"}
|
||||
flow = _make_flow(_make_entry())
|
||||
result = _run(flow.async_step_init(junk))
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {
|
||||
CONF_HORIZON_DAYS: "invalid",
|
||||
CONF_DAY_ROLLOVER_TIME: "invalid_time",
|
||||
CONF_PANEL_IDLE_TIMEOUT: "invalid",
|
||||
CONF_UPDATE_INTERVAL: "invalid",
|
||||
}
|
||||
|
||||
|
||||
@@ -412,10 +304,10 @@ def test_options_strings_present_and_matching(strings: dict, translations_en: di
|
||||
assert init["title"]
|
||||
assert init["description"]
|
||||
data = init["data"]
|
||||
for key in (CONF_HORIZON_DAYS, CONF_DAY_ROLLOVER_TIME, CONF_PANEL_IDLE_TIMEOUT):
|
||||
for key in (CONF_PANEL_IDLE_TIMEOUT,):
|
||||
assert data.get(key), f"Missing field name for '{key}'"
|
||||
errors = options["errors"]
|
||||
for key in ("invalid", "invalid_time"):
|
||||
for key in ("invalid",):
|
||||
assert errors.get(key), f"Missing error string for '{key}'"
|
||||
|
||||
|
||||
@@ -471,349 +363,6 @@ def test_options_flow_no_hardcoded_domain_or_db_filename() -> None:
|
||||
f"Hard-coded database filename in {py_file.name}:{node.lineno}"
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin allowlist picker (Feature 03)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_options_flow_picker_lists_existing_users(tmp_path) -> None:
|
||||
"""The picker offers EXISTING HA users (id -> name), never free text."""
|
||||
import voluptuous as vol
|
||||
|
||||
database = _open_allowlist_db(tmp_path, "picker-users.db")
|
||||
try:
|
||||
from custom_components.nestquest.admin_allowlist import set_admin_ids
|
||||
|
||||
_run(set_admin_ids(database, ["u1"]))
|
||||
entry = _make_entry()
|
||||
flow = _make_flow(
|
||||
entry,
|
||||
users=[_user("u1", "Joshua"), _user("u2", "Sam")],
|
||||
database=database,
|
||||
)
|
||||
result = _run(flow.async_step_init(None))
|
||||
schema = result["data_schema"]
|
||||
marker = next(
|
||||
m for m in schema.schema
|
||||
if getattr(m, "schema", None) == CONF_ADMIN_USER_IDS
|
||||
)
|
||||
# Default is the CURRENT database allowlist.
|
||||
values = _schema_values(schema)
|
||||
assert values[CONF_ADMIN_USER_IDS] == ["u1"]
|
||||
# Both users are selectable; an unknown id is rejected by the
|
||||
# schema itself (this is what real HA submits go through).
|
||||
validated = schema(
|
||||
{
|
||||
CONF_HORIZON_DAYS: 7,
|
||||
CONF_DAY_ROLLOVER_TIME: "03:30",
|
||||
CONF_PANEL_IDLE_TIMEOUT: 60,
|
||||
CONF_ADMIN_USER_IDS: ["u1", "u2"],
|
||||
}
|
||||
)
|
||||
assert validated[CONF_ADMIN_USER_IDS] == ["u1", "u2"]
|
||||
with pytest.raises(vol.Invalid):
|
||||
schema(
|
||||
{
|
||||
CONF_HORIZON_DAYS: 7,
|
||||
CONF_DAY_ROLLOVER_TIME: "03:30",
|
||||
CONF_PANEL_IDLE_TIMEOUT: 60,
|
||||
CONF_ADMIN_USER_IDS: ["impostor"],
|
||||
}
|
||||
)
|
||||
finally:
|
||||
_run(database.close())
|
||||
|
||||
|
||||
def test_options_flow_submit_applies_selection_to_database(tmp_path) -> None:
|
||||
database = _open_allowlist_db(tmp_path, "picker-apply.db")
|
||||
try:
|
||||
from custom_components.nestquest.admin_allowlist import (
|
||||
list_admin_ids,
|
||||
set_admin_ids,
|
||||
)
|
||||
|
||||
_run(set_admin_ids(database, ["u1"]))
|
||||
entry = _make_entry()
|
||||
flow = _make_flow(
|
||||
entry,
|
||||
users=[_user("u1", "Joshua"), _user("u2", "Sam")],
|
||||
database=database,
|
||||
)
|
||||
result = _run(
|
||||
flow.async_step_init({**VALID_INPUT, CONF_ADMIN_USER_IDS: ["u2"]})
|
||||
)
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["data"][CONF_ADMIN_USER_IDS] == ["u2"]
|
||||
# The allowlist (and the entry's recovery copy) carry the pick.
|
||||
assert await_gather(list_admin_ids(database)) == ["u2"]
|
||||
finally:
|
||||
_run(database.close())
|
||||
|
||||
|
||||
def await_gather(coro):
|
||||
return _run(coro)
|
||||
|
||||
|
||||
def test_options_flow_empty_selection_refused(tmp_path) -> None:
|
||||
"""Saving zero admins is refused: an empty allowlist fails closed."""
|
||||
database = _open_allowlist_db(tmp_path, "picker-empty.db")
|
||||
try:
|
||||
from custom_components.nestquest.admin_allowlist import (
|
||||
list_admin_ids,
|
||||
set_admin_ids,
|
||||
)
|
||||
|
||||
_run(set_admin_ids(database, ["u1"]))
|
||||
entry = _make_entry()
|
||||
flow = _make_flow(
|
||||
entry,
|
||||
users=[_user("u1", "Joshua")],
|
||||
database=database,
|
||||
)
|
||||
result = _run(
|
||||
flow.async_step_init({**VALID_INPUT, CONF_ADMIN_USER_IDS: []})
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {CONF_ADMIN_USER_IDS: "no_admins"}
|
||||
assert await_gather(list_admin_ids(database)) == ["u1"]
|
||||
finally:
|
||||
_run(database.close())
|
||||
|
||||
|
||||
def test_options_flow_unknown_selection_refused(tmp_path) -> None:
|
||||
database = _open_allowlist_db(tmp_path, "picker-unknown.db")
|
||||
try:
|
||||
from custom_components.nestquest.admin_allowlist import (
|
||||
list_admin_ids,
|
||||
set_admin_ids,
|
||||
)
|
||||
|
||||
_run(set_admin_ids(database, ["u1"]))
|
||||
entry = _make_entry()
|
||||
flow = _make_flow(
|
||||
entry,
|
||||
users=[_user("u1", "Joshua")],
|
||||
database=database,
|
||||
)
|
||||
result = _run(
|
||||
flow.async_step_init(
|
||||
{**VALID_INPUT, CONF_ADMIN_USER_IDS: ["impostor"]}
|
||||
)
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {CONF_ADMIN_USER_IDS: "invalid_admin"}
|
||||
assert await_gather(list_admin_ids(database)) == ["u1"]
|
||||
finally:
|
||||
_run(database.close())
|
||||
|
||||
|
||||
def test_options_flow_submit_without_admin_key_keeps_allowlist(tmp_path) -> None:
|
||||
"""Raw callers omitting the key (pre-picker payloads) do not
|
||||
touch the allowlist; the CURRENT allowlist is carried forward on
|
||||
the options copy so no removed admin can be resurrected later."""
|
||||
database = _open_allowlist_db(tmp_path, "picker-legacy.db")
|
||||
try:
|
||||
from custom_components.nestquest.admin_allowlist import (
|
||||
list_admin_ids,
|
||||
set_admin_ids,
|
||||
)
|
||||
|
||||
_run(set_admin_ids(database, ["u1"]))
|
||||
entry = _make_entry()
|
||||
flow = _make_flow(
|
||||
entry,
|
||||
users=[_user("u1", "Joshua")],
|
||||
database=database,
|
||||
)
|
||||
result = _run(flow.async_step_init(dict(VALID_INPUT)))
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["data"][CONF_ADMIN_USER_IDS] == ["u1"]
|
||||
assert await_gather(list_admin_ids(database)) == ["u1"]
|
||||
finally:
|
||||
_run(database.close())
|
||||
|
||||
|
||||
def test_options_flow_duplicate_selection_refused(tmp_path) -> None:
|
||||
"""Duplicate ids surface as a field error, not an unhandled
|
||||
ValueError from the business layer."""
|
||||
database = _open_allowlist_db(tmp_path, "picker-dupe.db")
|
||||
try:
|
||||
from custom_components.nestquest.admin_allowlist import (
|
||||
list_admin_ids,
|
||||
set_admin_ids,
|
||||
)
|
||||
|
||||
_run(set_admin_ids(database, ["u1"]))
|
||||
entry = _make_entry()
|
||||
flow = _make_flow(
|
||||
entry,
|
||||
users=[_user("u1", "Joshua"), _user("u2", "Sam")],
|
||||
database=database,
|
||||
)
|
||||
result = _run(
|
||||
flow.async_step_init(
|
||||
{**VALID_INPUT, CONF_ADMIN_USER_IDS: ["u2", "u2"]}
|
||||
)
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {CONF_ADMIN_USER_IDS: "invalid_admin"}
|
||||
assert await_gather(list_admin_ids(database)) == ["u1"]
|
||||
finally:
|
||||
_run(database.close())
|
||||
|
||||
|
||||
def test_options_flow_omitted_picker_carries_allowlist_forward(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
"""An omitted picker field must not drop the allowlist copy from
|
||||
the returned options: HA replaces entry.options wholesale, and a
|
||||
later database loss must not resurrect removed admins from the
|
||||
stale config-flow copy in entry.data."""
|
||||
database = _open_allowlist_db(tmp_path, "picker-carry.db")
|
||||
try:
|
||||
from custom_components.nestquest.admin_allowlist import (
|
||||
set_admin_ids,
|
||||
)
|
||||
|
||||
_run(set_admin_ids(database, ["u2"]))
|
||||
entry = _make_entry()
|
||||
flow = _make_flow(
|
||||
entry,
|
||||
users=[_user("u1", "Joshua"), _user("u2", "Sam")],
|
||||
database=database,
|
||||
)
|
||||
result = _run(flow.async_step_init(dict(VALID_INPUT)))
|
||||
assert result["type"] == "create_entry"
|
||||
# The current allowlist rides along on the legacy three-key path.
|
||||
assert result["data"][CONF_ADMIN_USER_IDS] == ["u2"]
|
||||
assert result["data"][CONF_HORIZON_DAYS] == VALID_INPUT[CONF_HORIZON_DAYS]
|
||||
finally:
|
||||
_run(database.close())
|
||||
|
||||
|
||||
def test_options_flow_notification_section_defaults() -> None:
|
||||
"""A bare submit fills the notification section from const defaults:
|
||||
08:00 / 15:00 / 20:00 — the report deliberately BEFORE the midnight
|
||||
rollover sweep — an empty notify target, and all toggles enabled."""
|
||||
flow = _make_flow(_make_entry())
|
||||
result = _run(flow.async_step_init(dict(VALID_INPUT)))
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["data"][CONF_MORNING_SUMMARY_TIME] == "08:00"
|
||||
assert result["data"][CONF_AFTERNOON_REMINDER_TIME] == "15:00"
|
||||
assert result["data"][CONF_END_OF_DAY_REPORT_TIME] == "20:00"
|
||||
assert result["data"][CONF_NOTIFY_TARGET] == ""
|
||||
for toggle in (
|
||||
CONF_MORNING_SUMMARY_ENABLED,
|
||||
CONF_AFTERNOON_REMINDER_ENABLED,
|
||||
CONF_END_OF_DAY_REPORT_ENABLED,
|
||||
CONF_CELEBRATION_ENABLED,
|
||||
):
|
||||
assert result["data"][toggle] is True
|
||||
|
||||
|
||||
def test_options_flow_notification_times_must_be_strict_hh_mm() -> None:
|
||||
flow = _make_flow(_make_entry())
|
||||
for bad_time in ("9:00", "25:00", "08:0", "08-00", 800):
|
||||
result = _run(
|
||||
flow.async_step_init(
|
||||
{
|
||||
**VALID_INPUT,
|
||||
CONF_MORNING_SUMMARY_TIME: bad_time,
|
||||
}
|
||||
)
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {CONF_MORNING_SUMMARY_TIME: "invalid_time"}
|
||||
|
||||
|
||||
def test_options_flow_notify_target_validation() -> None:
|
||||
"""The empty target is VALID (it is the shipped default and must be
|
||||
savable); only malformed non-empty values reject."""
|
||||
flow = _make_flow(_make_entry())
|
||||
# The empty default saves cleanly.
|
||||
result = _run(
|
||||
flow.async_step_init(
|
||||
{**VALID_INPUT, CONF_NOTIFY_TARGET: ""}
|
||||
)
|
||||
)
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["data"][CONF_NOTIFY_TARGET] == ""
|
||||
|
||||
# Malformed non-empty values reject.
|
||||
for bad_target in (" notify.x ", " ", 42):
|
||||
flow = _make_flow(_make_entry())
|
||||
result = _run(
|
||||
flow.async_step_init(
|
||||
{
|
||||
**VALID_INPUT,
|
||||
CONF_NOTIFY_TARGET: bad_target,
|
||||
}
|
||||
)
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {CONF_NOTIFY_TARGET: "invalid"}
|
||||
|
||||
|
||||
def test_options_flow_toggles_must_be_real_booleans() -> None:
|
||||
flow = _make_flow(_make_entry())
|
||||
result = _run(
|
||||
flow.async_step_init(
|
||||
{
|
||||
**VALID_INPUT,
|
||||
CONF_CELEBRATION_ENABLED: "on",
|
||||
}
|
||||
)
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {CONF_CELEBRATION_ENABLED: "invalid"}
|
||||
|
||||
|
||||
def test_options_flow_notification_stored_values_round_trip() -> None:
|
||||
"""Stored notification settings are the form's defaults and a raw
|
||||
submit without them keeps the stored values."""
|
||||
entry = _make_entry(
|
||||
options={
|
||||
**VALID_INPUT,
|
||||
CONF_UPDATE_INTERVAL: 90,
|
||||
CONF_NOTIFY_TARGET: "notify.mobile_app_dad",
|
||||
CONF_MORNING_SUMMARY_TIME: "07:15",
|
||||
CONF_AFTERNOON_REMINDER_TIME: "16:45",
|
||||
CONF_END_OF_DAY_REPORT_TIME: "21:00",
|
||||
CONF_MORNING_SUMMARY_ENABLED: False,
|
||||
CONF_AFTERNOON_REMINDER_ENABLED: True,
|
||||
CONF_END_OF_DAY_REPORT_ENABLED: False,
|
||||
CONF_CELEBRATION_ENABLED: True,
|
||||
}
|
||||
)
|
||||
flow = _make_flow(entry)
|
||||
form = _run(flow.async_step_init(None))
|
||||
schema_defaults = form["data_schema"]({})
|
||||
assert schema_defaults[CONF_NOTIFY_TARGET] == "notify.mobile_app_dad"
|
||||
assert schema_defaults[CONF_MORNING_SUMMARY_TIME] == "07:15"
|
||||
assert schema_defaults[CONF_END_OF_DAY_REPORT_ENABLED] is False
|
||||
|
||||
# A raw submit carrying only the legacy keys keeps the stored
|
||||
# notification settings.
|
||||
result = _run(flow.async_step_init(dict(VALID_INPUT)))
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["data"][CONF_NOTIFY_TARGET] == "notify.mobile_app_dad"
|
||||
assert result["data"][CONF_MORNING_SUMMARY_TIME] == "07:15"
|
||||
assert result["data"][CONF_MORNING_SUMMARY_ENABLED] is False
|
||||
assert result["data"][CONF_CELEBRATION_ENABLED] is True
|
||||
|
||||
|
||||
def test_options_flow_no_hard_coded_personal_target_in_source() -> None:
|
||||
"""The notify target's DEFAULT is the empty string: the household
|
||||
fills their own service name, and no personal target ships as a
|
||||
value anywhere in the const/flow defaults."""
|
||||
from custom_components.nestquest import const
|
||||
|
||||
assert getattr(const, "CONF_NOTIFY_TARGET", None) == "notify_target"
|
||||
# No const default carries a concrete notify service.
|
||||
assert const.__dict__.get("DEFAULT_NOTIFY_TARGET", "") == ""
|
||||
form = _run(_make_flow(_make_entry()).async_step_init(None))
|
||||
assert form["data_schema"]({})[CONF_NOTIFY_TARGET] == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Panel-plane API connection section (Feature 18)
|
||||
@@ -884,7 +433,7 @@ def test_options_flow_api_section_stored_values_round_trip() -> None:
|
||||
assert schema_defaults[CONF_API_BASE_URL] == "http://panel.lan:8443"
|
||||
assert schema_defaults[CONF_PANEL_TOKEN] == "stored-token"
|
||||
|
||||
# A raw submit carrying only the legacy keys keeps the stored
|
||||
# A raw submit carrying only the panel-idle key keeps the stored
|
||||
# API connection settings.
|
||||
result = _run(flow.async_step_init(dict(VALID_INPUT)))
|
||||
assert result["type"] == "create_entry"
|
||||
@@ -901,3 +450,112 @@ def test_options_flow_strings_cover_api_section(
|
||||
assert data.get(key), f"Missing field name for '{key}'"
|
||||
assert strings["options"]["errors"]["invalid_url"]
|
||||
assert translations_en["options"] == strings["options"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Last-good snapshot staleness section (Feature 18)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_options_flow_staleness_below_update_interval_refused() -> None:
|
||||
"""A staleness threshold shorter than the submit's update interval
|
||||
is refused with the dedicated error: such a threshold could never
|
||||
serve even one cached snapshot."""
|
||||
flow = _make_flow(_make_entry())
|
||||
result = _run(
|
||||
flow.async_step_init(
|
||||
{
|
||||
**VALID_INPUT,
|
||||
CONF_UPDATE_INTERVAL: 300,
|
||||
CONF_SNAPSHOT_STALENESS: 60,
|
||||
}
|
||||
)
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {CONF_SNAPSHOT_STALENESS: "invalid_staleness"}
|
||||
|
||||
|
||||
def test_options_flow_staleness_below_stored_interval_refused() -> None:
|
||||
"""A raw submit setting the staleness but omitting the update
|
||||
interval is checked against the STORED interval, not the default."""
|
||||
entry = _make_entry(options={CONF_UPDATE_INTERVAL: 300})
|
||||
flow = _make_flow(entry)
|
||||
result = _run(
|
||||
flow.async_step_init({**VALID_INPUT, CONF_SNAPSHOT_STALENESS: 60})
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {CONF_SNAPSHOT_STALENESS: "invalid_staleness"}
|
||||
|
||||
|
||||
def test_options_flow_staleness_must_be_positive_int() -> None:
|
||||
"""Non-int, boolean, and non-positive staleness values reject with
|
||||
the plain invalid error."""
|
||||
flow = _make_flow(_make_entry())
|
||||
for bad_staleness in ("900", True, 0, -10):
|
||||
result = _run(
|
||||
flow.async_step_init(
|
||||
{**VALID_INPUT, CONF_SNAPSHOT_STALENESS: bad_staleness}
|
||||
)
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {CONF_SNAPSHOT_STALENESS: "invalid"}
|
||||
|
||||
|
||||
def test_options_flow_staleness_equal_to_interval_saves() -> None:
|
||||
"""A threshold exactly as long as the update interval is valid (the
|
||||
boundary is inclusive)."""
|
||||
flow = _make_flow(_make_entry())
|
||||
result = _run(
|
||||
flow.async_step_init(
|
||||
{
|
||||
**VALID_INPUT,
|
||||
CONF_UPDATE_INTERVAL: 300,
|
||||
CONF_SNAPSHOT_STALENESS: 300,
|
||||
}
|
||||
)
|
||||
)
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["data"][CONF_SNAPSHOT_STALENESS] == 300
|
||||
|
||||
|
||||
def test_options_flow_staleness_defaults_saved() -> None:
|
||||
"""A bare submit saves the staleness default (three update
|
||||
intervals) alongside the other defaults."""
|
||||
flow = _make_flow(_make_entry())
|
||||
result = _run(flow.async_step_init(dict(VALID_INPUT)))
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["data"][CONF_SNAPSHOT_STALENESS] == DEFAULT_SNAPSHOT_STALENESS
|
||||
|
||||
|
||||
def test_options_flow_staleness_stored_values_round_trip() -> None:
|
||||
"""A stored staleness is the form's default and a raw legacy submit
|
||||
without it keeps the stored value."""
|
||||
entry = _make_entry(
|
||||
options={
|
||||
**VALID_INPUT,
|
||||
CONF_UPDATE_INTERVAL: 300,
|
||||
CONF_SNAPSHOT_STALENESS: 1200,
|
||||
}
|
||||
)
|
||||
flow = _make_flow(entry)
|
||||
form = _run(flow.async_step_init(None))
|
||||
schema_defaults = form["data_schema"]({})
|
||||
assert schema_defaults[CONF_SNAPSHOT_STALENESS] == 1200
|
||||
|
||||
# A raw submit carrying only the panel-idle key keeps the stored
|
||||
# staleness (and never trips the interval cross-check).
|
||||
result = _run(flow.async_step_init(dict(VALID_INPUT)))
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["data"][CONF_SNAPSHOT_STALENESS] == 1200
|
||||
|
||||
|
||||
def test_options_flow_strings_cover_staleness_section(
|
||||
strings: dict, translations_en: dict
|
||||
) -> None:
|
||||
"""Both string files name the staleness field and its error."""
|
||||
data = strings["options"]["step"]["init"]["data"]
|
||||
assert data.get(CONF_SNAPSHOT_STALENESS), (
|
||||
f"Missing field name for '{CONF_SNAPSHOT_STALENESS}'"
|
||||
)
|
||||
assert strings["options"]["errors"]["invalid_staleness"]
|
||||
assert translations_en["options"] == strings["options"]
|
||||
|
||||
+730
-2
@@ -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"
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
"""Feature 09 permission test matrix across every registered service."""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
|
||||
import pytest
|
||||
from homeassistant.exceptions import Unauthorized
|
||||
|
||||
from conftest import wire_entry_to_registry
|
||||
|
||||
from custom_components.nestquest import async_setup_entry
|
||||
from custom_components.nestquest.const import (
|
||||
CONF_ADMIN_USER_IDS,
|
||||
DEFAULT_HORIZON_DAYS,
|
||||
DOMAIN,
|
||||
DOMAIN_SERVICES,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
||||
SERVICE_CREATE_QUEST_DEFINITION,
|
||||
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
||||
SERVICE_EXPORT_HISTORY_CSV,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
SERVICE_REGENERATE,
|
||||
SERVICE_SET_PRESENCE_PATTERN,
|
||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
||||
SERVICE_UNCOMPLETE_QUEST,
|
||||
SERVICE_UPDATE_QUEST_DEFINITION,
|
||||
)
|
||||
from custom_components.nestquest.service_policy import (
|
||||
ADMIN_ONLY,
|
||||
OPEN,
|
||||
SERVICE_POLICY,
|
||||
)
|
||||
|
||||
ADMIN_ID = "admin-1"
|
||||
ADMIN_CTX = {"user_id": ADMIN_ID}
|
||||
NON_ADMIN_ID = "non-admin"
|
||||
NON_ADMIN_CTX = {"user_id": NON_ADMIN_ID}
|
||||
KIOSK_ID = "kiosk-panel"
|
||||
KIOSK_CTX = {"user_id": KIOSK_ID}
|
||||
|
||||
|
||||
def test_every_registered_service_is_in_the_operation_registry() -> None:
|
||||
"""The registry is total: a service outside it could never be gated."""
|
||||
missing = [
|
||||
service for service in DOMAIN_SERVICES if service not in SERVICE_POLICY
|
||||
]
|
||||
extra = [
|
||||
service for service in SERVICE_POLICY if service not in DOMAIN_SERVICES
|
||||
]
|
||||
assert not missing, f"registered but ungated: {missing}"
|
||||
assert not extra, f"gated but never registered: {extra}"
|
||||
assert set(SERVICE_POLICY.values()) == {OPEN, ADMIN_ONLY}
|
||||
|
||||
|
||||
async def _setup(hass, make_entry, *, admin=True):
|
||||
data = {CONF_ADMIN_USER_IDS: [ADMIN_ID]} if admin else {}
|
||||
entry = wire_entry_to_registry(make_entry(data=data), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
return entry
|
||||
|
||||
|
||||
async def _seed(hass, entry) -> dict:
|
||||
"""Seed one child, one definition, and materialized instances."""
|
||||
from custom_components.nestquest.children import list_children
|
||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||
|
||||
database = entry.runtime_data.database
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
{"action": "create", "display_name": "Ada"},
|
||||
context=ADMIN_CTX,
|
||||
)
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_CREATE_QUEST_DEFINITION,
|
||||
{
|
||||
"title": "Brush teeth",
|
||||
"rule": {"rule_type": "daily"},
|
||||
"assignee_child_ids": [(await list_children(database))[0].id],
|
||||
"windows": ["morning"],
|
||||
},
|
||||
context=ADMIN_CTX,
|
||||
)
|
||||
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
||||
child = (await list_children(database))[0]
|
||||
today = datetime.date.today().isoformat()
|
||||
end = (
|
||||
datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
||||
).isoformat()
|
||||
instances = await QuestInstancesDao(database).list_by_date_range(
|
||||
child.id, today, end
|
||||
)
|
||||
assert instances
|
||||
from custom_components.nestquest.dao_presence import (
|
||||
PresenceOverridesDao,
|
||||
)
|
||||
|
||||
return {
|
||||
"child_id": child.id,
|
||||
"instance_id": instances[0].id,
|
||||
"definition_id": instances[0].definition_id,
|
||||
"override": await PresenceOverridesDao(database).create(
|
||||
child.id, "2027-01-01", "2027-01-02", False
|
||||
),
|
||||
"today": today,
|
||||
"database": database,
|
||||
}
|
||||
|
||||
|
||||
def _admin_success_payload(service: str, ctx: dict) -> dict | None:
|
||||
"""A schema-valid, end-to-end-successful payload per admin service."""
|
||||
if service == SERVICE_UNCOMPLETE_QUEST:
|
||||
return {"instance_id": ctx["instance_id"], "actor": "user"}
|
||||
if service == SERVICE_CREATE_QUEST_DEFINITION:
|
||||
return {
|
||||
"title": "Second quest",
|
||||
"rule": {"rule_type": "daily"},
|
||||
"assignee_child_ids": [ctx["child_id"]],
|
||||
"windows": ["afternoon"],
|
||||
}
|
||||
if service == SERVICE_UPDATE_QUEST_DEFINITION:
|
||||
return {"definition_id": ctx["definition_id"], "title": "Brush teeth 2"}
|
||||
if service == SERVICE_SET_QUEST_DEFINITION_ACTIVE:
|
||||
return {"definition_id": ctx["definition_id"], "is_active": False}
|
||||
if service == SERVICE_SET_PRESENCE_PATTERN:
|
||||
return {
|
||||
"child_id": ctx["child_id"],
|
||||
"cycle_length_weeks": 1,
|
||||
"anchor_date": ctx["today"],
|
||||
"pattern": {0: [0, 1, 2, 3, 4, 5, 6]},
|
||||
}
|
||||
if service == SERVICE_CREATE_PRESENCE_OVERRIDE:
|
||||
return {
|
||||
"child_id": ctx["child_id"],
|
||||
"start_date": "2027-03-01",
|
||||
"end_date": "2027-03-02",
|
||||
"is_present": False,
|
||||
}
|
||||
if service == SERVICE_DELETE_PRESENCE_OVERRIDE:
|
||||
return {"override_id": ctx["override"].id}
|
||||
if service == SERVICE_EXPORT_HISTORY_CSV:
|
||||
return None # raises HomeAssistantError (Feature 13) — not Unauthorized
|
||||
if service == SERVICE_MANAGE_CHILD:
|
||||
return {"action": "create", "display_name": "Bo"}
|
||||
if service == SERVICE_REGENERATE:
|
||||
return {}
|
||||
raise AssertionError(f"unexpected service {service}")
|
||||
|
||||
|
||||
_ADMIN_ONLY = [
|
||||
service for service, policy in SERVICE_POLICY.items() if policy == ADMIN_ONLY
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("service", _ADMIN_ONLY)
|
||||
async def test_admin_succeeds_on_every_admin_only_service(
|
||||
hass, make_entry, service
|
||||
) -> None:
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
entry = await _setup(hass, make_entry)
|
||||
ctx = await _seed(hass, entry)
|
||||
if service == SERVICE_EXPORT_HISTORY_CSV:
|
||||
# The gate passes; the Feature 13 stub raises its "not
|
||||
# implemented" HomeAssistantError — NOT Unauthorized.
|
||||
with pytest.raises(HomeAssistantError, match="Feature 13"):
|
||||
await hass.services.call(
|
||||
DOMAIN, service, None, context=ADMIN_CTX
|
||||
)
|
||||
return
|
||||
await hass.services.call(
|
||||
DOMAIN, service, _admin_success_payload(service, ctx), context=ADMIN_CTX
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("service", _ADMIN_ONLY)
|
||||
async def test_non_admin_refused_on_every_admin_only_service(
|
||||
hass, make_entry, service
|
||||
) -> None:
|
||||
entry = await _setup(hass, make_entry)
|
||||
ctx = await _seed(hass, entry)
|
||||
if service != SERVICE_EXPORT_HISTORY_CSV:
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
service,
|
||||
_admin_success_payload(service, ctx),
|
||||
context=ADMIN_CTX,
|
||||
)
|
||||
with pytest.raises(Unauthorized):
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
service,
|
||||
_admin_success_payload(service, ctx),
|
||||
context=NON_ADMIN_CTX,
|
||||
)
|
||||
|
||||
|
||||
async def test_non_admin_allowed_on_complete_quest(hass, make_entry) -> None:
|
||||
from custom_components.nestquest.completion import instance_state
|
||||
|
||||
entry = await _setup(hass, make_entry)
|
||||
ctx = await _seed(hass, entry)
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
{
|
||||
"instance_id": ctx["instance_id"],
|
||||
"actor": "panel",
|
||||
"actor_child_id": ctx["child_id"],
|
||||
},
|
||||
context=NON_ADMIN_CTX,
|
||||
)
|
||||
assert (
|
||||
await instance_state(ctx["database"], ctx["instance_id"]) == "done"
|
||||
)
|
||||
|
||||
|
||||
async def test_kiosk_refused_on_uncomplete_and_regenerate(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
entry = await _setup(hass, make_entry)
|
||||
ctx = await _seed(hass, entry)
|
||||
from custom_components.nestquest.admin_allowlist import list_admin_ids
|
||||
|
||||
assert KIOSK_ID not in await list_admin_ids(ctx["database"])
|
||||
with pytest.raises(Unauthorized):
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_UNCOMPLETE_QUEST,
|
||||
{"instance_id": ctx["instance_id"], "actor": "user"},
|
||||
context=KIOSK_CTX,
|
||||
)
|
||||
with pytest.raises(Unauthorized):
|
||||
await hass.services.call(
|
||||
DOMAIN, SERVICE_REGENERATE, context=KIOSK_CTX
|
||||
)
|
||||
@@ -1,122 +0,0 @@
|
||||
"""Tests for the Feature 09 permission gate's audit logging."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from homeassistant.exceptions import Unauthorized
|
||||
|
||||
from conftest import wire_entry_to_registry
|
||||
|
||||
from custom_components.nestquest import async_setup_entry
|
||||
from custom_components.nestquest.const import (
|
||||
CONF_ADMIN_USER_IDS,
|
||||
DOMAIN,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
)
|
||||
|
||||
ADMIN_ID = "admin-1"
|
||||
ADMIN_CTX = {"user_id": ADMIN_ID}
|
||||
_TIMESTAMP = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}")
|
||||
|
||||
|
||||
async def _set_up(hass, make_entry):
|
||||
entry = wire_entry_to_registry(
|
||||
make_entry(data={CONF_ADMIN_USER_IDS: [ADMIN_ID]}), hass.registry
|
||||
)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
return entry
|
||||
|
||||
|
||||
async def test_denied_call_logs_warning_with_fields(
|
||||
hass, make_entry, caplog
|
||||
) -> None:
|
||||
entry = await _set_up(hass, make_entry)
|
||||
with pytest.raises(Unauthorized):
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
{"action": "create", "display_name": "Ada"},
|
||||
context={"user_id": "kiosk-panel"},
|
||||
)
|
||||
denials = [
|
||||
record
|
||||
for record in caplog.records
|
||||
if record.levelno == logging.WARNING
|
||||
and "denied" in record.getMessage()
|
||||
]
|
||||
assert len(denials) == 1
|
||||
message = denials[0].getMessage()
|
||||
assert SERVICE_MANAGE_CHILD in message
|
||||
assert "kiosk-panel" in message
|
||||
assert _TIMESTAMP.search(message), message
|
||||
|
||||
|
||||
async def test_denied_no_context_logs_user_none(
|
||||
hass, make_entry, caplog
|
||||
) -> None:
|
||||
entry = await _set_up(hass, make_entry)
|
||||
with pytest.raises(Unauthorized):
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
{"action": "create", "display_name": "Ada"},
|
||||
)
|
||||
denials = [
|
||||
record
|
||||
for record in caplog.records
|
||||
if record.levelno == logging.WARNING
|
||||
and "denied" in record.getMessage()
|
||||
]
|
||||
assert len(denials) == 1
|
||||
message = denials[0].getMessage()
|
||||
assert "none" in message
|
||||
assert _TIMESTAMP.search(message), message
|
||||
|
||||
|
||||
async def test_successful_admin_call_logs_debug_with_fields(
|
||||
hass, make_entry, caplog
|
||||
) -> None:
|
||||
caplog.set_level(logging.DEBUG, logger="custom_components.nestquest")
|
||||
entry = await _set_up(hass, make_entry)
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
{"action": "create", "display_name": "Ada"},
|
||||
context=ADMIN_CTX,
|
||||
)
|
||||
allowed = [
|
||||
record
|
||||
for record in caplog.records
|
||||
if record.levelno == logging.DEBUG
|
||||
and "allowed" in record.getMessage()
|
||||
]
|
||||
assert len(allowed) == 1
|
||||
message = allowed[0].getMessage()
|
||||
assert SERVICE_MANAGE_CHILD in message
|
||||
assert ADMIN_ID in message
|
||||
assert _TIMESTAMP.search(message), message
|
||||
|
||||
|
||||
async def test_denied_log_carries_no_personal_data_beyond_user_id(
|
||||
hass, make_entry, caplog
|
||||
) -> None:
|
||||
"""The log line names the caller's user id only: the payload's
|
||||
display name must not leak into the audit trail."""
|
||||
entry = await _set_up(hass, make_entry)
|
||||
with pytest.raises(Unauthorized):
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
{"action": "create", "display_name": "Ada"},
|
||||
context={"user_id": "kiosk-panel"},
|
||||
)
|
||||
denials = [
|
||||
record.getMessage()
|
||||
for record in caplog.records
|
||||
if record.levelno == logging.WARNING and "denied" in record.getMessage()
|
||||
]
|
||||
assert denials, "expected a denial log line"
|
||||
assert "Ada" not in denials[0]
|
||||
assert "display_name" not in denials[0]
|
||||
@@ -5,7 +5,7 @@ import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.presence import (
|
||||
from custom_components.nestquest.core.presence import (
|
||||
MAX_CYCLE_LENGTH_WEEKS,
|
||||
PresenceEngine,
|
||||
PresenceOverride,
|
||||
|
||||
@@ -7,20 +7,20 @@ import re
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.core.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
|
||||
from custom_components.nestquest.dao_instances import (
|
||||
from custom_components.nestquest.core.dao_instances import (
|
||||
CompletionEventsDao,
|
||||
QuestInstancesDao,
|
||||
)
|
||||
from custom_components.nestquest.dao_rules import (
|
||||
from custom_components.nestquest.core.dao_rules import (
|
||||
QuestDefinitionsDao,
|
||||
ScheduleRulesDao,
|
||||
schedule_rule_to_storage,
|
||||
)
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.migrations import apply_migrations
|
||||
from custom_components.nestquest.quest_definitions import (
|
||||
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||
from custom_components.nestquest.core.migrations import apply_migrations
|
||||
from custom_components.nestquest.core.quest_definitions import (
|
||||
CreatedQuestDefinition,
|
||||
assign_child,
|
||||
create_quest_definition,
|
||||
@@ -31,7 +31,7 @@ from custom_components.nestquest.quest_definitions import (
|
||||
set_quest_definition_active,
|
||||
unassign_child,
|
||||
)
|
||||
from custom_components.nestquest.recurrence import RuleType, ScheduleRule
|
||||
from custom_components.nestquest.core.recurrence import RuleType, ScheduleRule
|
||||
|
||||
|
||||
def _run(coro):
|
||||
@@ -1150,7 +1150,7 @@ def test_concurrent_assign_unassign_returns_consistent_roster(
|
||||
)
|
||||
bo = await children.create("Bo", NOW)
|
||||
|
||||
import custom_components.nestquest.dao_rules as dao_rules
|
||||
import custom_components.nestquest.core.dao_rules as dao_rules
|
||||
|
||||
original_list = dao_rules.QuestDefinitionsDao.list_assignees
|
||||
started = asyncio.Event()
|
||||
@@ -1666,7 +1666,7 @@ def test_list_query_snapshot_coherent_under_racing_edit(tmp_path) -> None:
|
||||
start_date="2026-09-14",
|
||||
)
|
||||
|
||||
import custom_components.nestquest.dao_rules as dao_rules
|
||||
import custom_components.nestquest.core.dao_rules as dao_rules
|
||||
|
||||
original_list = dao_rules.QuestDefinitionsDao.list_windows
|
||||
started = asyncio.Event()
|
||||
@@ -1732,7 +1732,7 @@ def test_list_definitions_for_child_snapshot_coherent_under_racing_unassign(
|
||||
database, "Brush teeth", _daily_rule(), [child.id], ["morning"]
|
||||
)
|
||||
|
||||
import custom_components.nestquest.dao_rules as dao_rules
|
||||
import custom_components.nestquest.core.dao_rules as dao_rules
|
||||
|
||||
original_list = dao_rules.QuestDefinitionsDao.list_assignees
|
||||
started = asyncio.Event()
|
||||
@@ -1795,7 +1795,7 @@ def test_list_active_definitions_snapshot_coherent_under_racing_edit(
|
||||
start_date="2026-09-14",
|
||||
)
|
||||
|
||||
import custom_components.nestquest.dao_rules as dao_rules
|
||||
import custom_components.nestquest.core.dao_rules as dao_rules
|
||||
|
||||
original_list = dao_rules.QuestDefinitionsDao.list_windows
|
||||
started = asyncio.Event()
|
||||
|
||||
@@ -12,7 +12,7 @@ import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.recurrence import (
|
||||
from custom_components.nestquest.core.recurrence import (
|
||||
RuleType,
|
||||
RuleValidationError,
|
||||
ScheduleRule,
|
||||
|
||||
@@ -15,7 +15,7 @@ import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.recurrence import (
|
||||
from custom_components.nestquest.core.recurrence import (
|
||||
ScheduleRule,
|
||||
RuleType,
|
||||
occurs_on,
|
||||
@@ -395,7 +395,7 @@ def test_recurrence_engine_imports_nothing_forbidden() -> None:
|
||||
|
||||
module_file = Path(
|
||||
importlib.util.find_spec(
|
||||
"custom_components.nestquest.recurrence"
|
||||
"custom_components.nestquest.core.recurrence"
|
||||
).origin
|
||||
)
|
||||
tree = ast.parse(module_file.read_text())
|
||||
|
||||
@@ -5,7 +5,7 @@ import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.recurrence import (
|
||||
from custom_components.nestquest.core.recurrence import (
|
||||
RuleType,
|
||||
RuleValidationError,
|
||||
ScheduleRule,
|
||||
@@ -219,7 +219,7 @@ def test_occurs_on_rejects_unknown_shapes_loudly() -> None:
|
||||
than a silent wrong answer. Forged via a private enum extension
|
||||
because ScheduleRule validation rejects unknown names."""
|
||||
import enum as enum_module
|
||||
from custom_components.nestquest import recurrence as mod
|
||||
from custom_components.nestquest.core import recurrence as mod
|
||||
|
||||
ForgedType = enum_module.Enum(
|
||||
"ForgedType", {"UNKNOWN_SHAPE": "unknown_shape"}
|
||||
|
||||
@@ -5,7 +5,7 @@ import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.recurrence import (
|
||||
from custom_components.nestquest.core.recurrence import (
|
||||
RuleType,
|
||||
RuleValidationError,
|
||||
ScheduleRule,
|
||||
@@ -344,12 +344,12 @@ def test_purity_guard_logic_catches_probe_statements(tmp_path) -> None:
|
||||
"from homeassistant.core import HomeAssistant",
|
||||
"import sqlite3",
|
||||
"from .db import NestQuestDatabase",
|
||||
"from custom_components.nestquest.db import NestQuestDatabase",
|
||||
"from custom_components.nestquest.core.db import NestQuestDatabase",
|
||||
"from custom_components.nestquest import db",
|
||||
"import custom_components.nestquest.dao_rules",
|
||||
"import custom_components.nestquest.core.dao_rules",
|
||||
"from . import db",
|
||||
"from . import schema",
|
||||
"from custom_components.nestquest.migrations import apply_migrations",
|
||||
"from custom_components.nestquest.core.migrations import apply_migrations",
|
||||
]
|
||||
for probe in probes:
|
||||
offenders = _scan_forbidden_imports(probe)
|
||||
|
||||
@@ -5,7 +5,7 @@ import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.recurrence import (
|
||||
from custom_components.nestquest.core.recurrence import (
|
||||
RuleType,
|
||||
RuleValidationError,
|
||||
ScheduleRule,
|
||||
|
||||
@@ -5,7 +5,7 @@ import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.recurrence import (
|
||||
from custom_components.nestquest.core.recurrence import (
|
||||
RuleType,
|
||||
RuleValidationError,
|
||||
ScheduleRule,
|
||||
|
||||
@@ -18,7 +18,7 @@ import random
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.recurrence import (
|
||||
from custom_components.nestquest.core.recurrence import (
|
||||
RuleType,
|
||||
RuleValidationError,
|
||||
ScheduleRule,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user