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.
This commit is contained in:
2026-09-19 12:03:42 -04:00
parent fe38e5040e
commit 2a4eec658f
4 changed files with 140 additions and 29 deletions
+88
View File
@@ -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)