diff --git a/custom_components/nestquest/materialize.py b/custom_components/nestquest/materialize.py index 3f22e07..e75c040 100644 --- a/custom_components/nestquest/materialize.py +++ b/custom_components/nestquest/materialize.py @@ -151,6 +151,14 @@ async def materialize( 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". + ``start_date`` is clamped up to the resolved "today" (the same + ``today`` anchor threaded into the no-past guard), so a caller that + passes a ``start_date`` already in the past never yields a + past-dated instance: the walk simply begins on today and skips every + earlier date. A range entirely in the past therefore produces zero + instances rather than an error, and the DAO's no-past guard remains + the authoritative backstop under the lock. + 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 @@ -184,6 +192,13 @@ async def materialize( start = datetime.datetime.strptime(start_date, "%Y-%m-%d").date() end = datetime.datetime.strptime(end_date, "%Y-%m-%d").date() + # Clamp a past start up to today (the HA-local anchor when the caller + # pinned one, else the host clock) so the walk can never emit a + # past-dated instance; the DAO's no-past guard remains the backstop. + # A range entirely in the past therefore yields zero instances. + today_date = _resolve_today(today) + start = max(start, today_date) + count = 0 cursor = start while cursor <= end: diff --git a/tests/test_materialize.py b/tests/test_materialize.py index c20f226..e4b29d8 100644 --- a/tests/test_materialize.py +++ b/tests/test_materialize.py @@ -631,13 +631,14 @@ def test_materialize_rejects_malformed_or_inverted_bounds(tmp_path) -> None: def test_materialize_accepts_ha_local_today_behind_host(tmp_path) -> None: - """The no-past guard compares against a caller-supplied HA-local today. + """The clamp and no-past guard compare 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. + ``date.today()``. The walk clamps its start to that anchor: without + a pinned HA-local date the effective start is the host's today (one + day after ha_today), so the now-past day is skipped rather than + rejected, and only the host-today-onward days materialize. """ async def _body(database): children = ChildrenDao(database) @@ -653,12 +654,13 @@ def test_materialize_accepts_ha_local_today_behind_host(tmp_path) -> None: ["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) + # Without a pinned HA-local today, the walk clamps its start to the + # host's today (one day after ha_today); the past day is dropped and + # the 14 host-today-onward days materialize. + without_pin = await materialize(database, start_iso, end_iso) + assert without_pin == 14 - # With today=ha_today, the same range is accepted and materialized. + # With today=ha_today, the full 15-day horizon materializes. count = await materialize(database, start_iso, end_iso, today=ha_today) assert count == 15 return count @@ -666,6 +668,50 @@ def test_materialize_accepts_ha_local_today_behind_host(tmp_path) -> None: _with_db(tmp_path, "materialize-ha-local-today.db")(_body) +def test_materialize_clamps_past_start_to_today(tmp_path) -> None: + """A past start_date is clamped to today: no past-dated instance is created. + + A caller passing a start one month in the past never yields a + past-dated instance: the walk clamps its effective start up to today + and materializes only dates on/after today, leaving the past untouched + rather than raising or corrupting state. + """ + async def _body(database): + children = ChildrenDao(database) + child = await children.create("Ada", NOW) + + today = datetime.date.today() + start_iso = (today - datetime.timedelta(days=30)).isoformat() + end_iso = (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"], + ) + + count = await materialize(database, start_iso, end_iso, today=today) + assert count == 15 + + dao = QuestInstancesDao(database) + records = await dao.list_by_date_range(child.id, start_iso, end_iso) + assert len(records) == 15 + assert all(r.due_date >= today.isoformat() for r in records) + + # The past month is empty: no past-dated instance was ever created. + past_records = await dao.list_by_date_range( + child.id, + start_iso, + (today - datetime.timedelta(days=1)).isoformat(), + ) + assert past_records == [] + return count + + _with_db(tmp_path, "materialize-past-start.db")(_body) + + def test_materialize_skips_inactive_definitions(tmp_path) -> None: async def _body(database): from custom_components.nestquest.quest_definitions import (