The HA integration is now purely a client of the NestQuest API service (Feature 18): the API owns the SQLite file, the migrations, and every business rule, so keeping a second copy of that ownership inside the integration meant two sources of truth for household state, dead wiring (the startup backfill, the day-rollover materialization and sweep listeners, the corrupt-DB handling, the admin-allowlist seeding) and settings keys nothing consumes any more. Removed the integration's re-export/DB shim modules (db, store, migrations, schema, the five DAOs, completion, children, quest_definitions, admin_allowlist, presence, presence_management, materialize, recurrence, events, sweep); the bundled domain package at custom_components/nestquest/core/ is untouched and remains the single home of those engines. Setup now builds only the API client, the snapshot coordinator, and the SSE subscription, registers the one complete_quest proxy, and forwards the platforms. The options flow keeps only the keys the client itself consumes (API base URL + panel token, update interval, staleness, panel idle timeout); the horizon, rollover, notification and admin-allowlist settings moved out with the database (the API's settings store owns horizon/rollover, and the automations take their behaviour from blueprint inputs). The integration-plane tests now script the panel route's payloads and SSE frames instead of seeding a local database; the core-plane tests keep their coverage with imports pointed at core directly.
400 lines
13 KiB
Python
400 lines
13 KiB
Python
"""Tests for the NestQuest bus events (Feature 10, task 6).
|
|
|
|
Since Feature 18 — and, since the DB removal, without any local
|
|
business layer — the integration fires NO transition event itself:
|
|
``nestquest.complete_quest`` proxies to the API's panel complete
|
|
route, the API service builds and publishes the transition frames on
|
|
its SSE stream, and the integration's subscription
|
|
(:mod:`custom_components.nestquest.sse`) re-fires them. The tests
|
|
below script the API's payloads and frames with
|
|
:class:`conftest.ScriptedPanelClient` and deliver them through
|
|
:func:`conftest.refire_api_transitions` (the production event path,
|
|
driven synchronously) before asserting the bus. The API's own frame
|
|
construction is pinned on the production plane by tests/test_api_sse.py.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
|
|
from conftest import ScriptedPanelClient, set_coordinator_client, wire_entry_to_registry
|
|
|
|
from custom_components.nestquest import async_setup_entry
|
|
from custom_components.nestquest.const import (
|
|
DOMAIN,
|
|
EVENT_CHILD_DAY_COMPLETE,
|
|
EVENT_QUEST_COMPLETED,
|
|
EVENT_QUEST_MISSED,
|
|
EVENT_QUEST_UNCOMPLETED,
|
|
SERVICE_COMPLETE_QUEST,
|
|
)
|
|
|
|
_TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$")
|
|
|
|
ADA = 1
|
|
_OCCURRED = "2026-09-23T07:55:00+00:00"
|
|
|
|
|
|
def _instance(instance_id: int, child_id: int, *, window="morning", state="open"):
|
|
return {
|
|
"id": instance_id,
|
|
"definition_id": 3,
|
|
"child_id": child_id,
|
|
"title": "Brush teeth",
|
|
"icon": "🦷",
|
|
"window": window,
|
|
"due_time": "08:00" if window == "morning" else "19:00",
|
|
"state": state,
|
|
"overdue": False,
|
|
"completed_at": None if state == "open" else _OCCURRED,
|
|
"on_time": None if state == "open" else True,
|
|
}
|
|
|
|
|
|
def _child(child_id: int, name: str, instances=()):
|
|
completed = sum(1 for i in instances if i["state"] == "completed")
|
|
due = len(instances)
|
|
return {
|
|
"child_id": child_id,
|
|
"child_name": name,
|
|
"present": True,
|
|
"next_present": None,
|
|
"due_today": due,
|
|
"completed_today": completed,
|
|
"remaining_today": due - completed,
|
|
"completion_pct": 100 if due == 0 else round(completed / due * 100),
|
|
"instances": list(instances),
|
|
}
|
|
|
|
|
|
def _payload(children) -> dict:
|
|
return {
|
|
"today_iso": "2026-09-23",
|
|
"cycle_day": 0,
|
|
"children": list(children),
|
|
}
|
|
|
|
|
|
def _open_payload(instance_count: int) -> dict:
|
|
return _payload(
|
|
[
|
|
_child(
|
|
ADA,
|
|
"Ada",
|
|
[_instance(10 + offset, ADA) for offset in range(instance_count)],
|
|
)
|
|
]
|
|
)
|
|
|
|
|
|
def _completed_frame(instance_id: int, *, window="morning"):
|
|
return (
|
|
EVENT_QUEST_COMPLETED,
|
|
{
|
|
"child_id": ADA,
|
|
"child_name": "Ada",
|
|
"instance_id": instance_id,
|
|
"quest_title": "Brush teeth",
|
|
"window": window,
|
|
"due_date": "2026-09-23",
|
|
"due_time": "08:00" if window == "morning" else "19:00",
|
|
"occurred_at": _OCCURRED,
|
|
"was_on_time": True,
|
|
},
|
|
)
|
|
|
|
|
|
def _day_complete_frame(quests_due: int):
|
|
return (
|
|
EVENT_CHILD_DAY_COMPLETE,
|
|
{
|
|
"child_id": ADA,
|
|
"child_name": "Ada",
|
|
"quests_due": quests_due,
|
|
"quests_completed": quests_due,
|
|
"occurred_at": _OCCURRED,
|
|
},
|
|
)
|
|
|
|
|
|
async def _setup_entry(hass, make_entry, client):
|
|
"""Wire, script, and set up an entry serving the scripted household."""
|
|
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
|
|
return entry
|
|
|
|
|
|
async def test_quest_completed_fires_with_documented_payload(
|
|
hass, make_entry
|
|
) -> None:
|
|
client = ScriptedPanelClient(_open_payload(1))
|
|
entry = await _setup_entry(hass, make_entry, client)
|
|
instance_id = 10
|
|
await hass.services.call(
|
|
DOMAIN,
|
|
SERVICE_COMPLETE_QUEST,
|
|
{"instance_id": instance_id, "actor": "panel", "actor_child_id": ADA},
|
|
)
|
|
# The completed event arrives over the API's SSE stream (the
|
|
# subscription re-fires it), never from the service handler.
|
|
client.published_frames.append(_completed_frame(instance_id))
|
|
from conftest import refire_api_transitions
|
|
|
|
await refire_api_transitions(hass, entry, client)
|
|
fired = hass.bus.fired(EVENT_QUEST_COMPLETED)
|
|
assert len(fired) == 1
|
|
payload = fired[0]
|
|
assert payload["child_id"] == ADA
|
|
assert payload["child_name"] == "Ada"
|
|
assert payload["instance_id"] == instance_id
|
|
assert payload["quest_title"] == "Brush teeth"
|
|
assert payload["window"] == "morning"
|
|
assert payload["was_on_time"] is not None
|
|
assert _TIMESTAMP.match(payload["occurred_at"]), payload["occurred_at"]
|
|
|
|
|
|
async def test_recomplete_no_op_fires_nothing(hass, make_entry) -> None:
|
|
client = ScriptedPanelClient(_open_payload(1))
|
|
entry = await _setup_entry(hass, make_entry, client)
|
|
instance_id = 10
|
|
await hass.services.call(
|
|
DOMAIN,
|
|
SERVICE_COMPLETE_QUEST,
|
|
{"instance_id": instance_id, "actor": "panel", "actor_child_id": ADA},
|
|
)
|
|
await hass.services.call(
|
|
DOMAIN,
|
|
SERVICE_COMPLETE_QUEST,
|
|
{"instance_id": instance_id, "actor": "panel", "actor_child_id": ADA},
|
|
)
|
|
# The no-op verdict is decided in the API's completion layer: the
|
|
# second call appended nothing, so the API published no second
|
|
# frame and the bus stays at exactly one event.
|
|
client.published_frames.append(_completed_frame(instance_id))
|
|
from conftest import refire_api_transitions
|
|
|
|
await refire_api_transitions(hass, entry, client)
|
|
assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == 1
|
|
|
|
|
|
async def test_child_day_complete_fires_when_day_clears(
|
|
hass, make_entry
|
|
) -> None:
|
|
client = ScriptedPanelClient(_open_payload(1))
|
|
entry = await _setup_entry(hass, make_entry, client)
|
|
instance_id = 10
|
|
await hass.services.call(
|
|
DOMAIN,
|
|
SERVICE_COMPLETE_QUEST,
|
|
{"instance_id": instance_id, "actor": "panel", "actor_child_id": ADA},
|
|
)
|
|
client.published_frames.extend(
|
|
[
|
|
_completed_frame(instance_id),
|
|
_day_complete_frame(quests_due=1),
|
|
]
|
|
)
|
|
from conftest import refire_api_transitions
|
|
|
|
await refire_api_transitions(hass, entry, client)
|
|
day_complete = hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)
|
|
assert len(day_complete) == 1, "exactly one day-complete per cleared day"
|
|
payload = day_complete[0]
|
|
assert payload["child_id"] == ADA
|
|
assert payload["child_name"] == "Ada"
|
|
assert payload["quests_due"] == 1
|
|
assert _TIMESTAMP.match(payload["occurred_at"])
|
|
|
|
|
|
async def test_child_day_complete_does_not_fire_partway(
|
|
hass, make_entry
|
|
) -> None:
|
|
client = ScriptedPanelClient(_open_payload(2))
|
|
entry = await _setup_entry(hass, make_entry, client)
|
|
await hass.services.call(
|
|
DOMAIN,
|
|
SERVICE_COMPLETE_QUEST,
|
|
{"instance_id": 10, "actor": "panel", "actor_child_id": ADA},
|
|
)
|
|
# The API publishes the completion but not a day-complete (the
|
|
# child's day is not clear yet); the subscription re-fires only
|
|
# what was published.
|
|
client.published_frames.append(_completed_frame(10))
|
|
from conftest import refire_api_transitions
|
|
|
|
await refire_api_transitions(hass, entry, client)
|
|
assert hass.bus.fired(EVENT_CHILD_DAY_COMPLETE) == []
|
|
await hass.services.call(
|
|
DOMAIN,
|
|
SERVICE_COMPLETE_QUEST,
|
|
{"instance_id": 11, "actor": "panel", "actor_child_id": ADA},
|
|
)
|
|
client.published_frames.extend(
|
|
[
|
|
_completed_frame(11, window="evening"),
|
|
_day_complete_frame(quests_due=2),
|
|
]
|
|
)
|
|
await refire_api_transitions(hass, entry, client)
|
|
assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1
|
|
|
|
|
|
async def test_uncompleted_fires_on_reversal_only(hass, make_entry) -> None:
|
|
"""An actual reversal fires the uncompleted event — but only once,
|
|
and only through the API service's transition stream: the HA
|
|
``uncomplete_quest`` service is gone, so the reversal is the API's
|
|
admin route; its published frame is delivered by the SSE
|
|
subscription."""
|
|
client = ScriptedPanelClient(_open_payload(1))
|
|
entry = await _setup_entry(hass, make_entry, client)
|
|
instance_id = 10
|
|
await hass.services.call(
|
|
DOMAIN,
|
|
SERVICE_COMPLETE_QUEST,
|
|
{"instance_id": instance_id, "actor": "panel", "actor_child_id": ADA},
|
|
)
|
|
client.published_frames.extend(
|
|
[
|
|
_completed_frame(instance_id),
|
|
# The API's admin uncomplete route publishes the reversal
|
|
# the same way the panel completion publishes its event.
|
|
(
|
|
EVENT_QUEST_UNCOMPLETED,
|
|
{
|
|
"child_id": ADA,
|
|
"child_name": "Ada",
|
|
"instance_id": instance_id,
|
|
"quest_title": "Brush teeth",
|
|
"window": "morning",
|
|
"due_date": "2026-09-23",
|
|
"due_time": "08:00",
|
|
"occurred_at": _OCCURRED,
|
|
},
|
|
),
|
|
]
|
|
)
|
|
from conftest import refire_api_transitions
|
|
|
|
await refire_api_transitions(hass, entry, client)
|
|
fired = hass.bus.fired(EVENT_QUEST_UNCOMPLETED)
|
|
assert len(fired) == 1
|
|
payload = fired[0]
|
|
assert payload["instance_id"] == instance_id
|
|
assert payload["quest_title"] == "Brush teeth"
|
|
assert _TIMESTAMP.match(payload["occurred_at"])
|
|
# Un-completing an already-open instance is a no-op: nothing is
|
|
# appended, nothing is published, and nothing further fires.
|
|
await refire_api_transitions(hass, entry, client)
|
|
assert len(hass.bus.fired(EVENT_QUEST_UNCOMPLETED)) == 1
|
|
|
|
|
|
def test_missed_event_name_is_documented_contract() -> None:
|
|
"""Feature 10 defines the missed event Feature 11's sweep fires."""
|
|
assert EVENT_QUEST_MISSED == "nestquest_quest_missed"
|
|
|
|
|
|
async def test_appended_verdict_is_atomic_under_duplicate_calls(
|
|
hass, make_entry
|
|
) -> None:
|
|
"""Concurrent duplicate completes each reach the API, whose
|
|
completion layer decides the appended verdict: exactly one
|
|
appended, so the API published exactly one frame and the bus
|
|
carries one event."""
|
|
import asyncio
|
|
|
|
client = ScriptedPanelClient(_open_payload(1))
|
|
entry = await _setup_entry(hass, make_entry, client)
|
|
instance_id = 10
|
|
calls = [
|
|
hass.services.call(
|
|
DOMAIN,
|
|
SERVICE_COMPLETE_QUEST,
|
|
{
|
|
"instance_id": instance_id,
|
|
"actor": "panel",
|
|
"actor_child_id": ADA,
|
|
},
|
|
)
|
|
for _ in range(2)
|
|
]
|
|
await asyncio.gather(*calls)
|
|
assert len(client.complete_calls) == 2
|
|
# The API's completion layer decides the verdict: one call
|
|
# appended, the duplicate did not — so the API published exactly
|
|
# one frame and the bus carries one event.
|
|
client.published_frames.append(_completed_frame(instance_id))
|
|
from conftest import refire_api_transitions
|
|
|
|
await refire_api_transitions(hass, entry, client)
|
|
assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == 1
|
|
|
|
|
|
async def test_completion_updates_entities_immediately(
|
|
hass, make_entry
|
|
) -> None:
|
|
"""The remaining-today sensor reflects the completion the moment
|
|
the service call returns — no manual refresh — and the day-complete
|
|
event fired exactly once when the day became clear."""
|
|
client = ScriptedPanelClient(
|
|
_open_payload(1),
|
|
_payload([_child(ADA, "Ada", [_instance(10, ADA, state="completed")])]),
|
|
)
|
|
entry = await _setup_entry(hass, make_entry, client)
|
|
remaining_before = hass.entities[
|
|
f"nestquest_child_{ADA}_quests_remaining_today"
|
|
].native_value
|
|
assert remaining_before == 1
|
|
|
|
await hass.services.call(
|
|
DOMAIN,
|
|
SERVICE_COMPLETE_QUEST,
|
|
{"instance_id": 10, "actor": "panel", "actor_child_id": ADA},
|
|
)
|
|
remaining_after = hass.entities[
|
|
f"nestquest_child_{ADA}_quests_remaining_today"
|
|
].native_value
|
|
assert remaining_after == 0, (
|
|
"sensor must reflect the completion without a manual refresh"
|
|
)
|
|
client.published_frames.extend(
|
|
[
|
|
_completed_frame(10),
|
|
_day_complete_frame(quests_due=1),
|
|
]
|
|
)
|
|
from conftest import refire_api_transitions
|
|
|
|
await refire_api_transitions(hass, entry, client)
|
|
assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1
|
|
|
|
|
|
async def test_forced_refreshes_serialize_no_stale_publish(
|
|
hass, make_entry
|
|
) -> None:
|
|
"""Overlapping forced refreshes never interleave: a slow pass
|
|
pauses mid-snapshot, and the queued pass still publishes AFTER
|
|
it, so the last published snapshot reflects the latest mutation."""
|
|
import asyncio
|
|
|
|
client = ScriptedPanelClient(_open_payload(1))
|
|
entry = await _setup_entry(hass, make_entry, client)
|
|
coordinator = entry.runtime_data.coordinator
|
|
order: list[str] = []
|
|
|
|
async def _slow_update():
|
|
order.append("start")
|
|
await asyncio.sleep(0.01)
|
|
order.append("end")
|
|
return coordinator.data
|
|
|
|
coordinator._async_update_data = _slow_update
|
|
await asyncio.gather(coordinator.async_refresh(), coordinator.async_refresh())
|
|
assert order == ["start", "end", "start", "end"], (
|
|
"overlapping refreshes must serialize: " + str(order)
|
|
)
|