import unittest from datetime import datetime, timedelta from app import create_app, db from app.models.subject import Subject, Commission from app.models.milestone import MilestoneType, AcademicMilestone from app.models.user import User from app.models.role import Role from app.models.enrollment import StudentEnrollment from app.models.grade import MilestoneGrade from app.services.gradebook_service import GradebookService class TestPhase3Gradebook(unittest.TestCase): """Pruebas unitarias e integrales para la Fase 3 del Roadmap MVP (UniCABA).""" def setUp(self): self.app = create_app() self.app_context = self.app.app_context() self.app_context.push() db.create_all() # Roles self.role_alumno = Role.query.filter_by(name='Alumno').first() if not self.role_alumno: self.role_alumno = Role(name='Alumno', description='Rol Alumno') db.session.add(self.role_alumno) self.role_docente = Role.query.filter_by(name='Docente').first() if not self.role_docente: self.role_docente = Role(name='Docente', description='Rol Docente') db.session.add(self.role_docente) db.session.commit() ts = int(datetime.utcnow().timestamp()) # Docente self.docente = User( name=f'Profesor Calificador {ts}', email=f'docente_calif_{ts}@unicaba.edu.ar', role_id=self.role_docente.id, role='Docente', is_active=True ) self.docente.set_password('Secret123!') db.session.add(self.docente) # Alumnos self.alumno1 = User( name=f'Estudiante Uno {ts}', email=f'alumno1_{ts}@unicaba.edu.ar', role_id=self.role_alumno.id, role='Alumno', is_active=True ) self.alumno1.set_password('Secret123!') db.session.add(self.alumno1) self.alumno2 = User( name=f'Estudiante Dos {ts}', email=f'alumno2_{ts}@unicaba.edu.ar', role_id=self.role_alumno.id, role='Alumno', is_active=True ) self.alumno2.set_password('Secret123!') db.session.add(self.alumno2) # Materia y Comisión self.subject = Subject( name=f'Evaluación y Métricas {ts}', code=f'EVAL{ts % 10000:04d}', is_active=True ) db.session.add(self.subject) db.session.flush() self.commission = Commission( subject_id=self.subject.id, code='C1', semester='1C', year=2026, teacher_id=self.docente.id, max_students=40, active=True ) db.session.add(self.commission) db.session.flush() # Matrículas self.enr1 = StudentEnrollment( student_id=self.alumno1.id, commission_id=self.commission.id, status='activo' ) self.enr2 = StudentEnrollment( student_id=self.alumno2.id, commission_id=self.commission.id, status='activo' ) db.session.add_all([self.enr1, self.enr2]) # Tipos de hito self.mtype_parcial = MilestoneType.query.filter_by(code='parcial_1').first() if not self.mtype_parcial: self.mtype_parcial = MilestoneType( name='Parcial 1', code='parcial_1', color='#0d6efd', is_active=True ) db.session.add(self.mtype_parcial) self.mtype_tp = MilestoneType.query.filter_by(code='tp_1').first() if not self.mtype_tp: self.mtype_tp = MilestoneType( name='Trabajo Práctico 1', code='tp_1', color='#198754', is_active=True ) db.session.add(self.mtype_tp) db.session.commit() # Hitos de la comisión now = datetime.utcnow() self.hito1 = AcademicMilestone( title='Primer Parcial Teórico', materia_codigo=self.subject.code, subject_id=self.subject.id, commission_id=self.commission.id, milestone_type_id=self.mtype_parcial.id, date=now + timedelta(days=5), classroom='Aula 101', is_active=True ) self.hito2 = AcademicMilestone( title='Entrega de TP Obligatorio', materia_codigo=self.subject.code, subject_id=self.subject.id, commission_id=self.commission.id, milestone_type_id=self.mtype_tp.id, date=now + timedelta(days=12), classroom='Campus Virtual', is_active=True ) db.session.add_all([self.hito1, self.hito2]) db.session.commit() def tearDown(self): db.session.rollback() self.app_context.pop() def test_gradebook_matrix_initial(self): """Verifica que la matriz de notas inicialice vacía pero con los alumnos e hitos correctos.""" gb = GradebookService.get_commission_gradebook(self.commission.id) self.assertIn('students', gb) self.assertEqual(len(gb['students']), 2) self.assertEqual(len(gb['milestones']), 2) self.assertFalse(gb['is_closed']) self.assertIsNone(gb['acta_number']) # Los alumnos deben figurar como 'Sin Calificar' for s in gb['students']: self.assertEqual(s['condition'], 'Sin Calificar') self.assertIsNone(s['average_score']) def test_save_grade_valid_and_absent(self): """Verifica la carga de notas numéricas y estados de ausente.""" # Alumno 1 saca 8.5 en hito 1 g1 = GradebookService.save_single_grade( commission_id=self.commission.id, milestone_id=self.hito1.id, student_id=self.alumno1.id, score=8.5, is_absent=False, feedback='Excelente razonamiento teórico.', grader_id=self.docente.id ) self.assertEqual(g1.score, 8.5) self.assertTrue(g1.is_passed) self.assertEqual(g1.display_score, '8.5') # Alumno 2 estuvo ausente en hito 1 g2 = GradebookService.save_single_grade( commission_id=self.commission.id, milestone_id=self.hito1.id, student_id=self.alumno2.id, score=None, is_absent=True, feedback='Ausente con aviso.', grader_id=self.docente.id ) self.assertIsNone(g2.score) self.assertTrue(g2.is_absent) self.assertFalse(g2.is_passed) self.assertEqual(g2.display_score, 'Ausente') def test_save_grade_validation_range(self): """Bloqueo de notas fuera del rango estándar (1.00 a 10.00).""" with self.assertRaises(ValueError): GradebookService.save_single_grade( commission_id=self.commission.id, milestone_id=self.hito1.id, student_id=self.alumno1.id, score=10.5 ) with self.assertRaises(ValueError): GradebookService.save_single_grade( commission_id=self.commission.id, milestone_id=self.hito1.id, student_id=self.alumno1.id, score=0.5 ) def test_academic_condition_calculation(self): """Verifica el cálculo de Promociona, Regular y Desaprobado.""" # Alumno 1: 8.0 en hito 1 y 9.0 en hito 2 -> Promedio 8.50 -> Promociona GradebookService.save_single_grade( commission_id=self.commission.id, milestone_id=self.hito1.id, student_id=self.alumno1.id, score=8.0 ) GradebookService.save_single_grade( commission_id=self.commission.id, milestone_id=self.hito2.id, student_id=self.alumno1.id, score=9.0 ) # Alumno 2: 5.0 en hito 1 y 6.0 en hito 2 -> Promedio 5.50 -> Regular GradebookService.save_single_grade( commission_id=self.commission.id, milestone_id=self.hito1.id, student_id=self.alumno2.id, score=5.0 ) GradebookService.save_single_grade( commission_id=self.commission.id, milestone_id=self.hito2.id, student_id=self.alumno2.id, score=6.0 ) gb = GradebookService.get_commission_gradebook(self.commission.id) al1_data = next(s for s in gb['students'] if s['student_id'] == self.alumno1.id) al2_data = next(s for s in gb['students'] if s['student_id'] == self.alumno2.id) self.assertEqual(al1_data['average_score'], 8.5) self.assertEqual(al1_data['condition'], 'Promociona') self.assertEqual(al2_data['average_score'], 5.5) self.assertEqual(al2_data['condition'], 'Regular') def test_close_gradebook_and_immutability(self): """Cierre formal del acta de regularidad y bloqueo de modificaciones posteriores.""" # Calificar GradebookService.save_single_grade( commission_id=self.commission.id, milestone_id=self.hito1.id, student_id=self.alumno1.id, score=7.0 ) # Cerrar acta res = GradebookService.close_gradebook( commission_id=self.commission.id, user_id=self.docente.id, notes='Cierre final de cursada primer cuatrimestre.' ) self.assertTrue(res['success']) self.assertIn('ACTA-2026-1C-', res['acta_number']) # Intentar modificar nota en acta cerrada debe ser rechazado with self.assertRaises(ValueError) as ctx: GradebookService.save_single_grade( commission_id=self.commission.id, milestone_id=self.hito1.id, student_id=self.alumno1.id, score=10.0 ) self.assertIn('cerrada', str(ctx.exception).lower()) def test_student_grades_and_countdown(self): """Verifica la consulta individual de notas y la cuenta regresiva para el alumno.""" # Calificar al alumno 1 GradebookService.save_single_grade( commission_id=self.commission.id, milestone_id=self.hito1.id, student_id=self.alumno1.id, score=8.0, feedback='Buen trabajo en el examen.' ) # Consulta del alumno grades = GradebookService.get_student_grades(self.alumno1.id) self.assertEqual(len(grades), 1) self.assertEqual(grades[0]['subject_name'], self.subject.name) self.assertEqual(len(grades[0]['milestones']), 2) # Próximos hitos con cuenta regresiva upcoming = GradebookService.get_student_upcoming_milestones(self.alumno1.id) self.assertGreaterEqual(len(upcoming), 1) self.assertIn('countdown_display', upcoming[0]) self.assertIn('days_remaining', upcoming[0]) if __name__ == '__main__': unittest.main()