412 lines
15 KiB
PHP
412 lines
15 KiB
PHP
<?php
|
|
|
|
namespace Tests\Unit;
|
|
|
|
use App\Enums\BucketAllocationTypeEnum;
|
|
use App\Models\Bucket;
|
|
use App\Models\Scenario;
|
|
use App\Services\Projection\PipelineAllocationService;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Tests\TestCase;
|
|
|
|
class PipelineAllocationServiceTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private PipelineAllocationService $service;
|
|
|
|
private Scenario $scenario;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
$this->service = new PipelineAllocationService;
|
|
$this->scenario = Scenario::factory()->create();
|
|
}
|
|
|
|
public function test_allocates_to_single_fixed_bucket()
|
|
{
|
|
// Arrange: Single bucket with $500 limit
|
|
$bucket = Bucket::factory()->create([
|
|
'scenario_id' => $this->scenario->id,
|
|
'name' => 'Emergency Fund',
|
|
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
|
'allocation_value' => 50000,
|
|
'starting_amount' => 0,
|
|
'priority' => 1,
|
|
]);
|
|
|
|
// Act: Allocate $300
|
|
$draws = $this->service->allocateInflow($this->scenario, 30000);
|
|
|
|
// Assert: All money goes to emergency fund
|
|
$this->assertCount(1, $draws);
|
|
$this->assertEquals($bucket->id, $draws[0]->bucket_id);
|
|
$this->assertEquals(30000, $draws[0]->amount);
|
|
}
|
|
|
|
public function test_allocates_across_multiple_fixed_buckets_by_priority()
|
|
{
|
|
// Arrange: Three buckets with different priorities
|
|
$bucket1 = Bucket::factory()->create([
|
|
'scenario_id' => $this->scenario->id,
|
|
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
|
'allocation_value' => 20000,
|
|
'starting_amount' => 0,
|
|
'priority' => 1,
|
|
]);
|
|
$bucket2 = Bucket::factory()->create([
|
|
'scenario_id' => $this->scenario->id,
|
|
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
|
'allocation_value' => 30000,
|
|
'starting_amount' => 0,
|
|
'priority' => 2,
|
|
]);
|
|
$bucket3 = Bucket::factory()->create([
|
|
'scenario_id' => $this->scenario->id,
|
|
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
|
'allocation_value' => 15000,
|
|
'starting_amount' => 0,
|
|
'priority' => 3,
|
|
]);
|
|
|
|
// Act: Allocate $550 (should fill bucket1 + bucket2 + partial bucket3)
|
|
$draws = $this->service->allocateInflow($this->scenario, 55000);
|
|
|
|
// Assert: Allocation follows priority order
|
|
$this->assertCount(3, $draws);
|
|
|
|
// Bucket 1: fully filled
|
|
$this->assertEquals($bucket1->id, $draws[0]->bucket_id);
|
|
$this->assertEquals(20000, $draws[0]->amount);
|
|
|
|
// Bucket 2: fully filled
|
|
$this->assertEquals($bucket2->id, $draws[1]->bucket_id);
|
|
$this->assertEquals(30000, $draws[1]->amount);
|
|
|
|
// Bucket 3: partially filled
|
|
$this->assertEquals($bucket3->id, $draws[2]->bucket_id);
|
|
$this->assertEquals(5000, $draws[2]->amount);
|
|
}
|
|
|
|
public function test_percentage_bucket_gets_percentage_of_remaining_amount()
|
|
{
|
|
// Arrange: Fixed bucket + percentage bucket
|
|
$fixedBucket = Bucket::factory()->create([
|
|
'scenario_id' => $this->scenario->id,
|
|
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
|
'allocation_value' => 30000,
|
|
'starting_amount' => 0,
|
|
'priority' => 1,
|
|
]);
|
|
$percentageBucket = Bucket::factory()->create([
|
|
'scenario_id' => $this->scenario->id,
|
|
'allocation_type' => BucketAllocationTypeEnum::PERCENTAGE,
|
|
'allocation_value' => 2000, // 20% in basis points
|
|
'starting_amount' => 0,
|
|
'priority' => 2,
|
|
]);
|
|
|
|
// Act: Allocate $1000
|
|
$draws = $this->service->allocateInflow($this->scenario, 100000);
|
|
|
|
// Assert: Fixed gets $300, percentage gets 20% of remaining $700 = $140
|
|
$this->assertCount(2, $draws);
|
|
$this->assertEquals(30000, $draws[0]->amount); // Fixed bucket
|
|
$this->assertEquals(14000, $draws[1]->amount); // 20% of $700
|
|
}
|
|
|
|
public function test_unlimited_bucket_gets_all_remaining_amount()
|
|
{
|
|
// Arrange: Fixed + unlimited buckets
|
|
$fixedBucket = Bucket::factory()->create([
|
|
'scenario_id' => $this->scenario->id,
|
|
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
|
'allocation_value' => 50000,
|
|
'starting_amount' => 0,
|
|
'priority' => 1,
|
|
]);
|
|
$unlimitedBucket = Bucket::factory()->create([
|
|
'scenario_id' => $this->scenario->id,
|
|
'allocation_type' => BucketAllocationTypeEnum::UNLIMITED,
|
|
'allocation_value' => null,
|
|
'starting_amount' => 0,
|
|
'priority' => 2,
|
|
]);
|
|
|
|
// Act: Allocate $1500
|
|
$draws = $this->service->allocateInflow($this->scenario, 150000);
|
|
|
|
// Assert: Fixed gets $500, unlimited gets remaining $1000
|
|
$this->assertCount(2, $draws);
|
|
$this->assertEquals(50000, $draws[0]->amount);
|
|
$this->assertEquals(100000, $draws[1]->amount);
|
|
}
|
|
|
|
public function test_skips_buckets_with_zero_allocation()
|
|
{
|
|
// Arrange: Bucket that can't accept any money
|
|
$fullBucket = Bucket::factory()->create([
|
|
'scenario_id' => $this->scenario->id,
|
|
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
|
'allocation_value' => 0, // No capacity
|
|
'starting_amount' => 0,
|
|
'priority' => 1,
|
|
]);
|
|
$normalBucket = Bucket::factory()->create([
|
|
'scenario_id' => $this->scenario->id,
|
|
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
|
'allocation_value' => 30000,
|
|
'starting_amount' => 0,
|
|
'priority' => 2,
|
|
]);
|
|
|
|
// Act: Allocate $200
|
|
$draws = $this->service->allocateInflow($this->scenario, 20000);
|
|
|
|
// Assert: Only normal bucket gets allocation
|
|
$this->assertCount(1, $draws);
|
|
$this->assertEquals($normalBucket->id, $draws[0]->bucket_id);
|
|
$this->assertEquals(20000, $draws[0]->amount);
|
|
}
|
|
|
|
public function test_handles_complex_mixed_bucket_scenario()
|
|
{
|
|
// Arrange: All bucket types in priority order
|
|
$fixed1 = Bucket::factory()->create([
|
|
'scenario_id' => $this->scenario->id,
|
|
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
|
'allocation_value' => 100000,
|
|
'starting_amount' => 0,
|
|
'priority' => 1,
|
|
]);
|
|
$percentage1 = Bucket::factory()->create([
|
|
'scenario_id' => $this->scenario->id,
|
|
'allocation_type' => BucketAllocationTypeEnum::PERCENTAGE,
|
|
'allocation_value' => 1500, // 15% in basis points
|
|
'starting_amount' => 0,
|
|
'priority' => 2,
|
|
]);
|
|
$fixed2 = Bucket::factory()->create([
|
|
'scenario_id' => $this->scenario->id,
|
|
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
|
'allocation_value' => 50000,
|
|
'starting_amount' => 0,
|
|
'priority' => 3,
|
|
]);
|
|
$percentage2 = Bucket::factory()->create([
|
|
'scenario_id' => $this->scenario->id,
|
|
'allocation_type' => BucketAllocationTypeEnum::PERCENTAGE,
|
|
'allocation_value' => 2500, // 25% in basis points
|
|
'starting_amount' => 0,
|
|
'priority' => 4,
|
|
]);
|
|
$unlimited = Bucket::factory()->create([
|
|
'scenario_id' => $this->scenario->id,
|
|
'allocation_type' => BucketAllocationTypeEnum::UNLIMITED,
|
|
'allocation_value' => null,
|
|
'starting_amount' => 0,
|
|
'priority' => 5,
|
|
]);
|
|
|
|
// Act: Allocate $5000
|
|
$draws = $this->service->allocateInflow($this->scenario, 500000);
|
|
|
|
// Assert: Complex allocation logic
|
|
$this->assertCount(5, $draws);
|
|
|
|
// Fixed1: gets $1000 (full capacity)
|
|
$this->assertEquals(100000, $draws[0]->amount);
|
|
|
|
// Percentage1: gets 15% of remaining $4000 = $600
|
|
$this->assertEquals(60000, $draws[1]->amount);
|
|
|
|
// Fixed2: gets $500 (full capacity)
|
|
$this->assertEquals(50000, $draws[2]->amount);
|
|
|
|
// Remaining after fixed allocations: $5000 - $1000 - $600 - $500 = $2900
|
|
// Percentage2: gets 25% of remaining $2900 = $725
|
|
$this->assertEquals(72500, $draws[3]->amount);
|
|
|
|
// Unlimited: gets remaining $2900 - $725 = $2175
|
|
$this->assertEquals(217500, $draws[4]->amount);
|
|
}
|
|
|
|
public function test_returns_empty_array_when_no_buckets()
|
|
{
|
|
// Act: Allocate to scenario with no buckets
|
|
$draws = $this->service->allocateInflow($this->scenario, 100000);
|
|
|
|
// Assert: No allocations made
|
|
$this->assertEmpty($draws);
|
|
}
|
|
|
|
public function test_returns_empty_array_when_amount_is_zero()
|
|
{
|
|
// Arrange: Create bucket
|
|
Bucket::factory()->create([
|
|
'scenario_id' => $this->scenario->id,
|
|
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
|
'allocation_value' => 50000,
|
|
'starting_amount' => 0,
|
|
'priority' => 1,
|
|
]);
|
|
|
|
// Act: Allocate $0
|
|
$draws = $this->service->allocateInflow($this->scenario, 0);
|
|
|
|
// Assert: No allocations made
|
|
$this->assertEmpty($draws);
|
|
}
|
|
|
|
public function test_handles_negative_amount_gracefully()
|
|
{
|
|
// Arrange: Create bucket
|
|
Bucket::factory()->create([
|
|
'scenario_id' => $this->scenario->id,
|
|
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
|
'allocation_value' => 50000,
|
|
'starting_amount' => 0,
|
|
'priority' => 1,
|
|
]);
|
|
|
|
// Act: Allocate negative amount
|
|
$draws = $this->service->allocateInflow($this->scenario, -10000);
|
|
|
|
// Assert: No allocations made
|
|
$this->assertEmpty($draws);
|
|
}
|
|
|
|
public function test_respects_bucket_priority_order()
|
|
{
|
|
// Arrange: Buckets in non-sequential priority order
|
|
$bucket3 = Bucket::factory()->create([
|
|
'scenario_id' => $this->scenario->id,
|
|
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
|
'allocation_value' => 10000,
|
|
'starting_amount' => 0,
|
|
'priority' => 10, // Higher number
|
|
]);
|
|
$bucket1 = Bucket::factory()->create([
|
|
'scenario_id' => $this->scenario->id,
|
|
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
|
'allocation_value' => 20000,
|
|
'starting_amount' => 0,
|
|
'priority' => 1, // Lower number (higher priority)
|
|
]);
|
|
$bucket2 = Bucket::factory()->create([
|
|
'scenario_id' => $this->scenario->id,
|
|
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
|
'allocation_value' => 15000,
|
|
'starting_amount' => 0,
|
|
'priority' => 5, // Middle
|
|
]);
|
|
|
|
// Act: Allocate $250
|
|
$draws = $this->service->allocateInflow($this->scenario, 25000);
|
|
|
|
// Assert: Priority order respected (1, 5, 10)
|
|
$this->assertCount(2, $draws);
|
|
$this->assertEquals($bucket1->id, $draws[0]->bucket_id); // Priority 1 first
|
|
$this->assertEquals(20000, $draws[0]->amount);
|
|
$this->assertEquals($bucket2->id, $draws[1]->bucket_id); // Priority 5 second
|
|
$this->assertEquals(5000, $draws[1]->amount); // Partial fill
|
|
}
|
|
|
|
public function test_percentage_allocation_with_insufficient_remaining_amount()
|
|
{
|
|
// Arrange: Large fixed bucket + percentage bucket
|
|
$fixedBucket = Bucket::factory()->create([
|
|
'scenario_id' => $this->scenario->id,
|
|
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
|
'allocation_value' => 95000,
|
|
'starting_amount' => 0,
|
|
'priority' => 1,
|
|
]);
|
|
$percentageBucket = Bucket::factory()->create([
|
|
'scenario_id' => $this->scenario->id,
|
|
'allocation_type' => BucketAllocationTypeEnum::PERCENTAGE,
|
|
'allocation_value' => 2000, // 20% in basis points
|
|
'starting_amount' => 0,
|
|
'priority' => 2,
|
|
]);
|
|
|
|
// Act: Allocate $1000 (only $50 left after fixed)
|
|
$draws = $this->service->allocateInflow($this->scenario, 100000);
|
|
|
|
// Assert: Percentage gets 20% of remaining $50 = $10
|
|
$this->assertCount(2, $draws);
|
|
$this->assertEquals(95000, $draws[0]->amount);
|
|
$this->assertEquals(1000, $draws[1]->amount); // 20% of $50
|
|
}
|
|
|
|
public function test_fixed_allocation_respects_buffer_multiplier(): void
|
|
{
|
|
Bucket::factory()->create([
|
|
'scenario_id' => $this->scenario->id,
|
|
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
|
'allocation_value' => 50000,
|
|
'buffer_multiplier' => 1.0,
|
|
'starting_amount' => 0,
|
|
'priority' => 1,
|
|
]);
|
|
|
|
// Effective capacity = 50000 * (1 + 1.0) = 100000
|
|
$draws = $this->service->allocateInflow($this->scenario, 100000);
|
|
|
|
$this->assertCount(1, $draws);
|
|
$this->assertEquals(100000, $draws[0]->amount);
|
|
}
|
|
|
|
public function test_fixed_allocation_with_buffer_partially_filled(): void
|
|
{
|
|
$bucket = Bucket::factory()->create([
|
|
'scenario_id' => $this->scenario->id,
|
|
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
|
'allocation_value' => 50000,
|
|
'buffer_multiplier' => 1.0,
|
|
'starting_amount' => 30000,
|
|
'priority' => 1,
|
|
]);
|
|
|
|
// Effective capacity = 100000, balance = 30000, available = 70000
|
|
$draws = $this->service->allocateInflow($this->scenario, 80000);
|
|
|
|
$this->assertCount(1, $draws);
|
|
$this->assertEquals(70000, $draws[0]->amount);
|
|
}
|
|
|
|
public function test_buffer_does_not_affect_percentage_allocation(): void
|
|
{
|
|
Bucket::factory()->create([
|
|
'scenario_id' => $this->scenario->id,
|
|
'allocation_type' => BucketAllocationTypeEnum::PERCENTAGE,
|
|
'allocation_value' => 2500, // 25% in basis points
|
|
'buffer_multiplier' => 1.0,
|
|
'starting_amount' => 0,
|
|
'priority' => 1,
|
|
]);
|
|
|
|
$draws = $this->service->allocateInflow($this->scenario, 100000);
|
|
|
|
$this->assertCount(1, $draws);
|
|
$this->assertEquals(25000, $draws[0]->amount); // 25% of 100000
|
|
}
|
|
|
|
public function test_buffer_does_not_affect_unlimited_allocation(): void
|
|
{
|
|
Bucket::factory()->create([
|
|
'scenario_id' => $this->scenario->id,
|
|
'allocation_type' => BucketAllocationTypeEnum::UNLIMITED,
|
|
'allocation_value' => null,
|
|
'buffer_multiplier' => 1.0,
|
|
'starting_amount' => 0,
|
|
'priority' => 1,
|
|
]);
|
|
|
|
$draws = $this->service->allocateInflow($this->scenario, 100000);
|
|
|
|
$this->assertCount(1, $draws);
|
|
$this->assertEquals(100000, $draws[0]->amount);
|
|
}
|
|
}
|