feat: Permettre la définition d'une semaine type récurrente pour l'emploi du temps
Some checks failed
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Naming Conventions (push) Has been cancelled
CI / Build Check (push) Has been cancelled

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:
2026-03-04 20:03:12 +01:00
parent e156755b86
commit ae640e91ac
35 changed files with 3550 additions and 81 deletions

View File

@@ -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;
}
}