"""Calendar recurrence expansion across a DST boundary (SPEC §A.3, acceptance A.7.2).""" from __future__ import annotations from datetime import UTC, datetime, timedelta from zoneinfo import ZoneInfo from webmail_plugin_calendar.recurrence import expand_occurrences # type: ignore[import-not-found] WARSAW = ZoneInfo("Europe/Warsaw") def _local(occ: list[tuple[datetime, datetime]]) -> dict[str, datetime]: # Map "YYYY-MM-DD" (Warsaw date) -> the UTC start of that occurrence. return {s.astimezone(WARSAW).date().isoformat(): s for s, _ in occ} def test_weekly_recurrence_keeps_local_time_across_dst() -> None: # Monday 2026-03-23 10:00 Europe/Warsaw == 09:00 UTC (UTC+1, before DST). # DST starts 2026-03-29, so from 03-30 the offset is UTC+2. dtstart = datetime(2026, 3, 23, 9, 0, tzinfo=UTC) occ = expand_occurrences( dtstart=dtstart, duration=timedelta(hours=1), rrule="FREQ=WEEKLY;BYDAY=MO", exdates=[], range_start=datetime(2026, 3, 1, tzinfo=UTC), range_end=datetime(2026, 4, 30, tzinfo=UTC), tz="Europe/Warsaw", ) by_date = _local(occ) # Every occurrence is at 10:00 local wall-clock, on both sides of the DST change. for start, _ in occ: assert start.astimezone(WARSAW).strftime("%H:%M") == "10:00" # ...but the UTC offset moves across the boundary. assert by_date["2026-03-23"].hour == 9 # UTC+1 assert by_date["2026-03-30"].hour == 8 # UTC+2 (DST) assert by_date["2026-04-06"].hour == 8 def test_exdates_remove_instances() -> None: dtstart = datetime(2026, 3, 23, 9, 0, tzinfo=UTC) occ = expand_occurrences( dtstart=dtstart, duration=timedelta(hours=1), rrule="FREQ=WEEKLY;BYDAY=MO", # Exclude the 2026-03-30 occurrence (10:00 Warsaw == 08:00 UTC under DST). exdates=[datetime(2026, 3, 30, 8, 0, tzinfo=UTC)], range_start=datetime(2026, 3, 1, tzinfo=UTC), range_end=datetime(2026, 4, 30, tzinfo=UTC), tz="Europe/Warsaw", ) dates = set(_local(occ)) assert "2026-03-23" in dates assert "2026-03-30" not in dates # excluded assert "2026-04-06" in dates def test_single_event_no_rrule() -> None: dtstart = datetime(2026, 6, 1, 12, 0, tzinfo=UTC) occ = expand_occurrences( dtstart=dtstart, duration=timedelta(minutes=30), rrule=None, exdates=[], range_start=datetime(2026, 6, 1, tzinfo=UTC), range_end=datetime(2026, 6, 2, tzinfo=UTC), tz="UTC", ) assert occ == [(dtstart, dtstart + timedelta(minutes=30))] # Outside the window -> nothing. empty = expand_occurrences( dtstart=dtstart, duration=timedelta(minutes=30), rrule=None, exdates=[], range_start=datetime(2026, 7, 1, tzinfo=UTC), range_end=datetime(2026, 7, 2, tzinfo=UTC), tz="UTC", ) assert empty == []