Files
college-infos/tests/unit/test_diff.py
Antoine Van Elstraete 093253a41c test(M8): tests unitaires pour AgendaComparator (17 cas)
Couvre : agendas vides, ADDED/REMOVED/MODIFIED, tolérance ±15 min
(bord inclusif), normalisation NFKC des matières, matching multi-candidats
par plus petit id, comparaison ordre-insensible des enseignants/salles,
statut != NORMAL, REMOVED par existence (pas par sélection), ordre
déterministe et idempotence. Couverture de sync/diff.py : 92%.

Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
2026-09-07 13:50:56 +02:00

641 lines
21 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Unit tests for AgendaComparator in pronote_sync/sync/diff.py."""
from __future__ import annotations
from datetime import date, datetime, time
from typing import override
import pytest
from pronote_sync.models.agenda import Lesson, LessonStatus, TheoreticalLesson
from pronote_sync.models.diff import AgendaChangeType
from pronote_sync.sources.theoretical.provider import TheoreticalAgendaProvider
from pronote_sync.sync.diff import AgendaComparator
class _StubProvider(TheoreticalAgendaProvider):
"""Stub implementation of TheoreticalAgendaProvider for testing."""
def __init__(self, lessons: list[TheoreticalLesson]) -> None:
"""Initialize with a fixed list of theoretical lessons."""
self._lessons = lessons
@override
def get_lessons(self, target_date: date) -> list[TheoreticalLesson]:
"""Return the stub lessons regardless of target_date."""
return self._lessons.copy()
@override
def get_lessons_for_range(self, start_date: date, end_date: date) -> list[TheoreticalLesson]:
"""Return the stub lessons regardless of date range."""
return self._lessons.copy()
# Target date: Monday, 2025-09-15 (weekday() = 0)
TARGET_DATE = date(2025, 9, 15)
@pytest.fixture(name="empty_provider")
def fixture_empty_provider() -> _StubProvider:
"""Provider with no theoretical lessons."""
return _StubProvider([])
@pytest.fixture(name="comparator")
def fixture_comparator(empty_provider: _StubProvider) -> AgendaComparator:
"""AgendaComparator with empty provider."""
return AgendaComparator(empty_provider)
# ==================== Test Case 1: Empty agendas ====================
def test_empty_agendas(comparator: AgendaComparator) -> None:
"""No real, no theoretical → AgendaDiff with empty changes."""
result = comparator.compare([], TARGET_DATE)
assert result.target_date == TARGET_DATE
assert result.changes == ()
# ==================== Test Case 2: Empty theoretical, real lessons present ====================
def test_empty_theoretical_real_present(comparator: AgendaComparator) -> None:
"""Empty theoretical, real lessons present → all real → ADDED."""
real_lessons = [
Lesson(
id="real_1",
start=datetime(2025, 9, 15, 10, 0, 0),
end=datetime(2025, 9, 15, 11, 0, 0),
subject="Mathématiques",
group=None,
content=None,
),
Lesson(
id="real_2",
start=datetime(2025, 9, 15, 14, 0, 0),
end=datetime(2025, 9, 15, 15, 0, 0),
subject="Français",
group=None,
content=None,
),
]
result = comparator.compare(real_lessons, TARGET_DATE)
assert len(result.changes) == 2
assert result.changes[0].type == AgendaChangeType.ADDED
assert result.changes[0].lesson == real_lessons[0]
assert result.changes[0].theoretical_lesson is None
assert result.changes[0].details == "Cours ajouté par rapport à l'agenda théorique"
assert result.changes[1].type == AgendaChangeType.ADDED
assert result.changes[1].lesson == real_lessons[1]
# ==================== Test Case 3: Empty real, theoretical present ====================
def test_empty_real_theoretical_present() -> None:
"""Empty real, theoretical present → all theoretical → REMOVED, sorted by id."""
theoretical_lessons = [
TheoreticalLesson(
id="theo_b",
day_of_week=0,
start_time=time(10, 0),
end_time=time(11, 0),
subject="Mathématiques",
),
TheoreticalLesson(
id="theo_a",
day_of_week=0,
start_time=time(14, 0),
end_time=time(15, 0),
subject="Français",
),
]
provider = _StubProvider(theoretical_lessons)
comparator = AgendaComparator(provider)
result = comparator.compare([], TARGET_DATE)
assert len(result.changes) == 2
assert result.changes[0].type == AgendaChangeType.REMOVED
assert result.changes[0].theoretical_lesson == theoretical_lessons[1] # theo_a first
assert result.changes[0].lesson is None
assert result.changes[0].details == "Cours supprimé par rapport à l'agenda théorique"
assert result.changes[1].type == AgendaChangeType.REMOVED
assert result.changes[1].theoretical_lesson == theoretical_lessons[0] # theo_b second
# ==================== Test Case 4: Exact match ====================
def test_exact_match() -> None:
"""Real and theoretical at same time, same subject → no changes."""
theoretical_lessons = [
TheoreticalLesson(
id="theo_1",
day_of_week=0,
start_time=time(10, 0),
end_time=time(11, 0),
subject="Mathématiques",
),
]
real_lessons = [
Lesson(
id="real_1",
start=datetime(2025, 9, 15, 10, 0, 0),
end=datetime(2025, 9, 15, 11, 0, 0),
subject="Mathématiques",
group=None,
content=None,
),
]
provider = _StubProvider(theoretical_lessons)
comparator = AgendaComparator(provider)
result = comparator.compare(real_lessons, TARGET_DATE)
assert result.changes == ()
# ==================== Test Case 5: Within tolerance (±14 min) ====================
def test_within_tolerance_14min() -> None:
"""Real start 14 min before theoretical → match, MODIFIED (horaires different)."""
theoretical_lessons = [
TheoreticalLesson(
id="theo_1",
day_of_week=0,
start_time=time(10, 0),
end_time=time(11, 0),
subject="Mathématiques",
),
]
real_lessons = [
Lesson(
id="real_1",
start=datetime(2025, 9, 15, 9, 46, 0),
end=datetime(2025, 9, 15, 10, 46, 0),
subject="Mathématiques",
group=None,
content=None,
),
]
provider = _StubProvider(theoretical_lessons)
comparator = AgendaComparator(provider)
result = comparator.compare(real_lessons, TARGET_DATE)
assert len(result.changes) == 1
assert result.changes[0].type == AgendaChangeType.MODIFIED
assert result.changes[0].lesson == real_lessons[0]
assert result.changes[0].theoretical_lesson == theoretical_lessons[0]
assert "horaires: 10:0011:00 → 09:4610:46" in result.changes[0].details
# ==================== Test Case 6: At tolerance boundary (exactly 15 min) ====================
def test_at_tolerance_boundary_15min() -> None:
"""Real start exactly 15 min from theoretical → match (inclusive), MODIFIED."""
theoretical_lessons = [
TheoreticalLesson(
id="theo_1",
day_of_week=0,
start_time=time(10, 0),
end_time=time(11, 0),
subject="Mathématiques",
),
]
real_lessons = [
Lesson(
id="real_1",
start=datetime(2025, 9, 15, 9, 45, 0),
end=datetime(2025, 9, 15, 10, 45, 0),
subject="Mathématiques",
group=None,
content=None,
),
]
provider = _StubProvider(theoretical_lessons)
comparator = AgendaComparator(provider)
result = comparator.compare(real_lessons, TARGET_DATE)
assert len(result.changes) == 1
assert result.changes[0].type == AgendaChangeType.MODIFIED
assert result.changes[0].lesson == real_lessons[0]
assert result.changes[0].theoretical_lesson == theoretical_lessons[0]
assert "horaires: 10:0011:00 → 09:4510:45" in result.changes[0].details
# ==================== Test Case 7: Outside tolerance (16 min) ====================
def test_outside_tolerance_16min() -> None:
"""Real start 16 min from theoretical → no match → ADDED + REMOVED."""
theoretical_lessons = [
TheoreticalLesson(
id="theo_1",
day_of_week=0,
start_time=time(10, 0),
end_time=time(11, 0),
subject="Mathématiques",
),
]
real_lessons = [
Lesson(
id="real_1",
start=datetime(2025, 9, 15, 9, 44, 0),
end=datetime(2025, 9, 15, 10, 44, 0),
subject="Mathématiques",
group=None,
content=None,
),
]
provider = _StubProvider(theoretical_lessons)
comparator = AgendaComparator(provider)
result = comparator.compare(real_lessons, TARGET_DATE)
assert len(result.changes) == 2
assert result.changes[0].type == AgendaChangeType.ADDED
assert result.changes[0].lesson == real_lessons[0]
assert result.changes[1].type == AgendaChangeType.REMOVED
assert result.changes[1].theoretical_lesson == theoretical_lessons[0]
# ==================== Test Case 8: Subject normalization match ====================
def test_subject_normalization_match() -> None:
"""Real '\\u212BNGSTRÖM' (angstrom sign), theoretical 'ångström' → NFKC → same form, match, no change."""
theoretical_lessons = [
TheoreticalLesson(
id="theo_1",
day_of_week=0,
start_time=time(10, 0),
end_time=time(11, 0),
subject="ångström",
),
]
real_lessons = [
Lesson(
id="real_1",
start=datetime(2025, 9, 15, 10, 0, 0),
end=datetime(2025, 9, 15, 11, 0, 0),
subject="\u212bNGSTRÖM",
group=None,
content=None,
),
]
provider = _StubProvider(theoretical_lessons)
comparator = AgendaComparator(provider)
result = comparator.compare(real_lessons, TARGET_DATE)
assert result.changes == ()
# ==================== Test Case 9: Different normalized subjects ====================
def test_different_normalized_subjects() -> None:
"""Real 'Mathématiques', theoretical 'Français' → no match → ADDED + REMOVED."""
theoretical_lessons = [
TheoreticalLesson(
id="theo_1",
day_of_week=0,
start_time=time(10, 0),
end_time=time(11, 0),
subject="Français",
),
]
real_lessons = [
Lesson(
id="real_1",
start=datetime(2025, 9, 15, 10, 0, 0),
end=datetime(2025, 9, 15, 11, 0, 0),
subject="Mathématiques",
group=None,
content=None,
),
]
provider = _StubProvider(theoretical_lessons)
comparator = AgendaComparator(provider)
result = comparator.compare(real_lessons, TARGET_DATE)
assert len(result.changes) == 2
assert result.changes[0].type == AgendaChangeType.ADDED
assert result.changes[0].lesson == real_lessons[0]
assert result.changes[1].type == AgendaChangeType.REMOVED
assert result.changes[1].theoretical_lesson == theoretical_lessons[0]
# ==================== Test Case 10: Multi-candidate selection by id ====================
def test_multi_candidate_selection_by_id() -> None:
"""Two theoretical candidates match one real → select the smaller id (theo_a).
theo_a (smaller id) has teachers identical to the real lesson (no MODIFIED);
theo_b (larger id) has different teachers and would trigger MODIFIED if selected.
A zero-change diff therefore proves theo_a was selected.
"""
theoretical_lessons = [
TheoreticalLesson(
id="theo_b",
day_of_week=0,
start_time=time(10, 0),
end_time=time(11, 0),
subject="Mathématiques",
teachers=("Mme Martin",),
),
TheoreticalLesson(
id="theo_a",
day_of_week=0,
start_time=time(10, 0),
end_time=time(11, 0),
subject="Mathématiques",
teachers=("M. Dupont",),
),
]
real_lessons = [
Lesson(
id="real_1",
start=datetime(2025, 9, 15, 10, 0, 0),
end=datetime(2025, 9, 15, 11, 0, 0),
subject="Mathématiques",
teachers=("M. Dupont",),
group=None,
content=None,
),
]
provider = _StubProvider(theoretical_lessons)
comparator = AgendaComparator(provider)
result = comparator.compare(real_lessons, TARGET_DATE)
# theo_a (smaller id) selected with identical teachers → no MODIFIED; theo_b matched by existence → not REMOVED
assert result.changes == ()
# ==================== Test Case 11: MODIFIED — teachers differ (order-insensitive) ====================
def test_teachers_differ_order_insensitive() -> None:
"""Real teachers ('M. Dupont', 'Mme Martin'), theoretical ('Mme Martin', 'M. Dupont') → match, NOT modified (same set)."""
theoretical_lessons = [
TheoreticalLesson(
id="theo_1",
day_of_week=0,
start_time=time(10, 0),
end_time=time(11, 0),
subject="Mathématiques",
teachers=("Mme Martin", "M. Dupont"),
),
]
real_lessons = [
Lesson(
id="real_1",
start=datetime(2025, 9, 15, 10, 0, 0),
end=datetime(2025, 9, 15, 11, 0, 0),
subject="Mathématiques",
teachers=("M. Dupont", "Mme Martin"),
group=None,
content=None,
),
]
provider = _StubProvider(theoretical_lessons)
comparator = AgendaComparator(provider)
result = comparator.compare(real_lessons, TARGET_DATE)
assert result.changes == ()
# ==================== Test Case 12: MODIFIED — teachers differ (different sets) ====================
def test_teachers_differ_different_sets() -> None:
"""Real ('M. Dupont',), theoretical ('Mme Martin',) → match, MODIFIED."""
theoretical_lessons = [
TheoreticalLesson(
id="theo_1",
day_of_week=0,
start_time=time(10, 0),
end_time=time(11, 0),
subject="Mathématiques",
teachers=("Mme Martin",),
),
]
real_lessons = [
Lesson(
id="real_1",
start=datetime(2025, 9, 15, 10, 0, 0),
end=datetime(2025, 9, 15, 11, 0, 0),
subject="Mathématiques",
teachers=("M. Dupont",),
group=None,
content=None,
),
]
provider = _StubProvider(theoretical_lessons)
comparator = AgendaComparator(provider)
result = comparator.compare(real_lessons, TARGET_DATE)
assert len(result.changes) == 1
assert result.changes[0].type == AgendaChangeType.MODIFIED
assert "professeurs: {'Mme Martin'} → {'M. Dupont'}" in result.changes[0].details
# ==================== Test Case 13: MODIFIED — rooms differ ====================
def test_rooms_differ() -> None:
"""Real ('Salle 12',), theoretical ('Salle 15',) → match, MODIFIED."""
theoretical_lessons = [
TheoreticalLesson(
id="theo_1",
day_of_week=0,
start_time=time(10, 0),
end_time=time(11, 0),
subject="Mathématiques",
rooms=("Salle 15",),
),
]
real_lessons = [
Lesson(
id="real_1",
start=datetime(2025, 9, 15, 10, 0, 0),
end=datetime(2025, 9, 15, 11, 0, 0),
subject="Mathématiques",
rooms=("Salle 12",),
group=None,
content=None,
),
]
provider = _StubProvider(theoretical_lessons)
comparator = AgendaComparator(provider)
result = comparator.compare(real_lessons, TARGET_DATE)
assert len(result.changes) == 1
assert result.changes[0].type == AgendaChangeType.MODIFIED
assert "salles: {'Salle 15'} → {'Salle 12'}" in result.changes[0].details
# ==================== Test Case 14: MODIFIED — status != NORMAL ====================
def test_status_not_normal() -> None:
"""Real status=CANCELLED, otherwise identical → match, MODIFIED with statut in details."""
theoretical_lessons = [
TheoreticalLesson(
id="theo_1",
day_of_week=0,
start_time=time(10, 0),
end_time=time(11, 0),
subject="Mathématiques",
),
]
real_lessons = [
Lesson(
id="real_1",
start=datetime(2025, 9, 15, 10, 0, 0),
end=datetime(2025, 9, 15, 11, 0, 0),
subject="Mathématiques",
status=LessonStatus.CANCELLED,
group=None,
content=None,
),
]
provider = _StubProvider(theoretical_lessons)
comparator = AgendaComparator(provider)
result = comparator.compare(real_lessons, TARGET_DATE)
assert len(result.changes) == 1
assert result.changes[0].type == AgendaChangeType.MODIFIED
assert "statut: cancelled" in result.changes[0].details
# ==================== Test Case 15: REMOVED by existence, not selection ====================
def test_removed_by_existence_not_selection() -> None:
"""Two theoretical match one real; real selects the smaller id; the other theoretical is a candidate (exists) → NOT REMOVED."""
theoretical_lessons = [
TheoreticalLesson(
id="theo_a",
day_of_week=0,
start_time=time(10, 0),
end_time=time(11, 0),
subject="Mathématiques",
),
TheoreticalLesson(
id="theo_b",
day_of_week=0,
start_time=time(10, 0),
end_time=time(11, 0),
subject="Mathématiques",
),
]
real_lessons = [
Lesson(
id="real_1",
start=datetime(2025, 9, 15, 10, 0, 0),
end=datetime(2025, 9, 15, 11, 0, 0),
subject="Mathématiques",
group=None,
content=None,
),
]
provider = _StubProvider(theoretical_lessons)
comparator = AgendaComparator(provider)
result = comparator.compare(real_lessons, TARGET_DATE)
# Both theoretical lessons are candidates (matched by existence), so neither is REMOVED
assert len(result.changes) == 0
# ==================== Test Case 16: Deterministic order ====================
def test_deterministic_order() -> None:
"""Multiple ADDED, MODIFIED, REMOVED in same run → verify exact order (reals in input order, then theoreticals sorted by id)."""
theoretical_lessons = [
TheoreticalLesson(
id="theo_c",
day_of_week=0,
start_time=time(15, 0),
end_time=time(16, 0),
subject="Histoire",
),
TheoreticalLesson(
id="theo_a",
day_of_week=0,
start_time=time(8, 0),
end_time=time(9, 0),
subject="Physique",
),
TheoreticalLesson(
id="theo_b",
day_of_week=0,
start_time=time(10, 0),
end_time=time(11, 0),
subject="Mathématiques",
),
]
real_lessons = [
Lesson(
id="real_1",
start=datetime(2025, 9, 15, 10, 0, 0),
end=datetime(2025, 9, 15, 11, 0, 0),
subject="Mathématiques",
teachers=("M. Dupont",),
group=None,
content=None,
),
Lesson(
id="real_2",
start=datetime(2025, 9, 15, 14, 0, 0),
end=datetime(2025, 9, 15, 15, 0, 0),
subject="Informatique",
group=None,
content=None,
),
]
provider = _StubProvider(theoretical_lessons)
comparator = AgendaComparator(provider)
result = comparator.compare(real_lessons, TARGET_DATE)
# real_1 matches theo_b but has different teachers → MODIFIED
# real_2 has no match → ADDED
# theo_a and theo_c are not matched by existence → REMOVED (sorted by id: theo_a, theo_c)
assert len(result.changes) == 4
# First: real_1 MODIFIED
assert result.changes[0].type == AgendaChangeType.MODIFIED
assert result.changes[0].lesson == real_lessons[0]
# Second: real_2 ADDED
assert result.changes[1].type == AgendaChangeType.ADDED
assert result.changes[1].lesson == real_lessons[1]
# Third: theo_a REMOVED
assert result.changes[2].type == AgendaChangeType.REMOVED
assert result.changes[2].theoretical_lesson == theoretical_lessons[1] # theo_a
# Fourth: theo_c REMOVED
assert result.changes[3].type == AgendaChangeType.REMOVED
assert result.changes[3].theoretical_lesson == theoretical_lessons[0] # theo_c
# ==================== Test Case 17: Idempotence ====================
def test_idempotence() -> None:
"""Call compare twice with same inputs → identical AgendaDiff."""
theoretical_lessons = [
TheoreticalLesson(
id="theo_1",
day_of_week=0,
start_time=time(10, 0),
end_time=time(11, 0),
subject="Mathématiques",
),
]
real_lessons = [
Lesson(
id="real_1",
start=datetime(2025, 9, 15, 10, 0, 0),
end=datetime(2025, 9, 15, 11, 0, 0),
subject="Mathématiques",
group=None,
content=None,
),
]
provider = _StubProvider(theoretical_lessons)
comparator = AgendaComparator(provider)
result1 = comparator.compare(real_lessons, TARGET_DATE)
result2 = comparator.compare(real_lessons, TARGET_DATE)
assert result1 == result2