Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com> Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
204 lines
6.5 KiB
Python
204 lines
6.5 KiB
Python
"""Entity lifecycle across child add/deactivate/reactivate (Feature 10, task 8).
|
|
|
|
The household state the entities mirror comes from the API service's
|
|
panel route, so a deactivate/reactivate/add is scripted as a payload
|
|
change served by :class:`conftest.StubSnapshotClient`; the reload
|
|
(unload + setup) re-polls it, exactly as production does.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from conftest import StubSnapshotClient, set_coordinator_client, wire_entry_to_registry
|
|
|
|
from custom_components.nestquest import async_setup_entry, async_unload_entry
|
|
|
|
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",
|
|
)
|
|
|
|
ADA = 1
|
|
BO = 2
|
|
CORY_ID = 4
|
|
|
|
|
|
def _child(child_id: int, name: str, *, present=True, instances=()):
|
|
completed = sum(1 for i in instances if i["state"] == "completed")
|
|
due = len(instances)
|
|
return {
|
|
"child_id": child_id,
|
|
"child_name": name,
|
|
"present": present,
|
|
"next_present": None,
|
|
"due_today": due,
|
|
"completed_today": completed,
|
|
"remaining_today": due - completed,
|
|
"completion_pct": 100 if due == 0 else round(completed / due * 100),
|
|
"instances": list(instances),
|
|
}
|
|
|
|
|
|
def _instance(instance_id: int, child_id: int, *, state="open") -> dict:
|
|
return {
|
|
"id": instance_id,
|
|
"definition_id": 3,
|
|
"child_id": child_id,
|
|
"title": "Brush teeth",
|
|
"icon": "🦷",
|
|
"window": "morning",
|
|
"due_time": "08:00",
|
|
"state": state,
|
|
"overdue": False,
|
|
"completed_at": None if state == "open" else "2026-09-23T07:55:00+00:00",
|
|
"on_time": None if state == "open" else True,
|
|
}
|
|
|
|
|
|
def _payload(children) -> dict:
|
|
return {
|
|
"today_iso": "2026-09-23",
|
|
"cycle_day": 0,
|
|
"timezone": "",
|
|
"children": list(children),
|
|
}
|
|
|
|
|
|
#: The full household (Ada + Bo, one daily quest each) and the states
|
|
#: the lifecycle walks through: Bo deactivated, reactivated, completed.
|
|
_BOTH = _payload(
|
|
[
|
|
_child(ADA, "Ada", instances=[_instance(7, ADA)]),
|
|
_child(BO, "Bo", instances=[_instance(8, BO)]),
|
|
]
|
|
)
|
|
_BO_GONE = _payload([_child(ADA, "Ada", instances=[_instance(7, ADA)])])
|
|
_BO_DONE = _payload(
|
|
[
|
|
_child(ADA, "Ada", instances=[_instance(7, ADA)]),
|
|
_child(BO, "Bo", instances=[_instance(8, BO, state="completed")]),
|
|
]
|
|
)
|
|
_ADA_ONLY = _payload([_child(ADA, "Ada", instances=[_instance(7, ADA)])])
|
|
_ADA_AND_CORY = _payload(
|
|
[
|
|
_child(ADA, "Ada", instances=[_instance(7, ADA)]),
|
|
_child(CORY_ID, "Cory"),
|
|
]
|
|
)
|
|
|
|
|
|
def _child_unique_ids(child_id: int) -> set[str]:
|
|
return {f"nestquest_child_{child_id}_{kind}" for kind in PER_CHILD_KINDS}
|
|
|
|
|
|
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
|
|
|
|
|
|
async def _setup_with_household(hass, make_entry, *payloads):
|
|
"""Set up the entry with a scripted, ordered snapshot history."""
|
|
entry = wire_entry_to_registry(
|
|
make_entry(
|
|
data={"api_base_url": "http://api.test:8000", "panel_token": "tok"}
|
|
),
|
|
hass.registry,
|
|
)
|
|
client = set_coordinator_client(entry, StubSnapshotClient(*payloads))
|
|
assert await async_setup_entry(hass, entry) is True
|
|
return entry, entry.runtime_data.coordinator, client
|
|
|
|
|
|
async def test_deactivated_child_entities_removed_on_reload(
|
|
hass, make_entry
|
|
) -> None:
|
|
"""A child absent from the API's snapshot loses its entities on
|
|
reload; the surviving child's entities are untouched."""
|
|
entry, _coordinator, _client = await _setup_with_household(
|
|
hass, make_entry, _BOTH, _BO_GONE
|
|
)
|
|
assert _child_unique_ids(ADA) <= set(hass.entities)
|
|
assert _child_unique_ids(BO) <= set(hass.entities)
|
|
|
|
await _reload(hass, entry)
|
|
|
|
assert _child_unique_ids(ADA) <= set(hass.entities), (
|
|
"the surviving child's entities must be untouched"
|
|
)
|
|
assert not (_child_unique_ids(BO) & 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:
|
|
original = _child_unique_ids(BO)
|
|
entry, _coordinator, _client = await _setup_with_household(
|
|
hass, make_entry, _BOTH, _BO_GONE, _BOTH
|
|
)
|
|
await _reload(hass, entry)
|
|
assert not (original & set(hass.entities))
|
|
|
|
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}_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:
|
|
"""A child ADDED to the API's snapshot gains its entities on
|
|
reload; the existing children's entities are untouched."""
|
|
entry, _coordinator, _client = await _setup_with_household(
|
|
hass, make_entry, _ADA_ONLY, _ADA_AND_CORY
|
|
)
|
|
before_ada = set(hass.entities) & _child_unique_ids(ADA)
|
|
|
|
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 the API's current states and the household
|
|
rollups."""
|
|
entry, _coordinator, _client = await _setup_with_household(
|
|
hass, make_entry, _BOTH, _BO_DONE
|
|
)
|
|
await _reload(hass, entry)
|
|
|
|
assert hass.entities[
|
|
f"nestquest_child_{ADA}_quests_completed_today"
|
|
].native_value == 0
|
|
assert hass.entities[
|
|
f"nestquest_child_{BO}_quests_completed_today"
|
|
].native_value == 1, "the API's recorded completion survives the restart"
|
|
assert set(HOUSEHOLD_UNIQUE_IDS) <= set(hass.entities)
|
|
assert hass.entities[
|
|
"nestquest_household_quests_completed_today"
|
|
].native_value == 1
|