From af467de439adb60bd4655b26b2c008dea7a9d19b Mon Sep 17 00:00:00 2001 From: myrmidex Date: Sun, 21 Jun 2026 23:58:45 +0200 Subject: [PATCH] 32 - Add AllocateIncome action for phased income distribution --- src/Service/Allocation/AllocateIncome.php | 133 +++++ src/Service/Allocation/Result.php | 46 ++ .../Service/Allocation/AllocateIncomeTest.php | 538 ++++++++++++++++++ tests/Service/Allocation/ResultTest.php | 97 ++++ 4 files changed, 814 insertions(+) create mode 100644 src/Service/Allocation/AllocateIncome.php create mode 100644 src/Service/Allocation/Result.php create mode 100644 tests/Service/Allocation/AllocateIncomeTest.php create mode 100644 tests/Service/Allocation/ResultTest.php diff --git a/src/Service/Allocation/AllocateIncome.php b/src/Service/Allocation/AllocateIncome.php new file mode 100644 index 0000000..a7e7d2d --- /dev/null +++ b/src/Service/Allocation/AllocateIncome.php @@ -0,0 +1,133 @@ + */ + private array $allocations = []; + + /** + * @param Collection $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 + */ + private function getAllocations(): array + { + return $this->buckets->map(fn (Bucket $bucket) => [ + 'bucket' => $bucket, + 'amount' => $this->allocations[(string) $bucket->getId()] ?? 0, + ])->toArray(); + } + + /** + * @return list + */ + private function bucketsOfType(BucketType $type): array + { + return $this->buckets + ->filter(static fn (Bucket $bucket) => $type === $bucket->getType()) + ->getValues(); + } + + /** + * @param list $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 $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; + } +} diff --git a/src/Service/Allocation/Result.php b/src/Service/Allocation/Result.php new file mode 100644 index 0000000..6828d2c --- /dev/null +++ b/src/Service/Allocation/Result.php @@ -0,0 +1,46 @@ + */ + private array $allocations; + + private int $totalAllocated; + + private ?int $unallocated = null; + + /** + * @param list $allocations + */ + public function __construct( + array $allocations, + int $totalAllocated, + ?int $unallocated = 0, + ) { + $this->allocations = $allocations; + $this->totalAllocated = $totalAllocated; + $this->unallocated = $unallocated; + } + + /** + * @return list + */ + public function getAllocations(): array + { + return $this->allocations; + } + + public function getTotalAllocated(): int + { + return $this->totalAllocated; + } + + public function getUnallocated(): int + { + return $this->unallocated; + } +} diff --git a/tests/Service/Allocation/AllocateIncomeTest.php b/tests/Service/Allocation/AllocateIncomeTest.php new file mode 100644 index 0000000..70adaca --- /dev/null +++ b/tests/Service/Allocation/AllocateIncomeTest.php @@ -0,0 +1,538 @@ + 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 $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; + } +} diff --git a/tests/Service/Allocation/ResultTest.php b/tests/Service/Allocation/ResultTest.php new file mode 100644 index 0000000..450e650 --- /dev/null +++ b/tests/Service/Allocation/ResultTest.php @@ -0,0 +1,97 @@ + + */ +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'); + } +}