complete_quest proxies to the API (task 47c7c90e) (#169)

This commit was merged in pull request #169.
This commit is contained in:
2026-09-23 07:52:32 -04:00
parent 84d179336b
commit b0bbb5f8c5
5 changed files with 585 additions and 47 deletions
+116 -26
View File
@@ -1,9 +1,21 @@
"""Tests for the NestQuest bus events (Feature 10, task 6)."""
"""Tests for the NestQuest bus events (Feature 10, task 6).
Since Feature 18 the integration is a client of the NestQuest API
service: ``nestquest.complete_quest`` proxies to the API's panel
complete route and fires NO local bus event — 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 service-driven tests below therefore deliver the
frames through :func:`conftest.refire_api_transitions` (the
production event path, driven synchronously) before asserting the
bus; the shim-level tests drive :mod:`custom_components.nestquest
.events` directly, the way its remaining callers do.
"""
from __future__ import annotations
import re
from conftest import wire_entry_to_registry
from conftest import refire_api_transitions, wire_entry_to_registry
from custom_components.nestquest import async_setup_entry
from custom_components.nestquest.children import create_child
@@ -86,6 +98,11 @@ async def test_quest_completed_fires_with_documented_payload(
SERVICE_COMPLETE_QUEST,
{"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id},
)
# The completed event arrives over the API's SSE stream (the
# subscription re-fires it), never from the service handler.
await refire_api_transitions(
hass, entry, entry.runtime_data.coordinator.api_client
)
fired = hass.bus.fired(EVENT_QUEST_COMPLETED)
assert len(fired) == 1
payload = fired[0]
@@ -111,6 +128,15 @@ async def test_recomplete_no_op_fires_nothing(hass, make_entry) -> None:
SERVICE_COMPLETE_QUEST,
{"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id},
)
# 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 = entry.runtime_data.coordinator.api_client
assert [result.appended for _id, result in client.completions] == [
True,
False,
]
await refire_api_transitions(hass, entry, client)
assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == 1
@@ -128,6 +154,9 @@ async def test_child_day_complete_fires_when_day_clears(
"actor_child_id": child.id,
},
)
await refire_api_transitions(
hass, entry, entry.runtime_data.coordinator.api_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]
@@ -153,6 +182,12 @@ async def test_child_day_complete_does_not_fire_partway(
"actor_child_id": child.id,
},
)
# 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.
await refire_api_transitions(
hass, entry, entry.runtime_data.coordinator.api_client
)
assert hass.bus.fired(EVENT_CHILD_DAY_COMPLETE) == []
await hass.services.call(
DOMAIN,
@@ -163,6 +198,9 @@ async def test_child_day_complete_does_not_fire_partway(
"actor_child_id": child.id,
},
)
await refire_api_transitions(
hass, entry, entry.runtime_data.coordinator.api_client
)
assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1
@@ -223,6 +261,15 @@ async def test_appended_verdict_is_atomic_under_duplicate_calls(
for _ in range(2)
]
await asyncio.gather(*calls)
# The API's completion layer (the same core lock) decides the
# verdict: one call appended, the duplicate did not — so the API
# published exactly one frame and the bus carries one event.
client = entry.runtime_data.coordinator.api_client
assert len(client.completions) == 2
assert [result.appended for _id, result in client.completions].count(
True
) == 1
await refire_api_transitions(hass, entry, client)
assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == 1
@@ -263,6 +310,9 @@ async def test_day_complete_ignored_for_backdated_completion(
"actor_child_id": child.id,
},
)
await refire_api_transitions(
hass, entry, entry.runtime_data.coordinator.api_client
)
assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1
await _complete(
@@ -280,12 +330,19 @@ async def test_day_complete_ignored_for_backdated_completion(
"actor_child_id": child.id,
},
)
# The service call on the already-done future instance is a no-op
# inside the API's completion layer: nothing appended, nothing
# published, nothing re-fired — today's single announcement stands.
client = entry.runtime_data.coordinator.api_client
assert client.completions[-1][1].appended is False
await refire_api_transitions(hass, entry, client)
assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1, (
"a back-dated/future completion must not re-announce today"
)
# The direct business-layer call fired nothing (only the service
# path fires), and the follow-up service call on the now-done
# future instance was a no-op: today's single completion stands.
# The direct business-layer call published nothing (only the API's
# completion path publishes), and the follow-up service call on
# the now-done future instance was a no-op: today's single
# completion stands.
assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == len(instances)
@@ -324,6 +381,10 @@ async def test_completion_updates_entities_immediately(
assert remaining_after == len(instances) - 1, (
"sensor must reflect the completion without a manual refresh"
)
# The transition events arrive over the API's SSE stream.
await refire_api_transitions(
hass, entry, entry.runtime_data.coordinator.api_client
)
assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == (
1 if len(instances) == 1 else 0
)
@@ -538,17 +599,32 @@ async def test_shim_fires_completed_before_day_complete(hass, make_entry) -> Non
"""The shim fires ``nestquest_quest_completed`` BEFORE
``nestquest_child_day_complete`` (the pre-extraction ordering), so
a failure in the day-complete DB reads cannot suppress the
completed event. Asserted by bus event ORDER, not just counts."""
completed event. Asserted by bus event ORDER, not just counts.
Driven directly: since Feature 18 the completion service proxies
to the API (whose event ordering is tested in test_api_sse.py);
this targets the shim module itself, the way its remaining
callers invoke it.
"""
from custom_components.nestquest.completion import (
complete_instance as _complete,
)
from custom_components.nestquest.events import fire_quest_completed
entry, child, instances = await _setup_and_seed(hass, make_entry)
database = entry.runtime_data.database
for instance in instances:
await hass.services.call(
DOMAIN,
SERVICE_COMPLETE_QUEST,
{
"instance_id": instance.id,
"actor": "panel",
"actor_child_id": child.id,
},
result = await _complete(
database,
instance.id,
actor_source="panel",
actor_child_id=child.id,
)
await fire_quest_completed(
hass,
database,
instance.id,
was_on_time=result.was_on_time,
)
ordered = [
etype for etype, _payload in hass.bus.events
@@ -732,21 +808,31 @@ async def test_day_complete_fully_cleared_today_yields_event(
async def test_shim_day_complete_uses_ha_local_timezone(
hass, make_entry
) -> None:
"""The day-complete rule reads ``hass.config.time_zone`` and
derives ``today`` in HA-local time, not UTC.
"""The shim's day-complete rule reads ``hass.config.time_zone``
and derives ``today`` in HA-local time, not UTC.
Picks a zone whose current local date differs from UTC, seeds an
instance for THAT zone's today (not UTC's today), completes it
through the service path, and asserts
instance for THAT zone's today (not UTC's today), fires the shim
directly for a completion of that instance, and asserts
``nestquest_child_day_complete`` fires — proving the local date
reached the day-complete rule. If no candidate zone has a
different date right now (only possible inside the ~1h window
around 12:00 UTC when all zones share one date), the test skips
rather than run vacuously.
Driven directly: since Feature 18 the completion service proxies
to the API (whose day-complete ``today`` is the API host's local
date, tested in test_api_sse.py); this targets the shim module
itself, the way its remaining callers invoke it.
"""
import datetime as _dt
from zoneinfo import ZoneInfo
from custom_components.nestquest.completion import (
complete_instance as _complete,
)
from custom_components.nestquest.events import fire_quest_completed
utc_today = _dt.datetime.now(_dt.timezone.utc).date()
far_zone = None
far_today = None
@@ -775,15 +861,19 @@ async def test_shim_day_complete_uses_ha_local_timezone(
entry, child, instances = await _setup_and_seed(
hass, make_entry, seed_date=far_today
)
database = entry.runtime_data.database
for instance in instances:
await hass.services.call(
DOMAIN,
SERVICE_COMPLETE_QUEST,
{
"instance_id": instance.id,
"actor": "panel",
"actor_child_id": child.id,
},
result = await _complete(
database,
instance.id,
actor_source="panel",
actor_child_id=child.id,
)
await fire_quest_completed(
hass,
database,
instance.id,
was_on_time=result.was_on_time,
)
day_complete = hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)
assert len(day_complete) == 1, (