diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index 52a5eeb..6615679 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -23,6 +23,7 @@ from .const import ( DEFAULT_HORIZON_DAYS, DOMAIN, LOGGER, + SERVICE_REGENERATE, ) from .db import NestQuestDatabase from .materialize import materialize as _materialize_run @@ -272,6 +273,29 @@ def _day_rollover_hour_minute(options: dict[str, Any]) -> tuple[int, int]: return int(hour), int(minute) +async def _run_horizon_materialization( + hass: HomeAssistant, database: NestQuestDatabase +) -> None: + """Materialize the rolling horizon ``[today, today + DEFAULT_HORIZON_DAYS]``. + + "today" is computed in HA local time (``hass.config.time_zone``) rather + than the system clock, so the horizon tracks the household's own day. + The walk itself runs through :func:`~.materialize.materialize`, whose DB + access is executor-only. + + This is the ONE generation path: the startup backfill, the daily rollover + listener and the ``regenerate`` service all funnel through it, so there is + no duplicated materialization logic. + """ + time_zone = ZoneInfo(hass.config.time_zone) + today = datetime.datetime.now(time_zone).date() + start_date = today.isoformat() + end_date = ( + today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) + ).isoformat() + await _materialize_run(database, start_date, end_date, today=today) + + def _register_day_rollover_listener( hass: HomeAssistant, database: NestQuestDatabase, @@ -280,28 +304,69 @@ def _register_day_rollover_listener( """Register the daily materialization listener and return its remover. Fires once per day at the configured local ``day_rollover_time`` and - materializes the rolling horizon ``[today, today + DEFAULT_HORIZON_DAYS]``. - "today" is computed in HA local time (``hass.config.time_zone``) rather - than the system clock, so the horizon tracks the household's own day. - The walk itself runs through :func:`~.materialize.materialize`, whose DB - access is executor-only. + materializes the rolling horizon ``[today, today + DEFAULT_HORIZON_DAYS]`` + through the shared :func:`_run_horizon_materialization` path. """ hour, minute = _day_rollover_hour_minute(options) async def _run_materialization(_now: datetime.datetime) -> None: - time_zone = ZoneInfo(hass.config.time_zone) - today = datetime.datetime.now(time_zone).date() - start_date = today.isoformat() - end_date = ( - today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) - ).isoformat() - await _materialize_run(database, start_date, end_date) + await _run_horizon_materialization(hass, database) return async_track_time_change( hass, _run_materialization, hour=hour, minute=minute ) +def _find_live_database(hass: HomeAssistant) -> NestQuestDatabase | None: + """Return a connected database from the active runtime data, or None. + + The domain-global services (e.g. ``regenerate``) have no database of + their own; they resolve whichever config entry's database is currently + live at call time, so none of them can hold a stale handle to a closed + connection after another entry unloads. + """ + for runtime_data in hass.data.get(DOMAIN, {}).values(): + database = getattr(runtime_data, "database", None) + if database is not None and database.connected: + return database + return None + + +def _register_regenerate_service(hass: HomeAssistant) -> None: + """Register ``nestquest.regenerate`` once at domain scope. + + The service triggers a full on-demand regeneration over the rolling + horizon, running the SAME idempotent materialize path + (:func:`_run_horizon_materialization`) as the startup backfill and the + daily rollover listener. It is DOMAIN-global — registered exactly once + (guarded by ``has_service``) and NOT bound to any config entry — so the + handler resolves the currently-live database at call time and a name + collision or duplicate setup is impossible. Removal is handled by the + unload path only once the LAST entry is removed (see + :func:`_deregister_regenerate_service`). + """ + if hass.services.has_service(DOMAIN, SERVICE_REGENERATE): + return + + async def _regenerate(_call: Any) -> None: + database = _find_live_database(hass) + if database is None: + _LOGGER.warning( + "NestQuest regenerate requested but no config entry has a " + "live database; ignoring" + ) + return + await _run_horizon_materialization(hass, database) + + hass.services.async_register(DOMAIN, SERVICE_REGENERATE, _regenerate) + + +def _deregister_regenerate_service(hass: HomeAssistant) -> None: + """Remove ``nestquest.regenerate`` when the last entry unloads.""" + if hass.services.has_service(DOMAIN, SERVICE_REGENERATE): + hass.services.async_remove(DOMAIN, SERVICE_REGENERATE) + + async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool: """Set up the NestQuest integration. YAML configuration is not used, returns True.""" return True @@ -359,6 +424,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await listener_hass.config_entries.async_reload(listener_entry.entry_id) database = await _async_open_database(hass, await async_get_db_path(hass)) + remove_update_listener: Callable[[], Any] | None = None + remove_time_change_listener: Callable[[], Any] | None = None try: # Seed the admin allowlist on first setup so the owner is never # locked out: an empty database takes the persisted admin copy @@ -387,9 +454,34 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: remove_time_change_listener = _register_day_rollover_listener( hass, database, dict(entry.options) ) + # Backfill any days the daily listener missed while the integration + # was off (or freshly installed): run the one idempotent walk over + # [today, today + DEFAULT_HORIZON_DAYS] once at setup. This runs + # through the SAME path as the daily listener and regenerate service. + await _run_horizon_materialization(hass, database) + _register_regenerate_service(hass) except BaseException: + # A failure anywhere after the database is open must not leak the + # listeners already registered against that (now-closed) database: + # unwind EVERY remover we acquired before the failure, keeping the + # original error as the one raised. Removal is best-effort — a + # failing remover must not mask the setup error that triggered it. + for remove_listener in ( + remove_time_change_listener, + remove_update_listener, + ): + if remove_listener is None: + continue + try: + result = remove_listener() + if inspect.isawaitable(result): + await result + except BaseException: + pass await database.close() raise + assert remove_update_listener is not None + assert remove_time_change_listener is not None runtime_data = NestQuestRuntimeData( entry_id=entry.entry_id, options=dict(entry.options), @@ -406,16 +498,17 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" domain_data = hass.data.setdefault(DOMAIN, {}) runtime_data = domain_data.pop(entry.entry_id, None) - if not domain_data: + is_last = not domain_data + if is_last: hass.data.pop(DOMAIN, None) if runtime_data is None: runtime_data = getattr(entry, "runtime_data", None) + unload_error: BaseException | None = None if runtime_data is not None: remove_update_listener = getattr(runtime_data, "remove_update_listener", None) remove_time_change_listener = getattr( runtime_data, "remove_time_change_listener", None ) - unload_error: BaseException | None = None try: for remove_listener in ( remove_update_listener, @@ -439,10 +532,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() - if unload_error is not None: - if getattr(entry, "runtime_data", None) is not None: - entry.runtime_data = None - raise unload_error + # The ``regenerate`` service is DOMAIN-global: it is torn down only + # once the FINAL entry unloads (``hass.data[DOMAIN]`` is now empty), + # never when a sibling entry is still loaded. + if is_last: + _deregister_regenerate_service(hass) if getattr(entry, "runtime_data", None) is not None: entry.runtime_data = None + if unload_error is not None: + raise unload_error return True diff --git a/custom_components/nestquest/const.py b/custom_components/nestquest/const.py index 8167e53..ce21568 100644 --- a/custom_components/nestquest/const.py +++ b/custom_components/nestquest/const.py @@ -9,6 +9,9 @@ LOGGER = logging.getLogger(LOGGER_NAME) PLATFORMS: list[str] = [] +#: Service name for the on-demand regeneration service (``nestquest.regenerate``). +SERVICE_REGENERATE = "regenerate" + SQLITE_DB_FILENAME = "nestquest.db" # Configuration keys and default values diff --git a/custom_components/nestquest/dao_instances.py b/custom_components/nestquest/dao_instances.py index c948aa8..94f05b9 100644 --- a/custom_components/nestquest/dao_instances.py +++ b/custom_components/nestquest/dao_instances.py @@ -59,6 +59,34 @@ def _today() -> datetime.date: return datetime.date.today() +def _resolve_today(today: datetime.date | None) -> datetime.date: + """Return the caller-resolved "today", or the host date when absent. + + The no-past guard and the delete cutoffs must compare due dates + against the SAME calendar date the materialization horizon was built + from. Home Assistant's configured time zone can lag the host clock + around midnight, so when the caller has already resolved the local + date (``today``) that anchor is authoritative; ``None`` falls back to + the host clock (:func:`_today`), which is exactly the previous + behavior for callers with no HA-local date. + """ + return _today() if today is None else today + + +def _reject_past(due_date: str, today_iso: str) -> None: + """Raise unless ``due_date`` is on or after ``today_iso``. + + The single no-past rule shared by :meth:`QuestInstancesDao.upsert` + and :meth:`QuestInstancesDao.upsert_if_valid`: instances are never + generated in the past, measured against the resolved "today". + """ + if due_date < today_iso: + raise ValueError( + f"instances are never generated in the past: due_date " + f"{due_date!r} is before today {today_iso!r}" + ) + + def _validate_utc_timestamp(value: str, field: str) -> None: """Raise ValueError unless ``value`` is a strict UTC ISO-8601 stamp. @@ -178,6 +206,7 @@ class QuestInstancesDao: *, window: str, due_time: str | None = None, + today: datetime.date | None = None, ) -> QuestInstanceRecord: """Create the dated instance for the widened key idempotently. @@ -196,6 +225,10 @@ class QuestInstancesDao: - ``due_date`` must be TODAY or later: instances are never generated in the past (Feature 07). The caller's materialization horizon is future-dated by definition. + ``today`` optionally pins the resolved HA-local date the + guard compares against (falling back to the host clock when + omitted) so a household time zone behind the host around + midnight is not misread as "past". - ``window`` must be one of ``const.QUEST_WINDOWS``. - An existing instance WITH a completion event is IMMUTABLE: the conflict path must not rewrite its snapshot columns @@ -215,24 +248,20 @@ class QuestInstancesDao: # check runs after the lock is acquired, because a caller can # wait on the lock across midnight — a date that was "today" # at call time may be "yesterday" by the time the INSERT runs. - today = _today().isoformat() - if due_date < today: - raise ValueError( - f"instances are never generated in the past: due_date " - f"{due_date!r} is before today {today!r}" - ) + # A caller-supplied ``today`` (HA-local) is used for BOTH + # checks and never re-read, since it is already the anchor the + # batch runs against. + today_date = _resolve_today(today) + _reject_past(due_date, today_date.isoformat()) async with _connection_lock(self._database): async with self._database.transaction(): # Re-read "today" under the lock: this is the date the # insert actually executes on, so the no-past rule - # holds across midnight rollovers. - today = _today().isoformat() - if due_date < today: - raise ValueError( - f"instances are never generated in the past: " - f"due_date {due_date!r} is before today " - f"{today!r}" - ) + # holds across midnight rollovers. A pinned HA-local + # anchor is exempt from the re-read (see above). + if today is None: + today_date = _today() + _reject_past(due_date, today_date.isoformat()) definition = await self._database.fetch_one( "SELECT 1 FROM quest_definitions WHERE id = ?", (definition_id,), @@ -298,6 +327,7 @@ class QuestInstancesDao: *, window: str, due_time: str | None = None, + today: datetime.date | None = None, ) -> QuestInstanceRecord | None: """Insert one dated instance iff its tuple is still materializable. @@ -327,7 +357,8 @@ class QuestInstancesDao: failed — the tuple no longer materializable, which the walk SKIPS rather than raising, so the rest of the batch is unaffected. The no-past rule (instances are never generated in the past) is - preserved from :meth:`upsert` and still raises ValueError. An + preserved from :meth:`upsert` and still raises ValueError; ``today`` + optionally pins the HA-local anchor it compares against. An instance that ALREADY has a completion event is also skipped (returns ``None``) rather than raised: it is immutable, so the walk leaves it untouched instead of failing the batch — the @@ -338,20 +369,13 @@ class QuestInstancesDao: _validate_window(window) # Fail fast on obviously-past dates before queuing on the lock; # the authoritative check re-runs under the lock (see upsert). - today = _today().isoformat() - if due_date < today: - raise ValueError( - f"instances are never generated in the past: due_date " - f"{due_date!r} is before today {today!r}" - ) + today_date = _resolve_today(today) + _reject_past(due_date, today_date.isoformat()) async with _connection_lock(self._database): async with self._database.transaction(): - today = _today().isoformat() - if due_date < today: - raise ValueError( - f"instances are never generated in the past: " - f"due_date {due_date!r} is before today {today!r}" - ) + if today is None: + today_date = _today() + _reject_past(due_date, today_date.isoformat()) still_valid = await self._database.fetch_one( "SELECT 1 FROM quest_definitions d " "JOIN quest_definition_assignees a " @@ -463,7 +487,7 @@ class QuestInstancesDao: return [_instance_from_row(row) for row in rows] async def delete_future_uncompleted( - self, definition_id: int, cutoff_date: str + self, definition_id: int, cutoff_date: str, *, today: datetime.date | None = None ) -> int: """Delete the definition's open instances at/after cutoff. @@ -475,9 +499,11 @@ class QuestInstancesDao: is measured against TODAY (materialization regenerates the rolling horizon from now), so a caller cannot use this to rewrite past open instances either — the day-rollover missed - sweep (Feature 11) handles past-due instances instead. Any - instance with a completion event is never touched, regardless - of its date. Returns the number deleted. + sweep (Feature 11) handles past-due instances instead. ``today`` + optionally pins the HA-local anchor the clamp compares against + (falling back to the host clock when omitted). Any instance with + a completion event is never touched, regardless of its date. + Returns the number deleted. """ _validate_date(cutoff_date, "cutoff_date") async with _connection_lock(self._database): @@ -487,8 +513,8 @@ class QuestInstancesDao: # midnight cannot delete instances that became past # while it waited (backdating is clamped to the # execution date, not the call date). - today = _today().isoformat() - effective_cutoff = max(cutoff_date, today) + today_date = _resolve_today(today) + effective_cutoff = max(cutoff_date, today_date.isoformat()) result = await self._database.execute( "DELETE FROM quest_instances WHERE definition_id = ? " "AND due_date >= ? AND NOT EXISTS (" @@ -500,7 +526,7 @@ class QuestInstancesDao: return result.rowcount async def delete_future_uncompleted_for_child( - self, child_id: int, cutoff_date: str + self, child_id: int, cutoff_date: str, *, today: datetime.date | None = None ) -> tuple[int, str]: """Delete the child's open instances at/after cutoff, across every definition, returning ``(deleted, effective_cutoff)``. @@ -511,8 +537,10 @@ class QuestInstancesDao: one transaction — an instance that gains a completion event mid-flight cannot be deleted. The cutoff cannot be backdated below today, for the same reason as the definition-scoped method (see its - docstring). Any instance with a completion event is never touched, - regardless of its date. + docstring). ``today`` optionally pins the HA-local anchor the clamp + compares against (falling back to the host clock when omitted). + Any instance with a completion event is never touched, regardless + of its date. Returns the number deleted and the effective cutoff that was actually applied (``max(cutoff_date, today)``, read under the @@ -530,8 +558,8 @@ class QuestInstancesDao: # definition-scoped method: the DELETE executes on this # date, so a caller queued across midnight cannot delete # instances that became past while it waited. - today = _today().isoformat() - effective_cutoff = max(cutoff_date, today) + today_date = _resolve_today(today) + effective_cutoff = max(cutoff_date, today_date.isoformat()) result = await self._database.execute( "DELETE FROM quest_instances WHERE child_id = ? " "AND due_date >= ? AND NOT EXISTS (" diff --git a/custom_components/nestquest/materialize.py b/custom_components/nestquest/materialize.py index 342e626..3f22e07 100644 --- a/custom_components/nestquest/materialize.py +++ b/custom_components/nestquest/materialize.py @@ -64,7 +64,7 @@ from __future__ import annotations import datetime from .const import DEFAULT_HORIZON_DAYS -from .dao_instances import QuestInstancesDao, _today +from .dao_instances import QuestInstancesDao, _resolve_today from .dao_rules import ( _validate_date, load_materialization_input, @@ -127,6 +127,7 @@ async def materialize( end_date: str, *, child_ids: list[int] | None = None, + today: datetime.date | None = None, ) -> int: """Generate quest instances for the closed range [start_date, end_date]. @@ -142,6 +143,14 @@ async def materialize( after a presence change) cannot touch any unrelated child's rows. When omitted (or None) the walk covers every assignee as before. + ``today`` optionally pins the resolved HA-local date the walk's + no-past guard compares against; it is threaded into every + :meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid` write. When + omitted the host clock (``datetime.date.today``) is used, which is the + historical default. A caller that computed the horizon in Home + Assistant's configured time zone MUST pass that same date here so a + time zone behind the host around midnight is not rejected as "past". + All inputs are read in ONE locked transaction; each tuple's write then goes through the atomic :meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid`, which re-validates the tuple and its current ``due_time`` in the same @@ -195,6 +204,7 @@ async def materialize( generated_at, window=window.window, due_time=window.due_time, + today=today, ) if written is not None: count += 1 @@ -205,6 +215,8 @@ async def materialize( async def regenerate_for_definition( database: NestQuestDatabase, definition_id: int, + *, + today: datetime.date | None = None, ) -> int: """Regenerate a definition's future instances after a config change. @@ -221,17 +233,24 @@ async def regenerate_for_definition( :func:`~.dao_instances._today` — so the delete cutoff and the re-materialization range share one anchor, and the walk's ``upsert_if_valid`` no-past guard never rejects the regenerated - range. Returns the number of instances the re-materialization + range. ``today`` optionally pins a caller-resolved HA-local date + (threaded into both the delete cutoff and the re-materialization) + so a household time zone behind the host around midnight stays + consistent. Returns the number of instances the re-materialization upserted across the whole snapshot (idempotent — other active definitions' tuples refresh in place, never duplicate). """ - today = _today() - start_date = today.isoformat() - end_date = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat() + today_date = _resolve_today(today) + start_date = today_date.isoformat() + end_date = ( + today_date + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) + ).isoformat() instances = QuestInstancesDao(database) - await instances.delete_future_uncompleted(definition_id, start_date) - return await materialize(database, start_date, end_date) + await instances.delete_future_uncompleted( + definition_id, start_date, today=today + ) + return await materialize(database, start_date, end_date, today=today) # HOOK (Feature 09 presence services): ``regenerate_for_child`` is the @@ -250,6 +269,8 @@ async def regenerate_for_definition( async def regenerate_for_child( database: NestQuestDatabase, child_id: int, + *, + today: datetime.date | None = None, ) -> int: """Regenerate a child's future instances after a presence change. @@ -277,13 +298,16 @@ async def regenerate_for_child( :meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted_for_child`), so the delete cutoff and the re-materialization window share ONE execution-day anchor even when the call is queued across midnight. - Returns the number of instances the re-materialization upserted for - the child across the whole snapshot. + ``today`` optionally pins a caller-resolved HA-local date, threaded + into the delete cutoff and the re-materialization for the same + host-vs-HA time-zone consistency. Returns the number of instances + the re-materialization upserted for the child across the whole + snapshot. """ if isinstance(child_id, bool) or not isinstance(child_id, int): raise ValueError(f"child_id must be an integer, got {child_id!r}") - today = _today() + today_date = _resolve_today(today) instances = QuestInstancesDao(database) # Use the delete's returned effective cutoff as the materialize anchor, @@ -293,10 +317,10 @@ async def regenerate_for_child( # walk's no-past guard and leave the child's future instances deleted # but not rebuilt. One anchor for both keeps them consistent. _, effective_cutoff = await instances.delete_future_uncompleted_for_child( - child_id, today.isoformat() + child_id, today_date.isoformat(), today=today ) start = datetime.date.fromisoformat(effective_cutoff) end_date = (start + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat() return await materialize( - database, start.isoformat(), end_date, child_ids=[child_id] + database, start.isoformat(), end_date, child_ids=[child_id], today=today ) diff --git a/custom_components/nestquest/services.yaml b/custom_components/nestquest/services.yaml new file mode 100644 index 0000000..eeae053 --- /dev/null +++ b/custom_components/nestquest/services.yaml @@ -0,0 +1,12 @@ +# Service declarations for the NestQuest integration (Home Assistant +# services.yaml metadata). Services are domain-scoped: nested under the +# "nestquest" domain, not repeated per config entry. + +regenerate: + name: Regenerate quest instances + description: >- + Regenerate every future quest instance for the household over the rolling + horizon, using the same idempotent materialization as the daily rollover + and the startup backfill. Safe to call at any time: a re-run refreshes the + same rows instead of duplicating them, and completed instances are never + touched. \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index ff8c5d6..b6603e8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -307,6 +307,50 @@ def _async_track_time_change(hass, action, hour=None, minute=None, second=None): _ha_mock("homeassistant.helpers.event").async_track_time_change = _async_track_time_change +class ServiceRegistry: + """Models HA's ``hass.services`` service registry. + + ``async_register`` stores a handler under its (domain, service) key, + ``has_service`` reports registration, and ``async_remove`` removes it + — exactly the surface Home Assistant's ``ServiceRegistry`` exposes + (there is deliberately NO ``async_unregister`` alias here, so a caller + that uses the wrong method name fails instead of being silently + papered over). ``call`` invokes a stored handler the way HA's service + bus would — awaiting a coroutine result — so tests can exercise a + registered service end-to-end. + """ + + def __init__(self, hass=None): + self._hass = hass + self._services: dict[tuple[str, str], object] = {} + + def async_register(self, domain, service, func, schema=None, supports_response=None): + self._services[(domain, service)] = func + return None + + def has_service(self, domain, service): + return (domain, service) in self._services + + def async_remove(self, domain, service): + self._services.pop((domain, service), None) + return None + + async def call(self, domain, service, data=None): + func = self._services.get((domain, service)) + if func is None: + raise KeyError(f"service {domain}.{service} not registered") + call = SimpleNamespace(domain=domain, service=service, data=data or {}) + result = func(call) + if inspect.isawaitable(result): + await result + return result + + @property + def registered_services(self): + """The registered (domain, service) pairs.""" + return list(self._services) + + def make_config_entry( entry_id: str = "test_entry", options: dict | None = None, @@ -369,6 +413,7 @@ def make_hass() -> tuple: hass.async_add_executor_job = _run_executor_job registry = ListenerRegistry(hass) hass.time_change = TimeChangeRegistry(hass) + hass.services = ServiceRegistry(hass) # A valid IANA time zone (the day-rollover listener reads it to # compute "today" in HA local time). hass.config.time_zone = "UTC" diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index f56864f..68be6ce 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -11,6 +11,7 @@ from custom_components.nestquest import DOMAIN, async_setup_entry, async_unload_ from custom_components.nestquest.const import ( CONF_DAY_ROLLOVER_TIME, DEFAULT_HORIZON_DAYS, + SERVICE_REGENERATE, DOMAIN as DOMAIN_CONST, ) @@ -345,7 +346,9 @@ async def test_day_rollover_listener_materializes_ha_local_horizon( await hass.time_change.fire() - fake.assert_awaited_once_with(database, expected_start, expected_end) + fake.assert_awaited_once_with( + database, expected_start, expected_end, today=today + ) def test_day_rollover_time_change_tracker_rejects_local_kwarg() -> None: @@ -381,4 +384,173 @@ async def test_setup_applies_all_v1_tables(hass, make_entry) -> None: "quest_definitions", "schedule_rules", ] - await async_unload_entry(hass, entry) \ No newline at end of file + await async_unload_entry(hass, entry) + + +async def _seed_daily_child_and_definition(database, today) -> int: + """Create one always-present child + a daily definition starting today. + + Returns the child's id. No presence schedule means the child is present + every day, so the daily rule yields exactly one instance per horizon day. + """ + from custom_components.nestquest.dao_children import ChildrenDao + from custom_components.nestquest.quest_definitions import ( + create_quest_definition, + ) + from custom_components.nestquest.recurrence import RuleType, ScheduleRule + + NOW = "2026-09-14T12:00:00+00:00" + child = await ChildrenDao(database).create("Ada", NOW) + await create_quest_definition( + database, + "Daily chore", + ScheduleRule(rule_type=RuleType.DAILY, start_date=today.isoformat()), + [child.id], + ["morning"], + ) + return child.id + + +def _local_today(hass) -> "datetime.date": + import datetime + from zoneinfo import ZoneInfo + + return datetime.datetime.now(ZoneInfo(hass.config.time_zone)).date() + + +async def test_setup_backfills_instances_over_horizon(hass, make_entry) -> None: + """Setup materializes once so a restart backfills any missed days.""" + import datetime + + from custom_components.nestquest.dao_instances import QuestInstancesDao + from custom_components.nestquest.db import NestQuestDatabase + from custom_components.nestquest.migrations import apply_migrations + from custom_components.nestquest.store import async_get_db_path + + # Seed the database BEFORE setup: a restart reopens an existing file that + # already holds definitions. The startup backfill must pick them up. + db_path = await async_get_db_path(hass) + pre = NestQuestDatabase(hass) + await pre.open(db_path) + await apply_migrations(pre) + child_id = await _seed_daily_child_and_definition(pre, _local_today(hass)) + await pre.close() + + entry = _wire(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + + today = _local_today(hass) + end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat() + records = await QuestInstancesDao(entry.runtime_data.database).list_by_date_range( + child_id, today.isoformat(), end + ) + assert len(records) == DEFAULT_HORIZON_DAYS + 1 + + +async def test_regenerate_service_registered_and_materializes( + hass, make_entry +) -> None: + """``nestquest.regenerate`` is registered and calling it regenerates.""" + import datetime + + from custom_components.nestquest.dao_instances import QuestInstancesDao + + entry = _wire(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE) + + # A definition created AFTER setup has no instances yet (create does not + # materialize); the service must generate them on demand. + database = entry.runtime_data.database + child_id = await _seed_daily_child_and_definition(database, _local_today(hass)) + + await hass.services.call(DOMAIN, SERVICE_REGENERATE) + + today = _local_today(hass) + end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat() + records = await QuestInstancesDao(database).list_by_date_range( + child_id, today.isoformat(), end + ) + assert len(records) == DEFAULT_HORIZON_DAYS + 1 + + +async def test_unload_deregisters_regenerate_service(hass, make_entry) -> None: + """Unload removes the ``regenerate`` service so no stale entry leaks.""" + entry = _wire(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE) + + assert await async_unload_entry(hass, entry) is True + assert not hass.services.has_service(DOMAIN, SERVICE_REGENERATE) + + +async def test_regenerate_service_is_domain_scoped_across_entries( + hass, make_entry +) -> None: + """The ``regenerate`` service is domain-global, not per-entry. + + With two loaded entries the service must be registered and functional; + unloading one entry must keep it registered and functional for the + remaining entry; unloading the final entry must remove it. + """ + import datetime + + from custom_components.nestquest.dao_instances import QuestInstancesDao + + entry_a = _wire(make_entry(entry_id="entry_a"), hass.registry) + entry_b = _wire(make_entry(entry_id="entry_b"), hass.registry) + + assert await async_setup_entry(hass, entry_a) is True + assert await async_setup_entry(hass, entry_b) is True + assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE) + + # The domain-global handler resolves a live database from any entry. + database_b = entry_b.runtime_data.database + child_id = await _seed_daily_child_and_definition( + database_b, _local_today(hass) + ) + await hass.services.call(DOMAIN, SERVICE_REGENERATE) + today = _local_today(hass) + end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat() + records = await QuestInstancesDao(database_b).list_by_date_range( + child_id, today.isoformat(), end + ) + assert len(records) == DEFAULT_HORIZON_DAYS + 1 + + # Unloading one entry must not tear down the shared service. + assert await async_unload_entry(hass, entry_a) is True + assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE) + + # It still works for the remaining entry. + await hass.services.call(DOMAIN, SERVICE_REGENERATE) + + # Unloading the final entry removes the domain-global service. + assert await async_unload_entry(hass, entry_b) is True + assert not hass.services.has_service(DOMAIN, SERVICE_REGENERATE) + + +async def test_setup_cleans_listeners_when_materialization_raises( + hass, make_entry, monkeypatch +) -> None: + """A startup-materialization failure rolls back every registered listener. + + The daily and update listeners are installed before the startup + materialization runs; when that walk raises, the database is closed but + the remover callables must also be invoked so no listener survives + firing against a closed database. + """ + import custom_components.nestquest as nestquest + + async def _boom(*_args, **_kwargs): + raise RuntimeError("materialization failed") + + monkeypatch.setattr(nestquest, "_materialize_run", _boom) + + entry = _wire(make_entry(), hass.registry) + with pytest.raises(RuntimeError, match="materialization failed"): + await async_setup_entry(hass, entry) + + assert hass.time_change.size == 0 + assert hass.registry.size == 0 + assert not hass.services.has_service(DOMAIN, SERVICE_REGENERATE) + assert not hasattr(entry, "runtime_data") \ No newline at end of file diff --git a/tests/test_materialize.py b/tests/test_materialize.py index 62523fd..c20f226 100644 --- a/tests/test_materialize.py +++ b/tests/test_materialize.py @@ -340,7 +340,7 @@ async def _materialize_with_change( seen = {"n": 0} async def _gated(self, definition_id, child_id, due_date, generated_at, - *, window, due_time=None): + *, window, due_time=None, today=None): seen["n"] += 1 if seen["n"] == 1: seen["first"] = (definition_id, child_id, window) @@ -348,7 +348,7 @@ async def _materialize_with_change( await release.wait() return await original_upsert( self, definition_id, child_id, due_date, generated_at, - window=window, due_time=due_time, + window=window, due_time=due_time, today=today, ) QuestInstancesDao.upsert_if_valid = _gated @@ -630,6 +630,42 @@ def test_materialize_rejects_malformed_or_inverted_bounds(tmp_path) -> None: _with_db(tmp_path, "materialize-bounds.db")(_body) +def test_materialize_accepts_ha_local_today_behind_host(tmp_path) -> None: + """The no-past guard compares against a caller-supplied HA-local today. + + A household time zone behind the host clock around midnight resolves a + HA-local "today" one calendar day earlier than the host's + ``date.today()``. The host-clock guard would reject that date as past, + so the resolved HA-local date must be threaded through ``materialize`` + and accepted instead of raising. + """ + async def _body(database): + children = ChildrenDao(database) + child = await children.create("Ada", NOW) + ha_today = datetime.date.today() - datetime.timedelta(days=1) + start_iso = ha_today.isoformat() + end_iso = (ha_today + datetime.timedelta(days=14)).isoformat() + await create_quest_definition( + database, + "Daily chore", + ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso), + [child.id], + ["morning"], + ) + + # Without the pinned HA-local today, the host-clock guard rejects + # the horizon start (ha_today is "yesterday" on the host). + with pytest.raises(ValueError, match="never generated in the past"): + await materialize(database, start_iso, end_iso) + + # With today=ha_today, the same range is accepted and materialized. + count = await materialize(database, start_iso, end_iso, today=ha_today) + assert count == 15 + return count + + _with_db(tmp_path, "materialize-ha-local-today.db")(_body) + + def test_materialize_skips_inactive_definitions(tmp_path) -> None: async def _body(database): from custom_components.nestquest.quest_definitions import ( @@ -914,10 +950,8 @@ def test_regenerate_for_child_uses_single_anchor_across_midnight( return day0 if clock["reads"] == 1 else day1 import custom_components.nestquest.dao_instances as dao_instances_module - import custom_components.nestquest.materialize as materialize_module monkeypatch.setattr(dao_instances_module, "_today", _fake_today) - monkeypatch.setattr(materialize_module, "_today", _fake_today) async def _body(database): children = ChildrenDao(database) diff --git a/tests/test_services.py b/tests/test_services.py new file mode 100644 index 0000000..68f2cde --- /dev/null +++ b/tests/test_services.py @@ -0,0 +1,41 @@ +"""Tests for the NestQuest services.yaml declaration file.""" +from __future__ import annotations + +import re +from pathlib import Path + +SERVICES_PATH = ( + Path(__file__).parent.parent + / "custom_components" + / "nestquest" + / "services.yaml" +) + + +def test_services_yaml_exists() -> None: + assert SERVICES_PATH.exists(), f"services.yaml missing at {SERVICES_PATH}" + + +def test_services_yaml_declares_regenerate() -> None: + text = SERVICES_PATH.read_text(encoding="utf-8") + assert re.search(r"^regenerate:\s*$", text, re.MULTILINE), ( + "services.yaml must declare the 'regenerate' service" + ) + + +def test_services_yaml_regenerate_has_description_and_no_fields() -> None: + """The regenerate service is documented with a clear description and no + required fields (there is no ``fields`` block under it).""" + text = SERVICES_PATH.read_text(encoding="utf-8") + match = re.search(r"^regenerate:\s*$", text, re.MULTILINE) + assert match is not None + section = text[match.end():] + next_service = re.search(r"^(?![#\s])\w+:\s*$", section, re.MULTILINE) + if next_service is not None: + section = section[: next_service.start()] + assert re.search(r"description:", section), ( + "regenerate must carry a description" + ) + assert "fields:" not in section, ( + "regenerate declares no required fields" + ) \ No newline at end of file