feat: Permettre la création et modification de l'emploi du temps des classes
L'administration a besoin de construire et maintenir les emplois du temps hebdomadaires pour chaque classe, en s'assurant que les enseignants ne sont pas en conflit (même créneau, classes différentes) et que les affectations enseignant-matière-classe sont respectées. Cette implémentation couvre le CRUD complet des créneaux (ScheduleSlot), la détection de conflits (classe, enseignant, salle) avec possibilité de forcer, la validation des affectations côté serveur (AC2), l'intégration calendrier pour les jours bloqués, une vue mobile-first avec onglets jour par jour, et le drag-and-drop pour réorganiser les créneaux sur desktop.
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Domain\Model\Schedule;
|
||||
|
||||
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\Event\CoursCree;
|
||||
use App\Scolarite\Domain\Event\CoursModifie;
|
||||
use App\Scolarite\Domain\Event\CoursSupprime;
|
||||
use App\Scolarite\Domain\Model\Schedule\DayOfWeek;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlotId;
|
||||
use App\Scolarite\Domain\Model\Schedule\TimeSlot;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ScheduleSlotTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string CLASS_ID = '550e8400-e29b-41d4-a716-446655440020';
|
||||
private const string SUBJECT_ID = '550e8400-e29b-41d4-a716-446655440030';
|
||||
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
|
||||
|
||||
#[Test]
|
||||
public function creerCreatesSlotWithCorrectProperties(): void
|
||||
{
|
||||
$slot = $this->createSlot();
|
||||
|
||||
self::assertTrue($slot->tenantId->equals(TenantId::fromString(self::TENANT_ID)));
|
||||
self::assertTrue($slot->classId->equals(ClassId::fromString(self::CLASS_ID)));
|
||||
self::assertTrue($slot->subjectId->equals(SubjectId::fromString(self::SUBJECT_ID)));
|
||||
self::assertTrue($slot->teacherId->equals(UserId::fromString(self::TEACHER_ID)));
|
||||
self::assertSame(DayOfWeek::MONDAY, $slot->dayOfWeek);
|
||||
self::assertSame('08:00', $slot->timeSlot->startTime);
|
||||
self::assertSame('09:00', $slot->timeSlot->endTime);
|
||||
self::assertNull($slot->room);
|
||||
self::assertTrue($slot->isRecurring);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function creerRecordsCoursCreeEvent(): void
|
||||
{
|
||||
$slot = $this->createSlot();
|
||||
|
||||
$events = $slot->pullDomainEvents();
|
||||
|
||||
self::assertCount(1, $events);
|
||||
self::assertInstanceOf(CoursCree::class, $events[0]);
|
||||
self::assertTrue($slot->id->equals($events[0]->slotId));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function creerWithRoomSetsRoom(): void
|
||||
{
|
||||
$slot = $this->createSlot(room: 'Salle 101');
|
||||
|
||||
self::assertSame('Salle 101', $slot->room);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function modifierUpdatesProperties(): void
|
||||
{
|
||||
$slot = $this->createSlot();
|
||||
$slot->pullDomainEvents();
|
||||
|
||||
$newSubjectId = SubjectId::fromString('550e8400-e29b-41d4-a716-446655440031');
|
||||
$newTeacherId = UserId::fromString('550e8400-e29b-41d4-a716-446655440011');
|
||||
$newTimeSlot = new TimeSlot('10:00', '11:00');
|
||||
$now = new DateTimeImmutable('2026-03-02 15:00:00');
|
||||
|
||||
$newClassId = ClassId::fromString('550e8400-e29b-41d4-a716-446655440021');
|
||||
|
||||
$slot->modifier(
|
||||
classId: $newClassId,
|
||||
subjectId: $newSubjectId,
|
||||
teacherId: $newTeacherId,
|
||||
dayOfWeek: DayOfWeek::TUESDAY,
|
||||
timeSlot: $newTimeSlot,
|
||||
room: 'Salle 202',
|
||||
at: $now,
|
||||
);
|
||||
|
||||
self::assertTrue($slot->classId->equals($newClassId));
|
||||
self::assertTrue($slot->subjectId->equals($newSubjectId));
|
||||
self::assertTrue($slot->teacherId->equals($newTeacherId));
|
||||
self::assertSame(DayOfWeek::TUESDAY, $slot->dayOfWeek);
|
||||
self::assertSame('10:00', $slot->timeSlot->startTime);
|
||||
self::assertSame('11:00', $slot->timeSlot->endTime);
|
||||
self::assertSame('Salle 202', $slot->room);
|
||||
self::assertEquals($now, $slot->updatedAt);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function modifierRecordsCoursModifieEvent(): void
|
||||
{
|
||||
$slot = $this->createSlot();
|
||||
$slot->pullDomainEvents();
|
||||
|
||||
$now = new DateTimeImmutable('2026-03-02 15:00:00');
|
||||
$slot->modifier(
|
||||
classId: $slot->classId,
|
||||
subjectId: $slot->subjectId,
|
||||
teacherId: $slot->teacherId,
|
||||
dayOfWeek: DayOfWeek::TUESDAY,
|
||||
timeSlot: new TimeSlot('10:00', '11:00'),
|
||||
room: null,
|
||||
at: $now,
|
||||
);
|
||||
|
||||
$events = $slot->pullDomainEvents();
|
||||
|
||||
self::assertCount(1, $events);
|
||||
self::assertInstanceOf(CoursModifie::class, $events[0]);
|
||||
self::assertTrue($slot->id->equals($events[0]->slotId));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function supprimerRecordsCoursSupprime(): void
|
||||
{
|
||||
$slot = $this->createSlot();
|
||||
$slot->pullDomainEvents();
|
||||
|
||||
$now = new DateTimeImmutable('2026-03-02 15:00:00');
|
||||
$slot->supprimer($now);
|
||||
|
||||
$events = $slot->pullDomainEvents();
|
||||
|
||||
self::assertCount(1, $events);
|
||||
self::assertInstanceOf(CoursSupprime::class, $events[0]);
|
||||
self::assertTrue($slot->id->equals($events[0]->slotId));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function reconstituteRestoresAllPropertiesWithoutEvents(): void
|
||||
{
|
||||
$id = ScheduleSlotId::generate();
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
$classId = ClassId::fromString(self::CLASS_ID);
|
||||
$subjectId = SubjectId::fromString(self::SUBJECT_ID);
|
||||
$teacherId = UserId::fromString(self::TEACHER_ID);
|
||||
$timeSlot = new TimeSlot('14:00', '15:30');
|
||||
$createdAt = new DateTimeImmutable('2026-03-01 10:00:00');
|
||||
$updatedAt = new DateTimeImmutable('2026-03-02 14:00:00');
|
||||
|
||||
$slot = ScheduleSlot::reconstitute(
|
||||
id: $id,
|
||||
tenantId: $tenantId,
|
||||
classId: $classId,
|
||||
subjectId: $subjectId,
|
||||
teacherId: $teacherId,
|
||||
dayOfWeek: DayOfWeek::FRIDAY,
|
||||
timeSlot: $timeSlot,
|
||||
room: 'Salle 305',
|
||||
isRecurring: false,
|
||||
createdAt: $createdAt,
|
||||
updatedAt: $updatedAt,
|
||||
);
|
||||
|
||||
self::assertTrue($slot->id->equals($id));
|
||||
self::assertTrue($slot->tenantId->equals($tenantId));
|
||||
self::assertTrue($slot->classId->equals($classId));
|
||||
self::assertTrue($slot->subjectId->equals($subjectId));
|
||||
self::assertTrue($slot->teacherId->equals($teacherId));
|
||||
self::assertSame(DayOfWeek::FRIDAY, $slot->dayOfWeek);
|
||||
self::assertSame('14:00', $slot->timeSlot->startTime);
|
||||
self::assertSame('15:30', $slot->timeSlot->endTime);
|
||||
self::assertSame('Salle 305', $slot->room);
|
||||
self::assertFalse($slot->isRecurring);
|
||||
self::assertEquals($createdAt, $slot->createdAt);
|
||||
self::assertEquals($updatedAt, $slot->updatedAt);
|
||||
self::assertEmpty($slot->pullDomainEvents());
|
||||
}
|
||||
|
||||
private function createSlot(?string $room = null): ScheduleSlot
|
||||
{
|
||||
return ScheduleSlot::creer(
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
classId: ClassId::fromString(self::CLASS_ID),
|
||||
subjectId: SubjectId::fromString(self::SUBJECT_ID),
|
||||
teacherId: UserId::fromString(self::TEACHER_ID),
|
||||
dayOfWeek: DayOfWeek::MONDAY,
|
||||
timeSlot: new TimeSlot('08:00', '09:00'),
|
||||
room: $room,
|
||||
isRecurring: true,
|
||||
now: new DateTimeImmutable('2026-03-01 10:00:00'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Domain\Model\Schedule;
|
||||
|
||||
use App\Scolarite\Domain\Exception\CreneauHoraireInvalideException;
|
||||
use App\Scolarite\Domain\Model\Schedule\TimeSlot;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class TimeSlotTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function createsValidTimeSlot(): void
|
||||
{
|
||||
$timeSlot = new TimeSlot('08:00', '09:00');
|
||||
|
||||
self::assertSame('08:00', $timeSlot->startTime);
|
||||
self::assertSame('09:00', $timeSlot->endTime);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsWhenEndTimeBeforeStartTime(): void
|
||||
{
|
||||
$this->expectException(CreneauHoraireInvalideException::class);
|
||||
|
||||
new TimeSlot('10:00', '09:00');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsWhenEndTimeEqualsStartTime(): void
|
||||
{
|
||||
$this->expectException(CreneauHoraireInvalideException::class);
|
||||
|
||||
new TimeSlot('08:00', '08:00');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsWhenDurationLessThanFiveMinutes(): void
|
||||
{
|
||||
$this->expectException(CreneauHoraireInvalideException::class);
|
||||
|
||||
new TimeSlot('08:00', '08:04');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function acceptsFiveMinuteDuration(): void
|
||||
{
|
||||
$timeSlot = new TimeSlot('08:00', '08:05');
|
||||
|
||||
self::assertSame('08:00', $timeSlot->startTime);
|
||||
self::assertSame('08:05', $timeSlot->endTime);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function overlapsReturnsTrueWhenTimesOverlap(): void
|
||||
{
|
||||
$slot1 = new TimeSlot('08:00', '09:00');
|
||||
$slot2 = new TimeSlot('08:30', '09:30');
|
||||
|
||||
self::assertTrue($slot1->overlaps($slot2));
|
||||
self::assertTrue($slot2->overlaps($slot1));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function overlapsReturnsFalseWhenTimesDoNotOverlap(): void
|
||||
{
|
||||
$slot1 = new TimeSlot('08:00', '09:00');
|
||||
$slot2 = new TimeSlot('09:00', '10:00');
|
||||
|
||||
self::assertFalse($slot1->overlaps($slot2));
|
||||
self::assertFalse($slot2->overlaps($slot1));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function overlapsReturnsTrueWhenOneContainsOther(): void
|
||||
{
|
||||
$slot1 = new TimeSlot('08:00', '12:00');
|
||||
$slot2 = new TimeSlot('09:00', '10:00');
|
||||
|
||||
self::assertTrue($slot1->overlaps($slot2));
|
||||
self::assertTrue($slot2->overlaps($slot1));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function overlapsReturnsFalseWhenAdjacent(): void
|
||||
{
|
||||
$slot1 = new TimeSlot('08:00', '09:00');
|
||||
$slot2 = new TimeSlot('09:00', '10:00');
|
||||
|
||||
self::assertFalse($slot1->overlaps($slot2));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function equalsReturnsTrueForSameTimes(): void
|
||||
{
|
||||
$slot1 = new TimeSlot('08:00', '09:00');
|
||||
$slot2 = new TimeSlot('08:00', '09:00');
|
||||
|
||||
self::assertTrue($slot1->equals($slot2));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function equalsReturnsFalseForDifferentTimes(): void
|
||||
{
|
||||
$slot1 = new TimeSlot('08:00', '09:00');
|
||||
$slot2 = new TimeSlot('08:00', '10:00');
|
||||
|
||||
self::assertFalse($slot1->equals($slot2));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function durationInMinutesReturnsCorrectValue(): void
|
||||
{
|
||||
$timeSlot = new TimeSlot('08:00', '09:30');
|
||||
|
||||
self::assertSame(90, $timeSlot->durationInMinutes());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user