Compare commits
37
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 |
@@ -38,6 +38,35 @@ After installation and restart:
|
|||||||
2. Click **Add Integration** and search for **NestQuest**.
|
2. Click **Add Integration** and search for **NestQuest**.
|
||||||
3. Follow the on-screen configuration flow.
|
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
|
## Developer Setup and Testing
|
||||||
|
|
||||||
This project uses [`uv`](https://github.com/astral-sh/uv) for fast Python package and dependency management.
|
This project uses [`uv`](https://github.com/astral-sh/uv) for fast Python package and dependency management.
|
||||||
|
|||||||
@@ -20,15 +20,19 @@ from .const import (
|
|||||||
CONF_ADMIN_USER_IDS,
|
CONF_ADMIN_USER_IDS,
|
||||||
CONF_DAY_ROLLOVER_TIME,
|
CONF_DAY_ROLLOVER_TIME,
|
||||||
CONF_HORIZON_DAYS,
|
CONF_HORIZON_DAYS,
|
||||||
|
CONF_UPDATE_INTERVAL,
|
||||||
DEFAULT_DAY_ROLLOVER_TIME,
|
DEFAULT_DAY_ROLLOVER_TIME,
|
||||||
DEFAULT_HORIZON_DAYS,
|
DEFAULT_HORIZON_DAYS,
|
||||||
|
DEFAULT_UPDATE_INTERVAL,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
LOGGER,
|
LOGGER,
|
||||||
SERVICE_REGENERATE,
|
PLATFORMS,
|
||||||
)
|
)
|
||||||
|
from .coordinator import NestQuestCoordinator
|
||||||
from .db import NestQuestDatabase
|
from .db import NestQuestDatabase
|
||||||
from .materialize import materialize as _materialize_run
|
from .materialize import materialize as _materialize_run
|
||||||
from .migrations import apply_migrations
|
from .migrations import apply_migrations
|
||||||
|
from .services import async_deregister_services, async_register_services
|
||||||
from .store import async_get_db_path
|
from .store import async_get_db_path
|
||||||
from .admin_allowlist import seed_setup_admin
|
from .admin_allowlist import seed_setup_admin
|
||||||
|
|
||||||
@@ -238,6 +242,7 @@ class NestQuestRuntimeData:
|
|||||||
database: NestQuestDatabase
|
database: NestQuestDatabase
|
||||||
remove_update_listener: Callable[[], Any]
|
remove_update_listener: Callable[[], Any]
|
||||||
remove_time_change_listener: Callable[[], Any]
|
remove_time_change_listener: Callable[[], Any]
|
||||||
|
coordinator: Any = None
|
||||||
|
|
||||||
|
|
||||||
async def _async_owner_user_ids(hass: HomeAssistant) -> list[str]:
|
async def _async_owner_user_ids(hass: HomeAssistant) -> list[str]:
|
||||||
@@ -356,21 +361,8 @@ def _find_live_runtime_data(hass: HomeAssistant) -> NestQuestRuntimeData | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _register_regenerate_service(hass: HomeAssistant) -> None:
|
def _make_regenerate_handler(hass: HomeAssistant):
|
||||||
"""Register ``nestquest.regenerate`` once at domain scope.
|
"""Return the Feature 07 ``regenerate`` handler closed over ``hass``."""
|
||||||
|
|
||||||
The service triggers a full on-demand regeneration over the rolling
|
|
||||||
horizon, running the SAME idempotent materialize path
|
|
||||||
(:func:`_run_horizon_materialization`) as the startup backfill and the
|
|
||||||
daily rollover listener. It is DOMAIN-global — registered exactly once
|
|
||||||
(guarded by ``has_service``) and NOT bound to any config entry — so the
|
|
||||||
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_regenerate_service`).
|
|
||||||
"""
|
|
||||||
if hass.services.has_service(DOMAIN, SERVICE_REGENERATE):
|
|
||||||
return
|
|
||||||
|
|
||||||
async def _regenerate(_call: Any) -> None:
|
async def _regenerate(_call: Any) -> None:
|
||||||
runtime_data = _find_live_runtime_data(hass)
|
runtime_data = _find_live_runtime_data(hass)
|
||||||
@@ -385,13 +377,29 @@ def _register_regenerate_service(hass: HomeAssistant) -> None:
|
|||||||
hass, runtime_data.database, horizon_days
|
hass, runtime_data.database, horizon_days
|
||||||
)
|
)
|
||||||
|
|
||||||
hass.services.async_register(DOMAIN, SERVICE_REGENERATE, _regenerate)
|
return _regenerate
|
||||||
|
|
||||||
|
|
||||||
def _deregister_regenerate_service(hass: HomeAssistant) -> None:
|
def _register_services(hass: HomeAssistant) -> None:
|
||||||
"""Remove ``nestquest.regenerate`` when the last entry unloads."""
|
"""Register domain-global NestQuest services once.
|
||||||
if hass.services.has_service(DOMAIN, SERVICE_REGENERATE):
|
|
||||||
hass.services.async_remove(DOMAIN, SERVICE_REGENERATE)
|
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:
|
async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
|
||||||
@@ -453,6 +461,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
database = await _async_open_database(hass, await async_get_db_path(hass))
|
database = await _async_open_database(hass, await async_get_db_path(hass))
|
||||||
remove_update_listener: Callable[[], Any] | None = None
|
remove_update_listener: Callable[[], Any] | None = None
|
||||||
remove_time_change_listener: Callable[[], Any] | None = None
|
remove_time_change_listener: Callable[[], Any] | None = None
|
||||||
|
coordinator: Any = None
|
||||||
try:
|
try:
|
||||||
# Seed the admin allowlist on first setup so the owner is never
|
# Seed the admin allowlist on first setup so the owner is never
|
||||||
# locked out: an empty database takes the persisted admin copy
|
# locked out: an empty database takes the persisted admin copy
|
||||||
@@ -489,7 +498,34 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
await _run_horizon_materialization(
|
await _run_horizon_materialization(
|
||||||
hass, database, _configured_horizon_days(dict(entry.options))
|
hass, database, _configured_horizon_days(dict(entry.options))
|
||||||
)
|
)
|
||||||
_register_regenerate_service(hass)
|
_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:
|
except BaseException:
|
||||||
# A failure anywhere after the database is open must not leak the
|
# A failure anywhere after the database is open must not leak the
|
||||||
# listeners already registered against that (now-closed) database:
|
# listeners already registered against that (now-closed) database:
|
||||||
@@ -508,18 +544,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
await result
|
await result
|
||||||
except BaseException:
|
except BaseException:
|
||||||
pass
|
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()
|
await database.close()
|
||||||
raise
|
raise
|
||||||
assert remove_update_listener is not None
|
assert remove_update_listener is not None
|
||||||
assert remove_time_change_listener is not None
|
assert remove_time_change_listener is not None
|
||||||
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,
|
|
||||||
)
|
|
||||||
entry.runtime_data = runtime_data
|
|
||||||
hass.data[DOMAIN][entry.entry_id] = runtime_data
|
hass.data[DOMAIN][entry.entry_id] = runtime_data
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -533,6 +575,20 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
hass.data.pop(DOMAIN, None)
|
hass.data.pop(DOMAIN, None)
|
||||||
if runtime_data is None:
|
if runtime_data is None:
|
||||||
runtime_data = getattr(entry, "runtime_data", 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
|
unload_error: BaseException | None = None
|
||||||
if runtime_data is not None:
|
if runtime_data is not None:
|
||||||
remove_update_listener = getattr(runtime_data, "remove_update_listener", None)
|
remove_update_listener = getattr(runtime_data, "remove_update_listener", None)
|
||||||
@@ -562,11 +618,18 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
database = getattr(runtime_data, "database", None)
|
database = getattr(runtime_data, "database", None)
|
||||||
if database is not None:
|
if database is not None:
|
||||||
await database.close()
|
await database.close()
|
||||||
# The ``regenerate`` service is DOMAIN-global: it is torn down only
|
# Tear the shared coordinator down after the DB close: no
|
||||||
# once the FINAL entry unloads (``hass.data[DOMAIN]`` is now empty),
|
# listener may outlive the connection it reads.
|
||||||
# never when a sibling entry is still loaded.
|
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:
|
if is_last:
|
||||||
_deregister_regenerate_service(hass)
|
_deregister_services(hass)
|
||||||
if getattr(entry, "runtime_data", None) is not None:
|
if getattr(entry, "runtime_data", None) is not None:
|
||||||
entry.runtime_data = None
|
entry.runtime_data = None
|
||||||
if unload_error is not None:
|
if unload_error is not None:
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -6,9 +6,12 @@ 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
|
DAO or SQL directly. This module is the only Feature-08 caller of
|
||||||
:meth:`~.dao_instances.CompletionEventsDao.append`. Completing an
|
:meth:`~.dao_instances.CompletionEventsDao.append`. Completing an
|
||||||
instance (no-op if already done) is :func:`complete_instance`;
|
instance (no-op if already done) is :func:`complete_instance`;
|
||||||
un-completing arrives in a later task. Instance current state is
|
un-completing (no-op if already open) is :func:`uncomplete_instance`.
|
||||||
derived from the latest event via :func:`instance_state`
|
Instance current state is derived from the latest event via
|
||||||
(``open`` / ``done``); there is no status column.
|
: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
|
Actor policy matches the schema CHECKs (D-008): ``actor_source`` is
|
||||||
``'user'`` or ``'panel'`` only — never ``'service'``. ``'user'``
|
``'user'`` or ``'panel'`` only — never ``'service'``. ``'user'``
|
||||||
@@ -34,6 +37,7 @@ from .dao_instances import (
|
|||||||
EVENT_UNCOMPLETED,
|
EVENT_UNCOMPLETED,
|
||||||
CompletionEventRecord,
|
CompletionEventRecord,
|
||||||
CompletionEventsDao,
|
CompletionEventsDao,
|
||||||
|
QuestInstanceRecord,
|
||||||
QuestInstancesDao,
|
QuestInstancesDao,
|
||||||
)
|
)
|
||||||
from .db import NestQuestDatabase
|
from .db import NestQuestDatabase
|
||||||
@@ -63,6 +67,110 @@ def _stamp_occurred_at(now: datetime.datetime | None) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
class _TaskReentrantLock:
|
||||||
"""Shared connection lock the owning task may re-enter."""
|
"""Shared connection lock the owning task may re-enter."""
|
||||||
|
|
||||||
@@ -104,9 +212,10 @@ def _reentrant_connection_lock(database) -> _TaskReentrantLock:
|
|||||||
"""Return the shared connection lock, made task-reentrant.
|
"""Return the shared connection lock, made task-reentrant.
|
||||||
|
|
||||||
``CompletionEventsDao.append`` also acquires this lock, so
|
``CompletionEventsDao.append`` also acquires this lock, so
|
||||||
check+append in :func:`complete_instance` must re-enter on the
|
check+append in :func:`complete_instance` and
|
||||||
same task. The wrapper is stored in the shared dict so the DAO
|
:func:`uncomplete_instance` must re-enter on the same task. The
|
||||||
sees the same object.
|
wrapper is stored in the shared dict so the DAO sees the same
|
||||||
|
object.
|
||||||
"""
|
"""
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
key = (id(database), id(loop))
|
key = (id(database), id(loop))
|
||||||
@@ -230,15 +339,21 @@ async def append_event(
|
|||||||
async def instance_state(
|
async def instance_state(
|
||||||
database: NestQuestDatabase,
|
database: NestQuestDatabase,
|
||||||
instance_id: int,
|
instance_id: int,
|
||||||
|
*,
|
||||||
|
today: datetime.date | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Return ``open`` or ``done`` from the instance's latest event.
|
"""Return ``open``, ``done``, or ``missed`` from the latest event.
|
||||||
|
|
||||||
No events, or a latest event of uncompleted, is ``open``. A latest
|
No events, or a latest event of uncompleted, is ``open`` unless
|
||||||
event of completed is ``done``. Unknown ``instance_id`` raises
|
``due_date`` is before ``today`` (HA-local, threaded), in which
|
||||||
ValueError naming the field. Existence and the latest event are
|
case it is ``missed``. A latest event of completed is ``done``
|
||||||
read inside the connection lock so the answer is one snapshot.
|
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")
|
instance_id = _validate_int_id(instance_id, "instance_id")
|
||||||
|
today_date = _resolve_today(today)
|
||||||
async with _connection_lock(database):
|
async with _connection_lock(database):
|
||||||
instance = await QuestInstancesDao(database).get_by_id(instance_id)
|
instance = await QuestInstancesDao(database).get_by_id(instance_id)
|
||||||
if instance is None:
|
if instance is None:
|
||||||
@@ -248,14 +363,26 @@ async def instance_state(
|
|||||||
latest = await CompletionEventsDao(database).get_latest_for_instance(
|
latest = await CompletionEventsDao(database).get_latest_for_instance(
|
||||||
instance_id
|
instance_id
|
||||||
)
|
)
|
||||||
if latest is None or latest.event_type == EVENT_UNCOMPLETED:
|
return _derived_state(instance, latest, today_date)
|
||||||
return "open"
|
|
||||||
if latest.event_type == EVENT_COMPLETED:
|
|
||||||
return "done"
|
class CompletionResult(str):
|
||||||
raise ValueError(
|
"""The derived instance state, carrying the append verdict.
|
||||||
f"event_type must be '{EVENT_COMPLETED}' or "
|
|
||||||
f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}"
|
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(
|
async def complete_instance(
|
||||||
@@ -267,20 +394,20 @@ async def complete_instance(
|
|||||||
actor_child_id: int | None = None,
|
actor_child_id: int | None = None,
|
||||||
now: datetime.datetime | None = None,
|
now: datetime.datetime | None = None,
|
||||||
today: datetime.date | None = None,
|
today: datetime.date | None = None,
|
||||||
) -> str:
|
) -> CompletionResult:
|
||||||
"""Append a completed event unless the instance is already done.
|
"""Append a completed event unless the instance is already done.
|
||||||
|
|
||||||
Returns the derived state ``done``. An already-done instance is a
|
Returns the derived state ``done``; ``appended`` reports whether
|
||||||
no-op: nothing is appended. Unknown ``instance_id`` raises
|
THIS call appended (False on an already-done no-op). Unknown
|
||||||
ValueError naming the field. Existence, latest-event, and the
|
``instance_id`` raises ValueError naming the field. Existence,
|
||||||
append run under one connection lock so a concurrent complete
|
latest-event, and the append run under one connection lock so a
|
||||||
cannot double-append.
|
concurrent complete cannot double-append.
|
||||||
|
|
||||||
``was_on_time`` is recorded as True — a placeholder until the
|
``was_on_time`` is computed from the HA-local completion moment
|
||||||
on-time task computes it from ``now`` / ``today`` against the
|
(threaded ``now`` / ``today``) against the instance ``due_date``
|
||||||
instance due_date and optional due_time. ``today`` is accepted so
|
and optional ``due_time``. ``now`` is the timezone-aware HA-local
|
||||||
callers can thread the HA-local date through without a signature
|
datetime; its wall clock is never converted to UTC. Same-day
|
||||||
change; it is unused here.
|
``due_time`` comparison requires ``now``.
|
||||||
"""
|
"""
|
||||||
instance_id = _validate_int_id(instance_id, "instance_id")
|
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 = _validate_actor(
|
||||||
@@ -296,7 +423,7 @@ async def complete_instance(
|
|||||||
instance_id
|
instance_id
|
||||||
)
|
)
|
||||||
if latest is not None and latest.event_type == EVENT_COMPLETED:
|
if latest is not None and latest.event_type == EVENT_COMPLETED:
|
||||||
return "done"
|
return CompletionResult("done", appended=False)
|
||||||
if latest is not None and latest.event_type != EVENT_UNCOMPLETED:
|
if latest is not None and latest.event_type != EVENT_UNCOMPLETED:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"event_type must be '{EVENT_COMPLETED}' or "
|
f"event_type must be '{EVENT_COMPLETED}' or "
|
||||||
@@ -310,7 +437,98 @@ async def complete_instance(
|
|||||||
actor_source=actor_source,
|
actor_source=actor_source,
|
||||||
actor_user_id=actor_user_id,
|
actor_user_id=actor_user_id,
|
||||||
actor_child_id=actor_child_id,
|
actor_child_id=actor_child_id,
|
||||||
was_on_time=True,
|
was_on_time=_was_on_time(instance, now, today),
|
||||||
now=now,
|
now=now,
|
||||||
)
|
)
|
||||||
return "done"
|
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,11 +7,47 @@ DOMAIN = "nestquest"
|
|||||||
LOGGER_NAME = "custom_components.nestquest"
|
LOGGER_NAME = "custom_components.nestquest"
|
||||||
LOGGER = logging.getLogger(LOGGER_NAME)
|
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"]
|
||||||
|
|
||||||
#: Service name for the on-demand regeneration service (``nestquest.regenerate``).
|
# 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"
|
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"
|
SQLITE_DB_FILENAME = "nestquest.db"
|
||||||
|
|
||||||
# Configuration keys and default values
|
# Configuration keys and default values
|
||||||
@@ -30,6 +66,13 @@ DEFAULT_PANEL_IDLE_TIMEOUT = 300
|
|||||||
# list as a disaster-recovery copy so a lost database can be re-seeded.
|
# list as a disaster-recovery copy so a lost database can be re-seeded.
|
||||||
CONF_ADMIN_USER_IDS = "admin_user_ids"
|
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;
|
# Quest windows (D-008): a definition may declare several day windows;
|
||||||
# each produces its own instance per day and groups the panel's Quest
|
# 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
|
# 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
|
||||||
@@ -270,6 +270,15 @@ class PresenceOverridesDao:
|
|||||||
)
|
)
|
||||||
return [_override_from_row(row) for row in rows]
|
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:
|
async def delete(self, override_id: int) -> bool:
|
||||||
"""Remove one override by id; True if a row was removed."""
|
"""Remove one override by id; True if a row was removed."""
|
||||||
result = await self._database.execute(
|
result = await self._database.execute(
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -14,10 +14,13 @@ from .const import (
|
|||||||
CONF_DAY_ROLLOVER_TIME,
|
CONF_DAY_ROLLOVER_TIME,
|
||||||
CONF_HORIZON_DAYS,
|
CONF_HORIZON_DAYS,
|
||||||
CONF_PANEL_IDLE_TIMEOUT,
|
CONF_PANEL_IDLE_TIMEOUT,
|
||||||
|
CONF_UPDATE_INTERVAL,
|
||||||
DEFAULT_DAY_ROLLOVER_TIME,
|
DEFAULT_DAY_ROLLOVER_TIME,
|
||||||
DEFAULT_HORIZON_DAYS,
|
DEFAULT_HORIZON_DAYS,
|
||||||
DEFAULT_PANEL_IDLE_TIMEOUT,
|
DEFAULT_PANEL_IDLE_TIMEOUT,
|
||||||
|
DEFAULT_UPDATE_INTERVAL,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
|
MIN_UPDATE_INTERVAL,
|
||||||
)
|
)
|
||||||
from .admin_allowlist import list_admin_ids, set_admin_ids
|
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"
|
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
|
return errors
|
||||||
|
|
||||||
|
|
||||||
@@ -82,6 +97,9 @@ def _build_schema(
|
|||||||
vol.Required(
|
vol.Required(
|
||||||
CONF_PANEL_IDLE_TIMEOUT, default=current[CONF_PANEL_IDLE_TIMEOUT]
|
CONF_PANEL_IDLE_TIMEOUT, default=current[CONF_PANEL_IDLE_TIMEOUT]
|
||||||
): int,
|
): int,
|
||||||
|
vol.Required(
|
||||||
|
CONF_UPDATE_INTERVAL, default=current[CONF_UPDATE_INTERVAL]
|
||||||
|
): int,
|
||||||
}
|
}
|
||||||
if admin_choices is not None:
|
if admin_choices is not None:
|
||||||
# HA's supported multi-select validator: the frontend can
|
# 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_HORIZON_DAYS: user_input[CONF_HORIZON_DAYS],
|
||||||
CONF_DAY_ROLLOVER_TIME: user_input[CONF_DAY_ROLLOVER_TIME],
|
CONF_DAY_ROLLOVER_TIME: user_input[CONF_DAY_ROLLOVER_TIME],
|
||||||
CONF_PANEL_IDLE_TIMEOUT: user_input[CONF_PANEL_IDLE_TIMEOUT],
|
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:
|
if admin_ids_present:
|
||||||
data[CONF_ADMIN_USER_IDS] = admin_ids
|
data[CONF_ADMIN_USER_IDS] = admin_ids
|
||||||
@@ -231,6 +253,7 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
|
|||||||
(CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS),
|
(CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS),
|
||||||
(CONF_DAY_ROLLOVER_TIME, DEFAULT_DAY_ROLLOVER_TIME),
|
(CONF_DAY_ROLLOVER_TIME, DEFAULT_DAY_ROLLOVER_TIME),
|
||||||
(CONF_PANEL_IDLE_TIMEOUT, DEFAULT_PANEL_IDLE_TIMEOUT),
|
(CONF_PANEL_IDLE_TIMEOUT, DEFAULT_PANEL_IDLE_TIMEOUT),
|
||||||
|
(CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL),
|
||||||
):
|
):
|
||||||
if key in options:
|
if key in options:
|
||||||
values[key] = options[key]
|
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
|
||||||
@@ -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)
|
||||||
@@ -10,3 +10,317 @@ regenerate:
|
|||||||
and the startup backfill. Safe to call at any time: a re-run refreshes the
|
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
|
same rows instead of duplicating them, and completed instances are never
|
||||||
touched.
|
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:
|
||||||
|
|||||||
+345
-6
@@ -27,6 +27,7 @@ import inspect
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import weakref
|
import weakref
|
||||||
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
@@ -44,7 +45,12 @@ _HA_MODULES = (
|
|||||||
"homeassistant.exceptions",
|
"homeassistant.exceptions",
|
||||||
"homeassistant.helpers",
|
"homeassistant.helpers",
|
||||||
"homeassistant.helpers.config_validation",
|
"homeassistant.helpers.config_validation",
|
||||||
|
"homeassistant.helpers.entity",
|
||||||
"homeassistant.helpers.event",
|
"homeassistant.helpers.event",
|
||||||
|
"homeassistant.helpers.update_coordinator",
|
||||||
|
"homeassistant.components",
|
||||||
|
"homeassistant.components.sensor",
|
||||||
|
"homeassistant.components.binary_sensor",
|
||||||
)
|
)
|
||||||
|
|
||||||
def _find_spec(name: str):
|
def _find_spec(name: str):
|
||||||
@@ -84,7 +90,12 @@ for _parent, _child in (
|
|||||||
("homeassistant", "exceptions"),
|
("homeassistant", "exceptions"),
|
||||||
("homeassistant", "helpers"),
|
("homeassistant", "helpers"),
|
||||||
("homeassistant.helpers", "config_validation"),
|
("homeassistant.helpers", "config_validation"),
|
||||||
|
("homeassistant.helpers", "entity"),
|
||||||
("homeassistant.helpers", "event"),
|
("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}"])
|
setattr(sys.modules[_parent], _child, sys.modules[f"{_parent}.{_child}"])
|
||||||
|
|
||||||
@@ -177,7 +188,11 @@ _data_entry_flow_mock.RESULT_TYPE_ABORT = "abort"
|
|||||||
_data_entry_flow_mock.FlowResult = dict
|
_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.
|
"""Stand-in mirroring homeassistant.exceptions.ConfigEntryNotReady.
|
||||||
|
|
||||||
HA retries setup when this is raised; the tests assert the corrupt
|
HA retries setup when this is raised; the tests assert the corrupt
|
||||||
@@ -185,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.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
|
import voluptuous as vol
|
||||||
|
|
||||||
@@ -307,6 +549,31 @@ def _async_track_time_change(hass, action, hour=None, minute=None, second=None):
|
|||||||
_ha_mock("homeassistant.helpers.event").async_track_time_change = _async_track_time_change
|
_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:
|
class ServiceRegistry:
|
||||||
"""Models HA's ``hass.services`` service registry.
|
"""Models HA's ``hass.services`` service registry.
|
||||||
|
|
||||||
@@ -325,7 +592,7 @@ class ServiceRegistry:
|
|||||||
self._services: dict[tuple[str, str], object] = {}
|
self._services: dict[tuple[str, str], object] = {}
|
||||||
|
|
||||||
def async_register(self, domain, service, func, schema=None, supports_response=None):
|
def async_register(self, domain, service, func, schema=None, supports_response=None):
|
||||||
self._services[(domain, service)] = func
|
self._services[(domain, service)] = (func, schema)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def has_service(self, domain, service):
|
def has_service(self, domain, service):
|
||||||
@@ -335,11 +602,26 @@ class ServiceRegistry:
|
|||||||
self._services.pop((domain, service), None)
|
self._services.pop((domain, service), None)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def call(self, domain, service, data=None):
|
async def call(self, domain, service, data=None, context=None):
|
||||||
func = self._services.get((domain, service))
|
registered = self._services.get((domain, service))
|
||||||
if func is None:
|
if registered is None:
|
||||||
raise KeyError(f"service {domain}.{service} not registered")
|
raise KeyError(f"service {domain}.{service} not registered")
|
||||||
call = SimpleNamespace(domain=domain, service=service, data=data or {})
|
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)
|
result = func(call)
|
||||||
if inspect.isawaitable(result):
|
if inspect.isawaitable(result):
|
||||||
await result
|
await result
|
||||||
@@ -414,10 +696,67 @@ def make_hass() -> tuple:
|
|||||||
registry = ListenerRegistry(hass)
|
registry = ListenerRegistry(hass)
|
||||||
hass.time_change = TimeChangeRegistry(hass)
|
hass.time_change = TimeChangeRegistry(hass)
|
||||||
hass.services = ServiceRegistry(hass)
|
hass.services = ServiceRegistry(hass)
|
||||||
|
hass.bus = EventBus()
|
||||||
# A valid IANA time zone (the day-rollover listener reads it to
|
# A valid IANA time zone (the day-rollover listener reads it to
|
||||||
# compute "today" in HA local time).
|
# compute "today" in HA local time).
|
||||||
hass.config.time_zone = "UTC"
|
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:
|
async def _async_reload(entry_id: str) -> None:
|
||||||
registry.reloaded.append(entry_id)
|
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",
|
||||||
|
}
|
||||||
+765
-1
@@ -12,6 +12,8 @@ from custom_components.nestquest.completion import (
|
|||||||
append_event,
|
append_event,
|
||||||
complete_instance,
|
complete_instance,
|
||||||
instance_state,
|
instance_state,
|
||||||
|
list_missed_for_child,
|
||||||
|
uncomplete_instance,
|
||||||
)
|
)
|
||||||
from custom_components.nestquest.dao_children import ChildrenDao
|
from custom_components.nestquest.dao_children import ChildrenDao
|
||||||
from custom_components.nestquest.dao_instances import (
|
from custom_components.nestquest.dao_instances import (
|
||||||
@@ -338,7 +340,8 @@ def test_module_never_exposes_update_or_delete() -> None:
|
|||||||
assert "append_event" in public
|
assert "append_event" in public
|
||||||
assert "instance_state" in public
|
assert "instance_state" in public
|
||||||
assert "complete_instance" in public
|
assert "complete_instance" in public
|
||||||
assert "uncomplete_instance" not in public
|
assert "uncomplete_instance" in public
|
||||||
|
assert "list_missed_for_child" in public
|
||||||
source = inspect.getsource(completion)
|
source = inspect.getsource(completion)
|
||||||
assert "UPDATE" not in source
|
assert "UPDATE" not in source
|
||||||
assert "DELETE" not in source
|
assert "DELETE" not in source
|
||||||
@@ -684,3 +687,764 @@ def test_complete_instance_concurrent_second_call_is_noop(tmp_path) -> None:
|
|||||||
await database.close()
|
await database.close()
|
||||||
|
|
||||||
_run(_main())
|
_run(_main())
|
||||||
|
|
||||||
|
|
||||||
|
_HA_TZ = datetime.timezone(datetime.timedelta(hours=-8))
|
||||||
|
|
||||||
|
|
||||||
|
def _ha_local(day: datetime.date, hour: int, minute: int = 0) -> datetime.datetime:
|
||||||
|
return datetime.datetime(
|
||||||
|
day.year, day.month, day.day, hour, minute, 0,
|
||||||
|
tzinfo=_HA_TZ,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_complete_instance_on_due_date_before_due_time_is_on_time(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
async def _body(database, child, instance):
|
||||||
|
due_date = datetime.date.fromisoformat(instance.due_date)
|
||||||
|
instance = await QuestInstancesDao(database).upsert(
|
||||||
|
instance.definition_id,
|
||||||
|
child.id,
|
||||||
|
instance.due_date,
|
||||||
|
_now_stamp(),
|
||||||
|
window=instance.window,
|
||||||
|
due_time="12:00",
|
||||||
|
)
|
||||||
|
await complete_instance(
|
||||||
|
database,
|
||||||
|
instance.id,
|
||||||
|
actor_source="user",
|
||||||
|
actor_user_id="user-1",
|
||||||
|
now=_ha_local(due_date, 11, 0),
|
||||||
|
today=due_date,
|
||||||
|
)
|
||||||
|
rows = await CompletionEventsDao(database).list_by_instance(
|
||||||
|
instance.id
|
||||||
|
)
|
||||||
|
assert rows[0].was_on_time is True
|
||||||
|
|
||||||
|
_with_db(tmp_path, "ontime-before-due-time.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_complete_instance_on_due_date_after_due_time_is_late(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
async def _body(database, child, instance):
|
||||||
|
due_date = datetime.date.fromisoformat(instance.due_date)
|
||||||
|
instance = await QuestInstancesDao(database).upsert(
|
||||||
|
instance.definition_id,
|
||||||
|
child.id,
|
||||||
|
instance.due_date,
|
||||||
|
_now_stamp(),
|
||||||
|
window=instance.window,
|
||||||
|
due_time="12:00",
|
||||||
|
)
|
||||||
|
await complete_instance(
|
||||||
|
database,
|
||||||
|
instance.id,
|
||||||
|
actor_source="user",
|
||||||
|
actor_user_id="user-1",
|
||||||
|
now=_ha_local(due_date, 12, 1),
|
||||||
|
today=due_date,
|
||||||
|
)
|
||||||
|
rows = await CompletionEventsDao(database).list_by_instance(
|
||||||
|
instance.id
|
||||||
|
)
|
||||||
|
assert rows[0].was_on_time is False
|
||||||
|
|
||||||
|
_with_db(tmp_path, "late-after-due-time.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_complete_instance_microsecond_after_due_time_is_late(tmp_path) -> None:
|
||||||
|
async def _body(database, child, instance):
|
||||||
|
due_date = datetime.date.fromisoformat(instance.due_date)
|
||||||
|
instance = await QuestInstancesDao(database).upsert(
|
||||||
|
instance.definition_id,
|
||||||
|
child.id,
|
||||||
|
instance.due_date,
|
||||||
|
_now_stamp(),
|
||||||
|
window=instance.window,
|
||||||
|
due_time="12:00",
|
||||||
|
)
|
||||||
|
now = datetime.datetime(
|
||||||
|
due_date.year, due_date.month, due_date.day, 12, 0, 0, 500,
|
||||||
|
tzinfo=_HA_TZ,
|
||||||
|
)
|
||||||
|
await complete_instance(
|
||||||
|
database,
|
||||||
|
instance.id,
|
||||||
|
actor_source="user",
|
||||||
|
actor_user_id="user-1",
|
||||||
|
now=now,
|
||||||
|
today=due_date,
|
||||||
|
)
|
||||||
|
rows = await CompletionEventsDao(database).list_by_instance(
|
||||||
|
instance.id
|
||||||
|
)
|
||||||
|
assert rows[0].was_on_time is False
|
||||||
|
|
||||||
|
_with_db(tmp_path, "late-microsecond-after-due-time.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_complete_instance_no_due_time_on_due_date_is_on_time(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
async def _body(database, child, instance):
|
||||||
|
due_date = datetime.date.fromisoformat(instance.due_date)
|
||||||
|
assert instance.due_time is None
|
||||||
|
await complete_instance(
|
||||||
|
database,
|
||||||
|
instance.id,
|
||||||
|
actor_source="user",
|
||||||
|
actor_user_id="user-1",
|
||||||
|
now=_ha_local(due_date, 23, 59),
|
||||||
|
today=due_date,
|
||||||
|
)
|
||||||
|
rows = await CompletionEventsDao(database).list_by_instance(
|
||||||
|
instance.id
|
||||||
|
)
|
||||||
|
assert rows[0].was_on_time is True
|
||||||
|
|
||||||
|
_with_db(tmp_path, "ontime-no-due-time.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_complete_instance_no_due_time_after_due_date_is_late(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
async def _body(database, child, instance):
|
||||||
|
due_date = datetime.date.fromisoformat(instance.due_date)
|
||||||
|
later = due_date + datetime.timedelta(days=1)
|
||||||
|
assert instance.due_time is None
|
||||||
|
await complete_instance(
|
||||||
|
database,
|
||||||
|
instance.id,
|
||||||
|
actor_source="user",
|
||||||
|
actor_user_id="user-1",
|
||||||
|
now=_ha_local(later, 8, 0),
|
||||||
|
today=later,
|
||||||
|
)
|
||||||
|
rows = await CompletionEventsDao(database).list_by_instance(
|
||||||
|
instance.id
|
||||||
|
)
|
||||||
|
assert rows[0].was_on_time is False
|
||||||
|
|
||||||
|
_with_db(tmp_path, "late-after-due-date.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_complete_instance_ha_local_wall_clock_not_utc(tmp_path) -> None:
|
||||||
|
"""HA-local 11:00 with due_time 12:00 is on time even when UTC is 19:00.
|
||||||
|
|
||||||
|
The helper's zone is UTC-8, so 11:00 local is 19:00 UTC. Converting
|
||||||
|
to UTC before comparing would mark this late.
|
||||||
|
"""
|
||||||
|
async def _body(database, child, instance):
|
||||||
|
due_date = datetime.date.fromisoformat(instance.due_date)
|
||||||
|
instance = await QuestInstancesDao(database).upsert(
|
||||||
|
instance.definition_id,
|
||||||
|
child.id,
|
||||||
|
instance.due_date,
|
||||||
|
_now_stamp(),
|
||||||
|
window=instance.window,
|
||||||
|
due_time="12:00",
|
||||||
|
)
|
||||||
|
now = _ha_local(due_date, 11, 0)
|
||||||
|
assert now.astimezone(datetime.timezone.utc).hour == 19
|
||||||
|
await complete_instance(
|
||||||
|
database,
|
||||||
|
instance.id,
|
||||||
|
actor_source="user",
|
||||||
|
actor_user_id="user-1",
|
||||||
|
now=now,
|
||||||
|
today=due_date,
|
||||||
|
)
|
||||||
|
rows = await CompletionEventsDao(database).list_by_instance(
|
||||||
|
instance.id
|
||||||
|
)
|
||||||
|
assert rows[0].was_on_time is True
|
||||||
|
|
||||||
|
_with_db(tmp_path, "ontime-ha-local-not-utc.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_complete_instance_due_time_requires_now_on_due_date(tmp_path) -> None:
|
||||||
|
async def _body(database, child, instance):
|
||||||
|
due_date = datetime.date.fromisoformat(instance.due_date)
|
||||||
|
instance = await QuestInstancesDao(database).upsert(
|
||||||
|
instance.definition_id,
|
||||||
|
child.id,
|
||||||
|
instance.due_date,
|
||||||
|
_now_stamp(),
|
||||||
|
window=instance.window,
|
||||||
|
due_time="12:00",
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match="now is required to compare due_time"):
|
||||||
|
await complete_instance(
|
||||||
|
database,
|
||||||
|
instance.id,
|
||||||
|
actor_source="user",
|
||||||
|
actor_user_id="user-1",
|
||||||
|
today=due_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
_with_db(tmp_path, "due-time-requires-now.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# uncomplete_instance: append uncompleted, no-op if already open
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_uncomplete_instance_appends_and_leaves_original_untouched(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
async def _body(database, child, instance):
|
||||||
|
completed_at = datetime.datetime(
|
||||||
|
2026, 9, 18, 8, 0, 0, tzinfo=datetime.timezone.utc
|
||||||
|
)
|
||||||
|
await complete_instance(
|
||||||
|
database,
|
||||||
|
instance.id,
|
||||||
|
actor_source="user",
|
||||||
|
actor_user_id="user-1",
|
||||||
|
now=completed_at,
|
||||||
|
)
|
||||||
|
events = CompletionEventsDao(database)
|
||||||
|
before = await events.list_by_instance(instance.id)
|
||||||
|
assert len(before) == 1
|
||||||
|
original = before[0]
|
||||||
|
assert original.event_type == EVENT_COMPLETED
|
||||||
|
assert original.occurred_at == "2026-09-18T08:00:00+00:00"
|
||||||
|
assert original.actor_source == "user"
|
||||||
|
assert original.actor_user_id == "user-1"
|
||||||
|
assert original.actor_child_id is None
|
||||||
|
uncompleted_at = completed_at + datetime.timedelta(seconds=1)
|
||||||
|
state = await uncomplete_instance(
|
||||||
|
database,
|
||||||
|
instance.id,
|
||||||
|
actor_source="panel",
|
||||||
|
actor_child_id=child.id,
|
||||||
|
now=uncompleted_at,
|
||||||
|
)
|
||||||
|
assert state == "open"
|
||||||
|
assert await instance_state(database, instance.id) == "open"
|
||||||
|
after = await events.list_by_instance(instance.id)
|
||||||
|
assert len(after) == 2
|
||||||
|
assert after[0].id == original.id
|
||||||
|
assert after[0].event_type == EVENT_COMPLETED
|
||||||
|
assert after[0].occurred_at == original.occurred_at
|
||||||
|
assert after[0].actor_source == original.actor_source
|
||||||
|
assert after[0].actor_user_id == original.actor_user_id
|
||||||
|
assert after[0].actor_child_id == original.actor_child_id
|
||||||
|
assert after[1].event_type == EVENT_UNCOMPLETED
|
||||||
|
assert after[1].actor_source == "panel"
|
||||||
|
assert after[1].actor_child_id == child.id
|
||||||
|
assert after[1].was_on_time is None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "uncomplete-append.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_uncomplete_instance_already_open_is_noop(tmp_path) -> None:
|
||||||
|
async def _body(database, child, instance):
|
||||||
|
events = CompletionEventsDao(database)
|
||||||
|
assert await events.list_by_instance(instance.id) == []
|
||||||
|
first = await uncomplete_instance(
|
||||||
|
database,
|
||||||
|
instance.id,
|
||||||
|
actor_source="user",
|
||||||
|
actor_user_id="user-1",
|
||||||
|
)
|
||||||
|
after_first = await events.list_by_instance(instance.id)
|
||||||
|
assert first == "open"
|
||||||
|
assert after_first == []
|
||||||
|
await complete_instance(
|
||||||
|
database,
|
||||||
|
instance.id,
|
||||||
|
actor_source="user",
|
||||||
|
actor_user_id="user-1",
|
||||||
|
)
|
||||||
|
await uncomplete_instance(
|
||||||
|
database,
|
||||||
|
instance.id,
|
||||||
|
actor_source="user",
|
||||||
|
actor_user_id="user-1",
|
||||||
|
)
|
||||||
|
before = await events.list_by_instance(instance.id)
|
||||||
|
assert len(before) == 2
|
||||||
|
second = await uncomplete_instance(
|
||||||
|
database,
|
||||||
|
instance.id,
|
||||||
|
actor_source="user",
|
||||||
|
actor_user_id="user-2",
|
||||||
|
)
|
||||||
|
after = await events.list_by_instance(instance.id)
|
||||||
|
assert second == "open"
|
||||||
|
assert after == before
|
||||||
|
assert after[0].actor_user_id == "user-1"
|
||||||
|
assert after[1].actor_user_id == "user-1"
|
||||||
|
|
||||||
|
_with_db(tmp_path, "uncomplete-noop.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_uncomplete_instance_unknown_instance_id_raises(tmp_path) -> None:
|
||||||
|
async def _body(database, child, instance):
|
||||||
|
with pytest.raises(ValueError, match="instance_id"):
|
||||||
|
await uncomplete_instance(
|
||||||
|
database,
|
||||||
|
instance.id + 999,
|
||||||
|
actor_source="user",
|
||||||
|
actor_user_id="user-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
_with_db(tmp_path, "uncomplete-unknown.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_uncomplete_instance_rejects_non_integer_id() -> None:
|
||||||
|
async def _body():
|
||||||
|
with pytest.raises(ValueError, match="instance_id"):
|
||||||
|
await uncomplete_instance(
|
||||||
|
object(),
|
||||||
|
"1",
|
||||||
|
actor_source="user",
|
||||||
|
actor_user_id="user-1",
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match="instance_id"):
|
||||||
|
await uncomplete_instance(
|
||||||
|
object(),
|
||||||
|
True,
|
||||||
|
actor_source="user",
|
||||||
|
actor_user_id="user-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
_run(_body())
|
||||||
|
|
||||||
|
|
||||||
|
def test_uncomplete_instance_now_pins_occurred_at(tmp_path) -> None:
|
||||||
|
async def _body(database, child, instance):
|
||||||
|
await complete_instance(
|
||||||
|
database,
|
||||||
|
instance.id,
|
||||||
|
actor_source="user",
|
||||||
|
actor_user_id="user-1",
|
||||||
|
now=datetime.datetime(
|
||||||
|
2026, 9, 18, 8, 0, 0, tzinfo=datetime.timezone.utc
|
||||||
|
),
|
||||||
|
)
|
||||||
|
pinned = datetime.datetime(
|
||||||
|
2026, 9, 18, 8, 0, 1, tzinfo=datetime.timezone.utc
|
||||||
|
)
|
||||||
|
await uncomplete_instance(
|
||||||
|
database,
|
||||||
|
instance.id,
|
||||||
|
actor_source="user",
|
||||||
|
actor_user_id="user-1",
|
||||||
|
now=pinned,
|
||||||
|
)
|
||||||
|
rows = await CompletionEventsDao(database).list_by_instance(
|
||||||
|
instance.id
|
||||||
|
)
|
||||||
|
assert rows[1].occurred_at == "2026-09-18T08:00:01+00:00"
|
||||||
|
|
||||||
|
_with_db(tmp_path, "uncomplete-pinned.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_uncomplete_instance_concurrent_second_call_is_noop(tmp_path) -> None:
|
||||||
|
"""Two racing uncompletes cannot both append.
|
||||||
|
|
||||||
|
Deterministic gate: task A is paused at its INSERT (it holds the
|
||||||
|
connection lock); task B is then started and must queue. A
|
||||||
|
finishes, B sees open and appends nothing.
|
||||||
|
"""
|
||||||
|
async def _main():
|
||||||
|
armed = {"active": False, "gated": False}
|
||||||
|
gate_open = asyncio.Event()
|
||||||
|
a_started = asyncio.Event()
|
||||||
|
hass = _gated_hass(armed, gate_open, a_started)
|
||||||
|
database = NestQuestDatabase(hass)
|
||||||
|
await database.open(tmp_path / "uncomplete-race.db")
|
||||||
|
try:
|
||||||
|
await apply_migrations(database)
|
||||||
|
children = ChildrenDao(database)
|
||||||
|
rules = ScheduleRulesDao(database)
|
||||||
|
definitions = QuestDefinitionsDao(database)
|
||||||
|
instances = QuestInstancesDao(database)
|
||||||
|
child = await children.create("Ada", _now_stamp())
|
||||||
|
rule = await rules.create("daily", D1)
|
||||||
|
definition = await definitions.create(
|
||||||
|
"Brush teeth",
|
||||||
|
rule.id,
|
||||||
|
_now_stamp(),
|
||||||
|
assignee_child_ids=[child.id],
|
||||||
|
)
|
||||||
|
instance = await instances.upsert(
|
||||||
|
definition.id,
|
||||||
|
child.id,
|
||||||
|
D1,
|
||||||
|
_now_stamp(),
|
||||||
|
window="morning",
|
||||||
|
)
|
||||||
|
await complete_instance(
|
||||||
|
database,
|
||||||
|
instance.id,
|
||||||
|
actor_source="user",
|
||||||
|
actor_user_id="user-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
armed["active"] = True
|
||||||
|
task_a = asyncio.ensure_future(
|
||||||
|
uncomplete_instance(
|
||||||
|
database,
|
||||||
|
instance.id,
|
||||||
|
actor_source="user",
|
||||||
|
actor_user_id="user-1",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await a_started.wait()
|
||||||
|
armed["active"] = False
|
||||||
|
task_b = asyncio.ensure_future(
|
||||||
|
uncomplete_instance(
|
||||||
|
database,
|
||||||
|
instance.id,
|
||||||
|
actor_source="user",
|
||||||
|
actor_user_id="user-2",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
gate_open.set()
|
||||||
|
state_a, state_b = await asyncio.gather(task_a, task_b)
|
||||||
|
assert state_a == "open"
|
||||||
|
assert state_b == "open"
|
||||||
|
rows = await CompletionEventsDao(database).list_by_instance(
|
||||||
|
instance.id
|
||||||
|
)
|
||||||
|
assert len(rows) == 2
|
||||||
|
assert rows[0].event_type == EVENT_COMPLETED
|
||||||
|
assert rows[1].event_type == EVENT_UNCOMPLETED
|
||||||
|
assert rows[1].actor_user_id == "user-1"
|
||||||
|
finally:
|
||||||
|
await database.close()
|
||||||
|
|
||||||
|
_run(_main())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# missed: derived for past-due open instances, never stored
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_append_event_rejects_missed_event_type() -> None:
|
||||||
|
async def _body():
|
||||||
|
with pytest.raises(ValueError, match="event_type"):
|
||||||
|
await append_event(
|
||||||
|
object(),
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
"missed",
|
||||||
|
actor_source="user",
|
||||||
|
actor_user_id="user-1",
|
||||||
|
was_on_time=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
_run(_body())
|
||||||
|
|
||||||
|
|
||||||
|
def test_instance_state_missed_when_past_due_and_open(tmp_path) -> None:
|
||||||
|
async def _body(database, child, instance):
|
||||||
|
due = datetime.date.fromisoformat(instance.due_date)
|
||||||
|
later = due + datetime.timedelta(days=1)
|
||||||
|
assert await instance_state(database, instance.id, today=later) == "missed"
|
||||||
|
rows = await CompletionEventsDao(database).list_by_instance(instance.id)
|
||||||
|
assert rows == []
|
||||||
|
|
||||||
|
_with_db(tmp_path, "state-missed-open.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_instance_state_open_when_due_today(tmp_path) -> None:
|
||||||
|
async def _body(database, child, instance):
|
||||||
|
due = datetime.date.fromisoformat(instance.due_date)
|
||||||
|
assert await instance_state(database, instance.id, today=due) == "open"
|
||||||
|
|
||||||
|
_with_db(tmp_path, "state-open-due-today.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_instance_state_done_when_past_due_and_completed(tmp_path) -> None:
|
||||||
|
async def _body(database, child, instance):
|
||||||
|
due = datetime.date.fromisoformat(instance.due_date)
|
||||||
|
await complete_instance(
|
||||||
|
database,
|
||||||
|
instance.id,
|
||||||
|
actor_source="user",
|
||||||
|
actor_user_id="user-1",
|
||||||
|
today=due,
|
||||||
|
)
|
||||||
|
later = due + datetime.timedelta(days=1)
|
||||||
|
assert await instance_state(database, instance.id, today=later) == "done"
|
||||||
|
|
||||||
|
_with_db(tmp_path, "state-done-past-due.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_instance_state_missed_after_uncompleted_when_past_due(tmp_path) -> None:
|
||||||
|
async def _body(database, child, instance):
|
||||||
|
due = datetime.date.fromisoformat(instance.due_date)
|
||||||
|
await append_event(
|
||||||
|
database,
|
||||||
|
instance.id,
|
||||||
|
child.id,
|
||||||
|
EVENT_COMPLETED,
|
||||||
|
actor_source="user",
|
||||||
|
actor_user_id="user-1",
|
||||||
|
was_on_time=True,
|
||||||
|
)
|
||||||
|
await append_event(
|
||||||
|
database,
|
||||||
|
instance.id,
|
||||||
|
child.id,
|
||||||
|
EVENT_UNCOMPLETED,
|
||||||
|
actor_source="user",
|
||||||
|
actor_user_id="user-1",
|
||||||
|
)
|
||||||
|
later = due + datetime.timedelta(days=1)
|
||||||
|
assert await instance_state(database, instance.id, today=later) == "missed"
|
||||||
|
|
||||||
|
_with_db(tmp_path, "state-missed-uncompleted.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_instance_state_missed_uses_host_today_when_unthreaded(tmp_path) -> None:
|
||||||
|
async def _body(database, child, instance):
|
||||||
|
yesterday = datetime.date.today() - datetime.timedelta(days=1)
|
||||||
|
past = await QuestInstancesDao(database).upsert(
|
||||||
|
instance.definition_id,
|
||||||
|
child.id,
|
||||||
|
yesterday.isoformat(),
|
||||||
|
_now_stamp(),
|
||||||
|
window="afternoon",
|
||||||
|
today=yesterday,
|
||||||
|
)
|
||||||
|
assert await instance_state(database, past.id) == "missed"
|
||||||
|
rows = await CompletionEventsDao(database).list_by_instance(past.id)
|
||||||
|
assert rows == []
|
||||||
|
assert all(row.event_type != "missed" for row in rows)
|
||||||
|
|
||||||
|
_with_db(tmp_path, "state-missed-host-today.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_uncomplete_past_due_done_returns_missed_not_done(tmp_path) -> None:
|
||||||
|
async def _body(database, child, instance):
|
||||||
|
due = datetime.date.fromisoformat(instance.due_date)
|
||||||
|
await complete_instance(
|
||||||
|
database,
|
||||||
|
instance.id,
|
||||||
|
actor_source="user",
|
||||||
|
actor_user_id="user-1",
|
||||||
|
today=due,
|
||||||
|
)
|
||||||
|
later = due + datetime.timedelta(days=1)
|
||||||
|
assert await instance_state(database, instance.id, today=later) == "done"
|
||||||
|
state = await uncomplete_instance(
|
||||||
|
database,
|
||||||
|
instance.id,
|
||||||
|
actor_source="user",
|
||||||
|
actor_user_id="user-1",
|
||||||
|
today=later,
|
||||||
|
)
|
||||||
|
assert state == "missed"
|
||||||
|
assert await instance_state(database, instance.id, today=later) == "missed"
|
||||||
|
rows = await CompletionEventsDao(database).list_by_instance(instance.id)
|
||||||
|
assert [row.event_type for row in rows] == [
|
||||||
|
EVENT_COMPLETED,
|
||||||
|
EVENT_UNCOMPLETED,
|
||||||
|
]
|
||||||
|
assert all(row.event_type != "missed" for row in rows)
|
||||||
|
|
||||||
|
_with_db(tmp_path, "uncomplete-past-due-missed.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_uncomplete_past_due_already_open_is_noop_missed(tmp_path) -> None:
|
||||||
|
async def _body(database, child, instance):
|
||||||
|
later = datetime.date.fromisoformat(instance.due_date) + datetime.timedelta(
|
||||||
|
days=1
|
||||||
|
)
|
||||||
|
events = CompletionEventsDao(database)
|
||||||
|
assert await events.list_by_instance(instance.id) == []
|
||||||
|
state = await uncomplete_instance(
|
||||||
|
database,
|
||||||
|
instance.id,
|
||||||
|
actor_source="user",
|
||||||
|
actor_user_id="user-1",
|
||||||
|
today=later,
|
||||||
|
)
|
||||||
|
assert state == "missed"
|
||||||
|
assert await events.list_by_instance(instance.id) == []
|
||||||
|
|
||||||
|
_with_db(tmp_path, "uncomplete-past-due-noop.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_complete_instance_of_missed_returns_done(tmp_path) -> None:
|
||||||
|
async def _body(database, child, instance):
|
||||||
|
due = datetime.date.fromisoformat(instance.due_date)
|
||||||
|
later = due + datetime.timedelta(days=1)
|
||||||
|
assert await instance_state(database, instance.id, today=later) == "missed"
|
||||||
|
state = await complete_instance(
|
||||||
|
database,
|
||||||
|
instance.id,
|
||||||
|
actor_source="user",
|
||||||
|
actor_user_id="user-1",
|
||||||
|
now=_ha_local(later, 8, 0),
|
||||||
|
today=later,
|
||||||
|
)
|
||||||
|
assert state == "done"
|
||||||
|
assert await instance_state(database, instance.id, today=later) == "done"
|
||||||
|
rows = await CompletionEventsDao(database).list_by_instance(instance.id)
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0].event_type == EVENT_COMPLETED
|
||||||
|
assert rows[0].was_on_time is False
|
||||||
|
|
||||||
|
_with_db(tmp_path, "complete-missed.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_missed_for_child_over_date_range(tmp_path) -> None:
|
||||||
|
async def _body(database, child, instance):
|
||||||
|
due = datetime.date.fromisoformat(instance.due_date)
|
||||||
|
later_due = due + datetime.timedelta(days=1)
|
||||||
|
other = await QuestInstancesDao(database).upsert(
|
||||||
|
instance.definition_id,
|
||||||
|
child.id,
|
||||||
|
later_due.isoformat(),
|
||||||
|
_now_stamp(),
|
||||||
|
window="morning",
|
||||||
|
)
|
||||||
|
await complete_instance(
|
||||||
|
database,
|
||||||
|
instance.id,
|
||||||
|
actor_source="user",
|
||||||
|
actor_user_id="user-1",
|
||||||
|
today=due,
|
||||||
|
)
|
||||||
|
today = later_due + datetime.timedelta(days=1)
|
||||||
|
found = await list_missed_for_child(
|
||||||
|
database,
|
||||||
|
child.id,
|
||||||
|
instance.due_date,
|
||||||
|
later_due.isoformat(),
|
||||||
|
today=today,
|
||||||
|
)
|
||||||
|
assert [row.id for row in found] == [other.id]
|
||||||
|
events = CompletionEventsDao(database)
|
||||||
|
assert await events.list_by_instance(other.id) == []
|
||||||
|
done_rows = await events.list_by_instance(instance.id)
|
||||||
|
assert [row.event_type for row in done_rows] == [EVENT_COMPLETED]
|
||||||
|
assert all(row.event_type != "missed" for row in done_rows)
|
||||||
|
|
||||||
|
_with_db(tmp_path, "list-missed-range.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_missed_for_child_empty_when_due_today(tmp_path) -> None:
|
||||||
|
async def _body(database, child, instance):
|
||||||
|
due = datetime.date.fromisoformat(instance.due_date)
|
||||||
|
found = await list_missed_for_child(
|
||||||
|
database,
|
||||||
|
child.id,
|
||||||
|
instance.due_date,
|
||||||
|
instance.due_date,
|
||||||
|
today=due,
|
||||||
|
)
|
||||||
|
assert found == []
|
||||||
|
|
||||||
|
_with_db(tmp_path, "list-missed-due-today.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_missed_for_child_rejects_non_integer_id() -> None:
|
||||||
|
async def _body():
|
||||||
|
with pytest.raises(ValueError, match="child_id"):
|
||||||
|
await list_missed_for_child(object(), "1", D1, D1)
|
||||||
|
with pytest.raises(ValueError, match="child_id"):
|
||||||
|
await list_missed_for_child(object(), True, D1, D1)
|
||||||
|
|
||||||
|
_run(_body())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# lifecycle: complete → uncomplete → complete
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_complete_uncomplete_complete_three_events_final_done(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
async def _body(database, child, instance):
|
||||||
|
due_date = datetime.date.fromisoformat(instance.due_date)
|
||||||
|
later = due_date + datetime.timedelta(days=1)
|
||||||
|
instance = await QuestInstancesDao(database).upsert(
|
||||||
|
instance.definition_id,
|
||||||
|
child.id,
|
||||||
|
instance.due_date,
|
||||||
|
_now_stamp(),
|
||||||
|
window=instance.window,
|
||||||
|
due_time="12:00",
|
||||||
|
)
|
||||||
|
events = CompletionEventsDao(database)
|
||||||
|
|
||||||
|
first = await complete_instance(
|
||||||
|
database,
|
||||||
|
instance.id,
|
||||||
|
actor_source="panel",
|
||||||
|
actor_child_id=child.id,
|
||||||
|
now=_ha_local(due_date, 11, 0),
|
||||||
|
today=due_date,
|
||||||
|
)
|
||||||
|
assert first == "done"
|
||||||
|
before_uncomplete = await events.list_by_instance(instance.id)
|
||||||
|
assert len(before_uncomplete) == 1
|
||||||
|
original = before_uncomplete[0]
|
||||||
|
assert original.event_type == EVENT_COMPLETED
|
||||||
|
assert original.was_on_time is True
|
||||||
|
assert original.actor_source == "panel"
|
||||||
|
assert original.actor_user_id is None
|
||||||
|
assert original.actor_child_id == child.id
|
||||||
|
assert await instance_state(database, instance.id, today=later) == "done"
|
||||||
|
|
||||||
|
reversed_state = await uncomplete_instance(
|
||||||
|
database,
|
||||||
|
instance.id,
|
||||||
|
actor_source="user",
|
||||||
|
actor_user_id="user-1",
|
||||||
|
now=_ha_local(later, 8, 0),
|
||||||
|
today=later,
|
||||||
|
)
|
||||||
|
assert reversed_state == "missed"
|
||||||
|
assert await instance_state(database, instance.id, today=later) == "missed"
|
||||||
|
|
||||||
|
second = await complete_instance(
|
||||||
|
database,
|
||||||
|
instance.id,
|
||||||
|
actor_source="panel",
|
||||||
|
actor_child_id=child.id,
|
||||||
|
now=_ha_local(later, 9, 0),
|
||||||
|
today=later,
|
||||||
|
)
|
||||||
|
assert second == "done"
|
||||||
|
assert await instance_state(database, instance.id, today=later) == "done"
|
||||||
|
|
||||||
|
after = await events.list_by_instance(instance.id)
|
||||||
|
assert [row.event_type for row in after] == [
|
||||||
|
EVENT_COMPLETED,
|
||||||
|
EVENT_UNCOMPLETED,
|
||||||
|
EVENT_COMPLETED,
|
||||||
|
]
|
||||||
|
assert after[0].id == original.id
|
||||||
|
assert after[0].occurred_at == original.occurred_at
|
||||||
|
assert after[0].actor_source == original.actor_source
|
||||||
|
assert after[0].actor_user_id == original.actor_user_id
|
||||||
|
assert after[0].actor_child_id == original.actor_child_id
|
||||||
|
assert after[0].was_on_time is True
|
||||||
|
assert after[1].actor_source == "user"
|
||||||
|
assert after[1].actor_user_id == "user-1"
|
||||||
|
assert after[1].actor_child_id is None
|
||||||
|
assert after[1].was_on_time is None
|
||||||
|
assert after[2].actor_source == "panel"
|
||||||
|
assert after[2].actor_user_id is None
|
||||||
|
assert after[2].actor_child_id == child.id
|
||||||
|
assert after[2].was_on_time is False
|
||||||
|
assert all(row.event_type != "missed" for row in after)
|
||||||
|
|
||||||
|
_with_db(tmp_path, "lifecycle-three.db")(_body)
|
||||||
|
|||||||
+2
-2
@@ -28,10 +28,10 @@ def test_logger() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_platforms() -> 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 hasattr(const, "PLATFORMS")
|
||||||
assert isinstance(const.PLATFORMS, list)
|
assert isinstance(const.PLATFORMS, list)
|
||||||
assert const.PLATFORMS == []
|
assert const.PLATFORMS == ["sensor", "binary_sensor"]
|
||||||
|
|
||||||
|
|
||||||
def test_sqlite_db_filename() -> None:
|
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) == []
|
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:
|
def test_override_delete_round_trip(tmp_path) -> None:
|
||||||
async def _body(database, schedules, overrides, children, child):
|
async def _body(database, schedules, overrides, children, child):
|
||||||
record = await overrides.create(
|
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.core"].callback is conftest._callback_decorator
|
||||||
assert sys.modules["homeassistant.data_entry_flow"].RESULT_TYPE_FORM == "form"
|
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.
|
# No homeassistant distribution is installed in this suite's own venv.
|
||||||
with pytest.raises(importlib.metadata.PackageNotFoundError):
|
with pytest.raises(importlib.metadata.PackageNotFoundError):
|
||||||
importlib.metadata.version("homeassistant")
|
importlib.metadata.version("homeassistant")
|
||||||
|
|||||||
+21
-8
@@ -9,6 +9,7 @@ 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 import DOMAIN, async_setup_entry, async_unload_entry
|
||||||
from custom_components.nestquest.const import (
|
from custom_components.nestquest.const import (
|
||||||
|
CONF_ADMIN_USER_IDS,
|
||||||
CONF_DAY_ROLLOVER_TIME,
|
CONF_DAY_ROLLOVER_TIME,
|
||||||
CONF_HORIZON_DAYS,
|
CONF_HORIZON_DAYS,
|
||||||
DEFAULT_HORIZON_DAYS,
|
DEFAULT_HORIZON_DAYS,
|
||||||
@@ -16,6 +17,13 @@ from custom_components.nestquest.const import (
|
|||||||
DOMAIN as DOMAIN_CONST,
|
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):
|
def _wire(entry, registry):
|
||||||
return wire_entry_to_registry(entry, registry)
|
return wire_entry_to_registry(entry, registry)
|
||||||
@@ -546,7 +554,7 @@ async def test_regenerate_service_registered_and_materializes(
|
|||||||
|
|
||||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||||
|
|
||||||
entry = _wire(make_entry(), hass.registry)
|
entry = _wire(make_entry(data=_admin_data()), hass.registry)
|
||||||
assert await async_setup_entry(hass, entry) is True
|
assert await async_setup_entry(hass, entry) is True
|
||||||
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
||||||
|
|
||||||
@@ -555,7 +563,7 @@ async def test_regenerate_service_registered_and_materializes(
|
|||||||
database = entry.runtime_data.database
|
database = entry.runtime_data.database
|
||||||
child_id = await _seed_daily_child_and_definition(database, _local_today(hass))
|
child_id = await _seed_daily_child_and_definition(database, _local_today(hass))
|
||||||
|
|
||||||
await hass.services.call(DOMAIN, SERVICE_REGENERATE)
|
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
||||||
|
|
||||||
today = _local_today(hass)
|
today = _local_today(hass)
|
||||||
end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
|
end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
|
||||||
@@ -574,14 +582,15 @@ async def test_regenerate_service_uses_configured_horizon_days(
|
|||||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||||
|
|
||||||
entry = _wire(
|
entry = _wire(
|
||||||
make_entry(options={CONF_HORIZON_DAYS: 5}), hass.registry
|
make_entry(options={CONF_HORIZON_DAYS: 5}, data=_admin_data()),
|
||||||
|
hass.registry,
|
||||||
)
|
)
|
||||||
assert await async_setup_entry(hass, entry) is True
|
assert await async_setup_entry(hass, entry) is True
|
||||||
|
|
||||||
database = entry.runtime_data.database
|
database = entry.runtime_data.database
|
||||||
child_id = await _seed_daily_child_and_definition(database, _local_today(hass))
|
child_id = await _seed_daily_child_and_definition(database, _local_today(hass))
|
||||||
|
|
||||||
await hass.services.call(DOMAIN, SERVICE_REGENERATE)
|
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
||||||
|
|
||||||
today = _local_today(hass)
|
today = _local_today(hass)
|
||||||
end = (today + datetime.timedelta(days=5)).isoformat()
|
end = (today + datetime.timedelta(days=5)).isoformat()
|
||||||
@@ -622,8 +631,12 @@ async def test_regenerate_service_is_domain_scoped_across_entries(
|
|||||||
|
|
||||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||||
|
|
||||||
entry_a = _wire(make_entry(entry_id="entry_a"), hass.registry)
|
entry_a = _wire(
|
||||||
entry_b = _wire(make_entry(entry_id="entry_b"), hass.registry)
|
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_a) is True
|
||||||
assert await async_setup_entry(hass, entry_b) is True
|
assert await async_setup_entry(hass, entry_b) is True
|
||||||
@@ -634,7 +647,7 @@ async def test_regenerate_service_is_domain_scoped_across_entries(
|
|||||||
child_id = await _seed_daily_child_and_definition(
|
child_id = await _seed_daily_child_and_definition(
|
||||||
database_b, _local_today(hass)
|
database_b, _local_today(hass)
|
||||||
)
|
)
|
||||||
await hass.services.call(DOMAIN, SERVICE_REGENERATE)
|
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
||||||
today = _local_today(hass)
|
today = _local_today(hass)
|
||||||
end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
|
end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
|
||||||
records = await QuestInstancesDao(database_b).list_by_date_range(
|
records = await QuestInstancesDao(database_b).list_by_date_range(
|
||||||
@@ -647,7 +660,7 @@ async def test_regenerate_service_is_domain_scoped_across_entries(
|
|||||||
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
||||||
|
|
||||||
# It still works for the remaining entry.
|
# It still works for the remaining entry.
|
||||||
await hass.services.call(DOMAIN, SERVICE_REGENERATE)
|
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
||||||
|
|
||||||
# Unloading the final entry removes the domain-global service.
|
# Unloading the final entry removes the domain-global service.
|
||||||
assert await async_unload_entry(hass, entry_b) is True
|
assert await async_unload_entry(hass, entry_b) is True
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ from custom_components.nestquest.const import (
|
|||||||
CONF_DAY_ROLLOVER_TIME,
|
CONF_DAY_ROLLOVER_TIME,
|
||||||
CONF_HORIZON_DAYS,
|
CONF_HORIZON_DAYS,
|
||||||
CONF_PANEL_IDLE_TIMEOUT,
|
CONF_PANEL_IDLE_TIMEOUT,
|
||||||
|
CONF_UPDATE_INTERVAL,
|
||||||
|
DEFAULT_UPDATE_INTERVAL,
|
||||||
DEFAULT_DAY_ROLLOVER_TIME,
|
DEFAULT_DAY_ROLLOVER_TIME,
|
||||||
DEFAULT_HORIZON_DAYS,
|
DEFAULT_HORIZON_DAYS,
|
||||||
DEFAULT_PANEL_IDLE_TIMEOUT,
|
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)))
|
result = _run(handler.async_step_init(dict(VALID_INPUT)))
|
||||||
assert result["type"] == "create_entry"
|
assert result["type"] == "create_entry"
|
||||||
assert result["title"] == "NestQuest"
|
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:
|
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_HORIZON_DAYS: 21,
|
||||||
CONF_DAY_ROLLOVER_TIME: "05:45",
|
CONF_DAY_ROLLOVER_TIME: "05:45",
|
||||||
CONF_PANEL_IDLE_TIMEOUT: 120,
|
CONF_PANEL_IDLE_TIMEOUT: 120,
|
||||||
|
CONF_UPDATE_INTERVAL: 90,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
result = _run(_make_flow(entry).async_step_init(None))
|
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_HORIZON_DAYS: 21,
|
||||||
CONF_DAY_ROLLOVER_TIME: "05:45",
|
CONF_DAY_ROLLOVER_TIME: "05:45",
|
||||||
CONF_PANEL_IDLE_TIMEOUT: 120,
|
CONF_PANEL_IDLE_TIMEOUT: 120,
|
||||||
|
CONF_UPDATE_INTERVAL: 90,
|
||||||
CONF_ADMIN_USER_IDS: [],
|
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"}
|
assert result["errors"] == {CONF_PANEL_IDLE_TIMEOUT: "invalid"}
|
||||||
|
|
||||||
|
|
||||||
def test_options_flow_creates_entry_with_three_keys() -> None:
|
def test_options_flow_creates_entry_with_four_keys() -> None:
|
||||||
"""A valid submit creates an entry with exactly the three validated keys."""
|
"""A valid submit creates an entry with exactly the four validated keys."""
|
||||||
flow = _make_flow(_make_entry())
|
flow = _make_flow(_make_entry())
|
||||||
result = _run(flow.async_step_init(dict(VALID_INPUT)))
|
result = _run(flow.async_step_init(dict(VALID_INPUT)))
|
||||||
assert result["type"] == "create_entry"
|
assert result["type"] == "create_entry"
|
||||||
@@ -292,8 +299,12 @@ def test_options_flow_creates_entry_with_three_keys() -> None:
|
|||||||
CONF_HORIZON_DAYS,
|
CONF_HORIZON_DAYS,
|
||||||
CONF_DAY_ROLLOVER_TIME,
|
CONF_DAY_ROLLOVER_TIME,
|
||||||
CONF_PANEL_IDLE_TIMEOUT,
|
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:
|
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))
|
_run(registry.dispatch_options_update(entry))
|
||||||
assert hass.config_entries.async_reload.call_count == 1
|
assert hass.config_entries.async_reload.call_count == 1
|
||||||
assert registry.reloaded == [entry.entry_id]
|
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:
|
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]
|
||||||
@@ -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}
|
||||||
+559
-7
@@ -1,9 +1,33 @@
|
|||||||
"""Tests for the NestQuest services.yaml declaration file."""
|
"""Tests for NestQuest service declarations and domain-global handlers."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
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 = (
|
SERVICES_PATH = (
|
||||||
Path(__file__).parent.parent
|
Path(__file__).parent.parent
|
||||||
/ "custom_components"
|
/ "custom_components"
|
||||||
@@ -12,10 +36,47 @@ SERVICES_PATH = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
def test_services_yaml_exists() -> None:
|
||||||
assert SERVICES_PATH.exists(), f"services.yaml missing at {SERVICES_PATH}"
|
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:
|
def test_services_yaml_declares_regenerate() -> None:
|
||||||
text = SERVICES_PATH.read_text(encoding="utf-8")
|
text = SERVICES_PATH.read_text(encoding="utf-8")
|
||||||
assert re.search(r"^regenerate:\s*$", text, re.MULTILINE), (
|
assert re.search(r"^regenerate:\s*$", text, re.MULTILINE), (
|
||||||
@@ -27,15 +88,506 @@ def test_services_yaml_regenerate_has_description_and_no_fields() -> None:
|
|||||||
"""The regenerate service is documented with a clear description and no
|
"""The regenerate service is documented with a clear description and no
|
||||||
required fields (there is no ``fields`` block under it)."""
|
required fields (there is no ``fields`` block under it)."""
|
||||||
text = SERVICES_PATH.read_text(encoding="utf-8")
|
text = SERVICES_PATH.read_text(encoding="utf-8")
|
||||||
match = re.search(r"^regenerate:\s*$", text, re.MULTILINE)
|
section = _service_section(text, "regenerate")
|
||||||
assert match is not None
|
|
||||||
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()]
|
|
||||||
assert re.search(r"description:", section), (
|
assert re.search(r"description:", section), (
|
||||||
"regenerate must carry a description"
|
"regenerate must carry a description"
|
||||||
)
|
)
|
||||||
assert "fields:" not in section, (
|
assert "fields:" not in section, (
|
||||||
"regenerate declares no required fields"
|
"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"
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user