From d64e7ce1ef969d9d73658e5ffa6266c54ec9cad9 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Sat, 20 Jun 2026 23:51:56 +0200 Subject: [PATCH] 32 - Add BucketRoomCalculator with FillStage --- src/Enum/FillStage.php | 9 + src/Service/BucketRoomCalculator.php | 71 ++++ tests/Enum/FillStageTest.php | 17 + tests/Service/BucketRoomCalculatorTest.php | 453 +++++++++++++++++++++ 4 files changed, 550 insertions(+) create mode 100644 src/Enum/FillStage.php create mode 100644 src/Service/BucketRoomCalculator.php create mode 100644 tests/Enum/FillStageTest.php create mode 100644 tests/Service/BucketRoomCalculatorTest.php diff --git a/src/Enum/FillStage.php b/src/Enum/FillStage.php new file mode 100644 index 0000000..7524f25 --- /dev/null +++ b/src/Enum/FillStage.php @@ -0,0 +1,9 @@ +getAllocationType()) { + BucketAllocationType::PERCENTAGE => $this->handlePercentage($bucket, $fillStage, $income, $alreadyAllocated), + BucketAllocationType::FIXED_LIMIT => $this->handleFixedLimit($bucket, $fillStage, $income, $alreadyAllocated), + BucketAllocationType::UNLIMITED => $this->handleUnlimited(), + }; + } + + private function handlePercentage(Bucket $bucket, FillStage $fillStage, int $income, int $alreadyAllocated): int + { + if (BucketAllocationType::PERCENTAGE === $bucket->getAllocationType() && FillStage::Buffer === $fillStage) { + return 0; + } + + if (BucketAllocationType::PERCENTAGE === $bucket->getAllocationType() && 0 === $income) { + return 0; + } + + $room = (int) (($bucket->getAllocationValue() / 10000) * $income) + - $bucket->getStartingAmount() + - $alreadyAllocated; + + return max(0, $room); + } + + private function handleFixedLimit(Bucket $bucket, FillStage $fillStage, int $income, int $alreadyAllocated): int + { + if (FillStage::Base === $fillStage) { + $multiplierEnabled = false; + } + + return $this->basicRoom($bucket, $income, $alreadyAllocated, $multiplierEnabled ?? true); + } + + private function handleUnlimited(): int + { + return \PHP_INT_MAX; + } + + private function basicRoom(Bucket $bucket, int $income, int $alreadyAllocated, bool $multiplierEnabled = true): int + { + $room = $bucket->getAllocationValue() - $bucket->getStartingAmount(); + + if ($room < $alreadyAllocated) { + return 0; + } + + if ($multiplierEnabled && ((float) $bucket->getBufferMultiplier()) > 0) { + $room *= (1 + ((float) $bucket->getBufferMultiplier())); + } + + $room -= $alreadyAllocated; + + return floor($room); + } +} diff --git a/tests/Enum/FillStageTest.php b/tests/Enum/FillStageTest.php new file mode 100644 index 0000000..e43cb38 --- /dev/null +++ b/tests/Enum/FillStageTest.php @@ -0,0 +1,17 @@ +assertSame('base', FillStage::Base->value); + $this->assertSame('buffer', FillStage::Buffer->value); + + $this->assertCount(2, FillStage::cases()); + } +} diff --git a/tests/Service/BucketRoomCalculatorTest.php b/tests/Service/BucketRoomCalculatorTest.php new file mode 100644 index 0000000..f207dcd --- /dev/null +++ b/tests/Service/BucketRoomCalculatorTest.php @@ -0,0 +1,453 @@ + 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); + self::assertIsInt($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); + self::assertIsInt($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); + self::assertIsInt($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); + self::assertIsInt($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); + } + + // --- 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'); + } +}