feat(M6): agenda théorique JSON avec parité des semaines et vacances scolaires
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 <coder@agents.invalid> Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
This commit is contained in:
146
tests/unit/test_theoretical_factory.py
Normal file
146
tests/unit/test_theoretical_factory.py
Normal file
@@ -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)
|
||||
188
tests/unit/test_theoretical_holidays.py
Normal file
188
tests/unit/test_theoretical_holidays.py
Normal file
@@ -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
|
||||
201
tests/unit/test_theoretical_model.py
Normal file
201
tests/unit/test_theoretical_model.py
Normal file
@@ -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
|
||||
116
tests/unit/test_theoretical_parity.py
Normal file
116
tests/unit/test_theoretical_parity.py
Normal file
@@ -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"
|
||||
591
tests/unit/test_theoretical_provider.py
Normal file
591
tests/unit/test_theoretical_provider.py
Normal file
@@ -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"
|
||||
Reference in New Issue
Block a user