user(); $setRepository = new FakeSetRepository; $setLevelRepository = new FakeSetLevelRepository; $elementRepository = new FakeElementRepository; $scheduleRepository = new FakeScheduleRepository; $set = $setRepository->create(new CreateSetDto( name: 'Bible', creator: $user, )); $bookLevel = $this->createLevel( $setLevelRepository, $set, 'book', ); $portionLevel = $this->createLevel( $setLevelRepository, $set, 'portion', ); $chapterLevel = $this->createLevel( $setLevelRepository, $set, 'chapter', ); $genesis = $elementRepository->create(new CreateElementDto( name: 'Genesis', level: $bookLevel, parentElement: null, )); $exodus = $elementRepository->create(new CreateElementDto( name: 'Exodus', level: $bookLevel, parentElement: null, )); $creation = $elementRepository->create(new CreateElementDto( name: 'Creation', level: $portionLevel, parentElement: $genesis, )); $shemot = $elementRepository->create(new CreateElementDto( name: 'Shemot', level: $portionLevel, parentElement: $exodus, )); $genesisChapterOne = $elementRepository->create( new CreateElementDto( name: 'Genesis 1', level: $chapterLevel, parentElement: $creation, ), ); $genesisChapterTwo = $elementRepository->create( new CreateElementDto( name: 'Genesis 2', level: $chapterLevel, parentElement: $creation, ), ); $genesisChapterThree = $elementRepository->create( new CreateElementDto( name: 'Genesis 3', level: $chapterLevel, parentElement: $creation, ), ); $exodusChapterOne = $elementRepository->create( new CreateElementDto( name: 'Exodus 1', level: $chapterLevel, parentElement: $shemot, ), ); $exodusChapterTwo = $elementRepository->create( new CreateElementDto( name: 'Exodus 2', level: $chapterLevel, parentElement: $shemot, ), ); $schedule = (new CreateSchedule( $setRepository, $setLevelRepository, $elementRepository, $scheduleRepository, new EvenDistributionScheduler, ))->execute(new CreateScheduleRequest( user: $user, setId: $set->getId(), levelId: $chapterLevel->getId(), startDate: '2026-08-10', targetDate: '2026-08-12', workloadPlacement: 'middle', )); $this->assertSame('chapter', $schedule->getElementKind()); $this->assertSame('2026-08-10', $schedule->getStartDate()->format( 'Y-m-d', )); $this->assertSame('2026-08-12', $schedule->getTargetDate()->format( 'Y-m-d', )); $this->assertSame( [ $genesisChapterOne->getName(), $genesisChapterTwo->getName(), $genesisChapterThree->getName(), $exodusChapterOne->getName(), $exodusChapterTwo->getName(), ], array_map(function ($assignment): string { return $assignment->getName(); }, $schedule->getAssignments()), ); $this->assertSame( ['Genesis', 'Creation', 'Genesis 1'], $schedule->getAssignments()[0]->getPath(), ); $this->assertSame( [ '2026-08-10', '2026-08-10', '2026-08-11', '2026-08-11', '2026-08-12', ], array_map(function ($assignment): string { return $assignment->getScheduledDate()->format('Y-m-d'); }, $schedule->getAssignments()), ); } public function test_it_defaults_sparse_work_to_spread_across_the_full_range(): void { $user = $this->user(); $setRepository = new FakeSetRepository; $setLevelRepository = new FakeSetLevelRepository; $elementRepository = new FakeElementRepository; $set = $setRepository->create(new CreateSetDto( name: 'Course', creator: $user, )); $lessonLevel = $this->createLevel( $setLevelRepository, $set, 'lesson', ); foreach (['First', 'Second', 'Third'] as $name) { $elementRepository->create(new CreateElementDto( name: $name, level: $lessonLevel, parentElement: null, )); } $schedule = (new CreateSchedule( $setRepository, $setLevelRepository, $elementRepository, new FakeScheduleRepository, new EvenDistributionScheduler, ))->execute(new CreateScheduleRequest( user: $user, setId: $set->getId(), levelId: $lessonLevel->getId(), startDate: '2026-08-10', targetDate: '2026-08-16', workloadPlacement: null, )); $this->assertSame( ['2026-08-10', '2026-08-13', '2026-08-16'], array_map(function ($assignment): string { return $assignment->getScheduledDate()->format('Y-m-d'); }, $schedule->getAssignments()), ); } public function test_it_places_one_element_at_the_requested_middle(): void { $user = $this->user(); $setRepository = new FakeSetRepository; $setLevelRepository = new FakeSetLevelRepository; $elementRepository = new FakeElementRepository; $set = $setRepository->create(new CreateSetDto( name: 'Project', creator: $user, )); $milestoneLevel = $this->createLevel( $setLevelRepository, $set, 'milestone', ); $elementRepository->create(new CreateElementDto( name: 'Ship it', level: $milestoneLevel, parentElement: null, )); $schedule = (new CreateSchedule( $setRepository, $setLevelRepository, $elementRepository, new FakeScheduleRepository, new EvenDistributionScheduler, ))->execute(new CreateScheduleRequest( user: $user, setId: $set->getId(), levelId: $milestoneLevel->getId(), startDate: '2020-01-01', targetDate: '2030-01-01', workloadPlacement: 'middle', )); $this->assertSame( '2024-12-31', $schedule->getAssignments()[0] ->getScheduledDate() ->format('Y-m-d'), ); } public function test_it_defaults_heavier_days_to_the_middle(): void { $user = $this->user(); $setRepository = new FakeSetRepository; $setLevelRepository = new FakeSetLevelRepository; $elementRepository = new FakeElementRepository; $set = $setRepository->create(new CreateSetDto( name: 'Course', creator: $user, )); $lessonLevel = $this->createLevel( $setLevelRepository, $set, 'lesson', ); foreach (range(1, 6) as $number) { $elementRepository->create(new CreateElementDto( name: "Lesson {$number}", level: $lessonLevel, parentElement: null, )); } $schedule = (new CreateSchedule( $setRepository, $setLevelRepository, $elementRepository, new FakeScheduleRepository, new EvenDistributionScheduler, ))->execute(new CreateScheduleRequest( user: $user, setId: $set->getId(), levelId: $lessonLevel->getId(), startDate: '2026-08-10', targetDate: '2026-08-13', workloadPlacement: null, )); $this->assertSame( [ '2026-08-10', '2026-08-11', '2026-08-11', '2026-08-12', '2026-08-12', '2026-08-13', ], array_map(function ($assignment): string { return $assignment->getScheduledDate()->format('Y-m-d'); }, $schedule->getAssignments()), ); } public function test_it_rejects_an_unknown_workload_placement(): void { $user = $this->user(); $setRepository = new FakeSetRepository; $setLevelRepository = new FakeSetLevelRepository; $elementRepository = new FakeElementRepository; $set = $setRepository->create(new CreateSetDto( name: 'Course', creator: $user, )); $lessonLevel = $this->createLevel( $setLevelRepository, $set, 'lesson', ); $elementRepository->create(new CreateElementDto( name: 'Lesson 1', level: $lessonLevel, parentElement: null, )); $this->expectException(BadRequestException::class); $this->expectExceptionMessage( 'workloadPlacement must be spread, start, middle, or end', ); (new CreateSchedule( $setRepository, $setLevelRepository, $elementRepository, new FakeScheduleRepository, new EvenDistributionScheduler, ))->execute(new CreateScheduleRequest( user: $user, setId: $set->getId(), levelId: $lessonLevel->getId(), startDate: '2026-08-10', targetDate: '2026-08-13', workloadPlacement: 'sideways', )); } public function test_it_rejects_an_unknown_set(): void { $this->expectException(NotFoundException::class); $this->expectExceptionMessage('set not found'); (new CreateSchedule( new FakeSetRepository, new FakeSetLevelRepository, new FakeElementRepository, new FakeScheduleRepository, new EvenDistributionScheduler, ))->execute(new CreateScheduleRequest( user: $this->user(), setId: 999, levelId: 1, startDate: '2026-08-10', targetDate: '2026-08-12', workloadPlacement: 'middle', )); } public function test_it_rejects_a_missing_level(): void { $user = $this->user(); $setRepository = new FakeSetRepository; $set = $setRepository->create(new CreateSetDto( name: 'Bible', creator: $user, )); $this->expectException(BadRequestException::class); $this->expectExceptionMessage('levelId is required'); (new CreateSchedule( $setRepository, new FakeSetLevelRepository, new FakeElementRepository, new FakeScheduleRepository, new EvenDistributionScheduler, ))->execute(new CreateScheduleRequest( user: $user, setId: $set->getId(), levelId: null, startDate: '2026-08-10', targetDate: '2026-08-12', workloadPlacement: 'middle', )); } public function test_it_rejects_a_level_from_another_set(): void { $user = $this->user(); $setRepository = new FakeSetRepository; $setLevelRepository = new FakeSetLevelRepository; $bible = $setRepository->create(new CreateSetDto( name: 'Bible', creator: $user, )); $course = $setRepository->create(new CreateSetDto( name: 'Course', creator: $user, )); $moduleLevel = $this->createLevel( $setLevelRepository, $course, 'module', ); $this->expectException(BadRequestException::class); $this->expectExceptionMessage('level is not available for set'); (new CreateSchedule( $setRepository, $setLevelRepository, new FakeElementRepository, new FakeScheduleRepository, new EvenDistributionScheduler, ))->execute(new CreateScheduleRequest( user: $user, setId: $bible->getId(), levelId: $moduleLevel->getId(), startDate: '2026-08-10', targetDate: '2026-08-12', workloadPlacement: 'middle', )); } public function test_it_rejects_a_level_without_elements(): void { $user = $this->user(); $setRepository = new FakeSetRepository; $setLevelRepository = new FakeSetLevelRepository; $set = $setRepository->create(new CreateSetDto( name: 'Bible', creator: $user, )); $chapterLevel = $this->createLevel( $setLevelRepository, $set, 'chapter', ); $this->expectException(BadRequestException::class); $this->expectExceptionMessage('level has no elements'); (new CreateSchedule( $setRepository, $setLevelRepository, new FakeElementRepository, new FakeScheduleRepository, new EvenDistributionScheduler, ))->execute(new CreateScheduleRequest( user: $user, setId: $set->getId(), levelId: $chapterLevel->getId(), startDate: '2026-08-10', targetDate: '2026-08-12', workloadPlacement: 'middle', )); } public function test_it_rejects_invalid_dates(): void { $user = $this->user(); $setRepository = new FakeSetRepository; $setLevelRepository = new FakeSetLevelRepository; $set = $setRepository->create(new CreateSetDto( name: 'Bible', creator: $user, )); $chapterLevel = $this->createLevel( $setLevelRepository, $set, 'chapter', ); $this->expectException(BadRequestException::class); $this->expectExceptionMessage('startDate must be a valid date'); (new CreateSchedule( $setRepository, $setLevelRepository, new FakeElementRepository, new FakeScheduleRepository, new EvenDistributionScheduler, ))->execute(new CreateScheduleRequest( user: $user, setId: $set->getId(), levelId: $chapterLevel->getId(), startDate: '2026-02-30', targetDate: '2026-08-12', workloadPlacement: 'middle', )); } public function test_it_rejects_a_target_before_the_start(): void { $user = $this->user(); $setRepository = new FakeSetRepository; $setLevelRepository = new FakeSetLevelRepository; $set = $setRepository->create(new CreateSetDto( name: 'Bible', creator: $user, )); $chapterLevel = $this->createLevel( $setLevelRepository, $set, 'chapter', ); $this->expectException(BadRequestException::class); $this->expectExceptionMessage( 'targetDate must not be before startDate', ); (new CreateSchedule( $setRepository, $setLevelRepository, new FakeElementRepository, new FakeScheduleRepository, new EvenDistributionScheduler, ))->execute(new CreateScheduleRequest( user: $user, setId: $set->getId(), levelId: $chapterLevel->getId(), startDate: '2026-08-12', targetDate: '2026-08-10', workloadPlacement: 'middle', )); } private function createLevel( FakeSetLevelRepository $repository, Set $set, string $kind, ): SetLevel { return $repository->create(new CreateSetLevelDto( set: $set, kind: $kind, )); } private function user(): User { return new User( id: 7, email: new EmailAddress('reader@example.com'), passwordHash: 'hashed-password', ); } }