fix(pronote): préserver les cours déplacés et changements de salle

Revue Codex validée. Statuts canoniques iCal/pronotepy et consolidation prudente des cours remplacés.

Co-authored-by: Codex <codex@antoineve.me>
Co-committed-by: Codex <codex@antoineve.me>
This commit was merged in pull request #32.
This commit is contained in:
2026-09-12 14:55:19 +02:00
committed by Codex
parent a9e4f610c0
commit 5bb5d2c2f1
6 changed files with 224 additions and 7 deletions
+33
View File
@@ -474,6 +474,39 @@ def test_parse_anonymized_variants_fixture() -> None:
assert {homework.subject for homework in homeworks} == {"Mathématiques", "Sciences"}
def test_parse_ical_removes_old_replaced_lesson() -> None:
"""Un ancien cours annulé est retiré lorsqu'un cours déplacé le remplace.
:return: None
"""
raw_ical = """BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
UID:old
DTSTART:20260911T080000Z
DTEND:20260911T090000Z
SUMMARY:Mathématiques
CATEGORIES:Cours - Cours annulé
STATUS:CANCELLED
DESCRIPTION:<div>Matière : Mathématiques Professeur : Enseignant A Salle : 101</div>
END:VEVENT
BEGIN:VEVENT
UID:new
DTSTART:20260911T080000Z
DTEND:20260911T090000Z
SUMMARY:Mathématiques
CATEGORIES:Cours - Changement de salle
DESCRIPTION:<div>Matière : Mathématiques Professeur : Enseignant A Salle : 204</div>
END:VEVENT
END:VCALENDAR"""
lessons, _, _ = parse_ical(raw_ical)
assert len(lessons) == 1
assert lessons[0].status == LessonStatus.MOVED
assert lessons[0].rooms == ("204",)
def test_collect_homeworks_sorted() -> None:
"""Les résultats sont triés par (subject.lower(), text.lower()).
+18
View File
@@ -233,6 +233,24 @@ def test_get_lessons_success(
assert lesson.content == "Lesson content"
def test_get_lessons_maps_moved_status(
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
) -> None:
"""Mappe le statut ``Changement de salle`` de pronotepy vers ``MOVED``.
:param mocker: Fixture pytest-mock pour le mocking.
:param pronote_settings: Paramètres Pronote valides.
:return: None
"""
mock_client = _make_lessons_mock_client(mocker)
mock_client.lessons.return_value[0].status = "Changement de salle"
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
lessons = PronoteClient(pronote_settings).get_lessons(date(2024, 9, 1), date(2024, 9, 30))
assert lessons[0].status == LessonStatus.MOVED
def test_get_homeworks_success(
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
) -> None:
+91
View File
@@ -0,0 +1,91 @@
"""Tests des règles communes de statut et de consolidation des cours."""
from __future__ import annotations
from datetime import datetime
import pytest
from pronote_sync.models.agenda import Lesson, LessonStatus
from pronote_sync.sources.pronote.lessons import collapse_replaced_lessons, lesson_status
def _lesson(status: LessonStatus, room: str) -> Lesson:
"""Construit un cours minimal pour les tests de consolidation.
:param status: Statut métier du cours.
:param room: Salle du cours.
:return: Cours de test.
:rtype: Lesson
"""
return Lesson(
id=f"lesson-{room}",
start=datetime(2026, 9, 11, 8, 0),
end=datetime(2026, 9, 11, 9, 0),
subject="Mathématiques",
teachers=("Enseignant A",),
rooms=(room,),
group="Classe entière",
status=status,
content=None,
)
@pytest.mark.parametrize(
("raw_status", "canceled", "expected"),
[
("Changement de salle", False, LessonStatus.MOVED),
("Cours déplacé", False, LessonStatus.MOVED),
("Cours annulé", False, LessonStatus.CANCELLED),
(None, True, LessonStatus.CANCELLED),
("Statut inconnu", False, LessonStatus.NORMAL),
],
)
def test_lesson_status_maps_pronotepy_variants(
raw_status: object, canceled: bool, expected: LessonStatus
) -> None:
"""Convertit les statuts Pronote connus sans interpréter les inconnus.
:param raw_status: Statut brut simulé.
:param canceled: Indicateur d'annulation simulé.
:param expected: Statut métier attendu.
:return: None
"""
assert lesson_status(raw_status, canceled) == expected
def test_collapse_replaced_lessons_keeps_effective_lesson() -> None:
"""Retire l'ancien exemplaire annulé d'un changement de salle.
:return: None
"""
cancelled = _lesson(LessonStatus.CANCELLED, "101")
moved = _lesson(LessonStatus.MOVED, "204")
result = collapse_replaced_lessons([cancelled, moved])
assert result == [moved]
def test_collapse_replaced_lessons_keeps_real_cancellation() -> None:
"""Conserve une annulation lorsqu'aucun cours déplacé ne la remplace.
:return: None
"""
cancelled = _lesson(LessonStatus.CANCELLED, "101")
assert collapse_replaced_lessons([cancelled]) == [cancelled]
def test_collapse_replaced_lessons_keeps_ambiguous_cancellations() -> None:
"""Conserve les annulations lorsqu'une paire de remplacement est ambiguë.
:return: None
"""
cancelled_old = _lesson(LessonStatus.CANCELLED, "101")
cancelled_real = _lesson(LessonStatus.CANCELLED, "305")
moved = _lesson(LessonStatus.MOVED, "204")
result = collapse_replaced_lessons([cancelled_old, cancelled_real, moved])
assert result == [cancelled_old, cancelled_real, moved]