Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9d8f1d225 | ||
|
|
a5dbae5d50 | ||
|
|
ad56d13c6e | ||
|
|
b90f55f750 |
@@ -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)
|
||||
|
||||
@@ -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
@@ -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] = []
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user