""" Tests de migration pour AssessmentProgressService (JOUR 4 - Étape 2.2) Ce module teste la migration de la propriété grading_progress du modèle Assessment vers le nouveau AssessmentProgressService, en validant que : 1. Les deux implémentations donnent des résultats identiques 2. Le feature flag fonctionne correctement 3. Les performances sont améliorées (moins de requêtes N+1) 4. Tous les cas de bord sont couverts Conformément au plan MIGRATION_PROGRESSIVE.md, cette migration utilise le feature flag USE_REFACTORED_ASSESSMENT pour permettre un rollback instantané. """ import pytest from unittest.mock import patch, MagicMock from datetime import datetime, date import time from models import db, Assessment, ClassGroup, Student, Exercise, GradingElement, Grade from config.feature_flags import FeatureFlag from services.assessment_services import ProgressResult from providers.concrete_providers import AssessmentServicesFactory class TestAssessmentProgressMigration: """ Suite de tests pour valider la migration de grading_progress. """ def test_feature_flag_disabled_uses_legacy_implementation(self, app, sample_assessment_with_grades): """ RÈGLE MÉTIER : Quand le feature flag USE_REFACTORED_ASSESSMENT est désactivé, la propriété grading_progress doit utiliser l'ancienne implémentation. """ assessment, _, _ = sample_assessment_with_grades # GIVEN : Feature flag désactivé (par défaut) from config.feature_flags import feature_flags assert not feature_flags.is_enabled(FeatureFlag.USE_REFACTORED_ASSESSMENT) # WHEN : On accède à grading_progress with patch.object(assessment, '_grading_progress_legacy') as mock_legacy: mock_legacy.return_value = { 'percentage': 50, 'completed': 10, 'total': 20, 'status': 'in_progress', 'students_count': 5 } result = assessment.grading_progress # THEN : La méthode legacy est appelée mock_legacy.assert_called_once() assert result['percentage'] == 50 def test_feature_flag_enabled_uses_new_service(self, app, sample_assessment_with_grades): """ RÈGLE MÉTIER : Quand le feature flag USE_REFACTORED_ASSESSMENT est activé, la propriété grading_progress doit utiliser AssessmentProgressService. """ assessment, _, _ = sample_assessment_with_grades # GIVEN : Feature flag activé from config.feature_flags import feature_flags feature_flags.enable(FeatureFlag.USE_REFACTORED_ASSESSMENT, "Test migration") try: # WHEN : On accède à grading_progress with patch.object(assessment, '_grading_progress_with_service') as mock_service: mock_service.return_value = { 'percentage': 50, 'completed': 10, 'total': 20, 'status': 'in_progress', 'students_count': 5 } result = assessment.grading_progress # THEN : La méthode service est appelée mock_service.assert_called_once() assert result['percentage'] == 50 finally: # Cleanup : Réinitialiser le feature flag feature_flags.disable(FeatureFlag.USE_REFACTORED_ASSESSMENT, "Fin de test") def test_legacy_and_service_implementations_return_identical_results(self, app, sample_assessment_with_grades): """ RÈGLE CRITIQUE : Les deux implémentations doivent retourner exactement les mêmes résultats pour éviter les régressions. """ assessment, students, grades = sample_assessment_with_grades # WHEN : On calcule avec les deux implémentations legacy_result = assessment._grading_progress_legacy() service_result = assessment._grading_progress_with_service() # THEN : Les résultats doivent être identiques assert legacy_result == service_result, ( f"Legacy: {legacy_result} != Service: {service_result}" ) # Vérification de tous les champs for key in ['percentage', 'completed', 'total', 'status', 'students_count']: assert legacy_result[key] == service_result[key], ( f"Différence sur le champ {key}: {legacy_result[key]} != {service_result[key]}" ) def test_empty_assessment_handling_consistency(self, app): """ CAS DE BORD : Assessment vide (pas d'exercices) - les deux implémentations doivent gérer ce cas identiquement. """ # GIVEN : Assessment sans exercices mais avec des élèves class_group = ClassGroup(name='Test Class', year='2025') student1 = Student(first_name='John', last_name='Doe', class_group=class_group) student2 = Student(first_name='Jane', last_name='Smith', class_group=class_group) assessment = Assessment( title='Empty Assessment', date=date.today(), trimester=1, class_group=class_group ) db.session.add_all([class_group, student1, student2, assessment]) db.session.commit() # WHEN : On calcule avec les deux implémentations legacy_result = assessment._grading_progress_legacy() service_result = assessment._grading_progress_with_service() # THEN : Résultats identiques pour cas vide assert legacy_result == service_result assert legacy_result['status'] == 'no_elements' assert legacy_result['percentage'] == 0 assert legacy_result['students_count'] == 2 def test_no_students_handling_consistency(self, app): """ CAS DE BORD : Assessment avec exercices mais sans élèves. """ # GIVEN : Assessment avec exercices mais sans élèves class_group = ClassGroup(name='Empty Class', year='2025') assessment = Assessment( title='Assessment No Students', date=date.today(), trimester=1, class_group=class_group ) exercise = Exercise(title='Exercise 1', assessment=assessment) element = GradingElement( label='Question 1', max_points=10, grading_type='notes', exercise=exercise ) db.session.add_all([class_group, assessment, exercise, element]) db.session.commit() # WHEN : On calcule avec les deux implémentations legacy_result = assessment._grading_progress_legacy() service_result = assessment._grading_progress_with_service() # THEN : Résultats identiques pour classe vide assert legacy_result == service_result assert legacy_result['status'] == 'no_students' assert legacy_result['percentage'] == 0 assert legacy_result['students_count'] == 0 def test_partial_grading_scenarios(self, app): """ CAS COMPLEXE : Différents scénarios de notation partielle. """ # GIVEN : Assessment avec notation partielle complexe class_group = ClassGroup(name='Test Class', year='2025') students = [ Student(first_name=f'Student{i}', last_name=f'Test{i}', class_group=class_group) for i in range(3) ] assessment = Assessment( title='Partial Assessment', date=date.today(), trimester=1, class_group=class_group ) exercise1 = Exercise(title='Ex1', assessment=assessment) exercise2 = Exercise(title='Ex2', assessment=assessment) element1 = GradingElement( label='Q1', max_points=10, grading_type='notes', exercise=exercise1 ) element2 = GradingElement( label='Q2', max_points=5, grading_type='notes', exercise=exercise1 ) element3 = GradingElement( label='Q3', max_points=3, grading_type='score', exercise=exercise2 ) db.session.add_all([ class_group, assessment, exercise1, exercise2, element1, element2, element3, *students ]) db.session.commit() # Notation partielle : # - Student0 : toutes les notes (3/3 = 100%) # - Student1 : 2 notes sur 3 (2/3 = 67%) # - Student2 : 1 note sur 3 (1/3 = 33%) # Total : 6/9 = 67% grades = [ # Student 0 : toutes les notes Grade(student=students[0], grading_element=element1, value='8'), Grade(student=students[0], grading_element=element2, value='4'), Grade(student=students[0], grading_element=element3, value='2'), # Student 1 : 2 notes Grade(student=students[1], grading_element=element1, value='7'), Grade(student=students[1], grading_element=element2, value='3'), # Student 2 : 1 note Grade(student=students[2], grading_element=element1, value='6'), ] db.session.add_all(grades) db.session.commit() # WHEN : On calcule avec les deux implémentations legacy_result = assessment._grading_progress_legacy() service_result = assessment._grading_progress_with_service() # THEN : Résultats identiques assert legacy_result == service_result expected_percentage = round((6 / 9) * 100) # 67% assert legacy_result['percentage'] == expected_percentage assert legacy_result['completed'] == 6 assert legacy_result['total'] == 9 assert legacy_result['status'] == 'in_progress' assert legacy_result['students_count'] == 3 def test_special_values_handling(self, app): """ CAS COMPLEXE : Gestion des valeurs spéciales (., d, etc.). """ # GIVEN : Assessment avec valeurs spéciales class_group = ClassGroup(name='Special Class', year='2025') student = Student(first_name='John', last_name='Doe', class_group=class_group) assessment = Assessment( title='Special Values Assessment', date=date.today(), trimester=1, class_group=class_group ) exercise = Exercise(title='Exercise', assessment=assessment) element1 = GradingElement( label='Q1', max_points=10, grading_type='notes', exercise=exercise ) element2 = GradingElement( label='Q2', max_points=5, grading_type='notes', exercise=exercise ) db.session.add_all([class_group, student, assessment, exercise, element1, element2]) db.session.commit() # Notes avec valeurs spéciales grades = [ Grade(student=student, grading_element=element1, value='.'), # Pas de réponse Grade(student=student, grading_element=element2, value='d'), # Dispensé ] db.session.add_all(grades) db.session.commit() # WHEN : On calcule avec les deux implémentations legacy_result = assessment._grading_progress_legacy() service_result = assessment._grading_progress_with_service() # THEN : Les valeurs spéciales sont comptées comme saisies assert legacy_result == service_result assert legacy_result['percentage'] == 100 # 2/2 notes saisies assert legacy_result['completed'] == 2 assert legacy_result['total'] == 2 assert legacy_result['status'] == 'completed' class TestPerformanceImprovement: """ Tests de performance pour valider les améliorations de requêtes. """ def test_service_makes_fewer_queries_than_legacy(self, app): """ PERFORMANCE : Le service optimisé doit faire moins de requêtes que l'implémentation legacy. """ # GIVEN : Assessment avec beaucoup d'éléments pour amplifier le problème N+1 class_group = ClassGroup(name='Big Class', year='2025') students = [ Student(first_name=f'Student{i}', last_name='Test', class_group=class_group) for i in range(5) # 5 étudiants ] assessment = Assessment( title='Big Assessment', date=date.today(), trimester=1, class_group=class_group ) exercises = [] elements = [] grades = [] # 3 exercices avec 2 éléments chacun = 6 éléments total for ex_idx in range(3): exercise = Exercise(title=f'Ex{ex_idx}', assessment=assessment) exercises.append(exercise) for elem_idx in range(2): element = GradingElement( label=f'Q{ex_idx}-{elem_idx}', max_points=10, grading_type='notes', exercise=exercise ) elements.append(element) # Chaque étudiant a une note pour chaque élément for student in students: grade = Grade( student=student, grading_element=element, value=str(8 + elem_idx) # Notes variables ) grades.append(grade) db.session.add_all([ class_group, assessment, *students, *exercises, *elements, *grades ]) db.session.commit() # WHEN : On mesure les requêtes pour chaque implémentation from sqlalchemy import event # Compteur de requêtes pour legacy legacy_query_count = [0] def count_legacy_queries(conn, cursor, statement, parameters, context, executemany): legacy_query_count[0] += 1 event.listen(db.engine, "before_cursor_execute", count_legacy_queries) try: legacy_result = assessment._grading_progress_legacy() finally: event.remove(db.engine, "before_cursor_execute", count_legacy_queries) # Compteur de requêtes pour service service_query_count = [0] def count_service_queries(conn, cursor, statement, parameters, context, executemany): service_query_count[0] += 1 event.listen(db.engine, "before_cursor_execute", count_service_queries) try: service_result = assessment._grading_progress_with_service() finally: event.remove(db.engine, "before_cursor_execute", count_service_queries) # THEN : Le service doit faire significativement moins de requêtes print(f"Legacy queries: {legacy_query_count[0]}") print(f"Service queries: {service_query_count[0]}") assert service_query_count[0] < legacy_query_count[0], ( f"Service ({service_query_count[0]} queries) devrait faire moins de requêtes " f"que legacy ({legacy_query_count[0]} queries)" ) # Les résultats doivent toujours être identiques assert legacy_result == service_result def test_service_performance_scales_better(self, app): """ PERFORMANCE : Le service doit avoir une complexité O(1) au lieu de O(n*m). """ # Ce test nécessiterait des données plus volumineuses pour être significatif # En production, on pourrait mesurer les temps d'exécution pass @pytest.fixture def sample_assessment_with_grades(app): """ Fixture créant un assessment avec quelques notes pour les tests. """ class_group = ClassGroup(name='Test Class', year='2025') students = [ Student(first_name='Alice', last_name='Test', class_group=class_group), Student(first_name='Bob', last_name='Test', class_group=class_group), ] assessment = Assessment( title='Sample Assessment', date=date.today(), trimester=1, class_group=class_group ) exercise = Exercise(title='Exercise 1', assessment=assessment) element1 = GradingElement( label='Question 1', max_points=10, grading_type='notes', exercise=exercise ) element2 = GradingElement( label='Question 2', max_points=5, grading_type='notes', exercise=exercise ) db.session.add_all([ class_group, assessment, exercise, element1, element2, *students ]) db.session.commit() # Notes partielles : Alice a 2 notes, Bob a 1 note grades = [ Grade(student=students[0], grading_element=element1, value='8'), Grade(student=students[0], grading_element=element2, value='4'), Grade(student=students[1], grading_element=element1, value='7'), # Bob n'a pas de note pour element2 ] db.session.add_all(grades) db.session.commit() return assessment, students, grades