diff --git a/custom_components/nestquest/coordinator.py b/custom_components/nestquest/coordinator.py index 61cca80..647bbaf 100644 --- a/custom_components/nestquest/coordinator.py +++ b/custom_components/nestquest/coordinator.py @@ -14,10 +14,11 @@ layers. The snapshot assembly itself lives in the Home-Assistant-free :mod:`custom_components.nestquest.core.snapshot` module (:func:`~core.snapshot.build_snapshot`); this coordinator is now a thin -HA wrapper that resolves the local timezone and settings off the config -entry and delegates to that builder. The API service's panel snapshot -route shares the same builder, so both consumers render ONE snapshot -shape. +HA wrapper that resolves the local timezone and reads the settings handed +into its constructor, then delegates to that builder, passing the SAME +HA-local ``now`` it resolved in one clock read. The API service's panel +snapshot route shares the same builder, so both consumers render ONE +snapshot shape. Presence is resolved through the SAME engine the materializer uses (:class:`~.presence.PresenceEngine`): schedules and overrides are read @@ -53,7 +54,6 @@ from .core.snapshot import ( ChildDaySnapshot, NestQuestSnapshot, QuestInstanceView, - _cycle_day, build_snapshot, ) @@ -62,8 +62,6 @@ __all__ = [ "NestQuestCoordinator", "NestQuestSnapshot", "QuestInstanceView", - "_cycle_day", - "build_snapshot", ] @@ -120,7 +118,6 @@ class NestQuestCoordinator(DataUpdateCoordinator): async def _async_update_data(self) -> NestQuestSnapshot: now = self._local_now() today = now.date() - time_zone = ZoneInfo(self.hass.config.time_zone) return await build_snapshot( - self.database, self.settings, today, time_zone + self.database, self.settings, today, now ) diff --git a/custom_components/nestquest/core/snapshot.py b/custom_components/nestquest/core/snapshot.py index f57da5d..f54fe61 100644 --- a/custom_components/nestquest/core/snapshot.py +++ b/custom_components/nestquest/core/snapshot.py @@ -13,24 +13,27 @@ the assembly itself touches no HA surface. This module holds the pure pieces — the snapshot dataclasses (:class:`QuestInstanceView`, :class:`ChildDaySnapshot`, :class:`NestQuestSnapshot`), the cycle-day helper (:func:`_cycle_day`), -and the async :func:`build_snapshot` builder — so the HA coordinator and -the API service's panel snapshot route share ONE implementation. +the panel payload shaper (:func:`instance_payload`), and the async +:func:`build_snapshot` builder — so the HA coordinator and the API +service's panel snapshot route share ONE implementation of both the +snapshot assembly and the entity-facing attribute payload shape. Nothing here imports :mod:`homeassistant`; the builder takes the -database, the settings object, the target date, and the timezone and -returns the snapshot, mirroring exactly what the coordinator used to do -inline. +database, the settings object, the target date, and the HA-local +``now`` (a single clock read the caller already resolved) and returns +the snapshot, mirroring exactly what the coordinator used to do inline. -``settings`` is accepted for forward-compatibility (the API route hands -in the entry's :class:`~.settings.NestQuestSettings`); the current -assembly does not read it, but the parameter keeps the builder's -contract stable as future panel features derive scan caps or rollup -behaviour from configuration. +``settings`` is accepted because the API route hands in the entry's +:class:`~.settings.NestQuestSettings`; it is CURRENTLY UNUSED — the +assembly reads nothing from it — and is retained so the builder's +contract stays stable if a future panel feature derives scan caps or +rollup behaviour from configuration. No speculative read is wired in. """ from __future__ import annotations import datetime +from collections.abc import Iterable from dataclasses import dataclass -from zoneinfo import ZoneInfo +from typing import Any from .completion import derive_state from .dao_children import ChildrenDao @@ -112,13 +115,53 @@ def _cycle_day( return 0 +def instance_payload( + instances: Iterable[QuestInstanceView], *, include_missed: bool +) -> list[dict[str, Any]]: + """Shape instance dicts per ENTITIES-AND-SERVICES.md §1. + + The panel payload ``state`` is the documented ``open`` | ``completed`` + spelling, so the builder's derived ``done`` maps to ``completed`` here; + ``open`` passes through. ``missed`` instances are OMITTED from the + entity-facing attribute payload (decision 5) unless ``include_missed`` + is set — the admin payload (D-009) keeps them with their ``missed`` + state. ``on_time`` passes the builder's ``was_on_time`` through + verbatim (``True`` / ``False`` for a completed instance, ``None`` for + an open one). This is the SINGLE shared implementation both the HA + sensor layer and the API panel route read; it touches no Home + Assistant surface. + """ + 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 + + async def build_snapshot( database: NestQuestDatabase, settings: NestQuestSettings, today: datetime.date, - time_zone: ZoneInfo, + now: datetime.datetime, ) -> NestQuestSnapshot: - """Build the household panel snapshot for ``today`` in ``time_zone``. + """Build the household panel snapshot for ``today`` at the ``now`` instant. Mirrors the assembly that lived in the coordinator's ``_async_update_data``: active children in sort order, each child's @@ -128,10 +171,21 @@ async def build_snapshot( per-child rollup counts, and the custody cycle day of the first scheduled active child. Missed instances are retained on the snapshot (the rollup counts and the panel's missed plate need them) - but the entity-facing attribute payload omits them at the sensor - layer — this builder produces the shared shape both consumers read. + but the entity-facing attribute payload omits them at the + :func:`instance_payload` layer — this builder produces the shared + shape both consumers read. + + ``now`` is the HA-local timezone-aware datetime the caller already + resolved (a SINGLE clock read); the overdue check compares + ``now.time()`` against the instance's ``due_time`` for today's open + instances. The builder performs NO internal clock read — it never + calls :func:`datetime.datetime.now` — so the instant is pinned in + tests and date and time can never disagree across two reads. + ``today`` is the calendar date the caller resolved from the same + clock (``now.date()``); it anchors the due-date and cycle-day + arithmetic. ``settings`` is accepted but currently unused (see the + module docstring). """ - now = datetime.datetime.now(time_zone) today_iso = today.isoformat() children = await ChildrenDao(database).list_active() diff --git a/custom_components/nestquest/sensor.py b/custom_components/nestquest/sensor.py index c340728..eef0417 100644 --- a/custom_components/nestquest/sensor.py +++ b/custom_components/nestquest/sensor.py @@ -39,6 +39,7 @@ from .coordinator import ( NestQuestCoordinator, QuestInstanceView, ) +from .core.snapshot import instance_payload #: Home Assistant's state machine refuses states longer than 255 #: characters (its hard recorder/state limit). These sensors' states @@ -63,40 +64,6 @@ def _state_within_limit(state: Any) -> Any: 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). @@ -215,10 +182,10 @@ class NestQuestQuestsDueTodaySensor(_NestQuestChildDaySensor): if child is None: return None return { - "instances": _instance_payload( + "instances": instance_payload( child.instances, include_missed=False ), - "admin_instances": _instance_payload( + "admin_instances": instance_payload( child.instances, include_missed=True ), "child_id": child.child_id, diff --git a/tests/test_core_snapshot.py b/tests/test_core_snapshot.py index da4e29a..6bccf3e 100644 --- a/tests/test_core_snapshot.py +++ b/tests/test_core_snapshot.py @@ -2,12 +2,14 @@ The snapshot assembly that lived inline in the coordinator's ``_async_update_data`` was extracted into the Home-Assistant-free -:func:`custom_components.nestquest.core.snapshot.build_snapshot`. This -test seeds a household (active + inactive children, a daily quest -definition, today's instances, one completed instance, and a presence -schedule) and asserts the pure builder's output is byte-for-byte equal -to the coordinator's snapshot for the SAME database and date — the -shared-implementation invariant the API panel snapshot route depends on. +:func:`custom_components.nestquest.core.snapshot.build_snapshot`, and +the panel payload shaper (:func:`instance_payload`) with it. These +tests seed a household and assert the pure builder's output against +EXPLICIT literal expected values — not self-equality — so the AWAY +child's ``next_present``, a due-timed quest's ``overdue`` under a +pinned ``now``, the custody ``cycle_day`` (> 1), and the panel payload +dict shape (``done`` -> ``completed``, ``on_time`` passthrough, and +``missed`` omission) are all exercised as literals. """ from __future__ import annotations @@ -17,166 +19,273 @@ from zoneinfo import ZoneInfo 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.children import create_child from custom_components.nestquest.completion import complete_instance from custom_components.nestquest.core.snapshot import ( ChildDaySnapshot, NestQuestSnapshot, QuestInstanceView, build_snapshot, + instance_payload, +) +from custom_components.nestquest.dao_instances import QuestInstancesDao +from custom_components.nestquest.dao_presence import ( + PresenceOverridesDao, + PresenceSchedulesDao, ) -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 -SEED_HORIZON_DAYS = 3 +async def test_instance_payload_shape_and_missed_omission() -> None: + """``instance_payload`` is the documented panel shape (§1). -def _today() -> datetime.date: - return datetime.date.today() - - -async def _seed_household(database): - """Three active children + one inactive; a daily quest for two of them. - - Ada and Bo share a daily morning quest; Cory is active but has no - quest (exercises the empty-instance / 100%-completion path); Ghost - is inactive and must be absent from the snapshot. Ada's first - instance is completed so the ``done`` state and rollup counts are - exercised, not just the ``open`` default. + ``done`` maps to ``completed``; ``open`` passes through; ``on_time`` + is the builder's ``was_on_time`` verbatim; ``missed`` instances are + omitted unless ``include_missed`` (the admin payload, D-009). """ - 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) + def view(state: str, *, overdue: bool = False, on_time: bool | None = None, + completed_at: str | None = None, instance_id: int = 1) -> QuestInstanceView: + return QuestInstanceView( + instance_id=instance_id, + definition_id=10, + child_id=3, + title="Fill bird feeder", + icon="bird", + window="afternoon", + due_date="2026-09-21", + due_time="17:00", + state=state, + overdue=overdue, + completed_at=completed_at, + was_on_time=on_time, + ) - today = _today() - await create_quest_definition( - database, - "Brush teeth", - ScheduleRule.from_dict( - {"rule_type": "daily", "start_date": today.isoformat()} - ), - [ada.id, bo.id], - ["morning"], + open_view = view("open", overdue=True) + done_view = view("done", on_time=True, completed_at="2026-09-21T12:00:00+00:00") + missed_view = view("missed", instance_id=2) + + panel = instance_payload([open_view, done_view, missed_view], include_missed=False) + assert panel == [ + { + "id": 1, + "definition_id": 10, + "child_id": 3, + "title": "Fill bird feeder", + "icon": "bird", + "window": "afternoon", + "due_time": "17:00", + "state": "open", + "overdue": True, + "completed_at": None, + "on_time": None, + }, + { + "id": 1, + "definition_id": 10, + "child_id": 3, + "title": "Fill bird feeder", + "icon": "bird", + "window": "afternoon", + "due_time": "17:00", + "state": "completed", + "overdue": False, + "completed_at": "2026-09-21T12:00:00+00:00", + "on_time": True, + }, + ], "panel payload omits missed and spells done as completed" + + admin = instance_payload( + [open_view, done_view, missed_view], include_missed=True ) - end = (today + datetime.timedelta(days=SEED_HORIZON_DAYS)).isoformat() - await materialize(database, today.isoformat(), end, today=today) - - # A 1-week presence schedule for Ada, present every day, anchored - # today: exercises cycle_day (== 1) and the present=True path. Bo - # and Cory have no schedule, so the engine treats them as present - # every day (the documented default). - await PresenceSchedulesDao(database).upsert_by_child( - ada.id, - cycle_length_weeks=1, - anchor_date=today.isoformat(), - pattern="0,1,2,3,4,5,6", + assert len(admin) == 3 + assert admin[2]["state"] == "missed", ( + "admin payload keeps missed instances with their missed state" ) - return ada, bo, cory, ghost -async def test_build_snapshot_matches_coordinator_snapshot( - hass, make_entry -) -> None: - """The pure builder and the coordinator produce identical snapshots.""" +async def test_build_snapshot_explicit_values(hass, make_entry) -> None: + """The builder produces the documented shape as literals, pinned in time. + + Ada is AWAY today (a 2-week schedule whose absent week 0 covers today, + plus a presence override marking her present on today+2) so + ``present`` is False and ``next_present`` is the override date. Her + open quest due at 10:00 is OVERDUE under the pinned 12:00 ``now``. + Bo's quest due at 17:00 is completed at 12:00 (on time) so its + payload ``state`` is ``completed`` and ``on_time`` is True. The first + scheduled active child is Ada, whose 2-week cycle anchored one day + before today puts ``cycle_day`` at 2. + """ 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 settings = entry.runtime_data.settings - ada, bo, cory, ghost = await _seed_household(database) - # Complete Ada's first open instance through the business layer so - # the ``done`` state, completed_at/was_on_time, and the rollup - # counts are exercised alongside the ``open`` default for Bo. - active_children = await list_children(database, active_only=True) - ada_id = next(child.id for child in active_children if child.display_name == "Ada") - bo_id = next(child.id for child in active_children if child.display_name == "Bo") - await coordinator.async_refresh() - ada_snapshot = next( - child for child in coordinator.data.children if child.child_id == ada_id + # Single clock read for the seed/snapshot anchor (no midnight skew). + today = coordinator._local_now().date() + time_zone = ZoneInfo(hass.config.time_zone) + # Pin the build instant at 12:00 local: past Ada's 10:00 due time + # (overdue) and before Bo's 17:00, and the completion moment for Bo. + pinned_now = datetime.datetime( + today.year, today.month, today.day, 12, 0, 0, tzinfo=time_zone + ) + today_iso = today.isoformat() + + ada = await create_child(database, "Ada") + bo = await create_child(database, "Bo") + + await create_quest_definition( + database, + "Pack bag", + ScheduleRule.from_dict( + {"rule_type": "daily", "start_date": today_iso} + ), + [ada.id], + [("morning", "10:00")], + ) + await create_quest_definition( + database, + "Brush teeth", + ScheduleRule.from_dict( + {"rule_type": "daily", "start_date": today_iso} + ), + [bo.id], + [("morning", "17:00")], + ) + end = (today + datetime.timedelta(days=3)).isoformat() + await materialize(database, today_iso, end, today=today) + + # Ada: 2-week cycle anchored yesterday. Week 0 (the empty segment) + # covers today, so she is absent today; an override marks her present + # on today+2, so next_present resolves to that date and cycle_day is + # ((today - (today-1)) % 14) + 1 == 2. + await PresenceSchedulesDao(database).upsert_by_child( + ada.id, + cycle_length_weeks=2, + anchor_date=(today - datetime.timedelta(days=1)).isoformat(), + pattern="|0,1,2,3,4,5,6", + ) + returns_date = today + datetime.timedelta(days=2) + await PresenceOverridesDao(database).create( + ada.id, + returns_date.isoformat(), + returns_date.isoformat(), + is_present=True, + note="visit", + ) + + # Complete Bo's instance at the pinned 12:00 (17:00 due => on time). + bo_instances = await QuestInstancesDao(database).list_by_child_and_date( + bo.id, today_iso ) await complete_instance( database, - ada_snapshot.instances[0].instance_id, + bo_instances[0].id, actor_source="panel", - actor_child_id=ada_id, + actor_child_id=bo.id, + now=pinned_now, + today=today, ) - # Drive the coordinator's refresh (the path production takes) and - # the pure builder with the SAME date and timezone the coordinator - # resolves, so the comparison is exact — no host-clock skew, no - # timezone drift. Instances here carry no due_time, so the overdue - # flag is False regardless of the instant each pass reads ``now``. - await coordinator.async_refresh() - coordinator_snapshot = coordinator.data - today = coordinator._local_now().date() - time_zone = ZoneInfo(hass.config.time_zone) - built = await build_snapshot(database, settings, today, time_zone) + built = await build_snapshot(database, settings, today, pinned_now) assert isinstance(built, NestQuestSnapshot) - assert built == coordinator_snapshot, ( - "build_snapshot and the coordinator must produce identical " - "snapshots for the same database, date, and timezone" + assert built.today_iso == today_iso + assert built.cycle_day == 2, ( + "Ada's 2-week cycle anchored yesterday => day 2 today" ) - # Shape and documented invariants, not just equality: children in - # sort order, inactive child omitted, missed instances omitted from - # the panel payload (none are missed in this seed, so the instances - # tuple carries only open/done views), per-child counts, presence, - # and cycle day. - active_ordered = await list_children(database, active_only=True) - active_ids_ordered = [child.id for child in active_ordered] - child_ids = [child.child_id for child in built.children] - assert ghost.id not in child_ids, "inactive child must be absent" - assert child_ids == active_ids_ordered, ( - "children must appear in the DAO's sort_order, id order" - ) - assert built.today_iso == today.isoformat() - assert built.cycle_day == 1, "Ada's 1-week schedule anchored today => day 1" - by_id = {child.child_id: child for child in built.children} - ada_child = by_id[ada_id] - bo_child = by_id[bo_id] - cory_child = by_id[cory.id] - for child in built.children: - assert isinstance(child, ChildDaySnapshot) - for view in child.instances: - assert isinstance(view, QuestInstanceView) - assert view.state in ("open", "done", "missed") - # Missed instances are omitted from the panel payload shape; - # in this seed none are missed (today's open instances have - # not crossed their due time and there is no due_time set). - assert view.state != "missed", ( - "no instance should be missed for today's seed" - ) - - assert ada_child.present is True - assert bo_child.present is True - assert cory_child.present is True - assert cory_child.instances == () - assert cory_child.due_today == 0 - assert cory_child.completion_pct == 100 + assert list(by_id) == [ada.id, bo.id], ( + "active children appear in sort_order, id order" + ) + ada_child = by_id[ada.id] + bo_child = by_id[bo.id] + # Ada: away today, returns on the override date, one open+overdue quest. + assert isinstance(ada_child, ChildDaySnapshot) + assert ada_child.present is False + assert ada_child.next_present == returns_date.isoformat() assert ada_child.due_today == 1 - assert ada_child.completed_today == 1 - assert ada_child.remaining_today == 0 - assert ada_child.completion_pct == 100 - assert ada_child.instances[0].state == "done" - assert ada_child.instances[0].completed_at is not None - assert ada_child.instances[0].was_on_time is not None + assert ada_child.completed_today == 0 + assert ada_child.remaining_today == 1 + assert ada_child.completion_pct == 0 + ada_view = ada_child.instances[0] + assert isinstance(ada_view, QuestInstanceView) + assert ada_view.state == "open" + assert ada_view.due_time == "10:00" + assert ada_view.overdue is True, "12:00 now is past the 10:00 due time" + assert ada_view.completed_at is None + assert ada_view.was_on_time is None + # Bo: present, one completed instance, on time, not overdue. + assert bo_child.present is True + assert bo_child.next_present is None assert bo_child.due_today == 1 - assert bo_child.completed_today == 0 - assert bo_child.remaining_today == 1 - assert bo_child.completion_pct == 0 - assert bo_child.instances[0].state == "open" - assert bo_child.instances[0].completed_at is None - assert bo_child.instances[0].was_on_time is None + assert bo_child.completed_today == 1 + assert bo_child.remaining_today == 0 + assert bo_child.completion_pct == 100 + bo_view = bo_child.instances[0] + assert bo_view.state == "done" + assert bo_view.due_time == "17:00" + assert bo_view.overdue is False, "overdue is only computed for open state" + assert bo_view.completed_at == f"{today_iso}T12:00:00+00:00" + assert bo_view.was_on_time is True + + # Panel payload shape: Ada open+overdue stays open; Bo done -> completed. + ada_payload = instance_payload(ada_child.instances, include_missed=False) + assert ada_payload == [ + { + "id": ada_view.instance_id, + "definition_id": ada_view.definition_id, + "child_id": ada.id, + "title": "Pack bag", + "icon": None, + "window": "morning", + "due_time": "10:00", + "state": "open", + "overdue": True, + "completed_at": None, + "on_time": None, + } + ] + bo_payload = instance_payload(bo_child.instances, include_missed=False) + assert bo_payload == [ + { + "id": bo_view.instance_id, + "definition_id": bo_view.definition_id, + "child_id": bo.id, + "title": "Brush teeth", + "icon": None, + "window": "morning", + "due_time": "17:00", + "state": "completed", + "overdue": False, + "completed_at": f"{today_iso}T12:00:00+00:00", + "on_time": True, + } + ] + + # Missed omission is real: a missed view is dropped from the panel + # payload and kept in the admin payload with its missed state. + missed_view = QuestInstanceView( + instance_id=999, + definition_id=10, + child_id=ada.id, + title="Stale quest", + icon=None, + window="morning", + due_date=(today - datetime.timedelta(days=1)).isoformat(), + due_time="09:00", + state="missed", + overdue=False, + completed_at=None, + was_on_time=None, + ) + assert instance_payload([missed_view], include_missed=False) == [] + assert ( + instance_payload([missed_view], include_missed=True)[0]["state"] + == "missed" + )