From 465db4a5b8f3cf418c9512c17b79fcb50cb96d9c Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 19 Aug 2026 08:58:17 +0300 Subject: [PATCH] test workload placement --- .../Feature/Schedule/ScheduleEndpointTest.php | 59 ++++++++ .../EvenDistributionSchedulerTest.php | 129 ++++++++++++++++++ .../Schedule/UseCases/CreateScheduleTest.php | 115 ++++++++++++++++ .../website/cypress/e2e/set-scheduling.cy.ts | 42 ++++++ 4 files changed, 345 insertions(+) create mode 100644 backend/tests/Unit/Schedule/EvenDistributionSchedulerTest.php diff --git a/backend/tests/Feature/Schedule/ScheduleEndpointTest.php b/backend/tests/Feature/Schedule/ScheduleEndpointTest.php index 295a245..5fed467 100644 --- a/backend/tests/Feature/Schedule/ScheduleEndpointTest.php +++ b/backend/tests/Feature/Schedule/ScheduleEndpointTest.php @@ -160,6 +160,65 @@ class ScheduleEndpointTest extends TestCase ->assertExactJson($response->json()); } + public function test_it_places_heavier_days_at_the_requested_end(): void + { + $user = $this->createUser('reader@example.com'); + $set = $this->createSet($user, 'Course'); + $lessonLevel = $this->createLevel($set, 'lesson'); + $repository = app(ElementRepository::class); + + foreach (range(1, 11) as $number) { + $repository->create(new CreateElementDto( + name: "Lesson {$number}", + level: $lessonLevel, + parentElement: null, + )); + } + $this->createSession($user, 'valid-token'); + + $response = $this->credentialedPost('/api/schedules', [ + 'setId' => $set->getId(), + 'levelId' => $lessonLevel->getId(), + 'startDate' => '2026-08-10', + 'targetDate' => '2026-08-14', + 'workloadPlacement' => 'end', + ])->assertCreated(); + + $days = $response->json('schedule.days'); + $this->assertIsArray($days); + $this->assertSame( + [2, 2, 2, 2, 3], + array_map(function (array $day): int { + return count($day['assignments']); + }, $days), + ); + $this->assertDatabaseCount('schedule_assignments', 11); + $this->assertDatabaseCount('schedules', 1); + } + + public function test_it_rejects_an_unknown_workload_placement(): void + { + $user = $this->createUser('reader@example.com'); + $set = $this->createSet($user, 'Course'); + $lessonLevel = $this->createLevel($set, 'lesson'); + app(ElementRepository::class)->create(new CreateElementDto( + name: 'Lesson 1', + level: $lessonLevel, + parentElement: null, + )); + $this->createSession($user, 'valid-token'); + + $this->credentialedPost('/api/schedules', [ + 'setId' => $set->getId(), + 'levelId' => $lessonLevel->getId(), + 'startDate' => '2026-08-10', + 'targetDate' => '2026-08-14', + 'workloadPlacement' => 'sideways', + ])->assertBadRequest()->assertExactJson([ + 'error' => 'workloadPlacement must be start, middle, or end', + ]); + } + public function test_it_lists_only_the_users_schedules_newest_first(): void { $user = $this->createUser('reader@example.com'); diff --git a/backend/tests/Unit/Schedule/EvenDistributionSchedulerTest.php b/backend/tests/Unit/Schedule/EvenDistributionSchedulerTest.php new file mode 100644 index 0000000..f9e63a7 --- /dev/null +++ b/backend/tests/Unit/Schedule/EvenDistributionSchedulerTest.php @@ -0,0 +1,129 @@ +value => [3, 2, 2, 2, 2], + WorkloadPlacement::Middle->value => [2, 2, 3, 2, 2], + WorkloadPlacement::End->value => [2, 2, 2, 2, 3], + ]; + + foreach (WorkloadPlacement::cases() as $placement) { + $dates = $scheduler->scheduledDates( + assignmentCount: 11, + startDate: $this->utc('2026-08-10'), + targetDate: $this->utc('2026-08-14'), + workloadPlacement: $placement, + ); + + $this->assertSame( + $expectedCounts[$placement->value], + $this->dailyCounts($dates), + ); + } + } + + public function test_it_keeps_multiple_heavier_days_together(): void + { + $scheduler = new EvenDistributionScheduler; + $expectedCounts = [ + WorkloadPlacement::Start->value => [3, 3, 2, 2, 2], + WorkloadPlacement::Middle->value => [2, 3, 3, 2, 2], + WorkloadPlacement::End->value => [2, 2, 2, 3, 3], + ]; + + foreach (WorkloadPlacement::cases() as $placement) { + $dates = $scheduler->scheduledDates( + assignmentCount: 12, + startDate: $this->utc('2026-08-10'), + targetDate: $this->utc('2026-08-14'), + workloadPlacement: $placement, + ); + + $this->assertSame( + $expectedCounts[$placement->value], + $this->dailyCounts($dates), + ); + } + } + + public function test_it_preserves_sparse_distribution_across_the_full_range(): void + { + $dates = (new EvenDistributionScheduler)->scheduledDates( + assignmentCount: 3, + startDate: $this->utc('2026-08-10'), + targetDate: $this->utc('2026-08-16'), + workloadPlacement: WorkloadPlacement::Middle, + ); + + $this->assertSame( + ['2026-08-10', '2026-08-13', '2026-08-16'], + $this->formattedDates($dates), + ); + } + + public function test_it_distributes_even_and_single_day_schedules(): void + { + $scheduler = new EvenDistributionScheduler; + + $evenDates = $scheduler->scheduledDates( + assignmentCount: 10, + startDate: $this->utc('2026-08-10'), + targetDate: $this->utc('2026-08-14'), + workloadPlacement: WorkloadPlacement::End, + ); + $singleDayDates = $scheduler->scheduledDates( + assignmentCount: 4, + startDate: $this->utc('2026-08-10'), + targetDate: $this->utc('2026-08-10'), + workloadPlacement: WorkloadPlacement::Middle, + ); + + $this->assertSame([2, 2, 2, 2, 2], $this->dailyCounts($evenDates)); + $this->assertSame( + [ + '2026-08-10', + '2026-08-10', + '2026-08-10', + '2026-08-10', + ], + $this->formattedDates($singleDayDates), + ); + } + + /** + * @param list $dates + * @return list + */ + private function dailyCounts(array $dates): array + { + return array_values(array_count_values($this->formattedDates($dates))); + } + + /** + * @param list $dates + * @return list + */ + private function formattedDates(array $dates): array + { + return array_map(function (DateTimeImmutable $date): string { + return $date->format('Y-m-d'); + }, $dates); + } + + private function utc(string $date): DateTimeImmutable + { + return new DateTimeImmutable($date, new DateTimeZone('UTC')); + } +} diff --git a/backend/tests/Unit/Schedule/UseCases/CreateScheduleTest.php b/backend/tests/Unit/Schedule/UseCases/CreateScheduleTest.php index 02b7a83..47cee8d 100644 --- a/backend/tests/Unit/Schedule/UseCases/CreateScheduleTest.php +++ b/backend/tests/Unit/Schedule/UseCases/CreateScheduleTest.php @@ -5,6 +5,7 @@ namespace Tests\Unit\Schedule\UseCases; use App\Element\CreateElementDto; use App\Exceptions\BadRequestException; use App\Exceptions\NotFoundException; +use App\Schedule\EvenDistributionScheduler; use App\Schedule\UseCases\CreateSchedule\CreateSchedule; use App\Schedule\UseCases\CreateSchedule\CreateScheduleRequest; use App\Set\CreateSetDto; @@ -108,12 +109,14 @@ class CreateScheduleTest extends TestCase $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()); @@ -182,12 +185,14 @@ class CreateScheduleTest extends TestCase $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: 'middle', )); $this->assertSame( @@ -224,12 +229,14 @@ class CreateScheduleTest extends TestCase $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( @@ -240,6 +247,102 @@ class CreateScheduleTest extends TestCase ); } + 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 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); @@ -250,12 +353,14 @@ class CreateScheduleTest extends TestCase 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', )); } @@ -276,12 +381,14 @@ class CreateScheduleTest extends TestCase 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', )); } @@ -312,12 +419,14 @@ class CreateScheduleTest extends TestCase $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', )); } @@ -344,12 +453,14 @@ class CreateScheduleTest extends TestCase $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', )); } @@ -376,12 +487,14 @@ class CreateScheduleTest extends TestCase $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', )); } @@ -410,12 +523,14 @@ class CreateScheduleTest extends TestCase $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', )); } diff --git a/frontend/website/cypress/e2e/set-scheduling.cy.ts b/frontend/website/cypress/e2e/set-scheduling.cy.ts index 0511405..25a5b4a 100644 --- a/frontend/website/cypress/e2e/set-scheduling.cy.ts +++ b/frontend/website/cypress/e2e/set-scheduling.cy.ts @@ -60,6 +60,13 @@ const bibleLayout = { ], } +const overloadedBibleLayout = { + ...bibleLayout, + levels: bibleLayout.levels.map((level) => + level.id === 3 ? { ...level, elementCount: 11 } : level, + ), +} + const scheduleDetail = { schedule: { id: 73, @@ -194,6 +201,7 @@ describe('set scheduling', () => { levelId: 3, startDate: '2026-08-10', targetDate: '2026-08-12', + workloadPlacement: 'middle', }) request.reply({ statusCode: 201, body: scheduleDetail }) }).as('createSchedule') @@ -244,6 +252,40 @@ describe('set scheduling', () => { ) }) + it('places heavier days where the user chooses', () => { + cy.intercept('GET', '**/api/sets/41', { + statusCode: 200, + body: overloadedBibleLayout, + }).as('layout') + cy.intercept('POST', '**/api/schedules', (request) => { + expect(request.body).to.deep.equal({ + setId: 41, + levelId: 3, + startDate: '2026-08-10', + targetDate: '2026-08-14', + workloadPlacement: 'end', + }) + request.reply({ statusCode: 201, body: scheduleDetail }) + }).as('createSchedule') + + cy.visit('/sets/41/schedules/new') + cy.wait('@me') + cy.wait('@layout') + cy.get('[data-workload-placement]').should('not.exist') + + cy.get('#schedule-level').select('3') + cy.get('#schedule-start-date').type('2026-08-10') + cy.get('#schedule-target-date').type('2026-08-14') + + cy.get('[data-workload-placement]').should('be.visible').within(() => { + cy.contains('legend', 'Heavier days').should('be.visible') + cy.get('input[value="middle"]').should('be.checked') + cy.get('input[value="end"]').check() + }) + cy.get('form').submit() + cy.wait('@createSchedule') + }) + it('validates the schedule form before submitting', () => { cy.intercept('GET', '**/api/sets/41', { statusCode: 200,