feat: Permettre la définition d'une semaine type récurrente pour l'emploi du temps
Les administrateurs devaient recréer manuellement l'emploi du temps chaque semaine. Cette implémentation introduit un système de récurrence hebdomadaire avec gestion des exceptions par occurrence, permettant de modifier ou annuler un cours spécifique sans affecter les autres semaines. Le ScheduleResolver calcule dynamiquement l'EDT réel en combinant les créneaux récurrents, les exceptions ponctuelles et le calendrier scolaire (vacances/fériés).
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Application\Command\CreateScheduleException;
|
||||
|
||||
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\CreateScheduleException\CreateScheduleExceptionCommand;
|
||||
use App\Scolarite\Application\Command\CreateScheduleException\CreateScheduleExceptionHandler;
|
||||
use App\Scolarite\Domain\Exception\DateExceptionInvalideException;
|
||||
use App\Scolarite\Domain\Model\Schedule\DayOfWeek;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleExceptionType;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
|
||||
use App\Scolarite\Domain\Model\Schedule\TimeSlot;
|
||||
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryScheduleExceptionRepository;
|
||||
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryScheduleSlotRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class CreateScheduleExceptionHandlerTest 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';
|
||||
private const string CREATOR_ID = '550e8400-e29b-41d4-a716-446655440099';
|
||||
|
||||
private InMemoryScheduleSlotRepository $slotRepository;
|
||||
private InMemoryScheduleExceptionRepository $exceptionRepository;
|
||||
private CreateScheduleExceptionHandler $handler;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->slotRepository = new InMemoryScheduleSlotRepository();
|
||||
$this->exceptionRepository = new InMemoryScheduleExceptionRepository();
|
||||
|
||||
$clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-10-01 10:00:00');
|
||||
}
|
||||
};
|
||||
|
||||
$this->handler = new CreateScheduleExceptionHandler(
|
||||
$this->slotRepository,
|
||||
$this->exceptionRepository,
|
||||
$clock,
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function cancelOccurrenceCreatesACancelledException(): void
|
||||
{
|
||||
$slot = $this->createAndSaveSlot();
|
||||
|
||||
$command = new CreateScheduleExceptionCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
slotId: (string) $slot->id,
|
||||
exceptionDate: '2026-10-05',
|
||||
type: 'cancelled',
|
||||
reason: 'Grève',
|
||||
createdBy: self::CREATOR_ID,
|
||||
);
|
||||
|
||||
$exception = ($this->handler)($command);
|
||||
|
||||
self::assertSame(ScheduleExceptionType::CANCELLED, $exception->type);
|
||||
self::assertSame('2026-10-05', $exception->exceptionDate->format('Y-m-d'));
|
||||
self::assertSame('Grève', $exception->reason);
|
||||
self::assertNull($exception->newTimeSlot);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function modifyOccurrenceCreatesAModifiedException(): void
|
||||
{
|
||||
$slot = $this->createAndSaveSlot();
|
||||
$newTeacherId = '550e8400-e29b-41d4-a716-446655440011';
|
||||
|
||||
$command = new CreateScheduleExceptionCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
slotId: (string) $slot->id,
|
||||
exceptionDate: '2026-10-05',
|
||||
type: 'modified',
|
||||
newStartTime: '10:00',
|
||||
newEndTime: '11:00',
|
||||
newRoom: 'Salle 301',
|
||||
newTeacherId: $newTeacherId,
|
||||
reason: 'Changement de salle',
|
||||
createdBy: self::CREATOR_ID,
|
||||
);
|
||||
|
||||
$exception = ($this->handler)($command);
|
||||
|
||||
self::assertSame(ScheduleExceptionType::MODIFIED, $exception->type);
|
||||
self::assertNotNull($exception->newTimeSlot);
|
||||
self::assertSame('10:00', $exception->newTimeSlot->startTime);
|
||||
self::assertSame('11:00', $exception->newTimeSlot->endTime);
|
||||
self::assertSame('Salle 301', $exception->newRoom);
|
||||
self::assertTrue($exception->newTeacherId->equals(UserId::fromString($newTeacherId)));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function exceptionIsSavedToRepository(): void
|
||||
{
|
||||
$slot = $this->createAndSaveSlot();
|
||||
|
||||
$command = new CreateScheduleExceptionCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
slotId: (string) $slot->id,
|
||||
exceptionDate: '2026-10-05',
|
||||
type: 'cancelled',
|
||||
createdBy: self::CREATOR_ID,
|
||||
);
|
||||
|
||||
$exception = ($this->handler)($command);
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
|
||||
$found = $this->exceptionRepository->findById($exception->id, $tenantId);
|
||||
self::assertNotNull($found);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsWhenSlotNotFound(): void
|
||||
{
|
||||
$this->expectException(\App\Scolarite\Domain\Exception\ScheduleSlotNotFoundException::class);
|
||||
|
||||
$command = new CreateScheduleExceptionCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
slotId: '550e8400-e29b-41d4-a716-446655440099',
|
||||
exceptionDate: '2026-10-05',
|
||||
type: 'cancelled',
|
||||
createdBy: self::CREATOR_ID,
|
||||
);
|
||||
|
||||
($this->handler)($command);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsWhenDateIsWrongDayOfWeek(): void
|
||||
{
|
||||
$slot = $this->createAndSaveSlot();
|
||||
|
||||
$this->expectException(DateExceptionInvalideException::class);
|
||||
|
||||
// Slot is MONDAY, but 2026-10-06 is a Tuesday
|
||||
$command = new CreateScheduleExceptionCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
slotId: (string) $slot->id,
|
||||
exceptionDate: '2026-10-06',
|
||||
type: 'cancelled',
|
||||
createdBy: self::CREATOR_ID,
|
||||
);
|
||||
|
||||
($this->handler)($command);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsWhenDateIsOutsideRecurrenceBounds(): void
|
||||
{
|
||||
$slot = 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: null,
|
||||
isRecurring: true,
|
||||
now: new DateTimeImmutable('2026-09-01 10:00:00'),
|
||||
recurrenceStart: new DateTimeImmutable('2026-09-07'),
|
||||
recurrenceEnd: new DateTimeImmutable('2026-09-28'),
|
||||
);
|
||||
$this->slotRepository->save($slot);
|
||||
|
||||
$this->expectException(DateExceptionInvalideException::class);
|
||||
|
||||
// 2026-10-05 is a Monday but after recurrenceEnd (2026-09-28)
|
||||
$command = new CreateScheduleExceptionCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
slotId: (string) $slot->id,
|
||||
exceptionDate: '2026-10-05',
|
||||
type: 'cancelled',
|
||||
createdBy: self::CREATOR_ID,
|
||||
);
|
||||
|
||||
($this->handler)($command);
|
||||
}
|
||||
|
||||
private function createAndSaveSlot(): ScheduleSlot
|
||||
{
|
||||
$slot = 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: null,
|
||||
isRecurring: true,
|
||||
now: new DateTimeImmutable('2026-09-01 10:00:00'),
|
||||
);
|
||||
|
||||
$this->slotRepository->save($slot);
|
||||
|
||||
return $slot;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user