``).
:return: Tuple ``(contenu pédagogique, devoirs dus, devoirs donnés)``.
- :rtype: tuple[str | None, dict[date, str], dict[date, str]]
+ :rtype: tuple[str | None, list[tuple[date, str]], list[tuple[date, str]]]
"""
content: str | None = None
- due_blocks: dict[date, str] = {}
- assigned_blocks: dict[date, str] = {}
+ due_blocks: list[tuple[date, str]] = []
+ assigned_blocks: list[tuple[date, str]] = []
content_match = _CONTENT_PATTERN.search(body)
if content_match is not None:
@@ -241,34 +243,35 @@ def parse_body(body: str) -> tuple[str | None, dict[date, str], dict[date, str]]
for match in _DUE_PATTERN.finditer(body):
due_date = _parse_french_date(match.group(1))
if due_date is not None:
- due_blocks[due_date] = _strip_html(match.group(2))
+ due_blocks.append((due_date, _strip_html(match.group(2))))
for match in _ASSIGNED_PATTERN.finditer(body):
assigned_date = _parse_french_date(match.group(1))
if assigned_date is not None:
- assigned_blocks[assigned_date] = _strip_html(match.group(2))
+ assigned_blocks.append((assigned_date, _strip_html(match.group(2))))
return content, due_blocks, assigned_blocks
def parse_homework_blocks(
- due_blocks: dict[date, str],
- assigned_blocks: dict[date, str],
+ due_blocks: list[tuple[date, str]],
+ assigned_blocks: list[tuple[date, str]],
) -> tuple[HomeworkBlock, ...]:
- """Construit les :class:`HomeworkBlock` depuis les dicts de devoirs.
+ """Construit les :class:`HomeworkBlock` depuis les listes de devoirs.
Les blocs dus (``kind="due"``) précèdent les blocs donnés
- (``kind="assigned"``), dans l'ordre d'insertion des dicts.
+ (``kind="assigned"``), dans l'ordre des listes. Tous les blocs
+ sont préservés, y compris lorsque plusieurs partagent la même date.
- :param due_blocks: Dict date → texte des devoirs à faire.
- :param assigned_blocks: Dict date → texte des devoirs donnés.
+ :param due_blocks: Liste de tuples ``(date, texte)`` des devoirs à faire.
+ :param assigned_blocks: Liste de tuples ``(date, texte)`` des devoirs donnés.
:return: Tuple de blocs de devoirs pour le cours.
:rtype: tuple[HomeworkBlock, ...]
"""
blocks: list[HomeworkBlock] = []
- for due_date, text in due_blocks.items():
+ for due_date, text in due_blocks:
blocks.append(HomeworkBlock(kind="due", date=due_date, text=text, html=text))
- for assigned_date, text in assigned_blocks.items():
+ for assigned_date, text in assigned_blocks:
blocks.append(HomeworkBlock(kind="assigned", date=assigned_date, text=text, html=text))
return tuple(blocks)
@@ -360,9 +363,10 @@ def parse_ical(raw_ical: str) -> tuple[list[Lesson], list[Homework], list[School
Les VEVENT de vacances/congés (tout le jour) deviennent des
:class:`SchoolEvent` de type ``holiday``. Les VEVENT horodatés
- deviennent des :class:`Lesson` dont le statut dérive de la
- catégorie (``Cours - Cours annulé`` → ``CANCELLED``,
- ``Cours - Cours déplacé`` → ``MOVED``). Les UID sont normalisés ;
+ deviennent des :class:`Lesson` dont le statut dérive de la propriété
+ ``STATUS`` (``CANCELLED`` → ``CANCELLED``) et de la catégorie
+ (``Cours - Cours annulé`` → ``CANCELLED``, ``Cours - Cours déplacé`` →
+ ``MOVED``). Les UID sont normalisés ;
un événement sans UID reçoit un UID déterministe généré à partir
de ses champs clés (début, fin, matière, enseignants, salles, groupe).
@@ -416,11 +420,14 @@ def parse_ical(raw_ical: str) -> tuple[list[Lesson], list[Homework], list[School
if not isinstance(start, datetime) or not isinstance(end, datetime):
continue
- status = LessonStatus.NORMAL
- if "Cours - Cours annulé" in categories:
+ status_obj = component.get("status")
+ status_value = str(status_obj).strip().upper() if status_obj is not None else ""
+ if status_value == "CANCELLED" or "Cours - Cours annulé" in categories:
status = LessonStatus.CANCELLED
elif "Cours - Cours déplacé" in categories:
status = LessonStatus.MOVED
+ else:
+ status = LessonStatus.NORMAL
description = component.get("description")
description_str = str(description) if description is not None else ""
diff --git a/tests/unit/test_ical.py b/tests/unit/test_ical.py
index 0f94ab3..0613ed5 100644
--- a/tests/unit/test_ical.py
+++ b/tests/unit/test_ical.py
@@ -26,6 +26,7 @@ from pronote_sync.sources.pronote.ical import (
generate_homework_id,
get_calendar_name,
normalize_homework_text,
+ parse_body,
parse_ical,
)
@@ -463,3 +464,137 @@ def test_generate_homework_id_deterministic() -> None:
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:
+ Matière : Math
+ Professeur : M. Dupont
+ Salle : 204
+
+ Pour le 10/09/2026 :
+ Exercice 1 à 5 page 42.
+ Pour le 10/09/2026 :
+ Exercice 6 à 10 page 43.
+
+END:VEVENT
+END:VCALENDAR"""
+
+
+def test_parse_ical_status_cancelled_only() -> None:
+ """Un cours avec STATUS:CANCELLED mais sans CATEGORIES contenant 'Cours annulé' a status == LessonStatus.CANCELLED.
+
+ :return: None
+ """
+ lessons, _, _ = parse_ical(CANCELLED_STATUS_ICAL)
+ assert len(lessons) == 1
+ assert lessons[0].status == LessonStatus.CANCELLED
+
+
+def test_parse_ical_status_normal_without_cancel() -> None:
+ """Un cours avec STATUS:CONFIRMED (ou sans STATUS) a status == LessonStatus.NORMAL.
+
+ :return: None
+ """
+ lessons, _, _ = parse_ical(NORMAL_STATUS_ICAL)
+ assert len(lessons) == 1
+ assert lessons[0].status == LessonStatus.NORMAL
+
+
+def test_parse_ical_moved_by_category_only() -> None:
+ """Un cours avec CATEGORIES:Cours - Cours déplacé et sans STATUS a status == LessonStatus.MOVED.
+
+ :return: None
+ """
+ lessons, _, _ = parse_ical(MOVED_BY_CATEGORY_ICAL)
+ assert len(lessons) == 1
+ assert lessons[0].status == LessonStatus.MOVED
+
+
+def test_parse_body_multiple_blocks_same_date() -> None:
+ """Un DESCRIPTION avec deux sections 'Pour le' à la même date conserve les deux blocs.
+
+ :return: None
+ """
+ body_html = (
+ "\n"
+ " Pour le 10/09/2026:\n"
+ " Exercice 1 à 5 page 42.\n"
+ " Pour le 10/09/2026:\n"
+ " Exercice 6 à 10 page 43.\n"
+ "
"
+ )
+ content, due_blocks, assigned_blocks = parse_body(body_html)
+ assert len(due_blocks) == 2
+ assert due_blocks[0][0] == date(2026, 9, 10)
+ assert due_blocks[0][1] == "Exercice 1 à 5 page 42."
+ assert due_blocks[1][0] == date(2026, 9, 10)
+ assert due_blocks[1][1] == "Exercice 6 à 10 page 43."
+
+
+def test_collect_homeworks_from_fixture() -> None:
+ """Parse le fixture pronote-4e.ics, appelle collect_homeworks pour le 10/09/2026 et vérifie qu'au moins un devoir est retourné.
+
+ :return: None
+ """
+ fixture_path = Path(__file__).parent.parent / "fixtures" / "pronote-4e.ics"
+ with open(fixture_path, encoding="utf-8") as f:
+ content = f.read()
+
+ lessons, _, _ = parse_ical(content)
+ homeworks = collect_homeworks(lessons, date(2026, 9, 10))
+
+ assert len(homeworks) >= 1
+ # Vérifie qu'au moins un devoir a le bon sujet et texte
+ assert any(hw.subject == "Mathématiques" for hw in homeworks)
+ assert any("Exercices 1 à 5 page 42" in hw.text for hw in homeworks)