diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index 6615679..8cc9b79 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -19,6 +19,7 @@ from homeassistant.helpers.event import async_track_time_change from .const import ( CONF_ADMIN_USER_IDS, CONF_DAY_ROLLOVER_TIME, + CONF_HORIZON_DAYS, DEFAULT_DAY_ROLLOVER_TIME, DEFAULT_HORIZON_DAYS, DOMAIN, @@ -273,10 +274,28 @@ def _day_rollover_hour_minute(options: dict[str, Any]) -> tuple[int, int]: return int(hour), int(minute) +def _configured_horizon_days(options: dict[str, Any]) -> int: + """Return the configured generation horizon, validated, else the default. + + The options flow already validates ``horizon_days`` as an integer >= 1, + but a stored ``entry.options`` dict could still carry a partial or + hand-edited value. Re-checking here — and falling back to + :data:`~.const.DEFAULT_HORIZON_DAYS` on a missing, non-int or sub-1 value + — keeps a malformed option from shrinking or exploding the materialized + horizon. + """ + value = options.get(CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS) + if isinstance(value, bool) or not isinstance(value, int) or value < 1: + return DEFAULT_HORIZON_DAYS + return value + + async def _run_horizon_materialization( - hass: HomeAssistant, database: NestQuestDatabase + hass: HomeAssistant, + database: NestQuestDatabase, + horizon_days: int = DEFAULT_HORIZON_DAYS, ) -> None: - """Materialize the rolling horizon ``[today, today + DEFAULT_HORIZON_DAYS]``. + """Materialize the rolling horizon ``[today, today + 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. @@ -285,13 +304,15 @@ async def _run_horizon_materialization( 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. + no duplicated materialization logic. ``horizon_days`` sizes the window + and defaults to :data:`~.const.DEFAULT_HORIZON_DAYS`; callers thread the + entry's validated option value through. """ 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) + today + datetime.timedelta(days=horizon_days) ).isoformat() await _materialize_run(database, start_date, end_date, today=today) @@ -304,31 +325,34 @@ 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]`` - through the shared :func:`_run_horizon_materialization` path. + materializes the rolling horizon ``[today, today + horizon_days]`` + through the shared :func:`_run_horizon_materialization` path, using the + entry's configured (validated) ``horizon_days``. """ hour, minute = _day_rollover_hour_minute(options) + horizon_days = _configured_horizon_days(options) async def _run_materialization(_now: datetime.datetime) -> None: - await _run_horizon_materialization(hass, database) + await _run_horizon_materialization(hass, database, horizon_days) 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. +def _find_live_runtime_data(hass: HomeAssistant) -> NestQuestRuntimeData | None: + """Return a live runtime record with a connected database, 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. + their own; they resolve whichever config entry's runtime data is + currently live at call time, so none of them can hold a stale handle + to a closed connection after another entry unloads. The record also + carries that entry's options (e.g. the configured horizon). """ 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 runtime_data return None @@ -349,14 +373,17 @@ def _register_regenerate_service(hass: HomeAssistant) -> None: return async def _regenerate(_call: Any) -> None: - database = _find_live_database(hass) - if database is None: + runtime_data = _find_live_runtime_data(hass) + if runtime_data is None: _LOGGER.warning( "NestQuest regenerate requested but no config entry has a " "live database; ignoring" ) return - await _run_horizon_materialization(hass, database) + horizon_days = _configured_horizon_days(runtime_data.options) + await _run_horizon_materialization( + hass, runtime_data.database, horizon_days + ) hass.services.async_register(DOMAIN, SERVICE_REGENERATE, _regenerate) @@ -456,9 +483,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) # 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) + # [today, today + horizon_days] once at setup, using the entry's + # configured horizon. This runs through the SAME path as the daily + # listener and regenerate service. + await _run_horizon_materialization( + hass, database, _configured_horizon_days(dict(entry.options)) + ) _register_regenerate_service(hass) except BaseException: # A failure anywhere after the database is open must not leak the diff --git a/custom_components/nestquest/materialize.py b/custom_components/nestquest/materialize.py index e75c040..2a0bf7e 100644 --- a/custom_components/nestquest/materialize.py +++ b/custom_components/nestquest/materialize.py @@ -44,7 +44,8 @@ definition edits / reassignment (Features 06/07); :func:`regenerate_for_child` covers a single child's presence change (Feature 09). Both delete the affected open instances at or after today — never a completed instance, never the past — and re-run the walk over the rolling horizon -``[today, today + DEFAULT_HORIZON_DAYS]``. +``[today, today + horizon_days]`` (``horizon_days`` defaults to +``DEFAULT_HORIZON_DAYS``). HOOK (Feature 09 presence services): the presence business layer that lands in Feature 09 MUST call :func:`regenerate_for_child` after EVERY @@ -232,6 +233,7 @@ async def regenerate_for_definition( definition_id: int, *, today: datetime.date | None = None, + horizon_days: int | None = None, ) -> int: """Regenerate a definition's future instances after a config change. @@ -241,7 +243,7 @@ async def regenerate_for_definition( completed instance, never the past) via :meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted`, then the materialization walk re-runs over the rolling horizon - ``[today, today + DEFAULT_HORIZON_DAYS]`` so the (changed) rule, + ``[today, today + horizon_days]`` so the (changed) rule, assignee set and windows re-materialize against today's state. "today" is computed the same way the walk computes it — @@ -251,14 +253,17 @@ async def regenerate_for_definition( 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 + consistent. ``horizon_days`` sizes the re-materialization window; + when omitted it falls back to :data:`~.const.DEFAULT_HORIZON_DAYS`. + Returns the number of instances the re-materialization upserted across the whole snapshot (idempotent — other active definitions' tuples refresh in place, never duplicate). """ + horizon = DEFAULT_HORIZON_DAYS if horizon_days is None else horizon_days today_date = _resolve_today(today) start_date = today_date.isoformat() end_date = ( - today_date + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) + today_date + datetime.timedelta(days=horizon) ).isoformat() instances = QuestInstancesDao(database) @@ -286,6 +291,7 @@ async def regenerate_for_child( child_id: int, *, today: datetime.date | None = None, + horizon_days: int | None = None, ) -> int: """Regenerate a child's future instances after a presence change. @@ -299,7 +305,7 @@ async def regenerate_for_child( (never a completed instance, never the past) via :meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted_for_child`, then the materialization walk re-runs over the rolling horizon - ``[today, today + DEFAULT_HORIZON_DAYS]`` scoped to that child only, + ``[today, today + horizon_days]`` scoped to that child only, so the child's now-absent/present dates re-materialize against today's presence WITHOUT touching any other child's rows. @@ -315,13 +321,16 @@ async def regenerate_for_child( execution-day anchor even when the call is queued across midnight. ``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 + host-vs-HA time-zone consistency. ``horizon_days`` sizes the + re-materialization window; when omitted it falls back to + :data:`~.const.DEFAULT_HORIZON_DAYS`. 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}") + horizon = DEFAULT_HORIZON_DAYS if horizon_days is None else horizon_days today_date = _resolve_today(today) instances = QuestInstancesDao(database) @@ -335,7 +344,7 @@ async def regenerate_for_child( child_id, today_date.isoformat(), today=today ) start = datetime.date.fromisoformat(effective_cutoff) - end_date = (start + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat() + end_date = (start + datetime.timedelta(days=horizon)).isoformat() return await materialize( database, start.isoformat(), end_date, child_ids=[child_id], today=today ) diff --git a/custom_components/nestquest/quest_definitions.py b/custom_components/nestquest/quest_definitions.py index 86ada89..d301aa4 100644 --- a/custom_components/nestquest/quest_definitions.py +++ b/custom_components/nestquest/quest_definitions.py @@ -272,6 +272,7 @@ async def edit_quest_definition( rule: ScheduleRule | object = _UNSET, windows: list[str | tuple[str, str | None]] | object = _UNSET, today: datetime.date | None = None, + horizon_days: int | None = None, ) -> CreatedQuestDefinition: """Edit a quest definition's metadata, rule and windows atomically. @@ -299,7 +300,10 @@ async def edit_quest_definition( into :func:`~.materialize.regenerate_for_definition`); it is a plain ``datetime.date`` with no HA import here — the Feature 09 service supplies the hass-derived value. When omitted, regeneration falls - back to the host clock, same as before. + back to the host clock, same as before. ``horizon_days`` sizes the + regeneration's re-materialization window the same way (threaded into + :func:`~.materialize.regenerate_for_definition`); it defaults to + :data:`~.const.DEFAULT_HORIZON_DAYS` when omitted. """ _validate_definition_id(definition_id) @@ -350,7 +354,9 @@ async def edit_quest_definition( schedule_rule_storage_from_record(snapshot.rule) ) - await regenerate_for_definition(database, definition_id, today=today) + await regenerate_for_definition( + database, definition_id, today=today, horizon_days=horizon_days + ) return CreatedQuestDefinition( definition=snapshot.definition, @@ -366,6 +372,7 @@ async def assign_child( child_id: int, *, today: datetime.date | None = None, + horizon_days: int | None = None, ) -> list[ChildRecord]: """Assign ``child_id`` to the definition (idempotent). @@ -382,6 +389,8 @@ async def assign_child( ``today`` optionally pins the caller-resolved HA-local date threaded into the assignment's regeneration (see :func:`edit_quest_definition`). + ``horizon_days`` optionally sizes the regeneration's re-materialization + window the same way. """ _validate_definition_id(definition_id) _validate_child_id(child_id) @@ -397,7 +406,9 @@ async def assign_child( if message.startswith("quest definition "): raise ValueError(f"definition_id: {message}") from error raise - await regenerate_for_definition(database, definition_id, today=today) + await regenerate_for_definition( + database, definition_id, today=today, horizon_days=horizon_days + ) return assignees @@ -407,6 +418,7 @@ async def unassign_child( child_id: int, *, today: datetime.date | None = None, + horizon_days: int | None = None, ) -> bool: """Unassign ``child_id`` from the definition; True when removed. @@ -419,6 +431,8 @@ async def unassign_child( ``today`` optionally pins the caller-resolved HA-local date threaded into the unassignment's regeneration (see :func:`edit_quest_definition`). + ``horizon_days`` optionally sizes the regeneration's re-materialization + window the same way. """ _validate_definition_id(definition_id) _validate_child_id(child_id) @@ -429,7 +443,9 @@ async def unassign_child( f"definition_id: quest definition {definition_id} does not exist" ) removed = await dao.remove_assignee(definition_id, child_id) - await regenerate_for_definition(database, definition_id, today=today) + await regenerate_for_definition( + database, definition_id, today=today, horizon_days=horizon_days + ) return removed @@ -439,6 +455,7 @@ async def set_quest_definition_active( is_active: bool, *, today: datetime.date | None = None, + horizon_days: int | None = None, ) -> CreatedQuestDefinition: """Deactivate or reactivate a definition; returns the updated view. @@ -464,7 +481,8 @@ async def set_quest_definition_active( ``today`` optionally pins the caller-resolved HA-local date threaded into the activation change's regeneration (see - :func:`edit_quest_definition`). + :func:`edit_quest_definition`). ``horizon_days`` optionally sizes the + regeneration's re-materialization window the same way. """ _validate_definition_id(definition_id) if not isinstance(is_active, bool): @@ -488,7 +506,9 @@ async def set_quest_definition_active( rule = schedule_rule_from_storage( schedule_rule_storage_from_record(rule_record) ) - await regenerate_for_definition(database, definition_id, today=today) + await regenerate_for_definition( + database, definition_id, today=today, horizon_days=horizon_days + ) return CreatedQuestDefinition( definition=updated, rule=rule, diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index 68be6ce..3acd258 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -10,6 +10,7 @@ from conftest import make_config_entry, make_hass, wire_entry_to_registry from custom_components.nestquest import DOMAIN, async_setup_entry, async_unload_entry from custom_components.nestquest.const import ( CONF_DAY_ROLLOVER_TIME, + CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS, SERVICE_REGENERATE, DOMAIN as DOMAIN_CONST, @@ -447,6 +448,55 @@ async def test_setup_backfills_instances_over_horizon(hass, make_entry) -> None: assert len(records) == DEFAULT_HORIZON_DAYS + 1 +async def test_setup_backfills_configured_horizon_days(hass, make_entry) -> None: + """Setup backfills only the configured (non-default) horizon window.""" + 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 + + 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(options={CONF_HORIZON_DAYS: 5}), hass.registry + ) + assert await async_setup_entry(hass, entry) is True + + today = _local_today(hass) + end = (today + datetime.timedelta(days=5)).isoformat() + records = await QuestInstancesDao(entry.runtime_data.database).list_by_date_range( + child_id, today.isoformat(), end + ) + assert len(records) == 6 + + +def test_configured_horizon_days_validates_and_falls_back() -> None: + """The configured horizon is validated; a malformed value uses the default.""" + import custom_components.nestquest as nestquest + + assert nestquest._configured_horizon_days({CONF_HORIZON_DAYS: 5}) == 5 + assert nestquest._configured_horizon_days({}) == DEFAULT_HORIZON_DAYS + assert ( + nestquest._configured_horizon_days({CONF_HORIZON_DAYS: True}) + == DEFAULT_HORIZON_DAYS + ) + assert ( + nestquest._configured_horizon_days({CONF_HORIZON_DAYS: "5"}) + == DEFAULT_HORIZON_DAYS + ) + assert ( + nestquest._configured_horizon_days({CONF_HORIZON_DAYS: 0}) + == DEFAULT_HORIZON_DAYS + ) + + async def test_regenerate_service_registered_and_materializes( hass, make_entry ) -> None: @@ -474,6 +524,32 @@ async def test_regenerate_service_registered_and_materializes( assert len(records) == DEFAULT_HORIZON_DAYS + 1 +async def test_regenerate_service_uses_configured_horizon_days( + hass, make_entry +) -> None: + """``nestquest.regenerate`` regenerates only the configured horizon.""" + import datetime + + from custom_components.nestquest.dao_instances import QuestInstancesDao + + entry = _wire( + make_entry(options={CONF_HORIZON_DAYS: 5}), hass.registry + ) + assert await async_setup_entry(hass, entry) is True + + 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=5)).isoformat() + records = await QuestInstancesDao(database).list_by_date_range( + child_id, today.isoformat(), end + ) + assert len(records) == 6 + + 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) diff --git a/tests/test_materialize.py b/tests/test_materialize.py index 390b3ea..66c9ad3 100644 --- a/tests/test_materialize.py +++ b/tests/test_materialize.py @@ -1113,6 +1113,70 @@ def test_regenerate_for_child_is_child_scoped(tmp_path) -> None: _with_db(tmp_path, "regenerate-child-scoped.db")(_body) +def test_regenerate_for_definition_respects_horizon_days(tmp_path) -> None: + """A non-default horizon_days sizes the definition's rebuild window.""" + async def _body(database): + children = ChildrenDao(database) + child = await children.create("Ada", NOW) + + today = datetime.date.today() + start_iso = today.isoformat() + + created = await create_quest_definition( + database, + "Daily chore", + ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso), + [child.id], + [("morning", "09:00")], + ) + + # ``create`` does not materialize; regeneration over a 5-day horizon + # must build exactly today..today+5 (six daily rows). + await regenerate_for_definition( + database, created.definition.id, horizon_days=5 + ) + + dao = QuestInstancesDao(database) + horizon_end = today + datetime.timedelta(days=5) + records = await dao.list_by_date_range( + child.id, start_iso, horizon_end.isoformat() + ) + assert len(records) == 6 + return None + + _with_db(tmp_path, "regenerate-def-horizon.db")(_body) + + +def test_regenerate_for_child_respects_horizon_days(tmp_path) -> None: + """A non-default horizon_days sizes the child's rebuild window.""" + async def _body(database): + children = ChildrenDao(database) + child = await children.create("Ada", NOW) + + today = datetime.date.today() + start_iso = today.isoformat() + + await create_quest_definition( + database, + "Daily chore", + ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso), + [child.id], + [("morning", "09:00")], + ) + + await regenerate_for_child(database, child.id, horizon_days=5) + + dao = QuestInstancesDao(database) + horizon_end = today + datetime.timedelta(days=5) + records = await dao.list_by_date_range( + child.id, start_iso, horizon_end.isoformat() + ) + assert len(records) == 6 + return None + + _with_db(tmp_path, "regenerate-child-horizon.db")(_body) + + #: Fixed end-to-end anchor: 2026-06-01 is a Monday AND the 1st of its month, #: so the two-week custody cycle is week-aligned and the yearly rule's #: "1st of the anchor month" is GUARANTEED to fire inside the 28-day window