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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Application\Command\TruncateSlotRecurrence;
|
||||
|
||||
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\TruncateSlotRecurrence\TruncateSlotRecurrenceCommand;
|
||||
use App\Scolarite\Application\Command\TruncateSlotRecurrence\TruncateSlotRecurrenceHandler;
|
||||
use App\Scolarite\Domain\Exception\DateExceptionInvalideException;
|
||||
use App\Scolarite\Domain\Model\Schedule\DayOfWeek;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
|
||||
use App\Scolarite\Domain\Model\Schedule\TimeSlot;
|
||||
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 TruncateSlotRecurrenceHandlerTest 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 UPDATER_ID = '550e8400-e29b-41d4-a716-446655440099';
|
||||
|
||||
private InMemoryScheduleSlotRepository $slotRepository;
|
||||
private TruncateSlotRecurrenceHandler $handler;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->slotRepository = new InMemoryScheduleSlotRepository();
|
||||
|
||||
$clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-10-01 10:00:00');
|
||||
}
|
||||
};
|
||||
|
||||
$this->handler = new TruncateSlotRecurrenceHandler(
|
||||
$this->slotRepository,
|
||||
$clock,
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function truncatesRecurrenceOneDayBeforeGivenDate(): void
|
||||
{
|
||||
$slot = $this->createAndSaveSlot(
|
||||
recurrenceStart: new DateTimeImmutable('2026-09-01'),
|
||||
recurrenceEnd: new DateTimeImmutable('2027-07-04'),
|
||||
);
|
||||
|
||||
$command = new TruncateSlotRecurrenceCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
slotId: (string) $slot->id,
|
||||
fromDate: '2026-10-05',
|
||||
updatedBy: self::UPDATER_ID,
|
||||
);
|
||||
|
||||
($this->handler)($command);
|
||||
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
$updated = $this->slotRepository->get($slot->id, $tenantId);
|
||||
self::assertSame('2026-10-04', $updated->recurrenceEnd->format('Y-m-d'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsWhenDateIsNotActiveForSlot(): void
|
||||
{
|
||||
$slot = $this->createAndSaveSlot(
|
||||
recurrenceStart: new DateTimeImmutable('2026-09-01'),
|
||||
recurrenceEnd: new DateTimeImmutable('2026-09-28'),
|
||||
);
|
||||
|
||||
$command = new TruncateSlotRecurrenceCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
slotId: (string) $slot->id,
|
||||
fromDate: '2026-10-05',
|
||||
updatedBy: self::UPDATER_ID,
|
||||
);
|
||||
|
||||
$this->expectException(DateExceptionInvalideException::class);
|
||||
($this->handler)($command);
|
||||
}
|
||||
|
||||
private function createAndSaveSlot(
|
||||
?DateTimeImmutable $recurrenceStart = null,
|
||||
?DateTimeImmutable $recurrenceEnd = null,
|
||||
): 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'),
|
||||
recurrenceStart: $recurrenceStart,
|
||||
recurrenceEnd: $recurrenceEnd,
|
||||
);
|
||||
|
||||
$this->slotRepository->save($slot);
|
||||
|
||||
return $slot;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Application\Command\UpdateRecurringSlot;
|
||||
|
||||
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\UpdateRecurringSlot\UpdateRecurringSlotCommand;
|
||||
use App\Scolarite\Application\Command\UpdateRecurringSlot\UpdateRecurringSlotHandler;
|
||||
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 UpdateRecurringSlotHandlerTest 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 NEW_TEACHER_ID = '550e8400-e29b-41d4-a716-446655440011';
|
||||
private const string UPDATER_ID = '550e8400-e29b-41d4-a716-446655440099';
|
||||
|
||||
private InMemoryScheduleSlotRepository $slotRepository;
|
||||
private InMemoryScheduleExceptionRepository $exceptionRepository;
|
||||
private UpdateRecurringSlotHandler $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 UpdateRecurringSlotHandler(
|
||||
$this->slotRepository,
|
||||
$this->exceptionRepository,
|
||||
$clock,
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function thisOccurrenceCreatesAnException(): void
|
||||
{
|
||||
$slot = $this->createAndSaveSlot();
|
||||
|
||||
$command = new UpdateRecurringSlotCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
slotId: (string) $slot->id,
|
||||
occurrenceDate: '2026-10-05',
|
||||
scope: 'this_occurrence',
|
||||
classId: self::CLASS_ID,
|
||||
subjectId: self::SUBJECT_ID,
|
||||
teacherId: self::NEW_TEACHER_ID,
|
||||
dayOfWeek: DayOfWeek::MONDAY->value,
|
||||
startTime: '10:00',
|
||||
endTime: '11:00',
|
||||
room: 'Salle 301',
|
||||
updatedBy: self::UPDATER_ID,
|
||||
);
|
||||
|
||||
$result = ($this->handler)($command);
|
||||
|
||||
self::assertNotNull($result['exception']);
|
||||
self::assertSame(ScheduleExceptionType::MODIFIED, $result['exception']->type);
|
||||
self::assertSame('10:00', $result['exception']->newTimeSlot->startTime);
|
||||
self::assertSame('Salle 301', $result['exception']->newRoom);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function allFutureEndCurrentRecurrenceAndCreatesNewSlot(): void
|
||||
{
|
||||
$slot = $this->createAndSaveSlot(
|
||||
recurrenceStart: new DateTimeImmutable('2026-09-01'),
|
||||
recurrenceEnd: new DateTimeImmutable('2027-07-04'),
|
||||
);
|
||||
|
||||
$command = new UpdateRecurringSlotCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
slotId: (string) $slot->id,
|
||||
occurrenceDate: '2026-10-12',
|
||||
scope: 'all_future',
|
||||
classId: self::CLASS_ID,
|
||||
subjectId: self::SUBJECT_ID,
|
||||
teacherId: self::NEW_TEACHER_ID,
|
||||
dayOfWeek: DayOfWeek::MONDAY->value,
|
||||
startTime: '10:00',
|
||||
endTime: '11:00',
|
||||
room: 'Salle 301',
|
||||
updatedBy: self::UPDATER_ID,
|
||||
);
|
||||
|
||||
$result = ($this->handler)($command);
|
||||
|
||||
// Original slot's recurrence ends before the change date
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
$updatedOriginal = $this->slotRepository->get($slot->id, $tenantId);
|
||||
self::assertSame('2026-10-11', $updatedOriginal->recurrenceEnd->format('Y-m-d'));
|
||||
|
||||
// New slot starts from the change date
|
||||
self::assertNotNull($result['newSlot']);
|
||||
self::assertSame('2026-10-12', $result['newSlot']->recurrenceStart->format('Y-m-d'));
|
||||
self::assertSame('2027-07-04', $result['newSlot']->recurrenceEnd->format('Y-m-d'));
|
||||
self::assertSame('10:00', $result['newSlot']->timeSlot->startTime);
|
||||
self::assertTrue($result['newSlot']->teacherId->equals(UserId::fromString(self::NEW_TEACHER_ID)));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function allFutureWithNoOriginalEndUsesNullForNewSlotEnd(): void
|
||||
{
|
||||
$slot = $this->createAndSaveSlot(
|
||||
recurrenceStart: new DateTimeImmutable('2026-09-01'),
|
||||
recurrenceEnd: null,
|
||||
);
|
||||
|
||||
$command = new UpdateRecurringSlotCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
slotId: (string) $slot->id,
|
||||
occurrenceDate: '2026-10-12',
|
||||
scope: 'all_future',
|
||||
classId: self::CLASS_ID,
|
||||
subjectId: self::SUBJECT_ID,
|
||||
teacherId: self::TEACHER_ID,
|
||||
dayOfWeek: DayOfWeek::MONDAY->value,
|
||||
startTime: '10:00',
|
||||
endTime: '11:00',
|
||||
room: null,
|
||||
updatedBy: self::UPDATER_ID,
|
||||
);
|
||||
|
||||
$result = ($this->handler)($command);
|
||||
|
||||
self::assertNotNull($result['newSlot']);
|
||||
self::assertNull($result['newSlot']->recurrenceEnd);
|
||||
}
|
||||
|
||||
private function createAndSaveSlot(
|
||||
?DateTimeImmutable $recurrenceStart = null,
|
||||
?DateTimeImmutable $recurrenceEnd = null,
|
||||
): 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'),
|
||||
recurrenceStart: $recurrenceStart,
|
||||
recurrenceEnd: $recurrenceEnd,
|
||||
);
|
||||
|
||||
$this->slotRepository->save($slot);
|
||||
|
||||
return $slot;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Application\Service;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntry;
|
||||
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryId;
|
||||
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryType;
|
||||
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
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\Service\ScheduleResolver;
|
||||
use App\Scolarite\Domain\Model\Schedule\DayOfWeek;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleException;
|
||||
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\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ScheduleResolverTest 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 ScheduleResolver $resolver;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->slotRepository = new InMemoryScheduleSlotRepository();
|
||||
$this->exceptionRepository = new InMemoryScheduleExceptionRepository();
|
||||
$this->resolver = new ScheduleResolver(
|
||||
$this->slotRepository,
|
||||
$this->exceptionRepository,
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function resolveForWeekReturnsRecurringSlotsForSchoolDays(): void
|
||||
{
|
||||
$slot = $this->createAndSaveSlot(DayOfWeek::MONDAY, '08:00', '09:00');
|
||||
|
||||
$calendar = $this->createCalendar();
|
||||
// 2026-09-07 is a Monday
|
||||
$weekStart = new DateTimeImmutable('2026-09-07');
|
||||
|
||||
$resolved = $this->resolver->resolveForWeek(
|
||||
ClassId::fromString(self::CLASS_ID),
|
||||
$weekStart,
|
||||
TenantId::fromString(self::TENANT_ID),
|
||||
$calendar,
|
||||
);
|
||||
|
||||
self::assertCount(1, $resolved);
|
||||
self::assertTrue($resolved[0]->slotId->equals($slot->id));
|
||||
self::assertSame('2026-09-07', $resolved[0]->date->format('Y-m-d'));
|
||||
self::assertFalse($resolved[0]->isModified);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function resolveForWeekReturnsMultipleSlotsOnDifferentDays(): void
|
||||
{
|
||||
$this->createAndSaveSlot(DayOfWeek::MONDAY, '08:00', '09:00');
|
||||
$this->createAndSaveSlot(DayOfWeek::WEDNESDAY, '10:00', '11:00');
|
||||
$this->createAndSaveSlot(DayOfWeek::FRIDAY, '14:00', '15:00');
|
||||
|
||||
$calendar = $this->createCalendar();
|
||||
$weekStart = new DateTimeImmutable('2026-09-07');
|
||||
|
||||
$resolved = $this->resolver->resolveForWeek(
|
||||
ClassId::fromString(self::CLASS_ID),
|
||||
$weekStart,
|
||||
TenantId::fromString(self::TENANT_ID),
|
||||
$calendar,
|
||||
);
|
||||
|
||||
self::assertCount(3, $resolved);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function resolveForWeekSkipsCancelledOccurrences(): void
|
||||
{
|
||||
$slot = $this->createAndSaveSlot(DayOfWeek::MONDAY, '08:00', '09:00');
|
||||
|
||||
$exception = ScheduleException::annuler(
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
slotId: $slot->id,
|
||||
exceptionDate: new DateTimeImmutable('2026-09-07'),
|
||||
reason: 'Grève',
|
||||
createdBy: UserId::fromString(self::CREATOR_ID),
|
||||
now: new DateTimeImmutable('2026-09-01 10:00:00'),
|
||||
);
|
||||
$this->exceptionRepository->save($exception);
|
||||
|
||||
$calendar = $this->createCalendar();
|
||||
$weekStart = new DateTimeImmutable('2026-09-07');
|
||||
|
||||
$resolved = $this->resolver->resolveForWeek(
|
||||
ClassId::fromString(self::CLASS_ID),
|
||||
$weekStart,
|
||||
TenantId::fromString(self::TENANT_ID),
|
||||
$calendar,
|
||||
);
|
||||
|
||||
self::assertCount(0, $resolved);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function resolveForWeekAppliesModifiedExceptionOverrides(): void
|
||||
{
|
||||
$slot = $this->createAndSaveSlot(DayOfWeek::MONDAY, '08:00', '09:00');
|
||||
|
||||
$newTimeSlot = new TimeSlot('10:00', '11:00');
|
||||
$newTeacherId = UserId::fromString('550e8400-e29b-41d4-a716-446655440011');
|
||||
$exception = ScheduleException::modifier(
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
slotId: $slot->id,
|
||||
exceptionDate: new DateTimeImmutable('2026-09-07'),
|
||||
newTimeSlot: $newTimeSlot,
|
||||
newRoom: 'Salle 301',
|
||||
newTeacherId: $newTeacherId,
|
||||
reason: 'Changement de salle',
|
||||
createdBy: UserId::fromString(self::CREATOR_ID),
|
||||
now: new DateTimeImmutable('2026-09-01 10:00:00'),
|
||||
);
|
||||
$this->exceptionRepository->save($exception);
|
||||
|
||||
$calendar = $this->createCalendar();
|
||||
$weekStart = new DateTimeImmutable('2026-09-07');
|
||||
|
||||
$resolved = $this->resolver->resolveForWeek(
|
||||
ClassId::fromString(self::CLASS_ID),
|
||||
$weekStart,
|
||||
TenantId::fromString(self::TENANT_ID),
|
||||
$calendar,
|
||||
);
|
||||
|
||||
self::assertCount(1, $resolved);
|
||||
self::assertTrue($resolved[0]->isModified);
|
||||
self::assertSame('10:00', $resolved[0]->timeSlot->startTime);
|
||||
self::assertSame('11:00', $resolved[0]->timeSlot->endTime);
|
||||
self::assertSame('Salle 301', $resolved[0]->room);
|
||||
self::assertTrue($resolved[0]->teacherId->equals($newTeacherId));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function resolveForWeekSkipsNonSchoolDays(): void
|
||||
{
|
||||
// Saturday slot — should not appear (weekend)
|
||||
$this->createAndSaveSlot(DayOfWeek::SATURDAY, '08:00', '09:00');
|
||||
|
||||
$calendar = $this->createCalendar();
|
||||
$weekStart = new DateTimeImmutable('2026-09-07');
|
||||
|
||||
$resolved = $this->resolver->resolveForWeek(
|
||||
ClassId::fromString(self::CLASS_ID),
|
||||
$weekStart,
|
||||
TenantId::fromString(self::TENANT_ID),
|
||||
$calendar,
|
||||
);
|
||||
|
||||
self::assertCount(0, $resolved);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function resolveForWeekSkipsVacationDays(): void
|
||||
{
|
||||
// Monday slot, but this Monday is during vacation
|
||||
$this->createAndSaveSlot(DayOfWeek::MONDAY, '08:00', '09:00');
|
||||
|
||||
$calendar = $this->createCalendarWithVacation(
|
||||
new DateTimeImmutable('2026-10-19'),
|
||||
new DateTimeImmutable('2026-11-01'),
|
||||
);
|
||||
|
||||
// Week starting Monday Oct 19 (during vacation)
|
||||
$weekStart = new DateTimeImmutable('2026-10-19');
|
||||
|
||||
$resolved = $this->resolver->resolveForWeek(
|
||||
ClassId::fromString(self::CLASS_ID),
|
||||
$weekStart,
|
||||
TenantId::fromString(self::TENANT_ID),
|
||||
$calendar,
|
||||
);
|
||||
|
||||
self::assertCount(0, $resolved);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function resolveForWeekSkipsHolidays(): void
|
||||
{
|
||||
// Monday slot
|
||||
$this->createAndSaveSlot(DayOfWeek::MONDAY, '08:00', '09:00');
|
||||
// Wednesday slot
|
||||
$this->createAndSaveSlot(DayOfWeek::WEDNESDAY, '10:00', '11:00');
|
||||
|
||||
// Nov 11 2026 is a Wednesday (holiday: Armistice)
|
||||
$calendar = $this->createCalendarWithHoliday(new DateTimeImmutable('2026-11-11'));
|
||||
|
||||
// Week of Nov 9 2026 (Monday)
|
||||
$weekStart = new DateTimeImmutable('2026-11-09');
|
||||
|
||||
$resolved = $this->resolver->resolveForWeek(
|
||||
ClassId::fromString(self::CLASS_ID),
|
||||
$weekStart,
|
||||
TenantId::fromString(self::TENANT_ID),
|
||||
$calendar,
|
||||
);
|
||||
|
||||
// Monday should appear, Wednesday (holiday) should not
|
||||
self::assertCount(1, $resolved);
|
||||
self::assertSame('2026-11-09', $resolved[0]->date->format('Y-m-d'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function resolveForWeekRespectsRecurrenceBounds(): void
|
||||
{
|
||||
// Slot with recurrence starting Sep 15
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
$slot = ScheduleSlot::creer(
|
||||
tenantId: $tenantId,
|
||||
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-15'),
|
||||
recurrenceEnd: new DateTimeImmutable('2027-07-04'),
|
||||
);
|
||||
$this->slotRepository->save($slot);
|
||||
|
||||
$calendar = $this->createCalendar();
|
||||
|
||||
// Week of Sep 7 — before recurrence start
|
||||
$resolved = $this->resolver->resolveForWeek(
|
||||
ClassId::fromString(self::CLASS_ID),
|
||||
new DateTimeImmutable('2026-09-07'),
|
||||
$tenantId,
|
||||
$calendar,
|
||||
);
|
||||
self::assertCount(0, $resolved);
|
||||
|
||||
// Week of Sep 15 — within recurrence bounds
|
||||
$resolved = $this->resolver->resolveForWeek(
|
||||
ClassId::fromString(self::CLASS_ID),
|
||||
new DateTimeImmutable('2026-09-15'),
|
||||
$tenantId,
|
||||
$calendar,
|
||||
);
|
||||
self::assertCount(1, $resolved);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function resolveForWeekIgnoresNonRecurringSlots(): void
|
||||
{
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
$slot = ScheduleSlot::creer(
|
||||
tenantId: $tenantId,
|
||||
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: false,
|
||||
now: new DateTimeImmutable('2026-09-01 10:00:00'),
|
||||
);
|
||||
$this->slotRepository->save($slot);
|
||||
|
||||
$calendar = $this->createCalendar();
|
||||
$weekStart = new DateTimeImmutable('2026-09-07');
|
||||
|
||||
$resolved = $this->resolver->resolveForWeek(
|
||||
ClassId::fromString(self::CLASS_ID),
|
||||
$weekStart,
|
||||
$tenantId,
|
||||
$calendar,
|
||||
);
|
||||
|
||||
self::assertCount(0, $resolved);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function resolveForWeekReturnsEmptyForClassWithNoSlots(): void
|
||||
{
|
||||
$calendar = $this->createCalendar();
|
||||
$weekStart = new DateTimeImmutable('2026-09-07');
|
||||
|
||||
$resolved = $this->resolver->resolveForWeek(
|
||||
ClassId::fromString(self::CLASS_ID),
|
||||
$weekStart,
|
||||
TenantId::fromString(self::TENANT_ID),
|
||||
$calendar,
|
||||
);
|
||||
|
||||
self::assertCount(0, $resolved);
|
||||
}
|
||||
|
||||
private function createAndSaveSlot(
|
||||
DayOfWeek $dayOfWeek,
|
||||
string $startTime,
|
||||
string $endTime,
|
||||
?string $room = null,
|
||||
): 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,
|
||||
timeSlot: new TimeSlot($startTime, $endTime),
|
||||
room: $room,
|
||||
isRecurring: true,
|
||||
now: new DateTimeImmutable('2026-09-01 10:00:00'),
|
||||
);
|
||||
|
||||
$this->slotRepository->save($slot);
|
||||
|
||||
return $slot;
|
||||
}
|
||||
|
||||
private function createCalendar(): SchoolCalendar
|
||||
{
|
||||
return SchoolCalendar::reconstitute(
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
academicYearId: AcademicYearId::fromString('550e8400-e29b-41d4-a716-446655440090'),
|
||||
zone: null,
|
||||
entries: [],
|
||||
);
|
||||
}
|
||||
|
||||
private function createCalendarWithVacation(
|
||||
DateTimeImmutable $start,
|
||||
DateTimeImmutable $end,
|
||||
): SchoolCalendar {
|
||||
$calendar = $this->createCalendar();
|
||||
$calendar->ajouterEntree(new CalendarEntry(
|
||||
id: CalendarEntryId::generate(),
|
||||
type: CalendarEntryType::VACATION,
|
||||
startDate: $start,
|
||||
endDate: $end,
|
||||
label: 'Vacances de la Toussaint',
|
||||
));
|
||||
|
||||
return $calendar;
|
||||
}
|
||||
|
||||
private function createCalendarWithHoliday(DateTimeImmutable $date): SchoolCalendar
|
||||
{
|
||||
$calendar = $this->createCalendar();
|
||||
$calendar->ajouterEntree(new CalendarEntry(
|
||||
id: CalendarEntryId::generate(),
|
||||
type: CalendarEntryType::HOLIDAY,
|
||||
startDate: $date,
|
||||
endDate: $date,
|
||||
label: 'Armistice',
|
||||
));
|
||||
|
||||
return $calendar;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Domain\Model\Schedule;
|
||||
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleException;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleExceptionId;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleExceptionType;
|
||||
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 ScheduleExceptionTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string SLOT_ID = '550e8400-e29b-41d4-a716-446655440050';
|
||||
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
|
||||
private const string CREATOR_ID = '550e8400-e29b-41d4-a716-446655440099';
|
||||
|
||||
#[Test]
|
||||
public function annulerCreatesACancelledExceptionWithCorrectProperties(): void
|
||||
{
|
||||
$exception = ScheduleException::annuler(
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
slotId: ScheduleSlotId::fromString(self::SLOT_ID),
|
||||
exceptionDate: new DateTimeImmutable('2026-10-05'),
|
||||
reason: 'Grève',
|
||||
createdBy: UserId::fromString(self::CREATOR_ID),
|
||||
now: new DateTimeImmutable('2026-10-01 10:00:00'),
|
||||
);
|
||||
|
||||
self::assertTrue($exception->tenantId->equals(TenantId::fromString(self::TENANT_ID)));
|
||||
self::assertTrue($exception->slotId->equals(ScheduleSlotId::fromString(self::SLOT_ID)));
|
||||
self::assertSame('2026-10-05', $exception->exceptionDate->format('Y-m-d'));
|
||||
self::assertSame(ScheduleExceptionType::CANCELLED, $exception->type);
|
||||
self::assertSame('Grève', $exception->reason);
|
||||
self::assertNull($exception->newTimeSlot);
|
||||
self::assertNull($exception->newRoom);
|
||||
self::assertNull($exception->newTeacherId);
|
||||
self::assertTrue($exception->createdBy->equals(UserId::fromString(self::CREATOR_ID)));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function modifierCreatesAModifiedExceptionWithNewValues(): void
|
||||
{
|
||||
$newTimeSlot = new TimeSlot('10:00', '11:00');
|
||||
$newTeacherId = UserId::fromString(self::TEACHER_ID);
|
||||
|
||||
$exception = ScheduleException::modifier(
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
slotId: ScheduleSlotId::fromString(self::SLOT_ID),
|
||||
exceptionDate: new DateTimeImmutable('2026-10-05'),
|
||||
newTimeSlot: $newTimeSlot,
|
||||
newRoom: 'Salle 301',
|
||||
newTeacherId: $newTeacherId,
|
||||
reason: 'Changement de salle',
|
||||
createdBy: UserId::fromString(self::CREATOR_ID),
|
||||
now: new DateTimeImmutable('2026-10-01 10:00:00'),
|
||||
);
|
||||
|
||||
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($newTeacherId));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function modifierWithPartialOverridesKeepsNullsForUnchangedFields(): void
|
||||
{
|
||||
$exception = ScheduleException::modifier(
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
slotId: ScheduleSlotId::fromString(self::SLOT_ID),
|
||||
exceptionDate: new DateTimeImmutable('2026-10-05'),
|
||||
newTimeSlot: null,
|
||||
newRoom: 'Salle 302',
|
||||
newTeacherId: null,
|
||||
reason: null,
|
||||
createdBy: UserId::fromString(self::CREATOR_ID),
|
||||
now: new DateTimeImmutable('2026-10-01 10:00:00'),
|
||||
);
|
||||
|
||||
self::assertSame(ScheduleExceptionType::MODIFIED, $exception->type);
|
||||
self::assertNull($exception->newTimeSlot);
|
||||
self::assertSame('Salle 302', $exception->newRoom);
|
||||
self::assertNull($exception->newTeacherId);
|
||||
self::assertNull($exception->reason);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function isCancelledReturnsTrueForCancelledType(): void
|
||||
{
|
||||
$exception = ScheduleException::annuler(
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
slotId: ScheduleSlotId::fromString(self::SLOT_ID),
|
||||
exceptionDate: new DateTimeImmutable('2026-10-05'),
|
||||
reason: null,
|
||||
createdBy: UserId::fromString(self::CREATOR_ID),
|
||||
now: new DateTimeImmutable('2026-10-01 10:00:00'),
|
||||
);
|
||||
|
||||
self::assertTrue($exception->isCancelled());
|
||||
self::assertFalse($exception->isModified());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function isModifiedReturnsTrueForModifiedType(): void
|
||||
{
|
||||
$exception = ScheduleException::modifier(
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
slotId: ScheduleSlotId::fromString(self::SLOT_ID),
|
||||
exceptionDate: new DateTimeImmutable('2026-10-05'),
|
||||
newTimeSlot: new TimeSlot('14:00', '15:00'),
|
||||
newRoom: null,
|
||||
newTeacherId: null,
|
||||
reason: null,
|
||||
createdBy: UserId::fromString(self::CREATOR_ID),
|
||||
now: new DateTimeImmutable('2026-10-01 10:00:00'),
|
||||
);
|
||||
|
||||
self::assertTrue($exception->isModified());
|
||||
self::assertFalse($exception->isCancelled());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function reconstituteRestoresAllPropertiesWithoutEvents(): void
|
||||
{
|
||||
$id = ScheduleExceptionId::generate();
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
$slotId = ScheduleSlotId::fromString(self::SLOT_ID);
|
||||
$newTimeSlot = new TimeSlot('11:00', '12:00');
|
||||
$newTeacherId = UserId::fromString(self::TEACHER_ID);
|
||||
$createdBy = UserId::fromString(self::CREATOR_ID);
|
||||
$createdAt = new DateTimeImmutable('2026-10-01 10:00:00');
|
||||
|
||||
$exception = ScheduleException::reconstitute(
|
||||
id: $id,
|
||||
tenantId: $tenantId,
|
||||
slotId: $slotId,
|
||||
exceptionDate: new DateTimeImmutable('2026-10-05'),
|
||||
type: ScheduleExceptionType::MODIFIED,
|
||||
newTimeSlot: $newTimeSlot,
|
||||
newRoom: 'Salle 303',
|
||||
newTeacherId: $newTeacherId,
|
||||
reason: 'Interversion',
|
||||
createdBy: $createdBy,
|
||||
createdAt: $createdAt,
|
||||
);
|
||||
|
||||
self::assertTrue($exception->id->equals($id));
|
||||
self::assertTrue($exception->tenantId->equals($tenantId));
|
||||
self::assertTrue($exception->slotId->equals($slotId));
|
||||
self::assertSame('2026-10-05', $exception->exceptionDate->format('Y-m-d'));
|
||||
self::assertSame(ScheduleExceptionType::MODIFIED, $exception->type);
|
||||
self::assertSame('11:00', $exception->newTimeSlot->startTime);
|
||||
self::assertSame('Salle 303', $exception->newRoom);
|
||||
self::assertTrue($exception->newTeacherId->equals($newTeacherId));
|
||||
self::assertSame('Interversion', $exception->reason);
|
||||
self::assertTrue($exception->createdBy->equals($createdBy));
|
||||
self::assertEquals($createdAt, $exception->createdAt);
|
||||
}
|
||||
}
|
||||
@@ -176,6 +176,161 @@ final class ScheduleSlotTest extends TestCase
|
||||
self::assertEmpty($slot->pullDomainEvents());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function creerWithRecurrenceBoundsSetsDates(): void
|
||||
{
|
||||
$recurrenceStart = new DateTimeImmutable('2026-09-01');
|
||||
$recurrenceEnd = new DateTimeImmutable('2027-07-04');
|
||||
|
||||
$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: $recurrenceStart,
|
||||
recurrenceEnd: $recurrenceEnd,
|
||||
);
|
||||
|
||||
self::assertEquals($recurrenceStart, $slot->recurrenceStart);
|
||||
self::assertEquals($recurrenceEnd, $slot->recurrenceEnd);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function creerWithoutRecurrenceBoundsDefaultsToNull(): void
|
||||
{
|
||||
$slot = $this->createSlot();
|
||||
|
||||
self::assertNull($slot->recurrenceStart);
|
||||
self::assertNull($slot->recurrenceEnd);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function isActiveOnDateReturnsTrueForMatchingDayWithinBounds(): void
|
||||
{
|
||||
$slot = $this->createRecurringSlot(
|
||||
dayOfWeek: DayOfWeek::MONDAY,
|
||||
recurrenceStart: new DateTimeImmutable('2026-09-01'),
|
||||
recurrenceEnd: new DateTimeImmutable('2027-07-04'),
|
||||
);
|
||||
|
||||
// 2026-09-07 is a Monday within bounds
|
||||
self::assertTrue($slot->isActiveOnDate(new DateTimeImmutable('2026-09-07')));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function isActiveOnDateReturnsFalseForWrongDayOfWeek(): void
|
||||
{
|
||||
$slot = $this->createRecurringSlot(
|
||||
dayOfWeek: DayOfWeek::MONDAY,
|
||||
recurrenceStart: new DateTimeImmutable('2026-09-01'),
|
||||
recurrenceEnd: new DateTimeImmutable('2027-07-04'),
|
||||
);
|
||||
|
||||
// 2026-09-08 is a Tuesday
|
||||
self::assertFalse($slot->isActiveOnDate(new DateTimeImmutable('2026-09-08')));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function isActiveOnDateReturnsFalseBeforeRecurrenceStart(): void
|
||||
{
|
||||
$slot = $this->createRecurringSlot(
|
||||
dayOfWeek: DayOfWeek::MONDAY,
|
||||
recurrenceStart: new DateTimeImmutable('2026-09-01'),
|
||||
recurrenceEnd: new DateTimeImmutable('2027-07-04'),
|
||||
);
|
||||
|
||||
// 2026-08-25 is a Monday but before start
|
||||
self::assertFalse($slot->isActiveOnDate(new DateTimeImmutable('2026-08-25')));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function isActiveOnDateReturnsFalseAfterRecurrenceEnd(): void
|
||||
{
|
||||
$slot = $this->createRecurringSlot(
|
||||
dayOfWeek: DayOfWeek::MONDAY,
|
||||
recurrenceStart: new DateTimeImmutable('2026-09-01'),
|
||||
recurrenceEnd: new DateTimeImmutable('2027-07-04'),
|
||||
);
|
||||
|
||||
// 2027-07-07 is a Monday but after end
|
||||
self::assertFalse($slot->isActiveOnDate(new DateTimeImmutable('2027-07-07')));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function isActiveOnDateReturnsTrueWithNoBounds(): void
|
||||
{
|
||||
$slot = $this->createRecurringSlot(
|
||||
dayOfWeek: DayOfWeek::MONDAY,
|
||||
recurrenceStart: null,
|
||||
recurrenceEnd: null,
|
||||
);
|
||||
|
||||
// Any Monday should work
|
||||
self::assertTrue($slot->isActiveOnDate(new DateTimeImmutable('2026-09-07')));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function isActiveOnDateReturnsFalseForNonRecurringSlot(): 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: false,
|
||||
now: new DateTimeImmutable('2026-09-01 10:00:00'),
|
||||
);
|
||||
|
||||
self::assertFalse($slot->isActiveOnDate(new DateTimeImmutable('2026-09-07')));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function isActiveOnDateIncludesBoundaryDates(): void
|
||||
{
|
||||
$slot = $this->createRecurringSlot(
|
||||
dayOfWeek: DayOfWeek::MONDAY,
|
||||
recurrenceStart: new DateTimeImmutable('2026-09-07'),
|
||||
recurrenceEnd: new DateTimeImmutable('2026-09-07'),
|
||||
);
|
||||
|
||||
// Exact start = exact end date
|
||||
self::assertTrue($slot->isActiveOnDate(new DateTimeImmutable('2026-09-07')));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function reconstituteRestoresRecurrenceBounds(): void
|
||||
{
|
||||
$recurrenceStart = new DateTimeImmutable('2026-09-01');
|
||||
$recurrenceEnd = new DateTimeImmutable('2027-07-04');
|
||||
|
||||
$slot = ScheduleSlot::reconstitute(
|
||||
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),
|
||||
dayOfWeek: DayOfWeek::FRIDAY,
|
||||
timeSlot: new TimeSlot('14:00', '15:30'),
|
||||
room: null,
|
||||
isRecurring: true,
|
||||
createdAt: new DateTimeImmutable('2026-03-01 10:00:00'),
|
||||
updatedAt: new DateTimeImmutable('2026-03-02 14:00:00'),
|
||||
recurrenceStart: $recurrenceStart,
|
||||
recurrenceEnd: $recurrenceEnd,
|
||||
);
|
||||
|
||||
self::assertEquals($recurrenceStart, $slot->recurrenceStart);
|
||||
self::assertEquals($recurrenceEnd, $slot->recurrenceEnd);
|
||||
}
|
||||
|
||||
private function createSlot(?string $room = null): ScheduleSlot
|
||||
{
|
||||
return ScheduleSlot::creer(
|
||||
@@ -190,4 +345,24 @@ final class ScheduleSlotTest extends TestCase
|
||||
now: new DateTimeImmutable('2026-03-01 10:00:00'),
|
||||
);
|
||||
}
|
||||
|
||||
private function createRecurringSlot(
|
||||
DayOfWeek $dayOfWeek,
|
||||
?DateTimeImmutable $recurrenceStart,
|
||||
?DateTimeImmutable $recurrenceEnd,
|
||||
): 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,
|
||||
timeSlot: new TimeSlot('08:00', '09:00'),
|
||||
room: null,
|
||||
isRecurring: true,
|
||||
now: new DateTimeImmutable('2026-09-01 10:00:00'),
|
||||
recurrenceStart: $recurrenceStart,
|
||||
recurrenceEnd: $recurrenceEnd,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user