diff --git a/src/Service/Allocation/BucketRoomCalculator.php b/src/Service/Allocation/BucketRoomCalculator.php new file mode 100644 index 0000000..fa76772 --- /dev/null +++ b/src/Service/Allocation/BucketRoomCalculator.php @@ -0,0 +1,55 @@ +getAllocationType()) { + BucketAllocationType::PERCENTAGE => $this->handlePercentage($bucket, $fillStage, $income, $alreadyAllocated), + BucketAllocationType::FIXED_LIMIT => $this->handleFixedLimit($bucket, $fillStage, $alreadyAllocated), + BucketAllocationType::UNLIMITED => $this->handleUnlimited(), + }; + } + + private function handlePercentage(Bucket $bucket, FillStage $fillStage, int $income, int $alreadyAllocated): int + { + if (FillStage::Buffer === $fillStage) { + return 0; + } + + $room = (($bucket->getAllocationValue() ?? 0) * $income / 10000); + + return $this->clamp($room, $bucket->getStartingAmount(), $alreadyAllocated); + } + + private function handleFixedLimit(Bucket $bucket, FillStage $fillStage, int $alreadyAllocated): int + { + $room = $bucket->getAllocationValue() ?? 0; + + if (FillStage::Buffer === $fillStage) { + $room *= (1 + (float) $bucket->getBufferMultiplier()); + } + + return $this->clamp($room, $bucket->getStartingAmount(), $alreadyAllocated); + } + + private function handleUnlimited(): int + { + return \PHP_INT_MAX; + } + + private function clamp(int|float $cap, int $startingAmount, int $alreadyAllocated): int + { + return max(0, (int) floor($cap - $startingAmount - $alreadyAllocated)); + } +} diff --git a/src/Service/BucketRoomCalculator.php b/src/Service/BucketRoomCalculator.php deleted file mode 100644 index b48134e..0000000 --- a/src/Service/BucketRoomCalculator.php +++ /dev/null @@ -1,71 +0,0 @@ -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/Service/BucketRoomCalculatorTest.php b/tests/Service/Allocation/BucketRoomCalculatorTest.php similarity index 86% rename from tests/Service/BucketRoomCalculatorTest.php rename to tests/Service/Allocation/BucketRoomCalculatorTest.php index f207dcd..a5325cb 100644 --- a/tests/Service/BucketRoomCalculatorTest.php +++ b/tests/Service/Allocation/BucketRoomCalculatorTest.php @@ -1,13 +1,13 @@ roomFor($bucket, FillStage::Buffer, income: 10000, alreadyAllocated: 0); self::assertSame(13301, $room); - self::assertIsInt($room); } public function testFixedLimitBufferWithZeroMultiplierEqualsBaseCap(): void @@ -226,7 +225,28 @@ final class BucketRoomCalculatorTest extends TestCase $room = (new BucketRoomCalculator())->roomFor($bucket, FillStage::Buffer, income: 10000, alreadyAllocated: 0); self::assertSame(7501, $room); - self::assertIsInt($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 @@ -265,7 +285,6 @@ final class BucketRoomCalculatorTest extends TestCase $room = (new BucketRoomCalculator())->roomFor($bucket, FillStage::Base, income: 10001, alreadyAllocated: 0); self::assertSame(3333, $room); - self::assertIsInt($room); } public function testPercentageBaseFloorsFractionalCap(): void @@ -285,7 +304,6 @@ final class BucketRoomCalculatorTest extends TestCase ); self::assertSame(3750, $room); - self::assertIsInt($room); } public function testPercentageBaseStartingAmountReducesRoom(): void @@ -381,6 +399,46 @@ final class BucketRoomCalculatorTest extends TestCase 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