549 lines
25 KiB
PHP
549 lines
25 KiB
PHP
<?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;
|
|
}
|
|
}
|