Compare commits
82
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca93176768 | ||
|
|
e508c80465 | ||
|
|
3a2e6faa69 | ||
|
|
5092369d49 | ||
|
|
f6166f25d9 | ||
|
|
bf84793a1c | ||
|
|
9d3cb30617 | ||
|
|
b913772007 | ||
|
|
d2f9c50930 | ||
|
|
19a17b647d | ||
|
|
715aac994a | ||
|
|
ad6f1768d7 | ||
|
|
fcc7a29f05 | ||
|
|
f31bda2714 | ||
|
|
dc7a82f6e2 | ||
|
|
1d5143f850 | ||
|
|
fbde3eeaa1 | ||
|
|
3c832c1cee | ||
|
|
8c421abeb2 | ||
|
|
8cd43141a9 | ||
|
|
3de9f4b970 | ||
|
|
25005cda77 | ||
|
|
35e66bd9e6 | ||
|
|
a65e32f577 | ||
|
|
36263d3f19 | ||
|
|
58cd68354f | ||
|
|
a9d8f1d225 | ||
|
|
933e29ae76 | ||
|
|
a5dbae5d50 | ||
|
|
ad56d13c6e | ||
|
|
b90f55f750 | ||
|
|
2a4eec658f | ||
|
|
fe38e5040e | ||
|
|
fa153059cb | ||
|
|
5bca24910a | ||
|
|
99a8a133e2 | ||
|
|
df4c9eb21a | ||
|
|
236e8f4adf | ||
|
|
1f7a7ba46c | ||
|
|
813055a24c | ||
|
|
7a0c8d1885 | ||
|
|
00409a76db | ||
|
|
322038998b | ||
|
|
5c99987e7f | ||
|
|
979eafc5a2 | ||
|
|
3ac1d32e1b | ||
|
|
fc81b4b813 | ||
|
|
c1efd39ce3 | ||
|
|
cf67e9bed9 | ||
|
|
b5058a3bed | ||
|
|
eb4de7b565 | ||
|
|
d0f49b44b5 | ||
|
|
2a0e568940 | ||
|
|
37d7b7e6c3 | ||
|
|
dd7ea10936 | ||
|
|
4281b9d01c | ||
|
|
a2e9bdffee | ||
|
|
b1c42f10d0 | ||
|
|
2d30f0ce77 | ||
|
|
c4fb2fbac5 | ||
|
|
0820544fce | ||
|
|
318d77f443 | ||
|
|
2ec016ebfe | ||
|
|
c3495bb277 | ||
|
|
775a9906e7 | ||
|
|
293efe9705 | ||
|
|
22797cbc7e | ||
|
|
743703e943 | ||
|
|
6e32761b0c | ||
|
|
3add2d2f48 | ||
|
|
c001aca62d | ||
|
|
353d88e3af | ||
|
|
f6aad9d20f | ||
|
|
5706d8b7a6 | ||
|
|
be6367b600 | ||
|
|
ca26ced59f | ||
|
|
2146531665 | ||
|
|
168bb7b2f1 | ||
|
|
4544f78390 | ||
|
|
9836a6f524 | ||
|
|
d6823a54bc | ||
|
|
fa40e21dc2 |
@@ -38,6 +38,123 @@ After installation and restart:
|
||||
2. Click **Add Integration** and search for **NestQuest**.
|
||||
3. Follow the on-screen configuration flow.
|
||||
|
||||
## Events
|
||||
|
||||
NestQuest fires Home Assistant bus events so automations can react to a
|
||||
child's progress. Each payload carries `child_id`, `child_name`,
|
||||
`instance_id`, `quest_title`, the instance's `window`, `due_date`,
|
||||
`due_time`, and `occurred_at` (a strict UTC ISO-8601 timestamp).
|
||||
|
||||
| Event | Fires when | Extra payload fields |
|
||||
|---|---|---|
|
||||
| `nestquest_quest_completed` | a quest is completed (an actual completion, never a re-complete no-op) | `was_on_time` (bool) |
|
||||
| `nestquest_quest_uncompleted` | an admin reverses a completion | — |
|
||||
| `nestquest_child_day_complete` | a completion clears the child's whole day (quests were owed and none remain; a zero-quest day never fires it) | `quests_due`, `quests_completed` |
|
||||
| `nestquest_quest_missed` | the nightly sweep marks a still-open past-due quest missed (integrated in a later release) | — |
|
||||
|
||||
Example automation trigger:
|
||||
|
||||
```yaml
|
||||
triggers:
|
||||
- trigger: event
|
||||
event_type: nestquest_child_day_complete
|
||||
conditions:
|
||||
- condition: template
|
||||
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`.
|
||||
|
||||
## Developer Setup and Testing
|
||||
|
||||
This project uses [`uv`](https://github.com/astral-sh/uv) for fast Python package and dependency management.
|
||||
|
||||
@@ -2,22 +2,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import inspect
|
||||
import sqlite3
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.event import async_track_time_change
|
||||
|
||||
from .const import CONF_ADMIN_USER_IDS, DOMAIN, LOGGER
|
||||
from .const import (
|
||||
CONF_ADMIN_USER_IDS,
|
||||
CONF_DAY_ROLLOVER_TIME,
|
||||
CONF_HORIZON_DAYS,
|
||||
CONF_UPDATE_INTERVAL,
|
||||
DEFAULT_DAY_ROLLOVER_TIME,
|
||||
DEFAULT_HORIZON_DAYS,
|
||||
DEFAULT_UPDATE_INTERVAL,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
PLATFORMS,
|
||||
)
|
||||
from .coordinator import NestQuestCoordinator
|
||||
from .db import NestQuestDatabase
|
||||
from .materialize import materialize as _materialize_run
|
||||
from .migrations import apply_migrations
|
||||
from .services import async_deregister_services, async_register_services
|
||||
from .store import async_get_db_path
|
||||
from .admin_allowlist import seed_setup_admin
|
||||
from .sweep import run_missed_sweep
|
||||
|
||||
_LOGGER = LOGGER
|
||||
|
||||
@@ -224,6 +242,8 @@ class NestQuestRuntimeData:
|
||||
options: dict[str, Any]
|
||||
database: NestQuestDatabase
|
||||
remove_update_listener: Callable[[], Any]
|
||||
remove_time_change_listener: Callable[[], Any]
|
||||
coordinator: Any = None
|
||||
|
||||
|
||||
async def _async_owner_user_ids(hass: HomeAssistant) -> list[str]:
|
||||
@@ -248,6 +268,146 @@ async def _async_owner_user_ids(hass: HomeAssistant) -> list[str]:
|
||||
return owners
|
||||
|
||||
|
||||
def _day_rollover_hour_minute(options: dict[str, Any]) -> tuple[int, int]:
|
||||
"""Return the (hour, minute) the daily rollover fires at from ``options``.
|
||||
|
||||
The options flow validates ``day_rollover_time`` as a strict ``HH:MM``
|
||||
string, so a stored value never needs re-validation here; a missing key
|
||||
falls back to :data:`~.const.DEFAULT_DAY_ROLLOVER_TIME`.
|
||||
"""
|
||||
rollover = options.get(CONF_DAY_ROLLOVER_TIME, DEFAULT_DAY_ROLLOVER_TIME)
|
||||
hour, _, minute = rollover.partition(":")
|
||||
return int(hour), int(minute)
|
||||
|
||||
|
||||
def _configured_horizon_days(options: dict[str, Any]) -> int:
|
||||
"""Return the configured generation horizon, validated, else the default.
|
||||
|
||||
The options flow already validates ``horizon_days`` as an integer >= 1,
|
||||
but a stored ``entry.options`` dict could still carry a partial or
|
||||
hand-edited value. Re-checking here — and falling back to
|
||||
:data:`~.const.DEFAULT_HORIZON_DAYS` on a missing, non-int or sub-1 value
|
||||
— keeps a malformed option from shrinking or exploding the materialized
|
||||
horizon.
|
||||
"""
|
||||
value = options.get(CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS)
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
||||
return DEFAULT_HORIZON_DAYS
|
||||
return value
|
||||
|
||||
|
||||
async def _run_horizon_materialization(
|
||||
hass: HomeAssistant,
|
||||
database: NestQuestDatabase,
|
||||
horizon_days: int = DEFAULT_HORIZON_DAYS,
|
||||
) -> None:
|
||||
"""Materialize the rolling horizon ``[today, today + horizon_days]``.
|
||||
|
||||
"today" is computed in HA local time (``hass.config.time_zone``) rather
|
||||
than the system clock, so the horizon tracks the household's own day.
|
||||
The walk itself runs through :func:`~.materialize.materialize`, whose DB
|
||||
access is executor-only.
|
||||
|
||||
This is the ONE generation path: the startup backfill, the daily rollover
|
||||
listener and the ``regenerate`` service all funnel through it, so there is
|
||||
no duplicated materialization logic. ``horizon_days`` sizes the window
|
||||
and defaults to :data:`~.const.DEFAULT_HORIZON_DAYS`; callers thread the
|
||||
entry's validated option value through.
|
||||
"""
|
||||
time_zone = ZoneInfo(hass.config.time_zone)
|
||||
today = datetime.datetime.now(time_zone).date()
|
||||
start_date = today.isoformat()
|
||||
end_date = (
|
||||
today + datetime.timedelta(days=horizon_days)
|
||||
).isoformat()
|
||||
await _materialize_run(database, start_date, end_date, today=today)
|
||||
|
||||
|
||||
def _register_day_rollover_listener(
|
||||
hass: HomeAssistant,
|
||||
database: NestQuestDatabase,
|
||||
options: dict[str, Any],
|
||||
) -> Callable[[], Any]:
|
||||
"""Register the daily rollover listener and return its remover.
|
||||
|
||||
Fires once per day at the configured local ``day_rollover_time``:
|
||||
first re-materializes the rolling horizon
|
||||
``[today, today + horizon_days]`` through the shared
|
||||
:func:`_run_horizon_materialization` path (the entry's configured,
|
||||
validated ``horizon_days``), 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
|
||||
)
|
||||
|
||||
|
||||
def _find_live_runtime_data(hass: HomeAssistant) -> NestQuestRuntimeData | None:
|
||||
"""Return a live runtime record with a connected database, or None.
|
||||
|
||||
The domain-global services (e.g. ``regenerate``) have no database of
|
||||
their own; they resolve whichever config entry's runtime data is
|
||||
currently live at call time, so none of them can hold a stale handle
|
||||
to a closed connection after another entry unloads. The record also
|
||||
carries that entry's options (e.g. the configured horizon).
|
||||
"""
|
||||
for runtime_data in hass.data.get(DOMAIN, {}).values():
|
||||
database = getattr(runtime_data, "database", None)
|
||||
if database is not None and database.connected:
|
||||
return runtime_data
|
||||
return None
|
||||
|
||||
|
||||
def _make_regenerate_handler(hass: HomeAssistant):
|
||||
"""Return the Feature 07 ``regenerate`` handler closed over ``hass``."""
|
||||
|
||||
async def _regenerate(_call: Any) -> None:
|
||||
runtime_data = _find_live_runtime_data(hass)
|
||||
if runtime_data is None:
|
||||
_LOGGER.warning(
|
||||
"NestQuest regenerate requested but no config entry has a "
|
||||
"live database; ignoring"
|
||||
)
|
||||
return
|
||||
horizon_days = _configured_horizon_days(runtime_data.options)
|
||||
await _run_horizon_materialization(
|
||||
hass, runtime_data.database, horizon_days
|
||||
)
|
||||
|
||||
return _regenerate
|
||||
|
||||
|
||||
def _register_services(hass: HomeAssistant) -> None:
|
||||
"""Register domain-global NestQuest services once.
|
||||
|
||||
Services are DOMAIN-global — registered exactly once (guarded by
|
||||
``has_service`` on regenerate) and NOT bound to any config entry —
|
||||
so each handler resolves the currently-live database at call time
|
||||
and a name collision or duplicate setup is impossible. Removal is
|
||||
handled by the unload path only once the LAST entry is removed
|
||||
(see :func:`_deregister_services`).
|
||||
"""
|
||||
async_register_services(
|
||||
hass,
|
||||
find_runtime=_find_live_runtime_data,
|
||||
regenerate_handler=_make_regenerate_handler(hass),
|
||||
)
|
||||
|
||||
|
||||
def _deregister_services(hass: HomeAssistant) -> None:
|
||||
"""Remove domain-global NestQuest services when the last entry unloads."""
|
||||
async_deregister_services(hass)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
|
||||
"""Set up the NestQuest integration. YAML configuration is not used, returns True."""
|
||||
return True
|
||||
@@ -305,6 +465,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
await listener_hass.config_entries.async_reload(listener_entry.entry_id)
|
||||
|
||||
database = await _async_open_database(hass, await async_get_db_path(hass))
|
||||
remove_update_listener: Callable[[], Any] | None = None
|
||||
remove_time_change_listener: Callable[[], Any] | None = None
|
||||
coordinator: Any = None
|
||||
try:
|
||||
# Seed the admin allowlist on first setup so the owner is never
|
||||
# locked out: an empty database takes the persisted admin copy
|
||||
@@ -330,16 +493,87 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
owner_ids=owner_ids,
|
||||
)
|
||||
remove_update_listener = entry.add_update_listener(_async_update_listener)
|
||||
remove_time_change_listener = _register_day_rollover_listener(
|
||||
hass, database, dict(entry.options)
|
||||
)
|
||||
# Backfill any days the daily listener missed while the integration
|
||||
# was off (or freshly installed): run the one idempotent walk over
|
||||
# [today, today + horizon_days] once at setup, using the entry's
|
||||
# configured horizon. This runs through the SAME path as the daily
|
||||
# listener and regenerate service.
|
||||
await _run_horizon_materialization(
|
||||
hass, database, _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
|
||||
# five minutes). Created AFTER services so a failure below
|
||||
# unwinds them through the except path's listener handling.
|
||||
coordinator = NestQuestCoordinator(
|
||||
hass,
|
||||
entry_id=entry.entry_id,
|
||||
database=database,
|
||||
update_interval_seconds=entry.options.get(
|
||||
CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL
|
||||
),
|
||||
)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
runtime_data = NestQuestRuntimeData(
|
||||
entry_id=entry.entry_id,
|
||||
options=dict(entry.options),
|
||||
database=database,
|
||||
remove_update_listener=remove_update_listener,
|
||||
remove_time_change_listener=remove_time_change_listener,
|
||||
coordinator=coordinator,
|
||||
)
|
||||
# runtime_data is assigned BEFORE the platforms forward so each
|
||||
# platform's async_setup_entry reads its coordinator off the
|
||||
# entry (the HA runtime-data contract); a forward failure below
|
||||
# unwinds it through the except path.
|
||||
entry.runtime_data = runtime_data
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
except BaseException:
|
||||
# A failure anywhere after the database is open must not leak the
|
||||
# listeners already registered against that (now-closed) database:
|
||||
# unwind EVERY remover we acquired before the failure, keeping the
|
||||
# original error as the one raised. Removal is best-effort — a
|
||||
# failing remover must not mask the setup error that triggered it.
|
||||
for remove_listener in (
|
||||
remove_time_change_listener,
|
||||
remove_update_listener,
|
||||
):
|
||||
if remove_listener is None:
|
||||
continue
|
||||
try:
|
||||
result = remove_listener()
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
except BaseException:
|
||||
pass
|
||||
# A coordinator created before the failure holds entity
|
||||
# listeners pointed at the database being closed below —
|
||||
# shut it down FIRST so no scheduled refresh can ever run
|
||||
# against a closed connection.
|
||||
if coordinator is not None:
|
||||
try:
|
||||
await coordinator.async_shutdown()
|
||||
except BaseException:
|
||||
pass
|
||||
# A platform-forward failure reached this unwind AFTER
|
||||
# runtime_data was assigned: clear it so no later unload path
|
||||
# closes the already-closed database through the stale record.
|
||||
if getattr(entry, "runtime_data", None) is not None:
|
||||
entry.runtime_data = None
|
||||
await database.close()
|
||||
raise
|
||||
runtime_data = NestQuestRuntimeData(
|
||||
entry_id=entry.entry_id,
|
||||
options=dict(entry.options),
|
||||
database=database,
|
||||
remove_update_listener=remove_update_listener,
|
||||
)
|
||||
entry.runtime_data = runtime_data
|
||||
assert remove_update_listener is not None
|
||||
assert remove_time_change_listener is not None
|
||||
hass.data[DOMAIN][entry.entry_id] = runtime_data
|
||||
return True
|
||||
|
||||
@@ -348,28 +582,71 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
domain_data = hass.data.setdefault(DOMAIN, {})
|
||||
runtime_data = domain_data.pop(entry.entry_id, None)
|
||||
if not domain_data:
|
||||
is_last = not domain_data
|
||||
if is_last:
|
||||
hass.data.pop(DOMAIN, None)
|
||||
if runtime_data is None:
|
||||
runtime_data = getattr(entry, "runtime_data", None)
|
||||
# Platforms unload FIRST (mirroring HA conventions): entity
|
||||
# listeners must not outlive the database and coordinator the
|
||||
# teardown below closes.
|
||||
platforms_unloaded = await hass.config_entries.async_unload_platforms(
|
||||
entry, PLATFORMS
|
||||
)
|
||||
if platforms_unloaded is False and runtime_data is not None:
|
||||
# Home Assistant KEPT one or more platforms loaded — their
|
||||
# entities still live and must keep their database. Restore
|
||||
# the runtime record popped above and report failure so HA
|
||||
# retries the unload later instead of leaving live entities
|
||||
# backed by a closed database.
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = runtime_data
|
||||
return False
|
||||
unload_error: BaseException | None = None
|
||||
if runtime_data is not None:
|
||||
remove_update_listener = getattr(runtime_data, "remove_update_listener", None)
|
||||
unload_error: BaseException | None = None
|
||||
remove_time_change_listener = getattr(
|
||||
runtime_data, "remove_time_change_listener", None
|
||||
)
|
||||
try:
|
||||
if remove_update_listener is not None:
|
||||
result = remove_update_listener()
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
except BaseException as err:
|
||||
unload_error = err
|
||||
for remove_listener in (
|
||||
remove_update_listener,
|
||||
remove_time_change_listener,
|
||||
):
|
||||
if remove_listener is None:
|
||||
continue
|
||||
try:
|
||||
result = remove_listener()
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
except BaseException as err:
|
||||
# Cancel EVERY listener even when an earlier remover
|
||||
# raises: a skipped remover would leave a daily
|
||||
# callback firing against a closed database after
|
||||
# unload. The first error is retained and re-raised
|
||||
# only after both removers (and the DB close) ran.
|
||||
if unload_error is None:
|
||||
unload_error = err
|
||||
finally:
|
||||
# The coordinator's entity listeners must stop BEFORE the
|
||||
# database closes: a scheduled refresh starting during the
|
||||
# close await would touch a cleared connection. (The
|
||||
# platform unload above already removed the entities; this
|
||||
# clears the coordinator's own remaining listeners.)
|
||||
coordinator = getattr(runtime_data, "coordinator", None)
|
||||
if coordinator is not None:
|
||||
shutdown = getattr(coordinator, "async_shutdown", None)
|
||||
if shutdown is not None:
|
||||
await shutdown()
|
||||
database = getattr(runtime_data, "database", None)
|
||||
if database is not None:
|
||||
await database.close()
|
||||
if unload_error is not None:
|
||||
if getattr(entry, "runtime_data", None) is not None:
|
||||
entry.runtime_data = None
|
||||
raise unload_error
|
||||
# Domain-global services are torn down only once the FINAL entry
|
||||
# unloads (``hass.data[DOMAIN]`` is now empty), never when a sibling
|
||||
# entry is still loaded.
|
||||
if is_last:
|
||||
_deregister_services(hass)
|
||||
if getattr(entry, "runtime_data", None) is not None:
|
||||
entry.runtime_data = None
|
||||
if unload_error is not None:
|
||||
raise unload_error
|
||||
return True
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Per-child binary sensors (Feature 10, task 4).
|
||||
|
||||
Two binary sensors per active child, both reading only the coordinator
|
||||
snapshot (the same ChildDaySnapshot the day sensors read):
|
||||
|
||||
- ``binary_sensor.nestquest_<child>_all_done`` — ON exactly when the
|
||||
child owes quests today (due today > 0) AND owes none of them any
|
||||
more (remaining today == 0). A zero-quest day is OFF: the
|
||||
celebration automation must never fire on an empty day (the
|
||||
day-complete event carries the same rule, Feature 10 task 7).
|
||||
- ``binary_sensor.nestquest_<child>_present_today`` — ON when the
|
||||
presence engine resolves the child present on the HA-local date
|
||||
(schedules and overrides, D-004 anchor-date arithmetic).
|
||||
|
||||
Naming and registry identity follow the day sensors' contract: names
|
||||
slug to the documented entity_id shape; unique_ids are built from the
|
||||
child's database id, never the display name, so a rename never
|
||||
orphans an entity's history.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Iterable
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import ChildDaySnapshot, NestQuestCoordinator
|
||||
|
||||
|
||||
class _NestQuestChildBinarySensor(CoordinatorEntity, BinarySensorEntity):
|
||||
"""Base for the per-child binary sensors.
|
||||
|
||||
Registry identity is built from the child's database id and never
|
||||
changes; the displayed name and device label re-derive from the
|
||||
child's CURRENT snapshot on every read, so a rename propagates on
|
||||
the next refresh without orphaning history. A child absent from
|
||||
the latest snapshot (a deactivation; removal is a later task's
|
||||
scope) yields ``None`` states rather than stale values.
|
||||
"""
|
||||
|
||||
_name_suffix: str = ""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: NestQuestCoordinator,
|
||||
child: ChildDaySnapshot,
|
||||
) -> None:
|
||||
super().__init__(coordinator)
|
||||
self._child_id = child.child_id
|
||||
self._fallback_name = child.child_name
|
||||
|
||||
@property
|
||||
def _child(self) -> ChildDaySnapshot | None:
|
||||
"""Return the child's current day snapshot, or None."""
|
||||
data = self.coordinator.data
|
||||
if data is None:
|
||||
return None
|
||||
for child in data.children:
|
||||
if child.child_id == self._child_id:
|
||||
return child
|
||||
return None
|
||||
|
||||
def _displayed_child_name(self) -> str:
|
||||
child = self._child
|
||||
return child.child_name if child is not None else self._fallback_name
|
||||
|
||||
@property
|
||||
def name(self) -> str | None:
|
||||
return f"NestQuest {self._displayed_child_name()} {self._name_suffix}"
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, f"child_{self._child_id}")},
|
||||
name=f"NestQuest {self._displayed_child_name()}",
|
||||
manufacturer="NestQuest",
|
||||
)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||
child = self._child
|
||||
if child is None:
|
||||
return None
|
||||
return {
|
||||
"child_id": child.child_id,
|
||||
"child_name": child.child_name,
|
||||
}
|
||||
|
||||
|
||||
class NestQuestAllDoneSensor(_NestQuestChildBinarySensor):
|
||||
"""ON when the child had quests today and owes none of them."""
|
||||
|
||||
_name_suffix = "all done"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: NestQuestCoordinator,
|
||||
child: ChildDaySnapshot,
|
||||
) -> None:
|
||||
super().__init__(coordinator, child)
|
||||
self._attr_unique_id = f"{DOMAIN}_child_{child.child_id}_all_done"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
child = self._child
|
||||
if child is None:
|
||||
return None
|
||||
# Zero-quest days are OFF, not ON: an empty day must never fire
|
||||
# the celebration automation.
|
||||
return child.due_today > 0 and child.remaining_today == 0
|
||||
|
||||
|
||||
class NestQuestPresentTodaySensor(_NestQuestChildBinarySensor):
|
||||
"""ON when the child is at this house today (presence engine)."""
|
||||
|
||||
_name_suffix = "present today"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: NestQuestCoordinator,
|
||||
child: ChildDaySnapshot,
|
||||
) -> None:
|
||||
super().__init__(coordinator, child)
|
||||
self._attr_unique_id = f"{DOMAIN}_child_{child.child_id}_present_today"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
child = self._child
|
||||
if child is None:
|
||||
return None
|
||||
return child.present
|
||||
|
||||
|
||||
def _child_binary_sensors(
|
||||
coordinator: NestQuestCoordinator, child: ChildDaySnapshot
|
||||
) -> tuple[_NestQuestChildBinarySensor, ...]:
|
||||
"""Build the two documented binary sensors for one active child."""
|
||||
return (
|
||||
NestQuestAllDoneSensor(coordinator, child),
|
||||
NestQuestPresentTodaySensor(coordinator, child),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: Callable[..., None],
|
||||
) -> None:
|
||||
"""Create the two binary sensors per active child.
|
||||
|
||||
Entities are created for the snapshot's children at platform
|
||||
setup; a coordinator listener adds sensors for children that first
|
||||
appear on a later refresh (the day sensors' same pattern, so a new
|
||||
child's whole entity set lands together). Deactivated children
|
||||
are NOT removed here — removal is a later task's scope.
|
||||
"""
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
known_child_ids: set[int] = set()
|
||||
|
||||
def _add_new_children(children: Iterable[ChildDaySnapshot]) -> None:
|
||||
new_children = [
|
||||
child
|
||||
for child in children
|
||||
if child.child_id not in known_child_ids
|
||||
]
|
||||
if not new_children:
|
||||
return
|
||||
known_child_ids.update(child.child_id for child in new_children)
|
||||
async_add_entities(
|
||||
[
|
||||
sensor
|
||||
for child in new_children
|
||||
for sensor in _child_binary_sensors(coordinator, child)
|
||||
]
|
||||
)
|
||||
|
||||
data = coordinator.data
|
||||
if data is not None:
|
||||
_add_new_children(data.children)
|
||||
|
||||
def _handle_coordinator_update() -> None:
|
||||
data = coordinator.data
|
||||
if data is not None:
|
||||
_add_new_children(data.children)
|
||||
|
||||
coordinator.async_add_listener(_handle_coordinator_update)
|
||||
@@ -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 -%}
|
||||
@@ -0,0 +1,549 @@
|
||||
"""Business layer for the completion event log (Feature 08).
|
||||
|
||||
Sits between the Feature 09 service gate and the typed
|
||||
:class:`~.dao_instances.CompletionEventsDao`: callers get actor-shape
|
||||
validation and the UTC occurred-at stamp here, and never talk to the
|
||||
DAO or SQL directly. This module is the only Feature-08 caller of
|
||||
:meth:`~.dao_instances.CompletionEventsDao.append`. Completing an
|
||||
instance (no-op if already done) is :func:`complete_instance`;
|
||||
un-completing (no-op if already open) is :func:`uncomplete_instance`.
|
||||
Instance current state is derived from the latest event via
|
||||
:func:`instance_state` (``open`` / ``done`` / ``missed``); there is
|
||||
no status column. ``missed`` is derived for past-due open instances
|
||||
(``due_date`` before HA-local ``today``) and is never stored as an
|
||||
event_type.
|
||||
|
||||
Actor policy matches the schema CHECKs (D-008): ``actor_source`` is
|
||||
``'user'`` or ``'panel'`` only — never ``'service'``. ``'user'``
|
||||
requires ``actor_user_id`` and forbids ``actor_child_id``; ``'panel'``
|
||||
requires ``actor_child_id`` (the tapped profile) and forbids
|
||||
``actor_user_id``. Inverted shapes raise a field-naming ValueError
|
||||
before any write.
|
||||
|
||||
``occurred_at`` is stamped UTC ISO-8601 at second precision with an
|
||||
explicit ``+00:00`` offset, matching the DAO's one accepted shape so
|
||||
lexicographic range queries stay exact. ``was_on_time`` is required
|
||||
(True/False) for completed events; un-completions may pass None. There
|
||||
is no permission check here (that is Feature 09).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
|
||||
from .dao_children import _CONNECTION_LOCKS, _connection_lock
|
||||
from .dao_instances import (
|
||||
EVENT_COMPLETED,
|
||||
EVENT_UNCOMPLETED,
|
||||
CompletionEventRecord,
|
||||
CompletionEventsDao,
|
||||
QuestInstanceRecord,
|
||||
QuestInstancesDao,
|
||||
)
|
||||
from .db import NestQuestDatabase
|
||||
|
||||
#: Timestamp policy for completion_events.occurred_at: strict UTC
|
||||
#: ISO-8601 with explicit '+00:00' offset (module timestamp policy).
|
||||
_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00"
|
||||
|
||||
|
||||
def _now_stamp() -> str:
|
||||
"""Return the module's strict UTC second-precision stamp."""
|
||||
return datetime.datetime.now(datetime.timezone.utc).strftime(
|
||||
_UTC_TIMESTAMP_FORMAT
|
||||
)
|
||||
|
||||
|
||||
def _stamp_occurred_at(now: datetime.datetime | None) -> str:
|
||||
"""Return a DAO-accepted UTC stamp for ``now``, or the current time."""
|
||||
if now is None:
|
||||
return _now_stamp()
|
||||
if not isinstance(now, datetime.datetime):
|
||||
raise ValueError(f"now must be a datetime, got {now!r}")
|
||||
if now.tzinfo is None:
|
||||
raise ValueError("now must be timezone-aware")
|
||||
return now.astimezone(datetime.timezone.utc).strftime(
|
||||
_UTC_TIMESTAMP_FORMAT
|
||||
)
|
||||
|
||||
|
||||
def _completion_moment(
|
||||
now: datetime.datetime | None,
|
||||
today: datetime.date | None,
|
||||
) -> tuple[datetime.date, datetime.time | None]:
|
||||
"""Return the HA-local completion date and optional wall-clock time.
|
||||
|
||||
``today`` is the HA-local calendar date when threaded (Feature 07
|
||||
style). ``now`` is a timezone-aware HA-local datetime; its wall
|
||||
clock is used as-is and is never converted to UTC. When ``today``
|
||||
is omitted the date comes from ``now``. When both are omitted the
|
||||
host calendar date is the Feature 07 fallback — never the UTC
|
||||
clock. Time-of-day is None unless ``now`` is supplied; comparing
|
||||
against ``due_time`` requires ``now``.
|
||||
"""
|
||||
if now is not None:
|
||||
if not isinstance(now, datetime.datetime):
|
||||
raise ValueError(f"now must be a datetime, got {now!r}")
|
||||
if now.tzinfo is None:
|
||||
raise ValueError("now must be timezone-aware")
|
||||
if today is not None:
|
||||
completion_date = today
|
||||
elif now is not None:
|
||||
completion_date = now.date()
|
||||
else:
|
||||
completion_date = datetime.date.today()
|
||||
if now is None:
|
||||
return completion_date, None
|
||||
return completion_date, now.time()
|
||||
|
||||
|
||||
def _resolve_today(today: datetime.date | None) -> datetime.date:
|
||||
"""Return the threaded HA-local date, or the host calendar date."""
|
||||
if today is None:
|
||||
return datetime.date.today()
|
||||
return today
|
||||
|
||||
|
||||
def _derived_state(
|
||||
instance: QuestInstanceRecord,
|
||||
latest: CompletionEventRecord | None,
|
||||
today: datetime.date,
|
||||
) -> str:
|
||||
"""Return ``open``, ``done``, or ``missed`` from event plus due date.
|
||||
|
||||
A latest completed event is ``done`` even when past due. No events
|
||||
or a latest uncompleted event is ``open``, or ``missed`` when
|
||||
``due_date`` is before ``today``. ``missed`` is never an event_type.
|
||||
"""
|
||||
if latest is None or latest.event_type == EVENT_UNCOMPLETED:
|
||||
due_date = datetime.date.fromisoformat(instance.due_date)
|
||||
if due_date < today:
|
||||
return "missed"
|
||||
return "open"
|
||||
if latest.event_type == EVENT_COMPLETED:
|
||||
return "done"
|
||||
raise ValueError(
|
||||
f"event_type must be '{EVENT_COMPLETED}' or "
|
||||
f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}"
|
||||
)
|
||||
|
||||
|
||||
def derive_state(
|
||||
instance: QuestInstanceRecord,
|
||||
latest: CompletionEventRecord | None,
|
||||
today: datetime.date,
|
||||
) -> str:
|
||||
"""Public state derivation for the Feature 10 coordinator.
|
||||
|
||||
Thin wrapper over :func:`_derived_state`: ``open``, ``done``, or
|
||||
``missed`` from the latest event plus the due date (``missed`` is
|
||||
derived, never an event_type). ``today`` is the caller-resolved
|
||||
HA-local date.
|
||||
"""
|
||||
return _derived_state(instance, latest, today)
|
||||
|
||||
|
||||
def _was_on_time(
|
||||
instance: QuestInstanceRecord,
|
||||
now: datetime.datetime | None,
|
||||
today: datetime.date | None,
|
||||
) -> bool:
|
||||
"""Return whether the completion moment is on time for ``instance``.
|
||||
|
||||
On ``due_date`` before ``due_time`` is on time; on ``due_date``
|
||||
after ``due_time``, or any later date, is late. With no
|
||||
``due_time``, on time if the completion date equals ``due_date``
|
||||
(or is earlier); late if after ``due_date``. Same-day
|
||||
``due_time`` comparison requires ``now``; UTC time-of-day is never
|
||||
substituted.
|
||||
"""
|
||||
completion_date, completion_time = _completion_moment(now, today)
|
||||
due_date = datetime.date.fromisoformat(instance.due_date)
|
||||
if completion_date < due_date:
|
||||
return True
|
||||
if completion_date > due_date:
|
||||
return False
|
||||
if instance.due_time is None:
|
||||
return True
|
||||
if completion_time is None:
|
||||
raise ValueError("now is required to compare due_time")
|
||||
due_time = datetime.datetime.strptime(instance.due_time, "%H:%M").time()
|
||||
return completion_time <= due_time
|
||||
|
||||
|
||||
class _TaskReentrantLock:
|
||||
"""Shared connection lock the owning task may re-enter."""
|
||||
|
||||
def __init__(self, inner: asyncio.Lock) -> None:
|
||||
self._inner = inner
|
||||
self._task: asyncio.Task | None = None
|
||||
self._depth = 0
|
||||
|
||||
def locked(self) -> bool:
|
||||
return self._inner.locked()
|
||||
|
||||
async def acquire(self) -> bool:
|
||||
task = asyncio.current_task()
|
||||
if self._task is task:
|
||||
self._depth += 1
|
||||
return True
|
||||
await self._inner.acquire()
|
||||
self._task = task
|
||||
self._depth = 1
|
||||
return True
|
||||
|
||||
def release(self) -> None:
|
||||
if self._task is not asyncio.current_task():
|
||||
raise RuntimeError("lock released by a non-owner")
|
||||
self._depth -= 1
|
||||
if self._depth == 0:
|
||||
self._task = None
|
||||
self._inner.release()
|
||||
|
||||
async def __aenter__(self) -> _TaskReentrantLock:
|
||||
await self.acquire()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc: object) -> None:
|
||||
self.release()
|
||||
|
||||
|
||||
def _reentrant_connection_lock(database) -> _TaskReentrantLock:
|
||||
"""Return the shared connection lock, made task-reentrant.
|
||||
|
||||
``CompletionEventsDao.append`` also acquires this lock, so
|
||||
check+append in :func:`complete_instance` and
|
||||
:func:`uncomplete_instance` must re-enter on the same task. The
|
||||
wrapper is stored in the shared dict so the DAO sees the same
|
||||
object.
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
key = (id(database), id(loop))
|
||||
lock = _CONNECTION_LOCKS.get(key)
|
||||
if isinstance(lock, _TaskReentrantLock):
|
||||
return lock
|
||||
wrapped = _TaskReentrantLock(
|
||||
lock if lock is not None else asyncio.Lock()
|
||||
)
|
||||
_CONNECTION_LOCKS[key] = wrapped
|
||||
return wrapped
|
||||
|
||||
|
||||
def _validate_int_id(value: object, field: str) -> int:
|
||||
"""Reject non-int ids (bools included) before any lookup."""
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError(f"{field} must be an integer, got {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_event_type(value: object) -> str:
|
||||
"""Return a schema-legal event type, or raise naming the field."""
|
||||
if value not in (EVENT_COMPLETED, EVENT_UNCOMPLETED):
|
||||
raise ValueError(
|
||||
f"event_type must be '{EVENT_COMPLETED}' or "
|
||||
f"'{EVENT_UNCOMPLETED}', got {value!r}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _validate_actor(
|
||||
actor_source: object,
|
||||
actor_user_id: object,
|
||||
actor_child_id: object,
|
||||
) -> tuple[str, str | None, int | None]:
|
||||
"""Return a schema-legal actor triple, or raise naming the field.
|
||||
|
||||
``'user'`` requires a non-empty ``actor_user_id`` string and forbids
|
||||
``actor_child_id``. ``'panel'`` requires an integer
|
||||
``actor_child_id`` and forbids ``actor_user_id``. Any other
|
||||
``actor_source`` (including ``'service'``) is rejected.
|
||||
"""
|
||||
if actor_source not in ("user", "panel"):
|
||||
raise ValueError(
|
||||
f"actor_source must be 'user' or 'panel', got {actor_source!r}"
|
||||
)
|
||||
if actor_source == "user":
|
||||
if not isinstance(actor_user_id, str) or not actor_user_id.strip():
|
||||
raise ValueError("actor_source 'user' requires actor_user_id")
|
||||
if actor_child_id is not None:
|
||||
raise ValueError(
|
||||
"actor_source 'user' must not carry actor_child_id"
|
||||
)
|
||||
return "user", actor_user_id.strip(), None
|
||||
if actor_user_id is not None:
|
||||
raise ValueError("actor_source 'panel' must not carry actor_user_id")
|
||||
if actor_child_id is None:
|
||||
raise ValueError(
|
||||
"actor_source 'panel' requires actor_child_id"
|
||||
)
|
||||
return (
|
||||
"panel",
|
||||
None,
|
||||
_validate_int_id(actor_child_id, "actor_child_id"),
|
||||
)
|
||||
|
||||
|
||||
def _validate_was_on_time(
|
||||
event_type: str, was_on_time: object
|
||||
) -> bool | None:
|
||||
"""Return a real bool or None; completed events require True/False."""
|
||||
if was_on_time is not None and type(was_on_time) is not bool:
|
||||
raise ValueError(
|
||||
f"was_on_time must be True, False or None, got {was_on_time!r}"
|
||||
)
|
||||
if event_type == EVENT_COMPLETED and was_on_time is None:
|
||||
raise ValueError(
|
||||
"a completed event requires was_on_time (True/False)"
|
||||
)
|
||||
return was_on_time
|
||||
|
||||
|
||||
async def append_event(
|
||||
database: NestQuestDatabase,
|
||||
instance_id: int,
|
||||
child_id: int,
|
||||
event_type: str,
|
||||
*,
|
||||
actor_source: str,
|
||||
actor_user_id: str | None = None,
|
||||
actor_child_id: int | None = None,
|
||||
was_on_time: bool | None = None,
|
||||
now: datetime.datetime | None = None,
|
||||
) -> CompletionEventRecord:
|
||||
"""Append one completion or un-completion event and return the row.
|
||||
|
||||
Validates actor shapes and the completed-event on-time requirement
|
||||
before calling :meth:`CompletionEventsDao.append`. ``occurred_at``
|
||||
is stamped here (UTC ``+00:00``); pass ``now`` to pin the clock.
|
||||
"""
|
||||
instance_id = _validate_int_id(instance_id, "instance_id")
|
||||
child_id = _validate_int_id(child_id, "child_id")
|
||||
event_type = _validate_event_type(event_type)
|
||||
actor_source, actor_user_id, actor_child_id = _validate_actor(
|
||||
actor_source, actor_user_id, actor_child_id
|
||||
)
|
||||
was_on_time = _validate_was_on_time(event_type, was_on_time)
|
||||
occurred_at = _stamp_occurred_at(now)
|
||||
return await CompletionEventsDao(database).append(
|
||||
instance_id,
|
||||
child_id,
|
||||
event_type,
|
||||
actor_source,
|
||||
occurred_at,
|
||||
was_on_time,
|
||||
actor_user_id=actor_user_id,
|
||||
actor_child_id=actor_child_id,
|
||||
)
|
||||
|
||||
|
||||
async def instance_state(
|
||||
database: NestQuestDatabase,
|
||||
instance_id: int,
|
||||
*,
|
||||
today: datetime.date | None = None,
|
||||
) -> str:
|
||||
"""Return ``open``, ``done``, or ``missed`` from the latest event.
|
||||
|
||||
No events, or a latest event of uncompleted, is ``open`` unless
|
||||
``due_date`` is before ``today`` (HA-local, threaded), in which
|
||||
case it is ``missed``. A latest event of completed is ``done``
|
||||
even when past due. ``missed`` is never stored as an event_type.
|
||||
Unknown ``instance_id`` raises ValueError naming the field.
|
||||
Existence and the latest event are read inside the connection lock
|
||||
so the answer is one snapshot.
|
||||
"""
|
||||
instance_id = _validate_int_id(instance_id, "instance_id")
|
||||
today_date = _resolve_today(today)
|
||||
async with _connection_lock(database):
|
||||
instance = await QuestInstancesDao(database).get_by_id(instance_id)
|
||||
if instance is None:
|
||||
raise ValueError(
|
||||
f"instance_id: quest instance {instance_id} does not exist"
|
||||
)
|
||||
latest = await CompletionEventsDao(database).get_latest_for_instance(
|
||||
instance_id
|
||||
)
|
||||
return _derived_state(instance, latest, today_date)
|
||||
|
||||
|
||||
class CompletionResult(str):
|
||||
"""The derived instance state, carrying the append verdict.
|
||||
|
||||
A ``str`` subclass so every existing ``result == "done"`` contract
|
||||
keeps working; ``appended`` reports whether THIS call actually
|
||||
appended an event (decided under the same lock as the append), so
|
||||
a caller — the Feature 09 service path firing bus events —
|
||||
announces a transition exactly once even under a concurrent
|
||||
duplicate call where the loser's write is a no-op. A completion
|
||||
also carries the ``was_on_time`` verdict computed under that same
|
||||
lock, so the event payload never re-reads mutable latest state a
|
||||
racing reversal could have replaced.
|
||||
"""
|
||||
|
||||
appended: bool
|
||||
was_on_time: bool | None
|
||||
|
||||
def __new__(
|
||||
cls,
|
||||
state: str,
|
||||
*,
|
||||
appended: bool,
|
||||
was_on_time: bool | None = None,
|
||||
) -> "CompletionResult":
|
||||
obj = super().__new__(cls, state)
|
||||
obj.appended = appended
|
||||
obj.was_on_time = was_on_time
|
||||
return obj
|
||||
|
||||
|
||||
async def complete_instance(
|
||||
database: NestQuestDatabase,
|
||||
instance_id: int,
|
||||
*,
|
||||
actor_source: str,
|
||||
actor_user_id: str | None = None,
|
||||
actor_child_id: int | None = None,
|
||||
now: datetime.datetime | None = None,
|
||||
today: datetime.date | None = None,
|
||||
) -> CompletionResult:
|
||||
"""Append a completed event unless the instance is already done.
|
||||
|
||||
Returns the derived state ``done``; ``appended`` reports whether
|
||||
THIS call appended (False on an already-done no-op). Unknown
|
||||
``instance_id`` raises ValueError naming the field. Existence,
|
||||
latest-event, and the append run under one connection lock so a
|
||||
concurrent complete cannot double-append.
|
||||
|
||||
``was_on_time`` is computed from the HA-local completion moment
|
||||
(threaded ``now`` / ``today``) against the instance ``due_date``
|
||||
and optional ``due_time``. ``now`` is the timezone-aware HA-local
|
||||
datetime; its wall clock is never converted to UTC. Same-day
|
||||
``due_time`` comparison requires ``now``.
|
||||
"""
|
||||
instance_id = _validate_int_id(instance_id, "instance_id")
|
||||
actor_source, actor_user_id, actor_child_id = _validate_actor(
|
||||
actor_source, actor_user_id, actor_child_id
|
||||
)
|
||||
async with _reentrant_connection_lock(database):
|
||||
instance = await QuestInstancesDao(database).get_by_id(instance_id)
|
||||
if instance is None:
|
||||
raise ValueError(
|
||||
f"instance_id: quest instance {instance_id} does not exist"
|
||||
)
|
||||
latest = await CompletionEventsDao(database).get_latest_for_instance(
|
||||
instance_id
|
||||
)
|
||||
if latest is not None and latest.event_type == EVENT_COMPLETED:
|
||||
return CompletionResult("done", appended=False)
|
||||
if latest is not None and latest.event_type != EVENT_UNCOMPLETED:
|
||||
raise ValueError(
|
||||
f"event_type must be '{EVENT_COMPLETED}' or "
|
||||
f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}"
|
||||
)
|
||||
# Computed under the completion lock and carried on the result:
|
||||
# the bus event must report THIS completion's timing, never a
|
||||
# re-read of the latest event a racing reversal could replace.
|
||||
on_time = _was_on_time(instance, now, today)
|
||||
await append_event(
|
||||
database,
|
||||
instance.id,
|
||||
instance.child_id,
|
||||
EVENT_COMPLETED,
|
||||
actor_source=actor_source,
|
||||
actor_user_id=actor_user_id,
|
||||
actor_child_id=actor_child_id,
|
||||
was_on_time=on_time,
|
||||
now=now,
|
||||
)
|
||||
return CompletionResult("done", appended=True, was_on_time=on_time)
|
||||
|
||||
|
||||
async def uncomplete_instance(
|
||||
database: NestQuestDatabase,
|
||||
instance_id: int,
|
||||
*,
|
||||
actor_source: str,
|
||||
actor_user_id: str | None = None,
|
||||
actor_child_id: int | None = None,
|
||||
now: datetime.datetime | None = None,
|
||||
today: datetime.date | None = None,
|
||||
) -> CompletionResult:
|
||||
"""Append an uncompleted event unless the instance is already open.
|
||||
|
||||
Returns the derived state ``open``, or ``missed`` when the instance
|
||||
is past due (``due_date`` before HA-local ``today``); ``appended``
|
||||
reports whether THIS call appended (False on an already-open
|
||||
no-op). Unknown ``instance_id`` raises ValueError naming the
|
||||
field. Existence, latest-event, and the append run under one
|
||||
connection lock so a concurrent uncomplete cannot double-append.
|
||||
The original completed row is not modified.
|
||||
"""
|
||||
instance_id = _validate_int_id(instance_id, "instance_id")
|
||||
actor_source, actor_user_id, actor_child_id = _validate_actor(
|
||||
actor_source, actor_user_id, actor_child_id
|
||||
)
|
||||
today_date = _resolve_today(today)
|
||||
async with _reentrant_connection_lock(database):
|
||||
instance = await QuestInstancesDao(database).get_by_id(instance_id)
|
||||
if instance is None:
|
||||
raise ValueError(
|
||||
f"instance_id: quest instance {instance_id} does not exist"
|
||||
)
|
||||
latest = await CompletionEventsDao(database).get_latest_for_instance(
|
||||
instance_id
|
||||
)
|
||||
if latest is None or latest.event_type == EVENT_UNCOMPLETED:
|
||||
return CompletionResult(
|
||||
_derived_state(instance, latest, today_date),
|
||||
appended=False,
|
||||
)
|
||||
if latest.event_type != EVENT_COMPLETED:
|
||||
raise ValueError(
|
||||
f"event_type must be '{EVENT_COMPLETED}' or "
|
||||
f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}"
|
||||
)
|
||||
latest = await append_event(
|
||||
database,
|
||||
instance.id,
|
||||
instance.child_id,
|
||||
EVENT_UNCOMPLETED,
|
||||
actor_source=actor_source,
|
||||
actor_user_id=actor_user_id,
|
||||
actor_child_id=actor_child_id,
|
||||
was_on_time=None,
|
||||
now=now,
|
||||
)
|
||||
return CompletionResult(
|
||||
_derived_state(instance, latest, today_date), appended=True
|
||||
)
|
||||
|
||||
|
||||
async def list_missed_for_child(
|
||||
database: NestQuestDatabase,
|
||||
child_id: int,
|
||||
range_start: str,
|
||||
range_end: str,
|
||||
*,
|
||||
today: datetime.date | None = None,
|
||||
) -> list[QuestInstanceRecord]:
|
||||
"""Return the child's missed instances due in the closed date range.
|
||||
|
||||
Missed means the latest event state is open and ``due_date`` is
|
||||
before ``today`` (HA-local, threaded). Uses
|
||||
:meth:`QuestInstancesDao.list_by_date_range` plus derived state.
|
||||
Never appends a missed event. Instances and latest events are
|
||||
read inside the connection lock so the list is one snapshot.
|
||||
"""
|
||||
child_id = _validate_int_id(child_id, "child_id")
|
||||
today_date = _resolve_today(today)
|
||||
async with _connection_lock(database):
|
||||
instances = await QuestInstancesDao(database).list_by_date_range(
|
||||
child_id, range_start, range_end
|
||||
)
|
||||
events = CompletionEventsDao(database)
|
||||
missed: list[QuestInstanceRecord] = []
|
||||
for instance in instances:
|
||||
latest = await events.get_latest_for_instance(instance.id)
|
||||
if _derived_state(instance, latest, today_date) == "missed":
|
||||
missed.append(instance)
|
||||
return missed
|
||||
@@ -7,7 +7,46 @@ DOMAIN = "nestquest"
|
||||
LOGGER_NAME = "custom_components.nestquest"
|
||||
LOGGER = logging.getLogger(LOGGER_NAME)
|
||||
|
||||
PLATFORMS: list[str] = []
|
||||
# Platforms set up per config entry (Feature 10): the per-child day
|
||||
# sensors and binary sensors.
|
||||
PLATFORMS: list[str] = ["sensor", "binary_sensor"]
|
||||
|
||||
# Home Assistant bus events (design/ENTITIES-AND-SERVICES.md §3). The
|
||||
# panel and admin cards listen for these; Feature 10 fires completed,
|
||||
# uncompleted, and child_day_complete from the service paths, and
|
||||
# Feature 11's nightly sweep fires quest_missed.
|
||||
EVENT_QUEST_COMPLETED = "nestquest_quest_completed"
|
||||
EVENT_QUEST_UNCOMPLETED = "nestquest_quest_uncompleted"
|
||||
EVENT_QUEST_MISSED = "nestquest_quest_missed"
|
||||
EVENT_CHILD_DAY_COMPLETE = "nestquest_child_day_complete"
|
||||
|
||||
#: Canonical domain-global service names (design/ENTITIES-AND-SERVICES.md §2
|
||||
#: plus the existing Feature 07 regenerate service).
|
||||
SERVICE_COMPLETE_QUEST = "complete_quest"
|
||||
SERVICE_UNCOMPLETE_QUEST = "uncomplete_quest"
|
||||
SERVICE_CREATE_QUEST_DEFINITION = "create_quest_definition"
|
||||
SERVICE_UPDATE_QUEST_DEFINITION = "update_quest_definition"
|
||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE = "set_quest_definition_active"
|
||||
SERVICE_SET_PRESENCE_PATTERN = "set_presence_pattern"
|
||||
SERVICE_CREATE_PRESENCE_OVERRIDE = "create_presence_override"
|
||||
SERVICE_DELETE_PRESENCE_OVERRIDE = "delete_presence_override"
|
||||
SERVICE_EXPORT_HISTORY_CSV = "export_history_csv"
|
||||
SERVICE_MANAGE_CHILD = "manage_child"
|
||||
SERVICE_REGENERATE = "regenerate"
|
||||
|
||||
DOMAIN_SERVICES: tuple[str, ...] = (
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
SERVICE_UNCOMPLETE_QUEST,
|
||||
SERVICE_CREATE_QUEST_DEFINITION,
|
||||
SERVICE_UPDATE_QUEST_DEFINITION,
|
||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
||||
SERVICE_SET_PRESENCE_PATTERN,
|
||||
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
||||
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
||||
SERVICE_EXPORT_HISTORY_CSV,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
SERVICE_REGENERATE,
|
||||
)
|
||||
|
||||
SQLITE_DB_FILENAME = "nestquest.db"
|
||||
|
||||
@@ -27,6 +66,33 @@ DEFAULT_PANEL_IDLE_TIMEOUT = 300
|
||||
# list as a disaster-recovery copy so a lost database can be re-seeded.
|
||||
CONF_ADMIN_USER_IDS = "admin_user_ids"
|
||||
|
||||
# Shared coordinator refresh interval (Feature 10), in SECONDS. Every
|
||||
# entity reads one snapshot per refresh; completions push an immediate
|
||||
# refresh so this poll is only the fallback cadence.
|
||||
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
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
"""The shared NestQuest DataUpdateCoordinator (Feature 10).
|
||||
|
||||
ONE refresh pass builds the whole entity snapshot: active children,
|
||||
today's instances with their derived states (open / done / missed via
|
||||
:func:`~.completion.derive_state`), each child's presence today, and
|
||||
per-child rollup counts. Every Feature 10 entity reads
|
||||
``coordinator.data`` — no entity queries the database itself, so one
|
||||
refresh updates the whole board coherently.
|
||||
|
||||
"Today" and "now" are HA-local (``hass.config.time_zone``), never the
|
||||
host clock and never UTC, matching the materialization and completion
|
||||
layers.
|
||||
|
||||
Presence is resolved through the SAME engine the materializer uses
|
||||
(:class:`~.presence.PresenceEngine`): schedules and overrides are read
|
||||
with the public DAO methods and the engine's anchor-date arithmetic
|
||||
answers ``is this child at this house today`` (D-004 — never ISO week
|
||||
parity).
|
||||
|
||||
``cycle_day`` reports where today falls in the custody cycle of the
|
||||
first active child (display order) that HAS a schedule — 1-based day
|
||||
``N`` of ``cycle_length_weeks * 7``, computed from that schedule's
|
||||
anchor date. ``0`` means no active child has a schedule at all, so
|
||||
the admin header degrades to a harmless zero instead of guessing.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
from dataclasses import dataclass
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .completion import derive_state
|
||||
from .const import (
|
||||
CONF_UPDATE_INTERVAL,
|
||||
DEFAULT_UPDATE_INTERVAL,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
MIN_UPDATE_INTERVAL,
|
||||
)
|
||||
from .dao_children import ChildrenDao
|
||||
from .dao_instances import (
|
||||
CompletionEventsDao,
|
||||
QuestInstancesDao,
|
||||
)
|
||||
from .dao_rules import QuestDefinitionsDao
|
||||
from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao
|
||||
from .db import NestQuestDatabase
|
||||
from .presence import (
|
||||
PresenceEngine,
|
||||
PresenceOverride,
|
||||
PresenceSchedule,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QuestInstanceView:
|
||||
"""One instance plus everything the entities render from it."""
|
||||
|
||||
instance_id: int
|
||||
definition_id: int
|
||||
child_id: int
|
||||
title: str
|
||||
icon: str | None
|
||||
window: str
|
||||
due_date: str
|
||||
due_time: str | None
|
||||
state: str # open | done | missed
|
||||
overdue: bool
|
||||
completed_at: str | None
|
||||
was_on_time: bool | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChildDaySnapshot:
|
||||
"""One active child's day: instances, presence, and rollup counts."""
|
||||
|
||||
child_id: int
|
||||
child_name: str
|
||||
instances: tuple[QuestInstanceView, ...]
|
||||
present: bool
|
||||
due_today: int
|
||||
completed_today: int
|
||||
remaining_today: int
|
||||
completion_pct: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NestQuestSnapshot:
|
||||
"""The coordinator's whole-entity data payload."""
|
||||
|
||||
today_iso: str
|
||||
children: tuple[ChildDaySnapshot, ...]
|
||||
cycle_day: int
|
||||
|
||||
|
||||
class NestQuestCoordinator(DataUpdateCoordinator):
|
||||
"""Shared refresh cycle for every NestQuest entity.
|
||||
|
||||
Forced refreshes (the service paths pushing an immediate update
|
||||
after a completion) are SERIALIZED per entry: overlapping
|
||||
complete/uncomplete calls must not interleave their snapshot
|
||||
passes, or an older pass could publish a stale snapshot AFTER a
|
||||
newer one and leave entities wrong until the next poll. Real
|
||||
HA's coordinator already serializes internally; the lock keeps
|
||||
the same guarantee on the stand-in and costs nothing upstream.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
entry_id: str,
|
||||
database: NestQuestDatabase,
|
||||
update_interval_seconds: int | None = None,
|
||||
) -> None:
|
||||
interval = update_interval_seconds
|
||||
if (
|
||||
not isinstance(interval, int)
|
||||
or isinstance(interval, bool)
|
||||
or interval < MIN_UPDATE_INTERVAL
|
||||
):
|
||||
interval = DEFAULT_UPDATE_INTERVAL
|
||||
super().__init__(
|
||||
hass,
|
||||
LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=datetime.timedelta(seconds=interval),
|
||||
)
|
||||
self.entry_id = entry_id
|
||||
self.database = database
|
||||
self._refresh_lock = asyncio.Lock()
|
||||
|
||||
async def async_refresh(self) -> None:
|
||||
"""One refresh at a time; a queued refresh runs AFTER the
|
||||
in-flight one publishes, so the last snapshot always reflects
|
||||
the latest mutation."""
|
||||
async with self._refresh_lock:
|
||||
await super().async_refresh()
|
||||
|
||||
def _local_now(self) -> datetime.datetime:
|
||||
time_zone = ZoneInfo(self.hass.config.time_zone)
|
||||
return datetime.datetime.now(time_zone)
|
||||
|
||||
async def _async_update_data(self) -> NestQuestSnapshot:
|
||||
now = self._local_now()
|
||||
today = now.date()
|
||||
today_iso = today.isoformat()
|
||||
|
||||
children = await ChildrenDao(self.database).list_active()
|
||||
|
||||
schedules: dict[int, PresenceSchedule] = {}
|
||||
overrides: dict[int, list[PresenceOverride]] = {}
|
||||
for child in children:
|
||||
schedule = await PresenceSchedulesDao(self.database).get_by_child(
|
||||
child.id
|
||||
)
|
||||
if schedule is not None:
|
||||
schedules[child.id] = PresenceSchedule.decode(
|
||||
child.id, schedule.anchor_date, schedule.pattern
|
||||
)
|
||||
todays = await PresenceOverridesDao(
|
||||
self.database
|
||||
).list_by_child_and_range(child.id, today_iso, today_iso)
|
||||
if todays:
|
||||
# The engine validates MODEL objects, not DAO records;
|
||||
# each record converts losslessly (the schema guarantees
|
||||
# end >= start and a real is_present).
|
||||
overrides[child.id] = [
|
||||
PresenceOverride(
|
||||
record.child_id,
|
||||
record.start_date,
|
||||
record.end_date,
|
||||
record.is_present,
|
||||
record.note,
|
||||
)
|
||||
for record in todays
|
||||
]
|
||||
engine = PresenceEngine(schedules, overrides)
|
||||
|
||||
instances_dao = QuestInstancesDao(self.database)
|
||||
events_dao = CompletionEventsDao(self.database)
|
||||
definitions_dao = QuestDefinitionsDao(self.database)
|
||||
|
||||
snapshots: list[ChildDaySnapshot] = []
|
||||
for child in children:
|
||||
instances = await instances_dao.list_by_date_range(
|
||||
child.id, today_iso, today_iso
|
||||
)
|
||||
views: list[QuestInstanceView] = []
|
||||
for instance in instances:
|
||||
latest = await events_dao.get_latest_for_instance(instance.id)
|
||||
definition = await definitions_dao.get(instance.definition_id)
|
||||
state = derive_state(instance, latest, today)
|
||||
overdue = False
|
||||
if state == "open" and instance.due_date == today_iso:
|
||||
if instance.due_time is not None:
|
||||
due_moment = datetime.datetime.strptime(
|
||||
instance.due_time, "%H:%M"
|
||||
).time()
|
||||
overdue = now.time() > due_moment
|
||||
views.append(
|
||||
QuestInstanceView(
|
||||
instance_id=instance.id,
|
||||
definition_id=instance.definition_id,
|
||||
child_id=instance.child_id,
|
||||
title=definition.title if definition else "Quest",
|
||||
icon=definition.icon if definition else None,
|
||||
window=instance.window,
|
||||
due_date=instance.due_date,
|
||||
due_time=instance.due_time,
|
||||
state=state,
|
||||
overdue=overdue,
|
||||
completed_at=(
|
||||
latest.occurred_at
|
||||
if latest is not None
|
||||
and latest.event_type == "completed"
|
||||
else None
|
||||
),
|
||||
was_on_time=(
|
||||
latest.was_on_time
|
||||
if latest is not None
|
||||
and latest.event_type == "completed"
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
due = len(views)
|
||||
completed = sum(1 for view in views if view.state == "done")
|
||||
remaining = due - completed
|
||||
snapshots.append(
|
||||
ChildDaySnapshot(
|
||||
child_id=child.id,
|
||||
child_name=child.display_name,
|
||||
instances=tuple(views),
|
||||
present=engine.is_present(child.id, today),
|
||||
due_today=due,
|
||||
completed_today=completed,
|
||||
remaining_today=remaining,
|
||||
completion_pct=(
|
||||
round(completed * 100 / due) if due else 100
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return NestQuestSnapshot(
|
||||
today_iso=today_iso,
|
||||
children=tuple(snapshots),
|
||||
cycle_day=_cycle_day(schedules, children, today),
|
||||
)
|
||||
|
||||
|
||||
def _cycle_day(
|
||||
schedules: dict[int, PresenceSchedule], children, today: datetime.date
|
||||
) -> int:
|
||||
"""Return today's 1-based day in the first scheduled child's cycle.
|
||||
|
||||
Anchor-date arithmetic ONLY (D-004): the day offset is computed from
|
||||
the schedule's own anchor, never ISO week numbers or parity, so a
|
||||
53-week year cannot silently invert the cycle.
|
||||
"""
|
||||
for child in children:
|
||||
schedule = schedules.get(child.id)
|
||||
if schedule is None:
|
||||
continue
|
||||
days = (today - schedule.anchor_date).days
|
||||
cycle_length = schedule.cycle_length_weeks * 7
|
||||
return (days % cycle_length) + 1
|
||||
return 0
|
||||
@@ -47,6 +47,46 @@ from .db import NestQuestDatabase
|
||||
_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00"
|
||||
|
||||
|
||||
def _today() -> datetime.date:
|
||||
"""Return the current local calendar date (the single date source).
|
||||
|
||||
Every "today" read in this module goes through this one function so
|
||||
the delete clamp and the materialize walk's no-past guard read the
|
||||
same clock, and a caller or test can pin/roll that clock
|
||||
deterministically (e.g. a queued-across-midnight regression). Pure
|
||||
clock read — no business rule lives here.
|
||||
"""
|
||||
return datetime.date.today()
|
||||
|
||||
|
||||
def _resolve_today(today: datetime.date | None) -> datetime.date:
|
||||
"""Return the caller-resolved "today", or the host date when absent.
|
||||
|
||||
The no-past guard and the delete cutoffs must compare due dates
|
||||
against the SAME calendar date the materialization horizon was built
|
||||
from. Home Assistant's configured time zone can lag the host clock
|
||||
around midnight, so when the caller has already resolved the local
|
||||
date (``today``) that anchor is authoritative; ``None`` falls back to
|
||||
the host clock (:func:`_today`), which is exactly the previous
|
||||
behavior for callers with no HA-local date.
|
||||
"""
|
||||
return _today() if today is None else today
|
||||
|
||||
|
||||
def _reject_past(due_date: str, today_iso: str) -> None:
|
||||
"""Raise unless ``due_date`` is on or after ``today_iso``.
|
||||
|
||||
The single no-past rule shared by :meth:`QuestInstancesDao.upsert`
|
||||
and :meth:`QuestInstancesDao.upsert_if_valid`: instances are never
|
||||
generated in the past, measured against the resolved "today".
|
||||
"""
|
||||
if due_date < today_iso:
|
||||
raise ValueError(
|
||||
f"instances are never generated in the past: due_date "
|
||||
f"{due_date!r} is before today {today_iso!r}"
|
||||
)
|
||||
|
||||
|
||||
def _validate_utc_timestamp(value: str, field: str) -> None:
|
||||
"""Raise ValueError unless ``value`` is a strict UTC ISO-8601 stamp.
|
||||
|
||||
@@ -166,6 +206,7 @@ class QuestInstancesDao:
|
||||
*,
|
||||
window: str,
|
||||
due_time: str | None = None,
|
||||
today: datetime.date | None = None,
|
||||
) -> QuestInstanceRecord:
|
||||
"""Create the dated instance for the widened key idempotently.
|
||||
|
||||
@@ -184,6 +225,10 @@ class QuestInstancesDao:
|
||||
- ``due_date`` must be TODAY or later: instances are never
|
||||
generated in the past (Feature 07). The caller's
|
||||
materialization horizon is future-dated by definition.
|
||||
``today`` optionally pins the resolved HA-local date the
|
||||
guard compares against (falling back to the host clock when
|
||||
omitted) so a household time zone behind the host around
|
||||
midnight is not misread as "past".
|
||||
- ``window`` must be one of ``const.QUEST_WINDOWS``.
|
||||
- An existing instance WITH a completion event is IMMUTABLE:
|
||||
the conflict path must not rewrite its snapshot columns
|
||||
@@ -203,24 +248,20 @@ class QuestInstancesDao:
|
||||
# check runs after the lock is acquired, because a caller can
|
||||
# wait on the lock across midnight — a date that was "today"
|
||||
# at call time may be "yesterday" by the time the INSERT runs.
|
||||
today = datetime.date.today().isoformat()
|
||||
if due_date < today:
|
||||
raise ValueError(
|
||||
f"instances are never generated in the past: due_date "
|
||||
f"{due_date!r} is before today {today!r}"
|
||||
)
|
||||
# A caller-supplied ``today`` (HA-local) is used for BOTH
|
||||
# checks and never re-read, since it is already the anchor the
|
||||
# batch runs against.
|
||||
today_date = _resolve_today(today)
|
||||
_reject_past(due_date, today_date.isoformat())
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
# Re-read "today" under the lock: this is the date the
|
||||
# insert actually executes on, so the no-past rule
|
||||
# holds across midnight rollovers.
|
||||
today = datetime.date.today().isoformat()
|
||||
if due_date < today:
|
||||
raise ValueError(
|
||||
f"instances are never generated in the past: "
|
||||
f"due_date {due_date!r} is before today "
|
||||
f"{today!r}"
|
||||
)
|
||||
# holds across midnight rollovers. A pinned HA-local
|
||||
# anchor is exempt from the re-read (see above).
|
||||
if today is None:
|
||||
today_date = _today()
|
||||
_reject_past(due_date, today_date.isoformat())
|
||||
definition = await self._database.fetch_one(
|
||||
"SELECT 1 FROM quest_definitions WHERE id = ?",
|
||||
(definition_id,),
|
||||
@@ -277,6 +318,123 @@ class QuestInstancesDao:
|
||||
assert instance is not None
|
||||
return instance
|
||||
|
||||
async def upsert_if_valid(
|
||||
self,
|
||||
definition_id: int,
|
||||
child_id: int,
|
||||
due_date: str,
|
||||
generated_at: str,
|
||||
*,
|
||||
window: str,
|
||||
due_time: str | None = None,
|
||||
today: datetime.date | None = None,
|
||||
) -> QuestInstanceRecord | None:
|
||||
"""Insert one dated instance iff its tuple is still materializable.
|
||||
|
||||
This is the materialization walk's single write path. Unlike the
|
||||
generic :meth:`upsert`, which validates only existence and
|
||||
assignment, this method re-validates EVERY materialization
|
||||
precondition in the SAME connection-lock-held transaction as the
|
||||
INSERT — closing the check-then-insert gap a concurrent config
|
||||
write could otherwise slip through:
|
||||
|
||||
- the definition exists AND is active (a definition deactivated
|
||||
after the input snapshot is skipped, never written);
|
||||
- the child exists AND is active (a deactivated child is skipped);
|
||||
- the (definition, child) assignment link still exists (a removed
|
||||
assignment is skipped);
|
||||
- the window is still declared on the definition (a removed window
|
||||
is skipped);
|
||||
- the window's live ``due_time`` still EQUALS the caller-supplied
|
||||
``due_time`` (NULL-safe: NULL == NULL counts as equal; a window
|
||||
with no due time matches a snapshot with no due time). A
|
||||
window-time edit landed after the input snapshot makes the
|
||||
caller's ``due_time`` stale, and writing it would overwrite the
|
||||
newer due_time a concurrent regeneration already upserted — so
|
||||
the tuple is skipped instead of written.
|
||||
|
||||
The due_time check closes the one check-then-insert gap this
|
||||
method previously left open: it re-validated every OTHER
|
||||
precondition but then wrote the snapshot's ``due_time``
|
||||
unconditionally, so a stale materialization could clobber a
|
||||
newer, concurrently-regenerated due_time.
|
||||
|
||||
Returns the stored instance, or ``None`` when a precondition
|
||||
failed — the tuple no longer materializable, which the walk SKIPS
|
||||
rather than raising, so the rest of the batch is unaffected. The
|
||||
no-past rule (instances are never generated in the past) is
|
||||
preserved from :meth:`upsert` and still raises ValueError; ``today``
|
||||
optionally pins the HA-local anchor it compares against. An
|
||||
instance that ALREADY has a completion event is also skipped
|
||||
(returns ``None``) rather than raised: it is immutable, so the
|
||||
walk leaves it untouched instead of failing the batch — the
|
||||
generic :meth:`upsert` keeps its hard refusal for the
|
||||
delete/regenerate path.
|
||||
"""
|
||||
_validate_date(due_date, "due_date")
|
||||
_validate_window(window)
|
||||
# Fail fast on obviously-past dates before queuing on the lock;
|
||||
# the authoritative check re-runs under the lock (see upsert).
|
||||
today_date = _resolve_today(today)
|
||||
_reject_past(due_date, today_date.isoformat())
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
if today is None:
|
||||
today_date = _today()
|
||||
_reject_past(due_date, today_date.isoformat())
|
||||
still_valid = await self._database.fetch_one(
|
||||
"SELECT 1 FROM quest_definitions d "
|
||||
"JOIN quest_definition_assignees a "
|
||||
"ON a.definition_id = d.id "
|
||||
"JOIN children c ON c.id = a.child_id "
|
||||
"JOIN quest_definition_windows w "
|
||||
"ON w.definition_id = d.id "
|
||||
"WHERE d.id = ? AND a.child_id = ? AND w.window = ? "
|
||||
"AND d.is_active = 1 AND c.is_active = 1 "
|
||||
"AND w.due_time IS ? LIMIT 1",
|
||||
(definition_id, child_id, window, due_time),
|
||||
)
|
||||
if still_valid is None:
|
||||
# A config change invalidated the tuple since the
|
||||
# snapshot: skip it rather than abort the batch.
|
||||
return None
|
||||
existing = await self._database.fetch_one(
|
||||
"SELECT 1 FROM completion_events "
|
||||
"WHERE instance_id = (SELECT id FROM quest_instances "
|
||||
"WHERE definition_id = ? AND child_id = ? "
|
||||
"AND due_date = ? AND window = ?)",
|
||||
(definition_id, child_id, due_date, window),
|
||||
)
|
||||
if existing is not None:
|
||||
# The instance is already completed and therefore
|
||||
# immutable: the walk SKIPS it (no-op) rather than
|
||||
# rewriting its snapshot columns or failing the
|
||||
# batch. This is the materialization walk's skip,
|
||||
# distinct from the generic :meth:`upsert` refusal.
|
||||
return None
|
||||
await self._database.execute(
|
||||
"INSERT INTO quest_instances (definition_id, child_id, "
|
||||
"window, due_date, due_time, generated_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?) "
|
||||
"ON CONFLICT (definition_id, child_id, due_date, "
|
||||
"window) DO UPDATE SET "
|
||||
"due_time = excluded.due_time, "
|
||||
"generated_at = excluded.generated_at",
|
||||
(
|
||||
definition_id,
|
||||
child_id,
|
||||
window,
|
||||
due_date,
|
||||
due_time,
|
||||
generated_at,
|
||||
),
|
||||
)
|
||||
instance = await self.get(
|
||||
definition_id, child_id, due_date, window
|
||||
)
|
||||
assert instance is not None
|
||||
return instance
|
||||
|
||||
async def get(
|
||||
self,
|
||||
definition_id: int,
|
||||
@@ -335,8 +493,45 @@ 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
|
||||
self, definition_id: int, cutoff_date: str, *, today: datetime.date | None = None
|
||||
) -> int:
|
||||
"""Delete the definition's open instances at/after cutoff.
|
||||
|
||||
@@ -348,9 +543,11 @@ class QuestInstancesDao:
|
||||
is measured against TODAY (materialization regenerates the
|
||||
rolling horizon from now), so a caller cannot use this to
|
||||
rewrite past open instances either — the day-rollover missed
|
||||
sweep (Feature 11) handles past-due instances instead. Any
|
||||
instance with a completion event is never touched, regardless
|
||||
of its date. Returns the number deleted.
|
||||
sweep (Feature 11) handles past-due instances instead. ``today``
|
||||
optionally pins the HA-local anchor the clamp compares against
|
||||
(falling back to the host clock when omitted). Any instance with
|
||||
a completion event is never touched, regardless of its date.
|
||||
Returns the number deleted.
|
||||
"""
|
||||
_validate_date(cutoff_date, "cutoff_date")
|
||||
async with _connection_lock(self._database):
|
||||
@@ -360,8 +557,8 @@ class QuestInstancesDao:
|
||||
# midnight cannot delete instances that became past
|
||||
# while it waited (backdating is clamped to the
|
||||
# execution date, not the call date).
|
||||
today = datetime.date.today().isoformat()
|
||||
effective_cutoff = max(cutoff_date, today)
|
||||
today_date = _resolve_today(today)
|
||||
effective_cutoff = max(cutoff_date, today_date.isoformat())
|
||||
result = await self._database.execute(
|
||||
"DELETE FROM quest_instances WHERE definition_id = ? "
|
||||
"AND due_date >= ? AND NOT EXISTS ("
|
||||
@@ -372,6 +569,51 @@ class QuestInstancesDao:
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
async def delete_future_uncompleted_for_child(
|
||||
self, child_id: int, cutoff_date: str, *, today: datetime.date | None = None
|
||||
) -> tuple[int, str]:
|
||||
"""Delete the child's open instances at/after cutoff, across every
|
||||
definition, returning ``(deleted, effective_cutoff)``.
|
||||
|
||||
Child-scoped counterpart to :meth:`delete_future_uncompleted`,
|
||||
used when a child's presence changes (Feature 09): only instances
|
||||
at or after the cutoff with NO completion event are removed, inside
|
||||
one transaction — an instance that gains a completion event
|
||||
mid-flight cannot be deleted. The cutoff cannot be backdated below
|
||||
today, for the same reason as the definition-scoped method (see its
|
||||
docstring). ``today`` optionally pins the HA-local anchor the clamp
|
||||
compares against (falling back to the host clock when omitted).
|
||||
Any instance with a completion event is never touched, regardless
|
||||
of its date.
|
||||
|
||||
Returns the number deleted and the effective cutoff that was
|
||||
actually applied (``max(cutoff_date, today)``, read under the
|
||||
lock). The caller (:func:`~.materialize.regenerate_for_child`)
|
||||
passes that effective cutoff back as its re-materialization anchor,
|
||||
so the delete and the rebuild share ONE execution-day even when the
|
||||
call is queued across midnight — instead of the caller re-reading a
|
||||
stale "today" and materializing a window the delete already
|
||||
superseded.
|
||||
"""
|
||||
_validate_date(cutoff_date, "cutoff_date")
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
# "today" is read under the lock, matching the
|
||||
# definition-scoped method: the DELETE executes on this
|
||||
# date, so a caller queued across midnight cannot delete
|
||||
# instances that became past while it waited.
|
||||
today_date = _resolve_today(today)
|
||||
effective_cutoff = max(cutoff_date, today_date.isoformat())
|
||||
result = await self._database.execute(
|
||||
"DELETE FROM quest_instances WHERE child_id = ? "
|
||||
"AND due_date >= ? AND NOT EXISTS ("
|
||||
" SELECT 1 FROM completion_events "
|
||||
" WHERE instance_id = quest_instances.id"
|
||||
")",
|
||||
(child_id, effective_cutoff),
|
||||
)
|
||||
return result.rowcount, effective_cutoff
|
||||
|
||||
|
||||
class CompletionEventsDao:
|
||||
"""Typed append-only access to the ``completion_events`` table.
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -270,10 +270,74 @@ class PresenceOverridesDao:
|
||||
)
|
||||
return [_override_from_row(row) for row in rows]
|
||||
|
||||
async def get(self, override_id: int) -> PresenceOverrideRecord | None:
|
||||
"""Return one override by id, or None when it does not exist."""
|
||||
row = await self._database.fetch_one(
|
||||
f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides "
|
||||
"WHERE id = ?",
|
||||
(override_id,),
|
||||
)
|
||||
return _override_from_row(row) if row is not None else None
|
||||
|
||||
async def delete(self, override_id: int) -> bool:
|
||||
"""Remove one override by id; True if a row was removed."""
|
||||
result = await self._database.execute(
|
||||
"DELETE FROM presence_overrides WHERE id = ?",
|
||||
(override_id,),
|
||||
)
|
||||
return result.rowcount > 0
|
||||
return result.rowcount > 0
|
||||
|
||||
|
||||
async def read_snapshot_unlocked(
|
||||
database: NestQuestDatabase,
|
||||
child_ids: list[int],
|
||||
range_start: str,
|
||||
range_end: str,
|
||||
) -> tuple[
|
||||
dict[int, PresenceScheduleRecord],
|
||||
dict[int, list[PresenceOverrideRecord]],
|
||||
]:
|
||||
"""Read schedules and overrides for ``child_ids`` WITHOUT locking.
|
||||
|
||||
MUST be called inside the connection lock and an open transaction, so
|
||||
the schedules and overrides returned here stay coherent with every
|
||||
other table the caller reads inside that same transaction (the
|
||||
materialization walk's single input snapshot does exactly that). The
|
||||
date range comes already validated by the caller.
|
||||
|
||||
Returns ``(schedules, overrides)``:
|
||||
|
||||
- ``schedules`` maps ``child_id`` to its :class:`PresenceScheduleRecord`
|
||||
for every child that HAS a schedule row. A child absent from the
|
||||
mapping has no schedule, i.e. is present every day (Feature 05).
|
||||
- ``overrides`` maps ``child_id`` to that child's overrides overlapping
|
||||
``[range_start, range_end]`` (inclusive boundaries), ordered by start
|
||||
date; a child with no matching override is absent from the mapping.
|
||||
|
||||
``child_ids`` must name existing children (the caller derives them
|
||||
from a trusted snapshot, within the same transaction).
|
||||
"""
|
||||
children = sorted(set(child_ids))
|
||||
if not children:
|
||||
return {}, {}
|
||||
placeholders = ",".join("?" for _ in children)
|
||||
schedule_rows = await database.fetch_all(
|
||||
f"SELECT {_SCHEDULE_COLUMNS} FROM presence_schedules "
|
||||
f"WHERE child_id IN ({placeholders})",
|
||||
tuple(children),
|
||||
)
|
||||
override_rows = await database.fetch_all(
|
||||
f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides "
|
||||
f"WHERE child_id IN ({placeholders}) AND end_date >= ? "
|
||||
"AND start_date <= ? ORDER BY start_date",
|
||||
tuple(children) + (range_start, range_end),
|
||||
)
|
||||
schedules = {
|
||||
record.child_id: record
|
||||
for record in map(_schedule_from_row, schedule_rows)
|
||||
}
|
||||
overrides: dict[int, list[PresenceOverrideRecord]] = {}
|
||||
for row in override_rows:
|
||||
record = _override_from_row(row)
|
||||
overrides.setdefault(record.child_id, []).append(record)
|
||||
return schedules, overrides
|
||||
@@ -907,6 +907,81 @@ class QuestDefinitionsDao:
|
||||
)
|
||||
return [_definition_from_row(row) for row in rows]
|
||||
|
||||
async def _snapshots_for(
|
||||
self, definitions: list[QuestDefinitionRecord]
|
||||
) -> list[QuestDefinitionSnapshot]:
|
||||
"""Read each definition's rule, assignees and windows.
|
||||
|
||||
MUST be called inside the connection lock (and ideally a
|
||||
transaction) so the definitions handed in and the rows this
|
||||
reads back cannot be raced by a concurrent edit between list
|
||||
and bundle. Each rule is read through
|
||||
:class:`ScheduleRulesDao` on the same connection, so the
|
||||
snapshot's rule record is the one the definition references AT
|
||||
the moment of the locked read.
|
||||
"""
|
||||
rules_dao = ScheduleRulesDao(self._database)
|
||||
snapshots: list[QuestDefinitionSnapshot] = []
|
||||
for definition in definitions:
|
||||
rule_record = await rules_dao.get(definition.schedule_rule_id)
|
||||
assert rule_record is not None
|
||||
snapshots.append(
|
||||
QuestDefinitionSnapshot(
|
||||
definition=definition,
|
||||
rule=rule_record,
|
||||
assignees=await self.list_assignees(definition.id),
|
||||
windows=await self.list_windows(definition.id),
|
||||
)
|
||||
)
|
||||
return snapshots
|
||||
|
||||
async def list_snapshots_active(self) -> list[QuestDefinitionSnapshot]:
|
||||
"""Return every active definition with its rule, assignees and
|
||||
windows, all read inside one locked transaction.
|
||||
|
||||
The definitive list and each definition's linked rows are read
|
||||
in a single BEGIN..COMMIT span under the connection lock, so a
|
||||
concurrent atomic edit cannot interleave between the definition
|
||||
list and the bundle read: the caller receives a coherent
|
||||
snapshot (each definition's rule, assignees and windows as they
|
||||
were at one instant), never a mixed old-rule/new-windows state.
|
||||
"""
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
definitions = await self.list_active()
|
||||
return await self._snapshots_for(definitions)
|
||||
|
||||
async def read_snapshots_active(self) -> list[QuestDefinitionSnapshot]:
|
||||
"""Return every active definition's snapshot WITHOUT locking.
|
||||
|
||||
Mirrors :meth:`list_snapshots_active` minus the lock and
|
||||
transaction: the definitions and their rules, assignees and
|
||||
windows are read on the caller's connection under whatever
|
||||
transaction the caller is already holding, so they stay coherent
|
||||
with every OTHER table the caller reads inside that same
|
||||
transaction. MUST be called inside the connection lock and an
|
||||
open transaction (the single-transaction input snapshot the
|
||||
materialization walk uses is that caller).
|
||||
"""
|
||||
definitions = await self.list_active()
|
||||
return await self._snapshots_for(definitions)
|
||||
|
||||
async def list_snapshots_by_child(
|
||||
self, child_id: int
|
||||
) -> list[QuestDefinitionSnapshot]:
|
||||
"""Return each definition assigned to ``child_id`` with its rule,
|
||||
assignees and windows, all read inside one locked transaction.
|
||||
|
||||
Mirrors :meth:`list_snapshots_active` but selects by assignment
|
||||
(``list_by_child`` order: newest first). Locks read-and-bundle
|
||||
into one transaction so the bundle never mixes one edit's
|
||||
definition with another's windows.
|
||||
"""
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
definitions = await self.list_by_child(child_id)
|
||||
return await self._snapshots_for(definitions)
|
||||
|
||||
async def update(
|
||||
self,
|
||||
definition_id: int,
|
||||
@@ -1129,4 +1204,47 @@ class QuestDefinitionsDao:
|
||||
if not child[0]:
|
||||
raise ValueError(
|
||||
f"child {child_id} is inactive and cannot be assigned"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def load_materialization_input(
|
||||
database: NestQuestDatabase,
|
||||
range_start: str,
|
||||
range_end: str,
|
||||
):
|
||||
"""Read every materialization input in ONE locked transaction.
|
||||
|
||||
Returns ``(snapshots, schedules, overrides)``:
|
||||
|
||||
- ``snapshots``: every active definition with its rule, assignees and
|
||||
windows (the :class:`QuestDefinitionSnapshot` shape).
|
||||
- ``schedules`` / ``overrides``: the presence schedules and
|
||||
range-scoped overrides of every assignee child, in
|
||||
:mod:`.dao_presence`'s record shapes.
|
||||
|
||||
The whole read — definitions, rules, assignees, windows, and the
|
||||
assignees' presence schedules and overrides — runs inside a single
|
||||
BEGIN..COMMIT span under the connection lock, so a concurrent edit
|
||||
(definition, assignment, window, or presence) cannot interleave
|
||||
between the parts and the walk never sees a mixed-time view. Each
|
||||
table's SQL stays in its own DAO module: the presence portion is
|
||||
delegated to :mod:`.dao_presence` (a lazy import here, since that
|
||||
module imports this one).
|
||||
"""
|
||||
definitions_dao = QuestDefinitionsDao(database)
|
||||
async with _connection_lock(database):
|
||||
async with database.transaction():
|
||||
snapshots = await definitions_dao.read_snapshots_active()
|
||||
child_ids = sorted(
|
||||
{
|
||||
child.id
|
||||
for snapshot in snapshots
|
||||
for child in snapshot.assignees
|
||||
}
|
||||
)
|
||||
from . import dao_presence # lazy: dao_presence imports us
|
||||
|
||||
schedules, overrides = await dao_presence.read_snapshot_unlocked(
|
||||
database, child_ids, range_start, range_end
|
||||
)
|
||||
return snapshots, schedules, overrides
|
||||
@@ -0,0 +1,130 @@
|
||||
"""HA bus events fired by the NestQuest service paths (Feature 10).
|
||||
|
||||
The four documented events (design/ENTITIES-AND-SERVICES.md §3, names
|
||||
in :mod:`~.const`): ``nestquest_quest_completed`` and
|
||||
``nestquest_quest_uncompleted`` fire from the completion service
|
||||
handlers on an actual transition (a no-op re-complete or re-uncomplete
|
||||
fires nothing), and ``nestquest_child_day_complete`` fires when a
|
||||
completion clears the child's whole day (quests were owed and none
|
||||
remain — a zero-quest day can never fire it, matching the all-done
|
||||
binary sensor's rule). ``nestquest_quest_missed`` is the documented
|
||||
contract Feature 11's nightly sweep fires; nothing here fires it.
|
||||
|
||||
Payload policy: one dict carrying ``child_id``, ``child_name``,
|
||||
``instance_id``, ``quest_title``, the instance's ``window``/
|
||||
``due_date``/``due_time`` so automation authors can filter without a
|
||||
database lookup, and ``occurred_at`` — the event's own strict UTC
|
||||
ISO-8601 timestamp. Completion payloads additionally carry
|
||||
``was_on_time``. No other personal data: names and titles are the
|
||||
household's own records, and nothing beyond them leaks.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from .completion import derive_state
|
||||
from .const import (
|
||||
EVENT_CHILD_DAY_COMPLETE,
|
||||
EVENT_QUEST_COMPLETED,
|
||||
EVENT_QUEST_UNCOMPLETED,
|
||||
)
|
||||
from .dao_children import ChildrenDao
|
||||
from .dao_instances import CompletionEventsDao, QuestInstancesDao
|
||||
from .dao_rules import QuestDefinitionsDao
|
||||
from .db import NestQuestDatabase
|
||||
|
||||
#: Strict UTC ISO-8601 timestamp (the DAO layers' policy): one shape,
|
||||
#: explicit offset, second precision.
|
||||
_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 _instance_payload(
|
||||
database: NestQuestDatabase, instance_id: int, now_stamp: str
|
||||
) -> dict:
|
||||
"""Build the shared event payload for one quest instance."""
|
||||
instance = await QuestInstancesDao(database).get_by_id(instance_id)
|
||||
if instance is None:
|
||||
raise ValueError(
|
||||
f"instance_id: quest instance {instance_id} does not exist"
|
||||
)
|
||||
child = await ChildrenDao(database).get(instance.child_id)
|
||||
definition = await QuestDefinitionsDao(database).get(
|
||||
instance.definition_id
|
||||
)
|
||||
return {
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
async def fire_quest_completed(
|
||||
hass,
|
||||
database: NestQuestDatabase,
|
||||
instance_id: int,
|
||||
*,
|
||||
was_on_time: bool | None,
|
||||
) -> None:
|
||||
"""Fire ``nestquest_quest_completed`` and, when the completion
|
||||
cleared the child's whole day, ``nestquest_child_day_complete``.
|
||||
|
||||
Called by the complete_quest service handler AFTER an actual
|
||||
completion transition, with the completion's ``was_on_time`` —
|
||||
computed under the completion lock and carried on the result — so
|
||||
the payload never re-reads mutable latest state a racing reversal
|
||||
could have replaced. A no-op re-complete never reaches here.
|
||||
"""
|
||||
now_stamp = _now_stamp()
|
||||
payload = await _instance_payload(database, instance_id, now_stamp)
|
||||
payload["was_on_time"] = was_on_time
|
||||
hass.bus.async_fire(EVENT_QUEST_COMPLETED, payload)
|
||||
|
||||
# Day-complete: quests were owed today and none remain (the same
|
||||
# rule as the all-done binary sensor; a zero-quest day never fires).
|
||||
# Only a completion of TODAY'S instance evaluates it: completing a
|
||||
# back-dated or future instance must not announce a cleared day.
|
||||
today = datetime.datetime.now(ZoneInfo(hass.config.time_zone)).date()
|
||||
if payload["due_date"] != today.isoformat():
|
||||
return
|
||||
instances = await QuestInstancesDao(database).list_by_date_range(
|
||||
payload["child_id"], today.isoformat(), today.isoformat()
|
||||
)
|
||||
events = CompletionEventsDao(database)
|
||||
remaining = 0
|
||||
for instance in instances:
|
||||
latest_for_instance = await events.get_latest_for_instance(
|
||||
instance.id
|
||||
)
|
||||
if derive_state(instance, latest_for_instance, today) != "done":
|
||||
remaining += 1
|
||||
if instances and remaining == 0:
|
||||
hass.bus.async_fire(
|
||||
EVENT_CHILD_DAY_COMPLETE,
|
||||
{
|
||||
"child_id": payload["child_id"],
|
||||
"child_name": payload["child_name"],
|
||||
"quests_due": len(instances),
|
||||
"quests_completed": len(instances),
|
||||
"occurred_at": now_stamp,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def fire_quest_uncompleted(
|
||||
hass, database: NestQuestDatabase, instance_id: int
|
||||
) -> None:
|
||||
"""Fire ``nestquest_quest_uncompleted`` for an actual reversal."""
|
||||
payload = await _instance_payload(database, instance_id, _now_stamp())
|
||||
hass.bus.async_fire(EVENT_QUEST_UNCOMPLETED, payload)
|
||||
@@ -0,0 +1,377 @@
|
||||
"""The materialization walk (Feature 07 core).
|
||||
|
||||
:func:`materialize` turns the schedule rules + custody presence of the
|
||||
active quest definitions into concrete ``quest_instances`` rows over a
|
||||
date range. It is the pure walk over definitions x dates x assignees x
|
||||
windows; HA scheduling (day-rollover triggers) is a separate later
|
||||
task and lives elsewhere.
|
||||
|
||||
The walk is deterministic and idempotent:
|
||||
|
||||
- All inputs — the active definitions with their rules, assignees and
|
||||
windows, AND the assignee children's presence schedules and scoped
|
||||
overrides — are read inside ONE locked transaction (see
|
||||
:func:`~.dao_rules.load_materialization_input`), so the walk sees a
|
||||
single coherent snapshot rather than a definition state from one
|
||||
instant and a presence state from another.
|
||||
- Presence is snapshotted into an immutable
|
||||
:class:`~.presence.PresenceEngine` from that same coherent read.
|
||||
- For each date in the range, each definition whose decoded rule
|
||||
:func:`~.recurrence.occurs_on` on that date, each assignee who is
|
||||
``is_present`` on that date and each declared window, one instance is
|
||||
written through :class:`~.dao_instances.QuestInstancesDao.upsert_if_valid`
|
||||
with a ``generated_at`` stamp shared by the whole batch (one coherent
|
||||
time per run).
|
||||
|
||||
A config change that lands AFTER the snapshot is read but BEFORE a
|
||||
tuple's write (an assignment removed, a definition or child deactivated,
|
||||
a window removed) makes that tuple SKIP rather than abort the batch:
|
||||
:meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid` re-validates
|
||||
every materialization precondition (active definition, active child,
|
||||
live assignment, declared window, and its CURRENT ``due_time``) inside
|
||||
the SAME transaction as the INSERT, so the check and the write cannot be
|
||||
split by a racing config edit.
|
||||
|
||||
The upsert is idempotent on (definition_id, child_id, due_date,
|
||||
window), so re-running the materialization over the same range never
|
||||
duplicates a row; a tuple whose instance is already completed is SKIPPED
|
||||
(never rewritten, never raised) by
|
||||
:meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid`.
|
||||
|
||||
Regeneration: a config or presence change must rebuild future instances
|
||||
so they track the new state. :func:`regenerate_for_definition` covers
|
||||
definition edits / reassignment (Features 06/07); :func:`regenerate_for_child`
|
||||
covers a single child's presence change (Feature 09). Both delete the
|
||||
affected open instances at or after today — never a completed instance,
|
||||
never the past — and re-run the walk over the rolling horizon
|
||||
``[today, today + horizon_days]`` (``horizon_days`` defaults to
|
||||
``DEFAULT_HORIZON_DAYS``).
|
||||
|
||||
HOOK (Feature 09 presence services): the presence business layer that
|
||||
lands in Feature 09 MUST call :func:`regenerate_for_child` after EVERY
|
||||
presence-state write so a child's future instances track its presence:
|
||||
``set_presence_pattern`` (including clearing/setting an empty pattern),
|
||||
deleting/clearing the presence schedule
|
||||
(:meth:`~.dao_presence.PresenceSchedulesDao.delete`),
|
||||
``create_presence_override``, and ``delete_presence_override``. A
|
||||
schedule delete changes presence state too (the child becomes
|
||||
always-present), so it must regenerate just like the others. This hook
|
||||
is documented, not yet wired: no presence business layer exists yet, and
|
||||
the presence DAO (``dao_presence.py``) must stay free of business rules
|
||||
— never call :func:`regenerate_for_child` from inside the DAO layer.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
|
||||
from .const import DEFAULT_HORIZON_DAYS
|
||||
from .dao_instances import QuestInstancesDao, _resolve_today
|
||||
from .dao_rules import (
|
||||
_validate_date,
|
||||
load_materialization_input,
|
||||
schedule_rule_from_storage,
|
||||
schedule_rule_storage_from_record,
|
||||
)
|
||||
from .db import NestQuestDatabase
|
||||
from .presence import PresenceEngine, PresenceOverride, PresenceSchedule
|
||||
from .recurrence import ScheduleRule, occurs_on
|
||||
|
||||
|
||||
def _now_stamp() -> str:
|
||||
"""Return the batch's shared UTC second-precision generation stamp."""
|
||||
return datetime.datetime.now(datetime.timezone.utc).isoformat(
|
||||
timespec="seconds"
|
||||
)
|
||||
|
||||
|
||||
def _resolve_horizon_days(horizon_days: int | None) -> int:
|
||||
"""Validate a regeneration horizon and return the resolved day count.
|
||||
|
||||
``None`` resolves to :data:`~.const.DEFAULT_HORIZON_DAYS`. A bool
|
||||
(SQLite would happily bind ``True``/``False`` onto an int), a non-int,
|
||||
or a value below 1 raises ValueError naming the field — the validation
|
||||
runs BEFORE any future-instance delete so a malformed horizon can never
|
||||
delete rows it then fails to rebuild. A negative horizon would
|
||||
otherwise delete first and only then be rejected by
|
||||
:func:`materialize`'s inverted-range check; ``0``/``False`` would
|
||||
silently shrink the window to a single day.
|
||||
"""
|
||||
if horizon_days is None:
|
||||
return DEFAULT_HORIZON_DAYS
|
||||
if (
|
||||
isinstance(horizon_days, bool)
|
||||
or not isinstance(horizon_days, int)
|
||||
or horizon_days < 1
|
||||
):
|
||||
raise ValueError(
|
||||
f"horizon_days must be a positive integer, got {horizon_days!r}"
|
||||
)
|
||||
return horizon_days
|
||||
|
||||
|
||||
def _decode_rule(snapshot) -> ScheduleRule:
|
||||
"""Decode a snapshot's rule record back into a ScheduleRule.
|
||||
|
||||
The record carried by the snapshot is the one the definition
|
||||
referenced at read time, so decoding it here (rather than re-reading
|
||||
through ``ScheduleRulesDao``) preserves the coherence the snapshot
|
||||
guarantees.
|
||||
"""
|
||||
return schedule_rule_from_storage(
|
||||
schedule_rule_storage_from_record(snapshot.rule)
|
||||
)
|
||||
|
||||
|
||||
def _build_engine(
|
||||
schedules_records,
|
||||
overrides_records,
|
||||
) -> PresenceEngine:
|
||||
"""Convert raw presence records into an immutable PresenceEngine."""
|
||||
schedules: dict[int, PresenceSchedule] = {}
|
||||
for child_id, record in schedules_records.items():
|
||||
schedules[child_id] = PresenceSchedule.decode(
|
||||
child_id, record.anchor_date, record.pattern
|
||||
)
|
||||
overrides: dict[int, list[PresenceOverride]] = {}
|
||||
for child_id, records in overrides_records.items():
|
||||
overrides[child_id] = [
|
||||
PresenceOverride(
|
||||
record.child_id,
|
||||
record.start_date,
|
||||
record.end_date,
|
||||
record.is_present,
|
||||
record.note,
|
||||
)
|
||||
for record in records
|
||||
]
|
||||
return PresenceEngine(schedules, overrides)
|
||||
|
||||
|
||||
async def materialize(
|
||||
database: NestQuestDatabase,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
*,
|
||||
child_ids: list[int] | None = None,
|
||||
today: datetime.date | None = None,
|
||||
) -> int:
|
||||
"""Generate quest instances for the closed range [start_date, end_date].
|
||||
|
||||
``start_date`` and ``end_date`` must be strict ISO calendar dates
|
||||
(YYYY-MM-DD) with ``end_date`` on or after ``start_date``; anything
|
||||
else raises ValueError naming the offending field. Returns the number
|
||||
of instances upserted for the range (idempotent — a re-run refreshes
|
||||
the same rows, never duplicates them).
|
||||
|
||||
``child_ids`` optionally scopes the walk to exactly those children:
|
||||
assignees outside the list are never written, so a caller that only
|
||||
wants one child's instances rebuilt (e.g. :func:`regenerate_for_child`
|
||||
after a presence change) cannot touch any unrelated child's rows.
|
||||
When omitted (or None) the walk covers every assignee as before.
|
||||
|
||||
``today`` optionally pins the resolved HA-local date the walk's
|
||||
no-past guard compares against; it is threaded into every
|
||||
:meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid` write. When
|
||||
omitted the host clock (``datetime.date.today``) is used, which is the
|
||||
historical default. A caller that computed the horizon in Home
|
||||
Assistant's configured time zone MUST pass that same date here so a
|
||||
time zone behind the host around midnight is not rejected as "past".
|
||||
|
||||
``start_date`` is clamped up to the resolved "today" (the same
|
||||
``today`` anchor threaded into the no-past guard), so a caller that
|
||||
passes a ``start_date`` already in the past never yields a
|
||||
past-dated instance: the walk simply begins on today and skips every
|
||||
earlier date. A range entirely in the past therefore produces zero
|
||||
instances rather than an error, and the DAO's no-past guard remains
|
||||
the authoritative backstop under the lock.
|
||||
|
||||
All inputs are read in ONE locked transaction; each tuple's write then
|
||||
goes through the atomic :meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid`,
|
||||
which re-validates the tuple and its current ``due_time`` in the same
|
||||
transaction as the INSERT, so a config change that arrives mid-batch
|
||||
skips the affected tuples rather than aborting the batch or leaving a
|
||||
partial result.
|
||||
"""
|
||||
_validate_date(start_date, "start_date")
|
||||
_validate_date(end_date, "end_date")
|
||||
if end_date < start_date:
|
||||
raise ValueError(
|
||||
f"end_date must be on or after start_date, got "
|
||||
f"{end_date!r} < {start_date!r}"
|
||||
)
|
||||
|
||||
snapshots, schedules_records, overrides_records = await (
|
||||
load_materialization_input(database, start_date, end_date)
|
||||
)
|
||||
engine = _build_engine(schedules_records, overrides_records)
|
||||
child_filter = None if child_ids is None else set(child_ids)
|
||||
|
||||
definitions = [
|
||||
(snapshot.definition.id, _decode_rule(snapshot),
|
||||
snapshot.assignees, snapshot.windows)
|
||||
for snapshot in snapshots
|
||||
]
|
||||
|
||||
generated_at = _now_stamp()
|
||||
instances = QuestInstancesDao(database)
|
||||
|
||||
start = datetime.datetime.strptime(start_date, "%Y-%m-%d").date()
|
||||
end = datetime.datetime.strptime(end_date, "%Y-%m-%d").date()
|
||||
|
||||
# Clamp a past start up to today (the HA-local anchor when the caller
|
||||
# pinned one, else the host clock) so the walk can never emit a
|
||||
# past-dated instance; the DAO's no-past guard remains the backstop.
|
||||
# A range entirely in the past therefore yields zero instances.
|
||||
today_date = _resolve_today(today)
|
||||
start = max(start, today_date)
|
||||
|
||||
count = 0
|
||||
cursor = start
|
||||
while cursor <= end:
|
||||
iso = cursor.isoformat()
|
||||
for definition_id, rule, assignees, windows in definitions:
|
||||
if not occurs_on(rule, cursor):
|
||||
continue
|
||||
for child in assignees:
|
||||
if child_filter is not None and child.id not in child_filter:
|
||||
continue
|
||||
if not engine.is_present(child.id, cursor):
|
||||
continue
|
||||
for window in windows:
|
||||
written = await instances.upsert_if_valid(
|
||||
definition_id,
|
||||
child.id,
|
||||
iso,
|
||||
generated_at,
|
||||
window=window.window,
|
||||
due_time=window.due_time,
|
||||
today=today,
|
||||
)
|
||||
if written is not None:
|
||||
count += 1
|
||||
cursor += datetime.timedelta(days=1)
|
||||
return count
|
||||
|
||||
|
||||
async def regenerate_for_definition(
|
||||
database: NestQuestDatabase,
|
||||
definition_id: int,
|
||||
*,
|
||||
today: datetime.date | None = None,
|
||||
horizon_days: int | None = None,
|
||||
) -> int:
|
||||
"""Regenerate a definition's future instances after a config change.
|
||||
|
||||
The Feature 06 edit/assignment/activation paths call this after a
|
||||
successful write so future instances track the new state: the
|
||||
definition's open instances at or after today are deleted (never a
|
||||
completed instance, never the past) via
|
||||
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted`,
|
||||
then the materialization walk re-runs over the rolling horizon
|
||||
``[today, today + horizon_days]`` so the (changed) rule,
|
||||
assignee set and windows re-materialize against today's state.
|
||||
|
||||
"today" is computed the same way the walk computes it —
|
||||
:func:`~.dao_instances._today` — so the delete cutoff and the
|
||||
re-materialization range share one anchor, and the walk's
|
||||
``upsert_if_valid`` no-past guard never rejects the regenerated
|
||||
range. ``today`` optionally pins a caller-resolved HA-local date
|
||||
(threaded into both the delete cutoff and the re-materialization)
|
||||
so a household time zone behind the host around midnight stays
|
||||
consistent. ``horizon_days`` sizes the re-materialization window;
|
||||
when omitted it falls back to :data:`~.const.DEFAULT_HORIZON_DAYS`.
|
||||
A bool, non-int, or sub-1 value raises ValueError BEFORE any delete.
|
||||
Returns the number of instances the re-materialization
|
||||
upserted across the whole snapshot (idempotent — other active
|
||||
definitions' tuples refresh in place, never duplicate).
|
||||
"""
|
||||
horizon = _resolve_horizon_days(horizon_days)
|
||||
today_date = _resolve_today(today)
|
||||
start_date = today_date.isoformat()
|
||||
end_date = (
|
||||
today_date + datetime.timedelta(days=horizon)
|
||||
).isoformat()
|
||||
|
||||
instances = QuestInstancesDao(database)
|
||||
await instances.delete_future_uncompleted(
|
||||
definition_id, start_date, today=today
|
||||
)
|
||||
return await materialize(database, start_date, end_date, today=today)
|
||||
|
||||
|
||||
# HOOK (Feature 09 presence services): ``regenerate_for_child`` is the
|
||||
# child-presence counterpart to ``regenerate_for_definition``. The
|
||||
# presence business layer that lands in Feature 09 MUST call
|
||||
# ``regenerate_for_child`` after EVERY presence-state write, so future
|
||||
# instances track a child's changed presence: set_presence_pattern
|
||||
# (including clearing/setting an empty pattern), deleting/clearing the
|
||||
# presence schedule (PresenceSchedulesDao.delete),
|
||||
# create_presence_override, and delete_presence_override. A schedule
|
||||
# delete changes presence state too (the child becomes always-present),
|
||||
# so it must regenerate just like the others. It is deliberately NOT
|
||||
# wired into ``dao_presence.py``: the DAO layer stays pure storage (no
|
||||
# business rules), and no presence business layer exists yet to host the
|
||||
# call — see the module docstring note above.
|
||||
async def regenerate_for_child(
|
||||
database: NestQuestDatabase,
|
||||
child_id: int,
|
||||
*,
|
||||
today: datetime.date | None = None,
|
||||
horizon_days: int | None = None,
|
||||
) -> int:
|
||||
"""Regenerate a child's future instances after a presence change.
|
||||
|
||||
The Feature 09 presence services call this after every
|
||||
presence-state write — set_presence_pattern (including clearing or
|
||||
setting an empty pattern), deleting/clearing the presence schedule
|
||||
(:meth:`~.dao_presence.PresenceSchedulesDao.delete`),
|
||||
create_presence_override, and delete_presence_override — so a
|
||||
child's future instances track its new presence: the child's open
|
||||
instances at or after today are deleted across ALL its definitions
|
||||
(never a completed instance, never the past) via
|
||||
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted_for_child`,
|
||||
then the materialization walk re-runs over the rolling horizon
|
||||
``[today, today + horizon_days]`` scoped to that child only,
|
||||
so the child's now-absent/present dates re-materialize against
|
||||
today's presence WITHOUT touching any other child's rows.
|
||||
|
||||
``child_id`` must be a plain int — bool and float are rejected
|
||||
(SQLite binds a bool as 0/1 and a float would round, so a malformed
|
||||
id must never mutate the wrong profile).
|
||||
|
||||
"today" is computed the same way the walk computes it —
|
||||
:func:`~.dao_instances._today` — and the re-materialization range uses the
|
||||
delete's returned effective cutoff (see
|
||||
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted_for_child`),
|
||||
so the delete cutoff and the re-materialization window share ONE
|
||||
execution-day anchor even when the call is queued across midnight.
|
||||
``today`` optionally pins a caller-resolved HA-local date, threaded
|
||||
into the delete cutoff and the re-materialization for the same
|
||||
host-vs-HA time-zone consistency. ``horizon_days`` sizes the
|
||||
re-materialization window; when omitted it falls back to
|
||||
:data:`~.const.DEFAULT_HORIZON_DAYS`. A bool, non-int, or sub-1 value
|
||||
raises ValueError BEFORE any delete. Returns the number of instances
|
||||
the re-materialization upserted for the child across the whole
|
||||
snapshot.
|
||||
"""
|
||||
if isinstance(child_id, bool) or not isinstance(child_id, int):
|
||||
raise ValueError(f"child_id must be an integer, got {child_id!r}")
|
||||
|
||||
horizon = _resolve_horizon_days(horizon_days)
|
||||
today_date = _resolve_today(today)
|
||||
|
||||
instances = QuestInstancesDao(database)
|
||||
# Use the delete's returned effective cutoff as the materialize anchor,
|
||||
# NOT the independently-computed "today" above. If the call is queued
|
||||
# across midnight the delete clamps its cutoff to the execution-day;
|
||||
# materializing from the stale "today" (now yesterday) would trip the
|
||||
# walk's no-past guard and leave the child's future instances deleted
|
||||
# but not rebuilt. One anchor for both keeps them consistent.
|
||||
_, effective_cutoff = await instances.delete_future_uncompleted_for_child(
|
||||
child_id, today_date.isoformat(), today=today
|
||||
)
|
||||
start = datetime.date.fromisoformat(effective_cutoff)
|
||||
end_date = (start + datetime.timedelta(days=horizon)).isoformat()
|
||||
return await materialize(
|
||||
database, start.isoformat(), end_date, child_ids=[child_id], today=today
|
||||
)
|
||||
@@ -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,13 +11,28 @@ 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,
|
||||
MIN_UPDATE_INTERVAL,
|
||||
)
|
||||
from .admin_allowlist import list_admin_ids, set_admin_ids
|
||||
|
||||
@@ -56,9 +71,63 @@ def _async_validate(user_input: dict[str, Any]) -> dict[str, str]:
|
||||
):
|
||||
errors[CONF_PANEL_IDLE_TIMEOUT] = "invalid"
|
||||
|
||||
# The coordinator refresh interval (Feature 10) is OPTIONAL in raw
|
||||
# submits: a missing key keeps the current value, so pre-Feature-10
|
||||
# form payloads (and API callers) are unaffected. Schema-validated
|
||||
# submits always carry it.
|
||||
update_interval = user_input.get(CONF_UPDATE_INTERVAL)
|
||||
if update_interval is not None and (
|
||||
isinstance(update_interval, bool)
|
||||
or not isinstance(update_interval, int)
|
||||
or update_interval < MIN_UPDATE_INTERVAL
|
||||
):
|
||||
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],
|
||||
*,
|
||||
@@ -70,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(
|
||||
@@ -82,6 +154,40 @@ def _build_schema(
|
||||
vol.Required(
|
||||
CONF_PANEL_IDLE_TIMEOUT, default=current[CONF_PANEL_IDLE_TIMEOUT]
|
||||
): int,
|
||||
vol.Required(
|
||||
CONF_UPDATE_INTERVAL, default=current[CONF_UPDATE_INTERVAL]
|
||||
): int,
|
||||
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
|
||||
@@ -210,7 +316,28 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
|
||||
CONF_HORIZON_DAYS: user_input[CONF_HORIZON_DAYS],
|
||||
CONF_DAY_ROLLOVER_TIME: user_input[CONF_DAY_ROLLOVER_TIME],
|
||||
CONF_PANEL_IDLE_TIMEOUT: user_input[CONF_PANEL_IDLE_TIMEOUT],
|
||||
CONF_UPDATE_INTERVAL: user_input.get(
|
||||
CONF_UPDATE_INTERVAL,
|
||||
self._current_values()[CONF_UPDATE_INTERVAL],
|
||||
),
|
||||
}
|
||||
# The notification section (Feature 11) stores the household's
|
||||
# choices the same fall-back-to-current way, so raw submits
|
||||
# that predate the section keep their stored values.
|
||||
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:
|
||||
@@ -231,6 +358,15 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
|
||||
(CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS),
|
||||
(CONF_DAY_ROLLOVER_TIME, DEFAULT_DAY_ROLLOVER_TIME),
|
||||
(CONF_PANEL_IDLE_TIMEOUT, DEFAULT_PANEL_IDLE_TIMEOUT),
|
||||
(CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL),
|
||||
(CONF_NOTIFY_TARGET, ""),
|
||||
(CONF_MORNING_SUMMARY_TIME, DEFAULT_MORNING_SUMMARY_TIME),
|
||||
(CONF_AFTERNOON_REMINDER_TIME, DEFAULT_AFTERNOON_REMINDER_TIME),
|
||||
(CONF_END_OF_DAY_REPORT_TIME, DEFAULT_END_OF_DAY_REPORT_TIME),
|
||||
(CONF_MORNING_SUMMARY_ENABLED, DEFAULT_AUTOMATION_ENABLED),
|
||||
(CONF_AFTERNOON_REMINDER_ENABLED, DEFAULT_AUTOMATION_ENABLED),
|
||||
(CONF_END_OF_DAY_REPORT_ENABLED, DEFAULT_AUTOMATION_ENABLED),
|
||||
(CONF_CELEBRATION_ENABLED, DEFAULT_AUTOMATION_ENABLED),
|
||||
):
|
||||
if key in options:
|
||||
values[key] = options[key]
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Permission gate for NestQuest services (Feature 09).
|
||||
|
||||
A wrapper factory, applied to EVERY registered service at registration
|
||||
time (no handler is exempt), reads ``call.context.user_id``, consults
|
||||
the :data:`~.service_policy.SERVICE_POLICY` registry and
|
||||
:func:`~.admin_allowlist.is_admin`, and raises
|
||||
:class:`homeassistant.exceptions.Unauthorized` for an admin-only
|
||||
operation called by a non-admin — including a call with NO user
|
||||
context, which fails closed. ``complete_quest`` (policy ``open``)
|
||||
proceeds regardless of the allowlist.
|
||||
|
||||
Logging (later Feature 09 tasks): every denied admin-only call logs a
|
||||
warning and every successful one logs a debug line, both carrying the
|
||||
service, the user id (or ``none``), and a UTC timestamp.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.exceptions import Unauthorized
|
||||
|
||||
from .admin_allowlist import is_admin
|
||||
from .service_policy import ADMIN_ONLY
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ServiceHandler = Callable[[Any], Awaitable[None]]
|
||||
|
||||
|
||||
def _context_user_id(call: Any) -> str | None:
|
||||
context = getattr(call, "context", None)
|
||||
user_id = getattr(context, "user_id", None) if context is not None else None
|
||||
if isinstance(user_id, str) and user_id:
|
||||
return user_id
|
||||
return None
|
||||
|
||||
|
||||
def _stamp() -> str:
|
||||
return datetime.datetime.now(datetime.timezone.utc).isoformat(
|
||||
timespec="seconds"
|
||||
)
|
||||
|
||||
|
||||
def permission_gate(
|
||||
service: str,
|
||||
policy: str,
|
||||
database_of: Callable[[Any], Any],
|
||||
) -> Callable[[ServiceHandler], ServiceHandler]:
|
||||
"""Wrap ``handler`` with the Feature 09 permission check.
|
||||
|
||||
``database_of`` resolves the live database from the call-time
|
||||
runtime (the same ``find_runtime`` the handler uses), so the gate
|
||||
never opens its own connection. The verdict is fail-closed:
|
||||
absent allowlist row, unknown id, no user context — all not admin.
|
||||
"""
|
||||
|
||||
def _wrap(handler: ServiceHandler) -> ServiceHandler:
|
||||
if policy != ADMIN_ONLY:
|
||||
return handler
|
||||
|
||||
async def _gated(call: Any) -> None:
|
||||
user_id = _context_user_id(call)
|
||||
allowed = user_id is not None and await is_admin(
|
||||
database_of(call), user_id
|
||||
)
|
||||
if not allowed:
|
||||
LOGGER.warning(
|
||||
"NestQuest denied %s: caller user_id=%s at %s",
|
||||
service,
|
||||
user_id if user_id is not None else "none",
|
||||
_stamp(),
|
||||
)
|
||||
raise Unauthorized(
|
||||
f"User '{user_id if user_id is not None else 'none'}' "
|
||||
f"is not allowed to call nestquest.{service}"
|
||||
)
|
||||
LOGGER.debug(
|
||||
"NestQuest allowed %s: caller user_id=%s at %s",
|
||||
service,
|
||||
user_id,
|
||||
_stamp(),
|
||||
)
|
||||
await handler(call)
|
||||
|
||||
return _gated
|
||||
|
||||
return _wrap
|
||||
@@ -44,6 +44,7 @@ from dataclasses import dataclass
|
||||
from .dao_children import ChildRecord, _connection_lock
|
||||
from .dao_rules import (
|
||||
QuestDefinitionRecord,
|
||||
QuestDefinitionSnapshot,
|
||||
QuestDefinitionWindowRecord,
|
||||
QuestDefinitionsDao,
|
||||
ScheduleRuleStorage,
|
||||
@@ -57,6 +58,7 @@ from .dao_rules import (
|
||||
schedule_rule_to_storage,
|
||||
)
|
||||
from .db import NestQuestDatabase
|
||||
from .materialize import regenerate_for_definition
|
||||
from .recurrence import ScheduleRule, occurs_on
|
||||
|
||||
|
||||
@@ -269,6 +271,8 @@ async def edit_quest_definition(
|
||||
icon: str | None | object = _UNSET,
|
||||
rule: ScheduleRule | object = _UNSET,
|
||||
windows: list[str | tuple[str, str | None]] | object = _UNSET,
|
||||
today: datetime.date | None = None,
|
||||
horizon_days: int | None = None,
|
||||
) -> CreatedQuestDefinition:
|
||||
"""Edit a quest definition's metadata, rule and windows atomically.
|
||||
|
||||
@@ -290,6 +294,16 @@ async def edit_quest_definition(
|
||||
so the returned bundle is a consistent edit snapshot. Edits change
|
||||
future instances only; no ``quest_instances`` or
|
||||
``completion_events`` row is touched.
|
||||
|
||||
``today`` optionally pins the caller-resolved HA-local date the
|
||||
regeneration's no-past guard and horizon are anchored to (threaded
|
||||
into :func:`~.materialize.regenerate_for_definition`); it is a plain
|
||||
``datetime.date`` with no HA import here — the Feature 09 service
|
||||
supplies the hass-derived value. When omitted, regeneration falls
|
||||
back to the host clock, same as before. ``horizon_days`` sizes the
|
||||
regeneration's re-materialization window the same way (threaded into
|
||||
:func:`~.materialize.regenerate_for_definition`); it defaults to
|
||||
:data:`~.const.DEFAULT_HORIZON_DAYS` when omitted.
|
||||
"""
|
||||
_validate_definition_id(definition_id)
|
||||
|
||||
@@ -318,19 +332,32 @@ async def edit_quest_definition(
|
||||
window_specs = _normalize_windows(windows)
|
||||
|
||||
dao = QuestDefinitionsDao(database)
|
||||
snapshot = await dao.edit_definition(
|
||||
definition_id,
|
||||
title=title_value,
|
||||
description=description_value,
|
||||
icon=icon_value,
|
||||
rule=rule_storage,
|
||||
windows=window_specs,
|
||||
)
|
||||
try:
|
||||
snapshot = await dao.edit_definition(
|
||||
definition_id,
|
||||
title=title_value,
|
||||
description=description_value,
|
||||
icon=icon_value,
|
||||
rule=rule_storage,
|
||||
windows=window_specs,
|
||||
)
|
||||
except ValueError as error:
|
||||
# The DAO's missing-definition error names only the id; re-raise
|
||||
# so the public contract names the field the caller passed (matching
|
||||
# assign/unassign/deactivate).
|
||||
message = str(error)
|
||||
if message.startswith("quest definition "):
|
||||
raise ValueError(f"definition_id: {message}") from error
|
||||
raise
|
||||
|
||||
decoded_rule = schedule_rule_from_storage(
|
||||
schedule_rule_storage_from_record(snapshot.rule)
|
||||
)
|
||||
|
||||
await regenerate_for_definition(
|
||||
database, definition_id, today=today, horizon_days=horizon_days
|
||||
)
|
||||
|
||||
return CreatedQuestDefinition(
|
||||
definition=snapshot.definition,
|
||||
rule=decoded_rule,
|
||||
@@ -343,6 +370,9 @@ async def assign_child(
|
||||
database: NestQuestDatabase,
|
||||
definition_id: int,
|
||||
child_id: int,
|
||||
*,
|
||||
today: datetime.date | None = None,
|
||||
horizon_days: int | None = None,
|
||||
) -> list[ChildRecord]:
|
||||
"""Assign ``child_id`` to the definition (idempotent).
|
||||
|
||||
@@ -356,12 +386,17 @@ async def assign_child(
|
||||
write). Raises ValueError on any rejected argument. Only the
|
||||
``quest_definition_assignees`` link is written; existing instances
|
||||
and completion history are never touched.
|
||||
|
||||
``today`` optionally pins the caller-resolved HA-local date threaded
|
||||
into the assignment's regeneration (see :func:`edit_quest_definition`).
|
||||
``horizon_days`` optionally sizes the regeneration's re-materialization
|
||||
window the same way.
|
||||
"""
|
||||
_validate_definition_id(definition_id)
|
||||
_validate_child_id(child_id)
|
||||
dao = QuestDefinitionsDao(database)
|
||||
try:
|
||||
return await dao.add_assignee(definition_id, child_id)
|
||||
assignees = await dao.add_assignee(definition_id, child_id)
|
||||
except ValueError as error:
|
||||
# The DAO names only the child/definition; re-raise so the
|
||||
# public contract names the field the caller actually passed.
|
||||
@@ -371,12 +406,19 @@ async def assign_child(
|
||||
if message.startswith("quest definition "):
|
||||
raise ValueError(f"definition_id: {message}") from error
|
||||
raise
|
||||
await regenerate_for_definition(
|
||||
database, definition_id, today=today, horizon_days=horizon_days
|
||||
)
|
||||
return assignees
|
||||
|
||||
|
||||
async def unassign_child(
|
||||
database: NestQuestDatabase,
|
||||
definition_id: int,
|
||||
child_id: int,
|
||||
*,
|
||||
today: datetime.date | None = None,
|
||||
horizon_days: int | None = None,
|
||||
) -> bool:
|
||||
"""Unassign ``child_id`` from the definition; True when removed.
|
||||
|
||||
@@ -386,6 +428,11 @@ async def unassign_child(
|
||||
was not currently assigned. Raises ValueError on a rejected
|
||||
argument. Only the ``quest_definition_assignees`` link is written;
|
||||
existing instances and completion history are never touched.
|
||||
|
||||
``today`` optionally pins the caller-resolved HA-local date threaded
|
||||
into the unassignment's regeneration (see :func:`edit_quest_definition`).
|
||||
``horizon_days`` optionally sizes the regeneration's re-materialization
|
||||
window the same way.
|
||||
"""
|
||||
_validate_definition_id(definition_id)
|
||||
_validate_child_id(child_id)
|
||||
@@ -395,13 +442,20 @@ async def unassign_child(
|
||||
raise ValueError(
|
||||
f"definition_id: quest definition {definition_id} does not exist"
|
||||
)
|
||||
return await dao.remove_assignee(definition_id, child_id)
|
||||
removed = await dao.remove_assignee(definition_id, child_id)
|
||||
await regenerate_for_definition(
|
||||
database, definition_id, today=today, horizon_days=horizon_days
|
||||
)
|
||||
return removed
|
||||
|
||||
|
||||
async def set_quest_definition_active(
|
||||
database: NestQuestDatabase,
|
||||
definition_id: int,
|
||||
is_active: bool,
|
||||
*,
|
||||
today: datetime.date | None = None,
|
||||
horizon_days: int | None = None,
|
||||
) -> CreatedQuestDefinition:
|
||||
"""Deactivate or reactivate a definition; returns the updated view.
|
||||
|
||||
@@ -424,6 +478,11 @@ async def set_quest_definition_active(
|
||||
definition, its rule, assignees and windows all run inside the
|
||||
connection-scoped lock, so the returned bundle is a consistent
|
||||
snapshot that a concurrent mutation cannot race.
|
||||
|
||||
``today`` optionally pins the caller-resolved HA-local date threaded
|
||||
into the activation change's regeneration (see
|
||||
:func:`edit_quest_definition`). ``horizon_days`` optionally sizes the
|
||||
regeneration's re-materialization window the same way.
|
||||
"""
|
||||
_validate_definition_id(definition_id)
|
||||
if not isinstance(is_active, bool):
|
||||
@@ -447,6 +506,9 @@ async def set_quest_definition_active(
|
||||
rule = schedule_rule_from_storage(
|
||||
schedule_rule_storage_from_record(rule_record)
|
||||
)
|
||||
await regenerate_for_definition(
|
||||
database, definition_id, today=today, horizon_days=horizon_days
|
||||
)
|
||||
return CreatedQuestDefinition(
|
||||
definition=updated,
|
||||
rule=rule,
|
||||
@@ -455,33 +517,25 @@ async def set_quest_definition_active(
|
||||
)
|
||||
|
||||
|
||||
async def _bundle_definition(
|
||||
database: NestQuestDatabase,
|
||||
dao: QuestDefinitionsDao,
|
||||
definition: QuestDefinitionRecord,
|
||||
def _bundle_snapshot(
|
||||
snapshot: QuestDefinitionSnapshot,
|
||||
) -> CreatedQuestDefinition:
|
||||
"""Assemble a definition's rich view: decoded rule + assignees + windows.
|
||||
"""Decode a DAO snapshot into the layer's rich view.
|
||||
|
||||
Reads the schedule rule through :class:`~.dao_rules.ScheduleRulesDao`
|
||||
and the assignees and windows through the DAO, then wraps them in the
|
||||
same :class:`CreatedQuestDefinition` shape the create/edit/activate
|
||||
paths return, so every query helper here reuses one snapshot type
|
||||
rather than inventing a second one.
|
||||
The rule record carried by the snapshot is the one the definition
|
||||
referenced at read time, so decoding it here (rather than re-reading
|
||||
through :class:`~.dao_rules.ScheduleRulesDao`) preserves the
|
||||
coherence the DAO's locked snapshot guarantees: the rule, assignees
|
||||
and windows all come from one atomic read, never three separate ones.
|
||||
"""
|
||||
rule_record = await ScheduleRulesDao(database).get(
|
||||
definition.schedule_rule_id
|
||||
)
|
||||
assert rule_record is not None
|
||||
rule = schedule_rule_from_storage(
|
||||
schedule_rule_storage_from_record(rule_record)
|
||||
schedule_rule_storage_from_record(snapshot.rule)
|
||||
)
|
||||
assignees = await dao.list_assignees(definition.id)
|
||||
windows = await dao.list_windows(definition.id)
|
||||
return CreatedQuestDefinition(
|
||||
definition=definition,
|
||||
definition=snapshot.definition,
|
||||
rule=rule,
|
||||
assignees=assignees,
|
||||
windows=windows,
|
||||
assignees=snapshot.assignees,
|
||||
windows=snapshot.windows,
|
||||
)
|
||||
|
||||
|
||||
@@ -494,14 +548,14 @@ async def list_active_definitions(
|
||||
order, so callers get a stable, repeatable sequence. Each entry is
|
||||
the same rich :class:`CreatedQuestDefinition` snapshot as the rest of
|
||||
the layer: the stored row plus its decoded
|
||||
:class:`~.recurrence.ScheduleRule`, assignees and windows.
|
||||
:class:`~.recurrence.ScheduleRule`, assignees and windows. The
|
||||
definitions and their links are read by the DAO inside one locked
|
||||
transaction, so each bundle is a coherent snapshot that a concurrent
|
||||
edit cannot interleave.
|
||||
"""
|
||||
dao = QuestDefinitionsDao(database)
|
||||
definitions = await dao.list_active()
|
||||
return [
|
||||
await _bundle_definition(database, dao, definition)
|
||||
for definition in definitions
|
||||
]
|
||||
snapshots = await dao.list_snapshots_active()
|
||||
return [_bundle_snapshot(snapshot) for snapshot in snapshots]
|
||||
|
||||
|
||||
async def list_definitions_for_child(
|
||||
@@ -515,15 +569,12 @@ async def list_definitions_for_child(
|
||||
rising definition id, matching ``list_active_definitions`` and
|
||||
``list_definitions_firing_on``. Each entry is a
|
||||
:class:`CreatedQuestDefinition` snapshot bundling the definition with
|
||||
its decoded rule, assignees and windows.
|
||||
its decoded rule, assignees and windows, read atomically by the DAO.
|
||||
"""
|
||||
_validate_child_id(child_id)
|
||||
dao = QuestDefinitionsDao(database)
|
||||
definitions = await dao.list_by_child(child_id)
|
||||
bundles = [
|
||||
await _bundle_definition(database, dao, definition)
|
||||
for definition in definitions
|
||||
]
|
||||
snapshots = await dao.list_snapshots_by_child(child_id)
|
||||
bundles = [_bundle_snapshot(snapshot) for snapshot in snapshots]
|
||||
return sorted(bundles, key=lambda bundle: bundle.definition.id)
|
||||
|
||||
|
||||
@@ -540,13 +591,13 @@ async def list_definitions_firing_on(
|
||||
definitions whose decoded rule fires on the date are returned, in
|
||||
rising definition-id order, each as the same rich
|
||||
:class:`CreatedQuestDefinition` snapshot as the rest of the layer.
|
||||
The definitions and their rules are read inside one locked
|
||||
transaction, so a bundle can never filter on one rule while
|
||||
presenting another state's windows.
|
||||
"""
|
||||
_validate_date(date, "date")
|
||||
target = datetime.datetime.strptime(date, "%Y-%m-%d").date()
|
||||
dao = QuestDefinitionsDao(database)
|
||||
definitions = await dao.list_active()
|
||||
bundles = [
|
||||
await _bundle_definition(database, dao, definition)
|
||||
for definition in definitions
|
||||
]
|
||||
snapshots = await dao.list_snapshots_active()
|
||||
bundles = [_bundle_snapshot(snapshot) for snapshot in snapshots]
|
||||
return [bundle for bundle in bundles if occurs_on(bundle.rule, target)]
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,535 @@
|
||||
"""Per-child daily count and percentage sensors (Feature 10, task 2).
|
||||
|
||||
Naming contract (design/ENTITIES-AND-SERVICES.md §1): the design table
|
||||
documents the entity_id shape ``sensor.nestquest_<child>_quests_due_today``.
|
||||
Home Assistant derives entity_id from the entity NAME and exact
|
||||
entity_id strings are not directly controllable, so names are spelled
|
||||
to slug into the documented shape — ``NestQuest Ada quests due today``
|
||||
slugs to ``sensor.nestquest_ada_quests_due_today`` — while the
|
||||
registry identity (``unique_id``) IS exact: built from the child's
|
||||
database id, never the display name, so a rename never orphans an
|
||||
entity's history. The design table is the visual contract; this
|
||||
module spells names so HA's default slugging produces it.
|
||||
|
||||
Every sensor reads ONLY ``coordinator.data`` (one
|
||||
:class:`~.coordinator.ChildDaySnapshot` per active child); no entity
|
||||
queries the database, so one refresh updates the whole board
|
||||
coherently. The due-today sensor additionally carries the child's
|
||||
day payload as attributes: ``instances`` is the panel shape of
|
||||
ENTITIES-AND-SERVICES.md §1 (state ``open``/``completed``) OMITTING
|
||||
missed instances, and ``admin_instances`` includes them (D-009).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Iterable
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import (
|
||||
ChildDaySnapshot,
|
||||
NestQuestCoordinator,
|
||||
QuestInstanceView,
|
||||
)
|
||||
|
||||
#: Home Assistant's state machine refuses states longer than 255
|
||||
#: characters (its hard recorder/state limit). These sensors' states
|
||||
#: are numeric, but the guard is applied uniformly: a payload-bearing
|
||||
#: sensor can never crash the state write, and later string-state
|
||||
#: sensors reuse the same helper.
|
||||
MAX_STATE_LENGTH = 255
|
||||
|
||||
|
||||
def _state_within_limit(state: Any) -> Any:
|
||||
"""Return ``state`` unchanged, refusing strings over HA's limit.
|
||||
|
||||
Numeric states pass through untouched; a string state longer than
|
||||
:data:`MAX_STATE_LENGTH` fails fast with ValueError at the property
|
||||
read — before HA's state machine rejects the write.
|
||||
"""
|
||||
if isinstance(state, str) and len(state) > MAX_STATE_LENGTH:
|
||||
raise ValueError(
|
||||
f"state exceeds Home Assistant's {MAX_STATE_LENGTH}-character "
|
||||
f"limit ({len(state)} characters)"
|
||||
)
|
||||
return state
|
||||
|
||||
|
||||
def _instance_payload(
|
||||
instances: Iterable[QuestInstanceView], *, include_missed: bool
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Shape instance dicts per ENTITIES-AND-SERVICES.md §1.
|
||||
|
||||
``state`` maps the coordinator's derived ``done`` to the panel's
|
||||
``completed`` spelling; ``missed`` instances are omitted unless
|
||||
``include_missed`` (the admin payload, D-009, keeps them with
|
||||
their ``missed`` state).
|
||||
"""
|
||||
payload: list[dict[str, Any]] = []
|
||||
for view in instances:
|
||||
if view.state == "missed" and not include_missed:
|
||||
continue
|
||||
payload.append(
|
||||
{
|
||||
"id": view.instance_id,
|
||||
"definition_id": view.definition_id,
|
||||
"child_id": view.child_id,
|
||||
"title": view.title,
|
||||
"icon": view.icon,
|
||||
"window": view.window,
|
||||
"due_time": view.due_time,
|
||||
"state": (
|
||||
"completed" if view.state == "done" else view.state
|
||||
),
|
||||
"overdue": view.overdue,
|
||||
"completed_at": view.completed_at,
|
||||
"on_time": view.was_on_time,
|
||||
}
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
#: State reported by the next-quest sensor when the child owes nothing
|
||||
#: else today. A documented sentinel, never an empty string (an empty
|
||||
#: state reads as "sensor went unavailable" in HA).
|
||||
NEXT_QUEST_NONE_STATE = "none"
|
||||
|
||||
|
||||
def _next_open_quest(
|
||||
child: ChildDaySnapshot,
|
||||
) -> QuestInstanceView | None:
|
||||
"""Return the child's earliest open quest from today's snapshot.
|
||||
|
||||
The snapshot carries TODAY's instances, so "next" is the earliest
|
||||
open instance by (due_date, due_time), with ``due_time`` ordering
|
||||
ahead of ``None`` (a window with no due time comes first only
|
||||
within the same date — the materializer snapshots each window's
|
||||
due_time, so a missing one means the window is unscheduled).
|
||||
Missed and completed instances never qualify.
|
||||
"""
|
||||
open_views = [
|
||||
view
|
||||
for view in child.instances
|
||||
if view.state == "open"
|
||||
]
|
||||
if not open_views:
|
||||
return None
|
||||
return min(
|
||||
open_views,
|
||||
key=lambda view: (
|
||||
view.due_date,
|
||||
view.due_time if view.due_time is not None else "",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class _NestQuestChildDaySensor(CoordinatorEntity, SensorEntity):
|
||||
"""Base for the per-child day sensors.
|
||||
|
||||
Registry identity is built from the child's database id and never
|
||||
changes; the DISPLAYED name and device label are derived from the
|
||||
child's CURRENT snapshot on every read, so a rename through
|
||||
``manage_child`` propagates on the next refresh without orphaning
|
||||
the entity's history. A child absent from the latest snapshot (a
|
||||
deactivation; removal is a later task's scope) yields ``None``
|
||||
states rather than stale counts.
|
||||
"""
|
||||
|
||||
#: Per-subclass label appended to the name (e.g. ``quests due
|
||||
#: today``), set as a class attribute so ``name`` composes from the
|
||||
#: current snapshot.
|
||||
_name_suffix: str = ""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: NestQuestCoordinator,
|
||||
child: ChildDaySnapshot,
|
||||
) -> None:
|
||||
super().__init__(coordinator)
|
||||
self._child_id = child.child_id
|
||||
self._fallback_name = child.child_name
|
||||
self._attr_unique_id = None # set by the subclass
|
||||
|
||||
@property
|
||||
def _child(self) -> ChildDaySnapshot | None:
|
||||
"""Return the child's current day snapshot, or None."""
|
||||
data = self.coordinator.data
|
||||
if data is None:
|
||||
return None
|
||||
for child in data.children:
|
||||
if child.child_id == self._child_id:
|
||||
return child
|
||||
return None
|
||||
|
||||
def _displayed_child_name(self) -> str:
|
||||
child = self._child
|
||||
return child.child_name if child is not None else self._fallback_name
|
||||
|
||||
@property
|
||||
def name(self) -> str | None:
|
||||
return f"NestQuest {self._displayed_child_name()} {self._name_suffix}"
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, f"child_{self._child_id}")},
|
||||
name=f"NestQuest {self._displayed_child_name()}",
|
||||
manufacturer="NestQuest",
|
||||
)
|
||||
|
||||
|
||||
class NestQuestQuestsDueTodaySensor(_NestQuestChildDaySensor):
|
||||
"""How many quests the child has due today, with the day payload."""
|
||||
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_name_suffix = "quests due today"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: NestQuestCoordinator,
|
||||
child: ChildDaySnapshot,
|
||||
) -> None:
|
||||
super().__init__(coordinator, child)
|
||||
self._attr_unique_id = (
|
||||
f"{DOMAIN}_child_{child.child_id}_quests_due_today"
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | None:
|
||||
child = self._child
|
||||
if child is None:
|
||||
return None
|
||||
return _state_within_limit(child.due_today)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||
child = self._child
|
||||
if child is None:
|
||||
return None
|
||||
return {
|
||||
"instances": _instance_payload(
|
||||
child.instances, include_missed=False
|
||||
),
|
||||
"admin_instances": _instance_payload(
|
||||
child.instances, include_missed=True
|
||||
),
|
||||
"child_id": child.child_id,
|
||||
"child_name": child.child_name,
|
||||
"present": child.present,
|
||||
}
|
||||
|
||||
|
||||
class NestQuestQuestsCompletedTodaySensor(_NestQuestChildDaySensor):
|
||||
"""How many of the child's quests were completed today."""
|
||||
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_name_suffix = "quests completed today"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: NestQuestCoordinator,
|
||||
child: ChildDaySnapshot,
|
||||
) -> None:
|
||||
super().__init__(coordinator, child)
|
||||
self._attr_unique_id = (
|
||||
f"{DOMAIN}_child_{child.child_id}_quests_completed_today"
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | None:
|
||||
child = self._child
|
||||
if child is None:
|
||||
return None
|
||||
return _state_within_limit(child.completed_today)
|
||||
|
||||
|
||||
class NestQuestQuestsRemainingTodaySensor(_NestQuestChildDaySensor):
|
||||
"""How many of the child's quests are still owed today."""
|
||||
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_name_suffix = "quests remaining today"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: NestQuestCoordinator,
|
||||
child: ChildDaySnapshot,
|
||||
) -> None:
|
||||
super().__init__(coordinator, child)
|
||||
self._attr_unique_id = (
|
||||
f"{DOMAIN}_child_{child.child_id}_quests_remaining_today"
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | None:
|
||||
child = self._child
|
||||
if child is None:
|
||||
return None
|
||||
return _state_within_limit(child.remaining_today)
|
||||
|
||||
|
||||
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"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: NestQuestCoordinator,
|
||||
child: ChildDaySnapshot,
|
||||
) -> None:
|
||||
super().__init__(coordinator, child)
|
||||
self._attr_unique_id = (
|
||||
f"{DOMAIN}_child_{child.child_id}_completion_pct_today"
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | None:
|
||||
child = self._child
|
||||
if child is None:
|
||||
return None
|
||||
return _state_within_limit(child.completion_pct)
|
||||
|
||||
|
||||
class NestQuestNextQuestSensor(_NestQuestChildDaySensor):
|
||||
"""The child's next open quest today (title in the state).
|
||||
|
||||
State is the quest TITLE, truncated to HA's 255-character state
|
||||
limit (attributes always carry the full title); when the child
|
||||
owes nothing else today the state is the documented sentinel
|
||||
``none`` (NEXT_QUEST_NONE_STATE), never an empty string.
|
||||
"""
|
||||
|
||||
_attr_state_class = None # a label, not a measurement
|
||||
_name_suffix = "next quest"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: NestQuestCoordinator,
|
||||
child: ChildDaySnapshot,
|
||||
) -> None:
|
||||
super().__init__(coordinator, child)
|
||||
self._attr_unique_id = (
|
||||
f"{DOMAIN}_child_{child.child_id}_next_quest"
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | None:
|
||||
child = self._child
|
||||
if child is None:
|
||||
return None
|
||||
quest = _next_open_quest(child)
|
||||
if quest is None:
|
||||
return NEXT_QUEST_NONE_STATE
|
||||
return quest.title[:MAX_STATE_LENGTH]
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||
child = self._child
|
||||
if child is None:
|
||||
return None
|
||||
quest = _next_open_quest(child)
|
||||
if quest is None:
|
||||
return {"child_id": child.child_id, "child_name": child.child_name}
|
||||
return {
|
||||
"instance_id": quest.instance_id,
|
||||
"definition_id": quest.definition_id,
|
||||
"child_id": quest.child_id,
|
||||
"child_name": child.child_name,
|
||||
"title": quest.title,
|
||||
"icon": quest.icon,
|
||||
"window": quest.window,
|
||||
"due_date": quest.due_date,
|
||||
"due_time": quest.due_time,
|
||||
"overdue": quest.overdue,
|
||||
}
|
||||
|
||||
|
||||
def _child_day_sensors(
|
||||
coordinator: NestQuestCoordinator, child: ChildDaySnapshot
|
||||
) -> tuple[_NestQuestChildDaySensor, ...]:
|
||||
"""Build the per-child day sensors for one active child."""
|
||||
return (
|
||||
NestQuestQuestsDueTodaySensor(coordinator, child),
|
||||
NestQuestQuestsCompletedTodaySensor(coordinator, child),
|
||||
NestQuestQuestsRemainingTodaySensor(coordinator, child),
|
||||
NestQuestCompletionPctTodaySensor(coordinator, child),
|
||||
NestQuestNextQuestSensor(coordinator, child),
|
||||
)
|
||||
|
||||
|
||||
class _NestQuestHouseholdSensor(CoordinatorEntity, SensorEntity):
|
||||
"""Base for the household-level rollup sensors.
|
||||
|
||||
Aggregates the ACTIVE children's snapshots only (the snapshot
|
||||
already excludes inactive children), matching the per-child
|
||||
sensors exactly — a household number is always the sum of the
|
||||
per-child numbers. Grouped under one household device so the
|
||||
three household sensors render together.
|
||||
"""
|
||||
|
||||
_attr_name: str
|
||||
|
||||
def __init__(self, coordinator: NestQuestCoordinator) -> None:
|
||||
super().__init__(coordinator)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, "household")},
|
||||
name="NestQuest Household",
|
||||
manufacturer="NestQuest",
|
||||
)
|
||||
|
||||
|
||||
class NestQuestHouseholdDueTodaySensor(_NestQuestHouseholdSensor):
|
||||
"""Total quests due today across active children."""
|
||||
|
||||
_attr_name = "NestQuest household quests due today"
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_attr_unique_id = f"{DOMAIN}_household_quests_due_today"
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | None:
|
||||
data = self.coordinator.data
|
||||
if data is None:
|
||||
return None
|
||||
return _state_within_limit(
|
||||
sum(child.due_today for child in data.children)
|
||||
)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||
data = self.coordinator.data
|
||||
if data is None:
|
||||
return None
|
||||
return {
|
||||
"children": {
|
||||
str(child.child_id): child.due_today
|
||||
for child in data.children
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class NestQuestHouseholdCompletedTodaySensor(_NestQuestHouseholdSensor):
|
||||
"""Total quests completed today across active children."""
|
||||
|
||||
_attr_name = "NestQuest household quests completed today"
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_attr_unique_id = f"{DOMAIN}_household_quests_completed_today"
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | None:
|
||||
data = self.coordinator.data
|
||||
if data is None:
|
||||
return None
|
||||
return _state_within_limit(
|
||||
sum(child.completed_today for child in data.children)
|
||||
)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||
data = self.coordinator.data
|
||||
if data is None:
|
||||
return None
|
||||
return {
|
||||
"children": {
|
||||
str(child.child_id): child.completed_today
|
||||
for child in data.children
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class NestQuestCycleDaySensor(_NestQuestHouseholdSensor):
|
||||
"""Cycle day N of the custody cycle (0 when nobody is scheduled).
|
||||
|
||||
Computed by the coordinator from the first active child with a
|
||||
presence schedule, anchor-date arithmetic only (D-004 — never ISO
|
||||
week parity), so a 53-week year cannot silently invert it.
|
||||
"""
|
||||
|
||||
_attr_name = "NestQuest cycle day"
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_attr_unique_id = f"{DOMAIN}_cycle_day"
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | None:
|
||||
data = self.coordinator.data
|
||||
if data is None:
|
||||
return None
|
||||
return _state_within_limit(data.cycle_day)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||
data = self.coordinator.data
|
||||
if data is None:
|
||||
return None
|
||||
return {"cycle_day": data.cycle_day, "today": data.today_iso}
|
||||
|
||||
|
||||
def _household_sensors(
|
||||
coordinator: NestQuestCoordinator,
|
||||
) -> tuple[_NestQuestHouseholdSensor, ...]:
|
||||
"""Build the three household-level sensors (one set per entry)."""
|
||||
return (
|
||||
NestQuestHouseholdDueTodaySensor(coordinator),
|
||||
NestQuestHouseholdCompletedTodaySensor(coordinator),
|
||||
NestQuestCycleDaySensor(coordinator),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: Callable[..., None],
|
||||
) -> None:
|
||||
"""Create the five day sensors per active child.
|
||||
|
||||
Entities are created for the snapshot's children at platform
|
||||
setup; a coordinator listener adds sensors for children that
|
||||
first appear on a later refresh. Deactivated children are NOT
|
||||
removed here — their sensors hold ``None`` states until the
|
||||
entity-removal task lands.
|
||||
"""
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
known_child_ids: set[int] = set()
|
||||
|
||||
def _add_new_children(children: Iterable[ChildDaySnapshot]) -> None:
|
||||
new_children = [
|
||||
child
|
||||
for child in children
|
||||
if child.child_id not in known_child_ids
|
||||
]
|
||||
if not new_children:
|
||||
return
|
||||
known_child_ids.update(child.child_id for child in new_children)
|
||||
async_add_entities(
|
||||
[
|
||||
sensor
|
||||
for child in new_children
|
||||
for sensor in _child_day_sensors(coordinator, child)
|
||||
]
|
||||
)
|
||||
|
||||
data = coordinator.data
|
||||
if data is not None:
|
||||
_add_new_children(data.children)
|
||||
# The household rollups are child-independent: created exactly
|
||||
# once at platform setup, they aggregate whatever the snapshot
|
||||
# carries (empty included — an empty household is a valid 0).
|
||||
async_add_entities(_household_sensors(coordinator))
|
||||
|
||||
def _handle_coordinator_update() -> None:
|
||||
data = coordinator.data
|
||||
if data is not None:
|
||||
_add_new_children(data.children)
|
||||
|
||||
coordinator.async_add_listener(_handle_coordinator_update)
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Explicit admin-only versus open service registry (Feature 09).
|
||||
|
||||
One mapping, one file: every registered ``nestquest`` service name is
|
||||
either ``open`` or ``admin_only``. The permission decorator (later
|
||||
Feature 09 tasks) reads this table; it does not take per-handler
|
||||
policy arguments.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .const import (
|
||||
DOMAIN_SERVICES,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
||||
SERVICE_CREATE_QUEST_DEFINITION,
|
||||
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
||||
SERVICE_EXPORT_HISTORY_CSV,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
SERVICE_REGENERATE,
|
||||
SERVICE_SET_PRESENCE_PATTERN,
|
||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
||||
SERVICE_UNCOMPLETE_QUEST,
|
||||
SERVICE_UPDATE_QUEST_DEFINITION,
|
||||
)
|
||||
|
||||
OPEN = "open"
|
||||
ADMIN_ONLY = "admin_only"
|
||||
|
||||
SERVICE_POLICY: dict[str, str] = {
|
||||
SERVICE_COMPLETE_QUEST: OPEN,
|
||||
SERVICE_UNCOMPLETE_QUEST: ADMIN_ONLY,
|
||||
SERVICE_CREATE_QUEST_DEFINITION: ADMIN_ONLY,
|
||||
SERVICE_UPDATE_QUEST_DEFINITION: ADMIN_ONLY,
|
||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE: ADMIN_ONLY,
|
||||
SERVICE_SET_PRESENCE_PATTERN: ADMIN_ONLY,
|
||||
SERVICE_CREATE_PRESENCE_OVERRIDE: ADMIN_ONLY,
|
||||
SERVICE_DELETE_PRESENCE_OVERRIDE: ADMIN_ONLY,
|
||||
SERVICE_EXPORT_HISTORY_CSV: ADMIN_ONLY,
|
||||
SERVICE_MANAGE_CHILD: ADMIN_ONLY,
|
||||
SERVICE_REGENERATE: ADMIN_ONLY,
|
||||
}
|
||||
|
||||
if frozenset(SERVICE_POLICY) != frozenset(DOMAIN_SERVICES):
|
||||
raise RuntimeError(
|
||||
"SERVICE_POLICY must name exactly the registered nestquest services, "
|
||||
f"got {sorted(SERVICE_POLICY)} vs {list(DOMAIN_SERVICES)}"
|
||||
)
|
||||
@@ -0,0 +1,636 @@
|
||||
"""Domain-global NestQuest service schemas and handlers (Feature 09).
|
||||
|
||||
Registration is domain-scoped and happens once (see
|
||||
:func:`async_register_services`); handlers resolve a live database at
|
||||
call time through the injected ``find_runtime`` callable. Permission
|
||||
checks are Feature 09 later tasks — this module only validates payload
|
||||
shape and forwards to the Feature 03/06/08 business layers (and the
|
||||
presence DAOs). ``export_history_csv`` is registered with a schema but
|
||||
raises until Feature 13. ``regenerate`` is supplied by the caller so
|
||||
the Feature 07 handler stays the one in ``__init__``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from . import children as children_layer
|
||||
from . import completion as completion_layer
|
||||
from . import quest_definitions as quest_definitions_layer
|
||||
from .const import (
|
||||
CONF_HORIZON_DAYS,
|
||||
DEFAULT_HORIZON_DAYS,
|
||||
DOMAIN,
|
||||
DOMAIN_SERVICES,
|
||||
QUEST_WINDOWS,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
||||
SERVICE_CREATE_QUEST_DEFINITION,
|
||||
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
||||
SERVICE_EXPORT_HISTORY_CSV,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
SERVICE_REGENERATE,
|
||||
SERVICE_SET_PRESENCE_PATTERN,
|
||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
||||
SERVICE_UNCOMPLETE_QUEST,
|
||||
SERVICE_UPDATE_QUEST_DEFINITION,
|
||||
)
|
||||
from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao
|
||||
from .events import fire_quest_completed, fire_quest_uncompleted
|
||||
from .materialize import regenerate_for_child
|
||||
from .permissions import permission_gate
|
||||
from .presence import PresenceSchedule
|
||||
from .recurrence import ScheduleRule
|
||||
from .service_policy import SERVICE_POLICY
|
||||
|
||||
FindRuntime = Callable[[HomeAssistant], Any]
|
||||
ServiceHandler = Callable[[Any], Awaitable[None]]
|
||||
|
||||
ACTOR_PANEL = "panel"
|
||||
ACTOR_USER = "user"
|
||||
MANAGE_CHILD_CREATE = "create"
|
||||
MANAGE_CHILD_EDIT = "edit"
|
||||
MANAGE_CHILD_SET_ACTIVE = "set_active"
|
||||
MANAGE_CHILD_REORDER = "reorder"
|
||||
|
||||
|
||||
def _strict_int(value: object) -> int:
|
||||
"""Reject bools and non-ints; voluptuous ``int`` would accept True."""
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise vol.Invalid(f"expected int, got {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
def _strict_bool(value: object) -> bool:
|
||||
"""Reject ints and other truthy values; require a real bool."""
|
||||
if type(value) is not bool:
|
||||
raise vol.Invalid(f"expected bool, got {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
def _weekday_list(value: object) -> list[int]:
|
||||
if type(value) is not list:
|
||||
raise vol.Invalid(f"expected a list of weekday ints, got {value!r}")
|
||||
return [_strict_int(item) for item in value]
|
||||
|
||||
|
||||
def _presence_pattern(value: object) -> dict[int, list[int]]:
|
||||
if not isinstance(value, dict):
|
||||
raise vol.Invalid(f"pattern must be a mapping, got {value!r}")
|
||||
pattern: dict[int, list[int]] = {}
|
||||
for week, days in value.items():
|
||||
if isinstance(week, str):
|
||||
try:
|
||||
week_index = int(week)
|
||||
except ValueError as err:
|
||||
raise vol.Invalid(
|
||||
f"pattern week index must be an integer, got {week!r}"
|
||||
) from err
|
||||
if str(week_index) != week:
|
||||
raise vol.Invalid(
|
||||
f"pattern week index must be an integer, got {week!r}"
|
||||
)
|
||||
else:
|
||||
week_index = _strict_int(week)
|
||||
pattern[week_index] = _weekday_list(days)
|
||||
return pattern
|
||||
|
||||
|
||||
def _window_entry(value: object) -> str | tuple[str, str | None]:
|
||||
if isinstance(value, str):
|
||||
if value not in QUEST_WINDOWS:
|
||||
raise vol.Invalid(f"unknown window {value!r}")
|
||||
return value
|
||||
if isinstance(value, dict):
|
||||
parsed = vol.Schema(
|
||||
{
|
||||
vol.Required("window"): vol.In(QUEST_WINDOWS),
|
||||
vol.Optional("due_time"): str,
|
||||
}
|
||||
)(value)
|
||||
due_time = parsed.get("due_time")
|
||||
if due_time is None:
|
||||
return parsed["window"]
|
||||
return (parsed["window"], due_time)
|
||||
raise vol.Invalid(
|
||||
f"windows entry must be a window name or object, got {value!r}"
|
||||
)
|
||||
|
||||
|
||||
def _windows(value: object) -> list[str | tuple[str, str | None]]:
|
||||
if type(value) is not list:
|
||||
raise vol.Invalid(f"windows must be a list, got {value!r}")
|
||||
if not value:
|
||||
raise vol.Invalid("windows must not be empty")
|
||||
return [_window_entry(entry) for entry in value]
|
||||
|
||||
|
||||
def _int_list(value: object) -> list[int]:
|
||||
if type(value) is not list:
|
||||
raise vol.Invalid(f"expected a list of ints, got {value!r}")
|
||||
return [_strict_int(item) for item in value]
|
||||
|
||||
|
||||
def _rule_dict(value: object) -> dict:
|
||||
if not isinstance(value, dict):
|
||||
raise vol.Invalid(f"rule must be a dict, got {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
SCHEMA_COMPLETE_QUEST = vol.Schema(
|
||||
{
|
||||
vol.Required("instance_id"): _strict_int,
|
||||
vol.Required("actor"): vol.In([ACTOR_PANEL, ACTOR_USER]),
|
||||
vol.Optional("actor_child_id"): _strict_int,
|
||||
vol.Optional("actor_user_id"): str,
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_UNCOMPLETE_QUEST = SCHEMA_COMPLETE_QUEST
|
||||
|
||||
SCHEMA_CREATE_QUEST_DEFINITION = vol.Schema(
|
||||
{
|
||||
vol.Required("title"): str,
|
||||
vol.Required("rule"): _rule_dict,
|
||||
vol.Required("assignee_child_ids"): vol.All(
|
||||
_int_list, vol.Length(min=1)
|
||||
),
|
||||
vol.Required("windows"): _windows,
|
||||
vol.Optional("description"): str,
|
||||
vol.Optional("icon"): str,
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_UPDATE_QUEST_DEFINITION = vol.Schema(
|
||||
{
|
||||
vol.Required("definition_id"): _strict_int,
|
||||
vol.Optional("title"): str,
|
||||
vol.Optional("description"): vol.Any(str, None),
|
||||
vol.Optional("icon"): vol.Any(str, None),
|
||||
vol.Optional("rule"): _rule_dict,
|
||||
vol.Optional("windows"): _windows,
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_SET_QUEST_DEFINITION_ACTIVE = vol.Schema(
|
||||
{
|
||||
vol.Required("definition_id"): _strict_int,
|
||||
vol.Required("is_active"): _strict_bool,
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_SET_PRESENCE_PATTERN = vol.Schema(
|
||||
{
|
||||
vol.Required("child_id"): _strict_int,
|
||||
vol.Required("cycle_length_weeks"): _strict_int,
|
||||
vol.Required("anchor_date"): str,
|
||||
vol.Required("pattern"): _presence_pattern,
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_CREATE_PRESENCE_OVERRIDE = vol.Schema(
|
||||
{
|
||||
vol.Required("child_id"): _strict_int,
|
||||
vol.Required("start_date"): str,
|
||||
vol.Required("end_date"): str,
|
||||
vol.Required("is_present"): _strict_bool,
|
||||
vol.Optional("note"): str,
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_DELETE_PRESENCE_OVERRIDE = vol.Schema(
|
||||
{
|
||||
vol.Required("override_id"): _strict_int,
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_EXPORT_HISTORY_CSV = vol.Schema({})
|
||||
|
||||
SCHEMA_MANAGE_CHILD = vol.Schema(
|
||||
{
|
||||
vol.Required("action"): vol.In(
|
||||
[
|
||||
MANAGE_CHILD_CREATE,
|
||||
MANAGE_CHILD_EDIT,
|
||||
MANAGE_CHILD_SET_ACTIVE,
|
||||
MANAGE_CHILD_REORDER,
|
||||
]
|
||||
),
|
||||
vol.Optional("child_id"): _strict_int,
|
||||
vol.Optional("display_name"): str,
|
||||
vol.Optional("colour"): str,
|
||||
vol.Optional("avatar_ref"): str,
|
||||
vol.Optional("sort_order"): _strict_int,
|
||||
vol.Optional("is_active"): _strict_bool,
|
||||
vol.Optional("ordered_ids"): _int_list,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _require_runtime(hass: HomeAssistant, find_runtime: FindRuntime):
|
||||
runtime = find_runtime(hass)
|
||||
if runtime is None:
|
||||
raise HomeAssistantError(
|
||||
"NestQuest has no live database; cannot run this service"
|
||||
)
|
||||
return runtime
|
||||
|
||||
|
||||
async def _refresh_entities(runtime: Any) -> None:
|
||||
"""Force an immediate coordinator refresh after a service mutation.
|
||||
|
||||
Only the SERVICE paths call this — the scheduled materialize and
|
||||
rollover internals are direct function calls and never widen this
|
||||
gate — so entities reflect a completion the moment the service
|
||||
call returns instead of waiting for the poll interval.
|
||||
"""
|
||||
coordinator = getattr(runtime, "coordinator", None)
|
||||
if coordinator is not None:
|
||||
await coordinator.async_refresh()
|
||||
|
||||
|
||||
def _generation_kwargs(hass: HomeAssistant, runtime: Any) -> dict[str, Any]:
|
||||
time_zone = ZoneInfo(hass.config.time_zone)
|
||||
today = datetime.datetime.now(time_zone).date()
|
||||
value = runtime.options.get(CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS)
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
||||
horizon_days = DEFAULT_HORIZON_DAYS
|
||||
else:
|
||||
horizon_days = value
|
||||
return {"today": today, "horizon_days": horizon_days}
|
||||
|
||||
|
||||
def _actor_kwargs(call: Any) -> dict[str, Any]:
|
||||
actor = call.data["actor"]
|
||||
if actor == ACTOR_PANEL:
|
||||
actor_child_id = call.data.get("actor_child_id")
|
||||
if actor_child_id is None:
|
||||
raise HomeAssistantError(
|
||||
"actor_child_id is required when actor is panel"
|
||||
)
|
||||
return {
|
||||
"actor_source": ACTOR_PANEL,
|
||||
"actor_child_id": actor_child_id,
|
||||
"actor_user_id": None,
|
||||
}
|
||||
context = getattr(call, "context", None)
|
||||
user_id = getattr(context, "user_id", None) if context is not None else None
|
||||
if not user_id:
|
||||
user_id = call.data.get("actor_user_id")
|
||||
return {
|
||||
"actor_source": ACTOR_USER,
|
||||
"actor_user_id": user_id,
|
||||
"actor_child_id": None,
|
||||
}
|
||||
|
||||
|
||||
def _with_errors(handler: ServiceHandler) -> ServiceHandler:
|
||||
async def _wrapped(call: Any) -> None:
|
||||
try:
|
||||
await handler(call)
|
||||
except HomeAssistantError:
|
||||
raise
|
||||
except ValueError as err:
|
||||
raise HomeAssistantError(str(err)) from err
|
||||
|
||||
return _wrapped
|
||||
|
||||
|
||||
def _complete_quest(
|
||||
hass: HomeAssistant, find_runtime: FindRuntime
|
||||
) -> ServiceHandler:
|
||||
async def _handler(call: Any) -> None:
|
||||
runtime = _require_runtime(hass, find_runtime)
|
||||
instance_id = call.data["instance_id"]
|
||||
# ``appended`` is decided under the same lock as the write, so
|
||||
# a concurrent duplicate call (a double tap) can not produce a
|
||||
# second transition event: the loser appends nothing.
|
||||
result = await completion_layer.complete_instance(
|
||||
runtime.database,
|
||||
instance_id,
|
||||
**_actor_kwargs(call),
|
||||
)
|
||||
if result.appended:
|
||||
await fire_quest_completed(
|
||||
hass,
|
||||
runtime.database,
|
||||
instance_id,
|
||||
was_on_time=result.was_on_time,
|
||||
)
|
||||
await _refresh_entities(runtime)
|
||||
|
||||
return _with_errors(_handler)
|
||||
|
||||
|
||||
def _uncomplete_quest(
|
||||
hass: HomeAssistant, find_runtime: FindRuntime
|
||||
) -> ServiceHandler:
|
||||
async def _handler(call: Any) -> None:
|
||||
runtime = _require_runtime(hass, find_runtime)
|
||||
instance_id = call.data["instance_id"]
|
||||
result = await completion_layer.uncomplete_instance(
|
||||
runtime.database,
|
||||
instance_id,
|
||||
**_actor_kwargs(call),
|
||||
)
|
||||
if result.appended:
|
||||
await fire_quest_uncompleted(
|
||||
hass, runtime.database, instance_id
|
||||
)
|
||||
await _refresh_entities(runtime)
|
||||
|
||||
return _with_errors(_handler)
|
||||
|
||||
|
||||
def _create_quest_definition(
|
||||
hass: HomeAssistant, find_runtime: FindRuntime
|
||||
) -> ServiceHandler:
|
||||
async def _handler(call: Any) -> None:
|
||||
database = _require_runtime(hass, find_runtime).database
|
||||
data = call.data
|
||||
await quest_definitions_layer.create_quest_definition(
|
||||
database,
|
||||
data["title"],
|
||||
ScheduleRule.from_dict(data["rule"]),
|
||||
data["assignee_child_ids"],
|
||||
data["windows"],
|
||||
description=data.get("description"),
|
||||
icon=data.get("icon"),
|
||||
)
|
||||
|
||||
return _with_errors(_handler)
|
||||
|
||||
|
||||
def _update_quest_definition(
|
||||
hass: HomeAssistant, find_runtime: FindRuntime
|
||||
) -> ServiceHandler:
|
||||
async def _handler(call: Any) -> None:
|
||||
runtime = _require_runtime(hass, find_runtime)
|
||||
data = call.data
|
||||
kwargs: dict[str, Any] = _generation_kwargs(hass, runtime)
|
||||
if "title" in data:
|
||||
kwargs["title"] = data["title"]
|
||||
if "description" in data:
|
||||
kwargs["description"] = data["description"]
|
||||
if "icon" in data:
|
||||
kwargs["icon"] = data["icon"]
|
||||
if "rule" in data:
|
||||
kwargs["rule"] = ScheduleRule.from_dict(data["rule"])
|
||||
if "windows" in data:
|
||||
kwargs["windows"] = data["windows"]
|
||||
await quest_definitions_layer.edit_quest_definition(
|
||||
runtime.database, data["definition_id"], **kwargs
|
||||
)
|
||||
|
||||
return _with_errors(_handler)
|
||||
|
||||
|
||||
def _set_quest_definition_active(
|
||||
hass: HomeAssistant, find_runtime: FindRuntime
|
||||
) -> ServiceHandler:
|
||||
async def _handler(call: Any) -> None:
|
||||
runtime = _require_runtime(hass, find_runtime)
|
||||
await quest_definitions_layer.set_quest_definition_active(
|
||||
runtime.database,
|
||||
call.data["definition_id"],
|
||||
call.data["is_active"],
|
||||
**_generation_kwargs(hass, runtime),
|
||||
)
|
||||
|
||||
return _with_errors(_handler)
|
||||
|
||||
|
||||
def _set_presence_pattern(
|
||||
hass: HomeAssistant, find_runtime: FindRuntime
|
||||
) -> ServiceHandler:
|
||||
async def _handler(call: Any) -> None:
|
||||
runtime = _require_runtime(hass, find_runtime)
|
||||
data = call.data
|
||||
schedule = PresenceSchedule(
|
||||
data["child_id"],
|
||||
data["cycle_length_weeks"],
|
||||
data["anchor_date"],
|
||||
data["pattern"],
|
||||
)
|
||||
await PresenceSchedulesDao(runtime.database).upsert_by_child(
|
||||
schedule.child_id,
|
||||
schedule.cycle_length_weeks,
|
||||
schedule.anchor_date.isoformat(),
|
||||
schedule.encode(),
|
||||
)
|
||||
await regenerate_for_child(
|
||||
runtime.database,
|
||||
schedule.child_id,
|
||||
**_generation_kwargs(hass, runtime),
|
||||
)
|
||||
|
||||
return _with_errors(_handler)
|
||||
|
||||
|
||||
def _create_presence_override(
|
||||
hass: HomeAssistant, find_runtime: FindRuntime
|
||||
) -> ServiceHandler:
|
||||
async def _handler(call: Any) -> None:
|
||||
runtime = _require_runtime(hass, find_runtime)
|
||||
data = call.data
|
||||
await PresenceOverridesDao(runtime.database).create(
|
||||
data["child_id"],
|
||||
data["start_date"],
|
||||
data["end_date"],
|
||||
data["is_present"],
|
||||
note=data.get("note"),
|
||||
)
|
||||
await regenerate_for_child(
|
||||
runtime.database,
|
||||
data["child_id"],
|
||||
**_generation_kwargs(hass, runtime),
|
||||
)
|
||||
|
||||
return _with_errors(_handler)
|
||||
|
||||
|
||||
def _delete_presence_override(
|
||||
hass: HomeAssistant, find_runtime: FindRuntime
|
||||
) -> ServiceHandler:
|
||||
async def _handler(call: Any) -> None:
|
||||
runtime = _require_runtime(hass, find_runtime)
|
||||
dao = PresenceOverridesDao(runtime.database)
|
||||
override_id = call.data["override_id"]
|
||||
override = await dao.get(override_id)
|
||||
if override is None:
|
||||
raise HomeAssistantError(
|
||||
f"override_id: presence override {override_id} does not exist"
|
||||
)
|
||||
await dao.delete(override_id)
|
||||
await regenerate_for_child(
|
||||
runtime.database,
|
||||
override.child_id,
|
||||
**_generation_kwargs(hass, runtime),
|
||||
)
|
||||
|
||||
return _with_errors(_handler)
|
||||
|
||||
|
||||
async def _export_history_csv(_call: Any) -> None:
|
||||
raise HomeAssistantError(
|
||||
"CSV history export is not implemented until Feature 13"
|
||||
)
|
||||
|
||||
|
||||
def _manage_child(
|
||||
hass: HomeAssistant, find_runtime: FindRuntime
|
||||
) -> ServiceHandler:
|
||||
async def _handler(call: Any) -> None:
|
||||
database = _require_runtime(hass, find_runtime).database
|
||||
data = call.data
|
||||
action = data["action"]
|
||||
if action == MANAGE_CHILD_CREATE:
|
||||
display_name = data.get("display_name")
|
||||
if display_name is None:
|
||||
raise HomeAssistantError(
|
||||
"display_name is required when action is create"
|
||||
)
|
||||
kwargs: dict[str, Any] = {}
|
||||
if "colour" in data:
|
||||
kwargs["colour"] = data["colour"]
|
||||
if "avatar_ref" in data:
|
||||
kwargs["avatar_ref"] = data["avatar_ref"]
|
||||
if "sort_order" in data:
|
||||
kwargs["sort_order"] = data["sort_order"]
|
||||
await children_layer.create_child(
|
||||
database, display_name, **kwargs
|
||||
)
|
||||
return
|
||||
if action == MANAGE_CHILD_EDIT:
|
||||
child_id = data.get("child_id")
|
||||
if child_id is None:
|
||||
raise HomeAssistantError(
|
||||
"child_id is required when action is edit"
|
||||
)
|
||||
kwargs = {}
|
||||
if "display_name" in data:
|
||||
kwargs["display_name"] = data["display_name"]
|
||||
if "colour" in data:
|
||||
kwargs["colour"] = data["colour"]
|
||||
if "avatar_ref" in data:
|
||||
kwargs["avatar_ref"] = data["avatar_ref"]
|
||||
if "sort_order" in data:
|
||||
kwargs["sort_order"] = data["sort_order"]
|
||||
await children_layer.edit_child(database, child_id, **kwargs)
|
||||
return
|
||||
if action == MANAGE_CHILD_SET_ACTIVE:
|
||||
child_id = data.get("child_id")
|
||||
is_active = data.get("is_active")
|
||||
if child_id is None:
|
||||
raise HomeAssistantError(
|
||||
"child_id is required when action is set_active"
|
||||
)
|
||||
if is_active is None:
|
||||
raise HomeAssistantError(
|
||||
"is_active is required when action is set_active"
|
||||
)
|
||||
await children_layer.set_child_active(
|
||||
database, child_id, is_active
|
||||
)
|
||||
return
|
||||
ordered_ids = data.get("ordered_ids")
|
||||
if ordered_ids is None:
|
||||
raise HomeAssistantError(
|
||||
"ordered_ids is required when action is reorder"
|
||||
)
|
||||
await children_layer.reorder_children(database, ordered_ids)
|
||||
|
||||
return _with_errors(_handler)
|
||||
|
||||
|
||||
def async_register_services(
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
find_runtime: FindRuntime,
|
||||
regenerate_handler: ServiceHandler,
|
||||
) -> None:
|
||||
"""Register every canonical service once at domain scope.
|
||||
|
||||
Every service — regenerate included — passes through the Feature
|
||||
09 permission gate before its handler runs; the gate reads
|
||||
:data:`~.service_policy.SERVICE_POLICY`, so no service can be
|
||||
registered exempt from the policy.
|
||||
"""
|
||||
if hass.services.has_service(DOMAIN, SERVICE_REGENERATE):
|
||||
return
|
||||
gate = permission_gate
|
||||
specs: list[tuple[str, ServiceHandler, object]] = [
|
||||
(SERVICE_REGENERATE, regenerate_handler, None),
|
||||
(
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
_complete_quest(hass, find_runtime),
|
||||
SCHEMA_COMPLETE_QUEST,
|
||||
),
|
||||
(
|
||||
SERVICE_UNCOMPLETE_QUEST,
|
||||
_uncomplete_quest(hass, find_runtime),
|
||||
SCHEMA_UNCOMPLETE_QUEST,
|
||||
),
|
||||
(
|
||||
SERVICE_CREATE_QUEST_DEFINITION,
|
||||
_create_quest_definition(hass, find_runtime),
|
||||
SCHEMA_CREATE_QUEST_DEFINITION,
|
||||
),
|
||||
(
|
||||
SERVICE_UPDATE_QUEST_DEFINITION,
|
||||
_update_quest_definition(hass, find_runtime),
|
||||
SCHEMA_UPDATE_QUEST_DEFINITION,
|
||||
),
|
||||
(
|
||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
||||
_set_quest_definition_active(hass, find_runtime),
|
||||
SCHEMA_SET_QUEST_DEFINITION_ACTIVE,
|
||||
),
|
||||
(
|
||||
SERVICE_SET_PRESENCE_PATTERN,
|
||||
_set_presence_pattern(hass, find_runtime),
|
||||
SCHEMA_SET_PRESENCE_PATTERN,
|
||||
),
|
||||
(
|
||||
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
||||
_create_presence_override(hass, find_runtime),
|
||||
SCHEMA_CREATE_PRESENCE_OVERRIDE,
|
||||
),
|
||||
(
|
||||
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
||||
_delete_presence_override(hass, find_runtime),
|
||||
SCHEMA_DELETE_PRESENCE_OVERRIDE,
|
||||
),
|
||||
(
|
||||
SERVICE_EXPORT_HISTORY_CSV,
|
||||
_export_history_csv,
|
||||
SCHEMA_EXPORT_HISTORY_CSV,
|
||||
),
|
||||
(
|
||||
SERVICE_MANAGE_CHILD,
|
||||
_manage_child(hass, find_runtime),
|
||||
SCHEMA_MANAGE_CHILD,
|
||||
),
|
||||
]
|
||||
|
||||
def _database_of(_call: Any) -> Any:
|
||||
return _require_runtime(hass, find_runtime).database
|
||||
|
||||
for service, handler, schema in specs:
|
||||
gated = gate(
|
||||
service, SERVICE_POLICY[service], _database_of
|
||||
)(handler)
|
||||
hass.services.async_register(DOMAIN, service, gated, schema=schema)
|
||||
|
||||
|
||||
def async_deregister_services(hass: HomeAssistant) -> None:
|
||||
"""Remove every canonical service when the last entry unloads."""
|
||||
for service in DOMAIN_SERVICES:
|
||||
if hass.services.has_service(DOMAIN, service):
|
||||
hass.services.async_remove(DOMAIN, service)
|
||||
@@ -0,0 +1,326 @@
|
||||
# Service declarations for the NestQuest integration (Home Assistant
|
||||
# services.yaml metadata). Services are domain-scoped: nested under the
|
||||
# "nestquest" domain, not repeated per config entry.
|
||||
|
||||
regenerate:
|
||||
name: Regenerate quest instances
|
||||
description: >-
|
||||
Regenerate every future quest instance for the household over the rolling
|
||||
horizon, using the same idempotent materialization as the daily rollover
|
||||
and the startup backfill. Safe to call at any time: a re-run refreshes the
|
||||
same rows instead of duplicating them, and completed instances are never
|
||||
touched.
|
||||
|
||||
complete_quest:
|
||||
name: Complete quest
|
||||
description: Mark a quest instance as completed.
|
||||
fields:
|
||||
instance_id:
|
||||
name: Instance ID
|
||||
description: The quest instance to complete.
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
actor:
|
||||
name: Actor
|
||||
description: Who completed the quest (panel or user).
|
||||
required: true
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- panel
|
||||
- user
|
||||
actor_child_id:
|
||||
name: Actor child ID
|
||||
description: The tapped child profile. Required when actor is panel.
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
actor_user_id:
|
||||
name: Actor user ID
|
||||
description: Home Assistant user id when actor is user and no call context is present.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
|
||||
uncomplete_quest:
|
||||
name: Uncomplete quest
|
||||
description: Undo a quest instance completion.
|
||||
fields:
|
||||
instance_id:
|
||||
name: Instance ID
|
||||
description: The quest instance to uncomplete.
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
actor:
|
||||
name: Actor
|
||||
description: Who is undoing the completion (panel or user).
|
||||
required: true
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- panel
|
||||
- user
|
||||
actor_child_id:
|
||||
name: Actor child ID
|
||||
description: The tapped child profile. Required when actor is panel.
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
actor_user_id:
|
||||
name: Actor user ID
|
||||
description: Home Assistant user id when actor is user and no call context is present.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
|
||||
create_quest_definition:
|
||||
name: Create quest definition
|
||||
description: Create a quest definition with its schedule rule, assignees and windows.
|
||||
fields:
|
||||
title:
|
||||
name: Title
|
||||
description: Display title for the quest.
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
rule:
|
||||
name: Rule
|
||||
description: Recurrence rule as a mapping (rule_type plus shape fields).
|
||||
required: true
|
||||
selector:
|
||||
object:
|
||||
assignee_child_ids:
|
||||
name: Assignee child IDs
|
||||
description: Child ids this definition is assigned to.
|
||||
required: true
|
||||
selector:
|
||||
object:
|
||||
windows:
|
||||
name: Windows
|
||||
description: Day windows (morning, afternoon, evening), optionally with a due time.
|
||||
required: true
|
||||
selector:
|
||||
object:
|
||||
description:
|
||||
name: Description
|
||||
description: Optional longer description.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
icon:
|
||||
name: Icon
|
||||
description: Optional icon name.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
|
||||
update_quest_definition:
|
||||
name: Update quest definition
|
||||
description: Edit a quest definition's title, description, icon, rule or windows.
|
||||
fields:
|
||||
definition_id:
|
||||
name: Definition ID
|
||||
description: The quest definition to update.
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
title:
|
||||
name: Title
|
||||
description: Replacement display title.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
description:
|
||||
name: Description
|
||||
description: Replacement description, or null to clear.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
icon:
|
||||
name: Icon
|
||||
description: Replacement icon, or null to clear.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
rule:
|
||||
name: Rule
|
||||
description: Replacement recurrence rule as a mapping.
|
||||
required: false
|
||||
selector:
|
||||
object:
|
||||
windows:
|
||||
name: Windows
|
||||
description: Replacement day windows.
|
||||
required: false
|
||||
selector:
|
||||
object:
|
||||
|
||||
set_quest_definition_active:
|
||||
name: Set quest definition active
|
||||
description: Deactivate or reactivate a quest definition.
|
||||
fields:
|
||||
definition_id:
|
||||
name: Definition ID
|
||||
description: The quest definition to update.
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
is_active:
|
||||
name: Active
|
||||
description: Whether the definition should generate future instances.
|
||||
required: true
|
||||
selector:
|
||||
boolean:
|
||||
|
||||
set_presence_pattern:
|
||||
name: Set presence pattern
|
||||
description: Upsert a child's repeating presence schedule.
|
||||
fields:
|
||||
child_id:
|
||||
name: Child ID
|
||||
description: The child whose schedule is being set.
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
cycle_length_weeks:
|
||||
name: Cycle length weeks
|
||||
description: Number of weeks in the repeating cycle (1 to 4).
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
min: 1
|
||||
max: 4
|
||||
anchor_date:
|
||||
name: Anchor date
|
||||
description: Cycle anchor as a YYYY-MM-DD date.
|
||||
required: true
|
||||
selector:
|
||||
date:
|
||||
pattern:
|
||||
name: Pattern
|
||||
description: Mapping of week index to present weekday ints (Monday is 0).
|
||||
required: true
|
||||
selector:
|
||||
object:
|
||||
|
||||
create_presence_override:
|
||||
name: Create presence override
|
||||
description: Create a date-range presence override for a child.
|
||||
fields:
|
||||
child_id:
|
||||
name: Child ID
|
||||
description: The child the override applies to.
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
start_date:
|
||||
name: Start date
|
||||
description: Inclusive start date (YYYY-MM-DD).
|
||||
required: true
|
||||
selector:
|
||||
date:
|
||||
end_date:
|
||||
name: End date
|
||||
description: Inclusive end date (YYYY-MM-DD).
|
||||
required: true
|
||||
selector:
|
||||
date:
|
||||
is_present:
|
||||
name: Present
|
||||
description: Whether the child is present for this range.
|
||||
required: true
|
||||
selector:
|
||||
boolean:
|
||||
note:
|
||||
name: Note
|
||||
description: Optional note stored with the override.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
|
||||
delete_presence_override:
|
||||
name: Delete presence override
|
||||
description: Delete a presence override by id.
|
||||
fields:
|
||||
override_id:
|
||||
name: Override ID
|
||||
description: The override to delete.
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
|
||||
export_history_csv:
|
||||
name: Export history CSV
|
||||
description: Export completion history as CSV. Not implemented until Feature 13.
|
||||
|
||||
manage_child:
|
||||
name: Manage child
|
||||
description: Create, edit, activate or reorder child profiles.
|
||||
fields:
|
||||
action:
|
||||
name: Action
|
||||
description: create, edit, set_active, or reorder.
|
||||
required: true
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- create
|
||||
- edit
|
||||
- set_active
|
||||
- reorder
|
||||
child_id:
|
||||
name: Child ID
|
||||
description: Required for edit and set_active.
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
display_name:
|
||||
name: Display name
|
||||
description: Required for create; optional for edit.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
colour:
|
||||
name: Colour
|
||||
description: Optional colour string.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
avatar_ref:
|
||||
name: Avatar
|
||||
description: Optional avatar reference.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
sort_order:
|
||||
name: Sort order
|
||||
description: Optional panel sort order.
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
is_active:
|
||||
name: Active
|
||||
description: Required for set_active.
|
||||
required: false
|
||||
selector:
|
||||
boolean:
|
||||
ordered_ids:
|
||||
name: Ordered IDs
|
||||
description: Complete permutation of child ids. Required for reorder.
|
||||
required: false
|
||||
selector:
|
||||
object:
|
||||
@@ -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
|
||||
@@ -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_]+)")
|
||||
+472
-1
@@ -27,6 +27,7 @@ import inspect
|
||||
import sys
|
||||
import tempfile
|
||||
import weakref
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
@@ -44,6 +45,12 @@ _HA_MODULES = (
|
||||
"homeassistant.exceptions",
|
||||
"homeassistant.helpers",
|
||||
"homeassistant.helpers.config_validation",
|
||||
"homeassistant.helpers.entity",
|
||||
"homeassistant.helpers.event",
|
||||
"homeassistant.helpers.update_coordinator",
|
||||
"homeassistant.components",
|
||||
"homeassistant.components.sensor",
|
||||
"homeassistant.components.binary_sensor",
|
||||
)
|
||||
|
||||
def _find_spec(name: str):
|
||||
@@ -83,6 +90,12 @@ for _parent, _child in (
|
||||
("homeassistant", "exceptions"),
|
||||
("homeassistant", "helpers"),
|
||||
("homeassistant.helpers", "config_validation"),
|
||||
("homeassistant.helpers", "entity"),
|
||||
("homeassistant.helpers", "event"),
|
||||
("homeassistant.helpers", "update_coordinator"),
|
||||
("homeassistant", "components"),
|
||||
("homeassistant.components", "sensor"),
|
||||
("homeassistant.components", "binary_sensor"),
|
||||
):
|
||||
setattr(sys.modules[_parent], _child, sys.modules[f"{_parent}.{_child}"])
|
||||
|
||||
@@ -175,7 +188,11 @@ _data_entry_flow_mock.RESULT_TYPE_ABORT = "abort"
|
||||
_data_entry_flow_mock.FlowResult = dict
|
||||
|
||||
|
||||
class ConfigEntryNotReady(Exception):
|
||||
class HomeAssistantError(Exception):
|
||||
"""Stand-in mirroring homeassistant.exceptions.HomeAssistantError."""
|
||||
|
||||
|
||||
class ConfigEntryNotReady(HomeAssistantError):
|
||||
"""Stand-in mirroring homeassistant.exceptions.ConfigEntryNotReady.
|
||||
|
||||
HA retries setup when this is raised; the tests assert the corrupt
|
||||
@@ -183,7 +200,234 @@ class ConfigEntryNotReady(Exception):
|
||||
"""
|
||||
|
||||
|
||||
class Unauthorized(HomeAssistantError):
|
||||
"""Stand-in mirroring homeassistant.exceptions.Unauthorized."""
|
||||
|
||||
|
||||
_exceptions_mock.HomeAssistantError = HomeAssistantError
|
||||
_exceptions_mock.ConfigEntryNotReady = ConfigEntryNotReady
|
||||
_exceptions_mock.Unauthorized = Unauthorized
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceInfo:
|
||||
"""Stand-in for homeassistant.helpers.entity.DeviceInfo.
|
||||
|
||||
The kwargs-only shape the integration builds per-child devices
|
||||
with: ``identifiers``, ``name``, ``manufacturer``, ``model``.
|
||||
"""
|
||||
|
||||
identifiers: object = None
|
||||
name: str | None = None
|
||||
manufacturer: str | None = None
|
||||
model: str | None = None
|
||||
|
||||
|
||||
class Entity:
|
||||
"""Stand-in for homeassistant.helpers.entity.Entity.
|
||||
|
||||
Mirrors the ``_attr_*`` attribute contract through the ``name``,
|
||||
``unique_id``, ``device_info`` and ``extra_state_attributes``
|
||||
properties, and ``async_write_ha_state`` registering the entity
|
||||
into the hass entity registry (``hass.entities``, a dict keyed by
|
||||
``unique_id`` storing the live object) — the harness's stand-in
|
||||
for HA's state machine, so tests enumerate entities and read
|
||||
their state directly off the registered object.
|
||||
"""
|
||||
|
||||
hass = None
|
||||
_attr_name: str | None = None
|
||||
_attr_unique_id: str | None = None
|
||||
_attr_device_info: DeviceInfo | None = None
|
||||
_attr_extra_state_attributes: dict | None = None
|
||||
|
||||
@property
|
||||
def name(self) -> str | None:
|
||||
return self._attr_name
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str | None:
|
||||
return self._attr_unique_id
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo | None:
|
||||
return self._attr_device_info
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict | None:
|
||||
return self._attr_extra_state_attributes
|
||||
|
||||
def async_write_ha_state(self) -> None:
|
||||
"""Register the entity under its unique_id (the mock state write)."""
|
||||
if self.hass is not None and self.unique_id is not None:
|
||||
self.hass.entities[self.unique_id] = self
|
||||
|
||||
|
||||
class CoordinatorEntity(Entity):
|
||||
"""Stand-in for homeassistant.helpers.update_coordinator.CoordinatorEntity.
|
||||
|
||||
Real HA subscribes the entity to coordinator notifications in the
|
||||
platform-scheduler-driven ``async_added_to_hass`` lifecycle hook;
|
||||
the stand-in subscribes eagerly in ``__init__`` so the synchronous
|
||||
``AddEntitiesCallback`` surface stays synchronous (the mock
|
||||
harness has no lifecycle scheduler). ``_handle_coordinator_update``
|
||||
mirrors HA: one state write per coordinator notification.
|
||||
"""
|
||||
|
||||
def __init__(self, coordinator) -> None:
|
||||
self.coordinator = coordinator
|
||||
self.coordinator.async_add_listener(self._handle_coordinator_update)
|
||||
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
class SensorStateClass:
|
||||
"""Stand-in namespace for homeassistant.components.sensor.SensorStateClass.
|
||||
|
||||
Only the members the integration uses (plus TOTAL for the
|
||||
long-history sensors later tasks add).
|
||||
"""
|
||||
|
||||
MEASUREMENT = "measurement"
|
||||
TOTAL = "total"
|
||||
|
||||
|
||||
class SensorDeviceClass:
|
||||
"""Stand-in namespace for homeassistant.components.sensor.SensorDeviceClass.
|
||||
|
||||
Only the members the integration uses.
|
||||
"""
|
||||
|
||||
PERCENTAGE = "percentage"
|
||||
|
||||
|
||||
class SensorEntity(Entity):
|
||||
"""Stand-in for homeassistant.components.sensor.SensorEntity.
|
||||
|
||||
The ``_attr_*`` passthrough for ``native_value``,
|
||||
``native_unit_of_measurement``, ``state_class``, ``device_class``
|
||||
and ``suggested_display_precision``.
|
||||
"""
|
||||
|
||||
_attr_native_value: object = None
|
||||
_attr_native_unit_of_measurement: str | None = None
|
||||
_attr_state_class: str | None = None
|
||||
_attr_device_class: str | None = None
|
||||
_attr_suggested_display_precision: int | None = None
|
||||
|
||||
@property
|
||||
def native_value(self) -> object:
|
||||
return self._attr_native_value
|
||||
|
||||
@property
|
||||
def native_unit_of_measurement(self) -> str | None:
|
||||
return self._attr_native_unit_of_measurement
|
||||
|
||||
@property
|
||||
def state_class(self) -> str | None:
|
||||
return self._attr_state_class
|
||||
|
||||
@property
|
||||
def device_class(self) -> str | None:
|
||||
return self._attr_device_class
|
||||
|
||||
@property
|
||||
def suggested_display_precision(self) -> int | None:
|
||||
return self._attr_suggested_display_precision
|
||||
|
||||
|
||||
class BinarySensorEntity(Entity):
|
||||
"""Stand-in for homeassistant.components.binary_sensor.BinarySensorEntity.
|
||||
|
||||
Wired now so PLATFORMS can grow (task 4's all-done / present
|
||||
sensors) without more harness surgery; only the surface the
|
||||
integration uses.
|
||||
"""
|
||||
|
||||
_attr_is_on: bool | None = None
|
||||
_attr_device_class: str | None = None
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
return self._attr_is_on
|
||||
|
||||
@property
|
||||
def device_class(self) -> str | None:
|
||||
return self._attr_device_class
|
||||
|
||||
|
||||
class DataUpdateCoordinator:
|
||||
"""Functional stand-in for HA's DataUpdateCoordinator.
|
||||
|
||||
Mirrors the surface NestQuest's coordinator subclass uses:
|
||||
``__init__(hass, logger, name=..., update_interval=...)``,
|
||||
``async_add_listener``/``async_update_listeners`` for entity
|
||||
subscription, ``async_refresh``/``async_config_entry_first_refresh``
|
||||
driving the subclass's ``_async_update_data``, ``last_update_success``,
|
||||
and ``data``. Interval scheduling is NOT simulated (no real clock
|
||||
ticks in tests) — tests drive refreshes directly.
|
||||
"""
|
||||
|
||||
def __init__(self, hass, logger, *, name=None, update_interval=None):
|
||||
self.hass = hass
|
||||
self.logger = logger
|
||||
self.name = name
|
||||
self.update_interval = update_interval
|
||||
self.data = None
|
||||
self.last_update_success = False
|
||||
self._listeners = []
|
||||
|
||||
def async_add_listener(self, update_callback, context=None):
|
||||
"""Subscribe; returns an unsubscribe callable like HA's."""
|
||||
entry = (update_callback, context)
|
||||
self._listeners.append(entry)
|
||||
|
||||
def _remove():
|
||||
if entry in self._listeners:
|
||||
self._listeners.remove(entry)
|
||||
|
||||
return _remove
|
||||
|
||||
def async_update_listeners(self):
|
||||
"""Notify every listener (entities schedule their writes)."""
|
||||
for callback, _context in list(self._listeners):
|
||||
callback()
|
||||
|
||||
async def async_refresh(self):
|
||||
"""Run one update pass and notify listeners."""
|
||||
try:
|
||||
self.data = await self._async_update_data()
|
||||
self.last_update_success = True
|
||||
except BaseException:
|
||||
self.last_update_success = False
|
||||
raise
|
||||
self.async_update_listeners()
|
||||
|
||||
async def async_config_entry_first_refresh(self):
|
||||
"""First refresh at config-entry setup (alias of refresh)."""
|
||||
await self.async_refresh()
|
||||
|
||||
async def async_shutdown(self):
|
||||
"""Release listeners on teardown."""
|
||||
self._listeners.clear()
|
||||
|
||||
|
||||
_update_coordinator_mock = _ha_mock("homeassistant.helpers.update_coordinator")
|
||||
_update_coordinator_mock.DataUpdateCoordinator = DataUpdateCoordinator
|
||||
_update_coordinator_mock.CoordinatorEntity = CoordinatorEntity
|
||||
|
||||
_entity_mock = _ha_mock("homeassistant.helpers.entity")
|
||||
_entity_mock.DeviceInfo = DeviceInfo
|
||||
_entity_mock.Entity = Entity
|
||||
|
||||
_sensor_mock = _ha_mock("homeassistant.components.sensor")
|
||||
_sensor_mock.SensorEntity = SensorEntity
|
||||
_sensor_mock.SensorDeviceClass = SensorDeviceClass
|
||||
_sensor_mock.SensorStateClass = SensorStateClass
|
||||
|
||||
_binary_sensor_mock = _ha_mock("homeassistant.components.binary_sensor")
|
||||
_binary_sensor_mock.BinarySensorEntity = BinarySensorEntity
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -242,6 +486,153 @@ class ListenerRegistry:
|
||||
return sum(len(listeners) for listeners in self._listeners.values())
|
||||
|
||||
|
||||
class TimeChangeRegistry:
|
||||
"""Models HA's time-change listener bookkeeping.
|
||||
|
||||
``track`` mirrors ``homeassistant.helpers.event.async_track_time_change``:
|
||||
each call appends a record (action + hour/minute/second) and returns
|
||||
a remove callable that reverses the registration. Tests inspect
|
||||
``registrations`` and exercise the newest action through :meth:`fire`.
|
||||
"""
|
||||
|
||||
def __init__(self, hass=None):
|
||||
self._hass = hass
|
||||
self._registrations: list[dict] = []
|
||||
|
||||
def track(self, action, hour=None, minute=None, second=None):
|
||||
record = {
|
||||
"action": action,
|
||||
"hour": hour,
|
||||
"minute": minute,
|
||||
"second": second,
|
||||
}
|
||||
|
||||
def _remove():
|
||||
self._registrations.remove(record)
|
||||
|
||||
record["remove"] = _remove
|
||||
self._registrations.append(record)
|
||||
return _remove
|
||||
|
||||
@property
|
||||
def registrations(self):
|
||||
"""Return a copy of the live registrations."""
|
||||
return list(self._registrations)
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
"""Number of live registrations."""
|
||||
return len(self._registrations)
|
||||
|
||||
async def fire(self, now=None, index: int = -1):
|
||||
"""Run a registered action as HA's scheduler would, awaiting coroutines."""
|
||||
result = self._registrations[index]["action"](now)
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
|
||||
|
||||
def _async_track_time_change(hass, action, hour=None, minute=None, second=None):
|
||||
"""Mirror homeassistant.helpers.event.async_track_time_change.
|
||||
|
||||
Exact HA 2024.6 signature — no ``local`` keyword: the local-time
|
||||
wrapper is already this function. Routes to the registry attached to
|
||||
``hass`` (created on demand), so tests reach the registrations via
|
||||
``hass.time_change``.
|
||||
"""
|
||||
registry = getattr(hass, "time_change", None)
|
||||
if registry is None:
|
||||
registry = TimeChangeRegistry(hass)
|
||||
hass.time_change = registry
|
||||
return registry.track(action, hour=hour, minute=minute, second=second)
|
||||
|
||||
|
||||
_ha_mock("homeassistant.helpers.event").async_track_time_change = _async_track_time_change
|
||||
|
||||
|
||||
class EventBus:
|
||||
"""Models HA's ``hass.bus`` event bus.
|
||||
|
||||
``async_fire`` is SYNCHRONOUS (matching real HA) and records
|
||||
``(event_type, payload)`` pairs in order so tests can assert
|
||||
exactly which events fired with which payloads; there is no
|
||||
listener dispatch (entities poll the coordinator, and the cards
|
||||
are not part of this harness).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.events: list[tuple[str, dict]] = []
|
||||
|
||||
def async_fire(self, event_type: str, payload=None):
|
||||
self.events.append((event_type, dict(payload or {})))
|
||||
|
||||
def fired(self, event_type: str) -> list[dict]:
|
||||
"""Return every payload fired for ``event_type``, in order."""
|
||||
return [
|
||||
payload
|
||||
for fired_type, payload in self.events
|
||||
if fired_type == event_type
|
||||
]
|
||||
|
||||
|
||||
class ServiceRegistry:
|
||||
"""Models HA's ``hass.services`` service registry.
|
||||
|
||||
``async_register`` stores a handler under its (domain, service) key,
|
||||
``has_service`` reports registration, and ``async_remove`` removes it
|
||||
— exactly the surface Home Assistant's ``ServiceRegistry`` exposes
|
||||
(there is deliberately NO ``async_unregister`` alias here, so a caller
|
||||
that uses the wrong method name fails instead of being silently
|
||||
papered over). ``call`` invokes a stored handler the way HA's service
|
||||
bus would — awaiting a coroutine result — so tests can exercise a
|
||||
registered service end-to-end.
|
||||
"""
|
||||
|
||||
def __init__(self, hass=None):
|
||||
self._hass = hass
|
||||
self._services: dict[tuple[str, str], object] = {}
|
||||
|
||||
def async_register(self, domain, service, func, schema=None, supports_response=None):
|
||||
self._services[(domain, service)] = (func, schema)
|
||||
return None
|
||||
|
||||
def has_service(self, domain, service):
|
||||
return (domain, service) in self._services
|
||||
|
||||
def async_remove(self, domain, service):
|
||||
self._services.pop((domain, service), None)
|
||||
return None
|
||||
|
||||
async def call(self, domain, service, data=None, context=None):
|
||||
registered = self._services.get((domain, service))
|
||||
if registered is None:
|
||||
raise KeyError(f"service {domain}.{service} not registered")
|
||||
func, schema = registered
|
||||
payload = dict(data or {})
|
||||
if schema is not None:
|
||||
payload = schema(payload)
|
||||
if context is None:
|
||||
context_obj = SimpleNamespace(user_id=None)
|
||||
elif isinstance(context, dict):
|
||||
context_obj = SimpleNamespace(**context)
|
||||
else:
|
||||
context_obj = context
|
||||
call = SimpleNamespace(
|
||||
domain=domain,
|
||||
service=service,
|
||||
data=payload,
|
||||
context=context_obj,
|
||||
)
|
||||
result = func(call)
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
return result
|
||||
|
||||
@property
|
||||
def registered_services(self):
|
||||
"""The registered (domain, service) pairs."""
|
||||
return list(self._services)
|
||||
|
||||
|
||||
def make_config_entry(
|
||||
entry_id: str = "test_entry",
|
||||
options: dict | None = None,
|
||||
@@ -303,6 +694,86 @@ def make_hass() -> tuple:
|
||||
|
||||
hass.async_add_executor_job = _run_executor_job
|
||||
registry = ListenerRegistry(hass)
|
||||
hass.time_change = TimeChangeRegistry(hass)
|
||||
hass.services = ServiceRegistry(hass)
|
||||
hass.bus = EventBus()
|
||||
# A valid IANA time zone (the day-rollover listener reads it to
|
||||
# compute "today" in HA local time).
|
||||
hass.config.time_zone = "UTC"
|
||||
|
||||
# The minimal entity registry: entities self-register through
|
||||
# Entity.async_write_ha_state keyed by unique_id, storing the live
|
||||
# object; tests enumerate created entities and read their state
|
||||
# directly. entities_by_entry mirrors HA's per-entry platform
|
||||
# scoping: unloading an entry's platforms removes exactly that
|
||||
# entry's entities.
|
||||
hass.entities: dict[str, Entity] = {}
|
||||
hass.entities_by_entry: dict[str, dict[str, Entity]] = {}
|
||||
|
||||
def _make_add_entities(entry):
|
||||
entry_entities = hass.entities_by_entry.setdefault(entry.entry_id, {})
|
||||
|
||||
def _async_add_entities(new_entities, update_before_add=False):
|
||||
"""Mirror HA's AddEntitiesCallback: bind hass and register.
|
||||
|
||||
Accepts a single entity or an iterable, mirroring HA's
|
||||
permissive call shape; registration goes through each
|
||||
entity's async_write_ha_state (the mock state write) and
|
||||
is recorded per entry so an unload removes exactly this
|
||||
entry's entities.
|
||||
"""
|
||||
if isinstance(new_entities, Entity):
|
||||
new_entities = [new_entities]
|
||||
for entity in new_entities:
|
||||
entity.hass = hass
|
||||
entity.async_write_ha_state()
|
||||
if entity.unique_id is not None:
|
||||
entry_entities[entity.unique_id] = entity
|
||||
|
||||
return _async_add_entities
|
||||
|
||||
async def _async_forward_entry_setups(entry, platforms):
|
||||
"""Mirror hass.config_entries.async_forward_entry_setups.
|
||||
|
||||
HA's contract, loosely: import each platform module of the
|
||||
entry's integration and drive its ``async_setup_entry`` with a
|
||||
per-entry AddEntitiesCallback. A stub entry without a domain
|
||||
defaults to this harness's single integration (NestQuest).
|
||||
"""
|
||||
for platform in list(platforms):
|
||||
domain = getattr(entry, "domain", "nestquest")
|
||||
module = importlib.import_module(
|
||||
f"custom_components.{domain}.{platform}"
|
||||
)
|
||||
await module.async_setup_entry(
|
||||
hass, entry, _make_add_entities(entry)
|
||||
)
|
||||
hass.config_entries.forwarded_platforms.append(platform)
|
||||
|
||||
async def _async_unload_platforms(entry, platforms):
|
||||
"""Mirror hass.config_entries.async_unload_platforms.
|
||||
|
||||
Runs each platform module's ``async_unload_entry`` when it
|
||||
defines one (per-platform unload is optional, like HA's), then
|
||||
removes the entry's entities — unloading an entry's platforms
|
||||
removes exactly that entry's entities, never another entry's.
|
||||
"""
|
||||
for platform in list(platforms):
|
||||
domain = getattr(entry, "domain", "nestquest")
|
||||
module = importlib.import_module(
|
||||
f"custom_components.{domain}.{platform}"
|
||||
)
|
||||
unload = getattr(module, "async_unload_entry", None)
|
||||
if unload is not None:
|
||||
await unload(hass, entry)
|
||||
hass.config_entries.unloaded_platforms.append(platform)
|
||||
for unique_id in hass.entities_by_entry.pop(entry.entry_id, {}):
|
||||
hass.entities.pop(unique_id, None)
|
||||
|
||||
hass.config_entries.forwarded_platforms: list[str] = []
|
||||
hass.config_entries.unloaded_platforms: list[str] = []
|
||||
hass.config_entries.async_forward_entry_setups = _async_forward_entry_setups
|
||||
hass.config_entries.async_unload_platforms = _async_unload_platforms
|
||||
|
||||
async def _async_reload(entry_id: str) -> None:
|
||||
registry.reloaded.append(entry_id)
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Tests for the per-child binary sensors (Feature 10, task 4)."""
|
||||
from __future__ import annotations
|
||||
|
||||
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.const import DOMAIN
|
||||
from custom_components.nestquest.dao_presence import PresenceSchedulesDao
|
||||
from custom_components.nestquest.materialize import materialize
|
||||
from custom_components.nestquest.quest_definitions import (
|
||||
create_quest_definition,
|
||||
)
|
||||
from custom_components.nestquest.recurrence import ScheduleRule
|
||||
from custom_components.nestquest.completion import complete_instance
|
||||
|
||||
SEED_HORIZON_DAYS = 3
|
||||
|
||||
|
||||
def _binary(hass, child_id: int, kind: str):
|
||||
return hass.entities[f"{DOMAIN}_child_{child_id}_{kind}"]
|
||||
|
||||
|
||||
async def _setup_entry(hass, make_entry):
|
||||
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
return entry
|
||||
|
||||
|
||||
async def _seed_two_children(database) -> tuple:
|
||||
ada = await create_child(database, "Ada")
|
||||
bo = await create_child(database, "Bo")
|
||||
today = datetime.date.today()
|
||||
await create_quest_definition(
|
||||
database,
|
||||
"Brush teeth",
|
||||
ScheduleRule.from_dict(
|
||||
{"rule_type": "daily", "start_date": today.isoformat()}
|
||||
),
|
||||
[ada.id, bo.id],
|
||||
["morning"],
|
||||
)
|
||||
end = (today + datetime.timedelta(days=SEED_HORIZON_DAYS)).isoformat()
|
||||
await materialize(database, today.isoformat(), end, today=today)
|
||||
return ada, bo
|
||||
|
||||
|
||||
async def test_all_done_off_with_open_quests_and_zero_quests(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""all_done is OFF while quests are owed AND on a zero-quest day;
|
||||
a child with no quests never fires the celebration automation."""
|
||||
entry = await _setup_entry(hass, make_entry)
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
database = entry.runtime_data.database
|
||||
ada, _bo = await _seed_two_children(database)
|
||||
await create_child(database, "Cory") # zero quests
|
||||
await coordinator.async_refresh()
|
||||
|
||||
assert _binary(hass, ada.id, "all_done").is_on is False
|
||||
cory = [
|
||||
child for child in coordinator.data.children
|
||||
if child.child_name == "Cory"
|
||||
][0]
|
||||
assert _binary(hass, cory.child_id, "all_done").is_on is False
|
||||
|
||||
|
||||
async def test_all_done_on_only_when_day_clear(hass, make_entry) -> None:
|
||||
entry = await _setup_entry(hass, make_entry)
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
database = entry.runtime_data.database
|
||||
ada, _bo = await _seed_two_children(database)
|
||||
await coordinator.async_refresh()
|
||||
snapshot_ada = [
|
||||
child for child in coordinator.data.children
|
||||
if child.child_id == ada.id
|
||||
][0]
|
||||
instance_id = snapshot_ada.instances[0].instance_id
|
||||
await complete_instance(
|
||||
database,
|
||||
instance_id,
|
||||
actor_source="panel",
|
||||
actor_child_id=ada.id,
|
||||
)
|
||||
await coordinator.async_refresh()
|
||||
assert _binary(hass, ada.id, "all_done").is_on is True
|
||||
|
||||
|
||||
async def test_present_today_on_without_schedule_off_when_absent(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""No schedule means present every day; an all-absent pattern
|
||||
today reports OFF."""
|
||||
entry = await _setup_entry(hass, make_entry)
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
database = entry.runtime_data.database
|
||||
ada, _bo = await _seed_two_children(database)
|
||||
await create_child(database, "Cory")
|
||||
today = datetime.date.today()
|
||||
await coordinator.async_refresh()
|
||||
cory = [
|
||||
child for child in coordinator.data.children
|
||||
if child.child_name == "Cory"
|
||||
][0]
|
||||
await PresenceSchedulesDao(database).upsert_by_child(
|
||||
cory.child_id, 1, today.isoformat(), ""
|
||||
)
|
||||
await coordinator.async_refresh()
|
||||
|
||||
assert _binary(hass, ada.id, "present_today").is_on is True
|
||||
assert _binary(hass, cory.child_id, "present_today").is_on is False
|
||||
|
||||
|
||||
async def test_binary_sensor_identity_and_attributes(hass, make_entry) -> None:
|
||||
entry = await _setup_entry(hass, make_entry)
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
database = entry.runtime_data.database
|
||||
ada, _bo = await _seed_two_children(database)
|
||||
await coordinator.async_refresh()
|
||||
|
||||
all_done = _binary(hass, ada.id, "all_done")
|
||||
present = _binary(hass, ada.id, "present_today")
|
||||
assert all_done.unique_id == f"nestquest_child_{ada.id}_all_done"
|
||||
assert present.unique_id == f"nestquest_child_{ada.id}_present_today"
|
||||
assert all_done.name == "NestQuest Ada all done"
|
||||
assert present.name == "NestQuest Ada present today"
|
||||
for entity in (all_done, present):
|
||||
assert entity.device_info.name == "NestQuest Ada"
|
||||
assert entity.extra_state_attributes == {
|
||||
"child_id": ada.id,
|
||||
"child_name": "Ada",
|
||||
}
|
||||
@@ -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"
|
||||
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -28,10 +28,10 @@ def test_logger() -> None:
|
||||
|
||||
|
||||
def test_platforms() -> None:
|
||||
"""Verify PLATFORMS is defined as an empty list."""
|
||||
"""Verify PLATFORMS is defined as the sensor + binary_sensor list."""
|
||||
assert hasattr(const, "PLATFORMS")
|
||||
assert isinstance(const.PLATFORMS, list)
|
||||
assert const.PLATFORMS == []
|
||||
assert const.PLATFORMS == ["sensor", "binary_sensor"]
|
||||
|
||||
|
||||
def test_sqlite_db_filename() -> None:
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
"""Tests for the shared NestQuest coordinator (Feature 10, task 1)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import wire_entry_to_registry
|
||||
|
||||
from custom_components.nestquest import async_setup_entry, async_unload_entry
|
||||
from custom_components.nestquest.children import (
|
||||
create_child,
|
||||
list_children,
|
||||
set_child_active,
|
||||
)
|
||||
from custom_components.nestquest.completion import complete_instance
|
||||
from custom_components.nestquest.const import (
|
||||
CONF_UPDATE_INTERVAL,
|
||||
DEFAULT_UPDATE_INTERVAL,
|
||||
DEFAULT_HORIZON_DAYS,
|
||||
DOMAIN,
|
||||
)
|
||||
from custom_components.nestquest.coordinator import NestQuestCoordinator
|
||||
from custom_components.nestquest.dao_presence import PresenceSchedulesDao
|
||||
from custom_components.nestquest.materialize import materialize
|
||||
from custom_components.nestquest.quest_definitions import (
|
||||
create_quest_definition,
|
||||
)
|
||||
from custom_components.nestquest.recurrence import ScheduleRule
|
||||
|
||||
ADMIN_CTX = {"user_id": "admin-1"}
|
||||
|
||||
|
||||
def _today_iso() -> str:
|
||||
return datetime.date.today().isoformat()
|
||||
|
||||
|
||||
async def _seed_day(database, *, completed: bool = False) -> None:
|
||||
"""Two active children + one inactive, one daily definition each."""
|
||||
await create_child(database, "Ada")
|
||||
await create_child(database, "Bo")
|
||||
inactive = await create_child(database, "Ghost")
|
||||
await set_child_active(database, inactive.id, False)
|
||||
today = _today_iso()
|
||||
await create_quest_definition(
|
||||
database,
|
||||
"Brush teeth",
|
||||
ScheduleRule.from_dict({"rule_type": "daily", "start_date": today}),
|
||||
[child.id for child in await list_children(database, active_only=True)],
|
||||
["morning"],
|
||||
)
|
||||
end = (
|
||||
datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
||||
).isoformat()
|
||||
await materialize(database, today, end, today=datetime.date.today())
|
||||
|
||||
|
||||
async def test_coordinator_snapshot_counts_and_states(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
|
||||
assert coordinator is not None
|
||||
database = entry.runtime_data.database
|
||||
await _seed_day(database)
|
||||
|
||||
snapshot = await coordinator._async_update_data()
|
||||
assert snapshot.today_iso == _today_iso()
|
||||
assert len(snapshot.children) == 2, "inactive child must be absent"
|
||||
by_name = {child.child_name: child for child in snapshot.children}
|
||||
ada = by_name["Ada"]
|
||||
assert ada.due_today == 1
|
||||
assert ada.completed_today == 0
|
||||
assert ada.remaining_today == 1
|
||||
assert ada.completion_pct == 0
|
||||
instance = ada.instances[0]
|
||||
assert instance.state == "open"
|
||||
assert instance.overdue is False
|
||||
assert instance.title == "Brush teeth"
|
||||
assert instance.window == "morning"
|
||||
assert ada.present is True, "no schedule means present every day"
|
||||
assert snapshot.cycle_day == 0, "no schedule anywhere"
|
||||
|
||||
|
||||
async def test_coordinator_reflects_completion_and_uncompletion(
|
||||
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 _seed_day(database)
|
||||
ada = (
|
||||
await coordinator._async_update_data()
|
||||
).children[0]
|
||||
instance_id = ada.instances[0].instance_id
|
||||
|
||||
await complete_instance(
|
||||
database,
|
||||
instance_id,
|
||||
actor_source="panel",
|
||||
actor_child_id=ada.child_id,
|
||||
)
|
||||
snapshot = await coordinator._async_update_data()
|
||||
child = snapshot.children[0]
|
||||
assert child.completed_today == 1
|
||||
assert child.remaining_today == 0
|
||||
assert child.completion_pct == 100
|
||||
view = child.instances[0]
|
||||
assert view.state == "done"
|
||||
assert view.completed_at is not None
|
||||
assert view.was_on_time is not None
|
||||
|
||||
from custom_components.nestquest.completion import uncomplete_instance
|
||||
|
||||
await uncomplete_instance(
|
||||
database, instance_id, actor_source="user", actor_user_id="admin-1"
|
||||
)
|
||||
snapshot = await coordinator._async_update_data()
|
||||
child = snapshot.children[0]
|
||||
assert child.completed_today == 0
|
||||
assert child.remaining_today == 1
|
||||
assert child.instances[0].state == "open"
|
||||
|
||||
|
||||
async def test_coordinator_zero_quests_reports_full_percentage(
|
||||
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
|
||||
await create_child(entry.runtime_data.database, "Ada")
|
||||
snapshot = await coordinator._async_update_data()
|
||||
child = snapshot.children[0]
|
||||
assert child.due_today == 0
|
||||
assert child.completion_pct == 100, "zero quests is a perfect day"
|
||||
assert child.instances == ()
|
||||
|
||||
|
||||
async def test_coordinator_cycle_day_from_first_scheduled_child(
|
||||
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()
|
||||
# A 2-week cycle anchored 3 days ago: today is day 4.
|
||||
anchor = today - datetime.timedelta(days=3)
|
||||
await PresenceSchedulesDao(database).upsert_by_child(
|
||||
ada.id, 2, anchor.isoformat(), "0,1,2,3,4,5,6|"
|
||||
)
|
||||
snapshot = await coordinator._async_update_data()
|
||||
assert snapshot.cycle_day == 4
|
||||
|
||||
|
||||
async def test_coordinator_presence_resolves_schedule_and_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()
|
||||
# Scheduled absent today (empty weekday set for this cycle week).
|
||||
await PresenceSchedulesDao(database).upsert_by_child(
|
||||
ada.id, 1, today.isoformat(), ""
|
||||
)
|
||||
snapshot = await coordinator._async_update_data()
|
||||
assert snapshot.children[0].present is False
|
||||
|
||||
# An override beats the pattern.
|
||||
from custom_components.nestquest.dao_presence import (
|
||||
PresenceOverridesDao,
|
||||
)
|
||||
|
||||
await PresenceOverridesDao(database).create(
|
||||
ada.id, today.isoformat(), today.isoformat(), True
|
||||
)
|
||||
snapshot = await coordinator._async_update_data()
|
||||
assert snapshot.children[0].present is True
|
||||
|
||||
|
||||
async def test_coordinator_derives_missed_for_past_due_open(
|
||||
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
|
||||
snapshot = await coordinator._async_update_data()
|
||||
# No children: empty but valid snapshot.
|
||||
assert snapshot.children == ()
|
||||
|
||||
|
||||
async def test_setup_creates_coordinator_with_configured_interval(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
entry = wire_entry_to_registry(
|
||||
make_entry(options={CONF_UPDATE_INTERVAL: 60}), hass.registry
|
||||
)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
assert isinstance(coordinator, NestQuestCoordinator)
|
||||
assert coordinator.update_interval == datetime.timedelta(seconds=60)
|
||||
assert coordinator.data is not None, "first refresh ran at setup"
|
||||
|
||||
|
||||
async def test_setup_defaults_to_five_minute_interval(
|
||||
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
|
||||
assert coordinator.update_interval == datetime.timedelta(
|
||||
seconds=DEFAULT_UPDATE_INTERVAL
|
||||
)
|
||||
|
||||
|
||||
async def test_unload_shuts_coordinator_down(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
|
||||
torn_down = []
|
||||
|
||||
async def _shutdown():
|
||||
torn_down.append(coordinator)
|
||||
|
||||
coordinator.async_shutdown = _shutdown
|
||||
assert await async_unload_entry(hass, entry) is True
|
||||
assert torn_down == [coordinator]
|
||||
|
||||
|
||||
async def test_coordinator_notifies_listeners_on_refresh(
|
||||
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
|
||||
called = []
|
||||
coordinator.async_add_listener(lambda: called.append("entity"))
|
||||
await coordinator.async_refresh()
|
||||
assert called == ["entity"]
|
||||
assert coordinator.last_update_success is True
|
||||
|
||||
|
||||
async def test_failed_first_refresh_shuts_coordinator_down(
|
||||
hass, make_entry, monkeypatch
|
||||
) -> None:
|
||||
"""A setup failure after coordinator creation must not leak its
|
||||
listeners against the database the except path closes."""
|
||||
from custom_components.nestquest import async_setup_entry
|
||||
import custom_components.nestquest.coordinator as coordinator_module
|
||||
|
||||
shutdown_calls = []
|
||||
|
||||
async def _record_shutdown(self):
|
||||
shutdown_calls.append(self.entry_id)
|
||||
|
||||
monkeypatch.setattr(
|
||||
coordinator_module.NestQuestCoordinator,
|
||||
"async_shutdown",
|
||||
_record_shutdown,
|
||||
)
|
||||
|
||||
async def _boom(self):
|
||||
raise RuntimeError("refresh failed")
|
||||
|
||||
monkeypatch.setattr(
|
||||
coordinator_module.NestQuestCoordinator,
|
||||
"_async_update_data",
|
||||
_boom,
|
||||
)
|
||||
|
||||
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
||||
with pytest.raises(RuntimeError, match="refresh failed"):
|
||||
await async_setup_entry(hass, entry)
|
||||
assert shutdown_calls == [entry.entry_id]
|
||||
assert getattr(entry, "runtime_data", None) is None
|
||||
@@ -455,6 +455,20 @@ def test_override_list_unknown_child_returns_empty(tmp_path) -> None:
|
||||
assert _with_db(tmp_path, "override-list-unknown.db")(_body) == []
|
||||
|
||||
|
||||
def test_override_get_round_trip(tmp_path) -> None:
|
||||
async def _body(database, schedules, overrides, children, child):
|
||||
record = await overrides.create(
|
||||
child.id, "2026-09-10", "2026-09-11", True, note="camp"
|
||||
)
|
||||
found = await overrides.get(record.id)
|
||||
assert found == record
|
||||
assert await overrides.get(999) is None
|
||||
return found
|
||||
|
||||
found = _with_db(tmp_path, "override-get.db")(_body)
|
||||
assert found.note == "camp"
|
||||
|
||||
|
||||
def test_override_delete_round_trip(tmp_path) -> None:
|
||||
async def _body(database, schedules, overrides, children, child):
|
||||
record = await overrides.create(
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Entity lifecycle across child add/deactivate/reactivate (Feature 10, task 8)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
|
||||
from conftest import wire_entry_to_registry
|
||||
|
||||
from custom_components.nestquest import async_setup_entry, async_unload_entry
|
||||
from custom_components.nestquest.children import (
|
||||
create_child,
|
||||
list_children,
|
||||
set_child_active,
|
||||
)
|
||||
from custom_components.nestquest.const import DOMAIN
|
||||
from custom_components.nestquest.materialize import materialize
|
||||
from custom_components.nestquest.quest_definitions import (
|
||||
create_quest_definition,
|
||||
)
|
||||
from custom_components.nestquest.recurrence import ScheduleRule
|
||||
|
||||
SEED_HORIZON_DAYS = 3
|
||||
PER_CHILD_KINDS = (
|
||||
"quests_due_today",
|
||||
"quests_completed_today",
|
||||
"quests_remaining_today",
|
||||
"completion_pct_today",
|
||||
"next_quest",
|
||||
"all_done",
|
||||
"present_today",
|
||||
)
|
||||
HOUSEHOLD_UNIQUE_IDS = (
|
||||
"nestquest_household_quests_due_today",
|
||||
"nestquest_household_quests_completed_today",
|
||||
"nestquest_cycle_day",
|
||||
)
|
||||
|
||||
|
||||
async def _reload(hass, entry) -> None:
|
||||
"""Unload then set up: the harness's restart/reload equivalent."""
|
||||
assert await async_unload_entry(hass, entry) is True
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
|
||||
|
||||
def _child_unique_ids(child_id: int) -> set[str]:
|
||||
return {f"nestquest_child_{child_id}_{kind}" for kind in PER_CHILD_KINDS}
|
||||
|
||||
|
||||
async def _setup_and_seed(hass, make_entry):
|
||||
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
database = entry.runtime_data.database
|
||||
ada = await create_child(database, "Ada")
|
||||
bo = await create_child(database, "Bo")
|
||||
today = datetime.date.today()
|
||||
await create_quest_definition(
|
||||
database,
|
||||
"Brush teeth",
|
||||
ScheduleRule.from_dict(
|
||||
{"rule_type": "daily", "start_date": today.isoformat()}
|
||||
),
|
||||
[ada.id, bo.id],
|
||||
["morning"],
|
||||
)
|
||||
end = (today + datetime.timedelta(days=SEED_HORIZON_DAYS)).isoformat()
|
||||
await materialize(database, today.isoformat(), end, today=today)
|
||||
await entry.runtime_data.coordinator.async_refresh()
|
||||
return entry, ada, bo
|
||||
|
||||
|
||||
async def test_deactivated_child_entities_removed_on_reload(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
entry, ada, bo = await _setup_and_seed(hass, make_entry)
|
||||
assert _child_unique_ids(ada.id) <= set(hass.entities)
|
||||
assert _child_unique_ids(bo.id) <= set(hass.entities)
|
||||
|
||||
await set_child_active(entry.runtime_data.database, bo.id, False)
|
||||
await _reload(hass, entry)
|
||||
|
||||
assert _child_unique_ids(ada.id) <= set(hass.entities), (
|
||||
"the surviving child's entities must be untouched"
|
||||
)
|
||||
assert not (_child_unique_ids(bo.id) & set(hass.entities)), (
|
||||
"the deactivated child's entities must be gone after reload"
|
||||
)
|
||||
|
||||
|
||||
async def test_reactivation_restores_entities_with_original_unique_ids(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
entry, ada, bo = await _setup_and_seed(hass, make_entry)
|
||||
original = _child_unique_ids(bo.id)
|
||||
await set_child_active(entry.runtime_data.database, bo.id, False)
|
||||
await _reload(hass, entry)
|
||||
assert not (original & set(hass.entities))
|
||||
|
||||
await set_child_active(entry.runtime_data.database, bo.id, True)
|
||||
await _reload(hass, entry)
|
||||
|
||||
assert original <= set(hass.entities), (
|
||||
"reactivation must restore the exact original unique_ids"
|
||||
)
|
||||
bo_due = hass.entities[f"nestquest_child_{bo.id}_quests_due_today"]
|
||||
assert bo_due.native_value == 1, "restored entities carry live state"
|
||||
|
||||
|
||||
async def test_new_child_gets_entities_without_disturbing_existing(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
entry, ada, bo = await _setup_and_seed(hass, make_entry)
|
||||
before_ada = set(hass.entities) & _child_unique_ids(ada.id)
|
||||
|
||||
cory = await create_child(entry.runtime_data.database, "Cory")
|
||||
await _reload(hass, entry)
|
||||
|
||||
assert before_ada <= set(hass.entities), "existing entities untouched"
|
||||
assert _child_unique_ids(cory.id) <= set(hass.entities), (
|
||||
"the new child's entities appear after reload"
|
||||
)
|
||||
assert hass.entities[f"nestquest_child_{cory.id}_quests_due_today"].native_value == 0
|
||||
|
||||
|
||||
async def test_restart_equivalent_restores_all_entity_states(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""Unload + setup (the restart equivalent) restores every active
|
||||
child's entities with correct states and the household rollups."""
|
||||
from custom_components.nestquest.completion import complete_instance
|
||||
|
||||
entry, ada, bo = await _setup_and_seed(hass, make_entry)
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
snapshot_ada = next(
|
||||
child
|
||||
for child in coordinator.data.children
|
||||
if child.child_id == ada.id
|
||||
)
|
||||
await complete_instance(
|
||||
entry.runtime_data.database,
|
||||
snapshot_ada.instances[0].instance_id,
|
||||
actor_source="panel",
|
||||
actor_child_id=ada.id,
|
||||
)
|
||||
await coordinator.async_refresh()
|
||||
assert hass.entities[
|
||||
f"nestquest_child_{ada.id}_quests_completed_today"
|
||||
].native_value == 1
|
||||
|
||||
await _reload(hass, entry)
|
||||
|
||||
assert hass.entities[
|
||||
f"nestquest_child_{ada.id}_quests_completed_today"
|
||||
].native_value == 1, "completed count survives the restart"
|
||||
assert hass.entities[
|
||||
f"nestquest_child_{bo.id}_quests_completed_today"
|
||||
].native_value == 0
|
||||
assert set(HOUSEHOLD_UNIQUE_IDS) <= set(hass.entities)
|
||||
assert hass.entities[
|
||||
"nestquest_household_quests_completed_today"
|
||||
].native_value == 1
|
||||
|
||||
|
||||
async def test_unload_shuts_coordinator_before_closing_database(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""The coordinator's listeners are cleared while the database is
|
||||
still connected: a scheduled refresh must never race the close."""
|
||||
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
database = entry.runtime_data.database
|
||||
seen_connected = []
|
||||
|
||||
async def _probe_shutdown():
|
||||
seen_connected.append(database.connected)
|
||||
|
||||
original_close = database.close
|
||||
|
||||
async def _recording_close():
|
||||
seen_connected.append("closing")
|
||||
await original_close()
|
||||
|
||||
coordinator.async_shutdown = _probe_shutdown
|
||||
database.close = _recording_close
|
||||
assert await async_unload_entry(hass, entry) is True
|
||||
assert seen_connected == [True, "closing"], (
|
||||
"coordinator shutdown must precede the database close"
|
||||
)
|
||||
@@ -0,0 +1,247 @@
|
||||
"""The Feature 10 entity-and-event matrix (task 9).
|
||||
|
||||
One fixture drives every assertion the done-condition names: correct
|
||||
values for every sensor and binary sensor (including the zero-quest
|
||||
child and the 255-character state limit), the three events firing
|
||||
exactly once with schema-valid payloads, the instances payload
|
||||
omitting missed instances while admin_instances includes them, and no
|
||||
entities for an inactive child. The per-task test files cover each
|
||||
behavior in depth; this file is the single pass that proves them all
|
||||
against ONE fixture, so a regression in any contract fails here too.
|
||||
"""
|
||||
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,
|
||||
set_child_active,
|
||||
)
|
||||
from custom_components.nestquest.completion import derive_state
|
||||
from custom_components.nestquest.const import (
|
||||
CONF_ADMIN_USER_IDS,
|
||||
DOMAIN,
|
||||
EVENT_CHILD_DAY_COMPLETE,
|
||||
EVENT_QUEST_COMPLETED,
|
||||
EVENT_QUEST_UNCOMPLETED,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
SERVICE_UNCOMPLETE_QUEST,
|
||||
)
|
||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||
from custom_components.nestquest.materialize import materialize
|
||||
from custom_components.nestquest.quest_definitions import (
|
||||
create_quest_definition,
|
||||
)
|
||||
from custom_components.nestquest.recurrence import ScheduleRule
|
||||
|
||||
_TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$")
|
||||
|
||||
|
||||
async def _fixture(hass, make_entry):
|
||||
"""Two quest children, one zero-quest child, one inactive child."""
|
||||
entry = wire_entry_to_registry(
|
||||
make_entry(data={CONF_ADMIN_USER_IDS: ["admin-1"]}), hass.registry
|
||||
)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
database = entry.runtime_data.database
|
||||
ada = await create_child(database, "Ada")
|
||||
bo = await create_child(database, "Bo")
|
||||
cory = await create_child(database, "Cory")
|
||||
ghost = await create_child(database, "Ghost")
|
||||
await set_child_active(database, ghost.id, False)
|
||||
today = datetime.date.today()
|
||||
await create_quest_definition(
|
||||
database,
|
||||
"Brush teeth",
|
||||
ScheduleRule.from_dict(
|
||||
{"rule_type": "daily", "start_date": today.isoformat()}
|
||||
),
|
||||
[ada.id, bo.id],
|
||||
["morning", "evening"],
|
||||
)
|
||||
end = (today + datetime.timedelta(days=3)).isoformat()
|
||||
await materialize(database, today.isoformat(), end, today=today)
|
||||
await entry.runtime_data.coordinator.async_refresh()
|
||||
return entry, (ada, bo, cory, ghost)
|
||||
|
||||
|
||||
def _sensor(hass, child_id, kind):
|
||||
return hass.entities[f"{DOMAIN}_child_{child_id}_{kind}"]
|
||||
|
||||
|
||||
async def test_entity_and_event_matrix(hass, make_entry) -> None:
|
||||
entry, (ada, bo, cory, ghost) = await _fixture(hass, make_entry)
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
|
||||
# --- Every active child has the full entity set; the inactive
|
||||
# child has none.
|
||||
kinds = (
|
||||
"quests_due_today",
|
||||
"quests_completed_today",
|
||||
"quests_remaining_today",
|
||||
"completion_pct_today",
|
||||
"next_quest",
|
||||
"all_done",
|
||||
"present_today",
|
||||
)
|
||||
for child in (ada, bo, cory):
|
||||
for kind in kinds:
|
||||
assert f"{DOMAIN}_child_{child.id}_{kind}" in hass.entities, kind
|
||||
for kind in kinds:
|
||||
assert f"{DOMAIN}_child_{ghost.id}_{kind}" not in hass.entities, (
|
||||
"inactive children must have no entities"
|
||||
)
|
||||
|
||||
# --- Correct values against the fixture: Ada and Bo owe two
|
||||
# quests (morning + evening), Cory owes none and is still 100%.
|
||||
for child in (ada, bo):
|
||||
assert _sensor(hass, child.id, "quests_due_today").native_value == 2
|
||||
assert (
|
||||
_sensor(hass, child.id, "quests_completed_today").native_value == 0
|
||||
)
|
||||
assert (
|
||||
_sensor(hass, child.id, "quests_remaining_today").native_value == 2
|
||||
)
|
||||
assert (
|
||||
_sensor(hass, child.id, "completion_pct_today").native_value == 0
|
||||
)
|
||||
assert _sensor(hass, child.id, "next_quest").native_value == (
|
||||
"Brush teeth"
|
||||
)
|
||||
assert _sensor(hass, child.id, "all_done").is_on is False
|
||||
assert _sensor(hass, child.id, "present_today").is_on is True
|
||||
assert _sensor(hass, cory.id, "quests_due_today").native_value == 0
|
||||
assert _sensor(hass, cory.id, "completion_pct_today").native_value == 100
|
||||
assert _sensor(hass, cory.id, "all_done").is_on is False
|
||||
assert _sensor(hass, cory.id, "next_quest").native_value == "none"
|
||||
|
||||
# --- Household rollups equal the per-child sums.
|
||||
assert hass.entities[
|
||||
"nestquest_household_quests_due_today"
|
||||
].native_value == 4
|
||||
assert hass.entities[
|
||||
"nestquest_household_quests_completed_today"
|
||||
].native_value == 0
|
||||
|
||||
# --- The 255-character limit holds for every registered entity.
|
||||
for unique_id, entity in hass.entities.items():
|
||||
state = getattr(entity, "native_value", None)
|
||||
if state is None:
|
||||
state = getattr(entity, "is_on", None)
|
||||
assert state is None or not (
|
||||
isinstance(state, str) and len(state) > 255
|
||||
), unique_id
|
||||
|
||||
# --- Completing both of Ada's quests fires the events exactly
|
||||
# once each and flips her entities immediately.
|
||||
instances = await QuestInstancesDao(
|
||||
entry.runtime_data.database
|
||||
).list_by_date_range(
|
||||
ada.id, datetime.date.today().isoformat(), datetime.date.today().isoformat()
|
||||
)
|
||||
for instance in instances:
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
{
|
||||
"instance_id": instance.id,
|
||||
"actor": "panel",
|
||||
"actor_child_id": ada.id,
|
||||
},
|
||||
)
|
||||
assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == 2
|
||||
for payload in hass.bus.fired(EVENT_QUEST_COMPLETED):
|
||||
assert _TIMESTAMP.match(payload["occurred_at"])
|
||||
assert isinstance(payload["was_on_time"], bool)
|
||||
assert payload["child_name"] in ("Ada",)
|
||||
assert payload["quest_title"] == "Brush teeth"
|
||||
assert isinstance(payload["instance_id"], int)
|
||||
assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1
|
||||
assert _sensor(hass, ada.id, "all_done").is_on is True
|
||||
assert _sensor(hass, ada.id, "quests_remaining_today").native_value == 0
|
||||
assert (
|
||||
_sensor(hass, ada.id, "completion_pct_today").native_value == 100
|
||||
)
|
||||
|
||||
# --- Reversing one quest fires the uncompleted event exactly once
|
||||
# and restores the owed counts.
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_UNCOMPLETE_QUEST,
|
||||
{"instance_id": instances[0].id, "actor": "user"},
|
||||
context={"user_id": "admin-1"},
|
||||
)
|
||||
assert len(hass.bus.fired(EVENT_QUEST_UNCOMPLETED)) == 1
|
||||
assert _sensor(hass, ada.id, "all_done").is_on is False
|
||||
assert _sensor(hass, ada.id, "quests_remaining_today").native_value == 1
|
||||
|
||||
# --- The instances payload omits missed instances while
|
||||
# admin_instances includes them (D-009): pin the derive clock
|
||||
# forward in the TEST ONLY so today's open instances read missed.
|
||||
original_refresh = coordinator._async_update_data
|
||||
|
||||
async def _future_dated_refresh():
|
||||
import custom_components.nestquest.coordinator as coordinator_module
|
||||
|
||||
real_derive = coordinator_module.derive_state
|
||||
|
||||
def _derive(instance, latest, today):
|
||||
return real_derive(instance, latest, today + datetime.timedelta(days=1))
|
||||
|
||||
coordinator_module.derive_state = _derive
|
||||
try:
|
||||
return await original_refresh()
|
||||
finally:
|
||||
coordinator_module.derive_state = real_derive
|
||||
|
||||
coordinator._async_update_data = _future_dated_refresh
|
||||
await coordinator.async_refresh()
|
||||
attributes = _sensor(hass, bo.id, "quests_due_today").extra_state_attributes
|
||||
assert attributes["instances"] == [], "panel payload omits missed"
|
||||
assert len(attributes["admin_instances"]) == 2
|
||||
assert all(
|
||||
entry["state"] == "missed" for entry in attributes["admin_instances"]
|
||||
)
|
||||
coordinator._async_update_data = original_refresh
|
||||
await coordinator.async_refresh()
|
||||
|
||||
|
||||
async def test_payload_schema_matches_the_documented_contract(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""Every fired payload carries the documented fields with the
|
||||
documented types."""
|
||||
entry, (ada, _bo, _cory, _ghost) = await _fixture(hass, make_entry)
|
||||
instances = await QuestInstancesDao(
|
||||
entry.runtime_data.database
|
||||
).list_by_date_range(
|
||||
ada.id, datetime.date.today().isoformat(), datetime.date.today().isoformat()
|
||||
)
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
{
|
||||
"instance_id": instances[0].id,
|
||||
"actor": "panel",
|
||||
"actor_child_id": ada.id,
|
||||
},
|
||||
)
|
||||
payload = hass.bus.fired(EVENT_QUEST_COMPLETED)[0]
|
||||
for key, check in (
|
||||
("child_id", lambda v: isinstance(v, int)),
|
||||
("child_name", lambda v: isinstance(v, str)),
|
||||
("instance_id", lambda v: isinstance(v, int)),
|
||||
("quest_title", lambda v: isinstance(v, str)),
|
||||
("window", lambda v: v in ("morning", "evening")),
|
||||
("due_date", lambda v: isinstance(v, str)),
|
||||
("due_time", lambda v: v is None or isinstance(v, str)),
|
||||
("occurred_at", lambda v: bool(_TIMESTAMP.match(v))),
|
||||
("was_on_time", lambda v: isinstance(v, bool)),
|
||||
):
|
||||
assert key in payload, key
|
||||
assert check(payload[key]), key
|
||||
@@ -0,0 +1,379 @@
|
||||
"""Tests for the NestQuest bus events (Feature 10, task 6)."""
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
from custom_components.nestquest.const import (
|
||||
CONF_ADMIN_USER_IDS,
|
||||
DOMAIN,
|
||||
EVENT_CHILD_DAY_COMPLETE,
|
||||
EVENT_QUEST_COMPLETED,
|
||||
EVENT_QUEST_MISSED,
|
||||
EVENT_QUEST_UNCOMPLETED,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
SERVICE_CREATE_QUEST_DEFINITION,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
SERVICE_REGENERATE,
|
||||
SERVICE_UNCOMPLETE_QUEST,
|
||||
)
|
||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||
from custom_components.nestquest.materialize import materialize
|
||||
from custom_components.nestquest.quest_definitions import (
|
||||
create_quest_definition,
|
||||
)
|
||||
from custom_components.nestquest.recurrence import ScheduleRule
|
||||
|
||||
ADMIN_CTX = {"user_id": "admin-1"}
|
||||
_TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$")
|
||||
|
||||
|
||||
async def _setup_and_seed(hass, make_entry, *, windows=("morning",)):
|
||||
entry = wire_entry_to_registry(
|
||||
make_entry(data={CONF_ADMIN_USER_IDS: ["admin-1"]}), hass.registry
|
||||
)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
database = entry.runtime_data.database
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
{"action": "create", "display_name": "Ada"},
|
||||
context=ADMIN_CTX,
|
||||
)
|
||||
child = None
|
||||
from custom_components.nestquest.children import list_children
|
||||
|
||||
child = (await list_children(database))[0]
|
||||
await create_quest_definition(
|
||||
database,
|
||||
"Brush teeth",
|
||||
ScheduleRule.from_dict(
|
||||
{"rule_type": "daily", "start_date": __import__("datetime").date.today().isoformat()}
|
||||
),
|
||||
[child.id],
|
||||
list(windows),
|
||||
)
|
||||
import datetime
|
||||
|
||||
today = datetime.date.today().isoformat()
|
||||
end = (datetime.date.today() + datetime.timedelta(days=3)).isoformat()
|
||||
await materialize(database, today, end, today=datetime.date.today())
|
||||
instances = await QuestInstancesDao(database).list_by_date_range(
|
||||
child.id, today, today
|
||||
)
|
||||
return entry, child, instances
|
||||
|
||||
|
||||
async def test_quest_completed_fires_with_documented_payload(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
entry, child, instances = await _setup_and_seed(hass, make_entry)
|
||||
instance_id = instances[0].id
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
{"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id},
|
||||
)
|
||||
fired = hass.bus.fired(EVENT_QUEST_COMPLETED)
|
||||
assert len(fired) == 1
|
||||
payload = fired[0]
|
||||
assert payload["child_id"] == child.id
|
||||
assert payload["child_name"] == "Ada"
|
||||
assert payload["instance_id"] == instance_id
|
||||
assert payload["quest_title"] == "Brush teeth"
|
||||
assert payload["window"] == "morning"
|
||||
assert payload["was_on_time"] is not None
|
||||
assert _TIMESTAMP.match(payload["occurred_at"]), payload["occurred_at"]
|
||||
|
||||
|
||||
async def test_recomplete_no_op_fires_nothing(hass, make_entry) -> None:
|
||||
entry, child, instances = await _setup_and_seed(hass, make_entry)
|
||||
instance_id = instances[0].id
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
{"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id},
|
||||
)
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
{"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id},
|
||||
)
|
||||
assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == 1
|
||||
|
||||
|
||||
async def test_child_day_complete_fires_when_day_clears(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
entry, child, instances = await _setup_and_seed(hass, make_entry)
|
||||
for instance in instances:
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
{
|
||||
"instance_id": instance.id,
|
||||
"actor": "panel",
|
||||
"actor_child_id": child.id,
|
||||
},
|
||||
)
|
||||
day_complete = hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)
|
||||
assert len(day_complete) == 1, "exactly one day-complete per cleared day"
|
||||
payload = day_complete[0]
|
||||
assert payload["child_id"] == child.id
|
||||
assert payload["child_name"] == "Ada"
|
||||
assert payload["quests_due"] == len(instances)
|
||||
assert _TIMESTAMP.match(payload["occurred_at"])
|
||||
|
||||
|
||||
async def test_child_day_complete_does_not_fire_partway(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
entry, child, instances = await _setup_and_seed(
|
||||
hass, make_entry, windows=("morning", "evening")
|
||||
)
|
||||
assert len(instances) == 2
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
{
|
||||
"instance_id": instances[0].id,
|
||||
"actor": "panel",
|
||||
"actor_child_id": child.id,
|
||||
},
|
||||
)
|
||||
assert hass.bus.fired(EVENT_CHILD_DAY_COMPLETE) == []
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
{
|
||||
"instance_id": instances[1].id,
|
||||
"actor": "panel",
|
||||
"actor_child_id": child.id,
|
||||
},
|
||||
)
|
||||
assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1
|
||||
|
||||
|
||||
async def test_uncompleted_fires_on_reversal_only(hass, make_entry) -> None:
|
||||
entry, child, instances = await _setup_and_seed(hass, make_entry)
|
||||
instance_id = instances[0].id
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
{"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id},
|
||||
)
|
||||
# Un-completing an open instance is a no-op and fires nothing.
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_UNCOMPLETE_QUEST,
|
||||
{"instance_id": instance_id, "actor": "user"},
|
||||
context=ADMIN_CTX,
|
||||
)
|
||||
fired = hass.bus.fired(EVENT_QUEST_UNCOMPLETED)
|
||||
assert len(fired) == 1
|
||||
payload = fired[0]
|
||||
assert payload["instance_id"] == instance_id
|
||||
assert payload["quest_title"] == "Brush teeth"
|
||||
assert _TIMESTAMP.match(payload["occurred_at"])
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_UNCOMPLETE_QUEST,
|
||||
{"instance_id": instance_id, "actor": "user"},
|
||||
context=ADMIN_CTX,
|
||||
)
|
||||
assert len(hass.bus.fired(EVENT_QUEST_UNCOMPLETED)) == 1
|
||||
|
||||
|
||||
def test_missed_event_name_is_documented_contract() -> None:
|
||||
"""Feature 10 defines the missed event Feature 11's sweep fires."""
|
||||
assert EVENT_QUEST_MISSED == "nestquest_quest_missed"
|
||||
|
||||
|
||||
async def test_appended_verdict_is_atomic_under_duplicate_calls(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""Concurrent duplicate completes decide appended under the same
|
||||
lock as the write: exactly one fires the transition event."""
|
||||
import asyncio
|
||||
|
||||
entry, child, instances = await _setup_and_seed(hass, make_entry)
|
||||
instance_id = instances[0].id
|
||||
calls = [
|
||||
hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
{
|
||||
"instance_id": instance_id,
|
||||
"actor": "panel",
|
||||
"actor_child_id": child.id,
|
||||
},
|
||||
)
|
||||
for _ in range(2)
|
||||
]
|
||||
await asyncio.gather(*calls)
|
||||
assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == 1
|
||||
|
||||
|
||||
async def test_day_complete_ignored_for_backdated_completion(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""Completing an instance NOT due today never evaluates the
|
||||
child's day-complete, so a cleared day is announced at most once
|
||||
per day and only by that day's completions."""
|
||||
import datetime
|
||||
|
||||
from custom_components.nestquest.completion import (
|
||||
complete_instance as _complete,
|
||||
)
|
||||
|
||||
entry, child, instances = await _setup_and_seed(hass, make_entry)
|
||||
database = entry.runtime_data.database
|
||||
# A future instance (tomorrow) completed after today's day is
|
||||
# already clear.
|
||||
today = datetime.date.today()
|
||||
end = (today + datetime.timedelta(days=3)).isoformat()
|
||||
all_instances = await QuestInstancesDao(database).list_by_date_range(
|
||||
child.id, today.isoformat(), end
|
||||
)
|
||||
today_ids = {instance.id for instance in instances}
|
||||
tomorrow_instance = next(
|
||||
instance
|
||||
for instance in all_instances
|
||||
if instance.id not in today_ids
|
||||
)
|
||||
for instance in instances:
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
{
|
||||
"instance_id": instance.id,
|
||||
"actor": "panel",
|
||||
"actor_child_id": child.id,
|
||||
},
|
||||
)
|
||||
assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1
|
||||
|
||||
await _complete(
|
||||
database,
|
||||
tomorrow_instance.id,
|
||||
actor_source="user",
|
||||
actor_user_id="admin-1",
|
||||
)
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
{
|
||||
"instance_id": tomorrow_instance.id,
|
||||
"actor": "panel",
|
||||
"actor_child_id": child.id,
|
||||
},
|
||||
)
|
||||
assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1, (
|
||||
"a back-dated/future completion must not re-announce today"
|
||||
)
|
||||
# The direct business-layer call fired nothing (only the service
|
||||
# path fires), and the follow-up service call on the now-done
|
||||
# future instance was a no-op: today's single completion stands.
|
||||
assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == len(instances)
|
||||
|
||||
|
||||
async def test_completion_updates_entities_immediately(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""The remaining-today sensor reflects the completion the moment
|
||||
the service call returns — no manual refresh — and the day-complete
|
||||
event fired exactly once when the day became clear."""
|
||||
from custom_components.nestquest.const import EVENT_CHILD_DAY_COMPLETE
|
||||
|
||||
entry, child, instances = await _setup_and_seed(hass, make_entry)
|
||||
from conftest import wire_entry_to_registry # noqa: F401
|
||||
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
# Entities only exist after one refresh since they were seeded
|
||||
# after setup; create them, then prove the service path refreshes.
|
||||
await coordinator.async_refresh()
|
||||
remaining_before = hass.entities[
|
||||
f"nestquest_child_{child.id}_quests_remaining_today"
|
||||
].native_value
|
||||
assert remaining_before == len(instances)
|
||||
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
{
|
||||
"instance_id": instances[0].id,
|
||||
"actor": "panel",
|
||||
"actor_child_id": child.id,
|
||||
},
|
||||
)
|
||||
remaining_after = hass.entities[
|
||||
f"nestquest_child_{child.id}_quests_remaining_today"
|
||||
].native_value
|
||||
assert remaining_after == len(instances) - 1, (
|
||||
"sensor must reflect the completion without a manual refresh"
|
||||
)
|
||||
assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == (
|
||||
1 if len(instances) == 1 else 0
|
||||
)
|
||||
|
||||
|
||||
async def test_uncompletion_updates_entities_immediately(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
entry, child, instances = await _setup_and_seed(hass, make_entry)
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
await coordinator.async_refresh()
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
{
|
||||
"instance_id": instances[0].id,
|
||||
"actor": "panel",
|
||||
"actor_child_id": child.id,
|
||||
},
|
||||
)
|
||||
completed_after_complete = hass.entities[
|
||||
f"nestquest_child_{child.id}_quests_completed_today"
|
||||
].native_value
|
||||
assert completed_after_complete == 1
|
||||
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_UNCOMPLETE_QUEST,
|
||||
{"instance_id": instances[0].id, "actor": "user"},
|
||||
context=ADMIN_CTX,
|
||||
)
|
||||
completed_after_uncomplete = hass.entities[
|
||||
f"nestquest_child_{child.id}_quests_completed_today"
|
||||
].native_value
|
||||
assert completed_after_uncomplete == 0, (
|
||||
"sensor must reflect the reversal without a manual refresh"
|
||||
)
|
||||
|
||||
|
||||
async def test_forced_refreshes_serialize_no_stale_publish(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""Overlapping forced refreshes never interleave: a slow pass
|
||||
pauses mid-snapshot, and the queued pass still publishes AFTER
|
||||
it, so the last published snapshot reflects the latest mutation."""
|
||||
import asyncio
|
||||
|
||||
entry, child, instances = await _setup_and_seed(hass, make_entry)
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
order: list[str] = []
|
||||
|
||||
async def _slow_update():
|
||||
order.append("start")
|
||||
await asyncio.sleep(0.01)
|
||||
order.append("end")
|
||||
return coordinator.data
|
||||
|
||||
coordinator._async_update_data = _slow_update
|
||||
await asyncio.gather(coordinator.async_refresh(), coordinator.async_refresh())
|
||||
assert order == ["start", "end", "start", "end"], (
|
||||
"overlapping refreshes must serialize: " + str(order)
|
||||
)
|
||||
@@ -31,6 +31,23 @@ def test_mock_only_harness_contract(monkeypatch):
|
||||
assert sys.modules["homeassistant.core"].callback is conftest._callback_decorator
|
||||
assert sys.modules["homeassistant.data_entry_flow"].RESULT_TYPE_FORM == "form"
|
||||
|
||||
# Entity-platform stand-ins are wired with the shaped classes, and
|
||||
# parent-package attributes resolve to the mocked submodules.
|
||||
entity_module = sys.modules["homeassistant.helpers.entity"]
|
||||
assert entity_module.DeviceInfo is conftest.DeviceInfo
|
||||
assert entity_module.Entity is conftest.Entity
|
||||
sensor_module = sys.modules["homeassistant.components.sensor"]
|
||||
assert sensor_module.SensorEntity is conftest.SensorEntity
|
||||
assert sensor_module.SensorStateClass.MEASUREMENT == "measurement"
|
||||
assert sensor_module.SensorDeviceClass.PERCENTAGE == "percentage"
|
||||
assert sys.modules[
|
||||
"homeassistant.components.binary_sensor"
|
||||
].BinarySensorEntity is conftest.BinarySensorEntity
|
||||
assert sys.modules[
|
||||
"homeassistant.helpers.update_coordinator"
|
||||
].CoordinatorEntity is conftest.CoordinatorEntity
|
||||
assert sys.modules["homeassistant"].components.sensor is sensor_module
|
||||
|
||||
# No homeassistant distribution is installed in this suite's own venv.
|
||||
with pytest.raises(importlib.metadata.PackageNotFoundError):
|
||||
importlib.metadata.version("homeassistant")
|
||||
|
||||
+438
-2
@@ -8,7 +8,21 @@ import pytest
|
||||
from conftest import make_config_entry, make_hass, wire_entry_to_registry
|
||||
|
||||
from custom_components.nestquest import DOMAIN, async_setup_entry, async_unload_entry
|
||||
from custom_components.nestquest.const import DOMAIN as DOMAIN_CONST
|
||||
from custom_components.nestquest.const import (
|
||||
CONF_ADMIN_USER_IDS,
|
||||
CONF_DAY_ROLLOVER_TIME,
|
||||
CONF_HORIZON_DAYS,
|
||||
DEFAULT_HORIZON_DAYS,
|
||||
SERVICE_REGENERATE,
|
||||
DOMAIN as DOMAIN_CONST,
|
||||
)
|
||||
|
||||
ADMIN_ID = "admin-1"
|
||||
ADMIN_CTX = {"user_id": ADMIN_ID}
|
||||
|
||||
|
||||
def _admin_data():
|
||||
return {CONF_ADMIN_USER_IDS: [ADMIN_ID]}
|
||||
|
||||
|
||||
def _wire(entry, registry):
|
||||
@@ -237,6 +251,163 @@ async def test_unload_closes_database_even_when_listener_removal_raises() -> Non
|
||||
assert database.connected is False, "database leaked despite remover error"
|
||||
assert entry.runtime_data is None
|
||||
assert DOMAIN not in hass.data
|
||||
assert hass.time_change.size == 0, "time-change listener leaked"
|
||||
|
||||
|
||||
async def test_unload_cancels_time_listener_even_when_update_remover_raises() -> None:
|
||||
"""A raising update-listener remover must not skip the time-change cancel.
|
||||
|
||||
Both removers are independent: the update-listener remover raising must
|
||||
still cancel the daily time-change callback, whose action would otherwise
|
||||
fire after unload against a closed database.
|
||||
"""
|
||||
|
||||
class _RemoveBoom(Exception):
|
||||
pass
|
||||
|
||||
def _remover():
|
||||
raise _RemoveBoom()
|
||||
|
||||
entry = make_config_entry(entry_id="update-boom-time-cancel")
|
||||
entry.add_update_listener = lambda listener: _remover
|
||||
hass, _registry = make_hass()
|
||||
await async_setup_entry(hass, entry)
|
||||
assert hass.time_change.size == 1
|
||||
with pytest.raises(_RemoveBoom):
|
||||
await async_unload_entry(hass, entry)
|
||||
assert hass.time_change.size == 0, "time-change listener not cancelled"
|
||||
assert entry.runtime_data is None
|
||||
assert DOMAIN not in hass.data
|
||||
|
||||
|
||||
async def test_setup_registers_day_rollover_listener(hass, make_entry) -> None:
|
||||
"""Setup registers a daily rollover listener and stores its cancel fn."""
|
||||
entry = _wire(make_entry(), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
assert hass.time_change.size == 1
|
||||
registration = hass.time_change.registrations[0]
|
||||
assert registration["hour"] == 0
|
||||
assert registration["minute"] == 0
|
||||
assert callable(entry.runtime_data.remove_time_change_listener)
|
||||
|
||||
|
||||
async def test_setup_registers_day_rollover_at_configured_time(hass, make_entry) -> None:
|
||||
"""The listener fires at the entry option's rollover time, not the default."""
|
||||
entry = _wire(make_entry(options={CONF_DAY_ROLLOVER_TIME: "06:30"}), hass.registry)
|
||||
await async_setup_entry(hass, entry)
|
||||
registration = hass.time_change.registrations[0]
|
||||
assert registration["hour"] == 6
|
||||
assert registration["minute"] == 30
|
||||
|
||||
|
||||
async def test_unload_cancels_day_rollover_listener(hass, make_entry) -> None:
|
||||
"""Unload removes the time-change listener alongside the update listener."""
|
||||
entry = _wire(make_entry(), hass.registry)
|
||||
await async_setup_entry(hass, entry)
|
||||
assert hass.time_change.size == 1
|
||||
assert await async_unload_entry(hass, entry) is True
|
||||
assert hass.time_change.size == 0
|
||||
|
||||
|
||||
async def test_rollover_time_change_reregisters_listener_at_new_time(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""A rollover-time options change re-registers the listener at the new time."""
|
||||
entry = _wire(
|
||||
make_entry(options={CONF_DAY_ROLLOVER_TIME: "00:00"}), hass.registry
|
||||
)
|
||||
await async_setup_entry(hass, entry)
|
||||
assert (hass.time_change.registrations[0]["hour"], hass.time_change.registrations[0]["minute"]) == (0, 0)
|
||||
|
||||
# The options flow writes the new rollover time, then HA reloads the
|
||||
# entry: unload tears down the old listener, setup re-registers it.
|
||||
entry.options = {CONF_DAY_ROLLOVER_TIME: "06:30"}
|
||||
await async_unload_entry(hass, entry)
|
||||
assert hass.time_change.size == 0
|
||||
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
assert hass.time_change.size == 1
|
||||
registration = hass.time_change.registrations[0]
|
||||
assert (registration["hour"], registration["minute"]) == (6, 30)
|
||||
|
||||
|
||||
async def test_day_rollover_listener_materializes_ha_local_horizon(
|
||||
hass, make_entry, monkeypatch
|
||||
) -> None:
|
||||
"""Firing the listener materializes today..today+horizon in HA local time."""
|
||||
import datetime
|
||||
from unittest.mock import AsyncMock
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import custom_components.nestquest as nestquest
|
||||
|
||||
entry = _wire(make_entry(), hass.registry)
|
||||
await async_setup_entry(hass, entry)
|
||||
database = entry.runtime_data.database
|
||||
|
||||
fake = AsyncMock()
|
||||
monkeypatch.setattr(nestquest, "_materialize_run", fake)
|
||||
|
||||
time_zone = ZoneInfo(hass.config.time_zone)
|
||||
today = datetime.datetime.now(time_zone).date()
|
||||
expected_start = today.isoformat()
|
||||
expected_end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
|
||||
|
||||
await hass.time_change.fire()
|
||||
|
||||
fake.assert_awaited_once_with(
|
||||
database, expected_start, expected_end, today=today
|
||||
)
|
||||
|
||||
|
||||
async def test_day_rollover_uses_configured_horizon_days(hass, make_entry) -> None:
|
||||
"""The daily run materializes only the configured horizon window."""
|
||||
import datetime
|
||||
|
||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||
|
||||
entry = _wire(make_entry(options={CONF_HORIZON_DAYS: 5}), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
database = entry.runtime_data.database
|
||||
|
||||
# A definition created AFTER setup has no instances yet (create does not
|
||||
# materialize); firing the daily listener must generate only the horizon.
|
||||
child_id = await _seed_daily_child_and_definition(
|
||||
database, _local_today(hass)
|
||||
)
|
||||
await hass.time_change.fire()
|
||||
|
||||
today = _local_today(hass)
|
||||
end = (today + datetime.timedelta(days=5)).isoformat()
|
||||
records = await QuestInstancesDao(database).list_by_date_range(
|
||||
child_id, today.isoformat(), end
|
||||
)
|
||||
assert len(records) == 6
|
||||
|
||||
beyond = (today + datetime.timedelta(days=6)).isoformat()
|
||||
beyond_end = (
|
||||
today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
||||
).isoformat()
|
||||
assert await QuestInstancesDao(database).list_by_date_range(
|
||||
child_id, beyond, beyond_end
|
||||
) == []
|
||||
|
||||
|
||||
def test_day_rollover_time_change_tracker_rejects_local_kwarg() -> None:
|
||||
"""The fake async_track_time_change mirrors HA 2024.6 (no ``local`` kwarg).
|
||||
|
||||
Setup drives the local-time wrapper directly, so passing ``local=`` must
|
||||
raise TypeError — otherwise a regression in the production call would be
|
||||
silently masked by the fake.
|
||||
"""
|
||||
import inspect as _inspect
|
||||
|
||||
from homeassistant.helpers.event import async_track_time_change
|
||||
|
||||
params = _inspect.signature(async_track_time_change).parameters
|
||||
assert list(params) == ["hass", "action", "hour", "minute", "second"]
|
||||
with pytest.raises(TypeError):
|
||||
async_track_time_change(None, lambda _now: None, hour=0, minute=0, local=True)
|
||||
|
||||
|
||||
async def test_setup_applies_all_v1_tables(hass, make_entry) -> None:
|
||||
@@ -255,4 +426,269 @@ async def test_setup_applies_all_v1_tables(hass, make_entry) -> None:
|
||||
"quest_definitions",
|
||||
"schedule_rules",
|
||||
]
|
||||
await async_unload_entry(hass, entry)
|
||||
await async_unload_entry(hass, entry)
|
||||
|
||||
|
||||
async def _seed_daily_child_and_definition(database, today) -> int:
|
||||
"""Create one always-present child + a daily definition starting today.
|
||||
|
||||
Returns the child's id. No presence schedule means the child is present
|
||||
every day, so the daily rule yields exactly one instance per horizon day.
|
||||
"""
|
||||
from custom_components.nestquest.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.quest_definitions import (
|
||||
create_quest_definition,
|
||||
)
|
||||
from custom_components.nestquest.recurrence import RuleType, ScheduleRule
|
||||
|
||||
NOW = "2026-09-14T12:00:00+00:00"
|
||||
child = await ChildrenDao(database).create("Ada", NOW)
|
||||
await create_quest_definition(
|
||||
database,
|
||||
"Daily chore",
|
||||
ScheduleRule(rule_type=RuleType.DAILY, start_date=today.isoformat()),
|
||||
[child.id],
|
||||
["morning"],
|
||||
)
|
||||
return child.id
|
||||
|
||||
|
||||
def _local_today(hass) -> "datetime.date":
|
||||
import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
return datetime.datetime.now(ZoneInfo(hass.config.time_zone)).date()
|
||||
|
||||
|
||||
async def test_setup_backfills_instances_over_horizon(hass, make_entry) -> None:
|
||||
"""Setup materializes once so a restart backfills any missed days."""
|
||||
import datetime
|
||||
|
||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.migrations import apply_migrations
|
||||
from custom_components.nestquest.store import async_get_db_path
|
||||
|
||||
# Seed the database BEFORE setup: a restart reopens an existing file that
|
||||
# already holds definitions. The startup backfill must pick them up.
|
||||
db_path = await async_get_db_path(hass)
|
||||
pre = NestQuestDatabase(hass)
|
||||
await pre.open(db_path)
|
||||
await apply_migrations(pre)
|
||||
child_id = await _seed_daily_child_and_definition(pre, _local_today(hass))
|
||||
await pre.close()
|
||||
|
||||
entry = _wire(make_entry(), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
|
||||
today = _local_today(hass)
|
||||
end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
|
||||
records = await QuestInstancesDao(entry.runtime_data.database).list_by_date_range(
|
||||
child_id, today.isoformat(), end
|
||||
)
|
||||
assert len(records) == DEFAULT_HORIZON_DAYS + 1
|
||||
|
||||
|
||||
async def test_setup_backfills_configured_horizon_days(hass, make_entry) -> None:
|
||||
"""Setup backfills only the configured (non-default) horizon window."""
|
||||
import datetime
|
||||
|
||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.migrations import apply_migrations
|
||||
from custom_components.nestquest.store import async_get_db_path
|
||||
|
||||
db_path = await async_get_db_path(hass)
|
||||
pre = NestQuestDatabase(hass)
|
||||
await pre.open(db_path)
|
||||
await apply_migrations(pre)
|
||||
child_id = await _seed_daily_child_and_definition(pre, _local_today(hass))
|
||||
await pre.close()
|
||||
|
||||
entry = _wire(
|
||||
make_entry(options={CONF_HORIZON_DAYS: 5}), hass.registry
|
||||
)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
|
||||
today = _local_today(hass)
|
||||
end = (today + datetime.timedelta(days=5)).isoformat()
|
||||
records = await QuestInstancesDao(
|
||||
entry.runtime_data.database
|
||||
).list_by_date_range(child_id, today.isoformat(), end)
|
||||
assert len(records) == 6
|
||||
|
||||
beyond = (today + datetime.timedelta(days=6)).isoformat()
|
||||
beyond_end = (
|
||||
today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
||||
).isoformat()
|
||||
assert await QuestInstancesDao(
|
||||
entry.runtime_data.database
|
||||
).list_by_date_range(child_id, beyond, beyond_end) == []
|
||||
|
||||
|
||||
def test_configured_horizon_days_validates_and_falls_back() -> None:
|
||||
"""The configured horizon is validated; a malformed value uses the default."""
|
||||
import custom_components.nestquest as nestquest
|
||||
|
||||
assert nestquest._configured_horizon_days({CONF_HORIZON_DAYS: 5}) == 5
|
||||
assert nestquest._configured_horizon_days({}) == DEFAULT_HORIZON_DAYS
|
||||
assert (
|
||||
nestquest._configured_horizon_days({CONF_HORIZON_DAYS: True})
|
||||
== DEFAULT_HORIZON_DAYS
|
||||
)
|
||||
assert (
|
||||
nestquest._configured_horizon_days({CONF_HORIZON_DAYS: "5"})
|
||||
== DEFAULT_HORIZON_DAYS
|
||||
)
|
||||
assert (
|
||||
nestquest._configured_horizon_days({CONF_HORIZON_DAYS: 0})
|
||||
== DEFAULT_HORIZON_DAYS
|
||||
)
|
||||
|
||||
|
||||
async def test_regenerate_service_registered_and_materializes(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""``nestquest.regenerate`` is registered and calling it regenerates."""
|
||||
import datetime
|
||||
|
||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||
|
||||
entry = _wire(make_entry(data=_admin_data()), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
||||
|
||||
# A definition created AFTER setup has no instances yet (create does not
|
||||
# materialize); the service must generate them on demand.
|
||||
database = entry.runtime_data.database
|
||||
child_id = await _seed_daily_child_and_definition(database, _local_today(hass))
|
||||
|
||||
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
||||
|
||||
today = _local_today(hass)
|
||||
end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
|
||||
records = await QuestInstancesDao(database).list_by_date_range(
|
||||
child_id, today.isoformat(), end
|
||||
)
|
||||
assert len(records) == DEFAULT_HORIZON_DAYS + 1
|
||||
|
||||
|
||||
async def test_regenerate_service_uses_configured_horizon_days(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""``nestquest.regenerate`` regenerates only the configured horizon."""
|
||||
import datetime
|
||||
|
||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||
|
||||
entry = _wire(
|
||||
make_entry(options={CONF_HORIZON_DAYS: 5}, data=_admin_data()),
|
||||
hass.registry,
|
||||
)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
|
||||
database = entry.runtime_data.database
|
||||
child_id = await _seed_daily_child_and_definition(database, _local_today(hass))
|
||||
|
||||
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
||||
|
||||
today = _local_today(hass)
|
||||
end = (today + datetime.timedelta(days=5)).isoformat()
|
||||
records = await QuestInstancesDao(database).list_by_date_range(
|
||||
child_id, today.isoformat(), end
|
||||
)
|
||||
assert len(records) == 6
|
||||
|
||||
beyond = (today + datetime.timedelta(days=6)).isoformat()
|
||||
beyond_end = (
|
||||
today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
||||
).isoformat()
|
||||
assert await QuestInstancesDao(database).list_by_date_range(
|
||||
child_id, beyond, beyond_end
|
||||
) == []
|
||||
|
||||
|
||||
async def test_unload_deregisters_regenerate_service(hass, make_entry) -> None:
|
||||
"""Unload removes the ``regenerate`` service so no stale entry leaks."""
|
||||
entry = _wire(make_entry(), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
||||
|
||||
assert await async_unload_entry(hass, entry) is True
|
||||
assert not hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
||||
|
||||
|
||||
async def test_regenerate_service_is_domain_scoped_across_entries(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""The ``regenerate`` service is domain-global, not per-entry.
|
||||
|
||||
With two loaded entries the service must be registered and functional;
|
||||
unloading one entry must keep it registered and functional for the
|
||||
remaining entry; unloading the final entry must remove it.
|
||||
"""
|
||||
import datetime
|
||||
|
||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||
|
||||
entry_a = _wire(
|
||||
make_entry(entry_id="entry_a", data=_admin_data()), hass.registry
|
||||
)
|
||||
entry_b = _wire(
|
||||
make_entry(entry_id="entry_b", data=_admin_data()), hass.registry
|
||||
)
|
||||
|
||||
assert await async_setup_entry(hass, entry_a) is True
|
||||
assert await async_setup_entry(hass, entry_b) is True
|
||||
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
||||
|
||||
# The domain-global handler resolves a live database from any entry.
|
||||
database_b = entry_b.runtime_data.database
|
||||
child_id = await _seed_daily_child_and_definition(
|
||||
database_b, _local_today(hass)
|
||||
)
|
||||
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
||||
today = _local_today(hass)
|
||||
end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
|
||||
records = await QuestInstancesDao(database_b).list_by_date_range(
|
||||
child_id, today.isoformat(), end
|
||||
)
|
||||
assert len(records) == DEFAULT_HORIZON_DAYS + 1
|
||||
|
||||
# Unloading one entry must not tear down the shared service.
|
||||
assert await async_unload_entry(hass, entry_a) is True
|
||||
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
||||
|
||||
# It still works for the remaining entry.
|
||||
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
||||
|
||||
# Unloading the final entry removes the domain-global service.
|
||||
assert await async_unload_entry(hass, entry_b) is True
|
||||
assert not hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
||||
|
||||
|
||||
async def test_setup_cleans_listeners_when_materialization_raises(
|
||||
hass, make_entry, monkeypatch
|
||||
) -> None:
|
||||
"""A startup-materialization failure rolls back every registered listener.
|
||||
|
||||
The daily and update listeners are installed before the startup
|
||||
materialization runs; when that walk raises, the database is closed but
|
||||
the remover callables must also be invoked so no listener survives
|
||||
firing against a closed database.
|
||||
"""
|
||||
import custom_components.nestquest as nestquest
|
||||
|
||||
async def _boom(*_args, **_kwargs):
|
||||
raise RuntimeError("materialization failed")
|
||||
|
||||
monkeypatch.setattr(nestquest, "_materialize_run", _boom)
|
||||
|
||||
entry = _wire(make_entry(), hass.registry)
|
||||
with pytest.raises(RuntimeError, match="materialization failed"):
|
||||
await async_setup_entry(hass, entry)
|
||||
|
||||
assert hass.time_change.size == 0
|
||||
assert hass.registry.size == 0
|
||||
assert not hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
||||
assert not hasattr(entry, "runtime_data")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+174
-10
@@ -18,6 +18,20 @@ from custom_components.nestquest.const import (
|
||||
CONF_DAY_ROLLOVER_TIME,
|
||||
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,
|
||||
DEFAULT_PANEL_IDLE_TIMEOUT,
|
||||
@@ -34,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)
|
||||
@@ -129,7 +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
|
||||
assert result["data"] == FULL_INPUT
|
||||
|
||||
|
||||
def test_options_flow_shows_form_initially() -> None:
|
||||
@@ -208,6 +238,11 @@ def test_options_flow_schema_applies_stored_defaults() -> None:
|
||||
CONF_HORIZON_DAYS: 21,
|
||||
CONF_DAY_ROLLOVER_TIME: "05:45",
|
||||
CONF_PANEL_IDLE_TIMEOUT: 120,
|
||||
CONF_UPDATE_INTERVAL: 90,
|
||||
CONF_NOTIFY_TARGET: "notify.mobile_app_test",
|
||||
CONF_MORNING_SUMMARY_TIME: "07:00",
|
||||
CONF_AFTERNOON_REMINDER_TIME: "14:00",
|
||||
CONF_END_OF_DAY_REPORT_TIME: "21:30",
|
||||
}
|
||||
)
|
||||
result = _run(_make_flow(entry).async_step_init(None))
|
||||
@@ -215,6 +250,15 @@ def test_options_flow_schema_applies_stored_defaults() -> None:
|
||||
CONF_HORIZON_DAYS: 21,
|
||||
CONF_DAY_ROLLOVER_TIME: "05:45",
|
||||
CONF_PANEL_IDLE_TIMEOUT: 120,
|
||||
CONF_UPDATE_INTERVAL: 90,
|
||||
CONF_NOTIFY_TARGET: "notify.mobile_app_test",
|
||||
CONF_MORNING_SUMMARY_TIME: "07:00",
|
||||
CONF_AFTERNOON_REMINDER_TIME: "14:00",
|
||||
CONF_END_OF_DAY_REPORT_TIME: "21:30",
|
||||
CONF_MORNING_SUMMARY_ENABLED: True,
|
||||
CONF_AFTERNOON_REMINDER_ENABLED: True,
|
||||
CONF_END_OF_DAY_REPORT_ENABLED: True,
|
||||
CONF_CELEBRATION_ENABLED: True,
|
||||
CONF_ADMIN_USER_IDS: [],
|
||||
}
|
||||
|
||||
@@ -282,18 +326,14 @@ def test_options_flow_rejects_invalid_idle_timeout(bad_timeout) -> None:
|
||||
assert result["errors"] == {CONF_PANEL_IDLE_TIMEOUT: "invalid"}
|
||||
|
||||
|
||||
def test_options_flow_creates_entry_with_three_keys() -> None:
|
||||
"""A valid submit creates an entry with exactly the three validated keys."""
|
||||
def test_options_flow_creates_entry_with_four_keys() -> None:
|
||||
"""A valid submit creates an entry with exactly the four validated keys."""
|
||||
flow = _make_flow(_make_entry())
|
||||
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,
|
||||
}
|
||||
assert result["data"] == VALID_INPUT
|
||||
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:
|
||||
@@ -316,7 +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
|
||||
assert entry.options == FULL_INPUT
|
||||
|
||||
|
||||
def test_options_flow_validation_never_raises() -> None:
|
||||
@@ -635,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,238 @@
|
||||
"""Feature 09 permission test matrix across every registered service."""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
|
||||
import pytest
|
||||
from homeassistant.exceptions import Unauthorized
|
||||
|
||||
from conftest import wire_entry_to_registry
|
||||
|
||||
from custom_components.nestquest import async_setup_entry
|
||||
from custom_components.nestquest.const import (
|
||||
CONF_ADMIN_USER_IDS,
|
||||
DEFAULT_HORIZON_DAYS,
|
||||
DOMAIN,
|
||||
DOMAIN_SERVICES,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
||||
SERVICE_CREATE_QUEST_DEFINITION,
|
||||
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
||||
SERVICE_EXPORT_HISTORY_CSV,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
SERVICE_REGENERATE,
|
||||
SERVICE_SET_PRESENCE_PATTERN,
|
||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
||||
SERVICE_UNCOMPLETE_QUEST,
|
||||
SERVICE_UPDATE_QUEST_DEFINITION,
|
||||
)
|
||||
from custom_components.nestquest.service_policy import (
|
||||
ADMIN_ONLY,
|
||||
OPEN,
|
||||
SERVICE_POLICY,
|
||||
)
|
||||
|
||||
ADMIN_ID = "admin-1"
|
||||
ADMIN_CTX = {"user_id": ADMIN_ID}
|
||||
NON_ADMIN_ID = "non-admin"
|
||||
NON_ADMIN_CTX = {"user_id": NON_ADMIN_ID}
|
||||
KIOSK_ID = "kiosk-panel"
|
||||
KIOSK_CTX = {"user_id": KIOSK_ID}
|
||||
|
||||
|
||||
def test_every_registered_service_is_in_the_operation_registry() -> None:
|
||||
"""The registry is total: a service outside it could never be gated."""
|
||||
missing = [
|
||||
service for service in DOMAIN_SERVICES if service not in SERVICE_POLICY
|
||||
]
|
||||
extra = [
|
||||
service for service in SERVICE_POLICY if service not in DOMAIN_SERVICES
|
||||
]
|
||||
assert not missing, f"registered but ungated: {missing}"
|
||||
assert not extra, f"gated but never registered: {extra}"
|
||||
assert set(SERVICE_POLICY.values()) == {OPEN, ADMIN_ONLY}
|
||||
|
||||
|
||||
async def _setup(hass, make_entry, *, admin=True):
|
||||
data = {CONF_ADMIN_USER_IDS: [ADMIN_ID]} if admin else {}
|
||||
entry = wire_entry_to_registry(make_entry(data=data), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
return entry
|
||||
|
||||
|
||||
async def _seed(hass, entry) -> dict:
|
||||
"""Seed one child, one definition, and materialized instances."""
|
||||
from custom_components.nestquest.children import list_children
|
||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||
|
||||
database = entry.runtime_data.database
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
{"action": "create", "display_name": "Ada"},
|
||||
context=ADMIN_CTX,
|
||||
)
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_CREATE_QUEST_DEFINITION,
|
||||
{
|
||||
"title": "Brush teeth",
|
||||
"rule": {"rule_type": "daily"},
|
||||
"assignee_child_ids": [(await list_children(database))[0].id],
|
||||
"windows": ["morning"],
|
||||
},
|
||||
context=ADMIN_CTX,
|
||||
)
|
||||
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
||||
child = (await list_children(database))[0]
|
||||
today = datetime.date.today().isoformat()
|
||||
end = (
|
||||
datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
||||
).isoformat()
|
||||
instances = await QuestInstancesDao(database).list_by_date_range(
|
||||
child.id, today, end
|
||||
)
|
||||
assert instances
|
||||
from custom_components.nestquest.dao_presence import (
|
||||
PresenceOverridesDao,
|
||||
)
|
||||
|
||||
return {
|
||||
"child_id": child.id,
|
||||
"instance_id": instances[0].id,
|
||||
"definition_id": instances[0].definition_id,
|
||||
"override": await PresenceOverridesDao(database).create(
|
||||
child.id, "2027-01-01", "2027-01-02", False
|
||||
),
|
||||
"today": today,
|
||||
"database": database,
|
||||
}
|
||||
|
||||
|
||||
def _admin_success_payload(service: str, ctx: dict) -> dict | None:
|
||||
"""A schema-valid, end-to-end-successful payload per admin service."""
|
||||
if service == SERVICE_UNCOMPLETE_QUEST:
|
||||
return {"instance_id": ctx["instance_id"], "actor": "user"}
|
||||
if service == SERVICE_CREATE_QUEST_DEFINITION:
|
||||
return {
|
||||
"title": "Second quest",
|
||||
"rule": {"rule_type": "daily"},
|
||||
"assignee_child_ids": [ctx["child_id"]],
|
||||
"windows": ["afternoon"],
|
||||
}
|
||||
if service == SERVICE_UPDATE_QUEST_DEFINITION:
|
||||
return {"definition_id": ctx["definition_id"], "title": "Brush teeth 2"}
|
||||
if service == SERVICE_SET_QUEST_DEFINITION_ACTIVE:
|
||||
return {"definition_id": ctx["definition_id"], "is_active": False}
|
||||
if service == SERVICE_SET_PRESENCE_PATTERN:
|
||||
return {
|
||||
"child_id": ctx["child_id"],
|
||||
"cycle_length_weeks": 1,
|
||||
"anchor_date": ctx["today"],
|
||||
"pattern": {0: [0, 1, 2, 3, 4, 5, 6]},
|
||||
}
|
||||
if service == SERVICE_CREATE_PRESENCE_OVERRIDE:
|
||||
return {
|
||||
"child_id": ctx["child_id"],
|
||||
"start_date": "2027-03-01",
|
||||
"end_date": "2027-03-02",
|
||||
"is_present": False,
|
||||
}
|
||||
if service == SERVICE_DELETE_PRESENCE_OVERRIDE:
|
||||
return {"override_id": ctx["override"].id}
|
||||
if service == SERVICE_EXPORT_HISTORY_CSV:
|
||||
return None # raises HomeAssistantError (Feature 13) — not Unauthorized
|
||||
if service == SERVICE_MANAGE_CHILD:
|
||||
return {"action": "create", "display_name": "Bo"}
|
||||
if service == SERVICE_REGENERATE:
|
||||
return {}
|
||||
raise AssertionError(f"unexpected service {service}")
|
||||
|
||||
|
||||
_ADMIN_ONLY = [
|
||||
service for service, policy in SERVICE_POLICY.items() if policy == ADMIN_ONLY
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("service", _ADMIN_ONLY)
|
||||
async def test_admin_succeeds_on_every_admin_only_service(
|
||||
hass, make_entry, service
|
||||
) -> None:
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
entry = await _setup(hass, make_entry)
|
||||
ctx = await _seed(hass, entry)
|
||||
if service == SERVICE_EXPORT_HISTORY_CSV:
|
||||
# The gate passes; the Feature 13 stub raises its "not
|
||||
# implemented" HomeAssistantError — NOT Unauthorized.
|
||||
with pytest.raises(HomeAssistantError, match="Feature 13"):
|
||||
await hass.services.call(
|
||||
DOMAIN, service, None, context=ADMIN_CTX
|
||||
)
|
||||
return
|
||||
await hass.services.call(
|
||||
DOMAIN, service, _admin_success_payload(service, ctx), context=ADMIN_CTX
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("service", _ADMIN_ONLY)
|
||||
async def test_non_admin_refused_on_every_admin_only_service(
|
||||
hass, make_entry, service
|
||||
) -> None:
|
||||
entry = await _setup(hass, make_entry)
|
||||
ctx = await _seed(hass, entry)
|
||||
if service != SERVICE_EXPORT_HISTORY_CSV:
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
service,
|
||||
_admin_success_payload(service, ctx),
|
||||
context=ADMIN_CTX,
|
||||
)
|
||||
with pytest.raises(Unauthorized):
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
service,
|
||||
_admin_success_payload(service, ctx),
|
||||
context=NON_ADMIN_CTX,
|
||||
)
|
||||
|
||||
|
||||
async def test_non_admin_allowed_on_complete_quest(hass, make_entry) -> None:
|
||||
from custom_components.nestquest.completion import instance_state
|
||||
|
||||
entry = await _setup(hass, make_entry)
|
||||
ctx = await _seed(hass, entry)
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
{
|
||||
"instance_id": ctx["instance_id"],
|
||||
"actor": "panel",
|
||||
"actor_child_id": ctx["child_id"],
|
||||
},
|
||||
context=NON_ADMIN_CTX,
|
||||
)
|
||||
assert (
|
||||
await instance_state(ctx["database"], ctx["instance_id"]) == "done"
|
||||
)
|
||||
|
||||
|
||||
async def test_kiosk_refused_on_uncomplete_and_regenerate(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
entry = await _setup(hass, make_entry)
|
||||
ctx = await _seed(hass, entry)
|
||||
from custom_components.nestquest.admin_allowlist import list_admin_ids
|
||||
|
||||
assert KIOSK_ID not in await list_admin_ids(ctx["database"])
|
||||
with pytest.raises(Unauthorized):
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_UNCOMPLETE_QUEST,
|
||||
{"instance_id": ctx["instance_id"], "actor": "user"},
|
||||
context=KIOSK_CTX,
|
||||
)
|
||||
with pytest.raises(Unauthorized):
|
||||
await hass.services.call(
|
||||
DOMAIN, SERVICE_REGENERATE, context=KIOSK_CTX
|
||||
)
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Tests for the Feature 09 permission gate's audit logging."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from homeassistant.exceptions import Unauthorized
|
||||
|
||||
from conftest import wire_entry_to_registry
|
||||
|
||||
from custom_components.nestquest import async_setup_entry
|
||||
from custom_components.nestquest.const import (
|
||||
CONF_ADMIN_USER_IDS,
|
||||
DOMAIN,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
)
|
||||
|
||||
ADMIN_ID = "admin-1"
|
||||
ADMIN_CTX = {"user_id": ADMIN_ID}
|
||||
_TIMESTAMP = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}")
|
||||
|
||||
|
||||
async def _set_up(hass, make_entry):
|
||||
entry = wire_entry_to_registry(
|
||||
make_entry(data={CONF_ADMIN_USER_IDS: [ADMIN_ID]}), hass.registry
|
||||
)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
return entry
|
||||
|
||||
|
||||
async def test_denied_call_logs_warning_with_fields(
|
||||
hass, make_entry, caplog
|
||||
) -> None:
|
||||
entry = await _set_up(hass, make_entry)
|
||||
with pytest.raises(Unauthorized):
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
{"action": "create", "display_name": "Ada"},
|
||||
context={"user_id": "kiosk-panel"},
|
||||
)
|
||||
denials = [
|
||||
record
|
||||
for record in caplog.records
|
||||
if record.levelno == logging.WARNING
|
||||
and "denied" in record.getMessage()
|
||||
]
|
||||
assert len(denials) == 1
|
||||
message = denials[0].getMessage()
|
||||
assert SERVICE_MANAGE_CHILD in message
|
||||
assert "kiosk-panel" in message
|
||||
assert _TIMESTAMP.search(message), message
|
||||
|
||||
|
||||
async def test_denied_no_context_logs_user_none(
|
||||
hass, make_entry, caplog
|
||||
) -> None:
|
||||
entry = await _set_up(hass, make_entry)
|
||||
with pytest.raises(Unauthorized):
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
{"action": "create", "display_name": "Ada"},
|
||||
)
|
||||
denials = [
|
||||
record
|
||||
for record in caplog.records
|
||||
if record.levelno == logging.WARNING
|
||||
and "denied" in record.getMessage()
|
||||
]
|
||||
assert len(denials) == 1
|
||||
message = denials[0].getMessage()
|
||||
assert "none" in message
|
||||
assert _TIMESTAMP.search(message), message
|
||||
|
||||
|
||||
async def test_successful_admin_call_logs_debug_with_fields(
|
||||
hass, make_entry, caplog
|
||||
) -> None:
|
||||
caplog.set_level(logging.DEBUG, logger="custom_components.nestquest")
|
||||
entry = await _set_up(hass, make_entry)
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
{"action": "create", "display_name": "Ada"},
|
||||
context=ADMIN_CTX,
|
||||
)
|
||||
allowed = [
|
||||
record
|
||||
for record in caplog.records
|
||||
if record.levelno == logging.DEBUG
|
||||
and "allowed" in record.getMessage()
|
||||
]
|
||||
assert len(allowed) == 1
|
||||
message = allowed[0].getMessage()
|
||||
assert SERVICE_MANAGE_CHILD in message
|
||||
assert ADMIN_ID in message
|
||||
assert _TIMESTAMP.search(message), message
|
||||
|
||||
|
||||
async def test_denied_log_carries_no_personal_data_beyond_user_id(
|
||||
hass, make_entry, caplog
|
||||
) -> None:
|
||||
"""The log line names the caller's user id only: the payload's
|
||||
display name must not leak into the audit trail."""
|
||||
entry = await _set_up(hass, make_entry)
|
||||
with pytest.raises(Unauthorized):
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
{"action": "create", "display_name": "Ada"},
|
||||
context={"user_id": "kiosk-panel"},
|
||||
)
|
||||
denials = [
|
||||
record.getMessage()
|
||||
for record in caplog.records
|
||||
if record.levelno == logging.WARNING and "denied" in record.getMessage()
|
||||
]
|
||||
assert denials, "expected a denial log line"
|
||||
assert "Ada" not in denials[0]
|
||||
assert "display_name" not in denials[0]
|
||||
+312
-36
@@ -8,6 +8,7 @@ import re
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
|
||||
from custom_components.nestquest.dao_instances import (
|
||||
CompletionEventsDao,
|
||||
QuestInstancesDao,
|
||||
@@ -786,6 +787,24 @@ def test_edit_rejects_nonexistent_definition(tmp_path) -> None:
|
||||
_with_db(tmp_path, "edit-missing-definition.db")(_body)
|
||||
|
||||
|
||||
def test_edit_nonexistent_definition_names_field(tmp_path) -> None:
|
||||
"""A missing definition's error names the ``definition_id`` field.
|
||||
|
||||
The DAO raises a bare "quest definition <id> does not exist"; the
|
||||
business layer must re-raise it prefixed with the field the caller
|
||||
passed, matching assign/unassign/deactivate.
|
||||
"""
|
||||
async def _body(database, children, rules, definitions, child):
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="definition_id: quest definition 999 does not exist",
|
||||
):
|
||||
await edit_quest_definition(database, 999, title="X")
|
||||
return None
|
||||
|
||||
_with_db(tmp_path, "edit-missing-definition-field.db")(_body)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [True, 1.5, "1", None])
|
||||
def test_edit_rejects_non_int_definition_id(tmp_path, bad) -> None:
|
||||
async def _body(database, children, rules, definitions, child):
|
||||
@@ -1261,11 +1280,19 @@ def test_set_quest_definition_active_nonexistent_definition_names_field(
|
||||
_with_db(tmp_path, "set-active-missing-definition.db")(_body)
|
||||
|
||||
|
||||
def test_set_quest_definition_active_never_deletes_or_touches_instances(
|
||||
def test_set_quest_definition_active_preserves_completed_and_history(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
"""Deactivation hides the definition but deletes nothing, and
|
||||
pre-existing instances and completion history survive unchanged."""
|
||||
"""Deactivation stops future generation; reactivation resumes it.
|
||||
|
||||
The Feature 07 wiring makes both transitions regenerate the
|
||||
definition's rolling horizon: future OPEN instances are removed on
|
||||
deactivation and re-materialized on reactivation. A pre-existing
|
||||
COMPLETED instance and its completion events always survive both
|
||||
transitions unchanged (regeneration never touches completed rows or
|
||||
the past), and the append-only completion-events table is never
|
||||
rewritten.
|
||||
"""
|
||||
async def _body(database, children, rules, definitions, child):
|
||||
created = await create_quest_definition(
|
||||
database, "Brush teeth", _daily_rule(), [child.id], ["morning"]
|
||||
@@ -1292,9 +1319,6 @@ def test_set_quest_definition_active_never_deletes_or_touches_instances(
|
||||
actor_user_id="admin-1",
|
||||
)
|
||||
|
||||
instance_count = await database.fetch_one(
|
||||
"SELECT COUNT(*) FROM quest_instances"
|
||||
)
|
||||
event_count = await database.fetch_one(
|
||||
"SELECT COUNT(*) FROM completion_events"
|
||||
)
|
||||
@@ -1314,29 +1338,25 @@ def test_set_quest_definition_active_never_deletes_or_touches_instances(
|
||||
definition_id
|
||||
)] == ["morning"]
|
||||
# The pre-existing instance and completion event survive with
|
||||
# unchanged ids and contents, and no new rows appeared.
|
||||
# unchanged ids and contents, and no new event rows appeared.
|
||||
assert await instances.get_by_id(instance.id) == instance
|
||||
assert await events.list_by_instance(instance.id) == [event]
|
||||
assert await database.fetch_one(
|
||||
"SELECT COUNT(*) FROM quest_instances"
|
||||
) == instance_count
|
||||
assert await database.fetch_one(
|
||||
"SELECT COUNT(*) FROM completion_events"
|
||||
) == event_count
|
||||
|
||||
await set_quest_definition_active(database, definition_id, True)
|
||||
assert (await definitions.get(definition_id)).is_active is True
|
||||
# Reactivation regenerates future OPEN instances, but the
|
||||
# completed instance and its history survive the sweep unchanged.
|
||||
assert await instances.get_by_id(instance.id) == instance
|
||||
assert await events.list_by_instance(instance.id) == [event]
|
||||
assert await database.fetch_one(
|
||||
"SELECT COUNT(*) FROM quest_instances"
|
||||
) == instance_count
|
||||
assert await database.fetch_one(
|
||||
"SELECT COUNT(*) FROM completion_events"
|
||||
) == event_count
|
||||
return None
|
||||
|
||||
_with_db(tmp_path, "set-active-never-deletes.db")(_body)
|
||||
_with_db(tmp_path, "set-active-preserves-completed.db")(_body)
|
||||
|
||||
|
||||
def _gated_hass(
|
||||
@@ -1623,21 +1643,215 @@ def test_list_definitions_firing_on_rejects_malformed_date(tmp_path, bad) -> Non
|
||||
_with_db(tmp_path, "list-firing-on-bad-date.db")(_body)
|
||||
|
||||
|
||||
def test_list_query_snapshot_coherent_under_racing_edit(tmp_path) -> None:
|
||||
"""A racing edit cannot mix states into a query helper's snapshot.
|
||||
|
||||
The query helper pauses mid-snapshot (inside its locked transaction)
|
||||
while an edit that changes BOTH the rule and the windows is queued
|
||||
behind the connection lock. Because the helper reads definitions,
|
||||
rules and windows in one locked transaction, its returned bundle is
|
||||
either the whole old state or the whole new state — never the old
|
||||
rule paired with the new windows (the exact interleaving the unlocked
|
||||
reads used to allow). The pause hook holds
|
||||
``list_windows`` mid-flight so the edit is deterministically queued
|
||||
against the helper's lock.
|
||||
"""
|
||||
async def _body(database, children, rules, definitions, child):
|
||||
created = await create_quest_definition(
|
||||
database, "Brush teeth", _daily_rule(), [child.id], ["morning"]
|
||||
)
|
||||
weekly = ScheduleRule(
|
||||
rule_type=RuleType.WEEKLY,
|
||||
weekday_set={2},
|
||||
start_date="2026-09-14",
|
||||
)
|
||||
|
||||
import custom_components.nestquest.dao_rules as dao_rules
|
||||
|
||||
original_list = dao_rules.QuestDefinitionsDao.list_windows
|
||||
started = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def _pausing_list(self, definition_id):
|
||||
if not started.is_set():
|
||||
started.set()
|
||||
await release.wait()
|
||||
return await original_list(self, definition_id)
|
||||
|
||||
dao_rules.QuestDefinitionsDao.list_windows = _pausing_list
|
||||
try:
|
||||
read_task = asyncio.ensure_future(
|
||||
list_definitions_firing_on(database, "2026-09-16")
|
||||
)
|
||||
await started.wait()
|
||||
edit_task = asyncio.ensure_future(
|
||||
edit_quest_definition(
|
||||
database,
|
||||
created.definition.id,
|
||||
rule=weekly,
|
||||
windows=["evening"],
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
release.set()
|
||||
result, edited = await asyncio.gather(read_task, edit_task)
|
||||
finally:
|
||||
dao_rules.QuestDefinitionsDao.list_windows = original_list
|
||||
|
||||
# The helper finished before the queued edit, so its snapshot is
|
||||
# the whole OLD state: the daily rule with its morning window.
|
||||
assert [b.definition.id for b in result] == [created.definition.id]
|
||||
assert result[0].rule == _daily_rule()
|
||||
assert [w.window for w in result[0].windows] == ["morning"]
|
||||
# The edit then reported the whole NEW state, not a mix either.
|
||||
assert edited.rule == weekly
|
||||
assert [w.window for w in edited.windows] == ["evening"]
|
||||
return result
|
||||
|
||||
_with_db(tmp_path, "list-snapshot-concurrent.db")(_body)
|
||||
|
||||
|
||||
def test_list_definitions_for_child_snapshot_coherent_under_racing_unassign(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
"""A racing unassign cannot strip the child from the child-scoped
|
||||
snapshot that ``list_definitions_for_child`` returns.
|
||||
|
||||
The helper pauses mid-snapshot (inside its locked transaction) while
|
||||
an unassign of the very child it is listing is queued behind the
|
||||
connection lock. Because the definition set and its assignees are
|
||||
read in one locked transaction, the returned bundle still shows the
|
||||
child assigned — the coherent pre-unassign snapshot — rather than the
|
||||
mixed state (definition returned "for child" but with the child
|
||||
already gone from its roster) that the unlocked reads used to allow.
|
||||
The pause hook holds ``list_assignees`` mid-flight so the unassign is
|
||||
deterministically queued against the helper's lock.
|
||||
"""
|
||||
async def _body(database, children, rules, definitions, child):
|
||||
created = await create_quest_definition(
|
||||
database, "Brush teeth", _daily_rule(), [child.id], ["morning"]
|
||||
)
|
||||
|
||||
import custom_components.nestquest.dao_rules as dao_rules
|
||||
|
||||
original_list = dao_rules.QuestDefinitionsDao.list_assignees
|
||||
started = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def _pausing_list(self, definition_id):
|
||||
if not started.is_set():
|
||||
started.set()
|
||||
await release.wait()
|
||||
return await original_list(self, definition_id)
|
||||
|
||||
dao_rules.QuestDefinitionsDao.list_assignees = _pausing_list
|
||||
try:
|
||||
read_task = asyncio.ensure_future(
|
||||
list_definitions_for_child(database, child.id)
|
||||
)
|
||||
await started.wait()
|
||||
unassign_task = asyncio.ensure_future(
|
||||
unassign_child(database, created.definition.id, child.id)
|
||||
)
|
||||
for _ in range(5):
|
||||
await asyncio.sleep(0)
|
||||
release.set()
|
||||
result, removed = await asyncio.gather(read_task, unassign_task)
|
||||
finally:
|
||||
dao_rules.QuestDefinitionsDao.list_assignees = original_list
|
||||
|
||||
# The helper finished before the queued unassign, so its snapshot
|
||||
# is the whole OLD state: the child still assigned to the
|
||||
# definition it returned for that child.
|
||||
assert [b.definition.id for b in result] == [created.definition.id]
|
||||
assert [c.id for c in result[0].assignees] == [child.id]
|
||||
assert removed is True
|
||||
return result
|
||||
|
||||
_with_db(tmp_path, "list-for-child-snapshot-concurrent.db")(_body)
|
||||
|
||||
|
||||
def test_list_active_definitions_snapshot_coherent_under_racing_edit(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
"""A racing edit cannot mix states into ``list_active_definitions``.
|
||||
|
||||
The helper pauses mid-snapshot (inside its locked transaction) while
|
||||
an edit that changes BOTH the rule and the windows is queued behind
|
||||
the connection lock. Because definitions, rules and windows are read
|
||||
in one locked transaction, the returned bundle is the whole old state
|
||||
— the daily rule with its morning window — never the old rule paired
|
||||
with the new windows. The pause hook holds ``list_windows``
|
||||
mid-flight so the edit is deterministically queued against the
|
||||
helper's lock.
|
||||
"""
|
||||
async def _body(database, children, rules, definitions, child):
|
||||
created = await create_quest_definition(
|
||||
database, "Brush teeth", _daily_rule(), [child.id], ["morning"]
|
||||
)
|
||||
weekly = ScheduleRule(
|
||||
rule_type=RuleType.WEEKLY,
|
||||
weekday_set={2},
|
||||
start_date="2026-09-14",
|
||||
)
|
||||
|
||||
import custom_components.nestquest.dao_rules as dao_rules
|
||||
|
||||
original_list = dao_rules.QuestDefinitionsDao.list_windows
|
||||
started = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def _pausing_list(self, definition_id):
|
||||
if not started.is_set():
|
||||
started.set()
|
||||
await release.wait()
|
||||
return await original_list(self, definition_id)
|
||||
|
||||
dao_rules.QuestDefinitionsDao.list_windows = _pausing_list
|
||||
try:
|
||||
read_task = asyncio.ensure_future(list_active_definitions(database))
|
||||
await started.wait()
|
||||
edit_task = asyncio.ensure_future(
|
||||
edit_quest_definition(
|
||||
database,
|
||||
created.definition.id,
|
||||
rule=weekly,
|
||||
windows=["evening"],
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
release.set()
|
||||
result, edited = await asyncio.gather(read_task, edit_task)
|
||||
finally:
|
||||
dao_rules.QuestDefinitionsDao.list_windows = original_list
|
||||
|
||||
# The helper finished before the queued edit, so its snapshot is
|
||||
# the whole OLD state: the daily rule with its morning window.
|
||||
assert [b.definition.id for b in result] == [created.definition.id]
|
||||
assert result[0].rule == _daily_rule()
|
||||
assert [w.window for w in result[0].windows] == ["morning"]
|
||||
# The edit then reported the whole NEW state, not a mix either.
|
||||
assert edited.rule == weekly
|
||||
assert [w.window for w in edited.windows] == ["evening"]
|
||||
return result
|
||||
|
||||
_with_db(tmp_path, "list-active-snapshot-concurrent.db")(_body)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# full-lifecycle integration: one definition through every operation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_full_lifecycle_never_touches_instances_or_history(tmp_path) -> None:
|
||||
"""End-to-end lifecycle on one definition writes only the four
|
||||
definition tables; ``quest_instances`` and ``completion_events`` are
|
||||
left exactly as they were at every stage.
|
||||
def test_full_lifecycle_preserves_completed_and_history(tmp_path) -> None:
|
||||
"""End-to-end lifecycle preserves completed rows and append-only history.
|
||||
|
||||
A real instance and completion event are seeded before the lifecycle
|
||||
begins, so each stage is checked against pre-existing data being
|
||||
mutated (not an empty table staying empty): create -> edit
|
||||
rule/windows -> assign a second child -> unassign the original child
|
||||
-> deactivate -> reactivate.
|
||||
Each stage (create -> edit rule/windows -> assign a second child ->
|
||||
unassign the original child -> deactivate -> reactivate) now
|
||||
regenerates the definition's future OPEN instances (Feature 07
|
||||
wiring), but a pre-existing COMPLETED instance and its completion
|
||||
events survive every stage unchanged — the completed row keeps its
|
||||
id and snapshot columns, the completion-events table stays append-only.
|
||||
"""
|
||||
async def _body(database, children, rules, definitions, child):
|
||||
created = await create_quest_definition(
|
||||
@@ -1663,24 +1877,18 @@ def test_full_lifecycle_never_touches_instances_or_history(tmp_path) -> None:
|
||||
True,
|
||||
actor_user_id="admin-1",
|
||||
)
|
||||
instance_count = await database.fetch_one(
|
||||
"SELECT COUNT(*) FROM quest_instances"
|
||||
)
|
||||
event_count = await database.fetch_one(
|
||||
"SELECT COUNT(*) FROM completion_events"
|
||||
)
|
||||
|
||||
async def _assert_history_untouched():
|
||||
async def _assert_history_preserved():
|
||||
assert await instances.get_by_id(instance.id) == instance
|
||||
assert await events.list_by_instance(instance.id) == [event]
|
||||
assert await database.fetch_one(
|
||||
"SELECT COUNT(*) FROM quest_instances"
|
||||
) == instance_count
|
||||
assert await database.fetch_one(
|
||||
"SELECT COUNT(*) FROM completion_events"
|
||||
) == event_count
|
||||
|
||||
await _assert_history_untouched()
|
||||
await _assert_history_preserved()
|
||||
|
||||
# Edit rule and windows together; the definition's rule row is
|
||||
# rewritten in place and its window set replaced.
|
||||
@@ -1701,33 +1909,101 @@ def test_full_lifecycle_never_touches_instances_or_history(tmp_path) -> None:
|
||||
)
|
||||
by_name = {w.window: w.due_time for w in edited.windows}
|
||||
assert by_name == {"morning": "08:00", "evening": "19:30"}
|
||||
await _assert_history_untouched()
|
||||
await _assert_history_preserved()
|
||||
|
||||
# Assign a second child: multi-assignee, both present.
|
||||
bo = await children.create("Bo", NOW)
|
||||
roster = await assign_child(database, definition_id, bo.id)
|
||||
assert sorted(c.id for c in roster) == sorted([child.id, bo.id])
|
||||
await _assert_history_untouched()
|
||||
await _assert_history_preserved()
|
||||
|
||||
# Unassign the original child: only the link drops.
|
||||
assert await unassign_child(database, definition_id, child.id) is True
|
||||
assert [
|
||||
c.id for c in await definitions.list_assignees(definition_id)
|
||||
] == [bo.id]
|
||||
await _assert_history_untouched()
|
||||
await _assert_history_preserved()
|
||||
|
||||
# Deactivate, then reactivate: the definition survives both.
|
||||
deactivated = await set_quest_definition_active(
|
||||
database, definition_id, False
|
||||
)
|
||||
assert deactivated.definition.is_active is False
|
||||
await _assert_history_untouched()
|
||||
await _assert_history_preserved()
|
||||
reactivated = await set_quest_definition_active(
|
||||
database, definition_id, True
|
||||
)
|
||||
assert reactivated.definition.is_active is True
|
||||
await _assert_history_untouched()
|
||||
await _assert_history_preserved()
|
||||
|
||||
return reactivated
|
||||
|
||||
_with_db(tmp_path, "full-lifecycle.db")(_body)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mode",
|
||||
["edit", "assign", "unassign", "set_active"],
|
||||
)
|
||||
def test_wrappers_forward_horizon_days(tmp_path, mode) -> None:
|
||||
"""The four change wrappers forward a non-default horizon_days.
|
||||
|
||||
Each wrapper threads ``horizon_days`` into the regeneration it triggers;
|
||||
passing 5 here must bound the regenerated window to today..today+5 —
|
||||
proving the forwarding argument is actually honored, not dropped.
|
||||
"""
|
||||
async def _body(database, children, rules, definitions, child):
|
||||
created = await create_quest_definition(
|
||||
database,
|
||||
"Chore",
|
||||
ScheduleRule(rule_type=RuleType.DAILY, start_date=_today_iso()),
|
||||
[child.id],
|
||||
["morning"],
|
||||
)
|
||||
definition_id = created.definition.id
|
||||
instances = QuestInstancesDao(database)
|
||||
|
||||
if mode == "edit":
|
||||
await edit_quest_definition(
|
||||
database,
|
||||
definition_id,
|
||||
windows=[("morning", "10:30")],
|
||||
horizon_days=5,
|
||||
)
|
||||
tracked = child
|
||||
elif mode == "assign":
|
||||
bo = await children.create("Bo", NOW)
|
||||
await assign_child(database, definition_id, bo.id, horizon_days=5)
|
||||
tracked = bo
|
||||
elif mode == "unassign":
|
||||
# The remaining assignee (``child``) must be regenerated over
|
||||
# the 5-day window after ``bo`` is removed.
|
||||
bo = await children.create("Bo", NOW)
|
||||
await assign_child(database, definition_id, bo.id)
|
||||
await unassign_child(
|
||||
database, definition_id, bo.id, horizon_days=5
|
||||
)
|
||||
tracked = child
|
||||
else: # set_active
|
||||
await set_quest_definition_active(
|
||||
database, definition_id, True, horizon_days=5
|
||||
)
|
||||
tracked = child
|
||||
|
||||
today = datetime.date.today()
|
||||
end = (today + datetime.timedelta(days=5)).isoformat()
|
||||
records = await instances.list_by_date_range(
|
||||
tracked.id, today.isoformat(), end
|
||||
)
|
||||
assert len(records) == 6
|
||||
|
||||
beyond = (today + datetime.timedelta(days=6)).isoformat()
|
||||
beyond_end = (
|
||||
today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
||||
).isoformat()
|
||||
assert await instances.list_by_date_range(
|
||||
tracked.id, beyond, beyond_end
|
||||
) == []
|
||||
return None
|
||||
|
||||
_with_db(tmp_path, f"wrapper-horizon-{mode}.db")(_body)
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
"""Tests for the per-child daily count and percentage sensors (Feature 10, task 2)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import wire_entry_to_registry
|
||||
|
||||
from custom_components.nestquest import async_setup_entry, async_unload_entry
|
||||
from custom_components.nestquest.children import create_child
|
||||
from custom_components.nestquest.completion import complete_instance
|
||||
from custom_components.nestquest.const import DOMAIN
|
||||
from custom_components.nestquest.materialize import materialize
|
||||
from custom_components.nestquest.quest_definitions import create_quest_definition
|
||||
from custom_components.nestquest.recurrence import ScheduleRule
|
||||
from custom_components.nestquest.sensor import (
|
||||
MAX_STATE_LENGTH,
|
||||
_state_within_limit,
|
||||
)
|
||||
|
||||
#: The seeding horizon: instances exist for today and a few days out,
|
||||
#: enough for the day snapshot without slowing the walk down.
|
||||
SEED_HORIZON_DAYS = 3
|
||||
|
||||
#: The documented per-child day sensor kinds (unique_id suffixes).
|
||||
SENSOR_KINDS = (
|
||||
"quests_due_today",
|
||||
"quests_completed_today",
|
||||
"quests_remaining_today",
|
||||
"completion_pct_today",
|
||||
)
|
||||
|
||||
|
||||
def _today() -> datetime.date:
|
||||
return datetime.date.today()
|
||||
|
||||
|
||||
async def _seed_children(database):
|
||||
"""Three active children: Ada and Bo share one daily quest; Cory has none."""
|
||||
ada = await create_child(database, "Ada")
|
||||
bo = await create_child(database, "Bo")
|
||||
cory = await create_child(database, "Cory")
|
||||
today = _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=SEED_HORIZON_DAYS)).isoformat()
|
||||
await materialize(database, today.isoformat(), end, today=today)
|
||||
return ada, bo, cory
|
||||
|
||||
|
||||
async def _setup_seeded_entry(hass, make_entry):
|
||||
"""Set up the integration, seed three children, refresh: sensors exist.
|
||||
|
||||
Children are seeded AFTER setup (the entry's database only exists
|
||||
once setup opens it), so the sensors appear through the refresh
|
||||
listener — the same path production takes when a child is added.
|
||||
"""
|
||||
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
|
||||
ada, bo, cory = await _seed_children(database)
|
||||
await coordinator.async_refresh()
|
||||
return entry, coordinator, (ada, bo, cory)
|
||||
|
||||
|
||||
def _snapshot_child(coordinator, child_id):
|
||||
"""Return the child's current day snapshot from the coordinator."""
|
||||
for child in coordinator.data.children:
|
||||
if child.child_id == child_id:
|
||||
return child
|
||||
raise AssertionError(f"child {child_id} missing from the snapshot")
|
||||
|
||||
|
||||
def _sensor(hass, child_id, kind):
|
||||
"""Return the registered sensor entity for (child, kind)."""
|
||||
return hass.entities[f"{DOMAIN}_child_{child_id}_{kind}"]
|
||||
|
||||
|
||||
async def _complete_first_instance(database, coordinator, child) -> None:
|
||||
"""Complete the child's first open instance through the business layer."""
|
||||
snapshot_child = _snapshot_child(coordinator, child.id)
|
||||
instance_id = snapshot_child.instances[0].instance_id
|
||||
await complete_instance(
|
||||
database,
|
||||
instance_id,
|
||||
actor_source="panel",
|
||||
actor_child_id=child.id,
|
||||
)
|
||||
|
||||
|
||||
async def test_every_active_child_gets_five_sensors_with_documented_identity(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry(
|
||||
hass, make_entry
|
||||
)
|
||||
database = entry.runtime_data.database
|
||||
assert hass.config_entries.forwarded_platforms == [
|
||||
"sensor",
|
||||
"binary_sensor",
|
||||
]
|
||||
await _complete_first_instance(database, coordinator, ada)
|
||||
await coordinator.async_refresh()
|
||||
|
||||
# Two quest children plus the zero-quest child: three children x
|
||||
# (five sensors + two binary sensors) + three household sensors,
|
||||
# no duplicates.
|
||||
assert len(hass.entities) == 24
|
||||
expected = {
|
||||
ada.id: ("Ada", 1, 1, 0, 100),
|
||||
bo.id: ("Bo", 1, 0, 1, 0),
|
||||
cory.id: ("Cory", 0, 0, 0, 100),
|
||||
}
|
||||
for child_id, (name, due, completed, remaining, pct) in expected.items():
|
||||
due_entity = _sensor(hass, child_id, "quests_due_today")
|
||||
completed_entity = _sensor(hass, child_id, "quests_completed_today")
|
||||
remaining_entity = _sensor(hass, child_id, "quests_remaining_today")
|
||||
pct_entity = _sensor(hass, child_id, "completion_pct_today")
|
||||
next_entity = _sensor(hass, child_id, "next_quest")
|
||||
assert due_entity.name == f"NestQuest {name} quests due today"
|
||||
assert completed_entity.name == (
|
||||
f"NestQuest {name} quests completed today"
|
||||
)
|
||||
assert remaining_entity.name == (
|
||||
f"NestQuest {name} quests remaining today"
|
||||
)
|
||||
assert pct_entity.name == f"NestQuest {name} completion pct today"
|
||||
assert due_entity.native_value == due
|
||||
assert completed_entity.native_value == completed
|
||||
assert remaining_entity.native_value == remaining
|
||||
assert pct_entity.native_value == pct
|
||||
for entity in (due_entity, completed_entity, remaining_entity):
|
||||
assert entity.state_class == "measurement"
|
||||
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"
|
||||
device_info = due_entity.device_info
|
||||
assert device_info.name == f"NestQuest {name}"
|
||||
assert device_info.manufacturer == "NestQuest"
|
||||
assert device_info.identifiers == {(DOMAIN, f"child_{child_id}")}
|
||||
|
||||
|
||||
async def test_zero_quest_child_is_a_perfect_day(hass, make_entry) -> None:
|
||||
entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry(
|
||||
hass, make_entry
|
||||
)
|
||||
due_entity = _sensor(hass, cory.id, "quests_due_today")
|
||||
pct_entity = _sensor(hass, cory.id, "completion_pct_today")
|
||||
assert due_entity.native_value == 0
|
||||
assert pct_entity.native_value == 100, (
|
||||
"zero quests is a perfect day, not a division error"
|
||||
)
|
||||
attributes = due_entity.extra_state_attributes
|
||||
assert attributes["instances"] == []
|
||||
assert attributes["admin_instances"] == []
|
||||
|
||||
|
||||
async def test_due_today_attributes_carry_the_panel_payload(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry(
|
||||
hass, make_entry
|
||||
)
|
||||
database = entry.runtime_data.database
|
||||
await _complete_first_instance(database, coordinator, ada)
|
||||
await coordinator.async_refresh()
|
||||
|
||||
ada_due = _sensor(hass, ada.id, "quests_due_today")
|
||||
attributes = ada_due.extra_state_attributes
|
||||
assert set(attributes) == {
|
||||
"instances",
|
||||
"admin_instances",
|
||||
"child_id",
|
||||
"child_name",
|
||||
"present",
|
||||
}
|
||||
assert attributes["child_id"] == ada.id
|
||||
assert attributes["child_name"] == "Ada"
|
||||
assert attributes["present"] is True
|
||||
[instance] = attributes["instances"]
|
||||
assert set(instance) == {
|
||||
"id",
|
||||
"definition_id",
|
||||
"child_id",
|
||||
"title",
|
||||
"icon",
|
||||
"window",
|
||||
"due_time",
|
||||
"state",
|
||||
"overdue",
|
||||
"completed_at",
|
||||
"on_time",
|
||||
}
|
||||
assert instance["child_id"] == ada.id
|
||||
assert instance["title"] == "Brush teeth"
|
||||
assert instance["window"] == "morning"
|
||||
assert instance["state"] == "completed", (
|
||||
"the derived 'done' maps to the panel's 'completed' spelling"
|
||||
)
|
||||
assert instance["completed_at"] is not None
|
||||
assert instance["on_time"] is not None
|
||||
assert instance["overdue"] is False
|
||||
assert attributes["admin_instances"] == attributes["instances"], (
|
||||
"with nothing missed, admin and panel payloads are identical"
|
||||
)
|
||||
|
||||
bo_due = _sensor(hass, bo.id, "quests_due_today")
|
||||
[bo_instance] = bo_due.extra_state_attributes["instances"]
|
||||
assert bo_instance["state"] == "open"
|
||||
assert bo_instance["completed_at"] is None
|
||||
assert bo_instance["on_time"] is None
|
||||
|
||||
|
||||
async def test_due_today_payload_omits_missed_for_panel_includes_for_admin(
|
||||
hass, make_entry, monkeypatch
|
||||
) -> None:
|
||||
"""D-009: the panel payload omits missed instances, the admin one keeps them.
|
||||
|
||||
A missed instance is derived (``due_date`` before the snapshot's
|
||||
"today"), never stored. The coordinator's day listing is keyed on
|
||||
``_local_now``'s date, so pinning the clock forward would move the
|
||||
listing window off today's rows entirely; the pinned FUTURE clock
|
||||
is therefore applied at the ``derive_state`` boundary instead —
|
||||
today's open instances then read as missed through the same
|
||||
derivation rule, without back-dating instance rows past the DAO's
|
||||
no-past guard or touching append-only completion events.
|
||||
"""
|
||||
entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry(
|
||||
hass, make_entry
|
||||
)
|
||||
import custom_components.nestquest.coordinator as coordinator_module
|
||||
|
||||
tomorrow = _today() + datetime.timedelta(days=1)
|
||||
real_derive_state = coordinator_module.derive_state
|
||||
|
||||
def _derive_against_pinned_future(instance, latest, today):
|
||||
return real_derive_state(instance, latest, tomorrow)
|
||||
|
||||
monkeypatch.setattr(
|
||||
coordinator_module, "derive_state", _derive_against_pinned_future
|
||||
)
|
||||
await coordinator.async_refresh()
|
||||
|
||||
bo_due = _sensor(hass, bo.id, "quests_due_today")
|
||||
attributes = bo_due.extra_state_attributes
|
||||
assert attributes["instances"] == [], (
|
||||
"missed instances are omitted from the panel payload"
|
||||
)
|
||||
[missed] = attributes["admin_instances"]
|
||||
assert missed["state"] == "missed"
|
||||
assert missed["completed_at"] is None
|
||||
# Counts still roll the missed instance up as not-completed.
|
||||
assert _sensor(hass, bo.id, "quests_completed_today").native_value == 0
|
||||
assert _sensor(hass, bo.id, "quests_remaining_today").native_value == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", SENSOR_KINDS)
|
||||
async def test_no_sensor_state_exceeds_the_255_character_limit(
|
||||
hass, make_entry, kind
|
||||
) -> None:
|
||||
entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry(
|
||||
hass, make_entry
|
||||
)
|
||||
for child in (ada, bo, cory):
|
||||
entity = _sensor(hass, child.id, kind)
|
||||
state = entity.native_value
|
||||
assert len(str(state)) <= MAX_STATE_LENGTH
|
||||
|
||||
|
||||
def test_state_within_limit_refuses_overlong_strings() -> None:
|
||||
"""The truncation guard: numerics pass, overlong strings fail fast."""
|
||||
assert _state_within_limit(3) == 3
|
||||
assert _state_within_limit("ok") == "ok"
|
||||
with pytest.raises(ValueError, match="255"):
|
||||
_state_within_limit("x" * (MAX_STATE_LENGTH + 1))
|
||||
|
||||
|
||||
async def test_sensors_update_on_refresh_after_a_completion(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry(
|
||||
hass, make_entry
|
||||
)
|
||||
database = entry.runtime_data.database
|
||||
completed_entity = _sensor(hass, ada.id, "quests_completed_today")
|
||||
remaining_entity = _sensor(hass, ada.id, "quests_remaining_today")
|
||||
pct_entity = _sensor(hass, ada.id, "completion_pct_today")
|
||||
due_entity = _sensor(hass, ada.id, "quests_due_today")
|
||||
assert completed_entity.native_value == 0
|
||||
assert remaining_entity.native_value == 1
|
||||
assert pct_entity.native_value == 0
|
||||
|
||||
# Coordinator listener wiring: each refresh writes the entity's
|
||||
# state (the registration the cards' state_changed handlers ride).
|
||||
writes = []
|
||||
real_write = due_entity.async_write_ha_state
|
||||
|
||||
def _record_write():
|
||||
writes.append(due_entity.unique_id)
|
||||
real_write()
|
||||
|
||||
due_entity.async_write_ha_state = _record_write
|
||||
|
||||
await _complete_first_instance(database, coordinator, ada)
|
||||
await coordinator.async_refresh()
|
||||
|
||||
assert completed_entity.native_value == 1
|
||||
assert remaining_entity.native_value == 0
|
||||
assert pct_entity.native_value == 100
|
||||
assert writes == [due_entity.unique_id]
|
||||
|
||||
|
||||
async def test_repeated_refreshes_do_not_duplicate_entities(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry(
|
||||
hass, make_entry
|
||||
)
|
||||
await coordinator.async_refresh()
|
||||
await coordinator.async_refresh()
|
||||
assert len(hass.entities) == 24
|
||||
|
||||
|
||||
async def test_setup_with_no_children_registers_only_household(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
# No children means no per-child entities; the three household
|
||||
# rollups still exist (an empty household is a valid 0).
|
||||
assert set(hass.entities) == {
|
||||
"nestquest_household_quests_due_today",
|
||||
"nestquest_household_quests_completed_today",
|
||||
"nestquest_cycle_day",
|
||||
}
|
||||
assert hass.entities["nestquest_household_quests_due_today"].native_value == 0
|
||||
|
||||
|
||||
async def test_unload_unloads_platforms(hass, make_entry) -> None:
|
||||
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
assert await async_unload_entry(hass, entry) is True
|
||||
assert hass.config_entries.unloaded_platforms == [
|
||||
"sensor",
|
||||
"binary_sensor",
|
||||
]
|
||||
|
||||
|
||||
async def test_rename_propagates_to_name_and_device_label(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""A manage_child rename shows on the next refresh: the name and
|
||||
device label re-derive from the snapshot while the unique_id (and
|
||||
therefore history) is untouched."""
|
||||
from custom_components.nestquest.children import edit_child
|
||||
|
||||
entry, coordinator, (ada, _bo, _cory) = await _setup_seeded_entry(
|
||||
hass, make_entry
|
||||
)
|
||||
database = entry.runtime_data.database
|
||||
due_sensor = _sensor(hass, ada.id, "quests_due_today")
|
||||
original_unique_id = due_sensor.unique_id
|
||||
assert due_sensor.name.startswith("NestQuest Ada ")
|
||||
|
||||
await edit_child(database, ada.id, display_name="Mirren")
|
||||
await coordinator.async_refresh()
|
||||
|
||||
assert due_sensor.name.startswith("NestQuest Mirren ")
|
||||
assert due_sensor.device_info.name == "NestQuest Mirren"
|
||||
assert due_sensor.unique_id == original_unique_id
|
||||
|
||||
|
||||
async def test_failed_platform_unload_returns_false_and_retains_runtime(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""When HA reports platforms still loaded, the unload must NOT
|
||||
close the database the live entities read: it returns False and
|
||||
keeps the runtime record for a retry."""
|
||||
entry, _coordinator, _children = await _setup_seeded_entry(
|
||||
hass, make_entry
|
||||
)
|
||||
database = entry.runtime_data.database
|
||||
|
||||
async def _refuse_unload(entry_arg, platforms):
|
||||
return False
|
||||
|
||||
hass.config_entries.async_unload_platforms = _refuse_unload
|
||||
assert await async_unload_entry(hass, entry) is False
|
||||
assert hass.data[DOMAIN][entry.entry_id] is entry.runtime_data
|
||||
assert database.connected is True
|
||||
|
||||
async def test_next_quest_sensor_reports_earliest_open_quest(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""The next-quest sensor carries the earliest open quest's title in
|
||||
state with the full shape in attributes."""
|
||||
entry, coordinator, (ada, _bo, _cory) = await _setup_seeded_entry(
|
||||
hass, make_entry
|
||||
)
|
||||
next_entity = _sensor(hass, ada.id, "next_quest")
|
||||
assert next_entity.name == f"NestQuest Ada next quest"
|
||||
assert next_entity.native_value == "Brush teeth"
|
||||
attributes = next_entity.extra_state_attributes
|
||||
assert attributes["title"] == "Brush teeth"
|
||||
assert attributes["window"] == "morning"
|
||||
assert attributes["instance_id"] == _snapshot_child(
|
||||
coordinator, ada.id
|
||||
).instances[0].instance_id
|
||||
assert attributes["child_id"] == ada.id
|
||||
|
||||
|
||||
async def test_next_quest_sensor_reports_none_sentinel_when_day_clear(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""A child with every quest done (and a zero-quest child) reports
|
||||
the documented sentinel, never an empty string."""
|
||||
entry, coordinator, (ada, _bo, cory) = await _setup_seeded_entry(
|
||||
hass, make_entry
|
||||
)
|
||||
database = entry.runtime_data.database
|
||||
await _complete_first_instance(database, coordinator, ada)
|
||||
await coordinator.async_refresh()
|
||||
assert _sensor(hass, ada.id, "next_quest").native_value == "none"
|
||||
assert _sensor(hass, cory.id, "next_quest").native_value == "none"
|
||||
|
||||
|
||||
async def test_next_quest_state_truncates_over_the_limit(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""A quest title longer than the state limit is truncated; the
|
||||
attribute keeps the full title."""
|
||||
entry, coordinator, (ada, _bo, _cory) = await _setup_seeded_entry(
|
||||
hass, make_entry
|
||||
)
|
||||
database = entry.runtime_data.database
|
||||
from custom_components.nestquest.quest_definitions import (
|
||||
edit_quest_definition,
|
||||
)
|
||||
|
||||
snapshot_child = _snapshot_child(coordinator, ada.id)
|
||||
await edit_quest_definition(
|
||||
database,
|
||||
snapshot_child.instances[0].definition_id,
|
||||
title="A very long quest title. " * 30,
|
||||
)
|
||||
await coordinator.async_refresh()
|
||||
next_entity = _sensor(hass, ada.id, "next_quest")
|
||||
full_title = _snapshot_child(coordinator, ada.id).instances[0].title
|
||||
assert len(full_title) > 255
|
||||
state = next_entity.native_value
|
||||
assert len(state) <= 255
|
||||
assert next_entity.extra_state_attributes["title"] == full_title
|
||||
|
||||
|
||||
async def test_household_rollups_sum_the_active_children(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""Household due/completed equal the sum of the per-child sensors;
|
||||
the cycle-day sensor carries the coordinator's answer."""
|
||||
import datetime as _dt
|
||||
|
||||
from custom_components.nestquest.dao_presence import (
|
||||
PresenceSchedulesDao,
|
||||
)
|
||||
|
||||
entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry(
|
||||
hass, make_entry
|
||||
)
|
||||
database = entry.runtime_data.database
|
||||
await _complete_first_instance(database, coordinator, ada)
|
||||
await coordinator.async_refresh()
|
||||
|
||||
household_due = hass.entities["nestquest_household_quests_due_today"]
|
||||
household_completed = hass.entities[
|
||||
"nestquest_household_quests_completed_today"
|
||||
]
|
||||
cycle_day_entity = hass.entities["nestquest_cycle_day"]
|
||||
per_child_due = [
|
||||
_sensor(hass, child.id, "quests_due_today").native_value
|
||||
for child in (ada, bo, cory)
|
||||
]
|
||||
per_child_completed = [
|
||||
_sensor(hass, child.id, "quests_completed_today").native_value
|
||||
for child in (ada, bo, cory)
|
||||
]
|
||||
assert household_due.native_value == sum(per_child_due)
|
||||
assert household_completed.native_value == sum(per_child_completed)
|
||||
assert household_due.name == "NestQuest household quests due today"
|
||||
assert household_completed.name == (
|
||||
"NestQuest household quests completed today"
|
||||
)
|
||||
assert cycle_day_entity.name == "NestQuest cycle day"
|
||||
assert cycle_day_entity.native_value == 0, "no schedule anywhere"
|
||||
|
||||
# A schedule on the FIRST active child drives cycle day.
|
||||
today = _dt.date.today()
|
||||
await PresenceSchedulesDao(database).upsert_by_child(
|
||||
ada.id, 2, (today - _dt.timedelta(days=3)).isoformat(),
|
||||
"0,1,2,3,4,5,6|",
|
||||
)
|
||||
await coordinator.async_refresh()
|
||||
assert cycle_day_entity.native_value == 4
|
||||
assert cycle_day_entity.extra_state_attributes["today"] == (
|
||||
today.isoformat()
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Tests for the Feature 09 admin-only versus open service registry."""
|
||||
from __future__ import annotations
|
||||
|
||||
from custom_components.nestquest.const import (
|
||||
DOMAIN_SERVICES,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
)
|
||||
from custom_components.nestquest.service_policy import (
|
||||
ADMIN_ONLY,
|
||||
OPEN,
|
||||
SERVICE_POLICY,
|
||||
)
|
||||
|
||||
|
||||
def test_policy_covers_exactly_the_registered_services() -> None:
|
||||
assert frozenset(SERVICE_POLICY) == frozenset(DOMAIN_SERVICES)
|
||||
|
||||
|
||||
def test_only_complete_quest_is_open() -> None:
|
||||
open_services = {
|
||||
name for name, access in SERVICE_POLICY.items() if access == OPEN
|
||||
}
|
||||
assert open_services == {SERVICE_COMPLETE_QUEST}
|
||||
assert all(
|
||||
access == ADMIN_ONLY
|
||||
for name, access in SERVICE_POLICY.items()
|
||||
if name != SERVICE_COMPLETE_QUEST
|
||||
)
|
||||
|
||||
|
||||
def test_policy_values_are_only_open_or_admin_only() -> None:
|
||||
assert set(SERVICE_POLICY.values()) <= {OPEN, ADMIN_ONLY}
|
||||
@@ -0,0 +1,593 @@
|
||||
"""Tests for NestQuest service declarations and domain-global handlers."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import voluptuous as vol
|
||||
from homeassistant.exceptions import HomeAssistantError, Unauthorized
|
||||
|
||||
from conftest import wire_entry_to_registry
|
||||
|
||||
from custom_components.nestquest import async_setup_entry, async_unload_entry
|
||||
from custom_components.nestquest.const import (
|
||||
CONF_ADMIN_USER_IDS,
|
||||
DOMAIN,
|
||||
DOMAIN_SERVICES,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
||||
SERVICE_CREATE_QUEST_DEFINITION,
|
||||
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
||||
SERVICE_EXPORT_HISTORY_CSV,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
SERVICE_REGENERATE,
|
||||
SERVICE_SET_PRESENCE_PATTERN,
|
||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
||||
SERVICE_UNCOMPLETE_QUEST,
|
||||
)
|
||||
from custom_components.nestquest.service_policy import ADMIN_ONLY, SERVICE_POLICY
|
||||
|
||||
SERVICES_PATH = (
|
||||
Path(__file__).parent.parent
|
||||
/ "custom_components"
|
||||
/ "nestquest"
|
||||
/ "services.yaml"
|
||||
)
|
||||
|
||||
|
||||
def _wire(entry, registry):
|
||||
return wire_entry_to_registry(entry, registry)
|
||||
|
||||
|
||||
ADMIN_ID = "admin-1"
|
||||
ADMIN_CTX = {"user_id": ADMIN_ID}
|
||||
|
||||
|
||||
def _make_admin_entry(make_entry):
|
||||
from custom_components.nestquest.const import CONF_ADMIN_USER_IDS
|
||||
|
||||
return make_entry(data={CONF_ADMIN_USER_IDS: [ADMIN_ID]})
|
||||
|
||||
|
||||
def _service_section(text: str, name: str) -> str:
|
||||
match = re.search(rf"^{name}:\s*$", text, re.MULTILINE)
|
||||
assert match is not None, f"services.yaml must declare {name!r}"
|
||||
section = text[match.end() :]
|
||||
next_service = re.search(r"^(?![#\s])\w+:\s*$", section, re.MULTILINE)
|
||||
if next_service is not None:
|
||||
section = section[: next_service.start()]
|
||||
return section
|
||||
|
||||
|
||||
def test_services_yaml_exists() -> None:
|
||||
assert SERVICES_PATH.exists(), f"services.yaml missing at {SERVICES_PATH}"
|
||||
|
||||
|
||||
def test_services_yaml_declares_every_canonical_service() -> None:
|
||||
text = SERVICES_PATH.read_text(encoding="utf-8")
|
||||
declared = re.findall(r"^(?![#\s])(\w+):\s*$", text, re.MULTILINE)
|
||||
assert declared == list(DOMAIN_SERVICES) or set(declared) == set(
|
||||
DOMAIN_SERVICES
|
||||
)
|
||||
for name in DOMAIN_SERVICES:
|
||||
section = _service_section(text, name)
|
||||
assert re.search(r"description:", section), (
|
||||
f"{name} must carry a description"
|
||||
)
|
||||
|
||||
|
||||
def test_services_yaml_declares_regenerate() -> None:
|
||||
text = SERVICES_PATH.read_text(encoding="utf-8")
|
||||
assert re.search(r"^regenerate:\s*$", text, re.MULTILINE), (
|
||||
"services.yaml must declare the 'regenerate' service"
|
||||
)
|
||||
|
||||
|
||||
def test_services_yaml_regenerate_has_description_and_no_fields() -> None:
|
||||
"""The regenerate service is documented with a clear description and no
|
||||
required fields (there is no ``fields`` block under it)."""
|
||||
text = SERVICES_PATH.read_text(encoding="utf-8")
|
||||
section = _service_section(text, "regenerate")
|
||||
assert re.search(r"description:", section), (
|
||||
"regenerate must carry a description"
|
||||
)
|
||||
assert "fields:" not in section, (
|
||||
"regenerate declares no required fields"
|
||||
)
|
||||
|
||||
|
||||
def test_services_yaml_other_services_declare_fields() -> None:
|
||||
text = SERVICES_PATH.read_text(encoding="utf-8")
|
||||
for name in DOMAIN_SERVICES:
|
||||
if name in (SERVICE_REGENERATE, SERVICE_EXPORT_HISTORY_CSV):
|
||||
continue
|
||||
section = _service_section(text, name)
|
||||
assert "fields:" in section, f"{name} must declare fields"
|
||||
|
||||
|
||||
async def test_all_canonical_services_registered_after_setup(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
entry = _wire(make_entry(), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
for service in DOMAIN_SERVICES:
|
||||
assert hass.services.has_service(DOMAIN, service), service
|
||||
|
||||
|
||||
async def test_unload_last_entry_removes_all_services(hass, make_entry) -> None:
|
||||
entry = _wire(make_entry(), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
assert await async_unload_entry(hass, entry) is True
|
||||
for service in DOMAIN_SERVICES:
|
||||
assert not hass.services.has_service(DOMAIN, service), service
|
||||
|
||||
|
||||
async def test_unload_one_of_two_entries_keeps_services(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
entry_a = _wire(make_entry(entry_id="entry_a"), hass.registry)
|
||||
entry_b = _wire(make_entry(entry_id="entry_b"), hass.registry)
|
||||
assert await async_setup_entry(hass, entry_a) is True
|
||||
assert await async_setup_entry(hass, entry_b) is True
|
||||
assert await async_unload_entry(hass, entry_a) is True
|
||||
for service in DOMAIN_SERVICES:
|
||||
assert hass.services.has_service(DOMAIN, service), service
|
||||
assert await async_unload_entry(hass, entry_b) is True
|
||||
for service in DOMAIN_SERVICES:
|
||||
assert not hass.services.has_service(DOMAIN, service), service
|
||||
|
||||
|
||||
async def test_manage_child_create_persists_child(hass, make_entry) -> None:
|
||||
from custom_components.nestquest.children import list_children
|
||||
|
||||
entry = _wire(_make_admin_entry(make_entry), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
{"action": "create", "display_name": "Ada"},
|
||||
context=ADMIN_CTX,
|
||||
)
|
||||
children = await list_children(entry.runtime_data.database)
|
||||
assert [child.display_name for child in children] == ["Ada"]
|
||||
|
||||
|
||||
async def test_create_and_set_quest_definition_active(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
from custom_components.nestquest.children import list_children
|
||||
from custom_components.nestquest.quest_definitions import (
|
||||
list_active_definitions,
|
||||
)
|
||||
from custom_components.nestquest.dao_rules import QuestDefinitionsDao
|
||||
|
||||
entry = _wire(_make_admin_entry(make_entry), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
{"action": "create", "display_name": "Ada"},
|
||||
context=ADMIN_CTX,
|
||||
)
|
||||
child = (await list_children(entry.runtime_data.database))[0]
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_CREATE_QUEST_DEFINITION,
|
||||
{
|
||||
"title": "Brush teeth",
|
||||
"rule": {"rule_type": "daily"},
|
||||
"assignee_child_ids": [child.id],
|
||||
"windows": ["morning"],
|
||||
},
|
||||
context=ADMIN_CTX,
|
||||
)
|
||||
active = await list_active_definitions(entry.runtime_data.database)
|
||||
assert len(active) == 1
|
||||
definition_id = active[0].definition.id
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
||||
{"definition_id": definition_id, "is_active": False},
|
||||
context=ADMIN_CTX,
|
||||
)
|
||||
assert await list_active_definitions(entry.runtime_data.database) == []
|
||||
stored = await QuestDefinitionsDao(entry.runtime_data.database).get(
|
||||
definition_id
|
||||
)
|
||||
assert stored is not None
|
||||
assert stored.is_active is False
|
||||
|
||||
|
||||
async def test_complete_and_uncomplete_quest_call_business_layer(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
import datetime
|
||||
|
||||
from custom_components.nestquest.children import list_children
|
||||
from custom_components.nestquest.completion import instance_state
|
||||
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
|
||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||
|
||||
entry = _wire(_make_admin_entry(make_entry), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
{"action": "create", "display_name": "Ada"},
|
||||
context=ADMIN_CTX,
|
||||
)
|
||||
child = (await list_children(entry.runtime_data.database))[0]
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_CREATE_QUEST_DEFINITION,
|
||||
{
|
||||
"title": "Brush teeth",
|
||||
"rule": {"rule_type": "daily"},
|
||||
"assignee_child_ids": [child.id],
|
||||
"windows": ["morning"],
|
||||
},
|
||||
context=ADMIN_CTX,
|
||||
)
|
||||
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
||||
today = datetime.date.today().isoformat()
|
||||
end = (
|
||||
datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
||||
).isoformat()
|
||||
records = await QuestInstancesDao(
|
||||
entry.runtime_data.database
|
||||
).list_by_date_range(child.id, today, end)
|
||||
assert records
|
||||
instance_id = records[0].id
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
{
|
||||
"instance_id": instance_id,
|
||||
"actor": "panel",
|
||||
"actor_child_id": child.id,
|
||||
},
|
||||
)
|
||||
assert (
|
||||
await instance_state(entry.runtime_data.database, instance_id)
|
||||
== "done"
|
||||
)
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_UNCOMPLETE_QUEST,
|
||||
{
|
||||
"instance_id": instance_id,
|
||||
"actor": "user",
|
||||
"actor_user_id": ADMIN_ID,
|
||||
},
|
||||
context=ADMIN_CTX,
|
||||
)
|
||||
assert (
|
||||
await instance_state(entry.runtime_data.database, instance_id)
|
||||
== "open"
|
||||
)
|
||||
|
||||
|
||||
async def test_set_presence_pattern_regenerates_child_instances(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
import datetime
|
||||
|
||||
from custom_components.nestquest.children import list_children
|
||||
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
|
||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||
|
||||
entry = _wire(_make_admin_entry(make_entry), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
{"action": "create", "display_name": "Ada"},
|
||||
context=ADMIN_CTX,
|
||||
)
|
||||
child = (await list_children(entry.runtime_data.database))[0]
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_CREATE_QUEST_DEFINITION,
|
||||
{
|
||||
"title": "Brush teeth",
|
||||
"rule": {"rule_type": "daily"},
|
||||
"assignee_child_ids": [child.id],
|
||||
"windows": ["morning"],
|
||||
},
|
||||
context=ADMIN_CTX,
|
||||
)
|
||||
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
||||
today = datetime.date.today().isoformat()
|
||||
end = (
|
||||
datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
||||
).isoformat()
|
||||
dao = QuestInstancesDao(entry.runtime_data.database)
|
||||
assert await dao.list_by_date_range(child.id, today, end)
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_SET_PRESENCE_PATTERN,
|
||||
{
|
||||
"child_id": child.id,
|
||||
"cycle_length_weeks": 1,
|
||||
"anchor_date": today,
|
||||
"pattern": {0: []},
|
||||
},
|
||||
context=ADMIN_CTX,
|
||||
)
|
||||
assert await dao.list_by_date_range(child.id, today, end) == []
|
||||
|
||||
|
||||
async def test_presence_override_create_and_delete_regenerate(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
import datetime
|
||||
|
||||
from custom_components.nestquest.children import list_children
|
||||
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
|
||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||
from custom_components.nestquest.dao_presence import PresenceOverridesDao
|
||||
|
||||
entry = _wire(_make_admin_entry(make_entry), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
{"action": "create", "display_name": "Ada"},
|
||||
context=ADMIN_CTX,
|
||||
)
|
||||
child = (await list_children(entry.runtime_data.database))[0]
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_CREATE_QUEST_DEFINITION,
|
||||
{
|
||||
"title": "Brush teeth",
|
||||
"rule": {"rule_type": "daily"},
|
||||
"assignee_child_ids": [child.id],
|
||||
"windows": ["morning"],
|
||||
},
|
||||
context=ADMIN_CTX,
|
||||
)
|
||||
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
||||
today = datetime.date.today()
|
||||
end = today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
||||
dao = QuestInstancesDao(entry.runtime_data.database)
|
||||
before = await dao.list_by_date_range(
|
||||
child.id, today.isoformat(), end.isoformat()
|
||||
)
|
||||
assert before
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
||||
{
|
||||
"child_id": child.id,
|
||||
"start_date": today.isoformat(),
|
||||
"end_date": end.isoformat(),
|
||||
"is_present": False,
|
||||
},
|
||||
context=ADMIN_CTX,
|
||||
)
|
||||
assert (
|
||||
await dao.list_by_date_range(
|
||||
child.id, today.isoformat(), end.isoformat()
|
||||
)
|
||||
== []
|
||||
)
|
||||
override = (
|
||||
await PresenceOverridesDao(
|
||||
entry.runtime_data.database
|
||||
).list_by_child_and_range(
|
||||
child.id, today.isoformat(), end.isoformat()
|
||||
)
|
||||
)[0]
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
||||
{"override_id": override.id},
|
||||
context=ADMIN_CTX,
|
||||
)
|
||||
after = await dao.list_by_date_range(
|
||||
child.id, today.isoformat(), end.isoformat()
|
||||
)
|
||||
assert len(after) == len(before)
|
||||
|
||||
|
||||
async def test_export_history_csv_raises_home_assistant_error(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
entry = _wire(_make_admin_entry(make_entry), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
with pytest.raises(HomeAssistantError, match="Feature 13"):
|
||||
await hass.services.call(
|
||||
DOMAIN, SERVICE_EXPORT_HISTORY_CSV, context=ADMIN_CTX
|
||||
)
|
||||
|
||||
|
||||
async def test_invalid_schema_data_surfaces_as_error(hass, make_entry) -> None:
|
||||
entry = _wire(make_entry(), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
with pytest.raises((vol.Invalid, ValueError, HomeAssistantError)):
|
||||
await hass.services.call(DOMAIN, SERVICE_COMPLETE_QUEST, {})
|
||||
with pytest.raises((vol.Invalid, ValueError, HomeAssistantError)):
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
{"instance_id": True, "actor": "panel", "actor_child_id": 1},
|
||||
)
|
||||
with pytest.raises((vol.Invalid, ValueError, HomeAssistantError)):
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
{"action": "create"},
|
||||
)
|
||||
|
||||
|
||||
async def test_admin_only_service_denies_non_admin(hass, make_entry) -> None:
|
||||
entry = _wire(_make_admin_entry(make_entry), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
with pytest.raises(Unauthorized):
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
{"action": "create", "display_name": "Ada"},
|
||||
context={"user_id": "kiosk-panel"},
|
||||
)
|
||||
|
||||
|
||||
async def test_admin_only_service_denies_missing_user_context(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
entry = _wire(_make_admin_entry(make_entry), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
with pytest.raises(Unauthorized):
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
{"action": "create", "display_name": "Ada"},
|
||||
)
|
||||
|
||||
|
||||
async def test_admin_only_service_denies_admin_on_empty_allowlist(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""Even the allowlisted id is denied when the allowlist is empty."""
|
||||
entry = _wire(make_entry(), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
with pytest.raises(Unauthorized):
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
{"action": "create", "display_name": "Ada"},
|
||||
context=ADMIN_CTX,
|
||||
)
|
||||
|
||||
|
||||
async def test_unauthorized_is_raised_before_handler_runs(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
from custom_components.nestquest.children import list_children
|
||||
|
||||
entry = _wire(_make_admin_entry(make_entry), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
with pytest.raises(Unauthorized):
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
{"action": "create", "display_name": "Ada"},
|
||||
context={"user_id": "kiosk-panel"},
|
||||
)
|
||||
assert await list_children(entry.runtime_data.database) == []
|
||||
|
||||
|
||||
_MINIMAL_PAYLOADS = {
|
||||
"uncomplete_quest": {"instance_id": 1, "actor": "user"},
|
||||
"create_quest_definition": {
|
||||
"title": "T",
|
||||
"rule": {"rule_type": "daily"},
|
||||
"assignee_child_ids": [1],
|
||||
"windows": ["morning"],
|
||||
},
|
||||
"update_quest_definition": {"definition_id": 1},
|
||||
"set_quest_definition_active": {"definition_id": 1, "is_active": True},
|
||||
"set_presence_pattern": {
|
||||
"child_id": 1,
|
||||
"cycle_length_weeks": 1,
|
||||
"anchor_date": "2026-09-19",
|
||||
"pattern": {0: [0]},
|
||||
},
|
||||
"create_presence_override": {
|
||||
"child_id": 1,
|
||||
"start_date": "2026-09-19",
|
||||
"end_date": "2026-09-19",
|
||||
"is_present": False,
|
||||
},
|
||||
"delete_presence_override": {"override_id": 1},
|
||||
"export_history_csv": {},
|
||||
"manage_child": {"action": "create", "display_name": "Ada"},
|
||||
"regenerate": {},
|
||||
}
|
||||
|
||||
_ADMIN_ONLY_SERVICES = [
|
||||
name for name, policy in SERVICE_POLICY.items() if policy == ADMIN_ONLY
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("service", _ADMIN_ONLY_SERVICES)
|
||||
async def test_admin_only_service_fails_closed_without_user_context(
|
||||
hass, make_entry, service
|
||||
) -> None:
|
||||
"""A call with no user_id is denied on every admin-only service."""
|
||||
entry = _wire(_make_admin_entry(make_entry), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
with pytest.raises(Unauthorized):
|
||||
await hass.services.call(
|
||||
DOMAIN, service, dict(_MINIMAL_PAYLOADS[service])
|
||||
)
|
||||
|
||||
|
||||
async def test_complete_quest_proceeds_without_user_context(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""The open service needs no user context: the kiosk panel taps a
|
||||
child profile and the call goes through with no HA user at all."""
|
||||
import datetime
|
||||
|
||||
from custom_components.nestquest.children import list_children
|
||||
from custom_components.nestquest.completion import instance_state
|
||||
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
|
||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||
|
||||
entry = _wire(_make_admin_entry(make_entry), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
{"action": "create", "display_name": "Ada"},
|
||||
context=ADMIN_CTX,
|
||||
)
|
||||
child = (await list_children(entry.runtime_data.database))[0]
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_CREATE_QUEST_DEFINITION,
|
||||
{
|
||||
"title": "Brush teeth",
|
||||
"rule": {"rule_type": "daily"},
|
||||
"assignee_child_ids": [child.id],
|
||||
"windows": ["morning"],
|
||||
},
|
||||
context=ADMIN_CTX,
|
||||
)
|
||||
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
||||
today = datetime.date.today().isoformat()
|
||||
end = (
|
||||
datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
||||
).isoformat()
|
||||
instance_id = (
|
||||
await QuestInstancesDao(
|
||||
entry.runtime_data.database
|
||||
).list_by_date_range(child.id, today, end)
|
||||
)[0].id
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
{"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id},
|
||||
)
|
||||
assert (
|
||||
await instance_state(entry.runtime_data.database, instance_id)
|
||||
== "done"
|
||||
)
|
||||
|
||||
|
||||
async def test_scheduled_internals_never_call_the_service_layer(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""The daily rollover and startup backfill run the materialization
|
||||
as direct function calls; they never widen the service gate."""
|
||||
import custom_components.nestquest as nestquest
|
||||
|
||||
assert not hasattr(nestquest, "_run_horizon_materialization_service")
|
||||
source = Path(nestquest.__file__).read_text(encoding="utf-8")
|
||||
assert "hass.services.call" not in source, (
|
||||
"scheduled internals must call the materialization directly, "
|
||||
"never through hass.services.call"
|
||||
)
|
||||
@@ -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
|
||||
@@ -0,0 +1,370 @@
|
||||
"""Timezone tests for the single HA-local date source in the generation path.
|
||||
|
||||
These pin the done-condition that every "today" read in the generation path
|
||||
derives from ``hass.config.time_zone`` — not the host clock and not UTC —
|
||||
and that the rolling horizon ``[today, today + DEFAULT_HORIZON_DAYS]`` and the
|
||||
no-past guard resolve the correct local dates around midnight and across the
|
||||
daylight-saving transitions.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
from types import SimpleNamespace
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
|
||||
from custom_components.nestquest.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.migrations import apply_migrations
|
||||
from custom_components.nestquest.quest_definitions import (
|
||||
assign_child,
|
||||
create_quest_definition,
|
||||
edit_quest_definition,
|
||||
)
|
||||
from custom_components.nestquest.recurrence import RuleType, ScheduleRule
|
||||
|
||||
NOW = "2026-09-14T12:00:00+00:00"
|
||||
NEW_YORK = "America/New_York"
|
||||
|
||||
|
||||
def _make_hass_mock():
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
hass = MagicMock()
|
||||
hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a)))
|
||||
return hass
|
||||
|
||||
|
||||
async def _prepare(path):
|
||||
database = NestQuestDatabase(_make_hass_mock())
|
||||
await database.open(path)
|
||||
await apply_migrations(database)
|
||||
return database
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.new_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
def _freeze_clock(
|
||||
monkeypatch, instant: datetime.datetime
|
||||
) -> tuple[datetime.datetime, list]:
|
||||
"""Freeze the integration's ``datetime`` clock to a fixed aware instant.
|
||||
|
||||
Only ``custom_components.nestquest``'s ``datetime`` binding is patched, so
|
||||
the generation path's "today" reads this instant (localized to whatever
|
||||
time zone it asks for) while every other module and the test harness keep
|
||||
the real clock. Returns ``(frozen, requested)`` where ``frozen`` is the
|
||||
instant normalized to UTC and ``requested`` is the list of ``tzinfo``
|
||||
objects the code under test passed to ``now`` — so a test can prove the
|
||||
generation path actually requested the configured HA time zone rather
|
||||
than silently reading UTC.
|
||||
"""
|
||||
import custom_components.nestquest as nestquest
|
||||
|
||||
frozen = instant.astimezone(datetime.timezone.utc)
|
||||
requested: list = []
|
||||
|
||||
class _FrozenDatetime(datetime.datetime):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
requested.append(tz)
|
||||
if tz is None:
|
||||
return frozen.replace(tzinfo=None)
|
||||
return frozen.astimezone(tz)
|
||||
|
||||
fake_module = SimpleNamespace(
|
||||
datetime=_FrozenDatetime,
|
||||
timedelta=datetime.timedelta,
|
||||
date=datetime.date,
|
||||
)
|
||||
monkeypatch.setattr(nestquest, "datetime", fake_module)
|
||||
return frozen, requested
|
||||
|
||||
|
||||
def _assert_requested_ha_timezone(requested, time_zone: str) -> None:
|
||||
"""Assert ``now`` was called with (only) the configured HA time zone.
|
||||
|
||||
This is what turns the DST tests into a real proof of the HA-local date
|
||||
source: if the generation path regressed to ``now(timezone.utc)``, its
|
||||
requested tz would be UTC and this fails.
|
||||
"""
|
||||
assert requested, "the generation path never called now(tz)"
|
||||
assert all(tz == ZoneInfo(time_zone) for tz in requested)
|
||||
|
||||
|
||||
def _tz_hass(time_zone: str):
|
||||
"""A minimal hass exposing only the ``config.time_zone`` the walk reads."""
|
||||
return SimpleNamespace(config=SimpleNamespace(time_zone=time_zone))
|
||||
|
||||
|
||||
async def _seed_daily(database, start_iso: str) -> int:
|
||||
"""One always-present child + a daily definition starting ``start_iso``."""
|
||||
child = await ChildrenDao(database).create("Ada", NOW)
|
||||
await create_quest_definition(
|
||||
database,
|
||||
"Daily chore",
|
||||
ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso),
|
||||
[child.id],
|
||||
["morning"],
|
||||
)
|
||||
return child.id
|
||||
|
||||
|
||||
def _local_date(instant_utc: datetime.datetime, time_zone: str) -> datetime.date:
|
||||
return instant_utc.astimezone(ZoneInfo(time_zone)).date()
|
||||
|
||||
|
||||
def _assert_contiguous_horizon(records, local_today: datetime.date) -> None:
|
||||
"""The horizon is exactly today..today+horizon, no off-by-one, no gap."""
|
||||
expected = {
|
||||
(local_today + datetime.timedelta(days=offset)).isoformat()
|
||||
for offset in range(DEFAULT_HORIZON_DAYS + 1)
|
||||
}
|
||||
assert {r.due_date for r in records} == expected
|
||||
assert len(records) == DEFAULT_HORIZON_DAYS + 1
|
||||
|
||||
|
||||
def test_horizon_uses_newyork_local_date_after_local_midnight(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
"""A run just after New York local midnight uses the local date.
|
||||
|
||||
The frozen instant is 00:05 New York local time on 2026-03-10 (EDT, so
|
||||
04:05 UTC). The horizon must start on 2026-03-10 — the local calendar
|
||||
date — not any host/UTC-derived value.
|
||||
"""
|
||||
instant = datetime.datetime(
|
||||
2026, 3, 10, 0, 5, tzinfo=ZoneInfo(NEW_YORK)
|
||||
)
|
||||
local_today = instant.date()
|
||||
|
||||
async def _body(database):
|
||||
hass = _tz_hass(NEW_YORK)
|
||||
_frozen, requested = _freeze_clock(monkeypatch, instant)
|
||||
import custom_components.nestquest as nestquest
|
||||
|
||||
child_id = await _seed_daily(database, local_today.isoformat())
|
||||
await nestquest._run_horizon_materialization(hass, database)
|
||||
records = await QuestInstancesDao(database).list_by_date_range(
|
||||
child_id,
|
||||
local_today.isoformat(),
|
||||
(local_today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat(),
|
||||
)
|
||||
_assert_contiguous_horizon(records, local_today)
|
||||
_assert_requested_ha_timezone(requested, NEW_YORK)
|
||||
return len(records)
|
||||
|
||||
async def _main():
|
||||
database = await _prepare(tmp_path / "tz-midnight.db")
|
||||
try:
|
||||
return await _body(database)
|
||||
finally:
|
||||
await database.close()
|
||||
|
||||
assert _run(_main()) == DEFAULT_HORIZON_DAYS + 1
|
||||
|
||||
|
||||
def test_horizon_uses_local_date_not_utc_when_host_ahead(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
"""The walk's "today" is HA-local, never the UTC/system calendar date.
|
||||
|
||||
FROZEN at 2026-01-15 03:00 UTC: the UTC calendar date is 2026-01-15, but
|
||||
New York local is still 2026-01-14 22:00 EST. The horizon must start on
|
||||
2026-01-14 (local), proving the UTC date did not win.
|
||||
"""
|
||||
instant = datetime.datetime(2026, 1, 15, 3, 0, tzinfo=datetime.timezone.utc)
|
||||
local_today = _local_date(instant, NEW_YORK)
|
||||
assert local_today == datetime.date(2026, 1, 14)
|
||||
assert instant.date() == datetime.date(2026, 1, 15)
|
||||
|
||||
async def _body(database):
|
||||
hass = _tz_hass(NEW_YORK)
|
||||
_frozen, requested = _freeze_clock(monkeypatch, instant)
|
||||
import custom_components.nestquest as nestquest
|
||||
|
||||
child_id = await _seed_daily(database, local_today.isoformat())
|
||||
await nestquest._run_horizon_materialization(hass, database)
|
||||
records = await QuestInstancesDao(database).list_by_date_range(
|
||||
child_id,
|
||||
local_today.isoformat(),
|
||||
(local_today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat(),
|
||||
)
|
||||
_assert_contiguous_horizon(records, local_today)
|
||||
_assert_requested_ha_timezone(requested, NEW_YORK)
|
||||
return len(records)
|
||||
|
||||
async def _main():
|
||||
database = await _prepare(tmp_path / "tz-host-ahead.db")
|
||||
try:
|
||||
return await _body(database)
|
||||
finally:
|
||||
await database.close()
|
||||
|
||||
assert _run(_main()) == DEFAULT_HORIZON_DAYS + 1
|
||||
|
||||
|
||||
def test_horizon_resolves_spring_forward_day_without_off_by_one(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
"""Across the 2026-03-08 spring-forward, today and the horizon stay local.
|
||||
|
||||
Before the jump ("01:30 EST") and after it ("03:30 EDT") both resolve to
|
||||
2026-03-08 local — the lost hour must not skip or repeat a calendar day —
|
||||
and the full horizon remains 15 contiguous days.
|
||||
"""
|
||||
zone = ZoneInfo(NEW_YORK)
|
||||
before = datetime.datetime(2026, 3, 8, 1, 30, tzinfo=zone) # EST
|
||||
after = datetime.datetime(2026, 3, 8, 3, 30, tzinfo=zone) # EDT
|
||||
assert before.date() == after.date() == datetime.date(2026, 3, 8)
|
||||
|
||||
async def _body(database):
|
||||
hass = _tz_hass(NEW_YORK)
|
||||
import custom_components.nestquest as nestquest
|
||||
|
||||
requested_batches: list[list] = []
|
||||
for instant in (before, after):
|
||||
_frozen, requested = _freeze_clock(monkeypatch, instant)
|
||||
requested_batches.append(requested)
|
||||
local_today = instant.date()
|
||||
child_id = await _seed_daily(database, local_today.isoformat())
|
||||
await nestquest._run_horizon_materialization(hass, database)
|
||||
records = await QuestInstancesDao(database).list_by_date_range(
|
||||
child_id,
|
||||
local_today.isoformat(),
|
||||
(local_today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat(),
|
||||
)
|
||||
_assert_contiguous_horizon(records, local_today)
|
||||
requested_all = [tz for batch in requested_batches for tz in batch]
|
||||
_assert_requested_ha_timezone(requested_all, NEW_YORK)
|
||||
return local_today
|
||||
|
||||
async def _main():
|
||||
database = await _prepare(tmp_path / "tz-spring-forward.db")
|
||||
try:
|
||||
return await _body(database)
|
||||
finally:
|
||||
await database.close()
|
||||
|
||||
assert _run(_main()) == datetime.date(2026, 3, 8)
|
||||
|
||||
|
||||
def test_horizon_resolves_fall_back_day_without_off_by_one(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
"""Across the 2026-11-01 fall-back, today and the horizon stay local.
|
||||
|
||||
The repeated 01:00-02:00 hour must not resolve to the wrong calendar
|
||||
day: the first 01:30 (``fold=0``, EDT) and the repeated 01:30
|
||||
(``fold=1``, EST) are distinct instants yet both resolve to 2026-11-01
|
||||
— and both, along with 03:30 EST after the jump, materialize the same
|
||||
15 contiguous-day horizon with no off-by-one.
|
||||
"""
|
||||
zone = ZoneInfo(NEW_YORK)
|
||||
first = datetime.datetime(2026, 11, 1, 1, 30, tzinfo=zone, fold=0) # EDT
|
||||
second = datetime.datetime(2026, 11, 1, 1, 30, tzinfo=zone, fold=1) # EST
|
||||
after = datetime.datetime(2026, 11, 1, 3, 30, tzinfo=zone) # EST
|
||||
assert first.date() == second.date() == after.date() == datetime.date(2026, 11, 1)
|
||||
assert first.utcoffset() != second.utcoffset() # the two repeated-hour folds
|
||||
|
||||
async def _body(database):
|
||||
hass = _tz_hass(NEW_YORK)
|
||||
import custom_components.nestquest as nestquest
|
||||
|
||||
requested_batches: list[list] = []
|
||||
for instant in (first, second, after):
|
||||
_frozen, requested = _freeze_clock(monkeypatch, instant)
|
||||
requested_batches.append(requested)
|
||||
local_today = instant.date()
|
||||
child_id = await _seed_daily(database, local_today.isoformat())
|
||||
await nestquest._run_horizon_materialization(hass, database)
|
||||
records = await QuestInstancesDao(database).list_by_date_range(
|
||||
child_id,
|
||||
local_today.isoformat(),
|
||||
(local_today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat(),
|
||||
)
|
||||
_assert_contiguous_horizon(records, local_today)
|
||||
requested_all = [tz for batch in requested_batches for tz in batch]
|
||||
_assert_requested_ha_timezone(requested_all, NEW_YORK)
|
||||
return local_today
|
||||
|
||||
async def _main():
|
||||
database = await _prepare(tmp_path / "tz-fall-back.db")
|
||||
try:
|
||||
return await _body(database)
|
||||
finally:
|
||||
await database.close()
|
||||
|
||||
assert _run(_main()) == datetime.date(2026, 11, 1)
|
||||
|
||||
|
||||
def test_definition_business_regeneration_uses_pinned_today_not_host(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
"""``edit_quest_definition``/``assign_child`` anchor regeneration on
|
||||
the caller-pinned HA-local ``today``, not the host clock.
|
||||
|
||||
The host clock (the ``_today`` fallback) is pinned one day AHEAD of the
|
||||
HA-local ``today``; the guard would reject the local "today" as past
|
||||
(and the horizon would skip it) if it consulted the host clock. Both
|
||||
business operations must regenerate against the pinned date and keep
|
||||
that first local day, proving the threaded ``today`` wins.
|
||||
"""
|
||||
import custom_components.nestquest.dao_instances as dao_instances_module
|
||||
|
||||
pinned = datetime.date(2026, 1, 14)
|
||||
host_today = datetime.date(2026, 1, 15)
|
||||
monkeypatch.setattr(dao_instances_module, "_today", lambda: host_today)
|
||||
|
||||
async def _body(database):
|
||||
children = ChildrenDao(database)
|
||||
dao = QuestInstancesDao(database)
|
||||
horizon_end = pinned + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
||||
|
||||
a = await children.create("Ada", NOW)
|
||||
created = await create_quest_definition(
|
||||
database,
|
||||
"Daily chore",
|
||||
ScheduleRule(rule_type=RuleType.DAILY, start_date=pinned.isoformat()),
|
||||
[a.id],
|
||||
[("morning", "09:00")],
|
||||
)
|
||||
definition_id = created.definition.id
|
||||
|
||||
# edit anchors on pinned: the horizon starts at the local day the
|
||||
# host clock calls "past", so its presence proves pinned won.
|
||||
await edit_quest_definition(
|
||||
database,
|
||||
definition_id,
|
||||
windows=[("morning", "10:15")],
|
||||
today=pinned,
|
||||
)
|
||||
a_records = await dao.list_by_date_range(
|
||||
a.id, pinned.isoformat(), horizon_end.isoformat()
|
||||
)
|
||||
assert [r.due_date for r in a_records][0] == pinned.isoformat()
|
||||
assert {r.due_time for r in a_records} == {"10:15"}
|
||||
assert len(a_records) == DEFAULT_HORIZON_DAYS + 1
|
||||
|
||||
# assign anchors on pinned too: the new child's regeneration keeps
|
||||
# the same pinned-local first day rather than skipping to the host day.
|
||||
b = await children.create("Bo", NOW)
|
||||
await assign_child(database, definition_id, b.id, today=pinned)
|
||||
b_records = await dao.list_by_date_range(
|
||||
b.id, pinned.isoformat(), horizon_end.isoformat()
|
||||
)
|
||||
assert [r.due_date for r in b_records][0] == pinned.isoformat()
|
||||
assert len(b_records) == DEFAULT_HORIZON_DAYS + 1
|
||||
return definition_id
|
||||
|
||||
async def _main():
|
||||
database = await _prepare(tmp_path / "tz-definition-regen.db")
|
||||
try:
|
||||
return await _body(database)
|
||||
finally:
|
||||
await database.close()
|
||||
|
||||
assert _run(_main()) is not None
|
||||
@@ -20,6 +20,92 @@ 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"
|
||||
@@ -27,8 +113,10 @@ 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