Files
NestQuest/tests/test_core_snapshot.py
T
overseer ec7c8d4bc2 Coordinator polls the API snapshot and rebuilds the sensors' data
Feature 18: the shared coordinator's refresh pass now fetches
GET /api/v1/panel/snapshot through the NestQuestApiClient and
reconstructs the exact core dataclasses (NestQuestSnapshot,
ChildDaySnapshot, QuestInstanceView) the Feature 10 entities read, so
sensor.py and binary_sensor.py are untouched and every entity_id and
attribute payload keeps its shape.

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

- snapshot_from_api_payload maps the route's JSON back onto the
  dataclasses field by field (state 'completed' -> 'done' so
  instance_payload renders 'completed' again; counts, presence,
  next_present, overdue, on_time, completed_at pass through verbatim;
  due_date is stamped from today_iso, the only date the route serves).
- The panel payload omits missed instances (D-009), so
  admin_instances can no longer carry missed rows — the admin surface
  for missed quests is the PWA reading the API directly. Documented in
  the module docstring and pinned by tests.
- An unconfigured panel token (the shipped default) gets a typed-error
  stub instead of a construction crash: setup succeeds, the first
  refresh fails cleanly, entities come up unavailable and HA's retry
  cycle applies. Fetch failures log the typed error and mark the
  entities unavailable while data keeps its last good snapshot.
- The runtime record registers before the first refresh so the refresh
  path always finds the live entry state; the update interval and the
  serialized forced-refresh behaviour are unchanged.
- Tests: a fixture snapshot drives the coordinator (stub client, no
  network) asserting per-child values, household rollups, cycle day,
  and the exact instance payload shape, plus unconfigured and
  failed-fetch cases; DB-seeded tests ride a lazy local-snapshot
  client through the same JSON path.
2026-09-23 06:02:26 -04:00

334 lines
12 KiB
Python

