L'élève avait accès à ses compétences mais pas à ses notes numériques. Cette fonctionnalité lui donne une vue complète de sa progression scolaire avec moyennes par matière, détail par évaluation, statistiques de classe, et un mode "découverte" pour révéler ses notes à son rythme (FR14, FR15). Les notes ne sont visibles qu'après publication par l'enseignant, ce qui garantit que l'élève les découvre avant ses parents (délai 24h story 6.7).
557 lines
19 KiB
PHP
557 lines
19 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Unit\Scolarite\Application\Query\GetChildrenGrades;
|
|
|
|
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
|
use App\Administration\Domain\Model\Subject\SubjectId;
|
|
use App\Administration\Domain\Model\User\UserId;
|
|
use App\Scolarite\Application\Port\ParentChildrenReader;
|
|
use App\Scolarite\Application\Port\ParentGradeDelayReader;
|
|
use App\Scolarite\Application\Port\ScheduleDisplayReader;
|
|
use App\Scolarite\Application\Query\GetChildrenGrades\GetChildrenGradesHandler;
|
|
use App\Scolarite\Application\Query\GetChildrenGrades\GetChildrenGradesQuery;
|
|
use App\Scolarite\Domain\Model\Evaluation\ClassStatistics;
|
|
use App\Scolarite\Domain\Model\Evaluation\Coefficient;
|
|
use App\Scolarite\Domain\Model\Evaluation\Evaluation;
|
|
use App\Scolarite\Domain\Model\Evaluation\GradeScale;
|
|
use App\Scolarite\Domain\Model\Grade\Grade;
|
|
use App\Scolarite\Domain\Model\Grade\GradeStatus;
|
|
use App\Scolarite\Domain\Policy\VisibiliteNotesPolicy;
|
|
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryEvaluationRepository;
|
|
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryEvaluationStatisticsRepository;
|
|
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryGradeRepository;
|
|
use App\Shared\Domain\Clock;
|
|
use App\Shared\Domain\Tenant\TenantId;
|
|
use DateTimeImmutable;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
final class GetChildrenGradesHandlerTest extends TestCase
|
|
{
|
|
use ParentGradeTestHelper;
|
|
|
|
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
|
private const string PARENT_ID = '550e8400-e29b-41d4-a716-446655440060';
|
|
private const string CHILD_A_ID = '550e8400-e29b-41d4-a716-446655440050';
|
|
private const string CHILD_B_ID = '550e8400-e29b-41d4-a716-446655440051';
|
|
private const string CLASS_A_ID = '550e8400-e29b-41d4-a716-446655440020';
|
|
private const string CLASS_B_ID = '550e8400-e29b-41d4-a716-446655440021';
|
|
private const string SUBJECT_MATH_ID = '550e8400-e29b-41d4-a716-446655440030';
|
|
private const string SUBJECT_FRENCH_ID = '550e8400-e29b-41d4-a716-446655440031';
|
|
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
|
|
|
|
private InMemoryEvaluationRepository $evaluationRepository;
|
|
private InMemoryGradeRepository $gradeRepository;
|
|
private InMemoryEvaluationStatisticsRepository $statisticsRepository;
|
|
private DateTimeImmutable $now;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->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<array{studentId: string, firstName: string, lastName: string}> $children
|
|
*/
|
|
private function createHandler(
|
|
array $children = [],
|
|
int $delayHours = 24,
|
|
): GetChildrenGradesHandler {
|
|
$parentChildrenReader = new class($children) implements ParentChildrenReader {
|
|
/** @param array<array{studentId: string, firstName: string, lastName: string}> $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;
|
|
}
|
|
}
|