Extract the panel snapshot builder into core (task 9047a97b) #148
@@ -496,6 +496,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
hass,
|
hass,
|
||||||
entry_id=entry.entry_id,
|
entry_id=entry.entry_id,
|
||||||
database=database,
|
database=database,
|
||||||
|
settings=settings,
|
||||||
update_interval_seconds=entry.options.get(
|
update_interval_seconds=entry.options.get(
|
||||||
CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL
|
CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -11,6 +11,15 @@ refresh updates the whole board coherently.
|
|||||||
host clock and never UTC, matching the materialization and completion
|
host clock and never UTC, matching the materialization and completion
|
||||||
layers.
|
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 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
|
Presence is resolved through the SAME engine the materializer uses
|
||||||
(:class:`~.presence.PresenceEngine`): schedules and overrides are read
|
(:class:`~.presence.PresenceEngine`): schedules and overrides are read
|
||||||
with the public DAO methods and the engine's anchor-date arithmetic
|
with the public DAO methods and the engine's anchor-date arithmetic
|
||||||
@@ -27,13 +36,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import datetime
|
import datetime
|
||||||
from dataclasses import dataclass
|
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||||
|
|
||||||
from .completion import derive_state
|
|
||||||
from .const import (
|
from .const import (
|
||||||
CONF_UPDATE_INTERVAL,
|
CONF_UPDATE_INTERVAL,
|
||||||
DEFAULT_UPDATE_INTERVAL,
|
DEFAULT_UPDATE_INTERVAL,
|
||||||
@@ -41,66 +48,21 @@ from .const import (
|
|||||||
LOGGER,
|
LOGGER,
|
||||||
MIN_UPDATE_INTERVAL,
|
MIN_UPDATE_INTERVAL,
|
||||||
)
|
)
|
||||||
from .dao_children import ChildrenDao
|
from .core.db import NestQuestDatabase
|
||||||
from .dao_instances import (
|
from .core.settings import NestQuestSettings
|
||||||
CompletionEventsDao,
|
from .core.snapshot import (
|
||||||
QuestInstancesDao,
|
ChildDaySnapshot,
|
||||||
)
|
NestQuestSnapshot,
|
||||||
from .dao_rules import QuestDefinitionsDao
|
QuestInstanceView,
|
||||||
from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao
|
build_snapshot,
|
||||||
from .db import NestQuestDatabase
|
|
||||||
from .presence import (
|
|
||||||
MAX_PREVIEW_SCAN_DAYS,
|
|
||||||
PresenceEngine,
|
|
||||||
PresenceOverride,
|
|
||||||
PresenceSchedule,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
@dataclass(frozen=True)
|
"ChildDaySnapshot",
|
||||||
class QuestInstanceView:
|
"NestQuestCoordinator",
|
||||||
"""One instance plus everything the entities render from it."""
|
"NestQuestSnapshot",
|
||||||
|
"QuestInstanceView",
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class NestQuestCoordinator(DataUpdateCoordinator):
|
class NestQuestCoordinator(DataUpdateCoordinator):
|
||||||
@@ -121,6 +83,7 @@ class NestQuestCoordinator(DataUpdateCoordinator):
|
|||||||
*,
|
*,
|
||||||
entry_id: str,
|
entry_id: str,
|
||||||
database: NestQuestDatabase,
|
database: NestQuestDatabase,
|
||||||
|
settings: NestQuestSettings,
|
||||||
update_interval_seconds: int | None = None,
|
update_interval_seconds: int | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
interval = update_interval_seconds
|
interval = update_interval_seconds
|
||||||
@@ -138,6 +101,7 @@ class NestQuestCoordinator(DataUpdateCoordinator):
|
|||||||
)
|
)
|
||||||
self.entry_id = entry_id
|
self.entry_id = entry_id
|
||||||
self.database = database
|
self.database = database
|
||||||
|
self.settings = settings
|
||||||
self._refresh_lock = asyncio.Lock()
|
self._refresh_lock = asyncio.Lock()
|
||||||
|
|
||||||
async def async_refresh(self) -> None:
|
async def async_refresh(self) -> None:
|
||||||
@@ -153,140 +117,6 @@ class NestQuestCoordinator(DataUpdateCoordinator):
|
|||||||
|
|
||||||
async def _async_update_data(self) -> NestQuestSnapshot:
|
async def _async_update_data(self) -> NestQuestSnapshot:
|
||||||
now = self._local_now()
|
now = self._local_now()
|
||||||
today = now.date()
|
return await build_snapshot(
|
||||||
today_iso = today.isoformat()
|
self.database, self.settings, now
|
||||||
|
|
||||||
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),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
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,306 @@
|
|||||||
|
"""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`),
|
||||||
|
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, and the HA-local ``now`` (a single
|
||||||
|
clock read the caller already resolved, from which it derives
|
||||||
|
``today``) and returns the snapshot, mirroring exactly what the
|
||||||
|
coordinator used to do inline.
|
||||||
|
|
||||||
|
``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 typing import Any
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
now: datetime.datetime,
|
||||||
|
) -> NestQuestSnapshot:
|
||||||
|
"""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
|
||||||
|
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
|
||||||
|
: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. ``today`` is derived INSIDE the builder as ``now.date()``,
|
||||||
|
so the calendar date that anchors the due-date and cycle-day
|
||||||
|
arithmetic is always the SAME clock read as the wall-clock time;
|
||||||
|
the two can never disagree. ``settings`` is accepted but currently
|
||||||
|
unused (see the module docstring).
|
||||||
|
"""
|
||||||
|
today = now.date()
|
||||||
|
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),
|
||||||
|
)
|
||||||
@@ -39,6 +39,7 @@ from .coordinator import (
|
|||||||
NestQuestCoordinator,
|
NestQuestCoordinator,
|
||||||
QuestInstanceView,
|
QuestInstanceView,
|
||||||
)
|
)
|
||||||
|
from .core.snapshot import instance_payload
|
||||||
|
|
||||||
#: Home Assistant's state machine refuses states longer than 255
|
#: Home Assistant's state machine refuses states longer than 255
|
||||||
#: characters (its hard recorder/state limit). These sensors' states
|
#: characters (its hard recorder/state limit). These sensors' states
|
||||||
@@ -63,40 +64,6 @@ def _state_within_limit(state: Any) -> Any:
|
|||||||
return state
|
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
|
#: State reported by the next-quest sensor when the child owes nothing
|
||||||
#: else today. A documented sentinel, never an empty string (an empty
|
#: else today. A documented sentinel, never an empty string (an empty
|
||||||
#: state reads as "sensor went unavailable" in HA).
|
#: state reads as "sensor went unavailable" in HA).
|
||||||
@@ -215,10 +182,10 @@ class NestQuestQuestsDueTodaySensor(_NestQuestChildDaySensor):
|
|||||||
if child is None:
|
if child is None:
|
||||||
return None
|
return None
|
||||||
return {
|
return {
|
||||||
"instances": _instance_payload(
|
"instances": instance_payload(
|
||||||
child.instances, include_missed=False
|
child.instances, include_missed=False
|
||||||
),
|
),
|
||||||
"admin_instances": _instance_payload(
|
"admin_instances": instance_payload(
|
||||||
child.instances, include_missed=True
|
child.instances, include_missed=True
|
||||||
),
|
),
|
||||||
"child_id": child.child_id,
|
"child_id": child.child_id,
|
||||||
|
|||||||
@@ -0,0 +1,306 @@
|
|||||||
|
"""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`, 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
|
||||||
|
|
||||||
|
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
|
||||||
|
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.materialize import materialize
|
||||||
|
from custom_components.nestquest.quest_definitions import create_quest_definition
|
||||||
|
from custom_components.nestquest.recurrence import ScheduleRule
|
||||||
|
|
||||||
|
|
||||||
|
async def test_instance_payload_shape_and_missed_omission() -> None:
|
||||||
|
"""``instance_payload`` is the documented panel shape (§1).
|
||||||
|
|
||||||
|
``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).
|
||||||
|
"""
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
assert len(admin) == 3
|
||||||
|
assert admin[2]["state"] == "missed", (
|
||||||
|
"admin payload keeps missed instances with their missed state"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_build_snapshot_explicit_values(hass, make_entry, monkeypatch) -> 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
|
||||||
|
|
||||||
|
# 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,
|
||||||
|
bo_instances[0].id,
|
||||||
|
actor_source="panel",
|
||||||
|
actor_child_id=bo.id,
|
||||||
|
now=pinned_now,
|
||||||
|
today=today,
|
||||||
|
)
|
||||||
|
|
||||||
|
built = await build_snapshot(database, settings, pinned_now)
|
||||||
|
|
||||||
|
assert isinstance(built, NestQuestSnapshot)
|
||||||
|
assert built.today_iso == today_iso
|
||||||
|
assert built.cycle_day == 2, (
|
||||||
|
"Ada's 2-week cycle anchored yesterday => day 2 today"
|
||||||
|
)
|
||||||
|
|
||||||
|
by_id = {child.child_id: child for child in built.children}
|
||||||
|
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 == 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 == 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 == pinned_now.astimezone(
|
||||||
|
datetime.timezone.utc
|
||||||
|
).isoformat()
|
||||||
|
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": pinned_now.astimezone(
|
||||||
|
datetime.timezone.utc
|
||||||
|
).isoformat(),
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
# REQUIRED wrapper tie-in: the coordinator's _async_update_data is a
|
||||||
|
# thin WRAPPER over build_snapshot, so pinning its clock to the SAME
|
||||||
|
# pinned_now the builder was called with must reproduce the builder's
|
||||||
|
# snapshot byte-for-byte. The dataclasses are frozen, so == is a full
|
||||||
|
# deep compare — a wrapper that mis-passed today vs now, or stopped
|
||||||
|
# calling the builder, would diverge here even though the literal
|
||||||
|
# assertions above still pass.
|
||||||
|
monkeypatch.setattr(coordinator, "_local_now", lambda: pinned_now)
|
||||||
|
await coordinator.async_refresh()
|
||||||
|
assert coordinator.data == built
|
||||||
@@ -186,18 +186,18 @@ async def test_entity_and_event_matrix(hass, make_entry) -> None:
|
|||||||
original_refresh = coordinator._async_update_data
|
original_refresh = coordinator._async_update_data
|
||||||
|
|
||||||
async def _future_dated_refresh():
|
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):
|
def _derive(instance, latest, today):
|
||||||
return real_derive(instance, latest, today + datetime.timedelta(days=1))
|
return real_derive(instance, latest, today + datetime.timedelta(days=1))
|
||||||
|
|
||||||
coordinator_module.derive_state = _derive
|
snapshot_module.derive_state = _derive
|
||||||
try:
|
try:
|
||||||
return await original_refresh()
|
return await original_refresh()
|
||||||
finally:
|
finally:
|
||||||
coordinator_module.derive_state = real_derive
|
snapshot_module.derive_state = real_derive
|
||||||
|
|
||||||
coordinator._async_update_data = _future_dated_refresh
|
coordinator._async_update_data = _future_dated_refresh
|
||||||
await coordinator.async_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(
|
entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry(
|
||||||
hass, make_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)
|
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):
|
def _derive_against_pinned_future(instance, latest, today):
|
||||||
return real_derive_state(instance, latest, tomorrow)
|
return real_derive_state(instance, latest, tomorrow)
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
coordinator_module, "derive_state", _derive_against_pinned_future
|
snapshot_module, "derive_state", _derive_against_pinned_future
|
||||||
)
|
)
|
||||||
await coordinator.async_refresh()
|
await coordinator.async_refresh()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user