diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index b0edf5b..2cfea93 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -20,11 +20,14 @@ from .const import ( CONF_ADMIN_USER_IDS, CONF_DAY_ROLLOVER_TIME, CONF_HORIZON_DAYS, + CONF_UPDATE_INTERVAL, DEFAULT_DAY_ROLLOVER_TIME, DEFAULT_HORIZON_DAYS, + DEFAULT_UPDATE_INTERVAL, DOMAIN, LOGGER, ) +from .coordinator import NestQuestCoordinator from .db import NestQuestDatabase from .materialize import materialize as _materialize_run from .migrations import apply_migrations @@ -238,6 +241,7 @@ class NestQuestRuntimeData: database: NestQuestDatabase remove_update_listener: Callable[[], Any] remove_time_change_listener: Callable[[], Any] + coordinator: Any = None async def _async_owner_user_ids(hass: HomeAssistant) -> list[str]: @@ -493,6 +497,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, database, _configured_horizon_days(dict(entry.options)) ) _register_services(hass) + # The shared Feature 10 coordinator: one refresh cycle every + # entity reads from (CONF_UPDATE_INTERVAL seconds, default + # five minutes). Created AFTER services so a failure below + # unwinds them through the except path's listener handling. + coordinator = NestQuestCoordinator( + hass, + entry_id=entry.entry_id, + database=database, + update_interval_seconds=entry.options.get( + CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL + ), + ) + await coordinator.async_config_entry_first_refresh() except BaseException: # A failure anywhere after the database is open must not leak the # listeners already registered against that (now-closed) database: @@ -521,6 +538,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: database=database, remove_update_listener=remove_update_listener, remove_time_change_listener=remove_time_change_listener, + coordinator=coordinator, ) entry.runtime_data = runtime_data hass.data[DOMAIN][entry.entry_id] = runtime_data @@ -565,6 +583,13 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: database = getattr(runtime_data, "database", None) if database is not None: await database.close() + # Tear the shared coordinator down after the DB close: no + # listener may outlive the connection it reads. + coordinator = getattr(runtime_data, "coordinator", None) + if coordinator is not None: + shutdown = getattr(coordinator, "async_shutdown", None) + if shutdown is not None: + await shutdown() # Domain-global services are torn down only once the FINAL entry # unloads (``hass.data[DOMAIN]`` is now empty), never when a sibling # entry is still loaded. diff --git a/custom_components/nestquest/completion.py b/custom_components/nestquest/completion.py index 15aa11c..034741d 100644 --- a/custom_components/nestquest/completion.py +++ b/custom_components/nestquest/completion.py @@ -128,6 +128,21 @@ def _derived_state( ) +def derive_state( + instance: QuestInstanceRecord, + latest: CompletionEventRecord | None, + today: datetime.date, +) -> str: + """Public state derivation for the Feature 10 coordinator. + + Thin wrapper over :func:`_derived_state`: ``open``, ``done``, or + ``missed`` from the latest event plus the due date (``missed`` is + derived, never an event_type). ``today`` is the caller-resolved + HA-local date. + """ + return _derived_state(instance, latest, today) + + def _was_on_time( instance: QuestInstanceRecord, now: datetime.datetime | None, diff --git a/custom_components/nestquest/const.py b/custom_components/nestquest/const.py index 6238252..69b55ae 100644 --- a/custom_components/nestquest/const.py +++ b/custom_components/nestquest/const.py @@ -55,6 +55,13 @@ DEFAULT_PANEL_IDLE_TIMEOUT = 300 # list as a disaster-recovery copy so a lost database can be re-seeded. CONF_ADMIN_USER_IDS = "admin_user_ids" +# Shared coordinator refresh interval (Feature 10), in SECONDS. Every +# entity reads one snapshot per refresh; completions push an immediate +# refresh so this poll is only the fallback cadence. +CONF_UPDATE_INTERVAL = "update_interval" +DEFAULT_UPDATE_INTERVAL = 300 +MIN_UPDATE_INTERVAL = 30 + # Quest windows (D-008): a definition may declare several day windows; # each produces its own instance per day and groups the panel's Quest # Log into three columns. The clock ranges are stored here so they stay diff --git a/custom_components/nestquest/coordinator.py b/custom_components/nestquest/coordinator.py new file mode 100644 index 0000000..7d8e79b --- /dev/null +++ b/custom_components/nestquest/coordinator.py @@ -0,0 +1,254 @@ +"""The shared NestQuest DataUpdateCoordinator (Feature 10). + +ONE refresh pass builds the whole entity snapshot: active children, +today's instances with their derived states (open / done / missed via +:func:`~.completion.derive_state`), each child's presence today, and +per-child rollup counts. Every Feature 10 entity reads +``coordinator.data`` — no entity queries the database itself, so one +refresh updates the whole board coherently. + +"Today" and "now" are HA-local (``hass.config.time_zone``), never the +host clock and never UTC, matching the materialization and completion +layers. + +Presence is resolved through the SAME engine the materializer uses +(:class:`~.presence.PresenceEngine`): schedules and overrides are read +with the public DAO methods and the engine's anchor-date arithmetic +answers ``is this child at this house today`` (D-004 — never ISO week +parity). + +``cycle_day`` reports where today falls in the custody cycle of the +first active child (display order) that HAS a schedule — 1-based day +``N`` of ``cycle_length_weeks * 7``, computed from that schedule's +anchor date. ``0`` means no active child has a schedule at all, so +the admin header degrades to a harmless zero instead of guessing. +""" +from __future__ import annotations + +import datetime +from dataclasses import dataclass +from zoneinfo import ZoneInfo + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .completion import derive_state +from .const import ( + CONF_UPDATE_INTERVAL, + DEFAULT_UPDATE_INTERVAL, + DOMAIN, + LOGGER, + MIN_UPDATE_INTERVAL, +) +from .dao_children import ChildrenDao +from .dao_instances import ( + CompletionEventsDao, + QuestInstancesDao, +) +from .dao_rules import QuestDefinitionsDao +from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao +from .db import NestQuestDatabase +from .presence import ( + PresenceEngine, + PresenceOverride, + PresenceSchedule, +) + + +@dataclass(frozen=True) +class QuestInstanceView: + """One instance plus everything the entities render from it.""" + + instance_id: int + definition_id: int + child_id: int + title: str + icon: str | None + window: str + due_date: str + due_time: str | None + state: str # open | done | missed + overdue: bool + completed_at: str | None + was_on_time: bool | None + + +@dataclass(frozen=True) +class ChildDaySnapshot: + """One active child's day: instances, presence, and rollup counts.""" + + child_id: int + child_name: str + instances: tuple[QuestInstanceView, ...] + present: bool + due_today: int + completed_today: int + remaining_today: int + completion_pct: int + + +@dataclass(frozen=True) +class NestQuestSnapshot: + """The coordinator's whole-entity data payload.""" + + today_iso: str + children: tuple[ChildDaySnapshot, ...] + cycle_day: int + + +class NestQuestCoordinator(DataUpdateCoordinator): + """Shared refresh cycle for every NestQuest entity.""" + + def __init__( + self, + hass: HomeAssistant, + *, + entry_id: str, + database: NestQuestDatabase, + update_interval_seconds: int | None = None, + ) -> None: + interval = update_interval_seconds + if ( + not isinstance(interval, int) + or isinstance(interval, bool) + or interval < MIN_UPDATE_INTERVAL + ): + interval = DEFAULT_UPDATE_INTERVAL + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_interval=datetime.timedelta(seconds=interval), + ) + self.entry_id = entry_id + self.database = database + + def _local_now(self) -> datetime.datetime: + time_zone = ZoneInfo(self.hass.config.time_zone) + return datetime.datetime.now(time_zone) + + async def _async_update_data(self) -> NestQuestSnapshot: + now = self._local_now() + today = now.date() + today_iso = today.isoformat() + + children = await ChildrenDao(self.database).list_active() + + schedules: dict[int, PresenceSchedule] = {} + overrides: dict[int, list[PresenceOverride]] = {} + for child in children: + schedule = await PresenceSchedulesDao(self.database).get_by_child( + child.id + ) + if schedule is not None: + schedules[child.id] = PresenceSchedule.decode( + child.id, schedule.anchor_date, schedule.pattern + ) + todays = await PresenceOverridesDao( + self.database + ).list_by_child_and_range(child.id, today_iso, today_iso) + if todays: + # The engine validates MODEL objects, not DAO records; + # each record converts losslessly (the schema guarantees + # end >= start and a real is_present). + overrides[child.id] = [ + PresenceOverride( + record.child_id, + record.start_date, + record.end_date, + record.is_present, + record.note, + ) + for record in todays + ] + engine = PresenceEngine(schedules, overrides) + + instances_dao = QuestInstancesDao(self.database) + events_dao = CompletionEventsDao(self.database) + definitions_dao = QuestDefinitionsDao(self.database) + + snapshots: list[ChildDaySnapshot] = [] + for child in children: + instances = await instances_dao.list_by_date_range( + child.id, today_iso, today_iso + ) + views: list[QuestInstanceView] = [] + for instance in instances: + latest = await events_dao.get_latest_for_instance(instance.id) + definition = await definitions_dao.get(instance.definition_id) + state = derive_state(instance, latest, today) + overdue = False + if state == "open" and instance.due_date == today_iso: + if instance.due_time is not None: + due_moment = datetime.datetime.strptime( + instance.due_time, "%H:%M" + ).time() + overdue = now.time() > due_moment + views.append( + QuestInstanceView( + instance_id=instance.id, + definition_id=instance.definition_id, + child_id=instance.child_id, + title=definition.title if definition else "Quest", + icon=definition.icon if definition else None, + window=instance.window, + due_date=instance.due_date, + due_time=instance.due_time, + state=state, + overdue=overdue, + completed_at=( + latest.occurred_at + if latest is not None + and latest.event_type == "completed" + else None + ), + was_on_time=( + latest.was_on_time + if latest is not None + and latest.event_type == "completed" + else None + ), + ) + ) + due = len(views) + completed = sum(1 for view in views if view.state == "done") + remaining = due - completed + snapshots.append( + ChildDaySnapshot( + child_id=child.id, + child_name=child.display_name, + instances=tuple(views), + present=engine.is_present(child.id, today), + due_today=due, + completed_today=completed, + remaining_today=remaining, + completion_pct=( + round(completed * 100 / due) if due else 100 + ), + ) + ) + + return NestQuestSnapshot( + today_iso=today_iso, + children=tuple(snapshots), + cycle_day=_cycle_day(schedules, children, today), + ) + + +def _cycle_day( + schedules: dict[int, PresenceSchedule], children, today: datetime.date +) -> int: + """Return today's 1-based day in the first scheduled child's cycle. + + Anchor-date arithmetic ONLY (D-004): the day offset is computed from + the schedule's own anchor, never ISO week numbers or parity, so a + 53-week year cannot silently invert the cycle. + """ + for child in children: + schedule = schedules.get(child.id) + if schedule is None: + continue + days = (today - schedule.anchor_date).days + cycle_length = schedule.cycle_length_weeks * 7 + return (days % cycle_length) + 1 + return 0 diff --git a/custom_components/nestquest/options_flow.py b/custom_components/nestquest/options_flow.py index 84ac218..32ddd3b 100644 --- a/custom_components/nestquest/options_flow.py +++ b/custom_components/nestquest/options_flow.py @@ -14,10 +14,13 @@ from .const import ( CONF_DAY_ROLLOVER_TIME, CONF_HORIZON_DAYS, CONF_PANEL_IDLE_TIMEOUT, + CONF_UPDATE_INTERVAL, DEFAULT_DAY_ROLLOVER_TIME, DEFAULT_HORIZON_DAYS, DEFAULT_PANEL_IDLE_TIMEOUT, + DEFAULT_UPDATE_INTERVAL, DOMAIN, + MIN_UPDATE_INTERVAL, ) from .admin_allowlist import list_admin_ids, set_admin_ids @@ -56,6 +59,18 @@ def _async_validate(user_input: dict[str, Any]) -> dict[str, str]: ): errors[CONF_PANEL_IDLE_TIMEOUT] = "invalid" + # The coordinator refresh interval (Feature 10) is OPTIONAL in raw + # submits: a missing key keeps the current value, so pre-Feature-10 + # form payloads (and API callers) are unaffected. Schema-validated + # submits always carry it. + update_interval = user_input.get(CONF_UPDATE_INTERVAL) + if update_interval is not None and ( + isinstance(update_interval, bool) + or not isinstance(update_interval, int) + or update_interval < MIN_UPDATE_INTERVAL + ): + errors[CONF_UPDATE_INTERVAL] = "invalid" + return errors @@ -82,6 +97,9 @@ def _build_schema( vol.Required( CONF_PANEL_IDLE_TIMEOUT, default=current[CONF_PANEL_IDLE_TIMEOUT] ): int, + vol.Required( + CONF_UPDATE_INTERVAL, default=current[CONF_UPDATE_INTERVAL] + ): int, } if admin_choices is not None: # HA's supported multi-select validator: the frontend can @@ -210,6 +228,10 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry): CONF_HORIZON_DAYS: user_input[CONF_HORIZON_DAYS], CONF_DAY_ROLLOVER_TIME: user_input[CONF_DAY_ROLLOVER_TIME], CONF_PANEL_IDLE_TIMEOUT: user_input[CONF_PANEL_IDLE_TIMEOUT], + CONF_UPDATE_INTERVAL: user_input.get( + CONF_UPDATE_INTERVAL, + self._current_values()[CONF_UPDATE_INTERVAL], + ), } if admin_ids_present: data[CONF_ADMIN_USER_IDS] = admin_ids @@ -231,6 +253,7 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry): (CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS), (CONF_DAY_ROLLOVER_TIME, DEFAULT_DAY_ROLLOVER_TIME), (CONF_PANEL_IDLE_TIMEOUT, DEFAULT_PANEL_IDLE_TIMEOUT), + (CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL), ): if key in options: values[key] = options[key] diff --git a/tests/conftest.py b/tests/conftest.py index f9e31bc..cbc5c24 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -45,6 +45,7 @@ _HA_MODULES = ( "homeassistant.helpers", "homeassistant.helpers.config_validation", "homeassistant.helpers.event", + "homeassistant.helpers.update_coordinator", ) def _find_spec(name: str): @@ -85,6 +86,7 @@ for _parent, _child in ( ("homeassistant", "helpers"), ("homeassistant.helpers", "config_validation"), ("homeassistant.helpers", "event"), + ("homeassistant.helpers", "update_coordinator"), ): setattr(sys.modules[_parent], _child, sys.modules[f"{_parent}.{_child}"]) @@ -197,6 +199,66 @@ _exceptions_mock.HomeAssistantError = HomeAssistantError _exceptions_mock.ConfigEntryNotReady = ConfigEntryNotReady _exceptions_mock.Unauthorized = Unauthorized + +class DataUpdateCoordinator: + """Functional stand-in for HA's DataUpdateCoordinator. + + Mirrors the surface NestQuest's coordinator subclass uses: + ``__init__(hass, logger, name=..., update_interval=...)``, + ``async_add_listener``/``async_update_listeners`` for entity + subscription, ``async_refresh``/``async_config_entry_first_refresh`` + driving the subclass's ``_async_update_data``, ``last_update_success``, + and ``data``. Interval scheduling is NOT simulated (no real clock + ticks in tests) — tests drive refreshes directly. + """ + + def __init__(self, hass, logger, *, name=None, update_interval=None): + self.hass = hass + self.logger = logger + self.name = name + self.update_interval = update_interval + self.data = None + self.last_update_success = False + self._listeners = [] + + def async_add_listener(self, update_callback, context=None): + """Subscribe; returns an unsubscribe callable like HA's.""" + entry = (update_callback, context) + self._listeners.append(entry) + + def _remove(): + if entry in self._listeners: + self._listeners.remove(entry) + + return _remove + + def async_update_listeners(self): + """Notify every listener (entities schedule their writes).""" + for callback, _context in list(self._listeners): + callback() + + async def async_refresh(self): + """Run one update pass and notify listeners.""" + try: + self.data = await self._async_update_data() + self.last_update_success = True + except BaseException: + self.last_update_success = False + raise + self.async_update_listeners() + + async def async_config_entry_first_refresh(self): + """First refresh at config-entry setup (alias of refresh).""" + await self.async_refresh() + + async def async_shutdown(self): + """Release listeners on teardown.""" + self._listeners.clear() + + +_update_coordinator_mock = _ha_mock("homeassistant.helpers.update_coordinator") +_update_coordinator_mock.DataUpdateCoordinator = DataUpdateCoordinator + import voluptuous as vol diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py new file mode 100644 index 0000000..414273a --- /dev/null +++ b/tests/test_coordinator.py @@ -0,0 +1,244 @@ +"""Tests for the shared NestQuest coordinator (Feature 10, task 1).""" +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.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 diff --git a/tests/test_options_flow.py b/tests/test_options_flow.py index bef04c8..af949b6 100644 --- a/tests/test_options_flow.py +++ b/tests/test_options_flow.py @@ -18,6 +18,8 @@ from custom_components.nestquest.const import ( CONF_DAY_ROLLOVER_TIME, CONF_HORIZON_DAYS, CONF_PANEL_IDLE_TIMEOUT, + CONF_UPDATE_INTERVAL, + DEFAULT_UPDATE_INTERVAL, DEFAULT_DAY_ROLLOVER_TIME, DEFAULT_HORIZON_DAYS, DEFAULT_PANEL_IDLE_TIMEOUT, @@ -129,7 +131,10 @@ def test_options_flow_submits_through_2024_6_surface() -> None: result = _run(handler.async_step_init(dict(VALID_INPUT))) assert result["type"] == "create_entry" assert result["title"] == "NestQuest" - assert result["data"] == VALID_INPUT + assert result["data"] == { + **VALID_INPUT, + CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL, + } def test_options_flow_shows_form_initially() -> None: @@ -208,6 +213,7 @@ def test_options_flow_schema_applies_stored_defaults() -> None: CONF_HORIZON_DAYS: 21, CONF_DAY_ROLLOVER_TIME: "05:45", CONF_PANEL_IDLE_TIMEOUT: 120, + CONF_UPDATE_INTERVAL: 90, } ) result = _run(_make_flow(entry).async_step_init(None)) @@ -215,6 +221,7 @@ def test_options_flow_schema_applies_stored_defaults() -> None: CONF_HORIZON_DAYS: 21, CONF_DAY_ROLLOVER_TIME: "05:45", CONF_PANEL_IDLE_TIMEOUT: 120, + CONF_UPDATE_INTERVAL: 90, CONF_ADMIN_USER_IDS: [], } @@ -282,8 +289,8 @@ def test_options_flow_rejects_invalid_idle_timeout(bad_timeout) -> None: assert result["errors"] == {CONF_PANEL_IDLE_TIMEOUT: "invalid"} -def test_options_flow_creates_entry_with_three_keys() -> None: - """A valid submit creates an entry with exactly the three validated keys.""" +def test_options_flow_creates_entry_with_four_keys() -> None: + """A valid submit creates an entry with exactly the four validated keys.""" flow = _make_flow(_make_entry()) result = _run(flow.async_step_init(dict(VALID_INPUT))) assert result["type"] == "create_entry" @@ -292,8 +299,12 @@ def test_options_flow_creates_entry_with_three_keys() -> None: CONF_HORIZON_DAYS, CONF_DAY_ROLLOVER_TIME, CONF_PANEL_IDLE_TIMEOUT, + CONF_UPDATE_INTERVAL, + } + assert result["data"] == { + **VALID_INPUT, + CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL, } - assert result["data"] == VALID_INPUT def test_options_flow_create_entry_data_lands_in_entry_options_and_reloads_once() -> None: @@ -316,7 +327,10 @@ def test_options_flow_create_entry_data_lands_in_entry_options_and_reloads_once( _run(registry.dispatch_options_update(entry)) assert hass.config_entries.async_reload.call_count == 1 assert registry.reloaded == [entry.entry_id] - assert entry.options == VALID_INPUT + assert entry.options == { + **VALID_INPUT, + CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL, + } def test_options_flow_validation_never_raises() -> None: