cap = allocationValue * FIXED_LIMIT, buffer -> cap = (int) floor(allocationValue * (1 + (float) bufferMultiplier)) * PERCENTAGE, base -> cap = (int) floor(income * allocationValue / 10000) (basis points) * PERCENTAGE, buffer -> cap = 0 (no buffer concept for percentage) * UNLIMITED, base or buffer -> room = PHP_INT_MAX (infinite capacity; the engine * partitions overflow buckets out of the phased fill, so this is never misused) * * roomFor TOLERATES degenerate inputs and never throws: null allocationValue is treated * as 0, and any over-subscription (negative room) clamps to 0 via max(0, ...). */ #[CoversClass(BucketRoomCalculator::class)] final class BucketRoomCalculatorTest extends TestCase { // --- fixed_limit, base ------------------------------------------------ public function testFixedLimitBaseFreshBucketHasFullRoom(): void { $bucket = $this->makeFixedLimitBucket(allocationValue: 5000, startingAmount: 0); $room = (new BucketRoomCalculator())->roomFor($bucket, FillStage::Base, income: 10000, alreadyAllocated: 0); self::assertSame(5000, $room); } public function testFixedLimitBaseStartingAmountReducesRoom(): void { $bucket = $this->makeFixedLimitBucket(allocationValue: 5000, startingAmount: 2000); $room = (new BucketRoomCalculator())->roomFor($bucket, FillStage::Base, income: 10000, alreadyAllocated: 0); self::assertSame(3000, $room); } public function testFixedLimitBaseAlreadyAllocatedReducesRoom(): void { $bucket = $this->makeFixedLimitBucket(allocationValue: 5000, startingAmount: 0); $room = (new BucketRoomCalculator())->roomFor( $bucket, FillStage::Base, income: 10000, alreadyAllocated: 1500 ); self::assertSame(3500, $room); } public function testFixedLimitBaseStartingAmountAndAlreadyAllocatedBothReduceRoom(): void { $bucket = $this->makeFixedLimitBucket(allocationValue: 5000, startingAmount: 1000); $room = (new BucketRoomCalculator())->roomFor($bucket, FillStage::Base, income: 10000, alreadyAllocated: 1000); self::assertSame(3000, $room); } public function testFixedLimitBaseOverSubscribedNeverGoesNegative(): void { $bucket = $this->makeFixedLimitBucket(allocationValue: 5000, startingAmount: 4000); $room = (new BucketRoomCalculator())->roomFor( $bucket, FillStage::Base, income: 10000, alreadyAllocated: 2000 ); self::assertSame(0, $room); } public function testFixedLimitBaseOverSubscribedByStartingAmountAloneIsZero(): void { $bucket = $this->makeFixedLimitBucket(allocationValue: 5000, startingAmount: 6000); $room = (new BucketRoomCalculator())->roomFor($bucket, FillStage::Base, income: 10000, alreadyAllocated: 0); self::assertSame(0, $room); } public function testFixedLimitBaseWithNullAllocationValueIsZero(): void { $bucket = (new Bucket()) ->setScenario(new Scenario()) ->setType(BucketType::NEED) ->setName('Bucket') ->setPriority(1) ->setAllocationType(BucketAllocationType::FIXED_LIMIT) ->setAllocationValue(null) ->setStartingAmount(0) ->setBufferMultiplier('0.00'); $room = (new BucketRoomCalculator())->roomFor($bucket, FillStage::Base, income: 10000, alreadyAllocated: 0); self::assertSame(0, $room); } public function testIncomeIsIgnoredForFixedLimitBuckets(): void { $bucket = $this->makeFixedLimitBucket(allocationValue: 5000, startingAmount: 0); $calculator = new BucketRoomCalculator(); $roomWithNoIncome = $calculator->roomFor($bucket, FillStage::Base, income: 0, alreadyAllocated: 0); $roomWithHugeIncome = $calculator->roomFor($bucket, FillStage::Base, income: 999999, alreadyAllocated: 0); self::assertSame(5000, $roomWithNoIncome); self::assertSame(5000, $roomWithHugeIncome); } public function testWantTypeBehavesIdenticallyToNeedForRoom(): void { $bucket = (new Bucket()) ->setScenario(new Scenario()) ->setType(BucketType::WANT) ->setName('Bucket') ->setPriority(1) ->setAllocationType(BucketAllocationType::FIXED_LIMIT) ->setAllocationValue(5000) ->setStartingAmount(0) ->setBufferMultiplier('0.00'); $room = (new BucketRoomCalculator())->roomFor($bucket, FillStage::Base, income: 10000, alreadyAllocated: 0); self::assertSame(5000, $room); } // --- fixed_limit, base vs buffer (stage independence) ----------------- public function testFixedLimitBaseIgnoresBufferMultiplier(): void { $bucket = $this->makeFixedLimitBucket( allocationValue: 5000, startingAmount: 0, bufferMultiplier: '0.50' ); // Base stage must use allocationValue ONLY -- the buffer multiplier never // applies outside the Buffer stage, even when it's non-zero. $room = (new BucketRoomCalculator())->roomFor( $bucket, FillStage::Base, income: 10000, alreadyAllocated: 0 ); self::assertSame(5000, $room); } // --- fixed_limit, buffer ---------------------------------------------- public function testFixedLimitBufferAppliesTheMultiplier(): void { $bucket = $this->makeFixedLimitBucket(allocationValue: 40000, startingAmount: 0, bufferMultiplier: '0.50'); // cap = round(40000 * 1.50) = 60000. $room = (new BucketRoomCalculator())->roomFor($bucket, FillStage::Buffer, income: 10000, alreadyAllocated: 0); self::assertSame(60000, $room); } public function testFixedLimitBufferFloorsToInteger(): void { $bucket = $this->makeFixedLimitBucket(allocationValue: 10001, startingAmount: 0, bufferMultiplier: '0.33'); // cap = (int) floor(10001 * 1.33) = (int) floor(13301.33) = 13301. $room = (new BucketRoomCalculator())->roomFor($bucket, FillStage::Buffer, income: 10000, alreadyAllocated: 0); self::assertSame(13301, $room); } public function testFixedLimitBufferWithZeroMultiplierEqualsBaseCap(): void { $bucket = $this->makeFixedLimitBucket(allocationValue: 5000, startingAmount: 0, bufferMultiplier: '0.00'); $room = (new BucketRoomCalculator())->roomFor($bucket, FillStage::Buffer, income: 10000, alreadyAllocated: 0); self::assertSame(5000, $room); } public function testFixedLimitBufferRoomIsTheGapAboveAlreadyAllocatedBase(): void { $bucket = $this->makeFixedLimitBucket( allocationValue: 40000, startingAmount: 0, bufferMultiplier: '0.50' ); // cap = round(40000 * 1.50) = 60000. Base phase already gave it 40000 -> // buffer room is the gap between base cap and buffer cap: 60000 - 40000 = 20000. $room = (new BucketRoomCalculator())->roomFor( $bucket, FillStage::Buffer, income: 10000, alreadyAllocated: 40000 ); self::assertSame(20000, $room); } public function testFixedLimitBufferFloorsFractionalCap(): void { $bucket = $this->makeFixedLimitBucket(allocationValue: 5001, startingAmount: 0, bufferMultiplier: '0.50'); // raw cap = 5001 * 1.50 = 7501.5 -- (int) floor(7501.5) = 7501 (caps floor, // never round up -- leftover sub-cent stays unallocated rather than overfilling). $room = (new BucketRoomCalculator())->roomFor($bucket, FillStage::Buffer, income: 10000, alreadyAllocated: 0); self::assertSame(7501, $room); } public function testFixedLimitBufferRoomWhenAlreadyAllocatedExceedsBaseCap(): void { $bucket = $this->makeFixedLimitBucket( allocationValue: 1000, startingAmount: 0, bufferMultiplier: '0.50' ); // buffered cap = (int) floor(1000 * 1.50) = 1500. // alreadyAllocated (1200) exceeds the BASE cap (1000) but is below the BUFFERED // cap (1500); buffer-stage room must be the gap to the buffered cap (300), not 0. // Regression test for the early-return-before-buffer bug. $room = (new BucketRoomCalculator())->roomFor( $bucket, FillStage::Buffer, income: 10000, alreadyAllocated: 1200 ); self::assertSame(300, $room); } public function testFixedLimitBufferOverSubscribedIsZero(): void { $bucket = $this->makeFixedLimitBucket(allocationValue: 40000, startingAmount: 0, bufferMultiplier: '0.50'); // cap = round(40000 * 1.50) = 60000, alreadyAllocated 70000 exceeds it. $room = (new BucketRoomCalculator())->roomFor( $bucket, FillStage::Buffer, income: 10000, alreadyAllocated: 70000 ); self::assertSame(0, $room); } // --- percentage, base --------------------------------------------------- public function testPercentageBaseIsProportionalToIncome(): void { // allocationValue 3000 basis points = 30%. $bucket = $this->makePercentageBucket(allocationValueBasisPoints: 3000, startingAmount: 0); // cap = round(10000 * 3000 / 10000) = 3000. $room = (new BucketRoomCalculator())->roomFor($bucket, FillStage::Base, income: 10000, alreadyAllocated: 0); self::assertSame(3000, $room); } public function testPercentageBaseFloorsFractionalResult(): void { $bucket = $this->makePercentageBucket(allocationValueBasisPoints: 3333, startingAmount: 0); // cap = (int) floor(10001 * 3333 / 10000) = (int) floor(3333.33) = 3333. $room = (new BucketRoomCalculator())->roomFor($bucket, FillStage::Base, income: 10001, alreadyAllocated: 0); self::assertSame(3333, $room); } public function testPercentageBaseFloorsFractionalCap(): void { $bucket = $this->makePercentageBucket( allocationValueBasisPoints: 5000, startingAmount: 0 ); // cap = 7501 * 5000 / 10000 = 3750.5 -- floor = 3750 (round would wrongly give // 3751; caps floor). $room = (new BucketRoomCalculator())->roomFor( $bucket, FillStage::Base, income: 7501, alreadyAllocated: 0 ); self::assertSame(3750, $room); } public function testPercentageBaseStartingAmountReducesRoom(): void { $bucket = $this->makePercentageBucket( allocationValueBasisPoints: 3000, startingAmount: 1000 ); // cap = round(10000 * 3000 / 10000) = 3000. $room = (new BucketRoomCalculator())->roomFor( $bucket, FillStage::Base, income: 10000, alreadyAllocated: 0 ); self::assertSame(2000, $room); } public function testPercentageOverSubscribedByStartingAmountIsZero(): void { // allocationValue 3000 basis points = 30%, cap = round(10000 * 3000 / 10000) = 3000. $bucket = $this->makePercentageBucket( allocationValueBasisPoints: 3000, startingAmount: 5000 ); $room = (new BucketRoomCalculator())->roomFor( $bucket, FillStage::Base, income: 10000, alreadyAllocated: 0 ); self::assertSame(0, $room); } public function testPercentageOverSubscribedByAlreadyAllocatedIsZero(): void { // cap = round(10000 * 3000 / 10000) = 3000, alreadyAllocated 4000 exceeds it. $bucket = $this->makePercentageBucket(allocationValueBasisPoints: 3000, startingAmount: 0); $room = (new BucketRoomCalculator())->roomFor( $bucket, FillStage::Base, income: 10000, alreadyAllocated: 4000 ); self::assertSame(0, $room); } // --- percentage, buffer -------------------------------------------------- public function testPercentageBucketHasNoBufferRoom(): void { $bucket = $this->makePercentageBucket(allocationValueBasisPoints: 3000, startingAmount: 0); $room = (new BucketRoomCalculator())->roomFor( $bucket, FillStage::Buffer, income: 10000, alreadyAllocated: 0 ); self::assertSame(0, $room); } public function testPercentageBucketHasNoBufferRoomAfterBaseRoundFilledIt(): void { // allocationValue 3000 basis points = 30%. In a real engine run the base round // would already have allocated its 30% cut before the buffer round runs. $bucket = $this->makePercentageBucket(allocationValueBasisPoints: 3000, startingAmount: 0); $room = (new BucketRoomCalculator())->roomFor( $bucket, FillStage::Buffer, income: 10000, alreadyAllocated: 3000 ); self::assertSame(0, $room); } public function testPercentageBaseRoomIsZeroWhenIncomeIsZero(): void { $bucket = $this->makePercentageBucket(allocationValueBasisPoints: 3000, startingAmount: 0); // cap = round(0 * 3000 / 10000) = 0. $room = (new BucketRoomCalculator())->roomFor($bucket, FillStage::Base, income: 0, alreadyAllocated: 0); self::assertSame(0, $room); } public function testPercentageBaseWithNullAllocationValueIsZero(): void { // Mirrors testFixedLimitBaseWithNullAllocationValueIsZero: roomFor must tolerate // a null allocationValue on the PERCENTAGE path too (null basis points = 0% = no // room), not throw when multiplying it against income. $bucket = (new Bucket()) ->setScenario(new Scenario()) ->setType(BucketType::NEED) ->setName('Bucket') ->setPriority(1) ->setAllocationType(BucketAllocationType::PERCENTAGE) ->setAllocationValue(null) ->setStartingAmount(0) ->setBufferMultiplier('0.00'); $room = (new BucketRoomCalculator())->roomFor($bucket, FillStage::Base, income: 10000, alreadyAllocated: 0); self::assertSame(0, $room); } public function testPercentageBufferWithNullAllocationValueIsZero(): void { // Same null-allocationValue bucket, but the Buffer stage -- percentage buffer // room is always 0 regardless of allocationValue, so this confirms the null // case doesn't throw in the buffer path either. $bucket = (new Bucket()) ->setScenario(new Scenario()) ->setType(BucketType::NEED) ->setName('Bucket') ->setPriority(1) ->setAllocationType(BucketAllocationType::PERCENTAGE) ->setAllocationValue(null) ->setStartingAmount(0) ->setBufferMultiplier('0.00'); $room = (new BucketRoomCalculator())->roomFor($bucket, FillStage::Buffer, income: 10000, alreadyAllocated: 0); self::assertSame(0, $room); } // --- unlimited (overflow) ------------------------------------------------- public function testUnlimitedBucketHasInfiniteBaseRoom(): void { $bucket = $this->makeOverflowBucket(); // Overflow/unlimited capacity is infinite; roomFor reports the truth. The // engine partitions overflow out of the phased fill so this is never misused. $room = (new BucketRoomCalculator())->roomFor( $bucket, FillStage::Base, income: 10000, alreadyAllocated: 0 ); self::assertSame(\PHP_INT_MAX, $room); } public function testUnlimitedBucketHasInfiniteBufferRoom(): void { $bucket = $this->makeOverflowBucket(); // Overflow/unlimited capacity is infinite; roomFor reports the truth. The // engine partitions overflow out of the phased fill so this is never misused. $room = (new BucketRoomCalculator())->roomFor($bucket, FillStage::Buffer, income: 10000, alreadyAllocated: 0); self::assertSame(\PHP_INT_MAX, $room); } // --- fixtures --------------------------------------------------------- private function makeFixedLimitBucket(int $allocationValue, int $startingAmount, string $bufferMultiplier = '0.00'): Bucket { return (new Bucket()) ->setScenario(new Scenario()) ->setType(BucketType::NEED) ->setName('Bucket') ->setPriority(1) ->setAllocationType(BucketAllocationType::FIXED_LIMIT) ->setAllocationValue($allocationValue) ->setStartingAmount($startingAmount) ->setBufferMultiplier($bufferMultiplier); } private function makePercentageBucket(int $allocationValueBasisPoints, int $startingAmount): Bucket { return (new Bucket()) ->setScenario(new Scenario()) ->setType(BucketType::NEED) ->setName('Bucket') ->setPriority(1) ->setAllocationType(BucketAllocationType::PERCENTAGE) ->setAllocationValue($allocationValueBasisPoints) ->setStartingAmount($startingAmount) ->setBufferMultiplier('0.00'); } private function makeOverflowBucket(): Bucket { return (new Bucket()) ->setScenario(new Scenario()) ->setType(BucketType::OVERFLOW) ->setName('Overflow') ->setPriority(1) ->setAllocationType(BucketAllocationType::UNLIMITED) ->setAllocationValue(null) ->setStartingAmount(0) ->setBufferMultiplier('0.00'); } }