diff --git a/tests/conftest.py b/tests/conftest.py index 8873d63..e2dfd3d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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] = [] diff --git a/tests/test_entity_lifecycle.py b/tests/test_entity_lifecycle.py new file mode 100644 index 0000000..a85c265 --- /dev/null +++ b/tests/test_entity_lifecycle.py @@ -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