Move panel payload shaping into core; pin the snapshot clock

PR #148 review (CHANGES_REQUIRED) follow-up:

- Move the panel payload dict shaper (_instance_payload) from the
  HA-importing sensor.py into core/snapshot.py as instance_payload, and
  have sensor.py import it from core. The API panel route now shares ONE
  shaping implementation with the HA sensor layer (done -> completed,
  on_time passthrough, missed omitted unless include_missed).
- build_snapshot takes the HA-local now explicitly instead of
  reading datetime.now(time_zone) internally, so date and time come from
  a single clock read and the overdue check is pinned in tests. The
  coordinator passes the same now it already resolved; time_zone is
  dropped (now is timezone-aware).
- Strengthen tests/test_core_snapshot.py with explicit literal expected
  values instead of self-equality: an AWAY child (2-week absent-week
  schedule + a presence override => next_present), a due-timed quest
  exercised as overdue under a pinned 12:00 now, a completed quest
  (done -> completed, on_time True), cycle_day == 2, and a constructed
  missed view proving omission from the panel payload and retention in
  the admin payload.
- Docstring cleanups: state plainly that settings is currently
  unused (no 'wired' implication); correct the coordinator docstring
  (settings are passed in, not resolved off the entry); trim the
  coordinator __all__ to what other modules actually import.
- Derive the seed/materialize date from coordinator._local_now() so the
  seed date and snapshot date cannot diverge near midnight; drop the
  redundant bo/ghost/list_children leftovers from the prior seed.
This commit is contained in:
2026-09-21 17:37:16 -04:00
parent 6741a1a210
commit c18246d58f
4 changed files with 317 additions and 190 deletions
+6 -9
View File
@@ -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
)
+70 -16
View File
@@ -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()
+3 -36
View File
@@ -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,
+238 -129
View File
@@ -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"
)