"""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) )