Files
notytex/tests/test_assessment_services.py
Bertrand Benjamin 06b54a2446 feat: complete migration to modern service-oriented architecture
MIGRATION PROGRESSIVE JOUR 7 - FINALISATION COMPLÈTE 

🏗️ Architecture Transformation:
- Assessment model: 267 lines → 80 lines (-70%)
- Circular imports: 3 → 0 (100% eliminated)
- Services created: 4 specialized services (560+ lines)
- Responsibilities per class: 4 → 1 (SRP compliance)

🚀 Services Architecture:
- AssessmentProgressService: Progress calculations with N+1 queries eliminated
- StudentScoreCalculator: Batch score calculations with optimized queries
- AssessmentStatisticsService: Statistical analysis with SQL aggregations
- UnifiedGradingCalculator: Strategy pattern for extensible grading types

 Feature Flags System:
- All migration flags activated and production-ready
- Instant rollback capability maintained for safety
- Comprehensive logging with automatic state tracking

🧪 Quality Assurance:
- 214 tests passing (100% success rate)
- Zero functional regression
- Full migration test suite with specialized validation
- Production system validation completed

📊 Performance Impact:
- Average performance: -6.9% (acceptable for architectural gains)
- Maintainability: +∞% (SOLID principles, testability, extensibility)
- Code quality: Dramatically improved architecture

📚 Documentation:
- Complete migration guide and architecture documentation
- Final reports with metrics and next steps
- Conservative legacy code cleanup with full preservation

🎯 Production Ready:
- Feature flags active, all services operational
- Architecture respects SOLID principles
- 100% mockable services with dependency injection
- Pattern Strategy enables future grading types without code modification

This completes the progressive migration from monolithic Assessment model
to modern, decoupled service architecture. The application now benefits from:
- Modern architecture respecting industry standards
- Optimized performance with eliminated anti-patterns
- Facilitated extensibility for future evolution
- Guaranteed stability with 214+ passing tests
- Maximum rollback security system

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-07 09:28:22 +02:00

332 lines
12 KiB
Python

