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:
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Application\Command\PublishGrades;
|
||||
|
||||
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\Command\PublishGrades\PublishGradesCommand;
|
||||
use App\Scolarite\Application\Command\PublishGrades\PublishGradesHandler;
|
||||
use App\Scolarite\Domain\Exception\AucuneNoteSaisieException;
|
||||
use App\Scolarite\Domain\Exception\NonProprietaireDeLEvaluationException;
|
||||
use App\Scolarite\Domain\Exception\NotesDejaPublieesException;
|
||||
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\Model\Grade\Grade;
|
||||
use App\Scolarite\Domain\Model\Grade\GradeStatus;
|
||||
use App\Scolarite\Domain\Model\Grade\GradeValue;
|
||||
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryEvaluationRepository;
|
||||
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 PublishGradesHandlerTest 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 TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
|
||||
private const string STUDENT_ID = '550e8400-e29b-41d4-a716-446655440050';
|
||||
|
||||
private InMemoryEvaluationRepository $evaluationRepository;
|
||||
private InMemoryGradeRepository $gradeRepository;
|
||||
private Clock $clock;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->evaluationRepository = new InMemoryEvaluationRepository();
|
||||
$this->gradeRepository = new InMemoryGradeRepository();
|
||||
$this->clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-03-27 14:00:00');
|
||||
}
|
||||
};
|
||||
|
||||
$this->seedEvaluation();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itPublishesGradesWhenGradesExist(): void
|
||||
{
|
||||
$this->seedGrade();
|
||||
$handler = $this->createHandler();
|
||||
$command = new PublishGradesCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
evaluationId: self::EVALUATION_ID,
|
||||
teacherId: self::TEACHER_ID,
|
||||
);
|
||||
|
||||
$evaluation = $handler($command);
|
||||
|
||||
self::assertTrue($evaluation->notesPubliees());
|
||||
self::assertEquals(
|
||||
new DateTimeImmutable('2026-03-27 14:00:00'),
|
||||
$evaluation->gradesPublishedAt,
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itPersistsPublishedEvaluation(): void
|
||||
{
|
||||
$this->seedGrade();
|
||||
$handler = $this->createHandler();
|
||||
$handler(new PublishGradesCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
evaluationId: self::EVALUATION_ID,
|
||||
teacherId: self::TEACHER_ID,
|
||||
));
|
||||
|
||||
$evaluation = $this->evaluationRepository->get(
|
||||
EvaluationId::fromString(self::EVALUATION_ID),
|
||||
TenantId::fromString(self::TENANT_ID),
|
||||
);
|
||||
|
||||
self::assertTrue($evaluation->notesPubliees());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsWhenNoGradesExist(): void
|
||||
{
|
||||
$handler = $this->createHandler();
|
||||
|
||||
$this->expectException(AucuneNoteSaisieException::class);
|
||||
|
||||
$handler(new PublishGradesCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
evaluationId: self::EVALUATION_ID,
|
||||
teacherId: self::TEACHER_ID,
|
||||
));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsWhenTeacherNotOwner(): void
|
||||
{
|
||||
$this->seedGrade();
|
||||
$handler = $this->createHandler();
|
||||
$otherTeacher = '550e8400-e29b-41d4-a716-446655440099';
|
||||
|
||||
$this->expectException(NonProprietaireDeLEvaluationException::class);
|
||||
|
||||
$handler(new PublishGradesCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
evaluationId: self::EVALUATION_ID,
|
||||
teacherId: $otherTeacher,
|
||||
));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsWhenAlreadyPublished(): void
|
||||
{
|
||||
$this->seedGrade();
|
||||
$handler = $this->createHandler();
|
||||
$command = new PublishGradesCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
evaluationId: self::EVALUATION_ID,
|
||||
teacherId: self::TEACHER_ID,
|
||||
);
|
||||
|
||||
$handler($command);
|
||||
|
||||
$this->expectException(NotesDejaPublieesException::class);
|
||||
|
||||
$handler($command);
|
||||
}
|
||||
|
||||
private function createHandler(): PublishGradesHandler
|
||||
{
|
||||
return new PublishGradesHandler(
|
||||
$this->evaluationRepository,
|
||||
$this->gradeRepository,
|
||||
$this->clock,
|
||||
);
|
||||
}
|
||||
|
||||
private function seedEvaluation(): void
|
||||
{
|
||||
$evaluation = Evaluation::reconstitute(
|
||||
id: EvaluationId::fromString(self::EVALUATION_ID),
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
classId: ClassId::fromString('550e8400-e29b-41d4-a716-446655440020'),
|
||||
subjectId: SubjectId::fromString('550e8400-e29b-41d4-a716-446655440030'),
|
||||
teacherId: UserId::fromString(self::TEACHER_ID),
|
||||
title: 'Contrôle chapitre 5',
|
||||
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'),
|
||||
);
|
||||
|
||||
$this->evaluationRepository->save($evaluation);
|
||||
}
|
||||
|
||||
private function seedGrade(): void
|
||||
{
|
||||
$grade = Grade::saisir(
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
evaluationId: EvaluationId::fromString(self::EVALUATION_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: new DateTimeImmutable('2026-03-27 10:00:00'),
|
||||
);
|
||||
|
||||
$this->gradeRepository->save($grade);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Application\Command\SaveGrades;
|
||||
|
||||
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\Command\SaveGrades\SaveGradesCommand;
|
||||
use App\Scolarite\Application\Command\SaveGrades\SaveGradesHandler;
|
||||
use App\Scolarite\Domain\Exception\NonProprietaireDeLEvaluationException;
|
||||
use App\Scolarite\Domain\Exception\NoteRequiseException;
|
||||
use App\Scolarite\Domain\Exception\ValeurNoteInvalideException;
|
||||
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\Model\Grade\GradeStatus;
|
||||
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryEvaluationRepository;
|
||||
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 SaveGradesHandlerTest 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 TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
|
||||
private const string STUDENT_1_ID = '550e8400-e29b-41d4-a716-446655440050';
|
||||
private const string STUDENT_2_ID = '550e8400-e29b-41d4-a716-446655440051';
|
||||
|
||||
private InMemoryEvaluationRepository $evaluationRepository;
|
||||
private InMemoryGradeRepository $gradeRepository;
|
||||
private Clock $clock;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->evaluationRepository = new InMemoryEvaluationRepository();
|
||||
$this->gradeRepository = new InMemoryGradeRepository();
|
||||
$this->clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-03-27 10:00:00');
|
||||
}
|
||||
};
|
||||
|
||||
$this->seedEvaluation();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itSavesNewGrades(): void
|
||||
{
|
||||
$handler = $this->createHandler();
|
||||
$command = new SaveGradesCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
evaluationId: self::EVALUATION_ID,
|
||||
teacherId: self::TEACHER_ID,
|
||||
grades: [
|
||||
['studentId' => self::STUDENT_1_ID, 'value' => 15.5, 'status' => 'graded'],
|
||||
['studentId' => self::STUDENT_2_ID, 'value' => 12.0, 'status' => 'graded'],
|
||||
],
|
||||
);
|
||||
|
||||
$savedGrades = $handler($command);
|
||||
|
||||
self::assertCount(2, $savedGrades);
|
||||
self::assertSame(15.5, $savedGrades[0]->value->value);
|
||||
self::assertSame(12.0, $savedGrades[1]->value->value);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itPersistsGradesInRepository(): void
|
||||
{
|
||||
$handler = $this->createHandler();
|
||||
$command = new SaveGradesCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
evaluationId: self::EVALUATION_ID,
|
||||
teacherId: self::TEACHER_ID,
|
||||
grades: [
|
||||
['studentId' => self::STUDENT_1_ID, 'value' => 15.5, 'status' => 'graded'],
|
||||
],
|
||||
);
|
||||
|
||||
$handler($command);
|
||||
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
$grades = $this->gradeRepository->findByEvaluation(
|
||||
EvaluationId::fromString(self::EVALUATION_ID),
|
||||
$tenantId,
|
||||
);
|
||||
|
||||
self::assertCount(1, $grades);
|
||||
self::assertSame(15.5, $grades[0]->value->value);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itUpdatesExistingGrades(): void
|
||||
{
|
||||
$handler = $this->createHandler();
|
||||
|
||||
$handler(new SaveGradesCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
evaluationId: self::EVALUATION_ID,
|
||||
teacherId: self::TEACHER_ID,
|
||||
grades: [
|
||||
['studentId' => self::STUDENT_1_ID, 'value' => 15.5, 'status' => 'graded'],
|
||||
],
|
||||
));
|
||||
|
||||
$handler(new SaveGradesCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
evaluationId: self::EVALUATION_ID,
|
||||
teacherId: self::TEACHER_ID,
|
||||
grades: [
|
||||
['studentId' => self::STUDENT_1_ID, 'value' => 18.0, 'status' => 'graded'],
|
||||
],
|
||||
));
|
||||
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
$grades = $this->gradeRepository->findByEvaluation(
|
||||
EvaluationId::fromString(self::EVALUATION_ID),
|
||||
$tenantId,
|
||||
);
|
||||
|
||||
self::assertCount(1, $grades);
|
||||
self::assertSame(18.0, $grades[0]->value->value);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itSavesAbsentGrade(): void
|
||||
{
|
||||
$handler = $this->createHandler();
|
||||
$command = new SaveGradesCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
evaluationId: self::EVALUATION_ID,
|
||||
teacherId: self::TEACHER_ID,
|
||||
grades: [
|
||||
['studentId' => self::STUDENT_1_ID, 'value' => null, 'status' => 'absent'],
|
||||
],
|
||||
);
|
||||
|
||||
$savedGrades = $handler($command);
|
||||
|
||||
self::assertSame(GradeStatus::ABSENT, $savedGrades[0]->status);
|
||||
self::assertNull($savedGrades[0]->value);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itSavesDispensedGrade(): void
|
||||
{
|
||||
$handler = $this->createHandler();
|
||||
$command = new SaveGradesCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
evaluationId: self::EVALUATION_ID,
|
||||
teacherId: self::TEACHER_ID,
|
||||
grades: [
|
||||
['studentId' => self::STUDENT_1_ID, 'value' => null, 'status' => 'dispensed'],
|
||||
],
|
||||
);
|
||||
|
||||
$savedGrades = $handler($command);
|
||||
|
||||
self::assertSame(GradeStatus::DISPENSED, $savedGrades[0]->status);
|
||||
self::assertNull($savedGrades[0]->value);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsWhenTeacherNotOwner(): void
|
||||
{
|
||||
$handler = $this->createHandler();
|
||||
$otherTeacher = '550e8400-e29b-41d4-a716-446655440099';
|
||||
|
||||
$this->expectException(NonProprietaireDeLEvaluationException::class);
|
||||
|
||||
$handler(new SaveGradesCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
evaluationId: self::EVALUATION_ID,
|
||||
teacherId: $otherTeacher,
|
||||
grades: [
|
||||
['studentId' => self::STUDENT_1_ID, 'value' => 15.5, 'status' => 'graded'],
|
||||
],
|
||||
));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsWhenValueExceedsGradeScale(): void
|
||||
{
|
||||
$handler = $this->createHandler();
|
||||
|
||||
$this->expectException(ValeurNoteInvalideException::class);
|
||||
|
||||
$handler(new SaveGradesCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
evaluationId: self::EVALUATION_ID,
|
||||
teacherId: self::TEACHER_ID,
|
||||
grades: [
|
||||
['studentId' => self::STUDENT_1_ID, 'value' => 25.0, 'status' => 'graded'],
|
||||
],
|
||||
));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsWhenGradedWithoutValue(): void
|
||||
{
|
||||
$handler = $this->createHandler();
|
||||
|
||||
$this->expectException(NoteRequiseException::class);
|
||||
|
||||
$handler(new SaveGradesCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
evaluationId: self::EVALUATION_ID,
|
||||
teacherId: self::TEACHER_ID,
|
||||
grades: [
|
||||
['studentId' => self::STUDENT_1_ID, 'value' => null, 'status' => 'graded'],
|
||||
],
|
||||
));
|
||||
}
|
||||
|
||||
private function createHandler(): SaveGradesHandler
|
||||
{
|
||||
return new SaveGradesHandler(
|
||||
$this->evaluationRepository,
|
||||
$this->gradeRepository,
|
||||
$this->clock,
|
||||
);
|
||||
}
|
||||
|
||||
private function seedEvaluation(): void
|
||||
{
|
||||
$evaluation = Evaluation::reconstitute(
|
||||
id: EvaluationId::fromString(self::EVALUATION_ID),
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
classId: ClassId::fromString('550e8400-e29b-41d4-a716-446655440020'),
|
||||
subjectId: SubjectId::fromString('550e8400-e29b-41d4-a716-446655440030'),
|
||||
teacherId: UserId::fromString(self::TEACHER_ID),
|
||||
title: 'Contrôle chapitre 5',
|
||||
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'),
|
||||
);
|
||||
|
||||
$this->evaluationRepository->save($evaluation);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user