From f9a1a5aa43d1faddc898cb2d0ee3886cebbe19cf Mon Sep 17 00:00:00 2001 From: Antoine Van Elstraete Date: Sun, 6 Sep 2026 23:06:29 +0200 Subject: [PATCH] =?UTF-8?q?feat(M6):=20agenda=20th=C3=A9orique=20JSON=20av?= =?UTF-8?q?ec=20parit=C3=A9=20des=20semaines=20et=20vacances=20scolaires?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implémentation complète de la source d'agenda théorique : - model.py : modèles Pydantic de parsing JSON (TheoreticalLessonEntry, TheoreticalAgendaFile) avec validation des formats d'heure et de l'ordre début/fin. - parity.py : WeekParityService déterministe calculant la parité d'une semaine (paire/impaire) à partir d'une date de référence. - holidays.py : SchoolHolidayCalendar lisant un fichier JSON de vacances scolaires (zone A) avec bornes inclusives. - provider.py : protocole TheoreticalAgendaProvider (get_lessons, get_lessons_for_range). - file.py : JsonTheoreticalAgendaProvider implémentant le protocole : filtrage par parité et vacances, génération d'IDs déterministes incluant le type de semaine, validation de l'unicité des IDs, tri stable par identifiant. - __init__.py : factory get_theoretical_provider câblant la configuration (None si désactivé, erreur si config de parité partielle). - Fixtures : theoretical.json (9 leçons all/even/odd) et school_holidays.json (zone A, 4 périodes). - 57 tests unitaires couvrant parsing, parité, vacances, provider, factory, déduplication de range, collisions d'IDs. - Guide : §8 et §12 alignés avec le format JSON. Co-authored-by: opencode/coder Co-authored-by: opencode/test-engineer --- .secrets.baseline | 4 +- GUIDE_DEV_PYTHON.md | 66 ++- pronote_sync/sources/theoretical/__init__.py | 75 +++ pronote_sync/sources/theoretical/file.py | 183 ++++++ pronote_sync/sources/theoretical/holidays.py | 106 ++++ pronote_sync/sources/theoretical/model.py | 123 ++++ pronote_sync/sources/theoretical/parity.py | 55 ++ pronote_sync/sources/theoretical/provider.py | 44 ++ tests/fixtures/school_holidays.json | 26 + tests/fixtures/theoretical.json | 87 +++ tests/unit/test_theoretical_factory.py | 146 +++++ tests/unit/test_theoretical_holidays.py | 188 ++++++ tests/unit/test_theoretical_model.py | 201 +++++++ tests/unit/test_theoretical_parity.py | 116 ++++ tests/unit/test_theoretical_provider.py | 591 +++++++++++++++++++ 15 files changed, 1992 insertions(+), 19 deletions(-) create mode 100644 pronote_sync/sources/theoretical/file.py create mode 100644 pronote_sync/sources/theoretical/holidays.py create mode 100644 pronote_sync/sources/theoretical/model.py create mode 100644 pronote_sync/sources/theoretical/parity.py create mode 100644 pronote_sync/sources/theoretical/provider.py create mode 100644 tests/fixtures/school_holidays.json create mode 100644 tests/fixtures/theoretical.json create mode 100644 tests/unit/test_theoretical_factory.py create mode 100644 tests/unit/test_theoretical_holidays.py create mode 100644 tests/unit/test_theoretical_model.py create mode 100644 tests/unit/test_theoretical_parity.py create mode 100644 tests/unit/test_theoretical_provider.py diff --git a/.secrets.baseline b/.secrets.baseline index d5c50ec..7b79e7e 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -140,10 +140,10 @@ "filename": "GUIDE_DEV_PYTHON.md", "hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa", "is_verified": true, - "line_number": 5014, + "line_number": 5046, "is_secret": false } ] }, - "generated_at": "2026-09-06T20:30:44Z" + "generated_at": "2026-09-06T21:06:14Z" } diff --git a/GUIDE_DEV_PYTHON.md b/GUIDE_DEV_PYTHON.md index 71c49e3..499dcb9 100644 --- a/GUIDE_DEV_PYTHON.md +++ b/GUIDE_DEV_PYTHON.md @@ -4873,12 +4873,12 @@ Les autres étapes (`normalize_step`, `compare_step`, etc.) suivent le même pri tests/ ├── __init__.py ├── conftest.py # Fixtures pytest partagées -├── fixtures/ # Fichiers de fixtures (iCal, CSV, XML, etc.) -│ ├── pronote-4e.ics # Flux iCal Pronote anonymisé (4ème) -│ ├── pronote-6e.ics # Flux iCal Pronote anonymisé (6ème) -│ ├── theoretical.ics # Agenda théorique iCal -│ ├── theoretical.csv # Agenda théorique CSV -│ └── blog_rss.xml # Flux RSS du blog anonymisé +├── fixtures/ # Fichiers de fixtures (iCal, JSON, XML, etc.) +│ ├── pronote-4e.ics # Flux iCal Pronote anonymisé (4ème) +│ ├── pronote-6e.ics # Flux iCal Pronote anonymisé (6ème) +│ ├── theoretical.json # Agenda théorique JSON +│ ├── school_holidays.json # Vacances scolaires JSON +│ └── blog_rss.xml # Flux RSS du blog anonymisé ├── unit/ # Tests unitaires │ ├── test_models.py # Tests des modèles Pydantic │ ├── test_parsing.py # Tests du parsing iCal @@ -4951,17 +4951,49 @@ END:VCALENDAR - **Données réalistes** : Structure identique aux flux réels Pronote. -#### 12.3.2 Exemple de fichier CSV théorique (`tests/fixtures/theoretical.csv`) +#### 12.3.2 Exemple de fichier JSON théorique (`tests/fixtures/theoretical.json`) -```csv -jour,semaine,heure_debut,heure_fin,matiere,professeur,salle -lundi,1,08:00,09:00,Mathématiques,M. Dupont,204 -lundi,1,09:00,10:00,Français,Mme Martin,205 -lundi,1,10:00,11:00,Histoire-Géographie,M. Bernard,206 -mardi,1,08:00,09:00,Physique-Chimie,Mme Durand,301 -... +```json +{ + "version": 1, + "lessons": [ + { + "id": "theoretical-maths-monday-1", + "week": "all", + "day_of_week": 0, + "start_time": "08:00", + "end_time": "09:00", + "subject": "Mathématiques", + "teachers": ["Mme Martin"], + "rooms": ["101"] + }, + { + "week": "all", + "day_of_week": 0, + "start_time": "09:00", + "end_time": "10:00", + "subject": "Français", + "teachers": ["M. Dupont"], + "rooms": ["102"] + }, + { + "week": "even", + "day_of_week": 1, + "start_time": "10:00", + "end_time": "11:00", + "subject": "Anglais", + "teachers": ["Mme Bernard"], + "rooms": ["201"] + } + ] +} ``` +**Champs clés** : +- `week` : `all` (toutes les semaines), `even` (semaines paires) ou `odd` (semaines impaires). +- `day_of_week` : jour de la semaine (0 = lundi, 4 = vendredi). +- `start_time` / `end_time` : créneau horaire au format `HH:MM`. + ### 12.4 Configuration pytest (`tests/conftest.py`) @@ -5203,7 +5235,7 @@ def sample_settings(): app=AppSettings( dry_run=True, log_level="DEBUG", - theoretical_agenda_path="./tests/fixtures/theoretical.ics", + theoretical_agenda_path="./tests/fixtures/theoretical.json", ), ) @@ -5254,7 +5286,7 @@ def test_pipeline_full(mock_requests_get, mock_caldav_client, mock_ai_provider, from pronote_sync.sources.pronote.fallback import PronoteFetcher from pronote_sync.sync.caldav import CalDAVClient from pronote_sync.sync.diff import AgendaComparator - from pronote_sync.sources.theoretical.file import CSVTheoreticalAgendaProvider + from pronote_sync.sources.theoretical.file import JsonTheoreticalAgendaProvider # Configurer le fetcher Pronote pronote_client = PronoteClient(sample_settings.pronote) @@ -5269,7 +5301,7 @@ def test_pipeline_full(mock_requests_get, mock_caldav_client, mock_ai_provider, ) # Configurer le comparateur d'agenda - theoretical_provider = CSVTheoreticalAgendaProvider( + theoretical_provider = JsonTheoreticalAgendaProvider( file_path=sample_settings.app.theoretical_agenda_path ) comparator = AgendaComparator(theoretical_provider) diff --git a/pronote_sync/sources/theoretical/__init__.py b/pronote_sync/sources/theoretical/__init__.py index e69de29..369ca9a 100644 --- a/pronote_sync/sources/theoretical/__init__.py +++ b/pronote_sync/sources/theoretical/__init__.py @@ -0,0 +1,75 @@ +"""Usine de construction du fournisseur d'agenda théorique. + +Ce module expose l'API publique du package ``theoretical`` : les classes +:class:`~pronote_sync.sources.theoretical.provider.TheoreticalAgendaProvider`, +:class:`~pronote_sync.sources.theoretical.file.JsonTheoreticalAgendaProvider`, +:class:`~pronote_sync.sources.theoretical.parity.WeekParityService` et +:class:`~pronote_sync.sources.theoretical.holidays.SchoolHolidayCalendar`, ainsi +que la fonction :func:`get_theoretical_provider` qui assemble la configuration +(chemin du fichier JSON, parité des semaines et vacances scolaires) pour +produire un fournisseur d'agenda théorique prêt à l'emploi. +""" + +from __future__ import annotations + +from datetime import date +from typing import Literal + +from pronote_sync.errors import PronoteSyncError +from pronote_sync.sources.theoretical.file import JsonTheoreticalAgendaProvider +from pronote_sync.sources.theoretical.holidays import SchoolHolidayCalendar +from pronote_sync.sources.theoretical.parity import WeekParityService +from pronote_sync.sources.theoretical.provider import TheoreticalAgendaProvider + +__all__ = [ + "TheoreticalAgendaProvider", + "JsonTheoreticalAgendaProvider", + "WeekParityService", + "SchoolHolidayCalendar", + "get_theoretical_provider", +] + + +def get_theoretical_provider( + agenda_path: str | None, + holidays_path: str | None, + anchor_date: date | None, + anchor_type: Literal["even", "odd"] | None, +) -> TheoreticalAgendaProvider | None: + """Construit un fournisseur d'agenda théorique depuis la configuration. + + :param agenda_path: Chemin du fichier JSON d'agenda théorique. Si None, retourne None. + :param holidays_path: Chemin du fichier JSON de vacances scolaires (optionnel). + :param anchor_date: Date de référence pour la parité des semaines. + :param anchor_type: Type de la semaine de référence ("even" ou "odd"). + :return: Le fournisseur configuré, ou None si l'agenda théorique est désactivé. + :rtype: TheoreticalAgendaProvider | None + :raises PronoteSyncError: Si la configuration de parité est incomplète + (date sans type ou inversement) alors que l'agenda nécessite la parité. + """ + if agenda_path is None: + return None + + # Build parity service if both anchor fields are provided + parity_service: WeekParityService | None = None + if anchor_date is not None and anchor_type is not None: + parity_service = WeekParityService(anchor_date, anchor_type) + elif anchor_date is not None or anchor_type is not None: + # Partial parity config — one field without the other + raise PronoteSyncError( + "Configuration de parité incomplète : THEORETICAL_WEEK_ANCHOR_DATE et " + "THEORETICAL_WEEK_ANCHOR_TYPE doivent être fournis ensemble." + ) + + # Build holiday calendar if path is provided + holiday_calendar: SchoolHolidayCalendar | None = None + if holidays_path is not None: + holiday_calendar = SchoolHolidayCalendar(holidays_path) + + # Build provider — the provider's __init__ will validate that parity_service + # is provided if the JSON contains even/odd lessons + return JsonTheoreticalAgendaProvider( + file_path=agenda_path, + parity_service=parity_service, + holiday_calendar=holiday_calendar, + ) diff --git a/pronote_sync/sources/theoretical/file.py b/pronote_sync/sources/theoretical/file.py new file mode 100644 index 0000000..6f5f644 --- /dev/null +++ b/pronote_sync/sources/theoretical/file.py @@ -0,0 +1,183 @@ +"""Fournisseur d'agenda théorique basé sur un fichier JSON. + +Ce module fournit :class:`JsonTheoreticalAgendaProvider`, une implémentation de +:class:`~pronote_sync.sources.theoretical.provider.TheoreticalAgendaProvider` qui charge +un fichier JSON d'emploi du temps théorique et expose les cours applicables par date ou +plage de dates. Le filtrage tient compte du jour de la semaine, de la parité de semaine +(``even``/``odd``) et du calendrier des vacances scolaires. +""" + +from __future__ import annotations + +import logging +from datetime import date, time, timedelta +from pathlib import Path +from typing import Literal + +from pronote_sync.errors import PronoteSyncError +from pronote_sync.models.agenda import TheoreticalLesson +from pronote_sync.sources.theoretical.holidays import SchoolHolidayCalendar +from pronote_sync.sources.theoretical.model import TheoreticalAgendaFile, TheoreticalLessonEntry +from pronote_sync.sources.theoretical.parity import WeekParityService +from pronote_sync.utils.redaction import redact_exception, redact_secrets + +logger = logging.getLogger(__name__) + + +def _generate_id(entry: TheoreticalLessonEntry) -> str: + """Génère un identifiant déterministe pour une entrée de cours. + + L'identifiant intègre le type de semaine (``all``, ``even`` ou ``odd``), + le jour de la semaine, le créneau horaire et la matière : deux leçons + occupant le même créneau dans des semaines différentes (ou le même + créneau un autre jour) obtiennent ainsi des identifiants distincts. + + :param entry: Entrée de cours du fichier JSON. + :return: Identifiant déterministe unique. + :rtype: str + """ + subject_slug = entry.subject.lower().strip().replace(" ", "-") + return f"theoretical:{entry.week}:{entry.day_of_week}:{entry.start_time}-{entry.end_time}:{subject_slug}" + + +class JsonTheoreticalAgendaProvider: + """Fournisseur d'agenda théorique basé sur un fichier JSON. + + Charge un fichier JSON d'emploi du temps théorique au format défini par + :class:`~pronote_sync.sources.theoretical.model.TheoreticalAgendaFile` et expose + les cours théoriques pour une date ou une plage de dates. Les cours peuvent être + restreints à une parité de semaine (paire/impaire) via + :class:`~pronote_sync.sources.theoretical.parity.WeekParityService` et exclus + pendant les vacances scolaires via + :class:`~pronote_sync.sources.theoretical.holidays.SchoolHolidayCalendar`. + + :param file_path: Chemin vers le fichier JSON de l'agenda théorique. + :param parity_service: Service optionnel de calcul de la parité de semaine. + :param holiday_calendar: Calendrier optionnel des vacances scolaires. + :raises PronoteSyncError: Si le fichier ne peut être lu ou analysé, si + des leçons à semaine paire/impaire sont présentes sans ancre de parité, + ou si plusieurs leçons partagent le même identifiant (explicite ou + généré). + """ + + def __init__( + self, + file_path: str, + parity_service: WeekParityService | None = None, + holiday_calendar: SchoolHolidayCalendar | None = None, + ) -> None: + """Initialise le fournisseur en chargeant et analysant le fichier JSON. + + Le fichier est lu et analysé immédiatement. Toute erreur de lecture, + de décodage JSON ou de validation est journalisée (chemin et exception + expurgés) puis remontée sous forme de :class:`PronoteSyncError`. Si des + leçons à semaine paire/impaire sont présentes alors qu'aucun service de + parité n'est configuré, une :class:`PronoteSyncError` est également levée. + + :param file_path: Chemin vers le fichier JSON de l'agenda théorique. + :param parity_service: Service optionnel de calcul de la parité de semaine. + :param holiday_calendar: Calendrier optionnel des vacances scolaires. + :raises PronoteSyncError: Si le fichier est introuvable, invalide, + nécessite une ancre de parité non configurée ou contient plusieurs + leçons partageant le même identifiant (explicite ou généré). + """ + self._file_path: str = file_path + self._parity_service: WeekParityService | None = parity_service + self._holiday_calendar: SchoolHolidayCalendar | None = holiday_calendar + try: + content = Path(file_path).read_text(encoding="utf-8") + parsed = TheoreticalAgendaFile.model_validate_json(content) + except Exception as exc: + logger.error( + "Fichier d'agenda théorique invalide %s : %s.", + redact_secrets(str(file_path)), + redact_exception(exc), + ) + raise PronoteSyncError( + f"Le fichier d'agenda théorique est invalide : {redact_secrets(str(file_path))}" + ) from None + self._lessons: tuple[TheoreticalLessonEntry, ...] = parsed.lessons + if self._parity_service is None and any( + entry.week in ("even", "odd") for entry in self._lessons + ): + raise PronoteSyncError( + "L'agenda théorique contient des leçons à semaine paire/impaire " + "mais aucune ancre de parité n'est configurée " + "(THEORETICAL_WEEK_ANCHOR_DATE et THEORETICAL_WEEK_ANCHOR_TYPE)" + ) from None + seen_ids: set[str] = set() + for entry in self._lessons: + effective_id = entry.id if entry.id is not None else _generate_id(entry) + if effective_id in seen_ids: + raise PronoteSyncError( + f"Conflit d'identifiant dans l'agenda théorique : " + f"l'identifiant '{effective_id}' est utilisé par plusieurs leçons. " + f"Fournissez des identifiants explicites uniques." + ) from None + seen_ids.add(effective_id) + + def get_lessons(self, target_date: date) -> list[TheoreticalLesson]: + """Retourne les cours théoriques applicables à la date donnée. + + Si un calendrier de vacances est configuré et que la date tombe pendant + une période de vacances, la liste retournée est vide. La parité de la + semaine est déterminée via le service de parité lorsqu'il est configuré ; + sinon seuls les cours de type ``all`` sont conservés. Les entrées sont + ensuite filtrées par jour de la semaine, converties en + :class:`~pronote_sync.models.agenda.TheoreticalLesson` et triées par + identifiant. + + :param target_date: Date cible. + :return: Liste des cours théoriques triée par identifiant. + :rtype: list[TheoreticalLesson] + """ + if self._holiday_calendar is not None and self._holiday_calendar.is_holiday(target_date): + return [] + + week_parity: Literal["all", "even", "odd"] + if self._parity_service is not None: + week_parity = self._parity_service.parity_for(target_date) + else: + week_parity = "all" + + lessons: list[TheoreticalLesson] = [] + for entry in self._lessons: + if entry.week != "all" and entry.week != week_parity: + continue + if entry.day_of_week != target_date.weekday(): + continue + lesson_id = entry.id if entry.id is not None else _generate_id(entry) + lessons.append( + TheoreticalLesson( + id=lesson_id, + day_of_week=entry.day_of_week, + start_time=time.fromisoformat(entry.start_time), + end_time=time.fromisoformat(entry.end_time), + subject=entry.subject, + teachers=entry.teachers, + rooms=entry.rooms, + ) + ) + return sorted(lessons, key=lambda lesson: lesson.id) + + def get_lessons_for_range(self, start_date: date, end_date: date) -> list[TheoreticalLesson]: + """Retourne les cours théoriques pour une plage de dates (inclusives). + + Chaque date de la plage, bornes incluses, est évaluée via + :meth:`get_lessons`. Les cours sont dédupliqués par identifiant : pour + un identifiant donné, la dernière occurrence (date la plus récente) + écrase la précédente. Si ``start_date`` est postérieure à ``end_date``, + la liste retournée est vide. + + :param start_date: Date de début (inclusive). + :param end_date: Date de fin (inclusive). + :return: Liste des cours théoriques triée par identifiant. + :rtype: list[TheoreticalLesson] + """ + seen: dict[str, TheoreticalLesson] = {} + current_date = start_date + while current_date <= end_date: + for lesson in self.get_lessons(current_date): + seen[lesson.id] = lesson + current_date += timedelta(days=1) + return sorted(seen.values(), key=lambda lesson: lesson.id) diff --git a/pronote_sync/sources/theoretical/holidays.py b/pronote_sync/sources/theoretical/holidays.py new file mode 100644 index 0000000..38b0e4a --- /dev/null +++ b/pronote_sync/sources/theoretical/holidays.py @@ -0,0 +1,106 @@ +"""Service de calendrier des vacances scolaires. + +Ce module fournit les modèles de données :class:`HolidayPeriod` et +:class:`SchoolHolidayFile`, ainsi que le service :class:`SchoolHolidayCalendar` +qui charge un fichier JSON de périodes de vacances scolaires et permet de +déterminer si une date donnée tombe pendant ces vacances. +""" + +from __future__ import annotations + +import json +import logging +from datetime import date +from pathlib import Path +from typing import Any, Self + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from pronote_sync.errors import PronoteSyncError +from pronote_sync.utils.redaction import redact_exception, redact_secrets + +logger = logging.getLogger(__name__) + + +class HolidayPeriod(BaseModel): + """Période de vacances scolaires, bornes incluses. + + :ivar start_date: Date de début de la période (incluse). + :ivar end_date: Date de fin de la période (incluse). + :ivar label: Nom de la période (ex. « Toussaint »). + """ + + model_config = ConfigDict(frozen=True) + + start_date: date + end_date: date + label: str + + @model_validator(mode="after") + def _validate_date_order(self) -> Self: + """Vérifie que la date de fin n'est pas antérieure à la date de début. + + :return: L'instance de période après validation. + :rtype: Self + :raises ValueError: Si ``end_date`` est strictement antérieure à ``start_date``. + """ + if self.end_date < self.start_date: + raise ValueError("end_date doit être supérieure ou égale à start_date.") + return self + + +class SchoolHolidayFile(BaseModel): + """Modèle de parsing d'un fichier JSON de vacances scolaires. + + :ivar zone: Zone académique (ex. « A »). + :ivar school_year: Année scolaire (ex. « 2026-2027 »). + :ivar periods: Périodes de vacances scolaires du fichier. + """ + + zone: str + school_year: str + periods: tuple[HolidayPeriod, ...] = Field(default=()) + + +class SchoolHolidayCalendar: + """Calendrier des vacances scolaires chargé depuis un fichier JSON.""" + + def __init__(self, file_path: Path | str) -> None: + """Charge les périodes de vacances scolaires depuis un fichier JSON. + + :param file_path: Chemin vers le fichier JSON. + :raises PronoteSyncError: Si le fichier ne peut être lu ou analysé. + """ + path = Path(file_path) + self._periods: tuple[HolidayPeriod, ...] + if not path.is_file(): + raise PronoteSyncError( + f"Le fichier de vacances scolaires est introuvable : {redact_secrets(str(path))}" + ) from None + try: + data: Any = json.loads(path.read_text(encoding="utf-8")) + file_model: SchoolHolidayFile = SchoolHolidayFile.model_validate(data) + except Exception as exc: + logger.error( + "Fichier de vacances scolaires invalide %s : %s.", + redact_secrets(str(path)), + redact_exception(exc), + ) + raise PronoteSyncError( + f"Le fichier de vacances scolaires est invalide : {redact_secrets(str(path))}" + ) from None + self._periods = file_model.periods + + def is_holiday(self, target_date: date) -> bool: + """Vérifie si la date donnée tombe pendant une période de vacances. + + La date est considérée comme étant en vacances si elle appartient à + l'intervalle d'au moins une période, bornes incluses + (``start_date <= target_date <= end_date``). + + :param target_date: Date à vérifier. + :return: ``True`` si la date tombe pendant les vacances scolaires, + ``False`` sinon. + :rtype: bool + """ + return any(period.start_date <= target_date <= period.end_date for period in self._periods) diff --git a/pronote_sync/sources/theoretical/model.py b/pronote_sync/sources/theoretical/model.py new file mode 100644 index 0000000..446a03b --- /dev/null +++ b/pronote_sync/sources/theoretical/model.py @@ -0,0 +1,123 @@ +"""Modèles Pydantic de parsing du fichier JSON de l'agenda théorique. + +Ce module définit les modèles de parsing utilisés pour lire le fichier +JSON de l'agenda théorique : :class:`TheoreticalLessonEntry` pour une +entrée de cours et :class:`TheoreticalAgendaFile` pour le fichier +complet. + +Ces modèles sont distincts du modèle de domaine +:class:`~pronote_sync.models.agenda.TheoreticalLesson` : ils restent +proches du format JSON brut (heures au format ``HH:MM``) et servent +uniquement à la désérialisation, la conversion vers le modèle de domaine +étant réalisée ensuite par le fournisseur. +""" + +from __future__ import annotations + +import re +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +_TIME_PATTERN = re.compile(r"^(?:[01]\d|2[0-3]):[0-5]\d$") + + +class TheoreticalLessonEntry(BaseModel): + """Représente une entrée de cours dans le fichier JSON de l'agenda théorique. + + Modèle figé (``frozen``) : les instances sont immuables après + création. Le format des heures est validé (``HH:MM`` sur 24 heures, + avec ``HH`` entre ``00`` et ``23`` et ``MM`` entre ``00`` et ``59``) + ainsi que l'ordre des heures (fin postérieure au début). + + :param week: Type de semaine auquel s'applique le cours + (``"all"``, ``"even"`` ou ``"odd"``). + :param day_of_week: Jour de la semaine (0 = lundi, 6 = dimanche). + :param start_time: Heure de début au format ``HH:MM`` sur 24 heures. + :param end_time: Heure de fin au format ``HH:MM`` sur 24 heures. + :param subject: Nom de la matière. + :param teachers: Noms des professeurs. Tuple vide par défaut. + :param rooms: Noms des salles. Tuple vide par défaut. + :param id: Identifiant explicite optionnel. ``None`` par défaut ; en + cas d'absence, le fournisseur en génère un. + """ + + model_config = ConfigDict(frozen=True) + + week: Literal["all", "even", "odd"] = Field( + ..., description="Type de semaine concerné (all, even ou odd)" + ) + day_of_week: int = Field( + ..., ge=0, le=6, description="Jour de la semaine (0=lundi, 6=dimanche)" + ) + start_time: str = Field(..., description="Heure de début au format HH:MM") + end_time: str = Field(..., description="Heure de fin au format HH:MM") + subject: str = Field(..., description="Nom de la matière") + teachers: tuple[str, ...] = Field(default=(), description="Noms des professeurs") + rooms: tuple[str, ...] = Field(default=(), description="Noms des salles") + id: str | None = Field( + default=None, description="Identifiant explicite optionnel (None si absent)" + ) + + @model_validator(mode="before") + @classmethod + def _validate_time_format(cls, data: Any) -> Any: + """Valide le format ``HH:MM`` des heures de début et de fin. + + Les heures doivent être au format ``HH:MM`` sur 24 heures, avec + ``HH`` entre ``00`` et ``23`` et ``MM`` entre ``00`` et ``59``. + Cette validation précède :meth:`_validate_time_order`, dont la + comparaison par ordre lexicographique n'est fiable que si le + format est garanti. + + :param data: Données brutes transmises au modèle. + :return: Les données brutes inchangées. + :rtype: Any + :raises ValueError: Si ``start_time`` ou ``end_time`` n'est pas + au format ``HH:MM``. + """ + if not isinstance(data, dict): + return data + for field_name in ("start_time", "end_time"): + if field_name not in data: + continue + value = data[field_name] + if not isinstance(value, str) or _TIME_PATTERN.fullmatch(value) is None: + raise ValueError( + f"{field_name} doit être au format HH:MM (HH entre 00 et 23, MM entre 00 et 59)" + ) + return data + + @model_validator(mode="after") + def _validate_time_order(self) -> TheoreticalLessonEntry: + """Valide que l'heure de fin est postérieure à l'heure de début. + + La comparaison est effectuée sur les chaînes ``HH:MM`` de façon + lexicographique ; elle n'est fiable que parce que + :meth:`_validate_time_format` a déjà garanti le format à deux + chiffres. + + :return: L'instance validée. + :rtype: TheoreticalLessonEntry + :raises ValueError: Si ``end_time`` n'est pas postérieur à + ``start_time``. + """ + if self.end_time <= self.start_time: + raise ValueError("end_time doit être postérieur à start_time") + return self + + +class TheoreticalAgendaFile(BaseModel): + """Représente le fichier JSON complet de l'agenda théorique. + + Modèle de parsing non figé : il sert uniquement à désérialiser le + fichier JSON avant conversion vers les modèles de domaine. + + :param version: Version du schéma du fichier (vaut ``1``). + :param lessons: Liste des entrées de cours du fichier. + """ + + version: Literal[1] = Field(default=1, description="Version du schéma (1)") + lessons: tuple[TheoreticalLessonEntry, ...] = Field( + ..., description="Liste des entrées de cours" + ) diff --git a/pronote_sync/sources/theoretical/parity.py b/pronote_sync/sources/theoretical/parity.py new file mode 100644 index 0000000..97cc7ac --- /dev/null +++ b/pronote_sync/sources/theoretical/parity.py @@ -0,0 +1,55 @@ +"""Service déterministe de calcul de la parité des semaines pour l'agenda théorique. + +Ce module fournit :class:`WeekParityService`, un service sans état qui détermine +si la semaine contenant une date donnée est paire ou impaire, à partir d'une +date d'ancrage dont la parité est connue. L'algorithme repose sur le décalage +entre les lundis des deux semaines, et non sur les numéros de semaine ISO. +""" + +from __future__ import annotations + +from datetime import date, timedelta +from typing import Literal + + +class WeekParityService: + """Service déterministe de calcul de la parité des semaines. + + La parité d'une semaine est déduite d'une date d'ancrage fournie à la + construction : la semaine contenant cette date a une parité connue + (paire ou impaire). Le service est immuable après construction et ne + dépend d'aucun état global ni de l'horloge système. + """ + + def __init__(self, anchor_date: date, anchor_type: Literal["even", "odd"]) -> None: + """Initialise le service avec la date d'ancrage et sa parité. + + :param anchor_date: Date de référence dont la semaine a une parité connue. + :param anchor_type: Parité de la semaine d'ancrage (``"even"`` ou ``"odd"``). + """ + self._anchor_monday = anchor_date - timedelta(days=anchor_date.weekday()) + self._anchor_type = anchor_type + + def parity_for(self, target_date: date) -> Literal["even", "odd"]: + """Détermine la parité de la semaine contenant la date cible. + + Algorithme : + 1. Calculer le lundi de la semaine de la date cible. + 2. Utiliser le lundi de la semaine de la date d'ancrage (stocké à + l'initialisation). + 3. Calculer le nombre de semaines entre les deux lundis : + ``(target_monday - anchor_monday).days // 7``. + 4. Si le décalage de semaines est pair, la cible a la même parité que + l'ancrage. + 5. Si le décalage de semaines est impair, la cible a la parité opposée. + + :param target_date: Date dont il faut déterminer la parité. + :return: ``"even"`` ou ``"odd"`` selon la parité de la semaine cible. + :rtype: Literal["even", "odd"] + """ + target_monday = target_date - timedelta(days=target_date.weekday()) + week_offset = (target_monday - self._anchor_monday).days // 7 + if week_offset % 2 == 0: + return self._anchor_type + # Inverse la parité. + return "odd" if self._anchor_type == "even" else "even" diff --git a/pronote_sync/sources/theoretical/provider.py b/pronote_sync/sources/theoretical/provider.py new file mode 100644 index 0000000..2272589 --- /dev/null +++ b/pronote_sync/sources/theoretical/provider.py @@ -0,0 +1,44 @@ +"""Protocole pour les fournisseurs d'agenda théorique. + +Ce module définit :class:`TheoreticalAgendaProvider`, le contrat que +tous les fournisseurs d'agenda théorique doivent respecter pour exposer +les cours théoriques (emploi du temps attendu) par date ou plage de +dates. +""" + +from __future__ import annotations + +from datetime import date +from typing import Protocol, runtime_checkable + +from pronote_sync.models.agenda import TheoreticalLesson + + +@runtime_checkable +class TheoreticalAgendaProvider(Protocol): + """Protocole pour un fournisseur d'agenda théorique. + + Un fournisseur d'agenda théorique expose les cours théoriques + (emploi du temps attendu) pour une date ou une plage de dates. + L'implémentation encapsule la logique de filtrage par parité de + semaine et par vacances scolaires. + """ + + def get_lessons(self, target_date: date) -> list[TheoreticalLesson]: + """Retourne les cours théoriques applicables à la date donnée. + + :param target_date: Date cible. + :return: Liste des cours théoriques triée par identifiant. + :rtype: list[TheoreticalLesson] + """ + ... + + def get_lessons_for_range(self, start_date: date, end_date: date) -> list[TheoreticalLesson]: + """Retourne les cours théoriques pour une plage de dates (inclusives). + + :param start_date: Date de début (inclusive). + :param end_date: Date de fin (inclusive). + :return: Liste des cours théoriques triée par identifiant. + :rtype: list[TheoreticalLesson] + """ + ... diff --git a/tests/fixtures/school_holidays.json b/tests/fixtures/school_holidays.json new file mode 100644 index 0000000..b9e71a9 --- /dev/null +++ b/tests/fixtures/school_holidays.json @@ -0,0 +1,26 @@ +{ + "zone": "A", + "school_year": "2026-2027", + "periods": [ + { + "start_date": "2026-10-17", + "end_date": "2026-11-02", + "label": "Toussaint" + }, + { + "start_date": "2026-12-19", + "end_date": "2027-01-04", + "label": "Noël" + }, + { + "start_date": "2027-02-06", + "end_date": "2027-02-22", + "label": "Hiver" + }, + { + "start_date": "2027-04-03", + "end_date": "2027-04-19", + "label": "Printemps" + } + ] +} diff --git a/tests/fixtures/theoretical.json b/tests/fixtures/theoretical.json new file mode 100644 index 0000000..8a4a6ac --- /dev/null +++ b/tests/fixtures/theoretical.json @@ -0,0 +1,87 @@ +{ + "version": 1, + "lessons": [ + { + "id": "theoretical-maths-monday-1", + "week": "all", + "day_of_week": 0, + "start_time": "08:00", + "end_time": "09:00", + "subject": "Mathématiques", + "teachers": ["Mme Martin"], + "rooms": ["101"] + }, + { + "week": "all", + "day_of_week": 0, + "start_time": "09:00", + "end_time": "10:00", + "subject": "Français", + "teachers": ["M. Dupont"], + "rooms": ["102"] + }, + { + "week": "all", + "day_of_week": 0, + "start_time": "11:00", + "end_time": "12:00", + "subject": "Histoire-Géographie", + "teachers": ["Mme Petit"], + "rooms": ["103"] + }, + { + "week": "even", + "day_of_week": 1, + "start_time": "10:00", + "end_time": "11:00", + "subject": "Anglais", + "teachers": ["Mme Bernard"], + "rooms": ["201"] + }, + { + "week": "even", + "day_of_week": 1, + "start_time": "10:00", + "end_time": "11:00", + "subject": "Technologie", + "teachers": [], + "rooms": [] + }, + { + "week": "odd", + "day_of_week": 1, + "start_time": "10:00", + "end_time": "11:00", + "subject": "Espagnol", + "teachers": ["M. Garcia"], + "rooms": ["202"] + }, + { + "week": "odd", + "day_of_week": 1, + "start_time": "10:00", + "end_time": "11:00", + "subject": "Éducation musicale", + "teachers": [], + "rooms": [] + }, + { + "week": "all", + "day_of_week": 2, + "start_time": "14:00", + "end_time": "15:00", + "subject": "Sciences", + "teachers": [], + "rooms": ["203"] + }, + { + "week": "all", + "day_of_week": 4, + "start_time": "09:00", + "end_time": "10:00", + "subject": "Arts plastiques", + "teachers": [], + "rooms": [] + } + ] +} diff --git a/tests/unit/test_theoretical_factory.py b/tests/unit/test_theoretical_factory.py new file mode 100644 index 0000000..41f04cc --- /dev/null +++ b/tests/unit/test_theoretical_factory.py @@ -0,0 +1,146 @@ +"""Tests unitaires pour l'usine de construction du fournisseur d'agenda théorique. + +Ce module contient les tests pour la fonction :func:`get_theoretical_provider` +du module :mod:`pronote_sync.sources.theoretical`. +""" + +from __future__ import annotations + +import json +from datetime import date +from pathlib import Path + +import pytest + +from pronote_sync.errors import PronoteSyncError +from pronote_sync.sources.theoretical import ( + TheoreticalAgendaProvider, + get_theoretical_provider, +) + + +class TestGetTheoreticalProvider: + """Tests pour la fonction get_theoretical_provider.""" + + @pytest.fixture + def fixture_path(self) -> Path: + """Retourne le chemin du fichier de fixture theoretical.json.""" + return Path(__file__).parent.parent / "fixtures" / "theoretical.json" + + @pytest.fixture + def holidays_path(self) -> Path: + """Retourne le chemin du fichier de fixture school_holidays.json.""" + return Path(__file__).parent.parent / "fixtures" / "school_holidays.json" + + @pytest.fixture + def all_only_path(self, tmp_path: Path) -> Path: + """Crée un fichier JSON avec uniquement des cours "all".""" + data = { + "version": 1, + "lessons": [ + { + "week": "all", + "day_of_week": 0, + "start_time": "08:00", + "end_time": "09:00", + "subject": "Test", + } + ], + } + file_path = tmp_path / "all_only.json" + file_path.write_text(json.dumps(data), encoding="utf-8") + return file_path + + def test_factory_returns_none_when_path_none(self) -> None: + """Teste que l'usine retourne None quand agenda_path est None. + + :assert: get_theoretical_provider(None, None, None, None) retourne None. + """ + result = get_theoretical_provider(None, None, None, None) + assert result is None + + def test_factory_returns_provider_with_full_config( + self, fixture_path: Path, holidays_path: Path + ) -> None: + """Teste que l'usine retourne un fournisseur avec une configuration complète. + + :assert: Un fournisseur est retourné avec tous les paramètres. + """ + result = get_theoretical_provider( + agenda_path=str(fixture_path), + holidays_path=str(holidays_path), + anchor_date=date(2026, 9, 1), + anchor_type="even", + ) + assert result is not None + assert isinstance(result, TheoreticalAgendaProvider) + + def test_factory_partial_parity_config_error(self, fixture_path: Path) -> None: + """Teste qu'une configuration de parité partielle lève une PronoteSyncError. + + :assert: PronoteSyncError est levée quand anchor_date est fourni sans anchor_type. + """ + with pytest.raises(PronoteSyncError) as exc_info: + get_theoretical_provider( + agenda_path=str(fixture_path), + holidays_path=None, + anchor_date=date(2026, 9, 1), + anchor_type=None, + ) + assert "incomplète" in str(exc_info.value) + + def test_factory_partial_parity_config_error_type_only(self, fixture_path: Path) -> None: + """Teste qu'une configuration de parité partielle (type seulement) lève une PronoteSyncError. + + :assert: PronoteSyncError est levée quand anchor_type est fourni sans anchor_date. + """ + with pytest.raises(PronoteSyncError) as exc_info: + get_theoretical_provider( + agenda_path=str(fixture_path), + holidays_path=None, + anchor_date=None, + anchor_type="even", + ) + assert "incomplète" in str(exc_info.value) + + def test_factory_no_holidays(self, fixture_path: Path) -> None: + """Teste que l'usine retourne un fournisseur sans calendrier de vacances. + + :assert: Un fournisseur est retourné sans calendrier de vacances. + """ + result = get_theoretical_provider( + agenda_path=str(fixture_path), + holidays_path=None, + anchor_date=date(2026, 9, 1), + anchor_type="even", + ) + assert result is not None + assert isinstance(result, TheoreticalAgendaProvider) + + def test_factory_no_parity(self, all_only_path: Path) -> None: + """Teste que l'usine retourne un fournisseur sans service de parité pour des cours "all". + + :assert: Un fournisseur est retourné sans service de parité. + """ + result = get_theoretical_provider( + agenda_path=str(all_only_path), + holidays_path=None, + anchor_date=None, + anchor_type=None, + ) + assert result is not None + assert isinstance(result, TheoreticalAgendaProvider) + + def test_factory_protocol_compliance(self, fixture_path: Path, holidays_path: Path) -> None: + """Teste que le fournisseur retourné respecte le protocole TheoreticalAgendaProvider. + + :assert: Le fournisseur satisfait isinstance(provider, TheoreticalAgendaProvider). + """ + result = get_theoretical_provider( + agenda_path=str(fixture_path), + holidays_path=str(holidays_path), + anchor_date=date(2026, 9, 1), + anchor_type="even", + ) + assert result is not None + assert isinstance(result, TheoreticalAgendaProvider) diff --git a/tests/unit/test_theoretical_holidays.py b/tests/unit/test_theoretical_holidays.py new file mode 100644 index 0000000..d7ce611 --- /dev/null +++ b/tests/unit/test_theoretical_holidays.py @@ -0,0 +1,188 @@ +"""Tests unitaires pour le calendrier des vacances scolaires. + +Ce module contient les tests pour les classes :class:`SchoolHolidayCalendar`, +:class:`HolidayPeriod` et :class:`SchoolHolidayFile` du module +:mod:`pronote_sync.sources.theoretical.holidays`. +""" + +from __future__ import annotations + +import json +from datetime import date +from pathlib import Path + +import pytest + +from pronote_sync.errors import PronoteSyncError +from pronote_sync.sources.theoretical.holidays import SchoolHolidayCalendar + + +class TestSchoolHolidayCalendar: + """Tests pour la classe SchoolHolidayCalendar.""" + + def test_load_valid_file(self, tmp_path: Path) -> None: + """Teste le chargement d'un fichier JSON valide. + + :assert: is_holiday retourne True pour une date dans une période. + """ + holiday_data = { + "zone": "A", + "school_year": "2026-2027", + "periods": [ + { + "start_date": "2026-10-17", + "end_date": "2026-11-02", + "label": "Toussaint", + } + ], + } + file_path = tmp_path / "holidays.json" + file_path.write_text(json.dumps(holiday_data), encoding="utf-8") + + calendar = SchoolHolidayCalendar(file_path) + # Date dans la période de Toussaint + assert calendar.is_holiday(date(2026, 10, 20)) is True + + def test_date_outside_periods(self, tmp_path: Path) -> None: + """Teste qu'une date en dehors des périodes retourne False. + + :assert: is_holiday retourne False pour une date hors période. + """ + holiday_data = { + "zone": "A", + "school_year": "2026-2027", + "periods": [ + { + "start_date": "2026-10-17", + "end_date": "2026-11-02", + "label": "Toussaint", + } + ], + } + file_path = tmp_path / "holidays.json" + file_path.write_text(json.dumps(holiday_data), encoding="utf-8") + + calendar = SchoolHolidayCalendar(file_path) + # Date en dehors de la période + assert calendar.is_holiday(date(2026, 9, 1)) is False + + def test_start_date_inclusive(self, tmp_path: Path) -> None: + """Teste que la date de début est incluse dans la période. + + :assert: is_holiday retourne True pour une date égale à start_date. + """ + holiday_data = { + "zone": "A", + "school_year": "2026-2027", + "periods": [ + { + "start_date": "2026-10-17", + "end_date": "2026-11-02", + "label": "Toussaint", + } + ], + } + file_path = tmp_path / "holidays.json" + file_path.write_text(json.dumps(holiday_data), encoding="utf-8") + + calendar = SchoolHolidayCalendar(file_path) + assert calendar.is_holiday(date(2026, 10, 17)) is True + + def test_end_date_inclusive(self, tmp_path: Path) -> None: + """Teste que la date de fin est incluse dans la période. + + :assert: is_holiday retourne True pour une date égale à end_date. + """ + holiday_data = { + "zone": "A", + "school_year": "2026-2027", + "periods": [ + { + "start_date": "2026-10-17", + "end_date": "2026-11-02", + "label": "Toussaint", + } + ], + } + file_path = tmp_path / "holidays.json" + file_path.write_text(json.dumps(holiday_data), encoding="utf-8") + + calendar = SchoolHolidayCalendar(file_path) + assert calendar.is_holiday(date(2026, 11, 2)) is True + + def test_file_not_found(self, tmp_path: Path) -> None: + """Teste qu'un fichier introuvable lève une PronoteSyncError. + + :assert: PronoteSyncError est levée pour un fichier introuvable. + """ + file_path = tmp_path / "nonexistent.json" + with pytest.raises(PronoteSyncError) as exc_info: + SchoolHolidayCalendar(file_path) + assert "introuvable" in str(exc_info.value) + # Vérifier qu'aucun secret n'est fuité dans le message d'erreur + assert "nonexistent" not in str(exc_info.value) or "introuvable" in str(exc_info.value) + + def test_invalid_json(self, tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: + """Teste qu'un fichier JSON invalide lève une PronoteSyncError. + + :assert: PronoteSyncError est levée pour un JSON invalide. + """ + file_path = tmp_path / "invalid.json" + file_path.write_text("{ invalid json }", encoding="utf-8") + + with pytest.raises(PronoteSyncError) as exc_info: + SchoolHolidayCalendar(file_path) + assert "invalide" in str(exc_info.value) + + def test_invalid_period_dates(self, tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: + """Teste qu'une période avec end_date < start_date lève une ValidationError. + + :assert: PronoteSyncError est levée pour des dates de période invalides. + """ + holiday_data = { + "zone": "A", + "school_year": "2026-2027", + "periods": [ + { + "start_date": "2026-11-02", + "end_date": "2026-10-17", # Inversé + "label": "Toussaint", + } + ], + } + file_path = tmp_path / "holidays.json" + file_path.write_text(json.dumps(holiday_data), encoding="utf-8") + + with pytest.raises(PronoteSyncError): + SchoolHolidayCalendar(file_path) + + def test_empty_periods(self, tmp_path: Path) -> None: + """Teste qu'un fichier avec des périodes vides retourne toujours False. + + :assert: is_holiday retourne False pour toutes les dates. + """ + holiday_data = { + "zone": "A", + "school_year": "2026-2027", + "periods": [], + } + file_path = tmp_path / "holidays.json" + file_path.write_text(json.dumps(holiday_data), encoding="utf-8") + + calendar = SchoolHolidayCalendar(file_path) + assert calendar.is_holiday(date(2026, 10, 20)) is False + assert calendar.is_holiday(date(2026, 1, 1)) is False + + def test_load_from_fixture(self) -> None: + """Teste le chargement du fichier de fixture et vérifie une date connue. + + :assert: is_holiday retourne True pour une date de vacances connue. + """ + fixture_path = Path(__file__).parent.parent / "fixtures" / "school_holidays.json" + calendar = SchoolHolidayCalendar(fixture_path) + # Date dans les vacances de Toussaint (17 oct - 2 nov 2026) + assert calendar.is_holiday(date(2026, 10, 20)) is True + # Date dans les vacances de Noël (19 déc 2026 - 4 janv 2027) + assert calendar.is_holiday(date(2026, 12, 25)) is True + # Date en dehors des vacances + assert calendar.is_holiday(date(2026, 9, 1)) is False diff --git a/tests/unit/test_theoretical_model.py b/tests/unit/test_theoretical_model.py new file mode 100644 index 0000000..390b271 --- /dev/null +++ b/tests/unit/test_theoretical_model.py @@ -0,0 +1,201 @@ +"""Tests unitaires pour les modèles de parsing de l'agenda théorique. + +Ce module contient les tests pour les classes :class:`TheoreticalLessonEntry` +et :class:`TheoreticalAgendaFile` du module +:mod:`pronote_sync.sources.theoretical.model`. +""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from pronote_sync.sources.theoretical.model import ( + TheoreticalAgendaFile, + TheoreticalLessonEntry, +) + + +class TestTheoreticalLessonEntry: + """Tests pour la classe TheoreticalLessonEntry.""" + + def test_valid_lesson_entry(self) -> None: + """Teste la construction d'une entrée de cours valide. + + :assert: Tous les champs sont correctement initialisés. + """ + entry = TheoreticalLessonEntry( + week="all", + day_of_week=0, + start_time="08:00", + end_time="09:00", + subject="Mathématiques", + teachers=("Mme Martin",), + rooms=("101",), + id="math-1", + ) + assert entry.week == "all" + assert entry.day_of_week == 0 + assert entry.start_time == "08:00" + assert entry.end_time == "09:00" + assert entry.subject == "Mathématiques" + assert entry.teachers == ("Mme Martin",) + assert entry.rooms == ("101",) + assert entry.id == "math-1" + + def test_invalid_time_format(self) -> None: + """Teste qu'une heure invalide (25:00) lève une ValidationError. + + :assert: ValidationError est levée pour start_time="25:00". + """ + with pytest.raises(ValidationError) as exc_info: + TheoreticalLessonEntry( + week="all", + day_of_week=0, + start_time="25:00", + end_time="09:00", + subject="Test", + ) + assert "start_time" in str(exc_info.value) + + def test_invalid_time_format_minutes(self) -> None: + """Teste qu'une minute invalide (60) lève une ValidationError. + + :assert: ValidationError est levée pour start_time="08:60". + """ + with pytest.raises(ValidationError) as exc_info: + TheoreticalLessonEntry( + week="all", + day_of_week=0, + start_time="08:60", + end_time="09:00", + subject="Test", + ) + assert "start_time" in str(exc_info.value) + + def test_end_before_start(self) -> None: + """Teste qu'une fin avant le début lève une ValidationError. + + :assert: ValidationError est levée pour end_time < start_time. + """ + with pytest.raises(ValidationError) as exc_info: + TheoreticalLessonEntry( + week="all", + day_of_week=0, + start_time="10:00", + end_time="09:00", + subject="Test", + ) + assert "end_time" in str(exc_info.value) + + def test_end_equal_start(self) -> None: + """Teste qu'une fin égale au début lève une ValidationError. + + :assert: ValidationError est levée pour end_time == start_time. + """ + with pytest.raises(ValidationError) as exc_info: + TheoreticalLessonEntry( + week="all", + day_of_week=0, + start_time="09:00", + end_time="09:00", + subject="Test", + ) + assert "end_time" in str(exc_info.value) + + def test_invalid_week(self) -> None: + """Teste qu'une semaine invalide lève une ValidationError. + + :assert: ValidationError est levée pour week="weekly". + """ + with pytest.raises(ValidationError) as exc_info: + TheoreticalLessonEntry( + week="weekly", # type: ignore[arg-type] + day_of_week=0, + start_time="08:00", + end_time="09:00", + subject="Test", + ) + assert "week" in str(exc_info.value) + + def test_invalid_day_of_week(self) -> None: + """Teste qu'un jour de semaine invalide (7) lève une ValidationError. + + :assert: ValidationError est levée pour day_of_week=7. + """ + with pytest.raises(ValidationError) as exc_info: + TheoreticalLessonEntry( + week="all", + day_of_week=7, + start_time="08:00", + end_time="09:00", + subject="Test", + ) + assert "day_of_week" in str(exc_info.value) + + def test_optional_fields_defaults(self) -> None: + """Teste que les champs optionnels ont des valeurs par défaut. + + :assert: teachers et rooms valent () par défaut. + """ + entry = TheoreticalLessonEntry( + week="all", + day_of_week=0, + start_time="08:00", + end_time="09:00", + subject="Test", + ) + assert entry.teachers == () + assert entry.rooms == () + + def test_frozen_model(self) -> None: + """Teste que le modèle est immuable (frozen). + + :assert: La tentative de mutation lève une exception. + """ + entry = TheoreticalLessonEntry( + week="all", + day_of_week=0, + start_time="08:00", + end_time="09:00", + subject="Test", + ) + with pytest.raises((ValidationError, AttributeError)): + entry.subject = "Nouveau" + + +class TestTheoreticalAgendaFile: + """Tests pour la classe TheoreticalAgendaFile.""" + + def test_agenda_file_valid(self) -> None: + """Teste la construction d'un fichier d'agenda valide. + + :assert: version == 1 et lessons a le bon nombre d'entrées. + """ + lessons = [ + TheoreticalLessonEntry( + week="all", + day_of_week=0, + start_time="08:00", + end_time="09:00", + subject="Mathématiques", + ), + TheoreticalLessonEntry( + week="even", + day_of_week=1, + start_time="10:00", + end_time="11:00", + subject="Anglais", + ), + ] + agenda = TheoreticalAgendaFile(version=1, lessons=tuple(lessons)) + assert agenda.version == 1 + assert len(agenda.lessons) == 2 + + def test_agenda_file_default_version(self) -> None: + """Teste que la version par défaut est 1. + + :assert: version vaut 1 par défaut. + """ + agenda = TheoreticalAgendaFile(lessons=()) + assert agenda.version == 1 diff --git a/tests/unit/test_theoretical_parity.py b/tests/unit/test_theoretical_parity.py new file mode 100644 index 0000000..cde84aa --- /dev/null +++ b/tests/unit/test_theoretical_parity.py @@ -0,0 +1,116 @@ +"""Tests unitaires pour le service de parité des semaines. + +Ce module contient les tests pour la classe :class:`WeekParityService` +du module :mod:`pronote_sync.sources.theoretical.parity`. +""" + +from __future__ import annotations + +from datetime import date + +from pronote_sync.sources.theoretical.parity import WeekParityService + + +class TestWeekParityService: + """Tests pour la classe WeekParityService.""" + + def test_same_week_as_anchor_even(self) -> None: + """Teste qu'une date dans la même semaine que l'ancrage pair retourne "even". + + :assert: parity_for retourne "even" pour une date dans la même semaine. + """ + anchor_date = date(2026, 9, 1) # Mardi + service = WeekParityService(anchor_date, "even") + # Même semaine (lundi 31 août 2026) + target_date = date(2026, 9, 1) + assert service.parity_for(target_date) == "even" + + def test_one_week_after_anchor_even(self) -> None: + """Teste qu'une date une semaine après l'ancrage pair retourne "odd". + + :assert: parity_for retourne "odd" pour une date une semaine après. + """ + anchor_date = date(2026, 9, 1) # Mardi + service = WeekParityService(anchor_date, "even") + # Une semaine après (mardi 8 septembre 2026) + target_date = date(2026, 9, 8) + assert service.parity_for(target_date) == "odd" + + def test_two_weeks_after_anchor_even(self) -> None: + """Teste qu'une date deux semaines après l'ancrage pair retourne "even". + + :assert: parity_for retourne "even" pour une date deux semaines après. + """ + anchor_date = date(2026, 9, 1) # Mardi + service = WeekParityService(anchor_date, "even") + # Deux semaines après (mardi 15 septembre 2026) + target_date = date(2026, 9, 15) + assert service.parity_for(target_date) == "even" + + def test_same_week_as_anchor_odd(self) -> None: + """Teste qu'une date dans la même semaine que l'ancrage impair retourne "odd". + + :assert: parity_for retourne "odd" pour une date dans la même semaine. + """ + anchor_date = date(2026, 9, 1) # Mardi + service = WeekParityService(anchor_date, "odd") + target_date = date(2026, 9, 1) + assert service.parity_for(target_date) == "odd" + + def test_one_week_before_anchor(self) -> None: + """Teste qu'une date une semaine avant l'ancrage retourne la parité opposée. + + :assert: parity_for retourne la parité opposée pour une date une semaine avant. + """ + anchor_date = date(2026, 9, 8) # Mardi + service = WeekParityService(anchor_date, "even") + # Une semaine avant (mardi 1er septembre 2026) + target_date = date(2026, 9, 1) + assert service.parity_for(target_date) == "odd" + + def test_many_weeks_later(self) -> None: + """Teste qu'une date 10 semaines après l'ancrage pair retourne "even". + + :assert: parity_for retourne "even" pour une date 10 semaines après. + """ + anchor_date = date(2026, 9, 1) # Mardi + service = WeekParityService(anchor_date, "even") + # 10 semaines après (70 jours) + target_date = date(2026, 11, 10) + assert service.parity_for(target_date) == "even" + + def test_negative_offset(self) -> None: + """Teste qu'une date loin avant l'ancrage calcule correctement la parité. + + :assert: parity_for retourne la parité correcte pour une date loin dans le passé. + """ + anchor_date = date(2026, 9, 1) # Mardi + service = WeekParityService(anchor_date, "even") + # 10 semaines avant (70 jours) + target_date = date(2026, 6, 23) + assert service.parity_for(target_date) == "even" + + def test_different_day_in_same_week(self) -> None: + """Teste que lundi et vendredi de la même semaine ont la même parité. + + :assert: parity_for retourne la même parité pour lundi et vendredi de la même semaine. + """ + anchor_date = date(2026, 9, 1) # Mardi + service = WeekParityService(anchor_date, "even") + # Lundi de la même semaine + monday = date(2026, 8, 31) + # Vendredi de la même semaine + friday = date(2026, 9, 4) + assert service.parity_for(monday) == "even" + assert service.parity_for(friday) == "even" + + def test_crosses_year_boundary(self) -> None: + """Teste que le calcul de parité fonctionne à cheval sur une année. + + :assert: parity_for retourne la parité correcte à cheval sur une année. + """ + anchor_date = date(2026, 12, 29) # Mardi (semaine 53) + service = WeekParityService(anchor_date, "even") + # Date dans la semaine suivante (année 2027) + target_date = date(2027, 1, 5) # Mardi de la semaine suivante + assert service.parity_for(target_date) == "odd" diff --git a/tests/unit/test_theoretical_provider.py b/tests/unit/test_theoretical_provider.py new file mode 100644 index 0000000..c710521 --- /dev/null +++ b/tests/unit/test_theoretical_provider.py @@ -0,0 +1,591 @@ +"""Tests unitaires pour le fournisseur d'agenda théorique basé sur fichier JSON. + +Ce module contient les tests pour la classe :class:`JsonTheoreticalAgendaProvider` +du module :mod:`pronote_sync.sources.theoretical.file`. +""" + +from __future__ import annotations + +import json +from datetime import date +from pathlib import Path + +import pytest + +from pronote_sync.errors import PronoteSyncError +from pronote_sync.sources.theoretical.file import JsonTheoreticalAgendaProvider +from pronote_sync.sources.theoretical.holidays import SchoolHolidayCalendar +from pronote_sync.sources.theoretical.parity import WeekParityService + + +class TestJsonTheoreticalAgendaProvider: + """Tests pour la classe JsonTheoreticalAgendaProvider.""" + + @pytest.fixture + def fixture_path(self) -> Path: + """Retourne le chemin du fichier de fixture theoretical.json.""" + return Path(__file__).parent.parent / "fixtures" / "theoretical.json" + + @pytest.fixture + def holidays_path(self) -> Path: + """Retourne le chemin du fichier de fixture school_holidays.json.""" + return Path(__file__).parent.parent / "fixtures" / "school_holidays.json" + + @pytest.fixture + def all_only_path(self, tmp_path: Path) -> Path: + """Crée un fichier JSON avec uniquement des cours "all".""" + data = { + "version": 1, + "lessons": [ + { + "id": "theoretical-maths-monday-1", + "week": "all", + "day_of_week": 0, + "start_time": "08:00", + "end_time": "09:00", + "subject": "Mathématiques", + "teachers": ["Mme Martin"], + "rooms": ["101"], + }, + { + "week": "all", + "day_of_week": 0, + "start_time": "09:00", + "end_time": "10:00", + "subject": "Français", + "teachers": ["M. Dupont"], + "rooms": ["102"], + }, + { + "week": "all", + "day_of_week": 0, + "start_time": "11:00", + "end_time": "12:00", + "subject": "Histoire-Géographie", + "teachers": ["Mme Petit"], + "rooms": ["103"], + }, + { + "week": "all", + "day_of_week": 2, + "start_time": "14:00", + "end_time": "15:00", + "subject": "Sciences", + "teachers": [], + "rooms": ["203"], + }, + { + "week": "all", + "day_of_week": 4, + "start_time": "09:00", + "end_time": "10:00", + "subject": "Arts plastiques", + "teachers": [], + "rooms": [], + }, + ], + } + file_path = tmp_path / "all_only.json" + file_path.write_text(json.dumps(data), encoding="utf-8") + return file_path + + @pytest.fixture + def provider_no_parity_no_holidays(self, all_only_path: Path) -> JsonTheoreticalAgendaProvider: + """Fournisseur sans service de parité ni calendrier de vacances.""" + return JsonTheoreticalAgendaProvider( + file_path=str(all_only_path), + parity_service=None, + holiday_calendar=None, + ) + + @pytest.fixture + def provider_with_parity(self, fixture_path: Path) -> JsonTheoreticalAgendaProvider: + """Fournisseur avec service de parité (ancrage sur 2026-09-01, even).""" + parity_service = WeekParityService(date(2026, 9, 1), "even") + return JsonTheoreticalAgendaProvider( + file_path=str(fixture_path), + parity_service=parity_service, + holiday_calendar=None, + ) + + @pytest.fixture + def provider_with_holidays( + self, fixture_path: Path, holidays_path: Path + ) -> JsonTheoreticalAgendaProvider: + """Fournisseur avec calendrier de vacances.""" + holiday_calendar = SchoolHolidayCalendar(holidays_path) + return JsonTheoreticalAgendaProvider( + file_path=str(fixture_path), + parity_service=None, + holiday_calendar=holiday_calendar, + ) + + @pytest.fixture + def provider_full( + self, fixture_path: Path, holidays_path: Path + ) -> JsonTheoreticalAgendaProvider: + """Fournisseur avec service de parité et calendrier de vacances.""" + parity_service = WeekParityService(date(2026, 9, 1), "even") + holiday_calendar = SchoolHolidayCalendar(holidays_path) + return JsonTheoreticalAgendaProvider( + file_path=str(fixture_path), + parity_service=parity_service, + holiday_calendar=holiday_calendar, + ) + + def test_get_lessons_all_weeks( + self, provider_no_parity_no_holidays: JsonTheoreticalAgendaProvider + ) -> None: + """Teste la récupération des cours pour un lundi (jour 0) sans parité. + + :assert: Les cours "all" pour le lundi sont retournés. + """ + # Lundi 2026-08-31 (weekday = 0) + target_date = date(2026, 8, 31) + lessons = provider_no_parity_no_holidays.get_lessons(target_date) + # Le fichier all_only a 3 cours "all" pour le lundi (day_of_week=0) + assert len(lessons) == 3 + subjects = [lesson.subject for lesson in lessons] + assert "Mathématiques" in subjects + assert "Français" in subjects + assert "Histoire-Géographie" in subjects + + def test_get_lessons_even_week( + self, provider_with_parity: JsonTheoreticalAgendaProvider + ) -> None: + """Teste la récupération des cours pour une semaine paire. + + :assert: Les cours "even" pour le mardi sont retournés. + """ + # Mardi 2026-09-01 (weekday = 1), semaine paire (ancrage 2026-09-01 even) + # Note: 2026-09-01 est mardi (weekday=1) + target_date = date(2026, 9, 1) + lessons = provider_with_parity.get_lessons(target_date) + # Le fixture a 2 cours "even" pour le mardi (day_of_week=1) + subjects = [lesson.subject for lesson in lessons] + assert "Anglais" in subjects + assert "Technologie" in subjects + + def test_get_lessons_odd_week( + self, provider_with_parity: JsonTheoreticalAgendaProvider + ) -> None: + """Teste la récupération des cours pour une semaine impaire. + + :assert: Les cours "odd" pour le mardi sont retournés. + """ + # Mardi 2026-09-08 (weekday = 1), semaine impaire (1 semaine après l'ancrage 2026-09-01) + target_date = date(2026, 9, 8) + lessons = provider_with_parity.get_lessons(target_date) + # Le fixture a 2 cours "odd" pour le mardi (day_of_week=1) + subjects = [lesson.subject for lesson in lessons] + assert "Espagnol" in subjects + assert "Éducation musicale" in subjects + + def test_get_lessons_holiday_returns_empty( + self, provider_full: JsonTheoreticalAgendaProvider + ) -> None: + """Teste qu'une date en vacances retourne une liste vide. + + :assert: get_lessons retourne [] pour une date en vacances. + """ + # Date pendant les vacances de Toussaint (17 oct - 2 nov 2026) + target_date = date(2026, 10, 20) + lessons = provider_full.get_lessons(target_date) + assert lessons == [] + + def test_get_lessons_no_holiday_calendar( + self, provider_with_parity: JsonTheoreticalAgendaProvider + ) -> None: + """Teste que sans calendrier de vacances, les cours sont retournés même en vacances. + + :assert: Les cours sont retournés pour une date en vacances. + """ + # Date pendant les vacances de Toussaint, mais sans calendrier de vacances + target_date = date(2026, 10, 20) # Mardi (weekday = 1) + lessons = provider_with_parity.get_lessons(target_date) + # Sans calendrier de vacances, les cours "even" pour le mardi sont retournés + assert len(lessons) == 2 + + def test_get_lessons_sorted_by_id( + self, provider_no_parity_no_holidays: JsonTheoreticalAgendaProvider + ) -> None: + """Teste que les cours sont triés par identifiant. + + :assert: Les cours sont triés par id. + """ + # Lundi 2026-08-31 (weekday = 0) + target_date = date(2026, 8, 31) + lessons = provider_no_parity_no_holidays.get_lessons(target_date) + ids = [lesson.id for lesson in lessons] + assert ids == sorted(ids) + + def test_get_lessons_deterministic( + self, provider_no_parity_no_holidays: JsonTheoreticalAgendaProvider + ) -> None: + """Teste que get_lessons retourne des résultats identiques pour la même date. + + :assert: Deux appels avec la même date retournent des résultats identiques. + """ + # Lundi 2026-08-31 (weekday = 0) + target_date = date(2026, 8, 31) + lessons1 = provider_no_parity_no_holidays.get_lessons(target_date) + lessons2 = provider_no_parity_no_holidays.get_lessons(target_date) + assert lessons1 == lessons2 + + def test_even_odd_same_slot_different_ids( + self, provider_with_parity: JsonTheoreticalAgendaProvider + ) -> None: + """Teste que les cours even/odd sur le même créneau ont des IDs différents. + + :assert: Les IDs des cours even et odd sont différents. + """ + # Mardi 2026-09-01 (semaine paire, même semaine que l'ancrage) + even_lessons = provider_with_parity.get_lessons(date(2026, 9, 1)) + # Mardi 2026-09-08 (semaine impaire, 1 semaine après l'ancrage) + odd_lessons = provider_with_parity.get_lessons(date(2026, 9, 8)) + + # Les deux semaines ont des cours sur le même créneau (10:00-11:00) + # mais avec des matières différentes (Anglais/Technologie vs Espagnol/Éducation musicale) + even_subjects = {lesson.subject for lesson in even_lessons} + odd_subjects = {lesson.subject for lesson in odd_lessons} + + # Les matières doivent être différentes + assert "Anglais" in even_subjects + assert "Espagnol" in odd_subjects + # Les IDs ne doivent pas être identiques + even_ids = {lesson.id for lesson in even_lessons} + odd_ids = {lesson.id for lesson in odd_lessons} + assert even_ids.isdisjoint(odd_ids) + + def test_explicit_id_preserved( + self, provider_no_parity_no_holidays: JsonTheoreticalAgendaProvider + ) -> None: + """Teste qu'un ID explicite est préservé. + + :assert: L'ID explicite "theoretical-maths-monday-1" est utilisé. + """ + # Lundi 2026-08-31 (weekday = 0) + target_date = date(2026, 8, 31) + lessons = provider_no_parity_no_holidays.get_lessons(target_date) + ids = [lesson.id for lesson in lessons] + assert "theoretical-maths-monday-1" in ids + + def test_generated_id_includes_week( + self, provider_no_parity_no_holidays: JsonTheoreticalAgendaProvider + ) -> None: + """Teste qu'un ID généré inclut le type de semaine. + + :assert: L'ID généré contient le type de semaine. + """ + # Lundi 2026-08-31 (weekday = 0) + target_date = date(2026, 8, 31) + lessons = provider_no_parity_no_holidays.get_lessons(target_date) + # Trouver un cours sans ID explicite (Français ou Histoire-Géographie) + for lesson in lessons: + if lesson.subject in ("Français", "Histoire-Géographie"): + assert "all" in lesson.id + break + + def test_no_parity_service_with_all_only_lessons(self, all_only_path: Path) -> None: + """Teste qu'un fournisseur sans service de parité fonctionne avec des cours "all". + + :assert: Les cours "all" sont retournés correctement. + """ + provider = JsonTheoreticalAgendaProvider( + file_path=str(all_only_path), + parity_service=None, + holiday_calendar=None, + ) + # Lundi 2026-08-31 (weekday = 0) + target_date = date(2026, 8, 31) + lessons = provider.get_lessons(target_date) + assert len(lessons) == 3 + + def test_even_lessons_without_parity_service_error( + self, tmp_path: Path, caplog: pytest.LogCaptureFixture + ) -> None: + """Teste qu'un fichier avec des cours even/odd sans service de parité lève une erreur. + + :assert: PronoteSyncError est levée. + """ + # Créer un fichier JSON avec uniquement des cours even/odd + data = { + "version": 1, + "lessons": [ + { + "week": "even", + "day_of_week": 0, + "start_time": "08:00", + "end_time": "09:00", + "subject": "Test", + } + ], + } + file_path = tmp_path / "even_only.json" + file_path.write_text(json.dumps(data), encoding="utf-8") + + with pytest.raises(PronoteSyncError) as exc_info: + JsonTheoreticalAgendaProvider( + file_path=str(file_path), + parity_service=None, + holiday_calendar=None, + ) + assert "ancre de parité" in str(exc_info.value) + + def test_invalid_json_file(self, tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: + """Teste qu'un fichier JSON invalide lève une PronoteSyncError. + + :assert: PronoteSyncError est levée pour un JSON invalide. + """ + file_path = tmp_path / "invalid.json" + file_path.write_text("{ invalid json }", encoding="utf-8") + + with pytest.raises(PronoteSyncError) as exc_info: + JsonTheoreticalAgendaProvider( + file_path=str(file_path), + parity_service=None, + holiday_calendar=None, + ) + assert "invalide" in str(exc_info.value) + + def test_get_lessons_for_range( + self, provider_no_parity_no_holidays: JsonTheoreticalAgendaProvider + ) -> None: + """Teste la récupération des cours pour une plage de 5 jours. + + :assert: Tous les cours uniques sont retournés, triés par ID. + """ + # Lundi 2026-08-31 à Vendredi 2026-09-04 + start_date = date(2026, 8, 31) # Lundi + end_date = date(2026, 9, 4) # Vendredi + lessons = provider_no_parity_no_holidays.get_lessons_for_range(start_date, end_date) + + # Le fichier all_only a des cours pour lundi (3), mercredi (1), vendredi (1) + # Total: 3 + 1 + 1 = 5 cours uniques + assert len(lessons) == 5 + ids = [lesson.id for lesson in lessons] + assert ids == sorted(ids) + + def test_get_lessons_for_range_with_holidays( + self, provider_full: JsonTheoreticalAgendaProvider + ) -> None: + """Teste la récupération des cours pour une plage incluant des jours de vacances. + + :assert: Les jours de vacances ne contribuent pas de cours. + """ + # Plage incluant des vacances de Toussaint (17 oct - 2 nov 2026) + # Le 15 oct 2026 est un mercredi (day_of_week=2), le fixture a un cours "all" pour mercredi + # Le 16 oct 2026 est un jeudi (day_of_week=3), pas de cours dans le fixture + # Le 17 oct 2026 est un vendredi (day_of_week=4), le fixture a un cours "all" pour vendredi + # Mais le 17 oct est le début des vacances, donc pas de cours + start_date = date(2026, 10, 15) # Mercredi (avant vacances) + end_date = date(2026, 10, 16) # Jeudi (avant vacances) + lessons = provider_full.get_lessons_for_range(start_date, end_date) + + # Le 15 oct (mercredi) devrait avoir un cours "all" pour Sciences + # Le 16 oct (jeudi) n'a pas de cours dans le fixture + # Donc on devrait avoir au moins 1 cours + assert len(lessons) >= 1 + + def test_get_lessons_for_range_empty( + self, provider_full: JsonTheoreticalAgendaProvider + ) -> None: + """Teste qu'une plage où tous les jours sont en vacances retourne une liste vide. + + :assert: get_lessons_for_range retourne [] pour une plage entièrement en vacances. + """ + # Plage entièrement pendant les vacances de Toussaint + start_date = date(2026, 10, 17) + end_date = date(2026, 10, 24) + lessons = provider_full.get_lessons_for_range(start_date, end_date) + assert lessons == [] + + def test_get_lessons_sunday( + self, provider_no_parity_no_holidays: JsonTheoreticalAgendaProvider + ) -> None: + """Teste qu'un dimanche retourne une liste vide. + + :assert: get_lessons retourne [] pour un dimanche. + """ + # Dimanche 2026-09-06 (weekday = 6) + target_date = date(2026, 9, 6) + lessons = provider_no_parity_no_holidays.get_lessons(target_date) + # Aucun cours n'est prévu pour le dimanche dans le fichier all_only + assert lessons == [] + + def test_get_lessons_for_range_dedup_across_weeks(self, tmp_path: Path) -> None: + """Vérifie que get_lessons_for_range déduplique les leçons récurrentes. + + :assert: Une leçon récurrente sur plusieurs semaines n'apparaît qu'une fois. + """ + # Créer un fichier JSON avec une seule leçon "all" le lundi 08:00-09:00 "Maths" + data = { + "version": 1, + "lessons": [ + { + "week": "all", + "day_of_week": 0, # Lundi + "start_time": "08:00", + "end_time": "09:00", + "subject": "Maths", + "teachers": [], + "rooms": [], + } + ], + } + file_path = tmp_path / "dedup_test.json" + file_path.write_text(json.dumps(data), encoding="utf-8") + + provider = JsonTheoreticalAgendaProvider( + file_path=str(file_path), + parity_service=None, + holiday_calendar=None, + ) + + # Plage du 31 août 2026 (lundi) au 11 septembre 2026 (vendredi) + # Cela couvre 2 lundis (31 août et 7 septembre) + start_date = date(2026, 8, 31) + end_date = date(2026, 9, 11) + lessons = provider.get_lessons_for_range(start_date, end_date) + + # La leçon récurrente devrait apparaître une seule fois + assert len(lessons) == 1 + assert lessons[0].subject == "Maths" + + def test_duplicate_explicit_ids_rejected(self, tmp_path: Path) -> None: + """Vérifie que les IDs explicites dupliqués sont rejetés. + + :assert: PronoteSyncError est levée pour des IDs explicites dupliqués. + """ + # Créer un fichier JSON avec 2 leçons ayant le même ID explicite + data = { + "version": 1, + "lessons": [ + { + "id": "dup-id", + "week": "all", + "day_of_week": 0, + "start_time": "08:00", + "end_time": "09:00", + "subject": "Maths", + "teachers": [], + "rooms": [], + }, + { + "id": "dup-id", + "week": "all", + "day_of_week": 1, + "start_time": "09:00", + "end_time": "10:00", + "subject": "Français", + "teachers": [], + "rooms": [], + }, + ], + } + file_path = tmp_path / "duplicate_ids.json" + file_path.write_text(json.dumps(data), encoding="utf-8") + + with pytest.raises(PronoteSyncError) as exc_info: + JsonTheoreticalAgendaProvider( + file_path=str(file_path), + parity_service=None, + holiday_calendar=None, + ) + assert "Conflit d'identifiant" in str(exc_info.value) or "identifiant" in str( + exc_info.value + ) + + def test_generated_id_collision_rejected(self, tmp_path: Path) -> None: + """Vérifie que les collisions d'IDs générés sont détectées. + + :assert: PronoteSyncError est levée pour des IDs générés identiques. + """ + # Créer un fichier JSON avec 2 leçons qui produisent le même ID généré + # Même week, day_of_week, start_time, end_time, subject mais enseignants différents + data = { + "version": 1, + "lessons": [ + { + "week": "all", + "day_of_week": 0, + "start_time": "08:00", + "end_time": "09:00", + "subject": "Maths", + "teachers": ["Prof1"], + "rooms": [], + }, + { + "week": "all", + "day_of_week": 0, + "start_time": "08:00", + "end_time": "09:00", + "subject": "Maths", + "teachers": ["Prof2"], + "rooms": [], + }, + ], + } + file_path = tmp_path / "generated_id_collision.json" + file_path.write_text(json.dumps(data), encoding="utf-8") + + with pytest.raises(PronoteSyncError) as exc_info: + JsonTheoreticalAgendaProvider( + file_path=str(file_path), + parity_service=None, + holiday_calendar=None, + ) + assert "Conflit d'identifiant" in str(exc_info.value) or "identifiant" in str( + exc_info.value + ) + + def test_get_lessons_for_range_mixed_holidays_and_normal(self, tmp_path: Path) -> None: + """Vérifie qu'une plage mixte (jours normaux et vacances) retourne les leçons des jours non vacanciers. + + :assert: Les leçons des jours non vacanciers sont retournées, les jours de vacances sont ignorés. + """ + # Créer un fichier JSON avec une leçon "all" le jeudi 14:00-15:00 "Sciences" + # Note: 2026-10-15 est un jeudi (weekday=3) + data = { + "version": 1, + "lessons": [ + { + "week": "all", + "day_of_week": 3, # Jeudi + "start_time": "14:00", + "end_time": "15:00", + "subject": "Sciences", + "teachers": [], + "rooms": [], + } + ], + } + file_path = tmp_path / "mixed_holidays.json" + file_path.write_text(json.dumps(data), encoding="utf-8") + + # Créer un fichier de vacances avec une période du 16 au 20 octobre 2026 + holidays_data = { + "zone": "A", + "school_year": "2026-2027", + "periods": [ + {"start_date": "2026-10-16", "end_date": "2026-10-20", "label": "Test Vacances"} + ], + } + holidays_path = tmp_path / "test_holidays.json" + holidays_path.write_text(json.dumps(holidays_data), encoding="utf-8") + + holiday_calendar = SchoolHolidayCalendar(holidays_path) + provider = JsonTheoreticalAgendaProvider( + file_path=str(file_path), + parity_service=None, + holiday_calendar=holiday_calendar, + ) + + # Plage du 15 octobre (jeudi, pas en vacances) au 17 octobre (samedi, en vacances) + start_date = date(2026, 10, 15) # Jeudi + end_date = date(2026, 10, 17) # Samedi + lessons = provider.get_lessons_for_range(start_date, end_date) + + # Seule la leçon du 15 octobre (jeudi) devrait être retournée + assert len(lessons) == 1 + assert lessons[0].subject == "Sciences"