From fafabeabbe5e55c9323b2230dc085d2e188b7087 Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Wed, 23 Sep 2026 06:33:44 -0400 Subject: [PATCH] Task 6e70d166: Coordinator polls the snapshot and builds the sensors (#167) --- custom_components/nestquest/__init__.py | 48 +- custom_components/nestquest/coordinator.py | 255 +++++-- tests/conftest.py | 170 ++++- tests/test_coordinator.py | 737 ++++++++++++++------- tests/test_core_snapshot.py | 47 +- tests/test_entity_matrix.py | 15 +- tests/test_sensors.py | 35 +- 7 files changed, 987 insertions(+), 320 deletions(-) diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index 6b0eac4..bbcb3ad 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -26,7 +26,7 @@ from .const import ( ) from .core.settings import NestQuestSettings 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 .materialize import materialize as _materialize_run from .migrations import apply_migrations @@ -490,18 +490,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _register_services(hass) # The shared Feature 10 coordinator: one refresh cycle every # entity reads from (CONF_UPDATE_INTERVAL seconds, default - # five minutes). Created AFTER services so a failure below - # unwinds them through the except path's listener handling. + # five minutes). Since Feature 18 it polls the API service's + # 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( hass, entry_id=entry.entry_id, - database=database, - settings=settings, + api_client=coordinator_client_from_entry(hass, entry), update_interval_seconds=entry.options.get( CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL ), ) - await coordinator.async_config_entry_first_refresh() runtime_data = NestQuestRuntimeData( entry_id=entry.entry_id, settings=settings, @@ -510,11 +512,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: remove_time_change_listener=remove_time_change_listener, coordinator=coordinator, ) - # runtime_data is assigned BEFORE the platforms forward so each - # platform's async_setup_entry reads its coordinator off the - # entry (the HA runtime-data contract); a forward failure below + # The runtime record is registered BEFORE the first refresh: + # the refresh path (and anything it calls) resolves the entry's + # 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. + hass.data[DOMAIN][entry.entry_id] = 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. HA's real + # ``async_config_entry_first_refresh`` wraps ANY failed update + # pass in ConfigEntryNotReady — the original error survives + # only as ``__cause__``, never as the raised exception — so the + # catch keys on ConfigEntryNotReady, NOT on the typed API + # error. Everything else (a platform-forward failure, + # cancellation) still unwinds through the except block below. + try: + await coordinator.async_config_entry_first_refresh() + except ConfigEntryNotReady: + pass await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) except BaseException: # A failure anywhere after the database is open must not leak the @@ -548,10 +569,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # closes the already-closed database through the stale record. if getattr(entry, "runtime_data", None) is not None: entry.runtime_data = None + # The runtime record was ALSO registered in hass.data before + # the first refresh: remove it here on ANY failure, or a later + # unload picks up the stale, already-torn-down record + # (hass.data wins over entry.runtime_data there) and + # re-invokes the already-consumed update-listener remover. + hass.data.get(DOMAIN, {}).pop(entry.entry_id, None) await database.close() raise assert remove_update_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 return True diff --git a/custom_components/nestquest/coordinator.py b/custom_components/nestquest/coordinator.py index 5a26876..1d634e1 100644 --- a/custom_components/nestquest/coordinator.py +++ b/custom_components/nestquest/coordinator.py @@ -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, today's instances with their derived states (open / done / missed via @@ -7,40 +7,56 @@ per-child rollup counts. Every Feature 10 entity reads ``coordinator.data`` — no entity queries the database itself, so one refresh updates the whole board coherently. -"Today" and "now" are HA-local (``hass.config.time_zone``), never the -host clock and never UTC, matching the materialization and completion -layers. +Since Feature 18 the snapshot no longer comes from the local database: +the coordinator polls the NestQuest API service's panel plane — +``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 -: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. +One loss is deliberate and documented (D-009 revisited): the panel +payload OMITS missed instances, so a snapshot rebuilt from the API can +no longer carry them — ``admin_instances`` (``include_missed=True``) +now equals ``instances`` (they can hold only open and completed rows). +The admin surface for missed quests is the PWA, which reads the API +service directly; the integration's entities never needed missed rows +for their states or counts (the counts arrive precomputed). -Presence is resolved through the SAME engine the materializer uses -(:class:`~.presence.PresenceEngine`): schedules and overrides are read -with the public DAO methods and the engine's anchor-date arithmetic -answers ``is this child at this house today`` (D-004 — never ISO week -parity). - -``cycle_day`` reports where today falls in the custody cycle of the -first active child (display order) that HAS a schedule — 1-based day -``N`` of ``cycle_length_weeks * 7``, computed from that schedule's -anchor date. ``0`` means no active child has a schedule at all, so -the admin header degrades to a harmless zero instead of guessing. +Failure handling: constructing the client fails fast (ValueError) when +the entry has no panel token configured, and a fetch failure raises the +typed :class:`~.api_client.NestQuestApiError`. Neither crashes +integration setup: the constructor logs a clear message and hands back +a client-shaped stub whose refresh raises the same typed error, and +``_async_update_data`` converts every typed error into +:class:`~homeassistant.helpers.update_coordinator.UpdateFailed` — the +exception a HA ``DataUpdateCoordinator`` records as a failed pass +(``last_update_success = False``, ``data = None``), so the entities +come up unavailable. The typed error is never swallowed silently: it +is logged with its message and chained into the UpdateFailed. (The +conversion matters at setup: HA's real +``async_config_entry_first_refresh`` wraps any failed pass in +``ConfigEntryNotReady``, so a setup that keys on the original +exception type would never match it.) """ from __future__ import annotations import asyncio import datetime -from zoneinfo import ZoneInfo +from typing import Any from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import ( + DataUpdateCoordinator, + UpdateFailed, +) +from .api_client import NestQuestApiClient, NestQuestApiError, resolve_api_config from .const import ( CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL, @@ -48,13 +64,10 @@ from .const import ( LOGGER, MIN_UPDATE_INTERVAL, ) -from .core.db import NestQuestDatabase -from .core.settings import NestQuestSettings from .core.snapshot import ( ChildDaySnapshot, NestQuestSnapshot, QuestInstanceView, - build_snapshot, ) __all__ = [ @@ -65,9 +78,120 @@ __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; + :meth:`NestQuestCoordinator._async_update_data` converts it to + :class:`~homeassistant.helpers.update_coordinator.UpdateFailed`, 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): """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 the client; + ``_async_update_data`` converts it to + :class:`~homeassistant.helpers.update_coordinator.UpdateFailed`, the + exception HA's coordinator machinery records as a failed pass: 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. An unexpected + non-typed error still propagates out of ``_async_update_data`` + and is logged (with its traceback) by HA's machinery, the same way. + Forced refreshes (the service paths pushing an immediate update after a completion) are SERIALIZED per entry: overlapping complete/uncomplete calls must not interleave their snapshot @@ -82,8 +206,7 @@ class NestQuestCoordinator(DataUpdateCoordinator): hass: HomeAssistant, *, entry_id: str, - database: NestQuestDatabase, - settings: NestQuestSettings, + api_client: NestQuestApiClient, update_interval_seconds: int | None = None, ) -> None: interval = update_interval_seconds @@ -100,8 +223,7 @@ class NestQuestCoordinator(DataUpdateCoordinator): update_interval=datetime.timedelta(seconds=interval), ) self.entry_id = entry_id - self.database = database - self.settings = settings + self.api_client = api_client self._refresh_lock = asyncio.Lock() async def async_refresh(self) -> None: @@ -111,12 +233,67 @@ class NestQuestCoordinator(DataUpdateCoordinator): async with self._refresh_lock: 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: - now = self._local_now() - return await build_snapshot( - self.database, self.settings, now + """One refresh pass: fetch the panel snapshot and rebuild it. + + A typed API failure (unconfigured token stub, transport + failure, non-2xx response) is converted to + :class:`~homeassistant.helpers.update_coordinator.UpdateFailed` + — the exception HA's coordinator machinery records as a failed + pass (``last_update_success = False``, ``data`` unchanged) so + entities go unavailable and the setup-time first refresh + arrives as ``ConfigEntryNotReady`` — with the typed error + logged and chained. Anything else still propagates. + """ + try: + payload = await self.api_client.get_snapshot() + except NestQuestApiError as err: + LOGGER.error( + "NestQuest coordinator refresh failed: %s", err + ) + raise UpdateFailed(str(err)) from err + 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``. + """ + 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] = {} diff --git a/tests/conftest.py b/tests/conftest.py index ece6b88..d7e836e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -52,6 +52,7 @@ _HA_MODULES = ( "homeassistant.helpers.entity", "homeassistant.helpers.event", "homeassistant.helpers.update_coordinator", + "homeassistant.helpers.aiohttp_client", "homeassistant.components", "homeassistant.components.sensor", "homeassistant.components.binary_sensor", @@ -99,6 +100,7 @@ for _parent, _child in ( ("homeassistant.helpers", "entity"), ("homeassistant.helpers", "event"), ("homeassistant.helpers", "update_coordinator"), + ("homeassistant.helpers", "aiohttp_client"), ("homeassistant", "components"), ("homeassistant.components", "sensor"), ("homeassistant.components", "binary_sensor"), @@ -365,6 +367,15 @@ class BinarySensorEntity(Entity): return self._attr_device_class +class UpdateFailed(HomeAssistantError): + """Stand-in mirroring homeassistant.helpers.update_coordinator.UpdateFailed. + + The exception an update pass raises to report a failed fetch: the + coordinator records ``last_update_success = False`` instead of + propagating it out of the refresh. + """ + + class DataUpdateCoordinator: """Functional stand-in for HA's DataUpdateCoordinator. @@ -373,8 +384,20 @@ class DataUpdateCoordinator: ``async_add_listener``/``async_update_listeners`` for entity subscription, ``async_refresh``/``async_config_entry_first_refresh`` driving the subclass's ``_async_update_data``, ``last_update_success``, - and ``data``. Interval scheduling is NOT simulated (no real clock - ticks in tests) — tests drive refreshes directly. + ``last_exception``, and ``data``. Interval scheduling is NOT + simulated (no real clock ticks in tests) — tests drive refreshes + directly. + + The failure contract mirrors real HA exactly (it is the trap this + harness once hid): an update failure NEVER propagates out of + ``async_refresh`` — the coordinator records + ``last_update_success = False``, keeps its last good ``data``, and + still notifies listeners so entities re-render unavailable — and + ``async_config_entry_first_refresh`` wraps ANY failed pass in + :class:`ConfigEntryNotReady`, with the original error only + surviving as ``__cause__``. A stand-in that re-raised the raw + update error would let setup code key on the original exception + type — a clause real HA can never reach. """ def __init__(self, hass, logger, *, name=None, update_interval=None): @@ -384,6 +407,7 @@ class DataUpdateCoordinator: self.update_interval = update_interval self.data = None self.last_update_success = False + self.last_exception = None self._listeners = [] def async_add_listener(self, update_callback, context=None): @@ -403,18 +427,35 @@ class DataUpdateCoordinator: callback() async def async_refresh(self): - """Run one update pass and notify listeners.""" + """Run one update pass and notify listeners. + + Mirrors HA: a failed update pass is RECORDED, never raised — + ``last_update_success`` goes False, the exception lands on + ``last_exception``, and listeners still fire; only cancellation + propagates. + """ try: self.data = await self._async_update_data() self.last_update_success = True - except BaseException: + except Exception as err: self.last_update_success = False - raise + self.last_exception = err self.async_update_listeners() async def async_config_entry_first_refresh(self): - """First refresh at config-entry setup (alias of refresh).""" + """First refresh at config-entry setup, per HA's real contract. + + HA's real ``async_config_entry_first_refresh`` wraps ANY failed + update pass in ``ConfigEntryNotReady`` — the original error + survives only as ``__cause__`` — so setup code that must + tolerate a failed first refresh keys on ConfigEntryNotReady, + never on the original exception type. + """ await self.async_refresh() + if not self.last_update_success: + raise ConfigEntryNotReady( + f"error fetching {self.name} data: {self.last_exception}" + ) from self.last_exception async def async_shutdown(self): """Release listeners on teardown.""" @@ -424,6 +465,7 @@ class DataUpdateCoordinator: _update_coordinator_mock = _ha_mock("homeassistant.helpers.update_coordinator") _update_coordinator_mock.DataUpdateCoordinator = DataUpdateCoordinator _update_coordinator_mock.CoordinatorEntity = CoordinatorEntity +_update_coordinator_mock.UpdateFailed = UpdateFailed _entity_mock = _ha_mock("homeassistant.helpers.entity") _entity_mock.DeviceInfo = DeviceInfo @@ -700,6 +742,122 @@ def wire_entry_to_registry(entry, registry: ListenerRegistry): 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 + + _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): """SimpleNamespace subclass so the hass stub can hold weak references.""" diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index 415318e..25dc0a0 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -1,263 +1,512 @@ -"""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 import datetime 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.children import ( - create_child, - list_children, - set_child_active, +from custom_components.nestquest.api_client import ( + NestQuestApiClient, + NestQuestApiError, ) -from custom_components.nestquest.completion import complete_instance from custom_components.nestquest.const import ( CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL, - DEFAULT_HORIZON_DAYS, DOMAIN, ) -from custom_components.nestquest.coordinator import NestQuestCoordinator -from custom_components.nestquest.dao_presence import PresenceSchedulesDao -from custom_components.nestquest.materialize import materialize -from custom_components.nestquest.quest_definitions import ( - create_quest_definition, +from custom_components.nestquest.coordinator import ( + NestQuestCoordinator, + coordinator_client_from_entry, + snapshot_from_api_payload, ) -from custom_components.nestquest.recurrence import ScheduleRule - -ADMIN_CTX = {"user_id": "admin-1"} +from custom_components.nestquest.core.snapshot import instance_payload +from homeassistant.helpers.update_coordinator import UpdateFailed def _today_iso() -> str: return datetime.date.today().isoformat() -async def _seed_day(database, *, completed: bool = False) -> None: - """Two active children + one inactive, one daily definition each.""" - await create_child(database, "Ada") - await create_child(database, "Bo") - inactive = await create_child(database, "Ghost") - await set_child_active(database, inactive.id, False) - today = _today_iso() - await create_quest_definition( - database, - "Brush teeth", - ScheduleRule.from_dict({"rule_type": "daily", "start_date": today}), - [child.id for child in await list_children(database, active_only=True)], - ["morning"], +class StubSnapshotClient: + """A no-network stand-in for the API client's snapshot surface. + + Answers ``get_snapshot()`` from a scripted queue of outcomes — a + payload dict or an exception per call, consumed in order — and + records every call, so each test drives exactly one refresh. + """ + + def __init__(self, *outcomes) -> None: + self.calls = 0 + self._outcomes = list(outcomes) + self._last = outcomes[-1] if outcomes else None + + 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 = ( - 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) + set_coordinator_client(entry, client) assert await async_setup_entry(hass, entry) is True coordinator = entry.runtime_data.coordinator - assert coordinator is not None - database = entry.runtime_data.database - await _seed_day(database) + assert coordinator.api_client is client + return entry, coordinator + +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() + + assert client.calls == calls_after_setup + 1 assert snapshot.today_iso == _today_iso() - assert len(snapshot.children) == 2, "inactive child must be absent" - by_name = {child.child_name: child for child in snapshot.children} - ada = by_name["Ada"] - assert ada.due_today == 1 - assert ada.completed_today == 0 + assert snapshot.cycle_day == 3 + assert len(snapshot.children) == 2 + + ada, bo = snapshot.children + 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.completion_pct == 0 - instance = ada.instances[0] - assert instance.state == "open" - assert instance.overdue is False - assert instance.title == "Brush teeth" - assert instance.window == "morning" - assert ada.present is True, "no schedule means present every day" - assert ada.next_present is None, "present child has no returns date" - assert snapshot.cycle_day == 0, "no schedule anywhere" + assert ada.completion_pct == 50 + assert ada.present is False + assert ada.next_present == "2026-09-24" + + done_view, open_view = ada.instances + assert done_view.instance_id == 7 + assert done_view.title == "Brush teeth" + assert done_view.icon == "🦷" + 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( - hass, make_entry -) -> None: - """A child absent today carries the ISO date of their next present - day; an always-absent pattern carries None instead.""" - 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() - pattern = ",".join( - str(day) for day in range(7) if day != today.weekday() - ) - await PresenceSchedulesDao(database).upsert_by_child( - ada.id, 1, today.isoformat(), pattern - ) - await create_child(database, "Bo") - bo = (await list_children(database))[1] - await PresenceSchedulesDao(database).upsert_by_child( - bo.id, 1, today.isoformat(), "" - ) +async def test_coordinator_refresh_publishes_to_listeners(hass, make_entry) -> None: + """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() - 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 + 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_reflects_completion_and_uncompletion( - 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 _seed_day(database) - ada = ( +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) is a + FAILED refresh pass: the update pass converts the typed error to + UpdateFailed (the exception HA's coordinator machinery records as a + failed pass, chaining the typed error as ``__cause__``), the refresh + records the failure instead of raising, and data keeps its last + good value.""" + 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: the update pass converts + # the typed error to UpdateFailed, with the typed error chained. + with pytest.raises(UpdateFailed, match="HTTP 503") as excinfo: await coordinator._async_update_data() - ).children[0] - instance_id = ada.instances[0].instance_id + assert isinstance(excinfo.value.__cause__, NestQuestApiError) - 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" + # The refresh pass records the failure the HA way — it does NOT + # raise: last_update_success goes False, data keeps the last GOOD + # snapshot, and the failed pass lands on last_exception as the + # converted UpdateFailed with the typed error chained. + await coordinator.async_refresh() + assert coordinator.last_update_success is False + assert coordinator.data.children[0].child_name == "Ada" + assert isinstance(coordinator.last_exception, UpdateFailed) + assert isinstance(coordinator.last_exception.__cause__, NestQuestApiError) -async def test_coordinator_zero_quests_reports_full_percentage( +async def test_failed_first_refresh_does_not_crash_setup( hass, make_entry ) -> None: + """A failed fetch at the FIRST refresh (at setup) does not crash + setup: the coordinator reports last_update_success=False, the setup + catches the ConfigEntryNotReady that HA's real first refresh raises + for a failed pass, and the platforms still forward so the entities + come up unavailable.""" + # The stub fails every snapshot fetch (HTTP 503), including setup's + # first refresh. + client = StubSnapshotClient( + NestQuestApiError( + "NestQuest API GET /api/v1/panel/snapshot failed with HTTP 503", + status=503, + ) + ) + entry = wire_entry_to_registry( + make_entry( + data={"api_base_url": "http://api.test:8000", "panel_token": "tok"} + ), + hass.registry, + ) + set_coordinator_client(entry, client) + assert await async_setup_entry(hass, entry) is True + coordinator = entry.runtime_data.coordinator + assert coordinator.api_client is client + # The failed first pass: the entities' surface stays unavailable. + assert coordinator.last_update_success is False + assert coordinator.data is None + # Setup survived the failed first refresh — the platforms were + # forwarded (the regression the review caught: a first-refresh + # failure used to abort setup before any entity was created). + assert hass.config_entries.forwarded_platforms == [ + "sensor", + "binary_sensor", + ] + # The unload after the failed-first-refresh setup stays clean. + assert await async_unload_entry(hass, entry) is True + + +async def test_coordinator_unconfigured_api_does_not_crash_setup( + hass, make_entry, monkeypatch +) -> None: + """An entry with no panel token set up fine: the coordinator gets + 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) 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 == () + from custom_components.nestquest.coordinator import _UnconfiguredApiClient + + assert isinstance(coordinator.api_client, _UnconfiguredApiClient) + # A real NestQuestApiClient was never built (it would ValueError). + assert not isinstance(coordinator.api_client, NestQuestApiClient) + # The first refresh failed the HA way: the update pass converted + # the stub's typed error to UpdateFailed, the first refresh raised + # ConfigEntryNotReady, and setup tolerated it — platforms forwarded + # so the entities come up unavailable. + assert coordinator.last_update_success is False + assert coordinator.data is None + with pytest.raises(UpdateFailed, match="not configured"): + await coordinator._async_update_data() + assert hass.config_entries.forwarded_platforms == [ + "sensor", + "binary_sensor", + ] + assert await async_unload_entry(hass, entry) is True -async def test_coordinator_cycle_day_from_first_scheduled_child( +async def test_coordinator_configured_token_builds_real_client( hass, make_entry ) -> 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 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 == () + assert isinstance(coordinator.api_client, NestQuestApiClient) + # The real client polls the stub transport; the snapshot lands. + await coordinator.async_refresh() + assert coordinator.data.children[0].child_name == "Ada" async def test_setup_creates_coordinator_with_configured_interval( @@ -270,12 +519,9 @@ async def test_setup_creates_coordinator_with_configured_interval( coordinator = entry.runtime_data.coordinator assert isinstance(coordinator, NestQuestCoordinator) 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( - hass, make_entry -) -> None: +async def test_setup_defaults_to_five_minute_interval(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 @@ -298,24 +544,23 @@ async def test_unload_shuts_coordinator_down(hass, make_entry) -> None: 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_setup_failure_after_registration_shuts_coordinator_down( hass, make_entry, monkeypatch ) -> None: """A setup failure after coordinator creation must not leak its - listeners against the database the except path closes.""" + listeners or leave a stale runtime record behind. + + The runtime record registers in ``hass.data`` BEFORE the platform + forward, so a forward failure reaches the unwind with the record + already registered. The unwind must shut the coordinator down, + clear ``entry.runtime_data``, AND remove the stale ``hass.data`` + entry — otherwise a later unload picks up the stale, already-torn- + down record and re-invokes the already-consumed update-listener + remover, which raises. (A failed first refresh is NOT such a + failure: HA's real first refresh wraps it in ConfigEntryNotReady, + which setup deliberately tolerates so entities come up + unavailable.) + """ from custom_components.nestquest import async_setup_entry import custom_components.nestquest.coordinator as coordinator_module @@ -330,17 +575,37 @@ async def test_failed_first_refresh_shuts_coordinator_down( _record_shutdown, ) - async def _boom(self): - raise RuntimeError("refresh failed") + async def _boom(entry, platforms): + raise RuntimeError("platform forward failed") monkeypatch.setattr( - coordinator_module.NestQuestCoordinator, - "_async_update_data", + hass.config_entries, + "async_forward_entry_setups", _boom, ) entry = wire_entry_to_registry(make_entry(), hass.registry) - with pytest.raises(RuntimeError, match="refresh failed"): + with pytest.raises(RuntimeError, match="platform forward failed"): await async_setup_entry(hass, entry) assert shutdown_calls == [entry.entry_id] assert getattr(entry, "runtime_data", None) is None + # The stale hass.data registration was removed with the runtime + # record: the unload below finds nothing stale, must not raise, + # and must leave no residue behind. + assert await async_unload_entry(hass, entry) is True + assert DOMAIN not in hass.data + assert hass.data == {} + assert hass.registry.size == 0 + assert hass.time_change.size == 0 + assert entry.runtime_data 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" diff --git a/tests/test_core_snapshot.py b/tests/test_core_snapshot.py index e795989..f704bc6 100644 --- a/tests/test_core_snapshot.py +++ b/tests/test_core_snapshot.py @@ -123,9 +123,11 @@ async def test_build_snapshot_explicit_values(hass, make_entry, monkeypatch) -> 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() + # Single clock read for the seed/snapshot anchor (no midnight skew); + # 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) + today = datetime.datetime.now(time_zone).date() # 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( @@ -294,13 +296,38 @@ async def test_build_snapshot_explicit_values(hass, make_entry, monkeypatch) -> == "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) + # REQUIRED wrapper tie-in: since Feature 18 the coordinator's + # refresh polls the snapshot through its API client, so pinning the + # client to serve the payload of the SAME snapshot built with + # pinned_now must reproduce the builder's snapshot byte-for-byte. + # The dataclasses are frozen, so == is a full deep compare — a + # reconstruction that dropped or reshaped a field would diverge + # here even though the literal assertions above still pass. + 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() assert coordinator.data == built diff --git a/tests/test_entity_matrix.py b/tests/test_entity_matrix.py index f1e8757..0452966 100644 --- a/tests/test_entity_matrix.py +++ b/tests/test_entity_matrix.py @@ -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, "quests_remaining_today").native_value == 1 - # --- The instances payload omits missed instances while - # admin_instances includes them (D-009): pin the derive clock - # forward in the TEST ONLY so today's open instances read missed. + # --- The instances payload omits missed instances (D-009) — and + # since Feature 18 the snapshot comes from the API panel route, + # 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 async def _future_dated_refresh(): @@ -203,9 +206,9 @@ async def test_entity_and_event_matrix(hass, make_entry) -> None: await coordinator.async_refresh() attributes = _sensor(hass, bo.id, "quests_due_today").extra_state_attributes assert attributes["instances"] == [], "panel payload omits missed" - assert len(attributes["admin_instances"]) == 2 - assert all( - entry["state"] == "missed" for entry in attributes["admin_instances"] + assert attributes["admin_instances"] == [], ( + "the API payload omits missed rows, so the admin payload " + "carries none either (the PWA is the admin surface now)" ) coordinator._async_update_data = original_refresh await coordinator.async_refresh() diff --git a/tests/test_sensors.py b/tests/test_sensors.py index dcacaea..fa29adb 100644 --- a/tests/test_sensors.py +++ b/tests/test_sensors.py @@ -221,19 +221,23 @@ async def test_due_today_attributes_carry_the_panel_payload( 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 ) -> 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 - "today"), never stored. The coordinator's day listing is keyed on - ``_local_now``'s date, so pinning the clock forward would move the - listing window off today's rows entirely; the pinned FUTURE clock - is therefore applied at the ``derive_state`` boundary instead — - today's open instances then read as missed through the same - derivation rule, without back-dating instance rows past the DAO's - no-past guard or touching append-only completion events. + Since Feature 18 the snapshot comes from the API panel route, + which omits missed rows entirely (the admin surface for missed + quests is the PWA, reading the API directly). The entity payload + therefore cannot carry missed rows any more: ``admin_instances`` + equals ``instances`` — open and completed rows only. A missed + instance is still derived locally (``due_date`` before the + snapshot's "today"); the pinned-FUTURE clock at the + ``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( hass, make_entry @@ -256,10 +260,13 @@ async def test_due_today_payload_omits_missed_for_panel_includes_for_admin( assert attributes["instances"] == [], ( "missed instances are omitted from the panel payload" ) - [missed] = attributes["admin_instances"] - assert missed["state"] == "missed" - assert missed["completed_at"] is None - # Counts still roll the missed instance up as not-completed. + assert attributes["admin_instances"] == [], ( + "the API payload omits missed rows, so the admin payload " + "carries none either (the PWA is the admin surface now)" + ) + # Counts still roll the missed instance up as not-completed: the + # counts arrive precomputed from the route's own build, which rolls + # the FULL day (the missed row included) into the numbers. assert _sensor(hass, bo.id, "quests_completed_today").native_value == 0 assert _sensor(hass, bo.id, "quests_remaining_today").native_value == 1