Fire the documented bus events from the completion service paths
This commit was merged in pull request #110.
This commit is contained in:
@@ -38,6 +38,35 @@ After installation and restart:
|
|||||||
2. Click **Add Integration** and search for **NestQuest**.
|
2. Click **Add Integration** and search for **NestQuest**.
|
||||||
3. Follow the on-screen configuration flow.
|
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
|
## Developer Setup and Testing
|
||||||
|
|
||||||
This project uses [`uv`](https://github.com/astral-sh/uv) for fast Python package and dependency management.
|
This project uses [`uv`](https://github.com/astral-sh/uv) for fast Python package and dependency management.
|
||||||
|
|||||||
@@ -366,6 +366,25 @@ async def instance_state(
|
|||||||
return _derived_state(instance, latest, today_date)
|
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(
|
async def complete_instance(
|
||||||
database: NestQuestDatabase,
|
database: NestQuestDatabase,
|
||||||
instance_id: int,
|
instance_id: int,
|
||||||
@@ -375,14 +394,14 @@ async def complete_instance(
|
|||||||
actor_child_id: int | None = None,
|
actor_child_id: int | None = None,
|
||||||
now: datetime.datetime | None = None,
|
now: datetime.datetime | None = None,
|
||||||
today: datetime.date | None = None,
|
today: datetime.date | None = None,
|
||||||
) -> str:
|
) -> CompletionResult:
|
||||||
"""Append a completed event unless the instance is already done.
|
"""Append a completed event unless the instance is already done.
|
||||||
|
|
||||||
Returns the derived state ``done``. An already-done instance is a
|
Returns the derived state ``done``; ``appended`` reports whether
|
||||||
no-op: nothing is appended. Unknown ``instance_id`` raises
|
THIS call appended (False on an already-done no-op). Unknown
|
||||||
ValueError naming the field. Existence, latest-event, and the
|
``instance_id`` raises ValueError naming the field. Existence,
|
||||||
append run under one connection lock so a concurrent complete
|
latest-event, and the append run under one connection lock so a
|
||||||
cannot double-append.
|
concurrent complete cannot double-append.
|
||||||
|
|
||||||
``was_on_time`` is computed from the HA-local completion moment
|
``was_on_time`` is computed from the HA-local completion moment
|
||||||
(threaded ``now`` / ``today``) against the instance ``due_date``
|
(threaded ``now`` / ``today``) against the instance ``due_date``
|
||||||
@@ -404,7 +423,7 @@ async def complete_instance(
|
|||||||
instance_id
|
instance_id
|
||||||
)
|
)
|
||||||
if latest is not None and latest.event_type == EVENT_COMPLETED:
|
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:
|
if latest is not None and latest.event_type != EVENT_UNCOMPLETED:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"event_type must be '{EVENT_COMPLETED}' or "
|
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),
|
was_on_time=_was_on_time(instance, now, today),
|
||||||
now=now,
|
now=now,
|
||||||
)
|
)
|
||||||
return "done"
|
return CompletionResult("done", appended=True)
|
||||||
|
|
||||||
|
|
||||||
async def uncomplete_instance(
|
async def uncomplete_instance(
|
||||||
@@ -433,16 +452,16 @@ async def uncomplete_instance(
|
|||||||
actor_child_id: int | None = None,
|
actor_child_id: int | None = None,
|
||||||
now: datetime.datetime | None = None,
|
now: datetime.datetime | None = None,
|
||||||
today: datetime.date | None = None,
|
today: datetime.date | None = None,
|
||||||
) -> str:
|
) -> CompletionResult:
|
||||||
"""Append an uncompleted event unless the instance is already open.
|
"""Append an uncompleted event unless the instance is already open.
|
||||||
|
|
||||||
Returns the derived state ``open``, or ``missed`` when the instance
|
Returns the derived state ``open``, or ``missed`` when the instance
|
||||||
is past due (``due_date`` before HA-local ``today``). An
|
is past due (``due_date`` before HA-local ``today``); ``appended``
|
||||||
already-open instance is a no-op: nothing is appended. Unknown
|
reports whether THIS call appended (False on an already-open
|
||||||
``instance_id`` raises ValueError naming the field. Existence,
|
no-op). Unknown ``instance_id`` raises ValueError naming the
|
||||||
latest-event, and the append run under one connection lock so a
|
field. Existence, latest-event, and the append run under one
|
||||||
concurrent uncomplete cannot double-append. The original completed
|
connection lock so a concurrent uncomplete cannot double-append.
|
||||||
row is not modified.
|
The original completed row is not modified.
|
||||||
"""
|
"""
|
||||||
instance_id = _validate_int_id(instance_id, "instance_id")
|
instance_id = _validate_int_id(instance_id, "instance_id")
|
||||||
actor_source, actor_user_id, actor_child_id = _validate_actor(
|
actor_source, actor_user_id, actor_child_id = _validate_actor(
|
||||||
@@ -459,7 +478,10 @@ async def uncomplete_instance(
|
|||||||
instance_id
|
instance_id
|
||||||
)
|
)
|
||||||
if latest is None or latest.event_type == EVENT_UNCOMPLETED:
|
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:
|
if latest.event_type != EVENT_COMPLETED:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"event_type must be '{EVENT_COMPLETED}' or "
|
f"event_type must be '{EVENT_COMPLETED}' or "
|
||||||
@@ -476,7 +498,9 @@ async def uncomplete_instance(
|
|||||||
was_on_time=None,
|
was_on_time=None,
|
||||||
now=now,
|
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(
|
async def list_missed_for_child(
|
||||||
|
|||||||
@@ -11,6 +11,15 @@ LOGGER = logging.getLogger(LOGGER_NAME)
|
|||||||
# sensors and binary sensors.
|
# sensors and binary sensors.
|
||||||
PLATFORMS: list[str] = ["sensor", "binary_sensor"]
|
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
|
#: Canonical domain-global service names (design/ENTITIES-AND-SERVICES.md §2
|
||||||
#: plus the existing Feature 07 regenerate service).
|
#: plus the existing Feature 07 regenerate service).
|
||||||
SERVICE_COMPLETE_QUEST = "complete_quest"
|
SERVICE_COMPLETE_QUEST = "complete_quest"
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""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).
|
||||||
|
# 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()
|
||||||
|
)
|
||||||
|
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)
|
||||||
@@ -42,6 +42,7 @@ from .const import (
|
|||||||
SERVICE_UPDATE_QUEST_DEFINITION,
|
SERVICE_UPDATE_QUEST_DEFINITION,
|
||||||
)
|
)
|
||||||
from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao
|
from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao
|
||||||
|
from .events import fire_quest_completed, fire_quest_uncompleted
|
||||||
from .materialize import regenerate_for_child
|
from .materialize import regenerate_for_child
|
||||||
from .permissions import permission_gate
|
from .permissions import permission_gate
|
||||||
from .presence import PresenceSchedule
|
from .presence import PresenceSchedule
|
||||||
@@ -293,11 +294,17 @@ def _complete_quest(
|
|||||||
) -> ServiceHandler:
|
) -> ServiceHandler:
|
||||||
async def _handler(call: Any) -> None:
|
async def _handler(call: Any) -> None:
|
||||||
database = _require_runtime(hass, find_runtime).database
|
database = _require_runtime(hass, find_runtime).database
|
||||||
await completion_layer.complete_instance(
|
instance_id = call.data["instance_id"]
|
||||||
|
# ``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,
|
database,
|
||||||
call.data["instance_id"],
|
instance_id,
|
||||||
**_actor_kwargs(call),
|
**_actor_kwargs(call),
|
||||||
)
|
)
|
||||||
|
if result.appended:
|
||||||
|
await fire_quest_completed(hass, database, instance_id)
|
||||||
|
|
||||||
return _with_errors(_handler)
|
return _with_errors(_handler)
|
||||||
|
|
||||||
@@ -307,11 +314,14 @@ def _uncomplete_quest(
|
|||||||
) -> ServiceHandler:
|
) -> ServiceHandler:
|
||||||
async def _handler(call: Any) -> None:
|
async def _handler(call: Any) -> None:
|
||||||
database = _require_runtime(hass, find_runtime).database
|
database = _require_runtime(hass, find_runtime).database
|
||||||
await completion_layer.uncomplete_instance(
|
instance_id = call.data["instance_id"]
|
||||||
|
result = await completion_layer.uncomplete_instance(
|
||||||
database,
|
database,
|
||||||
call.data["instance_id"],
|
instance_id,
|
||||||
**_actor_kwargs(call),
|
**_actor_kwargs(call),
|
||||||
)
|
)
|
||||||
|
if result.appended:
|
||||||
|
await fire_quest_uncompleted(hass, database, instance_id)
|
||||||
|
|
||||||
return _with_errors(_handler)
|
return _with_errors(_handler)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
_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:
|
class ServiceRegistry:
|
||||||
"""Models HA's ``hass.services`` service registry.
|
"""Models HA's ``hass.services`` service registry.
|
||||||
|
|
||||||
@@ -671,6 +696,7 @@ def make_hass() -> tuple:
|
|||||||
registry = ListenerRegistry(hass)
|
registry = ListenerRegistry(hass)
|
||||||
hass.time_change = TimeChangeRegistry(hass)
|
hass.time_change = TimeChangeRegistry(hass)
|
||||||
hass.services = ServiceRegistry(hass)
|
hass.services = ServiceRegistry(hass)
|
||||||
|
hass.bus = EventBus()
|
||||||
# A valid IANA time zone (the day-rollover listener reads it to
|
# A valid IANA time zone (the day-rollover listener reads it to
|
||||||
# compute "today" in HA local time).
|
# compute "today" in HA local time).
|
||||||
hass.config.time_zone = "UTC"
|
hass.config.time_zone = "UTC"
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
"""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"
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
Reference in New Issue
Block a user