32 - Add AllocateIncome action for phased income
distribution
This commit is contained in:
parent
556f45146c
commit
af467de439
4 changed files with 814 additions and 0 deletions
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, 0);
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
538
tests/Service/Allocation/AllocateIncomeTest.php
Normal file
538
tests/Service/Allocation/AllocateIncomeTest.php
Normal file
|
|
@ -0,0 +1,538 @@
|
|||
<?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(0, $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;
|
||||
}
|
||||
}
|
||||
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');
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue