Files
college-infos/tests/unit/test_models_construction.py
Antoine Van Elstraete 5907c9aeaf fix(M8): corrections d'audit FIXME_M8 — appariement, date, déterminisme, validateur
Quatre corrections bloquantes/majeures de l'audit FIXME_M8 :
- Appariement un-à-un déterministe (consommation du candidat sélectionné) ;
  1 réel / 2 théoriques → 1 REMOVED, 2 réels / 1 théorique → 1 ADDED.
- Filtrage strict par date : les cours réels hors target_date sont exclus
  du matching avec un warning logé (décision architecte : pas d'exception).
- Déterminisme des détails : formatage via sorted(set(...)) au lieu de
  set(...) brut, indépendant de PYTHONHASHSEED.
- Validateur AgendaChange strict : ADDED = lesson seule, REMOVED =
  theoretical_lesson seule, MODIFIED = les deux requis.
- Comparaison à la minute près dans _is_modified (cohérent avec _matches).
- Documentation §8.4/§8.5 alignée avec l'implémentation (tolérance 15 min,
  API compare(), normalize_subject référencé, appariement consommé).

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/tech-writer <tech-writer@agents.invalid>
2026-09-07 15:59:17 +02:00

388 lines
13 KiB
Python

"""Tests de construction et sérialisation JSON des modèles Pydantic.
Ce module vérifie que chaque modèle figé peut être sérialisé en JSON puis désérialisé
sans perte de données, garantissant la stabilité des contrats d'échange.
Les tests utilisent des données minimales valides extraites des définitions de modèles.
"""
from __future__ import annotations
from datetime import date, datetime, time
from typing import Any
import pytest
from pydantic import BaseModel
from pronote_sync.models.agenda import (
HomeworkBlock,
Lesson,
LessonStatus,
SchoolEvent,
SchoolEventKind,
TheoreticalLesson,
)
from pronote_sync.models.blog import BlogArticle, ExternalInfo
from pronote_sync.models.diff import AgendaChange, AgendaChangeType, AgendaDiff
from pronote_sync.models.homework import Homework
from pronote_sync.models.message import Message, MessageType
from pronote_sync.models.xmpp import XmppMessage
@pytest.mark.parametrize(
"model_class, kwargs",
[
(
Lesson,
{
"id": "lesson-001",
"start": datetime(2024, 9, 6, 8, 0, 0),
"end": datetime(2024, 9, 6, 9, 30, 0),
"subject": "Mathématiques",
"teachers": ("M. Dupont",),
"rooms": ("Salle 101",),
"group": "Classe A",
"status": LessonStatus.NORMAL,
"content": "Algèbre linéaire",
"homework_blocks": (
HomeworkBlock(kind="due", date=date(2024, 9, 13), text="Exercice 42"),
),
},
),
(
TheoreticalLesson,
{
"id": "theo-lesson-001",
"day_of_week": 0, # Lundi
"start_time": time(8, 0, 0),
"end_time": time(9, 30, 0),
"subject": "Mathématiques",
"teachers": ("M. Dupont",),
"rooms": ("Salle 101",),
},
),
(
SchoolEvent,
{
"kind": SchoolEventKind.HOLIDAY,
"label": "Vacances de la Toussaint",
"from_date": date(2024, 10, 19),
"to_date": date(2024, 11, 3),
},
),
(
Homework,
{
"id": "hw-001",
"subject": "Mathématiques",
"teachers": ("M. Dupont",),
"assigned_on": date(2024, 9, 1),
"due_on": date(2024, 9, 15),
"text": "Exercice 42 à rendre",
"html": "<p>Exercice 42 à rendre</p>",
},
),
(
AgendaChange,
{
"type": AgendaChangeType.ADDED,
"lesson": Lesson(
id="lesson-002",
start=datetime(2024, 9, 7, 10, 0, 0),
end=datetime(2024, 9, 7, 11, 30, 0),
subject="Physique",
teachers=("M. Martin",),
rooms=("Labo 205",),
group=None,
content=None,
),
"theoretical_lesson": None,
"details": "Cours ajouté exceptionnellement",
},
),
(
AgendaDiff,
{
"target_date": date(2024, 9, 6),
"changes": (
AgendaChange(
type=AgendaChangeType.ADDED,
lesson=Lesson(
id="lesson-003",
start=datetime(2024, 9, 8, 14, 0, 0),
end=datetime(2024, 9, 8, 15, 30, 0),
subject="Histoire",
teachers=("Mme Bernard",),
rooms=("Salle 201",),
group=None,
content=None,
),
theoretical_lesson=None,
),
),
},
),
(
XmppMessage,
{
"target_date": date(2024, 9, 6),
"synthesis": "Aucun changement majeur aujourd'hui.",
"homeworks": (
Homework(
id="hw-002",
subject="Physique",
teachers=("M. Martin",),
assigned_on=None,
due_on=date(2024, 9, 7),
text="TP à préparer",
),
),
"changes": (
AgendaChange(
type=AgendaChangeType.MODIFIED,
lesson=Lesson(
id="lesson-004",
start=datetime(2024, 9, 6, 16, 0, 0),
end=datetime(2024, 9, 6, 17, 30, 0),
subject="SVT",
teachers=("M. Lefèvre",),
rooms=("Salle 302",),
group=None,
content=None,
),
theoretical_lesson=TheoreticalLesson(
id="theo-lesson-004",
day_of_week=4,
start_time=time(16, 0, 0),
end_time=time(17, 30, 0),
subject="SVT",
teachers=("M. Lefèvre",),
rooms=("Salle 302",),
),
),
),
"messages": (
Message(
id="msg-001",
type=MessageType.INFORMATION,
title="Rappel",
content="Réunion parents-professeurs demain",
author="Secrétariat",
date=datetime(2024, 9, 5, 10, 0, 0),
read=False,
),
),
"external_info": ExternalInfo(blog_articles=(), pronote_messages=(), other_info=()),
},
),
(
BlogArticle,
{
"id": "article-001",
"title": "Sortie scolaire à Paris",
"url": "https://college.fr/blog/sortie-paris",
"published_at": datetime(2024, 9, 5, 14, 30, 0),
"updated_at": datetime(2024, 9, 5, 16, 45, 0),
"category": "Actualités",
"author": "M. Dubois",
"content_html": "<p>Sortie prévue le 15 octobre</p>",
"content_text": "Sortie prévue le 15 octobre",
},
),
(
ExternalInfo,
{
"blog_articles": (
BlogArticle(
id="article-002",
title="Nouveau règlement intérieur",
url="https://college.fr/blog/reglement",
published_at=datetime(2024, 9, 1, 9, 0, 0),
updated_at=None,
category=None,
author=None,
content_html="<p>Mise à jour du règlement</p>",
content_text="Mise à jour du règlement",
),
),
"pronote_messages": (
Message(
id="msg-002",
type=MessageType.DISCUSSION,
title="Devoirs de maths",
content="Voir Pronote pour les détails",
author="Prof Maths",
date=datetime(2024, 9, 4, 8, 0, 0),
read=False,
),
),
"other_info": ("Info complémentaire 1", "Info complémentaire 2"),
},
),
(
Message,
{
"id": "msg-003",
"type": MessageType.SURVEY,
"title": "Enquête de satisfaction",
"content": "Merci de répondre au questionnaire",
"author": "Direction",
"date": datetime(2024, 9, 5, 11, 0, 0),
"read": False,
},
),
],
)
def test_model_json_roundtrip(model_class: type[BaseModel], kwargs: dict[str, Any]) -> None:
"""Vérifie qu'un modèle peut être sérialisé en JSON puis désérialisé sans perte.
:param model_class: Classe du modèle à tester.
:type model_class: type[BaseModel]
:param kwargs: Arguments de constructeur valides.
:type kwargs: dict[str, Any]
"""
# Construction de l'instance
instance = model_class(**kwargs)
# Sérialisation JSON
json_str = instance.model_dump_json()
# Désérialisation depuis JSON
roundtrip_instance = model_class.model_validate_json(json_str)
# Vérification des champs clés (comparaison par valeur)
for field_name in kwargs:
if field_name in (
"start",
"end",
"start_time",
"end_time",
"published_at",
"updated_at",
"date",
"due_on",
"assigned_on",
"generated_at",
):
# Pour les dates/heures, comparer les objets directement
assert getattr(instance, field_name) == getattr(roundtrip_instance, field_name), (
f"Le champ {field_name} ne correspond pas après round-trip"
)
else:
# Pour les autres champs, comparer directement
assert getattr(instance, field_name) == getattr(roundtrip_instance, field_name), (
f"Le champ {field_name} ne correspond pas après round-trip"
)
def test_lesson_status_enum_values() -> None:
"""Vérifie que les valeurs de l'enum LessonStatus sont correctes.
:raises ValidationError: Si une valeur invalide est utilisée.
"""
# Test des valeurs valides
for status in [LessonStatus.NORMAL, LessonStatus.CANCELLED, LessonStatus.MOVED]:
assert isinstance(status.value, str)
Lesson(
id="test",
start=datetime(2024, 9, 6, 8, 0),
end=datetime(2024, 9, 6, 9, 0),
subject="Test",
status=status,
group=None,
content=None,
)
def test_school_event_kind_enum_values() -> None:
"""Vérifie que les valeurs de l'enum SchoolEventKind sont correctes.
:raises ValidationError: Si une valeur invalide est utilisée.
"""
# Test des valeurs valides
for kind in [SchoolEventKind.HOLIDAY, SchoolEventKind.PUBLIC_HOLIDAY]:
assert isinstance(kind.value, str)
SchoolEvent(
kind=kind,
label="Test",
from_date=date(2024, 9, 1),
to_date=date(2024, 9, 2),
)
def test_message_type_enum_values() -> None:
"""Vérifie que les valeurs de l'enum MessageType sont correctes.
:raises ValidationError: Si une valeur invalide est utilisée.
"""
# Test des valeurs valides
for msg_type in [MessageType.DISCUSSION, MessageType.INFORMATION, MessageType.SURVEY]:
assert isinstance(msg_type.value, str)
Message(
id="test",
type=msg_type,
title="Test",
content="Test",
author="Test",
date=datetime(2024, 9, 1, 8, 0),
read=False,
)
def test_agenda_change_type_enum_values() -> None:
"""Vérifie que les valeurs de l'enum AgendaChangeType sont correctes.
:raises ValidationError: Si une valeur invalide est utilisée.
"""
# Test des valeurs valides
for change_type in [
AgendaChangeType.ADDED,
AgendaChangeType.REMOVED,
AgendaChangeType.MODIFIED,
]:
assert isinstance(change_type.value, str)
if change_type == AgendaChangeType.ADDED:
AgendaChange(
type=change_type,
lesson=Lesson(
id="test",
start=datetime(2024, 9, 6, 8, 0),
end=datetime(2024, 9, 6, 9, 0),
subject="Test",
group=None,
content=None,
),
theoretical_lesson=None,
)
elif change_type == AgendaChangeType.REMOVED:
AgendaChange(
type=change_type,
lesson=None,
theoretical_lesson=TheoreticalLesson(
id="test",
day_of_week=0,
start_time=time(8, 0, 0),
end_time=time(9, 0, 0),
subject="Test",
),
)
else:
AgendaChange(
type=change_type,
lesson=Lesson(
id="test",
start=datetime(2024, 9, 6, 8, 0),
end=datetime(2024, 9, 6, 9, 0),
subject="Test",
group=None,
content=None,
),
theoretical_lesson=TheoreticalLesson(
id="test",
day_of_week=0,
start_time=time(8, 0, 0),
end_time=time(9, 0, 0),
subject="Test",
),
)