Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,35 @@ 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.payload.child_name == 'Declan' }}"
|
||||
actions:
|
||||
- action: notify.mobile_app_declans_phone
|
||||
data:
|
||||
message: "Every quest cleared — legendary!"
|
||||
```
|
||||
|
||||
## Developer Setup and Testing
|
||||
|
||||
This project uses [`uv`](https://github.com/astral-sh/uv) for fast Python package and dependency management.
|
||||
|
||||
@@ -2,20 +2,37 @@
|
||||
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
|
||||
|
||||
@@ -224,6 +241,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 +267,141 @@ 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 materialization listener and return its remover.
|
||||
|
||||
Fires once per day at the configured local ``day_rollover_time`` and
|
||||
materializes the rolling horizon ``[today, today + horizon_days]``
|
||||
through the shared :func:`_run_horizon_materialization` path, using the
|
||||
entry's configured (validated) ``horizon_days``.
|
||||
"""
|
||||
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)
|
||||
|
||||
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 +459,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 +487,81 @@ 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))
|
||||
)
|
||||
_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 +570,68 @@ 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:
|
||||
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
|
||||
# Tear the shared coordinator down after the DB close: no
|
||||
# listener may outlive the connection it reads.
|
||||
coordinator = getattr(runtime_data, "coordinator", None)
|
||||
if coordinator is not None:
|
||||
shutdown = getattr(coordinator, "async_shutdown", None)
|
||||
if shutdown is not None:
|
||||
await shutdown()
|
||||
# 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,534 @@
|
||||
"""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.
|
||||
"""
|
||||
|
||||
appended: bool
|
||||
|
||||
def __new__(cls, state: str, *, appended: bool) -> "CompletionResult":
|
||||
obj = super().__new__(cls, state)
|
||||
obj.appended = appended
|
||||
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}"
|
||||
)
|
||||
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=_was_on_time(instance, now, today),
|
||||
now=now,
|
||||
)
|
||||
return CompletionResult("done", appended=True)
|
||||
|
||||
|
||||
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,13 @@ 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
|
||||
|
||||
# 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,
|
||||
@@ -336,7 +494,7 @@ class QuestInstancesDao:
|
||||
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 +506,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 +520,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 +532,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.
|
||||
|
||||
@@ -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
|
||||
@@ -951,6 +951,21 @@ class QuestDefinitionsDao:
|
||||
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]:
|
||||
@@ -1189,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,128 @@
|
||||
"""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
|
||||
) -> 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; a no-op re-complete never reaches here.
|
||||
"""
|
||||
now_stamp = _now_stamp()
|
||||
payload = await _instance_payload(database, instance_id, now_stamp)
|
||||
latest = await CompletionEventsDao(database).get_latest_for_instance(
|
||||
instance_id
|
||||
)
|
||||
payload["was_on_time"] = (
|
||||
latest.was_on_time if latest is not None else None
|
||||
)
|
||||
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
|
||||
)
|
||||
@@ -14,10 +14,13 @@ from .const import (
|
||||
CONF_DAY_ROLLOVER_TIME,
|
||||
CONF_HORIZON_DAYS,
|
||||
CONF_PANEL_IDLE_TIMEOUT,
|
||||
CONF_UPDATE_INTERVAL,
|
||||
DEFAULT_DAY_ROLLOVER_TIME,
|
||||
DEFAULT_HORIZON_DAYS,
|
||||
DEFAULT_PANEL_IDLE_TIMEOUT,
|
||||
DEFAULT_UPDATE_INTERVAL,
|
||||
DOMAIN,
|
||||
MIN_UPDATE_INTERVAL,
|
||||
)
|
||||
from .admin_allowlist import list_admin_ids, set_admin_ids
|
||||
|
||||
@@ -56,6 +59,18 @@ 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"
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
@@ -82,6 +97,9 @@ 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,
|
||||
}
|
||||
if admin_choices is not None:
|
||||
# HA's supported multi-select validator: the frontend can
|
||||
@@ -210,6 +228,10 @@ 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],
|
||||
),
|
||||
}
|
||||
if admin_ids_present:
|
||||
data[CONF_ADMIN_USER_IDS] = admin_ids
|
||||
@@ -231,6 +253,7 @@ 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),
|
||||
):
|
||||
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
|
||||
@@ -58,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
|
||||
|
||||
|
||||
@@ -270,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.
|
||||
|
||||
@@ -291,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)
|
||||
|
||||
@@ -341,6 +354,10 @@ async def edit_quest_definition(
|
||||
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,
|
||||
@@ -353,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).
|
||||
|
||||
@@ -366,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.
|
||||
@@ -381,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.
|
||||
|
||||
@@ -396,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)
|
||||
@@ -405,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.
|
||||
|
||||
@@ -434,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):
|
||||
@@ -457,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,
|
||||
|
||||
@@ -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,631 @@
|
||||
"""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)
|
||||
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:
|
||||
+454
-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,68 @@ 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.
|
||||
hass.entities: dict[str, Entity] = {}
|
||||
|
||||
def _async_add_entities(new_entities, update_before_add=False):
|
||||
"""Mirror HA's AddEntitiesCallback: bind hass and register entities.
|
||||
|
||||
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).
|
||||
"""
|
||||
if isinstance(new_entities, Entity):
|
||||
new_entities = [new_entities]
|
||||
for entity in new_entities:
|
||||
entity.hass = hass
|
||||
entity.async_write_ha_state()
|
||||
|
||||
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
|
||||
the AddEntitiesCallback above. 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, _async_add_entities)
|
||||
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).
|
||||
"""
|
||||
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)
|
||||
|
||||
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",
|
||||
}
|
||||
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,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
@@ -18,6 +18,8 @@ from custom_components.nestquest.const import (
|
||||
CONF_DAY_ROLLOVER_TIME,
|
||||
CONF_HORIZON_DAYS,
|
||||
CONF_PANEL_IDLE_TIMEOUT,
|
||||
CONF_UPDATE_INTERVAL,
|
||||
DEFAULT_UPDATE_INTERVAL,
|
||||
DEFAULT_DAY_ROLLOVER_TIME,
|
||||
DEFAULT_HORIZON_DAYS,
|
||||
DEFAULT_PANEL_IDLE_TIMEOUT,
|
||||
@@ -129,7 +131,10 @@ 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"] == {
|
||||
**VALID_INPUT,
|
||||
CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL,
|
||||
}
|
||||
|
||||
|
||||
def test_options_flow_shows_form_initially() -> None:
|
||||
@@ -208,6 +213,7 @@ 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,
|
||||
}
|
||||
)
|
||||
result = _run(_make_flow(entry).async_step_init(None))
|
||||
@@ -215,6 +221,7 @@ 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_ADMIN_USER_IDS: [],
|
||||
}
|
||||
|
||||
@@ -282,8 +289,8 @@ 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"
|
||||
@@ -292,8 +299,12 @@ def test_options_flow_creates_entry_with_three_keys() -> None:
|
||||
CONF_HORIZON_DAYS,
|
||||
CONF_DAY_ROLLOVER_TIME,
|
||||
CONF_PANEL_IDLE_TIMEOUT,
|
||||
CONF_UPDATE_INTERVAL,
|
||||
}
|
||||
assert result["data"] == {
|
||||
**VALID_INPUT,
|
||||
CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL,
|
||||
}
|
||||
assert result["data"] == VALID_INPUT
|
||||
|
||||
|
||||
def test_options_flow_create_entry_data_lands_in_entry_options_and_reloads_once() -> None:
|
||||
@@ -316,7 +327,10 @@ 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 == {
|
||||
**VALID_INPUT,
|
||||
CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL,
|
||||
}
|
||||
|
||||
|
||||
def test_options_flow_validation_never_raises() -> None:
|
||||
|
||||
@@ -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]
|
||||
@@ -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,
|
||||
@@ -1279,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"]
|
||||
@@ -1310,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"
|
||||
)
|
||||
@@ -1332,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(
|
||||
@@ -1841,16 +1843,15 @@ def test_list_active_definitions_snapshot_coherent_under_racing_edit(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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(
|
||||
@@ -1876,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.
|
||||
@@ -1914,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)
|
||||
|
||||
@@ -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,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
|
||||
Reference in New Issue
Block a user