"""Unit tests for AgendaComparator in pronote_sync/sync/diff.py.""" from __future__ import annotations from datetime import date, datetime, time from typing import override import pytest from pronote_sync.models.agenda import Lesson, LessonStatus, TheoreticalLesson from pronote_sync.models.diff import AgendaChangeType from pronote_sync.sources.theoretical.provider import TheoreticalAgendaProvider from pronote_sync.sync.diff import AgendaComparator class _StubProvider(TheoreticalAgendaProvider): """Stub implementation of TheoreticalAgendaProvider for testing.""" def __init__(self, lessons: list[TheoreticalLesson]) -> None: """Initialize with a fixed list of theoretical lessons.""" self._lessons = lessons @override def get_lessons(self, target_date: date) -> list[TheoreticalLesson]: """Return the stub lessons regardless of target_date.""" return self._lessons.copy() @override def get_lessons_for_range(self, start_date: date, end_date: date) -> list[TheoreticalLesson]: """Return the stub lessons regardless of date range.""" return self._lessons.copy() # Target date: Monday, 2025-09-15 (weekday() = 0) TARGET_DATE = date(2025, 9, 15) @pytest.fixture(name="empty_provider") def fixture_empty_provider() -> _StubProvider: """Provider with no theoretical lessons.""" return _StubProvider([]) @pytest.fixture(name="comparator") def fixture_comparator(empty_provider: _StubProvider) -> AgendaComparator: """AgendaComparator with empty provider.""" return AgendaComparator(empty_provider) # ==================== Test Case 1: Empty agendas ==================== def test_empty_agendas(comparator: AgendaComparator) -> None: """No real, no theoretical → AgendaDiff with empty changes.""" result = comparator.compare([], TARGET_DATE) assert result.target_date == TARGET_DATE assert result.changes == () # ==================== Test Case 2: Empty theoretical, real lessons present ==================== def test_empty_theoretical_real_present(comparator: AgendaComparator) -> None: """Empty theoretical, real lessons present → all real → ADDED.""" real_lessons = [ Lesson( id="real_1", start=datetime(2025, 9, 15, 10, 0, 0), end=datetime(2025, 9, 15, 11, 0, 0), subject="Mathématiques", group=None, content=None, ), Lesson( id="real_2", start=datetime(2025, 9, 15, 14, 0, 0), end=datetime(2025, 9, 15, 15, 0, 0), subject="Français", group=None, content=None, ), ] result = comparator.compare(real_lessons, TARGET_DATE) assert len(result.changes) == 2 assert result.changes[0].type == AgendaChangeType.ADDED assert result.changes[0].lesson == real_lessons[0] assert result.changes[0].theoretical_lesson is None assert result.changes[0].details == "Cours ajouté par rapport à l'agenda théorique" assert result.changes[1].type == AgendaChangeType.ADDED assert result.changes[1].lesson == real_lessons[1] # ==================== Test Case 3: Empty real, theoretical present ==================== def test_empty_real_theoretical_present() -> None: """Empty real, theoretical present → all theoretical → REMOVED, sorted by id.""" theoretical_lessons = [ TheoreticalLesson( id="theo_b", day_of_week=0, start_time=time(10, 0), end_time=time(11, 0), subject="Mathématiques", ), TheoreticalLesson( id="theo_a", day_of_week=0, start_time=time(14, 0), end_time=time(15, 0), subject="Français", ), ] provider = _StubProvider(theoretical_lessons) comparator = AgendaComparator(provider) result = comparator.compare([], TARGET_DATE) assert len(result.changes) == 2 assert result.changes[0].type == AgendaChangeType.REMOVED assert result.changes[0].theoretical_lesson == theoretical_lessons[1] # theo_a first assert result.changes[0].lesson is None assert result.changes[0].details == "Cours supprimé par rapport à l'agenda théorique" assert result.changes[1].type == AgendaChangeType.REMOVED assert result.changes[1].theoretical_lesson == theoretical_lessons[0] # theo_b second # ==================== Test Case 4: Exact match ==================== def test_exact_match() -> None: """Real and theoretical at same time, same subject → no changes.""" theoretical_lessons = [ TheoreticalLesson( id="theo_1", day_of_week=0, start_time=time(10, 0), end_time=time(11, 0), subject="Mathématiques", ), ] real_lessons = [ Lesson( id="real_1", start=datetime(2025, 9, 15, 10, 0, 0), end=datetime(2025, 9, 15, 11, 0, 0), subject="Mathématiques", group=None, content=None, ), ] provider = _StubProvider(theoretical_lessons) comparator = AgendaComparator(provider) result = comparator.compare(real_lessons, TARGET_DATE) assert result.changes == () # ==================== Test Case 5: Within tolerance (±14 min) ==================== def test_within_tolerance_14min() -> None: """Real start 14 min before theoretical → match, MODIFIED (horaires different).""" theoretical_lessons = [ TheoreticalLesson( id="theo_1", day_of_week=0, start_time=time(10, 0), end_time=time(11, 0), subject="Mathématiques", ), ] real_lessons = [ Lesson( id="real_1", start=datetime(2025, 9, 15, 9, 46, 0), end=datetime(2025, 9, 15, 10, 46, 0), subject="Mathématiques", group=None, content=None, ), ] provider = _StubProvider(theoretical_lessons) comparator = AgendaComparator(provider) result = comparator.compare(real_lessons, TARGET_DATE) assert len(result.changes) == 1 assert result.changes[0].type == AgendaChangeType.MODIFIED assert result.changes[0].lesson == real_lessons[0] assert result.changes[0].theoretical_lesson == theoretical_lessons[0] assert "horaires: 10:00–11:00 → 09:46–10:46" in result.changes[0].details # ==================== Test Case 6: At tolerance boundary (exactly 15 min) ==================== def test_at_tolerance_boundary_15min() -> None: """Real start exactly 15 min from theoretical → match (inclusive), MODIFIED.""" theoretical_lessons = [ TheoreticalLesson( id="theo_1", day_of_week=0, start_time=time(10, 0), end_time=time(11, 0), subject="Mathématiques", ), ] real_lessons = [ Lesson( id="real_1", start=datetime(2025, 9, 15, 9, 45, 0), end=datetime(2025, 9, 15, 10, 45, 0), subject="Mathématiques", group=None, content=None, ), ] provider = _StubProvider(theoretical_lessons) comparator = AgendaComparator(provider) result = comparator.compare(real_lessons, TARGET_DATE) assert len(result.changes) == 1 assert result.changes[0].type == AgendaChangeType.MODIFIED assert result.changes[0].lesson == real_lessons[0] assert result.changes[0].theoretical_lesson == theoretical_lessons[0] assert "horaires: 10:00–11:00 → 09:45–10:45" in result.changes[0].details # ==================== Test Case 7: Outside tolerance (16 min) ==================== def test_outside_tolerance_16min() -> None: """Real start 16 min from theoretical → no match → ADDED + REMOVED.""" theoretical_lessons = [ TheoreticalLesson( id="theo_1", day_of_week=0, start_time=time(10, 0), end_time=time(11, 0), subject="Mathématiques", ), ] real_lessons = [ Lesson( id="real_1", start=datetime(2025, 9, 15, 9, 44, 0), end=datetime(2025, 9, 15, 10, 44, 0), subject="Mathématiques", group=None, content=None, ), ] provider = _StubProvider(theoretical_lessons) comparator = AgendaComparator(provider) result = comparator.compare(real_lessons, TARGET_DATE) assert len(result.changes) == 2 assert result.changes[0].type == AgendaChangeType.ADDED assert result.changes[0].lesson == real_lessons[0] assert result.changes[1].type == AgendaChangeType.REMOVED assert result.changes[1].theoretical_lesson == theoretical_lessons[0] # ==================== Test Case 8: Subject normalization match ==================== def test_subject_normalization_match() -> None: """Real '\\u212BNGSTRÖM' (angstrom sign), theoretical 'ångström' → NFKC → same form, match, no change.""" theoretical_lessons = [ TheoreticalLesson( id="theo_1", day_of_week=0, start_time=time(10, 0), end_time=time(11, 0), subject="ångström", ), ] real_lessons = [ Lesson( id="real_1", start=datetime(2025, 9, 15, 10, 0, 0), end=datetime(2025, 9, 15, 11, 0, 0), subject="\u212bNGSTRÖM", group=None, content=None, ), ] provider = _StubProvider(theoretical_lessons) comparator = AgendaComparator(provider) result = comparator.compare(real_lessons, TARGET_DATE) assert result.changes == () # ==================== Test Case 9: Different normalized subjects ==================== def test_different_normalized_subjects() -> None: """Real 'Mathématiques', theoretical 'Français' → no match → ADDED + REMOVED.""" theoretical_lessons = [ TheoreticalLesson( id="theo_1", day_of_week=0, start_time=time(10, 0), end_time=time(11, 0), subject="Français", ), ] real_lessons = [ Lesson( id="real_1", start=datetime(2025, 9, 15, 10, 0, 0), end=datetime(2025, 9, 15, 11, 0, 0), subject="Mathématiques", group=None, content=None, ), ] provider = _StubProvider(theoretical_lessons) comparator = AgendaComparator(provider) result = comparator.compare(real_lessons, TARGET_DATE) assert len(result.changes) == 2 assert result.changes[0].type == AgendaChangeType.ADDED assert result.changes[0].lesson == real_lessons[0] assert result.changes[1].type == AgendaChangeType.REMOVED assert result.changes[1].theoretical_lesson == theoretical_lessons[0] # ==================== Test Case 10: Multi-candidate selection by id ==================== def test_multi_candidate_selection_by_id() -> None: """Two theoretical candidates match one real → select the smaller id (theo_a). theo_a (smaller id) has teachers identical to the real lesson (no MODIFIED); theo_b (larger id) has different teachers and would trigger MODIFIED if selected. A zero-change diff therefore proves theo_a was selected. """ theoretical_lessons = [ TheoreticalLesson( id="theo_b", day_of_week=0, start_time=time(10, 0), end_time=time(11, 0), subject="Mathématiques", teachers=("Mme Martin",), ), TheoreticalLesson( id="theo_a", day_of_week=0, start_time=time(10, 0), end_time=time(11, 0), subject="Mathématiques", teachers=("M. Dupont",), ), ] real_lessons = [ Lesson( id="real_1", start=datetime(2025, 9, 15, 10, 0, 0), end=datetime(2025, 9, 15, 11, 0, 0), subject="Mathématiques", teachers=("M. Dupont",), group=None, content=None, ), ] provider = _StubProvider(theoretical_lessons) comparator = AgendaComparator(provider) result = comparator.compare(real_lessons, TARGET_DATE) # theo_a (smaller id) selected with identical teachers → no MODIFIED; theo_b matched by existence → not REMOVED assert result.changes == () # ==================== Test Case 11: MODIFIED — teachers differ (order-insensitive) ==================== def test_teachers_differ_order_insensitive() -> None: """Real teachers ('M. Dupont', 'Mme Martin'), theoretical ('Mme Martin', 'M. Dupont') → match, NOT modified (same set).""" theoretical_lessons = [ TheoreticalLesson( id="theo_1", day_of_week=0, start_time=time(10, 0), end_time=time(11, 0), subject="Mathématiques", teachers=("Mme Martin", "M. Dupont"), ), ] real_lessons = [ Lesson( id="real_1", start=datetime(2025, 9, 15, 10, 0, 0), end=datetime(2025, 9, 15, 11, 0, 0), subject="Mathématiques", teachers=("M. Dupont", "Mme Martin"), group=None, content=None, ), ] provider = _StubProvider(theoretical_lessons) comparator = AgendaComparator(provider) result = comparator.compare(real_lessons, TARGET_DATE) assert result.changes == () # ==================== Test Case 12: MODIFIED — teachers differ (different sets) ==================== def test_teachers_differ_different_sets() -> None: """Real ('M. Dupont',), theoretical ('Mme Martin',) → match, MODIFIED.""" theoretical_lessons = [ TheoreticalLesson( id="theo_1", day_of_week=0, start_time=time(10, 0), end_time=time(11, 0), subject="Mathématiques", teachers=("Mme Martin",), ), ] real_lessons = [ Lesson( id="real_1", start=datetime(2025, 9, 15, 10, 0, 0), end=datetime(2025, 9, 15, 11, 0, 0), subject="Mathématiques", teachers=("M. Dupont",), group=None, content=None, ), ] provider = _StubProvider(theoretical_lessons) comparator = AgendaComparator(provider) result = comparator.compare(real_lessons, TARGET_DATE) assert len(result.changes) == 1 assert result.changes[0].type == AgendaChangeType.MODIFIED assert "professeurs: {'Mme Martin'} → {'M. Dupont'}" in result.changes[0].details # ==================== Test Case 13: MODIFIED — rooms differ ==================== def test_rooms_differ() -> None: """Real ('Salle 12',), theoretical ('Salle 15',) → match, MODIFIED.""" theoretical_lessons = [ TheoreticalLesson( id="theo_1", day_of_week=0, start_time=time(10, 0), end_time=time(11, 0), subject="Mathématiques", rooms=("Salle 15",), ), ] real_lessons = [ Lesson( id="real_1", start=datetime(2025, 9, 15, 10, 0, 0), end=datetime(2025, 9, 15, 11, 0, 0), subject="Mathématiques", rooms=("Salle 12",), group=None, content=None, ), ] provider = _StubProvider(theoretical_lessons) comparator = AgendaComparator(provider) result = comparator.compare(real_lessons, TARGET_DATE) assert len(result.changes) == 1 assert result.changes[0].type == AgendaChangeType.MODIFIED assert "salles: {'Salle 15'} → {'Salle 12'}" in result.changes[0].details # ==================== Test Case 14: MODIFIED — status != NORMAL ==================== def test_status_not_normal() -> None: """Real status=CANCELLED, otherwise identical → match, MODIFIED with statut in details.""" theoretical_lessons = [ TheoreticalLesson( id="theo_1", day_of_week=0, start_time=time(10, 0), end_time=time(11, 0), subject="Mathématiques", ), ] real_lessons = [ Lesson( id="real_1", start=datetime(2025, 9, 15, 10, 0, 0), end=datetime(2025, 9, 15, 11, 0, 0), subject="Mathématiques", status=LessonStatus.CANCELLED, group=None, content=None, ), ] provider = _StubProvider(theoretical_lessons) comparator = AgendaComparator(provider) result = comparator.compare(real_lessons, TARGET_DATE) assert len(result.changes) == 1 assert result.changes[0].type == AgendaChangeType.MODIFIED assert "statut: cancelled" in result.changes[0].details # ==================== Test Case 15: REMOVED by existence, not selection ==================== def test_removed_by_existence_not_selection() -> None: """Two theoretical match one real; real selects the smaller id; the other theoretical is a candidate (exists) → NOT REMOVED.""" theoretical_lessons = [ TheoreticalLesson( id="theo_a", day_of_week=0, start_time=time(10, 0), end_time=time(11, 0), subject="Mathématiques", ), TheoreticalLesson( id="theo_b", day_of_week=0, start_time=time(10, 0), end_time=time(11, 0), subject="Mathématiques", ), ] real_lessons = [ Lesson( id="real_1", start=datetime(2025, 9, 15, 10, 0, 0), end=datetime(2025, 9, 15, 11, 0, 0), subject="Mathématiques", group=None, content=None, ), ] provider = _StubProvider(theoretical_lessons) comparator = AgendaComparator(provider) result = comparator.compare(real_lessons, TARGET_DATE) # Both theoretical lessons are candidates (matched by existence), so neither is REMOVED assert len(result.changes) == 0 # ==================== Test Case 16: Deterministic order ==================== def test_deterministic_order() -> None: """Multiple ADDED, MODIFIED, REMOVED in same run → verify exact order (reals in input order, then theoreticals sorted by id).""" theoretical_lessons = [ TheoreticalLesson( id="theo_c", day_of_week=0, start_time=time(15, 0), end_time=time(16, 0), subject="Histoire", ), TheoreticalLesson( id="theo_a", day_of_week=0, start_time=time(8, 0), end_time=time(9, 0), subject="Physique", ), TheoreticalLesson( id="theo_b", day_of_week=0, start_time=time(10, 0), end_time=time(11, 0), subject="Mathématiques", ), ] real_lessons = [ Lesson( id="real_1", start=datetime(2025, 9, 15, 10, 0, 0), end=datetime(2025, 9, 15, 11, 0, 0), subject="Mathématiques", teachers=("M. Dupont",), group=None, content=None, ), Lesson( id="real_2", start=datetime(2025, 9, 15, 14, 0, 0), end=datetime(2025, 9, 15, 15, 0, 0), subject="Informatique", group=None, content=None, ), ] provider = _StubProvider(theoretical_lessons) comparator = AgendaComparator(provider) result = comparator.compare(real_lessons, TARGET_DATE) # real_1 matches theo_b but has different teachers → MODIFIED # real_2 has no match → ADDED # theo_a and theo_c are not matched by existence → REMOVED (sorted by id: theo_a, theo_c) assert len(result.changes) == 4 # First: real_1 MODIFIED assert result.changes[0].type == AgendaChangeType.MODIFIED assert result.changes[0].lesson == real_lessons[0] # Second: real_2 ADDED assert result.changes[1].type == AgendaChangeType.ADDED assert result.changes[1].lesson == real_lessons[1] # Third: theo_a REMOVED assert result.changes[2].type == AgendaChangeType.REMOVED assert result.changes[2].theoretical_lesson == theoretical_lessons[1] # theo_a # Fourth: theo_c REMOVED assert result.changes[3].type == AgendaChangeType.REMOVED assert result.changes[3].theoretical_lesson == theoretical_lessons[0] # theo_c # ==================== Test Case 17: Idempotence ==================== def test_idempotence() -> None: """Call compare twice with same inputs → identical AgendaDiff.""" theoretical_lessons = [ TheoreticalLesson( id="theo_1", day_of_week=0, start_time=time(10, 0), end_time=time(11, 0), subject="Mathématiques", ), ] real_lessons = [ Lesson( id="real_1", start=datetime(2025, 9, 15, 10, 0, 0), end=datetime(2025, 9, 15, 11, 0, 0), subject="Mathématiques", group=None, content=None, ), ] provider = _StubProvider(theoretical_lessons) comparator = AgendaComparator(provider) result1 = comparator.compare(real_lessons, TARGET_DATE) result2 = comparator.compare(real_lessons, TARGET_DATE) assert result1 == result2