Compare commits

...
Author SHA1 Message Date
orchestrator-agent a9d8f1d225 Handle entity lifecycle for child add, deactivate, reactivate
The harness tracks entities per entry so a platform unload removes
exactly that entry's entities; lifecycle tests prove deactivated
children drop out on reload, reactivation restores the original
unique_ids, new children appear without disturbing existing ones,
and the restart equivalent restores live states.
2026-09-19 13:23:35 -04:00
orchestrator-agent a5dbae5d50 Serialize forced coordinator refreshes per entry
NestQuestCoordinator.async_refresh holds a per-entry lock so
overlapping service-path refreshes never interleave; the queued pass
always publishes after the in-flight one.
2026-09-19 13:21:54 -04:00
orchestrator-agent ad56d13c6e Refresh entities immediately after completion service calls
complete_quest and uncomplete_quest force a coordinator refresh when
the call appended, so entity states land the moment the service call
returns; scheduled internals are unaffected.
2026-09-19 13:16:12 -04:00
orchestrator-agent b90f55f750 Fire the documented bus events from the completion service paths 2026-09-19 12:03:44 -04:00
orchestrator-agent 2a4eec658f Decide event transitions under the completion lock
complete/uncomplete return a CompletionResult str subclass carrying
appended (decided with the write under the lock), so duplicate
concurrent calls fire one transition event; day-complete only
evaluates for an instance due today.
2026-09-19 12:03:42 -04:00
orchestrator-agent fe38e5040e Fire the documented bus events from the completion service paths
quest_completed and quest_uncompleted fire on actual transitions with
documented payloads; child_day_complete fires when a completion clears
the child's day; quest_missed is defined as Feature 11's contract.
Payloads documented in the README for automation authors.
2026-09-19 12:01:05 -04:00
orchestrator-agent fa153059cb Add household rollup and cycle-day sensors 2026-09-19 11:59:14 -04:00
orchestrator-agent 5bca24910a Add household rollup and cycle-day sensors
Household quests due/completed today sum the active children's
snapshots; cycle day reports the first scheduled child's cycle
position (0 when nobody is scheduled), anchor-date arithmetic only.
2026-09-19 11:58:12 -04:00
orchestrator-agent 99a8a133e2 Add per-child all-done and present-today binary sensors 2026-09-19 11:57:15 -04:00
11 changed files with 1046 additions and 47 deletions
+29
View File
@@ -38,6 +38,35 @@ After installation and restart:
2. Click **Add Integration** and search for **NestQuest**.
3. Follow the on-screen configuration flow.
## Events
NestQuest fires Home Assistant bus events so automations can react to a
child's progress. Each payload carries `child_id`, `child_name`,
`instance_id`, `quest_title`, the instance's `window`, `due_date`,
`due_time`, and `occurred_at` (a strict UTC ISO-8601 timestamp).
| Event | Fires when | Extra payload fields |
|---|---|---|
| `nestquest_quest_completed` | a quest is completed (an actual completion, never a re-complete no-op) | `was_on_time` (bool) |
| `nestquest_quest_uncompleted` | an admin reverses a completion | — |
| `nestquest_child_day_complete` | a completion clears the child's whole day (quests were owed and none remain; a zero-quest day never fires it) | `quests_due`, `quests_completed` |
| `nestquest_quest_missed` | the nightly sweep marks a still-open past-due quest missed (integrated in a later release) | — |
Example automation trigger:
```yaml
triggers:
- trigger: event
event_type: nestquest_child_day_complete
conditions:
- condition: template
value_template: "{{ trigger.payload.child_name == 'Declan' }}"
actions:
- action: notify.mobile_app_declans_phone
data:
message: "Every quest cleared — legendary!"
```
## Developer Setup and Testing
This project uses [`uv`](https://github.com/astral-sh/uv) for fast Python package and dependency management.
+41 -17
View File
@@ -366,6 +366,25 @@ async def instance_state(
return _derived_state(instance, latest, today_date)
class CompletionResult(str):
"""The derived instance state, carrying the append verdict.
A ``str`` subclass so every existing ``result == "done"`` contract
keeps working; ``appended`` reports whether THIS call actually
appended an event (decided under the same lock as the append), so
a caller — the Feature 09 service path firing bus events —
announces a transition exactly once even under a concurrent
duplicate call where the loser's write is a no-op.
"""
appended: bool
def __new__(cls, state: str, *, appended: bool) -> "CompletionResult":
obj = super().__new__(cls, state)
obj.appended = appended
return obj
async def complete_instance(
database: NestQuestDatabase,
instance_id: int,
@@ -375,14 +394,14 @@ async def complete_instance(
actor_child_id: int | None = None,
now: datetime.datetime | None = None,
today: datetime.date | None = None,
) -> str:
) -> CompletionResult:
"""Append a completed event unless the instance is already done.
Returns the derived state ``done``. An already-done instance is a
no-op: nothing is appended. Unknown ``instance_id`` raises
ValueError naming the field. Existence, latest-event, and the
append run under one connection lock so a concurrent complete
cannot double-append.
Returns the derived state ``done``; ``appended`` reports whether
THIS call appended (False on an already-done no-op). Unknown
``instance_id`` raises ValueError naming the field. Existence,
latest-event, and the append run under one connection lock so a
concurrent complete cannot double-append.
``was_on_time`` is computed from the HA-local completion moment
(threaded ``now`` / ``today``) against the instance ``due_date``
@@ -404,7 +423,7 @@ async def complete_instance(
instance_id
)
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:
raise ValueError(
f"event_type must be '{EVENT_COMPLETED}' or "
@@ -421,7 +440,7 @@ async def complete_instance(
was_on_time=_was_on_time(instance, now, today),
now=now,
)
return "done"
return CompletionResult("done", appended=True)
async def uncomplete_instance(
@@ -433,16 +452,16 @@ async def uncomplete_instance(
actor_child_id: int | None = None,
now: datetime.datetime | None = None,
today: datetime.date | None = None,
) -> str:
) -> 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``). An
already-open instance is a no-op: nothing is appended. 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.
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(
@@ -459,7 +478,10 @@ async def uncomplete_instance(
instance_id
)
if latest is None or latest.event_type == EVENT_UNCOMPLETED:
return _derived_state(instance, latest, today_date)
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 "
@@ -476,7 +498,9 @@ async def uncomplete_instance(
was_on_time=None,
now=now,
)
return _derived_state(instance, latest, today_date)
return CompletionResult(
_derived_state(instance, latest, today_date), appended=True
)
async def list_missed_for_child(
+9
View File
@@ -11,6 +11,15 @@ LOGGER = logging.getLogger(LOGGER_NAME)
# sensors and binary sensors.
PLATFORMS: list[str] = ["sensor", "binary_sensor"]
# Home Assistant bus events (design/ENTITIES-AND-SERVICES.md §3). The
# panel and admin cards listen for these; Feature 10 fires completed,
# uncompleted, and child_day_complete from the service paths, and
# Feature 11's nightly sweep fires quest_missed.
EVENT_QUEST_COMPLETED = "nestquest_quest_completed"
EVENT_QUEST_UNCOMPLETED = "nestquest_quest_uncompleted"
EVENT_QUEST_MISSED = "nestquest_quest_missed"
EVENT_CHILD_DAY_COMPLETE = "nestquest_child_day_complete"
#: Canonical domain-global service names (design/ENTITIES-AND-SERVICES.md §2
#: plus the existing Feature 07 regenerate service).
SERVICE_COMPLETE_QUEST = "complete_quest"
+19 -1
View File
@@ -25,6 +25,7 @@ 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
@@ -97,7 +98,16 @@ class NestQuestSnapshot:
class NestQuestCoordinator(DataUpdateCoordinator):
"""Shared refresh cycle for every NestQuest entity."""
"""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,
@@ -122,6 +132,14 @@ class NestQuestCoordinator(DataUpdateCoordinator):
)
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)
+128
View File
@@ -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)
+121
View File
@@ -369,6 +369,123 @@ def _child_day_sensors(
)
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,
@@ -405,6 +522,10 @@ async def async_setup_entry(
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
+35 -8
View File
@@ -42,6 +42,7 @@ from .const import (
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
@@ -241,6 +242,19 @@ def _require_runtime(hass: HomeAssistant, find_runtime: FindRuntime):
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()
@@ -292,12 +306,19 @@ def _complete_quest(
hass: HomeAssistant, find_runtime: FindRuntime
) -> ServiceHandler:
async def _handler(call: Any) -> None:
database = _require_runtime(hass, find_runtime).database
await completion_layer.complete_instance(
database,
call.data["instance_id"],
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)
@@ -306,12 +327,18 @@ def _uncomplete_quest(
hass: HomeAssistant, find_runtime: FindRuntime
) -> ServiceHandler:
async def _handler(call: Any) -> None:
database = _require_runtime(hass, find_runtime).database
await completion_layer.uncomplete_instance(
database,
call.data["instance_id"],
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)
+60 -16
View File
@@ -549,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
class EventBus:
"""Models HA's ``hass.bus`` event bus.
``async_fire`` is SYNCHRONOUS (matching real HA) and records
``(event_type, payload)`` pairs in order so tests can assert
exactly which events fired with which payloads; there is no
listener dispatch (entities poll the coordinator, and the cards
are not part of this harness).
"""
def __init__(self):
self.events: list[tuple[str, dict]] = []
def async_fire(self, event_type: str, payload=None):
self.events.append((event_type, dict(payload or {})))
def fired(self, event_type: str) -> list[dict]:
"""Return every payload fired for ``event_type``, in order."""
return [
payload
for fired_type, payload in self.events
if fired_type == event_type
]
class ServiceRegistry:
"""Models HA's ``hass.services`` service registry.
@@ -671,6 +696,7 @@ def make_hass() -> tuple:
registry = ListenerRegistry(hass)
hass.time_change = TimeChangeRegistry(hass)
hass.services = ServiceRegistry(hass)
hass.bus = EventBus()
# A valid IANA time zone (the day-rollover listener reads it to
# compute "today" in HA local time).
hass.config.time_zone = "UTC"
@@ -678,28 +704,40 @@ def make_hass() -> tuple:
# 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.
# directly. entities_by_entry mirrors HA's per-entry platform
# scoping: unloading an entry's platforms removes exactly that
# entry's entities.
hass.entities: dict[str, Entity] = {}
hass.entities_by_entry: dict[str, dict[str, Entity]] = {}
def _async_add_entities(new_entities, update_before_add=False):
"""Mirror HA's AddEntitiesCallback: bind hass and register entities.
def _make_add_entities(entry):
entry_entities = hass.entities_by_entry.setdefault(entry.entry_id, {})
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()
def _async_add_entities(new_entities, update_before_add=False):
"""Mirror HA's AddEntitiesCallback: bind hass and register.
Accepts a single entity or an iterable, mirroring HA's
permissive call shape; registration goes through each
entity's async_write_ha_state (the mock state write) and
is recorded per entry so an unload removes exactly this
entry's entities.
"""
if isinstance(new_entities, Entity):
new_entities = [new_entities]
for entity in new_entities:
entity.hass = hass
entity.async_write_ha_state()
if entity.unique_id is not None:
entry_entities[entity.unique_id] = entity
return _async_add_entities
async def _async_forward_entry_setups(entry, platforms):
"""Mirror hass.config_entries.async_forward_entry_setups.
HA's contract, loosely: import each platform module of the
entry's integration and drive its ``async_setup_entry`` with
the AddEntitiesCallback above. A stub entry without a domain
entry's integration and drive its ``async_setup_entry`` with a
per-entry AddEntitiesCallback. A stub entry without a domain
defaults to this harness's single integration (NestQuest).
"""
for platform in list(platforms):
@@ -707,14 +745,18 @@ def make_hass() -> tuple:
module = importlib.import_module(
f"custom_components.{domain}.{platform}"
)
await module.async_setup_entry(hass, entry, _async_add_entities)
await module.async_setup_entry(
hass, entry, _make_add_entities(entry)
)
hass.config_entries.forwarded_platforms.append(platform)
async def _async_unload_platforms(entry, platforms):
"""Mirror hass.config_entries.async_unload_platforms.
Runs each platform module's ``async_unload_entry`` when it
defines one (per-platform unload is optional, like HA's).
defines one (per-platform unload is optional, like HA's), then
removes the entry's entities — unloading an entry's platforms
removes exactly that entry's entities, never another entry's.
"""
for platform in list(platforms):
domain = getattr(entry, "domain", "nestquest")
@@ -725,6 +767,8 @@ def make_hass() -> tuple:
if unload is not None:
await unload(hass, entry)
hass.config_entries.unloaded_platforms.append(platform)
for unique_id in hass.entities_by_entry.pop(entry.entry_id, {}):
hass.entities.pop(unique_id, None)
hass.config_entries.forwarded_platforms: list[str] = []
hass.config_entries.unloaded_platforms: list[str] = []
+159
View File
@@ -0,0 +1,159 @@
"""Entity lifecycle across child add/deactivate/reactivate (Feature 10, task 8)."""
from __future__ import annotations
import datetime
from conftest import wire_entry_to_registry
from custom_components.nestquest import async_setup_entry, async_unload_entry
from custom_components.nestquest.children import (
create_child,
list_children,
set_child_active,
)
from custom_components.nestquest.const import DOMAIN
from custom_components.nestquest.materialize import materialize
from custom_components.nestquest.quest_definitions import (
create_quest_definition,
)
from custom_components.nestquest.recurrence import ScheduleRule
SEED_HORIZON_DAYS = 3
PER_CHILD_KINDS = (
"quests_due_today",
"quests_completed_today",
"quests_remaining_today",
"completion_pct_today",
"next_quest",
"all_done",
"present_today",
)
HOUSEHOLD_UNIQUE_IDS = (
"nestquest_household_quests_due_today",
"nestquest_household_quests_completed_today",
"nestquest_cycle_day",
)
async def _reload(hass, entry) -> None:
"""Unload then set up: the harness's restart/reload equivalent."""
assert await async_unload_entry(hass, entry) is True
assert await async_setup_entry(hass, entry) is True
def _child_unique_ids(child_id: int) -> set[str]:
return {f"nestquest_child_{child_id}_{kind}" for kind in PER_CHILD_KINDS}
async def _setup_and_seed(hass, make_entry):
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
database = entry.runtime_data.database
ada = await create_child(database, "Ada")
bo = await create_child(database, "Bo")
today = datetime.date.today()
await create_quest_definition(
database,
"Brush teeth",
ScheduleRule.from_dict(
{"rule_type": "daily", "start_date": today.isoformat()}
),
[ada.id, bo.id],
["morning"],
)
end = (today + datetime.timedelta(days=SEED_HORIZON_DAYS)).isoformat()
await materialize(database, today.isoformat(), end, today=today)
await entry.runtime_data.coordinator.async_refresh()
return entry, ada, bo
async def test_deactivated_child_entities_removed_on_reload(
hass, make_entry
) -> None:
entry, ada, bo = await _setup_and_seed(hass, make_entry)
assert _child_unique_ids(ada.id) <= set(hass.entities)
assert _child_unique_ids(bo.id) <= set(hass.entities)
await set_child_active(entry.runtime_data.database, bo.id, False)
await _reload(hass, entry)
assert _child_unique_ids(ada.id) <= set(hass.entities), (
"the surviving child's entities must be untouched"
)
assert not (_child_unique_ids(bo.id) & set(hass.entities)), (
"the deactivated child's entities must be gone after reload"
)
async def test_reactivation_restores_entities_with_original_unique_ids(
hass, make_entry
) -> None:
entry, ada, bo = await _setup_and_seed(hass, make_entry)
original = _child_unique_ids(bo.id)
await set_child_active(entry.runtime_data.database, bo.id, False)
await _reload(hass, entry)
assert not (original & set(hass.entities))
await set_child_active(entry.runtime_data.database, bo.id, True)
await _reload(hass, entry)
assert original <= set(hass.entities), (
"reactivation must restore the exact original unique_ids"
)
bo_due = hass.entities[f"nestquest_child_{bo.id}_quests_due_today"]
assert bo_due.native_value == 1, "restored entities carry live state"
async def test_new_child_gets_entities_without_disturbing_existing(
hass, make_entry
) -> None:
entry, ada, bo = await _setup_and_seed(hass, make_entry)
before_ada = set(hass.entities) & _child_unique_ids(ada.id)
cory = await create_child(entry.runtime_data.database, "Cory")
await _reload(hass, entry)
assert before_ada <= set(hass.entities), "existing entities untouched"
assert _child_unique_ids(cory.id) <= set(hass.entities), (
"the new child's entities appear after reload"
)
assert hass.entities[f"nestquest_child_{cory.id}_quests_due_today"].native_value == 0
async def test_restart_equivalent_restores_all_entity_states(
hass, make_entry
) -> None:
"""Unload + setup (the restart equivalent) restores every active
child's entities with correct states and the household rollups."""
from custom_components.nestquest.completion import complete_instance
entry, ada, bo = await _setup_and_seed(hass, make_entry)
coordinator = entry.runtime_data.coordinator
snapshot_ada = next(
child
for child in coordinator.data.children
if child.child_id == ada.id
)
await complete_instance(
entry.runtime_data.database,
snapshot_ada.instances[0].instance_id,
actor_source="panel",
actor_child_id=ada.id,
)
await coordinator.async_refresh()
assert hass.entities[
f"nestquest_child_{ada.id}_quests_completed_today"
].native_value == 1
await _reload(hass, entry)
assert hass.entities[
f"nestquest_child_{ada.id}_quests_completed_today"
].native_value == 1, "completed count survives the restart"
assert hass.entities[
f"nestquest_child_{bo.id}_quests_completed_today"
].native_value == 0
assert set(HOUSEHOLD_UNIQUE_IDS) <= set(hass.entities)
assert hass.entities[
"nestquest_household_quests_completed_today"
].native_value == 1
+379
View File
@@ -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)
)
+66 -5
View File
@@ -112,8 +112,9 @@ async def test_every_active_child_gets_five_sensors_with_documented_identity(
await coordinator.async_refresh()
# Two quest children plus the zero-quest child: three children x
# (five sensors + two binary sensors), no duplicates.
assert len(hass.entities) == 21
# (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),
@@ -327,15 +328,22 @@ async def test_repeated_refreshes_do_not_duplicate_entities(
)
await coordinator.async_refresh()
await coordinator.async_refresh()
assert len(hass.entities) == 21
assert len(hass.entities) == 24
async def test_setup_with_no_children_registers_no_entities(
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
assert hass.entities == {}
# 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:
@@ -452,3 +460,56 @@ async def test_next_quest_state_truncates_over_the_limit(
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()
)