"""Tests unitaires pour le module iCal : téléchargement et parsing. Ce module teste : - La récupération du flux iCal (file://, HTTP) - Le parsing des événements en modèles Lesson, SchoolEvent - L'extraction et normalisation des devoirs - La collecte et déduplication des devoirs par date cible Les tests utilisent des mocks pour éviter tout appel réseau réel. """ from __future__ import annotations import tempfile import urllib.parse from datetime import date, datetime from pathlib import Path import pytest import responses from pronote_sync.models.agenda import HomeworkBlock, Lesson, LessonStatus from pronote_sync.sources.pronote.ical import ( collect_homeworks, fetch_ical, generate_homework_id, get_calendar_name, normalize_homework_text, parse_body, parse_ical, ) @pytest.fixture def valid_ical_content() -> str: """Contenu iCal valide pour tests de parsing.""" return """BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Test//Test//FR X-WR-CALNAME:Test Calendar BEGIN:VEVENT UID:test-1@test.net DTSTAMP:20260905T120000Z DTSTART:20260905T080000Z DTEND:20260905T090000Z SUMMARY:Math CATEGORIES:Cours DESCRIPTION:
Exercice 1 à 5 page 42
", ), ), ) lesson2 = Lesson( id="lesson2", start=datetime(2026, 9, 10, 10, 0), end=datetime(2026, 9, 10, 11, 0), subject="Math", teachers=("M. Dupont",), rooms=("205",), group=None, status=LessonStatus.NORMAL, content=None, homework_blocks=( HomeworkBlock( kind="due", date=date(2026, 9, 10), text="Exercice 1 à 5 page 42", html="Exercice 1 à 5 page 42
", ), ), ) homeworks = collect_homeworks([lesson1, lesson2], target_date=date(2026, 9, 10)) assert len(homeworks) == 1 # Un seul devoir dédupliqué def test_collect_homeworks_id_stability() -> None: """Deux devoirs avec le même texte et date d'échéance produisent le même ID. :return: None """ lesson1 = Lesson( id="lesson1", start=datetime(2026, 9, 10, 8, 0), end=datetime(2026, 9, 10, 9, 0), subject="Math", teachers=("M. Dupont",), rooms=("204",), group=None, status=LessonStatus.NORMAL, content=None, homework_blocks=( HomeworkBlock( kind="due", date=date(2026, 9, 10), text="Devoir commun", html="Devoir commun
", ), ), ) lesson2 = Lesson( id="lesson2", start=datetime(2026, 9, 10, 10, 0), end=datetime(2026, 9, 10, 11, 0), subject="Math", teachers=("M. Dupont",), rooms=("205",), group=None, status=LessonStatus.NORMAL, content=None, homework_blocks=( HomeworkBlock( kind="due", date=date(2026, 9, 10), text="Devoir commun", html="Devoir commun
", ), ), ) homeworks = collect_homeworks([lesson1, lesson2], target_date=date(2026, 9, 10)) assert len(homeworks) == 1 assert homeworks[0].id == generate_homework_id( date(2026, 9, 10), "devoir commun", "Math", ("M. Dupont",) ) def test_collect_homeworks_keeps_distinct_subjects() -> None: """Deux matières différentes conservent deux devoirs homonymes. :return: None """ lesson = Lesson( id="lesson1", start=datetime(2026, 9, 10, 8, 0), end=datetime(2026, 9, 10, 9, 0), subject="Math", teachers=("M. Dupont",), group=None, content=None, homework_blocks=( HomeworkBlock( kind="due", date=date(2026, 9, 10), text="Devoir commun", html="Devoir commun
", ), ), ) other_lesson = lesson.model_copy( update={"id": "lesson2", "subject": "Physique", "teachers": ("M. Martin",)} ) homeworks = collect_homeworks([lesson, other_lesson], target_date=date(2026, 9, 10)) assert len(homeworks) == 2 assert {homework.subject for homework in homeworks} == {"Math", "Physique"} def test_collect_homeworks_deduplicates_teacher_order_variants() -> None: """Deux ordres d'enseignants équivalents ne créent pas de doublon. :return: None """ homework_block = HomeworkBlock( kind="due", date=date(2026, 9, 10), text="Devoir commun", html="Devoir commun
", ) lesson = Lesson( id="lesson1", start=datetime(2026, 9, 10, 8, 0), end=datetime(2026, 9, 10, 9, 0), subject="Math", teachers=("M. Martin", "M. Dupont"), group=None, content=None, homework_blocks=(homework_block,), ) other_lesson = lesson.model_copy( update={"id": "lesson2", "teachers": ("M. Dupont", "M. Martin")} ) homeworks = collect_homeworks([lesson, other_lesson], target_date=date(2026, 9, 10)) assert len(homeworks) == 1 def test_parse_body_accepts_html_variants_and_sanitizes() -> None: """Les attributs HTML, les espaces et le contenu actif sont traités correctement. :return: None """ body = ( ' Contenu pédagogique :Leçon & exemple
' ' Pour le 10/09/2026 : ' 'Lire le chapitre
' " Donné le 05/09/2026 :Noter & relire
" ) content, due_blocks, assigned_blocks = parse_body(body) assert content == "Leçon & exemple" assert due_blocks[0].date == date(2026, 9, 10) assert due_blocks[0].text == "Lire le chapitre" assert "le chapitre" in due_blocks[0].html assert "onclick" not in due_blocks[0].html assert "script" not in due_blocks[0].html.lower() assert assigned_blocks[0].date == date(2026, 9, 5) def test_parse_anonymized_variants_fixture() -> None: """Vérifie le corpus iCal anonymisé sur les statuts et le contexte des devoirs. :return: None """ fixture_path = Path(__file__).parent.parent / "fixtures" / "pronote-variants.ics" lessons, _, school_events = parse_ical(fixture_path.read_text(encoding="utf-8")) assert lessons[0].class_part == "Groupe 1" assert lessons[0].homework_blocks[0].html == "Lire le chapitre
" assert any(lesson.status == LessonStatus.MOVED for lesson in lessons) assert any(event.kind.value == "public_holiday" for event in school_events) assert any( event.from_date == date(2026, 10, 20) and event.to_date == date(2026, 10, 22) for event in school_events ) homeworks = collect_homeworks(lessons, target_date=date(2026, 9, 15)) assert len(homeworks) == 2 assert {homework.subject for homework in homeworks} == {"Mathématiques", "Sciences"} def test_collect_homeworks_sorted() -> None: """Les résultats sont triés par (subject.lower(), text.lower()). :return: None """ lesson1 = Lesson( id="lesson1", start=datetime(2026, 9, 10, 8, 0), end=datetime(2026, 9, 10, 9, 0), subject="Zoologie", teachers=("M. A",), rooms=("204",), group=None, status=LessonStatus.NORMAL, content=None, homework_blocks=( HomeworkBlock( kind="due", date=date(2026, 9, 10), text="Devoir B", html="Devoir B
" ), ), ) lesson2 = Lesson( id="lesson2", start=datetime(2026, 9, 10, 10, 0), end=datetime(2026, 9, 10, 11, 0), subject="Mathématiques", teachers=("M. B",), rooms=("205",), group=None, status=LessonStatus.NORMAL, content=None, homework_blocks=( HomeworkBlock( kind="due", date=date(2026, 9, 10), text="Devoir A", html="Devoir A
" ), ), ) homeworks = collect_homeworks([lesson1, lesson2], target_date=date(2026, 9, 10)) assert len(homeworks) == 2 assert homeworks[0].subject == "Mathématiques" assert homeworks[0].text == "Devoir A" assert homeworks[1].subject == "Zoologie" assert homeworks[1].text == "Devoir B" def test_collect_homeworks_empty() -> None: """Aucun bloc de devoir → liste vide. :return: None """ lesson = Lesson( id="lesson1", start=datetime(2026, 9, 10, 8, 0), end=datetime(2026, 9, 10, 9, 0), subject="Math", teachers=("M. Dupont",), rooms=("204",), group=None, status=LessonStatus.NORMAL, content=None, ) homeworks = collect_homeworks([lesson], target_date=date(2026, 9, 10)) assert homeworks == [] def test_normalize_homework_text() -> None: """Vérifie la normalisation des espaces, suppression HTML et minuscules. :return: None """ text = "Exercice 1 à 5
\n\n page 42 " normalized = normalize_homework_text(text) assert normalized == "exercice 1 à 5 page 42" def test_generate_homework_id_format() -> None: """Retourne un ID de 12 caractères hexadécimaux. :return: None """ due_on = date(2026, 9, 10) text = "devoir test" homework_id = generate_homework_id(due_on, text) assert len(homework_id) == 12 assert all(c in "0123456789abcdef" for c in homework_id) def test_generate_homework_id_deterministic() -> None: """Mêmes entrées → même sortie. :return: None """ due_on = date(2026, 9, 10) text = "devoir commun" id1 = generate_homework_id(due_on, text) id2 = generate_homework_id(due_on, text) assert id1 == id2 CANCELLED_STATUS_ICAL = """BEGIN:VCALENDAR VERSION:2.0 X-WR-CALNAME:Test BEGIN:VEVENT UID:Test-123-20260906T120000Z-Index-Education DTSTART:20260907T080000Z DTEND:20260907T090000Z SUMMARY:Test Course STATUS:CANCELLED DESCRIPTION: END:VEVENT END:VCALENDAR""" NORMAL_STATUS_ICAL = """BEGIN:VCALENDAR VERSION:2.0 X-WR-CALNAME:Test BEGIN:VEVENT UID:Test-123-20260906T120000Z-Index-Education DTSTART:20260907T080000Z DTEND:20260907T090000Z SUMMARY:Test Course STATUS:CONFIRMED DESCRIPTION: END:VEVENT END:VCALENDAR""" MOVED_BY_CATEGORY_ICAL = """BEGIN:VCALENDAR VERSION:2.0 X-WR-CALNAME:Test BEGIN:VEVENT UID:Test-123-20260906T120000Z-Index-Education DTSTART:20260907T080000Z DTEND:20260907T090000Z SUMMARY:Test Course CATEGORIES:Cours - Cours déplacé DESCRIPTION: END:VEVENT END:VCALENDAR""" MULTIPLE_BLOCKS_SAME_DATE_ICAL = """BEGIN:VCALENDAR VERSION:2.0 X-WR-CALNAME:Test BEGIN:VEVENT UID:Test-123-20260906T120000Z-Index-Education DTSTART:20260907T080000Z DTEND:20260907T090000Z SUMMARY:Test Course CATEGORIES:Cours DESCRIPTION: