chore(integration): intégrer les PR Pronote critiques dans main
Revue Codex validée. Intégration dans main de la chaîne des PR #31, #32 et #33. Co-authored-by: Codex <codex@antoineve.me> Co-committed-by: Codex <codex@antoineve.me>
This commit was merged in pull request #35.
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
"""Tests de sélection de la date cible du digest Pronote."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from pronote_sync.models.agenda import Lesson, LessonStatus, SchoolEvent, SchoolEventKind
|
||||
from pronote_sync.pipeline.steps.fetch import resolve_target_date
|
||||
|
||||
|
||||
def _lesson(day: date, status: LessonStatus = LessonStatus.NORMAL) -> Lesson:
|
||||
"""Construit un cours minimal à une date donnée.
|
||||
|
||||
:param day: Date du cours.
|
||||
:param status: Statut du cours.
|
||||
:return: Cours de test.
|
||||
:rtype: Lesson
|
||||
"""
|
||||
return Lesson(
|
||||
id=day.isoformat(),
|
||||
start=datetime.combine(day, datetime.min.time()),
|
||||
end=datetime.combine(day, datetime.min.time().replace(hour=1)),
|
||||
subject="Mathématiques",
|
||||
group=None,
|
||||
status=status,
|
||||
content=None,
|
||||
)
|
||||
|
||||
|
||||
def _event(from_date: date, to_date: date) -> SchoolEvent:
|
||||
"""Construit un événement scolaire à borne de fin exclusive.
|
||||
|
||||
:param from_date: Début inclusif.
|
||||
:param to_date: Fin exclusive.
|
||||
:return: Événement de vacances de test.
|
||||
:rtype: SchoolEvent
|
||||
"""
|
||||
return SchoolEvent(
|
||||
kind=SchoolEventKind.HOLIDAY,
|
||||
label="Vacances anonymisées",
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
)
|
||||
|
||||
|
||||
def test_target_date_prefers_tomorrow_effective_lesson() -> None:
|
||||
"""Un cours effectif demain est prioritaire.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
today = date(2026, 9, 11)
|
||||
|
||||
assert resolve_target_date(today, [_lesson(date(2026, 9, 12))], []) == date(2026, 9, 12)
|
||||
|
||||
|
||||
def test_target_date_skips_empty_days_and_cancelled_lessons() -> None:
|
||||
"""Le prochain cours effectif remplace un lendemain vide ou annulé.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
today = date(2026, 9, 11)
|
||||
lessons = [
|
||||
_lesson(date(2026, 9, 12), LessonStatus.CANCELLED),
|
||||
_lesson(date(2026, 9, 14)),
|
||||
]
|
||||
|
||||
assert resolve_target_date(today, lessons, []) == date(2026, 9, 14)
|
||||
|
||||
|
||||
def test_target_date_uses_exclusive_holiday_end_when_no_reprise_is_visible() -> None:
|
||||
"""La fin exclusive d'une période couvrant demain devient la reprise connue.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
today = date(2026, 10, 19)
|
||||
holiday = _event(date(2026, 10, 20), date(2026, 11, 2))
|
||||
|
||||
assert resolve_target_date(today, [], [holiday]) == date(2026, 11, 2)
|
||||
|
||||
|
||||
def test_target_date_falls_back_to_tomorrow_without_data() -> None:
|
||||
"""Une liste vide valide sans événement conserve J+1 explicitement.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
assert resolve_target_date(date(2026, 9, 11), [], []) == date(2026, 9, 12)
|
||||
@@ -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()).
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
Reference in New Issue
Block a user