buckets/tests/Service/Allocation/BucketRoomCalculatorTest.php

511 lines
18 KiB
PHP

<?php
namespace App\Tests\Service\Allocation;
use App\Entity\Bucket;
use App\Entity\Scenario;
use App\Enum\BucketAllocationType;
use App\Enum\BucketType;
use App\Enum\FillStage;
use App\Service\Allocation\BucketRoomCalculator;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
/**
* Pins BucketRoomCalculator::roomFor(Bucket $bucket, FillStage $stage, int $income, int $alreadyAllocated): int
* -- pure, per-bucket "how much more room is left" calculation.
*
* room = max(0, cap - startingAmount - alreadyAllocated)
*
* The cap (internal to roomFor) by allocationType x capKind:
* FIXED_LIMIT, base -> 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');
}
}