feat: Permettre à l'enseignant de saisir les notes dans une grille inline
L'enseignant avait besoin d'un moyen rapide de saisir les notes après une évaluation. La grille inline permet de compléter 30 élèves en moins de 3 minutes grâce à la navigation clavier (Tab/Enter/Shift+Tab), la validation temps réel, l'auto-save debounced (500ms) et les raccourcis /abs et /disp pour marquer absents/dispensés. Les notes restent en brouillon jusqu'à publication explicite (avec confirmation modale). Une fois publiées, les élèves les voient immédiatement ; les parents après un délai de 24h (VisibiliteNotesPolicy). Le mode offline stocke les notes en IndexedDB et synchronise automatiquement au retour de la connexion. Chaque modification est auditée dans grade_events via un event subscriber qui écoute NoteSaisie/NoteModifiee sur le bus d'événements.
This commit is contained in:
285
backend/tests/Unit/Scolarite/Domain/Model/Grade/GradeTest.php
Normal file
285
backend/tests/Unit/Scolarite/Domain/Model/Grade/GradeTest.php
Normal file
@@ -0,0 +1,285 @@
|
||||
<?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\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;
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Domain\Model\Grade;
|
||||
|
||||
use App\Scolarite\Domain\Exception\ValeurNoteInvalideException;
|
||||
use App\Scolarite\Domain\Model\Grade\GradeValue;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class GradeValueTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function acceptsZero(): void
|
||||
{
|
||||
self::assertSame(0.0, (new GradeValue(0))->value);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function acceptsPositiveValue(): void
|
||||
{
|
||||
self::assertSame(15.5, (new GradeValue(15.5))->value);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function rejectsNegativeValue(): void
|
||||
{
|
||||
$this->expectException(ValeurNoteInvalideException::class);
|
||||
|
||||
new GradeValue(-1);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function rejectsNegativeDecimal(): void
|
||||
{
|
||||
$this->expectException(ValeurNoteInvalideException::class);
|
||||
|
||||
new GradeValue(-0.5);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function equalsComparesValues(): void
|
||||
{
|
||||
$a = new GradeValue(15.0);
|
||||
$b = new GradeValue(15.0);
|
||||
$c = new GradeValue(12.0);
|
||||
|
||||
self::assertTrue($a->equals($b));
|
||||
self::assertFalse($a->equals($c));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Domain\Policy;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Domain\Model\Evaluation\Coefficient;
|
||||
use App\Scolarite\Domain\Model\Evaluation\Evaluation;
|
||||
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
|
||||
use App\Scolarite\Domain\Model\Evaluation\EvaluationStatus;
|
||||
use App\Scolarite\Domain\Model\Evaluation\GradeScale;
|
||||
use App\Scolarite\Domain\Policy\VisibiliteNotesPolicy;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class VisibiliteNotesPolicyTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function eleveVoitNotesPubliees(): void
|
||||
{
|
||||
$policy = $this->createPolicy(new DateTimeImmutable('2026-03-28 10:00:00'));
|
||||
$evaluation = $this->createPublishedEvaluation(new DateTimeImmutable('2026-03-27 14:00:00'));
|
||||
|
||||
self::assertTrue($policy->visiblePourEleve($evaluation));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function eleveNeVoitPasNotesBrouillon(): void
|
||||
{
|
||||
$policy = $this->createPolicy(new DateTimeImmutable('2026-03-27 15:00:00'));
|
||||
$evaluation = $this->createUnpublishedEvaluation();
|
||||
|
||||
self::assertFalse($policy->visiblePourEleve($evaluation));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function parentNeVoitPasAvant24h(): void
|
||||
{
|
||||
$publishedAt = new DateTimeImmutable('2026-03-27 14:00:00');
|
||||
$now = new DateTimeImmutable('2026-03-28 13:59:59'); // 23h59 après
|
||||
$policy = $this->createPolicy($now);
|
||||
$evaluation = $this->createPublishedEvaluation($publishedAt);
|
||||
|
||||
self::assertFalse($policy->visiblePourParent($evaluation));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function parentVoitApres24h(): void
|
||||
{
|
||||
$publishedAt = new DateTimeImmutable('2026-03-27 14:00:00');
|
||||
$now = new DateTimeImmutable('2026-03-28 14:00:00'); // exactement 24h après
|
||||
$policy = $this->createPolicy($now);
|
||||
$evaluation = $this->createPublishedEvaluation($publishedAt);
|
||||
|
||||
self::assertTrue($policy->visiblePourParent($evaluation));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function parentNeVoitPasNotesBrouillon(): void
|
||||
{
|
||||
$policy = $this->createPolicy(new DateTimeImmutable('2026-04-01 10:00:00'));
|
||||
$evaluation = $this->createUnpublishedEvaluation();
|
||||
|
||||
self::assertFalse($policy->visiblePourParent($evaluation));
|
||||
}
|
||||
|
||||
private function createPolicy(DateTimeImmutable $now): VisibiliteNotesPolicy
|
||||
{
|
||||
$clock = new class($now) implements Clock {
|
||||
public function __construct(private readonly DateTimeImmutable $now)
|
||||
{
|
||||
}
|
||||
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return $this->now;
|
||||
}
|
||||
};
|
||||
|
||||
return new VisibiliteNotesPolicy($clock);
|
||||
}
|
||||
|
||||
private function createPublishedEvaluation(DateTimeImmutable $publishedAt): Evaluation
|
||||
{
|
||||
return Evaluation::reconstitute(
|
||||
id: EvaluationId::generate(),
|
||||
tenantId: TenantId::fromString('550e8400-e29b-41d4-a716-446655440001'),
|
||||
classId: ClassId::fromString('550e8400-e29b-41d4-a716-446655440020'),
|
||||
subjectId: SubjectId::fromString('550e8400-e29b-41d4-a716-446655440030'),
|
||||
teacherId: UserId::fromString('550e8400-e29b-41d4-a716-446655440010'),
|
||||
title: 'Test',
|
||||
description: null,
|
||||
evaluationDate: new DateTimeImmutable('2026-04-15'),
|
||||
gradeScale: new GradeScale(20),
|
||||
coefficient: new Coefficient(1.0),
|
||||
status: EvaluationStatus::PUBLISHED,
|
||||
createdAt: new DateTimeImmutable('2026-03-12 10:00:00'),
|
||||
updatedAt: $publishedAt,
|
||||
gradesPublishedAt: $publishedAt,
|
||||
);
|
||||
}
|
||||
|
||||
private function createUnpublishedEvaluation(): Evaluation
|
||||
{
|
||||
return Evaluation::reconstitute(
|
||||
id: EvaluationId::generate(),
|
||||
tenantId: TenantId::fromString('550e8400-e29b-41d4-a716-446655440001'),
|
||||
classId: ClassId::fromString('550e8400-e29b-41d4-a716-446655440020'),
|
||||
subjectId: SubjectId::fromString('550e8400-e29b-41d4-a716-446655440030'),
|
||||
teacherId: UserId::fromString('550e8400-e29b-41d4-a716-446655440010'),
|
||||
title: 'Test',
|
||||
description: null,
|
||||
evaluationDate: new DateTimeImmutable('2026-04-15'),
|
||||
gradeScale: new GradeScale(20),
|
||||
coefficient: new Coefficient(1.0),
|
||||
status: EvaluationStatus::PUBLISHED,
|
||||
createdAt: new DateTimeImmutable('2026-03-12 10:00:00'),
|
||||
updatedAt: new DateTimeImmutable('2026-03-12 10:00:00'),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user