Les enseignants ont besoin de moyennes à jour immédiatement après la publication ou modification des notes, sans attendre un batch nocturne. Le système recalcule via Domain Events synchrones : statistiques d'évaluation (min/max/moyenne/médiane), moyennes matières pondérées (normalisation /20), et moyenne générale par élève. Les résultats sont stockés dans des tables dénormalisées avec cache Redis (TTL 5 min). Trois endpoints API exposent les données avec contrôle d'accès par rôle. Une commande console permet le backfill des données historiques au déploiement.
379 lines
13 KiB
PHP
379 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Unit\Scolarite\Domain\Model\Grade;
|
|
|
|
use App\Administration\Domain\Model\User\UserId;
|
|
use App\Scolarite\Domain\Event\NoteModifiee;
|
|
use App\Scolarite\Domain\Event\NoteSaisie;
|
|
use App\Scolarite\Domain\Exception\AppreciationTropLongueException;
|
|
use App\Scolarite\Domain\Exception\NoteRequiseException;
|
|
use App\Scolarite\Domain\Exception\ValeurNoteInvalideException;
|
|
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
|
|
use App\Scolarite\Domain\Model\Evaluation\GradeScale;
|
|
use App\Scolarite\Domain\Model\Grade\Grade;
|
|
use App\Scolarite\Domain\Model\Grade\GradeId;
|
|
use App\Scolarite\Domain\Model\Grade\GradeStatus;
|
|
use App\Scolarite\Domain\Model\Grade\GradeValue;
|
|
use App\Shared\Domain\Tenant\TenantId;
|
|
use DateTimeImmutable;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
use function str_repeat;
|
|
|
|
final class GradeTest extends TestCase
|
|
{
|
|
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
|
private const string EVALUATION_ID = '550e8400-e29b-41d4-a716-446655440040';
|
|
private const string STUDENT_ID = '550e8400-e29b-41d4-a716-446655440050';
|
|
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
|
|
|
|
#[Test]
|
|
public function saisirCreatesGradedGrade(): void
|
|
{
|
|
$grade = $this->createGrade();
|
|
|
|
self::assertSame(GradeStatus::GRADED, $grade->status);
|
|
self::assertNotNull($grade->value);
|
|
self::assertSame(15.5, $grade->value->value);
|
|
}
|
|
|
|
#[Test]
|
|
public function saisirRecordsNoteSaisieEvent(): void
|
|
{
|
|
$grade = $this->createGrade();
|
|
|
|
$events = $grade->pullDomainEvents();
|
|
|
|
self::assertCount(1, $events);
|
|
self::assertInstanceOf(NoteSaisie::class, $events[0]);
|
|
self::assertSame($grade->id, $events[0]->gradeId);
|
|
self::assertSame(15.5, $events[0]->value);
|
|
self::assertSame('graded', $events[0]->status);
|
|
}
|
|
|
|
#[Test]
|
|
public function saisirSetsAllProperties(): void
|
|
{
|
|
$tenantId = TenantId::fromString(self::TENANT_ID);
|
|
$evaluationId = EvaluationId::fromString(self::EVALUATION_ID);
|
|
$studentId = UserId::fromString(self::STUDENT_ID);
|
|
$teacherId = UserId::fromString(self::TEACHER_ID);
|
|
$now = new DateTimeImmutable('2026-03-27 10:00:00');
|
|
$value = new GradeValue(15.5);
|
|
$gradeScale = new GradeScale(20);
|
|
|
|
$grade = Grade::saisir(
|
|
tenantId: $tenantId,
|
|
evaluationId: $evaluationId,
|
|
studentId: $studentId,
|
|
value: $value,
|
|
status: GradeStatus::GRADED,
|
|
gradeScale: $gradeScale,
|
|
createdBy: $teacherId,
|
|
now: $now,
|
|
);
|
|
|
|
self::assertTrue($grade->tenantId->equals($tenantId));
|
|
self::assertTrue($grade->evaluationId->equals($evaluationId));
|
|
self::assertTrue($grade->studentId->equals($studentId));
|
|
self::assertTrue($grade->createdBy->equals($teacherId));
|
|
self::assertSame(15.5, $grade->value->value);
|
|
self::assertSame(GradeStatus::GRADED, $grade->status);
|
|
self::assertEquals($now, $grade->createdAt);
|
|
self::assertEquals($now, $grade->updatedAt);
|
|
}
|
|
|
|
#[Test]
|
|
public function saisirCreatesAbsentGrade(): void
|
|
{
|
|
$grade = Grade::saisir(
|
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
|
evaluationId: EvaluationId::fromString(self::EVALUATION_ID),
|
|
studentId: UserId::fromString(self::STUDENT_ID),
|
|
value: null,
|
|
status: GradeStatus::ABSENT,
|
|
gradeScale: new GradeScale(20),
|
|
createdBy: UserId::fromString(self::TEACHER_ID),
|
|
now: new DateTimeImmutable('2026-03-27 10:00:00'),
|
|
);
|
|
|
|
self::assertSame(GradeStatus::ABSENT, $grade->status);
|
|
self::assertNull($grade->value);
|
|
}
|
|
|
|
#[Test]
|
|
public function saisirCreatesDispensedGrade(): void
|
|
{
|
|
$grade = Grade::saisir(
|
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
|
evaluationId: EvaluationId::fromString(self::EVALUATION_ID),
|
|
studentId: UserId::fromString(self::STUDENT_ID),
|
|
value: null,
|
|
status: GradeStatus::DISPENSED,
|
|
gradeScale: new GradeScale(20),
|
|
createdBy: UserId::fromString(self::TEACHER_ID),
|
|
now: new DateTimeImmutable('2026-03-27 10:00:00'),
|
|
);
|
|
|
|
self::assertSame(GradeStatus::DISPENSED, $grade->status);
|
|
self::assertNull($grade->value);
|
|
}
|
|
|
|
#[Test]
|
|
public function saisirThrowsWhenGradedWithoutValue(): void
|
|
{
|
|
$this->expectException(NoteRequiseException::class);
|
|
|
|
Grade::saisir(
|
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
|
evaluationId: EvaluationId::fromString(self::EVALUATION_ID),
|
|
studentId: UserId::fromString(self::STUDENT_ID),
|
|
value: null,
|
|
status: GradeStatus::GRADED,
|
|
gradeScale: new GradeScale(20),
|
|
createdBy: UserId::fromString(self::TEACHER_ID),
|
|
now: new DateTimeImmutable('2026-03-27 10:00:00'),
|
|
);
|
|
}
|
|
|
|
#[Test]
|
|
public function saisirThrowsWhenValueExceedsGradeScale(): void
|
|
{
|
|
$this->expectException(ValeurNoteInvalideException::class);
|
|
|
|
Grade::saisir(
|
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
|
evaluationId: EvaluationId::fromString(self::EVALUATION_ID),
|
|
studentId: UserId::fromString(self::STUDENT_ID),
|
|
value: new GradeValue(25.0),
|
|
status: GradeStatus::GRADED,
|
|
gradeScale: new GradeScale(20),
|
|
createdBy: UserId::fromString(self::TEACHER_ID),
|
|
now: new DateTimeImmutable('2026-03-27 10:00:00'),
|
|
);
|
|
}
|
|
|
|
#[Test]
|
|
public function modifierUpdatesValueAndRecordsEvent(): void
|
|
{
|
|
$grade = $this->createGrade();
|
|
$grade->pullDomainEvents();
|
|
$modifiedAt = new DateTimeImmutable('2026-03-27 14:00:00');
|
|
|
|
$modifierId = UserId::fromString(self::TEACHER_ID);
|
|
|
|
$grade->modifier(
|
|
value: new GradeValue(18.0),
|
|
status: GradeStatus::GRADED,
|
|
gradeScale: new GradeScale(20),
|
|
modifiedBy: $modifierId,
|
|
now: $modifiedAt,
|
|
);
|
|
|
|
self::assertSame(18.0, $grade->value->value);
|
|
self::assertEquals($modifiedAt, $grade->updatedAt);
|
|
|
|
$events = $grade->pullDomainEvents();
|
|
self::assertCount(1, $events);
|
|
self::assertInstanceOf(NoteModifiee::class, $events[0]);
|
|
self::assertSame(15.5, $events[0]->oldValue);
|
|
self::assertSame(18.0, $events[0]->newValue);
|
|
self::assertSame('graded', $events[0]->oldStatus);
|
|
self::assertSame('graded', $events[0]->newStatus);
|
|
self::assertSame(self::TEACHER_ID, $events[0]->modifiedBy);
|
|
}
|
|
|
|
#[Test]
|
|
public function modifierChangesToAbsent(): void
|
|
{
|
|
$grade = $this->createGrade();
|
|
$grade->pullDomainEvents();
|
|
|
|
$grade->modifier(
|
|
value: null,
|
|
status: GradeStatus::ABSENT,
|
|
gradeScale: new GradeScale(20),
|
|
modifiedBy: UserId::fromString(self::TEACHER_ID),
|
|
now: new DateTimeImmutable('2026-03-27 14:00:00'),
|
|
);
|
|
|
|
self::assertSame(GradeStatus::ABSENT, $grade->status);
|
|
self::assertNull($grade->value);
|
|
}
|
|
|
|
#[Test]
|
|
public function modifierThrowsWhenGradedWithoutValue(): void
|
|
{
|
|
$grade = $this->createGrade();
|
|
|
|
$this->expectException(NoteRequiseException::class);
|
|
|
|
$grade->modifier(
|
|
value: null,
|
|
status: GradeStatus::GRADED,
|
|
gradeScale: new GradeScale(20),
|
|
modifiedBy: UserId::fromString(self::TEACHER_ID),
|
|
now: new DateTimeImmutable('2026-03-27 14:00:00'),
|
|
);
|
|
}
|
|
|
|
#[Test]
|
|
public function modifierThrowsWhenValueExceedsGradeScale(): void
|
|
{
|
|
$grade = $this->createGrade();
|
|
|
|
$this->expectException(ValeurNoteInvalideException::class);
|
|
|
|
$grade->modifier(
|
|
value: new GradeValue(25.0),
|
|
status: GradeStatus::GRADED,
|
|
gradeScale: new GradeScale(20),
|
|
modifiedBy: UserId::fromString(self::TEACHER_ID),
|
|
now: new DateTimeImmutable('2026-03-27 14:00:00'),
|
|
);
|
|
}
|
|
|
|
#[Test]
|
|
public function reconstituteRestoresAllPropertiesWithoutEvents(): void
|
|
{
|
|
$gradeId = GradeId::generate();
|
|
$tenantId = TenantId::fromString(self::TENANT_ID);
|
|
$evaluationId = EvaluationId::fromString(self::EVALUATION_ID);
|
|
$studentId = UserId::fromString(self::STUDENT_ID);
|
|
$teacherId = UserId::fromString(self::TEACHER_ID);
|
|
$createdAt = new DateTimeImmutable('2026-03-27 10:00:00');
|
|
$updatedAt = new DateTimeImmutable('2026-03-27 14:00:00');
|
|
$value = new GradeValue(15.5);
|
|
|
|
$grade = Grade::reconstitute(
|
|
id: $gradeId,
|
|
tenantId: $tenantId,
|
|
evaluationId: $evaluationId,
|
|
studentId: $studentId,
|
|
value: $value,
|
|
status: GradeStatus::GRADED,
|
|
createdBy: $teacherId,
|
|
createdAt: $createdAt,
|
|
updatedAt: $updatedAt,
|
|
);
|
|
|
|
self::assertTrue($grade->id->equals($gradeId));
|
|
self::assertTrue($grade->tenantId->equals($tenantId));
|
|
self::assertTrue($grade->evaluationId->equals($evaluationId));
|
|
self::assertTrue($grade->studentId->equals($studentId));
|
|
self::assertTrue($grade->createdBy->equals($teacherId));
|
|
self::assertSame(15.5, $grade->value->value);
|
|
self::assertSame(GradeStatus::GRADED, $grade->status);
|
|
self::assertEquals($createdAt, $grade->createdAt);
|
|
self::assertEquals($updatedAt, $grade->updatedAt);
|
|
self::assertEmpty($grade->pullDomainEvents());
|
|
}
|
|
|
|
#[Test]
|
|
public function saisirAppreciationSetsAppreciation(): void
|
|
{
|
|
$grade = $this->createGrade();
|
|
$now = new DateTimeImmutable('2026-03-31 10:00:00');
|
|
|
|
$grade->saisirAppreciation('Très bon travail', $now);
|
|
|
|
self::assertSame('Très bon travail', $grade->appreciation);
|
|
self::assertEquals($now, $grade->appreciationUpdatedAt);
|
|
self::assertEquals($now, $grade->updatedAt);
|
|
}
|
|
|
|
#[Test]
|
|
public function saisirAppreciationAcceptsNull(): void
|
|
{
|
|
$grade = $this->createGrade();
|
|
$grade->saisirAppreciation('Temporaire', new DateTimeImmutable('2026-03-31 09:00:00'));
|
|
|
|
$grade->saisirAppreciation(null, new DateTimeImmutable('2026-03-31 10:00:00'));
|
|
|
|
self::assertNull($grade->appreciation);
|
|
}
|
|
|
|
#[Test]
|
|
public function saisirAppreciationAcceptsEmptyString(): void
|
|
{
|
|
$grade = $this->createGrade();
|
|
$grade->saisirAppreciation('Temporaire', new DateTimeImmutable('2026-03-31 09:00:00'));
|
|
|
|
$grade->saisirAppreciation('', new DateTimeImmutable('2026-03-31 10:00:00'));
|
|
|
|
self::assertNull($grade->appreciation);
|
|
}
|
|
|
|
#[Test]
|
|
public function saisirAppreciationThrowsWhenTooLong(): void
|
|
{
|
|
$grade = $this->createGrade();
|
|
|
|
$this->expectException(AppreciationTropLongueException::class);
|
|
|
|
$grade->saisirAppreciation(str_repeat('a', 501), new DateTimeImmutable('2026-03-31 10:00:00'));
|
|
}
|
|
|
|
#[Test]
|
|
public function saisirAppreciationAcceptsMaxLength(): void
|
|
{
|
|
$grade = $this->createGrade();
|
|
|
|
$grade->saisirAppreciation(str_repeat('a', 500), new DateTimeImmutable('2026-03-31 10:00:00'));
|
|
|
|
self::assertSame(500, mb_strlen($grade->appreciation ?? ''));
|
|
}
|
|
|
|
#[Test]
|
|
public function newGradeHasNullAppreciation(): void
|
|
{
|
|
$grade = $this->createGrade();
|
|
|
|
self::assertNull($grade->appreciation);
|
|
self::assertNull($grade->appreciationUpdatedAt);
|
|
}
|
|
|
|
#[Test]
|
|
public function reconstituteRestoresAppreciation(): void
|
|
{
|
|
$gradeId = GradeId::generate();
|
|
$createdAt = new DateTimeImmutable('2026-03-27 10:00:00');
|
|
$updatedAt = new DateTimeImmutable('2026-03-27 14:00:00');
|
|
$appreciationUpdatedAt = new DateTimeImmutable('2026-03-31 10:00:00');
|
|
|
|
$grade = Grade::reconstitute(
|
|
id: $gradeId,
|
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
|
evaluationId: EvaluationId::fromString(self::EVALUATION_ID),
|
|
studentId: UserId::fromString(self::STUDENT_ID),
|
|
value: new GradeValue(15.5),
|
|
status: GradeStatus::GRADED,
|
|
createdBy: UserId::fromString(self::TEACHER_ID),
|
|
createdAt: $createdAt,
|
|
updatedAt: $updatedAt,
|
|
appreciation: 'Bon travail',
|
|
appreciationUpdatedAt: $appreciationUpdatedAt,
|
|
);
|
|
|
|
self::assertSame('Bon travail', $grade->appreciation);
|
|
self::assertEquals($appreciationUpdatedAt, $grade->appreciationUpdatedAt);
|
|
}
|
|
|
|
private function createGrade(): Grade
|
|
{
|
|
return Grade::saisir(
|
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
|
evaluationId: EvaluationId::fromString(self::EVALUATION_ID),
|
|
studentId: UserId::fromString(self::STUDENT_ID),
|
|
value: new GradeValue(15.5),
|
|
status: GradeStatus::GRADED,
|
|
gradeScale: new GradeScale(20),
|
|
createdBy: UserId::fromString(self::TEACHER_ID),
|
|
now: new DateTimeImmutable('2026-03-27 10:00:00'),
|
|
);
|
|
}
|
|
}
|