Task 6e70d166: Coordinator polls the snapshot and builds the sensors (#167)
This commit was merged in pull request #167.
This commit is contained in:
@@ -26,7 +26,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 +490,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 +512,30 @@ 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. 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)
|
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
|
||||||
@@ -548,10 +569,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
# closes the already-closed database through the stale record.
|
# closes the already-closed database through the stale record.
|
||||||
if getattr(entry, "runtime_data", None) is not None:
|
if getattr(entry, "runtime_data", None) is not None:
|
||||||
entry.runtime_data = 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()
|
await database.close()
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
@@ -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,56 @@ 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, and
|
||||||
|
``_async_update_data`` converts every typed error into
|
||||||
``cycle_day`` reports where today falls in the custody cycle of the
|
:class:`~homeassistant.helpers.update_coordinator.UpdateFailed` — the
|
||||||
first active child (display order) that HAS a schedule — 1-based day
|
exception a HA ``DataUpdateCoordinator`` records as a failed pass
|
||||||
``N`` of ``cycle_length_weeks * 7``, computed from that schedule's
|
(``last_update_success = False``, ``data = None``), so the entities
|
||||||
anchor date. ``0`` means no active child has a schedule at all, so
|
come up unavailable. The typed error is never swallowed silently: it
|
||||||
the admin header degrades to a harmless zero instead of guessing.
|
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
|
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,
|
||||||
|
UpdateFailed,
|
||||||
|
)
|
||||||
|
|
||||||
|
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 +64,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 +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):
|
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 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
|
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 +206,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 +223,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 +233,67 @@ 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()
|
"""One refresh pass: fetch the panel snapshot and rebuild it.
|
||||||
return await build_snapshot(
|
|
||||||
self.database, self.settings, now
|
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] = {}
|
||||||
|
|||||||
+164
-6
@@ -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"),
|
||||||
@@ -365,6 +367,15 @@ class BinarySensorEntity(Entity):
|
|||||||
return self._attr_device_class
|
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:
|
class DataUpdateCoordinator:
|
||||||
"""Functional stand-in for HA's DataUpdateCoordinator.
|
"""Functional stand-in for HA's DataUpdateCoordinator.
|
||||||
|
|
||||||
@@ -373,8 +384,20 @@ class DataUpdateCoordinator:
|
|||||||
``async_add_listener``/``async_update_listeners`` for entity
|
``async_add_listener``/``async_update_listeners`` for entity
|
||||||
subscription, ``async_refresh``/``async_config_entry_first_refresh``
|
subscription, ``async_refresh``/``async_config_entry_first_refresh``
|
||||||
driving the subclass's ``_async_update_data``, ``last_update_success``,
|
driving the subclass's ``_async_update_data``, ``last_update_success``,
|
||||||
and ``data``. Interval scheduling is NOT simulated (no real clock
|
``last_exception``, and ``data``. Interval scheduling is NOT
|
||||||
ticks in tests) — tests drive refreshes directly.
|
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):
|
def __init__(self, hass, logger, *, name=None, update_interval=None):
|
||||||
@@ -384,6 +407,7 @@ class DataUpdateCoordinator:
|
|||||||
self.update_interval = update_interval
|
self.update_interval = update_interval
|
||||||
self.data = None
|
self.data = None
|
||||||
self.last_update_success = False
|
self.last_update_success = False
|
||||||
|
self.last_exception = None
|
||||||
self._listeners = []
|
self._listeners = []
|
||||||
|
|
||||||
def async_add_listener(self, update_callback, context=None):
|
def async_add_listener(self, update_callback, context=None):
|
||||||
@@ -403,18 +427,35 @@ class DataUpdateCoordinator:
|
|||||||
callback()
|
callback()
|
||||||
|
|
||||||
async def async_refresh(self):
|
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:
|
try:
|
||||||
self.data = await self._async_update_data()
|
self.data = await self._async_update_data()
|
||||||
self.last_update_success = True
|
self.last_update_success = True
|
||||||
except BaseException:
|
except Exception as err:
|
||||||
self.last_update_success = False
|
self.last_update_success = False
|
||||||
raise
|
self.last_exception = err
|
||||||
self.async_update_listeners()
|
self.async_update_listeners()
|
||||||
|
|
||||||
async def async_config_entry_first_refresh(self):
|
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()
|
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):
|
async def async_shutdown(self):
|
||||||
"""Release listeners on teardown."""
|
"""Release listeners on teardown."""
|
||||||
@@ -424,6 +465,7 @@ class DataUpdateCoordinator:
|
|||||||
_update_coordinator_mock = _ha_mock("homeassistant.helpers.update_coordinator")
|
_update_coordinator_mock = _ha_mock("homeassistant.helpers.update_coordinator")
|
||||||
_update_coordinator_mock.DataUpdateCoordinator = DataUpdateCoordinator
|
_update_coordinator_mock.DataUpdateCoordinator = DataUpdateCoordinator
|
||||||
_update_coordinator_mock.CoordinatorEntity = CoordinatorEntity
|
_update_coordinator_mock.CoordinatorEntity = CoordinatorEntity
|
||||||
|
_update_coordinator_mock.UpdateFailed = UpdateFailed
|
||||||
|
|
||||||
_entity_mock = _ha_mock("homeassistant.helpers.entity")
|
_entity_mock = _ha_mock("homeassistant.helpers.entity")
|
||||||
_entity_mock.DeviceInfo = DeviceInfo
|
_entity_mock.DeviceInfo = DeviceInfo
|
||||||
@@ -700,6 +742,122 @@ 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
|
||||||
|
|
||||||
|
_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."""
|
||||||
|
|
||||||
|
|||||||
+501
-236
@@ -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
|
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
|
||||||
|
from homeassistant.helpers.update_coordinator import UpdateFailed
|
||||||
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
|
||||||
) -> None:
|
ONE refresh pass every entity reads from."""
|
||||||
"""A child absent today carries the ISO date of their next present
|
client = StubSnapshotClient(FIXTURE_SNAPSHOT)
|
||||||
day; an always-absent pattern carries None instead."""
|
_entry, coordinator = await _wire_entry(hass, make_entry, client)
|
||||||
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
called = []
|
||||||
assert await async_setup_entry(hass, entry) is True
|
coordinator.async_add_listener(lambda: called.append("entity"))
|
||||||
coordinator = entry.runtime_data.coordinator
|
await coordinator.async_refresh()
|
||||||
database = entry.runtime_data.database
|
assert called == ["entity"]
|
||||||
await create_child(database, "Ada")
|
assert coordinator.last_update_success is True
|
||||||
ada = (await list_children(database))[0]
|
assert coordinator.data is not None
|
||||||
today = datetime.date.today()
|
assert coordinator.data.children[0].child_name == "Ada"
|
||||||
pattern = ",".join(
|
|
||||||
str(day) for day in range(7) if day != today.weekday()
|
|
||||||
)
|
async def test_instance_payload_shape_is_unchanged(hass, make_entry) -> None:
|
||||||
await PresenceSchedulesDao(database).upsert_by_child(
|
"""The per-child instance attribute payload the due-today sensor
|
||||||
ada.id, 1, today.isoformat(), pattern
|
emits keeps its exact documented shape, and admin_instances now
|
||||||
)
|
equals instances (the API omits missed rows — D-009; the admin
|
||||||
await create_child(database, "Bo")
|
surface for missed quests is the PWA)."""
|
||||||
bo = (await list_children(database))[1]
|
client = StubSnapshotClient(FIXTURE_SNAPSHOT)
|
||||||
await PresenceSchedulesDao(database).upsert_by_child(
|
_entry, coordinator = await _wire_entry(hass, make_entry, client)
|
||||||
bo.id, 1, today.isoformat(), ""
|
|
||||||
)
|
|
||||||
snapshot = await coordinator._async_update_data()
|
snapshot = await coordinator._async_update_data()
|
||||||
by_name = {child.child_name: child for child in snapshot.children}
|
ada = snapshot.children[0]
|
||||||
assert by_name["Ada"].present is False
|
|
||||||
assert by_name["Ada"].next_present == (
|
panel = instance_payload(ada.instances, include_missed=False)
|
||||||
today + datetime.timedelta(days=1)
|
assert panel == [
|
||||||
).isoformat()
|
{
|
||||||
assert by_name["Bo"].present is False
|
"id": 7,
|
||||||
assert by_name["Bo"].next_present is None
|
"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(
|
async def test_coordinator_entities_read_the_api_snapshot(hass, make_entry) -> None:
|
||||||
hass, make_entry
|
"""The registered sensors and binary sensors read the rebuilt
|
||||||
) -> None:
|
snapshot: entity values come from the fixture, with the SAME
|
||||||
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
entity_ids as ever."""
|
||||||
assert await async_setup_entry(hass, entry) is True
|
client = StubSnapshotClient(FIXTURE_SNAPSHOT)
|
||||||
coordinator = entry.runtime_data.coordinator
|
entry, coordinator = await _wire_entry(hass, make_entry, client)
|
||||||
database = entry.runtime_data.database
|
await coordinator.async_refresh()
|
||||||
await _seed_day(database)
|
|
||||||
ada = (
|
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()
|
await coordinator._async_update_data()
|
||||||
).children[0]
|
assert isinstance(excinfo.value.__cause__, NestQuestApiError)
|
||||||
instance_id = ada.instances[0].instance_id
|
|
||||||
|
|
||||||
await complete_instance(
|
# The refresh pass records the failure the HA way — it does NOT
|
||||||
database,
|
# raise: last_update_success goes False, data keeps the last GOOD
|
||||||
instance_id,
|
# snapshot, and the failed pass lands on last_exception as the
|
||||||
actor_source="panel",
|
# converted UpdateFailed with the typed error chained.
|
||||||
actor_child_id=ada.child_id,
|
await coordinator.async_refresh()
|
||||||
)
|
assert coordinator.last_update_success is False
|
||||||
snapshot = await coordinator._async_update_data()
|
assert coordinator.data.children[0].child_name == "Ada"
|
||||||
child = snapshot.children[0]
|
assert isinstance(coordinator.last_exception, UpdateFailed)
|
||||||
assert child.completed_today == 1
|
assert isinstance(coordinator.last_exception.__cause__, NestQuestApiError)
|
||||||
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(
|
async def test_failed_first_refresh_does_not_crash_setup(
|
||||||
hass, make_entry
|
hass, make_entry
|
||||||
) -> None:
|
) -> 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)
|
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
|
||||||
await create_child(entry.runtime_data.database, "Ada")
|
from custom_components.nestquest.coordinator import _UnconfiguredApiClient
|
||||||
snapshot = await coordinator._async_update_data()
|
|
||||||
child = snapshot.children[0]
|
assert isinstance(coordinator.api_client, _UnconfiguredApiClient)
|
||||||
assert child.due_today == 0
|
# A real NestQuestApiClient was never built (it would ValueError).
|
||||||
assert child.completion_pct == 100, "zero quests is a perfect day"
|
assert not isinstance(coordinator.api_client, NestQuestApiClient)
|
||||||
assert child.instances == ()
|
# 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
|
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 create_child(database, "Ada")
|
# The real client polls the stub transport; the snapshot lands.
|
||||||
ada = (await list_children(database))[0]
|
await coordinator.async_refresh()
|
||||||
today = datetime.date.today()
|
assert coordinator.data.children[0].child_name == "Ada"
|
||||||
# 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 +519,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 +544,23 @@ 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(
|
async def test_setup_failure_after_registration_shuts_coordinator_down(
|
||||||
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(
|
|
||||||
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 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
|
from custom_components.nestquest import async_setup_entry
|
||||||
import custom_components.nestquest.coordinator as coordinator_module
|
import custom_components.nestquest.coordinator as coordinator_module
|
||||||
|
|
||||||
@@ -330,17 +575,37 @@ async def test_failed_first_refresh_shuts_coordinator_down(
|
|||||||
_record_shutdown,
|
_record_shutdown,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _boom(self):
|
async def _boom(entry, platforms):
|
||||||
raise RuntimeError("refresh failed")
|
raise RuntimeError("platform forward failed")
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
coordinator_module.NestQuestCoordinator,
|
hass.config_entries,
|
||||||
"_async_update_data",
|
"async_forward_entry_setups",
|
||||||
_boom,
|
_boom,
|
||||||
)
|
)
|
||||||
|
|
||||||
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
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)
|
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
|
||||||
|
# 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"
|
||||||
|
|||||||
+37
-10
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
+21
-14
@@ -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,10 +260,13 @@ 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.
|
)
|
||||||
|
# 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_completed_today").native_value == 0
|
||||||
assert _sensor(hass, bo.id, "quests_remaining_today").native_value == 1
|
assert _sensor(hass, bo.id, "quests_remaining_today").native_value == 1
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user