From d277d5816ea9f0c4a0232557bd38c95fe8621d20 Mon Sep 17 00:00:00 2001 From: Joshua Date: Thu, 17 Sep 2026 15:51:54 -0400 Subject: [PATCH] feat: HA-local date source end-to-end + DST boundary tests --- tests/test_timezones.py | 264 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 tests/test_timezones.py diff --git a/tests/test_timezones.py b/tests/test_timezones.py new file mode 100644 index 0000000..34609c8 --- /dev/null +++ b/tests/test_timezones.py @@ -0,0 +1,264 @@ +"""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 create_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) -> datetime.datetime: + """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 the instant normalized to UTC. + """ + import custom_components.nestquest as nestquest + + frozen = instant.astimezone(datetime.timezone.utc) + + class _FrozenDatetime(datetime.datetime): + @classmethod + def now(cls, tz=None): + 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 + + +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) + _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) + 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) + _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) + 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 + + for instant in (before, after): + _freeze_clock(monkeypatch, instant) + 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) + 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: + "01:30" (ambiguous) and "03:30 EST" (after the jump) both resolve to + 2026-11-01, and the full horizon remains 15 contiguous days. + """ + zone = ZoneInfo(NEW_YORK) + ambiguous = datetime.datetime(2026, 11, 1, 1, 30, tzinfo=zone) + after = datetime.datetime(2026, 11, 1, 3, 30, tzinfo=zone) # EST + assert ambiguous.date() == after.date() == datetime.date(2026, 11, 1) + + async def _body(database): + hass = _tz_hass(NEW_YORK) + import custom_components.nestquest as nestquest + + for instant in (ambiguous, after): + _freeze_clock(monkeypatch, instant) + 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) + 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) \ No newline at end of file