345 lines
13 KiB
PHP
345 lines
13 KiB
PHP
<?php
|
|
|
|
namespace Tests\Unit;
|
|
|
|
use App\Enums\BucketAllocationType;
|
|
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' => BucketAllocationType::FIXED_LIMIT,
|
|
'allocation_value' => 500.00,
|
|
'starting_amount' => 0,
|
|
'priority' => 1
|
|
]);
|
|
|
|
// Act: Allocate $300
|
|
$draws = $this->service->allocateInflow($this->scenario, 300.00);
|
|
|
|
// Assert: All money goes to emergency fund
|
|
$this->assertCount(1, $draws);
|
|
$this->assertEquals($bucket->id, $draws[0]->bucket_id);
|
|
$this->assertEquals(300.00, $draws[0]->amount);
|
|
$this->assertEquals(1, $draws[0]->priority_order);
|
|
}
|
|
|
|
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' => BucketAllocationType::FIXED_LIMIT,
|
|
'allocation_value' => 200.00,
|
|
'starting_amount' => 0,
|
|
'priority' => 1
|
|
]);
|
|
$bucket2 = Bucket::factory()->create([
|
|
'scenario_id' => $this->scenario->id,
|
|
'allocation_type' => BucketAllocationType::FIXED_LIMIT,
|
|
'allocation_value' => 300.00,
|
|
'starting_amount' => 0,
|
|
'priority' => 2
|
|
]);
|
|
$bucket3 = Bucket::factory()->create([
|
|
'scenario_id' => $this->scenario->id,
|
|
'allocation_type' => BucketAllocationType::FIXED_LIMIT,
|
|
'allocation_value' => 150.00,
|
|
'starting_amount' => 0,
|
|
'priority' => 3
|
|
]);
|
|
|
|
// Act: Allocate $550 (should fill bucket1 + bucket2 + partial bucket3)
|
|
$draws = $this->service->allocateInflow($this->scenario, 550.00);
|
|
|
|
// Assert: Allocation follows priority order
|
|
$this->assertCount(3, $draws);
|
|
|
|
// Bucket 1: fully filled
|
|
$this->assertEquals($bucket1->id, $draws[0]->bucket_id);
|
|
$this->assertEquals(200.00, $draws[0]->amount);
|
|
$this->assertEquals(1, $draws[0]->priority_order);
|
|
|
|
// Bucket 2: fully filled
|
|
$this->assertEquals($bucket2->id, $draws[1]->bucket_id);
|
|
$this->assertEquals(300.00, $draws[1]->amount);
|
|
$this->assertEquals(2, $draws[1]->priority_order);
|
|
|
|
// Bucket 3: partially filled
|
|
$this->assertEquals($bucket3->id, $draws[2]->bucket_id);
|
|
$this->assertEquals(50.00, $draws[2]->amount);
|
|
$this->assertEquals(3, $draws[2]->priority_order);
|
|
}
|
|
|
|
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' => BucketAllocationType::FIXED_LIMIT,
|
|
'allocation_value' => 300.00,
|
|
'starting_amount' => 0,
|
|
'priority' => 1
|
|
]);
|
|
$percentageBucket = Bucket::factory()->create([
|
|
'scenario_id' => $this->scenario->id,
|
|
'allocation_type' => BucketAllocationType::PERCENTAGE,
|
|
'allocation_value' => 20.00, // 20%
|
|
'starting_amount' => 0,
|
|
'priority' => 2
|
|
]);
|
|
|
|
// Act: Allocate $1000
|
|
$draws = $this->service->allocateInflow($this->scenario, 1000.00);
|
|
|
|
// Assert: Fixed gets $300, percentage gets 20% of remaining $700 = $140
|
|
$this->assertCount(2, $draws);
|
|
$this->assertEquals(300.00, $draws[0]->amount); // Fixed bucket
|
|
$this->assertEquals(140.00, $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' => BucketAllocationType::FIXED_LIMIT,
|
|
'allocation_value' => 500.00,
|
|
'starting_amount' => 0,
|
|
'priority' => 1
|
|
]);
|
|
$unlimitedBucket = Bucket::factory()->create([
|
|
'scenario_id' => $this->scenario->id,
|
|
'allocation_type' => BucketAllocationType::UNLIMITED,
|
|
'allocation_value' => null,
|
|
'starting_amount' => 0,
|
|
'priority' => 2
|
|
]);
|
|
|
|
// Act: Allocate $1500
|
|
$draws = $this->service->allocateInflow($this->scenario, 1500.00);
|
|
|
|
// Assert: Fixed gets $500, unlimited gets remaining $1000
|
|
$this->assertCount(2, $draws);
|
|
$this->assertEquals(500.00, $draws[0]->amount);
|
|
$this->assertEquals(1000.00, $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' => BucketAllocationType::FIXED_LIMIT,
|
|
'allocation_value' => 0, // No capacity
|
|
'starting_amount' => 0,
|
|
'priority' => 1
|
|
]);
|
|
$normalBucket = Bucket::factory()->create([
|
|
'scenario_id' => $this->scenario->id,
|
|
'allocation_type' => BucketAllocationType::FIXED_LIMIT,
|
|
'allocation_value' => 300.00,
|
|
'starting_amount' => 0,
|
|
'priority' => 2
|
|
]);
|
|
|
|
// Act: Allocate $200
|
|
$draws = $this->service->allocateInflow($this->scenario, 200.00);
|
|
|
|
// Assert: Only normal bucket gets allocation
|
|
$this->assertCount(1, $draws);
|
|
$this->assertEquals($normalBucket->id, $draws[0]->bucket_id);
|
|
$this->assertEquals(200.00, $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' => BucketAllocationType::FIXED_LIMIT,
|
|
'allocation_value' => 1000.00,
|
|
'starting_amount' => 0,
|
|
'priority' => 1
|
|
]);
|
|
$percentage1 = Bucket::factory()->create([
|
|
'scenario_id' => $this->scenario->id,
|
|
'allocation_type' => BucketAllocationType::PERCENTAGE,
|
|
'allocation_value' => 15.00, // 15%
|
|
'starting_amount' => 0,
|
|
'priority' => 2
|
|
]);
|
|
$fixed2 = Bucket::factory()->create([
|
|
'scenario_id' => $this->scenario->id,
|
|
'allocation_type' => BucketAllocationType::FIXED_LIMIT,
|
|
'allocation_value' => 500.00,
|
|
'starting_amount' => 0,
|
|
'priority' => 3
|
|
]);
|
|
$percentage2 = Bucket::factory()->create([
|
|
'scenario_id' => $this->scenario->id,
|
|
'allocation_type' => BucketAllocationType::PERCENTAGE,
|
|
'allocation_value' => 25.00, // 25%
|
|
'starting_amount' => 0,
|
|
'priority' => 4
|
|
]);
|
|
$unlimited = Bucket::factory()->create([
|
|
'scenario_id' => $this->scenario->id,
|
|
'allocation_type' => BucketAllocationType::UNLIMITED,
|
|
'allocation_value' => null,
|
|
'starting_amount' => 0,
|
|
'priority' => 5
|
|
]);
|
|
|
|
// Act: Allocate $5000
|
|
$draws = $this->service->allocateInflow($this->scenario, 5000.00);
|
|
|
|
// Assert: Complex allocation logic
|
|
$this->assertCount(5, $draws);
|
|
|
|
// Fixed1: gets $1000 (full capacity)
|
|
$this->assertEquals(1000.00, $draws[0]->amount);
|
|
|
|
// Percentage1: gets 15% of remaining $4000 = $600
|
|
$this->assertEquals(600.00, $draws[1]->amount);
|
|
|
|
// Fixed2: gets $500 (full capacity)
|
|
$this->assertEquals(500.00, $draws[2]->amount);
|
|
|
|
// Remaining after fixed allocations: $5000 - $1000 - $600 - $500 = $2900
|
|
// Percentage2: gets 25% of remaining $2900 = $725
|
|
$this->assertEquals(725.00, $draws[3]->amount);
|
|
|
|
// Unlimited: gets remaining $2900 - $725 = $2175
|
|
$this->assertEquals(2175.00, $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, 1000.00);
|
|
|
|
// 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' => BucketAllocationType::FIXED_LIMIT,
|
|
'allocation_value' => 500.00,
|
|
'starting_amount' => 0,
|
|
'priority' => 1
|
|
]);
|
|
|
|
// Act: Allocate $0
|
|
$draws = $this->service->allocateInflow($this->scenario, 0.00);
|
|
|
|
// 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' => BucketAllocationType::FIXED_LIMIT,
|
|
'allocation_value' => 500.00,
|
|
'starting_amount' => 0,
|
|
'priority' => 1
|
|
]);
|
|
|
|
// Act: Allocate negative amount
|
|
$draws = $this->service->allocateInflow($this->scenario, -100.00);
|
|
|
|
// 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' => BucketAllocationType::FIXED_LIMIT,
|
|
'allocation_value' => 100.00,
|
|
'starting_amount' => 0,
|
|
'priority' => 10 // Higher number
|
|
]);
|
|
$bucket1 = Bucket::factory()->create([
|
|
'scenario_id' => $this->scenario->id,
|
|
'allocation_type' => BucketAllocationType::FIXED_LIMIT,
|
|
'allocation_value' => 200.00,
|
|
'starting_amount' => 0,
|
|
'priority' => 1 // Lower number (higher priority)
|
|
]);
|
|
$bucket2 = Bucket::factory()->create([
|
|
'scenario_id' => $this->scenario->id,
|
|
'allocation_type' => BucketAllocationType::FIXED_LIMIT,
|
|
'allocation_value' => 150.00,
|
|
'starting_amount' => 0,
|
|
'priority' => 5 // Middle
|
|
]);
|
|
|
|
// Act: Allocate $250
|
|
$draws = $this->service->allocateInflow($this->scenario, 250.00);
|
|
|
|
// Assert: Priority order respected (1, 5, 10)
|
|
$this->assertCount(2, $draws);
|
|
$this->assertEquals($bucket1->id, $draws[0]->bucket_id); // Priority 1 first
|
|
$this->assertEquals(200.00, $draws[0]->amount);
|
|
$this->assertEquals($bucket2->id, $draws[1]->bucket_id); // Priority 5 second
|
|
$this->assertEquals(50.00, $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' => BucketAllocationType::FIXED_LIMIT,
|
|
'allocation_value' => 950.00,
|
|
'starting_amount' => 0,
|
|
'priority' => 1
|
|
]);
|
|
$percentageBucket = Bucket::factory()->create([
|
|
'scenario_id' => $this->scenario->id,
|
|
'allocation_type' => BucketAllocationType::PERCENTAGE,
|
|
'allocation_value' => 20.00, // 20%
|
|
'starting_amount' => 0,
|
|
'priority' => 2
|
|
]);
|
|
|
|
// Act: Allocate $1000 (only $50 left after fixed)
|
|
$draws = $this->service->allocateInflow($this->scenario, 1000.00);
|
|
|
|
// Assert: Percentage gets 20% of remaining $50 = $10
|
|
$this->assertCount(2, $draws);
|
|
$this->assertEquals(950.00, $draws[0]->amount);
|
|
$this->assertEquals(10.00, $draws[1]->amount); // 20% of $50
|
|
}
|
|
}
|