Compare commits

...

7 commits

19 changed files with 2459 additions and 0 deletions

View file

@ -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

View 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;
}

View 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
View file

@ -0,0 +1,9 @@
<?php
namespace App\Enum;
enum FillStage: string
{
case Base = 'base';
case Buffer = 'buffer';
}

View file

@ -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();
}
}

View 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;
}
}

View 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));
}
}

View 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;
}
}
}

View 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;
}
}

View 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);
}
}

View 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');
}
}

View 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());
}
}

View 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;
}
}

View file

@ -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();

View 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;
}
}

View 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');
}
}

View 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);
}
}

View 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');
}
}

View 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');
}
}