From f6aad9d20f840bc6d20facabae76af495b1ff651 Mon Sep 17 00:00:00 2001 From: Joshua Date: Thu, 17 Sep 2026 16:06:41 -0400 Subject: [PATCH] HA-local timezone + DST boundary tests (Feature 07, task 8) (#82) --- .../nestquest/quest_definitions.py | 32 +- tests/test_timezones.py | 370 ++++++++++++++++++ 2 files changed, 398 insertions(+), 4 deletions(-) create mode 100644 tests/test_timezones.py 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 new file mode 100644 index 0000000..273b588 --- /dev/null +++ b/tests/test_timezones.py @@ -0,0 +1,370 @@ +"""Timezone tests for the single HA-local date source in the generation path. + +These pin the done-condition that every "today" read in the generation path +derives from ``hass.config.time_zone`` — not the host clock and not UTC — +and that the rolling horizon ``[today, today + DEFAULT_HORIZON_DAYS]`` and the +no-past guard resolve the correct local dates around midnight and across the +daylight-saving transitions. +""" +from __future__ import annotations + +import asyncio +import datetime +from types import SimpleNamespace +from zoneinfo import ZoneInfo + +from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS +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 ( + assign_child, + create_quest_definition, + edit_quest_definition, +) +from custom_components.nestquest.recurrence import RuleType, ScheduleRule + +NOW = "2026-09-14T12:00:00+00:00" +NEW_YORK = "America/New_York" + + +def _make_hass_mock(): + from unittest.mock import AsyncMock, MagicMock + + hass = MagicMock() + hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a))) + return hass + + +async def _prepare(path): + database = NestQuestDatabase(_make_hass_mock()) + await database.open(path) + await apply_migrations(database) + return database + + +def _run(coro): + return asyncio.new_event_loop().run_until_complete(coro) + + +def _freeze_clock( + monkeypatch, instant: datetime.datetime +) -> tuple[datetime.datetime, list]: + """Freeze the integration's ``datetime`` clock to a fixed aware instant. + + Only ``custom_components.nestquest``'s ``datetime`` binding is patched, so + the generation path's "today" reads this instant (localized to whatever + time zone it asks for) while every other module and the test harness keep + the real clock. Returns ``(frozen, requested)`` where ``frozen`` is the + instant normalized to UTC and ``requested`` is the list of ``tzinfo`` + objects the code under test passed to ``now`` — so a test can prove the + generation path actually requested the configured HA time zone rather + than silently reading UTC. + """ + import custom_components.nestquest as nestquest + + frozen = instant.astimezone(datetime.timezone.utc) + requested: list = [] + + class _FrozenDatetime(datetime.datetime): + @classmethod + def now(cls, tz=None): + requested.append(tz) + if tz is None: + return frozen.replace(tzinfo=None) + return frozen.astimezone(tz) + + fake_module = SimpleNamespace( + datetime=_FrozenDatetime, + timedelta=datetime.timedelta, + date=datetime.date, + ) + monkeypatch.setattr(nestquest, "datetime", fake_module) + return frozen, requested + + +def _assert_requested_ha_timezone(requested, time_zone: str) -> None: + """Assert ``now`` was called with (only) the configured HA time zone. + + This is what turns the DST tests into a real proof of the HA-local date + source: if the generation path regressed to ``now(timezone.utc)``, its + requested tz would be UTC and this fails. + """ + assert requested, "the generation path never called now(tz)" + assert all(tz == ZoneInfo(time_zone) for tz in requested) + + +def _tz_hass(time_zone: str): + """A minimal hass exposing only the ``config.time_zone`` the walk reads.""" + return SimpleNamespace(config=SimpleNamespace(time_zone=time_zone)) + + +async def _seed_daily(database, start_iso: str) -> int: + """One always-present child + a daily definition starting ``start_iso``.""" + child = await ChildrenDao(database).create("Ada", NOW) + await create_quest_definition( + database, + "Daily chore", + ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso), + [child.id], + ["morning"], + ) + return child.id + + +def _local_date(instant_utc: datetime.datetime, time_zone: str) -> datetime.date: + return instant_utc.astimezone(ZoneInfo(time_zone)).date() + + +def _assert_contiguous_horizon(records, local_today: datetime.date) -> None: + """The horizon is exactly today..today+horizon, no off-by-one, no gap.""" + expected = { + (local_today + datetime.timedelta(days=offset)).isoformat() + for offset in range(DEFAULT_HORIZON_DAYS + 1) + } + assert {r.due_date for r in records} == expected + assert len(records) == DEFAULT_HORIZON_DAYS + 1 + + +def test_horizon_uses_newyork_local_date_after_local_midnight( + tmp_path, monkeypatch +) -> None: + """A run just after New York local midnight uses the local date. + + The frozen instant is 00:05 New York local time on 2026-03-10 (EDT, so + 04:05 UTC). The horizon must start on 2026-03-10 — the local calendar + date — not any host/UTC-derived value. + """ + instant = datetime.datetime( + 2026, 3, 10, 0, 5, tzinfo=ZoneInfo(NEW_YORK) + ) + local_today = instant.date() + + async def _body(database): + hass = _tz_hass(NEW_YORK) + _frozen, requested = _freeze_clock(monkeypatch, instant) + import custom_components.nestquest as nestquest + + child_id = await _seed_daily(database, local_today.isoformat()) + await nestquest._run_horizon_materialization(hass, database) + records = await QuestInstancesDao(database).list_by_date_range( + child_id, + local_today.isoformat(), + (local_today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat(), + ) + _assert_contiguous_horizon(records, local_today) + _assert_requested_ha_timezone(requested, NEW_YORK) + return len(records) + + async def _main(): + database = await _prepare(tmp_path / "tz-midnight.db") + try: + return await _body(database) + finally: + await database.close() + + assert _run(_main()) == DEFAULT_HORIZON_DAYS + 1 + + +def test_horizon_uses_local_date_not_utc_when_host_ahead( + tmp_path, monkeypatch +) -> None: + """The walk's "today" is HA-local, never the UTC/system calendar date. + + FROZEN at 2026-01-15 03:00 UTC: the UTC calendar date is 2026-01-15, but + New York local is still 2026-01-14 22:00 EST. The horizon must start on + 2026-01-14 (local), proving the UTC date did not win. + """ + instant = datetime.datetime(2026, 1, 15, 3, 0, tzinfo=datetime.timezone.utc) + local_today = _local_date(instant, NEW_YORK) + assert local_today == datetime.date(2026, 1, 14) + assert instant.date() == datetime.date(2026, 1, 15) + + async def _body(database): + hass = _tz_hass(NEW_YORK) + _frozen, requested = _freeze_clock(monkeypatch, instant) + import custom_components.nestquest as nestquest + + child_id = await _seed_daily(database, local_today.isoformat()) + await nestquest._run_horizon_materialization(hass, database) + records = await QuestInstancesDao(database).list_by_date_range( + child_id, + local_today.isoformat(), + (local_today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat(), + ) + _assert_contiguous_horizon(records, local_today) + _assert_requested_ha_timezone(requested, NEW_YORK) + return len(records) + + async def _main(): + database = await _prepare(tmp_path / "tz-host-ahead.db") + try: + return await _body(database) + finally: + await database.close() + + assert _run(_main()) == DEFAULT_HORIZON_DAYS + 1 + + +def test_horizon_resolves_spring_forward_day_without_off_by_one( + tmp_path, monkeypatch +) -> None: + """Across the 2026-03-08 spring-forward, today and the horizon stay local. + + Before the jump ("01:30 EST") and after it ("03:30 EDT") both resolve to + 2026-03-08 local — the lost hour must not skip or repeat a calendar day — + and the full horizon remains 15 contiguous days. + """ + zone = ZoneInfo(NEW_YORK) + before = datetime.datetime(2026, 3, 8, 1, 30, tzinfo=zone) # EST + after = datetime.datetime(2026, 3, 8, 3, 30, tzinfo=zone) # EDT + assert before.date() == after.date() == datetime.date(2026, 3, 8) + + async def _body(database): + hass = _tz_hass(NEW_YORK) + import custom_components.nestquest as nestquest + + requested_batches: list[list] = [] + for instant in (before, after): + _frozen, requested = _freeze_clock(monkeypatch, instant) + requested_batches.append(requested) + local_today = instant.date() + child_id = await _seed_daily(database, local_today.isoformat()) + await nestquest._run_horizon_materialization(hass, database) + records = await QuestInstancesDao(database).list_by_date_range( + child_id, + local_today.isoformat(), + (local_today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat(), + ) + _assert_contiguous_horizon(records, local_today) + requested_all = [tz for batch in requested_batches for tz in batch] + _assert_requested_ha_timezone(requested_all, NEW_YORK) + return local_today + + async def _main(): + database = await _prepare(tmp_path / "tz-spring-forward.db") + try: + return await _body(database) + finally: + await database.close() + + assert _run(_main()) == datetime.date(2026, 3, 8) + + +def test_horizon_resolves_fall_back_day_without_off_by_one( + tmp_path, monkeypatch +) -> 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: 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) + 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 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 + + requested_batches: list[list] = [] + for instant in (first, second, after): + _frozen, requested = _freeze_clock(monkeypatch, instant) + requested_batches.append(requested) + local_today = instant.date() + child_id = await _seed_daily(database, local_today.isoformat()) + await nestquest._run_horizon_materialization(hass, database) + records = await QuestInstancesDao(database).list_by_date_range( + child_id, + local_today.isoformat(), + (local_today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat(), + ) + _assert_contiguous_horizon(records, local_today) + requested_all = [tz for batch in requested_batches for tz in batch] + _assert_requested_ha_timezone(requested_all, NEW_YORK) + return local_today + + async def _main(): + database = await _prepare(tmp_path / "tz-fall-back.db") + try: + return await _body(database) + finally: + await database.close() + + 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