From fe38e5040ed5707be373e9bdb811c65ab8f21aaa Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 12:01:05 -0400 Subject: [PATCH 1/2] Fire the documented bus events from the completion service paths quest_completed and quest_uncompleted fire on actual transitions with documented payloads; child_day_complete fires when a completion clears the child's day; quest_missed is defined as Feature 11's contract. Payloads documented in the README for automation authors. --- README.md | 29 ++++ custom_components/nestquest/const.py | 9 ++ custom_components/nestquest/events.py | 124 +++++++++++++++ custom_components/nestquest/services.py | 19 ++- tests/conftest.py | 26 ++++ tests/test_events.py | 192 ++++++++++++++++++++++++ 6 files changed, 397 insertions(+), 2 deletions(-) create mode 100644 custom_components/nestquest/events.py create mode 100644 tests/test_events.py diff --git a/README.md b/README.md index dfa59e7..441ad74 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,35 @@ After installation and restart: 2. Click **Add Integration** and search for **NestQuest**. 3. Follow the on-screen configuration flow. +## Events + +NestQuest fires Home Assistant bus events so automations can react to a +child's progress. Each payload carries `child_id`, `child_name`, +`instance_id`, `quest_title`, the instance's `window`, `due_date`, +`due_time`, and `occurred_at` (a strict UTC ISO-8601 timestamp). + +| Event | Fires when | Extra payload fields | +|---|---|---| +| `nestquest_quest_completed` | a quest is completed (an actual completion, never a re-complete no-op) | `was_on_time` (bool) | +| `nestquest_quest_uncompleted` | an admin reverses a completion | — | +| `nestquest_child_day_complete` | a completion clears the child's whole day (quests were owed and none remain; a zero-quest day never fires it) | `quests_due`, `quests_completed` | +| `nestquest_quest_missed` | the nightly sweep marks a still-open past-due quest missed (integrated in a later release) | — | + +Example automation trigger: + +```yaml +triggers: + - trigger: event + event_type: nestquest_child_day_complete +conditions: + - condition: template + value_template: "{{ trigger.payload.child_name == 'Declan' }}" +actions: + - action: notify.mobile_app_declans_phone + data: + message: "Every quest cleared — legendary!" +``` + ## Developer Setup and Testing This project uses [`uv`](https://github.com/astral-sh/uv) for fast Python package and dependency management. diff --git a/custom_components/nestquest/const.py b/custom_components/nestquest/const.py index 3d1d89a..924898f 100644 --- a/custom_components/nestquest/const.py +++ b/custom_components/nestquest/const.py @@ -11,6 +11,15 @@ LOGGER = logging.getLogger(LOGGER_NAME) # sensors and binary sensors. PLATFORMS: list[str] = ["sensor", "binary_sensor"] +# Home Assistant bus events (design/ENTITIES-AND-SERVICES.md §3). The +# panel and admin cards listen for these; Feature 10 fires completed, +# uncompleted, and child_day_complete from the service paths, and +# Feature 11's nightly sweep fires quest_missed. +EVENT_QUEST_COMPLETED = "nestquest_quest_completed" +EVENT_QUEST_UNCOMPLETED = "nestquest_quest_uncompleted" +EVENT_QUEST_MISSED = "nestquest_quest_missed" +EVENT_CHILD_DAY_COMPLETE = "nestquest_child_day_complete" + #: Canonical domain-global service names (design/ENTITIES-AND-SERVICES.md §2 #: plus the existing Feature 07 regenerate service). SERVICE_COMPLETE_QUEST = "complete_quest" diff --git a/custom_components/nestquest/events.py b/custom_components/nestquest/events.py new file mode 100644 index 0000000..d8394c6 --- /dev/null +++ b/custom_components/nestquest/events.py @@ -0,0 +1,124 @@ +"""HA bus events fired by the NestQuest service paths (Feature 10). + +The four documented events (design/ENTITIES-AND-SERVICES.md §3, names +in :mod:`~.const`): ``nestquest_quest_completed`` and +``nestquest_quest_uncompleted`` fire from the completion service +handlers on an actual transition (a no-op re-complete or re-uncomplete +fires nothing), and ``nestquest_child_day_complete`` fires when a +completion clears the child's whole day (quests were owed and none +remain — a zero-quest day can never fire it, matching the all-done +binary sensor's rule). ``nestquest_quest_missed`` is the documented +contract Feature 11's nightly sweep fires; nothing here fires it. + +Payload policy: one dict carrying ``child_id``, ``child_name``, +``instance_id``, ``quest_title``, the instance's ``window``/ +``due_date``/``due_time`` so automation authors can filter without a +database lookup, and ``occurred_at`` — the event's own strict UTC +ISO-8601 timestamp. Completion payloads additionally carry +``was_on_time``. No other personal data: names and titles are the +household's own records, and nothing beyond them leaks. +""" +from __future__ import annotations + +import datetime +from zoneinfo import ZoneInfo + +from .completion import derive_state +from .const import ( + EVENT_CHILD_DAY_COMPLETE, + EVENT_QUEST_COMPLETED, + EVENT_QUEST_UNCOMPLETED, +) +from .dao_children import ChildrenDao +from .dao_instances import CompletionEventsDao, QuestInstancesDao +from .dao_rules import QuestDefinitionsDao +from .db import NestQuestDatabase + +#: Strict UTC ISO-8601 timestamp (the DAO layers' policy): one shape, +#: explicit offset, second precision. +_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00" + + +def _now_stamp() -> str: + return datetime.datetime.now(datetime.timezone.utc).strftime( + _UTC_TIMESTAMP_FORMAT + ) + + +async def _instance_payload( + database: NestQuestDatabase, instance_id: int, now_stamp: str +) -> dict: + """Build the shared event payload for one quest instance.""" + instance = await QuestInstancesDao(database).get_by_id(instance_id) + if instance is None: + raise ValueError( + f"instance_id: quest instance {instance_id} does not exist" + ) + child = await ChildrenDao(database).get(instance.child_id) + definition = await QuestDefinitionsDao(database).get( + instance.definition_id + ) + return { + "child_id": instance.child_id, + "child_name": child.display_name if child else None, + "instance_id": instance.id, + "quest_title": definition.title if definition else None, + "window": instance.window, + "due_date": instance.due_date, + "due_time": instance.due_time, + "occurred_at": now_stamp, + } + + +async def fire_quest_completed( + hass, database: NestQuestDatabase, instance_id: int +) -> None: + """Fire ``nestquest_quest_completed`` and, when the completion + cleared the child's whole day, ``nestquest_child_day_complete``. + + Called by the complete_quest service handler AFTER an actual + completion transition; a no-op re-complete never reaches here. + """ + now_stamp = _now_stamp() + payload = await _instance_payload(database, instance_id, now_stamp) + latest = await CompletionEventsDao(database).get_latest_for_instance( + instance_id + ) + payload["was_on_time"] = ( + latest.was_on_time if latest is not None else None + ) + hass.bus.async_fire(EVENT_QUEST_COMPLETED, payload) + + # Day-complete: quests were owed today and none remain (the same + # rule as the all-done binary sensor; a zero-quest day never fires). + today = datetime.datetime.now(ZoneInfo(hass.config.time_zone)).date() + instances = await QuestInstancesDao(database).list_by_date_range( + payload["child_id"], today.isoformat(), today.isoformat() + ) + events = CompletionEventsDao(database) + remaining = 0 + for instance in instances: + latest_for_instance = await events.get_latest_for_instance( + instance.id + ) + if derive_state(instance, latest_for_instance, today) != "done": + remaining += 1 + if instances and remaining == 0: + hass.bus.async_fire( + EVENT_CHILD_DAY_COMPLETE, + { + "child_id": payload["child_id"], + "child_name": payload["child_name"], + "quests_due": len(instances), + "quests_completed": len(instances), + "occurred_at": now_stamp, + }, + ) + + +async def fire_quest_uncompleted( + hass, database: NestQuestDatabase, instance_id: int +) -> None: + """Fire ``nestquest_quest_uncompleted`` for an actual reversal.""" + payload = await _instance_payload(database, instance_id, _now_stamp()) + hass.bus.async_fire(EVENT_QUEST_UNCOMPLETED, payload) diff --git a/custom_components/nestquest/services.py b/custom_components/nestquest/services.py index 9c6d765..3ee9903 100644 --- a/custom_components/nestquest/services.py +++ b/custom_components/nestquest/services.py @@ -42,6 +42,7 @@ from .const import ( SERVICE_UPDATE_QUEST_DEFINITION, ) from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao +from .events import fire_quest_completed, fire_quest_uncompleted from .materialize import regenerate_for_child from .permissions import permission_gate from .presence import PresenceSchedule @@ -293,11 +294,19 @@ def _complete_quest( ) -> ServiceHandler: async def _handler(call: Any) -> None: database = _require_runtime(hass, find_runtime).database + instance_id = call.data["instance_id"] + # Events fire on an ACTUAL transition only: an already-done + # instance is a no-op below and must not re-announce itself. + state_before = await completion_layer.instance_state( + database, instance_id + ) await completion_layer.complete_instance( database, - call.data["instance_id"], + instance_id, **_actor_kwargs(call), ) + if state_before != "done": + await fire_quest_completed(hass, database, instance_id) return _with_errors(_handler) @@ -307,11 +316,17 @@ def _uncomplete_quest( ) -> ServiceHandler: async def _handler(call: Any) -> None: database = _require_runtime(hass, find_runtime).database + instance_id = call.data["instance_id"] + state_before = await completion_layer.instance_state( + database, instance_id + ) await completion_layer.uncomplete_instance( database, - call.data["instance_id"], + instance_id, **_actor_kwargs(call), ) + if state_before == "done": + await fire_quest_uncompleted(hass, database, instance_id) return _with_errors(_handler) diff --git a/tests/conftest.py b/tests/conftest.py index d753166..8873d63 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -549,6 +549,31 @@ def _async_track_time_change(hass, action, hour=None, minute=None, second=None): _ha_mock("homeassistant.helpers.event").async_track_time_change = _async_track_time_change +class EventBus: + """Models HA's ``hass.bus`` event bus. + + ``async_fire`` is SYNCHRONOUS (matching real HA) and records + ``(event_type, payload)`` pairs in order so tests can assert + exactly which events fired with which payloads; there is no + listener dispatch (entities poll the coordinator, and the cards + are not part of this harness). + """ + + def __init__(self): + self.events: list[tuple[str, dict]] = [] + + def async_fire(self, event_type: str, payload=None): + self.events.append((event_type, dict(payload or {}))) + + def fired(self, event_type: str) -> list[dict]: + """Return every payload fired for ``event_type``, in order.""" + return [ + payload + for fired_type, payload in self.events + if fired_type == event_type + ] + + class ServiceRegistry: """Models HA's ``hass.services`` service registry. @@ -671,6 +696,7 @@ def make_hass() -> tuple: registry = ListenerRegistry(hass) hass.time_change = TimeChangeRegistry(hass) hass.services = ServiceRegistry(hass) + hass.bus = EventBus() # A valid IANA time zone (the day-rollover listener reads it to # compute "today" in HA local time). hass.config.time_zone = "UTC" diff --git a/tests/test_events.py b/tests/test_events.py new file mode 100644 index 0000000..ee3d79b --- /dev/null +++ b/tests/test_events.py @@ -0,0 +1,192 @@ +"""Tests for the NestQuest bus events (Feature 10, task 6).""" +from __future__ import annotations + +import re + +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.const import ( + CONF_ADMIN_USER_IDS, + DOMAIN, + EVENT_CHILD_DAY_COMPLETE, + EVENT_QUEST_COMPLETED, + EVENT_QUEST_MISSED, + EVENT_QUEST_UNCOMPLETED, + SERVICE_COMPLETE_QUEST, + SERVICE_CREATE_QUEST_DEFINITION, + SERVICE_MANAGE_CHILD, + SERVICE_REGENERATE, + SERVICE_UNCOMPLETE_QUEST, +) +from custom_components.nestquest.dao_instances import QuestInstancesDao +from custom_components.nestquest.materialize import materialize +from custom_components.nestquest.quest_definitions import ( + create_quest_definition, +) +from custom_components.nestquest.recurrence import ScheduleRule + +ADMIN_CTX = {"user_id": "admin-1"} +_TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$") + + +async def _setup_and_seed(hass, make_entry, *, windows=("morning",)): + entry = wire_entry_to_registry( + make_entry(data={CONF_ADMIN_USER_IDS: ["admin-1"]}), hass.registry + ) + assert await async_setup_entry(hass, entry) is True + database = entry.runtime_data.database + await hass.services.call( + DOMAIN, + SERVICE_MANAGE_CHILD, + {"action": "create", "display_name": "Ada"}, + context=ADMIN_CTX, + ) + child = None + from custom_components.nestquest.children import list_children + + child = (await list_children(database))[0] + await create_quest_definition( + database, + "Brush teeth", + ScheduleRule.from_dict( + {"rule_type": "daily", "start_date": __import__("datetime").date.today().isoformat()} + ), + [child.id], + list(windows), + ) + import datetime + + today = datetime.date.today().isoformat() + end = (datetime.date.today() + datetime.timedelta(days=3)).isoformat() + await materialize(database, today, end, today=datetime.date.today()) + instances = await QuestInstancesDao(database).list_by_date_range( + child.id, today, today + ) + return entry, child, instances + + +async def test_quest_completed_fires_with_documented_payload( + hass, make_entry +) -> None: + entry, child, instances = await _setup_and_seed(hass, make_entry) + instance_id = instances[0].id + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + {"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id}, + ) + fired = hass.bus.fired(EVENT_QUEST_COMPLETED) + assert len(fired) == 1 + payload = fired[0] + assert payload["child_id"] == child.id + 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: + entry, child, instances = await _setup_and_seed(hass, make_entry) + instance_id = instances[0].id + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + {"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id}, + ) + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + {"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id}, + ) + assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == 1 + + +async def test_child_day_complete_fires_when_day_clears( + hass, make_entry +) -> None: + entry, child, instances = await _setup_and_seed(hass, make_entry) + for instance in instances: + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + { + "instance_id": instance.id, + "actor": "panel", + "actor_child_id": child.id, + }, + ) + 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"] == child.id + assert payload["child_name"] == "Ada" + assert payload["quests_due"] == len(instances) + assert _TIMESTAMP.match(payload["occurred_at"]) + + +async def test_child_day_complete_does_not_fire_partway( + hass, make_entry +) -> None: + entry, child, instances = await _setup_and_seed( + hass, make_entry, windows=("morning", "evening") + ) + assert len(instances) == 2 + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + { + "instance_id": instances[0].id, + "actor": "panel", + "actor_child_id": child.id, + }, + ) + assert hass.bus.fired(EVENT_CHILD_DAY_COMPLETE) == [] + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + { + "instance_id": instances[1].id, + "actor": "panel", + "actor_child_id": child.id, + }, + ) + assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1 + + +async def test_uncompleted_fires_on_reversal_only(hass, make_entry) -> None: + entry, child, instances = await _setup_and_seed(hass, make_entry) + instance_id = instances[0].id + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + {"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id}, + ) + # Un-completing an open instance is a no-op and fires nothing. + await hass.services.call( + DOMAIN, + SERVICE_UNCOMPLETE_QUEST, + {"instance_id": instance_id, "actor": "user"}, + context=ADMIN_CTX, + ) + 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"]) + await hass.services.call( + DOMAIN, + SERVICE_UNCOMPLETE_QUEST, + {"instance_id": instance_id, "actor": "user"}, + context=ADMIN_CTX, + ) + 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" -- 2.55.0 From 2a4eec658fa4669156543db99c667cf075181563 Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 12:03:42 -0400 Subject: [PATCH 2/2] Decide event transitions under the completion lock complete/uncomplete return a CompletionResult str subclass carrying appended (decided with the write under the lock), so duplicate concurrent calls fire one transition event; day-complete only evaluates for an instance due today. --- custom_components/nestquest/completion.py | 58 ++++++++++----- custom_components/nestquest/events.py | 4 ++ custom_components/nestquest/services.py | 19 ++--- tests/test_events.py | 88 +++++++++++++++++++++++ 4 files changed, 140 insertions(+), 29 deletions(-) diff --git a/custom_components/nestquest/completion.py b/custom_components/nestquest/completion.py index 034741d..7d163b5 100644 --- a/custom_components/nestquest/completion.py +++ b/custom_components/nestquest/completion.py @@ -366,6 +366,25 @@ async def instance_state( return _derived_state(instance, latest, today_date) +class CompletionResult(str): + """The derived instance state, carrying the append verdict. + + A ``str`` subclass so every existing ``result == "done"`` contract + keeps working; ``appended`` reports whether THIS call actually + appended an event (decided under the same lock as the append), so + a caller — the Feature 09 service path firing bus events — + announces a transition exactly once even under a concurrent + duplicate call where the loser's write is a no-op. + """ + + appended: bool + + def __new__(cls, state: str, *, appended: bool) -> "CompletionResult": + obj = super().__new__(cls, state) + obj.appended = appended + return obj + + async def complete_instance( database: NestQuestDatabase, instance_id: int, @@ -375,14 +394,14 @@ async def complete_instance( actor_child_id: int | None = None, now: datetime.datetime | None = None, today: datetime.date | None = None, -) -> str: +) -> CompletionResult: """Append a completed event unless the instance is already done. - Returns the derived state ``done``. An already-done instance is a - no-op: nothing is appended. Unknown ``instance_id`` raises - ValueError naming the field. Existence, latest-event, and the - append run under one connection lock so a concurrent complete - cannot double-append. + Returns the derived state ``done``; ``appended`` reports whether + THIS call appended (False on an already-done no-op). Unknown + ``instance_id`` raises ValueError naming the field. Existence, + latest-event, and the append run under one connection lock so a + concurrent complete cannot double-append. ``was_on_time`` is computed from the HA-local completion moment (threaded ``now`` / ``today``) against the instance ``due_date`` @@ -404,7 +423,7 @@ async def complete_instance( instance_id ) if latest is not None and latest.event_type == EVENT_COMPLETED: - return "done" + return CompletionResult("done", appended=False) if latest is not None and latest.event_type != EVENT_UNCOMPLETED: raise ValueError( f"event_type must be '{EVENT_COMPLETED}' or " @@ -421,7 +440,7 @@ async def complete_instance( was_on_time=_was_on_time(instance, now, today), now=now, ) - return "done" + return CompletionResult("done", appended=True) async def uncomplete_instance( @@ -433,16 +452,16 @@ async def uncomplete_instance( actor_child_id: int | None = None, now: datetime.datetime | None = None, today: datetime.date | None = None, -) -> str: +) -> CompletionResult: """Append an uncompleted event unless the instance is already open. Returns the derived state ``open``, or ``missed`` when the instance - is past due (``due_date`` before HA-local ``today``). An - already-open instance is a no-op: nothing is appended. Unknown - ``instance_id`` raises ValueError naming the field. Existence, - latest-event, and the append run under one connection lock so a - concurrent uncomplete cannot double-append. The original completed - row is not modified. + is past due (``due_date`` before HA-local ``today``); ``appended`` + reports whether THIS call appended (False on an already-open + no-op). Unknown ``instance_id`` raises ValueError naming the + field. Existence, latest-event, and the append run under one + connection lock so a concurrent uncomplete cannot double-append. + The original completed row is not modified. """ instance_id = _validate_int_id(instance_id, "instance_id") actor_source, actor_user_id, actor_child_id = _validate_actor( @@ -459,7 +478,10 @@ async def uncomplete_instance( instance_id ) if latest is None or latest.event_type == EVENT_UNCOMPLETED: - return _derived_state(instance, latest, today_date) + return CompletionResult( + _derived_state(instance, latest, today_date), + appended=False, + ) if latest.event_type != EVENT_COMPLETED: raise ValueError( f"event_type must be '{EVENT_COMPLETED}' or " @@ -476,7 +498,9 @@ async def uncomplete_instance( was_on_time=None, now=now, ) - return _derived_state(instance, latest, today_date) + return CompletionResult( + _derived_state(instance, latest, today_date), appended=True + ) async def list_missed_for_child( diff --git a/custom_components/nestquest/events.py b/custom_components/nestquest/events.py index d8394c6..9798340 100644 --- a/custom_components/nestquest/events.py +++ b/custom_components/nestquest/events.py @@ -91,7 +91,11 @@ async def fire_quest_completed( # Day-complete: quests were owed today and none remain (the same # rule as the all-done binary sensor; a zero-quest day never fires). + # Only a completion of TODAY'S instance evaluates it: completing a + # back-dated or future instance must not announce a cleared day. today = datetime.datetime.now(ZoneInfo(hass.config.time_zone)).date() + if payload["due_date"] != today.isoformat(): + return instances = await QuestInstancesDao(database).list_by_date_range( payload["child_id"], today.isoformat(), today.isoformat() ) diff --git a/custom_components/nestquest/services.py b/custom_components/nestquest/services.py index 3ee9903..c03150d 100644 --- a/custom_components/nestquest/services.py +++ b/custom_components/nestquest/services.py @@ -295,17 +295,15 @@ def _complete_quest( async def _handler(call: Any) -> None: database = _require_runtime(hass, find_runtime).database instance_id = call.data["instance_id"] - # Events fire on an ACTUAL transition only: an already-done - # instance is a no-op below and must not re-announce itself. - state_before = await completion_layer.instance_state( - database, instance_id - ) - await completion_layer.complete_instance( + # ``appended`` is decided under the same lock as the write, so + # a concurrent duplicate call (a double tap) can not produce a + # second transition event: the loser appends nothing. + result = await completion_layer.complete_instance( database, instance_id, **_actor_kwargs(call), ) - if state_before != "done": + if result.appended: await fire_quest_completed(hass, database, instance_id) return _with_errors(_handler) @@ -317,15 +315,12 @@ def _uncomplete_quest( async def _handler(call: Any) -> None: database = _require_runtime(hass, find_runtime).database instance_id = call.data["instance_id"] - state_before = await completion_layer.instance_state( - database, instance_id - ) - await completion_layer.uncomplete_instance( + result = await completion_layer.uncomplete_instance( database, instance_id, **_actor_kwargs(call), ) - if state_before == "done": + if result.appended: await fire_quest_uncompleted(hass, database, instance_id) return _with_errors(_handler) diff --git a/tests/test_events.py b/tests/test_events.py index ee3d79b..ad019be 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -190,3 +190,91 @@ async def test_uncompleted_fires_on_reversal_only(hass, make_entry) -> None: 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 decide appended under the same + lock as the write: exactly one fires the transition event.""" + import asyncio + + entry, child, instances = await _setup_and_seed(hass, make_entry) + instance_id = instances[0].id + calls = [ + hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + { + "instance_id": instance_id, + "actor": "panel", + "actor_child_id": child.id, + }, + ) + for _ in range(2) + ] + await asyncio.gather(*calls) + assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == 1 + + +async def test_day_complete_ignored_for_backdated_completion( + hass, make_entry +) -> None: + """Completing an instance NOT due today never evaluates the + child's day-complete, so a cleared day is announced at most once + per day and only by that day's completions.""" + import datetime + + from custom_components.nestquest.completion import ( + complete_instance as _complete, + ) + + entry, child, instances = await _setup_and_seed(hass, make_entry) + database = entry.runtime_data.database + # A future instance (tomorrow) completed after today's day is + # already clear. + today = datetime.date.today() + end = (today + datetime.timedelta(days=3)).isoformat() + all_instances = await QuestInstancesDao(database).list_by_date_range( + child.id, today.isoformat(), end + ) + today_ids = {instance.id for instance in instances} + tomorrow_instance = next( + instance + for instance in all_instances + if instance.id not in today_ids + ) + for instance in instances: + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + { + "instance_id": instance.id, + "actor": "panel", + "actor_child_id": child.id, + }, + ) + assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1 + + await _complete( + database, + tomorrow_instance.id, + actor_source="user", + actor_user_id="admin-1", + ) + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + { + "instance_id": tomorrow_instance.id, + "actor": "panel", + "actor_child_id": child.id, + }, + ) + 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. + assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == len(instances) -- 2.55.0