"""Тесты recurrence-ядра (ADR-001, п.3): `RecurrenceRule` и `expand_occurrences`. Пишутся ДО реализации `services/recurrence.py` (TDD). Покрывают все 4 типа повторения, клэмп 31-го числа, переход года, пустой диапазон, включительность границ диапазона и корректность конвертации локального времени в UTC. """ from datetime import UTC, datetime, timedelta import pytest from pydantic import ValidationError from services.recurrence import RecurrenceRule, expand_occurrences def _rule(**overrides: object) -> RecurrenceRule: """Собрать валидное правило с разумными умолчаниями, переопределяя нужные поля.""" defaults: dict[str, object] = { "type": "weekly", "weekdays": [0], "anchor_date": datetime(2026, 1, 5).date(), # понедельник "time_local": "10:00", "timezone": "UTC", "duration_minutes": 60, } defaults.update(overrides) return RecurrenceRule.model_validate(defaults) class TestRecurrenceRuleValidation: """Валидация Pydantic-модели правила.""" def test_weekly_requires_weekdays(self) -> None: with pytest.raises(ValidationError): _rule(type="weekly", weekdays=[]) def test_monthly_requires_day_of_month(self) -> None: with pytest.raises(ValidationError): _rule(type="monthly", day_of_month=None) def test_every_n_days_requires_interval_days(self) -> None: with pytest.raises(ValidationError): _rule(type="every_n_days", interval_days=None) def test_weekday_out_of_range_rejected(self) -> None: with pytest.raises(ValidationError): _rule(weekdays=[7]) def test_day_of_month_out_of_range_rejected(self) -> None: with pytest.raises(ValidationError): _rule(type="monthly", day_of_month=32) def test_interval_days_must_be_positive(self) -> None: with pytest.raises(ValidationError): _rule(type="every_n_days", interval_days=0) def test_invalid_time_format_rejected(self) -> None: with pytest.raises(ValidationError): _rule(time_local="25:99") def test_invalid_timezone_rejected(self) -> None: with pytest.raises(ValidationError): _rule(timezone="Not/AZone") class TestExpandOccurrencesWeekly: """type='weekly': вхождение на каждой указанной неделе для всех weekdays.""" def test_multiple_weekdays_in_one_week(self) -> None: # Пн(0)/Ср(2)/Пт(4), 10:00 UTC, неделя с 2026-01-05 (пн) по 2026-01-11 (вс) rule = _rule(type="weekly", weekdays=[0, 2, 4]) occurrences = expand_occurrences( rule, datetime(2026, 1, 5, tzinfo=UTC), datetime(2026, 1, 11, 23, 59, tzinfo=UTC), ) assert occurrences == [ datetime(2026, 1, 5, 10, 0, tzinfo=UTC), datetime(2026, 1, 7, 10, 0, tzinfo=UTC), datetime(2026, 1, 9, 10, 0, tzinfo=UTC), ] def test_repeats_every_week(self) -> None: rule = _rule(type="weekly", weekdays=[0]) occurrences = expand_occurrences( rule, datetime(2026, 1, 1, tzinfo=UTC), datetime(2026, 1, 31, tzinfo=UTC), ) # Понедельники января 2026: 5, 12, 19, 26 assert [d.day for d in occurrences] == [5, 12, 19, 26] assert all(d.hour == 10 for d in occurrences) class TestExpandOccurrencesBiweekly: """type='biweekly': чётность недель считается от `anchor_date`.""" def test_every_other_week_from_anchor(self) -> None: # anchor 2026-01-05 (пн) — неделя-0 включена, неделя-1 (12-е) исключена, # неделя-2 (19-е) включена и т.д. rule = _rule(type="biweekly", weekdays=[0], anchor_date=datetime(2026, 1, 5).date()) occurrences = expand_occurrences( rule, datetime(2026, 1, 1, tzinfo=UTC), datetime(2026, 2, 28, tzinfo=UTC), ) assert [d.day for d in occurrences[:3]] == [5, 19, 2] # 2 февраля def test_anchor_not_matching_weekday_still_defines_parity(self) -> None: # anchor в среду той же недели, что и понедельник 2026-01-05 — # чётность определяется по началу недели anchor, а не по его weekday. rule = _rule(type="biweekly", weekdays=[0], anchor_date=datetime(2026, 1, 7).date()) occurrences = expand_occurrences( rule, datetime(2026, 1, 1, tzinfo=UTC), datetime(2026, 1, 31, tzinfo=UTC), ) assert [d.day for d in occurrences] == [5, 19] class TestExpandOccurrencesMonthly: """type='monthly': day_of_month=31 клэмпится к последнему дню короткого месяца.""" def test_31_clamped_in_short_months(self) -> None: rule = _rule(type="monthly", weekdays=[], day_of_month=31) occurrences = expand_occurrences( rule, datetime(2026, 1, 1, tzinfo=UTC), datetime(2026, 4, 30, 23, 59, tzinfo=UTC), ) # 2026 — невисокосный год: январь(31), февраль(28), март(31), апрель(30) assert [(d.month, d.day) for d in occurrences] == [ (1, 31), (2, 28), (3, 31), (4, 30), ] def test_31_clamped_to_29_in_leap_february(self) -> None: rule = _rule(type="monthly", weekdays=[], day_of_month=31) occurrences = expand_occurrences( rule, datetime(2024, 2, 1, tzinfo=UTC), datetime(2024, 2, 29, 23, 59, tzinfo=UTC), ) assert [(d.month, d.day) for d in occurrences] == [(2, 29)] def test_regular_day_of_month(self) -> None: rule = _rule(type="monthly", weekdays=[], day_of_month=15) occurrences = expand_occurrences( rule, datetime(2026, 1, 1, tzinfo=UTC), datetime(2026, 3, 31, tzinfo=UTC), ) assert [d.day for d in occurrences] == [15, 15, 15] class TestExpandOccurrencesEveryNDays: """type='every_n_days': шаг фиксированного числа дней от `anchor_date`.""" def test_step_of_five_days(self) -> None: rule = _rule( type="every_n_days", weekdays=[], interval_days=5, anchor_date=datetime(2026, 1, 1).date(), ) occurrences = expand_occurrences( rule, datetime(2026, 1, 1, tzinfo=UTC), datetime(2026, 1, 20, tzinfo=UTC), ) assert [d.day for d in occurrences] == [1, 6, 11, 16] def test_before_anchor_no_occurrences(self) -> None: rule = _rule( type="every_n_days", weekdays=[], interval_days=3, anchor_date=datetime(2026, 1, 10).date(), ) occurrences = expand_occurrences( rule, datetime(2026, 1, 1, tzinfo=UTC), datetime(2026, 1, 9, 23, 59, tzinfo=UTC), ) assert occurrences == [] class TestYearTransition: """Развёртка не должна ломаться на переходе декабрь -> январь.""" def test_weekly_across_year_boundary(self) -> None: rule = _rule(type="weekly", weekdays=[3]) # четверг occurrences = expand_occurrences( rule, datetime(2025, 12, 25, tzinfo=UTC), datetime(2026, 1, 8, 23, 59, tzinfo=UTC), ) assert [(d.year, d.month, d.day) for d in occurrences] == [ (2025, 12, 25), (2026, 1, 1), (2026, 1, 8), ] def test_every_n_days_across_year_boundary(self) -> None: rule = _rule( type="every_n_days", weekdays=[], interval_days=10, anchor_date=datetime(2025, 12, 20).date(), ) occurrences = expand_occurrences( rule, datetime(2025, 12, 20, tzinfo=UTC), datetime(2026, 1, 10, tzinfo=UTC), ) assert [(d.year, d.month, d.day) for d in occurrences] == [ (2025, 12, 20), (2025, 12, 30), (2026, 1, 9), ] class TestRangeEdgeCases: """Пустой диапазон и включительность границ.""" def test_empty_range_when_from_after_to(self) -> None: rule = _rule(type="weekly", weekdays=[0, 1, 2, 3, 4, 5, 6]) occurrences = expand_occurrences( rule, datetime(2026, 1, 10, tzinfo=UTC), datetime(2026, 1, 1, tzinfo=UTC), ) assert occurrences == [] def test_boundaries_are_inclusive(self) -> None: # Вхождение ровно в момент t_from и ровно в момент t_to должно попасть # в результат (обе границы включительны). rule = _rule(type="weekly", weekdays=[0], time_local="10:00", timezone="UTC") t_from = datetime(2026, 1, 5, 10, 0, tzinfo=UTC) t_to = datetime(2026, 1, 12, 10, 0, tzinfo=UTC) occurrences = expand_occurrences(rule, t_from, t_to) assert occurrences[0] == t_from assert occurrences[-1] == t_to def test_one_minute_outside_boundaries_excluded(self) -> None: rule = _rule(type="weekly", weekdays=[0], time_local="10:00", timezone="UTC") t_from = datetime(2026, 1, 5, 10, 1, tzinfo=UTC) t_to = datetime(2026, 1, 12, 9, 59, tzinfo=UTC) occurrences = expand_occurrences(rule, t_from, t_to) assert occurrences == [] class TestTimezoneOffset: """Правило хранит локальное время + IANA tz; развёртка возвращает aware UTC.""" def test_local_time_converted_to_utc(self) -> None: # Europe/Moscow — постоянное смещение UTC+3 без перехода на летнее время. rule = _rule( type="weekly", weekdays=[0], time_local="10:00", timezone="Europe/Moscow", ) occurrences = expand_occurrences( rule, datetime(2026, 1, 5, tzinfo=UTC), datetime(2026, 1, 5, 23, 59, tzinfo=UTC), ) assert occurrences == [datetime(2026, 1, 5, 7, 0, tzinfo=UTC)] def test_negative_offset_timezone(self) -> None: # America/New_York в начале января — зимнее время, UTC-5. rule = _rule( type="weekly", weekdays=[0], time_local="09:00", timezone="America/New_York", ) occurrences = expand_occurrences( rule, datetime(2026, 1, 5, tzinfo=UTC), datetime(2026, 1, 5, 23, 59, tzinfo=UTC), ) assert occurrences == [datetime(2026, 1, 5, 14, 0, tzinfo=UTC)] def test_result_timestamps_are_utc_aware(self) -> None: rule = _rule(type="weekly", weekdays=[0], timezone="Europe/Moscow") occurrences = expand_occurrences( rule, datetime(2026, 1, 1, tzinfo=UTC), datetime(2026, 1, 31, tzinfo=UTC), ) assert occurrences assert all(d.tzinfo is not None and d.utcoffset() == timedelta(0) for d in occurrences)