diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index e8bd9fb..6b0eac4 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -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 ), diff --git a/custom_components/nestquest/coordinator.py b/custom_components/nestquest/coordinator.py index 39dc777..61cca80 100644 --- a/custom_components/nestquest/coordinator.py +++ b/custom_components/nestquest/coordinator.py @@ -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 , `` - #: 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 diff --git a/custom_components/nestquest/core/snapshot.py b/custom_components/nestquest/core/snapshot.py new file mode 100644 index 0000000..f57da5d --- /dev/null +++ b/custom_components/nestquest/core/snapshot.py @@ -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 , `` + #: 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), + ) diff --git a/tests/test_core_snapshot.py b/tests/test_core_snapshot.py new file mode 100644 index 0000000..da4e29a --- /dev/null +++ b/tests/test_core_snapshot.py @@ -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 diff --git a/tests/test_entity_matrix.py b/tests/test_entity_matrix.py index 0bbbe49..f1e8757 100644 --- a/tests/test_entity_matrix.py +++ b/tests/test_entity_matrix.py @@ -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() diff --git a/tests/test_sensors.py b/tests/test_sensors.py index 08641fc..dcacaea 100644 --- a/tests/test_sensors.py +++ b/tests/test_sensors.py @@ -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()