The panel snapshot now carries the admin-saved `timezone` ("" when unset);
the coordinator threads it onto the household due-today sensor, and the
Party Board and Quest Log cards format their kicker, header and dock
date and clock in it, falling back to Home Assistant's zone only when
none is stored. The admin app loads the stored zone once
(AppTimezoneProvider) and uses it for completion clocks, History day
grouping, clocks and lateness, and "today" in Schedule, History and new
task defaults; Settings publishes a saved zone app-wide at once.
Resolves review finding TZ-001 (task 7e173881).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
401 lines
14 KiB
Python
401 lines
14 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,
|
|
"timezone": "",
|
|
"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)
|
|
)
|