Backfill materialization at setup and expose regenerate service (Feature 07, task 7)

This commit is contained in:
2026-09-17 15:20:18 -04:00
parent be6367b600
commit 0127362ebf
6 changed files with 267 additions and 13 deletions
+66 -12
View File
@@ -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
+3
View File
@@ -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
+12
View File
@@ -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.
+46
View File
@@ -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"
+99 -1
View File
@@ -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)
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)
+41
View File
@@ -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"
)