"""Test the pure panel snapshot builder (task 9047a97b).
The snapshot assembly that lived inline in the coordinator's
``_async_update_data`` was extracted into the Home-Assistant-free
:func:`custom_components.nestquest.core.snapshot.build_snapshot`, and
the panel payload shaper (:func:`instance_payload`) with it. These
tests seed a household and assert the pure builder's output against
EXPLICIT literal expected values — not self-equality — so the AWAY
child's ``next_present``, a due-timed quest's ``overdue`` under a
pinned ``now``, the custody ``cycle_day`` (> 1), and the panel payload
dict shape (``done`` -> ``completed``, ``on_time`` passthrough, and
``missed`` omission) are all exercised as literals.
"""
from __future__ import annotations
import datetime
from zoneinfo import ZoneInfo
from conftest import wire_entry_to_registry
from custom_components.nestquest import async_setup_entry
from custom_components.nestquest.children import create_child
from custom_components.nestquest.completion import complete_instance
from custom_components.nestquest.core.snapshot import (
ChildDaySnapshot,
NestQuestSnapshot,
QuestInstanceView,
build_snapshot,
instance_payload,
)
from custom_components.nestquest.dao_instances import QuestInstancesDao
from custom_components.nestquest.dao_presence import (
PresenceOverridesDao,
PresenceSchedulesDao,
)
from custom_components.nestquest.materialize import materialize
from custom_components.nestquest.quest_definitions import create_quest_definition
from custom_components.nestquest.recurrence import ScheduleRule
async def test_instance_payload_shape_and_missed_omission() -> None:
"""``instance_payload`` is the documented panel shape (§1).
``done`` maps to ``completed``; ``open`` passes through; ``on_time``
is the builder's ``was_on_time`` verbatim; ``missed`` instances are
omitted unless ``include_missed`` (the admin payload, D-009).
"""
def view(state: str, *, overdue: bool = False, on_time: bool | None = None,
completed_at: str | None = None, instance_id: int = 1) -> QuestInstanceView:
return QuestInstanceView(
instance_id=instance_id,
definition_id=10,
child_id=3,
title="Fill bird feeder",
icon="bird",
window="afternoon",
due_date="2026-09-21",
due_time="17:00",
state=state,
overdue=overdue,
completed_at=completed_at,
was_on_time=on_time,
)
open_view = view("open", overdue=True)
done_view = view("done", on_time=True, completed_at="2026-09-21T12:00:00+00:00")
missed_view = view("missed", instance_id=2)
panel = instance_payload([open_view, done_view, missed_view], include_missed=False)
assert panel == [
{
"id": 1,
"definition_id": 10,
"child_id": 3,
"title": "Fill bird feeder",
"icon": "bird",
"window": "afternoon",
"due_time": "17:00",
"state": "open",
"overdue": True,
"completed_at": None,
"on_time": None,
},
{
"id": 1,
"definition_id": 10,
"child_id": 3,
"title": "Fill bird feeder",
"icon": "bird",
"window": "afternoon",
"due_time": "17:00",
"state": "completed",
"overdue": False,
"completed_at": "2026-09-21T12:00:00+00:00",
"on_time": True,
},
], "panel payload omits missed and spells done as completed"
admin = instance_payload(
[open_view, done_view, missed_view], include_missed=True
)
assert len(admin) == 3
assert admin[2]["state"] == "missed", (
"admin payload keeps missed instances with their missed state"
)
async def test_build_snapshot_explicit_values(hass, make_entry, monkeypatch) -> None:
"""The builder produces the documented shape as literals, pinned in time.
Ada is AWAY today (a 2-week schedule whose absent week 0 covers today,
plus a presence override marking her present on today+2) so
``present`` is False and ``next_present`` is the override date. Her
open quest due at 10:00 is OVERDUE under the pinned 12:00 ``now``.
Bo's quest due at 17:00 is completed at 12:00 (on time) so its
payload ``state`` is ``completed`` and ``on_time`` is True. The first
scheduled active child is Ada, whose 2-week cycle anchored one day
before today puts ``cycle_day`` at 2.
"""
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
coordinator = entry.runtime_data.coordinator
database = entry.runtime_data.database
settings = entry.runtime_data.settings
# Single clock read for the seed/snapshot anchor (no midnight skew);
# 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(
today.year, today.month, today.day, 12, 0, 0, tzinfo=time_zone
)
today_iso = today.isoformat()
ada = await create_child(database, "Ada")
bo = await create_child(database, "Bo")
await create_quest_definition(
database,
"Pack bag",
ScheduleRule.from_dict(
{"rule_type": "daily", "start_date": today_iso}
),
[ada.id],
[("morning", "10:00")],
)
await create_quest_definition(
database,
"Brush teeth",
ScheduleRule.from_dict(
{"rule_type": "daily", "start_date": today_iso}
),
[bo.id],
[("morning", "17:00")],
)
end = (today + datetime.timedelta(days=3)).isoformat()
await materialize(database, today_iso, end, today=today)
# Ada: 2-week cycle anchored yesterday. Week 0 (the empty segment)
# covers today, so she is absent today; an override marks her present
# on today+2, so next_present resolves to that date and cycle_day is
# ((today - (today-1)) % 14) + 1 == 2.
await PresenceSchedulesDao(database).upsert_by_child(
ada.id,
cycle_length_weeks=2,
anchor_date=(today - datetime.timedelta(days=1)).isoformat(),
pattern="|0,1,2,3,4,5,6",
)
returns_date = today + datetime.timedelta(days=2)
await PresenceOverridesDao(database).create(
ada.id,
returns_date.isoformat(),
returns_date.isoformat(),
is_present=True,
note="visit",
)
# Complete Bo's instance at the pinned 12:00 (17:00 due => on time).
bo_instances = await QuestInstancesDao(database).list_by_child_and_date(
bo.id, today_iso
)
await complete_instance(
database,
bo_instances[0].id,
actor_source="panel",
actor_child_id=bo.id,
now=pinned_now,
today=today,
)
built = await build_snapshot(database, settings, pinned_now)
assert isinstance(built, NestQuestSnapshot)
assert built.today_iso == today_iso
assert built.cycle_day == 2, (
"Ada's 2-week cycle anchored yesterday => day 2 today"
)
by_id = {child.child_id: child for child in built.children}
assert list(by_id) == [ada.id, bo.id], (
"active children appear in sort_order, id order"
)
ada_child = by_id[ada.id]
bo_child = by_id[bo.id]
# Ada: away today, returns on the override date, one open+overdue quest.
assert isinstance(ada_child, ChildDaySnapshot)
assert ada_child.present is False
assert ada_child.next_present == returns_date.isoformat()
assert ada_child.due_today == 1
assert ada_child.completed_today == 0
assert ada_child.remaining_today == 1
assert ada_child.completion_pct == 0
ada_view = ada_child.instances[0]
assert isinstance(ada_view, QuestInstanceView)
assert ada_view.state == "open"
assert ada_view.due_time == "10:00"
assert ada_view.overdue is True, "12:00 now is past the 10:00 due time"
assert ada_view.completed_at is None
assert ada_view.was_on_time is None
# Bo: present, one completed instance, on time, not overdue.
assert bo_child.present is True
assert bo_child.next_present is None
assert bo_child.due_today == 1
assert bo_child.completed_today == 1
assert bo_child.remaining_today == 0
assert bo_child.completion_pct == 100
bo_view = bo_child.instances[0]
assert bo_view.state == "done"
assert bo_view.due_time == "17:00"
assert bo_view.overdue is False, "overdue is only computed for open state"
assert bo_view.completed_at == pinned_now.astimezone(
datetime.timezone.utc
).isoformat()
assert bo_view.was_on_time is True
# Panel payload shape: Ada open+overdue stays open; Bo done -> completed.
ada_payload = instance_payload(ada_child.instances, include_missed=False)
assert ada_payload == [
{
"id": ada_view.instance_id,
"definition_id": ada_view.definition_id,
"child_id": ada.id,
"title": "Pack bag",
"icon": None,
"window": "morning",
"due_time": "10:00",
"state": "open",
"overdue": True,
"completed_at": None,
"on_time": None,
}
]
bo_payload = instance_payload(bo_child.instances, include_missed=False)
assert bo_payload == [
{
"id": bo_view.instance_id,
"definition_id": bo_view.definition_id,
"child_id": bo.id,
"title": "Brush teeth",
"icon": None,
"window": "morning",
"due_time": "17:00",
"state": "completed",
"overdue": False,
"completed_at": pinned_now.astimezone(
datetime.timezone.utc
).isoformat(),
"on_time": True,
}
]
# Missed omission is real: a missed view is dropped from the panel
# payload and kept in the admin payload with its missed state.
missed_view = QuestInstanceView(
instance_id=999,
definition_id=10,
child_id=ada.id,
title="Stale quest",
icon=None,
window="morning",
due_date=(today - datetime.timedelta(days=1)).isoformat(),
due_time="09:00",
state="missed",
overdue=False,
completed_at=None,
was_on_time=None,
)
assert instance_payload([missed_view], include_missed=False) == []
assert (
instance_payload([missed_view], include_missed=True)[0]["state"]
== "missed"
)
# REQUIRED wrapper tie-in: 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