merge: jalon M8 — comparaison avec l'agenda théorique + corrections FIXME_M8

M8 livré : AgendaComparator dans sync/diff.py avec matching déterministe,
tolérance ±15 min, normalisation NFKC des matières, appariement un-à-un.
Correctifs FIXME_M8 : appariement consommé, filtrage par date, détails
triés déterministes, validateur AgendaChange strict, secondes à la minute
près, documentation §8.4/§8.5 alignée.

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
Co-authored-by: opencode/tech-writer <tech-writer@agents.invalid>
This commit is contained in:
2026-09-07 16:00:00 +02:00
10 changed files with 1424 additions and 228 deletions

View File

@@ -140,7 +140,7 @@
"filename": "GUIDE_DEV_PYTHON.md", "filename": "GUIDE_DEV_PYTHON.md",
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa", "hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
"is_verified": true, "is_verified": true,
"line_number": 4940, "line_number": 4809,
"is_secret": false "is_secret": false
} }
], ],
@@ -177,5 +177,5 @@
} }
] ]
}, },
"generated_at": "2026-09-07T10:24:08Z" "generated_at": "2026-09-07T13:30:38Z"
} }

View File

@@ -3376,9 +3376,11 @@ def week_parity(
**Règle déterministe** pour les collisions entre cours théoriques et réels : **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}`). 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. 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** : **Exemple de tri** :
```python ```python
# Tri des cours théoriques par ID stable (pour un matching déterministe) # 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: def to_minutes(t: time) -> int:
return t.hour * 60 + t.minute return t.hour * 60 + t.minute
# ``normalize_subject`` sera défini dans ``sync/diff.py`` (M8) ou dans le # ``normalize_subject`` est définie dans ``pronote_sync.utils.text`` ;
# module théorique ; il normalise les matières pour un matching déterministe. # elle normalise les matières pour un matching déterministe.
start_minutes = real_start.hour * 60 + real_start.minute start_minutes = real_start.hour * 60 + real_start.minute
end_minutes = real_lesson.end.hour * 60 + real_lesson.end.minute end_minutes = real_lesson.end.hour * 60 + real_lesson.end.minute
candidates = [ candidates = [
@@ -3436,203 +3438,70 @@ def match_theoretical_lesson(
### 8.5 Logique de comparaison (`sync/diff.py`) ### 8.5 Logique de comparaison (`sync/diff.py`)
```python La classe `AgendaComparator` implémente la comparaison entre l'agenda réel (Pronote) et l'agenda théorique. Son API publique est la suivante :
List, Tuple, Optional
from datetime import date, time, timedelta
from ..models.agenda import Lesson, TheoreticalLesson
from ..models.diff import AgendaDiff, AgendaChange, AgendaChangeType
import logging
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: class AgendaComparator:
""" """Compare l'agenda réel à l'agenda théorique pour une date cible.
Compare l'agenda réel (Pronote) avec l'agenda théorique.
: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) def __init__(self, theoretical_provider: TheoreticalAgendaProvider) -> None:
TIME_TOLERANCE = 5 """Initialise le comparateur avec un fournisseur d'agenda théorique.
def __init__(self, theoretical_provider: TheoreticalAgendaProvider): :param theoretical_provider: Fournisseur des cours théoriques.
self.theoretical_provider = theoretical_provider :rtype: None
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]:
""" """
Trouve le cours théorique correspondant à un cours réel. self._theoretical_provider = theoretical_provider
Args: def compare(self, real_lessons: list[Lesson], target_date: date) -> AgendaDiff:
real_lesson: Cours réel (Pronote). """Compare les cours réels aux cours théoriques pour la date cible.
theoretical_lessons: Liste des cours théoriques pour le même jour.
Returns: :param real_lessons: Liste des cours réels.
Cours théorique correspondant ou None. :param target_date: Date cible de la comparaison.
:return: Le diff entre l'agenda réel et l'agenda théorique.
**Politique de départage** : :rtype: AgendaDiff
Si plusieurs cours théoriques correspondent, on trie par UID stable (pour un matching déterministe)
et on retourne le premier.
""" """
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 ### 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é**. - **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é**.

25
TODO.md
View File

