"""Port of the calendar-probe Part A contract to the thin adapter. Policy: croniter owns calendar calculation, including DST resolution. The adapter converts now into the schedule zone, queries, converts back to UTC, and applies no correction of its own. See docs/superpowers/specs/2026-09-08-deployment-scheduling-design.md and the implementation plan Gate 2. """ from __future__ import annotations from datetime import datetime, timedelta, timezone from importlib.metadata import version as _pkg_version from zoneinfo import ZoneInfo import pytest from wf_scheduling.calendar import ( CronSource, InvalidScheduleDefinitionError, OneShotSource, ScheduleExhaustedError, ) UTC = timezone.utc HCMC = ZoneInfo("Asia/Ho_Chi_Minh") NYC = ZoneInfo("America/New_York") def utc(*args: int) -> datetime: return datetime(*args, tzinfo=UTC) def test_versions_pinned() -> None: assert _pkg_version("croniter") == "6.2.4" def test_utc_daily_next_is_strictly_increasing_and_unique() -> None: src = CronSource("30 9 * * *", "UTC") seen: set[datetime] = set() prev: datetime | None = None cursor = utc(2026, 9, 1, 0, 0, 0) for _ in range(10): nxt = src.next_after(cursor) assert nxt is not None assert nxt.tzinfo is not None assert nxt.utcoffset() == timedelta(0) assert nxt not in seen seen.add(nxt) if prev is not None: assert nxt > prev prev = nxt cursor = nxt assert len(seen) == 10 def test_hcmc_daily_converts_and_hourly_counts() -> None: src = CronSource("30 9 * * *", "Asia/Ho_Chi_Minh") nxt = src.next_after(datetime(2026, 9, 1, 0, 0, tzinfo=HCMC)) assert nxt is not None assert (nxt.hour, nxt.minute) == (2, 30) assert nxt.date().isoformat() == "2026-09-01" for _label, start in ( ("march", utc(2026, 3, 7, 17, 30, 0)), ("november", utc(2026, 10, 31, 17, 30, 0)), ): hourly = CronSource("0 * * * *", "Asia/Ho_Chi_Minh") cursor = start count = 0 while True: hit = hourly.next_after(cursor) assert hit is not None if hit >= start + timedelta(hours=48): break count += 1 assert count < 60 cursor = hit assert count == 48 def test_expression_forms_wildcard_step_list_range() -> None: cases = { "* * * * *": (datetime(2026, 9, 8, 0, 0, tzinfo=UTC), "2026-09-08T00:01:00"), "5/15 * * * *": ( datetime(2026, 9, 8, 0, 0, tzinfo=UTC), "2026-09-08T00:05:00", ), "0,30 1-2 * * *": ( datetime(2026, 9, 8, 0, 0, tzinfo=UTC), "2026-09-08T01:00:00", ), "*/20 1-3 * * *": ( datetime(2026, 9, 8, 1, 50, tzinfo=UTC), "2026-09-08T02:00:00", ), } for expr, (start, want) in cases.items(): src = CronSource(expr, "UTC") nxt = src.next_after(start) assert nxt is not None assert nxt.isoformat() == want + "+00:00" def test_weekday_names_and_numbers_unix() -> None: monday = datetime(2026, 9, 7, 0, 0, tzinfo=UTC) cases = { "0 12 * * 0": 6, "0 12 * * 7": 6, "0 12 * * 1": 0, "0 12 * * mon": 0, "0 12 * * sun": 6, } for expr, want_wd in cases.items(): nxt = CronSource(expr, "UTC").next_after(monday) assert nxt is not None assert nxt.weekday() == want_wd, expr assert CronSource("0 12 * * 0", "UTC").next_after(monday) == CronSource( "0 12 * * sun", "UTC" ).next_after(monday) def test_dom_dow_day_or_true_is_selected() -> None: start = datetime(2026, 9, 1, 0, 0, tzinfo=UTC) src = CronSource("0 12 13 * fri", "UTC") hits: list[str] = [] cursor = start for _ in range(4): nxt = src.next_after(cursor) assert nxt is not None hits.append(nxt.date().isoformat()) cursor = nxt assert hits == ["2026-09-04", "2026-09-11", "2026-09-13", "2026-09-18"] def test_zone_conversion_is_the_adapters_job() -> None: src = CronSource("30 9 * * *", "Asia/Ho_Chi_Minh") nxt = src.next_after(datetime(2026, 9, 1, 0, 0, tzinfo=HCMC)) assert nxt is not None assert nxt.tzinfo is not None assert nxt.utcoffset() == timedelta(0) def test_gap_nonexistent_wall_times_resolve_forward() -> None: src = CronSource("30 2 * * *", "America/New_York") first = src.next_after(datetime(2026, 3, 7, 12, 0, tzinfo=NYC)) assert first is not None assert first.isoformat() == "2026-03-08T07:00:00+00:00" following = src.next_after(first) assert following is not None assert following.isoformat() == "2026-03-09T06:30:00+00:00" def test_gap_per_minute_stream_jumps_forward() -> None: src = CronSource("* * * * *", "America/New_York") cursor = datetime(2026, 3, 8, 0, 0, tzinfo=NYC) seq: list[datetime] = [] for _ in range(300): nxt = src.next_after(cursor) assert nxt is not None seq.append(nxt) cursor = nxt assert all(b > a for a, b in zip(seq, seq[1:])) assert len(set(seq)) == len(seq) gap_day = [d for d in seq if d.astimezone(NYC).date().isoformat() == "2026-03-08"] assert all(d.astimezone(NYC).hour != 2 for d in gap_day) def test_gap_backward_queries_return_library_resolution() -> None: src = CronSource("30 2 * * *", "America/New_York") cases = { "2026-03-08T12:00": "2026-03-08T07:00:00+00:00", "2026-03-09T00:00": "2026-03-08T07:00:00+00:00", "2026-03-07T12:00": "2026-03-07T07:30:00+00:00", "2026-03-09T12:00": "2026-03-09T06:30:00+00:00", } for start_iso, want in cases.items(): start = datetime.fromisoformat(start_iso).replace(tzinfo=NYC) got = src.prev_before(start) assert got is not None assert got.isoformat() == want, start_iso def test_fold_repeated_times_are_distinct_utc() -> None: src = CronSource("30 1 * * *", "America/New_York") first = src.next_after(datetime(2026, 10, 31, 12, 0, tzinfo=NYC)) assert first is not None assert first == utc(2026, 11, 1, 5, 30) assert src.next_after(first) == utc(2026, 11, 1, 6, 30) def test_fold_per_minute_unique_increasing() -> None: src = CronSource("* * * * *", "America/New_York") cursor = datetime(2026, 10, 31, 20, 0, tzinfo=NYC) seq: list[datetime] = [] for _ in range(560): nxt = src.next_after(cursor) assert nxt is not None seq.append(nxt) cursor = nxt assert all(b > a for a, b in zip(seq, seq[1:])) assert len(set(seq)) == len(seq) iso = {d.isoformat() for d in seq} assert "2026-11-01T05:30:00+00:00" in iso assert "2026-11-01T06:30:00+00:00" in iso def test_latest_missed_after_years_of_downtime_is_bounded() -> None: import time src = CronSource("* * * * *", "UTC") now = utc(2026, 9, 8, 12, 0, 0) t0 = time.perf_counter() latest = src.prev_before(now) elapsed = time.perf_counter() - t0 assert latest is not None assert elapsed < 5 assert latest <= now assert (now - latest) < timedelta(minutes=2) nyc_src = CronSource("* * * * *", "America/New_York") latest_nyc = nyc_src.prev_before(datetime(2026, 9, 8, 12, 0, tzinfo=NYC)) assert latest_nyc is not None assert latest_nyc.isoformat() == "2026-09-08T15:59:00+00:00" def test_iteration_is_exclusive_both_directions() -> None: # Exclusive get_next/get_prev alone does not include an occurrence equal # to now: the adapter AND scheduler must handle exact due-time inclusion # via the last-consumed watermark, not by assuming inclusivity. src = CronSource("0 13 * * *", "UTC") due = utc(2026, 9, 8, 13, 0, 0) fwd = src.next_after(due) back = src.prev_before(due) assert fwd == utc(2026, 9, 9, 13, 0, 0) assert back is not None assert (back.year, back.month, back.day, back.hour, back.minute) == ( 2026, 9, 7, 13, 0, ) just_before = src.next_after(due - timedelta(seconds=1)) assert just_before == due # Exact due-time inclusion through the adapter: prev_before just after due # returns the due instant itself. just_after = src.prev_before(due + timedelta(seconds=1)) assert just_after == due def test_exact_due_time_inclusion_through_scheduler_watermark() -> None: # A scheduler querying forward from the last-consumed instant and comparing # catch-up results against the same watermark admits a due occurrence # exactly once: forward from consumed (exclusive) finds due, and due is # not <= consumed so it is eligible. Querying forward from due itself # must not return due again. src = CronSource("0 13 * * *", "UTC") due = utc(2026, 9, 8, 13, 0, 0) consumed = utc(2026, 9, 7, 13, 0, 0) candidate = src.next_after(consumed) assert candidate == due assert candidate is not None and candidate > consumed assert src.next_after(due) != due def test_impossible_schedule_maps_to_exhausted() -> None: import time src = CronSource("0 12 30 2 *", "UTC") start = time.perf_counter() with pytest.raises(ScheduleExhaustedError): src.next_after(utc(2026, 1, 1)) assert time.perf_counter() - start < 5 def test_bad_expressions_rejected_naive_rejected() -> None: with pytest.raises(InvalidScheduleDefinitionError): CronSource("nonsense", "UTC") with pytest.raises(InvalidScheduleDefinitionError): CronSource("* * * *", "UTC") with pytest.raises(InvalidScheduleDefinitionError): CronSource("* * * * * *", "UTC") with pytest.raises(InvalidScheduleDefinitionError): CronSource("* * * * *", "No/Such_Zone") with pytest.raises(ValueError, match="aware"): CronSource("* * * * *", "UTC").next_after(datetime(2026, 9, 8, 12, 0)) with pytest.raises(ValueError, match="aware"): CronSource("* * * * *", "UTC").prev_before(datetime(2026, 9, 8, 12, 0)) def test_oneshot_requires_offset_and_returns_utc() -> None: at = datetime(2026, 9, 8, 12, 0, tzinfo=timezone(timedelta(hours=2))) src = OneShotSource(at) assert src.next_after(utc(2026, 9, 8, 9, 0)) == utc(2026, 9, 8, 10, 0) assert src.next_after(utc(2026, 9, 8, 10, 0)) is None assert src.prev_before(utc(2026, 9, 8, 11, 0)) == utc(2026, 9, 8, 10, 0) assert src.prev_before(utc(2026, 9, 8, 10, 0)) is None with pytest.raises(InvalidScheduleDefinitionError): OneShotSource(datetime(2026, 9, 8, 12, 0)) with pytest.raises(ValueError, match="aware"): src.next_after(datetime(2026, 9, 8, 12, 0))