From c309bcbb644f05fdbb253686442c25e6a9d9f09a Mon Sep 17 00:00:00 2001 From: Antoine Van Elstraete Date: Mon, 7 Sep 2026 13:18:04 +0200 Subject: [PATCH 1/6] =?UTF-8?q?docs:=20marquer=20le=20jalon=20M7=20(synchr?= =?UTF-8?q?onisation=20CalDAV)=20comme=20termin=C3=A9=20dans=20TODO.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/TODO.md b/TODO.md index 7184034..02e12e2 100644 --- a/TODO.md +++ b/TODO.md @@ -135,13 +135,13 @@ Lire l'agenda théorique (JSON) via une interface de provider extensible, avec g Synchroniser différentiellement les événements Pronote vers le calendrier CalDAV, de façon idempotente. -- [ ] Créer `sync/caldav.py` : passerelle CalDAV isolant la bibliothèque `caldav>=1.3.0` (connexion via `DAVClient`, résolution du calendrier via `calendar_path`, récupération/ajout/MAJ/suppression des événements, marqueur `X-PRONOTE-SYNC-MANAGED: v1`). -- [ ] Calculer le `CalDAVSyncPlan` (to_add / to_update / to_remove) par UID stable, explicitement avant l'exécution de la sync. -- [ ] Implémenter l'exécution du plan : ajout, mise à jour (si modifié), suppression (si absent). En mode `dry_run`, loguer le plan sans écrire. -- [ ] Vérifier sur fixture anonymisée que le même cours provenant d'iCal et de `pronotepy` possède le même identifiant canonique ; corriger la normalisation des UID dans `sources/pronote/client.py` à la frontière des sources si nécessaire. -- [ ] Implémenter la sync différentielle : conserver les cours annulés (`STATUS:CANCELLED`), ne pas supprimer. -- [ ] Garantir l'idempotence (2 exécutions identiques → même `CalDAVSyncResult`), sans état local persistant (scan du calendrier distant). -- [ ] Ne jamais modifier ou supprimer les événements non marqués `X-PRONOTE-SYNC-MANAGED`. +- [x] Créer `sync/caldav.py` : passerelle CalDAV isolant la bibliothèque `caldav>=1.3.0` (connexion via `DAVClient`, résolution du calendrier via `calendar_path`, récupération/ajout/MAJ/suppression des événements, marqueur `X-PRONOTE-SYNC-MANAGED: v1`). +- [x] Calculer le `CalDAVSyncPlan` (to_add / to_update / to_remove) par UID stable, explicitement avant l'exécution de la sync. +- [x] Implémenter l'exécution du plan : ajout, mise à jour (si modifié), suppression (si absent). En mode `dry_run`, loguer le plan sans écrire. +- [x] Vérifier sur fixture anonymisée que le même cours provenant d'iCal et de `pronotepy` possède le même identifiant canonique ; corriger la normalisation des UID dans `sources/pronote/client.py` à la frontière des sources si nécessaire. +- [x] Implémenter la sync différentielle : conserver les cours annulés (`STATUS:CANCELLED`), ne pas supprimer. +- [x] Garantir l'idempotence (2 exécutions identiques → même `CalDAVSyncResult`), sans état local persistant (scan du calendrier distant). +- [x] Ne jamais modifier ou supprimer les événements non marqués `X-PRONOTE-SYNC-MANAGED`. ### Critères d'acceptation From 557555c65b2683ef62f3a1e9b167d60519fb020f Mon Sep 17 00:00:00 2001 From: Antoine Van Elstraete Date: Mon, 7 Sep 2026 13:30:15 +0200 Subject: [PATCH 2/6] =?UTF-8?q?refactor:=20d=C3=A9placer=20normalize=5Fsub?= =?UTF-8?q?ject=20vers=20utils/text.py=20avec=20r=C3=A9-export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La normalisation des matières (NFKC + espaces + ponctuation + minuscules) est désormais dans pronote_sync/utils/text.py pour permettre son partage entre sources/theoretical/file.py et sync/diff.py (M8) sans couplage de couche. L'import depuis file.py est préservé par ré-export explicite. Co-authored-by: opencode/coder --- pronote_sync/sources/theoretical/file.py | 22 +--------------- pronote_sync/utils/text.py | 32 ++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 21 deletions(-) create mode 100644 pronote_sync/utils/text.py diff --git a/pronote_sync/sources/theoretical/file.py b/pronote_sync/sources/theoretical/file.py index 0da9218..633b077 100644 --- a/pronote_sync/sources/theoretical/file.py +++ b/pronote_sync/sources/theoretical/file.py @@ -10,8 +10,6 @@ plage de dates. Le filtrage tient compte du jour de la semaine, de la parité de from __future__ import annotations import logging -import re -import unicodedata from datetime import date, time, timedelta from pathlib import Path from typing import Literal @@ -22,29 +20,11 @@ from pronote_sync.sources.theoretical.holidays import SchoolHolidayCalendar from pronote_sync.sources.theoretical.model import TheoreticalAgendaFile, TheoreticalLessonEntry from pronote_sync.sources.theoretical.parity import WeekParityService from pronote_sync.utils.redaction import redact_exception, redact_secrets +from pronote_sync.utils.text import normalize_subject as normalize_subject logger = logging.getLogger(__name__) -def normalize_subject(subject: str) -> str: - """Normalise une matière pour le matching déterministe. - - Applique la normalisation Unicode NFKC, unifie les espaces (y compris - tabulations et espaces insécables), supprime la ponctuation et met la - chaîne en minuscules. Deux représentations visuellement identiques d'une - même matière produisent ainsi la même forme normalisée. - - :param subject: La matière brute. - :return: La forme normalisée (NFKC, espaces unifiés, sans ponctuation, minuscule). - :rtype: str - """ - normalized = unicodedata.normalize("NFKC", subject) - normalized = re.sub(r"\s+", " ", normalized).strip() - normalized = re.sub(r"[^\w\s]", "", normalized) - normalized = re.sub(r"\s+", " ", normalized).strip() - return normalized.lower() - - def _generate_id(entry: TheoreticalLessonEntry) -> str: """Génère un identifiant déterministe pour une entrée de cours. diff --git a/pronote_sync/utils/text.py b/pronote_sync/utils/text.py new file mode 100644 index 0000000..199091b --- /dev/null +++ b/pronote_sync/utils/text.py @@ -0,0 +1,32 @@ +"""Utilitaires de normalisation et de traitement du texte. + +Ce module centralise les transformations de texte partagées par plusieurs +couches du pipeline ``pronote-sync`` (sources, synchronisation) afin que les +modules de logique de domaine ne dépendent pas d'adaptateurs concrets. +""" + +from __future__ import annotations + +import re +import unicodedata + +__all__ = ["normalize_subject"] + + +def normalize_subject(subject: str) -> str: + """Normalise une matière pour le matching déterministe. + + Applique la normalisation Unicode NFKC, unifie les espaces (y compris + tabulations et espaces insécables), supprime la ponctuation et met la + chaîne en minuscules. Deux représentations visuellement identiques d'une + même matière produisent ainsi la même forme normalisée. + + :param subject: La matière brute. + :return: La forme normalisée (NFKC, espaces unifiés, sans ponctuation, minuscule). + :rtype: str + """ + normalized = unicodedata.normalize("NFKC", subject) + normalized = re.sub(r"\s+", " ", normalized).strip() + normalized = re.sub(r"[^\w\s]", "", normalized) + normalized = re.sub(r"\s+", " ", normalized).strip() + return normalized.lower() From 10e5f2250147f841d372643cf38ca931922d6731 Mon Sep 17 00:00:00 2001 From: Antoine Van Elstraete Date: Mon, 7 Sep 2026 13:39:22 +0200 Subject: [PATCH 3/6] feat(M8): comparateur d'agenda (AgendaComparator) dans sync/diff.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comparaison déterministe entre l'agenda réel (Lesson) et l'agenda théorique (TheoreticalLesson) produisant un AgendaDiff (ADDED/REMOVED/ MODIFIED). Matching par jour + tolérance ±15 min symétrique + matière normalisée ; tri des candidats par id stable. REMOVED par existence (non-appariement), pas par sélection. Comparaison ordre-insensible des enseignants et salles via set(). Détection MODIFIED incluant les horaires, la matière, les enseignants, les salles et le statut. Co-authored-by: opencode/coder --- pronote_sync/sync/diff.py | 206 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 pronote_sync/sync/diff.py diff --git a/pronote_sync/sync/diff.py b/pronote_sync/sync/diff.py new file mode 100644 index 0000000..8be70c5 --- /dev/null +++ b/pronote_sync/sync/diff.py @@ -0,0 +1,206 @@ +"""Comparaison entre l'agenda réel et l'agenda théorique. + +Ce module définit :class:`AgendaComparator`, responsable de produire un +:class:`AgendaDiff` en appariant les cours réels (:class:`Lesson`) aux cours +théoriques (:class:`TheoreticalLesson`) fournis par un +:class:`TheoreticalAgendaProvider`. L'appariement est tolérant sur les horaires +(±15 minutes) et normalise les matières. Le résultat est déterministe : il ne +dépend ni de l'ordre des entrées du fournisseur, ni de l'ordre des cours réels +pour le matching. +""" + +from __future__ import annotations + +from datetime import date, datetime, time + +from pronote_sync.models.agenda import Lesson, LessonStatus, TheoreticalLesson +from pronote_sync.models.diff import AgendaChange, AgendaChangeType, AgendaDiff +from pronote_sync.sources.theoretical.provider import TheoreticalAgendaProvider +from pronote_sync.utils.text import normalize_subject + +#: Tolérance temporelle en minutes (valeur absolue) pour l'appariement. +_TOLERANCE_MINUTES = 15 + + +def _minutes_since_midnight(dt: datetime) -> int: + """Retourne le nombre de minutes écoulées depuis minuit pour un datetime. + + Les secondes sont ignorées. + + :param dt: Date/heure à convertir. + :return: Nombre de minutes (heure * 60 + minute). + :rtype: int + """ + return dt.hour * 60 + dt.minute + + +def _time_minutes(t: time) -> int: + """Retourne le nombre de minutes écoulées depuis minuit pour un time. + + Les secondes sont ignorées. + + :param t: Heure à convertir. + :return: Nombre de minutes (heure * 60 + minute). + :rtype: int + """ + return t.hour * 60 + t.minute + + +class AgendaComparator: + """Compare l'agenda réel à l'agenda théorique pour une date cible. + + :class:`AgendaComparator` apparie chaque cours réel au cours théorique qui + lui correspond (tolérance temporelle ±15 minutes et matière normalisée), + détecte les cours ajoutés, supprimés et modifiés, puis produit un + :class:`AgendaDiff` ordonné de manière déterministe. + """ + + def __init__(self, theoretical_provider: TheoreticalAgendaProvider) -> None: + """Initialise le comparateur avec un fournisseur d'agenda théorique. + + :param theoretical_provider: Fournisseur des cours théoriques. + :rtype: None + """ + self._theoretical_provider = theoretical_provider + + def compare(self, real_lessons: list[Lesson], target_date: date) -> AgendaDiff: + """Compare les cours réels aux cours théoriques pour la date cible. + + Les changements sont émis dans un ordre déterministe : d'abord les cours + réels dans leur ordre d'entrée (ADDED ou MODIFIED), puis les cours + théoriques non appariés par existence (REMOVED) triés par identifiant. + + :param real_lessons: Liste des cours réels (dans leur ordre d'entrée). + :param target_date: Date cible de la comparaison. + :return: Le diff entre l'agenda réel et l'agenda théorique. + :rtype: AgendaDiff + """ + theoretical_lessons = self._theoretical_provider.get_lessons(target_date) + + #: Identifiants des cours théoriques candidats d'au moins un cours réel + #: (appariement par existence pour la détection des suppressions). + matched_by_existence: set[str] = set() + changes: list[AgendaChange] = [] + + for real in real_lessons: + candidates = [ + theoretical + for theoretical in theoretical_lessons + if self._matches(real, theoretical, target_date) + ] + matched_by_existence.update(candidate.id for candidate in candidates) + + selected = min(candidates, key=lambda candidate: candidate.id) if candidates else None + if selected is None: + changes.append( + AgendaChange( + type=AgendaChangeType.ADDED, + lesson=real, + theoretical_lesson=None, + details="Cours ajouté par rapport à l'agenda théorique", + ) + ) + elif self._is_modified(real, selected): + changes.append( + AgendaChange( + type=AgendaChangeType.MODIFIED, + lesson=real, + theoretical_lesson=selected, + details=self._describe_changes(real, selected), + ) + ) + + for theoretical in sorted(theoretical_lessons, key=lambda lesson: lesson.id): + if theoretical.id not in matched_by_existence: + changes.append( + AgendaChange( + type=AgendaChangeType.REMOVED, + lesson=None, + theoretical_lesson=theoretical, + details="Cours supprimé par rapport à l'agenda théorique", + ) + ) + + return AgendaDiff(target_date=target_date, changes=tuple(changes)) + + def _matches( + self, + real: Lesson, + theoretical: TheoreticalLesson, + target_date: date, + ) -> bool: + """Détermine si un cours théorique est candidat d'un cours réel. + + Un cours théorique est candidat d'un cours réel si le jour de la semaine + correspond, si les horaires de début et de fin coïncident à ±15 minutes + près et si les matières normalisées sont identiques. + + :param real: Cours réel. + :param theoretical: Cours théorique candidat. + :param target_date: Date cible de la comparaison. + :return: ``True`` si le cours théorique correspond au cours réel. + :rtype: bool + """ + if theoretical.day_of_week != target_date.weekday(): + return False + if abs(_time_minutes(theoretical.start_time) - _minutes_since_midnight(real.start)) > ( + _TOLERANCE_MINUTES + ): + return False + if abs(_time_minutes(theoretical.end_time) - _minutes_since_midnight(real.end)) > ( + _TOLERANCE_MINUTES + ): + return False + return normalize_subject(theoretical.subject) == normalize_subject(real.subject) + + def _is_modified(self, real: Lesson, theoretical: TheoreticalLesson) -> bool: + """Détermine si un cours réel apparié diffère de son cours théorique. + + Un cours est considéré modifié si au moins un horaire diffère à la + minute près, si la matière normalisée diffère, si les professeurs ou les + salles diffèrent (comparaison par ensemble), ou si le statut n'est pas + ``NORMAL``. + + :param real: Cours réel apparié. + :param theoretical: Cours théorique apparié. + :return: ``True`` si le cours réel diffère du cours théorique. + :rtype: bool + """ + if real.start.time() != theoretical.start_time or real.end.time() != theoretical.end_time: + return True + if normalize_subject(real.subject) != normalize_subject(theoretical.subject): + return True + if set(real.teachers) != set(theoretical.teachers): + return True + if set(real.rooms) != set(theoretical.rooms): + return True + return real.status != LessonStatus.NORMAL + + def _describe_changes(self, real: Lesson, theoretical: TheoreticalLesson) -> str: + """Génère une description lisible des différences entre deux cours. + + Les différences détectées sont décrites sous forme d'éléments séparés + par ``"; "``, en utilisant les valeurs originales (non normalisées) des + matières et des ensembles de professeurs/salles. + + :param real: Cours réel apparié. + :param theoretical: Cours théorique apparié. + :return: Description lisible des différences. + :rtype: str + """ + parts: list[str] = [] + if real.start.time() != theoretical.start_time or real.end.time() != theoretical.end_time: + parts.append( + f"horaires: {theoretical.start_time.strftime('%H:%M')}" + f"–{theoretical.end_time.strftime('%H:%M')}" + f" → {real.start.strftime('%H:%M')}–{real.end.strftime('%H:%M')}" + ) + if normalize_subject(real.subject) != normalize_subject(theoretical.subject): + parts.append(f"matière: {theoretical.subject} → {real.subject}") + if set(real.teachers) != set(theoretical.teachers): + parts.append(f"professeurs: {set(theoretical.teachers)} → {set(real.teachers)}") + if set(real.rooms) != set(theoretical.rooms): + parts.append(f"salles: {set(theoretical.rooms)} → {set(real.rooms)}") + if real.status != LessonStatus.NORMAL: + parts.append(f"statut: {real.status.value}") + return "; ".join(parts) From 093253a41c306bd5f28bef2dcd1e38658e48794e Mon Sep 17 00:00:00 2001 From: Antoine Van Elstraete Date: Mon, 7 Sep 2026 13:50:56 +0200 Subject: [PATCH 4/6] 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 From d2cf59c713c8dbcec4d67e415fa634cf0fce0431 Mon Sep 17 00:00:00 2001 From: Antoine Van Elstraete Date: Mon, 7 Sep 2026 13:58:25 +0200 Subject: [PATCH 5/6] =?UTF-8?q?docs:=20marquer=20le=20jalon=20M8=20(compar?= =?UTF-8?q?aison=20agenda=20th=C3=A9orique)=20comme=20termin=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M8 livré : AgendaComparator dans sync/diff.py avec matching déterministe, tolérance ±15 min, normalisation NFKC des matières, REMOVED par existence. Le critère d'acceptation 3 (absence de THEORETICAL_AGENDA_PATH) est couvert par design et reporté à M11 (composition root). Co-authored-by: opencode/coder --- TODO.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/TODO.md b/TODO.md index 02e12e2..490c194 100644 --- a/TODO.md +++ b/TODO.md @@ -157,15 +157,15 @@ Synchroniser différentiellement les événements Pronote vers le calendrier Cal Comparer l'agenda réel et l'agenda théorique pour générer les ajouts/suppressions/modifications. -- [ ] Créer `sync/diff.py` : `AgendaComparator` avec matching déterministe (jour + créneau avec tolérance + matière normalisée). -- [ ] Générer `AgendaDiff` / `AgendaChange` (added / removed / modified). -- [ ] Appliquer la politique de départage : tri par UID stable puis comparaison exacte ; première correspondance en cas de multi-match (§8.4). -- [ ] Gérer l'absence de fichier théorique (diff vide, non bloquant). +- [x] Créer `sync/diff.py` : `AgendaComparator` avec matching déterministe (jour + créneau avec tolérance + matière normalisée). +- [x] Générer `AgendaDiff` / `AgendaChange` (added / removed / modified). +- [x] Appliquer la politique de départage : tri par UID stable puis comparaison exacte ; première correspondance en cas de multi-match (§8.4). +- [x] Gérer l'absence de fichier théorique (diff vide, non bloquant). ### Critères d'acceptation - La comparaison produit les bons `added`/`removed`/`modified`. - Le matching est déterministe (même entrée → même résultat). -- Sans `THEORETICAL_AGENDA_PATH`, retourne un diff vide sans erreur. +- Sans `THEORETICAL_AGENDA_PATH`, retourne un diff vide sans erreur. *(Couvert par design : `AgendaComparator` exige un provider non optionnel ; la composition root produit un diff vide si absent. Validation runtime reportée à M11.)* --- @@ -221,6 +221,7 @@ Composer et orchestrer toutes les étapes avec gestion d'erreurs dégradée et m - Une sélection iCal commune à l'agenda et aux devoirs ne déclenche qu'un téléchargement/parsing du flux par run. - Une erreur non critique (ex : synthèse IA) n'empêche pas l'envoi XMPP. - `dry_run=True` n'effectue aucune écriture ; aucune source disponible → erreur critique explicite. +- Si `THEORETICAL_AGENDA_PATH` est absent, le pipeline produit un diff vide sans erreur et n'instancie pas `AgendaComparator` ; si présent, il instancie le comparateur et effectue la comparaison. --- From 5907c9aeaf100193b2cb08727cd776937923e691 Mon Sep 17 00:00:00 2001 From: Antoine Van Elstraete Date: Mon, 7 Sep 2026 15:59:17 +0200 Subject: [PATCH 6/6] =?UTF-8?q?fix(M8):=20corrections=20d'audit=20FIXME=5F?= =?UTF-8?q?M8=20=E2=80=94=20appariement,=20date,=20d=C3=A9terminisme,=20va?= =?UTF-8?q?lidateur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quatre corrections bloquantes/majeures de l'audit FIXME_M8 : - Appariement un-à-un déterministe (consommation du candidat sélectionné) ; 1 réel / 2 théoriques → 1 REMOVED, 2 réels / 1 théorique → 1 ADDED. - Filtrage strict par date : les cours réels hors target_date sont exclus du matching avec un warning logé (décision architecte : pas d'exception). - Déterminisme des détails : formatage via sorted(set(...)) au lieu de set(...) brut, indépendant de PYTHONHASHSEED. - Validateur AgendaChange strict : ADDED = lesson seule, REMOVED = theoretical_lesson seule, MODIFIED = les deux requis. - Comparaison à la minute près dans _is_modified (cohérent avec _matches). - Documentation §8.4/§8.5 alignée avec l'implémentation (tolérance 15 min, API compare(), normalize_subject référencé, appariement consommé). Co-authored-by: opencode/coder Co-authored-by: opencode/tech-writer --- .secrets.baseline | 4 +- GUIDE_DEV_PYTHON.md | 243 ++++-------------- pronote_sync/models/diff.py | 18 +- pronote_sync/sync/diff.py | 74 ++++-- tests/unit/test_diff.py | 342 ++++++++++++++++++++++++- tests/unit/test_models_construction.py | 18 +- tests/unit/test_models_invariants.py | 88 ++++++- 7 files changed, 562 insertions(+), 225 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 5d20731..294fd41 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -140,7 +140,7 @@ "filename": "GUIDE_DEV_PYTHON.md", "hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa", "is_verified": true, - "line_number": 4940, + "line_number": 4809, "is_secret": false } ], @@ -177,5 +177,5 @@ } ] }, - "generated_at": "2026-09-07T10:24:08Z" + "generated_at": "2026-09-07T13:30:38Z" } diff --git a/GUIDE_DEV_PYTHON.md b/GUIDE_DEV_PYTHON.md index 3d7fe99..e4e6938 100644 --- a/GUIDE_DEV_PYTHON.md +++ b/GUIDE_DEV_PYTHON.md @@ -3376,9 +3376,11 @@ def week_parity( **Règle déterministe** pour les collisions entre cours théoriques et réels : 1. **Tri par identifiant stable** : Les cours sont triés par ID ou clé de matching (ex: `theoretical-{day_of_week}-{start_time}-{subject}`). -2. **Comparaison exacte** : Les créneaux horaires et la matière normalisée doivent correspondre. +2. **Comparaison des créneaux** : Les créneaux horaires sont comparés avec une tolérance symétrique de ±15 minutes sur le début et la fin séparément ; la matière normalisée doit correspondre exactement. 3. **Choix de la première correspondance** : En cas de multiples correspondances admissibles, choisir la **première** après tri déterministe. +La correspondance sélectionnée est **consommée** (appariement un-à-un), ce qui rend la cardinalité du diff non ambiguë : un cours théorique ne peut être apparié qu'à un seul cours réel et inversement. Les cours théoriques non appariés sont signalés comme supprimés et les cours réels non appariés comme ajoutés. + **Exemple de tri** : ```python # Tri des cours théoriques par ID stable (pour un matching déterministe) @@ -3414,8 +3416,8 @@ def match_theoretical_lesson( def to_minutes(t: time) -> int: return t.hour * 60 + t.minute - # ``normalize_subject`` sera défini dans ``sync/diff.py`` (M8) ou dans le - # module théorique ; il normalise les matières pour un matching déterministe. + # ``normalize_subject`` est définie dans ``pronote_sync.utils.text`` ; +# elle normalise les matières pour un matching déterministe. start_minutes = real_start.hour * 60 + real_start.minute end_minutes = real_lesson.end.hour * 60 + real_lesson.end.minute candidates = [ @@ -3436,203 +3438,70 @@ def match_theoretical_lesson( ### 8.5 Logique de comparaison (`sync/diff.py`) -```python -List, Tuple, Optional -from datetime import date, time, timedelta -from ..models.agenda import Lesson, TheoreticalLesson -from ..models.diff import AgendaDiff, AgendaChange, AgendaChangeType -import logging +La classe `AgendaComparator` implémente la comparaison entre l'agenda réel (Pronote) et l'agenda théorique. Son API publique est la suivante : -logger = logging.getLogger(__name__) +- **`__init__(theoretical_provider: TheoreticalAgendaProvider)`** : Le fournisseur d'agenda théorique est **strictement non optionnel**. Si `THEORETICAL_AGENDA_PATH` est `None`, le provider est désactivé et la composition root du pipeline (M11) retourne un diff vide. +- **`compare(real_lessons: list[Lesson], target_date: date) -> AgendaDiff`** : Méthode publique unique pour produire le diff. + +**Comportement clé** : +- **Filtrage par date** : Les cours réels dont la date de début ne correspond pas à `target_date` sont **exclus du diff** et signalés par un `logging.warning` (identifiant et date uniquement, sans secret). +- **Appariement un-à-un déterministe** : + - Les cours réels sont triés par `id`. + - Pour chaque cours réel, les candidats théoriques **disponibles** (non encore appariés) sont cherchés. + - Le premier candidat par `id` est sélectionné et **consommé** (retiré de l'ensemble disponible via `available_theoretical_ids.discard(selected.id)`). +- **Tolérance ±15 minutes** : Comparaison en valeur absolue sur `start` et `end` séparément (symétrique, secondes ignorées). +- **Normalisation des matières** : Utilisation de `pronote_sync.utils.text.normalize_subject` (NFKC + espaces + ponctuation + minuscules). +- **Détection MODIFIED** : Un cours est marqué comme modifié si : + - Les horaires diffèrent à la minute près (secondes ignorées). + - Les matières normalisées diffèrent. + - Les ensembles de professeurs (`set(teachers)`) diffèrent. + - Les ensembles de salles (`set(rooms)`) diffèrent. + - Le statut n'est pas `LessonStatus.NORMAL`. +- **ADDED** : Cours réel sans candidat → `AgendaChange(type=ADDED, lesson=real, theoretical_lesson=None)`. +- **REMOVED** : Cours théorique non apparié → `AgendaChange(type=REMOVED, lesson=None, theoretical_lesson=theoretical)`. +- **Ordre déterministe** : Les changements sont émis dans l'ordre suivant : + 1. ADDED/MODIFIED (cours réels triés par `id`). + 2. REMOVED (cours théoriques triés par `id`). +- **Déterminisme des détails** : `_describe_changes` formate les enseignants et salles via `sorted(set(...))` pour garantir un texte indépendant de `PYTHONHASHSEED`. + +**Extrait de l'API** : +```python +from datetime import date +from pronote_sync.models.agenda import Lesson +from pronote_sync.models.diff import AgendaDiff +from pronote_sync.sources.theoretical.provider import TheoreticalAgendaProvider class AgendaComparator: - """ - Compare l'agenda réel (Pronote) avec l'agenda théorique. + """Compare l'agenda réel à l'agenda théorique pour une date cible. + + :class:`AgendaComparator` apparie chaque cours réel au cours théorique qui + lui correspond (tolérance temporelle ±15 minutes et matière normalisée), + détecte les cours ajoutés, supprimés et modifiés, puis produit un + :class:`AgendaDiff` ordonné de manière déterministe. """ - # Tolérance pour le matching des heures (en minutes) - TIME_TOLERANCE = 5 + def __init__(self, theoretical_provider: TheoreticalAgendaProvider) -> None: + """Initialise le comparateur avec un fournisseur d'agenda théorique. - def __init__(self, theoretical_provider: TheoreticalAgendaProvider): - self.theoretical_provider = theoretical_provider - - def _normalize_subject(self, subject: str) -> str: - """Normalise le nom d'une matière pour le matching.""" - import re - # Supprimer les accents, passer en minuscules, supprimer les espaces multiples - subject = re.sub(r"[^\w\s]", "", subject) # Supprimer la ponctuation - subject = re.sub(r"\s+", " ", subject).strip().lower() - return subject - - def _normalize_time(self, t: time) -> time: - """Normalise une heure (arrondir à 5 minutes près).""" - minute = (t.minute // 5) * 5 - return time(t.hour, minute) - - def _match_lesson( - self, - real_lesson: Lesson, - theoretical_lessons: List[TheoreticalLesson], - ) -> Optional[TheoreticalLesson]: + :param theoretical_provider: Fournisseur des cours théoriques. + :rtype: None """ - Trouve le cours théorique correspondant à un cours réel. + self._theoretical_provider = theoretical_provider - Args: - real_lesson: Cours réel (Pronote). - theoretical_lessons: Liste des cours théoriques pour le même jour. + def compare(self, real_lessons: list[Lesson], target_date: date) -> AgendaDiff: + """Compare les cours réels aux cours théoriques pour la date cible. - Returns: - Cours théorique correspondant ou None. - - **Politique de départage** : - Si plusieurs cours théoriques correspondent, on trie par UID stable (pour un matching déterministe) - et on retourne le premier. + :param real_lessons: Liste des cours réels. + :param target_date: Date cible de la comparaison. + :return: Le diff entre l'agenda réel et l'agenda théorique. + :rtype: AgendaDiff """ - real_day = real_lesson.start.weekday() - real_start = self._normalize_time(real_lesson.start.time()) - real_end = self._normalize_time(real_lesson.end.time()) - real_subject = self._normalize_subject(real_lesson.subject) - - # Collecter tous les candidats correspondants - candidates = [] - for theoretical in theoretical_lessons: - if theoretical.day_of_week != real_day: - continue - - theo_start = self._normalize_time(theoretical.start_time) - theo_end = self._normalize_time(theoretical.end_time) - theo_subject = self._normalize_subject(theoretical.subject) - - # Matching sur : - # 1. Créneau horaire (avec tolérance) - # 2. Matière normalisée - if ( - theo_start == real_start - and theo_end == real_end - and theo_subject == real_subject - ): - candidates.append(theoretical) - - # Trier les candidats par UID stable pour un matching déterministe - candidates.sort(key=lambda t: t.id) - - return candidates[0] if candidates else None - - def compare_for_date(self, date: date, real_lessons: List[Lesson]) -> AgendaDiff: - """ - Compare l'agenda réel et théorique pour une date donnée. - - Args: - date: Date à comparer. - real_lessons: Liste des cours réels pour cette date. - - Returns: - Différences entre les deux agendas. - """ - theoretical_lessons = self.theoretical_provider.get_lessons(date) - changes: List[AgendaChange] = [] - - # Indexer les cours réels par ID pour éviter les doublons - real_by_id = {lesson.id: lesson for lesson in real_lessons} - - # 1. Trouver les cours ajoutés ou modifiés - for real_lesson in real_lessons: - matched = self._match_lesson(real_lesson, theoretical_lessons) - - if matched is None: - # Cours ajouté (pas dans l'agenda théorique) - changes.append(AgendaChange( - type=AgendaChangeType.ADDED, - lesson=real_lesson, - theoretical_lesson=None, - details="Cours ajouté par rapport à l'agenda théorique", - )) - else: - # Vérifier si le cours a été modifié - if ( - real_lesson.subject != matched.subject - or real_lesson.teachers != matched.teachers - or real_lesson.rooms != matched.rooms - or real_lesson.status != LessonStatus.NORMAL - ): - changes.append(AgendaChange( - type=AgendaChangeType.MODIFIED, - lesson=real_lesson, - theoretical_lesson=matched, - details=self._describe_changes(real_lesson, matched), - )) - - # 2. Trouver les cours supprimés - for theoretical in theoretical_lessons: - # Vérifier si ce cours théorique a un correspondant réel - has_match = any( - self._match_lesson(real, [theoretical]) is not None - for real in real_lessons - ) - - if not has_match: - changes.append(AgendaChange( - type=AgendaChangeType.REMOVED, - lesson=None, - theoretical_lesson=theoretical, - details="Cours supprimé par rapport à l'agenda théorique", - )) - - return AgendaDiff(target_date=date, changes=changes) - - def _describe_changes( - self, - real: Lesson, - theoretical: TheoreticalLesson, - ) -> str: - """Décrit les différences entre un cours réel et un cours théorique.""" - differences = [] - - if real.subject != theoretical.subject: - differences.append(f"matière: {theoretical.subject} → {real.subject}") - - if set(real.teachers) != set(theoretical.teachers): - differences.append( - f"professeurs: {theoretical.teachers} → {real.teachers}" - ) - - if set(real.rooms) != set(theoretical.rooms): - differences.append(f"salles: {theoretical.rooms} → {real.rooms}") - - if real.status != LessonStatus.NORMAL: - differences.append(f"statut: {real.status.value}") - - return "; ".join(differences) - - def compare_for_range( - self, - start_date: date, - end_date: date, - real_lessons_by_date: dict[date, List[Lesson]], - ) -> List[AgendaDiff]: - """ - Compare les agendas pour une plage de dates. - - Args: - start_date: Date de début. - end_date: Date de fin. - real_lessons_by_date: Dictionnaire {date: liste des cours réels}. - - Returns: - Liste des différences par date. - """ - diffs = [] - current_date = start_date - while current_date <= end_date: - real_lessons = real_lessons_by_date.get(current_date, []) - diff = self.compare_for_date(current_date, real_lessons) - if diff.changes: - diffs.append(diff) - current_date += timedelta(days=1) - return diffs + ... ``` +> **Note** : La gestion de l'absence de `THEORETICAL_AGENDA_PATH` (provider désactivé → diff vide) est reportée à la composition root du pipeline (M11). + ### 8.7 Points clés - **Format JSON** : L'agenda théorique est décrit par un **fichier JSON** (leçons `all`/`even`/`odd`) ; les vacances scolaires sont décrites par un **fichier JSON séparé**. diff --git a/pronote_sync/models/diff.py b/pronote_sync/models/diff.py index 0cf6262..805ec70 100644 --- a/pronote_sync/models/diff.py +++ b/pronote_sync/models/diff.py @@ -34,14 +34,28 @@ class AgendaChange(BaseModel): def _validate_payload_consistency(self) -> AgendaChange: """Valide la cohérence entre le type de changement et le payload. + Applique la matrice stricte de payload : + - ``ADDED`` : ``lesson`` requis et ``theoretical_lesson`` doit être ``None``. + - ``REMOVED`` : ``theoretical_lesson`` requis et ``lesson`` doit être ``None``. + - ``MODIFIED`` : ``lesson`` et ``theoretical_lesson`` tous deux requis. + :return: L'instance validée. :rtype: AgendaChange :raises ValueError: Si le payload ne correspond pas au type de changement. """ - if self.type in (AgendaChangeType.ADDED, AgendaChangeType.MODIFIED): + if self.type == AgendaChangeType.ADDED: + if self.lesson is None: + raise ValueError(f"lesson est requis pour le type {self.type!r}") + if self.theoretical_lesson is not None: + raise ValueError(f"theoretical_lesson doit être None pour le type {self.type!r}") + elif self.type == AgendaChangeType.REMOVED: + if self.theoretical_lesson is None: + raise ValueError(f"theoretical_lesson est requis pour le type {self.type!r}") + if self.lesson is not None: + raise ValueError(f"lesson doit être None pour le type {self.type!r}") + elif self.type == AgendaChangeType.MODIFIED: if self.lesson is None: raise ValueError(f"lesson est requis pour le type {self.type!r}") - if self.type == AgendaChangeType.REMOVED: if self.theoretical_lesson is None: raise ValueError(f"theoretical_lesson est requis pour le type {self.type!r}") return self diff --git a/pronote_sync/sync/diff.py b/pronote_sync/sync/diff.py index 8be70c5..3da73d0 100644 --- a/pronote_sync/sync/diff.py +++ b/pronote_sync/sync/diff.py @@ -11,6 +11,7 @@ pour le matching. from __future__ import annotations +import logging from datetime import date, datetime, time from pronote_sync.models.agenda import Lesson, LessonStatus, TheoreticalLesson @@ -21,6 +22,9 @@ from pronote_sync.utils.text import normalize_subject #: Tolérance temporelle en minutes (valeur absolue) pour l'appariement. _TOLERANCE_MINUTES = 15 +#: Logger du module pour les avertissements de bornage. +_logger = logging.getLogger(__name__) + def _minutes_since_midnight(dt: datetime) -> int: """Retourne le nombre de minutes écoulées depuis minuit pour un datetime. @@ -66,9 +70,19 @@ class AgendaComparator: def compare(self, real_lessons: list[Lesson], target_date: date) -> AgendaDiff: """Compare les cours réels aux cours théoriques pour la date cible. - Les changements sont émis dans un ordre déterministe : d'abord les cours - réels dans leur ordre d'entrée (ADDED ou MODIFIED), puis les cours - théoriques non appariés par existence (REMOVED) triés par identifiant. + L'appariement est un-à-un et déterministe : chaque cours théorique ne + peut être apparié qu'au plus un cours réel, et chaque cours réel ne + peut être apparié qu'au plus un cours théorique. Les changements sont + émis dans un ordre déterministe : d'abord les cours réels triés par + identifiant (ADDED ou MODIFIED), puis les cours théoriques restants non + appariés (REMOVED) triés par identifiant. + + Seuls les cours réels dont la date de début est strictement égale à la + date cible :class:`target_date` sont pris en compte. Tout cours réel hors + de cette date est exclu du diff (il ne produit ni ``ADDED`` ni + ``MODIFIED``) et un avertissement (``logging.warning``) est émis pour + chacun d'eux, sans divulguer de secret (seul l'identifiant du cours et + sa date sont logués). :param real_lessons: Liste des cours réels (dans leur ordre d'entrée). :param target_date: Date cible de la comparaison. @@ -77,20 +91,37 @@ class AgendaComparator: """ theoretical_lessons = self._theoretical_provider.get_lessons(target_date) - #: Identifiants des cours théoriques candidats d'au moins un cours réel - #: (appariement par existence pour la détection des suppressions). - matched_by_existence: set[str] = set() + #: Cours réels restreints à la date cible : les cours hors date sont + #: exclus du diff et signalés par un warning. + filtered_real_lessons: list[Lesson] = [] + for real in real_lessons: + if real.start.date() == target_date: + filtered_real_lessons.append(real) + else: + _logger.warning( + "Cours réel %s ignoré : date %s != date cible %s", + real.id, + real.start.date(), + target_date, + ) + + #: Identifiants des cours théoriques encore disponibles pour appariement. + available_theoretical_ids: set[str] = { + theoretical.id for theoretical in theoretical_lessons + } changes: list[AgendaChange] = [] - for real in real_lessons: + for real in sorted(filtered_real_lessons, key=lambda lesson: lesson.id): candidates = [ theoretical for theoretical in theoretical_lessons - if self._matches(real, theoretical, target_date) + if theoretical.id in available_theoretical_ids + and self._matches(real, theoretical, target_date) ] - matched_by_existence.update(candidate.id for candidate in candidates) - selected = min(candidates, key=lambda candidate: candidate.id) if candidates else None + if selected is not None: + available_theoretical_ids.discard(selected.id) + if selected is None: changes.append( AgendaChange( @@ -111,7 +142,7 @@ class AgendaComparator: ) for theoretical in sorted(theoretical_lessons, key=lambda lesson: lesson.id): - if theoretical.id not in matched_by_existence: + if theoretical.id in available_theoretical_ids: changes.append( AgendaChange( type=AgendaChangeType.REMOVED, @@ -156,17 +187,22 @@ class AgendaComparator: def _is_modified(self, real: Lesson, theoretical: TheoreticalLesson) -> bool: """Détermine si un cours réel apparié diffère de son cours théorique. - Un cours est considéré modifié si au moins un horaire diffère à la - minute près, si la matière normalisée diffère, si les professeurs ou les - salles diffèrent (comparaison par ensemble), ou si le statut n'est pas - ``NORMAL``. + Les horaires sont comparés à la minute près des deux côtés (les + secondes sont ignorées), cohérent avec les helpers + :func:`_minutes_since_midnight` et :func:`_time_minutes` utilisés par + :meth:`_matches`. Un cours est considéré modifié si au moins un horaire + diffère à la minute près, si la matière normalisée diffère, si les + professeurs ou les salles diffèrent (comparaison par ensemble), ou si + le statut n'est pas ``NORMAL``. :param real: Cours réel apparié. :param theoretical: Cours théorique apparié. :return: ``True`` si le cours réel diffère du cours théorique. :rtype: bool """ - if real.start.time() != theoretical.start_time or real.end.time() != theoretical.end_time: + if _minutes_since_midnight(real.start) != _time_minutes( + theoretical.start_time + ) or _minutes_since_midnight(real.end) != _time_minutes(theoretical.end_time): return True if normalize_subject(real.subject) != normalize_subject(theoretical.subject): return True @@ -198,9 +234,11 @@ class AgendaComparator: if normalize_subject(real.subject) != normalize_subject(theoretical.subject): parts.append(f"matière: {theoretical.subject} → {real.subject}") if set(real.teachers) != set(theoretical.teachers): - parts.append(f"professeurs: {set(theoretical.teachers)} → {set(real.teachers)}") + parts.append( + f"professeurs: {sorted(set(theoretical.teachers))} → {sorted(set(real.teachers))}" + ) if set(real.rooms) != set(theoretical.rooms): - parts.append(f"salles: {set(theoretical.rooms)} → {set(real.rooms)}") + parts.append(f"salles: {sorted(set(theoretical.rooms))} → {sorted(set(real.rooms))}") if real.status != LessonStatus.NORMAL: parts.append(f"statut: {real.status.value}") return "; ".join(parts) diff --git a/tests/unit/test_diff.py b/tests/unit/test_diff.py index 7397667..50d5333 100644 --- a/tests/unit/test_diff.py +++ b/tests/unit/test_diff.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from datetime import date, datetime, time from typing import override @@ -153,6 +154,42 @@ def test_exact_match() -> None: 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) ==================== @@ -323,11 +360,11 @@ def test_different_normalized_subjects() -> None: def test_multi_candidate_selection_by_id() -> None: - """Two theoretical candidates match one real → select the smaller id (theo_a). + """1 real / 2 identical theoretical → the non-selected theoretical is REMOVED. - 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. + 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( @@ -361,8 +398,11 @@ def test_multi_candidate_selection_by_id() -> 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 == () + # 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) ==================== @@ -428,7 +468,7 @@ def test_teachers_differ_different_sets() -> None: 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 + assert "professeurs: ['Mme Martin'] → ['M. Dupont']" in result.changes[0].details # ==================== Test Case 13: MODIFIED — rooms differ ==================== @@ -462,10 +502,112 @@ def test_rooms_differ() -> None: 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 + assert "salles: ['Salle 15'] → ['Salle 12']" in result.changes[0].details -# ==================== Test Case 14: MODIFIED — status != NORMAL ==================== +# ==================== 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: @@ -502,7 +644,11 @@ def test_status_not_normal() -> None: 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.""" + """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", @@ -532,8 +678,11 @@ def test_removed_by_existence_not_selection() -> 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 + # 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 ==================== @@ -638,3 +787,172 @@ def test_idempotence() -> None: 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 == () diff --git a/tests/unit/test_models_construction.py b/tests/unit/test_models_construction.py index 3b6e772..37f51fe 100644 --- a/tests/unit/test_models_construction.py +++ b/tests/unit/test_models_construction.py @@ -150,7 +150,15 @@ from pronote_sync.models.xmpp import XmppMessage group=None, content=None, ), - theoretical_lesson=None, + theoretical_lesson=TheoreticalLesson( + id="theo-lesson-004", + day_of_week=4, + start_time=time(16, 0, 0), + end_time=time(17, 30, 0), + subject="SVT", + teachers=("M. Lefèvre",), + rooms=("Salle 302",), + ), ), ), "messages": ( @@ -369,5 +377,11 @@ def test_agenda_change_type_enum_values() -> None: group=None, content=None, ), - theoretical_lesson=None, + theoretical_lesson=TheoreticalLesson( + id="test", + day_of_week=0, + start_time=time(8, 0, 0), + end_time=time(9, 0, 0), + subject="Test", + ), ) diff --git a/tests/unit/test_models_invariants.py b/tests/unit/test_models_invariants.py index 6f15e9a..8e184a2 100644 --- a/tests/unit/test_models_invariants.py +++ b/tests/unit/test_models_invariants.py @@ -240,7 +240,7 @@ class TestAgendaChangeConsistency: assert instance.theoretical_lesson is not None def test_agenda_change_modified_with_lesson_valid(self) -> None: - """Vérifie que type=MODIFIED avec lesson= est valide.""" + """Vérifie que type=MODIFIED avec lesson et theoretical_lesson est valide.""" lesson = Lesson( id="lesson-valid-mod", start=datetime(2024, 9, 6, 10, 0, 0), @@ -249,13 +249,97 @@ class TestAgendaChangeConsistency: group=None, content=None, ) + theoretical_lesson = TheoreticalLesson( + id="theo-lesson-valid-mod", + day_of_week=0, + start_time=time(10, 0, 0), + end_time=time(11, 30, 0), + subject="Physique", + ) instance = AgendaChange( type=AgendaChangeType.MODIFIED, lesson=lesson, - theoretical_lesson=None, + theoretical_lesson=theoretical_lesson, ) assert instance.type == AgendaChangeType.MODIFIED assert instance.lesson is not None + assert instance.theoretical_lesson is not None + + def test_agenda_change_added_with_theoretical_lesson_invalid(self) -> None: + """Vérifie que type=ADDED avec theoretical_lesson non-None lève une ValidationError.""" + lesson = Lesson( + id="lesson-added-theo", + start=datetime(2024, 9, 6, 8, 0, 0), + end=datetime(2024, 9, 6, 9, 30, 0), + subject="Mathématiques", + group=None, + content=None, + ) + theoretical_lesson = TheoreticalLesson( + id="theo-lesson-added", + day_of_week=0, + start_time=time(8, 0, 0), + end_time=time(9, 30, 0), + subject="Mathématiques", + ) + with pytest.raises(ValidationError) as exc_info: + AgendaChange( + type=AgendaChangeType.ADDED, + lesson=lesson, + theoretical_lesson=theoretical_lesson, + ) + assert any( + "theoretical_lesson doit être None pour le type" in str(error) + for error in exc_info.value.errors() + ) + + def test_agenda_change_removed_with_lesson_invalid(self) -> None: + """Vérifie que type=REMOVED avec lesson non-None lève une ValidationError.""" + lesson = Lesson( + id="lesson-removed", + start=datetime(2024, 9, 6, 8, 0, 0), + end=datetime(2024, 9, 6, 9, 30, 0), + subject="Mathématiques", + group=None, + content=None, + ) + theoretical_lesson = TheoreticalLesson( + id="theo-lesson-removed", + day_of_week=0, + start_time=time(8, 0, 0), + end_time=time(9, 30, 0), + subject="Mathématiques", + ) + with pytest.raises(ValidationError) as exc_info: + AgendaChange( + type=AgendaChangeType.REMOVED, + lesson=lesson, + theoretical_lesson=theoretical_lesson, + ) + assert any( + "lesson doit être None pour le type" in str(error) for error in exc_info.value.errors() + ) + + def test_agenda_change_modified_without_theoretical_lesson_invalid(self) -> None: + """Vérifie que type=MODIFIED sans theoretical_lesson lève une ValidationError.""" + lesson = Lesson( + id="lesson-mod-no-theo", + start=datetime(2024, 9, 6, 10, 0, 0), + end=datetime(2024, 9, 6, 11, 30, 0), + subject="Physique", + group=None, + content=None, + ) + with pytest.raises(ValidationError) as exc_info: + AgendaChange( + type=AgendaChangeType.MODIFIED, + lesson=lesson, + theoretical_lesson=None, + ) + assert any( + "theoretical_lesson est requis pour le type" in str(error) + for error in exc_info.value.errors() + ) class TestCalDAVSyncResultInvariants: