"""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, Event]] = [] plan = 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, Event]] = [("L-1234", remote_event)] plan = 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, Event]] = [("L-1234", remote_event)] plan = 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, Event]] = [("L-9999", remote_event)] plan = 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_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, Event]] = [] plan = 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, Event]] = [] plan = 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, Event]] = [("homework-HW-5678", remote_event)] plan = 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, Event]] = [("homework-HW-9999", remote_event)] plan = 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, Event]] = [] plan = 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, Event]] = [ ("school-event-Vacances de Noël-2026-12-20", remote_event) ] plan = 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, Event]] = [ ("school-event-Ancien événement-2026-01-01", remote_event) ] plan = 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, Event]] = [("homework-HW-9999", remote_event)] plan = 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, Event]] = [("school-event-Vacances-2026-12-20", remote_event)] plan = 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, Event]] = [] plan = 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, Event]] = [ ("L-0002", remote_lesson2), ("L-0003", remote_lesson3), ] plan = 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, Event]] = [] plan = compute_plan(pronote_data, remote_managed) # Le cours doit être dans lessons_to_add assert len(plan.lessons_to_add) == 1 # Ensure trailing newline