Compare commits
78
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
418fc69f36 | ||
|
|
b6b1ba65b4 | ||
|
|
37f7d25cd6 | ||
|
|
fe71d37bb0 | ||
|
|
1c08c793e3 | ||
|
|
7386999cf7 | ||
|
|
43ad44ee48 | ||
|
|
4cb07fbca4 | ||
|
|
030ba83b14 | ||
|
|
a2c027deca | ||
|
|
2b45ebf035 | ||
|
|
028215fa38 | ||
|
|
0d2e0d6f26 | ||
|
|
1911b2bf6e | ||
|
|
89f517b3ad | ||
|
|
edca25affa | ||
|
|
7c62c50526 | ||
|
|
ca93176768 | ||
|
|
e508c80465 | ||
|
|
3a2e6faa69 | ||
|
|
5092369d49 | ||
|
|
f6166f25d9 | ||
|
|
bf84793a1c | ||
|
|
9d3cb30617 | ||
|
|
b913772007 | ||
|
|
d2f9c50930 | ||
|
|
19a17b647d | ||
|
|
715aac994a | ||
|
|
ad6f1768d7 | ||
|
|
fcc7a29f05 | ||
|
|
f31bda2714 | ||
|
|
dc7a82f6e2 | ||
|
|
1d5143f850 | ||
|
|
fbde3eeaa1 | ||
|
|
3c832c1cee | ||
|
|
8c421abeb2 | ||
|
|
8cd43141a9 | ||
|
|
3de9f4b970 | ||
|
|
25005cda77 | ||
|
|
a6d9ec927b | ||
|
|
bcdf03dac7 | ||
|
|
52cbf33f33 | ||
|
|
43a7a722b9 | ||
|
|
b2f55ffb90 | ||
|
|
cdfb681f46 | ||
|
|
0637245d33 | ||
|
|
c157f6cb9e | ||
|
|
21948fdee4 | ||
|
|
8c99024dd0 | ||
|
|
b4c9ad35c3 | ||
|
|
4230c1d0d4 | ||
|
|
5a962e0ed7 | ||
|
|
db4f695541 | ||
|
|
d81f38528a | ||
|
|
23a52707af | ||
|
|
3b638cae6f | ||
|
|
ceb32074e7 | ||
|
|
a30bafcb68 | ||
|
|
c90892f082 | ||
|
|
1e1444d1a0 | ||
|
|
c6203327bd | ||
|
|
0db7d4d519 | ||
|
|
353f01e98c | ||
|
|
12bb4d58b4 | ||
|
|
29c058a045 | ||
|
|
c27ed24020 | ||
|
|
43b20f2b12 | ||
|
|
2c8c90a24e | ||
|
|
e98642e236 | ||
|
|
01905ec9ee | ||
|
|
30748f786a | ||
|
|
3dae2eadd7 | ||
|
|
eaf2e45e9a | ||
|
|
5e65cde078 | ||
|
|
748c8242c3 | ||
|
|
0f4b53fd4d | ||
|
|
22f8b5dc55 | ||
|
|
cb2be413bd |
@@ -4,3 +4,4 @@ __pycache__/
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
*.metadata.json
|
||||
frontend/node_modules/
|
||||
|
||||
@@ -13,14 +13,14 @@ NestQuest helps households coordinate daily life, chore tracking, custody schedu
|
||||
1. Ensure [HACS (Home Assistant Community Store)](https://hacs.xyz/) is installed.
|
||||
2. In Home Assistant, open **HACS** > **Integrations**.
|
||||
3. Select the three-dots menu in the top right and click **Custom repositories**.
|
||||
4. Enter the repository URL: `https://github.com/talon2king/NestQuest`
|
||||
4. Enter the repository URL: `https://github.com/Maniacs-Mansion/NestQuest`
|
||||
5. Select category **Integration** and click **Add**.
|
||||
6. Search for **NestQuest** and click **Download**.
|
||||
7. Restart Home Assistant.
|
||||
|
||||
> **Note:** Development happens in the private Gitea repository
|
||||
> (`https://code.cubecraftlabs.com/Maniacs_Mansion/NestQuest`). The GitHub
|
||||
> repository is a public mirror maintained for HACS distribution.
|
||||
> repository is a private mirror maintained for HACS distribution.
|
||||
|
||||
### Manual Installation
|
||||
|
||||
@@ -60,13 +60,180 @@ triggers:
|
||||
event_type: nestquest_child_day_complete
|
||||
conditions:
|
||||
- condition: template
|
||||
value_template: "{{ trigger.payload.child_name == 'Declan' }}"
|
||||
value_template: "{{ trigger.event.data.child_name == 'Declan' }}"
|
||||
actions:
|
||||
- action: notify.mobile_app_declans_phone
|
||||
data:
|
||||
message: "Every quest cleared — legendary!"
|
||||
```
|
||||
|
||||
## Automation Blueprints
|
||||
|
||||
NestQuest ships four automation blueprints in
|
||||
`custom_components/nestquest/blueprints/automation/`. Import them in
|
||||
Home Assistant under **Settings → Automations & Scenes → Blueprints**
|
||||
(or place the files in your `config/blueprints/automation/` folder),
|
||||
then create one automation per blueprint, filling the inputs with your
|
||||
household's choices.
|
||||
|
||||
### Wiring to the integration's notification settings
|
||||
|
||||
The integration's options flow records your notification choices —
|
||||
notify target, morning/afternoon/end-of-day times, and a toggle per
|
||||
automation. The blueprints take their own inputs at import time;
|
||||
fill them from the same values you stored in the options flow (the
|
||||
integration does not push its settings into imported automations):
|
||||
|
||||
| Blueprint input | Options-flow counterpart |
|
||||
|---|---|
|
||||
| `morning_time` (default 08:00) | `morning_summary_time` |
|
||||
| `afternoon_time` (default 15:00) | `afternoon_reminder_time` |
|
||||
| `report_time` (default 20:00) | `end_of_day_report_time` |
|
||||
| `send_summary` / `send_reminder` / `send_report` / `celebrate` | the four `*_enabled` toggles |
|
||||
| `notify_target` | `notify_target` (your notify service, e.g. `notify.mobile_app_your_phone`) |
|
||||
|
||||
The end-of-day report deliberately runs BEFORE the midnight missed
|
||||
sweep and reads the same day's data — do not schedule it at the
|
||||
day-rollover time.
|
||||
|
||||
### The four blueprints
|
||||
|
||||
1. **Morning summary** (`morning_summary.yaml`) — at the configured
|
||||
time, lists every child whose `binary_sensor.nestquest_<child>_present_today`
|
||||
is on and what they owe from `sensor.nestquest_<child>_quests_remaining_today`.
|
||||
Children not present today are omitted entirely.
|
||||
|
||||
2. **Afternoon reminder** (`afternoon_reminder.yaml`) — at the
|
||||
configured time, notifies only about present children who still
|
||||
have open quests. Sends nothing at all when every present child is
|
||||
clear.
|
||||
|
||||
3. **End-of-day report** (`end_of_day_report.yaml`) — one message per
|
||||
evening: each present child's completion count and the quests they
|
||||
still owe (the ones the midnight sweep will mark
|
||||
`nestquest_quest_missed`). Absent children are omitted. Sends
|
||||
nothing on an all-clear day unless `send_even_when_clear` is on.
|
||||
|
||||
4. **Day-complete celebration** (`day_complete_celebration.yaml`) —
|
||||
triggers on the `nestquest_child_day_complete` event (which only
|
||||
fires when quests were owed and all are complete, so a zero-quest
|
||||
day never celebrates). Create one `input_text` helper
|
||||
(`nestquest_last_celebrated`) for the dedupe: the automation
|
||||
records each celebrated child as `<child_id>:<today>` and
|
||||
celebrates each child at most once per day — a re-cleared day does
|
||||
not re-fire, but a second child clearing the same day still
|
||||
celebrates.
|
||||
|
||||
### Example package
|
||||
|
||||
```yaml
|
||||
# One automation per blueprint — Settings → Automations & Scenes →
|
||||
# Create → Blueprint, then pick each NestQuest blueprint.
|
||||
#
|
||||
# morning_summary: morning_time 08:00, notify_target notify.mobile_app_your_phone
|
||||
# afternoon_reminder: afternoon_time 15:00, same notify target
|
||||
# end_of_day_report: report_time 20:00, send_even_when_clear false, same target
|
||||
# day_complete_celebration: input_text.nestquest_last_celebrated,
|
||||
# celebration_message "Legendary! {{ trigger.event.data.child_name }} cleared every quest today!"
|
||||
#
|
||||
# Every blueprint has its own send/celebrate toggle input; turn an
|
||||
# automation off entirely from HA's automation menu.
|
||||
```
|
||||
|
||||
### Events the automations rely on
|
||||
|
||||
| Event | Payload |
|
||||
|---|---|
|
||||
| `nestquest_quest_completed` | child_id, child_name, instance_id, quest_title, window, due_date, due_time, occurred_at (UTC ISO-8601), was_on_time |
|
||||
| `nestquest_quest_uncompleted` | child_id, child_name, instance_id, quest_title, window, due_date, due_time, occurred_at |
|
||||
| `nestquest_child_day_complete` | child_id, child_name, quests_due, quests_completed, occurred_at |
|
||||
| `nestquest_quest_missed` | child_id, child_name, instance_id, quest_title, window, due_date, due_time, occurred_at (fired by the nightly sweep) |
|
||||
|
||||
Sensors the automations read: `sensor.nestquest_<child>_quests_due_today`
|
||||
(attributes: `child_id`, `child_name`, `present`, `instances`,
|
||||
`admin_instances`), `..._quests_remaining_today`,
|
||||
`..._quests_completed_today`, and
|
||||
`binary_sensor.nestquest_<child>_present_today`.
|
||||
|
||||
## TouchHub setup
|
||||
|
||||
Run these steps in order to display the NestQuest panel on a TouchHub
|
||||
wall device. The panel is an HA Lovelace dashboard, so the kiosk browser
|
||||
is simply a Home Assistant client.
|
||||
|
||||
1. **Create a dedicated kiosk user.** In *Settings → People → Users*, add
|
||||
a new user for the wall device. Make it **non-admin**, and ensure it is
|
||||
not in the NestQuest admin allowlist. The allowlist blocks the NestQuest
|
||||
**admin** operations — `uncomplete_quest`, `create_quest_definition`,
|
||||
`update_quest_definition`, `set_quest_definition_active`,
|
||||
`set_presence_pattern`, `create_presence_override`,
|
||||
`delete_presence_override`, `export_history_csv`, and `manage_child`
|
||||
(plus `regenerate` flows) — it does not make the account view-only. The
|
||||
kiosk user *can* call `nestquest.complete_quest`, the one open NestQuest
|
||||
service, to complete quests from the panel, and as a non-admin Home
|
||||
Assistant user it may also reach other permitted Home Assistant entities
|
||||
and services.
|
||||
|
||||
2. **Build a dashboard with only the NestQuest panel cards.** Create a
|
||||
Lovelace dashboard whose **URL slug is `nestquest`** (Settings →
|
||||
Dashboards → Edit → URL slug). A functional panel needs **two** views
|
||||
in that dashboard, each with its own card config from
|
||||
`design/ENTITIES-AND-SERVICES.md` §4. Give the views these exact URL
|
||||
paths (the path segment after the dashboard slug):
|
||||
|
||||
- **View 1 path `board`** (URL `/nestquest/board`) — party board
|
||||
(`type: custom:nestquest-party-board-card`): the household quest
|
||||
board. Set `weather_entity` (optional; the dock falls back to
|
||||
date/time), `quest_log_path: /nestquest/log` (the view the crest tap
|
||||
navigates to), and `child_order: [slug1, slug2, slug3]` listing each
|
||||
child's slug in display order.
|
||||
|
||||
- **View 2 path `log`** (URL `/nestquest/log`) — quest log
|
||||
(`type: custom:nestquest-quest-log-card`): the per-child quest screen
|
||||
the party board hops to. Set `board_path: /nestquest/board` (the
|
||||
party-board view to return to), `weather_entity`,
|
||||
`idle_return_seconds` (must be shorter than TouchHub Auto-Return),
|
||||
`confirm_timeout_seconds`, and `complete_screen_seconds`. The quest
|
||||
log reads the selected child's slug from the view's URL path, so the
|
||||
board → log hop is a plain Lovelace navigation.
|
||||
|
||||
Each view's layout MUST be set to **Panel** (Edit Dashboard → pencil on the
|
||||
view → View type: Panel). Home Assistant's default Masonry layout constrains
|
||||
the 1080px-tall NestQuest card to a narrow column; Panel (single-card)
|
||||
layout is what renders the card full-screen at 1920×1080.
|
||||
|
||||
The dashboard contains only the NestQuest panel card for each view —
|
||||
no other cards, no admin controls.
|
||||
|
||||
The `quest_log_path` and `board_path` values must match the dashboard
|
||||
slug and view paths above: `/<slug>/<view>`. If you chose `nestquest`
|
||||
as the dashboard slug with view paths `board` and `log`, the card
|
||||
configs are already correct. If you pick different names, substitute
|
||||
your actual paths in **both** card configs — e.g. a dashboard slug
|
||||
`chores` with view paths `main` and `history` needs
|
||||
`quest_log_path: /chores/history` and `board_path: /chores/main`.
|
||||
Navigation between the two views fails (the crest tap or back button
|
||||
lands on a 404) if the card config paths do not match the dashboard
|
||||
slug and view paths exactly.
|
||||
|
||||
See `design/ENTITIES-AND-SERVICES.md` §4 for the full card YAML.
|
||||
|
||||
3. **Point TouchHub at it.** In the TouchHub launcher, add the **Home
|
||||
Assistant Lovelace** app to the dock and paste the dashboard URL from
|
||||
step 2 — the party-board view URL, e.g. `/nestquest/board` (TouchHub
|
||||
loads that URL directly as the kiosk landing page). Set TouchHub
|
||||
**Auto-Return** to a value GREATER than the quest log's
|
||||
`idle_return_seconds` (default 40s) — recommend **60s or higher**. If
|
||||
Auto-Return is at or below 40s, TouchHub returns to its launcher before
|
||||
the quest log's idle return can navigate back to the party board.
|
||||
|
||||
4. **Log the kiosk session in.** Sign in on the device as the dedicated
|
||||
kiosk user from step 1, and leave that session logged in.
|
||||
|
||||
TouchHub loads the dashboard URL directly. The NestQuest cards are normal
|
||||
Lovelace cards, not an iframe, so no Home Assistant framing setting is
|
||||
needed.
|
||||
|
||||
## Developer Setup and Testing
|
||||
|
||||
This project uses [`uv`](https://github.com/astral-sh/uv) for fast Python package and dependency management.
|
||||
@@ -84,6 +251,28 @@ To set up the development environment and install dependencies:
|
||||
uv sync
|
||||
```
|
||||
|
||||
### Frontend cards
|
||||
|
||||
The Lovelace cards live in `frontend/` (TypeScript + Lit) and build to a
|
||||
single bundle at `custom_components/nestquest/www/nestquest-cards.js`. The
|
||||
integration registers that bundle as a Lovelace resource automatically on
|
||||
setup — do not add it by hand. Card YAML is documented in
|
||||
`design/ENTITIES-AND-SERVICES.md` §4.
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Kids panel layout
|
||||
|
||||
The Lovelace panel cards are designed for the wall display's confirmed
|
||||
resolution, 1920×1080 landscape, with no page scroll. TouchHub's bottom
|
||||
dock overlays the NestQuest weather dock strip until it auto-hides, so
|
||||
no tappable NestQuest control sits in that strip (the dock carries
|
||||
`pointer-events: none`). Keep it that way when editing the cards.
|
||||
|
||||
### Running Tests
|
||||
|
||||
Run the test suite with `pytest`:
|
||||
|
||||
+83
-1
@@ -1,5 +1,87 @@
|
||||
# NestQuest Release Notes
|
||||
|
||||
## Version 0.5.0 — 2026-09-18
|
||||
|
||||
### Scope
|
||||
|
||||
Fifth release of NestQuest, promoting Features 06, 07, and 08 from `dev` onto 0.4.0 (quest model, children registry, presence engine):
|
||||
|
||||
- **Feature 06 — quest definitions and assignment:** business layer `quest_definitions.py` over the multi-assignee / multi-window model (D-008). Create/edit/deactivate definitions; assign/unassign children; query helpers for materialization. `ScheduleRule` ⇄ `schedule_rules` storage mapping in the DAO layer.
|
||||
- **Feature 07 — instance materialization:** `materialize.py` fans out one instance per (definition, child, window, firing date) when the recurrence rule fires and the child is present. Idempotent on `(definition_id, child_id, due_date, window)`; skips completed instances; never generates past dates. HA-local `today` and configured `horizon_days` threaded end-to-end. Regeneration on definition/assignee/presence changes; daily rollover listener; startup backfill; `nestquest.regenerate` service.
|
||||
- **Feature 08 — completion and event log:** business layer `completion.py` wrapping the existing append-only `CompletionEventsDao`. `complete_instance` / `uncomplete_instance` (reversal event, original row untouched); state derived from the latest event; HA-local `was_on_time`; derived `missed` (never stored). Actor shapes `'user'` / `'panel'` with `actor_child_id` (D-008). Permission gating of un-complete is Feature 09.
|
||||
|
||||
### Requirements
|
||||
|
||||
- Minimum Home Assistant version: **2024.6.0**.
|
||||
|
||||
### Breaking changes
|
||||
|
||||
- None at the schema layer. Schema remains **version 6** (advanced in 0.4.0). This release adds application code only.
|
||||
|
||||
### Known Limitations
|
||||
|
||||
- No permission decorator yet (Feature 09): `complete_instance` / `uncomplete_instance` are Python APIs, not gated HA services. The only HA service in this release is `nestquest.regenerate`.
|
||||
- No entity or event surface (Feature 10): no sensors, no `nestquest_quest_completed` bus events.
|
||||
- No panel or admin Lovelace cards (Features 12/13).
|
||||
- Un-complete is implemented but not admin-gated until Feature 09.
|
||||
- Single-instance only.
|
||||
|
||||
### Rollback
|
||||
|
||||
Schema version is unchanged from 0.4.0 (still 6). A code downgrade to 0.4.0 does not require a database restore for schema compatibility, but **back up `nestquest.db` (plus `-wal`/`-shm`) before upgrading** — generated instances and completion events written by 0.5.0 remain in the file after downgrade; 0.4.0 will not generate or complete them.
|
||||
|
||||
**HACS installs:**
|
||||
|
||||
1. Downgrade via HACS (HACS > Integrations > NestQuest > Redownload > 0.4.0).
|
||||
2. Restart Home Assistant.
|
||||
|
||||
**Manual installs:**
|
||||
|
||||
1. Replace `custom_components/nestquest/` with the 0.4.0 tree.
|
||||
2. Restart Home Assistant.
|
||||
|
||||
**Repository operators:** git-revert the 0.5.0 release merge on `main`.
|
||||
|
||||
## Version 0.4.0 — 2026-09-15
|
||||
|
||||
### Scope
|
||||
|
||||
Fourth release of NestQuest, shipping three completed features on top of the data layer and recurrence engine:
|
||||
|
||||
- **Feature 14 — quest domain model (BREAKING rename, D-007/D-008):** `task_definitions`/`task_instances` are now `quest_definitions`/`quest_instances` everywhere; definitions are multi-assignee (`quest_definition_assignees`, no single child column) and multi-window (`quest_definition_windows` — morning/afternoon/evening, clock ranges in `const.py`); the instance key widens to `(definition_id, child_id, due_date, window)` so twice-daily and shared quests materialise one instance per child per window per day; `completion_events` gains `actor_child_id` (the tapped panel profile; admin events stay NULL) with a second actor-pair CHECK. Schema version advances to **6**.
|
||||
- **Feature 03 — children registry & admin allowlist:** typed business layer for child profiles (create/edit with trimmed required names and duplicate-name warnings, whole-table reorder, deactivation as the only removal path — history is never touched); the admin allowlist with a fail-closed `is_admin` resolver (empty allowlist denies everyone; the last admin cannot be removed); automatic seeding on first setup (the config-flow user is persisted in entry data, with the options-flow list and HA owner accounts as fallbacks) so the owner is never locked out; the options flow gains a multi-select picker of existing Home Assistant users.
|
||||
- **Feature 05 — presence & custody engine:** a pure `PresenceSchedule`/`PresenceOverride` model with total validation and lossless round-trips; anchor-date cycle arithmetic (D-004 — never ISO week numbers or parity); `PresenceEngine.is_present` with the always-present default for schedule-free children; date overrides beat the repeating pattern; a `next_present_dates` preview for the admin schedule UI; a full-year regression guard walking 2020 (366 days, ISO week 53) and 2015 (365 days, ISO week 53) proving the rotation never inverts at a year boundary.
|
||||
|
||||
### Requirements
|
||||
|
||||
- Minimum Home Assistant version: **2024.6.0**.
|
||||
|
||||
### Breaking changes
|
||||
|
||||
- The database schema advances from version 1 to version 6. Upgrades are automatic and data-preserving (legacy `task_*` tables are renamed; the definitions table is rebuilt for multi-assignee; legacy instances pin to the morning window; panel events backfill `actor_child_id`). **Back up `nestquest.db` before upgrading** — see Rollback.
|
||||
- Dev installs from before 2026-09-15 migrate automatically on first start.
|
||||
|
||||
### Known Limitations
|
||||
|
||||
- Still no entities, services, calendar platforms, or panel cards: nothing is user-visible in Home Assistant yet (Features 06-10 land the business layer, materialisation, permission gate, and entity surface).
|
||||
- No materialisation yet: instances are not generated from definitions + presence yet (Feature 07).
|
||||
- Single-instance only.
|
||||
|
||||
### Rollback
|
||||
|
||||
**Back up `nestquest.db` (plus `-wal`/`-shm` sidecars) BEFORE upgrading.** This release migrates the database to schema version 6 with renamed tables; an older NestQuest build refuses a version-6 database ("newer than this integration understands") and cannot read renamed tables at all — a plain downgrade is NOT possible once the database has been migrated.
|
||||
|
||||
**Rollback procedure (after a migrated database):**
|
||||
|
||||
1. Stop Home Assistant.
|
||||
2. Restore the pre-upgrade `nestquest.db` backup (and sidecars) you made before upgrading.
|
||||
3. Downgrade the integration (HACS > Integrations > NestQuest > Redownload > 0.3.0) or replace `custom_components/nestquest/` manually.
|
||||
4. Restart Home Assistant.
|
||||
|
||||
Without a backup, the only path is staying on 0.4.0 (recommended) or deleting the integration and its database to start fresh (Settings > Devices & Services > NestQuest > Delete, then remove `nestquest.db`).
|
||||
|
||||
**Repository operators:** instead of uninstalling, git-revert the release merge on `main` and restore the pre-upgrade database.
|
||||
|
||||
## Version 0.3.0 — 2026-09-14
|
||||
|
||||
### Scope
|
||||
@@ -126,4 +208,4 @@ v0.1.0 is the first release — there is no previous NestQuest version to downgr
|
||||
|
||||
**Data:** the integration does not yet create its own database file, so there is no application data. However, Home Assistant persists the config entry and its selected options — removing the integration deletes that configuration, and the option values (`horizon_days`, `day_rollover_time`, `panel_idle_timeout`) will be lost on removal.
|
||||
|
||||
**Repository operators:** instead of uninstalling, you may git-revert the release merge on `main`, which removes the integration files entirely. End users should use the uninstall path above.
|
||||
**Repository operators:** instead of uninstalling, you may git-revert the release merge on `main`, which removes the integration files entirely. End users should use the uninstall path above.
|
||||
|
||||
@@ -28,6 +28,7 @@ from .const import (
|
||||
LOGGER,
|
||||
PLATFORMS,
|
||||
)
|
||||
from .frontend import async_register_frontend
|
||||
from .coordinator import NestQuestCoordinator
|
||||
from .db import NestQuestDatabase
|
||||
from .materialize import materialize as _materialize_run
|
||||
@@ -35,6 +36,7 @@ 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
|
||||
|
||||
_LOGGER = LOGGER
|
||||
|
||||
@@ -327,18 +329,23 @@ def _register_day_rollover_listener(
|
||||
database: NestQuestDatabase,
|
||||
options: dict[str, Any],
|
||||
) -> Callable[[], Any]:
|
||||
"""Register the daily materialization listener and return its remover.
|
||||
"""Register the daily rollover listener and return its remover.
|
||||
|
||||
Fires once per day at the configured local ``day_rollover_time`` and
|
||||
materializes the rolling horizon ``[today, today + horizon_days]``
|
||||
through the shared :func:`_run_horizon_materialization` path, using the
|
||||
entry's configured (validated) ``horizon_days``.
|
||||
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``), 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 = _day_rollover_hour_minute(options)
|
||||
horizon_days = _configured_horizon_days(options)
|
||||
|
||||
async def _run_materialization(_now: datetime.datetime) -> None:
|
||||
await _run_horizon_materialization(hass, database, horizon_days)
|
||||
await run_missed_sweep(hass, database)
|
||||
|
||||
return async_track_time_change(
|
||||
hass, _run_materialization, hour=hour, minute=minute
|
||||
@@ -404,11 +411,13 @@ def _deregister_services(hass: HomeAssistant) -> None:
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
|
||||
"""Set up the NestQuest integration. YAML configuration is not used, returns True."""
|
||||
await async_register_frontend(hass)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up NestQuest from a config entry."""
|
||||
await async_register_frontend(hass)
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
existing = hass.data[DOMAIN].get(entry.entry_id)
|
||||
if existing is not None:
|
||||
@@ -498,6 +507,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
await _run_horizon_materialization(
|
||||
hass, database, _configured_horizon_days(dict(entry.options))
|
||||
)
|
||||
# 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
|
||||
|
||||
@@ -135,6 +135,17 @@ class NestQuestPresentTodaySensor(_NestQuestChildBinarySensor):
|
||||
return None
|
||||
return child.present
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||
"""Base attributes plus, for a child away today, the ISO date
|
||||
of their next present day (the panel's away plate renders
|
||||
``Returns <weekday>, <Mon D>`` from it)."""
|
||||
base = super().extra_state_attributes
|
||||
if base is None:
|
||||
return None
|
||||
child = self._child
|
||||
return {**base, "next_present": child.next_present if child else None}
|
||||
|
||||
|
||||
def _child_binary_sensors(
|
||||
coordinator: NestQuestCoordinator, child: ChildDaySnapshot
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
blueprint:
|
||||
name: NestQuest afternoon reminder
|
||||
description: >-
|
||||
Sends an afternoon nudge listing ONLY the children who still have
|
||||
open quests today. Children who are present and all clear are
|
||||
omitted (the nudge is for stragglers, not a roll call), children
|
||||
who are not present today are omitted entirely, and the household
|
||||
rollup sensors are excluded by the same present guard. When every
|
||||
present child is already done the automation sends NOTHING at all:
|
||||
a template condition stops the run before the notification. The
|
||||
notify service is chosen by the household when importing this
|
||||
blueprint — NestQuest never hard-codes a personal notify target.
|
||||
domain: automation
|
||||
input:
|
||||
afternoon_time:
|
||||
name: Afternoon time
|
||||
description: Local time of day the reminder is sent.
|
||||
default: "15:00"
|
||||
selector:
|
||||
time: {}
|
||||
notify_target:
|
||||
name: Notify service
|
||||
description: >-
|
||||
The Home Assistant notify service to call for the reminder,
|
||||
for example notify.mobile_app_your_phone — a placeholder;
|
||||
pick the notify service for your own device.
|
||||
selector:
|
||||
text: {}
|
||||
send_reminder:
|
||||
name: Send the reminder
|
||||
description: >-
|
||||
Turn off to keep the automation installed but skip the
|
||||
notification entirely.
|
||||
default: true
|
||||
selector:
|
||||
boolean: {}
|
||||
|
||||
mode: single
|
||||
|
||||
variables:
|
||||
send_reminder: !input send_reminder
|
||||
|
||||
triggers:
|
||||
- trigger: time
|
||||
at: !input afternoon_time
|
||||
|
||||
conditions:
|
||||
- condition: template
|
||||
value_template: "{{ send_reminder | bool }}"
|
||||
# Send nothing when every present child is already done: the guard is
|
||||
# false unless at least one present child has remaining quests today.
|
||||
- condition: template
|
||||
value_template: |
|
||||
{%- set ns = namespace(any_open=false) -%}
|
||||
{%- for due in states.sensor
|
||||
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%}
|
||||
{%- if due.attributes.get('present') -%}
|
||||
{%- set remaining_values = states.sensor
|
||||
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$')
|
||||
| selectattr('attributes.child_id', 'eq', due.attributes.child_id)
|
||||
| map(attribute='state')
|
||||
| list -%}
|
||||
{%- if remaining_values and (remaining_values | first | int) > 0 -%}
|
||||
{%- set ns.any_open = true -%}
|
||||
{%- endif -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{{- ns.any_open -}}
|
||||
|
||||
actions:
|
||||
- action: !input notify_target
|
||||
data:
|
||||
title: NestQuest afternoon reminder
|
||||
message: |
|
||||
{%- set ns = namespace(lines=[]) -%}
|
||||
{%- for due in states.sensor
|
||||
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%}
|
||||
{%- if due.attributes.get('present') -%}
|
||||
{%- set remaining_values = states.sensor
|
||||
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$')
|
||||
| selectattr('attributes.child_id', 'eq', due.attributes.child_id)
|
||||
| map(attribute='state')
|
||||
| list -%}
|
||||
{%- if remaining_values and (remaining_values | first | int) > 0 -%}
|
||||
{%- set line = due.attributes.child_name ~ ' still has ' ~ (remaining_values | first | int) ~ ' quest(s) open.' -%}
|
||||
{%- set ns.lines = ns.lines + [line] -%}
|
||||
{%- endif -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{{- ns.lines | join('\n') -}}
|
||||
@@ -0,0 +1,109 @@
|
||||
blueprint:
|
||||
name: NestQuest day complete celebration
|
||||
description: >-
|
||||
Celebrates the moment a child clears every quest for the day: the
|
||||
automation is driven by the nestquest_child_day_complete event,
|
||||
which NestQuest only fires when quests were owed and all of them
|
||||
are complete — a zero-quest day can never trigger this
|
||||
celebration. Once-per-child-per-day dedupe: point
|
||||
last_celebrated_helper at ONE input_text helper (create it under
|
||||
Settings, Devices and Services, Helpers — plain text). The
|
||||
automation records each celebrated child as
|
||||
"<child_id>:<today>" inside that helper, and a child who
|
||||
un-completes a quest and completes it again the same day is
|
||||
celebrated once, not twice — while a SECOND child clearing their
|
||||
day the same date is still celebrated in full. The helper is
|
||||
pruned to today's entries on every celebration, so it never
|
||||
grows past one entry per child per day. A helper that does not
|
||||
exist yet, or has never been set, lets the first celebration
|
||||
through. The notification message may use the event payload's
|
||||
child_name, quests_due and quests_completed. The notify service
|
||||
is chosen by the household when importing this blueprint —
|
||||
NestQuest never hard-codes a personal notify target.
|
||||
domain: automation
|
||||
input:
|
||||
notify_target:
|
||||
name: Notify service
|
||||
description: >-
|
||||
The Home Assistant notify service to call for the celebration,
|
||||
for example notify.mobile_app_your_phone — a placeholder;
|
||||
pick the notify service for your own device.
|
||||
selector:
|
||||
text: {}
|
||||
celebration_message:
|
||||
name: Celebration message
|
||||
description: >-
|
||||
The message sent when a child clears every quest today. May
|
||||
use the event payload: trigger.event.data.child_name,
|
||||
trigger.event.data.quests_due and trigger.event.data.quests_completed.
|
||||
default: "Legendary! {{ trigger.event.data.child_name }} cleared every quest today!"
|
||||
selector:
|
||||
text: {}
|
||||
celebrate:
|
||||
name: Send the celebration
|
||||
description: >-
|
||||
Turn off to keep the automation installed but skip the
|
||||
notification entirely.
|
||||
default: true
|
||||
selector:
|
||||
boolean: {}
|
||||
last_celebrated_helper:
|
||||
name: Last-celebrated helper
|
||||
description: >-
|
||||
A single input_text helper remembering which children were
|
||||
already celebrated today, as "<child_id>:<date>" entries
|
||||
separated by commas. The automation skips a child whose entry
|
||||
for today is already stored, and rewrites the helper with only
|
||||
today's entries plus the new one after each celebration.
|
||||
selector:
|
||||
entity:
|
||||
filter:
|
||||
- domain: input_text
|
||||
|
||||
mode: queued
|
||||
|
||||
variables:
|
||||
celebrate: !input celebrate
|
||||
last_celebrated_helper: !input last_celebrated_helper
|
||||
|
||||
triggers:
|
||||
- trigger: event
|
||||
event_type: nestquest_child_day_complete
|
||||
|
||||
conditions:
|
||||
- condition: template
|
||||
value_template: "{{ celebrate | bool }}"
|
||||
# Once-per-child-per-day dedupe: celebrate only when THIS child has
|
||||
# no equal entry for today in the helper. Entries compare as whole
|
||||
# comma-separated tokens — a substring check would wrongly suppress
|
||||
# child 1 after child 11 was celebrated. An unset or missing helper
|
||||
# reads as '' and celebrates.
|
||||
- condition: template
|
||||
value_template: >-
|
||||
{{ (trigger.event.data.child_id ~ ':' ~ now().strftime('%Y-%m-%d'))
|
||||
not in ((states(last_celebrated_helper) or '')
|
||||
.split(',')
|
||||
| select('ne', '')
|
||||
| list) }}
|
||||
|
||||
actions:
|
||||
- action: !input notify_target
|
||||
data:
|
||||
title: NestQuest day complete
|
||||
message: !input celebration_message
|
||||
# Record this child's celebration, keeping only today's entries so the
|
||||
# helper never outgrows its text limit and stale entries cannot block
|
||||
# next week's celebrations.
|
||||
- action: input_text.set_value
|
||||
target:
|
||||
entity_id: !input last_celebrated_helper
|
||||
data:
|
||||
value: >-
|
||||
{%- set today = now().strftime('%Y-%m-%d') -%}
|
||||
{%- set ns = namespace(entries=[]) -%}
|
||||
{%- for entry in (states(last_celebrated_helper) or '').split(',') -%}
|
||||
{%- if entry and entry.endswith(':' ~ today) -%}
|
||||
{%- set ns.entries = ns.entries + [entry] -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{{ (ns.entries + [trigger.event.data.child_id ~ ':' ~ today]) | join(',') }}
|
||||
@@ -0,0 +1,121 @@
|
||||
blueprint:
|
||||
name: NestQuest end-of-day report
|
||||
description: >-
|
||||
Sends the parent an end-of-day report on how the day went. It runs
|
||||
deliberately BEFORE NestQuest's midnight missed-quest sweep, so it
|
||||
reads the same day's data (what is still open at report time, and
|
||||
how much of the day was completed) rather than tomorrow-morning's
|
||||
hindsight. Every PRESENT child gets a completion line, so the
|
||||
parent always receives the day's summary; children who still owe
|
||||
quests get their open count called out on that line, and children
|
||||
who are not present today are omitted entirely. A child whose
|
||||
remaining-count sensor is unavailable is reported with the
|
||||
completion line only. By default the report is skipped when every
|
||||
present child is already done — turn on send-even-when-clear to
|
||||
always get the day's completion summary. The notify service is
|
||||
chosen by the household when importing this blueprint — NestQuest
|
||||
never hard-codes a personal notify target.
|
||||
domain: automation
|
||||
input:
|
||||
report_time:
|
||||
name: Report time
|
||||
description: >-
|
||||
Local time of day the report is sent — deliberately before the
|
||||
midnight missed-quest sweep, so the report reads the same
|
||||
day's data.
|
||||
default: "20:00"
|
||||
selector:
|
||||
time: {}
|
||||
notify_target:
|
||||
name: Notify service
|
||||
description: >-
|
||||
The Home Assistant notify service to call for the report,
|
||||
for example notify.mobile_app_your_phone — a placeholder;
|
||||
pick the notify service for your own device.
|
||||
selector:
|
||||
text: {}
|
||||
send_report:
|
||||
name: Send the report
|
||||
description: >-
|
||||
Turn off to keep the automation installed but skip the
|
||||
notification entirely.
|
||||
default: true
|
||||
selector:
|
||||
boolean: {}
|
||||
send_even_when_clear:
|
||||
name: Send even when clear
|
||||
description: >-
|
||||
Send the report even when every present child has already
|
||||
finished their quests today. When off (the default), an
|
||||
all-clear day sends nothing.
|
||||
default: false
|
||||
selector:
|
||||
boolean: {}
|
||||
|
||||
mode: single
|
||||
|
||||
variables:
|
||||
send_report: !input send_report
|
||||
send_even_when_clear: !input send_even_when_clear
|
||||
|
||||
triggers:
|
||||
- trigger: time
|
||||
at: !input report_time
|
||||
|
||||
conditions:
|
||||
- condition: template
|
||||
value_template: "{{ send_report | bool }}"
|
||||
# Skip an all-clear day unless the household opted into
|
||||
# send-even-when-clear: the guard is false only when every present
|
||||
# child has a usable remaining count of zero. A MISSING or
|
||||
# non-numeric remaining sensor reads as -1 and counts as open —
|
||||
# a degraded state must reach the parent, not look like all-clear.
|
||||
- condition: template
|
||||
value_template: |
|
||||
{%- set ns = namespace(any_open=false) -%}
|
||||
{%- for due in states.sensor
|
||||
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%}
|
||||
{%- if due.attributes.get('present') -%}
|
||||
{%- set remaining_values = states.sensor
|
||||
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$')
|
||||
| selectattr('attributes.child_id', 'eq', due.attributes.child_id)
|
||||
| map(attribute='state')
|
||||
| list -%}
|
||||
{%- if not remaining_values or (remaining_values | first | int(-1)) != 0 -%}
|
||||
{%- set ns.any_open = true -%}
|
||||
{%- endif -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{{- send_even_when_clear | bool or ns.any_open -}}
|
||||
|
||||
actions:
|
||||
- action: !input notify_target
|
||||
data:
|
||||
title: NestQuest end-of-day report
|
||||
message: |
|
||||
{%- set ns = namespace(lines=[]) -%}
|
||||
{%- for due in states.sensor
|
||||
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%}
|
||||
{%- if due.attributes.get('present') -%}
|
||||
{%- set due_count = due.state | int -%}
|
||||
{%- set completed_values = states.sensor
|
||||
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_completed_today(_\\d+)?$')
|
||||
| selectattr('attributes.child_id', 'eq', due.attributes.child_id)
|
||||
| map(attribute='state')
|
||||
| list -%}
|
||||
{%- set remaining_values = states.sensor
|
||||
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$')
|
||||
| selectattr('attributes.child_id', 'eq', due.attributes.child_id)
|
||||
| map(attribute='state')
|
||||
| list -%}
|
||||
{%- set completed_count = (completed_values | first | int) if completed_values else 0 -%}
|
||||
{%- set remaining_count = (remaining_values | first | int) if remaining_values else none -%}
|
||||
{%- if remaining_count is not none and remaining_count > 0 -%}
|
||||
{%- set line = due.attributes.child_name ~ ': ' ~ remaining_count ~ ' still open (' ~ completed_count ~ '/' ~ due_count ~ ' done).' -%}
|
||||
{%- else -%}
|
||||
{%- set line = due.attributes.child_name ~ ': ' ~ completed_count ~ '/' ~ due_count ~ ' done.' -%}
|
||||
{%- endif -%}
|
||||
{%- set ns.lines = ns.lines + [line] -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{{- ns.lines | join('\n') -}}
|
||||
@@ -0,0 +1,78 @@
|
||||
blueprint:
|
||||
name: NestQuest morning summary
|
||||
description: >-
|
||||
Sends a morning notification listing what each child still owes
|
||||
today. Only children whose NestQuest presence sensor is on are
|
||||
included; children who are not present today are omitted entirely.
|
||||
The notify service is chosen by the household when importing this
|
||||
blueprint — NestQuest never hard-codes a personal notify target.
|
||||
domain: automation
|
||||
input:
|
||||
morning_time:
|
||||
name: Morning time
|
||||
description: Local time of day the summary is sent.
|
||||
default: "08:00"
|
||||
selector:
|
||||
time: {}
|
||||
notify_target:
|
||||
name: Notify service
|
||||
description: >-
|
||||
The Home Assistant notify service to call for the summary,
|
||||
for example notify.mobile_app_your_phone — a placeholder;
|
||||
pick the notify service for your own device.
|
||||
selector:
|
||||
text: {}
|
||||
send_summary:
|
||||
name: Send the summary
|
||||
description: >-
|
||||
Turn off to keep the automation installed but skip the
|
||||
notification entirely.
|
||||
default: true
|
||||
selector:
|
||||
boolean: {}
|
||||
|
||||
mode: single
|
||||
|
||||
variables:
|
||||
send_summary: !input send_summary
|
||||
|
||||
triggers:
|
||||
- trigger: time
|
||||
at: !input morning_time
|
||||
|
||||
conditions:
|
||||
- condition: template
|
||||
value_template: "{{ send_summary | bool }}"
|
||||
|
||||
actions:
|
||||
- action: !input notify_target
|
||||
data:
|
||||
title: NestQuest morning summary
|
||||
message: |
|
||||
{%- set ns = namespace(lines=[]) -%}
|
||||
{%- for due in states.sensor
|
||||
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%}
|
||||
{%- if due.attributes.get('present') -%}
|
||||
{%- set remaining_values = states.sensor
|
||||
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$')
|
||||
| selectattr('attributes.child_id', 'eq', due.attributes.child_id)
|
||||
| map(attribute='state')
|
||||
| list -%}
|
||||
{%- if remaining_values -%}
|
||||
{%- set remaining = remaining_values | first | int -%}
|
||||
{%- if remaining > 0 -%}
|
||||
{%- set line = due.attributes.child_name ~ ' owes ' ~ remaining ~ ' quest(s) today.' -%}
|
||||
{%- else -%}
|
||||
{%- set line = due.attributes.child_name ~ ' is all clear today (0 quests remaining).' -%}
|
||||
{%- endif -%}
|
||||
{%- else -%}
|
||||
{%- set line = due.attributes.child_name ~ "'s quest count is unavailable today." -%}
|
||||
{%- endif -%}
|
||||
{%- set ns.lines = ns.lines + [line] -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{%- if ns.lines -%}
|
||||
{{- ns.lines | join('\n') -}}
|
||||
{%- else -%}
|
||||
No children present today.
|
||||
{%- endif -%}
|
||||
@@ -73,6 +73,26 @@ CONF_UPDATE_INTERVAL = "update_interval"
|
||||
DEFAULT_UPDATE_INTERVAL = 300
|
||||
MIN_UPDATE_INTERVAL = 30
|
||||
|
||||
# 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
|
||||
# strict HH:MM local; each of the four automations has an independent
|
||||
# enable toggle. Per the owner-settled resolution the end-of-day report
|
||||
# defaults to 20:00 — deliberately BEFORE the midnight missed sweep —
|
||||
# and never defaults to the rollover time.
|
||||
CONF_NOTIFY_TARGET = "notify_target"
|
||||
CONF_MORNING_SUMMARY_TIME = "morning_summary_time"
|
||||
DEFAULT_MORNING_SUMMARY_TIME = "08:00"
|
||||
CONF_AFTERNOON_REMINDER_TIME = "afternoon_reminder_time"
|
||||
DEFAULT_AFTERNOON_REMINDER_TIME = "15:00"
|
||||
CONF_END_OF_DAY_REPORT_TIME = "end_of_day_report_time"
|
||||
DEFAULT_END_OF_DAY_REPORT_TIME = "20:00"
|
||||
CONF_MORNING_SUMMARY_ENABLED = "morning_summary_enabled"
|
||||
CONF_AFTERNOON_REMINDER_ENABLED = "afternoon_reminder_enabled"
|
||||
CONF_END_OF_DAY_REPORT_ENABLED = "end_of_day_report_enabled"
|
||||
CONF_CELEBRATION_ENABLED = "celebration_enabled"
|
||||
DEFAULT_AUTOMATION_ENABLED = True
|
||||
|
||||
# Quest windows (D-008): a definition may declare several day windows;
|
||||
# each produces its own instance per day and groups the panel's Quest
|
||||
# Log into three columns. The clock ranges are stored here so they stay
|
||||
|
||||
@@ -50,6 +50,7 @@ from .dao_rules import QuestDefinitionsDao
|
||||
from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao
|
||||
from .db import NestQuestDatabase
|
||||
from .presence import (
|
||||
MAX_PREVIEW_SCAN_DAYS,
|
||||
PresenceEngine,
|
||||
PresenceOverride,
|
||||
PresenceSchedule,
|
||||
@@ -86,6 +87,11 @@ class ChildDaySnapshot:
|
||||
completed_today: int
|
||||
remaining_today: int
|
||||
completion_pct: int
|
||||
#: For a child away today: the ISO date of their next present day
|
||||
#: (the panel's away plate renders ``Returns <weekday>, <Mon D>``
|
||||
#: from it). ``None`` when the child is present today or no
|
||||
#: present day exists within the presence engine's scan cap.
|
||||
next_present: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -162,10 +168,14 @@ class NestQuestCoordinator(DataUpdateCoordinator):
|
||||
schedules[child.id] = PresenceSchedule.decode(
|
||||
child.id, schedule.anchor_date, schedule.pattern
|
||||
)
|
||||
todays = await PresenceOverridesDao(
|
||||
window_end = today + datetime.timedelta(
|
||||
days=MAX_PREVIEW_SCAN_DAYS
|
||||
)
|
||||
window_end_iso = window_end.isoformat()
|
||||
ranged = await PresenceOverridesDao(
|
||||
self.database
|
||||
).list_by_child_and_range(child.id, today_iso, today_iso)
|
||||
if todays:
|
||||
).list_by_child_and_range(child.id, today_iso, window_end_iso)
|
||||
if ranged:
|
||||
# The engine validates MODEL objects, not DAO records;
|
||||
# each record converts losslessly (the schema guarantees
|
||||
# end >= start and a real is_present).
|
||||
@@ -177,7 +187,7 @@ class NestQuestCoordinator(DataUpdateCoordinator):
|
||||
record.is_present,
|
||||
record.note,
|
||||
)
|
||||
for record in todays
|
||||
for record in ranged
|
||||
]
|
||||
engine = PresenceEngine(schedules, overrides)
|
||||
|
||||
@@ -231,18 +241,28 @@ class NestQuestCoordinator(DataUpdateCoordinator):
|
||||
due = len(views)
|
||||
completed = sum(1 for view in views if view.state == "done")
|
||||
remaining = due - completed
|
||||
child_present = engine.is_present(child.id, today)
|
||||
next_present: str | None = None
|
||||
if not child_present:
|
||||
try:
|
||||
next_present = engine.next_present_dates(
|
||||
child.id, today, 1
|
||||
)[0].isoformat()
|
||||
except ValueError:
|
||||
next_present = None
|
||||
snapshots.append(
|
||||
ChildDaySnapshot(
|
||||
child_id=child.id,
|
||||
child_name=child.display_name,
|
||||
instances=tuple(views),
|
||||
present=engine.is_present(child.id, today),
|
||||
present=child_present,
|
||||
due_today=due,
|
||||
completed_today=completed,
|
||||
remaining_today=remaining,
|
||||
completion_pct=(
|
||||
round(completed * 100 / due) if due else 100
|
||||
),
|
||||
next_present=next_present,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -493,6 +493,43 @@ class QuestInstancesDao:
|
||||
)
|
||||
return [_instance_from_row(row) for row in rows]
|
||||
|
||||
async def list_missed_unswept(
|
||||
self, today_iso: str, watermark: str | None
|
||||
) -> list[QuestInstanceRecord]:
|
||||
"""Return past-due open instances the sweep has not announced.
|
||||
|
||||
Feature 11's missed-sweep query: ``due_date`` strictly before
|
||||
``today_iso`` (past due), at or after ``watermark`` when one is
|
||||
recorded, and with NO completion event ever (the Feature 08
|
||||
append-only contract: an instance that was ever completed is
|
||||
never reported missed, even when later reversed — history keeps
|
||||
both rows and the sweep never mutates any). The watermark is
|
||||
the HA-local date the sweep last ran, and the window is
|
||||
``[watermark, today)``: the nightly run entering day D+1 sweeps
|
||||
exactly day D's instances, so every day's quests are announced
|
||||
once — at the midnight after their due date — and a same-night
|
||||
rerun (watermark == today) sees an empty window. Read-only;
|
||||
ordered deterministically by (due_date, id).
|
||||
"""
|
||||
_validate_date(today_iso, "today_iso")
|
||||
if watermark is not None:
|
||||
_validate_date(watermark, "watermark")
|
||||
sql = (
|
||||
f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances "
|
||||
"WHERE due_date < ? "
|
||||
"AND NOT EXISTS ("
|
||||
" SELECT 1 FROM completion_events "
|
||||
" WHERE instance_id = quest_instances.id"
|
||||
")"
|
||||
)
|
||||
parameters: tuple = (today_iso,)
|
||||
if watermark is not None:
|
||||
sql += " AND due_date >= ?"
|
||||
parameters += (watermark,)
|
||||
sql += " ORDER BY due_date, id"
|
||||
rows = await self._database.fetch_all(sql, parameters)
|
||||
return [_instance_from_row(row) for row in rows]
|
||||
|
||||
async def delete_future_uncompleted(
|
||||
self, definition_id: int, cutoff_date: str, *, today: datetime.date | None = None
|
||||
) -> int:
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Typed DAO for the integration-owned ``nestquest_meta_state`` table.
|
||||
|
||||
Generic persistent key/value bookkeeping that belongs to the
|
||||
integration itself rather than any domain table — currently the
|
||||
Feature 11 missed-sweep watermark. Values are plain strings; callers
|
||||
interpret them. All SQL for this table lives here per the feature
|
||||
guardrails, and every method runs through
|
||||
:class:`~.db.NestQuestDatabase` so nothing blocks the event loop.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .dao_children import _connection_lock
|
||||
from .db import NestQuestDatabase
|
||||
|
||||
_TABLE = "nestquest_meta_state"
|
||||
|
||||
|
||||
class MetaStateDao:
|
||||
"""Typed async access to the ``nestquest_meta_state`` table."""
|
||||
|
||||
def __init__(self, database: NestQuestDatabase) -> None:
|
||||
self._database = database
|
||||
|
||||
async def get(self, key: str) -> str | None:
|
||||
"""Return the stored value for ``key``, or None when absent."""
|
||||
row = await self._database.fetch_one(
|
||||
f"SELECT value FROM {_TABLE} WHERE key = ?", (key,)
|
||||
)
|
||||
return row[0] if row is not None else None
|
||||
|
||||
async def set(self, key: str, value: str) -> None:
|
||||
"""Upsert one key/value row atomically.
|
||||
|
||||
The write runs inside one transaction under the shared
|
||||
connection lock, so a racing reader either sees the old value
|
||||
or the fully-written new one, never a half-applied state.
|
||||
"""
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
await self._database.execute(
|
||||
f"INSERT INTO {_TABLE} (key, value) VALUES (?, ?) "
|
||||
"ON CONFLICT (key) DO UPDATE SET value = excluded.value",
|
||||
(key, value),
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Lovelace card bundle registration for NestQuest."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from homeassistant.components.frontend import add_extra_js_url
|
||||
from homeassistant.components.http import StaticPathConfig
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
WWW_DIR = Path(__file__).parent / "www"
|
||||
FRONTEND_URL_BASE = "/nestquest-static"
|
||||
BUNDLE_FILENAME = "nestquest-cards.js"
|
||||
BUNDLE_URL = f"{FRONTEND_URL_BASE}/{BUNDLE_FILENAME}"
|
||||
_REGISTERED_ATTR = "_nestquest_frontend_registered"
|
||||
|
||||
|
||||
async def async_register_frontend(hass: HomeAssistant) -> None:
|
||||
"""Serve the card bundle and inject it into every Lovelace page.
|
||||
|
||||
Idempotent across config entries: static paths and extra JS URLs are
|
||||
process-global, so a second entry must not register them again.
|
||||
"""
|
||||
if getattr(hass, _REGISTERED_ATTR, False):
|
||||
return
|
||||
await hass.http.async_register_static_paths(
|
||||
[
|
||||
StaticPathConfig(
|
||||
FRONTEND_URL_BASE,
|
||||
str(WWW_DIR),
|
||||
cache_headers=False,
|
||||
)
|
||||
]
|
||||
)
|
||||
add_extra_js_url(hass, BUNDLE_URL)
|
||||
setattr(hass, _REGISTERED_ATTR, True)
|
||||
@@ -5,10 +5,14 @@
|
||||
"@overseer"
|
||||
],
|
||||
"config_flow": true,
|
||||
"documentation": "https://github.com/talon2king/NestQuest",
|
||||
"dependencies": [
|
||||
"frontend",
|
||||
"http"
|
||||
],
|
||||
"documentation": "https://github.com/Maniacs-Mansion/NestQuest",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"issue_tracker": "https://github.com/talon2king/NestQuest/issues",
|
||||
"issue_tracker": "https://github.com/Maniacs-Mansion/NestQuest/issues",
|
||||
"requirements": [],
|
||||
"version": "0.3.0"
|
||||
"version": "0.5.0"
|
||||
}
|
||||
|
||||
@@ -28,7 +28,10 @@ copying each definition's assignee across. Migration 4 adds the
|
||||
windows table, migration 5 rebuilds ``quest_instances`` onto the
|
||||
widened (definition_id, child_id, due_date, window) key, and migration
|
||||
6 rebuilds ``completion_events`` with the D-008 ``actor_child_id``
|
||||
column. Each callable migration runs inside its own transaction
|
||||
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
|
||||
together with its version stamp, so a crash mid-step rolls the
|
||||
statements and the stamp back together.
|
||||
|
||||
@@ -80,6 +83,7 @@ from .schema import (
|
||||
SCHEMA_V1_STATEMENTS,
|
||||
SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL,
|
||||
SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL,
|
||||
SCHEMA_V7_META_STATE_DDL,
|
||||
)
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
@@ -414,6 +418,11 @@ MIGRATIONS: Sequence[MigrationStep] = [
|
||||
# actor_child_id column and its actor-pair coherence CHECK,
|
||||
# backfilling legacy panel rows to their own child_id.
|
||||
_add_actor_child_id,
|
||||
# Migration 7 (Feature 11): the generic meta_state key/value table
|
||||
# — a plain new table, so CREATE IF NOT EXISTS suffices for both
|
||||
# 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),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -11,12 +11,24 @@ from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .const import (
|
||||
CONF_ADMIN_USER_IDS,
|
||||
CONF_AFTERNOON_REMINDER_ENABLED,
|
||||
CONF_AFTERNOON_REMINDER_TIME,
|
||||
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_UPDATE_INTERVAL,
|
||||
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_UPDATE_INTERVAL,
|
||||
DOMAIN,
|
||||
@@ -71,9 +83,51 @@ 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,
|
||||
):
|
||||
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[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"
|
||||
|
||||
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],
|
||||
*,
|
||||
@@ -85,7 +139,10 @@ def _build_schema(
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
fields: dict[Any, Any] = {
|
||||
vol.Required(
|
||||
@@ -100,6 +157,37 @@ def _build_schema(
|
||||
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,
|
||||
}
|
||||
if admin_choices is not None:
|
||||
# HA's supported multi-select validator: the frontend can
|
||||
@@ -233,6 +321,23 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
|
||||
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.
|
||||
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[notification_key] = user_input.get(
|
||||
notification_key, current[notification_key]
|
||||
)
|
||||
if admin_ids_present:
|
||||
data[CONF_ADMIN_USER_IDS] = admin_ids
|
||||
elif admin_default:
|
||||
@@ -254,6 +359,14 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
|
||||
(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),
|
||||
):
|
||||
if key in options:
|
||||
values[key] = options[key]
|
||||
|
||||
@@ -384,6 +384,21 @@ SCHEMA_V1_COMPLETION_EVENTS_DDL: list[str] = [
|
||||
""",
|
||||
]
|
||||
|
||||
#: Generic integration-owned key/value state (schema version 7,
|
||||
#: Feature 11): persistent bookkeeping that belongs to the integration
|
||||
#: rather than any domain table — currently the missed-sweep
|
||||
#: watermark. ``value`` is a plain string; callers interpret it.
|
||||
META_STATE_TABLE_SQL = """(
|
||||
key TEXT NOT NULL PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)"""
|
||||
|
||||
SCHEMA_V7_META_STATE_DDL: list[str] = [
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS nestquest_meta_state {META_STATE_TABLE_SQL}
|
||||
""",
|
||||
]
|
||||
|
||||
SCHEMA_V1_STATEMENTS: list[str] = [
|
||||
*SCHEMA_V1_CHILDREN_DDL,
|
||||
*SCHEMA_V1_ADMIN_USERS_DDL,
|
||||
@@ -395,6 +410,7 @@ SCHEMA_V1_STATEMENTS: list[str] = [
|
||||
*SCHEMA_V1_PRESENCE_OVERRIDES_DDL,
|
||||
*SCHEMA_V1_QUEST_INSTANCES_DDL,
|
||||
*SCHEMA_V1_COMPLETION_EVENTS_DDL,
|
||||
*SCHEMA_V7_META_STATE_DDL,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ from collections.abc import Callable, Iterable
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorStateClass,
|
||||
)
|
||||
@@ -280,7 +279,6 @@ class NestQuestCompletionPctTodaySensor(_NestQuestChildDaySensor):
|
||||
"""The child's completion percentage for today (100 on a zero-quest day)."""
|
||||
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_attr_device_class = SensorDeviceClass.PERCENTAGE
|
||||
_attr_native_unit_of_measurement = "%"
|
||||
_name_suffix = "completion pct today"
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"""The nightly missed-quest sweep (Feature 11).
|
||||
|
||||
At the configured day-rollover time (the same HA-local listener that
|
||||
re-materializes the rolling horizon) this job finds every instance
|
||||
whose ``due_date`` is before the HA-local date, that has NO completion
|
||||
event, and that falls after the persisted sweep watermark — and fires
|
||||
ONE ``nestquest_quest_missed`` event per instance with the documented
|
||||
payload. The sweep is read-only over the domain tables: it never
|
||||
mutates an instance and never writes a completion event (``missed``
|
||||
is derived state, never an event_type — Feature 08's contract).
|
||||
|
||||
Idempotency is the 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 therefore fires nothing new, and a run after days of
|
||||
downtime sweeps exactly the accumulated window once — no
|
||||
per-instance journal, no re-announcing old misses. An instance swept
|
||||
as missed that is 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.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import logging
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from .const import EVENT_QUEST_MISSED
|
||||
from .dao_children import ChildrenDao
|
||||
from .dao_instances import QuestInstancesDao
|
||||
from .dao_meta import MetaStateDao
|
||||
from .dao_rules import QuestDefinitionsDao
|
||||
from .db import NestQuestDatabase
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
#: meta_state key holding the HA-local date the sweep last ran.
|
||||
SWEEP_WATERMARK_KEY = "missed_sweep_last_run_date"
|
||||
|
||||
#: One asyncio.Lock per (connection wrapper, running loop), mirroring
|
||||
#: the migration runner's pattern: the whole watermark read → query →
|
||||
#: fire → 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.
|
||||
_SWEEP_LOCKS: dict[tuple[int, int], asyncio.Lock] = {}
|
||||
|
||||
|
||||
def _sweep_lock(database: NestQuestDatabase) -> asyncio.Lock:
|
||||
"""Return the sweep lock bound to ``database``'s running loop."""
|
||||
loop = asyncio.get_running_loop()
|
||||
key = (id(database), id(loop))
|
||||
lock = _SWEEP_LOCKS.get(key)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
_SWEEP_LOCKS[key] = lock
|
||||
return lock
|
||||
|
||||
#: UTC ISO-8601 timestamp policy shared with the DAO layers.
|
||||
_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00"
|
||||
|
||||
|
||||
def _now_stamp() -> str:
|
||||
return datetime.datetime.now(datetime.timezone.utc).strftime(
|
||||
_UTC_TIMESTAMP_FORMAT
|
||||
)
|
||||
|
||||
|
||||
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 whole watermark read → query → fire → 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.
|
||||
"""
|
||||
today_date = (
|
||||
today
|
||||
if today is not None
|
||||
else datetime.datetime.now(ZoneInfo(hass.config.time_zone)).date()
|
||||
)
|
||||
today_iso = today_date.isoformat()
|
||||
dao = MetaStateDao(database)
|
||||
async with _sweep_lock(database):
|
||||
watermark = await dao.get(SWEEP_WATERMARK_KEY)
|
||||
if watermark == today_iso:
|
||||
LOGGER.debug(
|
||||
"NestQuest missed sweep already ran for %s", today_iso
|
||||
)
|
||||
return 0
|
||||
|
||||
instances_dao = QuestInstancesDao(database)
|
||||
rows = await instances_dao.list_missed_unswept(today_iso, watermark)
|
||||
children_dao = ChildrenDao(database)
|
||||
definitions_dao = QuestDefinitionsDao(database)
|
||||
now_stamp = _now_stamp()
|
||||
fired = 0
|
||||
for instance in rows:
|
||||
child = await children_dao.get(instance.child_id)
|
||||
definition = await definitions_dao.get(instance.definition_id)
|
||||
hass.bus.async_fire(
|
||||
EVENT_QUEST_MISSED,
|
||||
{
|
||||
"child_id": instance.child_id,
|
||||
"child_name": child.display_name if child else None,
|
||||
"instance_id": instance.id,
|
||||
"quest_title": definition.title if definition else None,
|
||||
"window": instance.window,
|
||||
"due_date": instance.due_date,
|
||||
"due_time": instance.due_time,
|
||||
"occurred_at": now_stamp,
|
||||
},
|
||||
)
|
||||
fired += 1
|
||||
await dao.set(SWEEP_WATERMARK_KEY, today_iso)
|
||||
LOGGER.info(
|
||||
"NestQuest missed sweep for %s fired %d event(s)", today_iso, fired
|
||||
)
|
||||
return fired
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,76 @@
|
||||
/* ============================================================
|
||||
NestQuest — Parent admin card tokens
|
||||
Surface: phone, 390x844 reference. Plain CubeCraft styling,
|
||||
no tabletop treatment, plain domain words in the UI.
|
||||
Scope on the card host: nestquest-admin-card.
|
||||
Derived from the approved mockups (admin screens 1d-1i).
|
||||
Two densities were built; COMPACT is the default. Flip
|
||||
--nq-a-density-* to the roomy values for the alternative.
|
||||
============================================================ */
|
||||
|
||||
:host {
|
||||
/* ── Brand + neutrals (CubeCraft tokens, verbatim) ─────── */
|
||||
--nq-brand-blue: #3B3AB8;
|
||||
--nq-brand-purple: #A035CC;
|
||||
--nq-brand-gradient: linear-gradient(135deg, var(--nq-brand-blue), var(--nq-brand-purple));
|
||||
--nq-brand-gradient-h: linear-gradient(90deg, var(--nq-brand-blue), var(--nq-brand-purple));
|
||||
|
||||
--nq-a-page: #F4F4F6;
|
||||
--nq-a-surface: #FFFFFF;
|
||||
--nq-a-surface-subtle: #FAFAFA;
|
||||
--nq-a-selected: #EBEBF8; /* row being edited */
|
||||
--nq-a-border: #E4E4EA;
|
||||
--nq-a-divider: #EDEDF1;
|
||||
|
||||
--nq-a-ink: #18181D;
|
||||
--nq-a-ink-secondary: #52525E;
|
||||
--nq-a-ink-tertiary: #70707E; /* inactive rows, unselected nav icons */
|
||||
|
||||
--nq-a-success: #15803D;
|
||||
--nq-a-danger: #DC2626;
|
||||
--nq-a-danger-strong: #B91C1C;
|
||||
--nq-a-danger-bg: #FEE2E2;
|
||||
--nq-a-warning-ink: #92400E;
|
||||
--nq-a-warning-ink-2: #78350F;
|
||||
--nq-a-warning-bg: #FEF3C7;
|
||||
--nq-a-warning-border: rgba(217,119,6,.35);
|
||||
--nq-a-info-bg: #DBEAFE;
|
||||
--nq-a-info-ink: #1E3A8A;
|
||||
--nq-a-info-icon: #1D4ED8;
|
||||
--nq-a-reversal: #7D28A0; /* un-complete events in history */
|
||||
|
||||
/* ── Type ─────────────────────────────────────────────── */
|
||||
--nq-a-font: Nunito, system-ui, sans-serif;
|
||||
--nq-a-size-screen: 24px; /* 800, screen title */
|
||||
--nq-a-size-hero: 32px; /* 800, roomy "8 of 12 done" */
|
||||
--nq-a-size-card-title: 19px; /* 800, roomy child card */
|
||||
--nq-a-size-stat: 22px; /* 800, compact stat number */
|
||||
--nq-a-size-row: 13.5px;/* 700, list row title */
|
||||
--nq-a-size-meta: 11px; /* 600, row meta */
|
||||
--nq-a-size-label: 10px; /* 700, uppercase field label */
|
||||
--nq-a-track-label: .10em;
|
||||
|
||||
/* ── Density: COMPACT (default) ───────────────────────── */
|
||||
--nq-a-density-page-pad: 14px 16px 92px; /* bottom clears the 70px tab bar */
|
||||
--nq-a-density-gap: 10px;
|
||||
--nq-a-density-card-pad: 13px;
|
||||
--nq-a-density-row-pad: 11px 13px;
|
||||
--nq-a-density-radius: 8px;
|
||||
--nq-a-density-shadow: none;
|
||||
/* Density: ROOMY — page-pad 18px 20px 92px; gap 16px;
|
||||
card-pad 18px; radius 12px; shadow 0 4px 12px rgba(0,0,0,.06) */
|
||||
|
||||
/* ── Metrics ──────────────────────────────────────────── */
|
||||
--nq-a-tabbar-height: 70px; /* + 12px safe-area padding */
|
||||
--nq-a-tap-min: 44px; /* every row/control clears this */
|
||||
--nq-a-radius-pill: 9999px;
|
||||
--nq-a-radius-sheet: 20px 20px 0 0;
|
||||
--nq-a-sheet-shadow: 0 -8px 28px rgba(9,9,11,.18);
|
||||
--nq-a-progress-height: 6px; /* 8px roomy, 10px hero */
|
||||
|
||||
/* ── Motion ───────────────────────────────────────────── */
|
||||
--nq-ease-out: cubic-bezier(0, 0, .2, 1);
|
||||
--nq-dur-micro: 120ms;
|
||||
--nq-dur-base: 200ms;
|
||||
--nq-dur-modal: 350ms;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
||||
@font-face {
|
||||
font-family: "Nunito";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("./fonts/nunito-latin-400-normal.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Nunito";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url("./fonts/nunito-latin-600-normal.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Nunito";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("./fonts/nunito-latin-700-normal.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Nunito";
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
font-display: swap;
|
||||
src: url("./fonts/nunito-latin-800-normal.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Cinzel";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url("./fonts/cinzel-latin-600-normal.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Cinzel";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("./fonts/cinzel-latin-700-normal.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Cinzel";
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
font-display: swap;
|
||||
src: url("./fonts/cinzel-latin-900-normal.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Cinzel Decorative";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("./fonts/cinzel-decorative-latin-700-normal.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Cinzel Decorative";
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
font-display: swap;
|
||||
src: url("./fonts/cinzel-decorative-latin-900-normal.woff2") format("woff2");
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/* ============================================================
|
||||
NestQuest — Kids' panel card tokens
|
||||
Surface: DAKboard DAKOS wall panel, 1920x1080 landscape,
|
||||
viewed from ~3 ft, no login, read/complete only.
|
||||
Scope these on the card host: nestquest-quest-log-card,
|
||||
nestquest-party-board-card.
|
||||
Derived from the approved mockups (panel screens 1a, 1b, 1c, 1j).
|
||||
============================================================ */
|
||||
|
||||
:host {
|
||||
/* ── Parchment ground ─────────────────────────────────── */
|
||||
--nq-p-parchment-top: #f0e4c7;
|
||||
--nq-p-parchment-bottom: #ddcca3;
|
||||
--nq-p-parchment: radial-gradient(ellipse at 25% 10%, rgba(255,255,255,.5), transparent 55%),
|
||||
radial-gradient(ellipse at 85% 90%, rgba(120,86,44,.28), transparent 60%),
|
||||
repeating-linear-gradient(93deg, rgba(150,115,70,.05) 0 2px, transparent 2px 6px),
|
||||
repeating-linear-gradient(2deg, rgba(150,115,70,.04) 0 3px, transparent 3px 7px),
|
||||
linear-gradient(var(--nq-p-parchment-top), var(--nq-p-parchment-bottom));
|
||||
--nq-p-vignette: inset 0 0 200px rgba(80,52,20,.35);
|
||||
|
||||
/* Card surfaces sitting on parchment */
|
||||
--nq-p-card-open: linear-gradient(#fdf7e6, #f2e7c9); /* incomplete quest */
|
||||
--nq-p-card-done: linear-gradient(#f0e8d3, #e6dcc2); /* sealed quest */
|
||||
--nq-p-card-panel: linear-gradient(#fcf6e6, #f1e6ca); /* adventurer plate, stat block */
|
||||
--nq-p-card-border: rgba(120,88,48,.38);
|
||||
--nq-p-panel-border: rgba(92,62,26,.40);
|
||||
--nq-p-card-shadow: 0 3px 0 rgba(120,88,48,.20), inset 0 1px 0 rgba(255,255,255,.70);
|
||||
--nq-p-panel-shadow: 0 6px 0 rgba(92,62,26,.18), inset 0 2px 0 rgba(255,255,255,.70);
|
||||
--nq-p-frame-outer: 2px solid rgba(92,62,26,.45);
|
||||
--nq-p-frame-inner: 1px solid rgba(92,62,26,.28);
|
||||
--nq-p-rule: 2px solid rgba(92,62,26,.35);
|
||||
|
||||
/* ── Ink (all values >= 4.5:1 on parchment) ───────────── */
|
||||
--nq-p-ink: #2b1f14; /* titles, quest names */
|
||||
--nq-p-ink-secondary: #5c452a; /* meta, window labels, counts */
|
||||
--nq-p-ink-muted: #6f6455; /* sealed quest titles only */
|
||||
--nq-p-ink-away: #4d4433; /* away adventurer name */
|
||||
--nq-p-ink-late: #8f1526; /* overdue line */
|
||||
|
||||
/* ── Brand (CubeCraft, toned down: accents only) ──────── */
|
||||
--nq-brand-blue: #3B3AB8;
|
||||
--nq-brand-purple: #A035CC;
|
||||
--nq-brand-gradient: linear-gradient(135deg, var(--nq-brand-blue), var(--nq-brand-purple));
|
||||
--nq-p-icon-tile: var(--nq-brand-gradient); /* open quest icon tile */
|
||||
--nq-p-icon-tile-done: rgba(92,62,26,.16);
|
||||
--nq-p-crest: var(--nq-brand-gradient);
|
||||
--nq-p-crest-away: linear-gradient(135deg, #6b6b7a, #7a7286);
|
||||
|
||||
/* ── Wax seal (completed marker) ──────────────────────── */
|
||||
--nq-p-seal: radial-gradient(circle at 35% 30%, #a8283a, #6d1322);
|
||||
--nq-p-seal-shadow: 0 4px 10px rgba(60,10,20,.40), inset 0 0 0 4px rgba(255,255,255,.14);
|
||||
--nq-p-seal-size: 78px; /* 168px on the quest-complete screen */
|
||||
|
||||
/* ── Dock (bottom strip, non-interactive) ─────────────── */
|
||||
--nq-p-dock-bg: rgba(43,31,20,.90);
|
||||
--nq-p-dock-ink: #f7efdb;
|
||||
--nq-p-dock-ink-secondary: #d8c9a6;
|
||||
--nq-p-dock-divider: rgba(233,220,189,.30);
|
||||
--nq-p-dock-height: 84px;
|
||||
|
||||
/* ── Type ─────────────────────────────────────────────── */
|
||||
--nq-p-font-display: 'Cinzel Decorative', Cinzel, serif; /* NestQuest, names, Quest complete */
|
||||
--nq-p-font-heading: Cinzel, serif; /* section heads, quest titles, numerals */
|
||||
--nq-p-font-body: Nunito, system-ui, sans-serif; /* meta lines, dock */
|
||||
|
||||
--nq-p-size-hero: 86px; /* 900 weight, "Quest complete" */
|
||||
--nq-p-size-wordmark: 66px; /* 900 weight */
|
||||
--nq-p-size-title: 56px; /* 700, "Declan's Quest Log" */
|
||||
--nq-p-size-name: 48px; /* 700, adventurer name */
|
||||
--nq-p-size-section: 32px; /* 700, Morning / Afternoon / Evening */
|
||||
--nq-p-size-quest: 30px; /* 700, quest title */
|
||||
--nq-p-size-body: 22px; /* 600 Nunito, meta — PANEL MINIMUM */
|
||||
--nq-p-size-label: 21px; /* 600 Cinzel, uppercase, tracked */
|
||||
--nq-p-track-label: .14em;
|
||||
--nq-p-track-kicker: .30em;
|
||||
|
||||
/* ── Metrics: touch ───────────────────────────────────── */
|
||||
--nq-p-quest-min-height: 116px; /* whole card is the tap target */
|
||||
--nq-p-quest-gap: 24px; /* separation between tap targets */
|
||||
--nq-p-quest-pad: 22px; /* padding inside a quest card */
|
||||
--nq-p-tile-size: 64px; /* quest icon tile */
|
||||
--nq-p-button-height: 72px; /* "Complete" button on a quest card */
|
||||
--nq-p-button-min-width: 150px;
|
||||
--nq-p-confirm-button: 108px; /* confirm dialog buttons */
|
||||
--nq-p-radius-card: 12px;
|
||||
--nq-p-radius-panel: 20px;
|
||||
--nq-p-radius-pill: 9999px;
|
||||
--nq-p-page-inset: 62px;
|
||||
--nq-p-frame-inset: 26px;
|
||||
|
||||
/* ── Motion (CubeCraft easing) ────────────────────────── */
|
||||
--nq-ease-out: cubic-bezier(0, 0, .2, 1);
|
||||
--nq-dur-micro: 120ms;
|
||||
--nq-dur-base: 200ms;
|
||||
--nq-dur-modal: 350ms;
|
||||
}
|
||||
@@ -90,6 +90,7 @@ child_order: [declan, jordyn, chloe]
|
||||
# kids' panel, view 2
|
||||
type: custom:nestquest-quest-log-card
|
||||
board_path: /nestquest/board
|
||||
weather_entity: weather.home # optional; dock falls back to date/time
|
||||
idle_return_seconds: 40 # must be shorter than TouchHub Auto-Return
|
||||
confirm_timeout_seconds: 15
|
||||
complete_screen_seconds: 12
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# Panel empty & error states — design addendum
|
||||
|
||||
Closes the gap DESIGN-HANDOFF.md lists under "Open questions": *"Empty and error
|
||||
states are unspecified by request (child with no quests, everyone away, integration
|
||||
unconfigured, DB unreachable). Panel must not render a blank parchment — decide
|
||||
before shipping the card."*
|
||||
|
||||
All decisions below reuse PANEL-SPEC.md's approved aesthetic — parchment ground,
|
||||
double frame, dock, panel tokens, no text below 22px, no alpha-muted ink — and add
|
||||
one shared component per card: a **notice panel** (radius 20px, `--nq-p-card-panel`,
|
||||
2px `--nq-p-panel-border`, `--nq-p-panel-shadow`, centred icon + headline + body).
|
||||
The frame, wordmark row, kicker and dock always render; only the content area
|
||||
changes. **No state renders a blank parchment.**
|
||||
|
||||
## 1. What the data can say
|
||||
|
||||
The cards read `sensor.nestquest_<child>_quests_due_today` (plus siblings). One of
|
||||
four things comes back, and the card must tell them apart:
|
||||
|
||||
| Backend condition | What the card sees | Card's interpretation |
|
||||
|---|---|---|
|
||||
| Resolving (DB fine) | numeric state + attributes (`child_name`, `present`, `instances`) | normal, all states below |
|
||||
| Integration not configured | entity does not exist at all | **not set up** |
|
||||
| Backend unreachable (DB locked, refresh failed) | state `unavailable` | **records unreachable** |
|
||||
| Child deactivated / absent from snapshot | state `unknown` | **records unreachable** (same panel voice: the records do not answer) |
|
||||
|
||||
An away child (presence `present: false`, from the due-sensor attributes with the
|
||||
presence binary sensor as fallback) is *not* an error: it is a first-class panel
|
||||
state ("On travels"). Away children get no instances (materialiser skips them), so
|
||||
away is detected from the presence flag, never from a zero quest count.
|
||||
|
||||
## 2. Quest log — state priority
|
||||
|
||||
`nestquest-quest-log-card` resolves exactly one state, in this order:
|
||||
|
||||
1. **No adventurer in the path** (direct navigation to a URL with no child slug —
|
||||
misconfigured `board_path`/`quest_log_path`) → notice *"No adventurer chosen"*,
|
||||
body *"Open The Party and tap your crest to open your quest log."*
|
||||
2. **Not set up** (entity missing) → notice *"NestQuest is not set up yet"*, body
|
||||
*"A parent needs to finish setting up NestQuest."*
|
||||
3. **Records unreachable** (`unavailable`/`unknown`) → notice *"The records cannot
|
||||
be reached"*, body *"The party's records are quiet right now. NestQuest will
|
||||
return shortly."*
|
||||
4. **Away** (present flag false) → away notice: dashed muted panel (the away-plate
|
||||
gradient), tent icon, *"<Name> is on travels"*, *"The quest log unlocks when
|
||||
they return."* and, when `next_present` is known, *"Returns <weekday>, <Mon D>"*.
|
||||
Direct navigation is the one path that can reach this state (the board's away
|
||||
plate is not tappable); the log answers it honestly instead of showing an empty
|
||||
column set. Header renders with the crest in away colours and the sub line
|
||||
`… · On travels`; the remaining-count block is hidden (it is meaningless while
|
||||
away).
|
||||
5. **No quests today** (present, due = 0) → all-clear notice, sun icon, *"No quests
|
||||
today"*, body *"Nothing was posted for today, <Name>. Enjoy the day's rest!"* —
|
||||
never three bare empty columns. The header and remaining block stay (0 quests
|
||||
left, party progress), so the log still reads like the log.
|
||||
6. **All quests done** (due > 0, remaining = 0) → the fallback celebration that
|
||||
shows until the 1c quest-complete screen takes over: centred wax-seal mark (the
|
||||
`--nq-p-seal` tokens at 78px, check glyph, −6°) with headline *"Quest complete"*
|
||||
— the same phrase screen 1c uses — and body *"Every quest is claimed, <Name>.
|
||||
The seal is set for today. Returning to The Party shortly."* The idle return
|
||||
(§3.5) is what actually navigates back; 1c remains the designed takeover.
|
||||
7. **Normal** → the three-window column layout of PANEL-SPEC §3.1 unchanged. A
|
||||
window with no quests still renders its head alone, exactly as the spec says.
|
||||
|
||||
Notices sit in the columns' content area, vertically centred. Error notices (states
|
||||
1–3) replace the header entirely — there is no honest name to headline when the
|
||||
records do not answer — while states 4–6 keep the full header.
|
||||
|
||||
## 3. Party board — state priority
|
||||
|
||||
`nestquest-party-board-card` resolves per configured child, then at board level:
|
||||
|
||||
1. **No children configured** (`child_order` empty) → board notice *"NestQuest is
|
||||
not set up yet"*, body *"A parent needs to finish setting up NestQuest before
|
||||
the party can gather."*
|
||||
2. **Every configured child unresolvable** → one board notice instead of a grid of
|
||||
broken plates: *"NestQuest is not set up yet"* when the entities are missing,
|
||||
*"The records cannot be reached"* when they answer `unavailable`/`unknown`.
|
||||
3. **Partial** (some children resolve, some do not) → the board still renders the
|
||||
resolving plates; an unresolvable child renders a muted, dashed plate in the
|
||||
away geometry with a compass icon and the pill label **"Unknown"**, progress
|
||||
line `—`, empty bar, **not tappable**. It must not claim "Home today" (0 of 0)
|
||||
and must not lie "On travels": the records simply do not say.
|
||||
4. **Normal** → the plates grid of PANEL-SPEC §2 unchanged (present + away plates).
|
||||
|
||||
The hint line ("Tap your crest…") is hidden in board-level notice states — there
|
||||
is nothing to tap.
|
||||
|
||||
## 4. Shared rules
|
||||
|
||||
- **Never a blank parchment.** Every render path emits the frames, wordmark,
|
||||
kicker, dock, and either content or a notice panel. The dock already degrades to
|
||||
date + time when weather is unset (PANEL-SPEC §2.3); that behaviour is unchanged.
|
||||
- **Notice panel type scale:** icon 46px, headline 40px/700 Cinzel
|
||||
(`--nq-p-font-heading`, `--nq-p-ink`), body 24px/600 Nunito
|
||||
(`--nq-p-ink-secondary`), `role="status"` so assistive tech announces it.
|
||||
- **Tone.** The panel speaks tabletop (PANEL-SPEC vocabulary) but never lies: an
|
||||
error says the records cannot be reached, not that everything is fine. Error
|
||||
copy names the actor who can fix it (a parent) or promises the return of the
|
||||
data — no stack traces, no entity IDs.
|
||||
- **No interaction in error/away states.** No confirm dialog, no idle return
|
||||
changes, no navigation attempts. Notices are static.
|
||||
- Both cards implement the same resolution rules, so the board and the log never
|
||||
disagree about whether the integration is up.
|
||||
@@ -77,6 +77,8 @@
|
||||
/* ── Metrics: touch ───────────────────────────────────── */
|
||||
--nq-p-quest-min-height: 116px; /* whole card is the tap target */
|
||||
--nq-p-quest-gap: 24px; /* separation between tap targets */
|
||||
--nq-p-quest-pad: 22px; /* padding inside a quest card */
|
||||
--nq-p-tile-size: 64px; /* quest icon tile */
|
||||
--nq-p-button-height: 72px; /* "Complete" button on a quest card */
|
||||
--nq-p-button-min-width: 150px;
|
||||
--nq-p-confirm-button: 108px; /* confirm dialog buttons */
|
||||
|
||||
Generated
+1232
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "nestquest-cards",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"lit": "^3.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.1.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { LitElement, html, unsafeCSS } from "lit";
|
||||
import adminTokens from "../../../custom_components/nestquest/www/nestquest-admin-tokens.css?inline";
|
||||
|
||||
import { registerCustomCard, type CardConfig, type HassLike } from "../types";
|
||||
|
||||
export class NestQuestAdminCard extends LitElement {
|
||||
static properties = {
|
||||
hass: { attribute: false },
|
||||
_config: { state: true },
|
||||
};
|
||||
|
||||
static styles = [unsafeCSS(adminTokens)];
|
||||
|
||||
hass?: HassLike;
|
||||
_config?: CardConfig;
|
||||
|
||||
setConfig(config: CardConfig): void {
|
||||
if (!config || typeof config !== "object") {
|
||||
throw new Error("Invalid configuration");
|
||||
}
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
getCardSize(): number {
|
||||
return 8;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`<div>NestQuest Admin</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("nestquest-admin-card", NestQuestAdminCard);
|
||||
registerCustomCard({
|
||||
type: "nestquest-admin-card",
|
||||
name: "NestQuest Admin",
|
||||
description: "Parent phone view for today, tasks, schedule, and history.",
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
import "./cards/party-board";
|
||||
import "./cards/quest-log";
|
||||
import "./cards/admin";
|
||||
|
||||
const FONTS_HREF = "/nestquest-static/nestquest-fonts.css";
|
||||
if (!document.querySelector('link[data-nq-fonts=""]')) {
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = FONTS_HREF;
|
||||
link.dataset.nqFonts = "";
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export type HassLike = Record<string, unknown>;
|
||||
|
||||
export type CardConfig = Record<string, unknown>;
|
||||
|
||||
export type CustomCardInfo = {
|
||||
type: string;
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
customCards?: CustomCardInfo[];
|
||||
}
|
||||
}
|
||||
|
||||
export function registerCustomCard(info: CustomCardInfo): void {
|
||||
window.customCards = window.customCards ?? [];
|
||||
window.customCards.push(info);
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
outDir: "../custom_components/nestquest/www",
|
||||
emptyOutDir: false,
|
||||
sourcemap: false,
|
||||
lib: {
|
||||
entry: "src/nestquest-cards.ts",
|
||||
formats: ["es"],
|
||||
fileName: () => "nestquest-cards.js",
|
||||
},
|
||||
rollupOptions: {
|
||||
output: {
|
||||
inlineDynamicImports: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "nestquest"
|
||||
version = "0.3.0"
|
||||
version = "0.5.0"
|
||||
description = "NestQuest Home Assistant Integration"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -8,8 +8,10 @@ dependencies = []
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"jinja2>=3",
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.24",
|
||||
"pyyaml>=6",
|
||||
"voluptuous>=0.14",
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Shared harness for the NestQuest automation-blueprint tests.
|
||||
|
||||
The blueprints are YAML data consumed by Home Assistant's blueprint
|
||||
engine; the integration itself never imports yaml or jinja2. The
|
||||
blueprint tests therefore exercise them exactly the way HA would:
|
||||
parse the YAML (with a loader that understands ``!input`` tags), then
|
||||
render the message/condition templates with a plain jinja2 environment
|
||||
over a fixture state set shaped like HA's template state objects.
|
||||
|
||||
The harness is mock-only (see conftest.py), so the fixtures model just
|
||||
the surface the templates touch: ``states.binary_sensor`` /
|
||||
``states.sensor`` lists of objects carrying ``entity_id``, ``state``
|
||||
and ``attributes`` — plus, for the celebration blueprint, a callable
|
||||
``states(entity_id)`` lookup over ``input_datetime`` helpers. Jinja's
|
||||
``search``/``match`` selectattr tests are Home Assistant additions,
|
||||
registered here explicitly.
|
||||
|
||||
This module is plain helper code (deliberately not named ``test_*`` so
|
||||
pytest never collects it); every blueprint test file imports from it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import jinja2
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
BLUEPRINT_DIR = (
|
||||
REPO_ROOT / "custom_components" / "nestquest" / "blueprints" / "automation"
|
||||
)
|
||||
|
||||
|
||||
class BlueprintInput(str):
|
||||
"""Marker for a parsed blueprint ``!input <name>`` reference."""
|
||||
|
||||
|
||||
def _input_constructor(loader: yaml.SafeLoader, node: yaml.Node) -> BlueprintInput:
|
||||
return BlueprintInput(loader.construct_scalar(node))
|
||||
|
||||
|
||||
class _BlueprintLoader(yaml.SafeLoader):
|
||||
"""SafeLoader extended with Home Assistant's ``!input`` blueprint tag."""
|
||||
|
||||
|
||||
_BlueprintLoader.add_constructor("!input", _input_constructor)
|
||||
|
||||
|
||||
def load_blueprints() -> dict[str, dict]:
|
||||
"""Parse every automation blueprint, asserting the directory exists."""
|
||||
assert BLUEPRINT_DIR.is_dir(), f"missing blueprint directory {BLUEPRINT_DIR}"
|
||||
paths = sorted(BLUEPRINT_DIR.glob("*.yaml"))
|
||||
assert paths, f"no blueprints under {BLUEPRINT_DIR}"
|
||||
return {
|
||||
path.stem: yaml.load(path.read_text(encoding="utf-8"), Loader=_BlueprintLoader)
|
||||
for path in paths
|
||||
}
|
||||
|
||||
|
||||
def blueprint(name: str) -> dict:
|
||||
"""Return one parsed blueprint by its file stem."""
|
||||
blueprints = load_blueprints()
|
||||
assert name in blueprints, f"{name}.yaml is missing"
|
||||
return blueprints[name]
|
||||
|
||||
|
||||
def morning_summary() -> dict:
|
||||
"""Return the parsed morning-summary blueprint."""
|
||||
return blueprint("morning_summary")
|
||||
|
||||
|
||||
def single_action(bp: dict) -> dict:
|
||||
"""Return the single action of a one-action blueprint."""
|
||||
actions = bp["actions"]
|
||||
assert isinstance(actions, list) and len(actions) == 1
|
||||
return actions[0]
|
||||
|
||||
|
||||
def morning_action(bp: dict) -> dict:
|
||||
"""Return the single notify action of the morning summary."""
|
||||
return single_action(bp)
|
||||
|
||||
|
||||
def notify_data(bp: dict, index: int = 0) -> dict:
|
||||
"""Return a notify action's service ``data`` (title/message live
|
||||
there in the modern HA action schema, not at the action's top level)."""
|
||||
action = bp["actions"][index]
|
||||
assert isinstance(action.get("data"), dict), (
|
||||
"notify payload fields must nest under data:"
|
||||
)
|
||||
return action["data"]
|
||||
|
||||
|
||||
def iter_strings(node: object):
|
||||
"""Yield every string value in a parsed YAML structure, recursively."""
|
||||
if isinstance(node, str):
|
||||
yield node
|
||||
elif isinstance(node, dict):
|
||||
for value in node.values():
|
||||
yield from iter_strings(value)
|
||||
elif isinstance(node, list):
|
||||
for value in node:
|
||||
yield from iter_strings(value)
|
||||
|
||||
|
||||
class TemplateState:
|
||||
"""Stand-in for an HA template state object: entity_id/state/attributes."""
|
||||
|
||||
def __init__(self, entity_id: str, state: str, attributes: dict | None = None):
|
||||
self.entity_id = entity_id
|
||||
self.state = state
|
||||
self.attributes = dict(attributes or {})
|
||||
|
||||
|
||||
class StatesFixture:
|
||||
"""HA's template ``states`` object over a fixture entity set.
|
||||
|
||||
Like HA's ``AllStates``, it exposes per-domain lists (``.sensor``,
|
||||
``.binary_sensor``, ``.input_datetime``, ``.input_text``) AND is
|
||||
callable as ``states(entity_id)`` returning the entity's state
|
||||
string — or ``None`` when the fixture holds no such entity, the
|
||||
way HA returns ``None`` for a nonexistent entity id.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, binary_sensors=(), sensors=(), input_datetimes=(), input_texts=()
|
||||
):
|
||||
self.binary_sensor = list(binary_sensors)
|
||||
self.sensor = list(sensors)
|
||||
self.input_datetime = list(input_datetimes)
|
||||
self.input_text = list(input_texts)
|
||||
self._all = (
|
||||
self.binary_sensor
|
||||
+ self.sensor
|
||||
+ self.input_datetime
|
||||
+ self.input_text
|
||||
)
|
||||
|
||||
def __call__(self, entity_id: str):
|
||||
"""Return the entity's state string, or None when absent."""
|
||||
for entity in self._all:
|
||||
if entity.entity_id == entity_id:
|
||||
return entity.state
|
||||
return None
|
||||
|
||||
|
||||
def make_states(
|
||||
binary_sensors=(), sensors=(), input_datetimes=(), input_texts=()
|
||||
) -> StatesFixture:
|
||||
"""Build the ``states`` fixture: per-domain lists of TemplateState."""
|
||||
return StatesFixture(
|
||||
binary_sensors, sensors, input_datetimes, input_texts
|
||||
)
|
||||
|
||||
|
||||
def make_template_environment() -> jinja2.Environment:
|
||||
"""A plain jinja2 environment with HA's regex tests and bool filter."""
|
||||
env = jinja2.Environment()
|
||||
env.tests["match"] = lambda value, pattern: re.match(pattern, value) is not None
|
||||
env.tests["search"] = lambda value, pattern: re.search(pattern, value) is not None
|
||||
|
||||
def _bool(value, default=False):
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in ("true", "on", "open", "yes", "1")
|
||||
return bool(value) or default
|
||||
|
||||
env.filters["bool"] = _bool
|
||||
return env
|
||||
|
||||
|
||||
def render(template: str, **variables) -> str:
|
||||
"""Render a template the way HA would substitute its variables."""
|
||||
return make_template_environment().from_string(template).render(**variables)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entity-reference discipline (Feature 10 shapes only). The scanners
|
||||
# are shared so every blueprint test file can assert the same rules;
|
||||
# the directory-wide tests in test_blueprints.py apply them to every
|
||||
# blueprint file automatically.
|
||||
# ---------------------------------------------------------------------------
|
||||
#: Entity shapes Feature 10 actually creates (sensor.py, binary_sensor.py).
|
||||
ALLOWED_ENTITY_SHAPES: tuple[re.Pattern, ...] = tuple(
|
||||
re.compile(pattern)
|
||||
for pattern in (
|
||||
r"sensor\.nestquest_[a-z0-9_]+_quests_due_today",
|
||||
r"sensor\.nestquest_[a-z0-9_]+_quests_remaining_today",
|
||||
r"sensor\.nestquest_[a-z0-9_]+_quests_completed_today",
|
||||
r"sensor\.nestquest_[a-z0-9_]+_completion_pct_today",
|
||||
r"sensor\.nestquest_[a-z0-9_]+_next_quest",
|
||||
r"binary_sensor\.nestquest_[a-z0-9_]+_all_done",
|
||||
r"binary_sensor\.nestquest_[a-z0-9_]+_present_today",
|
||||
r"sensor\.nestquest_household_quests_due_today",
|
||||
r"sensor\.nestquest_household_quests_completed_today",
|
||||
r"sensor\.nestquest_cycle_day",
|
||||
)
|
||||
)
|
||||
|
||||
#: An unescaped, id-like entity reference: any found must be a full
|
||||
#: match for one of the Feature 10 shapes above.
|
||||
_ENTITY_LITERAL_RE = re.compile(r"(?<![\\\w])(?:sensor|binary_sensor)\.[A-Za-z0-9_]+")
|
||||
|
||||
#: A domain-dot mention, ignoring the template's escaped-dot regexes
|
||||
#: (one backslash is stripped so ``binary_sensor\\.`` counts too).
|
||||
_MENTION_RE = re.compile(r"(?:sensor|binary_sensor)\.([A-Za-z0-9_]+)")
|
||||
@@ -51,6 +51,8 @@ _HA_MODULES = (
|
||||
"homeassistant.components",
|
||||
"homeassistant.components.sensor",
|
||||
"homeassistant.components.binary_sensor",
|
||||
"homeassistant.components.http",
|
||||
"homeassistant.components.frontend",
|
||||
)
|
||||
|
||||
def _find_spec(name: str):
|
||||
@@ -96,6 +98,8 @@ for _parent, _child in (
|
||||
("homeassistant", "components"),
|
||||
("homeassistant.components", "sensor"),
|
||||
("homeassistant.components", "binary_sensor"),
|
||||
("homeassistant.components", "http"),
|
||||
("homeassistant.components", "frontend"),
|
||||
):
|
||||
setattr(sys.modules[_parent], _child, sys.modules[f"{_parent}.{_child}"])
|
||||
|
||||
@@ -429,6 +433,28 @@ _sensor_mock.SensorStateClass = SensorStateClass
|
||||
_binary_sensor_mock = _ha_mock("homeassistant.components.binary_sensor")
|
||||
_binary_sensor_mock.BinarySensorEntity = BinarySensorEntity
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StaticPathConfig:
|
||||
"""Stand-in mirroring homeassistant.components.http.StaticPathConfig."""
|
||||
|
||||
url_path: str
|
||||
path: str
|
||||
cache_headers: bool = True
|
||||
|
||||
|
||||
def _add_extra_js_url(hass, url, es5=False):
|
||||
"""Stand-in mirroring homeassistant.components.frontend.add_extra_js_url."""
|
||||
urls = getattr(hass, "extra_js_urls", None)
|
||||
if urls is None:
|
||||
hass.extra_js_urls = []
|
||||
urls = hass.extra_js_urls
|
||||
urls.append(url)
|
||||
|
||||
|
||||
_ha_mock("homeassistant.components.http").StaticPathConfig = StaticPathConfig
|
||||
_ha_mock("homeassistant.components.frontend").add_extra_js_url = _add_extra_js_url
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
|
||||
@@ -549,6 +575,16 @@ def _async_track_time_change(hass, action, hour=None, minute=None, second=None):
|
||||
_ha_mock("homeassistant.helpers.event").async_track_time_change = _async_track_time_change
|
||||
|
||||
|
||||
class HttpRegistry:
|
||||
"""Models HA's ``hass.http`` static-path registration."""
|
||||
|
||||
def __init__(self):
|
||||
self.static_paths: list[StaticPathConfig] = []
|
||||
|
||||
async def async_register_static_paths(self, configs):
|
||||
self.static_paths.extend(list(configs))
|
||||
|
||||
|
||||
class EventBus:
|
||||
"""Models HA's ``hass.bus`` event bus.
|
||||
|
||||
@@ -697,6 +733,8 @@ def make_hass() -> tuple:
|
||||
hass.time_change = TimeChangeRegistry(hass)
|
||||
hass.services = ServiceRegistry(hass)
|
||||
hass.bus = EventBus()
|
||||
hass.http = HttpRegistry()
|
||||
hass.extra_js_urls: list[str] = []
|
||||
# A valid IANA time zone (the day-rollover listener reads it to
|
||||
# compute "today" in HA local time).
|
||||
hass.config.time_zone = "UTC"
|
||||
|
||||
@@ -6,7 +6,7 @@ import datetime
|
||||
from conftest import wire_entry_to_registry
|
||||
|
||||
from custom_components.nestquest import async_setup_entry
|
||||
from custom_components.nestquest.children import create_child
|
||||
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
|
||||
@@ -128,7 +128,57 @@ async def test_binary_sensor_identity_and_attributes(hass, make_entry) -> None:
|
||||
assert present.name == "NestQuest Ada present today"
|
||||
for entity in (all_done, present):
|
||||
assert entity.device_info.name == "NestQuest Ada"
|
||||
assert entity.extra_state_attributes == {
|
||||
assert all_done.extra_state_attributes == {
|
||||
"child_id": ada.id,
|
||||
"child_name": "Ada",
|
||||
}
|
||||
assert present.extra_state_attributes == {
|
||||
"child_id": ada.id,
|
||||
"child_name": "Ada",
|
||||
"next_present": None,
|
||||
}
|
||||
|
||||
|
||||
async def test_present_today_carries_next_present_when_away(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""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()
|
||||
)
|
||||
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[
|
||||
"next_present"
|
||||
] == (today + datetime.timedelta(days=1)).isoformat()
|
||||
|
||||
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[
|
||||
"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")
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
"""Tests for the NestQuest afternoon-reminder automation blueprint.
|
||||
|
||||
The afternoon reminder nudges only the stragglers: a child appears
|
||||
exactly when they are PRESENT today and still have open quests.
|
||||
Present-and-clear children, absent children, and the household rollup
|
||||
sensors are all omitted, and when nobody qualifies the automation
|
||||
sends nothing at all — the all-clear template condition stops the run
|
||||
before the notification. The harness (YAML ``!input`` loader, state
|
||||
fixtures, jinja rendering) is shared in
|
||||
:mod:`tests.blueprint_helpers`; the directory-wide shape and
|
||||
entity-discipline tests in test_blueprints.py cover this file too.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from custom_components.nestquest.const import DEFAULT_AFTERNOON_REMINDER_TIME
|
||||
|
||||
from tests.blueprint_helpers import (
|
||||
notify_data,
|
||||
BlueprintInput,
|
||||
blueprint,
|
||||
iter_strings,
|
||||
make_states,
|
||||
render,
|
||||
single_action,
|
||||
TemplateState,
|
||||
)
|
||||
|
||||
AFTERNOON_TITLE = "NestQuest afternoon reminder"
|
||||
|
||||
|
||||
def afternoon_reminder() -> dict:
|
||||
"""Return the parsed afternoon-reminder blueprint."""
|
||||
return blueprint("afternoon_reminder")
|
||||
|
||||
|
||||
def afternoon_conditions(bp: dict) -> list[dict]:
|
||||
"""Return the blueprint's conditions: toggle first, guard second."""
|
||||
conditions = bp["conditions"]
|
||||
assert isinstance(conditions, list) and len(conditions) == 2
|
||||
return conditions
|
||||
|
||||
|
||||
def all_clear_guard(bp: dict) -> str:
|
||||
"""Return the template that is false when every present child is done."""
|
||||
return afternoon_conditions(bp)[1]["value_template"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inputs, defaults, and wiring.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_afternoon_reminder_declares_inputs_with_selectors() -> None:
|
||||
"""The three inputs exist, carry selectors, and default sanely."""
|
||||
inputs = afternoon_reminder()["blueprint"]["input"]
|
||||
expected = {"afternoon_time", "notify_target", "send_reminder"}
|
||||
assert set(inputs) == expected
|
||||
for name, spec in inputs.items():
|
||||
assert isinstance(spec["selector"], dict) and spec["selector"], name
|
||||
assert inputs["afternoon_time"]["default"] == DEFAULT_AFTERNOON_REMINDER_TIME
|
||||
assert DEFAULT_AFTERNOON_REMINDER_TIME == "15:00"
|
||||
assert inputs["send_reminder"]["default"] is True
|
||||
assert "time" in inputs["afternoon_time"]["selector"]
|
||||
assert "text" in inputs["notify_target"]["selector"]
|
||||
assert "boolean" in inputs["send_reminder"]["selector"]
|
||||
|
||||
|
||||
def test_afternoon_reminder_wires_inputs_not_literals() -> None:
|
||||
"""Time trigger, toggle and guard conditions, and notify action use !input."""
|
||||
bp = afternoon_reminder()
|
||||
assert bp["variables"]["send_reminder"] == BlueprintInput("send_reminder")
|
||||
|
||||
trigger = bp["triggers"][0]
|
||||
assert trigger["trigger"] == "time"
|
||||
assert trigger["at"] == BlueprintInput("afternoon_time")
|
||||
|
||||
toggle, guard = afternoon_conditions(bp)
|
||||
assert toggle["condition"] == "template"
|
||||
assert "send_reminder" in toggle["value_template"]
|
||||
assert guard["condition"] == "template"
|
||||
|
||||
action = single_action(bp)
|
||||
assert action["action"] == BlueprintInput("notify_target")
|
||||
data = notify_data(afternoon_reminder())
|
||||
assert data["title"] == AFTERNOON_TITLE
|
||||
assert isinstance(data["message"], str) and data["message"].strip()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rendering: only present children with open quests are listed.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_afternoon_reminder_lists_only_present_children_with_open_quests() -> None:
|
||||
"""Present+open Alice is listed; present+clear Carol, absent Bob,
|
||||
and the household rollup are all omitted."""
|
||||
states = make_states(
|
||||
sensors=[
|
||||
TemplateState(
|
||||
"sensor.nestquest_alice_quests_due_today",
|
||||
"3",
|
||||
{"child_name": "Alice", "child_id": 1, "present": True},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_alice_quests_remaining_today",
|
||||
"2",
|
||||
{"child_id": 1},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_carol_quests_due_today",
|
||||
"2",
|
||||
{"child_name": "Carol", "child_id": 2, "present": True},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_carol_quests_remaining_today",
|
||||
"0",
|
||||
{"child_id": 2},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_bob_quests_due_today",
|
||||
"2",
|
||||
{"child_name": "Bob", "child_id": 3, "present": False},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_bob_quests_remaining_today",
|
||||
"1",
|
||||
{"child_id": 3},
|
||||
),
|
||||
TemplateState("sensor.nestquest_household_quests_due_today", "7"),
|
||||
],
|
||||
)
|
||||
rendered = render(notify_data(afternoon_reminder())["message"], states=states)
|
||||
assert rendered == "Alice still has 2 quest(s) open."
|
||||
assert "Carol" not in rendered
|
||||
assert "Bob" not in rendered
|
||||
assert "household" not in rendered
|
||||
|
||||
|
||||
def test_afternoon_reminder_all_clear_guard_renders_false() -> None:
|
||||
"""With every present child done (and absent children owing), the
|
||||
guard condition is false: the automation sends nothing at all."""
|
||||
states = make_states(
|
||||
sensors=[
|
||||
TemplateState(
|
||||
"sensor.nestquest_alice_quests_due_today",
|
||||
"2",
|
||||
{"child_name": "Alice", "child_id": 1, "present": True},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_alice_quests_remaining_today",
|
||||
"0",
|
||||
{"child_id": 1},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_bob_quests_due_today",
|
||||
"2",
|
||||
{"child_name": "Bob", "child_id": 2, "present": False},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_bob_quests_remaining_today",
|
||||
"1",
|
||||
{"child_id": 2},
|
||||
),
|
||||
TemplateState("sensor.nestquest_household_quests_due_today", "2"),
|
||||
],
|
||||
)
|
||||
guard = all_clear_guard(afternoon_reminder())
|
||||
assert render(guard, states=states).strip() == "False"
|
||||
|
||||
|
||||
def test_afternoon_reminder_guard_renders_true_when_a_present_child_owes() -> None:
|
||||
"""A present child with open quests makes the guard true."""
|
||||
states = make_states(
|
||||
sensors=[
|
||||
TemplateState(
|
||||
"sensor.nestquest_alice_quests_due_today",
|
||||
"3",
|
||||
{"child_name": "Alice", "child_id": 1, "present": True},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_alice_quests_remaining_today",
|
||||
"1",
|
||||
{"child_id": 1},
|
||||
),
|
||||
],
|
||||
)
|
||||
guard = all_clear_guard(afternoon_reminder())
|
||||
assert render(guard, states=states).strip() == "True"
|
||||
|
||||
|
||||
def test_afternoon_reminder_send_reminder_toggle_renders() -> None:
|
||||
"""The enable condition renders truthy/false from the input value."""
|
||||
toggle = afternoon_conditions(afternoon_reminder())[0]
|
||||
assert render(toggle["value_template"], send_reminder=True).strip() == "True"
|
||||
assert render(toggle["value_template"], send_reminder=False).strip() == "False"
|
||||
|
||||
|
||||
def test_afternoon_reminder_collision_suffixed_duplicate_names() -> None:
|
||||
"""Two children sharing a display name (HA registry suffix _2 on the
|
||||
second one's entity ids) both appear, each with their own count."""
|
||||
states = make_states(
|
||||
sensors=[
|
||||
TemplateState(
|
||||
"sensor.nestquest_ada_quests_due_today",
|
||||
"2",
|
||||
{"child_name": "Ada", "child_id": 1, "present": True},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_ada_quests_remaining_today",
|
||||
"1",
|
||||
{"child_id": 1},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_ada_quests_due_today_2",
|
||||
"3",
|
||||
{"child_name": "Ada", "child_id": 2, "present": True},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_ada_quests_remaining_today_2",
|
||||
"3",
|
||||
{"child_id": 2},
|
||||
),
|
||||
],
|
||||
)
|
||||
rendered = render(notify_data(afternoon_reminder())["message"], states=states)
|
||||
assert rendered.split("\n") == [
|
||||
"Ada still has 1 quest(s) open.",
|
||||
"Ada still has 3 quest(s) open.",
|
||||
]
|
||||
|
||||
|
||||
def test_afternoon_reminder_never_hard_codes_notify_target() -> None:
|
||||
"""No notify service literal outside the input's placeholder text."""
|
||||
bp = afternoon_reminder()
|
||||
spec = bp["blueprint"]
|
||||
assert not re.search(r"notify\.[a-z_]+", spec["description"])
|
||||
|
||||
outside_input = {k: v for k, v in bp.items() if k != "blueprint"}
|
||||
for value in iter_strings(outside_input):
|
||||
assert not re.search(r"notify\.[a-z_.]+", value), value
|
||||
|
||||
target_description = spec["input"]["notify_target"]["description"]
|
||||
assert re.search(r"notify\.[a-z_]+", target_description), (
|
||||
"the input description should show a placeholder example"
|
||||
)
|
||||
assert single_action(bp)["action"] == BlueprintInput("notify_target")
|
||||
|
||||
def test_afternoon_reminder_absent_child_regression() -> None:
|
||||
"""The Feature 11 guardrail, stated as one regression test: no
|
||||
reminder message is ever produced for an absent child."""
|
||||
states = make_states(
|
||||
sensors=[
|
||||
TemplateState(
|
||||
"sensor.nestquest_bob_quests_due_today",
|
||||
"3",
|
||||
{"child_name": "Bob", "child_id": 1, "present": False},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_bob_quests_remaining_today",
|
||||
"3",
|
||||
{"child_id": 1},
|
||||
),
|
||||
],
|
||||
)
|
||||
guard = afternoon_conditions(afternoon_reminder())[-1]
|
||||
assert render(guard["value_template"], states=states).strip() == "False"
|
||||
message = render(
|
||||
notify_data(afternoon_reminder())["message"], states=states
|
||||
)
|
||||
assert "Bob" not in message
|
||||
@@ -0,0 +1,313 @@
|
||||
"""Tests for the NestQuest day-complete celebration automation blueprint.
|
||||
|
||||
The celebration fires on the ``nestquest_child_day_complete`` event —
|
||||
which NestQuest only fires when quests were owed and all are complete,
|
||||
so a zero-quest day can never celebrate. Dedupe design (per-child,
|
||||
per-day): the household points ONE ``input_text`` helper at the
|
||||
blueprint; the condition celebrates only when THIS child has no
|
||||
``<child_id>:<today>`` entry stored, and the action rewrites the
|
||||
helper with only today's entries plus the new one — so a child who
|
||||
un-completes and re-completes the same day is celebrated once, not
|
||||
twice, while a second child clearing the same day is still celebrated.
|
||||
|
||||
The harness (YAML ``!input`` loader, state fixtures, jinja rendering)
|
||||
is shared in :mod:`tests.blueprint_helpers`; the directory-wide shape
|
||||
and entity-discipline tests in test_blueprints.py cover this file
|
||||
too. ``now()`` in the dedupe condition is injected by the test as a
|
||||
fixed callable, keeping the render deterministic.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import re
|
||||
|
||||
from custom_components.nestquest.const import EVENT_CHILD_DAY_COMPLETE
|
||||
|
||||
from tests.blueprint_helpers import (
|
||||
BlueprintInput,
|
||||
notify_data,
|
||||
blueprint,
|
||||
iter_strings,
|
||||
make_states,
|
||||
render,
|
||||
TemplateState,
|
||||
)
|
||||
|
||||
CELEBRATION_TITLE = "NestQuest day complete"
|
||||
|
||||
HELPER_ENTITY = "input_text.nestquest_last_celebrated"
|
||||
|
||||
#: The fixed "now" every dedupe render uses: 2026-09-19 15:00 local.
|
||||
FIXED_NOW = datetime.datetime(2026, 9, 19, 15, 0, 0)
|
||||
|
||||
|
||||
def celebration() -> dict:
|
||||
"""Return the parsed day-complete-celebration blueprint."""
|
||||
return blueprint("day_complete_celebration")
|
||||
|
||||
|
||||
def celebration_conditions(bp: dict) -> list[dict]:
|
||||
"""Return the blueprint's conditions: toggle first, dedupe second."""
|
||||
conditions = bp["conditions"]
|
||||
assert isinstance(conditions, list) and len(conditions) == 2
|
||||
return conditions
|
||||
|
||||
|
||||
def dedupe_condition(bp: dict) -> str:
|
||||
"""Return the once-per-child-per-day dedupe template (true = celebrate)."""
|
||||
return celebration_conditions(bp)[1]["value_template"]
|
||||
|
||||
|
||||
def stamp_value_template(bp: dict) -> str:
|
||||
"""Return the helper-stamp action's value template."""
|
||||
stamp = bp["actions"][1]
|
||||
return stamp["data"]["value"]
|
||||
|
||||
|
||||
def trigger_payload(child_id: int, child_name: str) -> dict:
|
||||
"""A nestquest_child_day_complete trigger fixture: HA exposes an
|
||||
event trigger's payload as trigger.event.data."""
|
||||
return {
|
||||
"event": {
|
||||
"data": {
|
||||
"child_id": child_id,
|
||||
"child_name": child_name,
|
||||
"quests_due": 3,
|
||||
"quests_completed": 3,
|
||||
"occurred_at": "2026-09-19T15:00:00+00:00",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def render_dedupe(
|
||||
helper_state: str | None, child_id: int = 1
|
||||
) -> str:
|
||||
"""Render the dedupe condition with the helper holding helper_state.
|
||||
|
||||
``None`` models a helper entity the fixture does not hold (never
|
||||
created or not yet set), which HA exposes as a missing state.
|
||||
"""
|
||||
states = make_states(
|
||||
input_texts=(
|
||||
[]
|
||||
if helper_state is None
|
||||
else [TemplateState(HELPER_ENTITY, helper_state)]
|
||||
)
|
||||
)
|
||||
return render(
|
||||
dedupe_condition(celebration()),
|
||||
states=states,
|
||||
last_celebrated_helper=HELPER_ENTITY,
|
||||
now=lambda: FIXED_NOW,
|
||||
trigger=trigger_payload(child_id, "Ada"),
|
||||
).strip()
|
||||
|
||||
|
||||
def render_stamp(helper_state: str | None, child_id: int = 1) -> str:
|
||||
"""Render the stamp action's value with the helper holding helper_state."""
|
||||
states = make_states(
|
||||
input_texts=(
|
||||
[]
|
||||
if helper_state is None
|
||||
else [TemplateState(HELPER_ENTITY, helper_state)]
|
||||
)
|
||||
)
|
||||
return render(
|
||||
stamp_value_template(celebration()),
|
||||
states=states,
|
||||
last_celebrated_helper=HELPER_ENTITY,
|
||||
now=lambda: FIXED_NOW,
|
||||
trigger=trigger_payload(child_id, "Ada"),
|
||||
).strip()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Trigger, inputs, defaults, and wiring.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_celebration_triggers_on_the_child_day_complete_event() -> None:
|
||||
"""The event trigger references the real nestquest_child_day_complete."""
|
||||
trigger = celebration()["triggers"][0]
|
||||
assert trigger["trigger"] == "event"
|
||||
assert trigger["event_type"] == "nestquest_child_day_complete"
|
||||
assert trigger["event_type"] == EVENT_CHILD_DAY_COMPLETE
|
||||
|
||||
|
||||
def test_celebration_declares_inputs_with_selectors() -> None:
|
||||
"""The four inputs exist, carry selectors, and default sanely."""
|
||||
bp = celebration()
|
||||
inputs = bp["blueprint"]["input"]
|
||||
expected = {
|
||||
"notify_target",
|
||||
"celebration_message",
|
||||
"celebrate",
|
||||
"last_celebrated_helper",
|
||||
}
|
||||
assert set(inputs) == expected
|
||||
for name, spec in inputs.items():
|
||||
assert isinstance(spec["selector"], dict) and spec["selector"], name
|
||||
assert inputs["celebrate"]["default"] is True
|
||||
assert "text" in inputs["notify_target"]["selector"]
|
||||
assert "text" in inputs["celebration_message"]["selector"]
|
||||
assert "boolean" in inputs["celebrate"]["selector"]
|
||||
assert inputs["last_celebrated_helper"]["selector"]["entity"]["filter"] == [
|
||||
{"domain": "input_text"}
|
||||
]
|
||||
# The description documents the zero-quest-day rule and the dedupe.
|
||||
description = bp["blueprint"]["description"]
|
||||
assert "zero-quest day" in description
|
||||
assert "once" in description.lower()
|
||||
|
||||
|
||||
def test_celebration_wires_inputs_not_literals() -> None:
|
||||
"""Notify and stamp actions both use !input; the dedupe reads the helper."""
|
||||
bp = celebration()
|
||||
assert bp["variables"]["celebrate"] == BlueprintInput("celebrate")
|
||||
assert bp["variables"]["last_celebrated_helper"] == BlueprintInput(
|
||||
"last_celebrated_helper"
|
||||
)
|
||||
|
||||
toggle, dedupe = celebration_conditions(bp)
|
||||
assert toggle["condition"] == "template"
|
||||
assert "celebrate" in toggle["value_template"]
|
||||
assert dedupe["condition"] == "template"
|
||||
assert "last_celebrated_helper" in dedupe["value_template"]
|
||||
assert "child_id" in dedupe["value_template"], (
|
||||
"the dedupe must be per child, not per household"
|
||||
)
|
||||
|
||||
notify_action, stamp_action = bp["actions"]
|
||||
assert notify_action["action"] == BlueprintInput("notify_target")
|
||||
data = notify_data(bp)
|
||||
assert data["title"] == CELEBRATION_TITLE
|
||||
assert data["message"] == BlueprintInput("celebration_message")
|
||||
|
||||
assert stamp_action["action"] == "input_text.set_value"
|
||||
assert stamp_action["target"]["entity_id"] == BlueprintInput(
|
||||
"last_celebrated_helper"
|
||||
)
|
||||
assert "now()" in stamp_action["data"]["value"]
|
||||
|
||||
|
||||
def test_celebration_message_default_renders_the_child_name() -> None:
|
||||
"""The default message renders the trigger payload's child_name."""
|
||||
default_message = celebration()["blueprint"]["input"]["celebration_message"][
|
||||
"default"
|
||||
]
|
||||
rendered = render(default_message, trigger=trigger_payload(1, "Ada"))
|
||||
assert rendered == "Legendary! Ada cleared every quest today!"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The once-per-child-per-day dedupe condition.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_celebration_dedupe_true_when_child_not_celebrated_today() -> None:
|
||||
"""A helper without THIS child's entry for today lets the
|
||||
celebration through — including when another child was celebrated
|
||||
earlier the same day."""
|
||||
assert render_dedupe(None) == "True"
|
||||
assert render_dedupe("") == "True"
|
||||
assert render_dedupe("2:2026-09-19") == "True", (
|
||||
"a second child clearing the same day must still celebrate"
|
||||
)
|
||||
assert render_dedupe("1:2026-09-18") == "True", (
|
||||
"yesterday's entry never blocks today"
|
||||
)
|
||||
|
||||
|
||||
def test_celebration_dedupe_false_when_child_already_celebrated_today() -> None:
|
||||
"""A child already celebrated today is suppressed — the un-complete
|
||||
and re-complete the same day case."""
|
||||
assert render_dedupe("1:2026-09-19") == "False"
|
||||
assert render_dedupe("2:2026-09-19,1:2026-09-19") == "False"
|
||||
|
||||
|
||||
def test_celebration_stamp_prunes_to_today_and_appends() -> None:
|
||||
"""The stamp keeps only today's entries plus the new one: yesterday's
|
||||
entries are dropped (the helper never outgrows its text limit and
|
||||
stale entries cannot block next week), and the celebrated child is
|
||||
recorded."""
|
||||
assert render_stamp(None, child_id=1) == "1:2026-09-19"
|
||||
assert render_stamp("1:2026-09-18", child_id=2) == "2:2026-09-19"
|
||||
assert render_stamp("1:2026-09-19", child_id=2) == (
|
||||
"1:2026-09-19,2:2026-09-19"
|
||||
)
|
||||
assert render_stamp("1:2026-09-18,2:2026-09-18", child_id=3) == (
|
||||
"3:2026-09-19"
|
||||
)
|
||||
|
||||
|
||||
def test_celebration_celebrate_toggle_renders() -> None:
|
||||
"""The enable condition renders truthy/false from the input value."""
|
||||
toggle = celebration_conditions(celebration())[0]
|
||||
assert render(toggle["value_template"], celebrate=True).strip() == "True"
|
||||
assert render(toggle["value_template"], celebrate=False).strip() == "False"
|
||||
|
||||
|
||||
def test_celebration_never_hard_codes_notify_target() -> None:
|
||||
"""No notify service literal outside the input's placeholder text."""
|
||||
bp = celebration()
|
||||
spec = bp["blueprint"]
|
||||
assert not re.search(r"notify\.[a-z_]+", spec["description"])
|
||||
|
||||
outside_input = {k: v for k, v in bp.items() if k != "blueprint"}
|
||||
for value in iter_strings(outside_input):
|
||||
assert not re.search(r"notify\.[a-z_.]+", value), value
|
||||
|
||||
target_description = spec["input"]["notify_target"]["description"]
|
||||
assert re.search(r"notify\.[a-z_]+", target_description), (
|
||||
"the input description should show a placeholder example"
|
||||
)
|
||||
assert bp["actions"][0]["action"] == BlueprintInput("notify_target")
|
||||
|
||||
def test_celebration_dedupe_entry_equality_not_substring() -> None:
|
||||
"""Child ids sharing a prefix must not suppress each other: after
|
||||
child 11 is celebrated, child 1 still celebrates (whole-entry
|
||||
comparison, not substring)."""
|
||||
assert render_dedupe("11:2026-09-19", child_id=11) == "False"
|
||||
assert render_dedupe("11:2026-09-19", child_id=1) == "True", (
|
||||
"child 1's entry must not be found inside child 11's entry"
|
||||
)
|
||||
assert render_dedupe("1:2026-09-19", child_id=11) == "True"
|
||||
|
||||
|
||||
def test_celebration_mode_is_queued() -> None:
|
||||
"""Two children completing at nearly the same time must both be
|
||||
processed: queued mode does not drop the second event."""
|
||||
assert celebration()["mode"] == "queued"
|
||||
|
||||
|
||||
def test_every_blueprint_event_reference_is_real() -> None:
|
||||
"""Every event a blueprint triggers on is one NestQuest actually
|
||||
fires or defines (Feature 10/11) — no invented event names."""
|
||||
from tests.blueprint_helpers import load_blueprints
|
||||
|
||||
real_events = {
|
||||
"nestquest_quest_completed",
|
||||
"nestquest_quest_uncompleted",
|
||||
"nestquest_quest_missed",
|
||||
"nestquest_child_day_complete",
|
||||
}
|
||||
for name, bp in load_blueprints().items():
|
||||
for trigger in bp.get("triggers", []):
|
||||
if trigger.get("trigger") == "event":
|
||||
assert trigger["event_type"] in real_events, (
|
||||
f"{name}: invented event {trigger['event_type']!r}"
|
||||
)
|
||||
assert celebration()["triggers"][0]["event_type"] == (
|
||||
"nestquest_child_day_complete"
|
||||
)
|
||||
|
||||
|
||||
def test_celebration_uses_the_event_trigger_data_path() -> None:
|
||||
"""HA exposes an event trigger's payload as trigger.event.data —
|
||||
never trigger.payload (Codex review regression)."""
|
||||
bp = celebration()
|
||||
for value in iter_strings(bp):
|
||||
assert "trigger.payload" not in value, value
|
||||
default_message = bp["blueprint"]["input"]["celebration_message"][
|
||||
"default"
|
||||
]
|
||||
assert "trigger.event.data.child_name" in default_message
|
||||
for condition in celebration_conditions(bp):
|
||||
assert "trigger.payload" not in condition["value_template"]
|
||||
@@ -0,0 +1,338 @@
|
||||
"""Tests for the NestQuest end-of-day report automation blueprint.
|
||||
|
||||
The end-of-day report closes the day for the parent: every PRESENT
|
||||
child gets a completion line (so the parent always receives the day's
|
||||
summary when the report runs), children who still owe quests get their
|
||||
open count called out on that line, and absent children are omitted
|
||||
entirely. The report runs deliberately before the midnight
|
||||
missed-quest sweep so it reads the same day's data, and by default an
|
||||
all-clear day sends nothing — unless the household opts into
|
||||
send-even-when-clear. The harness (YAML ``!input`` loader, state
|
||||
fixtures, jinja rendering) is shared in
|
||||
:mod:`tests.blueprint_helpers`; the directory-wide shape and
|
||||
entity-discipline tests in test_blueprints.py cover this file too.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from custom_components.nestquest.const import DEFAULT_END_OF_DAY_REPORT_TIME
|
||||
|
||||
from tests.blueprint_helpers import (
|
||||
BlueprintInput,
|
||||
notify_data,
|
||||
blueprint,
|
||||
iter_strings,
|
||||
make_states,
|
||||
render,
|
||||
single_action,
|
||||
TemplateState,
|
||||
)
|
||||
|
||||
REPORT_TITLE = "NestQuest end-of-day report"
|
||||
|
||||
|
||||
def end_of_day_report() -> dict:
|
||||
"""Return the parsed end-of-day-report blueprint."""
|
||||
return blueprint("end_of_day_report")
|
||||
|
||||
|
||||
def report_conditions(bp: dict) -> list[dict]:
|
||||
"""Return the blueprint's conditions: toggle first, guard second."""
|
||||
conditions = bp["conditions"]
|
||||
assert isinstance(conditions, list) and len(conditions) == 2
|
||||
return conditions
|
||||
|
||||
|
||||
def clear_guard(bp: dict) -> str:
|
||||
"""Return the template guarding an all-clear day (false = no report)."""
|
||||
return report_conditions(bp)[1]["value_template"]
|
||||
|
||||
|
||||
def owing_clear_absent_fixture():
|
||||
"""Alice owing (1/3 done, 2 open), Carol present and clear, Bob absent."""
|
||||
return make_states(
|
||||
sensors=[
|
||||
TemplateState(
|
||||
"sensor.nestquest_alice_quests_due_today",
|
||||
"3",
|
||||
{"child_name": "Alice", "child_id": 1, "present": True},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_alice_quests_remaining_today",
|
||||
"2",
|
||||
{"child_id": 1},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_alice_quests_completed_today",
|
||||
"1",
|
||||
{"child_id": 1},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_carol_quests_due_today",
|
||||
"2",
|
||||
{"child_name": "Carol", "child_id": 2, "present": True},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_carol_quests_remaining_today",
|
||||
"0",
|
||||
{"child_id": 2},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_carol_quests_completed_today",
|
||||
"2",
|
||||
{"child_id": 2},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_bob_quests_due_today",
|
||||
"2",
|
||||
{"child_name": "Bob", "child_id": 3, "present": False},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_bob_quests_remaining_today",
|
||||
"1",
|
||||
{"child_id": 3},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_bob_quests_completed_today",
|
||||
"1",
|
||||
{"child_id": 3},
|
||||
),
|
||||
TemplateState("sensor.nestquest_household_quests_due_today", "7"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def all_clear_fixture():
|
||||
"""Alice present with everything done; nobody owes anything."""
|
||||
return make_states(
|
||||
sensors=[
|
||||
TemplateState(
|
||||
"sensor.nestquest_alice_quests_due_today",
|
||||
"3",
|
||||
{"child_name": "Alice", "child_id": 1, "present": True},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_alice_quests_remaining_today",
|
||||
"0",
|
||||
{"child_id": 1},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_alice_quests_completed_today",
|
||||
"3",
|
||||
{"child_id": 1},
|
||||
),
|
||||
TemplateState("sensor.nestquest_household_quests_due_today", "3"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inputs, defaults, and wiring.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_end_of_day_report_declares_inputs_with_selectors() -> None:
|
||||
"""The four inputs exist, carry selectors, and default sanely."""
|
||||
bp = end_of_day_report()
|
||||
inputs = bp["blueprint"]["input"]
|
||||
expected = {"report_time", "notify_target", "send_report", "send_even_when_clear"}
|
||||
assert set(inputs) == expected
|
||||
for name, spec in inputs.items():
|
||||
assert isinstance(spec["selector"], dict) and spec["selector"], name
|
||||
assert inputs["report_time"]["default"] == DEFAULT_END_OF_DAY_REPORT_TIME
|
||||
assert DEFAULT_END_OF_DAY_REPORT_TIME == "20:00"
|
||||
assert inputs["send_report"]["default"] is True
|
||||
assert inputs["send_even_when_clear"]["default"] is False
|
||||
assert "time" in inputs["report_time"]["selector"]
|
||||
assert "text" in inputs["notify_target"]["selector"]
|
||||
assert "boolean" in inputs["send_report"]["selector"]
|
||||
assert "boolean" in inputs["send_even_when_clear"]["selector"]
|
||||
# The description must note the deliberate ordering: the report runs
|
||||
# before the midnight missed sweep, reading the same day's data.
|
||||
description = bp["blueprint"]["description"].lower()
|
||||
assert "before" in description
|
||||
assert "sweep" in description
|
||||
assert "same day" in description
|
||||
|
||||
|
||||
def test_end_of_day_report_wires_inputs_not_literals() -> None:
|
||||
"""Time trigger, toggle and guard conditions, and notify action use !input."""
|
||||
bp = end_of_day_report()
|
||||
assert bp["variables"]["send_report"] == BlueprintInput("send_report")
|
||||
assert bp["variables"]["send_even_when_clear"] == BlueprintInput(
|
||||
"send_even_when_clear"
|
||||
)
|
||||
|
||||
trigger = bp["triggers"][0]
|
||||
assert trigger["trigger"] == "time"
|
||||
assert trigger["at"] == BlueprintInput("report_time")
|
||||
|
||||
toggle, guard = report_conditions(bp)
|
||||
assert toggle["condition"] == "template"
|
||||
assert "send_report" in toggle["value_template"]
|
||||
assert guard["condition"] == "template"
|
||||
assert "send_even_when_clear" in guard["value_template"]
|
||||
|
||||
action = single_action(bp)
|
||||
assert action["action"] == BlueprintInput("notify_target")
|
||||
data = notify_data(end_of_day_report())
|
||||
assert data["title"] == REPORT_TITLE
|
||||
assert isinstance(data["message"], str) and data["message"].strip()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rendering the day's summary.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_end_of_day_report_renders_owing_clear_and_absent_children() -> None:
|
||||
"""The owing child's open count is called out with the completion
|
||||
ratio; the present-clear child still gets the day's completion
|
||||
line; the absent child is omitted entirely."""
|
||||
rendered = render(
|
||||
notify_data(end_of_day_report())["message"],
|
||||
states=owing_clear_absent_fixture(),
|
||||
)
|
||||
assert rendered.split("\n") == [
|
||||
"Alice: 2 still open (1/3 done).",
|
||||
"Carol: 2/2 done.",
|
||||
]
|
||||
assert "Bob" not in rendered
|
||||
assert "household" not in rendered
|
||||
|
||||
|
||||
def test_end_of_day_report_all_clear_guard_renders_false_by_default() -> None:
|
||||
"""An all-clear day with send_even_when_clear off sends nothing:
|
||||
the guard condition is false."""
|
||||
rendered = render(
|
||||
clear_guard(end_of_day_report()),
|
||||
states=all_clear_fixture(),
|
||||
send_even_when_clear=False,
|
||||
)
|
||||
assert rendered.strip() == "False"
|
||||
|
||||
|
||||
def test_end_of_day_report_all_clear_sends_summary_when_opted_in() -> None:
|
||||
"""With send_even_when_clear on, the guard lets an all-clear day
|
||||
through and the message renders the day's completion summary."""
|
||||
guard = clear_guard(end_of_day_report())
|
||||
assert (
|
||||
render(guard, states=all_clear_fixture(), send_even_when_clear=True).strip()
|
||||
== "True"
|
||||
)
|
||||
message = render(
|
||||
notify_data(end_of_day_report())["message"],
|
||||
states=all_clear_fixture(),
|
||||
)
|
||||
assert message == "Alice: 3/3 done."
|
||||
|
||||
|
||||
def test_end_of_day_report_guard_true_when_a_present_child_owes() -> None:
|
||||
"""A present child with open quests passes the guard even with
|
||||
send_even_when_clear off."""
|
||||
rendered = render(
|
||||
clear_guard(end_of_day_report()),
|
||||
states=owing_clear_absent_fixture(),
|
||||
send_even_when_clear=False,
|
||||
)
|
||||
assert rendered.strip() == "True"
|
||||
|
||||
|
||||
def test_end_of_day_report_collision_suffixed_duplicate_names() -> None:
|
||||
"""Two children sharing a display name (HA registry suffix _2 on the
|
||||
second one's entity ids) both appear, each with their own counts."""
|
||||
states = make_states(
|
||||
sensors=[
|
||||
TemplateState(
|
||||
"sensor.nestquest_ada_quests_due_today",
|
||||
"2",
|
||||
{"child_name": "Ada", "child_id": 1, "present": True},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_ada_quests_remaining_today",
|
||||
"1",
|
||||
{"child_id": 1},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_ada_quests_completed_today",
|
||||
"1",
|
||||
{"child_id": 1},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_ada_quests_due_today_2",
|
||||
"3",
|
||||
{"child_name": "Ada", "child_id": 2, "present": True},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_ada_quests_remaining_today_2",
|
||||
"0",
|
||||
{"child_id": 2},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_ada_quests_completed_today_2",
|
||||
"3",
|
||||
{"child_id": 2},
|
||||
),
|
||||
],
|
||||
)
|
||||
rendered = render(notify_data(end_of_day_report())["message"], states=states)
|
||||
assert rendered.split("\n") == [
|
||||
"Ada: 1 still open (1/2 done).",
|
||||
"Ada: 3/3 done.",
|
||||
]
|
||||
|
||||
|
||||
def test_end_of_day_report_never_hard_codes_notify_target() -> None:
|
||||
"""No notify service literal outside the input's placeholder text."""
|
||||
bp = end_of_day_report()
|
||||
spec = bp["blueprint"]
|
||||
assert not re.search(r"notify\.[a-z_]+", spec["description"])
|
||||
|
||||
outside_input = {k: v for k, v in bp.items() if k != "blueprint"}
|
||||
for value in iter_strings(outside_input):
|
||||
assert not re.search(r"notify\.[a-z_.]+", value), value
|
||||
|
||||
target_description = spec["input"]["notify_target"]["description"]
|
||||
assert re.search(r"notify\.[a-z_]+", target_description), (
|
||||
"the input description should show a placeholder example"
|
||||
)
|
||||
assert single_action(bp)["action"] == BlueprintInput("notify_target")
|
||||
|
||||
def test_end_of_day_report_guard_true_when_remaining_unavailable() -> None:
|
||||
"""A present child whose remaining sensor is missing or reads
|
||||
'unavailable' must NOT look like all-clear: the degraded state
|
||||
reaches the parent (Codex review regression)."""
|
||||
states = make_states(
|
||||
sensors=[
|
||||
TemplateState(
|
||||
"sensor.nestquest_alice_quests_due_today",
|
||||
"3",
|
||||
{"child_name": "Alice", "child_id": 1, "present": True},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_alice_quests_remaining_today",
|
||||
"unavailable",
|
||||
{"child_id": 1},
|
||||
),
|
||||
],
|
||||
)
|
||||
rendered = render(
|
||||
clear_guard(end_of_day_report()),
|
||||
states=states,
|
||||
send_even_when_clear=False,
|
||||
)
|
||||
assert rendered.strip() == "True"
|
||||
|
||||
# A missing remaining sensor entirely: also report-worthy.
|
||||
states_missing = make_states(
|
||||
sensors=[
|
||||
TemplateState(
|
||||
"sensor.nestquest_alice_quests_due_today",
|
||||
"3",
|
||||
{"child_name": "Alice", "child_id": 1, "present": True},
|
||||
),
|
||||
],
|
||||
)
|
||||
rendered_missing = render(
|
||||
clear_guard(end_of_day_report()),
|
||||
states=states_missing,
|
||||
send_even_when_clear=False,
|
||||
)
|
||||
assert rendered_missing.strip() == "True"
|
||||
@@ -0,0 +1,336 @@
|
||||
"""Tests for the NestQuest morning-summary automation blueprint.
|
||||
|
||||
The blueprints are YAML data consumed by Home Assistant's blueprint
|
||||
engine; the integration itself never imports yaml or jinja2. These
|
||||
tests therefore exercise them exactly the way HA would: parse the YAML
|
||||
(with a loader that understands ``!input`` tags), then render the
|
||||
message template with a plain jinja2 environment over a fixture state
|
||||
set shaped like HA's template state objects — all via the shared
|
||||
harness in :mod:`tests.blueprint_helpers`.
|
||||
|
||||
This module also carries the directory-wide blueprint discipline tests
|
||||
(entity-id shapes, domain mentions, notify-target hygiene), which scan
|
||||
EVERY blueprint file under the automation directory, so new blueprint
|
||||
files are covered automatically.
|
||||
|
||||
Rendering policy under test (settled for the morning summary): a
|
||||
PRESENT child always appears — with remaining quests owed, or as "all
|
||||
clear" at zero remaining — and a child who is not present that day is
|
||||
omitted entirely, never listed with zero quests.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from types import SimpleNamespace
|
||||
|
||||
from custom_components.nestquest.const import DEFAULT_MORNING_SUMMARY_TIME
|
||||
|
||||
from tests.blueprint_helpers import (
|
||||
notify_data,
|
||||
ALLOWED_ENTITY_SHAPES,
|
||||
BlueprintInput,
|
||||
_ENTITY_LITERAL_RE,
|
||||
_MENTION_RE,
|
||||
iter_strings,
|
||||
load_blueprints,
|
||||
make_states,
|
||||
morning_action,
|
||||
morning_summary,
|
||||
render,
|
||||
TemplateState,
|
||||
)
|
||||
|
||||
MORNING_TITLE = "NestQuest morning summary"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# YAML parsing and blueprint shape.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_blueprints_parse_and_declare_valid_shape() -> None:
|
||||
"""Every blueprint parses and carries the required blueprint keys."""
|
||||
blueprints = load_blueprints()
|
||||
assert blueprints, "no blueprints found"
|
||||
for name, bp in blueprints.items():
|
||||
assert isinstance(bp, dict), f"{name} did not parse to a mapping"
|
||||
spec = bp["blueprint"]
|
||||
assert isinstance(spec["name"], str) and spec["name"].strip()
|
||||
assert spec["domain"] == "automation"
|
||||
assert isinstance(spec["input"], dict) and spec["input"]
|
||||
for key in ("triggers", "conditions", "actions"):
|
||||
assert isinstance(bp[key], list) and bp[key], f"{name}: empty {key}"
|
||||
|
||||
|
||||
def test_morning_summary_declares_inputs_with_selectors() -> None:
|
||||
"""The three blueprint inputs exist, carry selectors, and default sanely."""
|
||||
inputs = morning_summary()["blueprint"]["input"]
|
||||
expected = {"morning_time", "notify_target", "send_summary"}
|
||||
assert set(inputs) == expected
|
||||
for name, spec in inputs.items():
|
||||
assert isinstance(spec["selector"], dict) and spec["selector"], name
|
||||
assert inputs["morning_time"]["default"] == DEFAULT_MORNING_SUMMARY_TIME
|
||||
assert inputs["send_summary"]["default"] is True
|
||||
assert "time" in inputs["morning_time"]["selector"]
|
||||
assert "text" in inputs["notify_target"]["selector"]
|
||||
assert "boolean" in inputs["send_summary"]["selector"]
|
||||
|
||||
|
||||
def test_morning_summary_wires_inputs_not_literals() -> None:
|
||||
"""Time trigger, toggle condition, and notify action all use !input."""
|
||||
bp = morning_summary()
|
||||
assert bp["variables"]["send_summary"] == BlueprintInput("send_summary")
|
||||
|
||||
trigger = bp["triggers"][0]
|
||||
assert trigger["trigger"] == "time"
|
||||
assert trigger["at"] == BlueprintInput("morning_time")
|
||||
|
||||
condition = bp["conditions"][0]
|
||||
assert condition["condition"] == "template"
|
||||
assert "send_summary" in condition["value_template"]
|
||||
|
||||
action = morning_action(bp)
|
||||
assert action["action"] == BlueprintInput("notify_target")
|
||||
data = notify_data(bp)
|
||||
assert data["title"] == MORNING_TITLE
|
||||
assert isinstance(data["message"], str) and data["message"].strip()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entity-reference discipline (Feature 10 shapes only). These tests
|
||||
# scan EVERY blueprint in the directory, so new blueprint files are
|
||||
# automatically held to the same rules.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_entity_literals_match_feature_10_shapes() -> None:
|
||||
"""Every id-like entity string in the YAML is a Feature 10 shape."""
|
||||
for name, bp in load_blueprints().items():
|
||||
for value in iter_strings(bp):
|
||||
for literal in _ENTITY_LITERAL_RE.findall(value):
|
||||
assert any(
|
||||
shape.fullmatch(literal) for shape in ALLOWED_ENTITY_SHAPES
|
||||
), f"{name}: invented entity {literal!r}"
|
||||
# The validator itself must separate Feature 10 ids from lookalikes.
|
||||
assert any(
|
||||
shape.fullmatch("binary_sensor.nestquest_alice_present_today")
|
||||
for shape in ALLOWED_ENTITY_SHAPES
|
||||
)
|
||||
assert any(
|
||||
shape.fullmatch("sensor.nestquest_bob_quests_remaining_today")
|
||||
for shape in ALLOWED_ENTITY_SHAPES
|
||||
)
|
||||
for foreign in (
|
||||
"binary_sensor.someone_else_present_today",
|
||||
"binary_sensor.nestquest_alice_at_home",
|
||||
"sensor.nestquest_alice_streak",
|
||||
"vacuum.nestquest_robot",
|
||||
):
|
||||
assert not any(shape.fullmatch(foreign) for shape in ALLOWED_ENTITY_SHAPES)
|
||||
|
||||
|
||||
def test_domain_mentions_are_nestquest_prefixed() -> None:
|
||||
"""Every sensor/binary_sensor mention (regex or literal) is nestquest_."""
|
||||
for name, bp in load_blueprints().items():
|
||||
for value in iter_strings(bp):
|
||||
flattened = value.replace("\\", "")
|
||||
for tail in _MENTION_RE.findall(flattened):
|
||||
assert tail.startswith("nestquest_"), (
|
||||
f"{name}: non-nestquest entity mention in {value!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_due_sensor_regex_selects_only_nestquest_due_sensors() -> None:
|
||||
"""The extracted due-sensor regex matches Feature 10 due ids —
|
||||
including HA collision-suffixed duplicates (a second child with
|
||||
the same name gets entity ids ending _2) — and rejects everything
|
||||
else."""
|
||||
message = notify_data(morning_summary())["message"]
|
||||
match = re.search(
|
||||
r"\^(sensor\\\\\.nestquest_\.\*_quests_due_today\(_\\\\d\+\)\?\$)", message
|
||||
)
|
||||
assert match is not None, "due-sensor pattern missing from the template"
|
||||
pattern = match.group(1).replace("\\\\", "\\")
|
||||
assert pattern.endswith("$")
|
||||
assert re.search(pattern, "sensor.nestquest_alice_quests_due_today")
|
||||
assert re.search(pattern, "sensor.nestquest_alice_quests_due_today_2")
|
||||
assert re.search(pattern, "sensor.nestquest_bob_quests_remaining_today") is None
|
||||
# The household rollup due sensor unavoidably matches the pattern;
|
||||
# the template's present-attribute guard excludes it (proven in the
|
||||
# render test below).
|
||||
assert re.search(pattern, "sensor.nestquest_household_quests_due_today")
|
||||
assert re.search(pattern, "sensor.carol_unrelated") is None
|
||||
|
||||
|
||||
def test_morning_summary_never_hard_codes_notify_target() -> None:
|
||||
"""No notify service literal outside input descriptions; the choice is text."""
|
||||
bp = morning_summary()
|
||||
spec = bp["blueprint"]
|
||||
assert "hard-code" in spec["description"]
|
||||
assert not re.search(r"notify\.[a-z_]+", spec["description"])
|
||||
|
||||
outside_input = {k: v for k, v in bp.items() if k != "blueprint"}
|
||||
for value in iter_strings(outside_input):
|
||||
assert not re.search(r"notify\.[a-z_.]+", value), value
|
||||
|
||||
target_description = spec["input"]["notify_target"]["description"]
|
||||
assert re.search(r"notify\.[a-z_]+", target_description), (
|
||||
"the input description should show a placeholder example"
|
||||
)
|
||||
assert morning_action(bp)["action"] == BlueprintInput("notify_target")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Message rendering against fixture state sets.
|
||||
# ---------------------------------------------------------------------------
|
||||
def morning_fixture() -> SimpleNamespace:
|
||||
"""Two children — Alice present (2 owed), Bob absent — plus decoys."""
|
||||
return make_states(
|
||||
binary_sensors=[
|
||||
TemplateState("binary_sensor.nestquest_alice_present_today", "on"),
|
||||
TemplateState("binary_sensor.nestquest_bob_present_today", "off"),
|
||||
TemplateState("binary_sensor.motion_present_today", "on"),
|
||||
TemplateState("binary_sensor.nestquest_dave_all_done", "on"),
|
||||
TemplateState("binary_sensor.nestquest_erin_present_today", "unavailable"),
|
||||
],
|
||||
sensors=[
|
||||
TemplateState(
|
||||
"sensor.nestquest_alice_quests_due_today",
|
||||
"3",
|
||||
{"child_name": "Alice", "child_id": 1, "present": True},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_alice_quests_remaining_today",
|
||||
"2",
|
||||
{"child_id": 1},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_bob_quests_due_today",
|
||||
"2",
|
||||
{"child_name": "Bob", "child_id": 2, "present": False},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_bob_quests_remaining_today",
|
||||
"1",
|
||||
{"child_id": 2},
|
||||
),
|
||||
TemplateState("sensor.nestquest_household_quests_due_today", "5"),
|
||||
TemplateState("sensor.nestquest_carol_unrelated", "7"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_morning_summary_lists_present_child_and_omits_absent() -> None:
|
||||
"""Present Alice appears with her count; absent Bob and decoys never do."""
|
||||
message = notify_data(morning_summary())["message"]
|
||||
rendered = render(message, states=morning_fixture())
|
||||
assert rendered == "Alice owes 2 quest(s) today."
|
||||
assert "Bob" not in rendered
|
||||
for absent in ("motion", "Dave", "Erin", "household", "carol"):
|
||||
assert absent not in rendered
|
||||
|
||||
|
||||
def test_morning_summary_present_child_zero_remaining_is_all_clear() -> None:
|
||||
"""A present child always appears — zero remaining reads as all clear."""
|
||||
states = make_states(
|
||||
sensors=[
|
||||
TemplateState(
|
||||
"sensor.nestquest_carol_quests_due_today",
|
||||
"2",
|
||||
{"child_name": "Carol", "child_id": 1, "present": True},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_carol_quests_remaining_today",
|
||||
"0",
|
||||
{"child_id": 1},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_alice_quests_due_today",
|
||||
"3",
|
||||
{"child_name": "Alice", "child_id": 2, "present": True},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_alice_quests_remaining_today",
|
||||
"2",
|
||||
{"child_id": 2},
|
||||
),
|
||||
],
|
||||
)
|
||||
rendered = render(notify_data(morning_summary())["message"], states=states)
|
||||
assert "Alice owes 2 quest(s) today." in rendered
|
||||
assert "Carol is all clear today (0 quests remaining)." in rendered
|
||||
|
||||
|
||||
def test_morning_summary_present_child_without_sensors_is_flagged() -> None:
|
||||
"""A present child whose remaining-count sensor is missing (the
|
||||
platform failed to add it) still gets a line, flagged unavailable."""
|
||||
states = make_states(
|
||||
sensors=[
|
||||
TemplateState(
|
||||
"sensor.nestquest_frances_quests_due_today",
|
||||
"2",
|
||||
{"child_name": "Frances", "child_id": 1, "present": True},
|
||||
),
|
||||
],
|
||||
)
|
||||
rendered = render(notify_data(morning_summary())["message"], states=states)
|
||||
assert rendered == "Frances's quest count is unavailable today."
|
||||
|
||||
|
||||
def test_morning_summary_collision_suffixed_duplicate_names() -> None:
|
||||
"""Two children sharing a display name get HA registry suffixes on
|
||||
their entity ids (..._2); both must appear, each with their own
|
||||
count (Codex review regression)."""
|
||||
states = make_states(
|
||||
sensors=[
|
||||
TemplateState(
|
||||
"sensor.nestquest_ada_quests_due_today",
|
||||
"2",
|
||||
{"child_name": "Ada", "child_id": 1, "present": True},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_ada_quests_remaining_today",
|
||||
"1",
|
||||
{"child_id": 1},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_ada_quests_due_today_2",
|
||||
"2",
|
||||
{"child_name": "Ada", "child_id": 2, "present": True},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_ada_quests_remaining_today_2",
|
||||
"2",
|
||||
{"child_id": 2},
|
||||
),
|
||||
],
|
||||
)
|
||||
rendered = render(notify_data(morning_summary())["message"], states=states)
|
||||
lines = rendered.split("\n")
|
||||
assert lines == [
|
||||
"Ada owes 1 quest(s) today.",
|
||||
"Ada owes 2 quest(s) today.",
|
||||
]
|
||||
|
||||
|
||||
def test_morning_summary_no_present_children() -> None:
|
||||
"""With nobody present the body says so rather than listing zeros."""
|
||||
states = make_states(
|
||||
sensors=[
|
||||
TemplateState(
|
||||
"sensor.nestquest_alice_quests_due_today",
|
||||
"3",
|
||||
{"child_name": "Alice", "child_id": 1, "present": False},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_alice_quests_remaining_today",
|
||||
"3",
|
||||
{"child_id": 1},
|
||||
),
|
||||
],
|
||||
)
|
||||
rendered = render(notify_data(morning_summary())["message"], states=states)
|
||||
assert rendered == "No children present today."
|
||||
|
||||
|
||||
def test_morning_summary_send_summary_toggle_renders() -> None:
|
||||
"""The enable condition renders truthy/false from the input value."""
|
||||
condition = morning_summary()["conditions"][0]
|
||||
assert render(condition["value_template"], send_summary=True).strip() == "True"
|
||||
assert render(condition["value_template"], send_summary=False).strip() == "False"
|
||||
@@ -78,9 +78,43 @@ async def test_coordinator_snapshot_counts_and_states(hass, make_entry) -> None:
|
||||
assert instance.title == "Brush teeth"
|
||||
assert instance.window == "morning"
|
||||
assert ada.present is True, "no schedule means present every day"
|
||||
assert ada.next_present is None, "present child has no returns date"
|
||||
assert snapshot.cycle_day == 0, "no schedule anywhere"
|
||||
|
||||
|
||||
async def test_coordinator_next_present_for_away_child(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""A child absent today carries the ISO date of their next present
|
||||
day; an always-absent pattern carries None instead."""
|
||||
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
|
||||
await create_child(database, "Ada")
|
||||
ada = (await list_children(database))[0]
|
||||
today = datetime.date.today()
|
||||
pattern = ",".join(
|
||||
str(day) for day in range(7) if day != today.weekday()
|
||||
)
|
||||
await PresenceSchedulesDao(database).upsert_by_child(
|
||||
ada.id, 1, today.isoformat(), pattern
|
||||
)
|
||||
await create_child(database, "Bo")
|
||||
bo = (await list_children(database))[1]
|
||||
await PresenceSchedulesDao(database).upsert_by_child(
|
||||
bo.id, 1, today.isoformat(), ""
|
||||
)
|
||||
snapshot = await coordinator._async_update_data()
|
||||
by_name = {child.child_name: child for child in snapshot.children}
|
||||
assert by_name["Ada"].present is False
|
||||
assert by_name["Ada"].next_present == (
|
||||
today + datetime.timedelta(days=1)
|
||||
).isoformat()
|
||||
assert by_name["Bo"].present is False
|
||||
assert by_name["Bo"].next_present is None
|
||||
|
||||
|
||||
async def test_coordinator_reflects_completion_and_uncompletion(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
@@ -184,6 +218,37 @@ async def test_coordinator_presence_resolves_schedule_and_override(
|
||||
assert snapshot.children[0].present is True
|
||||
|
||||
|
||||
async def test_coordinator_next_present_honours_future_override(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
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
|
||||
await create_child(database, "Ada")
|
||||
ada = (await list_children(database))[0]
|
||||
today = datetime.date.today()
|
||||
tomorrow = today + datetime.timedelta(days=1)
|
||||
# Scheduled present tomorrow (single-day cycle, weekday set set below
|
||||
# covers every day), but overridden absent tomorrow.
|
||||
await PresenceSchedulesDao(database).upsert_by_child(
|
||||
ada.id, 1, today.isoformat(), "0,1,2,3,4,5,6"
|
||||
)
|
||||
from custom_components.nestquest.dao_presence import (
|
||||
PresenceOverridesDao,
|
||||
)
|
||||
|
||||
await PresenceOverridesDao(database).create(
|
||||
ada.id, tomorrow.isoformat(), tomorrow.isoformat(), False
|
||||
)
|
||||
snapshot = await coordinator._async_update_data()
|
||||
child = snapshot.children[0]
|
||||
assert child.present is True
|
||||
assert child.next_present != tomorrow.isoformat(), (
|
||||
"a future absent override must remove tomorrow from the preview"
|
||||
)
|
||||
|
||||
|
||||
async def test_coordinator_derives_missed_for_past_due_open(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Tests for the Lovelace card toolchain and auto-registered resource."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from conftest import wire_entry_to_registry
|
||||
|
||||
from custom_components.nestquest import async_setup, async_setup_entry
|
||||
from custom_components.nestquest.frontend import (
|
||||
BUNDLE_FILENAME,
|
||||
BUNDLE_URL,
|
||||
FRONTEND_URL_BASE,
|
||||
WWW_DIR,
|
||||
async_register_frontend,
|
||||
)
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
FRONTEND_DIR = REPO_ROOT / "frontend"
|
||||
BUNDLE_PATH = WWW_DIR / BUNDLE_FILENAME
|
||||
CARD_TYPES = (
|
||||
"nestquest-party-board-card",
|
||||
"nestquest-quest-log-card",
|
||||
"nestquest-admin-card",
|
||||
)
|
||||
|
||||
|
||||
def test_frontend_package_documents_vite_lit_build() -> None:
|
||||
package = json.loads((FRONTEND_DIR / "package.json").read_text(encoding="utf-8"))
|
||||
assert package["scripts"]["build"] == "vite build"
|
||||
assert "lit" in package["dependencies"]
|
||||
assert "typescript" in package["devDependencies"]
|
||||
assert "vite" in package["devDependencies"]
|
||||
|
||||
|
||||
def test_card_modules_exist_as_separate_sources() -> None:
|
||||
src = FRONTEND_DIR / "src"
|
||||
assert (src / "nestquest-cards.ts").is_file()
|
||||
assert (src / "cards" / "party-board.ts").is_file()
|
||||
assert (src / "cards" / "quest-log.ts").is_file()
|
||||
assert (src / "cards" / "admin.ts").is_file()
|
||||
entry = (src / "nestquest-cards.ts").read_text(encoding="utf-8")
|
||||
assert "./cards/party-board" in entry
|
||||
assert "./cards/quest-log" in entry
|
||||
assert "./cards/admin" in entry
|
||||
|
||||
|
||||
def test_card_sources_have_no_visual_editor() -> None:
|
||||
for path in (FRONTEND_DIR / "src" / "cards").glob("*.ts"):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
assert "getConfigElement" not in text
|
||||
assert "setConfig" in text
|
||||
assert "getCardSize" in text
|
||||
|
||||
|
||||
def test_built_bundle_contains_all_three_cards() -> None:
|
||||
assert BUNDLE_PATH.is_file()
|
||||
bundle = BUNDLE_PATH.read_text(encoding="utf-8")
|
||||
for card_type in CARD_TYPES:
|
||||
assert card_type in bundle
|
||||
|
||||
|
||||
async def test_setup_registers_static_path_and_extra_js(hass, make_entry) -> None:
|
||||
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
assert len(hass.http.static_paths) == 1
|
||||
registered = hass.http.static_paths[0]
|
||||
assert registered.url_path == FRONTEND_URL_BASE
|
||||
assert registered.path == str(WWW_DIR)
|
||||
assert hass.extra_js_urls == [BUNDLE_URL]
|
||||
|
||||
|
||||
async def test_async_setup_registers_frontend_without_a_config_entry(hass) -> None:
|
||||
assert await async_setup(hass, {}) is True
|
||||
assert hass.extra_js_urls == [BUNDLE_URL]
|
||||
assert hass.http.static_paths[0].url_path == FRONTEND_URL_BASE
|
||||
|
||||
|
||||
async def test_frontend_registration_is_idempotent(hass, make_entry) -> None:
|
||||
await async_register_frontend(hass)
|
||||
await async_register_frontend(hass)
|
||||
entry_a = wire_entry_to_registry(make_entry(entry_id="a"), hass.registry)
|
||||
entry_b = wire_entry_to_registry(make_entry(entry_id="b"), hass.registry)
|
||||
assert await async_setup_entry(hass, entry_a) is True
|
||||
assert await async_setup_entry(hass, entry_b) is True
|
||||
assert len(hass.http.static_paths) == 1
|
||||
assert hass.extra_js_urls == [BUNDLE_URL]
|
||||
|
||||
|
||||
def test_readme_documents_npm_build() -> None:
|
||||
readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8")
|
||||
assert "npm run build" in readme
|
||||
assert "nestquest-cards.js" in readme
|
||||
|
||||
|
||||
def test_readme_documents_panel_resolution_and_touchhub_dock() -> None:
|
||||
readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8")
|
||||
assert "1920×1080" in readme
|
||||
assert "TouchHub" in readme
|
||||
assert "dock" in readme
|
||||
assert "pointer-events: none" in readme
|
||||
|
||||
|
||||
WOFF2_FILES = (
|
||||
"nunito-latin-400-normal.woff2",
|
||||
"nunito-latin-600-normal.woff2",
|
||||
"nunito-latin-700-normal.woff2",
|
||||
"nunito-latin-800-normal.woff2",
|
||||
"cinzel-latin-600-normal.woff2",
|
||||
"cinzel-latin-700-normal.woff2",
|
||||
"cinzel-latin-900-normal.woff2",
|
||||
"cinzel-decorative-latin-700-normal.woff2",
|
||||
"cinzel-decorative-latin-900-normal.woff2",
|
||||
)
|
||||
CDN_MARKERS = (
|
||||
"fonts.googleapis.com",
|
||||
"fonts.gstatic.com",
|
||||
"cdn.jsdelivr.net",
|
||||
"unpkg.com",
|
||||
"cdnjs.cloudflare.com",
|
||||
)
|
||||
|
||||
|
||||
def test_www_contains_required_woff2_fonts() -> None:
|
||||
fonts_dir = WWW_DIR / "fonts"
|
||||
for name in WOFF2_FILES:
|
||||
path = fonts_dir / name
|
||||
assert path.is_file(), name
|
||||
assert path.read_bytes()[:4] == b"wOF2"
|
||||
|
||||
|
||||
def test_font_css_declares_swap_and_local_urls() -> None:
|
||||
css = (WWW_DIR / "nestquest-fonts.css").read_text(encoding="utf-8")
|
||||
assert css.count("font-display: swap") == 9
|
||||
assert css.count("@font-face") == 9
|
||||
assert 'font-family: "Nunito"' in css
|
||||
assert 'font-family: "Cinzel"' in css
|
||||
assert 'font-family: "Cinzel Decorative"' in css
|
||||
for name in WOFF2_FILES:
|
||||
assert f"./fonts/{name}" in css
|
||||
assert "http://" not in css
|
||||
assert "https://" not in css
|
||||
|
||||
|
||||
def test_token_css_copied_verbatim_from_design() -> None:
|
||||
design = REPO_ROOT / "design" / "tokens"
|
||||
for name in ("nestquest-panel-tokens.css", "nestquest-admin-tokens.css"):
|
||||
expected = (design / name).read_bytes()
|
||||
actual = (WWW_DIR / name).read_bytes()
|
||||
assert actual == expected
|
||||
|
||||
|
||||
def test_frontend_and_www_have_no_cdn_font_references() -> None:
|
||||
roots = (FRONTEND_DIR, WWW_DIR)
|
||||
for root in roots:
|
||||
for path in root.rglob("*"):
|
||||
if not path.is_file():
|
||||
continue
|
||||
if "node_modules" in path.parts:
|
||||
continue
|
||||
if path.suffix.lower() in {".woff2", ".png", ".jpg"}:
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
for marker in CDN_MARKERS:
|
||||
assert marker not in text, f"{path}: {marker}"
|
||||
|
||||
|
||||
def test_readme_documents_the_touchhub_setup_runbook() -> None:
|
||||
readme = " ".join(
|
||||
(REPO_ROOT / "README.md").read_text(encoding="utf-8").split()
|
||||
)
|
||||
for required in ("TouchHub", "kiosk"):
|
||||
assert required in readme, required
|
||||
assert "not in the NestQuest admin allowlist" in readme
|
||||
assert "contains only the NestQuest panel card" in readme
|
||||
assert "nestquest-party-board-card" in readme
|
||||
assert "nestquest-quest-log-card" in readme
|
||||
assert "Panel" in readme
|
||||
assert "Auto-Return" in readme
|
||||
assert "use_x_frame_options" not in readme
|
||||
assert "clickjacking" not in readme
|
||||
+2
-2
@@ -67,9 +67,9 @@ def test_readme_exists_and_contains_required_sections() -> None:
|
||||
def test_readme_hacs_points_at_public_github_mirror() -> None:
|
||||
"""Verify README directs HACS users to the public GitHub mirror, not the private Gitea repo."""
|
||||
content = README_PATH.read_text(encoding="utf-8")
|
||||
assert "https://github.com/talon2king/NestQuest" in content
|
||||
assert "https://github.com/Maniacs-Mansion/NestQuest" in content
|
||||
assert "https://code.cubecraftlabs.com/Maniacs_Mansion/NestQuest" in content
|
||||
assert content.index("https://github.com/talon2king/NestQuest") < content.index(
|
||||
assert content.index("https://github.com/Maniacs-Mansion/NestQuest") < content.index(
|
||||
"https://code.cubecraftlabs.com/Maniacs_Mansion/NestQuest"
|
||||
)
|
||||
|
||||
|
||||
@@ -58,19 +58,19 @@ def test_manifest_requirements(manifest: dict) -> None:
|
||||
|
||||
def test_manifest_documentation_url_is_public_github_mirror(manifest: dict) -> None:
|
||||
"""Verify documentation URL points at the public GitHub mirror."""
|
||||
assert manifest.get("documentation") == "https://github.com/talon2king/NestQuest"
|
||||
assert manifest.get("documentation") == "https://github.com/Maniacs-Mansion/NestQuest"
|
||||
|
||||
|
||||
def test_manifest_issue_tracker_url_is_public_github_mirror(manifest: dict) -> None:
|
||||
"""Verify issue_tracker URL points at the public GitHub mirror."""
|
||||
assert manifest.get("issue_tracker") == "https://github.com/talon2king/NestQuest/issues"
|
||||
assert manifest.get("issue_tracker") == "https://github.com/Maniacs-Mansion/NestQuest/issues"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["documentation", "issue_tracker"])
|
||||
def test_manifest_urls_use_github_com(manifest: dict, key: str) -> None:
|
||||
"""Verify manifest URLs use github.com rather than the private Gitea host."""
|
||||
url = manifest.get(key, "")
|
||||
assert url.startswith("https://github.com/talon2king/NestQuest")
|
||||
assert url.startswith("https://github.com/Maniacs-Mansion/NestQuest")
|
||||
assert "cubecraftlabs" not in url
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ 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 (
|
||||
MIGRATIONS,
|
||||
VERSION_TABLE,
|
||||
@@ -110,6 +111,7 @@ def test_fresh_file_migration_creates_all_v1_tables(tmp_path) -> None:
|
||||
"presence_overrides",
|
||||
"quest_instances",
|
||||
"completion_events",
|
||||
"nestquest_meta_state",
|
||||
VERSION_TABLE,
|
||||
}
|
||||
assert _tables(database) == expected
|
||||
@@ -159,7 +161,8 @@ def test_migration_list_shape() -> None:
|
||||
assert MIGRATIONS[3] == SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL
|
||||
assert callable(MIGRATIONS[4])
|
||||
assert callable(MIGRATIONS[5])
|
||||
assert len(MIGRATIONS) == 6
|
||||
assert MIGRATIONS[6] == SCHEMA_V7_META_STATE_DDL
|
||||
assert len(MIGRATIONS) == 7
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+168
-18
@@ -19,6 +19,18 @@ from custom_components.nestquest.const import (
|
||||
CONF_HORIZON_DAYS,
|
||||
CONF_PANEL_IDLE_TIMEOUT,
|
||||
CONF_UPDATE_INTERVAL,
|
||||
CONF_AFTERNOON_REMINDER_ENABLED,
|
||||
CONF_AFTERNOON_REMINDER_TIME,
|
||||
CONF_CELEBRATION_ENABLED,
|
||||
CONF_END_OF_DAY_REPORT_ENABLED,
|
||||
CONF_END_OF_DAY_REPORT_TIME,
|
||||
CONF_MORNING_SUMMARY_ENABLED,
|
||||
CONF_MORNING_SUMMARY_TIME,
|
||||
CONF_NOTIFY_TARGET,
|
||||
DEFAULT_AFTERNOON_REMINDER_TIME,
|
||||
DEFAULT_AUTOMATION_ENABLED,
|
||||
DEFAULT_END_OF_DAY_REPORT_TIME,
|
||||
DEFAULT_MORNING_SUMMARY_TIME,
|
||||
DEFAULT_UPDATE_INTERVAL,
|
||||
DEFAULT_DAY_ROLLOVER_TIME,
|
||||
DEFAULT_HORIZON_DAYS,
|
||||
@@ -36,6 +48,22 @@ VALID_INPUT = {
|
||||
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 and the Feature 11
|
||||
#: notification section 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,
|
||||
}
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.new_event_loop().run_until_complete(coro)
|
||||
@@ -131,10 +159,7 @@ def test_options_flow_submits_through_2024_6_surface() -> None:
|
||||
result = _run(handler.async_step_init(dict(VALID_INPUT)))
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["title"] == "NestQuest"
|
||||
assert result["data"] == {
|
||||
**VALID_INPUT,
|
||||
CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL,
|
||||
}
|
||||
assert result["data"] == FULL_INPUT
|
||||
|
||||
|
||||
def test_options_flow_shows_form_initially() -> None:
|
||||
@@ -214,6 +239,10 @@ def test_options_flow_schema_applies_stored_defaults() -> None:
|
||||
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",
|
||||
}
|
||||
)
|
||||
result = _run(_make_flow(entry).async_step_init(None))
|
||||
@@ -222,6 +251,14 @@ def test_options_flow_schema_applies_stored_defaults() -> None:
|
||||
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_ADMIN_USER_IDS: [],
|
||||
}
|
||||
|
||||
@@ -295,16 +332,8 @@ def test_options_flow_creates_entry_with_four_keys() -> None:
|
||||
result = _run(flow.async_step_init(dict(VALID_INPUT)))
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["title"] == "NestQuest"
|
||||
assert set(result["data"]) == {
|
||||
CONF_HORIZON_DAYS,
|
||||
CONF_DAY_ROLLOVER_TIME,
|
||||
CONF_PANEL_IDLE_TIMEOUT,
|
||||
CONF_UPDATE_INTERVAL,
|
||||
}
|
||||
assert result["data"] == {
|
||||
**VALID_INPUT,
|
||||
CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL,
|
||||
}
|
||||
assert set(result["data"]) == set(FULL_INPUT)
|
||||
assert result["data"] == FULL_INPUT
|
||||
|
||||
|
||||
def test_options_flow_create_entry_data_lands_in_entry_options_and_reloads_once() -> None:
|
||||
@@ -327,10 +356,7 @@ def test_options_flow_create_entry_data_lands_in_entry_options_and_reloads_once(
|
||||
_run(registry.dispatch_options_update(entry))
|
||||
assert hass.config_entries.async_reload.call_count == 1
|
||||
assert registry.reloaded == [entry.entry_id]
|
||||
assert entry.options == {
|
||||
**VALID_INPUT,
|
||||
CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL,
|
||||
}
|
||||
assert entry.options == FULL_INPUT
|
||||
|
||||
|
||||
def test_options_flow_validation_never_raises() -> None:
|
||||
@@ -649,3 +675,127 @@ def test_options_flow_omitted_picker_carries_allowlist_forward(
|
||||
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] == ""
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Source-level tests for the kid panel cards.
|
||||
|
||||
The panel cards are Lit/TypeScript modules bundled by Vite; these tests assert
|
||||
the contract by reading the card sources and the built bundle, in the same
|
||||
spirit as test_frontend.py. They cover the states decided in
|
||||
design/PANEL-EMPTY-STATES.md and PANEL-SPEC.md, and guard the kid-facing
|
||||
bundles against ever gaining a parent-only service call.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
FRONTEND_CARDS_DIR = REPO_ROOT / "frontend" / "src" / "cards"
|
||||
PARTY_BOARD = FRONTEND_CARDS_DIR / "party-board.ts"
|
||||
QUEST_LOG = FRONTEND_CARDS_DIR / "quest-log.ts"
|
||||
BUNDLE_PATH = REPO_ROOT / "custom_components" / "nestquest" / "www" / "nestquest-cards.js"
|
||||
|
||||
PANEL_SOURCES = (PARTY_BOARD, QUEST_LOG)
|
||||
|
||||
# Parent-only NestQuest services. The party board and quest log are the
|
||||
# kid-facing panel: they may complete a quest for the tapped child, and nothing
|
||||
# else. Any of these names appearing in a panel source or bundle means an admin
|
||||
# control has leaked into the kid panel.
|
||||
ADMIN_ONLY_SERVICES = (
|
||||
"uncomplete_quest",
|
||||
"create_quest_definition",
|
||||
"update_quest_definition",
|
||||
"set_quest_definition_active",
|
||||
"set_presence_pattern",
|
||||
"create_presence_override",
|
||||
"delete_presence_override",
|
||||
"export_history_csv",
|
||||
"manage_child",
|
||||
"regenerate",
|
||||
)
|
||||
|
||||
WINDOW_KEYS = ("morning", "afternoon", "evening")
|
||||
WINDOW_NAMES = ("Morning", "Afternoon", "Evening")
|
||||
|
||||
|
||||
def _read(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_party_board_renders_present_and_away_plates() -> None:
|
||||
source = _read(PARTY_BOARD)
|
||||
assert "nestquest-party-board-card" in source
|
||||
# Present adventurers get the tappable crest plate.
|
||||
assert "button.plate" in source
|
||||
assert 'class="plate"' in source
|
||||
assert "Home today" in source
|
||||
assert "aria-label=\"Open ${plate.name}'s Quest Log\"" in source
|
||||
# Away adventurers get the dashed On travels plate, not a tappable one.
|
||||
assert ".plate.away" in source
|
||||
assert 'class="plate away"' in source
|
||||
assert "On travels" in source
|
||||
assert "next_present" in source
|
||||
assert "Returns ${formatDay(returnsDate" in source
|
||||
|
||||
|
||||
def test_quest_log_renders_three_window_columns() -> None:
|
||||
source = _read(QUEST_LOG)
|
||||
for key, name in zip(WINDOW_KEYS, WINDOW_NAMES):
|
||||
assert f'key: "{key}"' in source, key
|
||||
assert f'name: "{name}"' in source, name
|
||||
assert "grid-template-columns: repeat(3, 1fr)" in source
|
||||
assert 'class="column-head"' in source
|
||||
assert 'class="column-names"' in source
|
||||
assert 'class="column-range"' in source
|
||||
|
||||
|
||||
def test_empty_window_still_renders_its_column_head() -> None:
|
||||
"""A window with no quests renders the head alone (PANEL-SPEC §3.1)."""
|
||||
source = _read(QUEST_LOG)
|
||||
column = source[source.index("private _renderColumn(windowDef: WindowDef)") :]
|
||||
column = column[: column.index("private _renderQuest(")]
|
||||
# The head is emitted unconditionally, before the stack is built.
|
||||
assert 'class="column-head"' in column
|
||||
assert "sealed.length}/${instances.length}" in column
|
||||
# There is no early return that would drop the head on an empty window.
|
||||
assert "instances.length === 0" not in column
|
||||
assert "return nothing" not in column
|
||||
|
||||
|
||||
def test_complete_quest_is_optimistic_with_rollback() -> None:
|
||||
source = _read(QUEST_LOG)
|
||||
assert "_optimistic" in source
|
||||
assert "new Date().toISOString()" in source
|
||||
assert ".set(instance.id, stampedAt)" in source
|
||||
# The overlay is applied to the rendered instance before the service answers.
|
||||
assert 'instance.state = "completed"' in source
|
||||
# A rejected call reverts the overlay and raises the parchment toast.
|
||||
assert "reverted.delete(instance.id)" in source
|
||||
assert "NestQuest could not set the seal just now. Please try again." in source
|
||||
assert "_showToast(" in source
|
||||
assert "await this._callCompleteQuest(instance)" in source
|
||||
assert '"nestquest", "complete_quest"' in source
|
||||
|
||||
|
||||
def test_idle_return_to_board() -> None:
|
||||
source = _read(QUEST_LOG)
|
||||
assert "idle_return_seconds" in source
|
||||
assert "_armIdle" in source
|
||||
assert "_clearIdle" in source
|
||||
assert "this._idleSeconds() * 1000" in source
|
||||
assert "board_path" in source
|
||||
assert "_returnToBoard" in source
|
||||
assert 'window.dispatchEvent(new Event("location-changed"))' in source
|
||||
|
||||
|
||||
def test_decided_empty_and_error_states_are_present() -> None:
|
||||
log = _read(QUEST_LOG)
|
||||
board = _read(PARTY_BOARD)
|
||||
for kind in (
|
||||
"no-adventurer",
|
||||
"not-set-up",
|
||||
"unreachable",
|
||||
"away",
|
||||
"empty-day",
|
||||
"complete-day",
|
||||
):
|
||||
assert f'kind: "{kind}"' in log, kind
|
||||
assert "PANEL-EMPTY-STATES" in log
|
||||
assert "No adventurer chosen" in log
|
||||
assert "NestQuest is not set up yet" in log
|
||||
assert "The records cannot be reached" in log
|
||||
assert "No quests today" in log
|
||||
assert 'role="status"' in log
|
||||
# The board distinguishes the same setup/unreachable cases at board level.
|
||||
assert "NestQuest is not set up yet" in board
|
||||
assert "The records cannot be reached" in board
|
||||
assert "BOARD_NOTICE_NOT_SET_UP" in board
|
||||
assert "BOARD_NOTICE_UNREACHABLE" in board
|
||||
|
||||
|
||||
def test_quest_complete_trigger() -> None:
|
||||
source = _read(QUEST_LOG)
|
||||
assert "binary_sensor.nestquest_${slug}_all_done" in source
|
||||
assert "nestquest_child_day_complete" in source
|
||||
assert "NESTQUEST_EVENT_TYPES" in source
|
||||
assert 'class="complete-screen"' in source
|
||||
assert "Quest complete" in source
|
||||
assert "Returning to The Party in" in source
|
||||
assert "_armCompleteTimer" in source
|
||||
assert "complete_screen_seconds" in source
|
||||
|
||||
|
||||
def test_panel_cards_reference_no_admin_only_services() -> None:
|
||||
# Only the kid panel sources are scanned: the bundle also contains the
|
||||
# sibling admin card, so its parent-only service names are expected there.
|
||||
for path in PANEL_SOURCES:
|
||||
text = _read(path)
|
||||
for service in ADMIN_ONLY_SERVICES:
|
||||
assert service not in text, f"{path}: {service}"
|
||||
# The bundle is still built and carries both panel card custom elements.
|
||||
assert BUNDLE_PATH.is_file()
|
||||
bundle = _read(BUNDLE_PATH)
|
||||
assert "nestquest-party-board-card" in bundle
|
||||
assert "nestquest-quest-log-card" in bundle
|
||||
@@ -11,6 +11,7 @@ from custom_components.nestquest.schema import (
|
||||
SCHEMA_V1_ADMIN_USERS_DDL,
|
||||
SCHEMA_V1_CHILDREN_DDL,
|
||||
SCHEMA_V1_COMPLETION_EVENTS_DDL,
|
||||
SCHEMA_V7_META_STATE_DDL,
|
||||
SCHEMA_V1_PRESENCE_OVERRIDES_DDL,
|
||||
SCHEMA_V1_PRESENCE_SCHEDULES_DDL,
|
||||
SCHEMA_V1_SCHEDULE_RULES_DDL,
|
||||
@@ -168,7 +169,7 @@ def _insert_override(database, child_id, **overrides):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_schema_v1_statements_compose_the_eight_tables() -> None:
|
||||
def test_schema_v1_statements_compose_the_nine_tables() -> None:
|
||||
assert SCHEMA_V1_STATEMENTS == [
|
||||
*SCHEMA_V1_CHILDREN_DDL,
|
||||
*SCHEMA_V1_ADMIN_USERS_DDL,
|
||||
@@ -180,6 +181,7 @@ def test_schema_v1_statements_compose_the_eight_tables() -> None:
|
||||
*SCHEMA_V1_PRESENCE_OVERRIDES_DDL,
|
||||
*SCHEMA_V1_QUEST_INSTANCES_DDL,
|
||||
*SCHEMA_V1_COMPLETION_EVENTS_DDL,
|
||||
*SCHEMA_V7_META_STATE_DDL,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -143,7 +143,7 @@ async def test_every_active_child_gets_five_sensors_with_documented_identity(
|
||||
assert entity.native_unit_of_measurement is None
|
||||
assert pct_entity.state_class == "measurement"
|
||||
assert pct_entity.native_unit_of_measurement == "%"
|
||||
assert pct_entity.device_class == "percentage"
|
||||
assert pct_entity.device_class is None
|
||||
device_info = due_entity.device_info
|
||||
assert device_info.name == f"NestQuest {name}"
|
||||
assert device_info.manufacturer == "NestQuest"
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
"""Tests for the nightly missed-quest sweep (Feature 11, task 1)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import re
|
||||
|
||||
from conftest import 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.completion import (
|
||||
complete_instance,
|
||||
uncomplete_instance,
|
||||
)
|
||||
from custom_components.nestquest.const import EVENT_QUEST_MISSED
|
||||
from custom_components.nestquest.dao_instances import (
|
||||
CompletionEventsDao,
|
||||
QuestInstancesDao,
|
||||
)
|
||||
from custom_components.nestquest.dao_meta import MetaStateDao
|
||||
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.sweep import (
|
||||
SWEEP_WATERMARK_KEY,
|
||||
run_missed_sweep,
|
||||
)
|
||||
|
||||
_TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$")
|
||||
|
||||
|
||||
async def _seed(hass, make_entry) -> tuple:
|
||||
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()}
|
||||
),
|
||||
[ada.id, bo.id],
|
||||
["morning"],
|
||||
)
|
||||
end = (today + datetime.timedelta(days=3)).isoformat()
|
||||
await materialize(database, today.isoformat(), end, today=today)
|
||||
return entry, ada, bo
|
||||
|
||||
|
||||
async def test_sweep_fires_one_event_per_past_due_open_instance(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
entry, ada, bo = await _seed(hass, make_entry)
|
||||
database = entry.runtime_data.database
|
||||
today = datetime.date.today()
|
||||
tomorrow = today + datetime.timedelta(days=1)
|
||||
|
||||
fired = await run_missed_sweep(hass, database, today=tomorrow)
|
||||
assert fired == 2, "one per child for today's now-past-due instance"
|
||||
|
||||
events = hass.bus.fired(EVENT_QUEST_MISSED)
|
||||
assert len(events) == 2
|
||||
payload = events[0]
|
||||
assert payload["child_name"] in ("Ada", "Bo")
|
||||
assert payload["quest_title"] == "Brush teeth"
|
||||
assert payload["window"] == "morning"
|
||||
assert payload["due_date"] == today.isoformat()
|
||||
assert payload["due_time"] is None
|
||||
assert _TIMESTAMP.match(payload["occurred_at"])
|
||||
assert {payload["child_id"] for payload in events} == {ada.id, bo.id}
|
||||
# The sweep is read-only over the domain tables.
|
||||
assert await _count_events(database) == 0
|
||||
|
||||
|
||||
async def test_sweep_is_idempotent_same_night(hass, make_entry) -> None:
|
||||
entry, _ada, _bo = await _seed(hass, make_entry)
|
||||
database = entry.runtime_data.database
|
||||
tomorrow = datetime.date.today() + datetime.timedelta(days=1)
|
||||
|
||||
first = await run_missed_sweep(hass, database, today=tomorrow)
|
||||
second = await run_missed_sweep(hass, database, today=tomorrow)
|
||||
assert first == 2
|
||||
assert second == 0, "the watermark makes a rerun fire nothing"
|
||||
assert len(hass.bus.fired(EVENT_QUEST_MISSED)) == 2
|
||||
assert await MetaStateDao(database).get(SWEEP_WATERMARK_KEY) == (
|
||||
tomorrow.isoformat()
|
||||
)
|
||||
|
||||
|
||||
async def test_sweep_only_fires_new_instances_after_watermark(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
entry, ada, _bo = await _seed(hass, make_entry)
|
||||
database = entry.runtime_data.database
|
||||
today = datetime.date.today()
|
||||
tomorrow = today + datetime.timedelta(days=1)
|
||||
day_after = today + datetime.timedelta(days=2)
|
||||
|
||||
# First sweep one day ahead: today's instances fire.
|
||||
fired = await run_missed_sweep(hass, database, today=tomorrow)
|
||||
assert fired == 2
|
||||
first_events = hass.bus.fired(EVENT_QUEST_MISSED)
|
||||
assert all(
|
||||
payload["due_date"] == today.isoformat()
|
||||
for payload in first_events
|
||||
)
|
||||
|
||||
# One more day passes: only tomorrow's instances fire now.
|
||||
fired = await run_missed_sweep(hass, database, today=day_after)
|
||||
assert fired == 2
|
||||
all_events = hass.bus.fired(EVENT_QUEST_MISSED)
|
||||
assert len(all_events) == 4
|
||||
assert all(
|
||||
payload["due_date"] == tomorrow.isoformat()
|
||||
for payload in all_events[2:]
|
||||
)
|
||||
|
||||
|
||||
async def test_sweep_never_reports_completed_or_reversed_instances(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
entry, ada, bo = await _seed(hass, make_entry)
|
||||
database = entry.runtime_data.database
|
||||
today = datetime.date.today()
|
||||
tomorrow = today + datetime.timedelta(days=1)
|
||||
|
||||
instances_ada = await QuestInstancesDao(database).list_by_date_range(
|
||||
ada.id, today.isoformat(), today.isoformat()
|
||||
)
|
||||
instances_bo = await QuestInstancesDao(database).list_by_date_range(
|
||||
bo.id, today.isoformat(), today.isoformat()
|
||||
)
|
||||
# Ada completed hers; Bo completed his then reversed it.
|
||||
await complete_instance(
|
||||
database,
|
||||
instances_ada[0].id,
|
||||
actor_source="panel",
|
||||
actor_child_id=ada.id,
|
||||
)
|
||||
await complete_instance(
|
||||
database,
|
||||
instances_bo[0].id,
|
||||
actor_source="panel",
|
||||
actor_child_id=bo.id,
|
||||
)
|
||||
await uncomplete_instance(
|
||||
database,
|
||||
instances_bo[0].id,
|
||||
actor_source="user",
|
||||
actor_user_id="admin-1",
|
||||
)
|
||||
|
||||
fired = await run_missed_sweep(hass, database, today=tomorrow)
|
||||
assert fired == 0, (
|
||||
"no-completion-event rule excludes both the completed and the "
|
||||
"reversed instance (append-only history keeps both)"
|
||||
)
|
||||
assert hass.bus.fired(EVENT_QUEST_MISSED) == []
|
||||
|
||||
|
||||
async def test_sweep_skips_instance_completed_after_sweep(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""A missed instance completed later is never re-announced; the
|
||||
late completion still lands in the append-only log."""
|
||||
entry, ada, _bo = await _seed(hass, make_entry)
|
||||
database = entry.runtime_data.database
|
||||
today = datetime.date.today()
|
||||
tomorrow = today + datetime.timedelta(days=1)
|
||||
day3 = today + datetime.timedelta(days=2)
|
||||
|
||||
await run_missed_sweep(hass, database, today=tomorrow)
|
||||
instance = (
|
||||
await QuestInstancesDao(database).list_by_date_range(
|
||||
ada.id, today.isoformat(), today.isoformat()
|
||||
)
|
||||
)[0]
|
||||
await complete_instance(
|
||||
database,
|
||||
instance.id,
|
||||
actor_source="panel",
|
||||
actor_child_id=ada.id,
|
||||
)
|
||||
fired = await run_missed_sweep(hass, database, today=day3)
|
||||
assert fired == 0 or all(
|
||||
payload["instance_id"] != instance.id
|
||||
for payload in hass.bus.fired(EVENT_QUEST_MISSED)[2:]
|
||||
)
|
||||
|
||||
|
||||
async def test_rollover_listener_runs_sweep_after_materialization(
|
||||
hass, make_entry, monkeypatch
|
||||
) -> None:
|
||||
import custom_components.nestquest as nestquest
|
||||
|
||||
calls = []
|
||||
|
||||
async def _record_sweep(hass_arg, database, **kwargs):
|
||||
calls.append("sweep")
|
||||
|
||||
monkeypatch.setattr(nestquest, "run_missed_sweep", _record_sweep)
|
||||
|
||||
entry = wire_entry_to_registry(
|
||||
make_entry(options={"day_rollover_time": "00:00"}), hass.registry
|
||||
)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
assert calls == ["sweep"], "startup runs the sweep once"
|
||||
|
||||
await hass.time_change.fire()
|
||||
assert calls == ["sweep", "sweep"], "the rollover listener sweeps too"
|
||||
|
||||
|
||||
async def test_v6_database_migrates_to_v7_meta_state(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""A database stamped at version 6 gains the meta_state table and
|
||||
version 7 on the next startup; existing rows are untouched."""
|
||||
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
database = entry.runtime_data.database
|
||||
version = await database.fetch_one(
|
||||
"SELECT version FROM nestquest_schema_version"
|
||||
)
|
||||
assert version == (7,)
|
||||
tables = await database.fetch_all(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' "
|
||||
"AND name='nestquest_meta_state'"
|
||||
)
|
||||
assert tables, "migration 7 created the meta_state table"
|
||||
|
||||
|
||||
async def _count_events(database) -> int:
|
||||
rows = await database.fetch_all("SELECT COUNT(*) FROM completion_events")
|
||||
return rows[0][0]
|
||||
|
||||
|
||||
async def test_overlapping_sweeps_never_double_announce(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""A startup sweep and a rollover sweep racing on the same night
|
||||
queue on the sweep lock: the winner announces, the loser re-reads
|
||||
the watermark inside the lock and fires nothing."""
|
||||
import asyncio
|
||||
|
||||
entry, _ada, _bo = await _seed(hass, make_entry)
|
||||
database = entry.runtime_data.database
|
||||
tomorrow = datetime.date.today() + datetime.timedelta(days=1)
|
||||
|
||||
first, second = await asyncio.gather(
|
||||
run_missed_sweep(hass, database, today=tomorrow),
|
||||
run_missed_sweep(hass, database, today=tomorrow),
|
||||
)
|
||||
assert first + second == 2, "each instance announced exactly once"
|
||||
assert len(hass.bus.fired(EVENT_QUEST_MISSED)) == 2
|
||||
@@ -20,15 +20,103 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jinja2"
|
||||
version = "3.1.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markupsafe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markupsafe"
|
||||
version = "3.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nestquest"
|
||||
version = "0.3.0"
|
||||
version = "0.5.0"
|
||||
source = { virtual = "." }
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "jinja2" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "voluptuous" },
|
||||
]
|
||||
|
||||
@@ -36,8 +124,10 @@ dev = [
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "jinja2", specifier = ">=3" },
|
||||
{ name = "pytest", specifier = ">=8.0.0" },
|
||||
{ name = "pytest-asyncio", specifier = ">=0.24" },
|
||||
{ name = "pyyaml", specifier = ">=6" },
|
||||
{ name = "voluptuous", specifier = ">=0.14" },
|
||||
]
|
||||
|
||||
@@ -97,6 +187,61 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.16.0"
|
||||
|
||||
Reference in New Issue
Block a user