Coordinator polls the API snapshot and rebuilds the sensors' data

Feature 18: the shared coordinator's refresh pass now fetches
GET /api/v1/panel/snapshot through the NestQuestApiClient and
reconstructs the exact core dataclasses (NestQuestSnapshot,
ChildDaySnapshot, QuestInstanceView) the Feature 10 entities read, so
sensor.py and binary_sensor.py are untouched and every entity_id and
attribute payload keeps its shape.

Why: the integration becomes an API client of the NestQuest service
instead of a second reader of the SQLite file, and the panel cards and
the four blueprints keep working without changes.

- snapshot_from_api_payload maps the route's JSON back onto the
  dataclasses field by field (state 'completed' -> 'done' so
  instance_payload renders 'completed' again; counts, presence,
  next_present, overdue, on_time, completed_at pass through verbatim;
  due_date is stamped from today_iso, the only date the route serves).
- The panel payload omits missed instances (D-009), so
  admin_instances can no longer carry missed rows — the admin surface
  for missed quests is the PWA reading the API directly. Documented in
  the module docstring and pinned by tests.
- An unconfigured panel token (the shipped default) gets a typed-error
  stub instead of a construction crash: setup succeeds, the first
  refresh fails cleanly, entities come up unavailable and HA's retry
  cycle applies. Fetch failures log the typed error and mark the
  entities unavailable while data keeps its last good snapshot.
- The runtime record registers before the first refresh so the refresh
  path always finds the live entry state; the update interval and the
  serialized forced-refresh behaviour are unchanged.
- Tests: a fixture snapshot drives the coordinator (stub client, no
  network) asserting per-child values, household rollups, cycle day,
  and the exact instance payload shape, plus unconfigured and
  failed-fetch cases; DB-seeded tests ride a lazy local-snapshot
  client through the same JSON path.
