"""Week helpers and WeeklyClock schedule arithmetic.""" from __future__ import annotations from datetime import date, datetime, timedelta from zoneinfo import ZoneInfo import pytest from sebrief.week import WeeklyClock, parse_hhmm, week_id, week_range KST = ZoneInfo("Asia/Seoul") # ------------------------------------------------------------------------- week id / range def test_week_id_and_range_roundtrip(): assert week_id(date(2026, 8, 31)) == "2026-W36" assert week_id(date(2026, 9, 6)) == "2026-W36" assert week_range("2026-W36") == (date(2026, 8, 31), date(2026, 9, 6)) def test_week_id_uses_iso_year_at_boundaries(): # 2026-01-01 is a Thursday -> belongs to ISO 2026-W01 starting Mon 2025-12-29 assert week_id(date(2025, 12, 29)) == "2026-W01" assert week_range("2026-W01")[0] == date(2025, 12, 29) @pytest.mark.parametrize("bad", ["2026-36", "2026-W99", "2026W36", "abcd-W01", "2026-W1"]) def test_week_range_rejects_invalid(bad): with pytest.raises(ValueError): week_range(bad) @pytest.mark.parametrize("bad", ["8:", "25:00", "08:60", "abc", "0820"]) def test_parse_hhmm_rejects_invalid(bad): with pytest.raises(ValueError): parse_hhmm(bad) def test_parse_hhmm_ok(): assert parse_hhmm("07:00") == (7, 0) assert parse_hhmm(" 7:05 ") == (7, 5) # ------------------------------------------------------------------------------ WeeklyClock def test_clock_rejects_bad_config(): with pytest.raises(ValueError): WeeklyClock(run_weekday=7) with pytest.raises(ValueError): WeeklyClock(run_time="25:00") def test_next_run_before_and_after_the_weekly_slot(): clock = WeeklyClock(0, "07:00") before = datetime(2026, 9, 7, 6, 59, tzinfo=KST) # Monday, one minute before the run week, run_at = clock.next_run(before) assert (week, run_at) == ("2026-W36", datetime(2026, 9, 7, 7, 0, tzinfo=KST)) after = datetime(2026, 9, 7, 7, 0, tzinfo=KST) # exactly at the slot -> next week's run week, run_at = clock.next_run(after) assert (week, run_at) == ("2026-W37", datetime(2026, 9, 14, 7, 0, tzinfo=KST)) def test_next_run_midweek_and_custom_weekday(): wed = datetime(2026, 9, 9, 12, 0, tzinfo=KST) assert WeeklyClock(0, "07:00").next_run(wed) == ( "2026-W37", datetime(2026, 9, 14, 7, 0, tzinfo=KST), ) # Thursday 08:30 runs cover the week that ended the previous Sunday assert WeeklyClock(3, "08:30").next_run(wed) == ( "2026-W36", datetime(2026, 9, 10, 8, 30, tzinfo=KST), ) def test_next_run_requires_tz_aware(): with pytest.raises(ValueError): WeeklyClock().next_run(datetime(2026, 9, 7, 12, 0)) # noqa: DTZ001 - naive on purpose def test_run_at_for_is_consistent_with_next_run(): clock = WeeklyClock(0, "07:00") assert clock.run_at_for("2026-W36") == datetime(2026, 9, 7, 7, 0, tzinfo=KST) clock2 = WeeklyClock(3, "08:30") assert clock2.run_at_for("2026-W36") == datetime(2026, 9, 10, 8, 30, tzinfo=KST) def test_last_run_and_latest_completed_week(): clock = WeeklyClock(0, "07:00") monday_noon = datetime(2026, 9, 7, 12, 0, tzinfo=KST) assert clock.last_run(monday_noon) == ("2026-W36", datetime(2026, 9, 7, 7, 0, tzinfo=KST)) assert clock.latest_completed_week(monday_noon) == "2026-W36" # mid-week: the last run is still the past Monday wed = datetime(2026, 9, 9, 9, 0, tzinfo=KST) assert clock.last_run(wed) == ("2026-W36", datetime(2026, 9, 7, 7, 0, tzinfo=KST)) @pytest.mark.parametrize( ("now_delta", "expected"), [ (timedelta(hours=1), "2026-W36"), # 1h after the slot: inside the window (timedelta(hours=71), "2026-W36"), # still inside the 72h window (timedelta(hours=73), None), # outside ], ) def test_catchup_candidate_window(now_delta, expected): clock = WeeklyClock(0, "07:00") run_at = datetime(2026, 9, 7, 7, 0, tzinfo=KST) assert clock.catchup_candidate(run_at + now_delta, 72) == expected def test_covered_week(): clock = WeeklyClock(0, "07:00") assert clock.covered_week(date(2026, 9, 7)) == "2026-W36" assert clock.covered_week(date(2026, 9, 14)) == "2026-W37"