diff --git a/custom_components/nestquest/quest_definitions.py b/custom_components/nestquest/quest_definitions.py index 817f7fc..86ada89 100644 --- a/custom_components/nestquest/quest_definitions.py +++ b/custom_components/nestquest/quest_definitions.py @@ -271,6 +271,7 @@ async def edit_quest_definition( icon: str | None | object = _UNSET, rule: ScheduleRule | object = _UNSET, windows: list[str | tuple[str, str | None]] | object = _UNSET, + today: datetime.date | None = None, ) -> CreatedQuestDefinition: """Edit a quest definition's metadata, rule and windows atomically. @@ -292,6 +293,13 @@ async def edit_quest_definition( so the returned bundle is a consistent edit snapshot. Edits change future instances only; no ``quest_instances`` or ``completion_events`` row is touched. + + ``today`` optionally pins the caller-resolved HA-local date the + regeneration's no-past guard and horizon are anchored to (threaded + 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. """ _validate_definition_id(definition_id) @@ -342,7 +350,7 @@ async def edit_quest_definition( schedule_rule_storage_from_record(snapshot.rule) ) - await regenerate_for_definition(database, definition_id) + await regenerate_for_definition(database, definition_id, today=today) return CreatedQuestDefinition( definition=snapshot.definition, @@ -356,6 +364,8 @@ async def assign_child( database: NestQuestDatabase, definition_id: int, child_id: int, + *, + today: datetime.date | None = None, ) -> list[ChildRecord]: """Assign ``child_id`` to the definition (idempotent). @@ -369,6 +379,9 @@ async def assign_child( write). Raises ValueError on any rejected argument. Only the ``quest_definition_assignees`` link is written; existing instances and completion history are never touched. + + ``today`` optionally pins the caller-resolved HA-local date threaded + into the assignment's regeneration (see :func:`edit_quest_definition`). """ _validate_definition_id(definition_id) _validate_child_id(child_id) @@ -384,7 +397,7 @@ 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) + await regenerate_for_definition(database, definition_id, today=today) return assignees @@ -392,6 +405,8 @@ async def unassign_child( database: NestQuestDatabase, definition_id: int, child_id: int, + *, + today: datetime.date | None = None, ) -> bool: """Unassign ``child_id`` from the definition; True when removed. @@ -401,6 +416,9 @@ async def unassign_child( was not currently assigned. Raises ValueError on a rejected argument. Only the ``quest_definition_assignees`` link is written; existing instances and completion history are never touched. + + ``today`` optionally pins the caller-resolved HA-local date threaded + into the unassignment's regeneration (see :func:`edit_quest_definition`). """ _validate_definition_id(definition_id) _validate_child_id(child_id) @@ -411,7 +429,7 @@ 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) + await regenerate_for_definition(database, definition_id, today=today) return removed @@ -419,6 +437,8 @@ async def set_quest_definition_active( database: NestQuestDatabase, definition_id: int, is_active: bool, + *, + today: datetime.date | None = None, ) -> CreatedQuestDefinition: """Deactivate or reactivate a definition; returns the updated view. @@ -441,6 +461,10 @@ async def set_quest_definition_active( definition, its rule, assignees and windows all run inside the connection-scoped lock, so the returned bundle is a consistent snapshot that a concurrent mutation cannot race. + + ``today`` optionally pins the caller-resolved HA-local date threaded + into the activation change's regeneration (see + :func:`edit_quest_definition`). """ _validate_definition_id(definition_id) if not isinstance(is_active, bool): @@ -464,7 +488,7 @@ 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) + await regenerate_for_definition(database, definition_id, today=today) return CreatedQuestDefinition( definition=updated, rule=rule, diff --git a/tests/test_timezones.py b/tests/test_timezones.py index 34609c8..746fce9 100644 --- a/tests/test_timezones.py +++ b/tests/test_timezones.py @@ -18,7 +18,11 @@ from custom_components.nestquest.dao_children import ChildrenDao 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.quest_definitions import create_quest_definition +from custom_components.nestquest.quest_definitions import ( + assign_child, + create_quest_definition, + edit_quest_definition, +) from custom_components.nestquest.recurrence import RuleType, ScheduleRule NOW = "2026-09-14T12:00:00+00:00" @@ -228,20 +232,24 @@ def test_horizon_resolves_fall_back_day_without_off_by_one( ) -> None: """Across the 2026-11-01 fall-back, today and the horizon stay local. - The repeated 01:00-02:00 hour must not resolve to the wrong calendar day: - "01:30" (ambiguous) and "03:30 EST" (after the jump) both resolve to - 2026-11-01, and the full horizon remains 15 contiguous days. + The repeated 01:00-02:00 hour must not resolve to the wrong calendar + day: the first 01:30 (``fold=0``, EDT) and the repeated 01:30 + (``fold=1``, EST) are distinct instants yet both resolve to 2026-11-01 + — and both, along with 03:30 EST after the jump, materialize the same + 15 contiguous-day horizon with no off-by-one. """ zone = ZoneInfo(NEW_YORK) - ambiguous = datetime.datetime(2026, 11, 1, 1, 30, tzinfo=zone) + first = datetime.datetime(2026, 11, 1, 1, 30, tzinfo=zone, fold=0) # EDT + second = datetime.datetime(2026, 11, 1, 1, 30, tzinfo=zone, fold=1) # EST after = datetime.datetime(2026, 11, 1, 3, 30, tzinfo=zone) # EST - assert ambiguous.date() == after.date() == datetime.date(2026, 11, 1) + assert first.date() == second.date() == after.date() == datetime.date(2026, 11, 1) + assert first.utcoffset() != second.utcoffset() # the two repeated-hour folds async def _body(database): hass = _tz_hass(NEW_YORK) import custom_components.nestquest as nestquest - for instant in (ambiguous, after): + for instant in (first, second, after): _freeze_clock(monkeypatch, instant) local_today = instant.date() child_id = await _seed_daily(database, local_today.isoformat()) @@ -261,4 +269,73 @@ def test_horizon_resolves_fall_back_day_without_off_by_one( finally: await database.close() - assert _run(_main()) == datetime.date(2026, 11, 1) \ No newline at end of file + assert _run(_main()) == datetime.date(2026, 11, 1) + + +def test_definition_business_regeneration_uses_pinned_today_not_host( + tmp_path, monkeypatch +) -> None: + """``edit_quest_definition``/``assign_child`` anchor regeneration on + the caller-pinned HA-local ``today``, not the host clock. + + The host clock (the ``_today`` fallback) is pinned one day AHEAD of the + HA-local ``today``; the guard would reject the local "today" as past + (and the horizon would skip it) if it consulted the host clock. Both + business operations must regenerate against the pinned date and keep + that first local day, proving the threaded ``today`` wins. + """ + import custom_components.nestquest.dao_instances as dao_instances_module + + pinned = datetime.date(2026, 1, 14) + host_today = datetime.date(2026, 1, 15) + monkeypatch.setattr(dao_instances_module, "_today", lambda: host_today) + + async def _body(database): + children = ChildrenDao(database) + dao = QuestInstancesDao(database) + horizon_end = pinned + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) + + a = await children.create("Ada", NOW) + created = await create_quest_definition( + database, + "Daily chore", + ScheduleRule(rule_type=RuleType.DAILY, start_date=pinned.isoformat()), + [a.id], + [("morning", "09:00")], + ) + definition_id = created.definition.id + + # edit anchors on pinned: the horizon starts at the local day the + # host clock calls "past", so its presence proves pinned won. + await edit_quest_definition( + database, + definition_id, + windows=[("morning", "10:15")], + today=pinned, + ) + a_records = await dao.list_by_date_range( + a.id, pinned.isoformat(), horizon_end.isoformat() + ) + assert [r.due_date for r in a_records][0] == pinned.isoformat() + assert {r.due_time for r in a_records} == {"10:15"} + assert len(a_records) == DEFAULT_HORIZON_DAYS + 1 + + # assign anchors on pinned too: the new child's regeneration keeps + # the same pinned-local first day rather than skipping to the host day. + b = await children.create("Bo", NOW) + await assign_child(database, definition_id, b.id, today=pinned) + b_records = await dao.list_by_date_range( + b.id, pinned.isoformat(), horizon_end.isoformat() + ) + assert [r.due_date for r in b_records][0] == pinned.isoformat() + assert len(b_records) == DEFAULT_HORIZON_DAYS + 1 + return definition_id + + async def _main(): + database = await _prepare(tmp_path / "tz-definition-regen.db") + try: + return await _body(database) + finally: + await database.close() + + assert _run(_main()) is not None