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..c32d3d9 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 @@ -83,6 +84,31 @@ def _now_stamp() -> str: ) +def _resolve_horizon_days(horizon_days: int | None) -> int: + """Validate a regeneration horizon and return the resolved day count. + + ``None`` resolves to :data:`~.const.DEFAULT_HORIZON_DAYS`. A bool + (SQLite would happily bind ``True``/``False`` onto an int), a non-int, + or a value below 1 raises ValueError naming the field — the validation + runs BEFORE any future-instance delete so a malformed horizon can never + delete rows it then fails to rebuild. A negative horizon would + otherwise delete first and only then be rejected by + :func:`materialize`'s inverted-range check; ``0``/``False`` would + silently shrink the window to a single day. + """ + if horizon_days is None: + return DEFAULT_HORIZON_DAYS + if ( + isinstance(horizon_days, bool) + or not isinstance(horizon_days, int) + or horizon_days < 1 + ): + raise ValueError( + f"horizon_days must be a positive integer, got {horizon_days!r}" + ) + return horizon_days + + def _decode_rule(snapshot) -> ScheduleRule: """Decode a snapshot's rule record back into a ScheduleRule. @@ -232,6 +258,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 +268,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 +278,18 @@ 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`. + A bool, non-int, or sub-1 value raises ValueError BEFORE any delete. + Returns the number of instances the re-materialization upserted across the whole snapshot (idempotent — other active definitions' tuples refresh in place, never duplicate). """ + horizon = _resolve_horizon_days(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 +317,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 +331,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 +347,17 @@ 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`. A bool, non-int, or sub-1 value + raises ValueError BEFORE any delete. 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 = _resolve_horizon_days(horizon_days) today_date = _resolve_today(today) instances = QuestInstancesDao(database) @@ -335,7 +371,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..b81f3f9 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, @@ -351,6 +352,39 @@ async def test_day_rollover_listener_materializes_ha_local_horizon( ) +async def test_day_rollover_uses_configured_horizon_days(hass, make_entry) -> None: + """The daily run materializes only the configured horizon window.""" + 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 + + # A definition created AFTER setup has no instances yet (create does not + # materialize); firing the daily listener must generate only the horizon. + child_id = await _seed_daily_child_and_definition( + database, _local_today(hass) + ) + await hass.time_change.fire() + + 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 + + beyond = (today + datetime.timedelta(days=6)).isoformat() + beyond_end = ( + today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) + ).isoformat() + assert await QuestInstancesDao(database).list_by_date_range( + child_id, beyond, beyond_end + ) == [] + + def test_day_rollover_time_change_tracker_rejects_local_kwarg() -> None: """The fake async_track_time_change mirrors HA 2024.6 (no ``local`` kwarg). @@ -447,6 +481,63 @@ 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 + + beyond = (today + datetime.timedelta(days=6)).isoformat() + beyond_end = ( + today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) + ).isoformat() + assert await QuestInstancesDao( + entry.runtime_data.database + ).list_by_date_range(child_id, beyond, beyond_end) == [] + + +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 +565,40 @@ 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 + + beyond = (today + datetime.timedelta(days=6)).isoformat() + beyond_end = ( + today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) + ).isoformat() + assert await QuestInstancesDao(database).list_by_date_range( + child_id, beyond, beyond_end + ) == [] + + 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..2135559 100644 --- a/tests/test_materialize.py +++ b/tests/test_materialize.py @@ -7,6 +7,7 @@ import datetime import pytest from custom_components.nestquest.dao_children import ChildrenDao +from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS from custom_components.nestquest.dao_instances import ( CompletionEventsDao, QuestInstancesDao, @@ -1113,6 +1114,158 @@ 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 + + # The window is BOUNDED: nothing must exist at day 6 or later (a + # hard-coded 14-day default would still emit rows there). + beyond = (today + datetime.timedelta(days=6)).isoformat() + beyond_end = ( + today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) + ).isoformat() + assert await dao.list_by_date_range(child.id, beyond, beyond_end) == [] + 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 + + # Bounded window: nothing must exist at day 6 or later. + beyond = (today + datetime.timedelta(days=6)).isoformat() + beyond_end = ( + today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) + ).isoformat() + assert await dao.list_by_date_range(child.id, beyond, beyond_end) == [] + return None + + _with_db(tmp_path, "regenerate-child-horizon.db")(_body) + + +@pytest.mark.parametrize("bad", [-1, 0, True, False, "5", 1.5]) +def test_regenerate_for_definition_rejects_invalid_horizon_before_delete( + tmp_path, bad +) -> None: + """A malformed horizon fails BEFORE any future-instance delete.""" + async def _body(database): + children = ChildrenDao(database) + child = await children.create("Ada", NOW) + + today = datetime.date.today() + start_iso = today.isoformat() + horizon_end = today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) + + created = await create_quest_definition( + database, + "Daily chore", + ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso), + [child.id], + [("morning", "09:00")], + ) + dao = QuestInstancesDao(database) + await materialize(database, start_iso, horizon_end.isoformat()) + + with pytest.raises(ValueError, match="horizon_days"): + await regenerate_for_definition( + database, created.definition.id, horizon_days=bad + ) + + # The refused call deleted nothing: the seeded horizon survives. + records = await dao.list_by_date_range( + child.id, start_iso, horizon_end.isoformat() + ) + assert len(records) == DEFAULT_HORIZON_DAYS + 1 + return None + + _with_db(tmp_path, "regenerate-def-horizon-invalid.db")(_body) + + +@pytest.mark.parametrize("bad", [-1, 0, True, False, "5", 1.5]) +def test_regenerate_for_child_rejects_invalid_horizon_before_delete( + tmp_path, bad +) -> None: + """A malformed child horizon fails BEFORE any future-instance delete.""" + async def _body(database): + children = ChildrenDao(database) + child = await children.create("Ada", NOW) + + today = datetime.date.today() + start_iso = today.isoformat() + horizon_end = today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) + + await create_quest_definition( + database, + "Daily chore", + ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso), + [child.id], + [("morning", "09:00")], + ) + dao = QuestInstancesDao(database) + await materialize(database, start_iso, horizon_end.isoformat()) + + with pytest.raises(ValueError, match="horizon_days"): + await regenerate_for_child(database, child.id, horizon_days=bad) + + records = await dao.list_by_date_range( + child.id, start_iso, horizon_end.isoformat() + ) + assert len(records) == DEFAULT_HORIZON_DAYS + 1 + return None + + _with_db(tmp_path, "regenerate-child-horizon-invalid.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 diff --git a/tests/test_quest_definitions.py b/tests/test_quest_definitions.py index 2770113..a755670 100644 --- a/tests/test_quest_definitions.py +++ b/tests/test_quest_definitions.py @@ -8,6 +8,7 @@ import re import pytest from custom_components.nestquest.dao_children import ChildrenDao +from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS from custom_components.nestquest.dao_instances import ( CompletionEventsDao, QuestInstancesDao, @@ -1938,3 +1939,71 @@ def test_full_lifecycle_preserves_completed_and_history(tmp_path) -> None: return reactivated _with_db(tmp_path, "full-lifecycle.db")(_body) + + +@pytest.mark.parametrize( + "mode", + ["edit", "assign", "unassign", "set_active"], +) +def test_wrappers_forward_horizon_days(tmp_path, mode) -> None: + """The four change wrappers forward a non-default horizon_days. + + Each wrapper threads ``horizon_days`` into the regeneration it triggers; + passing 5 here must bound the regenerated window to today..today+5 — + proving the forwarding argument is actually honored, not dropped. + """ + async def _body(database, children, rules, definitions, child): + created = await create_quest_definition( + database, + "Chore", + ScheduleRule(rule_type=RuleType.DAILY, start_date=_today_iso()), + [child.id], + ["morning"], + ) + definition_id = created.definition.id + instances = QuestInstancesDao(database) + + if mode == "edit": + await edit_quest_definition( + database, + definition_id, + windows=[("morning", "10:30")], + horizon_days=5, + ) + tracked = child + elif mode == "assign": + bo = await children.create("Bo", NOW) + await assign_child(database, definition_id, bo.id, horizon_days=5) + tracked = bo + elif mode == "unassign": + # The remaining assignee (``child``) must be regenerated over + # the 5-day window after ``bo`` is removed. + bo = await children.create("Bo", NOW) + await assign_child(database, definition_id, bo.id) + await unassign_child( + database, definition_id, bo.id, horizon_days=5 + ) + tracked = child + else: # set_active + await set_quest_definition_active( + database, definition_id, True, horizon_days=5 + ) + tracked = child + + today = datetime.date.today() + end = (today + datetime.timedelta(days=5)).isoformat() + records = await instances.list_by_date_range( + tracked.id, today.isoformat(), end + ) + assert len(records) == 6 + + beyond = (today + datetime.timedelta(days=6)).isoformat() + beyond_end = ( + today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) + ).isoformat() + assert await instances.list_by_date_range( + tracked.id, beyond, beyond_end + ) == [] + return None + + _with_db(tmp_path, f"wrapper-horizon-{mode}.db")(_body)