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; } }