Migration v1 (Flask) -> v2 (FastAPI + Vue.js) complétée
✨ 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!
This commit is contained in:
312
backend/tests/comparison/test_calculation_parity.py
Normal file
312
backend/tests/comparison/test_calculation_parity.py
Normal file
@@ -0,0 +1,312 @@
|
||||
"""
|
||||
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}"
|
||||
Reference in New Issue
Block a user