diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 2cd9474..c80f90a 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -115,8 +115,8 @@ development_status: 6-2-saisie-notes-grille-inline: done 6-3-calcul-automatique-des-moyennes: done 6-4-saisie-des-appreciations: done - 6-5-mode-competences: review - 6-6-consultation-notes-par-leleve: ready-for-dev + 6-5-mode-competences: done + 6-6-consultation-notes-par-leleve: review 6-7-consultation-notes-par-le-parent: ready-for-dev 6-8-statistiques-enseignant: ready-for-dev epic-6-retrospective: optional diff --git a/backend/src/Scolarite/Infrastructure/Api/Provider/StudentGradeCollectionProvider.php b/backend/src/Scolarite/Infrastructure/Api/Provider/StudentGradeCollectionProvider.php new file mode 100644 index 0000000..9f7cb2f --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Provider/StudentGradeCollectionProvider.php @@ -0,0 +1,160 @@ + + */ +final readonly class StudentGradeCollectionProvider implements ProviderInterface +{ + public function __construct( + private Connection $connection, + private TenantContext $tenantContext, + private Security $security, + ) { + } + + /** @return list */ + #[Override] + public function provide(Operation $operation, array $uriVariables = [], array $context = []): array + { + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + $user = $this->security->getUser(); + + if (!$user instanceof SecurityUser) { + throw new UnauthorizedHttpException('Bearer', 'Authentification requise.'); + } + + if (!in_array(Role::ELEVE->value, $user->getRoles(), true)) { + throw new AccessDeniedHttpException('Accès réservé aux élèves.'); + } + + $tenantId = (string) $this->tenantContext->getCurrentTenantId(); + $studentId = $user->userId(); + + /** @var string|null $subjectId */ + $subjectId = $uriVariables['subjectId'] ?? null; + + if (is_string($subjectId) && $subjectId === '') { + $subjectId = null; + } + + $subjectFilter = $subjectId !== null ? 'AND s.id = :subject_id' : ''; + + $rows = $this->connection->fetchAllAssociative( + "SELECT g.id AS grade_id, g.value, g.status AS grade_status, g.appreciation, + e.id AS evaluation_id, e.title AS evaluation_title, + e.evaluation_date, e.grade_scale, e.coefficient, + e.grades_published_at, + s.id AS subject_id, s.name AS subject_name, + es.average AS class_average, es.min_grade AS class_min, es.max_grade AS class_max + FROM grades g + JOIN evaluations e ON g.evaluation_id = e.id + JOIN subjects s ON e.subject_id = s.id + LEFT JOIN evaluation_statistics es ON es.evaluation_id = e.id + WHERE g.student_id = :student_id + AND g.tenant_id = :tenant_id + AND e.grades_published_at IS NOT NULL + AND e.status != :deleted_status + {$subjectFilter} + ORDER BY e.evaluation_date DESC, e.created_at DESC", + $subjectId !== null + ? ['student_id' => $studentId, 'tenant_id' => $tenantId, 'deleted_status' => 'deleted', 'subject_id' => $subjectId] + : ['student_id' => $studentId, 'tenant_id' => $tenantId, 'deleted_status' => 'deleted'], + ); + + return array_map(self::hydrateResource(...), $rows); + } + + /** @param array $row */ + private static function hydrateResource(array $row): StudentGradeResource + { + $resource = new StudentGradeResource(); + + /** @var string $gradeId */ + $gradeId = $row['grade_id']; + $resource->id = $gradeId; + + /** @var string $evaluationId */ + $evaluationId = $row['evaluation_id']; + $resource->evaluationId = $evaluationId; + + /** @var string $evaluationTitle */ + $evaluationTitle = $row['evaluation_title']; + $resource->evaluationTitle = $evaluationTitle; + + /** @var string $evaluationDate */ + $evaluationDate = $row['evaluation_date']; + $resource->evaluationDate = $evaluationDate; + + /** @var string|int $gradeScale */ + $gradeScale = $row['grade_scale']; + $resource->gradeScale = (int) $gradeScale; + + /** @var string|float $coefficient */ + $coefficient = $row['coefficient']; + $resource->coefficient = (float) $coefficient; + + /** @var string $subjectIdVal */ + $subjectIdVal = $row['subject_id']; + $resource->subjectId = $subjectIdVal; + + /** @var string|null $subjectName */ + $subjectName = $row['subject_name']; + $resource->subjectName = $subjectName; + + /** @var string|float|null $value */ + $value = $row['value']; + $resource->value = $value !== null ? (float) $value : null; + + /** @var string $gradeStatus */ + $gradeStatus = $row['grade_status']; + $resource->status = $gradeStatus; + + /** @var string|null $appreciation */ + $appreciation = $row['appreciation']; + $resource->appreciation = $appreciation; + + /** @var string|null $publishedAt */ + $publishedAt = $row['grades_published_at']; + $resource->publishedAt = $publishedAt; + + /** @var string|float|null $classAverage */ + $classAverage = $row['class_average']; + $resource->classAverage = $classAverage !== null ? (float) $classAverage : null; + + /** @var string|float|null $classMin */ + $classMin = $row['class_min']; + $resource->classMin = $classMin !== null ? (float) $classMin : null; + + /** @var string|float|null $classMax */ + $classMax = $row['class_max']; + $resource->classMax = $classMax !== null ? (float) $classMax : null; + + return $resource; + } +} diff --git a/backend/src/Scolarite/Infrastructure/Api/Provider/StudentMyAveragesProvider.php b/backend/src/Scolarite/Infrastructure/Api/Provider/StudentMyAveragesProvider.php new file mode 100644 index 0000000..1c021f9 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Provider/StudentMyAveragesProvider.php @@ -0,0 +1,94 @@ + + */ +final readonly class StudentMyAveragesProvider implements ProviderInterface +{ + public function __construct( + private StudentAverageRepository $studentAverageRepository, + private PeriodFinder $periodFinder, + private TenantContext $tenantContext, + private Security $security, + ) { + } + + #[Override] + public function provide(Operation $operation, array $uriVariables = [], array $context = []): StudentMyAveragesResource + { + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + $user = $this->security->getUser(); + + if (!$user instanceof SecurityUser) { + throw new UnauthorizedHttpException('Bearer', 'Authentification requise.'); + } + + if (!in_array(Role::ELEVE->value, $user->getRoles(), true)) { + throw new AccessDeniedHttpException('Accès réservé aux élèves.'); + } + + $tenantId = $this->tenantContext->getCurrentTenantId(); + $studentId = UserId::fromString($user->userId()); + + /** @var array $filters */ + $filters = $context['filters'] ?? []; + /** @var string|null $periodId */ + $periodId = $filters['periodId'] ?? null; + + // Auto-detect current period if not specified + if ($periodId === null) { + $periodInfo = $this->periodFinder->findForDate(new DateTimeImmutable(), $tenantId); + + if ($periodInfo !== null) { + $periodId = $periodInfo->periodId; + } + } + + $resource = new StudentMyAveragesResource(); + $resource->studentId = $user->userId(); + $resource->periodId = $periodId; + + if ($periodId === null) { + return $resource; + } + + $resource->subjectAverages = $this->studentAverageRepository->findDetailedSubjectAveragesForStudent( + $studentId, + $periodId, + $tenantId, + ); + + $resource->generalAverage = $this->studentAverageRepository->findGeneralAverageForStudent( + $studentId, + $periodId, + $tenantId, + ); + + return $resource; + } +} diff --git a/backend/src/Scolarite/Infrastructure/Api/Resource/StudentGradeResource.php b/backend/src/Scolarite/Infrastructure/Api/Resource/StudentGradeResource.php new file mode 100644 index 0000000..b4228b7 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Resource/StudentGradeResource.php @@ -0,0 +1,60 @@ + */ + public array $subjectAverages = []; + + public ?float $generalAverage = null; +} diff --git a/backend/tests/Functional/Scolarite/Api/StudentGradeEndpointsTest.php b/backend/tests/Functional/Scolarite/Api/StudentGradeEndpointsTest.php new file mode 100644 index 0000000..ebbdbd8 --- /dev/null +++ b/backend/tests/Functional/Scolarite/Api/StudentGradeEndpointsTest.php @@ -0,0 +1,649 @@ +seedFixtures(); + } + + protected function tearDown(): void + { + /** @var Connection $connection */ + $connection = static::getContainer()->get(Connection::class); + $connection->executeStatement('DELETE FROM evaluation_statistics WHERE evaluation_id IN (SELECT id FROM evaluations WHERE tenant_id = :tid)', ['tid' => self::TENANT_ID]); + $connection->executeStatement('DELETE FROM student_general_averages WHERE tenant_id = :tid', ['tid' => self::TENANT_ID]); + $connection->executeStatement('DELETE FROM student_averages WHERE tenant_id = :tid', ['tid' => self::TENANT_ID]); + $connection->executeStatement('DELETE FROM grade_events WHERE grade_id IN (SELECT id FROM grades WHERE tenant_id = :tid)', ['tid' => self::TENANT_ID]); + $connection->executeStatement('DELETE FROM grades WHERE tenant_id = :tid', ['tid' => self::TENANT_ID]); + $connection->executeStatement('DELETE FROM evaluations WHERE tenant_id = :tid', ['tid' => self::TENANT_ID]); + + parent::tearDown(); + } + + // ========================================================================= + // GET /me/grades — Auth & Access + // ========================================================================= + + #[Test] + public function getMyGradesReturns401WithoutAuthentication(): void + { + $client = static::createClient(); + $client->request('GET', self::BASE_URL . '/me/grades', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseStatusCodeSame(401); + } + + #[Test] + public function getMyGradesReturns403ForTeacher(): void + { + $client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']); + $client->request('GET', self::BASE_URL . '/me/grades', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseStatusCodeSame(403); + } + + #[Test] + public function getMyGradesReturns403ForParent(): void + { + $parentId = '88888888-8888-8888-8888-888888888888'; + $client = $this->createAuthenticatedClient($parentId, ['ROLE_PARENT']); + $client->request('GET', self::BASE_URL . '/me/grades', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseStatusCodeSame(403); + } + + // ========================================================================= + // GET /me/grades — Happy path + // ========================================================================= + + #[Test] + public function getMyGradesReturnsPublishedGradesForStudent(): void + { + $client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']); + $client->request('GET', self::BASE_URL . '/me/grades', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseIsSuccessful(); + + /** @var string $content */ + $content = $client->getResponse()->getContent(); + /** @var list> $data */ + $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + + // Only published grades should be returned (not unpublished) + self::assertCount(2, $data); + + // First grade (sorted by eval date DESC, subject2 is more recent) + self::assertSame(self::SUBJECT2_ID, $data[0]['subjectId']); + self::assertSame(14.0, $data[0]['value']); + self::assertSame('graded', $data[0]['status']); + self::assertNotNull($data[0]['publishedAt']); + + // Second grade + self::assertSame(self::SUBJECT_ID, $data[1]['subjectId']); + self::assertSame(16.0, $data[1]['value']); + } + + #[Test] + public function getMyGradesDoesNotReturnUnpublishedGrades(): void + { + $client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']); + $client->request('GET', self::BASE_URL . '/me/grades', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseIsSuccessful(); + + /** @var string $content */ + $content = $client->getResponse()->getContent(); + /** @var list> $data */ + $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + + // The unpublished evaluation grade should not appear + foreach ($data as $grade) { + self::assertNotSame((string) $this->unpublishedEvalId, $grade['evaluationId']); + } + } + + #[Test] + public function getMyGradesIncludesClassStatistics(): void + { + $client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']); + $client->request('GET', self::BASE_URL . '/me/grades', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseIsSuccessful(); + + /** @var string $content */ + $content = $client->getResponse()->getContent(); + /** @var list> $data */ + $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + + // First grade should have class statistics + self::assertArrayHasKey('classAverage', $data[0]); + self::assertArrayHasKey('classMin', $data[0]); + self::assertArrayHasKey('classMax', $data[0]); + } + + #[Test] + public function getMyGradesReturnsEmptyForStudentWithNoGrades(): void + { + $noGradeStudentId = '77777777-7777-7777-7777-777777777777'; + /** @var Connection $connection */ + $connection = static::getContainer()->get(Connection::class); + $connection->executeStatement( + "INSERT INTO users (id, tenant_id, email, hashed_password, first_name, last_name, roles, statut, created_at, updated_at) + VALUES (:id, :tid, 'no-grade@test.local', '', 'No', 'Grades', '[\"ROLE_ELEVE\"]', 'active', NOW(), NOW()) + ON CONFLICT (id) DO NOTHING", + ['id' => $noGradeStudentId, 'tid' => self::TENANT_ID], + ); + + $client = $this->createAuthenticatedClient($noGradeStudentId, ['ROLE_ELEVE']); + $client->request('GET', self::BASE_URL . '/me/grades', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseIsSuccessful(); + + /** @var string $content */ + $content = $client->getResponse()->getContent(); + /** @var list $data */ + $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + self::assertCount(0, $data); + } + + // ========================================================================= + // GET /me/grades/subject/{subjectId} — Happy path + // ========================================================================= + + #[Test] + public function getMyGradesBySubjectFiltersCorrectly(): void + { + $client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']); + $client->request('GET', self::BASE_URL . '/me/grades/subject/' . self::SUBJECT_ID, [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseIsSuccessful(); + + /** @var string $content */ + $content = $client->getResponse()->getContent(); + /** @var list> $data */ + $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + + self::assertCount(1, $data); + self::assertSame(self::SUBJECT_ID, $data[0]['subjectId']); + self::assertSame(16.0, $data[0]['value']); + } + + #[Test] + public function getMyGradesBySubjectReturnsEmptyForUnknownSubject(): void + { + $unknownSubjectId = '99999999-9999-9999-9999-999999999999'; + $client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']); + $client->request('GET', self::BASE_URL . '/me/grades/subject/' . $unknownSubjectId, [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseIsSuccessful(); + + /** @var string $content */ + $content = $client->getResponse()->getContent(); + /** @var list $data */ + $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + self::assertCount(0, $data); + } + + // ========================================================================= + // GET /me/averages — Auth & Access + // ========================================================================= + + #[Test] + public function getMyAveragesReturns401WithoutAuthentication(): void + { + $client = static::createClient(); + $client->request('GET', self::BASE_URL . '/me/averages', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseStatusCodeSame(401); + } + + #[Test] + public function getMyAveragesReturns403ForTeacher(): void + { + $client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']); + $client->request('GET', self::BASE_URL . '/me/averages', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseStatusCodeSame(403); + } + + // ========================================================================= + // GET /me/averages — Happy path + // ========================================================================= + + #[Test] + public function getMyAveragesReturnsAveragesForStudent(): void + { + $client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']); + $client->request('GET', self::BASE_URL . '/me/averages?periodId=' . self::PERIOD_ID, [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseIsSuccessful(); + self::assertJsonContains([ + 'studentId' => self::STUDENT_ID, + 'periodId' => self::PERIOD_ID, + 'generalAverage' => 16.0, + ]); + } + + #[Test] + public function getMyAveragesReturnsSubjectAverages(): void + { + $client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']); + $client->request('GET', self::BASE_URL . '/me/averages?periodId=' . self::PERIOD_ID, [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseIsSuccessful(); + + /** @var string $content */ + $content = $client->getResponse()->getContent(); + /** @var array{subjectAverages: list>, generalAverage: float|null} $data */ + $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + + self::assertNotEmpty($data['subjectAverages']); + self::assertSame(self::SUBJECT_ID, $data['subjectAverages'][0]['subjectId']); + self::assertSame(16.0, $data['subjectAverages'][0]['average']); + } + + // ========================================================================= + // GET /me/grades — Student isolation + // ========================================================================= + + #[Test] + public function getMyGradesReturnsOnlyCurrentStudentGrades(): void + { + $client = $this->createAuthenticatedClient(self::STUDENT2_ID, ['ROLE_ELEVE']); + $client->request('GET', self::BASE_URL . '/me/grades', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseIsSuccessful(); + + /** @var string $content */ + $content = $client->getResponse()->getContent(); + /** @var list> $data */ + $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + + // Student2 only has 1 grade (eval1, Maths), not the eval2/eval3 grades + self::assertCount(1, $data); + self::assertSame(12.0, $data[0]['value']); + } + + // ========================================================================= + // GET /me/grades — Response completeness + // ========================================================================= + + #[Test] + public function getMyGradesReturnsAllExpectedFields(): void + { + $client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']); + $client->request('GET', self::BASE_URL . '/me/grades', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseIsSuccessful(); + + /** @var string $content */ + $content = $client->getResponse()->getContent(); + /** @var list> $data */ + $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + + // First grade (eval2 — Français, more recent) + $grade = $data[0]; + self::assertArrayHasKey('id', $grade); + self::assertArrayHasKey('evaluationId', $grade); + self::assertArrayHasKey('evaluationTitle', $grade); + self::assertArrayHasKey('evaluationDate', $grade); + self::assertArrayHasKey('gradeScale', $grade); + self::assertArrayHasKey('coefficient', $grade); + self::assertArrayHasKey('subjectId', $grade); + self::assertArrayHasKey('value', $grade); + self::assertArrayHasKey('status', $grade); + self::assertArrayHasKey('publishedAt', $grade); + + self::assertSame('Dictée', $grade['evaluationTitle']); + self::assertSame(20, $grade['gradeScale']); + self::assertSame(2.0, $grade['coefficient']); + self::assertSame('Français', $grade['subjectName'] ?? null); + } + + #[Test] + public function getMyGradesIncludesAppreciationWhenSet(): void + { + // Add appreciation to eval1 grade + /** @var Connection $connection */ + $connection = static::getContainer()->get(Connection::class); + $connection->executeStatement( + "UPDATE grades SET appreciation = 'Excellent travail' WHERE student_id = :sid AND evaluation_id IN (SELECT id FROM evaluations WHERE title = 'DS Mathématiques' AND tenant_id = :tid)", + ['sid' => self::STUDENT_ID, 'tid' => self::TENANT_ID], + ); + + $client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']); + $client->request('GET', self::BASE_URL . '/me/grades', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseIsSuccessful(); + + /** @var string $content */ + $content = $client->getResponse()->getContent(); + /** @var list> $data */ + $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + + // Find the Maths grade (eval1) + $mathsGrade = null; + foreach ($data as $grade) { + if (($grade['evaluationTitle'] ?? null) === 'DS Mathématiques') { + $mathsGrade = $grade; + break; + } + } + + self::assertNotNull($mathsGrade, 'DS Mathématiques grade not found'); + self::assertSame('Excellent travail', $mathsGrade['appreciation']); + } + + // ========================================================================= + // GET /me/averages — Auto-detect period + // ========================================================================= + + #[Test] + public function getMyAveragesReturnsEmptyWhenNoPeriodCoversCurrentDate(): void + { + // The seeded period (2026-01-01 to 2026-03-31) does not cover today (2026-04-04) + // So auto-detect returns no period → empty averages + $client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']); + $client->request('GET', self::BASE_URL . '/me/averages', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseIsSuccessful(); + + /** @var string $content */ + $content = $client->getResponse()->getContent(); + /** @var array $data */ + $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + + self::assertArrayHasKey('studentId', $data); + self::assertEmpty($data['subjectAverages'] ?? []); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + /** + * @param list $roles + */ + private function createAuthenticatedClient(string $userId, array $roles): \ApiPlatform\Symfony\Bundle\Test\Client + { + $client = static::createClient(); + + $user = new SecurityUser( + userId: UserId::fromString($userId), + email: 'test@classeo.local', + hashedPassword: '', + tenantId: TenantId::fromString(self::TENANT_ID), + roles: $roles, + ); + + $client->loginUser($user, 'api'); + + return $client; + } + + private function seedFixtures(): void + { + $container = static::getContainer(); + /** @var Connection $connection */ + $connection = $container->get(Connection::class); + $tenantId = TenantId::fromString(self::TENANT_ID); + $now = new DateTimeImmutable(); + + $schoolId = '550e8400-e29b-41d4-a716-ff6655440001'; + $academicYearId = '550e8400-e29b-41d4-a716-ff6655440002'; + + // Seed users + $connection->executeStatement( + "INSERT INTO users (id, tenant_id, email, hashed_password, first_name, last_name, roles, statut, created_at, updated_at) + VALUES (:id, :tid, 'teacher-sg@test.local', '', 'Test', 'Teacher', '[\"ROLE_PROF\"]', 'active', NOW(), NOW()) + ON CONFLICT (id) DO NOTHING", + ['id' => self::TEACHER_ID, 'tid' => self::TENANT_ID], + ); + $connection->executeStatement( + "INSERT INTO users (id, tenant_id, email, hashed_password, first_name, last_name, roles, statut, created_at, updated_at) + VALUES (:id, :tid, 'student-sg@test.local', '', 'Alice', 'Durand', '[\"ROLE_ELEVE\"]', 'active', NOW(), NOW()) + ON CONFLICT (id) DO NOTHING", + ['id' => self::STUDENT_ID, 'tid' => self::TENANT_ID], + ); + $connection->executeStatement( + "INSERT INTO users (id, tenant_id, email, hashed_password, first_name, last_name, roles, statut, created_at, updated_at) + VALUES (:id, :tid, 'student2-sg@test.local', '', 'Bob', 'Martin', '[\"ROLE_ELEVE\"]', 'active', NOW(), NOW()) + ON CONFLICT (id) DO NOTHING", + ['id' => self::STUDENT2_ID, 'tid' => self::TENANT_ID], + ); + + // Seed class and subjects + $connection->executeStatement( + "INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, status, created_at, updated_at) + VALUES (:id, :tid, :sid, :ayid, 'Test-SG-Class', 'active', NOW(), NOW()) + ON CONFLICT (id) DO NOTHING", + ['id' => self::CLASS_ID, 'tid' => self::TENANT_ID, 'sid' => $schoolId, 'ayid' => $academicYearId], + ); + $connection->executeStatement( + "INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) + VALUES (:id, :tid, :sid, 'Mathématiques', 'MATH', 'active', NOW(), NOW()) + ON CONFLICT (id) DO NOTHING", + ['id' => self::SUBJECT_ID, 'tid' => self::TENANT_ID, 'sid' => $schoolId], + ); + $connection->executeStatement( + "INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) + VALUES (:id, :tid, :sid, 'Français', 'FRA', 'active', NOW(), NOW()) + ON CONFLICT (id) DO NOTHING", + ['id' => self::SUBJECT2_ID, 'tid' => self::TENANT_ID, 'sid' => $schoolId], + ); + $connection->executeStatement( + "INSERT INTO academic_periods (id, tenant_id, academic_year_id, period_type, sequence, label, start_date, end_date) + VALUES (:id, :tid, :ayid, 'trimester', 2, 'Trimestre 2', '2026-01-01', '2026-03-31') + ON CONFLICT (id) DO NOTHING", + ['id' => self::PERIOD_ID, 'tid' => self::TENANT_ID, 'ayid' => $academicYearId], + ); + + /** @var EvaluationRepository $evalRepo */ + $evalRepo = $container->get(EvaluationRepository::class); + /** @var GradeRepository $gradeRepo */ + $gradeRepo = $container->get(GradeRepository::class); + /** @var AverageCalculator $calculator */ + $calculator = $container->get(AverageCalculator::class); + /** @var EvaluationStatisticsRepository $statsRepo */ + $statsRepo = $container->get(EvaluationStatisticsRepository::class); + + // Evaluation 1: Published, Subject 1 (Maths), older date + $eval1 = Evaluation::creer( + tenantId: $tenantId, + classId: ClassId::fromString(self::CLASS_ID), + subjectId: SubjectId::fromString(self::SUBJECT_ID), + teacherId: UserId::fromString(self::TEACHER_ID), + title: 'DS Mathématiques', + description: null, + evaluationDate: new DateTimeImmutable('2026-02-15'), + gradeScale: new GradeScale(20), + coefficient: new Coefficient(1.0), + now: $now, + ); + $eval1->publierNotes($now); + $eval1->pullDomainEvents(); + $evalRepo->save($eval1); + + foreach ([ + [self::STUDENT_ID, 16.0], + [self::STUDENT2_ID, 12.0], + ] as [$studentId, $value]) { + $grade = Grade::saisir( + tenantId: $tenantId, + evaluationId: $eval1->id, + studentId: UserId::fromString($studentId), + value: new GradeValue($value), + status: GradeStatus::GRADED, + gradeScale: new GradeScale(20), + createdBy: UserId::fromString(self::TEACHER_ID), + now: $now, + ); + $grade->pullDomainEvents(); + $gradeRepo->save($grade); + } + + $stats1 = $calculator->calculateClassStatistics([16.0, 12.0]); + $statsRepo->save($eval1->id, $stats1); + + // Evaluation 2: Published, Subject 2 (Français), more recent date + $eval2 = Evaluation::creer( + tenantId: $tenantId, + classId: ClassId::fromString(self::CLASS_ID), + subjectId: SubjectId::fromString(self::SUBJECT2_ID), + teacherId: UserId::fromString(self::TEACHER_ID), + title: 'Dictée', + description: null, + evaluationDate: new DateTimeImmutable('2026-03-01'), + gradeScale: new GradeScale(20), + coefficient: new Coefficient(2.0), + now: $now, + ); + $eval2->publierNotes($now); + $eval2->pullDomainEvents(); + $evalRepo->save($eval2); + + $grade2 = Grade::saisir( + tenantId: $tenantId, + evaluationId: $eval2->id, + studentId: UserId::fromString(self::STUDENT_ID), + value: new GradeValue(14.0), + status: GradeStatus::GRADED, + gradeScale: new GradeScale(20), + createdBy: UserId::fromString(self::TEACHER_ID), + now: $now, + ); + $grade2->pullDomainEvents(); + $gradeRepo->save($grade2); + + $stats2 = $calculator->calculateClassStatistics([14.0]); + $statsRepo->save($eval2->id, $stats2); + + // Evaluation 3: NOT published (grades should NOT appear for student) + $eval3 = Evaluation::creer( + tenantId: $tenantId, + classId: ClassId::fromString(self::CLASS_ID), + subjectId: SubjectId::fromString(self::SUBJECT_ID), + teacherId: UserId::fromString(self::TEACHER_ID), + title: 'Contrôle surprise', + description: null, + evaluationDate: new DateTimeImmutable('2026-03-10'), + gradeScale: new GradeScale(20), + coefficient: new Coefficient(0.5), + now: $now, + ); + // NOT published - don't call publierNotes() + $eval3->pullDomainEvents(); + $evalRepo->save($eval3); + $this->unpublishedEvalId = $eval3->id; + + $grade3 = Grade::saisir( + tenantId: $tenantId, + evaluationId: $eval3->id, + studentId: UserId::fromString(self::STUDENT_ID), + value: new GradeValue(8.0), + status: GradeStatus::GRADED, + gradeScale: new GradeScale(20), + createdBy: UserId::fromString(self::TEACHER_ID), + now: $now, + ); + $grade3->pullDomainEvents(); + $gradeRepo->save($grade3); + + // Save student averages for /me/averages endpoint + /** @var StudentAverageRepository $avgRepo */ + $avgRepo = $container->get(StudentAverageRepository::class); + $avgRepo->saveSubjectAverage( + $tenantId, + UserId::fromString(self::STUDENT_ID), + SubjectId::fromString(self::SUBJECT_ID), + self::PERIOD_ID, + 16.0, + 1, + ); + $avgRepo->saveGeneralAverage( + $tenantId, + UserId::fromString(self::STUDENT_ID), + self::PERIOD_ID, + 16.0, + ); + } +} diff --git a/frontend/e2e/admin-search-pagination.spec.ts b/frontend/e2e/admin-search-pagination.spec.ts index ed28ab0..67b80ee 100644 --- a/frontend/e2e/admin-search-pagination.spec.ts +++ b/frontend/e2e/admin-search-pagination.spec.ts @@ -107,7 +107,7 @@ test.describe('Admin Search & Pagination (Story 2.8b)', () => { await page.waitForTimeout(500); // URL should contain search param - await expect(page).toHaveURL(/[?&]search=test-search/); + await expect(page).toHaveURL(/[?&]search=test-search/, { timeout: 15000 }); }); test('search term from URL is restored on page load', async ({ page }) => { diff --git a/frontend/e2e/branding.spec.ts b/frontend/e2e/branding.spec.ts index 1404a81..dfd12e3 100644 --- a/frontend/e2e/branding.spec.ts +++ b/frontend/e2e/branding.spec.ts @@ -229,7 +229,7 @@ test.describe('Branding Visual Customization', () => { await responsePromise; // Success message - await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('.alert-success')).toBeVisible({ timeout: 15000 }); await expect(page.locator('.alert-success')).toContainText(/couleurs mises à jour/i); // CSS variables applied to document root diff --git a/frontend/e2e/calendar.spec.ts b/frontend/e2e/calendar.spec.ts index 1829537..535b54d 100644 --- a/frontend/e2e/calendar.spec.ts +++ b/frontend/e2e/calendar.spec.ts @@ -321,8 +321,8 @@ test.describe('Calendar Management (Story 2.11)', () => { ).toBeVisible({ timeout: 10000 }); // Verify specific imported holiday entries are displayed - await expect(page.getByText('Toussaint', { exact: true })).toBeVisible(); - await expect(page.getByText('Noël', { exact: true })).toBeVisible(); + await expect(page.getByText('Toussaint', { exact: true })).toBeVisible({ timeout: 15000 }); + await expect(page.getByText('Noël', { exact: true })).toBeVisible({ timeout: 15000 }); // Verify entry cards exist (not just the heading) const holidaySection = page.locator('.entry-section').filter({ diff --git a/frontend/e2e/competencies.spec.ts b/frontend/e2e/competencies.spec.ts index 0606a7e..9d44473 100644 --- a/frontend/e2e/competencies.spec.ts +++ b/frontend/e2e/competencies.spec.ts @@ -237,11 +237,11 @@ test.describe('Competencies Mode (Story 6.5)', () => { // Click to set level await levelBtn.click(); - await expect(levelBtn).toHaveClass(/active/, { timeout: 5000 }); + await expect(levelBtn).toHaveClass(/active/, { timeout: 15000 }); // Click same button immediately to toggle off (no wait for save) await levelBtn.click(); - await expect(levelBtn).not.toHaveClass(/active/, { timeout: 5000 }); + await expect(levelBtn).not.toHaveClass(/active/, { timeout: 15000 }); }); }); diff --git a/frontend/e2e/dashboard-responsive-nav.spec.ts b/frontend/e2e/dashboard-responsive-nav.spec.ts index 1623c20..b81bd21 100644 --- a/frontend/e2e/dashboard-responsive-nav.spec.ts +++ b/frontend/e2e/dashboard-responsive-nav.spec.ts @@ -106,10 +106,10 @@ test.describe('Dashboard Responsive Navigation', () => { await page.getByRole('button', { name: /ouvrir le menu/i }).click(); const drawer = page.locator('[role="dialog"][aria-modal="true"]'); - await expect(drawer).toBeVisible(); + await expect(drawer).toBeVisible({ timeout: 10000 }); const logoutButton = drawer.locator('.mobile-logout'); - await expect(logoutButton).toBeVisible(); + await expect(logoutButton).toBeVisible({ timeout: 10000 }); await expect(logoutButton).toHaveText(/déconnexion/i); }); }); diff --git a/frontend/e2e/parent-schedule.spec.ts b/frontend/e2e/parent-schedule.spec.ts index c2ef8eb..6dd8509 100644 --- a/frontend/e2e/parent-schedule.spec.ts +++ b/frontend/e2e/parent-schedule.spec.ts @@ -397,12 +397,17 @@ test.describe('Parent Schedule Consultation (Story 4.4)', () => { timeout: 20000 }); - // Switch to week view + // Switch to week view (retry click if view doesn't switch — Svelte hydration race) const weekButton = page.locator('.view-toggle button', { hasText: 'Semaine' }); + await expect(weekButton).toBeVisible({ timeout: 10000 }); await weekButton.click(); - - // Week headers should show - await expect(page.getByText('Lun', { exact: true })).toBeVisible({ timeout: 15000 }); + const lunHeader = page.getByText('Lun', { exact: true }); + try { + await expect(lunHeader).toBeVisible({ timeout: 10000 }); + } catch { + await weekButton.click(); + await expect(lunHeader).toBeVisible({ timeout: 30000 }); + } await expect(page.getByText('Ven', { exact: true })).toBeVisible(); }); @@ -422,7 +427,7 @@ test.describe('Parent Schedule Consultation (Story 4.4)', () => { // Navigate forward and wait for the new day to load await page.getByLabel('Suivant').click(); // Wait for the day title to change, confirming navigation completed - await page.waitForTimeout(1500); + await page.waitForTimeout(3000); // Navigate back to the original day await page.getByLabel('Précédent').click(); diff --git a/frontend/e2e/sessions.spec.ts b/frontend/e2e/sessions.spec.ts index 1b0d778..6c8256c 100644 --- a/frontend/e2e/sessions.spec.ts +++ b/frontend/e2e/sessions.spec.ts @@ -295,7 +295,7 @@ test.describe('Sessions Management', () => { await page.goto(getTenantUrl('/settings/sessions')); // Should redirect to login - await expect(page).toHaveURL(/login/, { timeout: 5000 }); + await expect(page).toHaveURL(/login/, { timeout: 30000 }); }); }); @@ -332,7 +332,7 @@ test.describe('Sessions Management', () => { await page.locator('.back-button').click(); // Wait for navigation - URL should no longer contain /sessions - await expect(page).not.toHaveURL(/\/sessions/); + await expect(page).not.toHaveURL(/\/sessions/, { timeout: 15000 }); // Verify we're on the main settings page await expect(page.getByText(/paramètres|mes sessions/i).first()).toBeVisible(); diff --git a/frontend/e2e/settings.spec.ts b/frontend/e2e/settings.spec.ts index df3ab90..806f872 100644 --- a/frontend/e2e/settings.spec.ts +++ b/frontend/e2e/settings.spec.ts @@ -116,10 +116,19 @@ test.describe('Settings Page [P1]', () => { await page.goto(getTenantUrl('/settings')); - // Click on the Sessions card (it's a button with heading text) - await page.getByText(/mes sessions/i).click(); + // Wait for the settings page to be fully interactive before clicking + const sessionsCard = page.getByText(/mes sessions/i); + await expect(sessionsCard).toBeVisible({ timeout: 15000 }); - await expect(page).toHaveURL(/\/settings\/sessions/); + // Click and retry once if navigation doesn't happen (Svelte hydration race) + await sessionsCard.click(); + try { + await page.waitForURL(/\/settings\/sessions/, { timeout: 10000 }); + } catch { + // Retry click in case hydration wasn't complete + await sessionsCard.click(); + await page.waitForURL(/\/settings\/sessions/, { timeout: 30000 }); + } await expect( page.getByRole('heading', { name: /mes sessions/i }) ).toBeVisible(); diff --git a/frontend/e2e/student-grades.spec.ts b/frontend/e2e/student-grades.spec.ts new file mode 100644 index 0000000..6e20140 --- /dev/null +++ b/frontend/e2e/student-grades.spec.ts @@ -0,0 +1,415 @@ +import { test, expect } from '@playwright/test'; +import { execWithRetry, runSql, clearCache, resolveDeterministicIds, createTestUser, composeFile } from './helpers'; + +const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173'; +const urlMatch = baseUrl.match(/:(\d+)$/); +const PORT = urlMatch ? urlMatch[1] : '4173'; +const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`; + +const STUDENT_EMAIL = 'e2e-student-grades@example.com'; +const STUDENT_PASSWORD = 'StudentGrades123'; +const TEACHER_EMAIL = 'e2e-sg-teacher@example.com'; +const TEACHER_PASSWORD = 'TeacherGrades123'; +const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; + +let classId: string; +let subjectId: string; +let subject2Id: string; +let studentId: string; +let evalId1: string; +let evalId2: string; +let periodId: string; + +function uuid5(name: string): string { + return execWithRetry( + `docker compose -f "${composeFile}" exec -T php php -r '` + + `require "/app/vendor/autoload.php"; ` + + `echo Ramsey\\Uuid\\Uuid::uuid5("6ba7b814-9dad-11d1-80b4-00c04fd430c8","${name}")->toString();` + + `' 2>&1` + ).trim(); +} + +async function loginAsStudent(page: import('@playwright/test').Page) { + await page.goto(`${ALPHA_URL}/login`); + await page.locator('#email').fill(STUDENT_EMAIL); + await page.locator('#password').fill(STUDENT_PASSWORD); + await Promise.all([ + page.waitForURL(/\/dashboard/, { timeout: 60000 }), + page.getByRole('button', { name: /se connecter/i }).click() + ]); +} + +test.describe('Student Grade Consultation (Story 6.6)', () => { + test.describe.configure({ mode: 'serial' }); + + test.beforeAll(async () => { + // Create users + createTestUser('ecole-alpha', STUDENT_EMAIL, STUDENT_PASSWORD, 'ROLE_ELEVE --firstName=Émilie --lastName=Dubois'); + createTestUser('ecole-alpha', TEACHER_EMAIL, TEACHER_PASSWORD, 'ROLE_PROF'); + + const { schoolId, academicYearId } = resolveDeterministicIds(TENANT_ID); + + // Resolve student ID + const idOutput = execWithRetry( + `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "SELECT id FROM users WHERE email='${STUDENT_EMAIL}' AND tenant_id='${TENANT_ID}'" 2>&1` + ); + const idMatch = idOutput.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/); + studentId = idMatch![0]!; + + // Create deterministic IDs + classId = uuid5(`sg-class-${TENANT_ID}`); + subjectId = uuid5(`sg-subject1-${TENANT_ID}`); + subject2Id = uuid5(`sg-subject2-${TENANT_ID}`); + evalId1 = uuid5(`sg-eval1-${TENANT_ID}`); + evalId2 = uuid5(`sg-eval2-${TENANT_ID}`); + periodId = uuid5(`sg-period-${TENANT_ID}`); + + // Create class + runSql( + `INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` + + `VALUES ('${classId}', '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-SG-4A', '4ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` + ); + + // Create subjects + runSql( + `INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` + + `VALUES ('${subjectId}', '${TENANT_ID}', '${schoolId}', 'E2E-SG-Mathématiques', 'E2ESGMATH', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` + ); + runSql( + `INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` + + `VALUES ('${subject2Id}', '${TENANT_ID}', '${schoolId}', 'E2E-SG-Français', 'E2ESGFRA', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` + ); + + // Assign student to class + runSql( + `INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at) ` + + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${studentId}', '${classId}', '${academicYearId}', NOW(), NOW(), NOW()) ON CONFLICT (user_id, academic_year_id) DO NOTHING` + ); + + // Create teacher assignment + runSql( + `INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` + + `SELECT gen_random_uuid(), '${TENANT_ID}', u.id, '${classId}', '${subjectId}', '${academicYearId}', 'active', NOW(), NOW(), NOW() ` + + `FROM users u WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` + + `ON CONFLICT DO NOTHING` + ); + runSql( + `INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` + + `SELECT gen_random_uuid(), '${TENANT_ID}', u.id, '${classId}', '${subject2Id}', '${academicYearId}', 'active', NOW(), NOW(), NOW() ` + + `FROM users u WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` + + `ON CONFLICT DO NOTHING` + ); + + // Create published evaluation 1 (Maths - older) + runSql( + `INSERT INTO evaluations (id, tenant_id, class_id, subject_id, teacher_id, title, evaluation_date, grade_scale, coefficient, status, grades_published_at, created_at, updated_at) ` + + `SELECT '${evalId1}', '${TENANT_ID}', '${classId}', '${subjectId}', u.id, 'DS Mathématiques', '2026-03-01', 20, 2.0, 'published', NOW(), NOW(), NOW() ` + + `FROM users u WHERE u.email='${TEACHER_EMAIL}' AND u.tenant_id='${TENANT_ID}' ` + + `ON CONFLICT (id) DO NOTHING` + ); + + // Create published evaluation 2 (Français - more recent) + runSql( + `INSERT INTO evaluations (id, tenant_id, class_id, subject_id, teacher_id, title, evaluation_date, grade_scale, coefficient, status, grades_published_at, created_at, updated_at) ` + + `SELECT '${evalId2}', '${TENANT_ID}', '${classId}', '${subject2Id}', u.id, 'Dictée', '2026-03-15', 20, 1.0, 'published', NOW(), NOW(), NOW() ` + + `FROM users u WHERE u.email='${TEACHER_EMAIL}' AND u.tenant_id='${TENANT_ID}' ` + + `ON CONFLICT (id) DO NOTHING` + ); + + // Insert grades + runSql( + `INSERT INTO grades (id, tenant_id, evaluation_id, student_id, value, status, created_by, created_at, updated_at, appreciation) ` + + `SELECT gen_random_uuid(), '${TENANT_ID}', '${evalId1}', '${studentId}', 16.5, 'graded', u.id, NOW(), NOW(), 'Très bon travail' ` + + `FROM users u WHERE u.email='${TEACHER_EMAIL}' AND u.tenant_id='${TENANT_ID}' ` + + `ON CONFLICT (evaluation_id, student_id) DO NOTHING` + ); + runSql( + `INSERT INTO grades (id, tenant_id, evaluation_id, student_id, value, status, created_by, created_at, updated_at) ` + + `SELECT gen_random_uuid(), '${TENANT_ID}', '${evalId2}', '${studentId}', 14.0, 'graded', u.id, NOW(), NOW() ` + + `FROM users u WHERE u.email='${TEACHER_EMAIL}' AND u.tenant_id='${TENANT_ID}' ` + + `ON CONFLICT (evaluation_id, student_id) DO NOTHING` + ); + + // Insert class statistics + runSql( + `INSERT INTO evaluation_statistics (evaluation_id, average, min_grade, max_grade, median_grade, graded_count, updated_at) ` + + `VALUES ('${evalId1}', 14.2, 8.0, 18.5, 14.5, 25, NOW()) ON CONFLICT (evaluation_id) DO NOTHING` + ); + runSql( + `INSERT INTO evaluation_statistics (evaluation_id, average, min_grade, max_grade, median_grade, graded_count, updated_at) ` + + `VALUES ('${evalId2}', 12.8, 6.0, 17.0, 13.0, 25, NOW()) ON CONFLICT (evaluation_id) DO NOTHING` + ); + + // Find the academic period covering the current date (needed for /me/averages auto-detection) + const periodOutput = execWithRetry( + `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "SELECT id FROM academic_periods WHERE tenant_id='${TENANT_ID}' AND start_date <= CURRENT_DATE AND end_date >= CURRENT_DATE LIMIT 1" 2>&1` + ); + const periodMatch = periodOutput.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/); + periodId = periodMatch ? periodMatch[0]! : uuid5(`sg-period-${TENANT_ID}`); + + // Insert student averages (subject + general) + runSql( + `INSERT INTO student_averages (id, tenant_id, student_id, subject_id, period_id, average, grade_count, updated_at) ` + + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${studentId}', '${subjectId}', '${periodId}', 16.5, 1, NOW()) ` + + `ON CONFLICT (student_id, subject_id, period_id) DO NOTHING` + ); + runSql( + `INSERT INTO student_averages (id, tenant_id, student_id, subject_id, period_id, average, grade_count, updated_at) ` + + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${studentId}', '${subject2Id}', '${periodId}', 14.0, 1, NOW()) ` + + `ON CONFLICT (student_id, subject_id, period_id) DO NOTHING` + ); + runSql( + `INSERT INTO student_general_averages (id, tenant_id, student_id, period_id, average, updated_at) ` + + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${studentId}', '${periodId}', 15.25, NOW()) ` + + `ON CONFLICT (student_id, period_id) DO NOTHING` + ); + + clearCache(); + }); + + // ========================================================================= + // AC2: Dashboard notes — grades and averages visible + // ========================================================================= + + test('AC2: student sees recent grades on dashboard', async ({ page }) => { + await loginAsStudent(page); + + // Dashboard should show grades widget + const gradesSection = page.locator('.grades-list'); + await expect(gradesSection).toBeVisible({ timeout: 15000 }); + + // Should show at least one grade + const gradeItems = page.locator('.grade-item'); + await expect(gradeItems.first()).toBeVisible({ timeout: 10000 }); + }); + + test('AC2: student navigates to full grades page', async ({ page }) => { + await loginAsStudent(page); + await page.goto(`${ALPHA_URL}/dashboard/student-grades`); + + // Page title + await expect(page.getByRole('heading', { name: 'Mes notes' })).toBeVisible({ timeout: 15000 }); + + // Should show grade cards + const gradeCards = page.locator('.grade-card'); + await expect(gradeCards.first()).toBeVisible({ timeout: 10000 }); + + // Should show both grades (Dictée more recent first) + await expect(gradeCards).toHaveCount(2); + }); + + test('AC2: grades show value, subject, and evaluation title', async ({ page }) => { + await loginAsStudent(page); + await page.goto(`${ALPHA_URL}/dashboard/student-grades`); + + await expect(page.locator('.grade-card').first()).toBeVisible({ timeout: 15000 }); + + // Check first grade (Dictée - more recent) + const firstCard = page.locator('.grade-card').first(); + await expect(firstCard.locator('.grade-subject-btn')).toContainText('E2E-SG-Français'); + await expect(firstCard.locator('.grade-eval-title')).toContainText('Dictée'); + await expect(firstCard.locator('.grade-value')).toContainText('14/20'); + + // Check second grade (DS Maths) + const secondCard = page.locator('.grade-card').nth(1); + await expect(secondCard.locator('.grade-subject-btn')).toContainText('E2E-SG-Mathématiques'); + await expect(secondCard.locator('.grade-value')).toContainText('16.5/20'); + }); + + test('AC2: class statistics visible on grades', async ({ page }) => { + await loginAsStudent(page); + await page.goto(`${ALPHA_URL}/dashboard/student-grades`); + + await expect(page.locator('.grade-card').first()).toBeVisible({ timeout: 15000 }); + + const firstStats = page.locator('.grade-card').first().locator('.grade-card-stats'); + await expect(firstStats).toContainText('Moy. classe'); + }); + + test('AC2: appreciation visible on grade', async ({ page }) => { + await loginAsStudent(page); + await page.goto(`${ALPHA_URL}/dashboard/student-grades`); + + await expect(page.locator('.grade-card').first()).toBeVisible({ timeout: 15000 }); + + // The Maths grade has an appreciation + await expect(page.locator('.grade-appreciation').first()).toContainText('Très bon travail'); + }); + + // ========================================================================= + // AC3: Subject detail — click on subject shows all evaluations + // ========================================================================= + + test('AC3: click on subject shows detail modal', async ({ page }) => { + await loginAsStudent(page); + await page.goto(`${ALPHA_URL}/dashboard/student-grades`); + + // Wait for averages section + const avgCard = page.locator('.average-card').first(); + await expect(avgCard).toBeVisible({ timeout: 15000 }); + + // Click on first subject average card + await avgCard.click(); + + // Modal should appear + const modal = page.getByRole('dialog'); + await expect(modal).toBeVisible({ timeout: 5000 }); + + // Modal should show grade details + await expect(modal.locator('.detail-item')).toHaveCount(1); + }); + + test('AC3: subject detail modal closes with Escape', async ({ page }) => { + await loginAsStudent(page); + await page.goto(`${ALPHA_URL}/dashboard/student-grades`); + + const avgCard = page.locator('.average-card').first(); + await expect(avgCard).toBeVisible({ timeout: 15000 }); + + await avgCard.click(); + + const modal = page.getByRole('dialog'); + await expect(modal).toBeVisible({ timeout: 5000 }); + + await page.keyboard.press('Escape'); + await expect(modal).not.toBeVisible({ timeout: 5000 }); + }); + + // ========================================================================= + // AC4: Discover mode — notes hidden by default, click to reveal + // ========================================================================= + + test('AC4: discover mode toggle exists', async ({ page }) => { + await loginAsStudent(page); + await page.goto(`${ALPHA_URL}/dashboard/student-grades`); + + await expect(page.locator('.discover-toggle')).toBeVisible({ timeout: 15000 }); + await expect(page.locator('.toggle-label')).toContainText('Mode découverte'); + }); + + test('AC4: enabling discover mode hides grade values', async ({ page }) => { + await loginAsStudent(page); + await page.goto(`${ALPHA_URL}/dashboard/student-grades`); + + await expect(page.locator('.grade-card').first()).toBeVisible({ timeout: 15000 }); + + // Enable discover mode + const toggle = page.locator('.discover-toggle input'); + await toggle.check(); + + // Grades should show reveal buttons instead of values + await expect(page.locator('.reveal-btn').first()).toBeVisible({ timeout: 5000 }); + await expect(page.locator('.reveal-hint').first()).toContainText('Cliquer pour révéler'); + }); + + test('AC4: clicking reveal shows the grade value', async ({ page }) => { + await loginAsStudent(page); + await page.goto(`${ALPHA_URL}/dashboard/student-grades`); + + await expect(page.locator('.grade-card').first()).toBeVisible({ timeout: 15000 }); + + // Enable discover mode + await page.locator('.discover-toggle input').check(); + + await expect(page.locator('.reveal-btn').first()).toBeVisible({ timeout: 5000 }); + + // Click to reveal first grade + await page.locator('.reveal-btn').first().click(); + + // Grade value should now be visible + await expect(page.locator('.grade-card').first().locator('.grade-value')).toBeVisible({ timeout: 5000 }); + }); + + // ========================================================================= + // AC5: Badge "Nouveau" on recent grades + // ========================================================================= + + test('AC5: new grades show Nouveau badge', async ({ page }) => { + // Clear localStorage to simulate fresh session + await page.goto(`${ALPHA_URL}/login`); + await page.evaluate(() => { + localStorage.removeItem('classeo_grades_seen'); + localStorage.removeItem('classeo_grade_preferences'); + localStorage.removeItem('classeo_grades_revealed'); + }); + + await loginAsStudent(page); + await page.goto(`${ALPHA_URL}/dashboard/student-grades`); + + await expect(page.locator('.grade-card').first()).toBeVisible({ timeout: 15000 }); + + // Badges should be visible on new grades + await expect(page.locator('.badge-new').first()).toBeVisible({ timeout: 5000 }); + }); + + // ========================================================================= + // AC2: Averages section visible + // ========================================================================= + + test('AC2: subject averages section displays correctly', async ({ page }) => { + await loginAsStudent(page); + await page.goto(`${ALPHA_URL}/dashboard/student-grades`); + + // Wait for averages section + const avgSection = page.locator('.averages-section'); + await expect(avgSection).toBeVisible({ timeout: 15000 }); + + // Should show heading + await expect(avgSection.getByRole('heading', { name: 'Moyennes par matière' })).toBeVisible(); + + // Should show at least one average card + const avgCards = page.locator('.average-card'); + await expect(avgCards.first()).toBeVisible({ timeout: 10000 }); + }); + + test('AC2: general average visible on grades page', async ({ page }) => { + await loginAsStudent(page); + await page.goto(`${ALPHA_URL}/dashboard/student-grades`); + + const generalAvg = page.locator('.general-average'); + await expect(generalAvg).toBeVisible({ timeout: 15000 }); + await expect(generalAvg).toContainText('Moyenne générale'); + await expect(generalAvg.locator('.avg-value')).toContainText('/20'); + }); + + // ========================================================================= + // AC4: Discover mode persistence + // ========================================================================= + + test('AC4: discover mode persists after page reload', async ({ page }) => { + await loginAsStudent(page); + await page.goto(`${ALPHA_URL}/dashboard/student-grades`); + + await expect(page.locator('.grade-card').first()).toBeVisible({ timeout: 15000 }); + + // Enable discover mode + await page.locator('.discover-toggle input').check(); + await expect(page.locator('.reveal-btn').first()).toBeVisible({ timeout: 5000 }); + + // Reload the page + await page.reload(); + + // Discover mode should still be active after reload + await expect(page.locator('.grade-card').first()).toBeVisible({ timeout: 15000 }); + await expect(page.locator('.reveal-btn').first()).toBeVisible({ timeout: 5000 }); + + // Disable discover mode for cleanup + await page.locator('.discover-toggle input').uncheck(); + }); + + // ========================================================================= + // Navigation + // ========================================================================= + + test('student can navigate to grades page from nav bar', async ({ page }) => { + await loginAsStudent(page); + + const navLink = page.getByRole('link', { name: /mes notes/i }); + await expect(navLink).toBeVisible({ timeout: 15000 }); + + await navLink.click(); + await page.waitForURL(/student-grades/, { timeout: 10000 }); + + await expect(page.getByRole('heading', { name: 'Mes notes' })).toBeVisible({ timeout: 10000 }); + }); +}); diff --git a/frontend/e2e/student-schedule.spec.ts b/frontend/e2e/student-schedule.spec.ts index 6059aac..dee412b 100644 --- a/frontend/e2e/student-schedule.spec.ts +++ b/frontend/e2e/student-schedule.spec.ts @@ -350,9 +350,9 @@ test.describe('Student Schedule Consultation (Story 4.3)', () => { await page.goto(`${ALPHA_URL}/dashboard/schedule`); await navigateToSeededDay(page); - // Wait for day view to load + // Wait for day view to load (may need extra time for navigation on slow CI) await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({ - timeout: 15000 + timeout: 30000 }); // Switch to week view @@ -419,7 +419,7 @@ test.describe('Student Schedule Consultation (Story 4.3)', () => { // Desktop grid should be visible, mobile list should be hidden const weekList = page.locator('.week-list'); const weekGrid = page.locator('.week-grid'); - await expect(weekGrid).toBeVisible({ timeout: 15000 }); + await expect(weekGrid).toBeVisible({ timeout: 30000 }); await expect(weekList).not.toBeVisible(); }); diff --git a/frontend/e2e/teacher-replacements.spec.ts b/frontend/e2e/teacher-replacements.spec.ts index ef29f41..90dd3c7 100644 --- a/frontend/e2e/teacher-replacements.spec.ts +++ b/frontend/e2e/teacher-replacements.spec.ts @@ -270,8 +270,8 @@ test.describe('Teacher Replacements (Story 2.9)', () => { await confirmDialog.getByRole('button', { name: /terminer/i }).click(); - await expect(confirmDialog).not.toBeVisible({ timeout: 10000 }); - await expect(page.getByText(/remplacement terminé/i)).toBeVisible({ timeout: 10000 }); + await expect(confirmDialog).not.toBeVisible({ timeout: 15000 }); + await expect(page.getByText(/remplacement terminé/i)).toBeVisible({ timeout: 15000 }); }); }); diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 104f790..3c96f58 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -22,7 +22,7 @@ const config: PlaywrightTestConfig = { // Use 1 worker in CI to ensure no parallel execution across different browser projects workers: process.env.CI ? 1 : undefined, // Long sequential CI runs (~3h) cause sporadic slowdowns across all browsers - expect: process.env.CI ? { timeout: 15000 } : undefined, + expect: process.env.CI ? { timeout: 20000 } : undefined, use: { baseURL, trace: 'on-first-retry', @@ -45,7 +45,8 @@ const config: PlaywrightTestConfig = { use: { browserName: 'firefox' }, - timeout: process.env.CI ? 60000 : undefined + timeout: process.env.CI ? 90000 : undefined, + expect: process.env.CI ? { timeout: 25000 } : undefined }, { name: 'webkit', diff --git a/frontend/src/lib/components/organisms/Dashboard/DashboardStudent.svelte b/frontend/src/lib/components/organisms/Dashboard/DashboardStudent.svelte index 450558c..dc09837 100644 --- a/frontend/src/lib/components/organisms/Dashboard/DashboardStudent.svelte +++ b/frontend/src/lib/components/organisms/Dashboard/DashboardStudent.svelte @@ -2,8 +2,12 @@ import type { DemoData } from '$types'; import type { ScheduleSlot } from '$lib/features/schedule/api/schedule'; import type { StudentHomework, StudentHomeworkDetail } from '$lib/features/homework/api/studentHomework'; + import type { StudentGrade } from '$lib/features/grades/api/studentGrades'; import { fetchDaySchedule, fetchNextClass } from '$lib/features/schedule/api/schedule'; import { fetchStudentHomework, fetchHomeworkDetail } from '$lib/features/homework/api/studentHomework'; + import type { StudentAverages } from '$lib/features/grades/api/studentGrades'; + import { fetchMyGrades, fetchMyAverages } from '$lib/features/grades/api/studentGrades'; + import { isGradeNew, markGradesSeen } from '$lib/features/grades/stores/gradePreferences.svelte'; import HomeworkDetail from '$lib/components/organisms/StudentHomework/HomeworkDetail.svelte'; import { recordSync } from '$lib/features/schedule/stores/scheduleCache.svelte'; import { getHomeworkStatuses } from '$lib/features/homework/stores/homeworkStatus.svelte'; @@ -36,6 +40,11 @@ let studentHomeworks = $state([]); let homeworkLoading = $state(false); + // Grades widget state + let recentGrades = $state([]); + let studentAverages = $state(null); + let gradesLoading = $state(false); + let hwStatuses = $derived(getHomeworkStatuses()); let pendingHomeworks = $derived( @@ -88,6 +97,33 @@ } } + let gradeSeenTimerId: number | null = null; + + async function loadGrades() { + gradesLoading = true; + + try { + const [all, avgs] = await Promise.all([fetchMyGrades(), fetchMyAverages()]); + recentGrades = all.slice(0, 5); + studentAverages = avgs; + + const ids = all.map((g) => g.id); + gradeSeenTimerId = window.setTimeout(() => markGradesSeen(ids), 3000); + } catch { + // Silently fail on dashboard widget + } finally { + gradesLoading = false; + } + } + + function gradeColor(value: number | null, scale: number): string { + if (value === null || scale <= 0) return '#6b7280'; + const normalized = (value / scale) * 20; + if (normalized >= 14) return '#22c55e'; + if (normalized >= 10) return '#f59e0b'; + return '#ef4444'; + } + // Homework detail modal let selectedHomeworkDetail = $state(null); @@ -116,6 +152,10 @@ if (!isEleve) return; void loadTodaySchedule(); void loadHomeworks(); + void loadGrades(); + return () => { + if (gradeSeenTimerId !== null) window.clearTimeout(gradeSeenTimerId); + }; }); @@ -179,11 +219,51 @@ - {#if hasRealData} + {#if isEleve} + {#if gradesLoading} + + {:else if recentGrades.length === 0} +

Aucune note publiée

+ {:else} + {#if studentAverages?.generalAverage != null} +
+ Moyenne générale + + {studentAverages.generalAverage.toFixed(1)}/20 + +
+ {/if} +
    + {#each recentGrades as grade} +
  • +
    + {grade.subjectName ?? 'Matière'} + {#if isGradeNew(grade.id)} + Nouveau + {/if} + {#if grade.status === 'graded' && grade.value != null} + + {grade.value}/{grade.gradeScale} + + {:else if grade.status === 'absent'} + Absent + {:else if grade.status === 'dispensed'} + Dispensé + {/if} +
    + {grade.evaluationTitle} +
  • + {/each} +
+ + Voir toutes les notes → + + {/if} + {:else if hasRealData} {#if isLoading} {:else} @@ -406,6 +486,45 @@ color: #6b7280; } + .grade-badge-new { + font-size: 0.5625rem; + padding: 0.0625rem 0.375rem; + background: #eff6ff; + color: #2563eb; + border-radius: 1rem; + font-weight: 600; + text-transform: uppercase; + } + + .empty-grades { + margin: 0; + text-align: center; + padding: 1rem; + color: #6b7280; + font-size: 0.875rem; + } + + .widget-general-avg { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 0.75rem; + background: #f0fdf4; + border: 1px solid #bbf7d0; + border-radius: 0.5rem; + margin-bottom: 0.5rem; + } + + .widget-avg-label { + font-size: 0.75rem; + color: #6b7280; + } + + .widget-avg-value { + font-size: 1rem; + font-weight: 700; + } + /* Homework List */ .homework-list { list-style: none; diff --git a/frontend/src/lib/features/grades/api/studentGrades.ts b/frontend/src/lib/features/grades/api/studentGrades.ts new file mode 100644 index 0000000..00f6ed9 --- /dev/null +++ b/frontend/src/lib/features/grades/api/studentGrades.ts @@ -0,0 +1,82 @@ +import { getApiBaseUrl } from '$lib/api'; +import { authenticatedFetch } from '$lib/auth'; + +export interface StudentGrade { + id: string; + evaluationId: string; + evaluationTitle: string; + evaluationDate: string; + gradeScale: number; + coefficient: number; + subjectId: string; + subjectName: string | null; + value: number | null; + status: string; + appreciation: string | null; + publishedAt: string | null; + classAverage: number | null; + classMin: number | null; + classMax: number | null; +} + +export interface SubjectAverage { + subjectId: string; + subjectName: string | null; + average: number; + gradeCount: number; +} + +export interface StudentAverages { + studentId: string; + periodId: string | null; + subjectAverages: SubjectAverage[]; + generalAverage: number | null; +} + +/** + * Récupère toutes les notes publiées de l'élève connecté. + */ +export async function fetchMyGrades(): Promise { + const apiUrl = getApiBaseUrl(); + const response = await authenticatedFetch(`${apiUrl}/me/grades`); + + if (!response.ok) { + throw new Error(`Erreur lors du chargement des notes (${response.status})`); + } + + const json = await response.json(); + // API Platform returns hydra:member or raw array + return json['hydra:member'] ?? json.member ?? json; +} + +/** + * Récupère les notes de l'élève pour une matière. + */ +export async function fetchMyGradesBySubject(subjectId: string): Promise { + const apiUrl = getApiBaseUrl(); + const response = await authenticatedFetch( + `${apiUrl}/me/grades/subject/${encodeURIComponent(subjectId)}` + ); + + if (!response.ok) { + throw new Error(`Erreur lors du chargement des notes (${response.status})`); + } + + const json = await response.json(); + return json['hydra:member'] ?? json.member ?? json; +} + +/** + * Récupère les moyennes de l'élève connecté. + */ +export async function fetchMyAverages(periodId?: string): Promise { + const apiUrl = getApiBaseUrl(); + const params = periodId ? `?periodId=${encodeURIComponent(periodId)}` : ''; + const response = await authenticatedFetch(`${apiUrl}/me/averages${params}`); + + if (!response.ok) { + throw new Error(`Erreur lors du chargement des moyennes (${response.status})`); + } + + return response.json(); +} diff --git a/frontend/src/lib/features/grades/stores/gradePreferences.svelte.ts b/frontend/src/lib/features/grades/stores/gradePreferences.svelte.ts new file mode 100644 index 0000000..2285e02 --- /dev/null +++ b/frontend/src/lib/features/grades/stores/gradePreferences.svelte.ts @@ -0,0 +1,105 @@ +import { browser } from '$app/environment'; + +const PREFS_KEY = 'classeo_grade_preferences'; +const SEEN_KEY = 'classeo_grades_seen'; +const REVEALED_KEY = 'classeo_grades_revealed'; + +export type RevealMode = 'immediate' | 'discover'; + +interface GradePreferences { + revealMode: RevealMode; +} + +// Reactive state +let revealMode = $state('immediate'); +let seenGradeIds = $state>(new Set()); +let revealedGradeIds = $state>(new Set()); + +// Load from localStorage on init +if (browser) { + try { + const stored = localStorage.getItem(PREFS_KEY); + if (stored) { + const prefs = JSON.parse(stored) as GradePreferences; + revealMode = prefs.revealMode ?? 'immediate'; + } + } catch { + // Ignore parse errors + } + + try { + const stored = localStorage.getItem(SEEN_KEY); + if (stored) { + seenGradeIds = new Set(JSON.parse(stored) as string[]); + } + } catch { + // Ignore + } + + try { + const stored = localStorage.getItem(REVEALED_KEY); + if (stored) { + revealedGradeIds = new Set(JSON.parse(stored) as string[]); + } + } catch { + // Ignore + } +} + +function savePrefs(): void { + if (!browser) return; + try { + localStorage.setItem(PREFS_KEY, JSON.stringify({ revealMode })); + } catch { + // QuotaExceededError — preference still active in memory + } +} + +function saveSeen(): void { + if (!browser) return; + try { + localStorage.setItem(SEEN_KEY, JSON.stringify([...seenGradeIds])); + } catch { + // QuotaExceededError + } +} + +function saveRevealed(): void { + if (!browser) return; + try { + localStorage.setItem(REVEALED_KEY, JSON.stringify([...revealedGradeIds])); + } catch { + // QuotaExceededError + } +} + +export function getRevealMode(): RevealMode { + return revealMode; +} + +export function setRevealMode(mode: RevealMode): void { + revealMode = mode; + savePrefs(); +} + +export function isGradeNew(gradeId: string): boolean { + return !seenGradeIds.has(gradeId); +} + +export function markGradesSeen(gradeIds: string[]): void { + seenGradeIds = new Set([...seenGradeIds, ...gradeIds]); + saveSeen(); +} + +export function isGradeRevealed(gradeId: string): boolean { + return revealedGradeIds.has(gradeId); +} + +export function revealGrade(gradeId: string): void { + revealedGradeIds = new Set([...revealedGradeIds, gradeId]); + saveRevealed(); +} + +export function isDiscoverMode(): boolean { + return revealMode === 'discover'; +} diff --git a/frontend/src/routes/dashboard/+layout.svelte b/frontend/src/routes/dashboard/+layout.svelte index 164661a..2ff0487 100644 --- a/frontend/src/routes/dashboard/+layout.svelte +++ b/frontend/src/routes/dashboard/+layout.svelte @@ -109,6 +109,7 @@ {/if} {#if isEleve} Mon EDT + Mes notes Compétences {/if} {#if isParent} @@ -164,6 +165,9 @@ Mon emploi du temps + + Mes notes + Compétences diff --git a/frontend/src/routes/dashboard/student-grades/+page.svelte b/frontend/src/routes/dashboard/student-grades/+page.svelte new file mode 100644 index 0000000..6fa4abf --- /dev/null +++ b/frontend/src/routes/dashboard/student-grades/+page.svelte @@ -0,0 +1,691 @@ + + + + +
+ + + {#if error} + + {/if} + + {#if isLoading} + + {:else if grades.length === 0} +
+

Aucune note publiée pour le moment.

+
+ {:else} + + {#if averages && averages.subjectAverages.length > 0} +
+

Moyennes par matière

+
+ {#each averages.subjectAverages as avg} + + {/each} +
+
+ {/if} + + +
+

Dernières notes

+
    + {#each grades as grade (grade.id)} + {@const discover = isDiscoverMode() && !isGradeRevealed(grade.id)} + {@const isNew = isGradeNew(grade.id)} +
  • +
    + + {formatDate(grade.evaluationDate)} + {#if isNew} + Nouveau + {/if} +
    +
    + {grade.evaluationTitle} + {#if discover} + + {:else if grade.status === 'graded' && grade.value != null} + + {grade.value}/{grade.gradeScale} + + {:else if grade.status === 'absent'} + Absent + {:else if grade.status === 'dispensed'} + Dispensé + {/if} +
    + {#if !discover && grade.classAverage != null} +
    + Moy. classe : {grade.classAverage.toFixed(1)} + {#if grade.classMin != null && grade.classMax != null} + Min : {grade.classMin.toFixed(1)} / Max : {grade.classMax.toFixed(1)} + {/if} +
    + {/if} + {#if !discover && grade.appreciation} +

    {grade.appreciation}

    + {/if} +
    + Coeff. {grade.coefficient} +
    +
  • + {/each} +
+
+ {/if} +
+ + +{#if selectedSubjectId} + + +{/if} + +