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:
@@ -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
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user