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: