32 - Fix buffer-stage room and unify cap clamping
This commit is contained in:
parent
d64e7ce1ef
commit
d7af49cf8e
3 changed files with 119 additions and 77 deletions
55
src/Service/Allocation/BucketRoomCalculator.php
Normal file
55
src/Service/Allocation/BucketRoomCalculator.php
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service\Allocation;
|
||||||
|
|
||||||
|
use App\Entity\Bucket;
|
||||||
|
use App\Enum\BucketAllocationType;
|
||||||
|
use App\Enum\FillStage;
|
||||||
|
|
||||||
|
class BucketRoomCalculator
|
||||||
|
{
|
||||||
|
public function roomFor(
|
||||||
|
Bucket $bucket,
|
||||||
|
FillStage $fillStage,
|
||||||
|
int $income,
|
||||||
|
int $alreadyAllocated,
|
||||||
|
): int {
|
||||||
|
return match ($bucket->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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Service;
|
|
||||||
|
|
||||||
use App\Entity\Bucket;
|
|
||||||
use App\Enum\BucketAllocationType;
|
|
||||||
use App\Enum\FillStage;
|
|
||||||
|
|
||||||
class BucketRoomCalculator
|
|
||||||
{
|
|
||||||
public function roomFor(
|
|
||||||
Bucket $bucket,
|
|
||||||
FillStage $fillStage,
|
|
||||||
int $income,
|
|
||||||
int $alreadyAllocated,
|
|
||||||
): int {
|
|
||||||
return match ($bucket->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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Tests\Service;
|
namespace App\Tests\Service\Allocation;
|
||||||
|
|
||||||
use App\Entity\Bucket;
|
use App\Entity\Bucket;
|
||||||
use App\Entity\Scenario;
|
use App\Entity\Scenario;
|
||||||
use App\Enum\BucketAllocationType;
|
use App\Enum\BucketAllocationType;
|
||||||
use App\Enum\BucketType;
|
use App\Enum\BucketType;
|
||||||
use App\Enum\FillStage;
|
use App\Enum\FillStage;
|
||||||
use App\Service\BucketRoomCalculator;
|
use App\Service\Allocation\BucketRoomCalculator;
|
||||||
use PHPUnit\Framework\Attributes\CoversClass;
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
|
@ -185,7 +185,6 @@ final class BucketRoomCalculatorTest extends TestCase
|
||||||
$room = (new BucketRoomCalculator())->roomFor($bucket, FillStage::Buffer, income: 10000, alreadyAllocated: 0);
|
$room = (new BucketRoomCalculator())->roomFor($bucket, FillStage::Buffer, income: 10000, alreadyAllocated: 0);
|
||||||
|
|
||||||
self::assertSame(13301, $room);
|
self::assertSame(13301, $room);
|
||||||
self::assertIsInt($room);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testFixedLimitBufferWithZeroMultiplierEqualsBaseCap(): void
|
public function testFixedLimitBufferWithZeroMultiplierEqualsBaseCap(): void
|
||||||
|
|
@ -226,7 +225,28 @@ final class BucketRoomCalculatorTest extends TestCase
|
||||||
$room = (new BucketRoomCalculator())->roomFor($bucket, FillStage::Buffer, income: 10000, alreadyAllocated: 0);
|
$room = (new BucketRoomCalculator())->roomFor($bucket, FillStage::Buffer, income: 10000, alreadyAllocated: 0);
|
||||||
|
|
||||||
self::assertSame(7501, $room);
|
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
|
public function testFixedLimitBufferOverSubscribedIsZero(): void
|
||||||
|
|
@ -265,7 +285,6 @@ final class BucketRoomCalculatorTest extends TestCase
|
||||||
$room = (new BucketRoomCalculator())->roomFor($bucket, FillStage::Base, income: 10001, alreadyAllocated: 0);
|
$room = (new BucketRoomCalculator())->roomFor($bucket, FillStage::Base, income: 10001, alreadyAllocated: 0);
|
||||||
|
|
||||||
self::assertSame(3333, $room);
|
self::assertSame(3333, $room);
|
||||||
self::assertIsInt($room);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testPercentageBaseFloorsFractionalCap(): void
|
public function testPercentageBaseFloorsFractionalCap(): void
|
||||||
|
|
@ -285,7 +304,6 @@ final class BucketRoomCalculatorTest extends TestCase
|
||||||
);
|
);
|
||||||
|
|
||||||
self::assertSame(3750, $room);
|
self::assertSame(3750, $room);
|
||||||
self::assertIsInt($room);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testPercentageBaseStartingAmountReducesRoom(): void
|
public function testPercentageBaseStartingAmountReducesRoom(): void
|
||||||
|
|
@ -381,6 +399,46 @@ final class BucketRoomCalculatorTest extends TestCase
|
||||||
self::assertSame(0, $room);
|
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) -------------------------------------------------
|
// --- unlimited (overflow) -------------------------------------------------
|
||||||
|
|
||||||
public function testUnlimitedBucketHasInfiniteBaseRoom(): void
|
public function testUnlimitedBucketHasInfiniteBaseRoom(): void
|
||||||
Loading…
Reference in a new issue