This commit is contained in:
2026-09-23 06:02:26 -04:00
parent 78fd75ec45
commit ec7c8d4bc2
7 changed files with 825 additions and 317 deletions
+28 -9
View File
@@ -16,6 +16,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.event import async_track_time_change from homeassistant.helpers.event import async_track_time_change
from .api_client import NestQuestApiError
from .const import ( from .const import (
CONF_ADMIN_USER_IDS, CONF_ADMIN_USER_IDS,
CONF_UPDATE_INTERVAL, CONF_UPDATE_INTERVAL,
@@ -26,7 +27,7 @@ from .const import (
) )
from .core.settings import NestQuestSettings from .core.settings import NestQuestSettings
from .frontend import async_register_frontend from .frontend import async_register_frontend
from .coordinator import NestQuestCoordinator from .coordinator import NestQuestCoordinator, coordinator_client_from_entry
from .db import NestQuestDatabase, make_database from .db import NestQuestDatabase, make_database
from .materialize import materialize as _materialize_run from .materialize import materialize as _materialize_run
from .migrations import apply_migrations from .migrations import apply_migrations
@@ -490,18 +491,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
_register_services(hass) _register_services(hass)
# The shared Feature 10 coordinator: one refresh cycle every # The shared Feature 10 coordinator: one refresh cycle every
# entity reads from (CONF_UPDATE_INTERVAL seconds, default # entity reads from (CONF_UPDATE_INTERVAL seconds, default
# five minutes). Created AFTER services so a failure below # five minutes). Since Feature 18 it polls the API service's
# unwinds them through the except path's listener handling. # panel snapshot instead of reading the local database; an
# entry with no panel token yet gets the typed-error stub
# (unavailable entities, never a setup crash). Created AFTER
# services so a failure below unwinds them through the except
# path's listener handling.
coordinator = NestQuestCoordinator( coordinator = NestQuestCoordinator(
hass, hass,
entry_id=entry.entry_id, entry_id=entry.entry_id,
database=database, api_client=coordinator_client_from_entry(hass, entry),
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
), ),
) )
await coordinator.async_config_entry_first_refresh()
runtime_data = NestQuestRuntimeData( runtime_data = NestQuestRuntimeData(
entry_id=entry.entry_id, entry_id=entry.entry_id,
settings=settings, settings=settings,
@@ -510,11 +513,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
remove_time_change_listener=remove_time_change_listener, remove_time_change_listener=remove_time_change_listener,
coordinator=coordinator, coordinator=coordinator,
) )
# runtime_data is assigned BEFORE the platforms forward so each # The runtime record is registered BEFORE the first refresh:
# platform's async_setup_entry reads its coordinator off the # the refresh path (and anything it calls) resolves the entry's
# entry (the HA runtime-data contract); a forward failure below # runtime state through hass.data, and it must find the live
# database there — never a half-set-up entry. The platforms
# forward below reads the coordinator off the entry
# (the HA runtime-data contract); a forward failure below
# unwinds it through the except path. # unwinds it through the except path.
hass.data[DOMAIN][entry.entry_id] = runtime_data
entry.runtime_data = runtime_data entry.runtime_data = runtime_data
# The first refresh polls the API snapshot; an API failure
# (unconfigured token, API service down) does NOT crash setup:
# the coordinator reports last_update_success=False, entities
# come up unavailable, and HA's standard retry cycle applies
# once the API is reachable. Any non-API error still raises.
try:
await coordinator.async_config_entry_first_refresh()
except NestQuestApiError:
pass
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
except BaseException: except BaseException:
# A failure anywhere after the database is open must not leak the # A failure anywhere after the database is open must not leak the
@@ -552,6 +568,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
raise raise
assert remove_update_listener is not None assert remove_update_listener is not None
assert remove_time_change_listener is not None assert remove_time_change_listener is not None
# The runtime record was already registered before the first
# refresh; re-assert the (unchanged) record so the return path
# stays readable as one registration site.
hass.data[DOMAIN][entry.entry_id] = runtime_data hass.data[DOMAIN][entry.entry_id] = runtime_data
return True return True
+190 -38
View File
@@ -1,4 +1,4 @@
"""The shared NestQuest DataUpdateCoordinator (Feature 10). """The shared NestQuest DataUpdateCoordinator (Feature 10, Feature 18).
ONE refresh pass builds the whole entity snapshot: active children, ONE refresh pass builds the whole entity snapshot: active children,
today's instances with their derived states (open / done / missed via today's instances with their derived states (open / done / missed via
@@ -7,40 +7,47 @@ per-child rollup counts. Every Feature 10 entity reads
``coordinator.data`` — no entity queries the database itself, so one ``coordinator.data`` — no entity queries the database itself, so one
refresh updates the whole board coherently. refresh updates the whole board coherently.
"Today" and "now" are HA-local (``hass.config.time_zone``), never the Since Feature 18 the snapshot no longer comes from the local database:
host clock and never UTC, matching the materialization and completion the coordinator polls the NestQuest API service's panel plane —
layers. ``GET /api/v1/panel/snapshot`` through
:class:`~.api_client.NestQuestApiClient` on its update interval — and
RECONSTRUCTS the same core dataclasses the local builder produced
(:class:`~.core.snapshot.NestQuestSnapshot`,
:class:`~.core.snapshot.ChildDaySnapshot`,
:class:`~.core.snapshot.QuestInstanceView`). ``sensor.py`` and
``binary_sensor.py`` read ``coordinator.data`` and
:func:`~.core.snapshot.instance_payload` exactly as before, so every
entity_id and attribute payload shape is unchanged: the panel cards and
the four blueprints keep working untouched.
The snapshot assembly itself lives in the Home-Assistant-free One loss is deliberate and documented (D-009 revisited): the panel
:mod:`custom_components.nestquest.core.snapshot` module payload OMITS missed instances, so a snapshot rebuilt from the API can
(:func:`~core.snapshot.build_snapshot`); this coordinator is now a thin no longer carry them — ``admin_instances`` (``include_missed=True``)
HA wrapper that resolves the local timezone and reads the settings handed now equals ``instances`` (they can hold only open and completed rows).
into its constructor, then delegates to that builder, passing the SAME The admin surface for missed quests is the PWA, which reads the API
HA-local ``now`` it resolved in one clock read. The API service's panel service directly; the integration's entities never needed missed rows
snapshot route shares the same builder, so both consumers render ONE for their states or counts (the counts arrive precomputed).
snapshot shape.
Presence is resolved through the SAME engine the materializer uses Failure handling: constructing the client fails fast (ValueError) when
(:class:`~.presence.PresenceEngine`): schedules and overrides are read the entry has no panel token configured, and a fetch failure raises the
with the public DAO methods and the engine's anchor-date arithmetic typed :class:`~.api_client.NestQuestApiError`. Neither crashes
answers ``is this child at this house today`` (D-004 — never ISO week integration setup: the constructor logs a clear message and hands back
parity). a client-shaped stub whose refresh raises the same typed error, so the
coordinator's refresh pass marks the entities unavailable
``cycle_day`` reports where today falls in the custody cycle of the (``last_update_success = False``, ``data = None``) — exactly how a HA
first active child (display order) that HAS a schedule — 1-based day ``DataUpdateCoordinator`` surfaces a failed update. The typed error is
``N`` of ``cycle_length_weeks * 7``, computed from that schedule's never swallowed silently: it is logged with its message and chained.
anchor date. ``0`` means no active child has a schedule at all, so
the admin header degrades to a harmless zero instead of guessing.
""" """
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import datetime import datetime
from zoneinfo import ZoneInfo from typing import Any
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 .api_client import NestQuestApiClient, NestQuestApiError, resolve_api_config
from .const import ( from .const import (
CONF_UPDATE_INTERVAL, CONF_UPDATE_INTERVAL,
DEFAULT_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL,
@@ -48,13 +55,10 @@ from .const import (
LOGGER, LOGGER,
MIN_UPDATE_INTERVAL, MIN_UPDATE_INTERVAL,
) )
from .core.db import NestQuestDatabase
from .core.settings import NestQuestSettings
from .core.snapshot import ( from .core.snapshot import (
ChildDaySnapshot, ChildDaySnapshot,
NestQuestSnapshot, NestQuestSnapshot,
QuestInstanceView, QuestInstanceView,
build_snapshot,
) )
__all__ = [ __all__ = [
@@ -65,9 +69,113 @@ __all__ = [
] ]
def snapshot_from_api_payload(payload: dict[str, Any]) -> NestQuestSnapshot:
"""Reconstruct the core snapshot dataclasses from the API's JSON.
The panel snapshot route (``api/routes_panel.py``) returns today's
household as plain JSON — ``today_iso``, ``cycle_day``, and the
per-child list with precomputed counts and the instances in the
documented payload shape. This function maps that shape BACK onto
the dataclasses ``sensor.py`` / ``binary_sensor.py`` read, field by
field:
- the API's ``state`` ``"completed"`` maps back to the core view's
``"done"`` (so :func:`~.core.snapshot.instance_payload` renders
``"completed"`` again — the round-trip is identity for open and
completed rows);
- ``overdue``, ``on_time`` (→ ``was_on_time``), ``completed_at``,
``window``, ``due_time``, ``icon`` and the per-child counts pass
through verbatim;
- ``present`` and ``next_present`` pass through verbatim (the API
resolved them through the same presence engine server-side);
- the instance list is rebuilt IN ORDER, so the panel payload the
sensors emit matches the route's order byte for byte.
Because the route omits missed instances (D-009), a snapshot built
here cannot contain any ``missed`` view — see the module docstring.
"""
children: list[ChildDaySnapshot] = []
for child in payload.get("children", []):
views = tuple(
QuestInstanceView(
instance_id=instance["id"],
definition_id=instance["definition_id"],
child_id=instance["child_id"],
title=instance["title"],
icon=instance.get("icon"),
window=instance["window"],
# The API payload does not carry due_date: every row
# is TODAY's instance (the route queries
# [today, today]), so the view's due_date is the
# snapshot's today_iso — the same value the local
# builder stamped on every today row.
due_date=payload["today_iso"],
due_time=instance.get("due_time"),
state=(
"done" if instance["state"] == "completed" else
instance["state"]
),
overdue=instance["overdue"],
completed_at=instance.get("completed_at"),
was_on_time=instance.get("on_time"),
)
for instance in child.get("instances", [])
)
children.append(
ChildDaySnapshot(
child_id=child["child_id"],
child_name=child["child_name"],
instances=views,
present=child["present"],
due_today=child["due_today"],
completed_today=child["completed_today"],
remaining_today=child["remaining_today"],
completion_pct=child["completion_pct"],
next_present=child.get("next_present"),
)
)
return NestQuestSnapshot(
today_iso=payload["today_iso"],
children=tuple(children),
cycle_day=payload["cycle_day"],
)
class _UnconfiguredApiClient:
"""Client-shaped stand-in used when the entry has no panel token.
Constructing a real :class:`~.api_client.NestQuestApiClient` with an
empty token raises ValueError by design (fail fast on a client that
could only ever 401). An entry that simply has not been configured
yet must not crash integration setup, so the coordinator swaps in
this stub: every refresh raises the SAME typed
:class:`~.api_client.NestQuestApiError` a transport failure would,
carrying a clear "API not configured" message, so the coordinator
marks the entities unavailable and the HA standard retry cycle
applies. The error is logged, never silent.
"""
__slots__ = ()
async def get_snapshot(self) -> dict[str, Any]:
"""Always raise: there is no configured API to fetch from."""
raise NestQuestApiError(
"NestQuest API not configured: set the panel token in the "
"integration options"
)
class NestQuestCoordinator(DataUpdateCoordinator): class NestQuestCoordinator(DataUpdateCoordinator):
"""Shared refresh cycle for every NestQuest entity. """Shared refresh cycle for every NestQuest entity.
One refresh pass = one ``GET /api/v1/panel/snapshot`` through the
API client, reconstructed into the core snapshot dataclasses every
entity reads. A failed pass (unconfigured API, transport failure,
non-2xx response) raises the typed error out of
``_async_update_data``, so the coordinator's ``data`` keeps its last
good value while ``last_update_success`` goes False — HA marks the
entities unavailable until a later pass succeeds.
Forced refreshes (the service paths pushing an immediate update Forced refreshes (the service paths pushing an immediate update
after a completion) are SERIALIZED per entry: overlapping after a completion) are SERIALIZED per entry: overlapping
complete/uncomplete calls must not interleave their snapshot complete/uncomplete calls must not interleave their snapshot
@@ -82,8 +190,7 @@ class NestQuestCoordinator(DataUpdateCoordinator):
hass: HomeAssistant, hass: HomeAssistant,
*, *,
entry_id: str, entry_id: str,
database: NestQuestDatabase, api_client: NestQuestApiClient,
settings: NestQuestSettings,
update_interval_seconds: int | None = None, update_interval_seconds: int | None = None,
) -> None: ) -> None:
interval = update_interval_seconds interval = update_interval_seconds
@@ -100,8 +207,7 @@ class NestQuestCoordinator(DataUpdateCoordinator):
update_interval=datetime.timedelta(seconds=interval), update_interval=datetime.timedelta(seconds=interval),
) )
self.entry_id = entry_id self.entry_id = entry_id
self.database = database self.api_client = api_client
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:
@@ -111,12 +217,58 @@ class NestQuestCoordinator(DataUpdateCoordinator):
async with self._refresh_lock: async with self._refresh_lock:
await super().async_refresh() await super().async_refresh()
def _local_now(self) -> datetime.datetime:
time_zone = ZoneInfo(self.hass.config.time_zone)
return datetime.datetime.now(time_zone)
async def _async_update_data(self) -> NestQuestSnapshot: async def _async_update_data(self) -> NestQuestSnapshot:
now = self._local_now() try:
return await build_snapshot( payload = await self.api_client.get_snapshot()
self.database, self.settings, now except NestQuestApiError as err:
LOGGER.error(
"NestQuest coordinator refresh failed: %s", err
)
raise
return snapshot_from_api_payload(payload)
def coordinator_client_from_entry(
hass: HomeAssistant, entry: Any, *, session_factory: Any = None
) -> Any:
"""Build the refresh client for a config entry, tolerating no token.
Reads ``(base_url, panel_token)`` off the entry and constructs the
real client. An entry with no panel token configured (the shipped
default is the empty string) makes the constructor raise ValueError
by design; integration setup must not crash on an unconfigured
entry, so this factory swaps in the typed-error stub instead and
logs ONE clear message. The ValueError itself is never re-raised
past this point — it is converted, with the reason logged.
``session_factory`` overrides where the HTTP session comes from
(production: HA's shared aiohttp session via
:func:`~.api_client.client_from_entry`); it exists so tests can
inject a stub transport without importing aiohttp — the mock-only
test harness has no ``homeassistant.helpers.aiohttp_client``.
"""
from custom_components.nestquest.api_client import NestQuestApiClient
base_url, panel_token = resolve_api_config(entry)
if not isinstance(panel_token, str) or not panel_token.strip():
LOGGER.warning(
"NestQuest API panel token is not configured for entry %s: "
"entities will stay unavailable until a token is set in the "
"integration options",
entry.entry_id,
) )
return _UnconfiguredApiClient()
if session_factory is not None:
return NestQuestApiClient(
base_url, panel_token, session_factory(hass)
)
from custom_components.nestquest.api_client import client_from_entry
return client_from_entry(hass, entry)
#: Test seam: entry_id → client, consulted by the conftest's client
#: factory BEFORE the production path. Always empty in production
#: (nothing in the integration writes to it); tests register scripted
#: clients through it and clear it between tests.
_COORDINATOR_CLIENT_OVERRIDES: dict[str, Any] = {}
+121
View File
@@ -52,6 +52,7 @@ _HA_MODULES = (
"homeassistant.helpers.entity", "homeassistant.helpers.entity",
"homeassistant.helpers.event", "homeassistant.helpers.event",
"homeassistant.helpers.update_coordinator", "homeassistant.helpers.update_coordinator",
"homeassistant.helpers.aiohttp_client",
"homeassistant.components", "homeassistant.components",
"homeassistant.components.sensor", "homeassistant.components.sensor",
"homeassistant.components.binary_sensor", "homeassistant.components.binary_sensor",
@@ -99,6 +100,7 @@ for _parent, _child in (
("homeassistant.helpers", "entity"), ("homeassistant.helpers", "entity"),
("homeassistant.helpers", "event"), ("homeassistant.helpers", "event"),
("homeassistant.helpers", "update_coordinator"), ("homeassistant.helpers", "update_coordinator"),
("homeassistant.helpers", "aiohttp_client"),
("homeassistant", "components"), ("homeassistant", "components"),
("homeassistant.components", "sensor"), ("homeassistant.components", "sensor"),
("homeassistant.components", "binary_sensor"), ("homeassistant.components", "binary_sensor"),
@@ -700,6 +702,125 @@ def wire_entry_to_registry(entry, registry: ListenerRegistry):
return entry return entry
class LazyLocalSnapshotClient:
"""A test client that serves the snapshot built LOCALLY.
Feature 18 moved the coordinator's refresh onto the API service's
panel snapshot, but most integration tests still seed the local
database directly (the DB removal is a later task). This stub
bridges the two: ``get_snapshot`` resolves the entry's runtime
record (its database and settings) and runs the SAME pure core
builder the API route runs
(:func:`~custom_components.nestquest.core.snapshot.build_snapshot`),
shaping the payload exactly like ``api/routes_panel.py`` —
instances via ``instance_payload(include_missed=False)`` — so the
coordinator reconstructs from a byte-identical payload without any
network or FastAPI machinery.
The resolution is LAZY on purpose: the coordinator's factory runs
mid-setup, before the runtime record exists, so the first refresh
(and any refresh while the entry is torn down) serves an EMPTY but
valid snapshot; every later refresh reads the seeded database.
"""
def __init__(self, hass, entry_id: str) -> None:
self._hass = hass
self._entry_id = entry_id
self.calls = 0
async def get_snapshot(self):
import datetime
import zoneinfo
from custom_components.nestquest.const import DOMAIN as NQ_DOMAIN
from custom_components.nestquest.core import snapshot as core_snapshot
self.calls += 1
runtime = self._hass.data.get(NQ_DOMAIN, {}).get(self._entry_id)
database = getattr(runtime, "database", None)
if database is None or not getattr(database, "connected", True):
return {
"today_iso": datetime.date.today().isoformat(),
"cycle_day": 0,
"children": [],
}
settings = getattr(runtime, "settings", None)
# The HA-LOCAL now, the same single clock read the production
# coordinator used to hand the builder: the hass stub's
# configured time zone, never the host clock.
now = datetime.datetime.now(
zoneinfo.ZoneInfo(self._hass.config.time_zone)
)
snapshot = await core_snapshot.build_snapshot(database, settings, now)
return {
"today_iso": snapshot.today_iso,
"cycle_day": snapshot.cycle_day,
"children": [
{
"child_id": child.child_id,
"child_name": child.child_name,
"present": child.present,
"next_present": child.next_present,
"due_today": child.due_today,
"completed_today": child.completed_today,
"remaining_today": child.remaining_today,
"completion_pct": child.completion_pct,
"instances": core_snapshot.instance_payload(
child.instances, include_missed=False
),
}
for child in snapshot.children
],
}
@pytest.fixture(autouse=True)
def coordinator_serves_local_snapshot():
"""Default every test entry's coordinator to the local-snapshot client.
The integration's setup builds its API client through
``coordinator_client_from_entry``; the fixture swaps that factory
for a test one handing out :class:`LazyLocalSnapshotClient` (so
DB-seeded tests exercise the coordinator's API path against local
data). Tests that script their own client register it with
:func:`set_coordinator_client`, which the factory honours first.
"""
import custom_components.nestquest as nq_mod
import custom_components.nestquest.coordinator as coordinator_module
from custom_components.nestquest.coordinator import (
coordinator_client_from_entry as production_factory,
)
_COORDINATOR_CLIENT_OVERRIDES = (
coordinator_module._COORDINATOR_CLIENT_OVERRIDES
)
def _factory(hass, entry, **kwargs):
override = _COORDINATOR_CLIENT_OVERRIDES.get(entry.entry_id)
if override is not None:
return override
return LazyLocalSnapshotClient(hass, entry.entry_id)
import unittest.mock as mock
_COORDINATOR_CLIENT_OVERRIDES.clear()
patcher = mock.patch.object(
nq_mod, "coordinator_client_from_entry", _factory
)
patcher.start()
yield
patcher.stop()
_COORDINATOR_CLIENT_OVERRIDES.clear()
def set_coordinator_client(entry, client):
"""Register a scripted client for ``entry``'s coordinator factory."""
import custom_components.nestquest.coordinator as coordinator_module
coordinator_module._COORDINATOR_CLIENT_OVERRIDES[entry.entry_id] = client
return client
class _HassNamespace(SimpleNamespace): class _HassNamespace(SimpleNamespace):
"""SimpleNamespace subclass so the hass stub can hold weak references.""" """SimpleNamespace subclass so the hass stub can hold weak references."""
+422 -238
View File
@@ -1,263 +1,452 @@
"""Tests for the shared NestQuest coordinator (Feature 10, task 1).""" """Tests for the shared NestQuest coordinator (Feature 10, Feature 18).
The coordinator now polls the API service's panel snapshot through the
API client (Feature 18) and reconstructs the core snapshot dataclasses
every entity reads. The tests stub the client — NO network, NO aiohttp
— with a FIXTURE snapshot payload (the documented shape of the Feature
16 snapshot route, ``api/routes_panel.py``) and assert the
reconstruction: per-child counts and presence, the instance views
(``completed`` mapped back to ``done``), the household rollups, the
cycle day, and the unconfigured / failed-fetch behaviour.
"""
from __future__ import annotations from __future__ import annotations
import datetime import datetime
import pytest import pytest
from conftest import wire_entry_to_registry from conftest import set_coordinator_client, wire_entry_to_registry
from custom_components.nestquest import async_setup_entry, async_unload_entry from custom_components.nestquest import async_setup_entry, async_unload_entry
from custom_components.nestquest.children import ( from custom_components.nestquest.api_client import (
create_child, NestQuestApiClient,
list_children, NestQuestApiError,
set_child_active,
) )
from custom_components.nestquest.completion import complete_instance
from custom_components.nestquest.const import ( from custom_components.nestquest.const import (
CONF_UPDATE_INTERVAL, CONF_UPDATE_INTERVAL,
DEFAULT_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL,
DEFAULT_HORIZON_DAYS,
DOMAIN, DOMAIN,
) )
from custom_components.nestquest.coordinator import NestQuestCoordinator from custom_components.nestquest.coordinator import (
from custom_components.nestquest.dao_presence import PresenceSchedulesDao NestQuestCoordinator,
from custom_components.nestquest.materialize import materialize coordinator_client_from_entry,
from custom_components.nestquest.quest_definitions import ( snapshot_from_api_payload,
create_quest_definition,
) )
from custom_components.nestquest.recurrence import ScheduleRule from custom_components.nestquest.core.snapshot import instance_payload
ADMIN_CTX = {"user_id": "admin-1"}
def _today_iso() -> str: def _today_iso() -> str:
return datetime.date.today().isoformat() return datetime.date.today().isoformat()
async def _seed_day(database, *, completed: bool = False) -> None: class StubSnapshotClient:
"""Two active children + one inactive, one daily definition each.""" """A no-network stand-in for the API client's snapshot surface.
await create_child(database, "Ada")
await create_child(database, "Bo") Answers ``get_snapshot()`` from a scripted queue of outcomes — a
inactive = await create_child(database, "Ghost") payload dict or an exception per call, consumed in order — and
await set_child_active(database, inactive.id, False) records every call, so each test drives exactly one refresh.
today = _today_iso() """
await create_quest_definition(
database, def __init__(self, *outcomes) -> None:
"Brush teeth", self.calls = 0
ScheduleRule.from_dict({"rule_type": "daily", "start_date": today}), self._outcomes = list(outcomes)
[child.id for child in await list_children(database, active_only=True)], self._last = outcomes[-1] if outcomes else None
["morning"],
async def get_snapshot(self):
self.calls += 1
if not self._outcomes:
# Re-serve the last outcome: setup's first refresh and the
# test's explicit pass both poll the same scripted state.
outcome = self._last
else:
outcome = self._outcomes.pop(0)
self._last = outcome
if isinstance(outcome, BaseException):
raise outcome
return outcome
#: A fixture panel snapshot (the documented Feature 16 route shape):
#: two children — Ada owes one, owes none of it yet, is away today with
#: a returns date; Bo completed his only quest and is present.
FIXTURE_SNAPSHOT = {
"today_iso": _today_iso(),
"cycle_day": 3,
"children": [
{
"child_id": 1,
"child_name": "Ada",
"present": False,
"next_present": "2026-09-24",
"due_today": 2,
"completed_today": 1,
"remaining_today": 1,
"completion_pct": 50,
"instances": [
{
"id": 7,
"definition_id": 3,
"child_id": 1,
"title": "Brush teeth",
"icon": "🦷",
"window": "morning",
"due_time": "08:00",
"state": "completed",
"overdue": False,
"completed_at": f"{_today_iso()}T07:55:00+00:00",
"on_time": True,
},
{
"id": 8,
"definition_id": 3,
"child_id": 1,
"title": "Pack bag",
"icon": None,
"window": "morning",
"due_time": "10:00",
"state": "open",
"overdue": True,
"completed_at": None,
"on_time": None,
},
],
},
{
"child_id": 2,
"child_name": "Bo",
"present": True,
"next_present": None,
"due_today": 1,
"completed_today": 1,
"remaining_today": 0,
"completion_pct": 100,
"instances": [
{
"id": 9,
"definition_id": 4,
"child_id": 2,
"title": "Make bed",
"icon": "🛏",
"window": "morning",
"due_time": "07:30",
"state": "completed",
"overdue": False,
"completed_at": f"{_today_iso()}T07:31:00+00:00",
"on_time": True,
}
],
},
],
}
async def _wire_entry(hass, make_entry, client, options=None):
"""Build an entry whose coordinator uses the scripted API client.
The client is registered with the conftest override BEFORE setup:
the first refresh at setup already polls the snapshot, so the
platform setup sees real data and creates the whole entity set.
"""
from conftest import set_coordinator_client
entry = wire_entry_to_registry(
make_entry(
options=options,
data={"api_base_url": "http://api.test:8000", "panel_token": "tok"},
),
hass.registry,
) )
end = ( set_coordinator_client(entry, client)
datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
).isoformat()
await materialize(database, today, end, today=datetime.date.today())
async def test_coordinator_snapshot_counts_and_states(hass, make_entry) -> None:
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True assert await async_setup_entry(hass, entry) is True
coordinator = entry.runtime_data.coordinator coordinator = entry.runtime_data.coordinator
assert coordinator is not None assert coordinator.api_client is client
database = entry.runtime_data.database return entry, coordinator
await _seed_day(database)
async def test_coordinator_builds_snapshot_from_fixture(hass, make_entry) -> None:
"""One refresh = one snapshot GET; the fixture's values land on the
snapshot the entities read, per child and household-wide."""
client = StubSnapshotClient(FIXTURE_SNAPSHOT)
_entry, coordinator = await _wire_entry(hass, make_entry, client)
calls_after_setup = client.calls
snapshot = await coordinator._async_update_data() snapshot = await coordinator._async_update_data()
assert client.calls == calls_after_setup + 1
assert snapshot.today_iso == _today_iso() assert snapshot.today_iso == _today_iso()
assert len(snapshot.children) == 2, "inactive child must be absent" assert snapshot.cycle_day == 3
by_name = {child.child_name: child for child in snapshot.children} assert len(snapshot.children) == 2
ada = by_name["Ada"]
assert ada.due_today == 1 ada, bo = snapshot.children
assert ada.completed_today == 0 assert (ada.child_id, ada.child_name) == (1, "Ada")
assert ada.due_today == 2
assert ada.completed_today == 1
assert ada.remaining_today == 1 assert ada.remaining_today == 1
assert ada.completion_pct == 0 assert ada.completion_pct == 50
instance = ada.instances[0] assert ada.present is False
assert instance.state == "open" assert ada.next_present == "2026-09-24"
assert instance.overdue is False
assert instance.title == "Brush teeth" done_view, open_view = ada.instances
assert instance.window == "morning" assert done_view.instance_id == 7
assert ada.present is True, "no schedule means present every day" assert done_view.title == "Brush teeth"
assert ada.next_present is None, "present child has no returns date" assert done_view.icon == "🦷"
assert snapshot.cycle_day == 0, "no schedule anywhere" assert done_view.window == "morning"
assert done_view.due_time == "08:00"
assert done_view.due_date == _today_iso()
# The API's "completed" maps back to the core view's "done".
assert done_view.state == "done"
assert done_view.overdue is False
assert done_view.completed_at == f"{_today_iso()}T07:55:00+00:00"
assert done_view.was_on_time is True
assert open_view.state == "open"
assert open_view.overdue is True
assert open_view.completed_at is None
assert open_view.was_on_time is None
assert (bo.child_id, bo.child_name) == (2, "Bo")
assert bo.due_today == 1
assert bo.completed_today == 1
assert bo.remaining_today == 0
assert bo.completion_pct == 100
assert bo.present is True
assert bo.next_present is None
# Household rollups: the sums the household sensors compute.
assert sum(child.due_today for child in snapshot.children) == 3
assert sum(child.completed_today for child in snapshot.children) == 2
assert sum(child.remaining_today for child in snapshot.children) == 1
async def test_coordinator_next_present_for_away_child( async def test_coordinator_refresh_publishes_to_listeners(hass, make_entry) -> None:
hass, make_entry """The refresh pass notifies listeners and reports success — the
ONE refresh pass every entity reads from."""
client = StubSnapshotClient(FIXTURE_SNAPSHOT)
_entry, coordinator = await _wire_entry(hass, make_entry, client)
called = []
coordinator.async_add_listener(lambda: called.append("entity"))
await coordinator.async_refresh()
assert called == ["entity"]
assert coordinator.last_update_success is True
assert coordinator.data is not None
assert coordinator.data.children[0].child_name == "Ada"
async def test_instance_payload_shape_is_unchanged(hass, make_entry) -> None:
"""The per-child instance attribute payload the due-today sensor
emits keeps its exact documented shape, and admin_instances now
equals instances (the API omits missed rows — D-009; the admin
surface for missed quests is the PWA)."""
client = StubSnapshotClient(FIXTURE_SNAPSHOT)
_entry, coordinator = await _wire_entry(hass, make_entry, client)
snapshot = await coordinator._async_update_data()
ada = snapshot.children[0]
panel = instance_payload(ada.instances, include_missed=False)
assert panel == [
{
"id": 7,
"definition_id": 3,
"child_id": 1,
"title": "Brush teeth",
"icon": "🦷",
"window": "morning",
"due_time": "08:00",
"state": "completed",
"overdue": False,
"completed_at": f"{_today_iso()}T07:55:00+00:00",
"on_time": True,
},
{
"id": 8,
"definition_id": 3,
"child_id": 1,
"title": "Pack bag",
"icon": None,
"window": "morning",
"due_time": "10:00",
"state": "open",
"overdue": True,
"completed_at": None,
"on_time": None,
},
]
# The API payload omits missed rows, so include_missed cannot
# re-add them: admin_instances == instances, byte for byte.
assert instance_payload(ada.instances, include_missed=True) == panel
async def test_coordinator_entities_read_the_api_snapshot(hass, make_entry) -> None:
"""The registered sensors and binary sensors read the rebuilt
snapshot: entity values come from the fixture, with the SAME
entity_ids as ever."""
client = StubSnapshotClient(FIXTURE_SNAPSHOT)
entry, coordinator = await _wire_entry(hass, make_entry, client)
await coordinator.async_refresh()
ada_due = hass.entities[f"{DOMAIN}_child_1_quests_due_today"]
assert ada_due.native_value == 2
attributes = ada_due.extra_state_attributes
assert attributes["child_id"] == 1
assert attributes["child_name"] == "Ada"
assert attributes["present"] is False
assert [row["id"] for row in attributes["instances"]] == [7, 8]
assert (
hass.entities[f"{DOMAIN}_child_1_quests_completed_today"].native_value
== 1
)
assert (
hass.entities[f"{DOMAIN}_child_1_quests_remaining_today"].native_value
== 1
)
assert (
hass.entities[f"{DOMAIN}_child_1_completion_pct_today"].native_value
== 50
)
assert hass.entities[f"{DOMAIN}_child_1_next_quest"].native_value == (
"Pack bag"
)
# Ada owes one still: not all done; away today.
assert hass.entities[f"{DOMAIN}_child_1_all_done"].is_on is False
assert hass.entities[f"{DOMAIN}_child_1_present_today"].is_on is False
assert (
hass.entities[f"{DOMAIN}_child_2_completion_pct_today"].native_value
== 100
)
# Bo had a quest and owes none of it: all done, present today.
assert hass.entities[f"{DOMAIN}_child_2_all_done"].is_on is True
assert hass.entities[f"{DOMAIN}_child_2_present_today"].is_on is True
bo_attributes = hass.entities[
f"{DOMAIN}_child_2_present_today"
].extra_state_attributes
assert bo_attributes["next_present"] is None
assert (
hass.entities[f"{DOMAIN}_household_quests_due_today"].native_value == 3
)
assert (
hass.entities[f"{DOMAIN}_household_quests_completed_today"].native_value
== 2
)
cycle = hass.entities[f"{DOMAIN}_cycle_day"]
assert cycle.native_value == 3
assert cycle.extra_state_attributes == {
"cycle_day": 3,
"today": _today_iso(),
}
await async_unload_entry(hass, entry)
async def test_coordinator_failed_fetch_marks_unavailable(hass, make_entry) -> None:
"""A failed fetch (transport failure or non-2xx — both typed) raises
out of the refresh pass: last_update_success goes False (entities
unavailable), data keeps its last good value, and the typed error is
logged, never swallowed."""
error = NestQuestApiError(
"NestQuest API GET /api/v1/panel/snapshot failed with HTTP 503",
status=503,
)
# The stub serves the good snapshot for setup's first refresh, then
# the scripted failure.
client = StubSnapshotClient(FIXTURE_SNAPSHOT, error)
_entry, coordinator = await _wire_entry(hass, make_entry, client)
# First refresh (at setup) served the good snapshot.
assert coordinator.data is not None
assert coordinator.last_update_success is True
# The next pass hits the scripted failure: it raises out of the
# refresh, marks the entities unavailable, and keeps the last GOOD
# snapshot as data.
with pytest.raises(NestQuestApiError):
await coordinator.async_refresh()
assert coordinator.last_update_success is False
assert coordinator.data.children[0].child_name == "Ada"
async def test_coordinator_unconfigured_api_does_not_crash_setup(
hass, make_entry, monkeypatch
) -> None: ) -> None:
"""A child absent today carries the ISO date of their next present """An entry with no panel token set up fine: the coordinator gets
day; an always-absent pattern carries None instead.""" the typed-error stub, the first refresh fails cleanly (unavailable
entities), and setup does NOT crash."""
# Restore the production factory: the conftest default would serve
# the local snapshot, but this test exercises the token guard.
import custom_components.nestquest as nq
monkeypatch.setattr(
nq, "coordinator_client_from_entry", coordinator_client_from_entry
)
entry = wire_entry_to_registry(make_entry(), hass.registry) entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True assert await async_setup_entry(hass, entry) is True
coordinator = entry.runtime_data.coordinator coordinator = entry.runtime_data.coordinator
database = entry.runtime_data.database from custom_components.nestquest.coordinator import _UnconfiguredApiClient
await create_child(database, "Ada")
ada = (await list_children(database))[0] assert isinstance(coordinator.api_client, _UnconfiguredApiClient)
today = datetime.date.today() # A real NestQuestApiClient was never built (it would ValueError).
pattern = ",".join( assert not isinstance(coordinator.api_client, NestQuestApiClient)
str(day) for day in range(7) if day != today.weekday() assert coordinator.last_update_success is False
) assert coordinator.data is None
await PresenceSchedulesDao(database).upsert_by_child( with pytest.raises(NestQuestApiError, match="not configured"):
ada.id, 1, today.isoformat(), pattern await coordinator.async_refresh()
)
await create_child(database, "Bo")
bo = (await list_children(database))[1]
await PresenceSchedulesDao(database).upsert_by_child(
bo.id, 1, today.isoformat(), ""
)
snapshot = await coordinator._async_update_data()
by_name = {child.child_name: child for child in snapshot.children}
assert by_name["Ada"].present is False
assert by_name["Ada"].next_present == (
today + datetime.timedelta(days=1)
).isoformat()
assert by_name["Bo"].present is False
assert by_name["Bo"].next_present is None
async def test_coordinator_reflects_completion_and_uncompletion( async def test_coordinator_configured_token_builds_real_client(
hass, make_entry hass, make_entry
) -> None: ) -> None:
entry = wire_entry_to_registry(make_entry(), hass.registry) """An entry WITH a panel token builds the real client through the
production factory (a stub transport injected via session_factory,
the mock-only harness having no aiohttp module to hand in), and the
coordinator's refresh polls the snapshot route end to end."""
import json as json_module
class _OneShotSession:
"""A session-shaped stub answering every request with the fixture."""
def __init__(self, payload) -> None:
self._body = json_module.dumps(payload)
def request(self, method, url, **kwargs):
body = self._body
class _Response:
def __init__(self) -> None:
self.status = 200
async def text(self):
return body
class _Context:
async def __aenter__(self):
return _Response()
async def __aexit__(self, *exc):
return False
return _Context()
def _session_factory(_hass):
return _OneShotSession(FIXTURE_SNAPSHOT)
entry = wire_entry_to_registry(
make_entry(
data={"api_base_url": "http://api.test:8000", "panel_token": "tok"}
),
hass.registry,
)
real_client = coordinator_client_from_entry(
hass, entry, session_factory=_session_factory
)
assert isinstance(real_client, NestQuestApiClient)
set_coordinator_client(entry, real_client)
assert await async_setup_entry(hass, entry) is True assert await async_setup_entry(hass, entry) is True
coordinator = entry.runtime_data.coordinator coordinator = entry.runtime_data.coordinator
database = entry.runtime_data.database assert isinstance(coordinator.api_client, NestQuestApiClient)
await _seed_day(database) # The real client polls the stub transport; the snapshot lands.
ada = ( await coordinator.async_refresh()
await coordinator._async_update_data() assert coordinator.data.children[0].child_name == "Ada"
).children[0]
instance_id = ada.instances[0].instance_id
await complete_instance(
database,
instance_id,
actor_source="panel",
actor_child_id=ada.child_id,
)
snapshot = await coordinator._async_update_data()
child = snapshot.children[0]
assert child.completed_today == 1
assert child.remaining_today == 0
assert child.completion_pct == 100
view = child.instances[0]
assert view.state == "done"
assert view.completed_at is not None
assert view.was_on_time is not None
from custom_components.nestquest.completion import uncomplete_instance
await uncomplete_instance(
database, instance_id, actor_source="user", actor_user_id="admin-1"
)
snapshot = await coordinator._async_update_data()
child = snapshot.children[0]
assert child.completed_today == 0
assert child.remaining_today == 1
assert child.instances[0].state == "open"
async def test_coordinator_zero_quests_reports_full_percentage(
hass, make_entry
) -> None:
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
coordinator = entry.runtime_data.coordinator
await create_child(entry.runtime_data.database, "Ada")
snapshot = await coordinator._async_update_data()
child = snapshot.children[0]
assert child.due_today == 0
assert child.completion_pct == 100, "zero quests is a perfect day"
assert child.instances == ()
async def test_coordinator_cycle_day_from_first_scheduled_child(
hass, make_entry
) -> None:
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
await create_child(database, "Ada")
ada = (await list_children(database))[0]
today = datetime.date.today()
# A 2-week cycle anchored 3 days ago: today is day 4.
anchor = today - datetime.timedelta(days=3)
await PresenceSchedulesDao(database).upsert_by_child(
ada.id, 2, anchor.isoformat(), "0,1,2,3,4,5,6|"
)
snapshot = await coordinator._async_update_data()
assert snapshot.cycle_day == 4
async def test_coordinator_presence_resolves_schedule_and_override(
hass, make_entry
) -> None:
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
await create_child(database, "Ada")
ada = (await list_children(database))[0]
today = datetime.date.today()
# Scheduled absent today (empty weekday set for this cycle week).
await PresenceSchedulesDao(database).upsert_by_child(
ada.id, 1, today.isoformat(), ""
)
snapshot = await coordinator._async_update_data()
assert snapshot.children[0].present is False
# An override beats the pattern.
from custom_components.nestquest.dao_presence import (
PresenceOverridesDao,
)
await PresenceOverridesDao(database).create(
ada.id, today.isoformat(), today.isoformat(), True
)
snapshot = await coordinator._async_update_data()
assert snapshot.children[0].present is True
async def test_coordinator_next_present_honours_future_override(
hass, make_entry
) -> None:
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
await create_child(database, "Ada")
ada = (await list_children(database))[0]
today = datetime.date.today()
tomorrow = today + datetime.timedelta(days=1)
# Scheduled present tomorrow (single-day cycle, weekday set set below
# covers every day), but overridden absent tomorrow.
await PresenceSchedulesDao(database).upsert_by_child(
ada.id, 1, today.isoformat(), "0,1,2,3,4,5,6"
)
from custom_components.nestquest.dao_presence import (
PresenceOverridesDao,
)
await PresenceOverridesDao(database).create(
ada.id, tomorrow.isoformat(), tomorrow.isoformat(), False
)
snapshot = await coordinator._async_update_data()
child = snapshot.children[0]
assert child.present is True
assert child.next_present != tomorrow.isoformat(), (
"a future absent override must remove tomorrow from the preview"
)
async def test_coordinator_derives_missed_for_past_due_open(
hass, make_entry
) -> None:
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
coordinator = entry.runtime_data.coordinator
snapshot = await coordinator._async_update_data()
# No children: empty but valid snapshot.
assert snapshot.children == ()
async def test_setup_creates_coordinator_with_configured_interval( async def test_setup_creates_coordinator_with_configured_interval(
@@ -270,12 +459,9 @@ async def test_setup_creates_coordinator_with_configured_interval(
coordinator = entry.runtime_data.coordinator coordinator = entry.runtime_data.coordinator
assert isinstance(coordinator, NestQuestCoordinator) assert isinstance(coordinator, NestQuestCoordinator)
assert coordinator.update_interval == datetime.timedelta(seconds=60) assert coordinator.update_interval == datetime.timedelta(seconds=60)
assert coordinator.data is not None, "first refresh ran at setup"
async def test_setup_defaults_to_five_minute_interval( async def test_setup_defaults_to_five_minute_interval(hass, make_entry) -> None:
hass, make_entry
) -> None:
entry = wire_entry_to_registry(make_entry(), hass.registry) entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True assert await async_setup_entry(hass, entry) is True
coordinator = entry.runtime_data.coordinator coordinator = entry.runtime_data.coordinator
@@ -298,24 +484,11 @@ async def test_unload_shuts_coordinator_down(hass, make_entry) -> None:
assert torn_down == [coordinator] assert torn_down == [coordinator]
async def test_coordinator_notifies_listeners_on_refresh(
hass, make_entry
) -> None:
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
coordinator = entry.runtime_data.coordinator
called = []
coordinator.async_add_listener(lambda: called.append("entity"))
await coordinator.async_refresh()
assert called == ["entity"]
assert coordinator.last_update_success is True
async def test_failed_first_refresh_shuts_coordinator_down( async def test_failed_first_refresh_shuts_coordinator_down(
hass, make_entry, monkeypatch hass, make_entry, monkeypatch
) -> None: ) -> None:
"""A setup failure after coordinator creation must not leak its """A setup failure after coordinator creation must not leak its
listeners against the database the except path closes.""" listeners."""
from custom_components.nestquest import async_setup_entry from custom_components.nestquest import async_setup_entry
import custom_components.nestquest.coordinator as coordinator_module import custom_components.nestquest.coordinator as coordinator_module
@@ -344,3 +517,14 @@ async def test_failed_first_refresh_shuts_coordinator_down(
await async_setup_entry(hass, entry) await async_setup_entry(hass, entry)
assert shutdown_calls == [entry.entry_id] assert shutdown_calls == [entry.entry_id]
assert getattr(entry, "runtime_data", None) is None assert getattr(entry, "runtime_data", None) is None
async def test_snapshot_from_api_payload_empty_household() -> None:
"""An empty household (no children) is a valid snapshot: empty
children tuple, cycle day passes through."""
snapshot = snapshot_from_api_payload(
{"today_iso": "2026-09-23", "cycle_day": 0, "children": []}
)
assert snapshot.children == ()
assert snapshot.cycle_day == 0
assert snapshot.today_iso == "2026-09-23"
+37 -10
View File
@@ -123,9 +123,11 @@ async def test_build_snapshot_explicit_values(hass, make_entry, monkeypatch) ->
database = entry.runtime_data.database database = entry.runtime_data.database
settings = entry.runtime_data.settings settings = entry.runtime_data.settings
# Single clock read for the seed/snapshot anchor (no midnight skew). # Single clock read for the seed/snapshot anchor (no midnight skew);
today = coordinator._local_now().date() # the coordinator no longer owns a clock read (the API serves the
# snapshot), so the test resolves the HA-local zone directly.
time_zone = ZoneInfo(hass.config.time_zone) time_zone = ZoneInfo(hass.config.time_zone)
today = datetime.datetime.now(time_zone).date()
# Pin the build instant at 12:00 local: past Ada's 10:00 due time # 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. # (overdue) and before Bo's 17:00, and the completion moment for Bo.
pinned_now = datetime.datetime( pinned_now = datetime.datetime(
@@ -294,13 +296,38 @@ async def test_build_snapshot_explicit_values(hass, make_entry, monkeypatch) ->
== "missed" == "missed"
) )
# REQUIRED wrapper tie-in: the coordinator's _async_update_data is a # REQUIRED wrapper tie-in: since Feature 18 the coordinator's
# thin WRAPPER over build_snapshot, so pinning its clock to the SAME # refresh polls the snapshot through its API client, so pinning the
# pinned_now the builder was called with must reproduce the builder's # client to serve the payload of the SAME snapshot built with
# snapshot byte-for-byte. The dataclasses are frozen, so == is a full # pinned_now must reproduce the builder's snapshot byte-for-byte.
# deep compare — a wrapper that mis-passed today vs now, or stopped # The dataclasses are frozen, so == is a full deep compare — a
# calling the builder, would diverge here even though the literal # reconstruction that dropped or reshaped a field would diverge
# assertions above still pass. # here even though the literal assertions above still pass.
monkeypatch.setattr(coordinator, "_local_now", lambda: pinned_now) from conftest import set_coordinator_client
class _PinnedPayloadClient:
async def get_snapshot(self):
return {
"today_iso": built.today_iso,
"cycle_day": built.cycle_day,
"children": [
{
"child_id": child.child_id,
"child_name": child.child_name,
"present": child.present,
"next_present": child.next_present,
"due_today": child.due_today,
"completed_today": child.completed_today,
"remaining_today": child.remaining_today,
"completion_pct": child.completion_pct,
"instances": instance_payload(
child.instances, include_missed=False
),
}
for child in built.children
],
}
set_coordinator_client(entry, _PinnedPayloadClient())
await coordinator.async_refresh() await coordinator.async_refresh()
assert coordinator.data == built assert coordinator.data == built
+9 -6
View File
@@ -180,9 +180,12 @@ async def test_entity_and_event_matrix(hass, make_entry) -> None:
assert _sensor(hass, ada.id, "all_done").is_on is False assert _sensor(hass, ada.id, "all_done").is_on is False
assert _sensor(hass, ada.id, "quests_remaining_today").native_value == 1 assert _sensor(hass, ada.id, "quests_remaining_today").native_value == 1
# --- The instances payload omits missed instances while # --- The instances payload omits missed instances (D-009) — and
# admin_instances includes them (D-009): pin the derive clock # since Feature 18 the snapshot comes from the API panel route,
# forward in the TEST ONLY so today's open instances read missed. # which omits missed rows ENTIRELY, so admin_instances cannot
# carry them either (the admin surface for missed quests is the
# PWA, reading the API directly): pin the derive clock forward in
# the TEST ONLY so today's open instances read missed.
original_refresh = coordinator._async_update_data original_refresh = coordinator._async_update_data
async def _future_dated_refresh(): async def _future_dated_refresh():
@@ -203,9 +206,9 @@ async def test_entity_and_event_matrix(hass, make_entry) -> None:
await coordinator.async_refresh() await coordinator.async_refresh()
attributes = _sensor(hass, bo.id, "quests_due_today").extra_state_attributes attributes = _sensor(hass, bo.id, "quests_due_today").extra_state_attributes
assert attributes["instances"] == [], "panel payload omits missed" assert attributes["instances"] == [], "panel payload omits missed"
assert len(attributes["admin_instances"]) == 2 assert attributes["admin_instances"] == [], (
assert all( "the API payload omits missed rows, so the admin payload "
entry["state"] == "missed" for entry in attributes["admin_instances"] "carries none either (the PWA is the admin surface now)"
) )
coordinator._async_update_data = original_refresh coordinator._async_update_data = original_refresh
await coordinator.async_refresh() await coordinator.async_refresh()
+18 -16
View File
@@ -221,19 +221,23 @@ async def test_due_today_attributes_carry_the_panel_payload(
assert bo_instance["on_time"] is None assert bo_instance["on_time"] is None
async def test_due_today_payload_omits_missed_for_panel_includes_for_admin( async def test_due_today_payload_omits_missed_admin_equals_panel(
hass, make_entry, monkeypatch hass, make_entry, monkeypatch
) -> None: ) -> None:
"""D-009: the panel payload omits missed instances, the admin one keeps them. """D-009: the panel payload omits missed instances.
A missed instance is derived (``due_date`` before the snapshot's Since Feature 18 the snapshot comes from the API panel route,
"today"), never stored. The coordinator's day listing is keyed on which omits missed rows entirely (the admin surface for missed
``_local_now``'s date, so pinning the clock forward would move the quests is the PWA, reading the API directly). The entity payload
listing window off today's rows entirely; the pinned FUTURE clock therefore cannot carry missed rows any more: ``admin_instances``
is therefore applied at the ``derive_state`` boundary instead — equals ``instances`` — open and completed rows only. A missed
today's open instances then read as missed through the same instance is still derived locally (``due_date`` before the
derivation rule, without back-dating instance rows past the DAO's snapshot's "today"); the pinned-FUTURE clock at the
no-past guard or touching append-only completion events. ``derive_state`` boundary makes today's open instances read
missed through the same derivation rule, and this test pins the
change: neither payload carries them, and the count rollups
(precomputed server-side from the full day) are unaffected here
because the API counts arrive per the route's own build.
""" """
entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry( entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry(
hass, make_entry hass, make_entry
@@ -256,12 +260,10 @@ async def test_due_today_payload_omits_missed_for_panel_includes_for_admin(
assert attributes["instances"] == [], ( assert attributes["instances"] == [], (
"missed instances are omitted from the panel payload" "missed instances are omitted from the panel payload"
) )
[missed] = attributes["admin_instances"] assert attributes["admin_instances"] == [], (
assert missed["state"] == "missed" "the API payload omits missed rows, so the admin payload "
assert missed["completed_at"] is None "carries none either (the PWA is the admin surface now)"
# Counts still roll the missed instance up as not-completed. )
assert _sensor(hass, bo.id, "quests_completed_today").native_value == 0
assert _sensor(hass, bo.id, "quests_remaining_today").native_value == 1
@pytest.mark.parametrize("kind", SENSOR_KINDS) @pytest.mark.parametrize("kind", SENSOR_KINDS)