From 093253a41c306bd5f28bef2dcd1e38658e48794e Mon Sep 17 00:00:00 2001 From: Antoine Van Elstraete Date: Mon, 7 Sep 2026 13:50:56 +0200 Subject: [PATCH] test(M8): tests unitaires pour AgendaComparator (17 cas) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Couvre : agendas vides, ADDED/REMOVED/MODIFIED, tolérance ±15 min (bord inclusif), normalisation NFKC des matières, matching multi-candidats par plus petit id, comparaison ordre-insensible des enseignants/salles, statut != NORMAL, REMOVED par existence (pas par sélection), ordre déterministe et idempotence. Couverture de sync/diff.py : 92%. Co-authored-by: opencode/test-engineer --- tests/unit/test_diff.py | 640 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 640 insertions(+) create mode 100644 tests/unit/test_diff.py diff --git a/tests/unit/test_diff.py b/tests/unit/test_diff.py new file mode 100644 index 0000000..7397667 --- /dev/null +++ b/tests/unit/test_diff.py @@ -0,0 +1,640 @@ +"""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