Extract the panel snapshot builder into core (task 9047a97b)

The household snapshot assembly that lived inline in the coordinator's
_async_update_data is extracted into a pure, Home-Assistant-free
core/snapshot.py: the QuestInstanceView / ChildDaySnapshot /
NestQuestSnapshot dataclasses, the _cycle_day helper, and a new async
build_snapshot(database, settings, today, time_zone) builder.

The coordinator is now a thin DataUpdateCoordinator wrapper that
resolves the local timezone and settings and delegates to
build_snapshot; the dataclasses and _cycle_day are re-exported from the
coordinator module so existing sensor.py / binary_sensor.py imports keep
working unchanged (entity_ids and per-child instance attribute payload
shape unchanged). The API service's panel snapshot route (next task)
shares this one implementation.

Tests that monkeypatched derive_state on the coordinator module now
patch core.snapshot.derive_state (the module that now owns the call).
A new test seeds a household and asserts build_snapshot's output equals
the coordinator's snapshot for the same database and date. Suite green
at 1588.
This commit is contained in:
2026-09-21 17:29:04 -04:00
parent 7fec3c76ef
commit 6741a1a210
6 changed files with 470 additions and 202 deletions
+1
View File
@@ -496,6 +496,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass,
entry_id=entry.entry_id,
database=database,
settings=settings,
update_interval_seconds=entry.options.get(
CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL
),
+29 -195
View File
@@ -11,6 +11,14 @@ refresh updates the whole board coherently.
host clock and never UTC, matching the materialization and completion
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.
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
@@ -27,13 +35,11 @@ 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,
@@ -41,66 +47,24 @@ from .const import (
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 (
MAX_PREVIEW_SCAN_DAYS,
PresenceEngine,
PresenceOverride,
PresenceSchedule,
from .core.db import NestQuestDatabase
from .core.settings import NestQuestSettings
from .core.snapshot import (
ChildDaySnapshot,
NestQuestSnapshot,
QuestInstanceView,
_cycle_day,
build_snapshot,
)
@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
#: For a child away today: the ISO date of their next present day
#: (the panel's away plate renders ``Returns <weekday>, <Mon D>``
#: from it). ``None`` when the child is present today or no
#: present day exists within the presence engine's scan cap.
next_present: str | None = None
@dataclass(frozen=True)
class NestQuestSnapshot:
"""The coordinator's whole-entity data payload."""
today_iso: str
children: tuple[ChildDaySnapshot, ...]
cycle_day: int
__all__ = [
"ChildDaySnapshot",
"NestQuestCoordinator",
"NestQuestSnapshot",
"QuestInstanceView",
"_cycle_day",
"build_snapshot",
]
class NestQuestCoordinator(DataUpdateCoordinator):
@@ -121,6 +85,7 @@ class NestQuestCoordinator(DataUpdateCoordinator):
*,
entry_id: str,
database: NestQuestDatabase,
settings: NestQuestSettings,
update_interval_seconds: int | None = None,
) -> None:
interval = update_interval_seconds
@@ -138,6 +103,7 @@ class NestQuestCoordinator(DataUpdateCoordinator):
)
self.entry_id = entry_id
self.database = database
self.settings = settings
self._refresh_lock = asyncio.Lock()
async def async_refresh(self) -> None:
@@ -154,139 +120,7 @@ class NestQuestCoordinator(DataUpdateCoordinator):
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
)
window_end = today + datetime.timedelta(
days=MAX_PREVIEW_SCAN_DAYS
)
window_end_iso = window_end.isoformat()
ranged = await PresenceOverridesDao(
self.database
).list_by_child_and_range(child.id, today_iso, window_end_iso)
if ranged:
# 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 ranged
]
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
child_present = engine.is_present(child.id, today)
next_present: str | None = None
if not child_present:
try:
next_present = engine.next_present_dates(
child.id, today, 1
)[0].isoformat()
except ValueError:
next_present = None
snapshots.append(
ChildDaySnapshot(
child_id=child.id,
child_name=child.display_name,
instances=tuple(views),
present=child_present,
due_today=due,
completed_today=completed,
remaining_today=remaining,
completion_pct=(
round(completed * 100 / due) if due else 100
),
next_present=next_present,
)
)
return NestQuestSnapshot(
today_iso=today_iso,
children=tuple(snapshots),
cycle_day=_cycle_day(schedules, children, today),
time_zone = ZoneInfo(self.hass.config.time_zone)
return await build_snapshot(
self.database, self.settings, today, time_zone
)
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
@@ -0,0 +1,251 @@
"""Pure, Home-Assistant-free panel snapshot builder.
The household snapshot the NestQuest panel renders — active children in
sort order, each child's today-instances in the documented payload shape
(missed instances OMITTED from the entity-facing attribute payload, but
retained on the snapshot for the count rollups), per-child counts and
presence, and the custody cycle day — used to be assembled inline inside
:mod:`custom_components.nestquest.coordinator`'s
``_async_update_data``. That assembly is HA-coupled only because its
host is a :class:`~homeassistant.helpers.update_coordinator.DataUpdateCoordinator`;
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.
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.
``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.
"""
from __future__ import annotations
import datetime
from dataclasses import dataclass
from zoneinfo import ZoneInfo
from .completion import derive_state
from .dao_children import ChildrenDao
from .dao_instances import CompletionEventsDao, QuestInstancesDao
from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao
from .dao_rules import QuestDefinitionsDao
from .db import NestQuestDatabase
from .presence import (
MAX_PREVIEW_SCAN_DAYS,
PresenceEngine,
PresenceOverride,
PresenceSchedule,
)
from .settings import NestQuestSettings
@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
#: For a child away today: the ISO date of their next present day
#: (the panel's away plate renders ``Returns <weekday>, <Mon D>``
#: from it). ``None`` when the child is present today or no
#: present day exists within the presence engine's scan cap.
next_present: str | None = None
@dataclass(frozen=True)
class NestQuestSnapshot:
"""The coordinator's whole-entity data payload."""
today_iso: str
children: tuple[ChildDaySnapshot, ...]
cycle_day: int
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
async def build_snapshot(
database: NestQuestDatabase,
settings: NestQuestSettings,
today: datetime.date,
time_zone: ZoneInfo,
) -> NestQuestSnapshot:
"""Build the household panel snapshot for ``today`` in ``time_zone``.
Mirrors the assembly that lived in the coordinator's
``_async_update_data``: active children in sort order, each child's
today-instances with derived state (open / done / missed via
:func:`~.completion.derive_state`) and the overdue flag, per-child
presence (resolved through :class:`~.presence.PresenceEngine`), the
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.
"""
now = datetime.datetime.now(time_zone)
today_iso = today.isoformat()
children = await ChildrenDao(database).list_active()
schedules: dict[int, PresenceSchedule] = {}
overrides: dict[int, list[PresenceOverride]] = {}
for child in children:
schedule = await PresenceSchedulesDao(database).get_by_child(
child.id
)
if schedule is not None:
schedules[child.id] = PresenceSchedule.decode(
child.id, schedule.anchor_date, schedule.pattern
)
window_end = today + datetime.timedelta(
days=MAX_PREVIEW_SCAN_DAYS
)
window_end_iso = window_end.isoformat()
ranged = await PresenceOverridesDao(
database
).list_by_child_and_range(child.id, today_iso, window_end_iso)
if ranged:
# 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 ranged
]
engine = PresenceEngine(schedules, overrides)
instances_dao = QuestInstancesDao(database)
events_dao = CompletionEventsDao(database)
definitions_dao = QuestDefinitionsDao(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
child_present = engine.is_present(child.id, today)
next_present: str | None = None
if not child_present:
try:
next_present = engine.next_present_dates(
child.id, today, 1
)[0].isoformat()
except ValueError:
next_present = None
snapshots.append(
ChildDaySnapshot(
child_id=child.id,
child_name=child.display_name,
instances=tuple(views),
present=child_present,
due_today=due,
completed_today=completed,
remaining_today=remaining,
completion_pct=(
round(completed * 100 / due) if due else 100
),
next_present=next_present,
)
)
return NestQuestSnapshot(
today_iso=today_iso,
children=tuple(snapshots),
cycle_day=_cycle_day(schedules, children, today),
)
+182
View File
@@ -0,0 +1,182 @@
"""Test the pure panel snapshot builder (task 9047a97b).
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.
"""
from __future__ import annotations
import datetime
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.completion import complete_instance
from custom_components.nestquest.core.snapshot import (
ChildDaySnapshot,
NestQuestSnapshot,
QuestInstanceView,
build_snapshot,
)
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
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.
"""
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 = _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)
# 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",
)
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."""
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
)
await complete_instance(
database,
ada_snapshot.instances[0].instance_id,
actor_source="panel",
actor_child_id=ada_id,
)
# 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)
assert isinstance(built, NestQuestSnapshot)
assert built == coordinator_snapshot, (
"build_snapshot and the coordinator must produce identical "
"snapshots for the same database, date, and timezone"
)
# 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 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 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
+4 -4
View File
@@ -186,18 +186,18 @@ async def test_entity_and_event_matrix(hass, make_entry) -> None:
original_refresh = coordinator._async_update_data
async def _future_dated_refresh():
import custom_components.nestquest.coordinator as coordinator_module
import custom_components.nestquest.core.snapshot as snapshot_module
real_derive = coordinator_module.derive_state
real_derive = snapshot_module.derive_state
def _derive(instance, latest, today):
return real_derive(instance, latest, today + datetime.timedelta(days=1))
coordinator_module.derive_state = _derive
snapshot_module.derive_state = _derive
try:
return await original_refresh()
finally:
coordinator_module.derive_state = real_derive
snapshot_module.derive_state = real_derive
coordinator._async_update_data = _future_dated_refresh
await coordinator.async_refresh()
+3 -3
View File
@@ -238,16 +238,16 @@ async def test_due_today_payload_omits_missed_for_panel_includes_for_admin(
entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry(
hass, make_entry
)
import custom_components.nestquest.coordinator as coordinator_module
import custom_components.nestquest.core.snapshot as snapshot_module
tomorrow = _today() + datetime.timedelta(days=1)
real_derive_state = coordinator_module.derive_state
real_derive_state = snapshot_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
snapshot_module, "derive_state", _derive_against_pinned_future
)
await coordinator.async_refresh()