✨ Changements majeurs: - Suppression complète du code Flask legacy - Migration backend FastAPI vers racine /backend - Migration frontend Vue.js vers racine /frontend - Suppression de notytex-v2/ (code monté à la racine) ✅ Validations: - Backend démarre correctement (port 8000) - API /api/v2/health répond healthy - 99/99 tests unitaires passent - Frontend configuré avec proxy Vite 📝 Documentation: - README.md réécrit pour v2 - Instructions de démarrage mises à jour - .gitignore adapté pour backend/frontend/ 🎯 Architecture finale: notytex/ ├── backend/ # FastAPI + SQLAlchemy + Pydantic ├── frontend/ # Vue 3 + Vite + TailwindCSS ├── docs/ # Documentation └── school_management.db # Base de données (inchangée) Jalon 6 complété: Application v2 prête pour utilisation!
313 lines
11 KiB
Python
313 lines
11 KiB
Python
"""
|
|
Tests de parité des calculs entre v1 et v2.
|
|
|
|
Ces tests vérifient que les calculs du GradingCalculator v2 produisent
|
|
les mêmes résultats que la logique inline de v1.
|
|
"""
|
|
import pytest
|
|
from domain.services import GradingCalculator, StatisticsService
|
|
|
|
|
|
class TestGradingCalculatorParity:
|
|
"""
|
|
Tests de parité pour GradingCalculator.
|
|
|
|
Compare les résultats avec la logique v1 définie dans:
|
|
- services/assessment_services.py (NotesStrategy, ScoreStrategy)
|
|
- app_config.py (special_values)
|
|
"""
|
|
|
|
@pytest.fixture
|
|
def calculator(self):
|
|
return GradingCalculator()
|
|
|
|
# === Tests pour le type "notes" ===
|
|
|
|
@pytest.mark.parametrize("value,max_points,expected", [
|
|
# Cas normaux - v1: return float(value)
|
|
("15.5", 20, 15.5),
|
|
("20", 20, 20.0),
|
|
("0", 20, 0.0),
|
|
("18.75", 20, 18.75),
|
|
# Cas avec virgule française
|
|
("15,5", 20, 15.5),
|
|
("18,25", 20, 18.25),
|
|
])
|
|
def test_notes_strategy_parity(self, calculator, value, max_points, expected):
|
|
"""Les notes numériques doivent produire les mêmes résultats que v1."""
|
|
result = calculator.calculate_score(value, "notes", max_points)
|
|
assert result == expected, f"v1: {expected}, v2: {result}"
|
|
|
|
# === Tests pour le type "score" (compétences 0-3) ===
|
|
|
|
@pytest.mark.parametrize("value,max_points,expected", [
|
|
# Formule v1: (score / 3) * max_points
|
|
("0", 3, 0.0), # (0/3) * 3 = 0
|
|
("1", 3, 1.0), # (1/3) * 3 = 1
|
|
("2", 3, 2.0), # (2/3) * 3 = 2
|
|
("3", 3, 3.0), # (3/3) * 3 = 3
|
|
("0", 6, 0.0), # (0/3) * 6 = 0
|
|
("1", 6, 2.0), # (1/3) * 6 = 2
|
|
("2", 6, 4.0), # (2/3) * 6 = 4
|
|
("3", 6, 6.0), # (3/3) * 6 = 6
|
|
# Cas avec max_points différent
|
|
("2", 9, 6.0), # (2/3) * 9 = 6
|
|
("1", 12, 4.0), # (1/3) * 12 = 4
|
|
])
|
|
def test_score_strategy_parity(self, calculator, value, max_points, expected):
|
|
"""Les scores de compétences doivent produire les mêmes résultats que v1."""
|
|
result = calculator.calculate_score(value, "score", max_points)
|
|
assert result == expected, f"v1: {expected}, v2: {result}"
|
|
|
|
# === Tests pour les valeurs spéciales ===
|
|
|
|
@pytest.mark.parametrize("value,grading_type,expected", [
|
|
# "." = pas de réponse = 0 (compte dans le total)
|
|
(".", "notes", 0.0),
|
|
(".", "score", 0.0),
|
|
# "d" = dispensé = None (ne compte pas)
|
|
("d", "notes", None),
|
|
("d", "score", None),
|
|
# "a" = absent = 0 (compte dans le total)
|
|
("a", "notes", 0.0),
|
|
("a", "score", 0.0),
|
|
])
|
|
def test_special_values_parity(self, calculator, value, grading_type, expected):
|
|
"""Les valeurs spéciales doivent être gérées comme en v1."""
|
|
result = calculator.calculate_score(value, grading_type, 20)
|
|
assert result == expected, f"v1: {expected}, v2: {result}"
|
|
|
|
# === Tests pour is_counted_in_total ===
|
|
|
|
@pytest.mark.parametrize("value,expected", [
|
|
# Configuration v1 dans app_config.py
|
|
(".", True), # counts: True
|
|
("d", False), # counts: False (dispensé)
|
|
("a", True), # counts: True
|
|
("15", True), # Valeur normale
|
|
("", False), # Vide
|
|
])
|
|
def test_is_counted_in_total_parity(self, calculator, value, expected):
|
|
"""La logique de comptage dans le total doit être identique à v1."""
|
|
result = calculator.is_counted_in_total(value)
|
|
assert result == expected, f"v1: {expected}, v2: {result}"
|
|
|
|
|
|
class TestStatisticsServiceParity:
|
|
"""
|
|
Tests de parité pour StatisticsService.
|
|
|
|
Compare les résultats avec la logique v1 dans:
|
|
- services/assessment_services.py (AssessmentStatisticsService)
|
|
- models.py (get_assessment_statistics)
|
|
"""
|
|
|
|
@pytest.fixture
|
|
def stats_service(self):
|
|
return StatisticsService()
|
|
|
|
def test_mean_calculation_parity(self, stats_service):
|
|
"""La moyenne doit être calculée comme en v1: statistics.mean()"""
|
|
scores = [10.0, 15.0, 20.0, 12.5, 17.5]
|
|
result = stats_service.calculate_statistics(scores)
|
|
|
|
import statistics
|
|
expected_mean = round(statistics.mean(scores), 2)
|
|
|
|
assert result.mean == expected_mean
|
|
|
|
def test_median_calculation_parity(self, stats_service):
|
|
"""La médiane doit être calculée comme en v1: statistics.median()"""
|
|
scores = [10.0, 15.0, 20.0, 12.5, 17.5]
|
|
result = stats_service.calculate_statistics(scores)
|
|
|
|
import statistics
|
|
expected_median = round(statistics.median(scores), 2)
|
|
|
|
assert result.median == expected_median
|
|
|
|
def test_std_dev_calculation_parity(self, stats_service):
|
|
"""L'écart-type doit être calculé comme en v1: statistics.stdev()"""
|
|
scores = [10.0, 15.0, 20.0, 12.5, 17.5]
|
|
result = stats_service.calculate_statistics(scores)
|
|
|
|
import statistics
|
|
expected_std_dev = round(statistics.stdev(scores), 2)
|
|
|
|
assert result.std_dev == expected_std_dev
|
|
|
|
def test_std_dev_single_value_parity(self, stats_service):
|
|
"""Avec une seule valeur, std_dev doit être 0 (comme v1)"""
|
|
result = stats_service.calculate_statistics([15.0])
|
|
assert result.std_dev == 0.0
|
|
|
|
def test_histogram_creation_parity(self, stats_service):
|
|
"""L'histogramme doit avoir le même format que v1."""
|
|
scores = [5.5, 10.5, 15.5]
|
|
max_points = 20
|
|
|
|
# v1 crée des bins de [0] * (int(max_points) + 1)
|
|
result = stats_service.create_simple_histogram(scores, max_points)
|
|
|
|
assert len(result) == 21 # 0 à 20 inclus
|
|
assert result[5] == 1 # 5.5 → bin 5
|
|
assert result[10] == 1 # 10.5 → bin 10
|
|
assert result[15] == 1 # 15.5 → bin 15
|
|
assert sum(result) == 3
|
|
|
|
|
|
class TestRealWorldScenarios:
|
|
"""
|
|
Tests avec des scénarios réalistes de Notytex.
|
|
|
|
Simule les calculs d'une vraie évaluation avec un mélange de:
|
|
- Notes numériques
|
|
- Scores de compétences
|
|
- Valeurs spéciales
|
|
"""
|
|
|
|
@pytest.fixture
|
|
def calculator(self):
|
|
return GradingCalculator()
|
|
|
|
@pytest.fixture
|
|
def stats_service(self):
|
|
return StatisticsService()
|
|
|
|
def test_typical_assessment_calculation(self, calculator, stats_service):
|
|
"""
|
|
Simule le calcul d'une évaluation type avec:
|
|
- 3 exercices
|
|
- Mix de notes et compétences
|
|
- Valeurs spéciales
|
|
"""
|
|
# Structure de l'évaluation
|
|
elements = [
|
|
# Exercice 1 - Notes numériques
|
|
{"type": "notes", "max": 5},
|
|
{"type": "notes", "max": 3},
|
|
# Exercice 2 - Compétences
|
|
{"type": "score", "max": 3},
|
|
{"type": "score", "max": 3},
|
|
# Exercice 3 - Mix
|
|
{"type": "notes", "max": 4},
|
|
{"type": "score", "max": 3},
|
|
]
|
|
|
|
# Notes d'un élève
|
|
grades = [
|
|
"4.5", # notes: 4.5/5
|
|
"2", # notes: 2/3
|
|
"2", # score: (2/3)*3 = 2/3
|
|
"3", # score: (3/3)*3 = 3/3
|
|
".", # absent: 0/4
|
|
"d", # dispensé: non compté
|
|
]
|
|
|
|
total_score = 0.0
|
|
total_max = 0.0
|
|
|
|
for elem, grade in zip(elements, grades):
|
|
score = calculator.calculate_score(grade, elem["type"], elem["max"])
|
|
|
|
if calculator.is_counted_in_total(grade):
|
|
if score is not None:
|
|
total_score += score
|
|
total_max += elem["max"]
|
|
|
|
# Vérification des calculs
|
|
# Ex1: 4.5 + 2 = 6.5/8
|
|
# Ex2: 2 + 3 = 5/6
|
|
# Ex3: 0/4 (. compte, d non)
|
|
# Total: 11.5/18
|
|
|
|
assert round(total_score, 2) == 11.5
|
|
assert total_max == 18.0
|
|
|
|
def test_class_statistics_calculation(self, calculator, stats_service):
|
|
"""
|
|
Simule le calcul des statistiques pour une classe de 5 élèves.
|
|
"""
|
|
# Scores calculés (comme si déjà passés par GradingCalculator)
|
|
student_scores = [
|
|
15.0, # Élève 1
|
|
12.5, # Élève 2
|
|
18.0, # Élève 3
|
|
10.0, # Élève 4
|
|
14.5, # Élève 5
|
|
]
|
|
|
|
result = stats_service.calculate_statistics(student_scores)
|
|
|
|
# Vérifications
|
|
assert result.count == 5
|
|
assert result.mean == 14.0 # (15+12.5+18+10+14.5)/5
|
|
assert result.median == 14.5
|
|
assert result.min == 10.0
|
|
assert result.max == 18.0
|
|
# std_dev calculé avec statistics.stdev
|
|
import statistics
|
|
expected_std = round(statistics.stdev(student_scores), 2)
|
|
assert result.std_dev == expected_std
|
|
|
|
def test_edge_case_all_dispensed(self, calculator):
|
|
"""Cas où tous les élèves sont dispensés."""
|
|
grades = ["d", "d", "d"]
|
|
|
|
results = []
|
|
for grade in grades:
|
|
score = calculator.calculate_score(grade, "notes", 20)
|
|
counts = calculator.is_counted_in_total(grade)
|
|
results.append((score, counts))
|
|
|
|
# Tous les scores sont None et ne comptent pas
|
|
assert all(score is None for score, _ in results)
|
|
assert all(not counts for _, counts in results)
|
|
|
|
def test_edge_case_all_absent(self, calculator):
|
|
"""Cas où tous les élèves sont absents."""
|
|
grades = ["a", "a", "a"]
|
|
|
|
total_score = 0.0
|
|
total_max = 0.0
|
|
|
|
for grade in grades:
|
|
score = calculator.calculate_score(grade, "notes", 20)
|
|
if calculator.is_counted_in_total(grade):
|
|
if score is not None:
|
|
total_score += score
|
|
total_max += 20
|
|
|
|
# Tous les scores sont 0 et comptent
|
|
assert total_score == 0.0
|
|
assert total_max == 60.0 # 3 * 20
|
|
|
|
|
|
class TestFormulaParity:
|
|
"""
|
|
Tests pour vérifier que les formules exactes sont les mêmes entre v1 et v2.
|
|
"""
|
|
|
|
def test_score_formula_exact(self):
|
|
"""
|
|
Vérifie la formule exacte: score = (value / 3) * max_points
|
|
|
|
C'est la formule utilisée dans:
|
|
- v1: services/assessment_services.py ligne 114
|
|
- v2: domain/services/grading_calculator.py ligne 72
|
|
"""
|
|
calc = GradingCalculator()
|
|
|
|
# Test avec des valeurs qui pourraient avoir des erreurs d'arrondi
|
|
test_cases = [
|
|
# (value, max_points, formule exacte)
|
|
("1", 10, 10/3), # 3.333...
|
|
("2", 7, (2/3) * 7), # 4.666...
|
|
("1", 11, 11/3), # 3.666...
|
|
]
|
|
|
|
for value, max_points, expected in test_cases:
|
|
result = calc.calculate_score(value, "score", max_points)
|
|
# Utiliser approx pour gérer les erreurs de précision flottante
|
|
assert result == pytest.approx(expected, abs=1e-10), f"Formula mismatch: {value}/3 * {max_points}"
|