Compare commits
7 commits
bebc0427b9
...
d6f47dea66
| Author | SHA1 | Date | |
|---|---|---|---|
| d6f47dea66 | |||
| 5c5369328a | |||
| af467de439 | |||
| 556f45146c | |||
| d7af49cf8e | |||
| d64e7ce1ef | |||
| d19154af31 |
19 changed files with 2459 additions and 0 deletions
|
|
@ -103,6 +103,13 @@ pkgs.mkShell {
|
|||
$COMPOSE exec php php bin/phpunit "$@"
|
||||
}
|
||||
|
||||
dev-coverage() {
|
||||
# Code coverage as a plain text table in the terminal (needs Xdebug, which is installed).
|
||||
# --colors=never keeps the table readable (the ANSI colors garble it).
|
||||
# Pass-through args, e.g. scope to one class: dev-coverage --filter BucketRoomCalculator
|
||||
$COMPOSE exec -e XDEBUG_MODE=coverage php php bin/phpunit --coverage-text --colors=never "$@"
|
||||
}
|
||||
|
||||
dev-stan() {
|
||||
# Warm the dev cache so phpstan-symfony can read the compiled container, then analyse.
|
||||
$COMPOSE exec php php bin/console cache:warmup --quiet
|
||||
|
|
|
|||
12
src/ApiResource/ProjectionPreviewInput.php
Normal file
12
src/ApiResource/ProjectionPreviewInput.php
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
namespace App\ApiResource;
|
||||
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
class ProjectionPreviewInput
|
||||
{
|
||||
#[Assert\NotNull]
|
||||
#[Assert\GreaterThanOrEqual(1)]
|
||||
public ?int $amount = null;
|
||||
}
|
||||
73
src/ApiResource/ProjectionPreviewOutput.php
Normal file
73
src/ApiResource/ProjectionPreviewOutput.php
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<?php
|
||||
|
||||
namespace App\ApiResource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Service\Allocation\Result;
|
||||
use App\State\PreviewProcessor;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Post(
|
||||
uriTemplate: '/scenarios/{scenario}/projections/preview',
|
||||
status: 200,
|
||||
input: ProjectionPreviewInput::class,
|
||||
output: ProjectionPreviewOutput::class,
|
||||
processor: PreviewProcessor::class,
|
||||
security: "is_granted('ROLE_USER')",
|
||||
),
|
||||
],
|
||||
)]
|
||||
class ProjectionPreviewOutput
|
||||
{
|
||||
/** @var array<array{
|
||||
* bucket_id: string,
|
||||
* bucket_name: string,
|
||||
* bucket_type: string,
|
||||
* allocated_amount: int,
|
||||
* remaining_capacity: int|null
|
||||
* }>
|
||||
*/
|
||||
public array $allocations;
|
||||
|
||||
public int $totalAllocated;
|
||||
|
||||
public int $unallocated;
|
||||
|
||||
public function __construct(Result $result)
|
||||
{
|
||||
$this->allocations = $this->setAllocationsFromResult($result);
|
||||
$this->totalAllocated = $result->getTotalAllocated();
|
||||
$this->unallocated = $result->getUnallocated();
|
||||
}
|
||||
|
||||
public static function fromResult(Result $result): self
|
||||
{
|
||||
return new self($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<array{
|
||||
* bucket_id: string,
|
||||
* bucket_name: string,
|
||||
* bucket_type: string,
|
||||
* allocated_amount: int,
|
||||
* remaining_capacity: int|null
|
||||
* }>
|
||||
*/
|
||||
private function setAllocationsFromResult(Result $result): array
|
||||
{
|
||||
return new ArrayCollection($result->getAllocations())
|
||||
->map(static function (array $allocation) {
|
||||
return [
|
||||
'bucket_id' => (string) $allocation['bucket']->getId(),
|
||||
'bucket_name' => $allocation['bucket']->getName(),
|
||||
'bucket_type' => $allocation['bucket']->getType()->value,
|
||||
'allocated_amount' => $allocation['amount'],
|
||||
'remaining_capacity' => null,
|
||||
];
|
||||
})->toArray();
|
||||
}
|
||||
}
|
||||
9
src/Enum/FillStage.php
Normal file
9
src/Enum/FillStage.php
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
enum FillStage: string
|
||||
{
|
||||
case Base = 'base';
|
||||
case Buffer = 'buffer';
|
||||
}
|
||||
|
|
@ -35,4 +35,17 @@ class BucketRepository extends ServiceEntityRepository
|
|||
|
||||
return (int) $qb->getQuery()->getSingleScalarResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Bucket>
|
||||
*/
|
||||
public function findByScenarioOrderedByPriority(Scenario $scenario): array
|
||||
{
|
||||
return $this->createQueryBuilder('b')
|
||||
->where('b.scenario = :scenario')
|
||||
->setParameter('scenario', $scenario)
|
||||
->orderBy('b.priority', 'ASC')
|
||||
->getQuery()
|
||||
->execute();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
133
src/Service/Allocation/AllocateIncome.php
Normal file
133
src/Service/Allocation/AllocateIncome.php
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
<?php
|
||||
|
||||
namespace App\Service\Allocation;
|
||||
|
||||
use App\Entity\Bucket;
|
||||
use App\Enum\BucketType;
|
||||
use App\Enum\FillStage;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
|
||||
final class AllocateIncome
|
||||
{
|
||||
/** @var array<string, int> */
|
||||
private array $allocations = [];
|
||||
|
||||
/**
|
||||
* @param Collection<int, Bucket> $buckets
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly Collection $buckets,
|
||||
private readonly int $income,
|
||||
) {
|
||||
}
|
||||
|
||||
public function execute(): Result
|
||||
{
|
||||
$toAllocate = $this->income;
|
||||
|
||||
if ($toAllocate <= 0 || $this->buckets->isEmpty()) {
|
||||
return new Result([], 0, max(0, $toAllocate));
|
||||
}
|
||||
|
||||
$needs = $this->bucketsOfType(BucketType::NEED);
|
||||
$wants = $this->bucketsOfType(BucketType::WANT);
|
||||
$overflow = $this->bucketsOfType(BucketType::OVERFLOW)[0] ?? null;
|
||||
|
||||
$phases = [
|
||||
[$needs, FillStage::Base], [$wants, FillStage::Base],
|
||||
[$needs, FillStage::Buffer], [$wants, FillStage::Buffer],
|
||||
];
|
||||
|
||||
if (!empty($overflow)) {
|
||||
$phases[] = [[$overflow], FillStage::Buffer];
|
||||
}
|
||||
|
||||
$remaining = $toAllocate;
|
||||
foreach ($phases as [$group, $fillStage]) {
|
||||
$remaining = $this->allocateToGroup($group, $fillStage, $remaining);
|
||||
}
|
||||
|
||||
$totalAllocated = array_sum($this->allocations);
|
||||
|
||||
return new Result($this->getAllocations(), $totalAllocated, $toAllocate - $totalAllocated);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{bucket: Bucket, amount: int}>
|
||||
*/
|
||||
private function getAllocations(): array
|
||||
{
|
||||
return $this->buckets->map(fn (Bucket $bucket) => [
|
||||
'bucket' => $bucket,
|
||||
'amount' => $this->allocations[(string) $bucket->getId()] ?? 0,
|
||||
])->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<Bucket>
|
||||
*/
|
||||
private function bucketsOfType(BucketType $type): array
|
||||
{
|
||||
return $this->buckets
|
||||
->filter(static fn (Bucket $bucket) => $type === $bucket->getType())
|
||||
->getValues();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Bucket> $group
|
||||
*/
|
||||
private function allocateToGroup(array $group, FillStage $fillStage, int $toAllocate): int
|
||||
{
|
||||
$tiers = [];
|
||||
|
||||
foreach ($group as $bucket) {
|
||||
$tiers[$bucket->getPriority()][] = $bucket;
|
||||
}
|
||||
|
||||
ksort($tiers);
|
||||
|
||||
foreach ($tiers as $tier) {
|
||||
$toAllocate = $this->allocateToTier($tier, $fillStage, $toAllocate);
|
||||
}
|
||||
|
||||
return $toAllocate;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Bucket> $group
|
||||
*/
|
||||
private function allocateToTier(array $group, FillStage $fillStage, int $toAllocate): int
|
||||
{
|
||||
$allocations = $this->allocations;
|
||||
$groupBuckets = new ArrayCollection($group);
|
||||
|
||||
$split = (new EvenSplitter())->split(
|
||||
amount: $toAllocate,
|
||||
slots: $groupBuckets->map(fn (Bucket $bucket) => (new BucketRoomCalculator())
|
||||
->roomFor($bucket, $fillStage, $this->income, $allocations[(string) $bucket->getId()] ?? 0))
|
||||
->toArray()
|
||||
);
|
||||
|
||||
for ($i = 0; $i < \count($split); ++$i) {
|
||||
$bucket = $group[$i];
|
||||
$amount = $split[$i];
|
||||
$this->addAllocation($bucket, $amount);
|
||||
$toAllocate -= $amount;
|
||||
}
|
||||
|
||||
return $toAllocate;
|
||||
}
|
||||
|
||||
private function addAllocation(Bucket $bucket, int $amount): void
|
||||
{
|
||||
if (isset($this->allocations[(string) $bucket->getId()])) {
|
||||
$bucketAllocation = (int) $this->allocations[(string) $bucket->getId()];
|
||||
$this->allocations[(string) $bucket->getId()] = $bucketAllocation + $amount;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->allocations[(string) $bucket->getId()] = $amount;
|
||||
}
|
||||
}
|
||||
55
src/Service/Allocation/BucketRoomCalculator.php
Normal file
55
src/Service/Allocation/BucketRoomCalculator.php
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
|
||||
namespace App\Service\Allocation;
|
||||
|
||||
use App\Entity\Bucket;
|
||||
use App\Enum\BucketAllocationType;
|
||||
use App\Enum\FillStage;
|
||||
|
||||
class BucketRoomCalculator
|
||||
{
|
||||
public function roomFor(
|
||||
Bucket $bucket,
|
||||
FillStage $fillStage,
|
||||
int $income,
|
||||
int $alreadyAllocated,
|
||||
): int {
|
||||
return match ($bucket->getAllocationType()) {
|
||||
BucketAllocationType::PERCENTAGE => $this->handlePercentage($bucket, $fillStage, $income, $alreadyAllocated),
|
||||
BucketAllocationType::FIXED_LIMIT => $this->handleFixedLimit($bucket, $fillStage, $alreadyAllocated),
|
||||
BucketAllocationType::UNLIMITED => $this->handleUnlimited(),
|
||||
};
|
||||
}
|
||||
|
||||
private function handlePercentage(Bucket $bucket, FillStage $fillStage, int $income, int $alreadyAllocated): int
|
||||
{
|
||||
if (FillStage::Buffer === $fillStage) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$room = (($bucket->getAllocationValue() ?? 0) * $income / 10000);
|
||||
|
||||
return $this->clamp($room, $bucket->getStartingAmount(), $alreadyAllocated);
|
||||
}
|
||||
|
||||
private function handleFixedLimit(Bucket $bucket, FillStage $fillStage, int $alreadyAllocated): int
|
||||
{
|
||||
$room = $bucket->getAllocationValue() ?? 0;
|
||||
|
||||
if (FillStage::Buffer === $fillStage) {
|
||||
$room *= (1 + (float) $bucket->getBufferMultiplier());
|
||||
}
|
||||
|
||||
return $this->clamp($room, $bucket->getStartingAmount(), $alreadyAllocated);
|
||||
}
|
||||
|
||||
private function handleUnlimited(): int
|
||||
{
|
||||
return \PHP_INT_MAX;
|
||||
}
|
||||
|
||||
private function clamp(int|float $cap, int $startingAmount, int $alreadyAllocated): int
|
||||
{
|
||||
return max(0, (int) floor($cap - $startingAmount - $alreadyAllocated));
|
||||
}
|
||||
}
|
||||
88
src/Service/Allocation/EvenSplitter.php
Normal file
88
src/Service/Allocation/EvenSplitter.php
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
<?php
|
||||
|
||||
namespace App\Service\Allocation;
|
||||
|
||||
class EvenSplitter
|
||||
{
|
||||
/**
|
||||
* @param list<int> $slots per-slot maximum
|
||||
*
|
||||
* @return list<int> per-slot allocated, positionally aligned with $slots
|
||||
*/
|
||||
public function split(int $amount, array $slots): array
|
||||
{
|
||||
$allocated = array_map(static fn () => 0, $slots);
|
||||
|
||||
return $this->distribute($amount, $slots, $allocated);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $slots
|
||||
* @param list<int> $allocated amounts placed so far (accumulator)
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function distribute(int $amount, array $slots, array $allocated): array
|
||||
{
|
||||
if (0 === $amount) {
|
||||
return $allocated;
|
||||
}
|
||||
|
||||
$unfilled = $this->getUnfilled($slots, $allocated);
|
||||
if (0 === \count($unfilled)) {
|
||||
return $allocated;
|
||||
}
|
||||
|
||||
$remaining = $amount;
|
||||
$avgAmount = intdiv($remaining, \count($unfilled));
|
||||
$this->applyAvg($avgAmount, $allocated, $slots, $remaining);
|
||||
|
||||
return $this->distribute($remaining, $slots, $allocated);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $slots
|
||||
* @param list<int> $allocated
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function getUnfilled(array $slots, array $allocated): array
|
||||
{
|
||||
// get unfilled
|
||||
$unfilled = array_filter(
|
||||
$slots,
|
||||
static fn ($slot, int $i) => $slot > $allocated[$i],
|
||||
\ARRAY_FILTER_USE_BOTH
|
||||
);
|
||||
|
||||
return $unfilled;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> &$allocated
|
||||
* @param list<int> $slots
|
||||
*/
|
||||
private function applyAvg(int $avgAmount, array &$allocated, array $slots, int &$remaining): void
|
||||
{
|
||||
$unfilledCount = \count($this->getUnfilled($slots, $allocated));
|
||||
|
||||
$remainderCents = $unfilledCount > 0 ? $remaining % $unfilledCount : 0;
|
||||
|
||||
// apply remaining to slots
|
||||
$position = 0;
|
||||
foreach ($slots as $i => $slot) {
|
||||
if ($slot <= $allocated[$i]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$share = $avgAmount + ($position < $remainderCents ? 1 : 0);
|
||||
++$position;
|
||||
|
||||
$gap = $slot - $allocated[$i];
|
||||
$toAssign = min($share, $gap);
|
||||
|
||||
$allocated[$i] += $toAssign;
|
||||
$remaining -= $toAssign;
|
||||
}
|
||||
}
|
||||
}
|
||||
46
src/Service/Allocation/Result.php
Normal file
46
src/Service/Allocation/Result.php
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
namespace App\Service\Allocation;
|
||||
|
||||
use App\Entity\Bucket;
|
||||
|
||||
class Result
|
||||
{
|
||||
/** @var list<array{bucket: Bucket, amount: int}> */
|
||||
private array $allocations;
|
||||
|
||||
private int $totalAllocated;
|
||||
|
||||
private ?int $unallocated = null;
|
||||
|
||||
/**
|
||||
* @param list<array{bucket: Bucket, amount: int}> $allocations
|
||||
*/
|
||||
public function __construct(
|
||||
array $allocations,
|
||||
int $totalAllocated,
|
||||
?int $unallocated = 0,
|
||||
) {
|
||||
$this->allocations = $allocations;
|
||||
$this->totalAllocated = $totalAllocated;
|
||||
$this->unallocated = $unallocated;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{bucket: Bucket, amount: int}>
|
||||
*/
|
||||
public function getAllocations(): array
|
||||
{
|
||||
return $this->allocations;
|
||||
}
|
||||
|
||||
public function getTotalAllocated(): int
|
||||
{
|
||||
return $this->totalAllocated;
|
||||
}
|
||||
|
||||
public function getUnallocated(): int
|
||||
{
|
||||
return $this->unallocated;
|
||||
}
|
||||
}
|
||||
56
src/State/PreviewProcessor.php
Normal file
56
src/State/PreviewProcessor.php
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\ApiResource\ProjectionPreviewInput;
|
||||
use App\ApiResource\ProjectionPreviewOutput;
|
||||
use App\Entity\Scenario;
|
||||
use App\Repository\BucketRepository;
|
||||
use App\Repository\ScenarioRepository;
|
||||
use App\Service\Allocation\AllocateIncome;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* @implements ProcessorInterface<ProjectionPreviewInput, ProjectionPreviewOutput>
|
||||
*/
|
||||
class PreviewProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ScenarioRepository $scenarioRepository,
|
||||
private readonly BucketRepository $bucketRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
public function process(
|
||||
mixed $data,
|
||||
Operation $operation,
|
||||
array $uriVariables = [],
|
||||
array $context = [],
|
||||
): ProjectionPreviewOutput {
|
||||
try {
|
||||
$uuid = Uuid::fromString($uriVariables['scenario']);
|
||||
} catch (\InvalidArgumentException) {
|
||||
throw new NotFoundHttpException();
|
||||
}
|
||||
|
||||
$scenario = $this->scenarioRepository->find($uuid);
|
||||
|
||||
if (null === $scenario) {
|
||||
throw new NotFoundHttpException();
|
||||
}
|
||||
|
||||
return $this->buildOutput($scenario, $data->amount);
|
||||
}
|
||||
|
||||
public function buildOutput(Scenario $scenario, int $amount): ProjectionPreviewOutput
|
||||
{
|
||||
$buckets = $this->bucketRepository->findByScenarioOrderedByPriority($scenario);
|
||||
$result = (new AllocateIncome(new ArrayCollection($buckets), $amount))->execute();
|
||||
|
||||
return ProjectionPreviewOutput::fromResult($result);
|
||||
}
|
||||
}
|
||||
124
tests/ApiResource/ProjectionPreviewOutputTest.php
Normal file
124
tests/ApiResource/ProjectionPreviewOutputTest.php
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
<?php
|
||||
|
||||
namespace App\Tests\ApiResource;
|
||||
|
||||
use App\ApiResource\ProjectionPreviewOutput;
|
||||
use App\Entity\Bucket;
|
||||
use App\Entity\Scenario;
|
||||
use App\Enum\BucketAllocationType;
|
||||
use App\Enum\BucketType;
|
||||
use App\Service\Allocation\Result;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Pure-unit test for `ProjectionPreviewOutput::fromResult()` — the named constructor
|
||||
* that shapes a `Result` (allocation engine output) into the API-facing output DTO.
|
||||
*
|
||||
* Contract pinned here:
|
||||
* - allocations: list of ['bucket_id' => string (uuid, RFC4122), 'bucket_name' =>
|
||||
* string, 'bucket_type' => string (enum backing value), 'allocated_amount' => int,
|
||||
* 'remaining_capacity' => null]
|
||||
* - totalAllocated: int (Result::getTotalAllocated())
|
||||
* - unallocated: int (Result::getUnallocated())
|
||||
*
|
||||
* `remaining_capacity` is ALWAYS null in this contract — a deliberate simplification.
|
||||
* The anemic Bucket entity has no getEffectiveCapacity(); computing real remaining
|
||||
* capacity here would couple this DTO to BucketRoomCalculator/FillStage/income, which
|
||||
* doesn't belong in a pure mapping function. Real capacity is a follow-up, not this story.
|
||||
*/
|
||||
final class ProjectionPreviewOutputTest extends TestCase
|
||||
{
|
||||
public function testFromResultMapsAllocationsToTheOutputShape(): void
|
||||
{
|
||||
$scenario = $this->makeScenario();
|
||||
|
||||
$need = $this->makeBucket(
|
||||
$scenario,
|
||||
BucketType::NEED,
|
||||
BucketAllocationType::FIXED_LIMIT,
|
||||
priority: 1,
|
||||
allocationValue: 5000,
|
||||
name: 'Groceries',
|
||||
);
|
||||
$overflow = $this->makeBucket(
|
||||
$scenario,
|
||||
BucketType::OVERFLOW,
|
||||
BucketAllocationType::UNLIMITED,
|
||||
priority: 2,
|
||||
allocationValue: null,
|
||||
name: 'Overflow',
|
||||
);
|
||||
|
||||
$result = new Result(
|
||||
[
|
||||
['bucket' => $need, 'amount' => 5000],
|
||||
['bucket' => $overflow, 'amount' => 3000],
|
||||
],
|
||||
8000,
|
||||
0,
|
||||
);
|
||||
|
||||
$output = ProjectionPreviewOutput::fromResult($result);
|
||||
|
||||
self::assertCount(2, $output->allocations);
|
||||
|
||||
self::assertSame((string) $need->getId(), $output->allocations[0]['bucket_id']);
|
||||
self::assertSame('Groceries', $output->allocations[0]['bucket_name']);
|
||||
self::assertSame('need', $output->allocations[0]['bucket_type']);
|
||||
self::assertSame(5000, $output->allocations[0]['allocated_amount']);
|
||||
self::assertNull($output->allocations[0]['remaining_capacity']);
|
||||
|
||||
self::assertSame((string) $overflow->getId(), $output->allocations[1]['bucket_id']);
|
||||
self::assertSame('Overflow', $output->allocations[1]['bucket_name']);
|
||||
self::assertSame('overflow', $output->allocations[1]['bucket_type']);
|
||||
self::assertSame(3000, $output->allocations[1]['allocated_amount']);
|
||||
self::assertNull($output->allocations[1]['remaining_capacity']);
|
||||
|
||||
self::assertSame(8000, $output->totalAllocated);
|
||||
self::assertSame(0, $output->unallocated);
|
||||
}
|
||||
|
||||
public function testFromResultWithNoAllocationsProducesEmptyAllocationsList(): void
|
||||
{
|
||||
$result = new Result([], 0, 5000);
|
||||
|
||||
$output = ProjectionPreviewOutput::fromResult($result);
|
||||
|
||||
self::assertSame([], $output->allocations);
|
||||
self::assertSame(0, $output->totalAllocated);
|
||||
self::assertSame(5000, $output->unallocated);
|
||||
}
|
||||
|
||||
public function testFromResultReturnsAProjectionPreviewOutputInstance(): void
|
||||
{
|
||||
$result = new Result([], 0, 1000);
|
||||
|
||||
$output = ProjectionPreviewOutput::fromResult($result);
|
||||
|
||||
self::assertInstanceOf(ProjectionPreviewOutput::class, $output);
|
||||
}
|
||||
|
||||
private function makeScenario(): Scenario
|
||||
{
|
||||
return (new Scenario())->setName('Household Budget');
|
||||
}
|
||||
|
||||
private function makeBucket(
|
||||
Scenario $scenario,
|
||||
BucketType $type,
|
||||
BucketAllocationType $allocationType,
|
||||
int $priority,
|
||||
?int $allocationValue,
|
||||
string $name,
|
||||
): Bucket {
|
||||
return (new Bucket())
|
||||
->setScenario($scenario)
|
||||
->setType($type)
|
||||
->setName($name)
|
||||
->setPriority($priority)
|
||||
->setAllocationType($allocationType)
|
||||
->setAllocationValue($allocationValue)
|
||||
->setStartingAmount(0)
|
||||
->setBufferMultiplier('0.00');
|
||||
}
|
||||
}
|
||||
17
tests/Enum/FillStageTest.php
Normal file
17
tests/Enum/FillStageTest.php
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
namespace App\Tests\Enum;
|
||||
|
||||
use App\Enum\FillStage;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class FillStageTest extends TestCase
|
||||
{
|
||||
public function testFillStageCasesAndBackingValues(): void
|
||||
{
|
||||
$this->assertSame('base', FillStage::Base->value);
|
||||
$this->assertSame('buffer', FillStage::Buffer->value);
|
||||
|
||||
$this->assertCount(2, FillStage::cases());
|
||||
}
|
||||
}
|
||||
344
tests/Functional/ProjectionPreviewApiTest.php
Normal file
344
tests/Functional/ProjectionPreviewApiTest.php
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
<?php
|
||||
|
||||
namespace App\Tests\Functional;
|
||||
|
||||
use App\Entity\Bucket;
|
||||
use App\Entity\Scenario;
|
||||
use App\Entity\User;
|
||||
use App\Enum\BucketAllocationType;
|
||||
use App\Enum\BucketType;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\PasswordHasher\Hasher\NativePasswordHasher;
|
||||
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactory;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasher;
|
||||
|
||||
/**
|
||||
* POST /api/scenarios/{scenario}/projections/preview — stateless allocation
|
||||
* preview. Wires AllocateIncome (the allocation engine, App\Service\Allocation)
|
||||
* into the API via a custom State Provider. No entities are persisted by this
|
||||
* endpoint — it's a pure read+compute+return.
|
||||
*/
|
||||
final class ProjectionPreviewApiTest extends WebTestCase
|
||||
{
|
||||
private const EMAIL = 'projection-preview-api-test@example.com';
|
||||
private const PASSWORD = 'correct-horse-battery-staple';
|
||||
|
||||
private KernelBrowser $client;
|
||||
private EntityManagerInterface $em;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->client = static::createClient();
|
||||
|
||||
/** @var EntityManagerInterface $em */
|
||||
$em = static::getContainer()->get(EntityManagerInterface::class);
|
||||
$this->em = $em;
|
||||
|
||||
$factory = new PasswordHasherFactory([
|
||||
User::class => new NativePasswordHasher(cost: 4),
|
||||
]);
|
||||
$hasher = new UserPasswordHasher($factory);
|
||||
|
||||
$user = new User();
|
||||
$user->setEmail(self::EMAIL);
|
||||
$user->setPassword($hasher->hashPassword($user, self::PASSWORD));
|
||||
|
||||
$this->em->persist($user);
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
private function login(): void
|
||||
{
|
||||
$this->client->jsonRequest('POST', '/api/login', [
|
||||
'username' => self::EMAIL,
|
||||
'password' => self::PASSWORD,
|
||||
]);
|
||||
|
||||
self::assertLessThan(
|
||||
300,
|
||||
$this->client->getResponse()->getStatusCode(),
|
||||
'Test setup precondition: login must succeed before exercising the projection preview API.',
|
||||
);
|
||||
}
|
||||
|
||||
private function persistScenario(string $name = 'Household Budget'): Scenario
|
||||
{
|
||||
$scenario = new Scenario();
|
||||
$scenario->setName($name);
|
||||
|
||||
$this->em->persist($scenario);
|
||||
$this->em->flush();
|
||||
|
||||
return $scenario;
|
||||
}
|
||||
|
||||
private function persistBucket(
|
||||
Scenario $scenario,
|
||||
BucketType $type,
|
||||
BucketAllocationType $allocationType,
|
||||
int $priority,
|
||||
?int $allocationValue,
|
||||
string $name,
|
||||
): Bucket {
|
||||
$bucket = new Bucket();
|
||||
$bucket->setScenario($scenario);
|
||||
$bucket->setType($type);
|
||||
$bucket->setName($name);
|
||||
$bucket->setPriority($priority);
|
||||
$bucket->setAllocationType($allocationType);
|
||||
$bucket->setAllocationValue($allocationValue);
|
||||
$bucket->setStartingAmount(0);
|
||||
$bucket->setBufferMultiplier('0.00');
|
||||
|
||||
$this->em->persist($bucket);
|
||||
$this->em->flush();
|
||||
|
||||
return $bucket;
|
||||
}
|
||||
|
||||
private function previewUri(Scenario $scenario): string
|
||||
{
|
||||
return '/api/scenarios/'.$scenario->getId().'/projections/preview';
|
||||
}
|
||||
|
||||
public function testPreviewIsRejectedWhenUnauthenticated(): void
|
||||
{
|
||||
$scenario = $this->persistScenario();
|
||||
|
||||
$this->client->request(
|
||||
'POST',
|
||||
$this->previewUri($scenario),
|
||||
server: [
|
||||
'CONTENT_TYPE' => 'application/ld+json',
|
||||
'HTTP_ACCEPT' => 'application/ld+json',
|
||||
],
|
||||
content: json_encode(['amount' => 8000]),
|
||||
);
|
||||
|
||||
self::assertSame(
|
||||
401,
|
||||
$this->client->getResponse()->getStatusCode(),
|
||||
'POST .../projections/preview must require authentication.',
|
||||
);
|
||||
}
|
||||
|
||||
public function testPreviewReturnsAllocationBreakdownForAuthenticatedUser(): void
|
||||
{
|
||||
$scenario = $this->persistScenario();
|
||||
|
||||
$need = $this->persistBucket(
|
||||
$scenario,
|
||||
BucketType::NEED,
|
||||
BucketAllocationType::FIXED_LIMIT,
|
||||
priority: 1,
|
||||
allocationValue: 5000,
|
||||
name: 'Rent',
|
||||
);
|
||||
$overflow = $this->persistBucket(
|
||||
$scenario,
|
||||
BucketType::OVERFLOW,
|
||||
BucketAllocationType::UNLIMITED,
|
||||
priority: 2,
|
||||
allocationValue: null,
|
||||
name: 'Overflow',
|
||||
);
|
||||
|
||||
$this->login();
|
||||
|
||||
$this->client->request(
|
||||
'POST',
|
||||
$this->previewUri($scenario),
|
||||
server: [
|
||||
'CONTENT_TYPE' => 'application/ld+json',
|
||||
'HTTP_ACCEPT' => 'application/ld+json',
|
||||
],
|
||||
content: json_encode(['amount' => 8000]),
|
||||
);
|
||||
|
||||
$response = $this->client->getResponse();
|
||||
|
||||
self::assertSame(
|
||||
200,
|
||||
$response->getStatusCode(),
|
||||
'POST .../projections/preview with a valid amount must return 200 for an authenticated user.',
|
||||
);
|
||||
|
||||
$body = json_decode((string) $response->getContent(), true);
|
||||
|
||||
self::assertIsArray($body, 'The preview response must be a JSON-LD object.');
|
||||
self::assertArrayHasKey('allocations', $body, 'The preview response must expose an allocations array.');
|
||||
self::assertIsArray($body['allocations']);
|
||||
|
||||
$byBucketId = [];
|
||||
foreach ($body['allocations'] as $allocation) {
|
||||
self::assertArrayHasKey('bucket_id', $allocation);
|
||||
self::assertArrayHasKey('bucket_name', $allocation);
|
||||
self::assertArrayHasKey('bucket_type', $allocation);
|
||||
self::assertArrayHasKey('allocated_amount', $allocation);
|
||||
|
||||
$byBucketId[$allocation['bucket_id']] = $allocation;
|
||||
}
|
||||
|
||||
self::assertArrayHasKey(
|
||||
$need->getId()->toRfc4122(),
|
||||
$byBucketId,
|
||||
'The NEED bucket must appear in the allocation breakdown.',
|
||||
);
|
||||
self::assertArrayHasKey(
|
||||
$overflow->getId()->toRfc4122(),
|
||||
$byBucketId,
|
||||
'The OVERFLOW bucket must appear in the allocation breakdown.',
|
||||
);
|
||||
|
||||
self::assertSame(
|
||||
5000,
|
||||
$byBucketId[$need->getId()->toRfc4122()]['allocated_amount'],
|
||||
'The fixed-limit NEED bucket (cap 5000) must be allocated its full base cap first.',
|
||||
);
|
||||
self::assertSame(
|
||||
3000,
|
||||
$byBucketId[$overflow->getId()->toRfc4122()]['allocated_amount'],
|
||||
'The OVERFLOW bucket must sweep the remainder (8000 income - 5000 need = 3000).',
|
||||
);
|
||||
|
||||
$total = $this->findTotalAllocated($body);
|
||||
$unallocated = $this->findUnallocated($body);
|
||||
|
||||
self::assertSame(8000, $total, 'totalAllocated must equal the full income — nothing left stranded.');
|
||||
self::assertSame(0, $unallocated, 'unallocated must be zero — the overflow bucket absorbed the remainder.');
|
||||
}
|
||||
|
||||
public function testPreviewWithNonexistentScenarioIdIsRejectedAsNotFound(): void
|
||||
{
|
||||
$this->login();
|
||||
|
||||
$nonexistentScenarioUri = '/api/scenarios/'.\Symfony\Component\Uid\Uuid::v7().'/projections/preview';
|
||||
|
||||
$this->client->request(
|
||||
'POST',
|
||||
$nonexistentScenarioUri,
|
||||
server: [
|
||||
'CONTENT_TYPE' => 'application/ld+json',
|
||||
'HTTP_ACCEPT' => 'application/ld+json',
|
||||
],
|
||||
content: json_encode(['amount' => 8000]),
|
||||
);
|
||||
|
||||
self::assertSame(
|
||||
404,
|
||||
$this->client->getResponse()->getStatusCode(),
|
||||
'POST .../projections/preview with a well-formed but unknown scenario UUID must return 404.',
|
||||
);
|
||||
}
|
||||
|
||||
public function testPreviewWithMalformedScenarioIdIsRejectedAsNotFound(): void
|
||||
{
|
||||
$this->login();
|
||||
|
||||
$this->client->request(
|
||||
'POST',
|
||||
'/api/scenarios/not-a-uuid/projections/preview',
|
||||
server: [
|
||||
'CONTENT_TYPE' => 'application/ld+json',
|
||||
'HTTP_ACCEPT' => 'application/ld+json',
|
||||
],
|
||||
content: json_encode(['amount' => 8000]),
|
||||
);
|
||||
|
||||
self::assertSame(
|
||||
404,
|
||||
$this->client->getResponse()->getStatusCode(),
|
||||
'POST .../projections/preview with a malformed scenario id must return 404 — '
|
||||
.'indistinguishable from an unknown-but-valid UUID, so clients cannot probe id validity.',
|
||||
);
|
||||
}
|
||||
|
||||
public function testPreviewWithMissingAmountIsRejectedAsUnprocessable(): void
|
||||
{
|
||||
$scenario = $this->persistScenario();
|
||||
|
||||
$this->login();
|
||||
|
||||
$this->client->request(
|
||||
'POST',
|
||||
$this->previewUri($scenario),
|
||||
server: [
|
||||
'CONTENT_TYPE' => 'application/ld+json',
|
||||
'HTTP_ACCEPT' => 'application/ld+json',
|
||||
],
|
||||
content: json_encode([]),
|
||||
);
|
||||
|
||||
self::assertSame(
|
||||
422,
|
||||
$this->client->getResponse()->getStatusCode(),
|
||||
'POST .../projections/preview without an amount must return 422 Unprocessable Entity.',
|
||||
);
|
||||
}
|
||||
|
||||
public function testPreviewWithZeroOrNegativeAmountIsRejectedAsUnprocessable(): void
|
||||
{
|
||||
$scenario = $this->persistScenario();
|
||||
|
||||
$this->login();
|
||||
|
||||
$this->client->request(
|
||||
'POST',
|
||||
$this->previewUri($scenario),
|
||||
server: [
|
||||
'CONTENT_TYPE' => 'application/ld+json',
|
||||
'HTTP_ACCEPT' => 'application/ld+json',
|
||||
],
|
||||
content: json_encode(['amount' => 0]),
|
||||
);
|
||||
|
||||
self::assertSame(
|
||||
422,
|
||||
$this->client->getResponse()->getStatusCode(),
|
||||
'POST .../projections/preview with amount=0 must return 422 Unprocessable Entity '
|
||||
.'(GreaterThanOrEqual(1) violated).',
|
||||
);
|
||||
}
|
||||
|
||||
public function testPreviewWithNonJsonContentTypeIsRejected(): void
|
||||
{
|
||||
$scenario = $this->persistScenario();
|
||||
|
||||
$this->login();
|
||||
|
||||
$this->client->request(
|
||||
'POST',
|
||||
$this->previewUri($scenario),
|
||||
['amount' => 8000],
|
||||
);
|
||||
|
||||
self::assertSame(
|
||||
415,
|
||||
$this->client->getResponse()->getStatusCode(),
|
||||
'POST .../projections/preview without a JSON Content-Type must be rejected with 415 Unsupported Media Type.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tolerant lookup for the total-allocated figure: accepts either
|
||||
* camelCase (totalAllocated) or snake_case (total_allocated) serialized
|
||||
* property names, since the DTO's exact property/serialized-name choice
|
||||
* is the implementer's call.
|
||||
*
|
||||
* @param array<string, mixed> $body
|
||||
*/
|
||||
private function findTotalAllocated(array $body): ?int
|
||||
{
|
||||
return $body['totalAllocated'] ?? $body['total_allocated'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $body
|
||||
*/
|
||||
private function findUnallocated(array $body): ?int
|
||||
{
|
||||
return $body['unallocated'] ?? null;
|
||||
}
|
||||
}
|
||||
|
|
@ -92,6 +92,53 @@ final class BucketRepositoryTest extends KernelTestCase
|
|||
);
|
||||
}
|
||||
|
||||
public function testFindByScenarioOrderedByPriorityReturnsScenarioBucketsInPriorityOrder(): void
|
||||
{
|
||||
$scenario = $this->persistScenario();
|
||||
|
||||
// Inserted out of priority order on purpose.
|
||||
$third = $this->persistBucket($scenario, BucketType::NEED, 3);
|
||||
$first = $this->persistBucket($scenario, BucketType::WANT, 1);
|
||||
$second = $this->persistBucket($scenario, BucketType::OVERFLOW, 2);
|
||||
|
||||
$buckets = $this->repository->findByScenarioOrderedByPriority($scenario);
|
||||
|
||||
self::assertSame(
|
||||
[$first->getId(), $second->getId(), $third->getId()],
|
||||
array_map(static fn (Bucket $bucket) => $bucket->getId(), $buckets),
|
||||
'Buckets must come back ordered by priority ascending, regardless of insertion order.',
|
||||
);
|
||||
}
|
||||
|
||||
public function testFindByScenarioOrderedByPriorityReturnsOnlyTheGivenScenariosBuckets(): void
|
||||
{
|
||||
$scenarioA = $this->persistScenario('Scenario A');
|
||||
$scenarioB = $this->persistScenario('Scenario B');
|
||||
|
||||
$bucketA1 = $this->persistBucket($scenarioA, BucketType::NEED, 1);
|
||||
$bucketA2 = $this->persistBucket($scenarioA, BucketType::OVERFLOW, 2);
|
||||
$this->persistBucket($scenarioB, BucketType::NEED, 1);
|
||||
|
||||
$buckets = $this->repository->findByScenarioOrderedByPriority($scenarioA);
|
||||
|
||||
self::assertSame(
|
||||
[$bucketA1->getId(), $bucketA2->getId()],
|
||||
array_map(static fn (Bucket $bucket) => $bucket->getId(), $buckets),
|
||||
'Only the queried scenario\'s buckets must be returned — another scenario\'s buckets must not leak in.',
|
||||
);
|
||||
}
|
||||
|
||||
public function testFindByScenarioOrderedByPriorityReturnsEmptyForScenarioWithNoBuckets(): void
|
||||
{
|
||||
$scenario = $this->persistScenario();
|
||||
|
||||
self::assertSame(
|
||||
[],
|
||||
$this->repository->findByScenarioOrderedByPriority($scenario),
|
||||
'A scenario with no buckets must return an empty array.',
|
||||
);
|
||||
}
|
||||
|
||||
private function persistScenario(string $name = 'Household Budget'): Scenario
|
||||
{
|
||||
$scenario = new Scenario();
|
||||
|
|
|
|||
549
tests/Service/Allocation/AllocateIncomeTest.php
Normal file
549
tests/Service/Allocation/AllocateIncomeTest.php
Normal file
|
|
@ -0,0 +1,549 @@
|
|||
<?php
|
||||
|
||||
namespace App\Tests\Service\Allocation;
|
||||
|
||||
use App\Entity\Bucket;
|
||||
use App\Entity\Scenario;
|
||||
use App\Enum\BucketAllocationType;
|
||||
use App\Enum\BucketType;
|
||||
use App\Enum\DistributionMode;
|
||||
use App\Service\Allocation\AllocateIncome;
|
||||
use App\Service\Allocation\Result;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests for the redesigned allocation engine — single strategy, no distribution
|
||||
* modes. `preview()` no longer accepts a `DistributionMode` parameter at all —
|
||||
* Model C's even-split-on-tie behaviour is intrinsic to the engine, not a mode.
|
||||
*
|
||||
* THE MODEL (type-phased macro-order, priority tiers within each phase):
|
||||
* 1. Guard: amount <= 0 or no buckets -> empty Result.
|
||||
* 2. FIVE MACRO-PHASES, strictly in this order:
|
||||
* P1: needs -> base
|
||||
* P2: wants -> base
|
||||
* P3: needs -> buffer
|
||||
* P4: wants -> buffer
|
||||
* P5: overflow -> all remaining
|
||||
* A WANT's base (P2) fills BEFORE a NEED's buffer (P3) -- necessity's base
|
||||
* beats luxury's buffer, but luxury's base still beats necessity's buffer.
|
||||
* 3. WITHIN each phase, only that phase's buckets are in play (e.g. P1 only
|
||||
* considers type=NEED buckets), partitioned into TIERS by getPriority()
|
||||
* (buckets CAN share a priority -> a tier = every bucket at that priority
|
||||
* within the phase). Tiers are processed in ASCENDING priority order.
|
||||
* 4. BASE phases (P1, P2) fill each bucket toward its base cap (fixed-limit:
|
||||
* allocationValue; percentage: round(amount * allocationValue / 10000),
|
||||
* allocationValue is basis points of the ORIGINAL income, not of remaining).
|
||||
* 5. BUFFER phases (P3, P4) fill FIXED-LIMIT buckets only, toward
|
||||
* round(allocationValue * (1 + bufferMultiplier)). Percentage buckets are
|
||||
* skipped entirely in buffer phases (no buffer concept for them).
|
||||
* 6. OVERFLOW (P5): any money still remaining goes to the one OVERFLOW bucket.
|
||||
* 7. Leftover only flows to the NEXT tier once every tier member in the
|
||||
* current tier is capped; leftover only flows to the NEXT phase once every
|
||||
* tier in the current phase is done.
|
||||
*
|
||||
* WITHIN-TIER DISTRIBUTION when a tier can't be fully funded -- even split with
|
||||
* redistribute: even share = intdiv(remaining, memberCount), first
|
||||
* (remaining % memberCount) members (iteration order) get +1 cent. Each member
|
||||
* takes min(share, space). A member whose space < its share caps out and is
|
||||
* removed from the round; the round repeats over the rest with the leftover.
|
||||
*
|
||||
* AllocateIncome::__construct(Collection<int, Bucket> $buckets, int $income)
|
||||
* AllocateIncome::execute(): Result
|
||||
*/
|
||||
final class AllocateIncomeTest extends TestCase
|
||||
{
|
||||
// --- A. Guards -----------------------------------------------------
|
||||
|
||||
public function testNonPositiveAmountProducesEmptyDistribution(): void
|
||||
{
|
||||
$scenario = $this->makeScenario();
|
||||
$bucket = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 1, allocationValue: 50000, name: 'Bucket');
|
||||
|
||||
$engine = new AllocateIncome(new ArrayCollection([$bucket]), 0);
|
||||
$result = $engine->execute();
|
||||
|
||||
self::assertInstanceOf(Result::class, $result);
|
||||
self::assertSame([], $result->getAllocations());
|
||||
self::assertSame(0, $result->getTotalAllocated());
|
||||
self::assertSame(0, $result->getUnallocated());
|
||||
}
|
||||
|
||||
public function testNoBucketsProducesEmptyDistribution(): void
|
||||
{
|
||||
$engine = new AllocateIncome(new ArrayCollection([]), 10000);
|
||||
$result = $engine->execute();
|
||||
|
||||
self::assertInstanceOf(Result::class, $result);
|
||||
self::assertSame([], $result->getAllocations());
|
||||
self::assertSame(0, $result->getTotalAllocated());
|
||||
self::assertSame(10000, $result->getUnallocated());
|
||||
}
|
||||
|
||||
public function testNoBucketsWithPositiveIncomeLeavesTheFullIncomeUnallocated(): void
|
||||
{
|
||||
$engine = new AllocateIncome(new ArrayCollection([]), 5000);
|
||||
$result = $engine->execute();
|
||||
|
||||
self::assertInstanceOf(Result::class, $result);
|
||||
self::assertSame([], $result->getAllocations());
|
||||
self::assertSame(0, $result->getTotalAllocated());
|
||||
self::assertSame(5000, $result->getUnallocated());
|
||||
}
|
||||
|
||||
// --- B. Needs-base fills before wants-base, regardless of priority --
|
||||
|
||||
public function testNeedsBaseFillsBeforeWantsBaseRegardlessOfPriority(): void
|
||||
{
|
||||
$scenario = $this->makeScenario();
|
||||
|
||||
// WANT at priority 1 (numerically higher precedence), NEED at priority 2
|
||||
// -- under Model C the macro-phase (type) decides before priority does,
|
||||
// so the NEED still fills first because needs-base (P1) precedes
|
||||
// wants-base (P2), even though the WANT has the "better" priority number.
|
||||
$want = $this->makeFixedLimitBucket($scenario, BucketType::WANT, priority: 1, allocationValue: 50000, name: 'Want');
|
||||
$need = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 2, allocationValue: 50000, name: 'Need');
|
||||
|
||||
$engine = new AllocateIncome(new ArrayCollection([$need, $want]), 50000);
|
||||
$result = $engine->execute();
|
||||
|
||||
self::assertSame(50000, $this->amountFor($result, $need));
|
||||
self::assertSame(0, $this->amountFor($result, $want));
|
||||
self::assertSame(50000, $result->getTotalAllocated());
|
||||
self::assertSame(0, $result->getUnallocated());
|
||||
}
|
||||
|
||||
// --- B2. Wants-base fills before needs-buffer ------------------------
|
||||
|
||||
public function testWantBaseFillsBeforeNeedBuffer(): void
|
||||
{
|
||||
$scenario = $this->makeScenario();
|
||||
|
||||
$need = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 1, allocationValue: 40000, name: 'Need', bufferMultiplier: '0.50');
|
||||
$want = $this->makeFixedLimitBucket($scenario, BucketType::WANT, priority: 1, allocationValue: 40000, name: 'Want');
|
||||
|
||||
$engine = new AllocateIncome(new ArrayCollection([$need, $want]), 90000);
|
||||
// P1 needs-base: need=40000 (50000 left). P2 wants-base: want=40000
|
||||
// (10000 left). P3 needs-buffer: need's buffered cap = round(40000*1.50)
|
||||
// = 60000, space = 20000, takes min(20000, 10000) = 10000 -> need=50000.
|
||||
// P4 wants-buffer: want has no buffer (0.00) -> stays 40000.
|
||||
$result = $engine->execute();
|
||||
|
||||
self::assertSame(50000, $this->amountFor($result, $need));
|
||||
self::assertSame(40000, $this->amountFor($result, $want));
|
||||
self::assertSame(90000, $result->getTotalAllocated());
|
||||
self::assertSame(0, $result->getUnallocated());
|
||||
}
|
||||
|
||||
// --- C. Base before buffer across tiers ------------------------------
|
||||
|
||||
public function testAllTiersBaseFillsBeforeAnyTierBuffer(): void
|
||||
{
|
||||
$scenario = $this->makeScenario();
|
||||
|
||||
$p1 = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 1, allocationValue: 40000, name: 'P1', bufferMultiplier: '0.50');
|
||||
$p2 = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 2, allocationValue: 40000, name: 'P2');
|
||||
|
||||
$engine = new AllocateIncome(new ArrayCollection([$p1, $p2]), 90000);
|
||||
// Base phase: p1=40000, p2=40000 (80000 used, 10000 left).
|
||||
// Buffer phase: p1 cap = round(40000 * 1.50) = 60000, space = 60000-40000=20000,
|
||||
// takes min(20000, 10000) = 10000 -> p1 = 50000. p2 has no buffer (0.00) -> stays 40000.
|
||||
$result = $engine->execute();
|
||||
|
||||
self::assertSame(50000, $this->amountFor($result, $p1));
|
||||
self::assertSame(40000, $this->amountFor($result, $p2));
|
||||
self::assertSame(90000, $result->getTotalAllocated());
|
||||
self::assertSame(0, $result->getUnallocated());
|
||||
}
|
||||
|
||||
// --- D. Percentage bucket gets its proportional cut in BASE ---------
|
||||
|
||||
public function testPercentageBucketGetsProportionalBaseCut(): void
|
||||
{
|
||||
$scenario = $this->makeScenario();
|
||||
|
||||
$pct = $this->makePercentageBucket($scenario, BucketType::NEED, priority: 1, allocationValueBasisPoints: 3000, name: 'Pct');
|
||||
$overflow = $this->makeOverflowBucket($scenario, priority: 2, name: 'Overflow');
|
||||
|
||||
$engine = new AllocateIncome(new ArrayCollection([$pct, $overflow]), 10000);
|
||||
// Base cap = floor(10000 * 3000 / 10000) = 3000.
|
||||
$result = $engine->execute();
|
||||
|
||||
self::assertSame(3000, $this->amountFor($result, $pct));
|
||||
self::assertSame(7000, $this->amountFor($result, $overflow));
|
||||
self::assertSame(10000, $result->getTotalAllocated());
|
||||
self::assertSame(0, $result->getUnallocated());
|
||||
}
|
||||
|
||||
// --- E. Percentage bucket has no buffer ------------------------------
|
||||
|
||||
public function testPercentageBucketIsNotToppedUpInBufferPhase(): void
|
||||
{
|
||||
$scenario = $this->makeScenario();
|
||||
|
||||
$pct = $this->makePercentageBucket($scenario, BucketType::NEED, priority: 1, allocationValueBasisPoints: 3000, name: 'Pct', bufferMultiplier: '1.00');
|
||||
$overflow = $this->makeOverflowBucket($scenario, priority: 2, name: 'Overflow');
|
||||
|
||||
$engine = new AllocateIncome(new ArrayCollection([$pct, $overflow]), 10000);
|
||||
// Base cap = floor(10000 * 3000 / 10000) = 3000. Buffer phase skips percentage
|
||||
// entirely -- stays at 3000 even with bufferMultiplier=1.00 set.
|
||||
$result = $engine->execute();
|
||||
|
||||
self::assertSame(3000, $this->amountFor($result, $pct));
|
||||
self::assertSame(7000, $this->amountFor($result, $overflow));
|
||||
self::assertSame(10000, $result->getTotalAllocated());
|
||||
self::assertSame(0, $result->getUnallocated());
|
||||
}
|
||||
|
||||
// --- F. Even split within a tied tier (fully funded) -----------------
|
||||
|
||||
public function testEvenSplitWithinTiedTierWhenFullyFunded(): void
|
||||
{
|
||||
$scenario = $this->makeScenario();
|
||||
|
||||
$a = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 1, allocationValue: 5000, name: 'A');
|
||||
$b = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 1, allocationValue: 5000, name: 'B');
|
||||
|
||||
$engine = new AllocateIncome(new ArrayCollection([$a, $b]), 10000);
|
||||
$result = $engine->execute();
|
||||
|
||||
self::assertSame(5000, $this->amountFor($result, $a));
|
||||
self::assertSame(5000, $this->amountFor($result, $b));
|
||||
self::assertSame(10000, $result->getTotalAllocated());
|
||||
self::assertSame(0, $result->getUnallocated());
|
||||
}
|
||||
|
||||
// --- G. Even split within a tied tier (underfunded, equal caps) ------
|
||||
|
||||
public function testEvenSplitWithinTiedTierWhenUnderfunded(): void
|
||||
{
|
||||
$scenario = $this->makeScenario();
|
||||
|
||||
$a = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 1, allocationValue: 5000, name: 'A');
|
||||
$b = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 1, allocationValue: 5000, name: 'B');
|
||||
|
||||
$engine = new AllocateIncome(new ArrayCollection([$a, $b]), 6000);
|
||||
// even share = intdiv(6000, 2) = 3000 each, neither caps (space 5000 >= 3000).
|
||||
$result = $engine->execute();
|
||||
|
||||
self::assertSame(3000, $this->amountFor($result, $a));
|
||||
self::assertSame(3000, $this->amountFor($result, $b));
|
||||
self::assertSame(6000, $result->getTotalAllocated());
|
||||
self::assertSame(0, $result->getUnallocated());
|
||||
}
|
||||
|
||||
// --- H. Even split with remainder cent -------------------------------
|
||||
|
||||
public function testEvenSplitGivesRemainderCentToFirstMemberInIterationOrder(): void
|
||||
{
|
||||
$scenario = $this->makeScenario();
|
||||
|
||||
$a = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 1, allocationValue: 100000, name: 'A');
|
||||
$b = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 1, allocationValue: 100000, name: 'B');
|
||||
|
||||
$engine = new AllocateIncome(new ArrayCollection([$a, $b]), 1001);
|
||||
// even share = intdiv(1001, 2) = 500, remainder = 1 -> first member (A) gets +1.
|
||||
$result = $engine->execute();
|
||||
|
||||
self::assertSame(501, $this->amountFor($result, $a));
|
||||
self::assertSame(500, $this->amountFor($result, $b));
|
||||
self::assertSame(1001, $result->getTotalAllocated());
|
||||
self::assertSame(0, $result->getUnallocated());
|
||||
}
|
||||
|
||||
// --- I. Redistribute when a peer caps mid-tier -----------------------
|
||||
|
||||
public function testSurplusFromCappedPeerRedistributesWithinTier(): void
|
||||
{
|
||||
$scenario = $this->makeScenario();
|
||||
|
||||
$a = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 1, allocationValue: 100, name: 'A');
|
||||
$b = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 1, allocationValue: 5000, name: 'B');
|
||||
|
||||
$engine = new AllocateIncome(new ArrayCollection([$a, $b]), 1000);
|
||||
// Round 1: even share = intdiv(1000, 2) = 500 each. A's space is only 100
|
||||
// (its cap) so it caps at 100 and is removed from the round; its surplus of
|
||||
// 400 flows back into the pot. Round 2: remaining = 900 for B alone -> B's
|
||||
// space (5000) covers it -> B takes all 900.
|
||||
$result = $engine->execute();
|
||||
|
||||
self::assertSame(100, $this->amountFor($result, $a));
|
||||
self::assertSame(900, $this->amountFor($result, $b));
|
||||
self::assertSame(1000, $result->getTotalAllocated());
|
||||
self::assertSame(0, $result->getUnallocated());
|
||||
}
|
||||
|
||||
// --- J. Leftover flows to next tier only after current tier caps ----
|
||||
|
||||
public function testLeftoverAfterTierCapsFlowsToNextTier(): void
|
||||
{
|
||||
$scenario = $this->makeScenario();
|
||||
|
||||
$p1 = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 1, allocationValue: 3000, name: 'P1');
|
||||
$p2 = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 2, allocationValue: 5000, name: 'P2');
|
||||
|
||||
$engine = new AllocateIncome(new ArrayCollection([$p1, $p2]), 7000);
|
||||
// Tier 1 (p1 alone) fully caps at 3000. Remainder 4000 flows to tier 2 (p2),
|
||||
// which has space for all of it (cap 5000 >= 4000).
|
||||
$result = $engine->execute();
|
||||
|
||||
self::assertSame(3000, $this->amountFor($result, $p1));
|
||||
self::assertSame(4000, $this->amountFor($result, $p2));
|
||||
self::assertSame(7000, $result->getTotalAllocated());
|
||||
self::assertSame(0, $result->getUnallocated());
|
||||
}
|
||||
|
||||
// --- K. Overflow sweeps remainder ------------------------------------
|
||||
|
||||
public function testOverflowBucketSweepsRemainderAfterAllCapsAreFilled(): void
|
||||
{
|
||||
$scenario = $this->makeScenario();
|
||||
|
||||
$need = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 1, allocationValue: 10000, name: 'Need');
|
||||
$overflow = $this->makeOverflowBucket($scenario, priority: 2, name: 'Overflow');
|
||||
|
||||
$engine = new AllocateIncome(new ArrayCollection([$need, $overflow]), 15000);
|
||||
$result = $engine->execute();
|
||||
|
||||
self::assertSame(10000, $this->amountFor($result, $need));
|
||||
self::assertSame(5000, $this->amountFor($result, $overflow));
|
||||
self::assertSame(15000, $result->getTotalAllocated());
|
||||
self::assertSame(0, $result->getUnallocated());
|
||||
}
|
||||
|
||||
// --- L. No overflow bucket -> remainder unallocated ------------------
|
||||
|
||||
public function testRemainderIsUnallocatedWithoutAnOverflowBucket(): void
|
||||
{
|
||||
$scenario = $this->makeScenario();
|
||||
|
||||
$need = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 1, allocationValue: 10000, name: 'Need');
|
||||
|
||||
$engine = new AllocateIncome(new ArrayCollection([$need]), 15000);
|
||||
$result = $engine->execute();
|
||||
|
||||
self::assertSame(10000, $this->amountFor($result, $need));
|
||||
self::assertSame(10000, $result->getTotalAllocated());
|
||||
self::assertSame(5000, $result->getUnallocated());
|
||||
}
|
||||
|
||||
// --- M. startingAmount reduces space ---------------------------------
|
||||
|
||||
public function testStartingAmountReducesAvailableSpace(): void
|
||||
{
|
||||
$scenario = $this->makeScenario();
|
||||
|
||||
$need = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 1, allocationValue: 50000, name: 'Need', startingAmount: 45000);
|
||||
|
||||
$engine = new AllocateIncome(new ArrayCollection([$need]), 20000);
|
||||
// space = max(0, 50000 - 45000 - 0) = 5000. No overflow bucket -> rest unallocated.
|
||||
$result = $engine->execute();
|
||||
|
||||
self::assertSame(5000, $this->amountFor($result, $need));
|
||||
self::assertSame(5000, $result->getTotalAllocated());
|
||||
self::assertSame(15000, $result->getUnallocated());
|
||||
}
|
||||
|
||||
// --- N. Rounding pinned -----------------------------------------------
|
||||
|
||||
public function testBufferedCapUsesRoundedIntegerCents(): void
|
||||
{
|
||||
$scenario = $this->makeScenario();
|
||||
|
||||
$need = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 1, allocationValue: 10001, name: 'Need', bufferMultiplier: '0.33');
|
||||
|
||||
$engine = new AllocateIncome(new ArrayCollection([$need]), 20000);
|
||||
// bufferedCap = (int) round(10001 * 1.33) = (int) round(13301.33) = 13301.
|
||||
$result = $engine->execute();
|
||||
|
||||
self::assertSame(13301, $this->amountFor($result, $need));
|
||||
self::assertIsInt($this->amountFor($result, $need));
|
||||
self::assertSame(13301, $result->getTotalAllocated());
|
||||
self::assertSame(6699, $result->getUnallocated());
|
||||
}
|
||||
|
||||
// --- O. Priority tiers within a phase (gap coverage) -----------------
|
||||
|
||||
public function testHigherPriorityTierFillsBeforeLowerPriorityTier(): void
|
||||
{
|
||||
$scenario = $this->makeScenario();
|
||||
|
||||
$p1 = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 1, allocationValue: 10000, name: 'P1');
|
||||
$p2 = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 2, allocationValue: 10000, name: 'P2');
|
||||
$p3 = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 3, allocationValue: 10000, name: 'P3');
|
||||
|
||||
$engine = new AllocateIncome(new ArrayCollection([$p1, $p2, $p3]), 15000);
|
||||
// Tier 1 (priority 1) fills fully to its cap (10000), leaving 5000.
|
||||
// Tier 2 (priority 2) takes the remaining 5000 (under its 10000 cap).
|
||||
// Tier 3 (priority 3) gets nothing -- nothing left to flow to it.
|
||||
$result = $engine->execute();
|
||||
|
||||
self::assertSame(10000, $this->amountFor($result, $p1));
|
||||
self::assertSame(5000, $this->amountFor($result, $p2));
|
||||
self::assertSame(0, $this->amountFor($result, $p3));
|
||||
self::assertSame(15000, $result->getTotalAllocated());
|
||||
self::assertSame(0, $result->getUnallocated());
|
||||
}
|
||||
|
||||
public function testSamePriorityBucketsShareOneTierEvenly(): void
|
||||
{
|
||||
$scenario = $this->makeScenario();
|
||||
|
||||
$a = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 1, allocationValue: 10000, name: 'A');
|
||||
$b = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 1, allocationValue: 10000, name: 'B');
|
||||
$c = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 2, allocationValue: 10000, name: 'C');
|
||||
|
||||
$engine = new AllocateIncome(new ArrayCollection([$a, $b, $c]), 12000);
|
||||
// Tier 1 (A and B, same priority) splits the 12000 evenly: 6000 each,
|
||||
// both well under their 10000 cap -- tier 1 absorbs the whole amount.
|
||||
// Tier 2 (C) gets nothing left over.
|
||||
$result = $engine->execute();
|
||||
|
||||
self::assertSame(6000, $this->amountFor($result, $a));
|
||||
self::assertSame(6000, $this->amountFor($result, $b));
|
||||
self::assertSame(0, $this->amountFor($result, $c));
|
||||
self::assertSame(12000, $result->getTotalAllocated());
|
||||
self::assertSame(0, $result->getUnallocated());
|
||||
}
|
||||
|
||||
public function testLowerPriorityTierGetsRemainderAfterHigherTierCaps(): void
|
||||
{
|
||||
$scenario = $this->makeScenario();
|
||||
|
||||
$p1 = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 1, allocationValue: 4000, name: 'P1');
|
||||
$p2 = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 2, allocationValue: 10000, name: 'P2');
|
||||
|
||||
$engine = new AllocateIncome(new ArrayCollection([$p1, $p2]), 9000);
|
||||
// Tier 1 (P1) caps out at 4000. Remaining 5000 flows to tier 2 (P2),
|
||||
// which has room (cap 10000 >= 5000).
|
||||
$result = $engine->execute();
|
||||
|
||||
self::assertSame(4000, $this->amountFor($result, $p1));
|
||||
self::assertSame(5000, $this->amountFor($result, $p2));
|
||||
self::assertSame(9000, $result->getTotalAllocated());
|
||||
self::assertSame(0, $result->getUnallocated());
|
||||
}
|
||||
|
||||
public function testPriorityTiersAlsoApplyWithinBufferPhase(): void
|
||||
{
|
||||
$scenario = $this->makeScenario();
|
||||
|
||||
$p1 = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 1, allocationValue: 4000, name: 'P1', bufferMultiplier: '1.00');
|
||||
$p2 = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 2, allocationValue: 4000, name: 'P2', bufferMultiplier: '1.00');
|
||||
|
||||
$engine = new AllocateIncome(new ArrayCollection([$p1, $p2]), 14000);
|
||||
// Base phase, tier 1 (P1) then tier 2 (P2): both fill their base cap of
|
||||
// 4000 (8000 used, 6000 left).
|
||||
// Buffer phase, tier 1 (P1) first: buffered cap = round(4000*2.00) = 8000,
|
||||
// space = 8000-4000 = 4000, takes min(4000, 6000) = 4000 -> P1 = 8000
|
||||
// (2000 left). Tier 2 (P2): buffered cap 8000, space 4000, takes
|
||||
// min(4000, 2000) = 2000 -> P2 = 6000.
|
||||
$result = $engine->execute();
|
||||
|
||||
self::assertSame(8000, $this->amountFor($result, $p1));
|
||||
self::assertSame(6000, $this->amountFor($result, $p2));
|
||||
self::assertSame(14000, $result->getTotalAllocated());
|
||||
self::assertSame(0, $result->getUnallocated());
|
||||
}
|
||||
|
||||
// --- P. Percentage cap must use ORIGINAL income, not the remainder ---
|
||||
|
||||
public function testPercentageBucketCapIsBasedOnOriginalIncomeNotRemaining(): void
|
||||
{
|
||||
$scenario = $this->makeScenario();
|
||||
|
||||
$fixed = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 1, allocationValue: 4000, name: 'Fixed');
|
||||
$pct = $this->makePercentageBucket($scenario, BucketType::NEED, priority: 2, allocationValueBasisPoints: 3000, name: 'Pct');
|
||||
$overflow = $this->makeOverflowBucket($scenario, priority: 3, name: 'Overflow');
|
||||
|
||||
$engine = new AllocateIncome(new ArrayCollection([$fixed, $pct, $overflow]), 10000);
|
||||
// Tier 1 (priority 1, fixed): caps at 4000. Remaining = 6000.
|
||||
// Tier 2 (priority 2, percentage): cap MUST be floor(10000 * 3000 / 10000)
|
||||
// = 3000 -- based on the ORIGINAL income (10000), NOT the dwindled
|
||||
// remaining (6000). It takes 3000. Remaining = 3000.
|
||||
// Overflow sweeps the rest: 3000.
|
||||
$result = $engine->execute();
|
||||
|
||||
self::assertSame(4000, $this->amountFor($result, $fixed));
|
||||
self::assertSame(3000, $this->amountFor($result, $pct));
|
||||
self::assertSame(3000, $this->amountFor($result, $overflow));
|
||||
self::assertSame(10000, $result->getTotalAllocated());
|
||||
self::assertSame(0, $result->getUnallocated());
|
||||
}
|
||||
|
||||
// --- fixtures ---------------------------------------------------------
|
||||
|
||||
private function makeScenario(): Scenario
|
||||
{
|
||||
return (new Scenario())
|
||||
->setName('Household Budget')
|
||||
->setDistributionMode(DistributionMode::PRIORITY);
|
||||
}
|
||||
|
||||
private function makeFixedLimitBucket(
|
||||
Scenario $scenario,
|
||||
BucketType $type,
|
||||
int $priority,
|
||||
int $allocationValue,
|
||||
string $name,
|
||||
string $bufferMultiplier = '0.00',
|
||||
int $startingAmount = 0,
|
||||
): Bucket {
|
||||
return (new Bucket())
|
||||
->setScenario($scenario)
|
||||
->setType($type)
|
||||
->setName($name)
|
||||
->setPriority($priority)
|
||||
->setAllocationType(BucketAllocationType::FIXED_LIMIT)
|
||||
->setAllocationValue($allocationValue)
|
||||
->setStartingAmount($startingAmount)
|
||||
->setBufferMultiplier($bufferMultiplier);
|
||||
}
|
||||
|
||||
private function makePercentageBucket(
|
||||
Scenario $scenario,
|
||||
BucketType $type,
|
||||
int $priority,
|
||||
int $allocationValueBasisPoints,
|
||||
string $name,
|
||||
string $bufferMultiplier = '0.00',
|
||||
int $startingAmount = 0,
|
||||
): Bucket {
|
||||
return (new Bucket())
|
||||
->setScenario($scenario)
|
||||
->setType($type)
|
||||
->setName($name)
|
||||
->setPriority($priority)
|
||||
->setAllocationType(BucketAllocationType::PERCENTAGE)
|
||||
->setAllocationValue($allocationValueBasisPoints)
|
||||
->setStartingAmount($startingAmount)
|
||||
->setBufferMultiplier($bufferMultiplier);
|
||||
}
|
||||
|
||||
private function makeOverflowBucket(Scenario $scenario, int $priority, string $name): Bucket
|
||||
{
|
||||
return (new Bucket())
|
||||
->setScenario($scenario)
|
||||
->setType(BucketType::OVERFLOW)
|
||||
->setName($name)
|
||||
->setPriority($priority)
|
||||
->setAllocationType(BucketAllocationType::UNLIMITED)
|
||||
->setAllocationValue(null)
|
||||
->setStartingAmount(0)
|
||||
->setBufferMultiplier('0.00');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Result $result
|
||||
*/
|
||||
private function amountFor($result, Bucket $bucket): int
|
||||
{
|
||||
foreach ($result->getAllocations() as $allocation) {
|
||||
if ($allocation['bucket'] === $bucket) {
|
||||
return $allocation['amount'];
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
511
tests/Service/Allocation/BucketRoomCalculatorTest.php
Normal file
511
tests/Service/Allocation/BucketRoomCalculatorTest.php
Normal file
|
|
@ -0,0 +1,511 @@
|
|||
<?php
|
||||
|
||||
namespace App\Tests\Service\Allocation;
|
||||
|
||||
use App\Entity\Bucket;
|
||||
use App\Entity\Scenario;
|
||||
use App\Enum\BucketAllocationType;
|
||||
use App\Enum\BucketType;
|
||||
use App\Enum\FillStage;
|
||||
use App\Service\Allocation\BucketRoomCalculator;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Pins BucketRoomCalculator::roomFor(Bucket $bucket, FillStage $stage, int $income, int $alreadyAllocated): int
|
||||
* -- pure, per-bucket "how much more room is left" calculation.
|
||||
*
|
||||
* room = max(0, cap - startingAmount - alreadyAllocated)
|
||||
*
|
||||
* The cap (internal to roomFor) by allocationType x capKind:
|
||||
* FIXED_LIMIT, base -> cap = allocationValue
|
||||
* FIXED_LIMIT, buffer -> cap = (int) floor(allocationValue * (1 + (float) bufferMultiplier))
|
||||
* PERCENTAGE, base -> cap = (int) floor(income * allocationValue / 10000) (basis points)
|
||||
* PERCENTAGE, buffer -> cap = 0 (no buffer concept for percentage)
|
||||
* UNLIMITED, base or buffer -> room = PHP_INT_MAX (infinite capacity; the engine
|
||||
* partitions overflow buckets out of the phased fill, so this is never misused)
|
||||
*
|
||||
* roomFor TOLERATES degenerate inputs and never throws: null allocationValue is treated
|
||||
* as 0, and any over-subscription (negative room) clamps to 0 via max(0, ...).
|
||||
*/
|
||||
#[CoversClass(BucketRoomCalculator::class)]
|
||||
final class BucketRoomCalculatorTest extends TestCase
|
||||
{
|
||||
// --- fixed_limit, base ------------------------------------------------
|
||||
|
||||
public function testFixedLimitBaseFreshBucketHasFullRoom(): void
|
||||
{
|
||||
$bucket = $this->makeFixedLimitBucket(allocationValue: 5000, startingAmount: 0);
|
||||
|
||||
$room = (new BucketRoomCalculator())->roomFor($bucket, FillStage::Base, income: 10000, alreadyAllocated: 0);
|
||||
|
||||
self::assertSame(5000, $room);
|
||||
}
|
||||
|
||||
public function testFixedLimitBaseStartingAmountReducesRoom(): void
|
||||
{
|
||||
$bucket = $this->makeFixedLimitBucket(allocationValue: 5000, startingAmount: 2000);
|
||||
|
||||
$room = (new BucketRoomCalculator())->roomFor($bucket, FillStage::Base, income: 10000, alreadyAllocated: 0);
|
||||
|
||||
self::assertSame(3000, $room);
|
||||
}
|
||||
|
||||
public function testFixedLimitBaseAlreadyAllocatedReducesRoom(): void
|
||||
{
|
||||
$bucket = $this->makeFixedLimitBucket(allocationValue: 5000, startingAmount: 0);
|
||||
|
||||
$room = (new BucketRoomCalculator())->roomFor(
|
||||
$bucket,
|
||||
FillStage::Base,
|
||||
income: 10000,
|
||||
alreadyAllocated: 1500
|
||||
);
|
||||
|
||||
self::assertSame(3500, $room);
|
||||
}
|
||||
|
||||
public function testFixedLimitBaseStartingAmountAndAlreadyAllocatedBothReduceRoom(): void
|
||||
{
|
||||
$bucket = $this->makeFixedLimitBucket(allocationValue: 5000, startingAmount: 1000);
|
||||
|
||||
$room = (new BucketRoomCalculator())->roomFor($bucket, FillStage::Base, income: 10000, alreadyAllocated: 1000);
|
||||
|
||||
self::assertSame(3000, $room);
|
||||
}
|
||||
|
||||
public function testFixedLimitBaseOverSubscribedNeverGoesNegative(): void
|
||||
{
|
||||
$bucket = $this->makeFixedLimitBucket(allocationValue: 5000, startingAmount: 4000);
|
||||
|
||||
$room = (new BucketRoomCalculator())->roomFor(
|
||||
$bucket,
|
||||
FillStage::Base,
|
||||
income: 10000,
|
||||
alreadyAllocated: 2000
|
||||
);
|
||||
|
||||
self::assertSame(0, $room);
|
||||
}
|
||||
|
||||
public function testFixedLimitBaseOverSubscribedByStartingAmountAloneIsZero(): void
|
||||
{
|
||||
$bucket = $this->makeFixedLimitBucket(allocationValue: 5000, startingAmount: 6000);
|
||||
|
||||
$room = (new BucketRoomCalculator())->roomFor($bucket, FillStage::Base, income: 10000, alreadyAllocated: 0);
|
||||
|
||||
self::assertSame(0, $room);
|
||||
}
|
||||
|
||||
public function testFixedLimitBaseWithNullAllocationValueIsZero(): void
|
||||
{
|
||||
$bucket = (new Bucket())
|
||||
->setScenario(new Scenario())
|
||||
->setType(BucketType::NEED)
|
||||
->setName('Bucket')
|
||||
->setPriority(1)
|
||||
->setAllocationType(BucketAllocationType::FIXED_LIMIT)
|
||||
->setAllocationValue(null)
|
||||
->setStartingAmount(0)
|
||||
->setBufferMultiplier('0.00');
|
||||
|
||||
$room = (new BucketRoomCalculator())->roomFor($bucket, FillStage::Base, income: 10000, alreadyAllocated: 0);
|
||||
|
||||
self::assertSame(0, $room);
|
||||
}
|
||||
|
||||
public function testIncomeIsIgnoredForFixedLimitBuckets(): void
|
||||
{
|
||||
$bucket = $this->makeFixedLimitBucket(allocationValue: 5000, startingAmount: 0);
|
||||
$calculator = new BucketRoomCalculator();
|
||||
|
||||
$roomWithNoIncome = $calculator->roomFor($bucket, FillStage::Base, income: 0, alreadyAllocated: 0);
|
||||
$roomWithHugeIncome = $calculator->roomFor($bucket, FillStage::Base, income: 999999, alreadyAllocated: 0);
|
||||
|
||||
self::assertSame(5000, $roomWithNoIncome);
|
||||
self::assertSame(5000, $roomWithHugeIncome);
|
||||
}
|
||||
|
||||
public function testWantTypeBehavesIdenticallyToNeedForRoom(): void
|
||||
{
|
||||
$bucket = (new Bucket())
|
||||
->setScenario(new Scenario())
|
||||
->setType(BucketType::WANT)
|
||||
->setName('Bucket')
|
||||
->setPriority(1)
|
||||
->setAllocationType(BucketAllocationType::FIXED_LIMIT)
|
||||
->setAllocationValue(5000)
|
||||
->setStartingAmount(0)
|
||||
->setBufferMultiplier('0.00');
|
||||
|
||||
$room = (new BucketRoomCalculator())->roomFor($bucket, FillStage::Base, income: 10000, alreadyAllocated: 0);
|
||||
|
||||
self::assertSame(5000, $room);
|
||||
}
|
||||
|
||||
// --- fixed_limit, base vs buffer (stage independence) -----------------
|
||||
|
||||
public function testFixedLimitBaseIgnoresBufferMultiplier(): void
|
||||
{
|
||||
$bucket = $this->makeFixedLimitBucket(
|
||||
allocationValue: 5000,
|
||||
startingAmount: 0,
|
||||
bufferMultiplier: '0.50'
|
||||
);
|
||||
|
||||
// Base stage must use allocationValue ONLY -- the buffer multiplier never
|
||||
// applies outside the Buffer stage, even when it's non-zero.
|
||||
$room = (new BucketRoomCalculator())->roomFor(
|
||||
$bucket,
|
||||
FillStage::Base,
|
||||
income: 10000,
|
||||
alreadyAllocated: 0
|
||||
);
|
||||
|
||||
self::assertSame(5000, $room);
|
||||
}
|
||||
|
||||
// --- fixed_limit, buffer ----------------------------------------------
|
||||
|
||||
public function testFixedLimitBufferAppliesTheMultiplier(): void
|
||||
{
|
||||
$bucket = $this->makeFixedLimitBucket(allocationValue: 40000, startingAmount: 0, bufferMultiplier: '0.50');
|
||||
|
||||
// cap = round(40000 * 1.50) = 60000.
|
||||
$room = (new BucketRoomCalculator())->roomFor($bucket, FillStage::Buffer, income: 10000, alreadyAllocated: 0);
|
||||
|
||||
self::assertSame(60000, $room);
|
||||
}
|
||||
|
||||
public function testFixedLimitBufferFloorsToInteger(): void
|
||||
{
|
||||
$bucket = $this->makeFixedLimitBucket(allocationValue: 10001, startingAmount: 0, bufferMultiplier: '0.33');
|
||||
|
||||
// cap = (int) floor(10001 * 1.33) = (int) floor(13301.33) = 13301.
|
||||
$room = (new BucketRoomCalculator())->roomFor($bucket, FillStage::Buffer, income: 10000, alreadyAllocated: 0);
|
||||
|
||||
self::assertSame(13301, $room);
|
||||
}
|
||||
|
||||
public function testFixedLimitBufferWithZeroMultiplierEqualsBaseCap(): void
|
||||
{
|
||||
$bucket = $this->makeFixedLimitBucket(allocationValue: 5000, startingAmount: 0, bufferMultiplier: '0.00');
|
||||
|
||||
$room = (new BucketRoomCalculator())->roomFor($bucket, FillStage::Buffer, income: 10000, alreadyAllocated: 0);
|
||||
|
||||
self::assertSame(5000, $room);
|
||||
}
|
||||
|
||||
public function testFixedLimitBufferRoomIsTheGapAboveAlreadyAllocatedBase(): void
|
||||
{
|
||||
$bucket = $this->makeFixedLimitBucket(
|
||||
allocationValue: 40000,
|
||||
startingAmount: 0,
|
||||
bufferMultiplier: '0.50'
|
||||
);
|
||||
|
||||
// cap = round(40000 * 1.50) = 60000. Base phase already gave it 40000 ->
|
||||
// buffer room is the gap between base cap and buffer cap: 60000 - 40000 = 20000.
|
||||
$room = (new BucketRoomCalculator())->roomFor(
|
||||
$bucket,
|
||||
FillStage::Buffer,
|
||||
income: 10000,
|
||||
alreadyAllocated: 40000
|
||||
);
|
||||
|
||||
self::assertSame(20000, $room);
|
||||
}
|
||||
|
||||
public function testFixedLimitBufferFloorsFractionalCap(): void
|
||||
{
|
||||
$bucket = $this->makeFixedLimitBucket(allocationValue: 5001, startingAmount: 0, bufferMultiplier: '0.50');
|
||||
|
||||
// raw cap = 5001 * 1.50 = 7501.5 -- (int) floor(7501.5) = 7501 (caps floor,
|
||||
// never round up -- leftover sub-cent stays unallocated rather than overfilling).
|
||||
$room = (new BucketRoomCalculator())->roomFor($bucket, FillStage::Buffer, income: 10000, alreadyAllocated: 0);
|
||||
|
||||
self::assertSame(7501, $room);
|
||||
}
|
||||
|
||||
public function testFixedLimitBufferRoomWhenAlreadyAllocatedExceedsBaseCap(): void
|
||||
{
|
||||
$bucket = $this->makeFixedLimitBucket(
|
||||
allocationValue: 1000,
|
||||
startingAmount: 0,
|
||||
bufferMultiplier: '0.50'
|
||||
);
|
||||
|
||||
// buffered cap = (int) floor(1000 * 1.50) = 1500.
|
||||
// alreadyAllocated (1200) exceeds the BASE cap (1000) but is below the BUFFERED
|
||||
// cap (1500); buffer-stage room must be the gap to the buffered cap (300), not 0.
|
||||
// Regression test for the early-return-before-buffer bug.
|
||||
$room = (new BucketRoomCalculator())->roomFor(
|
||||
$bucket,
|
||||
FillStage::Buffer,
|
||||
income: 10000,
|
||||
alreadyAllocated: 1200
|
||||
);
|
||||
|
||||
self::assertSame(300, $room);
|
||||
}
|
||||
|
||||
public function testFixedLimitBufferOverSubscribedIsZero(): void
|
||||
{
|
||||
$bucket = $this->makeFixedLimitBucket(allocationValue: 40000, startingAmount: 0, bufferMultiplier: '0.50');
|
||||
|
||||
// cap = round(40000 * 1.50) = 60000, alreadyAllocated 70000 exceeds it.
|
||||
$room = (new BucketRoomCalculator())->roomFor(
|
||||
$bucket,
|
||||
FillStage::Buffer,
|
||||
income: 10000,
|
||||
alreadyAllocated: 70000
|
||||
);
|
||||
|
||||
self::assertSame(0, $room);
|
||||
}
|
||||
|
||||
// --- percentage, base ---------------------------------------------------
|
||||
|
||||
public function testPercentageBaseIsProportionalToIncome(): void
|
||||
{
|
||||
// allocationValue 3000 basis points = 30%.
|
||||
$bucket = $this->makePercentageBucket(allocationValueBasisPoints: 3000, startingAmount: 0);
|
||||
|
||||
// cap = round(10000 * 3000 / 10000) = 3000.
|
||||
$room = (new BucketRoomCalculator())->roomFor($bucket, FillStage::Base, income: 10000, alreadyAllocated: 0);
|
||||
|
||||
self::assertSame(3000, $room);
|
||||
}
|
||||
|
||||
public function testPercentageBaseFloorsFractionalResult(): void
|
||||
{
|
||||
$bucket = $this->makePercentageBucket(allocationValueBasisPoints: 3333, startingAmount: 0);
|
||||
|
||||
// cap = (int) floor(10001 * 3333 / 10000) = (int) floor(3333.33) = 3333.
|
||||
$room = (new BucketRoomCalculator())->roomFor($bucket, FillStage::Base, income: 10001, alreadyAllocated: 0);
|
||||
|
||||
self::assertSame(3333, $room);
|
||||
}
|
||||
|
||||
public function testPercentageBaseFloorsFractionalCap(): void
|
||||
{
|
||||
$bucket = $this->makePercentageBucket(
|
||||
allocationValueBasisPoints: 5000,
|
||||
startingAmount: 0
|
||||
);
|
||||
|
||||
// cap = 7501 * 5000 / 10000 = 3750.5 -- floor = 3750 (round would wrongly give
|
||||
// 3751; caps floor).
|
||||
$room = (new BucketRoomCalculator())->roomFor(
|
||||
$bucket,
|
||||
FillStage::Base,
|
||||
income: 7501,
|
||||
alreadyAllocated: 0
|
||||
);
|
||||
|
||||
self::assertSame(3750, $room);
|
||||
}
|
||||
|
||||
public function testPercentageBaseStartingAmountReducesRoom(): void
|
||||
{
|
||||
$bucket = $this->makePercentageBucket(
|
||||
allocationValueBasisPoints: 3000,
|
||||
startingAmount: 1000
|
||||
);
|
||||
|
||||
// cap = round(10000 * 3000 / 10000) = 3000.
|
||||
$room = (new BucketRoomCalculator())->roomFor(
|
||||
$bucket,
|
||||
FillStage::Base,
|
||||
income: 10000,
|
||||
alreadyAllocated: 0
|
||||
);
|
||||
|
||||
self::assertSame(2000, $room);
|
||||
}
|
||||
|
||||
public function testPercentageOverSubscribedByStartingAmountIsZero(): void
|
||||
{
|
||||
// allocationValue 3000 basis points = 30%, cap = round(10000 * 3000 / 10000) = 3000.
|
||||
$bucket = $this->makePercentageBucket(
|
||||
allocationValueBasisPoints: 3000,
|
||||
startingAmount: 5000
|
||||
);
|
||||
|
||||
$room = (new BucketRoomCalculator())->roomFor(
|
||||
$bucket,
|
||||
FillStage::Base,
|
||||
income: 10000,
|
||||
alreadyAllocated: 0
|
||||
);
|
||||
|
||||
self::assertSame(0, $room);
|
||||
}
|
||||
|
||||
public function testPercentageOverSubscribedByAlreadyAllocatedIsZero(): void
|
||||
{
|
||||
// cap = round(10000 * 3000 / 10000) = 3000, alreadyAllocated 4000 exceeds it.
|
||||
$bucket = $this->makePercentageBucket(allocationValueBasisPoints: 3000, startingAmount: 0);
|
||||
|
||||
$room = (new BucketRoomCalculator())->roomFor(
|
||||
$bucket,
|
||||
FillStage::Base,
|
||||
income: 10000,
|
||||
alreadyAllocated: 4000
|
||||
);
|
||||
|
||||
self::assertSame(0, $room);
|
||||
}
|
||||
|
||||
// --- percentage, buffer --------------------------------------------------
|
||||
|
||||
public function testPercentageBucketHasNoBufferRoom(): void
|
||||
{
|
||||
$bucket = $this->makePercentageBucket(allocationValueBasisPoints: 3000, startingAmount: 0);
|
||||
|
||||
$room = (new BucketRoomCalculator())->roomFor(
|
||||
$bucket,
|
||||
FillStage::Buffer,
|
||||
income: 10000,
|
||||
alreadyAllocated: 0
|
||||
);
|
||||
|
||||
self::assertSame(0, $room);
|
||||
}
|
||||
|
||||
public function testPercentageBucketHasNoBufferRoomAfterBaseRoundFilledIt(): void
|
||||
{
|
||||
// allocationValue 3000 basis points = 30%. In a real engine run the base round
|
||||
// would already have allocated its 30% cut before the buffer round runs.
|
||||
$bucket = $this->makePercentageBucket(allocationValueBasisPoints: 3000, startingAmount: 0);
|
||||
|
||||
$room = (new BucketRoomCalculator())->roomFor(
|
||||
$bucket,
|
||||
FillStage::Buffer,
|
||||
income: 10000,
|
||||
alreadyAllocated: 3000
|
||||
);
|
||||
|
||||
self::assertSame(0, $room);
|
||||
}
|
||||
|
||||
public function testPercentageBaseRoomIsZeroWhenIncomeIsZero(): void
|
||||
{
|
||||
$bucket = $this->makePercentageBucket(allocationValueBasisPoints: 3000, startingAmount: 0);
|
||||
|
||||
// cap = round(0 * 3000 / 10000) = 0.
|
||||
$room = (new BucketRoomCalculator())->roomFor($bucket, FillStage::Base, income: 0, alreadyAllocated: 0);
|
||||
|
||||
self::assertSame(0, $room);
|
||||
}
|
||||
|
||||
public function testPercentageBaseWithNullAllocationValueIsZero(): void
|
||||
{
|
||||
// Mirrors testFixedLimitBaseWithNullAllocationValueIsZero: roomFor must tolerate
|
||||
// a null allocationValue on the PERCENTAGE path too (null basis points = 0% = no
|
||||
// room), not throw when multiplying it against income.
|
||||
$bucket = (new Bucket())
|
||||
->setScenario(new Scenario())
|
||||
->setType(BucketType::NEED)
|
||||
->setName('Bucket')
|
||||
->setPriority(1)
|
||||
->setAllocationType(BucketAllocationType::PERCENTAGE)
|
||||
->setAllocationValue(null)
|
||||
->setStartingAmount(0)
|
||||
->setBufferMultiplier('0.00');
|
||||
|
||||
$room = (new BucketRoomCalculator())->roomFor($bucket, FillStage::Base, income: 10000, alreadyAllocated: 0);
|
||||
|
||||
self::assertSame(0, $room);
|
||||
}
|
||||
|
||||
public function testPercentageBufferWithNullAllocationValueIsZero(): void
|
||||
{
|
||||
// Same null-allocationValue bucket, but the Buffer stage -- percentage buffer
|
||||
// room is always 0 regardless of allocationValue, so this confirms the null
|
||||
// case doesn't throw in the buffer path either.
|
||||
$bucket = (new Bucket())
|
||||
->setScenario(new Scenario())
|
||||
->setType(BucketType::NEED)
|
||||
->setName('Bucket')
|
||||
->setPriority(1)
|
||||
->setAllocationType(BucketAllocationType::PERCENTAGE)
|
||||
->setAllocationValue(null)
|
||||
->setStartingAmount(0)
|
||||
->setBufferMultiplier('0.00');
|
||||
|
||||
$room = (new BucketRoomCalculator())->roomFor($bucket, FillStage::Buffer, income: 10000, alreadyAllocated: 0);
|
||||
|
||||
self::assertSame(0, $room);
|
||||
}
|
||||
|
||||
// --- unlimited (overflow) -------------------------------------------------
|
||||
|
||||
public function testUnlimitedBucketHasInfiniteBaseRoom(): void
|
||||
{
|
||||
$bucket = $this->makeOverflowBucket();
|
||||
|
||||
// Overflow/unlimited capacity is infinite; roomFor reports the truth. The
|
||||
// engine partitions overflow out of the phased fill so this is never misused.
|
||||
$room = (new BucketRoomCalculator())->roomFor(
|
||||
$bucket,
|
||||
FillStage::Base,
|
||||
income: 10000,
|
||||
alreadyAllocated: 0
|
||||
);
|
||||
|
||||
self::assertSame(\PHP_INT_MAX, $room);
|
||||
}
|
||||
|
||||
public function testUnlimitedBucketHasInfiniteBufferRoom(): void
|
||||
{
|
||||
$bucket = $this->makeOverflowBucket();
|
||||
|
||||
// Overflow/unlimited capacity is infinite; roomFor reports the truth. The
|
||||
// engine partitions overflow out of the phased fill so this is never misused.
|
||||
$room = (new BucketRoomCalculator())->roomFor($bucket, FillStage::Buffer, income: 10000, alreadyAllocated: 0);
|
||||
|
||||
self::assertSame(\PHP_INT_MAX, $room);
|
||||
}
|
||||
|
||||
// --- fixtures ---------------------------------------------------------
|
||||
|
||||
private function makeFixedLimitBucket(int $allocationValue, int $startingAmount, string $bufferMultiplier = '0.00'): Bucket
|
||||
{
|
||||
return (new Bucket())
|
||||
->setScenario(new Scenario())
|
||||
->setType(BucketType::NEED)
|
||||
->setName('Bucket')
|
||||
->setPriority(1)
|
||||
->setAllocationType(BucketAllocationType::FIXED_LIMIT)
|
||||
->setAllocationValue($allocationValue)
|
||||
->setStartingAmount($startingAmount)
|
||||
->setBufferMultiplier($bufferMultiplier);
|
||||
}
|
||||
|
||||
private function makePercentageBucket(int $allocationValueBasisPoints, int $startingAmount): Bucket
|
||||
{
|
||||
return (new Bucket())
|
||||
->setScenario(new Scenario())
|
||||
->setType(BucketType::NEED)
|
||||
->setName('Bucket')
|
||||
->setPriority(1)
|
||||
->setAllocationType(BucketAllocationType::PERCENTAGE)
|
||||
->setAllocationValue($allocationValueBasisPoints)
|
||||
->setStartingAmount($startingAmount)
|
||||
->setBufferMultiplier('0.00');
|
||||
}
|
||||
|
||||
private function makeOverflowBucket(): Bucket
|
||||
{
|
||||
return (new Bucket())
|
||||
->setScenario(new Scenario())
|
||||
->setType(BucketType::OVERFLOW)
|
||||
->setName('Overflow')
|
||||
->setPriority(1)
|
||||
->setAllocationType(BucketAllocationType::UNLIMITED)
|
||||
->setAllocationValue(null)
|
||||
->setStartingAmount(0)
|
||||
->setBufferMultiplier('0.00');
|
||||
}
|
||||
}
|
||||
125
tests/Service/Allocation/EvenSplitterTest.php
Normal file
125
tests/Service/Allocation/EvenSplitterTest.php
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
<?php
|
||||
|
||||
namespace App\Tests\Service\Allocation;
|
||||
|
||||
use App\Service\Allocation\EvenSplitter;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Pins EvenSplitter::split() -- pure integer even-split-with-redistribute.
|
||||
* ZERO bucket/domain knowledge: just int $pool + list<int> $caps -> list<int>
|
||||
* amounts, positionally aligned with $caps. The caller maps buckets<->positions
|
||||
* outside this class entirely.
|
||||
*
|
||||
* Algorithm pinned (all intdiv, no floats), per round while pool > 0 and at
|
||||
* least one slot still has remaining room:
|
||||
* - eligible = slot indices where (caps[i] - givenSoFar[i]) > 0
|
||||
* - share = intdiv(pool, count(eligible)); remainder = pool % count(eligible)
|
||||
* - the FIRST `remainder` eligible slots (in order) get +1 on top of share
|
||||
* - each eligible slot takes min(itsShare, itsRemainingRoom)
|
||||
* - a slot that took LESS than its share is now capped (drops out next round)
|
||||
* - stops when a round places nothing, or no slot has remaining room
|
||||
*
|
||||
* Signature pinned:
|
||||
* EvenSplitter::split(int $pool, array $caps): array (list<int>, same
|
||||
* length/order as $caps; sum of result <= $pool).
|
||||
*/
|
||||
final class EvenSplitterTest extends TestCase
|
||||
{
|
||||
public function testFullyAbsorbedByBothSlots(): void
|
||||
{
|
||||
$result = (new EvenSplitter())->split(10000, [5000, 5000]);
|
||||
|
||||
self::assertSame([5000, 5000], $result);
|
||||
}
|
||||
|
||||
public function testUnderfundedWithEqualCapsSplitsEvenly(): void
|
||||
{
|
||||
$result = (new EvenSplitter())->split(6000, [5000, 5000]);
|
||||
|
||||
self::assertSame([3000, 3000], $result);
|
||||
}
|
||||
|
||||
public function testRemainderCentGoesToFirstSlot(): void
|
||||
{
|
||||
// intdiv(1001, 2) = 500, remainder 1 -> first slot gets +1.
|
||||
$result = (new EvenSplitter())->split(1001, [100000, 100000]);
|
||||
|
||||
self::assertSame([501, 500], $result);
|
||||
}
|
||||
|
||||
public function testRemainderOfTwoCentsGoesToFirstTwoSlots(): void
|
||||
{
|
||||
// intdiv(1001, 3) = 333, remainder 2 -> first TWO slots get +1.
|
||||
$result = (new EvenSplitter())->split(1001, [100000, 100000, 100000]);
|
||||
|
||||
self::assertSame([334, 334, 333], $result);
|
||||
}
|
||||
|
||||
public function testRedistributesSurplusFromCappedSlotToItsPeer(): void
|
||||
{
|
||||
// Round 1: share = intdiv(1000, 2) = 500 each. Slot0's cap is only 100 ->
|
||||
// caps at 100, drops out; surplus carries to round 2.
|
||||
// Round 2: only slot1 eligible, remaining = 1000 - 100 - 500 = 400 ->
|
||||
// takes min(400, 4500) = 400 -> slot1 total = 900.
|
||||
$result = (new EvenSplitter())->split(1000, [100, 5000]);
|
||||
|
||||
self::assertSame([100, 900], $result);
|
||||
}
|
||||
|
||||
public function testAllSlotsCappedLeavesPoolLeftoverUnplaced(): void
|
||||
{
|
||||
$result = (new EvenSplitter())->split(1000, [100, 100]);
|
||||
|
||||
self::assertSame([100, 100], $result);
|
||||
}
|
||||
|
||||
public function testAllSlotsCappedSumIsLessThanPool(): void
|
||||
{
|
||||
$result = (new EvenSplitter())->split(1000, [100, 100]);
|
||||
|
||||
self::assertSame(200, array_sum($result));
|
||||
}
|
||||
|
||||
public function testSingleSlotWithAmpleCapTakesItAll(): void
|
||||
{
|
||||
$result = (new EvenSplitter())->split(3000, [5000]);
|
||||
|
||||
self::assertSame([3000], $result);
|
||||
}
|
||||
|
||||
public function testSingleSlotCapsAndLeavesPoolLeftoverUnplaced(): void
|
||||
{
|
||||
$result = (new EvenSplitter())->split(500, [100]);
|
||||
|
||||
self::assertSame([100], $result);
|
||||
}
|
||||
|
||||
public function testZeroPoolAllocatesNothing(): void
|
||||
{
|
||||
$result = (new EvenSplitter())->split(0, [5000, 5000]);
|
||||
|
||||
self::assertSame([0, 0], $result);
|
||||
}
|
||||
|
||||
public function testEmptyCapsReturnsEmptyList(): void
|
||||
{
|
||||
$result = (new EvenSplitter())->split(1000, []);
|
||||
|
||||
self::assertSame([], $result);
|
||||
}
|
||||
|
||||
public function testCascadingRedistributeAcrossTwoCappedSlots(): void
|
||||
{
|
||||
// Round 1: eligible=[0,1,2], share=intdiv(1000,3)=333, remainder=1 ->
|
||||
// slot0 gets 334, slot1/2 get 333.
|
||||
// slot0: min(334,100)=100 (capped)
|
||||
// slot1: min(333,200)=200 (capped)
|
||||
// slot2: min(333,5000)=333 (not capped)
|
||||
// placed=633, pool=367.
|
||||
// Round 2: eligible=[2], share=intdiv(367,1)=367 -> slot2 +367=700.
|
||||
$result = (new EvenSplitter())->split(1000, [100, 200, 5000]);
|
||||
|
||||
self::assertSame([100, 200, 700], $result);
|
||||
}
|
||||
}
|
||||
97
tests/Service/Allocation/ResultTest.php
Normal file
97
tests/Service/Allocation/ResultTest.php
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
<?php
|
||||
|
||||
namespace App\Tests\Service\Allocation;
|
||||
|
||||
use App\Entity\Bucket;
|
||||
use App\Entity\Scenario;
|
||||
use App\Enum\BucketAllocationType;
|
||||
use App\Enum\BucketType;
|
||||
use App\Enum\DistributionMode;
|
||||
use App\Service\Allocation\Result;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for the Result value object.
|
||||
*
|
||||
* Pins Result as a DUMB HOLDER: it does not compute totalAllocated
|
||||
* or unallocated itself. The constructor receives all three values directly
|
||||
* and the getters return exactly what was passed in. Summing the allocation
|
||||
* amounts is the Engine's responsibility, not the result object's
|
||||
* (single responsibility — the engine knows the income amount and the fill
|
||||
* rules; the result is a plain DTO carrying the engine's output).
|
||||
*
|
||||
* Pinned constructor:
|
||||
* Result::__construct(array $allocations, int $totalAllocated, int $unallocated)
|
||||
*
|
||||
* Pinned shape (must match EngineTest's allocation entries):
|
||||
* $allocations is list<array{bucket: Bucket, amount: int}>
|
||||
*/
|
||||
final class ResultTest extends TestCase
|
||||
{
|
||||
public function testGettersReturnConstructorValues(): void
|
||||
{
|
||||
$scenario = $this->makeScenario();
|
||||
$bucketA = $this->makeBucket($scenario, priority: 1, name: 'Bucket A');
|
||||
$bucketB = $this->makeBucket($scenario, priority: 2, name: 'Bucket B');
|
||||
|
||||
$allocations = [
|
||||
['bucket' => $bucketA, 'amount' => 30000],
|
||||
['bucket' => $bucketB, 'amount' => 20000],
|
||||
];
|
||||
|
||||
$result = new Result($allocations, 50000, 0);
|
||||
|
||||
self::assertSame($allocations, $result->getAllocations());
|
||||
self::assertSame(50000, $result->getTotalAllocated());
|
||||
self::assertSame(0, $result->getUnallocated());
|
||||
}
|
||||
|
||||
public function testEmptyAllocationsWithZeroTotals(): void
|
||||
{
|
||||
$result = new Result([], 0, 0);
|
||||
|
||||
self::assertSame([], $result->getAllocations());
|
||||
self::assertSame(0, $result->getTotalAllocated());
|
||||
self::assertSame(0, $result->getUnallocated());
|
||||
}
|
||||
|
||||
public function testTotalsAreStoredNotComputedFromAllocations(): void
|
||||
{
|
||||
$scenario = $this->makeScenario();
|
||||
$bucket = $this->makeBucket($scenario, priority: 1, name: 'Bucket A');
|
||||
|
||||
// Deliberately inconsistent: the allocation entry says 10000, but the
|
||||
// constructor is told totalAllocated is 99999 and unallocated is 1.
|
||||
// A dumb holder returns exactly what it was given, regardless of
|
||||
// whether it matches the sum of $allocations.
|
||||
$allocations = [
|
||||
['bucket' => $bucket, 'amount' => 10000],
|
||||
];
|
||||
|
||||
$result = new Result($allocations, 99999, 1);
|
||||
|
||||
self::assertSame($allocations, $result->getAllocations());
|
||||
self::assertSame(99999, $result->getTotalAllocated());
|
||||
self::assertSame(1, $result->getUnallocated());
|
||||
}
|
||||
|
||||
private function makeScenario(): Scenario
|
||||
{
|
||||
return (new Scenario())
|
||||
->setName('Household Budget')
|
||||
->setDistributionMode(DistributionMode::PRIORITY);
|
||||
}
|
||||
|
||||
private function makeBucket(Scenario $scenario, int $priority, string $name): Bucket
|
||||
{
|
||||
return (new Bucket())
|
||||
->setScenario($scenario)
|
||||
->setType(BucketType::NEED)
|
||||
->setName($name)
|
||||
->setPriority($priority)
|
||||
->setAllocationType(BucketAllocationType::FIXED_LIMIT)
|
||||
->setAllocationValue(50000)
|
||||
->setStartingAmount(0)
|
||||
->setBufferMultiplier('0.00');
|
||||
}
|
||||
}
|
||||
153
tests/State/PreviewProcessorTest.php
Normal file
153
tests/State/PreviewProcessorTest.php
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
<?php
|
||||
|
||||
namespace App\Tests\State;
|
||||
|
||||
use App\ApiResource\ProjectionPreviewOutput;
|
||||
use App\Entity\Bucket;
|
||||
use App\Entity\Scenario;
|
||||
use App\Enum\BucketAllocationType;
|
||||
use App\Enum\BucketType;
|
||||
use App\Repository\BucketRepository;
|
||||
use App\Repository\ScenarioRepository;
|
||||
use App\State\PreviewProcessor;
|
||||
use PHPUnit\Framework\MockObject\Stub;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Pure-unit complement to tests/Functional/ProjectionPreviewApiTest.php.
|
||||
*
|
||||
* SEAM PINNED: this test targets `PreviewProcessor::buildOutput(Scenario $scenario,
|
||||
* int $amount): ProjectionPreviewOutput` directly, NOT `process()`. `process()` is
|
||||
* thin API Platform glue (extracting scenario + amount from $uriVariables/$context)
|
||||
* and stays covered by the functional HTTP test. `buildOutput()` is the actual
|
||||
* mapping logic — repository lookup -> AllocateIncome -> ProjectionPreviewOutput —
|
||||
* and is what's worth unit-testing in isolation, without mocking AP request internals.
|
||||
*
|
||||
* `AllocateIncome` is NOT mocked — it's `new`-ed inside buildOutput() and the real
|
||||
* engine runs against the stubbed bucket list. The engine has its own full unit
|
||||
* coverage (AllocateIncomeTest); trusting it here is correct.
|
||||
*
|
||||
* Expected ProjectionPreviewOutput shape (mirrors the functional test's JSON contract):
|
||||
* - allocations: list of ['bucket_id' => string (uuid, RFC4122), 'bucket_name' =>
|
||||
* string, 'bucket_type' => string (enum backing value), 'allocated_amount' => int,
|
||||
* 'remaining_capacity' => int|null]
|
||||
* - totalAllocated: int
|
||||
* - unallocated: int
|
||||
*/
|
||||
final class PreviewProcessorTest extends TestCase
|
||||
{
|
||||
private BucketRepository&Stub $bucketRepository;
|
||||
private PreviewProcessor $provider;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->bucketRepository = $this->createStub(BucketRepository::class);
|
||||
$this->provider = new PreviewProcessor(
|
||||
$this->createStub(ScenarioRepository::class),
|
||||
$this->bucketRepository,
|
||||
);
|
||||
}
|
||||
|
||||
public function testItMapsTheAllocationResultToTheOutputDto(): void
|
||||
{
|
||||
$scenario = $this->makeScenario();
|
||||
|
||||
$need = $this->makeBucket(
|
||||
$scenario,
|
||||
BucketType::NEED,
|
||||
BucketAllocationType::FIXED_LIMIT,
|
||||
priority: 1,
|
||||
allocationValue: 5000,
|
||||
name: 'Rent',
|
||||
);
|
||||
$overflow = $this->makeBucket(
|
||||
$scenario,
|
||||
BucketType::OVERFLOW,
|
||||
BucketAllocationType::UNLIMITED,
|
||||
priority: 2,
|
||||
allocationValue: null,
|
||||
name: 'Overflow',
|
||||
);
|
||||
|
||||
$this->bucketRepository
|
||||
->method('findByScenarioOrderedByPriority')
|
||||
->willReturn([$need, $overflow]);
|
||||
|
||||
$output = $this->provider->buildOutput($scenario, 8000);
|
||||
|
||||
self::assertInstanceOf(ProjectionPreviewOutput::class, $output);
|
||||
|
||||
$byBucketId = [];
|
||||
foreach ($output->allocations as $allocation) {
|
||||
$byBucketId[$allocation['bucket_id']] = $allocation;
|
||||
}
|
||||
|
||||
self::assertArrayHasKey($need->getId()->toRfc4122(), $byBucketId);
|
||||
self::assertArrayHasKey($overflow->getId()->toRfc4122(), $byBucketId);
|
||||
|
||||
$needAllocation = $byBucketId[$need->getId()->toRfc4122()];
|
||||
self::assertSame('Rent', $needAllocation['bucket_name']);
|
||||
self::assertSame(BucketType::NEED->value, $needAllocation['bucket_type']);
|
||||
self::assertSame(5000, $needAllocation['allocated_amount']);
|
||||
|
||||
$overflowAllocation = $byBucketId[$overflow->getId()->toRfc4122()];
|
||||
self::assertSame('Overflow', $overflowAllocation['bucket_name']);
|
||||
self::assertSame(BucketType::OVERFLOW->value, $overflowAllocation['bucket_type']);
|
||||
self::assertSame(3000, $overflowAllocation['allocated_amount']);
|
||||
|
||||
self::assertSame(8000, $output->totalAllocated);
|
||||
self::assertSame(0, $output->unallocated);
|
||||
}
|
||||
|
||||
public function testItReturnsZeroAllocationsForAScenarioWithNoBuckets(): void
|
||||
{
|
||||
$scenario = $this->makeScenario();
|
||||
|
||||
$this->bucketRepository
|
||||
->method('findByScenarioOrderedByPriority')
|
||||
->willReturn([]);
|
||||
|
||||
$output = $this->provider->buildOutput($scenario, 5000);
|
||||
|
||||
self::assertSame([], $output->allocations);
|
||||
self::assertSame(0, $output->totalAllocated);
|
||||
self::assertSame(5000, $output->unallocated);
|
||||
}
|
||||
|
||||
public function testTheOutputIsTheProjectionPreviewOutputDto(): void
|
||||
{
|
||||
$scenario = $this->makeScenario();
|
||||
|
||||
$this->bucketRepository
|
||||
->method('findByScenarioOrderedByPriority')
|
||||
->willReturn([]);
|
||||
|
||||
$output = $this->provider->buildOutput($scenario, 1000);
|
||||
|
||||
self::assertInstanceOf(ProjectionPreviewOutput::class, $output);
|
||||
}
|
||||
|
||||
private function makeScenario(): Scenario
|
||||
{
|
||||
return (new Scenario())->setName('Household Budget');
|
||||
}
|
||||
|
||||
private function makeBucket(
|
||||
Scenario $scenario,
|
||||
BucketType $type,
|
||||
BucketAllocationType $allocationType,
|
||||
int $priority,
|
||||
?int $allocationValue,
|
||||
string $name,
|
||||
): Bucket {
|
||||
return (new Bucket())
|
||||
->setScenario($scenario)
|
||||
->setType($type)
|
||||
->setName($name)
|
||||
->setPriority($priority)
|
||||
->setAllocationType($allocationType)
|
||||
->setAllocationValue($allocationValue)
|
||||
->setStartingAmount(0)
|
||||
->setBufferMultiplier('0.00');
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue