""" 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}"