Files
NestQuest/tests/test_coordinator.py
T
orchestrator-agent 5c99987e7f Shut the coordinator down when first refresh fails setup
The setup except path closes the database; the coordinator created
above it now shuts down first so no listener can fire against the
closed connection. Regression test included.
2026-09-19 11:44:11 -04:00

282 lines
9.7 KiB
Python

"""Tests for the shared NestQuest coordinator (Feature 10, task 1)."""
from __future__ import annotations
import datetime
import pytest
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.completion import complete_instance
from custom_components.nestquest.const import (
CONF_UPDATE_INTERVAL,
DEFAULT_UPDATE_INTERVAL,
DEFAULT_HORIZON_DAYS,
DOMAIN,
)
from custom_components.nestquest.coordinator import NestQuestCoordinator
from custom_components.nestquest.dao_presence import PresenceSchedulesDao
from custom_components.nestquest.materialize import materialize
from custom_components.nestquest.quest_definitions import (
create_quest_definition,
)
from custom_components.nestquest.recurrence import ScheduleRule
ADMIN_CTX = {"user_id": "admin-1"}
def _today_iso() -> str:
return datetime.date.today().isoformat()
async def _seed_day(database, *, completed: bool = False) -> None:
"""Two active children + one inactive, one daily definition each."""
await create_child(database, "Ada")
await create_child(database, "Bo")
inactive = await create_child(database, "Ghost")
await set_child_active(database, inactive.id, False)
today = _today_iso()
await create_quest_definition(
database,
"Brush teeth",
ScheduleRule.from_dict({"rule_type": "daily", "start_date": today}),
[child.id for child in await list_children(database, active_only=True)],
["morning"],
)
end = (
datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
).isoformat()
await materialize(database, today, end, today=datetime.date.today())
async def test_coordinator_snapshot_counts_and_states(hass, make_entry) -> None:
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
coordinator = entry.runtime_data.coordinator
assert coordinator is not None
database = entry.runtime_data.database
await _seed_day(database)
snapshot = await coordinator._async_update_data()
assert snapshot.today_iso == _today_iso()
assert len(snapshot.children) == 2, "inactive child must be absent"
by_name = {child.child_name: child for child in snapshot.children}
ada = by_name["Ada"]
assert ada.due_today == 1
assert ada.completed_today == 0
assert ada.remaining_today == 1
assert ada.completion_pct == 0
instance = ada.instances[0]
assert instance.state == "open"
assert instance.overdue is False
assert instance.title == "Brush teeth"
assert instance.window == "morning"
assert ada.present is True, "no schedule means present every day"
assert snapshot.cycle_day == 0, "no schedule anywhere"
async def test_coordinator_reflects_completion_and_uncompletion(
hass, make_entry
) -> None:
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
coordinator = entry.runtime_data.coordinator
database = entry.runtime_data.database
await _seed_day(database)
ada = (
await coordinator._async_update_data()
).children[0]
instance_id = ada.instances[0].instance_id
await complete_instance(
database,
instance_id,
actor_source="panel",
actor_child_id=ada.child_id,
)
snapshot = await coordinator._async_update_data()
child = snapshot.children[0]
assert child.completed_today == 1
assert child.remaining_today == 0
assert child.completion_pct == 100
view = child.instances[0]
assert view.state == "done"
assert view.completed_at is not None
assert view.was_on_time is not None
from custom_components.nestquest.completion import uncomplete_instance
await uncomplete_instance(
database, instance_id, actor_source="user", actor_user_id="admin-1"
)
snapshot = await coordinator._async_update_data()
child = snapshot.children[0]
assert child.completed_today == 0
assert child.remaining_today == 1
assert child.instances[0].state == "open"
async def test_coordinator_zero_quests_reports_full_percentage(
hass, make_entry
) -> None:
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
coordinator = entry.runtime_data.coordinator
await create_child(entry.runtime_data.database, "Ada")
snapshot = await coordinator._async_update_data()
child = snapshot.children[0]
assert child.due_today == 0
assert child.completion_pct == 100, "zero quests is a perfect day"
assert child.instances == ()
async def test_coordinator_cycle_day_from_first_scheduled_child(
hass, make_entry
) -> None:
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
coordinator = entry.runtime_data.coordinator
database = entry.runtime_data.database
await create_child(database, "Ada")
ada = (await list_children(database))[0]
today = datetime.date.today()
# A 2-week cycle anchored 3 days ago: today is day 4.
anchor = today - datetime.timedelta(days=3)
await PresenceSchedulesDao(database).upsert_by_child(
ada.id, 2, anchor.isoformat(), "0,1,2,3,4,5,6|"
)
snapshot = await coordinator._async_update_data()
assert snapshot.cycle_day == 4
async def test_coordinator_presence_resolves_schedule_and_override(
hass, make_entry
) -> None:
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
coordinator = entry.runtime_data.coordinator
database = entry.runtime_data.database
await create_child(database, "Ada")
ada = (await list_children(database))[0]
today = datetime.date.today()
# Scheduled absent today (empty weekday set for this cycle week).
await PresenceSchedulesDao(database).upsert_by_child(
ada.id, 1, today.isoformat(), ""
)
snapshot = await coordinator._async_update_data()
assert snapshot.children[0].present is False
# An override beats the pattern.
from custom_components.nestquest.dao_presence import (
PresenceOverridesDao,
)
await PresenceOverridesDao(database).create(
ada.id, today.isoformat(), today.isoformat(), True
)
snapshot = await coordinator._async_update_data()
assert snapshot.children[0].present is True
async def test_coordinator_derives_missed_for_past_due_open(
hass, make_entry
) -> None:
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
coordinator = entry.runtime_data.coordinator
snapshot = await coordinator._async_update_data()
# No children: empty but valid snapshot.
assert snapshot.children == ()
async def test_setup_creates_coordinator_with_configured_interval(
hass, make_entry
) -> None:
entry = wire_entry_to_registry(
make_entry(options={CONF_UPDATE_INTERVAL: 60}), hass.registry
)
assert await async_setup_entry(hass, entry) is True
coordinator = entry.runtime_data.coordinator
assert isinstance(coordinator, NestQuestCoordinator)
assert coordinator.update_interval == datetime.timedelta(seconds=60)
assert coordinator.data is not None, "first refresh ran at setup"
async def test_setup_defaults_to_five_minute_interval(
hass, make_entry
) -> None:
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
coordinator = entry.runtime_data.coordinator
assert coordinator.update_interval == datetime.timedelta(
seconds=DEFAULT_UPDATE_INTERVAL
)
async def test_unload_shuts_coordinator_down(hass, make_entry) -> None:
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
coordinator = entry.runtime_data.coordinator
torn_down = []
async def _shutdown():
torn_down.append(coordinator)
coordinator.async_shutdown = _shutdown
assert await async_unload_entry(hass, entry) is True
assert torn_down == [coordinator]
async def test_coordinator_notifies_listeners_on_refresh(
hass, make_entry
) -> None:
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
coordinator = entry.runtime_data.coordinator
called = []
coordinator.async_add_listener(lambda: called.append("entity"))
await coordinator.async_refresh()
assert called == ["entity"]
assert coordinator.last_update_success is True
async def test_failed_first_refresh_shuts_coordinator_down(
hass, make_entry, monkeypatch
) -> None:
"""A setup failure after coordinator creation must not leak its
listeners against the database the except path closes."""
from custom_components.nestquest import async_setup_entry
import custom_components.nestquest.coordinator as coordinator_module
shutdown_calls = []
async def _record_shutdown(self):
shutdown_calls.append(self.entry_id)
monkeypatch.setattr(
coordinator_module.NestQuestCoordinator,
"async_shutdown",
_record_shutdown,
)
async def _boom(self):
raise RuntimeError("refresh failed")
monkeypatch.setattr(
coordinator_module.NestQuestCoordinator,
"_async_update_data",
_boom,
)
entry = wire_entry_to_registry(make_entry(), hass.registry)
with pytest.raises(RuntimeError, match="refresh failed"):
await async_setup_entry(hass, entry)
assert shutdown_calls == [entry.entry_id]
assert getattr(entry, "runtime_data", None) is None