diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 2cd9474..a6924e9 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -53,8 +53,8 @@ development_status: 1-9-dashboard-placeholder-avec-preview-score-serenite: done epic-1-retrospective: optional - # Epic 2: Configuration Établissement (13 stories) - epic-2: done + # Epic 2: Configuration Établissement (14 stories) + epic-2: in-progress 2-1-creation-et-gestion-des-classes: done 2-2-creation-et-gestion-des-matieres: done 2-3-gestion-des-periodes-scolaires: done @@ -74,6 +74,7 @@ development_status: 2-12b-optimistic-update-pages-admin: done 2-13-personnalisation-visuelle-etablissement: done 2-15-organisation-sections-dashboard-admin: done + 2-17-provisioning-automatique-etablissements: ready-for-dev # Tâches post-MVP différées de 2-10 epic-2-retrospective: done # Epic 3: Import & Onboarding (5 stories) @@ -94,8 +95,8 @@ development_status: 4-6-recherche-parent-liaison-eleve: done epic-4-retrospective: optional - # Epic 5: Devoirs & Règles (8 stories) - epic-5: done + # Epic 5: Devoirs & Règles (9 stories) + epic-5: in-progress 5-1-creation-de-devoirs: done 5-2-duplication-de-devoirs-multi-classes: done 5-2b-optimisation-chargement-page-devoirs: done @@ -107,18 +108,23 @@ development_status: 5-8-consultation-des-devoirs-par-le-parent: done 5-9-description-enrichie-et-pieces-jointes-enseignant: done 5-10-rendu-de-devoir-par-leleve: done + 5-11-description-enrichie-upload-calendrier-devoirs: ready-for-dev # Tâches UX différées de 5-1 epic-5-retrospective: optional - # Epic 6: Notes & Évaluations (8 stories) + # Epic 6: Notes & Évaluations (12 stories) epic-6: in-progress 6-1-creation-devaluation: done 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-7-consultation-notes-par-le-parent: ready-for-dev + 6-5-mode-competences: done + 6-6-consultation-notes-par-leleve: done + 6-7-consultation-notes-par-le-parent: done 6-8-statistiques-enseignant: ready-for-dev + 6-9-grade-voter-et-acces-notes-affectations: ready-for-dev # Débloque tâches différées de 2-6, 2-8, 2-9 + 6-10-statistiques-notes-par-matiere-admin: ready-for-dev # Débloque tâches différées de 2-2 + 6-11-audit-trail-evenements-notes: ready-for-dev # Débloque tâches différées de 1-7 + 6-12-correctifs-mode-competences: ready-for-dev # Patches critiques review 6-5 epic-6-retrospective: optional # Epic 7: Vie Scolaire (8 stories) @@ -164,3 +170,7 @@ development_status: 10-6-droits-rgpd-utilisateurs: ready-for-dev 10-7-passage-de-classe-et-cloture-annee: ready-for-dev epic-10-retrospective: optional + + # Epic 11: Infrastructure Transversale + epic-11: in-progress + 11-1-infrastructure-cache-offline-pwa: ready-for-dev # Centralise tâches offline de 4-3, 5-7, 5-8, 6-6, 6-7 diff --git a/backend/config/services.yaml b/backend/config/services.yaml index bf027cb..6f43968 100644 --- a/backend/config/services.yaml +++ b/backend/config/services.yaml @@ -98,6 +98,13 @@ services: App\Administration\Domain\Policy\ConsentementParentalPolicy: autowire: true + App\Scolarite\Domain\Policy\VisibiliteNotesPolicy: + autowire: true + + # Ports + App\Scolarite\Application\Port\ParentGradeDelayReader: + alias: App\Scolarite\Infrastructure\Service\DatabaseParentGradeDelayReader + # Email handlers App\Administration\Infrastructure\Messaging\SendActivationConfirmationHandler: arguments: diff --git a/backend/migrations/Version20260406074932.php b/backend/migrations/Version20260406074932.php new file mode 100644 index 0000000..f78db4e --- /dev/null +++ b/backend/migrations/Version20260406074932.php @@ -0,0 +1,30 @@ +addSql(<<<'SQL' + ALTER TABLE school_grading_configurations + ADD COLUMN parent_grade_delay_hours SMALLINT NOT NULL DEFAULT 24 + CHECK (parent_grade_delay_hours >= 0 AND parent_grade_delay_hours <= 72) + SQL); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE school_grading_configurations DROP COLUMN parent_grade_delay_hours'); + } +} diff --git a/backend/src/Administration/Domain/Model/SchoolClass/SchoolClass.php b/backend/src/Administration/Domain/Model/SchoolClass/SchoolClass.php index 02281af..e5187cc 100644 --- a/backend/src/Administration/Domain/Model/SchoolClass/SchoolClass.php +++ b/backend/src/Administration/Domain/Model/SchoolClass/SchoolClass.php @@ -107,6 +107,14 @@ final class SchoolClass extends AggregateRoot $this->level = $niveau; $this->updatedAt = $at; + + $this->recordEvent(new ClasseModifiee( + classId: $this->id, + tenantId: $this->tenantId, + ancienNom: $this->name, + nouveauNom: $this->name, + occurredOn: $at, + )); } /** diff --git a/backend/src/Scolarite/Application/Port/ParentGradeDelayReader.php b/backend/src/Scolarite/Application/Port/ParentGradeDelayReader.php new file mode 100644 index 0000000..507ccba --- /dev/null +++ b/backend/src/Scolarite/Application/Port/ParentGradeDelayReader.php @@ -0,0 +1,18 @@ + $grades + */ + public function __construct( + public string $childId, + public string $firstName, + public string $lastName, + public array $grades, + ) { + } +} diff --git a/backend/src/Scolarite/Application/Query/GetChildrenGrades/ChildGradesSummaryDto.php b/backend/src/Scolarite/Application/Query/GetChildrenGrades/ChildGradesSummaryDto.php new file mode 100644 index 0000000..b54adbb --- /dev/null +++ b/backend/src/Scolarite/Application/Query/GetChildrenGrades/ChildGradesSummaryDto.php @@ -0,0 +1,21 @@ + $subjectAverages + */ + public function __construct( + public string $childId, + public string $firstName, + public string $lastName, + public ?string $periodId, + public array $subjectAverages, + public ?float $generalAverage, + ) { + } +} diff --git a/backend/src/Scolarite/Application/Query/GetChildrenGrades/GetChildrenGradesHandler.php b/backend/src/Scolarite/Application/Query/GetChildrenGrades/GetChildrenGradesHandler.php new file mode 100644 index 0000000..f492bb6 --- /dev/null +++ b/backend/src/Scolarite/Application/Query/GetChildrenGrades/GetChildrenGradesHandler.php @@ -0,0 +1,186 @@ + */ + public function __invoke(GetChildrenGradesQuery $query): array + { + $tenantId = TenantId::fromString($query->tenantId); + $allChildren = $this->parentChildrenReader->childrenOf($query->parentId, $tenantId); + + if ($allChildren === []) { + return []; + } + + $children = $query->childId !== null + ? array_values(array_filter($allChildren, static fn (array $c): bool => $c['studentId'] === $query->childId)) + : $allChildren; + + if ($children === []) { + return []; + } + + $delaiHeures = $this->delayReader->delayHoursForTenant($tenantId); + + $result = []; + + foreach ($children as $child) { + $studentId = UserId::fromString($child['studentId']); + + // Get all grades for this student (not filtered by class) + $studentGrades = $this->gradeRepository->findByStudent($studentId, $tenantId); + + if ($studentGrades === []) { + $result[] = new ChildGradesDto( + childId: $child['studentId'], + firstName: $child['firstName'], + lastName: $child['lastName'], + grades: [], + ); + + continue; + } + + // Collect unique evaluation IDs and load evaluations + $evaluationIds = array_values(array_unique( + array_map(static fn (Grade $g): string => (string) $g->evaluationId, $studentGrades), + )); + + $evaluationsById = []; + + foreach ($evaluationIds as $evalIdStr) { + $evaluation = $this->evaluationRepository->findById( + EvaluationId::fromString($evalIdStr), + $tenantId, + ); + + if ($evaluation !== null) { + $evaluationsById[$evalIdStr] = $evaluation; + } + } + + // Filter evaluations visible to parents (published + delay elapsed) + $visibleEvaluationIds = []; + + foreach ($evaluationsById as $evalIdStr => $evaluation) { + if ($this->visibiliteNotesPolicy->visiblePourParent($evaluation, $delaiHeures)) { + $visibleEvaluationIds[$evalIdStr] = true; + } + } + + // Filter by subject if requested + if ($query->subjectId !== null) { + $filterSubjectId = $query->subjectId; + + foreach ($visibleEvaluationIds as $evalIdStr => $_) { + $evaluation = $evaluationsById[$evalIdStr]; + + if ((string) $evaluation->subjectId !== $filterSubjectId) { + unset($visibleEvaluationIds[$evalIdStr]); + } + } + } + + if ($visibleEvaluationIds === []) { + $result[] = new ChildGradesDto( + childId: $child['studentId'], + firstName: $child['firstName'], + lastName: $child['lastName'], + grades: [], + ); + + continue; + } + + // Resolve display names + $visibleEvaluations = array_values(array_filter( + $evaluationsById, + static fn (Evaluation $e): bool => isset($visibleEvaluationIds[(string) $e->id]), + )); + $subjectIds = array_values(array_unique( + array_map(static fn (Evaluation $e): string => (string) $e->subjectId, $visibleEvaluations), + )); + $subjects = $this->displayReader->subjectDisplay($query->tenantId, ...$subjectIds); + + // Build grade DTOs + $childGrades = []; + + foreach ($studentGrades as $grade) { + $evalIdStr = (string) $grade->evaluationId; + + if (!isset($visibleEvaluationIds[$evalIdStr])) { + continue; + } + + $evaluation = $evaluationsById[$evalIdStr]; + $subjectInfo = $subjects[(string) $evaluation->subjectId] ?? ['name' => null, 'color' => null]; + $statistics = $this->statisticsRepository->findByEvaluation($evaluation->id); + + $childGrades[] = new ParentGradeDto( + id: (string) $grade->id, + evaluationId: $evalIdStr, + evaluationTitle: $evaluation->title, + evaluationDate: $evaluation->evaluationDate->format('Y-m-d'), + gradeScale: $evaluation->gradeScale->maxValue, + coefficient: $evaluation->coefficient->value, + subjectId: (string) $evaluation->subjectId, + subjectName: $subjectInfo['name'], + subjectColor: $subjectInfo['color'], + value: $grade->value?->value, + status: $grade->status->value, + appreciation: $grade->appreciation, + publishedAt: $evaluation->gradesPublishedAt?->format('Y-m-d\TH:i:sP') ?? '', + classAverage: $statistics?->average, + classMin: $statistics?->min, + classMax: $statistics?->max, + ); + } + + // Sort by evaluation date descending + usort($childGrades, static fn (ParentGradeDto $a, ParentGradeDto $b): int => $b->evaluationDate <=> $a->evaluationDate); + + $result[] = new ChildGradesDto( + childId: $child['studentId'], + firstName: $child['firstName'], + lastName: $child['lastName'], + grades: $childGrades, + ); + } + + return $result; + } +} diff --git a/backend/src/Scolarite/Application/Query/GetChildrenGrades/GetChildrenGradesQuery.php b/backend/src/Scolarite/Application/Query/GetChildrenGrades/GetChildrenGradesQuery.php new file mode 100644 index 0000000..d84fbd7 --- /dev/null +++ b/backend/src/Scolarite/Application/Query/GetChildrenGrades/GetChildrenGradesQuery.php @@ -0,0 +1,16 @@ + */ + public function __invoke(GetChildrenGradesSummaryQuery $query): array + { + // Réutilise le handler notes qui applique le délai de visibilité + $childrenGrades = ($this->gradesHandler)(new GetChildrenGradesQuery( + parentId: $query->parentId, + tenantId: $query->tenantId, + )); + + $result = []; + + foreach ($childrenGrades as $child) { + // Grouper par matière et calculer les moyennes pondérées + $subjectData = []; + + foreach ($child->grades as $grade) { + if ($grade->value === null) { + continue; + } + + $key = $grade->subjectId; + + if (!isset($subjectData[$key])) { + $subjectData[$key] = [ + 'subjectId' => $grade->subjectId, + 'subjectName' => $grade->subjectName, + 'weightedSum' => 0.0, + 'coefficientSum' => 0.0, + 'gradeCount' => 0, + ]; + } + + $subjectData[$key]['weightedSum'] += $grade->value * $grade->coefficient; + $subjectData[$key]['coefficientSum'] += $grade->coefficient; + ++$subjectData[$key]['gradeCount']; + } + + $subjectAverages = []; + + foreach ($subjectData as $data) { + if ($data['coefficientSum'] > 0) { + $subjectAverages[] = [ + 'subjectId' => $data['subjectId'], + 'subjectName' => $data['subjectName'], + 'average' => round($data['weightedSum'] / $data['coefficientSum'], 2), + 'gradeCount' => $data['gradeCount'], + ]; + } + } + + $generalAverage = null; + + if ($subjectAverages !== []) { + $averages = array_map(static fn (array $a): float => $a['average'], $subjectAverages); + $generalAverage = round(array_sum($averages) / count($averages), 2); + } + + $result[] = new ChildGradesSummaryDto( + childId: $child->childId, + firstName: $child->firstName, + lastName: $child->lastName, + periodId: null, + subjectAverages: $subjectAverages, + generalAverage: $generalAverage, + ); + } + + return $result; + } +} diff --git a/backend/src/Scolarite/Application/Query/GetChildrenGrades/GetChildrenGradesSummaryQuery.php b/backend/src/Scolarite/Application/Query/GetChildrenGrades/GetChildrenGradesSummaryQuery.php new file mode 100644 index 0000000..80fa063 --- /dev/null +++ b/backend/src/Scolarite/Application/Query/GetChildrenGrades/GetChildrenGradesSummaryQuery.php @@ -0,0 +1,15 @@ +notesPubliees(); } - public function visiblePourParent(Evaluation $evaluation): bool + public function visiblePourParent(Evaluation $evaluation, int $delaiHeures = self::DELAI_PARENTS_HEURES_DEFAUT): bool { if (!$evaluation->notesPubliees()) { return false; } - $delai = $evaluation->gradesPublishedAt?->modify('+' . self::DELAI_PARENTS_HEURES . ' hours'); + $delaiHeures = max(0, $delaiHeures); + $delai = $evaluation->gradesPublishedAt?->modify('+' . $delaiHeures . ' hours'); return $delai !== null && $delai <= $this->clock->now(); } diff --git a/backend/src/Scolarite/Domain/Repository/GradeRepository.php b/backend/src/Scolarite/Domain/Repository/GradeRepository.php index a3a45f0..85a7253 100644 --- a/backend/src/Scolarite/Domain/Repository/GradeRepository.php +++ b/backend/src/Scolarite/Domain/Repository/GradeRepository.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Scolarite\Domain\Repository; +use App\Administration\Domain\Model\User\UserId; use App\Scolarite\Domain\Exception\GradeNotFoundException; use App\Scolarite\Domain\Model\Evaluation\EvaluationId; use App\Scolarite\Domain\Model\Grade\Grade; @@ -32,4 +33,7 @@ interface GradeRepository public function findByEvaluations(array $evaluationIds, TenantId $tenantId): array; public function hasGradesForEvaluation(EvaluationId $evaluationId, TenantId $tenantId): bool; + + /** @return array */ + public function findByStudent(UserId $studentId, TenantId $tenantId): array; } diff --git a/backend/src/Scolarite/Infrastructure/Api/Controller/ParentGradeController.php b/backend/src/Scolarite/Infrastructure/Api/Controller/ParentGradeController.php new file mode 100644 index 0000000..5f01b03 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Controller/ParentGradeController.php @@ -0,0 +1,172 @@ +getSecurityUser(); + + $children = ($this->gradesHandler)(new GetChildrenGradesQuery( + parentId: $user->userId(), + tenantId: $user->tenantId(), + childId: $childId, + )); + + if ($children === []) { + throw new NotFoundHttpException('Enfant non trouvé ou non lié à ce parent.'); + } + + return new JsonResponse([ + 'data' => $this->serializeChild($children[0]), + ]); + } + + /** + * Notes d'un enfant filtrées par matière. + */ + #[Route('/api/me/children/{childId}/grades/subject/{subjectId}', name: 'api_parent_child_grades_by_subject', methods: ['GET'])] + public function childGradesBySubject(string $childId, string $subjectId): JsonResponse + { + $user = $this->getSecurityUser(); + + $children = ($this->gradesHandler)(new GetChildrenGradesQuery( + parentId: $user->userId(), + tenantId: $user->tenantId(), + childId: $childId, + subjectId: $subjectId, + )); + + if ($children === []) { + throw new NotFoundHttpException('Enfant non trouvé ou non lié à ce parent.'); + } + + return new JsonResponse([ + 'data' => $this->serializeChild($children[0]), + ]); + } + + /** + * Résumé des moyennes de tous les enfants. + */ + #[Route('/api/me/children/grades/summary', name: 'api_parent_children_grades_summary', methods: ['GET'])] + public function gradesSummary(Request $request): JsonResponse + { + $user = $this->getSecurityUser(); + + $periodId = $request->query->get('periodId'); + + $summaries = ($this->summaryHandler)(new GetChildrenGradesSummaryQuery( + parentId: $user->userId(), + tenantId: $user->tenantId(), + periodId: is_string($periodId) && $periodId !== '' ? $periodId : null, + )); + + return new JsonResponse([ + 'data' => array_map($this->serializeSummary(...), $summaries), + ]); + } + + private function getSecurityUser(): SecurityUser + { + $user = $this->security->getUser(); + + if (!$user instanceof SecurityUser) { + throw new AccessDeniedHttpException('Authentification requise.'); + } + + return $user; + } + + /** + * @return array + */ + private function serializeChild(ChildGradesDto $child): array + { + return [ + 'childId' => $child->childId, + 'firstName' => $child->firstName, + 'lastName' => $child->lastName, + 'grades' => array_map($this->serializeGrade(...), $child->grades), + ]; + } + + /** + * @return array + */ + private function serializeGrade(ParentGradeDto $grade): array + { + return [ + 'id' => $grade->id, + 'evaluationId' => $grade->evaluationId, + 'evaluationTitle' => $grade->evaluationTitle, + 'evaluationDate' => $grade->evaluationDate, + 'gradeScale' => $grade->gradeScale, + 'coefficient' => $grade->coefficient, + 'subjectId' => $grade->subjectId, + 'subjectName' => $grade->subjectName, + 'subjectColor' => $grade->subjectColor, + 'value' => $grade->value, + 'status' => $grade->status, + 'appreciation' => $grade->appreciation, + 'publishedAt' => $grade->publishedAt, + 'classAverage' => $grade->classAverage, + 'classMin' => $grade->classMin, + 'classMax' => $grade->classMax, + ]; + } + + /** + * @return array + */ + private function serializeSummary(ChildGradesSummaryDto $summary): array + { + return [ + 'childId' => $summary->childId, + 'firstName' => $summary->firstName, + 'lastName' => $summary->lastName, + 'periodId' => $summary->periodId, + 'subjectAverages' => $summary->subjectAverages, + 'generalAverage' => $summary->generalAverage, + ]; + } +} 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/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineGradeRepository.php b/backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineGradeRepository.php index 9809080..5798152 100644 --- a/backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineGradeRepository.php +++ b/backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineGradeRepository.php @@ -170,6 +170,23 @@ final readonly class DoctrineGradeRepository implements GradeRepository return (int) $countValue > 0; } + #[Override] + public function findByStudent(UserId $studentId, TenantId $tenantId): array + { + $rows = $this->connection->fetchAllAssociative( + 'SELECT * FROM grades + WHERE student_id = :student_id + AND tenant_id = :tenant_id + ORDER BY created_at DESC', + [ + 'student_id' => (string) $studentId, + 'tenant_id' => (string) $tenantId, + ], + ); + + return array_map($this->hydrate(...), $rows); + } + /** @param array $row */ private function hydrate(array $row): Grade { diff --git a/backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemoryGradeRepository.php b/backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemoryGradeRepository.php index e3a695e..7cfc513 100644 --- a/backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemoryGradeRepository.php +++ b/backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemoryGradeRepository.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Scolarite\Infrastructure\Persistence\InMemory; +use App\Administration\Domain\Model\User\UserId; use App\Scolarite\Domain\Exception\GradeNotFoundException; use App\Scolarite\Domain\Model\Evaluation\EvaluationId; use App\Scolarite\Domain\Model\Grade\Grade; @@ -98,4 +99,14 @@ final class InMemoryGradeRepository implements GradeRepository return false; } + + #[Override] + public function findByStudent(UserId $studentId, TenantId $tenantId): array + { + return array_values(array_filter( + $this->byId, + static fn (Grade $g): bool => $g->studentId->equals($studentId) + && $g->tenantId->equals($tenantId), + )); + } } diff --git a/backend/src/Scolarite/Infrastructure/Security/GradeParentVoter.php b/backend/src/Scolarite/Infrastructure/Security/GradeParentVoter.php new file mode 100644 index 0000000..8574d66 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Security/GradeParentVoter.php @@ -0,0 +1,43 @@ + + */ +final class GradeParentVoter extends Voter +{ + public const string VIEW = 'GRADE_PARENT_VIEW'; + + #[Override] + protected function supports(string $attribute, mixed $subject): bool + { + return $attribute === self::VIEW; + } + + #[Override] + protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool + { + $user = $token->getUser(); + + if (!$user instanceof SecurityUser) { + return false; + } + + return in_array(Role::PARENT->value, $user->getRoles(), true); + } +} diff --git a/backend/src/Scolarite/Infrastructure/Service/DatabaseParentGradeDelayReader.php b/backend/src/Scolarite/Infrastructure/Service/DatabaseParentGradeDelayReader.php new file mode 100644 index 0000000..6950e2c --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Service/DatabaseParentGradeDelayReader.php @@ -0,0 +1,38 @@ +connection->fetchOne( + 'SELECT parent_grade_delay_hours FROM school_grading_configurations WHERE tenant_id = :tenant_id LIMIT 1', + ['tenant_id' => (string) $tenantId], + ); + + if ($result === false || !is_numeric($result)) { + return self::DEFAULT_DELAY_HOURS; + } + + return (int) $result; + } +} diff --git a/backend/tests/Functional/Scolarite/Api/ParentGradeEndpointsTest.php b/backend/tests/Functional/Scolarite/Api/ParentGradeEndpointsTest.php new file mode 100644 index 0000000..a990e6a --- /dev/null +++ b/backend/tests/Functional/Scolarite/Api/ParentGradeEndpointsTest.php @@ -0,0 +1,461 @@ +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 AND class_id = :cid)', ['tid' => self::TENANT_ID, 'cid' => self::CLASS_ID]); + $connection->executeStatement('DELETE FROM grade_events WHERE grade_id IN (SELECT id FROM grades WHERE tenant_id = :tid AND evaluation_id IN (SELECT id FROM evaluations WHERE class_id = :cid))', ['tid' => self::TENANT_ID, 'cid' => self::CLASS_ID]); + $connection->executeStatement('DELETE FROM grades WHERE tenant_id = :tid AND evaluation_id IN (SELECT id FROM evaluations WHERE class_id = :cid)', ['tid' => self::TENANT_ID, 'cid' => self::CLASS_ID]); + $connection->executeStatement('DELETE FROM evaluations WHERE tenant_id = :tid AND class_id = :cid', ['tid' => self::TENANT_ID, 'cid' => self::CLASS_ID]); + $connection->executeStatement('DELETE FROM student_guardians WHERE guardian_id = :gid', ['gid' => self::PARENT_ID]); + $connection->executeStatement('DELETE FROM class_assignments WHERE user_id = :uid', ['uid' => self::STUDENT_ID]); + $connection->executeStatement('DELETE FROM users WHERE id IN (:p, :s, :t)', ['p' => self::PARENT_ID, 's' => self::STUDENT_ID, 't' => self::TEACHER_ID]); + + parent::tearDown(); + } + + // ========================================================================= + // GET /api/me/children/{childId}/grades — Auth & Access + // ========================================================================= + + #[Test] + public function getChildGradesReturns401WithoutAuthentication(): void + { + $client = static::createClient(); + $client->request('GET', self::BASE_URL . '/me/children/' . self::STUDENT_ID . '/grades', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseStatusCodeSame(401); + } + + #[Test] + public function getChildGradesReturns403ForStudent(): void + { + $client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']); + $client->request('GET', self::BASE_URL . '/me/children/' . self::STUDENT_ID . '/grades', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseStatusCodeSame(403); + } + + #[Test] + public function getChildGradesReturns403ForTeacher(): void + { + $client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']); + $client->request('GET', self::BASE_URL . '/me/children/' . self::STUDENT_ID . '/grades', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseStatusCodeSame(403); + } + + #[Test] + public function getChildGradesReturns404ForUnlinkedChild(): void + { + $unlinkedChildId = '99990001-0001-0001-0001-000000000099'; + $client = $this->createAuthenticatedClient(self::PARENT_ID, ['ROLE_PARENT']); + $client->request('GET', self::BASE_URL . '/me/children/' . $unlinkedChildId . '/grades', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseStatusCodeSame(404); + } + + // ========================================================================= + // GET /api/me/children/{childId}/grades — Happy path + // ========================================================================= + + #[Test] + public function getChildGradesReturnsGradesForLinkedChild(): void + { + $client = $this->createAuthenticatedClient(self::PARENT_ID, ['ROLE_PARENT']); + $client->request('GET', self::BASE_URL . '/me/children/' . self::STUDENT_ID . '/grades', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseIsSuccessful(); + + /** @var string $content */ + $content = $client->getResponse()->getContent(); + /** @var array{data: array{childId: string, grades: list>}} $json */ + $json = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + + self::assertSame(self::STUDENT_ID, $json['data']['childId']); + self::assertNotEmpty($json['data']['grades']); + + $grade = $json['data']['grades'][0]; + self::assertArrayHasKey('evaluationTitle', $grade); + self::assertArrayHasKey('value', $grade); + self::assertArrayHasKey('status', $grade); + self::assertArrayHasKey('classAverage', $grade); + } + + // ========================================================================= + // GET /api/me/children/{childId}/grades/subject/{subjectId} — Auth & Access + // ========================================================================= + + #[Test] + public function getChildGradesBySubjectReturns401WithoutAuthentication(): void + { + $client = static::createClient(); + $client->request('GET', self::BASE_URL . '/me/children/' . self::STUDENT_ID . '/grades/subject/' . self::SUBJECT_ID, [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseStatusCodeSame(401); + } + + #[Test] + public function getChildGradesBySubjectReturns403ForStudent(): void + { + $client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']); + $client->request('GET', self::BASE_URL . '/me/children/' . self::STUDENT_ID . '/grades/subject/' . self::SUBJECT_ID, [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseStatusCodeSame(403); + } + + #[Test] + public function getChildGradesBySubjectReturns403ForTeacher(): void + { + $client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']); + $client->request('GET', self::BASE_URL . '/me/children/' . self::STUDENT_ID . '/grades/subject/' . self::SUBJECT_ID, [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseStatusCodeSame(403); + } + + #[Test] + public function getChildGradesBySubjectReturns404ForUnlinkedChild(): void + { + $unlinkedChildId = '99990001-0001-0001-0001-000000000099'; + $client = $this->createAuthenticatedClient(self::PARENT_ID, ['ROLE_PARENT']); + $client->request('GET', self::BASE_URL . '/me/children/' . $unlinkedChildId . '/grades/subject/' . self::SUBJECT_ID, [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseStatusCodeSame(404); + } + + // ========================================================================= + // GET /api/me/children/{childId}/grades/subject/{subjectId} — Happy path + // ========================================================================= + + #[Test] + public function getChildGradesBySubjectFiltersCorrectly(): void + { + $client = $this->createAuthenticatedClient(self::PARENT_ID, ['ROLE_PARENT']); + $client->request('GET', self::BASE_URL . '/me/children/' . self::STUDENT_ID . '/grades/subject/' . self::SUBJECT_ID, [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseIsSuccessful(); + + /** @var string $content */ + $content = $client->getResponse()->getContent(); + /** @var array{data: array{grades: list>}} $json */ + $json = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + + foreach ($json['data']['grades'] as $grade) { + self::assertSame(self::SUBJECT_ID, $grade['subjectId']); + } + } + + // ========================================================================= + // GET /api/me/children/grades/summary — Auth & Access + // ========================================================================= + + #[Test] + public function getGradesSummaryReturns401WithoutAuthentication(): void + { + $client = static::createClient(); + $client->request('GET', self::BASE_URL . '/me/children/grades/summary', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseStatusCodeSame(401); + } + + #[Test] + public function getGradesSummaryReturns403ForStudent(): void + { + $client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']); + $client->request('GET', self::BASE_URL . '/me/children/grades/summary', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseStatusCodeSame(403); + } + + // ========================================================================= + // GET /api/me/children/grades/summary — Happy path + // ========================================================================= + + #[Test] + public function getGradesSummaryReturnsAveragesForParent(): void + { + $client = $this->createAuthenticatedClient(self::PARENT_ID, ['ROLE_PARENT']); + $client->request('GET', self::BASE_URL . '/me/children/grades/summary', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseIsSuccessful(); + + /** @var string $content */ + $content = $client->getResponse()->getContent(); + /** @var array{data: list}>} $json */ + $json = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + + self::assertNotEmpty($json['data']); + self::assertSame(self::STUDENT_ID, $json['data'][0]['childId']); + self::assertNotNull($json['data'][0]['generalAverage']); + } + + #[Test] + public function getGradesSummaryAcceptsPeriodIdQueryParameter(): void + { + $periodId = '99990001-0001-0001-0001-000000000050'; + $client = $this->createAuthenticatedClient(self::PARENT_ID, ['ROLE_PARENT']); + $client->request('GET', self::BASE_URL . '/me/children/grades/summary?periodId=' . $periodId, [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseIsSuccessful(); + + /** @var string $content */ + $content = $client->getResponse()->getContent(); + /** @var array{data: list} $json */ + $json = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + + // With a non-existent period, the response should still be 200 but with + // empty or zero averages (no grades match). The key assertion is that the + // endpoint accepts the parameter without error. + self::assertIsArray($json['data']); + } + + #[Test] + public function getGradesSummaryReturns403ForTeacher(): void + { + $client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']); + $client->request('GET', self::BASE_URL . '/me/children/grades/summary', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseStatusCodeSame(403); + } + + // ========================================================================= + // 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-pg@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('2026-03-15 10:00:00'); + + $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, 'parent-pg@test.local', '', 'Marie', 'Dupont', '[\"ROLE_PARENT\"]', 'active', NOW(), NOW()) + ON CONFLICT (id) DO NOTHING", + ['id' => self::PARENT_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-pg@test.local', '', 'Emma', 'Dupont', '[\"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, 'teacher-pg@test.local', '', 'Jean', 'Martin', '[\"ROLE_PROF\"]', 'active', NOW(), NOW()) + ON CONFLICT (id) DO NOTHING", + ['id' => self::TEACHER_ID, 'tid' => self::TENANT_ID], + ); + + // Link parent to student + $connection->executeStatement( + "INSERT INTO student_guardians (id, tenant_id, student_id, guardian_id, relationship_type, created_at) + VALUES (gen_random_uuid(), :tid, :sid, :gid, 'mère', NOW()) + ON CONFLICT DO NOTHING", + ['tid' => self::TENANT_ID, 'sid' => self::STUDENT_ID, 'gid' => self::PARENT_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-PG-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, 'PG-Mathématiques', 'PGMATH', '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, 'PG-Français', 'PGFRA', 'active', NOW(), NOW()) + ON CONFLICT (id) DO NOTHING", + ['id' => self::SUBJECT2_ID, 'tid' => self::TENANT_ID, 'sid' => $schoolId], + ); + + // Assign student to class + $connection->executeStatement( + '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(), :tid, :uid, :cid, :ayid, NOW(), NOW(), NOW()) + ON CONFLICT (user_id, academic_year_id) DO NOTHING', + ['tid' => self::TENANT_ID, 'uid' => self::STUDENT_ID, 'cid' => self::CLASS_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); + + // Published evaluation (well past 24h delay) + $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 Maths PG', + description: null, + evaluationDate: new DateTimeImmutable('2026-02-15'), + gradeScale: new GradeScale(20), + coefficient: new Coefficient(1.0), + now: new DateTimeImmutable('2026-02-10'), + ); + $eval1->publierNotes(new DateTimeImmutable('2026-02-16 10:00:00')); + $eval1->pullDomainEvents(); + $evalRepo->save($eval1); + + $grade1 = Grade::saisir( + tenantId: $tenantId, + evaluationId: $eval1->id, + studentId: UserId::fromString(self::STUDENT_ID), + value: new GradeValue(15.0), + status: GradeStatus::GRADED, + gradeScale: new GradeScale(20), + createdBy: UserId::fromString(self::TEACHER_ID), + now: $now, + ); + $grade1->pullDomainEvents(); + $gradeRepo->save($grade1); + + $stats1 = $calculator->calculateClassStatistics([15.0, 12.0, 18.0]); + $statsRepo->save($eval1->id, $stats1); + + // Second evaluation, different subject + $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 PG', + description: null, + evaluationDate: new DateTimeImmutable('2026-03-01'), + gradeScale: new GradeScale(20), + coefficient: new Coefficient(2.0), + now: new DateTimeImmutable('2026-02-25'), + ); + $eval2->publierNotes(new DateTimeImmutable('2026-03-02 10:00:00')); + $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); + } +} 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/backend/tests/Unit/Scolarite/Application/Query/GetChildrenGrades/GetChildrenGradesHandlerTest.php b/backend/tests/Unit/Scolarite/Application/Query/GetChildrenGrades/GetChildrenGradesHandlerTest.php new file mode 100644 index 0000000..2e52266 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Application/Query/GetChildrenGrades/GetChildrenGradesHandlerTest.php @@ -0,0 +1,556 @@ +evaluationRepository = new InMemoryEvaluationRepository(); + $this->gradeRepository = new InMemoryGradeRepository(); + $this->statisticsRepository = new InMemoryEvaluationStatisticsRepository(); + $this->now = new DateTimeImmutable('2026-04-06 14:00:00'); + } + + #[Test] + public function itReturnsEmptyWhenParentHasNoChildren(): void + { + $handler = $this->createHandler(children: []); + + $result = $handler(new GetChildrenGradesQuery( + parentId: self::PARENT_ID, + tenantId: self::TENANT_ID, + )); + + self::assertSame([], $result); + } + + #[Test] + public function itReturnsGradesForSingleChild(): void + { + $evaluation = $this->givenPublishedEvaluation( + title: 'Contrôle chapitre 5', + publishedAt: '2026-04-04 10:00:00', + ); + $this->givenGrade($evaluation, self::CHILD_A_ID, 15.0); + + $handler = $this->createHandler( + children: [ + ['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'], + ], + ); + + $result = $handler(new GetChildrenGradesQuery( + parentId: self::PARENT_ID, + tenantId: self::TENANT_ID, + )); + + self::assertCount(1, $result); + self::assertSame(self::CHILD_A_ID, $result[0]->childId); + self::assertSame('Emma', $result[0]->firstName); + self::assertSame('Dupont', $result[0]->lastName); + self::assertCount(1, $result[0]->grades); + self::assertSame(15.0, $result[0]->grades[0]->value); + self::assertSame('Contrôle chapitre 5', $result[0]->grades[0]->evaluationTitle); + } + + #[Test] + public function itFiltersOutGradesWithinDelayPeriod(): void + { + // Published 12h ago — within the 24h delay + $evaluation = $this->givenPublishedEvaluation( + title: 'Récent', + publishedAt: '2026-04-06 02:00:00', + ); + $this->givenGrade($evaluation, self::CHILD_A_ID, 10.0); + + $handler = $this->createHandler( + children: [ + ['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'], + ], + ); + + $result = $handler(new GetChildrenGradesQuery( + parentId: self::PARENT_ID, + tenantId: self::TENANT_ID, + )); + + self::assertCount(1, $result); + self::assertSame([], $result[0]->grades); + } + + #[Test] + public function itIncludesGradesPastDelayPeriod(): void + { + $evaluation = $this->givenPublishedEvaluation( + title: 'Ancien', + publishedAt: '2026-04-04 10:00:00', + ); + $this->givenGrade($evaluation, self::CHILD_A_ID, 12.0); + + $handler = $this->createHandler( + children: [ + ['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'], + ], + ); + + $result = $handler(new GetChildrenGradesQuery( + parentId: self::PARENT_ID, + tenantId: self::TENANT_ID, + )); + + self::assertCount(1, $result); + self::assertCount(1, $result[0]->grades); + self::assertSame(12.0, $result[0]->grades[0]->value); + } + + #[Test] + public function itReturnsGradesForMultipleChildren(): void + { + $evalA = $this->givenPublishedEvaluation( + title: 'Maths 6A', + classId: self::CLASS_A_ID, + publishedAt: '2026-04-03 10:00:00', + ); + $this->givenGrade($evalA, self::CHILD_A_ID, 14.0); + + $evalB = $this->givenPublishedEvaluation( + title: 'Maths 6B', + classId: self::CLASS_B_ID, + publishedAt: '2026-04-03 10:00:00', + ); + $this->givenGrade($evalB, self::CHILD_B_ID, 16.0); + + $handler = $this->createHandler( + children: [ + ['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'], + ['studentId' => self::CHILD_B_ID, 'firstName' => 'Lucas', 'lastName' => 'Dupont'], + ], + ); + + $result = $handler(new GetChildrenGradesQuery( + parentId: self::PARENT_ID, + tenantId: self::TENANT_ID, + )); + + self::assertCount(2, $result); + self::assertSame('Emma', $result[0]->firstName); + self::assertCount(1, $result[0]->grades); + self::assertSame(14.0, $result[0]->grades[0]->value); + self::assertSame('Lucas', $result[1]->firstName); + self::assertCount(1, $result[1]->grades); + self::assertSame(16.0, $result[1]->grades[0]->value); + } + + #[Test] + public function itFiltersToSpecificChild(): void + { + $evalA = $this->givenPublishedEvaluation( + title: 'Maths 6A', + classId: self::CLASS_A_ID, + publishedAt: '2026-04-03 10:00:00', + ); + $this->givenGrade($evalA, self::CHILD_A_ID, 14.0); + + $evalB = $this->givenPublishedEvaluation( + title: 'Maths 6B', + classId: self::CLASS_B_ID, + publishedAt: '2026-04-03 10:00:00', + ); + $this->givenGrade($evalB, self::CHILD_B_ID, 16.0); + + $handler = $this->createHandler( + children: [ + ['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'], + ['studentId' => self::CHILD_B_ID, 'firstName' => 'Lucas', 'lastName' => 'Dupont'], + ], + ); + + $result = $handler(new GetChildrenGradesQuery( + parentId: self::PARENT_ID, + tenantId: self::TENANT_ID, + childId: self::CHILD_B_ID, + )); + + self::assertCount(1, $result); + self::assertSame('Lucas', $result[0]->firstName); + self::assertCount(1, $result[0]->grades); + self::assertSame(16.0, $result[0]->grades[0]->value); + } + + #[Test] + public function itReturnsEmptyGradesWhenChildHasNoGrades(): void + { + $handler = $this->createHandler( + children: [ + ['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'], + ], + ); + + $result = $handler(new GetChildrenGradesQuery( + parentId: self::PARENT_ID, + tenantId: self::TENANT_ID, + )); + + self::assertCount(1, $result); + self::assertSame([], $result[0]->grades); + } + + #[Test] + public function itIncludesClassStatistics(): void + { + $evaluation = $this->givenPublishedEvaluation( + title: 'Stats test', + publishedAt: '2026-04-03 10:00:00', + ); + $this->givenGrade($evaluation, self::CHILD_A_ID, 14.0); + $this->statisticsRepository->save( + $evaluation->id, + new ClassStatistics(average: 12.5, min: 6.0, max: 18.0, median: 13.0, gradedCount: 25), + ); + + $handler = $this->createHandler( + children: [ + ['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'], + ], + ); + + $result = $handler(new GetChildrenGradesQuery( + parentId: self::PARENT_ID, + tenantId: self::TENANT_ID, + )); + + self::assertCount(1, $result[0]->grades); + self::assertSame(12.5, $result[0]->grades[0]->classAverage); + self::assertSame(6.0, $result[0]->grades[0]->classMin); + self::assertSame(18.0, $result[0]->grades[0]->classMax); + } + + #[Test] + public function itFiltersBySubject(): void + { + $evalMath = $this->givenPublishedEvaluation( + title: 'Maths', + subjectId: self::SUBJECT_MATH_ID, + publishedAt: '2026-04-03 10:00:00', + ); + $this->givenGrade($evalMath, self::CHILD_A_ID, 15.0); + + $evalFrench = $this->givenPublishedEvaluation( + title: 'Français', + subjectId: self::SUBJECT_FRENCH_ID, + publishedAt: '2026-04-03 10:00:00', + ); + $this->givenGrade($evalFrench, self::CHILD_A_ID, 12.0); + + $handler = $this->createHandler( + children: [ + ['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'], + ], + ); + + $result = $handler(new GetChildrenGradesQuery( + parentId: self::PARENT_ID, + tenantId: self::TENANT_ID, + subjectId: self::SUBJECT_MATH_ID, + )); + + self::assertCount(1, $result); + self::assertCount(1, $result[0]->grades); + self::assertSame(15.0, $result[0]->grades[0]->value); + } + + #[Test] + public function itFiltersOutUnpublishedEvaluations(): void + { + $unpublished = Evaluation::creer( + tenantId: TenantId::fromString(self::TENANT_ID), + classId: ClassId::fromString(self::CLASS_A_ID), + subjectId: SubjectId::fromString(self::SUBJECT_MATH_ID), + teacherId: UserId::fromString(self::TEACHER_ID), + title: 'Non publié', + description: null, + evaluationDate: new DateTimeImmutable('2026-04-01'), + gradeScale: new GradeScale(20), + coefficient: new Coefficient(1.0), + now: new DateTimeImmutable('2026-03-25'), + ); + $this->evaluationRepository->save($unpublished); + // Grade exists but evaluation not published → should not appear + $this->givenGrade($unpublished, self::CHILD_A_ID, 10.0); + + $handler = $this->createHandler( + children: [ + ['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'], + ], + ); + + $result = $handler(new GetChildrenGradesQuery( + parentId: self::PARENT_ID, + tenantId: self::TENANT_ID, + )); + + self::assertCount(1, $result); + self::assertSame([], $result[0]->grades); + } + + #[Test] + public function itSortsGradesByEvaluationDateDescending(): void + { + $evalOld = $this->givenPublishedEvaluation( + title: 'Ancien', + publishedAt: '2026-04-01 10:00:00', + evaluationDate: '2026-03-20', + ); + $this->givenGrade($evalOld, self::CHILD_A_ID, 10.0); + + $evalNew = $this->givenPublishedEvaluation( + title: 'Récent', + publishedAt: '2026-04-02 10:00:00', + evaluationDate: '2026-04-01', + ); + $this->givenGrade($evalNew, self::CHILD_A_ID, 16.0); + + $evalMid = $this->givenPublishedEvaluation( + title: 'Milieu', + publishedAt: '2026-04-01 12:00:00', + evaluationDate: '2026-03-25', + ); + $this->givenGrade($evalMid, self::CHILD_A_ID, 13.0); + + $handler = $this->createHandler( + children: [ + ['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'], + ], + ); + + $result = $handler(new GetChildrenGradesQuery( + parentId: self::PARENT_ID, + tenantId: self::TENANT_ID, + )); + + self::assertCount(1, $result); + $titles = array_map(static fn ($g) => $g->evaluationTitle, $result[0]->grades); + self::assertSame(['Récent', 'Milieu', 'Ancien'], $titles); + } + + #[Test] + public function itUsesConfigurableDelayOf0HoursForImmediateVisibility(): void + { + $evaluation = $this->givenPublishedEvaluation( + title: 'Immédiat', + publishedAt: '2026-04-06 13:00:00', + ); + $this->givenGrade($evaluation, self::CHILD_A_ID, 18.0); + + $handler = $this->createHandler( + children: [ + ['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'], + ], + delayHours: 0, + ); + + $result = $handler(new GetChildrenGradesQuery( + parentId: self::PARENT_ID, + tenantId: self::TENANT_ID, + )); + + self::assertCount(1, $result); + self::assertCount(1, $result[0]->grades); + self::assertSame(18.0, $result[0]->grades[0]->value); + } + + #[Test] + public function itIncludesAbsentAndDispensedGrades(): void + { + $evaluation = $this->givenPublishedEvaluation( + title: 'Contrôle mixte', + publishedAt: '2026-04-03 10:00:00', + ); + + // Absent grade (no value) + $absentGrade = Grade::saisir( + tenantId: $evaluation->tenantId, + evaluationId: $evaluation->id, + studentId: UserId::fromString(self::CHILD_A_ID), + value: null, + status: GradeStatus::ABSENT, + gradeScale: $evaluation->gradeScale, + createdBy: UserId::fromString(self::TEACHER_ID), + now: new DateTimeImmutable('2026-03-26 10:00:00'), + ); + $this->gradeRepository->save($absentGrade); + + $handler = $this->createHandler( + children: [ + ['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'], + ], + ); + + $result = $handler(new GetChildrenGradesQuery( + parentId: self::PARENT_ID, + tenantId: self::TENANT_ID, + )); + + self::assertCount(1, $result); + self::assertCount(1, $result[0]->grades); + self::assertNull($result[0]->grades[0]->value); + self::assertSame('absent', $result[0]->grades[0]->status); + } + + #[Test] + public function itUsesConfigurableDelayOf48Hours(): void + { + $evaluation = $this->givenPublishedEvaluation( + title: 'Lent', + publishedAt: '2026-04-05 08:00:00', + ); + $this->givenGrade($evaluation, self::CHILD_A_ID, 11.0); + + $handler = $this->createHandler( + children: [ + ['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'], + ], + delayHours: 48, + ); + + $result = $handler(new GetChildrenGradesQuery( + parentId: self::PARENT_ID, + tenantId: self::TENANT_ID, + )); + + self::assertCount(1, $result); + self::assertSame([], $result[0]->grades); + } + + /** + * @param array $children + */ + private function createHandler( + array $children = [], + int $delayHours = 24, + ): GetChildrenGradesHandler { + $parentChildrenReader = new class($children) implements ParentChildrenReader { + /** @param array $children */ + public function __construct(private readonly array $children) + { + } + + public function childrenOf(string $guardianId, TenantId $tenantId): array + { + return $this->children; + } + }; + + $displayReader = new class implements ScheduleDisplayReader { + public function subjectDisplay(string $tenantId, string ...$subjectIds): array + { + $map = []; + + foreach ($subjectIds as $id) { + $map[$id] = ['name' => 'Mathématiques', 'color' => '#3b82f6']; + } + + return $map; + } + + public function teacherNames(string $tenantId, string ...$teacherIds): array + { + $map = []; + + foreach ($teacherIds as $id) { + $map[$id] = 'Jean Dupont'; + } + + return $map; + } + }; + + $clock = new class($this->now) implements Clock { + public function __construct(private readonly DateTimeImmutable $now) + { + } + + public function now(): DateTimeImmutable + { + return $this->now; + } + }; + + $policy = new VisibiliteNotesPolicy($clock); + + $delayReader = new class($delayHours) implements ParentGradeDelayReader { + public function __construct(private readonly int $hours) + { + } + + public function delayHoursForTenant(TenantId $tenantId): int + { + return $this->hours; + } + }; + + return new GetChildrenGradesHandler( + $parentChildrenReader, + $this->evaluationRepository, + $this->gradeRepository, + $this->statisticsRepository, + $displayReader, + $policy, + $delayReader, + ); + } + + protected function evaluationRepository(): InMemoryEvaluationRepository + { + return $this->evaluationRepository; + } + + protected function gradeRepository(): InMemoryGradeRepository + { + return $this->gradeRepository; + } +} diff --git a/backend/tests/Unit/Scolarite/Application/Query/GetChildrenGrades/GetChildrenGradesSummaryHandlerTest.php b/backend/tests/Unit/Scolarite/Application/Query/GetChildrenGrades/GetChildrenGradesSummaryHandlerTest.php new file mode 100644 index 0000000..fa5e0e3 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Application/Query/GetChildrenGrades/GetChildrenGradesSummaryHandlerTest.php @@ -0,0 +1,310 @@ +evaluationRepository = new InMemoryEvaluationRepository(); + $this->gradeRepository = new InMemoryGradeRepository(); + $this->statisticsRepository = new InMemoryEvaluationStatisticsRepository(); + $this->now = new DateTimeImmutable('2026-04-06 14:00:00'); + } + + #[Test] + public function itReturnsEmptyWhenParentHasNoChildren(): void + { + $handler = $this->createHandler(children: []); + + $result = $handler(new GetChildrenGradesSummaryQuery( + parentId: self::PARENT_ID, + tenantId: self::TENANT_ID, + )); + + self::assertSame([], $result); + } + + #[Test] + public function itComputesAveragesFromVisibleGrades(): void + { + // Maths: 16/20 coeff 2 + 12/20 coeff 1 → weighted = (32+12)/3 = 14.67 + $eval1 = $this->givenPublishedEvaluation( + title: 'DS Maths', + subjectId: self::SUBJECT_MATH_ID, + publishedAt: '2026-04-03 10:00:00', + coefficient: 2.0, + ); + $this->givenGrade($eval1, self::CHILD_A_ID, 16.0); + + $eval2 = $this->givenPublishedEvaluation( + title: 'Contrôle Maths', + subjectId: self::SUBJECT_MATH_ID, + publishedAt: '2026-04-03 12:00:00', + coefficient: 1.0, + ); + $this->givenGrade($eval2, self::CHILD_A_ID, 12.0); + + // Français: 15/20 coeff 1 → 15.0 + $eval3 = $this->givenPublishedEvaluation( + title: 'Dictée', + subjectId: self::SUBJECT_FRENCH_ID, + publishedAt: '2026-04-03 14:00:00', + coefficient: 1.0, + ); + $this->givenGrade($eval3, self::CHILD_A_ID, 15.0); + + $handler = $this->createHandler( + children: [ + ['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'], + ], + ); + + $result = $handler(new GetChildrenGradesSummaryQuery( + parentId: self::PARENT_ID, + tenantId: self::TENANT_ID, + )); + + self::assertCount(1, $result); + self::assertSame(self::CHILD_A_ID, $result[0]->childId); + self::assertCount(2, $result[0]->subjectAverages); + + // General = mean of subject averages = (14.67 + 15.0) / 2 = 14.84 + self::assertNotNull($result[0]->generalAverage); + self::assertEqualsWithDelta(14.84, $result[0]->generalAverage, 0.01); + } + + #[Test] + public function itRespectsDelayAndExcludesRecentGradesFromAverages(): void + { + // Visible grade (48h ago) + $evalOld = $this->givenPublishedEvaluation( + title: 'Ancien', + subjectId: self::SUBJECT_MATH_ID, + publishedAt: '2026-04-04 10:00:00', + ); + $this->givenGrade($evalOld, self::CHILD_A_ID, 10.0); + + // Not yet visible (12h ago, within 24h delay) + $evalRecent = $this->givenPublishedEvaluation( + title: 'Récent', + subjectId: self::SUBJECT_MATH_ID, + publishedAt: '2026-04-06 02:00:00', + ); + $this->givenGrade($evalRecent, self::CHILD_A_ID, 20.0); + + $handler = $this->createHandler( + children: [ + ['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'], + ], + ); + + $result = $handler(new GetChildrenGradesSummaryQuery( + parentId: self::PARENT_ID, + tenantId: self::TENANT_ID, + )); + + self::assertCount(1, $result); + // Only the old grade (10.0) should be in the average, not the recent one (20.0) + self::assertSame(10.0, $result[0]->generalAverage); + } + + #[Test] + public function itReturnsNullAverageWhenNoVisibleGrades(): void + { + $handler = $this->createHandler( + children: [ + ['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'], + ], + ); + + $result = $handler(new GetChildrenGradesSummaryQuery( + parentId: self::PARENT_ID, + tenantId: self::TENANT_ID, + )); + + self::assertCount(1, $result); + self::assertSame([], $result[0]->subjectAverages); + self::assertNull($result[0]->generalAverage); + } + + #[Test] + public function itReturnsNullAverageWhenAllGradesHaveNullValue(): void + { + // Grades with null value (status ABSENT / DISPENSED) should not contribute to averages + $eval1 = $this->givenPublishedEvaluation( + title: 'DS Maths', + subjectId: self::SUBJECT_MATH_ID, + publishedAt: '2026-04-03 10:00:00', + ); + $this->givenGradeWithStatus($eval1, self::CHILD_A_ID, GradeStatus::ABSENT); + + $eval2 = $this->givenPublishedEvaluation( + title: 'Dictee', + subjectId: self::SUBJECT_FRENCH_ID, + publishedAt: '2026-04-03 12:00:00', + ); + $this->givenGradeWithStatus($eval2, self::CHILD_A_ID, GradeStatus::DISPENSED); + + $handler = $this->createHandler( + children: [ + ['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'], + ], + ); + + $result = $handler(new GetChildrenGradesSummaryQuery( + parentId: self::PARENT_ID, + tenantId: self::TENANT_ID, + )); + + self::assertCount(1, $result); + self::assertSame([], $result[0]->subjectAverages); + self::assertNull($result[0]->generalAverage); + } + + #[Test] + public function itReturnsAveragesForMultipleChildren(): void + { + $evalA = $this->givenPublishedEvaluation( + title: 'Maths A', + publishedAt: '2026-04-03 10:00:00', + ); + $this->givenGrade($evalA, self::CHILD_A_ID, 16.0); + + $evalB = $this->givenPublishedEvaluation( + title: 'Maths B', + classId: '550e8400-e29b-41d4-a716-446655440021', + publishedAt: '2026-04-03 10:00:00', + ); + $this->givenGrade($evalB, self::CHILD_B_ID, 8.0); + + $handler = $this->createHandler( + children: [ + ['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'], + ['studentId' => self::CHILD_B_ID, 'firstName' => 'Lucas', 'lastName' => 'Dupont'], + ], + ); + + $result = $handler(new GetChildrenGradesSummaryQuery( + parentId: self::PARENT_ID, + tenantId: self::TENANT_ID, + )); + + self::assertCount(2, $result); + self::assertSame(16.0, $result[0]->generalAverage); + self::assertSame(8.0, $result[1]->generalAverage); + } + + /** + * @param array $children + */ + private function createHandler( + array $children = [], + ): GetChildrenGradesSummaryHandler { + $parentChildrenReader = new class($children) implements ParentChildrenReader { + /** @param array $children */ + public function __construct(private readonly array $children) + { + } + + public function childrenOf(string $guardianId, TenantId $tenantId): array + { + return $this->children; + } + }; + + $displayReader = new class implements ScheduleDisplayReader { + public function subjectDisplay(string $tenantId, string ...$subjectIds): array + { + $map = []; + + foreach ($subjectIds as $id) { + $map[$id] = ['name' => 'Mathématiques', 'color' => '#3b82f6']; + } + + return $map; + } + + public function teacherNames(string $tenantId, string ...$teacherIds): array + { + return []; + } + }; + + $clock = new class($this->now) implements Clock { + public function __construct(private readonly DateTimeImmutable $now) + { + } + + public function now(): DateTimeImmutable + { + return $this->now; + } + }; + + $delayReader = new class implements ParentGradeDelayReader { + public function delayHoursForTenant(TenantId $tenantId): int + { + return 24; + } + }; + + $gradesHandler = new GetChildrenGradesHandler( + $parentChildrenReader, + $this->evaluationRepository, + $this->gradeRepository, + $this->statisticsRepository, + $displayReader, + new VisibiliteNotesPolicy($clock), + $delayReader, + ); + + return new GetChildrenGradesSummaryHandler($gradesHandler); + } + + protected function evaluationRepository(): InMemoryEvaluationRepository + { + return $this->evaluationRepository; + } + + protected function gradeRepository(): InMemoryGradeRepository + { + return $this->gradeRepository; + } +} diff --git a/backend/tests/Unit/Scolarite/Application/Query/GetChildrenGrades/ParentGradeTestHelper.php b/backend/tests/Unit/Scolarite/Application/Query/GetChildrenGrades/ParentGradeTestHelper.php new file mode 100644 index 0000000..b2d7b95 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Application/Query/GetChildrenGrades/ParentGradeTestHelper.php @@ -0,0 +1,95 @@ +publierNotes(new DateTimeImmutable($publishedAt)); + $this->evaluationRepository()->save($evaluation); + + return $evaluation; + } + + private function givenGrade( + Evaluation $evaluation, + string $studentId, + float $value, + ): Grade { + $grade = Grade::saisir( + tenantId: $evaluation->tenantId, + evaluationId: $evaluation->id, + studentId: UserId::fromString($studentId), + value: new GradeValue($value), + status: GradeStatus::GRADED, + gradeScale: $evaluation->gradeScale, + createdBy: UserId::fromString(self::TEACHER_ID), + now: new DateTimeImmutable('2026-03-26 10:00:00'), + ); + + $this->gradeRepository()->save($grade); + + return $grade; + } + + private function givenGradeWithStatus( + Evaluation $evaluation, + string $studentId, + GradeStatus $status, + ): Grade { + $grade = Grade::saisir( + tenantId: $evaluation->tenantId, + evaluationId: $evaluation->id, + studentId: UserId::fromString($studentId), + value: null, + status: $status, + gradeScale: $evaluation->gradeScale, + createdBy: UserId::fromString(self::TEACHER_ID), + now: new DateTimeImmutable('2026-03-26 10:00:00'), + ); + + $this->gradeRepository()->save($grade); + + return $grade; + } +} diff --git a/backend/tests/Unit/Scolarite/Domain/Policy/VisibiliteNotesPolicyTest.php b/backend/tests/Unit/Scolarite/Domain/Policy/VisibiliteNotesPolicyTest.php index 67081bf..c5cbf4f 100644 --- a/backend/tests/Unit/Scolarite/Domain/Policy/VisibiliteNotesPolicyTest.php +++ b/backend/tests/Unit/Scolarite/Domain/Policy/VisibiliteNotesPolicyTest.php @@ -70,6 +70,50 @@ final class VisibiliteNotesPolicyTest extends TestCase self::assertFalse($policy->visiblePourParent($evaluation)); } + #[Test] + public function parentVoitImmediatementAvecDelaiZero(): void + { + $publishedAt = new DateTimeImmutable('2026-03-27 14:00:00'); + $now = new DateTimeImmutable('2026-03-27 14:00:01'); // 1 seconde après + $policy = $this->createPolicy($now); + $evaluation = $this->createPublishedEvaluation($publishedAt); + + self::assertTrue($policy->visiblePourParent($evaluation, delaiHeures: 0)); + } + + #[Test] + public function parentNeVoitPasAvec48hDeDelai(): void + { + $publishedAt = new DateTimeImmutable('2026-03-27 14:00:00'); + $now = new DateTimeImmutable('2026-03-29 13:59:59'); // 47h59 après + $policy = $this->createPolicy($now); + $evaluation = $this->createPublishedEvaluation($publishedAt); + + self::assertFalse($policy->visiblePourParent($evaluation, delaiHeures: 48)); + } + + #[Test] + public function parentVoitApres48hDeDelai(): void + { + $publishedAt = new DateTimeImmutable('2026-03-27 14:00:00'); + $now = new DateTimeImmutable('2026-03-29 14:00:00'); // exactement 48h après + $policy = $this->createPolicy($now); + $evaluation = $this->createPublishedEvaluation($publishedAt); + + self::assertTrue($policy->visiblePourParent($evaluation, delaiHeures: 48)); + } + + #[Test] + public function parentVoitImmediatementAvecDelaiNegatifTraiteCommeZero(): void + { + $publishedAt = new DateTimeImmutable('2026-03-27 14:00:00'); + $now = new DateTimeImmutable('2026-03-27 14:00:01'); + $policy = $this->createPolicy($now); + $evaluation = $this->createPublishedEvaluation($publishedAt); + + self::assertTrue($policy->visiblePourParent($evaluation, delaiHeures: -5)); + } + private function createPolicy(DateTimeImmutable $now): VisibiliteNotesPolicy { $clock = new class($now) implements Clock { diff --git a/backend/tests/Unit/Scolarite/Infrastructure/Api/Provider/StudentMyAveragesProviderTest.php b/backend/tests/Unit/Scolarite/Infrastructure/Api/Provider/StudentMyAveragesProviderTest.php new file mode 100644 index 0000000..256562c --- /dev/null +++ b/backend/tests/Unit/Scolarite/Infrastructure/Api/Provider/StudentMyAveragesProviderTest.php @@ -0,0 +1,347 @@ +averageRepository = new InMemoryStudentAverageRepository(); + $this->tenantContext = new TenantContext(); + } + + // ========================================================================= + // Auth & Tenant Guards + // ========================================================================= + + #[Test] + public function itRejects401WhenNoTenant(): void + { + $provider = $this->createProvider( + user: $this->studentUser(), + periodForDate: null, + ); + + $this->expectException(UnauthorizedHttpException::class); + $provider->provide(new Get()); + } + + #[Test] + public function itRejects401WhenNoUser(): void + { + $this->setTenant(); + $provider = $this->createProvider( + user: null, + periodForDate: null, + ); + + $this->expectException(UnauthorizedHttpException::class); + $provider->provide(new Get()); + } + + #[Test] + public function itRejects403ForTeacher(): void + { + $this->setTenant(); + $provider = $this->createProvider( + user: $this->teacherUser(), + periodForDate: null, + ); + + $this->expectException(AccessDeniedHttpException::class); + $provider->provide(new Get()); + } + + #[Test] + public function itRejects403ForParent(): void + { + $this->setTenant(); + $provider = $this->createProvider( + user: $this->parentUser(), + periodForDate: null, + ); + + $this->expectException(AccessDeniedHttpException::class); + $provider->provide(new Get()); + } + + #[Test] + public function itRejects403ForAdmin(): void + { + $this->setTenant(); + $provider = $this->createProvider( + user: $this->adminUser(), + periodForDate: null, + ); + + $this->expectException(AccessDeniedHttpException::class); + $provider->provide(new Get()); + } + + // ========================================================================= + // Period auto-detection + // ========================================================================= + + #[Test] + public function itAutoDetectsCurrentPeriodWhenNoPeriodIdInFilters(): void + { + $this->setTenant(); + $this->seedAverages(); + + $provider = $this->createProvider( + user: $this->studentUser(), + periodForDate: new PeriodInfo(self::PERIOD_ID, new DateTimeImmutable('2026-01-01'), new DateTimeImmutable('2026-03-31')), + ); + + $result = $provider->provide(new Get()); + + self::assertInstanceOf(StudentMyAveragesResource::class, $result); + self::assertSame(self::PERIOD_ID, $result->periodId); + self::assertNotEmpty($result->subjectAverages); + self::assertSame(16.0, $result->generalAverage); + } + + #[Test] + public function itReturnsEmptyResourceWhenNoPeriodDetected(): void + { + $this->setTenant(); + $this->seedAverages(); + + $provider = $this->createProvider( + user: $this->studentUser(), + periodForDate: null, + ); + + $result = $provider->provide(new Get()); + + self::assertInstanceOf(StudentMyAveragesResource::class, $result); + self::assertNull($result->periodId); + self::assertEmpty($result->subjectAverages); + self::assertNull($result->generalAverage); + } + + // ========================================================================= + // Explicit periodId from filters + // ========================================================================= + + #[Test] + public function itUsesExplicitPeriodIdFromFilters(): void + { + $this->setTenant(); + $this->seedAverages(); + + $provider = $this->createProvider( + user: $this->studentUser(), + periodForDate: null, + ); + + $result = $provider->provide(new Get(), [], [ + 'filters' => ['periodId' => self::PERIOD_ID], + ]); + + self::assertInstanceOf(StudentMyAveragesResource::class, $result); + self::assertSame(self::PERIOD_ID, $result->periodId); + self::assertNotEmpty($result->subjectAverages); + } + + #[Test] + public function itReturnsEmptySubjectAveragesForUnknownPeriod(): void + { + $this->setTenant(); + $this->seedAverages(); + + $unknownPeriod = '99999999-9999-9999-9999-999999999999'; + $provider = $this->createProvider( + user: $this->studentUser(), + periodForDate: null, + ); + + $result = $provider->provide(new Get(), [], [ + 'filters' => ['periodId' => $unknownPeriod], + ]); + + self::assertInstanceOf(StudentMyAveragesResource::class, $result); + self::assertSame($unknownPeriod, $result->periodId); + self::assertEmpty($result->subjectAverages); + self::assertNull($result->generalAverage); + } + + // ========================================================================= + // Response shape + // ========================================================================= + + #[Test] + public function itReturnsStudentIdInResource(): void + { + $this->setTenant(); + + $provider = $this->createProvider( + user: $this->studentUser(), + periodForDate: null, + ); + + $result = $provider->provide(new Get(), [], [ + 'filters' => ['periodId' => self::PERIOD_ID], + ]); + + self::assertInstanceOf(StudentMyAveragesResource::class, $result); + self::assertSame(self::STUDENT_UUID, $result->studentId); + } + + #[Test] + public function itReturnsSubjectAverageShape(): void + { + $this->setTenant(); + $this->seedAverages(); + + $provider = $this->createProvider( + user: $this->studentUser(), + periodForDate: null, + ); + + $result = $provider->provide(new Get(), [], [ + 'filters' => ['periodId' => self::PERIOD_ID], + ]); + + self::assertInstanceOf(StudentMyAveragesResource::class, $result); + self::assertCount(1, $result->subjectAverages); + + $avg = $result->subjectAverages[0]; + self::assertSame(self::SUBJECT_UUID, $avg['subjectId']); + self::assertSame(16.0, $avg['average']); + self::assertSame(1, $avg['gradeCount']); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + private function setTenant(): void + { + $this->tenantContext->setCurrentTenant(new TenantConfig( + tenantId: InfraTenantId::fromString(self::TENANT_UUID), + subdomain: 'ecole-alpha', + databaseUrl: 'sqlite:///:memory:', + )); + } + + private function seedAverages(): void + { + $tenantId = TenantId::fromString(self::TENANT_UUID); + $studentId = UserId::fromString(self::STUDENT_UUID); + + $this->averageRepository->saveSubjectAverage( + $tenantId, + $studentId, + SubjectId::fromString(self::SUBJECT_UUID), + self::PERIOD_ID, + 16.0, + 1, + ); + + $this->averageRepository->saveGeneralAverage( + $tenantId, + $studentId, + self::PERIOD_ID, + 16.0, + ); + } + + private function createProvider(?SecurityUser $user, ?PeriodInfo $periodForDate): StudentMyAveragesProvider + { + $security = $this->createMock(Security::class); + $security->method('getUser')->willReturn($user); + + $periodFinder = new class($periodForDate) implements PeriodFinder { + public function __construct(private readonly ?PeriodInfo $info) + { + } + + public function findForDate(DateTimeImmutable $date, TenantId $tenantId): ?PeriodInfo + { + return $this->info; + } + }; + + return new StudentMyAveragesProvider( + $this->averageRepository, + $periodFinder, + $this->tenantContext, + $security, + ); + } + + private function studentUser(): SecurityUser + { + return new SecurityUser( + userId: UserId::fromString(self::STUDENT_UUID), + email: 'student@test.local', + hashedPassword: '', + tenantId: InfraTenantId::fromString(self::TENANT_UUID), + roles: ['ROLE_ELEVE'], + ); + } + + private function teacherUser(): SecurityUser + { + return new SecurityUser( + userId: UserId::fromString('44444444-4444-4444-4444-444444444444'), + email: 'teacher@test.local', + hashedPassword: '', + tenantId: InfraTenantId::fromString(self::TENANT_UUID), + roles: ['ROLE_PROF'], + ); + } + + private function parentUser(): SecurityUser + { + return new SecurityUser( + userId: UserId::fromString('88888888-8888-8888-8888-888888888888'), + email: 'parent@test.local', + hashedPassword: '', + tenantId: InfraTenantId::fromString(self::TENANT_UUID), + roles: ['ROLE_PARENT'], + ); + } + + private function adminUser(): SecurityUser + { + return new SecurityUser( + userId: UserId::fromString('33333333-3333-3333-3333-333333333333'), + email: 'admin@test.local', + hashedPassword: '', + tenantId: InfraTenantId::fromString(self::TENANT_UUID), + roles: ['ROLE_ADMIN'], + ); + } +} diff --git a/backend/tests/Unit/Scolarite/Infrastructure/Persistence/InMemory/InMemoryGradeRepositoryTest.php b/backend/tests/Unit/Scolarite/Infrastructure/Persistence/InMemory/InMemoryGradeRepositoryTest.php new file mode 100644 index 0000000..e6f8c88 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Infrastructure/Persistence/InMemory/InMemoryGradeRepositoryTest.php @@ -0,0 +1,131 @@ +repository = new InMemoryGradeRepository(); + } + + #[Test] + public function findByEvaluationsReturnsGroupedResults(): void + { + $gradeA1 = $this->createGrade(self::EVALUATION_A_ID, self::TENANT_ID, 14.0); + $gradeA2 = $this->createGrade(self::EVALUATION_A_ID, self::TENANT_ID, 16.0); + $gradeB1 = $this->createGrade(self::EVALUATION_B_ID, self::TENANT_ID, 12.0); + + $this->repository->save($gradeA1); + $this->repository->save($gradeA2); + $this->repository->save($gradeB1); + + $result = $this->repository->findByEvaluations( + [ + EvaluationId::fromString(self::EVALUATION_A_ID), + EvaluationId::fromString(self::EVALUATION_B_ID), + ], + TenantId::fromString(self::TENANT_ID), + ); + + self::assertCount(2, $result); + self::assertArrayHasKey(self::EVALUATION_A_ID, $result); + self::assertArrayHasKey(self::EVALUATION_B_ID, $result); + self::assertCount(2, $result[self::EVALUATION_A_ID]); + self::assertCount(1, $result[self::EVALUATION_B_ID]); + } + + #[Test] + public function findByEvaluationsReturnsEmptyArrayWhenNoEvaluationIds(): void + { + $grade = $this->createGrade(self::EVALUATION_A_ID, self::TENANT_ID, 14.0); + $this->repository->save($grade); + + $result = $this->repository->findByEvaluations( + [], + TenantId::fromString(self::TENANT_ID), + ); + + self::assertSame([], $result); + } + + #[Test] + public function findByEvaluationsExcludesGradesFromDifferentTenant(): void + { + $gradeOwnTenant = $this->createGrade(self::EVALUATION_A_ID, self::TENANT_ID, 14.0); + $gradeOtherTenant = $this->createGrade(self::EVALUATION_A_ID, self::OTHER_TENANT_ID, 18.0); + + $this->repository->save($gradeOwnTenant); + $this->repository->save($gradeOtherTenant); + + $result = $this->repository->findByEvaluations( + [EvaluationId::fromString(self::EVALUATION_A_ID)], + TenantId::fromString(self::TENANT_ID), + ); + + self::assertCount(1, $result); + self::assertArrayHasKey(self::EVALUATION_A_ID, $result); + self::assertCount(1, $result[self::EVALUATION_A_ID]); + self::assertSame($gradeOwnTenant, $result[self::EVALUATION_A_ID][0]); + } + + #[Test] + public function findByEvaluationsExcludesGradesForUnrequestedEvaluations(): void + { + $gradeA = $this->createGrade(self::EVALUATION_A_ID, self::TENANT_ID, 14.0); + $gradeC = $this->createGrade(self::EVALUATION_C_ID, self::TENANT_ID, 10.0); + + $this->repository->save($gradeA); + $this->repository->save($gradeC); + + $result = $this->repository->findByEvaluations( + [EvaluationId::fromString(self::EVALUATION_A_ID)], + TenantId::fromString(self::TENANT_ID), + ); + + self::assertCount(1, $result); + self::assertArrayHasKey(self::EVALUATION_A_ID, $result); + self::assertArrayNotHasKey(self::EVALUATION_C_ID, $result); + } + + private function createGrade( + string $evaluationId, + string $tenantId, + float $value, + ): Grade { + return Grade::saisir( + tenantId: TenantId::fromString($tenantId), + evaluationId: EvaluationId::fromString($evaluationId), + studentId: UserId::fromString(self::STUDENT_ID), + value: new GradeValue($value), + status: GradeStatus::GRADED, + gradeScale: new GradeScale(20), + createdBy: UserId::fromString(self::TEACHER_ID), + now: new DateTimeImmutable('2026-03-26 10:00:00'), + ); + } +} diff --git a/backend/tests/Unit/Scolarite/Infrastructure/Security/GradeParentVoterTest.php b/backend/tests/Unit/Scolarite/Infrastructure/Security/GradeParentVoterTest.php new file mode 100644 index 0000000..fe13640 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Infrastructure/Security/GradeParentVoterTest.php @@ -0,0 +1,114 @@ +voter = new GradeParentVoter(); + } + + #[Test] + public function itAbstainsForUnrelatedAttributes(): void + { + $token = $this->tokenWithSecurityUser(Role::PARENT->value); + + $result = $this->voter->vote($token, null, ['SOME_OTHER_ATTRIBUTE']); + + self::assertSame(Voter::ACCESS_ABSTAIN, $result); + } + + #[Test] + public function itDeniesAccessToUnauthenticatedUsers(): void + { + $token = $this->createMock(TokenInterface::class); + $token->method('getUser')->willReturn(null); + + $result = $this->voter->vote($token, null, [GradeParentVoter::VIEW]); + + self::assertSame(Voter::ACCESS_DENIED, $result); + } + + #[Test] + public function itDeniesAccessToNonSecurityUserInstances(): void + { + $user = $this->createMock(UserInterface::class); + $user->method('getRoles')->willReturn([Role::PARENT->value]); + + $token = $this->createMock(TokenInterface::class); + $token->method('getUser')->willReturn($user); + + $result = $this->voter->vote($token, null, [GradeParentVoter::VIEW]); + + self::assertSame(Voter::ACCESS_DENIED, $result); + } + + #[Test] + public function itGrantsViewToParent(): void + { + $token = $this->tokenWithSecurityUser(Role::PARENT->value); + + $result = $this->voter->vote($token, null, [GradeParentVoter::VIEW]); + + self::assertSame(Voter::ACCESS_GRANTED, $result); + } + + #[Test] + #[DataProvider('nonParentRolesProvider')] + public function itDeniesViewToNonParentRoles(string $role): void + { + $token = $this->tokenWithSecurityUser($role); + + $result = $this->voter->vote($token, null, [GradeParentVoter::VIEW]); + + self::assertSame(Voter::ACCESS_DENIED, $result); + } + + /** @return iterable */ + public static function nonParentRolesProvider(): iterable + { + yield 'SUPER_ADMIN' => [Role::SUPER_ADMIN->value]; + yield 'ADMIN' => [Role::ADMIN->value]; + yield 'PROF' => [Role::PROF->value]; + yield 'VIE_SCOLAIRE' => [Role::VIE_SCOLAIRE->value]; + yield 'SECRETARIAT' => [Role::SECRETARIAT->value]; + yield 'ELEVE' => [Role::ELEVE->value]; + } + + private function tokenWithSecurityUser( + string $role, + string $userId = '550e8400-e29b-41d4-a716-446655440001', + ): TokenInterface { + $securityUser = new SecurityUser( + UserId::fromString($userId), + 'test@example.com', + 'hashed_password', + TenantId::fromString(self::TENANT_ID), + [$role], + ); + + $token = $this->createMock(TokenInterface::class); + $token->method('getUser')->willReturn($securityUser); + + return $token; + } +} diff --git a/backend/tests/Unit/Scolarite/Infrastructure/Service/DatabaseParentGradeDelayReaderTest.php b/backend/tests/Unit/Scolarite/Infrastructure/Service/DatabaseParentGradeDelayReaderTest.php new file mode 100644 index 0000000..91e4677 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Infrastructure/Service/DatabaseParentGradeDelayReaderTest.php @@ -0,0 +1,98 @@ +createMock(Connection::class); + $connection + ->method('fetchOne') + ->willReturn('48'); + + $reader = new DatabaseParentGradeDelayReader($connection); + + $result = $reader->delayHoursForTenant(TenantId::fromString(self::TENANT_ID)); + + self::assertSame(48, $result); + } + + #[Test] + public function itReturnsDefault24HoursWhenNoRowFound(): void + { + $connection = $this->createMock(Connection::class); + $connection + ->method('fetchOne') + ->willReturn(false); + + $reader = new DatabaseParentGradeDelayReader($connection); + + $result = $reader->delayHoursForTenant(TenantId::fromString(self::TENANT_ID)); + + self::assertSame(24, $result); + } + + #[Test] + public function itReturnsDefault24HoursWhenResultIsNonNumeric(): void + { + $connection = $this->createMock(Connection::class); + $connection + ->method('fetchOne') + ->willReturn('not-a-number'); + + $reader = new DatabaseParentGradeDelayReader($connection); + + $result = $reader->delayHoursForTenant(TenantId::fromString(self::TENANT_ID)); + + self::assertSame(24, $result); + } + + #[Test] + public function itPassesTenantIdToQuery(): void + { + $tenantId = TenantId::fromString(self::TENANT_ID); + + $connection = $this->createMock(Connection::class); + $connection + ->expects(self::once()) + ->method('fetchOne') + ->with( + self::stringContains('tenant_id'), + self::equalTo(['tenant_id' => (string) $tenantId]), + ) + ->willReturn('12'); + + $reader = new DatabaseParentGradeDelayReader($connection); + + $result = $reader->delayHoursForTenant($tenantId); + + self::assertSame(12, $result); + } + + #[Test] + public function itCastsNumericStringToInt(): void + { + $connection = $this->createMock(Connection::class); + $connection + ->method('fetchOne') + ->willReturn('0'); + + $reader = new DatabaseParentGradeDelayReader($connection); + + $result = $reader->delayHoursForTenant(TenantId::fromString(self::TENANT_ID)); + + self::assertSame(0, $result); + } +} 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/appreciations.spec.ts b/frontend/e2e/appreciations.spec.ts index 25f99b7..0558279 100644 --- a/frontend/e2e/appreciations.spec.ts +++ b/frontend/e2e/appreciations.spec.ts @@ -165,10 +165,14 @@ test.describe('Appreciations (Story 6.4)', () => { await page.locator('.btn-appreciation').first().click(); await expect(page.locator('.appreciation-textarea')).toBeVisible({ timeout: 5000 }); - // Type appreciation text - await page.locator('.appreciation-textarea').fill('Très bon travail ce trimestre'); + // Type appreciation text (pressSequentially to reliably trigger Svelte bind + oninput) + const textarea = page.locator('.appreciation-textarea'); + await textarea.click(); + await expect(textarea).toBeFocused(); + await textarea.pressSequentially('Bon travail', { delay: 50 }); + await expect(textarea).not.toHaveValue(''); - // Wait for auto-save by checking the UI status indicator + // Wait for auto-save by checking the UI status indicator (1s debounce + network) await expect(page.getByText('Sauvegardé')).toBeVisible({ timeout: 15000 }); }); 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/class-detail.spec.ts b/frontend/e2e/class-detail.spec.ts index c9f21d2..d35139c 100644 --- a/frontend/e2e/class-detail.spec.ts +++ b/frontend/e2e/class-detail.spec.ts @@ -200,6 +200,12 @@ test.describe('Admin Class Detail Page [P1]', () => { await page.getByRole('button', { name: /créer la classe/i }).click(); await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); + // Search for the class (it may be on page 2 due to pagination) + const searchInput2 = page.locator('input[type="search"]'); + await searchInput2.fill(className); + await page.waitForTimeout(500); + await page.waitForLoadState('networkidle'); + // Navigate to edit page const classCard = page.locator('.class-card', { hasText: className }); await classCard.getByRole('button', { name: /modifier/i }).click(); 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/grades.spec.ts b/frontend/e2e/grades.spec.ts index a4b1780..2d1e070 100644 --- a/frontend/e2e/grades.spec.ts +++ b/frontend/e2e/grades.spec.ts @@ -183,7 +183,7 @@ test.describe('Grade Input Grid (Story 6.2)', () => { const firstInput = page.locator('.grade-input').first(); await firstInput.clear(); - await firstInput.pressSequentially('/abs'); + await firstInput.fill('/abs'); await expect(page.locator('.status-absent').first()).toBeVisible({ timeout: 15000 }); }); @@ -195,7 +195,7 @@ test.describe('Grade Input Grid (Story 6.2)', () => { const firstInput = page.locator('.grade-input').first(); await firstInput.clear(); - await firstInput.pressSequentially('/disp'); + await firstInput.fill('/disp'); await expect(page.locator('.status-dispensed').first()).toBeVisible({ timeout: 15000 }); }); diff --git a/frontend/e2e/parent-grades.spec.ts b/frontend/e2e/parent-grades.spec.ts new file mode 100644 index 0000000..e40a0f3 --- /dev/null +++ b/frontend/e2e/parent-grades.spec.ts @@ -0,0 +1,241 @@ +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 PARENT_EMAIL = 'e2e-parent-grades@example.com'; +const PARENT_PASSWORD = 'ParentGrades123'; +const TEACHER_EMAIL = 'e2e-pg-teacher@example.com'; +const TEACHER_PASSWORD = 'TeacherPG123'; +const STUDENT_EMAIL = 'e2e-pg-student@example.com'; +const STUDENT_PASSWORD = 'StudentPG123'; +const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; + +let parentId: string; +let studentId: string; +let classId: string; +let subjectId: string; +let evalId: 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 loginAsParent(page: import('@playwright/test').Page) { + await page.goto(`${ALPHA_URL}/login`); + await page.locator('#email').fill(PARENT_EMAIL); + await page.locator('#password').fill(PARENT_PASSWORD); + await Promise.all([ + page.waitForURL(/\/dashboard/, { timeout: 60000 }), + page.getByRole('button', { name: /se connecter/i }).click() + ]); +} + +test.describe('Parent Grade Consultation (Story 6.7)', () => { + test.describe.configure({ mode: 'serial' }); + + test.beforeAll(async () => { + // Create users + createTestUser( + 'ecole-alpha', + PARENT_EMAIL, + PARENT_PASSWORD, + 'ROLE_PARENT --firstName=Marie --lastName=Dupont' + ); + createTestUser( + 'ecole-alpha', + TEACHER_EMAIL, + TEACHER_PASSWORD, + 'ROLE_PROF --firstName=Jean --lastName=Martin' + ); + createTestUser( + 'ecole-alpha', + STUDENT_EMAIL, + STUDENT_PASSWORD, + 'ROLE_ELEVE --firstName=Emma --lastName=Dupont' + ); + + const { schoolId, academicYearId } = resolveDeterministicIds(TENANT_ID); + + // Resolve user IDs + const parentOutput = execWithRetry( + `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "SELECT id FROM users WHERE email='${PARENT_EMAIL}' AND tenant_id='${TENANT_ID}'" 2>&1` + ); + parentId = parentOutput.match( + /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/ + )![0]!; + + const studentOutput = 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` + ); + studentId = studentOutput.match( + /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/ + )![0]!; + + // Create deterministic IDs + classId = uuid5(`pg-class-${TENANT_ID}`); + subjectId = uuid5(`pg-subject-${TENANT_ID}`); + evalId = uuid5(`pg-eval-${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-PG-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` + ); + + // Create subject + runSql( + `INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` + + `VALUES ('${subjectId}', '${TENANT_ID}', '${schoolId}', 'E2E-PG-Mathématiques', 'E2EPGMATH', '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` + ); + + // Link parent to student + runSql( + `INSERT INTO student_guardians (id, tenant_id, student_id, guardian_id, relationship_type, created_at) ` + + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${studentId}', '${parentId}', 'mère', NOW()) ON CONFLICT 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` + ); + + // Create published evaluation (published 48h ago so delay is passed) + 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 '${evalId}', '${TENANT_ID}', '${classId}', '${subjectId}', u.id, 'DS Maths Parent', '2026-03-01', 20, 2.0, 'published', NOW() - INTERVAL '48 hours', NOW() - INTERVAL '48 hours', NOW() ` + + `FROM users u WHERE u.email='${TEACHER_EMAIL}' AND u.tenant_id='${TENANT_ID}' ` + + `ON CONFLICT (id) DO NOTHING` + ); + + // Insert grade for student + 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}', '${evalId}', '${studentId}', 15.5, 'graded', u.id, NOW(), NOW(), 'Bon travail' ` + + `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 ('${evalId}', 13.5, 7.0, 18.0, 13.5, 25, NOW()) ON CONFLICT (evaluation_id) DO NOTHING` + ); + + // Find academic period + 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(`pg-period-${TENANT_ID}`); + + // Insert student averages + 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}', 15.5, 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.5, NOW()) ` + + `ON CONFLICT (student_id, period_id) DO NOTHING` + ); + + clearCache(); + }); + + // ========================================================================= + // AC2: Parent can see child's grades and averages + // ========================================================================= + + test('AC2: parent navigates to grades page', async ({ page }) => { + await loginAsParent(page); + await page.goto(`${ALPHA_URL}/dashboard/parent-grades`); + + await expect(page.getByRole('heading', { name: 'Notes des enfants' })).toBeVisible({ + timeout: 15000 + }); + }); + + test('AC2: parent sees child selector', async ({ page }) => { + await loginAsParent(page); + await page.goto(`${ALPHA_URL}/dashboard/parent-grades`); + + // Child selector should be visible with the child's name + await expect(page.getByText('Emma Dupont')).toBeVisible({ timeout: 15000 }); + }); + + test("AC2: parent sees child's grade card", async ({ page }) => { + await loginAsParent(page); + await page.goto(`${ALPHA_URL}/dashboard/parent-grades`); + + // Wait for grades to load (single child auto-selected) + await expect(page.getByText('DS Maths Parent')).toBeVisible({ timeout: 15000 }); + await expect(page.locator('.grade-value', { hasText: '15.5/20' }).first()).toBeVisible(); + }); + + // ========================================================================= + // AC4: Subject detail with class statistics + // ========================================================================= + + test('AC4: parent sees class statistics on grade card', async ({ page }) => { + await loginAsParent(page); + await page.goto(`${ALPHA_URL}/dashboard/parent-grades`); + + await expect(page.getByText('DS Maths Parent')).toBeVisible({ timeout: 15000 }); + await expect(page.getByText(/Moy\. classe/)).toBeVisible(); + }); + + test('AC4: parent opens subject detail modal', async ({ page }) => { + await loginAsParent(page); + await page.goto(`${ALPHA_URL}/dashboard/parent-grades`); + + // Wait for grade cards to appear + await expect(page.getByText('DS Maths Parent')).toBeVisible({ timeout: 15000 }); + + // Click on a grade card to open subject detail + await page.getByRole('button', { name: /DS Maths Parent/ }).click(); + + // Modal should appear with subject name and grade details + const modal = page.getByRole('dialog'); + await expect(modal).toBeVisible({ timeout: 5000 }); + await expect(modal.locator('.grade-value', { hasText: '15.5/20' }).first()).toBeVisible(); + }); + + // ========================================================================= + // Navigation + // ========================================================================= + + test('navigation: parent sees Notes link in nav', async ({ page }) => { + await loginAsParent(page); + + await expect(page.getByRole('link', { name: 'Notes' })).toBeVisible({ timeout: 15000 }); + }); +}); 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..526ea4b --- /dev/null +++ b/frontend/e2e/student-grades.spec.ts @@ -0,0 +1,581 @@ +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')).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')).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(); + + // Grade values should be blurred and reveal hint visible + await expect(page.locator('.grade-blur').first()).toBeVisible({ timeout: 5000 }); + await expect(page.locator('.reveal-hint').first()).toContainText('Cliquer pour révéler'); + }); + + test('AC4: clicking card in discover mode reveals the grade', 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('.grade-blur').first()).toBeVisible({ timeout: 5000 }); + + // Click the card to reveal + await page.locator('.grade-card-btn').first().click(); + + // Grade value should now be visible (no longer blurred) + const firstCard = page.locator('.grade-card').first(); + await expect(firstCard.locator('.grade-value:not(.grade-blur)')).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('.grade-blur').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('.grade-blur').first()).toBeVisible({ timeout: 5000 }); + + // Disable discover mode for cleanup + await page.locator('.discover-toggle input').uncheck(); + }); + + // ========================================================================= + // Dashboard: grade card pop-in + // ========================================================================= + + test('dashboard: clicking a grade card opens detail pop-in', async ({ page }) => { + // Ensure discover mode is off + await page.goto(`${ALPHA_URL}/login`); + await page.evaluate(() => localStorage.setItem('classeo_grade_preferences', '{"revealMode":"immediate"}')); + + await loginAsStudent(page); + + const gradeBtn = page.locator('.grade-item-btn').first(); + await expect(gradeBtn).toBeVisible({ timeout: 15000 }); + + await gradeBtn.click(); + + // Detail modal should appear + const modal = page.locator('.grade-detail-modal'); + await expect(modal).toBeVisible({ timeout: 5000 }); + + // Modal shows evaluation title + await expect(modal.locator('.grade-detail-title')).toBeVisible(); + + // Modal shows grade value + await expect(modal.locator('.grade-detail-value')).toBeVisible(); + }); + + test('dashboard: grade pop-in shows appreciation', async ({ page }) => { + await loginAsStudent(page); + + const gradeItems = page.locator('.grade-item-btn'); + await expect(gradeItems.first()).toBeVisible({ timeout: 15000 }); + + // Click the Maths grade which has an appreciation + // Maths is second in the list (Dictée/Français is more recent) + await gradeItems.nth(1).click(); + + const modal = page.locator('.grade-detail-modal'); + await expect(modal).toBeVisible({ timeout: 5000 }); + await expect(modal.locator('.grade-detail-appreciation')).toContainText('Très bon travail'); + }); + + test('dashboard: grade pop-in shows class statistics', async ({ page }) => { + await loginAsStudent(page); + + const gradeBtn = page.locator('.grade-item-btn').first(); + await expect(gradeBtn).toBeVisible({ timeout: 15000 }); + + await gradeBtn.click(); + + const modal = page.locator('.grade-detail-modal'); + await expect(modal).toBeVisible({ timeout: 5000 }); + + // Stats grid with Moyenne, Min, Max + await expect(modal.locator('.grade-detail-stats')).toBeVisible(); + await expect(modal.locator('.stat-label').first()).toBeVisible(); + }); + + test('dashboard: grade pop-in closes with Escape', async ({ page }) => { + await loginAsStudent(page); + + const gradeBtn = page.locator('.grade-item-btn').first(); + await expect(gradeBtn).toBeVisible({ timeout: 15000 }); + + await gradeBtn.click(); + + const modal = page.locator('.grade-detail-modal'); + await expect(modal).toBeVisible({ timeout: 5000 }); + + await page.keyboard.press('Escape'); + await expect(modal).not.toBeVisible({ timeout: 5000 }); + }); + + // ========================================================================= + // Dashboard: discover mode + // ========================================================================= + + test('dashboard: discover mode toggle exists', async ({ page }) => { + await loginAsStudent(page); + + const toggle = page.locator('.widget-discover-toggle'); + await expect(toggle).toBeVisible({ timeout: 15000 }); + await expect(toggle).toContainText('Mode découverte'); + }); + + test('dashboard: discover mode blurs grades', async ({ page }) => { + await loginAsStudent(page); + + const toggle = page.locator('.widget-discover-toggle input'); + await expect(toggle).toBeVisible({ timeout: 15000 }); + + await toggle.check(); + + // Grades should be blurred + await expect(page.locator('.grade-item-btn .grade-blur').first()).toBeVisible({ timeout: 5000 }); + await expect(page.locator('.grade-reveal-hint').first()).toBeVisible(); + + // Cleanup + await toggle.uncheck(); + }); + + test('dashboard: clicking blurred card reveals grade', async ({ page }) => { + await loginAsStudent(page); + + // Enable discover mode + const toggle = page.locator('.widget-discover-toggle input'); + await expect(toggle).toBeVisible({ timeout: 15000 }); + await toggle.check(); + + await expect(page.locator('.grade-item-btn .grade-blur').first()).toBeVisible({ timeout: 5000 }); + + // Click to reveal + await page.locator('.grade-item-btn').first().click(); + + // Grade value should now be visible (no blur), and no pop-in should open + const firstItem = page.locator('.grade-item-btn').first(); + await expect(firstItem.locator('.grade-value:not(.grade-blur)')).toBeVisible({ timeout: 5000 }); + await expect(page.locator('.grade-detail-modal')).not.toBeVisible(); + + // Cleanup + await toggle.uncheck(); + }); + + // ========================================================================= + // Full page: clickable grade cards + // ========================================================================= + + test('grades page: clicking a grade card opens subject detail modal', async ({ page }) => { + // Ensure discover mode is off + await page.goto(`${ALPHA_URL}/login`); + await page.evaluate(() => localStorage.setItem('classeo_grade_preferences', '{"revealMode":"immediate"}')); + + await loginAsStudent(page); + await page.goto(`${ALPHA_URL}/dashboard/student-grades`); + + const gradeCard = page.locator('.grade-card-btn').first(); + await expect(gradeCard).toBeVisible({ timeout: 15000 }); + + await gradeCard.click(); + + // Subject detail modal should appear + const modal = page.getByRole('dialog'); + await expect(modal).toBeVisible({ timeout: 5000 }); + + // Should show detail items + await expect(modal.locator('.detail-item')).toHaveCount(1); + }); + + test('grades page: clicking second card opens correct subject modal', async ({ page }) => { + await loginAsStudent(page); + await page.goto(`${ALPHA_URL}/dashboard/student-grades`); + + const secondCard = page.locator('.grade-card-btn').nth(1); + await expect(secondCard).toBeVisible({ timeout: 15000 }); + + await secondCard.click(); + + const modal = page.getByRole('dialog'); + await expect(modal).toBeVisible({ timeout: 5000 }); + + // Should show the Maths subject detail + await expect(modal.locator('.detail-item')).toHaveCount(1); + await expect(modal.locator('.detail-title')).toContainText('DS Mathématiques'); + }); + + // ========================================================================= + // 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/e2e/user-blocking-session.spec.ts b/frontend/e2e/user-blocking-session.spec.ts index bfbd76d..c2d36ea 100644 --- a/frontend/e2e/user-blocking-session.spec.ts +++ b/frontend/e2e/user-blocking-session.spec.ts @@ -182,7 +182,7 @@ test.describe('User Blocking Mid-Session [P1]', () => { // Should see a suspended account error, not the generic credentials error const errorBanner = page.locator('.error-banner.account-suspended'); - await expect(errorBanner).toBeVisible({ timeout: 5000 }); + await expect(errorBanner).toBeVisible({ timeout: 15000 }); await expect(errorBanner).toContainText(/suspendu|contactez/i); }); 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..3f5ecd3 100644 --- a/frontend/src/lib/components/organisms/Dashboard/DashboardStudent.svelte +++ b/frontend/src/lib/components/organisms/Dashboard/DashboardStudent.svelte @@ -2,8 +2,19 @@ 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, + isDiscoverMode, + setRevealMode, + isGradeRevealed, + revealGrade + } 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 +47,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 +104,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); @@ -105,17 +148,51 @@ } function handleOverlayClick(e: MouseEvent) { - if (e.target === e.currentTarget) closeHomeworkDetail(); + if (e.target === e.currentTarget) { + closeHomeworkDetail(); + closeGradeDetail(); + } } - function handleModalKeydown(e: KeyboardEvent) { - if (e.key === 'Escape') closeHomeworkDetail(); + function handleKeydown(e: KeyboardEvent) { + if (e.key === 'Escape') { + closeHomeworkDetail(); + closeGradeDetail(); + } + } + + // Grade detail modal + let selectedGrade = $state(null); + + function openGradeDetail(grade: StudentGrade) { + selectedGrade = grade; + } + + function closeGradeDetail() { + selectedGrade = null; + } + + function toggleDiscoverMode() { + setRevealMode(isDiscoverMode() ? 'immediate' : 'discover'); + } + + function handleReveal(gradeId: string) { + revealGrade(gradeId); + } + + function formatDate(dateStr: string): string { + const d = new Date(dateStr + 'T00:00:00'); + return d.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: 'numeric' }); } $effect(() => { if (!isEleve) return; void loadTodaySchedule(); void loadHomeworks(); + void loadGrades(); + return () => { + if (gradeSeenTimerId !== null) window.clearTimeout(gradeSeenTimerId); + }; }); @@ -179,11 +256,65 @@ - {#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} + {@const discover = isDiscoverMode() && !isGradeRevealed(grade.id)} +
  • + +
  • + {/each} +
+ + Voir toutes les notes → + + {/if} + {:else if hasRealData} {#if isLoading} {:else} @@ -258,9 +389,11 @@ + + {#if selectedHomeworkDetail} -