test workload placement
This commit is contained in:
parent
80f8031ecc
commit
465db4a5b8
4 changed files with 345 additions and 0 deletions
|
|
@ -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');
|
||||
|
|
|
|||
129
backend/tests/Unit/Schedule/EvenDistributionSchedulerTest.php
Normal file
129
backend/tests/Unit/Schedule/EvenDistributionSchedulerTest.php
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Unit\Schedule;
|
||||
|
||||
use App\Schedule\EvenDistributionScheduler;
|
||||
use App\Schedule\WorkloadPlacement;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class EvenDistributionSchedulerTest extends TestCase
|
||||
{
|
||||
public function test_it_places_one_heavier_day_at_the_requested_position(): void
|
||||
{
|
||||
$scheduler = new EvenDistributionScheduler;
|
||||
$expectedCounts = [
|
||||
WorkloadPlacement::Start->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<DateTimeImmutable> $dates
|
||||
* @return list<int>
|
||||
*/
|
||||
private function dailyCounts(array $dates): array
|
||||
{
|
||||
return array_values(array_count_values($this->formattedDates($dates)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<DateTimeImmutable> $dates
|
||||
* @return list<string>
|
||||
*/
|
||||
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'));
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue