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.
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.
|
||||||
|
|||||||
@@ -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,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)
|
||||||
@@ -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,19 @@ 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
|
||||||
|
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(
|
await completion_layer.complete_instance(
|
||||||
database,
|
database,
|
||||||
call.data["instance_id"],
|
instance_id,
|
||||||
**_actor_kwargs(call),
|
**_actor_kwargs(call),
|
||||||
)
|
)
|
||||||
|
if state_before != "done":
|
||||||
|
await fire_quest_completed(hass, database, instance_id)
|
||||||
|
|
||||||
return _with_errors(_handler)
|
return _with_errors(_handler)
|
||||||
|
|
||||||
@@ -307,11 +316,17 @@ 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
|
||||||
|
instance_id = call.data["instance_id"]
|
||||||
|
state_before = await completion_layer.instance_state(
|
||||||
|
database, instance_id
|
||||||
|
)
|
||||||
await completion_layer.uncomplete_instance(
|
await completion_layer.uncomplete_instance(
|
||||||
database,
|
database,
|
||||||
call.data["instance_id"],
|
instance_id,
|
||||||
**_actor_kwargs(call),
|
**_actor_kwargs(call),
|
||||||
)
|
)
|
||||||
|
if state_before == "done":
|
||||||
|
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,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"
|
||||||
Reference in New Issue
Block a user