"""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__)