"""Unit tests for recurrence engine - daily/weekly/monthly/yearly/custom + exceptions.""" from datetime import UTC, date, datetime from app.plugins.builtins.calendar.recurrence import ( _days_in_month, _extract_bydays, _extract_int, _extract_setpos, _nth_weekday_of_month, _parse_date, _parse_datetime, generate_occurrences, ) class TestParseDate: def test_parse_date_from_date(self): d = date(2026, 6, 15) assert _parse_date(d) == d def test_parse_date_from_datetime(self): dt = datetime(2026, 6, 15, 10, 30, tzinfo=UTC) assert _parse_date(dt) == date(2026, 6, 15) def test_parse_date_from_iso_string(self): assert _parse_date("2026-06-15") == date(2026, 6, 15) assert _parse_date("2026-06-15T10:30:00") == date(2026, 6, 15) def test_parse_date_from_invalid_string(self): assert _parse_date("not-a-date") is None def test_parse_date_none(self): assert _parse_date(None) is None class TestParseDatetime: def test_parse_datetime_from_datetime(self): dt = datetime(2026, 6, 15, 10, 30, tzinfo=UTC) assert _parse_datetime(dt) == dt def test_parse_datetime_from_date(self): d = date(2026, 6, 15) result = _parse_datetime(d) assert result == datetime(2026, 6, 15) def test_parse_datetime_from_iso_string(self): result = _parse_datetime("2026-06-15T10:30:00") assert result == datetime(2026, 6, 15, 10, 30) def test_parse_datetime_from_invalid_string(self): assert _parse_datetime("not-a-datetime") is None def test_parse_datetime_none(self): assert _parse_datetime(None) is None class TestGenerateOccurrencesDaily: def test_daily_basic(self): base = datetime(2026, 6, 1, 9, 0, tzinfo=UTC) rec = {"pattern": "daily"} occs = generate_occurrences(rec, base, date(2026, 6, 1), date(2026, 6, 5)) assert len(occs) == 5 assert occs[0].date() == date(2026, 6, 1) assert occs[-1].date() == date(2026, 6, 5) def test_daily_with_interval(self): base = datetime(2026, 6, 1, 9, 0, tzinfo=UTC) rec = {"pattern": "daily", "custom_rule": "INTERVAL=2"} occs = generate_occurrences(rec, base, date(2026, 6, 1), date(2026, 6, 7)) assert len(occs) == 4 assert occs[0].date() == date(2026, 6, 1) assert occs[1].date() == date(2026, 6, 3) def test_daily_with_exception(self): base = datetime(2026, 6, 1, 9, 0, tzinfo=UTC) rec = {"pattern": "daily", "exceptions": ["2026-06-02", "2026-06-04"]} occs = generate_occurrences(rec, base, date(2026, 6, 1), date(2026, 6, 5)) assert len(occs) == 3 dates = [o.date() for o in occs] assert date(2026, 6, 2) not in dates assert date(2026, 6, 4) not in dates def test_daily_with_end_date(self): base = datetime(2026, 6, 1, 9, 0, tzinfo=UTC) rec = {"pattern": "daily", "end_date": "2026-06-03"} occs = generate_occurrences(rec, base, date(2026, 6, 1), date(2026, 6, 10)) assert len(occs) == 3 def test_no_recurrence(self): base = datetime(2026, 6, 1, 9, 0, tzinfo=UTC) occs = generate_occurrences(None, base, date(2026, 6, 1), date(2026, 6, 5)) assert len(occs) == 1 assert occs[0].date() == date(2026, 6, 1) def test_no_base_start(self): occs = generate_occurrences({"pattern": "daily"}, None, date(2026, 6, 1), date(2026, 6, 5)) assert occs == [] class TestGenerateOccurrencesWeekly: def test_weekly_basic(self): base = datetime(2026, 6, 2, 9, 0, tzinfo=UTC) rec = {"pattern": "weekly"} occs = generate_occurrences(rec, base, date(2026, 6, 1), date(2026, 6, 22)) assert len(occs) == 3 assert all(o.weekday() == 1 for o in occs) def test_weekly_with_bydays(self): base = datetime(2026, 6, 1, 9, 0, tzinfo=UTC) rec = {"pattern": "weekly", "custom_rule": "BYDAY=MO,WE,FR"} occs = generate_occurrences(rec, base, date(2026, 6, 1), date(2026, 6, 7)) assert len(occs) == 3 def test_weekly_with_interval(self): base = datetime(2026, 6, 2, 9, 0, tzinfo=UTC) rec = {"pattern": "weekly", "custom_rule": "INTERVAL=2"} occs = generate_occurrences(rec, base, date(2026, 6, 1), date(2026, 6, 30)) assert len(occs) == 3 class TestGenerateOccurrencesMonthly: def test_monthly_basic(self): base = datetime(2026, 6, 15, 10, 0, tzinfo=UTC) rec = {"pattern": "monthly"} occs = generate_occurrences(rec, base, date(2026, 6, 1), date(2026, 9, 30)) assert len(occs) == 4 assert all(o.day == 15 for o in occs) def test_monthly_with_interval(self): base = datetime(2026, 6, 15, 10, 0, tzinfo=UTC) rec = {"pattern": "monthly", "custom_rule": "INTERVAL=2"} occs = generate_occurrences(rec, base, date(2026, 6, 1), date(2026, 12, 31)) assert len(occs) == 4 def test_monthly_with_bysetpos(self): base = datetime(2026, 6, 1, 10, 0, tzinfo=UTC) rec = {"pattern": "monthly", "custom_rule": "BYSETPOS=2"} occs = generate_occurrences(rec, base, date(2026, 6, 1), date(2026, 7, 31)) assert len(occs) == 2 class TestGenerateOccurrencesYearly: def test_yearly_basic(self): base = datetime(2026, 6, 15, 10, 0, tzinfo=UTC) rec = {"pattern": "yearly"} occs = generate_occurrences(rec, base, date(2026, 1, 1), date(2028, 12, 31)) assert len(occs) == 3 assert all(o.month == 6 for o in occs) assert all(o.day == 15 for o in occs) def test_yearly_with_interval(self): base = datetime(2026, 6, 15, 10, 0, tzinfo=UTC) rec = {"pattern": "yearly", "custom_rule": "INTERVAL=2"} occs = generate_occurrences(rec, base, date(2026, 1, 1), date(2030, 12, 31)) assert len(occs) == 2 # 2026, 2028 (2030 beyond 2-year cap) class TestGenerateOccurrencesCustom: def test_custom_with_bydays(self): base = datetime(2026, 6, 1, 9, 0, tzinfo=UTC) rec = {"pattern": "custom", "custom_rule": "INTERVAL=1;BYDAY=TU,TH"} occs = generate_occurrences(rec, base, date(2026, 6, 1), date(2026, 6, 7)) assert len(occs) == 2 class TestExtractHelpers: def test_extract_int(self): assert _extract_int("INTERVAL=2;BYDAY=MO", "INTERVAL") == 2 assert _extract_int("BYDAY=MO", "INTERVAL") == 1 def test_extract_bydays(self): assert _extract_bydays("BYDAY=MO,WE,FR") == ["MO", "WE", "FR"] assert _extract_bydays("INTERVAL=2") is None def test_extract_setpos(self): assert _extract_setpos("BYSETPOS=2") == [2] assert _extract_setpos("BYSETPOS=1,3") == [1, 3] assert _extract_setpos("INTERVAL=2") is None class TestDateHelpers: def test_days_in_month(self): assert _days_in_month(2026, 2) == 28 assert _days_in_month(2024, 2) == 29 assert _days_in_month(2026, 12) == 31 assert _days_in_month(2026, 4) == 30 def test_nth_weekday_of_month_positive(self): first_mon = _nth_weekday_of_month(2026, 6, 1, 0) assert first_mon == date(2026, 6, 1) second_mon = _nth_weekday_of_month(2026, 6, 2, 0) assert second_mon == date(2026, 6, 8) def test_nth_weekday_of_month_negative(self): last_mon = _nth_weekday_of_month(2026, 6, -1, 0) assert last_mon == date(2026, 6, 29) def test_nth_weekday_of_month_invalid(self): result = _nth_weekday_of_month(2026, 2, 5, 0) assert result is None class TestEdgeCases: def test_range_before_base_start(self): base = datetime(2026, 6, 15, 9, 0, tzinfo=UTC) rec = {"pattern": "daily"} occs = generate_occurrences(rec, base, date(2026, 6, 1), date(2026, 6, 10)) assert occs == [] def test_range_after_end_date(self): base = datetime(2026, 6, 1, 9, 0, tzinfo=UTC) rec = {"pattern": "daily", "end_date": "2026-06-05"} occs = generate_occurrences(rec, base, date(2026, 6, 10), date(2026, 6, 20)) assert occs == [] def test_unknown_pattern(self): base = datetime(2026, 6, 1, 9, 0, tzinfo=UTC) rec = {"pattern": "unknown"} occs = generate_occurrences(rec, base, date(2026, 6, 1), date(2026, 6, 5)) assert occs == [] def test_max_2_years_forward(self): base = datetime(2026, 6, 1, 9, 0, tzinfo=UTC) rec = {"pattern": "daily"} occs = generate_occurrences(rec, base, date(2026, 6, 1), date(2030, 6, 1)) assert len(occs) <= 731