diff --git a/README.md b/README.md index dfa59e7..441ad74 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index b0edf5b..5c66d8c 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -20,11 +20,15 @@ from .const import ( CONF_ADMIN_USER_IDS, CONF_DAY_ROLLOVER_TIME, CONF_HORIZON_DAYS, + CONF_UPDATE_INTERVAL, DEFAULT_DAY_ROLLOVER_TIME, DEFAULT_HORIZON_DAYS, + DEFAULT_UPDATE_INTERVAL, DOMAIN, LOGGER, + PLATFORMS, ) +from .coordinator import NestQuestCoordinator from .db import NestQuestDatabase from .materialize import materialize as _materialize_run from .migrations import apply_migrations @@ -238,6 +242,7 @@ class NestQuestRuntimeData: database: NestQuestDatabase remove_update_listener: Callable[[], Any] remove_time_change_listener: Callable[[], Any] + coordinator: Any = None async def _async_owner_user_ids(hass: HomeAssistant) -> list[str]: @@ -456,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)) remove_update_listener: Callable[[], Any] | None = None remove_time_change_listener: Callable[[], Any] | None = None + coordinator: Any = None try: # Seed the admin allowlist on first setup so the owner is never # locked out: an empty database takes the persisted admin copy @@ -493,6 +499,33 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, database, _configured_horizon_days(dict(entry.options)) ) _register_services(hass) + # The shared Feature 10 coordinator: one refresh cycle every + # entity reads from (CONF_UPDATE_INTERVAL seconds, default + # five minutes). Created AFTER services so a failure below + # unwinds them through the except path's listener handling. + coordinator = NestQuestCoordinator( + hass, + entry_id=entry.entry_id, + database=database, + update_interval_seconds=entry.options.get( + CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL + ), + ) + await coordinator.async_config_entry_first_refresh() + runtime_data = NestQuestRuntimeData( + entry_id=entry.entry_id, + options=dict(entry.options), + database=database, + remove_update_listener=remove_update_listener, + remove_time_change_listener=remove_time_change_listener, + coordinator=coordinator, + ) + # runtime_data is assigned BEFORE the platforms forward so each + # platform's async_setup_entry reads its coordinator off the + # entry (the HA runtime-data contract); a forward failure below + # unwinds it through the except path. + entry.runtime_data = runtime_data + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) except BaseException: # A failure anywhere after the database is open must not leak the # listeners already registered against that (now-closed) database: @@ -511,18 +544,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await result except BaseException: pass + # A coordinator created before the failure holds entity + # listeners pointed at the database being closed below — + # shut it down FIRST so no scheduled refresh can ever run + # against a closed connection. + if coordinator is not None: + try: + await coordinator.async_shutdown() + except BaseException: + pass + # A platform-forward failure reached this unwind AFTER + # runtime_data was assigned: clear it so no later unload path + # closes the already-closed database through the stale record. + if getattr(entry, "runtime_data", None) is not None: + entry.runtime_data = None await database.close() raise assert remove_update_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 return True @@ -536,6 +575,20 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.pop(DOMAIN, None) if runtime_data is None: runtime_data = getattr(entry, "runtime_data", None) + # Platforms unload FIRST (mirroring HA conventions): entity + # listeners must not outlive the database and coordinator the + # teardown below closes. + platforms_unloaded = await hass.config_entries.async_unload_platforms( + entry, PLATFORMS + ) + if platforms_unloaded is False and runtime_data is not None: + # Home Assistant KEPT one or more platforms loaded — their + # entities still live and must keep their database. Restore + # the runtime record popped above and report failure so HA + # retries the unload later instead of leaving live entities + # backed by a closed database. + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = runtime_data + return False unload_error: BaseException | None = None if runtime_data is not None: remove_update_listener = getattr(runtime_data, "remove_update_listener", None) @@ -562,6 +615,16 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_error is None: unload_error = err finally: + # The coordinator's entity listeners must stop BEFORE the + # database closes: a scheduled refresh starting during the + # close await would touch a cleared connection. (The + # platform unload above already removed the entities; this + # clears the coordinator's own remaining listeners.) + coordinator = getattr(runtime_data, "coordinator", None) + if coordinator is not None: + shutdown = getattr(coordinator, "async_shutdown", None) + if shutdown is not None: + await shutdown() database = getattr(runtime_data, "database", None) if database is not None: await database.close() diff --git a/custom_components/nestquest/binary_sensor.py b/custom_components/nestquest/binary_sensor.py new file mode 100644 index 0000000..7cd45b3 --- /dev/null +++ b/custom_components/nestquest/binary_sensor.py @@ -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__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__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) diff --git a/custom_components/nestquest/completion.py b/custom_components/nestquest/completion.py index 15aa11c..bb6becb 100644 --- a/custom_components/nestquest/completion.py +++ b/custom_components/nestquest/completion.py @@ -128,6 +128,21 @@ def _derived_state( ) +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, @@ -351,6 +366,36 @@ 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. A completion + also carries the ``was_on_time`` verdict computed under that same + lock, so the event payload never re-reads mutable latest state a + racing reversal could have replaced. + """ + + appended: bool + was_on_time: bool | None + + def __new__( + cls, + state: str, + *, + appended: bool, + was_on_time: bool | None = None, + ) -> "CompletionResult": + obj = super().__new__(cls, state) + obj.appended = appended + obj.was_on_time = was_on_time + return obj + + async def complete_instance( database: NestQuestDatabase, instance_id: int, @@ -360,14 +405,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`` @@ -389,12 +434,16 @@ 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 " f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}" ) + # Computed under the completion lock and carried on the result: + # the bus event must report THIS completion's timing, never a + # re-read of the latest event a racing reversal could replace. + on_time = _was_on_time(instance, now, today) await append_event( database, instance.id, @@ -403,10 +452,10 @@ async def complete_instance( actor_source=actor_source, actor_user_id=actor_user_id, actor_child_id=actor_child_id, - was_on_time=_was_on_time(instance, now, today), + was_on_time=on_time, now=now, ) - return "done" + return CompletionResult("done", appended=True, was_on_time=on_time) async def uncomplete_instance( @@ -418,16 +467,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( @@ -444,7 +493,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 " @@ -461,7 +513,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( diff --git a/custom_components/nestquest/const.py b/custom_components/nestquest/const.py index 6238252..924898f 100644 --- a/custom_components/nestquest/const.py +++ b/custom_components/nestquest/const.py @@ -7,7 +7,18 @@ DOMAIN = "nestquest" LOGGER_NAME = "custom_components.nestquest" LOGGER = logging.getLogger(LOGGER_NAME) -PLATFORMS: list[str] = [] +# Platforms set up per config entry (Feature 10): the per-child day +# sensors and binary sensors. +PLATFORMS: list[str] = ["sensor", "binary_sensor"] + +# Home Assistant bus events (design/ENTITIES-AND-SERVICES.md §3). The +# panel and admin cards listen for these; Feature 10 fires completed, +# uncompleted, and child_day_complete from the service paths, and +# Feature 11's nightly sweep fires quest_missed. +EVENT_QUEST_COMPLETED = "nestquest_quest_completed" +EVENT_QUEST_UNCOMPLETED = "nestquest_quest_uncompleted" +EVENT_QUEST_MISSED = "nestquest_quest_missed" +EVENT_CHILD_DAY_COMPLETE = "nestquest_child_day_complete" #: Canonical domain-global service names (design/ENTITIES-AND-SERVICES.md §2 #: plus the existing Feature 07 regenerate service). @@ -55,6 +66,13 @@ DEFAULT_PANEL_IDLE_TIMEOUT = 300 # list as a disaster-recovery copy so a lost database can be re-seeded. CONF_ADMIN_USER_IDS = "admin_user_ids" +# Shared coordinator refresh interval (Feature 10), in SECONDS. Every +# entity reads one snapshot per refresh; completions push an immediate +# refresh so this poll is only the fallback cadence. +CONF_UPDATE_INTERVAL = "update_interval" +DEFAULT_UPDATE_INTERVAL = 300 +MIN_UPDATE_INTERVAL = 30 + # Quest windows (D-008): a definition may declare several day windows; # each produces its own instance per day and groups the panel's Quest # Log into three columns. The clock ranges are stored here so they stay diff --git a/custom_components/nestquest/coordinator.py b/custom_components/nestquest/coordinator.py new file mode 100644 index 0000000..08f9ad9 --- /dev/null +++ b/custom_components/nestquest/coordinator.py @@ -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 diff --git a/custom_components/nestquest/events.py b/custom_components/nestquest/events.py new file mode 100644 index 0000000..129af0b --- /dev/null +++ b/custom_components/nestquest/events.py @@ -0,0 +1,130 @@ +"""HA bus events fired by the NestQuest service paths (Feature 10). + +The four documented events (design/ENTITIES-AND-SERVICES.md §3, names +in :mod:`~.const`): ``nestquest_quest_completed`` and +``nestquest_quest_uncompleted`` fire from the completion service +handlers on an actual transition (a no-op re-complete or re-uncomplete +fires nothing), and ``nestquest_child_day_complete`` fires when a +completion clears the child's whole day (quests were owed and none +remain — a zero-quest day can never fire it, matching the all-done +binary sensor's rule). ``nestquest_quest_missed`` is the documented +contract Feature 11's nightly sweep fires; nothing here fires it. + +Payload policy: one dict carrying ``child_id``, ``child_name``, +``instance_id``, ``quest_title``, the instance's ``window``/ +``due_date``/``due_time`` so automation authors can filter without a +database lookup, and ``occurred_at`` — the event's own strict UTC +ISO-8601 timestamp. Completion payloads additionally carry +``was_on_time``. No other personal data: names and titles are the +household's own records, and nothing beyond them leaks. +""" +from __future__ import annotations + +import datetime +from zoneinfo import ZoneInfo + +from .completion import derive_state +from .const import ( + EVENT_CHILD_DAY_COMPLETE, + EVENT_QUEST_COMPLETED, + EVENT_QUEST_UNCOMPLETED, +) +from .dao_children import ChildrenDao +from .dao_instances import CompletionEventsDao, QuestInstancesDao +from .dao_rules import QuestDefinitionsDao +from .db import NestQuestDatabase + +#: Strict UTC ISO-8601 timestamp (the DAO layers' policy): one shape, +#: explicit offset, second precision. +_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00" + + +def _now_stamp() -> str: + return datetime.datetime.now(datetime.timezone.utc).strftime( + _UTC_TIMESTAMP_FORMAT + ) + + +async def _instance_payload( + database: NestQuestDatabase, instance_id: int, now_stamp: str +) -> dict: + """Build the shared event payload for one quest instance.""" + instance = await QuestInstancesDao(database).get_by_id(instance_id) + if instance is None: + raise ValueError( + f"instance_id: quest instance {instance_id} does not exist" + ) + child = await ChildrenDao(database).get(instance.child_id) + definition = await QuestDefinitionsDao(database).get( + instance.definition_id + ) + return { + "child_id": instance.child_id, + "child_name": child.display_name if child else None, + "instance_id": instance.id, + "quest_title": definition.title if definition else None, + "window": instance.window, + "due_date": instance.due_date, + "due_time": instance.due_time, + "occurred_at": now_stamp, + } + + +async def fire_quest_completed( + hass, + database: NestQuestDatabase, + instance_id: int, + *, + was_on_time: bool | None, +) -> None: + """Fire ``nestquest_quest_completed`` and, when the completion + cleared the child's whole day, ``nestquest_child_day_complete``. + + Called by the complete_quest service handler AFTER an actual + completion transition, with the completion's ``was_on_time`` — + computed under the completion lock and carried on the result — so + the payload never re-reads mutable latest state a racing reversal + could have replaced. A no-op re-complete never reaches here. + """ + now_stamp = _now_stamp() + payload = await _instance_payload(database, instance_id, now_stamp) + payload["was_on_time"] = was_on_time + hass.bus.async_fire(EVENT_QUEST_COMPLETED, payload) + + # Day-complete: quests were owed today and none remain (the same + # rule as the all-done binary sensor; a zero-quest day never fires). + # Only a completion of TODAY'S instance evaluates it: completing a + # back-dated or future instance must not announce a cleared day. + today = datetime.datetime.now(ZoneInfo(hass.config.time_zone)).date() + if payload["due_date"] != today.isoformat(): + return + instances = await QuestInstancesDao(database).list_by_date_range( + payload["child_id"], today.isoformat(), today.isoformat() + ) + events = CompletionEventsDao(database) + remaining = 0 + for instance in instances: + latest_for_instance = await events.get_latest_for_instance( + instance.id + ) + if derive_state(instance, latest_for_instance, today) != "done": + remaining += 1 + if instances and remaining == 0: + hass.bus.async_fire( + EVENT_CHILD_DAY_COMPLETE, + { + "child_id": payload["child_id"], + "child_name": payload["child_name"], + "quests_due": len(instances), + "quests_completed": len(instances), + "occurred_at": now_stamp, + }, + ) + + +async def fire_quest_uncompleted( + hass, database: NestQuestDatabase, instance_id: int +) -> None: + """Fire ``nestquest_quest_uncompleted`` for an actual reversal.""" + payload = await _instance_payload(database, instance_id, _now_stamp()) + hass.bus.async_fire(EVENT_QUEST_UNCOMPLETED, payload) diff --git a/custom_components/nestquest/options_flow.py b/custom_components/nestquest/options_flow.py index 84ac218..32ddd3b 100644 --- a/custom_components/nestquest/options_flow.py +++ b/custom_components/nestquest/options_flow.py @@ -14,10 +14,13 @@ from .const import ( CONF_DAY_ROLLOVER_TIME, CONF_HORIZON_DAYS, CONF_PANEL_IDLE_TIMEOUT, + CONF_UPDATE_INTERVAL, DEFAULT_DAY_ROLLOVER_TIME, DEFAULT_HORIZON_DAYS, DEFAULT_PANEL_IDLE_TIMEOUT, + DEFAULT_UPDATE_INTERVAL, DOMAIN, + MIN_UPDATE_INTERVAL, ) from .admin_allowlist import list_admin_ids, set_admin_ids @@ -56,6 +59,18 @@ def _async_validate(user_input: dict[str, Any]) -> dict[str, str]: ): errors[CONF_PANEL_IDLE_TIMEOUT] = "invalid" + # The coordinator refresh interval (Feature 10) is OPTIONAL in raw + # submits: a missing key keeps the current value, so pre-Feature-10 + # form payloads (and API callers) are unaffected. Schema-validated + # submits always carry it. + update_interval = user_input.get(CONF_UPDATE_INTERVAL) + if update_interval is not None and ( + isinstance(update_interval, bool) + or not isinstance(update_interval, int) + or update_interval < MIN_UPDATE_INTERVAL + ): + errors[CONF_UPDATE_INTERVAL] = "invalid" + return errors @@ -82,6 +97,9 @@ def _build_schema( vol.Required( CONF_PANEL_IDLE_TIMEOUT, default=current[CONF_PANEL_IDLE_TIMEOUT] ): int, + vol.Required( + CONF_UPDATE_INTERVAL, default=current[CONF_UPDATE_INTERVAL] + ): int, } if admin_choices is not None: # HA's supported multi-select validator: the frontend can @@ -210,6 +228,10 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry): CONF_HORIZON_DAYS: user_input[CONF_HORIZON_DAYS], CONF_DAY_ROLLOVER_TIME: user_input[CONF_DAY_ROLLOVER_TIME], CONF_PANEL_IDLE_TIMEOUT: user_input[CONF_PANEL_IDLE_TIMEOUT], + CONF_UPDATE_INTERVAL: user_input.get( + CONF_UPDATE_INTERVAL, + self._current_values()[CONF_UPDATE_INTERVAL], + ), } if admin_ids_present: data[CONF_ADMIN_USER_IDS] = admin_ids @@ -231,6 +253,7 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry): (CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS), (CONF_DAY_ROLLOVER_TIME, DEFAULT_DAY_ROLLOVER_TIME), (CONF_PANEL_IDLE_TIMEOUT, DEFAULT_PANEL_IDLE_TIMEOUT), + (CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL), ): if key in options: values[key] = options[key] diff --git a/custom_components/nestquest/sensor.py b/custom_components/nestquest/sensor.py new file mode 100644 index 0000000..0157750 --- /dev/null +++ b/custom_components/nestquest/sensor.py @@ -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__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) diff --git a/custom_components/nestquest/services.py b/custom_components/nestquest/services.py index 9c6d765..0c66635 100644 --- a/custom_components/nestquest/services.py +++ b/custom_components/nestquest/services.py @@ -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,24 @@ 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, + was_on_time=result.was_on_time, + ) + await _refresh_entities(runtime) return _with_errors(_handler) @@ -306,12 +332,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) diff --git a/tests/conftest.py b/tests/conftest.py index f9e31bc..e2dfd3d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -27,6 +27,7 @@ import inspect import sys import tempfile import weakref +from dataclasses import dataclass from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock @@ -44,7 +45,12 @@ _HA_MODULES = ( "homeassistant.exceptions", "homeassistant.helpers", "homeassistant.helpers.config_validation", + "homeassistant.helpers.entity", "homeassistant.helpers.event", + "homeassistant.helpers.update_coordinator", + "homeassistant.components", + "homeassistant.components.sensor", + "homeassistant.components.binary_sensor", ) def _find_spec(name: str): @@ -84,7 +90,12 @@ for _parent, _child in ( ("homeassistant", "exceptions"), ("homeassistant", "helpers"), ("homeassistant.helpers", "config_validation"), + ("homeassistant.helpers", "entity"), ("homeassistant.helpers", "event"), + ("homeassistant.helpers", "update_coordinator"), + ("homeassistant", "components"), + ("homeassistant.components", "sensor"), + ("homeassistant.components", "binary_sensor"), ): setattr(sys.modules[_parent], _child, sys.modules[f"{_parent}.{_child}"]) @@ -197,6 +208,227 @@ _exceptions_mock.HomeAssistantError = HomeAssistantError _exceptions_mock.ConfigEntryNotReady = ConfigEntryNotReady _exceptions_mock.Unauthorized = Unauthorized + +@dataclass +class DeviceInfo: + """Stand-in for homeassistant.helpers.entity.DeviceInfo. + + The kwargs-only shape the integration builds per-child devices + with: ``identifiers``, ``name``, ``manufacturer``, ``model``. + """ + + identifiers: object = None + name: str | None = None + manufacturer: str | None = None + model: str | None = None + + +class Entity: + """Stand-in for homeassistant.helpers.entity.Entity. + + Mirrors the ``_attr_*`` attribute contract through the ``name``, + ``unique_id``, ``device_info`` and ``extra_state_attributes`` + properties, and ``async_write_ha_state`` registering the entity + into the hass entity registry (``hass.entities``, a dict keyed by + ``unique_id`` storing the live object) — the harness's stand-in + for HA's state machine, so tests enumerate entities and read + their state directly off the registered object. + """ + + hass = None + _attr_name: str | None = None + _attr_unique_id: str | None = None + _attr_device_info: DeviceInfo | None = None + _attr_extra_state_attributes: dict | None = None + + @property + def name(self) -> str | None: + return self._attr_name + + @property + def unique_id(self) -> str | None: + return self._attr_unique_id + + @property + def device_info(self) -> DeviceInfo | None: + return self._attr_device_info + + @property + def extra_state_attributes(self) -> dict | None: + return self._attr_extra_state_attributes + + def async_write_ha_state(self) -> None: + """Register the entity under its unique_id (the mock state write).""" + if self.hass is not None and self.unique_id is not None: + self.hass.entities[self.unique_id] = self + + +class CoordinatorEntity(Entity): + """Stand-in for homeassistant.helpers.update_coordinator.CoordinatorEntity. + + Real HA subscribes the entity to coordinator notifications in the + platform-scheduler-driven ``async_added_to_hass`` lifecycle hook; + the stand-in subscribes eagerly in ``__init__`` so the synchronous + ``AddEntitiesCallback`` surface stays synchronous (the mock + harness has no lifecycle scheduler). ``_handle_coordinator_update`` + mirrors HA: one state write per coordinator notification. + """ + + def __init__(self, coordinator) -> None: + self.coordinator = coordinator + self.coordinator.async_add_listener(self._handle_coordinator_update) + + def _handle_coordinator_update(self) -> None: + self.async_write_ha_state() + + +class SensorStateClass: + """Stand-in namespace for homeassistant.components.sensor.SensorStateClass. + + Only the members the integration uses (plus TOTAL for the + long-history sensors later tasks add). + """ + + MEASUREMENT = "measurement" + TOTAL = "total" + + +class SensorDeviceClass: + """Stand-in namespace for homeassistant.components.sensor.SensorDeviceClass. + + Only the members the integration uses. + """ + + PERCENTAGE = "percentage" + + +class SensorEntity(Entity): + """Stand-in for homeassistant.components.sensor.SensorEntity. + + The ``_attr_*`` passthrough for ``native_value``, + ``native_unit_of_measurement``, ``state_class``, ``device_class`` + and ``suggested_display_precision``. + """ + + _attr_native_value: object = None + _attr_native_unit_of_measurement: str | None = None + _attr_state_class: str | None = None + _attr_device_class: str | None = None + _attr_suggested_display_precision: int | None = None + + @property + def native_value(self) -> object: + return self._attr_native_value + + @property + def native_unit_of_measurement(self) -> str | None: + return self._attr_native_unit_of_measurement + + @property + def state_class(self) -> str | None: + return self._attr_state_class + + @property + def device_class(self) -> str | None: + return self._attr_device_class + + @property + def suggested_display_precision(self) -> int | None: + return self._attr_suggested_display_precision + + +class BinarySensorEntity(Entity): + """Stand-in for homeassistant.components.binary_sensor.BinarySensorEntity. + + Wired now so PLATFORMS can grow (task 4's all-done / present + sensors) without more harness surgery; only the surface the + integration uses. + """ + + _attr_is_on: bool | None = None + _attr_device_class: str | None = None + + @property + def is_on(self) -> bool | None: + return self._attr_is_on + + @property + def device_class(self) -> str | None: + return self._attr_device_class + + +class DataUpdateCoordinator: + """Functional stand-in for HA's DataUpdateCoordinator. + + Mirrors the surface NestQuest's coordinator subclass uses: + ``__init__(hass, logger, name=..., update_interval=...)``, + ``async_add_listener``/``async_update_listeners`` for entity + subscription, ``async_refresh``/``async_config_entry_first_refresh`` + driving the subclass's ``_async_update_data``, ``last_update_success``, + and ``data``. Interval scheduling is NOT simulated (no real clock + ticks in tests) — tests drive refreshes directly. + """ + + def __init__(self, hass, logger, *, name=None, update_interval=None): + self.hass = hass + self.logger = logger + self.name = name + self.update_interval = update_interval + self.data = None + self.last_update_success = False + self._listeners = [] + + def async_add_listener(self, update_callback, context=None): + """Subscribe; returns an unsubscribe callable like HA's.""" + entry = (update_callback, context) + self._listeners.append(entry) + + def _remove(): + if entry in self._listeners: + self._listeners.remove(entry) + + return _remove + + def async_update_listeners(self): + """Notify every listener (entities schedule their writes).""" + for callback, _context in list(self._listeners): + callback() + + async def async_refresh(self): + """Run one update pass and notify listeners.""" + try: + self.data = await self._async_update_data() + self.last_update_success = True + except BaseException: + self.last_update_success = False + raise + self.async_update_listeners() + + async def async_config_entry_first_refresh(self): + """First refresh at config-entry setup (alias of refresh).""" + await self.async_refresh() + + async def async_shutdown(self): + """Release listeners on teardown.""" + self._listeners.clear() + + +_update_coordinator_mock = _ha_mock("homeassistant.helpers.update_coordinator") +_update_coordinator_mock.DataUpdateCoordinator = DataUpdateCoordinator +_update_coordinator_mock.CoordinatorEntity = CoordinatorEntity + +_entity_mock = _ha_mock("homeassistant.helpers.entity") +_entity_mock.DeviceInfo = DeviceInfo +_entity_mock.Entity = Entity + +_sensor_mock = _ha_mock("homeassistant.components.sensor") +_sensor_mock.SensorEntity = SensorEntity +_sensor_mock.SensorDeviceClass = SensorDeviceClass +_sensor_mock.SensorStateClass = SensorStateClass + +_binary_sensor_mock = _ha_mock("homeassistant.components.binary_sensor") +_binary_sensor_mock.BinarySensorEntity = BinarySensorEntity + import voluptuous as vol @@ -317,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. @@ -439,10 +696,85 @@ 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" + # The minimal entity registry: entities self-register through + # Entity.async_write_ha_state keyed by unique_id, storing the live + # object; tests enumerate created entities and read their state + # directly. entities_by_entry mirrors HA's per-entry platform + # scoping: unloading an entry's platforms removes exactly that + # entry's entities. + hass.entities: dict[str, Entity] = {} + hass.entities_by_entry: dict[str, dict[str, Entity]] = {} + + def _make_add_entities(entry): + entry_entities = hass.entities_by_entry.setdefault(entry.entry_id, {}) + + def _async_add_entities(new_entities, update_before_add=False): + """Mirror HA's AddEntitiesCallback: bind hass and register. + + Accepts a single entity or an iterable, mirroring HA's + permissive call shape; registration goes through each + entity's async_write_ha_state (the mock state write) and + is recorded per entry so an unload removes exactly this + entry's entities. + """ + if isinstance(new_entities, Entity): + new_entities = [new_entities] + for entity in new_entities: + entity.hass = hass + entity.async_write_ha_state() + if entity.unique_id is not None: + entry_entities[entity.unique_id] = entity + + return _async_add_entities + + async def _async_forward_entry_setups(entry, platforms): + """Mirror hass.config_entries.async_forward_entry_setups. + + HA's contract, loosely: import each platform module of the + entry's integration and drive its ``async_setup_entry`` with a + per-entry AddEntitiesCallback. A stub entry without a domain + defaults to this harness's single integration (NestQuest). + """ + for platform in list(platforms): + domain = getattr(entry, "domain", "nestquest") + module = importlib.import_module( + f"custom_components.{domain}.{platform}" + ) + await module.async_setup_entry( + hass, entry, _make_add_entities(entry) + ) + hass.config_entries.forwarded_platforms.append(platform) + + async def _async_unload_platforms(entry, platforms): + """Mirror hass.config_entries.async_unload_platforms. + + Runs each platform module's ``async_unload_entry`` when it + defines one (per-platform unload is optional, like HA's), then + removes the entry's entities — unloading an entry's platforms + removes exactly that entry's entities, never another entry's. + """ + for platform in list(platforms): + domain = getattr(entry, "domain", "nestquest") + module = importlib.import_module( + f"custom_components.{domain}.{platform}" + ) + unload = getattr(module, "async_unload_entry", None) + if unload is not None: + await unload(hass, entry) + hass.config_entries.unloaded_platforms.append(platform) + for unique_id in hass.entities_by_entry.pop(entry.entry_id, {}): + hass.entities.pop(unique_id, None) + + hass.config_entries.forwarded_platforms: list[str] = [] + hass.config_entries.unloaded_platforms: list[str] = [] + hass.config_entries.async_forward_entry_setups = _async_forward_entry_setups + hass.config_entries.async_unload_platforms = _async_unload_platforms + async def _async_reload(entry_id: str) -> None: registry.reloaded.append(entry_id) diff --git a/tests/test_binary_sensors.py b/tests/test_binary_sensors.py new file mode 100644 index 0000000..2c31abf --- /dev/null +++ b/tests/test_binary_sensors.py @@ -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", + } diff --git a/tests/test_const.py b/tests/test_const.py index 85ff105..361ca83 100644 --- a/tests/test_const.py +++ b/tests/test_const.py @@ -28,10 +28,10 @@ def test_logger() -> None: def test_platforms() -> None: - """Verify PLATFORMS is defined as an empty list.""" + """Verify PLATFORMS is defined as the sensor + binary_sensor list.""" assert hasattr(const, "PLATFORMS") assert isinstance(const.PLATFORMS, list) - assert const.PLATFORMS == [] + assert const.PLATFORMS == ["sensor", "binary_sensor"] def test_sqlite_db_filename() -> None: diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py new file mode 100644 index 0000000..745c53a --- /dev/null +++ b/tests/test_coordinator.py @@ -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 diff --git a/tests/test_entity_lifecycle.py b/tests/test_entity_lifecycle.py new file mode 100644 index 0000000..48f9b84 --- /dev/null +++ b/tests/test_entity_lifecycle.py @@ -0,0 +1,187 @@ +"""Entity lifecycle across child add/deactivate/reactivate (Feature 10, task 8).""" +from __future__ import annotations + +import datetime + +from conftest import wire_entry_to_registry + +from custom_components.nestquest import async_setup_entry, async_unload_entry +from custom_components.nestquest.children import ( + create_child, + list_children, + set_child_active, +) +from custom_components.nestquest.const import DOMAIN +from custom_components.nestquest.materialize import materialize +from custom_components.nestquest.quest_definitions import ( + create_quest_definition, +) +from custom_components.nestquest.recurrence import ScheduleRule + +SEED_HORIZON_DAYS = 3 +PER_CHILD_KINDS = ( + "quests_due_today", + "quests_completed_today", + "quests_remaining_today", + "completion_pct_today", + "next_quest", + "all_done", + "present_today", +) +HOUSEHOLD_UNIQUE_IDS = ( + "nestquest_household_quests_due_today", + "nestquest_household_quests_completed_today", + "nestquest_cycle_day", +) + + +async def _reload(hass, entry) -> None: + """Unload then set up: the harness's restart/reload equivalent.""" + assert await async_unload_entry(hass, entry) is True + assert await async_setup_entry(hass, entry) is True + + +def _child_unique_ids(child_id: int) -> set[str]: + return {f"nestquest_child_{child_id}_{kind}" for kind in PER_CHILD_KINDS} + + +async def _setup_and_seed(hass, make_entry): + entry = wire_entry_to_registry(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + database = entry.runtime_data.database + ada = await create_child(database, "Ada") + bo = await create_child(database, "Bo") + today = datetime.date.today() + await create_quest_definition( + database, + "Brush teeth", + ScheduleRule.from_dict( + {"rule_type": "daily", "start_date": today.isoformat()} + ), + [ada.id, bo.id], + ["morning"], + ) + end = (today + datetime.timedelta(days=SEED_HORIZON_DAYS)).isoformat() + await materialize(database, today.isoformat(), end, today=today) + await entry.runtime_data.coordinator.async_refresh() + return entry, ada, bo + + +async def test_deactivated_child_entities_removed_on_reload( + hass, make_entry +) -> None: + entry, ada, bo = await _setup_and_seed(hass, make_entry) + assert _child_unique_ids(ada.id) <= set(hass.entities) + assert _child_unique_ids(bo.id) <= set(hass.entities) + + await set_child_active(entry.runtime_data.database, bo.id, False) + await _reload(hass, entry) + + assert _child_unique_ids(ada.id) <= set(hass.entities), ( + "the surviving child's entities must be untouched" + ) + assert not (_child_unique_ids(bo.id) & set(hass.entities)), ( + "the deactivated child's entities must be gone after reload" + ) + + +async def test_reactivation_restores_entities_with_original_unique_ids( + hass, make_entry +) -> None: + entry, ada, bo = await _setup_and_seed(hass, make_entry) + original = _child_unique_ids(bo.id) + await set_child_active(entry.runtime_data.database, bo.id, False) + await _reload(hass, entry) + assert not (original & set(hass.entities)) + + await set_child_active(entry.runtime_data.database, bo.id, True) + await _reload(hass, entry) + + assert original <= set(hass.entities), ( + "reactivation must restore the exact original unique_ids" + ) + bo_due = hass.entities[f"nestquest_child_{bo.id}_quests_due_today"] + assert bo_due.native_value == 1, "restored entities carry live state" + + +async def test_new_child_gets_entities_without_disturbing_existing( + hass, make_entry +) -> None: + entry, ada, bo = await _setup_and_seed(hass, make_entry) + before_ada = set(hass.entities) & _child_unique_ids(ada.id) + + cory = await create_child(entry.runtime_data.database, "Cory") + await _reload(hass, entry) + + assert before_ada <= set(hass.entities), "existing entities untouched" + assert _child_unique_ids(cory.id) <= set(hass.entities), ( + "the new child's entities appear after reload" + ) + assert hass.entities[f"nestquest_child_{cory.id}_quests_due_today"].native_value == 0 + + +async def test_restart_equivalent_restores_all_entity_states( + hass, make_entry +) -> None: + """Unload + setup (the restart equivalent) restores every active + child's entities with correct states and the household rollups.""" + from custom_components.nestquest.completion import complete_instance + + entry, ada, bo = await _setup_and_seed(hass, make_entry) + coordinator = entry.runtime_data.coordinator + snapshot_ada = next( + child + for child in coordinator.data.children + if child.child_id == ada.id + ) + await complete_instance( + entry.runtime_data.database, + snapshot_ada.instances[0].instance_id, + actor_source="panel", + actor_child_id=ada.id, + ) + await coordinator.async_refresh() + assert hass.entities[ + f"nestquest_child_{ada.id}_quests_completed_today" + ].native_value == 1 + + await _reload(hass, entry) + + assert hass.entities[ + f"nestquest_child_{ada.id}_quests_completed_today" + ].native_value == 1, "completed count survives the restart" + assert hass.entities[ + f"nestquest_child_{bo.id}_quests_completed_today" + ].native_value == 0 + assert set(HOUSEHOLD_UNIQUE_IDS) <= set(hass.entities) + assert hass.entities[ + "nestquest_household_quests_completed_today" + ].native_value == 1 + + +async def test_unload_shuts_coordinator_before_closing_database( + hass, make_entry +) -> None: + """The coordinator's listeners are cleared while the database is + still connected: a scheduled refresh must never race the close.""" + entry = wire_entry_to_registry(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + coordinator = entry.runtime_data.coordinator + database = entry.runtime_data.database + seen_connected = [] + + async def _probe_shutdown(): + seen_connected.append(database.connected) + + original_close = database.close + + async def _recording_close(): + seen_connected.append("closing") + await original_close() + + coordinator.async_shutdown = _probe_shutdown + database.close = _recording_close + assert await async_unload_entry(hass, entry) is True + assert seen_connected == [True, "closing"], ( + "coordinator shutdown must precede the database close" + ) diff --git a/tests/test_entity_matrix.py b/tests/test_entity_matrix.py new file mode 100644 index 0000000..0bbbe49 --- /dev/null +++ b/tests/test_entity_matrix.py @@ -0,0 +1,247 @@ +"""The Feature 10 entity-and-event matrix (task 9). + +One fixture drives every assertion the done-condition names: correct +values for every sensor and binary sensor (including the zero-quest +child and the 255-character state limit), the three events firing +exactly once with schema-valid payloads, the instances payload +omitting missed instances while admin_instances includes them, and no +entities for an inactive child. The per-task test files cover each +behavior in depth; this file is the single pass that proves them all +against ONE fixture, so a regression in any contract fails here too. +""" +from __future__ import annotations + +import datetime +import re + +from conftest import wire_entry_to_registry + +from custom_components.nestquest import async_setup_entry +from custom_components.nestquest.children import ( + create_child, + list_children, + set_child_active, +) +from custom_components.nestquest.completion import derive_state +from custom_components.nestquest.const import ( + CONF_ADMIN_USER_IDS, + DOMAIN, + EVENT_CHILD_DAY_COMPLETE, + EVENT_QUEST_COMPLETED, + EVENT_QUEST_UNCOMPLETED, + SERVICE_COMPLETE_QUEST, + SERVICE_UNCOMPLETE_QUEST, +) +from custom_components.nestquest.dao_instances import QuestInstancesDao +from custom_components.nestquest.materialize import materialize +from custom_components.nestquest.quest_definitions import ( + create_quest_definition, +) +from custom_components.nestquest.recurrence import ScheduleRule + +_TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$") + + +async def _fixture(hass, make_entry): + """Two quest children, one zero-quest child, one inactive child.""" + entry = wire_entry_to_registry( + make_entry(data={CONF_ADMIN_USER_IDS: ["admin-1"]}), hass.registry + ) + assert await async_setup_entry(hass, entry) is True + database = entry.runtime_data.database + ada = await create_child(database, "Ada") + bo = await create_child(database, "Bo") + cory = await create_child(database, "Cory") + ghost = await create_child(database, "Ghost") + await set_child_active(database, ghost.id, False) + today = datetime.date.today() + await create_quest_definition( + database, + "Brush teeth", + ScheduleRule.from_dict( + {"rule_type": "daily", "start_date": today.isoformat()} + ), + [ada.id, bo.id], + ["morning", "evening"], + ) + end = (today + datetime.timedelta(days=3)).isoformat() + await materialize(database, today.isoformat(), end, today=today) + await entry.runtime_data.coordinator.async_refresh() + return entry, (ada, bo, cory, ghost) + + +def _sensor(hass, child_id, kind): + return hass.entities[f"{DOMAIN}_child_{child_id}_{kind}"] + + +async def test_entity_and_event_matrix(hass, make_entry) -> None: + entry, (ada, bo, cory, ghost) = await _fixture(hass, make_entry) + coordinator = entry.runtime_data.coordinator + + # --- Every active child has the full entity set; the inactive + # child has none. + kinds = ( + "quests_due_today", + "quests_completed_today", + "quests_remaining_today", + "completion_pct_today", + "next_quest", + "all_done", + "present_today", + ) + for child in (ada, bo, cory): + for kind in kinds: + assert f"{DOMAIN}_child_{child.id}_{kind}" in hass.entities, kind + for kind in kinds: + assert f"{DOMAIN}_child_{ghost.id}_{kind}" not in hass.entities, ( + "inactive children must have no entities" + ) + + # --- Correct values against the fixture: Ada and Bo owe two + # quests (morning + evening), Cory owes none and is still 100%. + for child in (ada, bo): + assert _sensor(hass, child.id, "quests_due_today").native_value == 2 + assert ( + _sensor(hass, child.id, "quests_completed_today").native_value == 0 + ) + assert ( + _sensor(hass, child.id, "quests_remaining_today").native_value == 2 + ) + assert ( + _sensor(hass, child.id, "completion_pct_today").native_value == 0 + ) + assert _sensor(hass, child.id, "next_quest").native_value == ( + "Brush teeth" + ) + assert _sensor(hass, child.id, "all_done").is_on is False + assert _sensor(hass, child.id, "present_today").is_on is True + assert _sensor(hass, cory.id, "quests_due_today").native_value == 0 + assert _sensor(hass, cory.id, "completion_pct_today").native_value == 100 + assert _sensor(hass, cory.id, "all_done").is_on is False + assert _sensor(hass, cory.id, "next_quest").native_value == "none" + + # --- Household rollups equal the per-child sums. + assert hass.entities[ + "nestquest_household_quests_due_today" + ].native_value == 4 + assert hass.entities[ + "nestquest_household_quests_completed_today" + ].native_value == 0 + + # --- The 255-character limit holds for every registered entity. + for unique_id, entity in hass.entities.items(): + state = getattr(entity, "native_value", None) + if state is None: + state = getattr(entity, "is_on", None) + assert state is None or not ( + isinstance(state, str) and len(state) > 255 + ), unique_id + + # --- Completing both of Ada's quests fires the events exactly + # once each and flips her entities immediately. + instances = await QuestInstancesDao( + entry.runtime_data.database + ).list_by_date_range( + ada.id, datetime.date.today().isoformat(), datetime.date.today().isoformat() + ) + for instance in instances: + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + { + "instance_id": instance.id, + "actor": "panel", + "actor_child_id": ada.id, + }, + ) + assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == 2 + for payload in hass.bus.fired(EVENT_QUEST_COMPLETED): + assert _TIMESTAMP.match(payload["occurred_at"]) + assert isinstance(payload["was_on_time"], bool) + assert payload["child_name"] in ("Ada",) + assert payload["quest_title"] == "Brush teeth" + assert isinstance(payload["instance_id"], int) + assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1 + assert _sensor(hass, ada.id, "all_done").is_on is True + assert _sensor(hass, ada.id, "quests_remaining_today").native_value == 0 + assert ( + _sensor(hass, ada.id, "completion_pct_today").native_value == 100 + ) + + # --- Reversing one quest fires the uncompleted event exactly once + # and restores the owed counts. + await hass.services.call( + DOMAIN, + SERVICE_UNCOMPLETE_QUEST, + {"instance_id": instances[0].id, "actor": "user"}, + context={"user_id": "admin-1"}, + ) + assert len(hass.bus.fired(EVENT_QUEST_UNCOMPLETED)) == 1 + assert _sensor(hass, ada.id, "all_done").is_on is False + assert _sensor(hass, ada.id, "quests_remaining_today").native_value == 1 + + # --- The instances payload omits missed instances while + # admin_instances includes them (D-009): pin the derive clock + # forward in the TEST ONLY so today's open instances read missed. + original_refresh = coordinator._async_update_data + + async def _future_dated_refresh(): + import custom_components.nestquest.coordinator as coordinator_module + + real_derive = coordinator_module.derive_state + + def _derive(instance, latest, today): + return real_derive(instance, latest, today + datetime.timedelta(days=1)) + + coordinator_module.derive_state = _derive + try: + return await original_refresh() + finally: + coordinator_module.derive_state = real_derive + + coordinator._async_update_data = _future_dated_refresh + await coordinator.async_refresh() + attributes = _sensor(hass, bo.id, "quests_due_today").extra_state_attributes + assert attributes["instances"] == [], "panel payload omits missed" + assert len(attributes["admin_instances"]) == 2 + assert all( + entry["state"] == "missed" for entry in attributes["admin_instances"] + ) + coordinator._async_update_data = original_refresh + await coordinator.async_refresh() + + +async def test_payload_schema_matches_the_documented_contract( + hass, make_entry +) -> None: + """Every fired payload carries the documented fields with the + documented types.""" + entry, (ada, _bo, _cory, _ghost) = await _fixture(hass, make_entry) + instances = await QuestInstancesDao( + entry.runtime_data.database + ).list_by_date_range( + ada.id, datetime.date.today().isoformat(), datetime.date.today().isoformat() + ) + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + { + "instance_id": instances[0].id, + "actor": "panel", + "actor_child_id": ada.id, + }, + ) + payload = hass.bus.fired(EVENT_QUEST_COMPLETED)[0] + for key, check in ( + ("child_id", lambda v: isinstance(v, int)), + ("child_name", lambda v: isinstance(v, str)), + ("instance_id", lambda v: isinstance(v, int)), + ("quest_title", lambda v: isinstance(v, str)), + ("window", lambda v: v in ("morning", "evening")), + ("due_date", lambda v: isinstance(v, str)), + ("due_time", lambda v: v is None or isinstance(v, str)), + ("occurred_at", lambda v: bool(_TIMESTAMP.match(v))), + ("was_on_time", lambda v: isinstance(v, bool)), + ): + assert key in payload, key + assert check(payload[key]), key diff --git a/tests/test_events.py b/tests/test_events.py new file mode 100644 index 0000000..829f4ea --- /dev/null +++ b/tests/test_events.py @@ -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) + ) diff --git a/tests/test_harness.py b/tests/test_harness.py index b947990..fa2e88b 100644 --- a/tests/test_harness.py +++ b/tests/test_harness.py @@ -31,6 +31,23 @@ def test_mock_only_harness_contract(monkeypatch): assert sys.modules["homeassistant.core"].callback is conftest._callback_decorator assert sys.modules["homeassistant.data_entry_flow"].RESULT_TYPE_FORM == "form" + # Entity-platform stand-ins are wired with the shaped classes, and + # parent-package attributes resolve to the mocked submodules. + entity_module = sys.modules["homeassistant.helpers.entity"] + assert entity_module.DeviceInfo is conftest.DeviceInfo + assert entity_module.Entity is conftest.Entity + sensor_module = sys.modules["homeassistant.components.sensor"] + assert sensor_module.SensorEntity is conftest.SensorEntity + assert sensor_module.SensorStateClass.MEASUREMENT == "measurement" + assert sensor_module.SensorDeviceClass.PERCENTAGE == "percentage" + assert sys.modules[ + "homeassistant.components.binary_sensor" + ].BinarySensorEntity is conftest.BinarySensorEntity + assert sys.modules[ + "homeassistant.helpers.update_coordinator" + ].CoordinatorEntity is conftest.CoordinatorEntity + assert sys.modules["homeassistant"].components.sensor is sensor_module + # No homeassistant distribution is installed in this suite's own venv. with pytest.raises(importlib.metadata.PackageNotFoundError): importlib.metadata.version("homeassistant") diff --git a/tests/test_options_flow.py b/tests/test_options_flow.py index bef04c8..af949b6 100644 --- a/tests/test_options_flow.py +++ b/tests/test_options_flow.py @@ -18,6 +18,8 @@ from custom_components.nestquest.const import ( CONF_DAY_ROLLOVER_TIME, CONF_HORIZON_DAYS, CONF_PANEL_IDLE_TIMEOUT, + CONF_UPDATE_INTERVAL, + DEFAULT_UPDATE_INTERVAL, DEFAULT_DAY_ROLLOVER_TIME, DEFAULT_HORIZON_DAYS, DEFAULT_PANEL_IDLE_TIMEOUT, @@ -129,7 +131,10 @@ def test_options_flow_submits_through_2024_6_surface() -> None: result = _run(handler.async_step_init(dict(VALID_INPUT))) assert result["type"] == "create_entry" assert result["title"] == "NestQuest" - assert result["data"] == VALID_INPUT + assert result["data"] == { + **VALID_INPUT, + CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL, + } def test_options_flow_shows_form_initially() -> None: @@ -208,6 +213,7 @@ def test_options_flow_schema_applies_stored_defaults() -> None: CONF_HORIZON_DAYS: 21, CONF_DAY_ROLLOVER_TIME: "05:45", CONF_PANEL_IDLE_TIMEOUT: 120, + CONF_UPDATE_INTERVAL: 90, } ) result = _run(_make_flow(entry).async_step_init(None)) @@ -215,6 +221,7 @@ def test_options_flow_schema_applies_stored_defaults() -> None: CONF_HORIZON_DAYS: 21, CONF_DAY_ROLLOVER_TIME: "05:45", CONF_PANEL_IDLE_TIMEOUT: 120, + CONF_UPDATE_INTERVAL: 90, CONF_ADMIN_USER_IDS: [], } @@ -282,8 +289,8 @@ def test_options_flow_rejects_invalid_idle_timeout(bad_timeout) -> None: assert result["errors"] == {CONF_PANEL_IDLE_TIMEOUT: "invalid"} -def test_options_flow_creates_entry_with_three_keys() -> None: - """A valid submit creates an entry with exactly the three validated keys.""" +def test_options_flow_creates_entry_with_four_keys() -> None: + """A valid submit creates an entry with exactly the four validated keys.""" flow = _make_flow(_make_entry()) result = _run(flow.async_step_init(dict(VALID_INPUT))) assert result["type"] == "create_entry" @@ -292,8 +299,12 @@ def test_options_flow_creates_entry_with_three_keys() -> None: CONF_HORIZON_DAYS, CONF_DAY_ROLLOVER_TIME, CONF_PANEL_IDLE_TIMEOUT, + CONF_UPDATE_INTERVAL, + } + assert result["data"] == { + **VALID_INPUT, + CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL, } - assert result["data"] == VALID_INPUT def test_options_flow_create_entry_data_lands_in_entry_options_and_reloads_once() -> None: @@ -316,7 +327,10 @@ def test_options_flow_create_entry_data_lands_in_entry_options_and_reloads_once( _run(registry.dispatch_options_update(entry)) assert hass.config_entries.async_reload.call_count == 1 assert registry.reloaded == [entry.entry_id] - assert entry.options == VALID_INPUT + assert entry.options == { + **VALID_INPUT, + CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL, + } def test_options_flow_validation_never_raises() -> None: diff --git a/tests/test_sensors.py b/tests/test_sensors.py new file mode 100644 index 0000000..cadeca9 --- /dev/null +++ b/tests/test_sensors.py @@ -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() + )