From 0127362ebfed1a9566dc6e290762c3c939b5de84 Mon Sep 17 00:00:00 2001 From: Joshua Date: Thu, 17 Sep 2026 15:20:18 -0400 Subject: [PATCH] Backfill materialization at setup and expose regenerate service (Feature 07, task 7) --- custom_components/nestquest/__init__.py | 78 ++++++++++++++--- custom_components/nestquest/const.py | 3 + custom_components/nestquest/services.yaml | 12 +++ tests/conftest.py | 46 ++++++++++ tests/test_lifecycle.py | 100 +++++++++++++++++++++- tests/test_services.py | 41 +++++++++ 6 files changed, 267 insertions(+), 13 deletions(-) create mode 100644 custom_components/nestquest/services.yaml create mode 100644 tests/test_services.py diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index 52a5eeb..8fe8ad6 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 @@ -236,6 +237,7 @@ class NestQuestRuntimeData: database: NestQuestDatabase remove_update_listener: Callable[[], Any] remove_time_change_listener: Callable[[], Any] + remove_service_listener: Callable[[], Any] async def _async_owner_user_ids(hass: HomeAssistant) -> list[str]: @@ -272,6 +274,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) + + def _register_day_rollover_listener( hass: HomeAssistant, database: NestQuestDatabase, @@ -280,28 +305,46 @@ 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 _register_regenerate_service( + hass: HomeAssistant, + database: NestQuestDatabase, +) -> Callable[[], Any]: + """Register ``nestquest.regenerate`` and return its unregister callable. + + 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. Registration is guarded by ``has_service`` so a + reload can never leave a duplicate; the returned remover deregisters the + service (idempotently) so unload leaves no stale entry behind. + """ + if not hass.services.has_service(DOMAIN, SERVICE_REGENERATE): + + async def _regenerate(_call: Any) -> None: + await _run_horizon_materialization(hass, database) + + hass.services.async_register(DOMAIN, SERVICE_REGENERATE, _regenerate) + + def _unregister() -> None: + if hass.services.has_service(DOMAIN, SERVICE_REGENERATE): + hass.services.async_unregister(DOMAIN, SERVICE_REGENERATE) + + return _unregister + + 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 @@ -387,6 +430,12 @@ 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) + remove_service_listener = _register_regenerate_service(hass, database) except BaseException: await database.close() raise @@ -396,6 +445,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, + remove_service_listener=remove_service_listener, ) entry.runtime_data = runtime_data hass.data[DOMAIN][entry.entry_id] = runtime_data @@ -415,11 +465,15 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: remove_time_change_listener = getattr( runtime_data, "remove_time_change_listener", None ) + remove_service_listener = getattr( + runtime_data, "remove_service_listener", None + ) unload_error: BaseException | None = None try: for remove_listener in ( remove_update_listener, remove_time_change_listener, + remove_service_listener, ): if remove_listener is None: continue 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/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..0486d74 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -307,6 +307,51 @@ 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_unregister`` removes + it. ``async_remove`` is the deprecated HA alias, kept so either + spelling resolves. ``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_unregister(self, domain, service): + self._services.pop((domain, service), None) + return None + + def async_remove(self, domain, service): + return self.async_unregister(domain, service) + + 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 +414,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..e68fa40 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, ) @@ -381,4 +382,101 @@ 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) \ No newline at end of file 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