feat: all-season assignments

This commit is contained in:
Björn Fromme authored and Björn Fromme committed 2026-09-26 12:13:16 +02:00
1 parent 675cec1b69
commit cf876ed224
119 files changed
+6194 -294

No files matched your search

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Season;
use App\Config\HouseCatalog;
use App\Config\SeasonCalendar;
use App\Entity\SeasonAssignment;
use App\Service\Season\SeasonAssignmentLabeler;
use PHPUnit\Framework\TestCase;
/**
* Every list, dialog, log line and mail names a placement through this one class, so what it
* produces in the awkward cases - no dates yet, a date outside any season, a house that has left
* the configured map - is what the office would see there.
*/
class SeasonAssignmentLabelerTest extends TestCase
{
public function testItNamesTheSeasonAndTheHouse(): void
{
$this->assertSame(
'Saison 2026/27 - Rotbach',
$this->labeler()->label($this->seasonAssignment('2026-11-01', 'AGR'))
);
}
/**
* The season window is 01.11.-01.04., so a placement starting in July belongs to no season.
* It still has to name itself rather than render blank.
*/
public function testAPlacementOutsideAnySeasonSaysSo(): void
{
$this->assertSame(
'außerhalb der Saison - Rotbach',
$this->labeler()->label($this->seasonAssignment('2027-07-15', 'AGR'))
);
}
public function testAHalfFilledRecordStillNamesItself(): void
{
$this->assertSame('? - ?', $this->labeler()->label(new SeasonAssignment()));
}
/**
* The houses map is curated and can lose an entry; a placement that named that house falls
* back to the raw code rather than losing its name.
*/
public function testACodeThatLeftTheMapFallsBackToTheCode(): void
{
$this->assertSame(
'Saison 2026/27 - XYZ',
$this->labeler()->label($this->seasonAssignment('2026-11-01', 'XYZ'))
);
}
private function labeler(): SeasonAssignmentLabeler
{
return new SeasonAssignmentLabeler(
new SeasonCalendar('11-01', '04-01'),
new HouseCatalog(['AGR' => 'Rotbach'])
);
}
private function seasonAssignment(string $dateFrom, string $hotelCode): SeasonAssignment
{
return (new SeasonAssignment())
->setDateFrom(new \DateTimeImmutable($dateFrom))
->setDateTo(new \DateTimeImmutable($dateFrom.' +5 months'))
->setHotelCode($hotelCode)
;
}
}
@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Season;
use App\Config\SeasonTask;
use App\Entity\SeasonAssignment;
use App\Entity\SeasonAssignmentTask;
use App\Service\Season\SeasonTaskDeadlineResolver;
use PHPUnit\Framework\TestCase;
/**
* Neither deadline is stored, so what is asserted here is the arithmetic every reminder, every
* checklist row and the teamer's own page read - a deadline computed one day off is as silent a
* failure as a reminder that never fires.
*/
class SeasonTaskDeadlineResolverTest extends TestCase
{
public function testTheFirstDeadlineIsTheOffsetAppliedToTheTravelStart(): void
{
$task = $this->task('2026-11-01');
$this->assertSame('2026-10-01', $this->resolver()->resolveFirst($task)?->format('Y-m-d'));
}
public function testTheOffsetComesFromTheParameter(): void
{
$task = $this->task('2026-11-01');
$this->assertSame(
'2026-10-18',
$this->resolver('-14 days')->resolveFirst($task)?->format('Y-m-d')
);
}
public function testAnOverriddenFirstDeadlineWins(): void
{
$task = $this->task('2026-11-01')->setDeadlineOverride1(new \DateTimeImmutable('2026-09-15'));
$this->assertSame('2026-09-15', $this->resolver()->resolveFirst($task)?->format('Y-m-d'));
}
public function testTheSecondDeadlineDefaultsToTheFirst(): void
{
$task = $this->task('2026-11-01');
$this->assertSame('2026-10-01', $this->resolver()->resolveSecond($task)?->format('Y-m-d'));
}
public function testTheSecondDeadlineFollowsAnOverriddenFirstOne(): void
{
$task = $this->task('2026-11-01')->setDeadlineOverride1(new \DateTimeImmutable('2026-09-15'));
$this->assertSame('2026-09-15', $this->resolver()->resolveSecond($task)?->format('Y-m-d'));
}
public function testAnOverriddenSecondDeadlineWinsOverBoth(): void
{
$task = $this
->task('2026-11-01')
->setDeadlineOverride1(new \DateTimeImmutable('2026-09-15'))
->setDeadlineOverride2(new \DateTimeImmutable('2026-10-20'))
;
$this->assertSame('2026-09-15', $this->resolver()->resolveFirst($task)?->format('Y-m-d'));
$this->assertSame('2026-10-20', $this->resolver()->resolveSecond($task)?->format('Y-m-d'));
}
public function testAnAssignmentWithoutAStartDateHasNoDeadlines(): void
{
$task = (new SeasonAssignmentTask())
->setTaskKey(SeasonTask::CONTRACT->value)
->setSeasonAssignment(new SeasonAssignment())
;
$this->assertNull($this->resolver()->resolveFirst($task));
$this->assertNull($this->resolver()->resolveSecond($task));
}
private function resolver(string $offset = '-1 month'): SeasonTaskDeadlineResolver
{
return new SeasonTaskDeadlineResolver($offset);
}
private function task(string $dateFrom): SeasonAssignmentTask
{
$seasonAssignment = (new SeasonAssignment())
->setDateFrom(new \DateTimeImmutable($dateFrom))
->setDateTo(new \DateTimeImmutable($dateFrom.' +5 months'))
->setHotelCode('AGR')
;
$task = (new SeasonAssignmentTask())->setTaskKey(SeasonTask::CONTRACT->value);
$seasonAssignment->addTask($task);
return $task;
}
}
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Season;
use App\Config\SeasonTask;
use App\Entity\SeasonAssignment;
use App\Entity\SeasonAssignmentTask;
use App\Service\Season\SeasonTaskSynchronizer;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* What makes the enum extensible is that this fills in whatever an assignment is missing, so
* that is what is asserted: new entries appear on assignments that already exist, and rows of
* an entry that was removed are left alone rather than deleted.
*/
class SeasonTaskSynchronizerTest extends TestCase
{
private EntityManagerInterface&MockObject $entityManager;
private SeasonTaskSynchronizer $synchronizer;
protected function setUp(): void
{
$this->entityManager = $this->createMock(EntityManagerInterface::class);
$this->synchronizer = new SeasonTaskSynchronizer($this->entityManager);
}
public function testItCreatesOneRowPerEntryInEnumOrder(): void
{
$seasonAssignment = new SeasonAssignment();
$this->entityManager->expects($this->exactly(count(SeasonTask::cases())))->method('persist');
$this->entityManager->expects($this->once())->method('flush');
$this->synchronizer->sync($seasonAssignment);
$this->assertSame(
array_map(static fn (SeasonTask $task): string => $task->value, SeasonTask::cases()),
$this->keysOf($seasonAssignment)
);
}
public function testItOnlyCreatesWhatIsMissing(): void
{
$seasonAssignment = new SeasonAssignment();
$seasonAssignment->addTask((new SeasonAssignmentTask())->setTaskKey(SeasonTask::CONTRACT->value));
$this->entityManager->expects($this->exactly(count(SeasonTask::cases()) - 1))->method('persist');
$this->synchronizer->sync($seasonAssignment);
$this->assertCount(count(SeasonTask::cases()), $seasonAssignment->getTasks());
$this->assertSame(
1,
count(array_filter(
$this->keysOf($seasonAssignment),
static fn (string $key): bool => SeasonTask::CONTRACT->value === $key
))
);
}
public function testASyncedAssignmentIsNotFlushedAgain(): void
{
$seasonAssignment = new SeasonAssignment();
foreach (SeasonTask::cases() as $case) {
$seasonAssignment->addTask((new SeasonAssignmentTask())->setTaskKey($case->value));
}
$this->entityManager->expects($this->never())->method('persist');
$this->entityManager->expects($this->never())->method('flush');
$this->synchronizer->sync($seasonAssignment);
}
public function testARowOfAnUnknownEntryIsKept(): void
{
$seasonAssignment = new SeasonAssignment();
$seasonAssignment->addTask((new SeasonAssignmentTask())->setTaskKey('entry_removed_from_the_enum'));
$this->entityManager->expects($this->never())->method('remove');
$this->synchronizer->sync($seasonAssignment);
$this->assertCount(count(SeasonTask::cases()) + 1, $seasonAssignment->getTasks());
$this->assertContains('entry_removed_from_the_enum', $this->keysOf($seasonAssignment));
}
/**
* @return string[]
*/
private function keysOf(SeasonAssignment $seasonAssignment): array
{
return array_map(
static fn (SeasonAssignmentTask $task): string => $task->getTaskKey(),
$seasonAssignment->getTasks()->toArray()
);
}
}