"""Tests unitaires pour le planificateur de synchronisation CalDAV. Ce module vérifie que la fonction :func:`compute_plan` produit correctement les listes d'ajouts, mises à jour et suppressions pour chaque catégorie (cours, devoirs, événements scolaires) en comparant les données Pronote normalisées aux événements distants marqués comme gérés. """ from __future__ import annotations from datetime import date, datetime from icalendar import Event from pronote_sync.models.agenda import Lesson, LessonStatus, SchoolEvent, SchoolEventKind from pronote_sync.models.homework import Homework from pronote_sync.models.pronote import PronoteData from pronote_sync.sync.planner import compute_plan from pronote_sync.sync.serialization import ( lesson_to_vevent, ) # --- Helper fixtures --- def _make_lesson( lesson_id: str = "L-1234", subject: str = "Mathématiques", start: datetime | None = None, end: datetime | None = None, status: LessonStatus = LessonStatus.NORMAL, content: str | None = None, group: str | None = None, ) -> Lesson: """Fabrique un cours Pronote pour les tests. :param lesson_id: Identifiant du cours. :param subject: Matière. :param start: Date/heure de début. :param end: Date/heure de fin. :param status: Statut du cours. :param content: Contenu pédagogique. :param group: Groupe. :return: Instance de Lesson. :rtype: Lesson """ if start is None: start = datetime(2026, 1, 15, 8, 0) if end is None: end = datetime(2026, 1, 15, 9, 0) return Lesson( id=lesson_id, start=start, end=end, subject=subject, status=status, content=content, group=group, ) def _make_homework( homework_id: str = "HW-5678", subject: str = "Mathématiques", due_on: date | None = None, text: str = "Exercice 1 à 5", assigned_on: date | None = None, ) -> Homework: """Fabrique un devoir Pronote pour les tests. :param homework_id: Identifiant du devoir. :param subject: Matière. :param due_on: Date d'échéance. :param text: Texte du devoir. :param assigned_on: Date de distribution. :return: Instance de Homework. :rtype: Homework """ if due_on is None: due_on = date(2026, 1, 20) return Homework( id=homework_id, subject=subject, due_on=due_on, text=text, assigned_on=assigned_on, ) def _make_school_event( label: str = "Vacances de Noël", from_date: date | None = None, to_date: date | None = None, kind: SchoolEventKind = SchoolEventKind.HOLIDAY, ) -> SchoolEvent: """Fabrique un événement scolaire pour les tests. :param label: Libellé de l'événement. :param from_date: Date de début. :param to_date: Date de fin. :param kind: Type d'événement. :return: Instance de SchoolEvent. :rtype: SchoolEvent """ if from_date is None: from_date = date(2026, 12, 20) if to_date is None: to_date = date(2027, 1, 5) return SchoolEvent( label=label, from_date=from_date, to_date=to_date, kind=kind, ) def _make_vevent( uid: str, summary: str, dtstart: datetime, dtend: datetime, status: str = "CONFIRMED", categories: list[str] | None = None, ) -> Event: """Fabrique un VEVENT iCalendar pour les tests. :param uid: UID de l'événement. :param summary: Résumé. :param dtstart: Date/heure de début. :param dtend: Date/heure de fin. :param status: Statut. :param categories: Catégories. :return: Instance de Event. :rtype: Event """ from icalendar import vDatetime event = Event() event.add("UID", uid) event.add("SUMMARY", summary) event.add("DTSTART", vDatetime(dtstart)) event.add("DTEND", vDatetime(dtend)) event.add("STATUS", status) if categories: event.add("CATEGORIES", categories) # Ajouter le marqueur de gestion from pronote_sync.sync.serialization import MANAGED_PROPERTY, MANAGED_VALUE event.add(MANAGED_PROPERTY, MANAGED_VALUE) return event def _make_pronote_data( lessons: list[Lesson] | None = None, homeworks: list[Homework] | None = None, school_events: list[SchoolEvent] | None = None, ) -> PronoteData: """Fabrique des données Pronote pour les tests. :param lessons: Liste des cours. :param homeworks: Liste des devoirs. :param school_events: Liste des événements scolaires. :return: Instance de PronoteData. :rtype: PronoteData """ return PronoteData( lessons=lessons or [], homeworks=homeworks or [], school_events=school_events or [], messages=[], target_date=date(2026, 1, 15), generated_at=datetime(2026, 1, 15, 0, 0), ) # --- Tests for lessons --- def test_lesson_add_when_not_in_remote() -> None: """Vérifie qu'un cours non présent à distance va dans lessons_to_add. :return: None """ lesson = _make_lesson(lesson_id="L-1234") pronote_data = _make_pronote_data(lessons=[lesson]) remote_managed: list[tuple[str, str, Event]] = [] plan, raw_mapping = compute_plan(pronote_data, remote_managed) assert len(plan.lessons_to_add) == 1 assert plan.lessons_to_add[0].id == "L-1234" assert len(plan.lessons_to_update) == 0 assert len(plan.lessons_to_remove) == 0 def test_lesson_update_when_signature_differs() -> None: """Vérifie qu'un cours présent à distance avec une signature différente va dans lessons_to_update. :return: None """ lesson = _make_lesson(lesson_id="L-1234", subject="Mathématiques") pronote_data = _make_pronote_data(lessons=[lesson]) # Créer un VEVENT distant avec un sujet différent remote_event = _make_vevent( uid="L-1234", summary="Physique", # Différent dtstart=datetime(2026, 1, 15, 8, 0), dtend=datetime(2026, 1, 15, 9, 0), ) remote_managed: list[tuple[str, str, Event]] = [("L-1234", "L-1234", remote_event)] plan, raw_mapping = compute_plan(pronote_data, remote_managed) assert len(plan.lessons_to_add) == 0 assert len(plan.lessons_to_update) == 1 assert plan.lessons_to_update[0].id == "L-1234" assert len(plan.lessons_to_remove) == 0 def test_lesson_idempotent_when_signature_same() -> None: """Vérifie qu'un cours présent à distance avec la même signature n'apparaît dans aucune liste. :return: None """ lesson = _make_lesson(lesson_id="L-1234", subject="Mathématiques") pronote_data = _make_pronote_data(lessons=[lesson]) # Créer un VEVENT distant avec les mêmes propriétés remote_event = lesson_to_vevent(lesson) remote_managed: list[tuple[str, str, Event]] = [("L-1234", "L-1234", remote_event)] plan, raw_mapping = compute_plan(pronote_data, remote_managed) assert len(plan.lessons_to_add) == 0 assert len(plan.lessons_to_update) == 0 assert len(plan.lessons_to_remove) == 0 def test_lesson_remove_when_not_in_local() -> None: """Vérifie qu'un UID distant non présent en local va dans lessons_to_remove. :return: None """ pronote_data = _make_pronote_data(lessons=[]) remote_event = _make_vevent( uid="L-9999", summary="Ancien cours", dtstart=datetime(2026, 1, 15, 8, 0), dtend=datetime(2026, 1, 15, 9, 0), ) remote_managed: list[tuple[str, str, Event]] = [("L-9999", "L-9999", remote_event)] plan, raw_mapping = compute_plan(pronote_data, remote_managed) assert len(plan.lessons_to_add) == 0 assert len(plan.lessons_to_update) == 0 assert len(plan.lessons_to_remove) == 1 assert plan.lessons_to_remove[0] == "L-9999" def test_plan_with_suffixed_remote_uid_matches_canonical() -> None: """Vérifie qu'un UID distant suffixé apparié par UID canonique ne produit rien. L'événement distant porte un UID brut suffixé (``L-1234-20260905T080000Z-Index-Education``) dont la forme canonique (``L-1234``) correspond au cours local ; les signatures étant identiques, le plan doit être vide — sans ajout, mise à jour ou suppression artificiels. :return: None """ lesson = _make_lesson(lesson_id="L-1234", subject="Mathématiques") pronote_data = _make_pronote_data(lessons=[lesson]) # VEVENT distant construit à partir du même cours : contenu sémantique # identique (seul l'UID brut stocké diffère, capturé par le triplet). remote_event = lesson_to_vevent(lesson) remote_managed: list[tuple[str, str, Event]] = [ ("L-1234-20260905T080000Z-Index-Education", "L-1234", remote_event) ] plan, raw_mapping = compute_plan(pronote_data, remote_managed) assert len(plan.lessons_to_add) == 0 assert len(plan.lessons_to_update) == 0 assert len(plan.lessons_to_remove) == 0 def test_plan_with_suffixed_remote_uid_and_no_local_adds_to_remove() -> None: """Vérifie que la suppression d'un UID distant suffixé utilise l'UID brut. Un événement distant orphelin (aucun cours local) dont l'UID brut est suffixé doit être supprimé en ciblant l'UID brut stocké sur le serveur, et non sa forme canonique. :return: None """ pronote_data = _make_pronote_data(lessons=[]) raw_uid = "L-9999-20260101T080000Z-Index-Education" remote_event = _make_vevent( uid=raw_uid, summary="Ancien cours", dtstart=datetime(2026, 1, 15, 8, 0), dtend=datetime(2026, 1, 15, 9, 0), ) remote_managed: list[tuple[str, str, Event]] = [(raw_uid, "L-9999", remote_event)] plan, raw_mapping = compute_plan(pronote_data, remote_managed) assert len(plan.lessons_to_add) == 0 assert len(plan.lessons_to_update) == 0 assert len(plan.lessons_to_remove) == 1 # La liste de suppression contient l'UID brut, pas la forme canonique. assert plan.lessons_to_remove[0] == raw_uid def test_lesson_cancelled_preserved() -> None: """Vérifie qu'un cours annulé est traité normalement (ajout/mise à jour). :return: None """ lesson = _make_lesson(lesson_id="L-1234", status=LessonStatus.CANCELLED) pronote_data = _make_pronote_data(lessons=[lesson]) remote_managed: list[tuple[str, str, Event]] = [] plan, raw_mapping = compute_plan(pronote_data, remote_managed) # Un cours annulé doit aller dans lessons_to_add comme n'importe quel autre cours assert len(plan.lessons_to_add) == 1 assert plan.lessons_to_add[0].id == "L-1234" assert plan.lessons_to_add[0].status == LessonStatus.CANCELLED # --- Tests for homeworks --- def test_homework_add_when_not_in_remote() -> None: """Vérifie qu'un devoir non présent à distance va dans homeworks_to_add. :return: None """ homework = _make_homework(homework_id="HW-5678") pronote_data = _make_pronote_data(homeworks=[homework]) remote_managed: list[tuple[str, str, Event]] = [] plan, raw_mapping = compute_plan(pronote_data, remote_managed) assert len(plan.homeworks_to_add) == 1 assert plan.homeworks_to_add[0].id == "HW-5678" assert len(plan.homeworks_to_update) == 0 assert len(plan.homeworks_to_remove) == 0 def test_homework_update_when_signature_differs() -> None: """Vérifie qu'un devoir présent à distance avec une signature différente va dans homeworks_to_update. :return: None """ homework = _make_homework(homework_id="HW-5678", subject="Mathématiques") pronote_data = _make_pronote_data(homeworks=[homework]) # Créer un VEVENT distant avec un sujet différent remote_event = _make_vevent( uid="homework-HW-5678", summary="Devoir: Physique", # Différent dtstart=datetime(2026, 1, 20, 8, 0), dtend=datetime(2026, 1, 20, 18, 0), ) remote_managed: list[tuple[str, str, Event]] = [ ("homework-HW-5678", "homework-HW-5678", remote_event) ] plan, raw_mapping = compute_plan(pronote_data, remote_managed) assert len(plan.homeworks_to_add) == 0 assert len(plan.homeworks_to_update) == 1 assert plan.homeworks_to_update[0].id == "HW-5678" assert len(plan.homeworks_to_remove) == 0 def test_homework_remove_when_not_in_local() -> None: """Vérifie qu'un UID de devoir distant non présent en local va dans homeworks_to_remove. :return: None """ pronote_data = _make_pronote_data(homeworks=[]) remote_event = _make_vevent( uid="homework-HW-9999", summary="Devoir: Ancien devoir", dtstart=datetime(2026, 1, 20, 8, 0), dtend=datetime(2026, 1, 20, 18, 0), ) remote_managed: list[tuple[str, str, Event]] = [ ("homework-HW-9999", "homework-HW-9999", remote_event) ] plan, raw_mapping = compute_plan(pronote_data, remote_managed) assert len(plan.homeworks_to_add) == 0 assert len(plan.homeworks_to_update) == 0 assert len(plan.homeworks_to_remove) == 1 assert plan.homeworks_to_remove[0] == "homework-HW-9999" # --- Tests for school events --- def test_school_event_add_when_not_in_remote() -> None: """Vérifie qu'un événement scolaire non présent à distance va dans school_events_to_add. :return: None """ school_event = _make_school_event( label="Vacances de Noël", from_date=date(2026, 12, 20), ) pronote_data = _make_pronote_data(school_events=[school_event]) remote_managed: list[tuple[str, str, Event]] = [] plan, raw_mapping = compute_plan(pronote_data, remote_managed) assert len(plan.school_events_to_add) == 1 assert plan.school_events_to_add[0].label == "Vacances de Noël" assert len(plan.school_events_to_update) == 0 assert len(plan.school_events_to_remove) == 0 def test_school_event_update_when_signature_differs() -> None: """Vérifie qu'un événement scolaire présent à distance avec une signature différente va dans school_events_to_update. :return: None """ school_event = _make_school_event( label="Vacances de Noël", from_date=date(2026, 12, 20), to_date=date(2027, 1, 5), kind=SchoolEventKind.HOLIDAY, ) pronote_data = _make_pronote_data(school_events=[school_event]) # Créer un VEVENT distant avec le même UID mais un libellé différent # L'UID doit correspondre à celui généré par school_event_to_vevent remote_event = _make_vevent( uid="school-event-Vacances de Noël-2026-12-20", summary="Vacances d'hiver", # Différent du local "Vacances de Noël" dtstart=datetime(2026, 12, 20, 0, 0), dtend=datetime(2027, 1, 5, 0, 0), ) remote_managed: list[tuple[str, str, Event]] = [ ( "school-event-Vacances de Noël-2026-12-20", "school-event-Vacances de Noël-2026-12-20", remote_event, ) ] plan, raw_mapping = compute_plan(pronote_data, remote_managed) assert len(plan.school_events_to_add) == 0 assert len(plan.school_events_to_update) == 1 assert len(plan.school_events_to_remove) == 0 def test_school_event_remove_when_not_in_local() -> None: """Vérifie qu'un UID d'événement scolaire distant non présent en local va dans school_events_to_remove. :return: None """ pronote_data = _make_pronote_data(school_events=[]) remote_event = _make_vevent( uid="school-event-Ancien événement-2026-01-01", summary="Ancien événement", dtstart=datetime(2026, 1, 1, 0, 0), dtend=datetime(2026, 1, 2, 0, 0), ) remote_managed: list[tuple[str, str, Event]] = [ ( "school-event-Ancien événement-2026-01-01", "school-event-Ancien événement-2026-01-01", remote_event, ) ] plan, raw_mapping = compute_plan(pronote_data, remote_managed) assert len(plan.school_events_to_add) == 0 assert len(plan.school_events_to_update) == 0 assert len(plan.school_events_to_remove) == 1 assert plan.school_events_to_remove[0] == "school-event-Ancien événement-2026-01-01" # --- Tests for UID routing --- def test_uid_routing_homework_to_remove() -> None: """Vérifie qu'un UID distant commençant par 'homework-' va dans homeworks_to_remove. :return: None """ pronote_data = _make_pronote_data(lessons=[], homeworks=[], school_events=[]) remote_event = _make_vevent( uid="homework-HW-9999", summary="Devoir à supprimer", dtstart=datetime(2026, 1, 20, 8, 0), dtend=datetime(2026, 1, 20, 18, 0), ) remote_managed: list[tuple[str, str, Event]] = [ ("homework-HW-9999", "homework-HW-9999", remote_event) ] plan, raw_mapping = compute_plan(pronote_data, remote_managed) # Ne doit PAS aller dans lessons_to_remove assert len(plan.lessons_to_remove) == 0 assert len(plan.homeworks_to_remove) == 1 assert plan.homeworks_to_remove[0] == "homework-HW-9999" def test_uid_routing_school_event_to_remove() -> None: """Vérifie qu'un UID distant commençant par 'school-event-' va dans school_events_to_remove. :return: None """ pronote_data = _make_pronote_data(lessons=[], homeworks=[], school_events=[]) remote_event = _make_vevent( uid="school-event-Vacances-2026-12-20", summary="Événement à supprimer", dtstart=datetime(2026, 12, 20, 0, 0), dtend=datetime(2027, 1, 5, 0, 0), ) remote_managed: list[tuple[str, str, Event]] = [ ("school-event-Vacances-2026-12-20", "school-event-Vacances-2026-12-20", remote_event) ] plan, raw_mapping = compute_plan(pronote_data, remote_managed) # Ne doit PAS aller dans lessons_to_remove assert len(plan.lessons_to_remove) == 0 assert len(plan.school_events_to_remove) == 1 assert plan.school_events_to_remove[0] == "school-event-Vacances-2026-12-20" # --- Tests for empty inputs --- def test_empty_inputs_empty_plan() -> None: """Vérifie que des entrées vides produisent un plan vide. :return: None """ pronote_data = _make_pronote_data(lessons=[], homeworks=[], school_events=[]) remote_managed: list[tuple[str, str, Event]] = [] plan, raw_mapping = compute_plan(pronote_data, remote_managed) assert len(plan.lessons_to_add) == 0 assert len(plan.lessons_to_update) == 0 assert len(plan.lessons_to_remove) == 0 assert len(plan.homeworks_to_add) == 0 assert len(plan.homeworks_to_update) == 0 assert len(plan.homeworks_to_remove) == 0 assert len(plan.school_events_to_add) == 0 assert len(plan.school_events_to_update) == 0 assert len(plan.school_events_to_remove) == 0 # --- Tests for mixed scenarios --- def test_mixed_scenario() -> None: """Vérifie un scénario mixte avec ajouts, mises à jour et suppressions. :return: None """ # Données locales lesson1 = _make_lesson(lesson_id="L-0001") # Nouveau lesson2 = _make_lesson(lesson_id="L-0002", subject="Mathématiques") # À mettre à jour homework1 = _make_homework(homework_id="HW-0001") # Nouveau pronote_data = _make_pronote_data( lessons=[lesson1, lesson2], homeworks=[homework1], school_events=[], ) # Événements distants # L-0002 existe mais avec un sujet différent remote_lesson2 = _make_vevent( uid="L-0002", summary="Physique", dtstart=datetime(2026, 1, 15, 8, 0), dtend=datetime(2026, 1, 15, 9, 0), ) # L-0003 n'existe plus localement remote_lesson3 = _make_vevent( uid="L-0003", summary="Ancien cours", dtstart=datetime(2026, 1, 15, 8, 0), dtend=datetime(2026, 1, 15, 9, 0), ) remote_managed: list[tuple[str, str, Event]] = [ ("L-0002", "L-0002", remote_lesson2), ("L-0003", "L-0003", remote_lesson3), ] plan, raw_mapping = compute_plan(pronote_data, remote_managed) # Ajouts assert len(plan.lessons_to_add) == 1 assert plan.lessons_to_add[0].id == "L-0001" assert len(plan.homeworks_to_add) == 1 assert plan.homeworks_to_add[0].id == "HW-0001" # Mises à jour assert len(plan.lessons_to_update) == 1 assert plan.lessons_to_update[0].id == "L-0002" # Suppressions assert len(plan.lessons_to_remove) == 1 assert plan.lessons_to_remove[0] == "L-0003" def test_unmanaged_events_not_in_remote_managed() -> None: """Vérifie que remote_managed ne contient que des événements gérés. Le contrat indique que remote_managed ne contient déjà que des événements marqués comme gérés. Le planner ne doit pas filtrer. :return: None """ lesson = _make_lesson(lesson_id="L-1234") pronote_data = _make_pronote_data(lessons=[lesson]) # remote_managed ne contient que des événements gérés (par hypothèse) # Donc pas besoin de tester le filtrage ici - c'est la responsabilité de list_managed_events remote_managed: list[tuple[str, str, Event]] = [] plan, raw_mapping = compute_plan(pronote_data, remote_managed) # Le cours doit être dans lessons_to_add assert len(plan.lessons_to_add) == 1 # Ensure trailing newline