Compare commits

...
Author SHA1 Message Date
orchestrator-agent a9d8f1d225 Handle entity lifecycle for child add, deactivate, reactivate
The harness tracks entities per entry so a platform unload removes
exactly that entry's entities; lifecycle tests prove deactivated
children drop out on reload, reactivation restores the original
unique_ids, new children appear without disturbing existing ones,
and the restart equivalent restores live states.
2026-09-19 13:23:35 -04:00
orchestrator-agent a5dbae5d50 Serialize forced coordinator refreshes per entry
NestQuestCoordinator.async_refresh holds a per-entry lock so
overlapping service-path refreshes never interleave; the queued pass
always publishes after the in-flight one.
2026-09-19 13:21:54 -04:00
orchestrator-agent ad56d13c6e Refresh entities immediately after completion service calls
complete_quest and uncomplete_quest force a coordinator refresh when
the call appended, so entity states land the moment the service call
returns; scheduled internals are unaffected.
2026-09-19 13:16:12 -04:00
orchestrator-agent b90f55f750 Fire the documented bus events from the completion service paths 2026-09-19 12:03:44 -04:00
5 changed files with 334 additions and 23 deletions
+19 -1
View File
@@ -25,6 +25,7 @@ the admin header degrades to a harmless zero instead of guessing.
"""
from __future__ import annotations
import asyncio
import datetime
from dataclasses import dataclass
from zoneinfo import ZoneInfo
@@ -97,7 +98,16 @@ class NestQuestSnapshot:
class NestQuestCoordinator(DataUpdateCoordinator):
"""Shared refresh cycle for every NestQuest entity."""
"""Shared refresh cycle for every NestQuest entity.
Forced refreshes (the service paths pushing an immediate update
after a completion) are SERIALIZED per entry: overlapping
complete/uncomplete calls must not interleave their snapshot
passes, or an older pass could publish a stale snapshot AFTER a
newer one and leave entities wrong until the next poll. Real
HA's coordinator already serializes internally; the lock keeps
the same guarantee on the stand-in and costs nothing upstream.
"""
def __init__(
self,
@@ -122,6 +132,14 @@ class NestQuestCoordinator(DataUpdateCoordinator):
)
self.entry_id = entry_id
self.database = database
self._refresh_lock = asyncio.Lock()
async def async_refresh(self) -> None:
"""One refresh at a time; a queued refresh runs AFTER the
in-flight one publishes, so the last snapshot always reflects
the latest mutation."""
async with self._refresh_lock:
await super().async_refresh()
def _local_now(self) -> datetime.datetime:
time_zone = ZoneInfo(self.hass.config.time_zone)
+23 -6
View File
@@ -242,6 +242,19 @@ def _require_runtime(hass: HomeAssistant, find_runtime: FindRuntime):
return runtime
async def _refresh_entities(runtime: Any) -> None:
"""Force an immediate coordinator refresh after a service mutation.
Only the SERVICE paths call this — the scheduled materialize and
rollover internals are direct function calls and never widen this
gate — so entities reflect a completion the moment the service
call returns instead of waiting for the poll interval.
"""
coordinator = getattr(runtime, "coordinator", None)
if coordinator is not None:
await coordinator.async_refresh()
def _generation_kwargs(hass: HomeAssistant, runtime: Any) -> dict[str, Any]:
time_zone = ZoneInfo(hass.config.time_zone)
today = datetime.datetime.now(time_zone).date()
@@ -293,18 +306,19 @@ def _complete_quest(
hass: HomeAssistant, find_runtime: FindRuntime
) -> ServiceHandler:
async def _handler(call: Any) -> None:
database = _require_runtime(hass, find_runtime).database
runtime = _require_runtime(hass, find_runtime)
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,
runtime.database,
instance_id,
**_actor_kwargs(call),
)
if result.appended:
await fire_quest_completed(hass, database, instance_id)
await fire_quest_completed(hass, runtime.database, instance_id)
await _refresh_entities(runtime)
return _with_errors(_handler)
@@ -313,15 +327,18 @@ def _uncomplete_quest(
hass: HomeAssistant, find_runtime: FindRuntime
) -> ServiceHandler:
async def _handler(call: Any) -> None:
database = _require_runtime(hass, find_runtime).database
runtime = _require_runtime(hass, find_runtime)
instance_id = call.data["instance_id"]
result = await completion_layer.uncomplete_instance(
database,
runtime.database,
instance_id,
**_actor_kwargs(call),
)
if result.appended:
await fire_quest_uncompleted(hass, database, instance_id)
await fire_quest_uncompleted(
hass, runtime.database, instance_id
)
await _refresh_entities(runtime)
return _with_errors(_handler)
+34 -16
View File
@@ -704,28 +704,40 @@ def make_hass() -> tuple:
# The minimal entity registry: entities self-register through
# Entity.async_write_ha_state keyed by unique_id, storing the live
# object; tests enumerate created entities and read their state
# directly.
# directly. entities_by_entry mirrors HA's per-entry platform
# scoping: unloading an entry's platforms removes exactly that
# entry's entities.
hass.entities: dict[str, Entity] = {}
hass.entities_by_entry: dict[str, dict[str, Entity]] = {}
def _async_add_entities(new_entities, update_before_add=False):
"""Mirror HA's AddEntitiesCallback: bind hass and register entities.
def _make_add_entities(entry):
entry_entities = hass.entities_by_entry.setdefault(entry.entry_id, {})
Accepts a single entity or an iterable, mirroring HA's
permissive call shape; registration goes through each entity's
async_write_ha_state (the mock state write).
"""
if isinstance(new_entities, Entity):
new_entities = [new_entities]
for entity in new_entities:
entity.hass = hass
entity.async_write_ha_state()
def _async_add_entities(new_entities, update_before_add=False):
"""Mirror HA's AddEntitiesCallback: bind hass and register.
Accepts a single entity or an iterable, mirroring HA's
permissive call shape; registration goes through each
entity's async_write_ha_state (the mock state write) and
is recorded per entry so an unload removes exactly this
entry's entities.
"""
if isinstance(new_entities, Entity):
new_entities = [new_entities]
for entity in new_entities:
entity.hass = hass
entity.async_write_ha_state()
if entity.unique_id is not None:
entry_entities[entity.unique_id] = entity
return _async_add_entities
async def _async_forward_entry_setups(entry, platforms):
"""Mirror hass.config_entries.async_forward_entry_setups.
HA's contract, loosely: import each platform module of the
entry's integration and drive its ``async_setup_entry`` with
the AddEntitiesCallback above. A stub entry without a domain
entry's integration and drive its ``async_setup_entry`` with a
per-entry AddEntitiesCallback. A stub entry without a domain
defaults to this harness's single integration (NestQuest).
"""
for platform in list(platforms):
@@ -733,14 +745,18 @@ def make_hass() -> tuple:
module = importlib.import_module(
f"custom_components.{domain}.{platform}"
)
await module.async_setup_entry(hass, entry, _async_add_entities)
await module.async_setup_entry(
hass, entry, _make_add_entities(entry)
)
hass.config_entries.forwarded_platforms.append(platform)
async def _async_unload_platforms(entry, platforms):
"""Mirror hass.config_entries.async_unload_platforms.
Runs each platform module's ``async_unload_entry`` when it
defines one (per-platform unload is optional, like HA's).
defines one (per-platform unload is optional, like HA's), then
removes the entry's entities — unloading an entry's platforms
removes exactly that entry's entities, never another entry's.
"""
for platform in list(platforms):
domain = getattr(entry, "domain", "nestquest")
@@ -751,6 +767,8 @@ def make_hass() -> tuple:
if unload is not None:
await unload(hass, entry)
hass.config_entries.unloaded_platforms.append(platform)
for unique_id in hass.entities_by_entry.pop(entry.entry_id, {}):
hass.entities.pop(unique_id, None)
hass.config_entries.forwarded_platforms: list[str] = []
hass.config_entries.unloaded_platforms: list[str] = []
+159
View File
@@ -0,0 +1,159 @@
"""Entity lifecycle across child add/deactivate/reactivate (Feature 10, task 8)."""
from __future__ import annotations
import datetime
from conftest import wire_entry_to_registry
from custom_components.nestquest import async_setup_entry, async_unload_entry
from custom_components.nestquest.children import (
create_child,
list_children,
set_child_active,
)
from custom_components.nestquest.const import DOMAIN
from custom_components.nestquest.materialize import materialize
from custom_components.nestquest.quest_definitions import (
create_quest_definition,
)
from custom_components.nestquest.recurrence import ScheduleRule
SEED_HORIZON_DAYS = 3
PER_CHILD_KINDS = (
"quests_due_today",
"quests_completed_today",
"quests_remaining_today",
"completion_pct_today",
"next_quest",
"all_done",
"present_today",
)
HOUSEHOLD_UNIQUE_IDS = (
"nestquest_household_quests_due_today",
"nestquest_household_quests_completed_today",
"nestquest_cycle_day",
)
async def _reload(hass, entry) -> None:
"""Unload then set up: the harness's restart/reload equivalent."""
assert await async_unload_entry(hass, entry) is True
assert await async_setup_entry(hass, entry) is True
def _child_unique_ids(child_id: int) -> set[str]:
return {f"nestquest_child_{child_id}_{kind}" for kind in PER_CHILD_KINDS}
async def _setup_and_seed(hass, make_entry):
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
database = entry.runtime_data.database
ada = await create_child(database, "Ada")
bo = await create_child(database, "Bo")
today = datetime.date.today()
await create_quest_definition(
database,
"Brush teeth",
ScheduleRule.from_dict(
{"rule_type": "daily", "start_date": today.isoformat()}
),
[ada.id, bo.id],
["morning"],
)
end = (today + datetime.timedelta(days=SEED_HORIZON_DAYS)).isoformat()
await materialize(database, today.isoformat(), end, today=today)
await entry.runtime_data.coordinator.async_refresh()
return entry, ada, bo
async def test_deactivated_child_entities_removed_on_reload(
hass, make_entry
) -> None:
entry, ada, bo = await _setup_and_seed(hass, make_entry)
assert _child_unique_ids(ada.id) <= set(hass.entities)
assert _child_unique_ids(bo.id) <= set(hass.entities)
await set_child_active(entry.runtime_data.database, bo.id, False)
await _reload(hass, entry)
assert _child_unique_ids(ada.id) <= set(hass.entities), (
"the surviving child's entities must be untouched"
)
assert not (_child_unique_ids(bo.id) & set(hass.entities)), (
"the deactivated child's entities must be gone after reload"
)
async def test_reactivation_restores_entities_with_original_unique_ids(
hass, make_entry
) -> None:
entry, ada, bo = await _setup_and_seed(hass, make_entry)
original = _child_unique_ids(bo.id)
await set_child_active(entry.runtime_data.database, bo.id, False)
await _reload(hass, entry)
assert not (original & set(hass.entities))
await set_child_active(entry.runtime_data.database, bo.id, True)
await _reload(hass, entry)
assert original <= set(hass.entities), (
"reactivation must restore the exact original unique_ids"
)
bo_due = hass.entities[f"nestquest_child_{bo.id}_quests_due_today"]
assert bo_due.native_value == 1, "restored entities carry live state"
async def test_new_child_gets_entities_without_disturbing_existing(
hass, make_entry
) -> None:
entry, ada, bo = await _setup_and_seed(hass, make_entry)
before_ada = set(hass.entities) & _child_unique_ids(ada.id)
cory = await create_child(entry.runtime_data.database, "Cory")
await _reload(hass, entry)
assert before_ada <= set(hass.entities), "existing entities untouched"
assert _child_unique_ids(cory.id) <= set(hass.entities), (
"the new child's entities appear after reload"
)
assert hass.entities[f"nestquest_child_{cory.id}_quests_due_today"].native_value == 0
async def test_restart_equivalent_restores_all_entity_states(
hass, make_entry
) -> None:
"""Unload + setup (the restart equivalent) restores every active
child's entities with correct states and the household rollups."""
from custom_components.nestquest.completion import complete_instance
entry, ada, bo = await _setup_and_seed(hass, make_entry)
coordinator = entry.runtime_data.coordinator
snapshot_ada = next(
child
for child in coordinator.data.children
if child.child_id == ada.id
)
await complete_instance(
entry.runtime_data.database,
snapshot_ada.instances[0].instance_id,
actor_source="panel",
actor_child_id=ada.id,
)
await coordinator.async_refresh()
assert hass.entities[
f"nestquest_child_{ada.id}_quests_completed_today"
].native_value == 1
await _reload(hass, entry)
assert hass.entities[
f"nestquest_child_{ada.id}_quests_completed_today"
].native_value == 1, "completed count survives the restart"
assert hass.entities[
f"nestquest_child_{bo.id}_quests_completed_today"
].native_value == 0
assert set(HOUSEHOLD_UNIQUE_IDS) <= set(hass.entities)
assert hass.entities[
"nestquest_household_quests_completed_today"
].native_value == 1
+99
View File
@@ -278,3 +278,102 @@ async def test_day_complete_ignored_for_backdated_completion(
# 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)
async def test_completion_updates_entities_immediately(
hass, make_entry
) -> None:
"""The remaining-today sensor reflects the completion the moment
the service call returns — no manual refresh — and the day-complete
event fired exactly once when the day became clear."""
from custom_components.nestquest.const import EVENT_CHILD_DAY_COMPLETE
entry, child, instances = await _setup_and_seed(hass, make_entry)
from conftest import wire_entry_to_registry # noqa: F401
coordinator = entry.runtime_data.coordinator
# Entities only exist after one refresh since they were seeded
# after setup; create them, then prove the service path refreshes.
await coordinator.async_refresh()
remaining_before = hass.entities[
f"nestquest_child_{child.id}_quests_remaining_today"
].native_value
assert remaining_before == len(instances)
await hass.services.call(
DOMAIN,
SERVICE_COMPLETE_QUEST,
{
"instance_id": instances[0].id,
"actor": "panel",
"actor_child_id": child.id,
},
)
remaining_after = hass.entities[
f"nestquest_child_{child.id}_quests_remaining_today"
].native_value
assert remaining_after == len(instances) - 1, (
"sensor must reflect the completion without a manual refresh"
)
assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == (
1 if len(instances) == 1 else 0
)
async def test_uncompletion_updates_entities_immediately(
hass, make_entry
) -> None:
entry, child, instances = await _setup_and_seed(hass, make_entry)
coordinator = entry.runtime_data.coordinator
await coordinator.async_refresh()
await hass.services.call(
DOMAIN,
SERVICE_COMPLETE_QUEST,
{
"instance_id": instances[0].id,
"actor": "panel",
"actor_child_id": child.id,
},
)
completed_after_complete = hass.entities[
f"nestquest_child_{child.id}_quests_completed_today"
].native_value
assert completed_after_complete == 1
await hass.services.call(
DOMAIN,
SERVICE_UNCOMPLETE_QUEST,
{"instance_id": instances[0].id, "actor": "user"},
context=ADMIN_CTX,
)
completed_after_uncomplete = hass.entities[
f"nestquest_child_{child.id}_quests_completed_today"
].native_value
assert completed_after_uncomplete == 0, (
"sensor must reflect the reversal without a manual refresh"
)
async def test_forced_refreshes_serialize_no_stale_publish(
hass, make_entry
) -> None:
"""Overlapping forced refreshes never interleave: a slow pass
pauses mid-snapshot, and the queued pass still publishes AFTER
it, so the last published snapshot reflects the latest mutation."""
import asyncio
entry, child, instances = await _setup_and_seed(hass, make_entry)
coordinator = entry.runtime_data.coordinator
order: list[str] = []
async def _slow_update():
order.append("start")
await asyncio.sleep(0.01)
order.append("end")
return coordinator.data
coordinator._async_update_data = _slow_update
await asyncio.gather(coordinator.async_refresh(), coordinator.async_refresh())
assert order == ["start", "end", "start", "end"], (
"overlapping refreshes must serialize: " + str(order)
)