"""Unit tests for AgendaComparator in pronote_sync/sync/diff.py.""" from __future__ import annotations import logging 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 4bis: Seconds ignored in modification detection ==================== def test_seconds_ignored_in_modification_detection() -> None: """Real with seconds and theoretical without → same minutes → no MODIFIED. The real lesson starts at 10:00:30 and ends at 11:00:45 while the theoretical lesson is at 10:00–11:00. The minute-level times match (10:00 and 11:00), so the real lesson matches the theoretical one within the ±15 min tolerance and is NOT marked MODIFIED despite the differing seconds. """ 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, 30), end=datetime(2025, 9, 15, 11, 0, 45), 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: """1 real / 2 identical theoretical → the non-selected theoretical is REMOVED. Two theoretical candidates match one real; the real selects theo_a (the smaller id, identical teachers → no MODIFIED). theo_b (larger id) is not selected and, being unmatched, must be REMOVED. """ 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 change; theo_b unmatched → REMOVED assert len(result.changes) == 1 assert result.changes[0].type == AgendaChangeType.REMOVED assert result.changes[0].lesson is None assert result.changes[0].theoretical_lesson == theoretical_lessons[0] # theo_b # ==================== 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: Deterministic teachers formatting ==================== def test_teachers_sorted_in_details() -> None: """Multiple teachers → details list sorted alphabetically regardless of input order.""" theoretical_lessons = [ TheoreticalLesson( id="theo_1", day_of_week=0, start_time=time(10, 0), end_time=time(11, 0), subject="Mathématiques", teachers=("Chloe", "Alice", "Bob"), ), ] 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=("Bob", "Chloe"), 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].details == "professeurs: ['Alice', 'Bob', 'Chloe'] → ['Bob', 'Chloe']" # ==================== Test Case 15: Deterministic rooms formatting ==================== def test_rooms_sorted_in_details() -> None: """Multiple rooms → details list sorted alphabetically regardless of input order.""" theoretical_lessons = [ TheoreticalLesson( id="theo_1", day_of_week=0, start_time=time(10, 0), end_time=time(11, 0), subject="Mathématiques", rooms=("C101", "A102", "B103"), ), ] 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=("B103", "C101"), 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].details == "salles: ['A102', 'B103', 'C101'] → ['B103', 'C101']" # ==================== Test Case 16: Inter-process deterministic details ==================== def test_details_deterministic_sorted_exact() -> None: """MODIFIED details are exactly sorted, independent of teachers input order.""" theoretical_lessons = [ TheoreticalLesson( id="theo_1", day_of_week=0, start_time=time(10, 0), end_time=time(11, 0), subject="Mathématiques", teachers=("Alice", "Bob"), ), ] 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=("Bob", "Alice", "Chloe"), 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].details == "professeurs: ['Alice', 'Bob'] → ['Alice', 'Bob', 'Chloe']" # ==================== Test Case 17: 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: """1 real / 2 identical theoretical → the unmatched theoretical is REMOVED. The matching is one-to-one: the real consumes theo_a (smaller id) and theo_b remains available, hence REMOVED even though it is a candidate by existence. """ 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) # theo_a (smaller id) matched → no change; theo_b unmatched → REMOVED assert len(result.changes) == 1 assert result.changes[0].type == AgendaChangeType.REMOVED assert result.changes[0].lesson is None assert result.changes[0].theoretical_lesson == theoretical_lessons[1] # theo_b # ==================== 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 # ==================== Test Case 18: 2 reals identical / 1 theoretical → 1 ADDED ==================== def test_two_reals_one_theoretical_added() -> None: """2 identical reals / 1 matching theoretical → the surplus real is ADDED. The real with the smaller id is matched to the theoretical; the real with the larger id has no remaining candidate and must be ADDED. """ 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_b", 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_a", 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) # real_a (smaller id) matched to theo_1; real_b (larger id) unmatched → ADDED assert len(result.changes) == 1 assert result.changes[0].type == AgendaChangeType.ADDED assert result.changes[0].lesson == real_lessons[0] # real_b assert result.changes[0].theoretical_lesson is None # ==================== Test Case 19: Order stability ==================== def test_order_stability() -> None: """Presenting real lessons in different orders yields the same result.""" theoretical_lessons = [ TheoreticalLesson( id="theo_1", day_of_week=0, start_time=time(10, 0), end_time=time(11, 0), subject="Mathématiques", ), ] real_a = Lesson( id="real_a", start=datetime(2025, 9, 15, 10, 0, 0), end=datetime(2025, 9, 15, 11, 0, 0), subject="Mathématiques", group=None, content=None, ) real_b = Lesson( id="real_b", 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_ab = comparator.compare([real_a, real_b], TARGET_DATE) result_ba = comparator.compare([real_b, real_a], TARGET_DATE) # real_a matched to theo_1 (no change); real_b unmatched → ADDED assert result_ab == result_ba assert len(result_ab.changes) == 1 assert result_ab.changes[0].type == AgendaChangeType.ADDED assert result_ab.changes[0].lesson == real_b # ============ Test Case 20: Off-target-date real lesson is strictly filtered ============ def _theoretical_monday() -> TheoreticalLesson: """Theoretical Monday 10:00–11:00 in Mathematics.""" return TheoreticalLesson( id="theo_1", day_of_week=0, start_time=time(10, 0), end_time=time(11, 0), subject="Mathématiques", ) def _real_lesson(lesson_id: str, day: int, hour: int) -> Lesson: """Real lesson on 2025-09-15+``day`` days at ``hour``:00–:60.""" return Lesson( id=lesson_id, start=datetime(2025, 9, 15 + day, hour, 0, 0), end=datetime(2025, 9, 15 + day, hour + 1, 0, 0), subject="Mathématiques", group=None, content=None, ) def test_off_date_real_does_not_match() -> None: """A real on Tuesday must not match a theoretical Monday → REMOVED, no ADDED. The Tuesday real is filtered out (never produces ADDED) and the Monday theoretical, having no matching real, is REMOVED. """ theoretical_lessons = [_theoretical_monday()] real_lessons = [_real_lesson("real_tue", day=1, hour=10)] # Tuesday 2025-09-16 comparator = AgendaComparator(_StubProvider(theoretical_lessons)) result = comparator.compare(real_lessons, TARGET_DATE) assert len(result.changes) == 1 assert result.changes[0].type == AgendaChangeType.REMOVED assert result.changes[0].lesson is None assert result.changes[0].theoretical_lesson == theoretical_lessons[0] def test_off_date_filtered_and_in_date_matched() -> None: """A Tuesday real is ignored while a Monday real still matches the theoretical. The Tuesday real is excluded; the Monday real pairs with the theoretical, so the theoretical is not REMOVED and the on-date real produces no change. """ theoretical_lessons = [_theoretical_monday()] real_lessons = [ _real_lesson("real_tue", day=1, hour=14), # Tuesday, off target date _real_lesson("real_mon", day=0, hour=10), # Monday, on target date ] comparator = AgendaComparator(_StubProvider(theoretical_lessons)) result = comparator.compare(real_lessons, TARGET_DATE) assert result.changes == () def test_off_date_real_logs_warning(caplog: pytest.LogCaptureFixture) -> None: """An off-target-date real lesson logs a warning containing its id.""" theoretical_lessons = [_theoretical_monday()] real_lessons = [_real_lesson("real_out", day=1, hour=10)] comparator = AgendaComparator(_StubProvider(theoretical_lessons)) with caplog.at_level(logging.WARNING): comparator.compare(real_lessons, TARGET_DATE) assert any("real_out" in record.message for record in caplog.records) assert all(record.levelno >= logging.WARNING for record in caplog.records) def test_nominal_matching_produces_no_change() -> None: """An identical Monday real / Monday theoretical pair yields an empty diff. Confirms the strict date filtering does not break the nominal case. """ theoretical_lessons = [_theoretical_monday()] real_lessons = [_real_lesson("real_mon", day=0, hour=10)] comparator = AgendaComparator(_StubProvider(theoretical_lessons)) result = comparator.compare(real_lessons, TARGET_DATE) assert result.changes == ()