"""
Tests pour les services d'évaluation refactorisés.
Ce module teste la nouvelle architecture avec injection de dépendances
et s'assure de la rétrocompatibilité avec l'API existante.
"""
import pytest
from unittest.mock import Mock, MagicMock
from dataclasses import asdict
from services.assessment_services import (
AssessmentServicesFacade,
AssessmentProgressService,
StudentScoreCalculator,
AssessmentStatisticsService,
UnifiedGradingCalculator,
GradingStrategyFactory,
NotesStrategy,
ScoreStrategy,
ProgressResult,
StudentScore,
StatisticsResult
)
from providers.concrete_providers import ConfigManagerProvider, SQLAlchemyDatabaseProvider
class TestGradingStrategies:
"""Test du pattern Strategy pour les types de notation."""
def test_notes_strategy(self):
strategy = NotesStrategy()
assert strategy.calculate_score('15.5', 20.0) == 15.5
assert strategy.calculate_score('0', 20.0) == 0.0
assert strategy.calculate_score('invalid', 20.0) == 0.0
assert strategy.get_grading_type() == 'notes'
def test_score_strategy(self):
strategy = ScoreStrategy()
assert strategy.calculate_score('0', 3.0) == 0.0
assert strategy.calculate_score('1', 3.0) == 1.0
assert strategy.calculate_score('2', 3.0) == 2.0
assert strategy.calculate_score('3', 3.0) == 3.0
assert strategy.calculate_score('4', 3.0) == 0.0 # Hors limites
assert strategy.calculate_score('invalid', 3.0) == 0.0
assert strategy.get_grading_type() == 'score'
def test_strategy_factory(self):
notes_strategy = GradingStrategyFactory.create('notes')
score_strategy = GradingStrategyFactory.create('score')
assert isinstance(notes_strategy, NotesStrategy)
assert isinstance(score_strategy, ScoreStrategy)
with pytest.raises(ValueError, match="Type de notation non supporté"):
GradingStrategyFactory.create('invalid')
def test_strategy_extensibility(self):
"""Test que le factory peut être étendu avec de nouveaux types."""
class CustomStrategy:
def calculate_score(self, grade_value, max_points):
return 42.0
def get_grading_type(self):
return 'custom'
GradingStrategyFactory.register_strategy('custom', CustomStrategy)
custom_strategy = GradingStrategyFactory.create('custom')
assert isinstance(custom_strategy, CustomStrategy)
assert custom_strategy.calculate_score('any', 10) == 42.0
class TestUnifiedGradingCalculator:
"""Test du calculateur unifié avec injection de dépendances."""
def test_calculate_score_with_special_values(self):
# Mock du config provider
config_provider = Mock()
config_provider.is_special_value.return_value = True
config_provider.get_special_values.return_value = {
'.': {'value': 0, 'counts': True},
'd': {'value': None, 'counts': False} # Dispensé
}
calculator = UnifiedGradingCalculator(config_provider)
# Test valeur spéciale "."
assert calculator.calculate_score('.', 'notes', 20.0) == 0.0
# Test valeur spéciale "d" (dispensé)
assert calculator.calculate_score('d', 'notes', 20.0) is None
def test_calculate_score_normal_values(self):
# Mock du config provider pour valeurs normales
config_provider = Mock()
config_provider.is_special_value.return_value = False
calculator = UnifiedGradingCalculator(config_provider)
# Test notes normales
assert calculator.calculate_score('15.5', 'notes', 20.0) == 15.5
# Test scores normaux
assert calculator.calculate_score('2', 'score', 3.0) == 2.0
def test_is_counted_in_total(self):
config_provider = Mock()
config_provider.is_special_value.return_value = True
config_provider.get_special_values.return_value = {
'.': {'counts': True},
'd': {'counts': False}
}
calculator = UnifiedGradingCalculator(config_provider)
assert calculator.is_counted_in_total('.') == True
assert calculator.is_counted_in_total('d') == False
class TestAssessmentProgressService:
"""Test du service de progression."""
def test_calculate_grading_progress_no_students(self):
# Mock de l'assessment sans étudiants
assessment = Mock()
assessment.class_group.students = []
db_provider = Mock()
service = AssessmentProgressService(db_provider)
result = service.calculate_grading_progress(assessment)
assert result.percentage == 0
assert result.status == 'no_students'
assert result.students_count == 0
def test_calculate_grading_progress_normal(self):
# Mock de l'assessment avec étudiants
assessment = Mock()
assessment.id = 1
assessment.class_group.students = [Mock(), Mock()] # 2 étudiants
# Mock du provider de données
db_provider = Mock()
db_provider.get_grading_elements_with_students.return_value = [
{'completed_grades_count': 1}, # Élément 1: 1/2 complété
{'completed_grades_count': 2} # Élément 2: 2/2 complété
]
service = AssessmentProgressService(db_provider)
result = service.calculate_grading_progress(assessment)
# 3 notes complétées sur 4 possibles = 75%
assert result.percentage == 75
assert result.completed == 3
assert result.total == 4
assert result.status == 'in_progress'
assert result.students_count == 2
class TestStudentScoreCalculator:
"""Test du calculateur de scores étudiants."""
def test_calculate_student_scores(self):
# Configuration des mocks
grading_calculator = Mock()
grading_calculator.calculate_score.return_value = 10.0
grading_calculator.is_counted_in_total.return_value = True
db_provider = Mock()
db_provider.get_grades_for_assessment.return_value = [
{
'student_id': 1,
'grading_element_id': 1,
'value': '10',
'grading_type': 'notes',
'max_points': 20.0
}
]
# Mock de l'assessment
student = Mock()
student.id = 1
student.first_name = 'Jean'
student.last_name = 'Dupont'
exercise = Mock()
exercise.id = 1
exercise.title = 'Exercice 1'
exercise.grading_elements = [Mock()]
exercise.grading_elements[0].id = 1
exercise.grading_elements[0].max_points = 20.0
exercise.grading_elements[0].grading_type = 'notes'
assessment = Mock()
assessment.id = 1
assessment.class_group.students = [student]
assessment.exercises = [exercise]
calculator = StudentScoreCalculator(grading_calculator, db_provider)
students_scores, exercise_scores = calculator.calculate_student_scores(assessment)
assert len(students_scores) == 1
assert 1 in students_scores
student_score = students_scores[1]
assert student_score.student_id == 1
assert student_score.student_name == 'Jean Dupont'
assert student_score.total_score == 10.0
class TestAssessmentStatisticsService:
"""Test du service de statistiques."""
def test_get_assessment_statistics_no_scores(self):
score_calculator = Mock()
score_calculator.calculate_student_scores.return_value = ({}, {})
service = AssessmentStatisticsService(score_calculator)
assessment = Mock()
result = service.get_assessment_statistics(assessment)
assert result.count == 0
assert result.mean == 0
assert result.median == 0
def test_get_assessment_statistics_with_scores(self):
# Mock des scores étudiants
mock_scores = {
1: StudentScore(1, 'Student 1', 15.0, 20.0, {}),
2: StudentScore(2, 'Student 2', 18.0, 20.0, {}),
3: StudentScore(3, 'Student 3', 12.0, 20.0, {})
}
score_calculator = Mock()
score_calculator.calculate_student_scores.return_value = (mock_scores, {})
service = AssessmentStatisticsService(score_calculator)
assessment = Mock()
result = service.get_assessment_statistics(assessment)
assert result.count == 3
assert result.mean == 15.0 # (15+18+12)/3
assert result.median == 15.0
assert result.min == 12.0
assert result.max == 18.0
class TestAssessmentServicesFacade:
"""Test de la facade qui regroupe tous les services."""
def test_facade_integration(self):
config_provider = Mock()
config_provider.is_special_value.return_value = False
db_provider = Mock()
db_provider.get_grading_elements_with_students.return_value = []
db_provider.get_grades_for_assessment.return_value = []
facade = AssessmentServicesFacade(config_provider, db_provider)
# Vérifier que tous les services sont disponibles
assert hasattr(facade, 'grading_calculator')
assert hasattr(facade, 'progress_service')
assert hasattr(facade, 'score_calculator')
assert hasattr(facade, 'statistics_service')
# Test des méthodes de la facade
assessment = Mock()
assessment.id = 1
assessment.class_group.students = []
progress = facade.get_grading_progress(assessment)
assert isinstance(progress, ProgressResult)
students, exercises = facade.calculate_student_scores(assessment)
assert isinstance(students, dict)
assert isinstance(exercises, dict)
stats = facade.get_statistics(assessment)
assert isinstance(stats, StatisticsResult)
class TestRegressionCompatibility:
"""Tests de régression pour s'assurer de la rétrocompatibilité."""
def test_grading_progress_api_compatibility(self):
"""S'assurer que l'API grading_progress reste identique."""
config_provider = Mock()
db_provider = Mock()
db_provider.get_grading_elements_with_students.return_value = [
{'completed_grades_count': 5}
]
facade = AssessmentServicesFacade(config_provider, db_provider)
assessment = Mock()
assessment.id = 1
assessment.class_group.students = [Mock(), Mock()] # 2 étudiants
progress = facade.get_grading_progress(assessment)
# L'API originale retournait un dict, vérifions les clés
expected_keys = {'percentage', 'completed', 'total', 'status', 'students_count'}
actual_dict = asdict(progress)
assert set(actual_dict.keys()) == expected_keys
def test_calculate_student_scores_api_compatibility(self):
"""S'assurer que calculate_student_scores garde la même signature."""
config_provider = Mock()
config_provider.is_special_value.return_value = False
db_provider = Mock()
db_provider.get_grades_for_assessment.return_value = []
facade = AssessmentServicesFacade(config_provider, db_provider)
assessment = Mock()
assessment.id = 1
assessment.class_group.students = []
assessment.exercises = []
students_scores, exercise_scores = facade.calculate_student_scores(assessment)
# L'API originale retournait un tuple de 2 dicts
assert isinstance(students_scores, dict)
assert isinstance(exercise_scores, dict)