"""Tests unitaires pour la sérialisation des modèles Pronote en VEVENT iCalendar. Ce module vérifie que les fonctions de conversion des modèles Pronote (:class:`Lesson`, :class:`Homework`, :class:`SchoolEvent`) en composants :class:`icalendar.Event` produisent les propriétés attendues (UID, SUMMARY, DTSTART, DTEND, STATUS, CATEGORIES, etc.) et que la signature sémantique déterministe est correctement calculée. """ from __future__ import annotations from datetime import date, datetime from icalendar import Calendar, Event from pronote_sync.models.agenda import Lesson, LessonStatus, SchoolEvent, SchoolEventKind from pronote_sync.models.homework import Homework from pronote_sync.sync.serialization import ( MANAGED_PROPERTY, MANAGED_VALUE, component_to_signature, homework_to_vevent, lesson_to_vevent, model_to_vcalendar_text, school_event_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, teachers: tuple[str, ...] = ("Prof Dupont",), rooms: tuple[str, ...] = ("Salle 101",), 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 teachers: Professeurs. :param rooms: Salles. :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, teachers=teachers, rooms=rooms, 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", teachers: tuple[str, ...] = ("Prof Dupont",), 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 teachers: Professeurs. :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, teachers=teachers, 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, ) # --- lesson_to_vevent tests --- def test_model_to_vcalendar_text_produces_complete_vcalendar() -> None: """Vérifie que la sérialisation produit un VCALENDAR complet avec un VEVENT. :return: None """ lesson = _make_lesson() output = model_to_vcalendar_text(lesson) calendar = Calendar.from_ical(output) assert "VERSION:2.0" in output assert "PRODID" in output assert output.startswith("BEGIN:VCALENDAR") vevents = calendar.walk("VEVENT") assert len(vevents) == 1 def test_lesson_to_vevent_normal() -> None: """Vérifie qu'un cours normal produit un VEVENT avec les bonnes propriétés. Un cours normal doit avoir : - UID = id du cours - SUMMARY = matière - DTSTART/DTEND = dates de début/fin - STATUS = CONFIRMED - CATEGORIES contient "Pronote" - MANAGED_PROPERTY présent avec MANAGED_VALUE """ lesson = _make_lesson() event = lesson_to_vevent(lesson) assert str(event.get("UID")) == "L-1234" assert str(event.get("SUMMARY")) == "Mathématiques" assert event.get("DTSTART").dt == datetime(2026, 1, 15, 8, 0) assert event.get("DTEND").dt == datetime(2026, 1, 15, 9, 0) assert str(event.get("STATUS")) == "CONFIRMED" categories = event.get("CATEGORIES") assert categories is not None assert "Pronote" in categories.cats assert str(event.get(MANAGED_PROPERTY)) == MANAGED_VALUE def test_lesson_to_vevent_cancelled() -> None: """Vérifie qu'un cours annulé a STATUS=CANCELLED et CATEGORIES contient 'Annulé'. :return: None """ lesson = _make_lesson(status=LessonStatus.CANCELLED) event = lesson_to_vevent(lesson) assert str(event.get("STATUS")) == "CANCELLED" categories = event.get("CATEGORIES") assert categories is not None assert "Pronote" in categories.cats assert "Annulé" in categories.cats def test_lesson_to_vevent_moved() -> None: """Vérifie qu'un cours déplacé a CATEGORIES contient 'Déplacé'. :return: None """ lesson = _make_lesson(status=LessonStatus.MOVED) event = lesson_to_vevent(lesson) assert str(event.get("STATUS")) == "CONFIRMED" categories = event.get("CATEGORIES") assert categories is not None assert "Pronote" in categories.cats assert "Déplacé" in categories.cats def test_lesson_to_vevent_description() -> None: """Vérifie que la description contient tous les champs renseignés. :return: None """ lesson = _make_lesson( subject="Mathématiques", teachers=("Prof Dupont", "Prof Martin"), rooms=("Salle 101", "Salle 102"), content="Chapitre 1", ) event = lesson_to_vevent(lesson) description = str(event.get("DESCRIPTION")) assert "Matière: Mathématiques" in description assert "Professeur(s): Prof Dupont, Prof Martin" in description assert "Salle(s): Salle 101, Salle 102" in description assert "Contenu: Chapitre 1" in description # --- homework_to_vevent tests --- def test_homework_to_vevent_uid_prefix() -> None: """Vérifie que l'UID d'un devoir est préfixé par 'homework-'. :return: None """ homework = _make_homework() event = homework_to_vevent(homework) assert str(event.get("UID")) == "homework-HW-5678" def test_homework_to_vevent_status() -> None: """Vérifie qu'un devoir a STATUS=NEEDS-ACTION. :return: None """ homework = _make_homework() event = homework_to_vevent(homework) assert str(event.get("STATUS")) == "NEEDS-ACTION" def test_homework_to_vevent_categories() -> None: """Vérifie que les CATEGORIES d'un devoir contiennent 'Pronote' et 'Devoir'. :return: None """ homework = _make_homework() event = homework_to_vevent(homework) categories = event.get("CATEGORIES") assert categories is not None assert "Pronote" in categories.cats assert "Devoir" in categories.cats def test_homework_to_vevent_dtstart_dtend() -> None: """Vérifie que DTSTART et DTEND couvrent la journée d'échéance (08:00-18:00). :return: None """ homework = _make_homework(due_on=date(2026, 1, 20)) event = homework_to_vevent(homework) assert event.get("DTSTART").dt == datetime(2026, 1, 20, 8, 0) assert event.get("DTEND").dt == datetime(2026, 1, 20, 18, 0) def test_homework_to_vevent_summary() -> None: """Vérifie que le SUMMARY d'un devoir est préfixé par 'Devoir: '. :return: None """ homework = _make_homework(subject="Mathématiques") event = homework_to_vevent(homework) assert str(event.get("SUMMARY")) == "Devoir: Mathématiques" def test_homework_to_vevent_managed_marker() -> None: """Vérifie que le marqueur MANAGED_PROPERTY est présent. :return: None """ homework = _make_homework() event = homework_to_vevent(homework) assert str(event.get(MANAGED_PROPERTY)) == MANAGED_VALUE # --- school_event_to_vevent tests --- def test_school_event_to_vevent_uid_prefix() -> None: """Vérifie que l'UID d'un événement scolaire est préfixé correctement. :return: None """ school_event = _make_school_event( label="Vacances de Noël", from_date=date(2026, 12, 20), ) event = school_event_to_vevent(school_event) assert str(event.get("UID")) == "school-event-Vacances de Noël-2026-12-20" def test_school_event_to_vevent_status() -> None: """Vérifie qu'un événement scolaire a STATUS=CONFIRMED. :return: None """ school_event = _make_school_event() event = school_event_to_vevent(school_event) assert str(event.get("STATUS")) == "CONFIRMED" def test_school_event_to_vevent_categories() -> None: """Vérifie que les CATEGORIES contiennent 'Pronote' et la valeur du kind. :return: None """ school_event = _make_school_event(kind=SchoolEventKind.HOLIDAY) event = school_event_to_vevent(school_event) categories = event.get("CATEGORIES") assert categories is not None assert "Pronote" in categories.cats assert "holiday" in categories.cats def test_school_event_to_vevent_dtstart_dtend() -> None: """Vérifie que DTSTART et DTEND sont des vDate (pas vDatetime). :return: None """ school_event = _make_school_event( from_date=date(2026, 12, 20), to_date=date(2027, 1, 5), ) event = school_event_to_vevent(school_event) from icalendar import vDate assert isinstance(event.get("DTSTART"), vDate) assert isinstance(event.get("DTEND"), vDate) assert event.get("DTSTART").dt == date(2026, 12, 20) assert event.get("DTEND").dt == date(2027, 1, 5) def test_school_event_to_vevent_managed_marker() -> None: """Vérifie que le marqueur MANAGED_PROPERTY est présent. :return: None """ school_event = _make_school_event() event = school_event_to_vevent(school_event) assert str(event.get(MANAGED_PROPERTY)) == MANAGED_VALUE # --- component_to_signature tests --- def test_component_to_signature_ignores_volatile_properties() -> None: """Vérifie que deux VEVENTs ne différant que par DTSTAMP/CREATED/LAST-MODIFIED/SEQUENCE produisent la même signature. :return: None """ # Créer deux événements identiques sauf pour les propriétés volatiles event1 = Event() event1.add("UID", "test-uid") event1.add("SUMMARY", "Test Event") event1.add("DTSTART", datetime(2026, 1, 15, 8, 0)) event1.add("DTEND", datetime(2026, 1, 15, 9, 0)) event1.add("STATUS", "CONFIRMED") event1.add("DTSTAMP", datetime(2026, 1, 1, 0, 0)) # Différent event1.add("CREATED", datetime(2026, 1, 1, 0, 0)) # Différent event2 = Event() event2.add("UID", "test-uid") event2.add("SUMMARY", "Test Event") event2.add("DTSTART", datetime(2026, 1, 15, 8, 0)) event2.add("DTEND", datetime(2026, 1, 15, 9, 0)) event2.add("STATUS", "CONFIRMED") event2.add("DTSTAMP", datetime(2026, 1, 2, 0, 0)) # Différent event2.add("LAST-MODIFIED", datetime(2026, 1, 2, 0, 0)) # Différent event2.add("SEQUENCE", 1) # Différent sig1 = component_to_signature(event1) sig2 = component_to_signature(event2) assert sig1 == sig2 def test_component_to_signature_different_summary() -> None: """Vérifie que deux VEVENTs avec SUMMARY différent produisent des signatures différentes. :return: None """ event1 = Event() event1.add("UID", "test-uid") event1.add("SUMMARY", "Event 1") event1.add("DTSTART", datetime(2026, 1, 15, 8, 0)) event1.add("DTEND", datetime(2026, 1, 15, 9, 0)) event1.add("STATUS", "CONFIRMED") event2 = Event() event2.add("UID", "test-uid") event2.add("SUMMARY", "Event 2") # Différent event2.add("DTSTART", datetime(2026, 1, 15, 8, 0)) event2.add("DTEND", datetime(2026, 1, 15, 9, 0)) event2.add("STATUS", "CONFIRMED") sig1 = component_to_signature(event1) sig2 = component_to_signature(event2) assert sig1 != sig2 def test_component_to_signature_deterministic() -> None: """Vérifie que la signature est déterministe (même entrée → même sortie). :return: None """ event = Event() event.add("UID", "test-uid") event.add("SUMMARY", "Test Event") event.add("DTSTART", datetime(2026, 1, 15, 8, 0)) event.add("DTEND", datetime(2026, 1, 15, 9, 0)) event.add("STATUS", "CONFIRMED") sig1 = component_to_signature(event) sig2 = component_to_signature(event) assert sig1 == sig2 def test_component_to_signature_includes_categories() -> None: """Vérifie que les CATEGORIES sont incluses dans la signature (triées). :return: None """ event = Event() event.add("UID", "test-uid") event.add("SUMMARY", "Test Event") event.add("DTSTART", datetime(2026, 1, 15, 8, 0)) event.add("DTEND", datetime(2026, 1, 15, 9, 0)) event.add("STATUS", "CONFIRMED") event.add("CATEGORIES", ["Pronote", "Devoir"]) sig = component_to_signature(event) # Les catégories doivent apparaître dans la signature assert "categories=devoir,pronote" in sig def test_component_to_signature_includes_managed_property() -> None: """Vérifie que MANAGED_PROPERTY est incluse dans la signature. :return: None """ event = Event() event.add("UID", "test-uid") event.add("SUMMARY", "Test Event") event.add("DTSTART", datetime(2026, 1, 15, 8, 0)) event.add("DTEND", datetime(2026, 1, 15, 9, 0)) event.add("STATUS", "CONFIRMED") event.add(MANAGED_PROPERTY, MANAGED_VALUE) sig = component_to_signature(event) assert f"managed={MANAGED_VALUE.lower()}" in sig # Ensure trailing newline