Compare commits

...
Author SHA1 Message Date
overseer 57d354b54c feat: clamp materialize start to today (no past instances) 2026-09-17 16:10:36 -04:00
2 changed files with 70 additions and 9 deletions
@@ -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:
+55 -9
View File
@@ -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 (