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