@@ -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. 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`). - [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`).
- [ ] Calculer le `CalDAVSyncPlan` (to_add / to_update / to_remove) par UID stable, explicitement avant l'exécution de la sync. - [x] 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. - [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.
- [ ] 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] 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. - [x] 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). - [x] 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] Ne jamais modifier ou supprimer les événements non marqués `X-PRONOTE-SYNC-MANAGED`.
### Critères d'acceptation ### Critères d'acceptation
@@ -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. 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). - [x] 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). - [x] 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). - [x] 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] Gérer l'absence de fichier théorique (diff vide, non bloquant).
### Critères d'acceptation ### Critères d'acceptation
- La comparaison produit les bons `added`/`removed`/`modified`. - La comparaison produit les bons `added`/`removed`/`modified`.
- Le matching est déterministe (même entrée → même résultat). - 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 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. - 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. - `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.
--- ---

View File

@@ -34,14 +34,28 @@ class AgendaChange(BaseModel):
def _validate_payload_consistency(self) -> AgendaChange: def _validate_payload_consistency(self) -> AgendaChange:
"""Valide la cohérence entre le type de changement et le payload. """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. :return: L'instance validée.
:rtype: AgendaChange :rtype: AgendaChange
:raises ValueError: Si le payload ne correspond pas au type de changement. :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: if self.lesson is None:
raise ValueError(f"lesson est requis pour le type {self.type!r}") raise ValueError(f"lesson est requis pour le type {self.type!r}")
if self.type == AgendaChangeType.REMOVED:
if self.theoretical_lesson is None: if self.theoretical_lesson is None:
raise ValueError(f"theoretical_lesson est requis pour le type {self.type!r}") raise ValueError(f"theoretical_lesson est requis pour le type {self.type!r}")
return self return self

View File

@@ -10,8 +10,6 @@ plage de dates. Le filtrage tient compte du jour de la semaine, de la parité de
from __future__ import annotations from __future__ import annotations
import logging import logging
import re
import unicodedata
from datetime import date, time, timedelta from datetime import date, time, timedelta
from pathlib import Path from pathlib import Path
from typing import Literal 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.model import TheoreticalAgendaFile, TheoreticalLessonEntry
from pronote_sync.sources.theoretical.parity import WeekParityService from pronote_sync.sources.theoretical.parity import WeekParityService
from pronote_sync.utils.redaction import redact_exception, redact_secrets 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__) 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: def _generate_id(entry: TheoreticalLessonEntry) -> str:
"""Génère un identifiant déterministe pour une entrée de cours. """Génère un identifiant déterministe pour une entrée de cours.

244
pronote_sync/sync/diff.py Normal file
View File

@@ -0,0 +1,244 @@
"""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
import logging
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
#: 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.
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.
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.
:return: Le diff entre l'agenda réel et l'agenda théorique.
:rtype: AgendaDiff
"""
theoretical_lessons = self._theoretical_provider.get_lessons(target_date)
#: 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 sorted(filtered_real_lessons, key=lambda lesson: lesson.id):
candidates = [
theoretical
for theoretical in theoretical_lessons
if theoretical.id in available_theoretical_ids
and self._matches(real, theoretical, target_date)
]
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(
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 in available_theoretical_ids:
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.
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 _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
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: {sorted(set(theoretical.teachers))}{sorted(set(real.teachers))}"
)
if set(real.rooms) != set(theoretical.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)

View File

@@ -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()

958
tests/unit/test_diff.py Normal file
View File

@@ -0,0 +1,958 @@
"""Unit tests for AgendaComparator in pronote_sync/sync/diff.py."""
from __future__ import annotations
import logging
from datetime import date, datetime, time
from typing import override
import pytest
from pronote_sync.models.agenda import Lesson, LessonStatus, TheoreticalLesson
from pronote_sync.models.diff import AgendaChangeType
from pronote_sync.sources.theoretical.provider import TheoreticalAgendaProvider
from pronote_sync.sync.diff import AgendaComparator
class _StubProvider(TheoreticalAgendaProvider):
"""Stub implementation of TheoreticalAgendaProvider for testing."""
def __init__(self, lessons: list[TheoreticalLesson]) -> None:
"""Initialize with a fixed list of theoretical lessons."""
self._lessons = lessons
@override
def get_lessons(self, target_date: date) -> list[TheoreticalLesson]:
"""Return the stub lessons regardless of target_date."""
return self._lessons.copy()
@override
def get_lessons_for_range(self, start_date: date, end_date: date) -> list[TheoreticalLesson]:
"""Return the stub lessons regardless of date range."""
return self._lessons.copy()
# Target date: Monday, 2025-09-15 (weekday() = 0)
TARGET_DATE = date(2025, 9, 15)
@pytest.fixture(name="empty_provider")
def fixture_empty_provider() -> _StubProvider:
"""Provider with no theoretical lessons."""
return _StubProvider([])
@pytest.fixture(name="comparator")
def fixture_comparator(empty_provider: _StubProvider) -> AgendaComparator:
"""AgendaComparator with empty provider."""
return AgendaComparator(empty_provider)
# ==================== Test Case 1: Empty agendas ====================
def test_empty_agendas(comparator: AgendaComparator) -> None:
"""No real, no theoretical → AgendaDiff with empty changes."""
result = comparator.compare([], TARGET_DATE)
assert result.target_date == TARGET_DATE
assert result.changes == ()
# ==================== Test Case 2: Empty theoretical, real lessons present ====================
def test_empty_theoretical_real_present(comparator: AgendaComparator) -> None:
"""Empty theoretical, real lessons present → all real → ADDED."""
real_lessons = [
Lesson(
id="real_1",
start=datetime(2025, 9, 15, 10, 0, 0),
end=datetime(2025, 9, 15, 11, 0, 0),
subject="Mathématiques",
group=None,
content=None,
),
Lesson(
id="real_2",
start=datetime(2025, 9, 15, 14, 0, 0),
end=datetime(2025, 9, 15, 15, 0, 0),
subject="Français",
group=None,
content=None,
),
]
result = comparator.compare(real_lessons, TARGET_DATE)
assert len(result.changes) == 2
assert result.changes[0].type == AgendaChangeType.ADDED
assert result.changes[0].lesson == real_lessons[0]
assert result.changes[0].theoretical_lesson is None
assert result.changes[0].details == "Cours ajouté par rapport à l'agenda théorique"
assert result.changes[1].type == AgendaChangeType.ADDED
assert result.changes[1].lesson == real_lessons[1]
# ==================== Test Case 3: Empty real, theoretical present ====================
def test_empty_real_theoretical_present() -> None:
"""Empty real, theoretical present → all theoretical → REMOVED, sorted by id."""
theoretical_lessons = [
TheoreticalLesson(
id="theo_b",
day_of_week=0,
start_time=time(10, 0),
end_time=time(11, 0),
subject="Mathématiques",
),
TheoreticalLesson(
id="theo_a",
day_of_week=0,
start_time=time(14, 0),
end_time=time(15, 0),
subject="Français",
),
]
provider = _StubProvider(theoretical_lessons)
comparator = AgendaComparator(provider)
result = comparator.compare([], TARGET_DATE)
assert len(result.changes) == 2
assert result.changes[0].type == AgendaChangeType.REMOVED
assert result.changes[0].theoretical_lesson == theoretical_lessons[1] # theo_a first
assert result.changes[0].lesson is None
assert result.changes[0].details == "Cours supprimé par rapport à l'agenda théorique"
assert result.changes[1].type == AgendaChangeType.REMOVED
assert result.changes[1].theoretical_lesson == theoretical_lessons[0] # theo_b second
# ==================== Test Case 4: Exact match ====================
def test_exact_match() -> None:
"""Real and theoretical at same time, same subject → no changes."""
theoretical_lessons = [
TheoreticalLesson(
id="theo_1",
day_of_week=0,
start_time=time(10, 0),
end_time=time(11, 0),
subject="Mathématiques",
),
]
real_lessons = [
Lesson(
id="real_1",
start=datetime(2025, 9, 15, 10, 0, 0),
end=datetime(2025, 9, 15, 11, 0, 0),
subject="Mathématiques",
group=None,
content=None,
),
]
provider = _StubProvider(theoretical_lessons)
comparator = AgendaComparator(provider)
result = comparator.compare(real_lessons, TARGET_DATE)
assert result.changes == ()
# ==================== Test Case 4bis: Seconds ignored in modification detection ====================
def test_seconds_ignored_in_modification_detection() -> None:
"""Real with seconds and theoretical without → same minutes → no MODIFIED.
The real lesson starts at 10:00:30 and ends at 11:00:45 while the
theoretical lesson is at 10:0011:00. The minute-level times match (10:00
and 11:00), so the real lesson matches the theoretical one within the
±15 min tolerance and is NOT marked MODIFIED despite the differing seconds.
"""
theoretical_lessons = [
TheoreticalLesson(
id="theo_1",
day_of_week=0,
start_time=time(10, 0),
end_time=time(11, 0),
subject="Mathématiques",
),
]
real_lessons = [
Lesson(
id="real_1",
start=datetime(2025, 9, 15, 10, 0, 30),
end=datetime(2025, 9, 15, 11, 0, 45),
subject="Mathématiques",
group=None,
content=None,
),
]
provider = _StubProvider(theoretical_lessons)
comparator = AgendaComparator(provider)
result = comparator.compare(real_lessons, TARGET_DATE)
assert result.changes == ()
# ==================== Test Case 5: Within tolerance (±14 min) ====================
def test_within_tolerance_14min() -> None:
"""Real start 14 min before theoretical → match, MODIFIED (horaires different)."""
theoretical_lessons = [
TheoreticalLesson(
id="theo_1",
day_of_week=0,
start_time=time(10, 0),
end_time=time(11, 0),
subject="Mathématiques",
),
]
real_lessons = [
Lesson(
id="real_1",
start=datetime(2025, 9, 15, 9, 46, 0),
end=datetime(2025, 9, 15, 10, 46, 0),
subject="Mathématiques",
group=None,
content=None,
),
]
provider = _StubProvider(theoretical_lessons)
comparator = AgendaComparator(provider)
result = comparator.compare(real_lessons, TARGET_DATE)
assert len(result.changes) == 1
assert result.changes[0].type == AgendaChangeType.MODIFIED
assert result.changes[0].lesson == real_lessons[0]
assert result.changes[0].theoretical_lesson == theoretical_lessons[0]
assert "horaires: 10:0011:00 → 09:4610: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:0011:00 → 09:4510:45" in result.changes[0].details
# ==================== Test Case 7: Outside tolerance (16 min) ====================
def test_outside_tolerance_16min() -> None:
"""Real start 16 min from theoretical → no match → ADDED + REMOVED."""
theoretical_lessons = [
TheoreticalLesson(
id="theo_1",
day_of_week=0,
start_time=time(10, 0),
end_time=time(11, 0),
subject="Mathématiques",
),
]
real_lessons = [
Lesson(
id="real_1",
start=datetime(2025, 9, 15, 9, 44, 0),
end=datetime(2025, 9, 15, 10, 44, 0),
subject="Mathématiques",
group=None,
content=None,
),
]
provider = _StubProvider(theoretical_lessons)
comparator = AgendaComparator(provider)
result = comparator.compare(real_lessons, TARGET_DATE)
assert len(result.changes) == 2
assert result.changes[0].type == AgendaChangeType.ADDED
assert result.changes[0].lesson == real_lessons[0]
assert result.changes[1].type == AgendaChangeType.REMOVED
assert result.changes[1].theoretical_lesson == theoretical_lessons[0]
# ==================== Test Case 8: Subject normalization match ====================
def test_subject_normalization_match() -> None:
"""Real '\\u212BNGSTRÖM' (angstrom sign), theoretical 'ångström' → NFKC → same form, match, no change."""
theoretical_lessons = [
TheoreticalLesson(
id="theo_1",
day_of_week=0,
start_time=time(10, 0),
end_time=time(11, 0),
subject="ångström",
),
]
real_lessons = [
Lesson(
id="real_1",
start=datetime(2025, 9, 15, 10, 0, 0),
end=datetime(2025, 9, 15, 11, 0, 0),
subject="\u212bNGSTRÖM",
group=None,
content=None,
),
]
provider = _StubProvider(theoretical_lessons)
comparator = AgendaComparator(provider)
result = comparator.compare(real_lessons, TARGET_DATE)
assert result.changes == ()
# ==================== Test Case 9: Different normalized subjects ====================
def test_different_normalized_subjects() -> None:
"""Real 'Mathématiques', theoretical 'Français' → no match → ADDED + REMOVED."""
theoretical_lessons = [
TheoreticalLesson(
id="theo_1",
day_of_week=0,
start_time=time(10, 0),
end_time=time(11, 0),
subject="Français",
),
]
real_lessons = [
Lesson(
id="real_1",
start=datetime(2025, 9, 15, 10, 0, 0),
end=datetime(2025, 9, 15, 11, 0, 0),
subject="Mathématiques",
group=None,
content=None,
),
]
provider = _StubProvider(theoretical_lessons)
comparator = AgendaComparator(provider)
result = comparator.compare(real_lessons, TARGET_DATE)
assert len(result.changes) == 2
assert result.changes[0].type == AgendaChangeType.ADDED
assert result.changes[0].lesson == real_lessons[0]
assert result.changes[1].type == AgendaChangeType.REMOVED
assert result.changes[1].theoretical_lesson == theoretical_lessons[0]
# ==================== Test Case 10: Multi-candidate selection by id ====================
def test_multi_candidate_selection_by_id() -> None:
"""1 real / 2 identical theoretical → the non-selected theoretical is REMOVED.
Two theoretical candidates match one real; the real selects theo_a (the
smaller id, identical teachers → no MODIFIED). theo_b (larger id) is not
selected and, being unmatched, must be REMOVED.
"""
theoretical_lessons = [
TheoreticalLesson(
id="theo_b",
day_of_week=0,
start_time=time(10, 0),
end_time=time(11, 0),
subject="Mathématiques",
teachers=("Mme Martin",),
),
TheoreticalLesson(
id="theo_a",
day_of_week=0,
start_time=time(10, 0),
end_time=time(11, 0),
subject="Mathématiques",
teachers=("M. Dupont",),
),
]
real_lessons = [
Lesson(
id="real_1",
start=datetime(2025, 9, 15, 10, 0, 0),
end=datetime(2025, 9, 15, 11, 0, 0),
subject="Mathématiques",
teachers=("M. Dupont",),
group=None,
content=None,
),
]
provider = _StubProvider(theoretical_lessons)
comparator = AgendaComparator(provider)
result = comparator.compare(real_lessons, TARGET_DATE)
# theo_a (smaller id) selected with identical teachers → no change; theo_b unmatched → REMOVED
assert len(result.changes) == 1
assert result.changes[0].type == AgendaChangeType.REMOVED
assert result.changes[0].lesson is None
assert result.changes[0].theoretical_lesson == theoretical_lessons[0] # theo_b
# ==================== Test Case 11: MODIFIED — teachers differ (order-insensitive) ====================
def test_teachers_differ_order_insensitive() -> None:
"""Real teachers ('M. Dupont', 'Mme Martin'), theoretical ('Mme Martin', 'M. Dupont') → match, NOT modified (same set)."""
theoretical_lessons = [
TheoreticalLesson(
id="theo_1",
day_of_week=0,
start_time=time(10, 0),
end_time=time(11, 0),
subject="Mathématiques",
teachers=("Mme Martin", "M. Dupont"),
),
]
real_lessons = [
Lesson(
id="real_1",
start=datetime(2025, 9, 15, 10, 0, 0),
end=datetime(2025, 9, 15, 11, 0, 0),
subject="Mathématiques",
teachers=("M. Dupont", "Mme Martin"),
group=None,
content=None,
),
]
provider = _StubProvider(theoretical_lessons)
comparator = AgendaComparator(provider)
result = comparator.compare(real_lessons, TARGET_DATE)
assert result.changes == ()
# ==================== Test Case 12: MODIFIED — teachers differ (different sets) ====================
def test_teachers_differ_different_sets() -> None:
"""Real ('M. Dupont',), theoretical ('Mme Martin',) → match, MODIFIED."""
theoretical_lessons = [
TheoreticalLesson(
id="theo_1",
day_of_week=0,
start_time=time(10, 0),
end_time=time(11, 0),
subject="Mathématiques",
teachers=("Mme Martin",),
),
]
real_lessons = [
Lesson(
id="real_1",
start=datetime(2025, 9, 15, 10, 0, 0),
end=datetime(2025, 9, 15, 11, 0, 0),
subject="Mathématiques",
teachers=("M. Dupont",),
group=None,
content=None,
),
]
provider = _StubProvider(theoretical_lessons)
comparator = AgendaComparator(provider)
result = comparator.compare(real_lessons, TARGET_DATE)
assert len(result.changes) == 1
assert result.changes[0].type == AgendaChangeType.MODIFIED
assert "professeurs: ['Mme Martin'] → ['M. Dupont']" in result.changes[0].details
# ==================== Test Case 13: MODIFIED — rooms differ ====================
def test_rooms_differ() -> None:
"""Real ('Salle 12',), theoretical ('Salle 15',) → match, MODIFIED."""
theoretical_lessons = [
TheoreticalLesson(
id="theo_1",
day_of_week=0,
start_time=time(10, 0),
end_time=time(11, 0),
subject="Mathématiques",
rooms=("Salle 15",),
),
]
real_lessons = [
Lesson(
id="real_1",
start=datetime(2025, 9, 15, 10, 0, 0),
end=datetime(2025, 9, 15, 11, 0, 0),
subject="Mathématiques",
rooms=("Salle 12",),
group=None,
content=None,
),
]
provider = _StubProvider(theoretical_lessons)
comparator = AgendaComparator(provider)
result = comparator.compare(real_lessons, TARGET_DATE)
assert len(result.changes) == 1
assert result.changes[0].type == AgendaChangeType.MODIFIED
assert "salles: ['Salle 15'] → ['Salle 12']" in result.changes[0].details
# ==================== Test Case 14: Deterministic teachers formatting ====================
def test_teachers_sorted_in_details() -> None:
"""Multiple teachers → details list sorted alphabetically regardless of input order."""
theoretical_lessons = [
TheoreticalLesson(
id="theo_1",
day_of_week=0,
start_time=time(10, 0),
end_time=time(11, 0),
subject="Mathématiques",
teachers=("Chloe", "Alice", "Bob"),
),
]
real_lessons = [
Lesson(
id="real_1",
start=datetime(2025, 9, 15, 10, 0, 0),
end=datetime(2025, 9, 15, 11, 0, 0),
subject="Mathématiques",
teachers=("Bob", "Chloe"),
group=None,
content=None,
),
]
provider = _StubProvider(theoretical_lessons)
comparator = AgendaComparator(provider)
result = comparator.compare(real_lessons, TARGET_DATE)
assert len(result.changes) == 1
assert result.changes[0].type == AgendaChangeType.MODIFIED
assert result.changes[0].details == "professeurs: ['Alice', 'Bob', 'Chloe'] → ['Bob', 'Chloe']"
# ==================== Test Case 15: Deterministic rooms formatting ====================
def test_rooms_sorted_in_details() -> None:
"""Multiple rooms → details list sorted alphabetically regardless of input order."""
theoretical_lessons = [
TheoreticalLesson(
id="theo_1",
day_of_week=0,
start_time=time(10, 0),
end_time=time(11, 0),
subject="Mathématiques",
rooms=("C101", "A102", "B103"),
),
]
real_lessons = [
Lesson(
id="real_1",
start=datetime(2025, 9, 15, 10, 0, 0),
end=datetime(2025, 9, 15, 11, 0, 0),
subject="Mathématiques",
rooms=("B103", "C101"),
group=None,
content=None,
),
]
provider = _StubProvider(theoretical_lessons)
comparator = AgendaComparator(provider)
result = comparator.compare(real_lessons, TARGET_DATE)
assert len(result.changes) == 1
assert result.changes[0].type == AgendaChangeType.MODIFIED
assert result.changes[0].details == "salles: ['A102', 'B103', 'C101'] → ['B103', 'C101']"
# ==================== Test Case 16: Inter-process deterministic details ====================
def test_details_deterministic_sorted_exact() -> None:
"""MODIFIED details are exactly sorted, independent of teachers input order."""
theoretical_lessons = [
TheoreticalLesson(
id="theo_1",
day_of_week=0,
start_time=time(10, 0),
end_time=time(11, 0),
subject="Mathématiques",
teachers=("Alice", "Bob"),
),
]
real_lessons = [
Lesson(
id="real_1",
start=datetime(2025, 9, 15, 10, 0, 0),
end=datetime(2025, 9, 15, 11, 0, 0),
subject="Mathématiques",
teachers=("Bob", "Alice", "Chloe"),
group=None,
content=None,
),
]
provider = _StubProvider(theoretical_lessons)
comparator = AgendaComparator(provider)
result = comparator.compare(real_lessons, TARGET_DATE)
assert len(result.changes) == 1
assert result.changes[0].type == AgendaChangeType.MODIFIED
assert result.changes[0].details == "professeurs: ['Alice', 'Bob'] → ['Alice', 'Bob', 'Chloe']"
# ==================== Test Case 17: MODIFIED — status != NORMAL ====================
def test_status_not_normal() -> None:
"""Real status=CANCELLED, otherwise identical → match, MODIFIED with statut in details."""
theoretical_lessons = [
TheoreticalLesson(
id="theo_1",
day_of_week=0,
start_time=time(10, 0),
end_time=time(11, 0),
subject="Mathématiques",
),
]
real_lessons = [
Lesson(
id="real_1",
start=datetime(2025, 9, 15, 10, 0, 0),
end=datetime(2025, 9, 15, 11, 0, 0),
subject="Mathématiques",
status=LessonStatus.CANCELLED,
group=None,
content=None,
),
]
provider = _StubProvider(theoretical_lessons)
comparator = AgendaComparator(provider)
result = comparator.compare(real_lessons, TARGET_DATE)
assert len(result.changes) == 1
assert result.changes[0].type == AgendaChangeType.MODIFIED
assert "statut: cancelled" in result.changes[0].details
# ==================== Test Case 15: REMOVED by existence, not selection ====================
def test_removed_by_existence_not_selection() -> None:
"""1 real / 2 identical theoretical → the unmatched theoretical is REMOVED.
The matching is one-to-one: the real consumes theo_a (smaller id) and theo_b
remains available, hence REMOVED even though it is a candidate by existence.
"""
theoretical_lessons = [
TheoreticalLesson(
id="theo_a",
day_of_week=0,
start_time=time(10, 0),
end_time=time(11, 0),
subject="Mathématiques",
),
TheoreticalLesson(
id="theo_b",
day_of_week=0,
start_time=time(10, 0),
end_time=time(11, 0),
subject="Mathématiques",
),
]
real_lessons = [
Lesson(
id="real_1",
start=datetime(2025, 9, 15, 10, 0, 0),
end=datetime(2025, 9, 15, 11, 0, 0),
subject="Mathématiques",
group=None,
content=None,
),
]
provider = _StubProvider(theoretical_lessons)
comparator = AgendaComparator(provider)
result = comparator.compare(real_lessons, TARGET_DATE)
# theo_a (smaller id) matched → no change; theo_b unmatched → REMOVED
assert len(result.changes) == 1
assert result.changes[0].type == AgendaChangeType.REMOVED
assert result.changes[0].lesson is None
assert result.changes[0].theoretical_lesson == theoretical_lessons[1] # theo_b
# ==================== Test Case 16: Deterministic order ====================
def test_deterministic_order() -> None:
"""Multiple ADDED, MODIFIED, REMOVED in same run → verify exact order (reals in input order, then theoreticals sorted by id)."""
theoretical_lessons = [
TheoreticalLesson(
id="theo_c",
day_of_week=0,
start_time=time(15, 0),
end_time=time(16, 0),
subject="Histoire",
),
TheoreticalLesson(
id="theo_a",
day_of_week=0,
start_time=time(8, 0),
end_time=time(9, 0),
subject="Physique",
),
TheoreticalLesson(
id="theo_b",
day_of_week=0,
start_time=time(10, 0),
end_time=time(11, 0),
subject="Mathématiques",
),
]
real_lessons = [
Lesson(
id="real_1",
start=datetime(2025, 9, 15, 10, 0, 0),
end=datetime(2025, 9, 15, 11, 0, 0),
subject="Mathématiques",
teachers=("M. Dupont",),
group=None,
content=None,
),
Lesson(
id="real_2",
start=datetime(2025, 9, 15, 14, 0, 0),
end=datetime(2025, 9, 15, 15, 0, 0),
subject="Informatique",
group=None,
content=None,
),
]
provider = _StubProvider(theoretical_lessons)
comparator = AgendaComparator(provider)
result = comparator.compare(real_lessons, TARGET_DATE)
# real_1 matches theo_b but has different teachers → MODIFIED
# real_2 has no match → ADDED
# theo_a and theo_c are not matched by existence → REMOVED (sorted by id: theo_a, theo_c)
assert len(result.changes) == 4
# First: real_1 MODIFIED
assert result.changes[0].type == AgendaChangeType.MODIFIED
assert result.changes[0].lesson == real_lessons[0]
# Second: real_2 ADDED
assert result.changes[1].type == AgendaChangeType.ADDED
assert result.changes[1].lesson == real_lessons[1]
# Third: theo_a REMOVED
assert result.changes[2].type == AgendaChangeType.REMOVED
assert result.changes[2].theoretical_lesson == theoretical_lessons[1] # theo_a
# Fourth: theo_c REMOVED
assert result.changes[3].type == AgendaChangeType.REMOVED
assert result.changes[3].theoretical_lesson == theoretical_lessons[0] # theo_c
# ==================== Test Case 17: Idempotence ====================
def test_idempotence() -> None:
"""Call compare twice with same inputs → identical AgendaDiff."""
theoretical_lessons = [
TheoreticalLesson(
id="theo_1",
day_of_week=0,
start_time=time(10, 0),
end_time=time(11, 0),
subject="Mathématiques",
),
]
real_lessons = [
Lesson(
id="real_1",
start=datetime(2025, 9, 15, 10, 0, 0),
end=datetime(2025, 9, 15, 11, 0, 0),
subject="Mathématiques",
group=None,
content=None,
),
]
provider = _StubProvider(theoretical_lessons)
comparator = AgendaComparator(provider)
result1 = comparator.compare(real_lessons, TARGET_DATE)
result2 = comparator.compare(real_lessons, TARGET_DATE)
assert result1 == result2
# ==================== Test Case 18: 2 reals identical / 1 theoretical → 1 ADDED ====================
def test_two_reals_one_theoretical_added() -> None:
"""2 identical reals / 1 matching theoretical → the surplus real is ADDED.
The real with the smaller id is matched to the theoretical; the real with
the larger id has no remaining candidate and must be ADDED.
"""
theoretical_lessons = [
TheoreticalLesson(
id="theo_1",
day_of_week=0,
start_time=time(10, 0),
end_time=time(11, 0),
subject="Mathématiques",
),
]
real_lessons = [
Lesson(
id="real_b",
start=datetime(2025, 9, 15, 10, 0, 0),
end=datetime(2025, 9, 15, 11, 0, 0),
subject="Mathématiques",
group=None,
content=None,
),
Lesson(
id="real_a",
start=datetime(2025, 9, 15, 10, 0, 0),
end=datetime(2025, 9, 15, 11, 0, 0),
subject="Mathématiques",
group=None,
content=None,
),
]
provider = _StubProvider(theoretical_lessons)
comparator = AgendaComparator(provider)
result = comparator.compare(real_lessons, TARGET_DATE)
# real_a (smaller id) matched to theo_1; real_b (larger id) unmatched → ADDED
assert len(result.changes) == 1
assert result.changes[0].type == AgendaChangeType.ADDED
assert result.changes[0].lesson == real_lessons[0] # real_b
assert result.changes[0].theoretical_lesson is None
# ==================== Test Case 19: Order stability ====================
def test_order_stability() -> None:
"""Presenting real lessons in different orders yields the same result."""
theoretical_lessons = [
TheoreticalLesson(
id="theo_1",
day_of_week=0,
start_time=time(10, 0),
end_time=time(11, 0),
subject="Mathématiques",
),
]
real_a = Lesson(
id="real_a",
start=datetime(2025, 9, 15, 10, 0, 0),
end=datetime(2025, 9, 15, 11, 0, 0),
subject="Mathématiques",
group=None,
content=None,
)
real_b = Lesson(
id="real_b",
start=datetime(2025, 9, 15, 10, 0, 0),
end=datetime(2025, 9, 15, 11, 0, 0),
subject="Mathématiques",
teachers=("M. Dupont",),
group=None,
content=None,
)
provider = _StubProvider(theoretical_lessons)
comparator = AgendaComparator(provider)
result_ab = comparator.compare([real_a, real_b], TARGET_DATE)
result_ba = comparator.compare([real_b, real_a], TARGET_DATE)
# real_a matched to theo_1 (no change); real_b unmatched → ADDED
assert result_ab == result_ba
assert len(result_ab.changes) == 1
assert result_ab.changes[0].type == AgendaChangeType.ADDED
assert result_ab.changes[0].lesson == real_b
# ============ Test Case 20: Off-target-date real lesson is strictly filtered ============
def _theoretical_monday() -> TheoreticalLesson:
"""Theoretical Monday 10:0011: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 == ()

View File

@@ -150,7 +150,15 @@ from pronote_sync.models.xmpp import XmppMessage
group=None, group=None,
content=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": ( "messages": (
@@ -369,5 +377,11 @@ def test_agenda_change_type_enum_values() -> None:
group=None, group=None,
content=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",
),
) )

View File

@@ -240,7 +240,7 @@ class TestAgendaChangeConsistency:
assert instance.theoretical_lesson is not None assert instance.theoretical_lesson is not None
def test_agenda_change_modified_with_lesson_valid(self) -> None: def test_agenda_change_modified_with_lesson_valid(self) -> None:
"""Vérifie que type=MODIFIED avec lesson=<valide> est valide.""" """Vérifie que type=MODIFIED avec lesson et theoretical_lesson est valide."""
lesson = Lesson( lesson = Lesson(
id="lesson-valid-mod", id="lesson-valid-mod",
start=datetime(2024, 9, 6, 10, 0, 0), start=datetime(2024, 9, 6, 10, 0, 0),
@@ -249,13 +249,97 @@ class TestAgendaChangeConsistency:
group=None, group=None,
content=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( instance = AgendaChange(
type=AgendaChangeType.MODIFIED,
lesson=lesson,
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, type=AgendaChangeType.MODIFIED,
lesson=lesson, lesson=lesson,
theoretical_lesson=None, theoretical_lesson=None,
) )
assert instance.type == AgendaChangeType.MODIFIED assert any(
assert instance.lesson is not None "theoretical_lesson est requis pour le type" in str(error)
for error in exc_info.value.errors()
)
class TestCalDAVSyncResultInvariants: class TestCalDAVSyncResultInvariants: