Corrige les 5 points de l'audit FIXME_M6 : 1. Normalisation des matières : fonction normalize_subject (NFKC + unification des espaces + suppression ponctuation + minuscule) partagée par la génération d'ID et le futur comparateur M8. 2. Expurgation du secret dans l'erreur de collision d'IDs : redact_secrets enveloppe l'identifiant dans le message. 3. Test even/odd avec même matière pour isoler la parité comme seul différenciateur d'ID ; tests de normalisation (casse, espaces, Unicode) ; test de non-fuite de secret. 4. TODO.md M6 : 8 items cochés après validation. 5. GUIDE_DEV §8.4 : bloc de code corrigé (clôture, types Lesson/ TheoreticalLesson, comparaison des horaires en minutes, début ET fin, référence à normalize_subject). Co-authored-by: opencode/coder <coder@agents.invalid> Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
723 lines
29 KiB
Python
723 lines
29 KiB
Python
"""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, normalize_subject
|
|
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_different_subjects_different_ids(
|
|
self, provider_with_parity: JsonTheoreticalAgendaProvider
|
|
) -> None:
|
|
"""Teste que les cours even/odd sur le même créneau avec des matières différentes 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_even_odd_same_subject_different_ids(self, tmp_path: Path) -> None:
|
|
"""Vérifie que les leçons paire/impaire sur le même créneau avec la même matière ont des IDs distincts.
|
|
|
|
Utilise la même matière pour isoler la parité comme seul différenciateur.
|
|
|
|
:assert: Les IDs des cours even et odd sont différents, avec la même matière.
|
|
"""
|
|
json_content = json.dumps(
|
|
{
|
|
"version": 1,
|
|
"lessons": [
|
|
{
|
|
"week": "even",
|
|
"day_of_week": 1,
|
|
"start_time": "10:00",
|
|
"end_time": "11:00",
|
|
"subject": "Langue vivante",
|
|
"teachers": [],
|
|
"rooms": [],
|
|
},
|
|
{
|
|
"week": "odd",
|
|
"day_of_week": 1,
|
|
"start_time": "10:00",
|
|
"end_time": "11:00",
|
|
"subject": "Langue vivante",
|
|
"teachers": [],
|
|
"rooms": [],
|
|
},
|
|
],
|
|
}
|
|
)
|
|
file_path = tmp_path / "theoretical.json"
|
|
file_path.write_text(json_content, encoding="utf-8")
|
|
|
|
anchor_date = date(2026, 9, 1) # Tuesday
|
|
parity = WeekParityService(anchor_date, "even")
|
|
provider = JsonTheoreticalAgendaProvider(file_path=str(file_path), parity_service=parity)
|
|
|
|
# Tuesday in even week
|
|
even_tuesday = date(2026, 9, 1) # Same week as anchor (even)
|
|
odd_tuesday = date(2026, 9, 8) # One week later (odd)
|
|
|
|
even_lessons = provider.get_lessons(even_tuesday)
|
|
odd_lessons = provider.get_lessons(odd_tuesday)
|
|
|
|
assert len(even_lessons) == 1
|
|
assert len(odd_lessons) == 1
|
|
assert even_lessons[0].id != odd_lessons[0].id
|
|
# The only difference in the ID should be the week type
|
|
assert "even" in even_lessons[0].id
|
|
assert "odd" in odd_lessons[0].id
|
|
|
|
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"
|
|
|
|
def test_normalize_subject_variants_produce_same_id(self, tmp_path: Path) -> None:
|
|
"""Vérifie que des variantes de casse, d'espacement et d'Unicode produisent le même ID.
|
|
|
|
:assert: Les variantes de la même matière produisent le même ID normalisé.
|
|
"""
|
|
# Test the normalize_subject function directly
|
|
# Note: hyphens are removed entirely (not replaced with spaces) by normalize_subject
|
|
variants = [
|
|
"Mathématiques avancées",
|
|
"mathématiques avancées",
|
|
"Mathématiques avancées",
|
|
"MATHÉMATIQUES AVANCÉES",
|
|
]
|
|
normalized = [normalize_subject(variant) for variant in variants]
|
|
# All should normalize to the same value
|
|
assert all(n == normalized[0] for n in normalized)
|
|
# Should be lowercase, no extra spaces, no punctuation
|
|
assert normalized[0] == "mathématiques avancées"
|
|
|
|
def test_collision_error_no_secret_leak(
|
|
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
"""Vérifie qu'une sentinelle dans un ID dupliqué n'apparaît pas dans l'erreur.
|
|
|
|
:assert: PronoteSyncError est levée et la sentinelle n'apparaît pas dans l'erreur.
|
|
"""
|
|
# Use a secret pattern that the redaction system will actually catch
|
|
# The _ISOLATED_SECRET_PATTERN looks for things like secret=value or secret: value
|
|
sentinel_value = "SENTINELLE_M6_SECRET"
|
|
# Create an ID that contains a pattern like "secret=value" which will be redacted
|
|
secret_pattern = f"secret={sentinel_value}"
|
|
|
|
# Créer un fichier JSON avec 2 entrées ayant le même ID explicite contenant la sentinelle
|
|
data = {
|
|
"version": 1,
|
|
"lessons": [
|
|
{
|
|
"id": f"theoretical-{secret_pattern}-1",
|
|
"week": "all",
|
|
"day_of_week": 0,
|
|
"start_time": "08:00",
|
|
"end_time": "09:00",
|
|
"subject": "Maths",
|
|
"teachers": [],
|
|
"rooms": [],
|
|
},
|
|
{
|
|
"id": f"theoretical-{secret_pattern}-1",
|
|
"week": "all",
|
|
"day_of_week": 1,
|
|
"start_time": "09:00",
|
|
"end_time": "10:00",
|
|
"subject": "Français",
|
|
"teachers": [],
|
|
"rooms": [],
|
|
},
|
|
],
|
|
}
|
|
file_path = tmp_path / "collision_test.json"
|
|
file_path.write_text(json.dumps(data), encoding="utf-8")
|
|
|
|
with caplog.at_level("ERROR"):
|
|
with pytest.raises(PronoteSyncError) as exc_info:
|
|
JsonTheoreticalAgendaProvider(
|
|
file_path=str(file_path),
|
|
parity_service=None,
|
|
holiday_calendar=None,
|
|
)
|
|
# Check that the sentinel value does not appear in the error message
|
|
assert sentinel_value not in str(exc_info.value)
|
|
# Check that the sentinel value does not appear in the logs
|
|
assert sentinel_value not in caplog.text
|
|
# Check that the secret pattern was redacted (should contain REDACTED)
|
|
assert "REDACTED" in str(exc_info.value)
|
|
# Check that the sentinel does not appear in the cause
|
|
if exc_info.value.__cause__ is not None:
|
|
assert sentinel_value not in repr(exc_info.value.__cause__)
|