The panel snapshot now carries the admin-saved `timezone` ("" when unset);
the coordinator threads it onto the household due-today sensor, and the
Party Board and Quest Log cards format their kicker, header and dock
date and clock in it, falling back to Home Assistant's zone only when
none is stored. The admin app loads the stored zone once
(AppTimezoneProvider) and uses it for completion clocks, History day
grouping, clocks and lateness, and "today" in Schedule, History and new
task defaults; Settings publishes a saved zone app-wide at once.
Resolves review finding TZ-001 (task 7e173881).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.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
|