From 246ca69f40b720e1f9192d18c5d1d193d82b8942 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Sat, 21 Mar 2026 23:55:21 +0100 Subject: [PATCH] 17 - Rewrite tests for phased distribution service --- tests/Unit/PipelineAllocationServiceTest.php | 792 ++++++++++-------- tests/Unit/ProjectionGeneratorServiceTest.php | 17 +- 2 files changed, 465 insertions(+), 344 deletions(-) diff --git a/tests/Unit/PipelineAllocationServiceTest.php b/tests/Unit/PipelineAllocationServiceTest.php index f5ad68a..f397133 100644 --- a/tests/Unit/PipelineAllocationServiceTest.php +++ b/tests/Unit/PipelineAllocationServiceTest.php @@ -3,6 +3,7 @@ namespace Tests\Unit; use App\Enums\BucketAllocationTypeEnum; +use App\Enums\BucketTypeEnum; use App\Models\Bucket; use App\Models\Scenario; use App\Services\Projection\PipelineAllocationService; @@ -24,389 +25,516 @@ protected function setUp(): void $this->scenario = Scenario::factory()->create(); } - public function test_allocates_to_single_fixed_bucket() + // ────────────────────────────────────────────────────── + // Guard clauses + // ────────────────────────────────────────────────────── + + public function test_returns_empty_collection_when_no_buckets(): void { - // 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() + public function test_returns_empty_collection_when_amount_is_zero(): void { - // Arrange: Create bucket - Bucket::factory()->create([ - 'scenario_id' => $this->scenario->id, - 'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT, - 'allocation_value' => 50000, - 'starting_amount' => 0, - 'priority' => 1, - ]); + $this->createNeedBucket(50000, 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() + public function test_returns_empty_collection_when_amount_is_negative(): void { - // Arrange: Create bucket - Bucket::factory()->create([ - 'scenario_id' => $this->scenario->id, - 'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT, - 'allocation_value' => 50000, - 'starting_amount' => 0, - 'priority' => 1, - ]); + $this->createNeedBucket(50000, 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() + // ────────────────────────────────────────────────────── + // Even mode — base phase + // ────────────────────────────────────────────────────── + + public function test_even_mode_splits_evenly_across_need_buckets(): void { - // 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 - ]); + // 3 need buckets, $500 base each, $900 income → $300 each + $b1 = $this->createNeedBucket(50000, priority: 1); + $b2 = $this->createNeedBucket(50000, priority: 2); + $b3 = $this->createNeedBucket(50000, priority: 3); - // Act: Allocate $250 - $draws = $this->service->allocateInflow($this->scenario, 25000); + $draws = $this->service->allocateInflow($this->scenario, 90000); - // 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 + $this->assertCount(3, $draws); + $this->assertDrawAmount($draws, $b1->id, 30000); + $this->assertDrawAmount($draws, $b2->id, 30000); + $this->assertDrawAmount($draws, $b3->id, 30000); } - public function test_percentage_allocation_with_insufficient_remaining_amount() + public function test_even_mode_redistributes_when_bucket_fills_up(): void { - // 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, - ]); + // Bucket A: $200 base, Bucket B: $500 base, Bucket C: $500 base + // $900 income → even share = $300 each → A caps at $200, excess $100 redistributed + // B and C get $300 + $50 = $350 each + $a = $this->createNeedBucket(20000, priority: 1); + $b = $this->createNeedBucket(50000, priority: 2); + $c = $this->createNeedBucket(50000, priority: 3); - // Act: Allocate $1000 (only $50 left after fixed) - $draws = $this->service->allocateInflow($this->scenario, 100000); + $draws = $this->service->allocateInflow($this->scenario, 90000); - // 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 + $this->assertCount(3, $draws); + $this->assertDrawAmount($draws, $a->id, 20000); + $this->assertDrawAmount($draws, $b->id, 35000); + $this->assertDrawAmount($draws, $c->id, 35000); } - public function test_fixed_allocation_respects_buffer_multiplier(): void + public function test_even_mode_needs_filled_before_wants(): 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, - ]); + // 2 need buckets ($500 base each), 1 want bucket ($500 base) + // $800 income → needs get $400 each (even split), wants get $0 + $n1 = $this->createNeedBucket(50000, priority: 1); + $n2 = $this->createNeedBucket(50000, priority: 2); + $w1 = $this->createWantBucket(50000, priority: 3); - // 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); + $this->assertCount(2, $draws); + $this->assertDrawAmount($draws, $n1->id, 40000); + $this->assertDrawAmount($draws, $n2->id, 40000); } - public function test_buffer_does_not_affect_percentage_allocation(): void + public function test_even_mode_wants_filled_after_needs_base(): void { + // 1 need ($300 base), 2 wants ($500 base each) + // $1000 income → need gets $300 (base), wants split remaining $700 = $350 each + $n = $this->createNeedBucket(30000, priority: 1); + $w1 = $this->createWantBucket(50000, priority: 2); + $w2 = $this->createWantBucket(50000, priority: 3); + + $draws = $this->service->allocateInflow($this->scenario, 100000); + + $this->assertCount(3, $draws); + $this->assertDrawAmount($draws, $n->id, 30000); + $this->assertDrawAmount($draws, $w1->id, 35000); + $this->assertDrawAmount($draws, $w2->id, 35000); + } + + public function test_even_mode_remainder_cents_go_to_highest_priority(): void + { + // 3 need buckets, $500 base each, $100 income → $33/$33/$34 or similar + // intdiv(10000, 3) = 3333, remainder = 1 → first bucket gets 3334 + $b1 = $this->createNeedBucket(50000, priority: 1); + $b2 = $this->createNeedBucket(50000, priority: 2); + $b3 = $this->createNeedBucket(50000, priority: 3); + + $draws = $this->service->allocateInflow($this->scenario, 10000); + + $this->assertCount(3, $draws); + $this->assertDrawAmount($draws, $b1->id, 3334); + $this->assertDrawAmount($draws, $b2->id, 3333); + $this->assertDrawAmount($draws, $b3->id, 3333); + } + + public function test_even_mode_fewer_cents_than_buckets(): void + { + // 3 need buckets, 2 cents income → first 2 get 1 cent each, third gets nothing + $b1 = $this->createNeedBucket(50000, priority: 1); + $b2 = $this->createNeedBucket(50000, priority: 2); + $b3 = $this->createNeedBucket(50000, priority: 3); + + $draws = $this->service->allocateInflow($this->scenario, 2); + + $this->assertCount(2, $draws); + $this->assertDrawAmount($draws, $b1->id, 1); + $this->assertDrawAmount($draws, $b2->id, 1); + } + + // ────────────────────────────────────────────────────── + // Priority mode — base phase + // ────────────────────────────────────────────────────── + + public function test_priority_mode_fills_highest_priority_first(): void + { + $scenario = Scenario::factory()->priority()->create(); + + $n1 = $this->createNeedBucket(50000, priority: 1, scenarioId: $scenario->id); + $n2 = $this->createNeedBucket(50000, priority: 2, scenarioId: $scenario->id); + $w1 = $this->createWantBucket(50000, priority: 3, scenarioId: $scenario->id); + + // $800 → need1 gets $500, need2 gets $300, want gets $0 + $draws = $this->service->allocateInflow($scenario, 80000); + + $this->assertCount(2, $draws); + $this->assertDrawAmount($draws, $n1->id, 50000); + $this->assertDrawAmount($draws, $n2->id, 30000); + } + + public function test_priority_mode_needs_before_wants(): void + { + $scenario = Scenario::factory()->priority()->create(); + + $n1 = $this->createNeedBucket(50000, priority: 1, scenarioId: $scenario->id); + $n2 = $this->createNeedBucket(30000, priority: 2, scenarioId: $scenario->id); + $w1 = $this->createWantBucket(50000, priority: 3, scenarioId: $scenario->id); + + // $1000 → need1 $500, need2 $300, want1 gets remaining $200 + $draws = $this->service->allocateInflow($scenario, 100000); + + $this->assertCount(3, $draws); + $this->assertDrawAmount($draws, $n1->id, 50000); + $this->assertDrawAmount($draws, $n2->id, 30000); + $this->assertDrawAmount($draws, $w1->id, 20000); + } + + // ────────────────────────────────────────────────────── + // Buffer phases + // ────────────────────────────────────────────────────── + + public function test_base_filled_before_buffer(): void + { + // Need: $500 base, 0.5x buffer → $750 effective capacity + // $2000 income → base phase: $500, buffer phase: $250, overflow: $1250 + $n = $this->createNeedBucket(50000, priority: 1, buffer: 0.5); + $overflow = $this->createOverflowBucket(priority: 2); + + $draws = $this->service->allocateInflow($this->scenario, 200000); + + $this->assertCount(2, $draws); + $this->assertDrawAmount($draws, $n->id, 75000); // base $500 + buffer $250 + $this->assertDrawAmount($draws, $overflow->id, 125000); + } + + public function test_need_buffer_filled_before_want_buffer(): void + { + // Need: $500 base, 1x buffer → $1000 effective + // Want: $500 base, 1x buffer → $1000 effective + // $1800 → need base $500, want base $500, need buffer $500, want gets $300 buffer + $n = $this->createNeedBucket(50000, priority: 1, buffer: 1.0); + $w = $this->createWantBucket(50000, priority: 2, buffer: 1.0); + + $draws = $this->service->allocateInflow($this->scenario, 180000); + + $this->assertCount(2, $draws); + $this->assertDrawAmount($draws, $n->id, 100000); // full: base + buffer + $this->assertDrawAmount($draws, $w->id, 80000); // base $500 + $300 buffer + } + + public function test_priority_mode_buffer_fills_by_priority(): void + { + $scenario = Scenario::factory()->priority()->create(); + + // Need1: $300 base, 1x buffer → $600 effective + // Need2: $300 base, 1x buffer → $600 effective + // $1000 income + $n1 = $this->createNeedBucket(30000, priority: 1, buffer: 1.0, scenarioId: $scenario->id); + $n2 = $this->createNeedBucket(30000, priority: 2, buffer: 1.0, scenarioId: $scenario->id); + + $draws = $this->service->allocateInflow($scenario, 100000); + + // Phase 1 (needs base, priority): n1=$300, n2=$300, remaining=$400 + // Phase 3 (needs buffer, priority): n1 buffer=$300, n2 gets $100, remaining=$0 + $this->assertCount(2, $draws); + $this->assertDrawAmount($draws, $n1->id, 60000); // full base + full buffer + $this->assertDrawAmount($draws, $n2->id, 40000); // full base + $100 buffer + } + + public function test_no_buffer_when_multiplier_is_zero(): void + { + // Need: $500 base, 0x buffer → $500 effective + // $800 → need gets $500, overflow gets $300 + $n = $this->createNeedBucket(50000, priority: 1, buffer: 0); + $overflow = $this->createOverflowBucket(priority: 2); + + $draws = $this->service->allocateInflow($this->scenario, 80000); + + $this->assertCount(2, $draws); + $this->assertDrawAmount($draws, $n->id, 50000); + $this->assertDrawAmount($draws, $overflow->id, 30000); + } + + public function test_partially_filled_bucket_respects_existing_balance(): void + { + // Need: $500 base, already has $300 → only $200 space in base phase + $n = $this->createNeedBucket(50000, priority: 1, startingAmount: 30000); + $overflow = $this->createOverflowBucket(priority: 2); + + $draws = $this->service->allocateInflow($this->scenario, 80000); + + $this->assertCount(2, $draws); + $this->assertDrawAmount($draws, $n->id, 20000); + $this->assertDrawAmount($draws, $overflow->id, 60000); + } + + // ────────────────────────────────────────────────────── + // Overflow + // ────────────────────────────────────────────────────── + + public function test_overflow_captures_remainder(): void + { + $n = $this->createNeedBucket(30000, priority: 1); + $overflow = $this->createOverflowBucket(priority: 2); + + $draws = $this->service->allocateInflow($this->scenario, 100000); + + $this->assertCount(2, $draws); + $this->assertDrawAmount($draws, $n->id, 30000); + $this->assertDrawAmount($draws, $overflow->id, 70000); + } + + public function test_all_buckets_full_everything_to_overflow(): void + { + $n = $this->createNeedBucket(10000, priority: 1, startingAmount: 10000); // already full + $w = $this->createWantBucket(10000, priority: 2, startingAmount: 10000); // already full + $overflow = $this->createOverflowBucket(priority: 3); + + $draws = $this->service->allocateInflow($this->scenario, 50000); + + $this->assertCount(1, $draws); + $this->assertDrawAmount($draws, $overflow->id, 50000); + } + + // ────────────────────────────────────────────────────── + // Edge cases + // ────────────────────────────────────────────────────── + + public function test_no_need_buckets_goes_straight_to_wants(): void + { + $w1 = $this->createWantBucket(50000, priority: 1); + $w2 = $this->createWantBucket(50000, priority: 2); + + $draws = $this->service->allocateInflow($this->scenario, 60000); + + $this->assertCount(2, $draws); + $this->assertDrawAmount($draws, $w1->id, 30000); + $this->assertDrawAmount($draws, $w2->id, 30000); + } + + public function test_single_need_bucket_gets_all_up_to_capacity(): void + { + $n = $this->createNeedBucket(50000, priority: 1); + + $draws = $this->service->allocateInflow($this->scenario, 30000); + + $this->assertCount(1, $draws); + $this->assertDrawAmount($draws, $n->id, 30000); + } + + public function test_allocates_to_single_need_bucket_with_buffer(): void + { + // $500 base, 1x buffer → $1000 effective, $1000 income → gets all + $n = $this->createNeedBucket(50000, priority: 1, buffer: 1.0); + + $draws = $this->service->allocateInflow($this->scenario, 100000); + + $this->assertCount(1, $draws); + $this->assertDrawAmount($draws, $n->id, 100000); + } + + public function test_skips_buckets_with_zero_allocation_value(): void + { + $zero = $this->createNeedBucket(0, priority: 1); + $normal = $this->createNeedBucket(30000, priority: 2); + + $draws = $this->service->allocateInflow($this->scenario, 20000); + + $this->assertCount(1, $draws); + $this->assertDrawAmount($draws, $normal->id, 20000); + } + + public function test_non_fixed_limit_buckets_are_skipped_in_phases(): void + { + // Percentage bucket as need → gets nothing (no meaningful cap in phased distribution) Bucket::factory()->create([ 'scenario_id' => $this->scenario->id, + 'type' => BucketTypeEnum::NEED, 'allocation_type' => BucketAllocationTypeEnum::PERCENTAGE, - 'allocation_value' => 2500, // 25% in basis points - 'buffer_multiplier' => 1.0, + 'allocation_value' => 2500, 'starting_amount' => 0, 'priority' => 1, ]); + $overflow = $this->createOverflowBucket(priority: 2); - $draws = $this->service->allocateInflow($this->scenario, 100000); + $draws = $this->service->allocateInflow($this->scenario, 50000); $this->assertCount(1, $draws); - $this->assertEquals(25000, $draws[0]->amount); // 25% of 100000 + $this->assertDrawAmount($draws, $overflow->id, 50000); } - public function test_buffer_does_not_affect_unlimited_allocation(): void + public function test_surplus_is_lost_when_no_overflow_bucket(): 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, - ]); + // $500 income, only $300 capacity → $200 surplus has nowhere to go + $n = $this->createNeedBucket(30000, priority: 1); - $draws = $this->service->allocateInflow($this->scenario, 100000); + $draws = $this->service->allocateInflow($this->scenario, 50000); $this->assertCount(1, $draws); - $this->assertEquals(100000, $draws[0]->amount); + $this->assertDrawAmount($draws, $n->id, 30000); + // Remaining $200 is silently dropped — no overflow bucket to catch it + } + + public function test_priority_order_respected_regardless_of_creation_order(): void + { + $scenario = Scenario::factory()->priority()->create(); + + // Create in reverse priority order + $b3 = $this->createNeedBucket(10000, priority: 10, scenarioId: $scenario->id); + $b1 = $this->createNeedBucket(20000, priority: 1, scenarioId: $scenario->id); + $b2 = $this->createNeedBucket(15000, priority: 5, scenarioId: $scenario->id); + + $draws = $this->service->allocateInflow($scenario, 25000); + + $this->assertCount(2, $draws); + $this->assertDrawAmount($draws, $b1->id, 20000); + $this->assertDrawAmount($draws, $b2->id, 5000); + } + + // ────────────────────────────────────────────────────── + // Full pipeline integration + // ────────────────────────────────────────────────────── + + public function test_full_pipeline_even_mode(): void + { + // Need1: $500 base, 0.5x buffer → $750 effective + // Need2: $200 base, no buffer → $200 effective + // Want1: $400 base, no buffer → $400 effective + // Overflow + // $900 income — not enough to fill everything + $n1 = $this->createNeedBucket(50000, priority: 1, buffer: 0.5); + $n2 = $this->createNeedBucket(20000, priority: 2); + $w1 = $this->createWantBucket(40000, priority: 3); + $overflow = $this->createOverflowBucket(priority: 4); + + $draws = $this->service->allocateInflow($this->scenario, 90000); + + // Phase 1 (needs base, even): n1=$500 (capped), n2=$200 (capped), remaining=$200 + // Phase 2 (wants base, even): w1=$200 (partial of $400), remaining=$0 + + $this->assertCount(3, $draws); + $this->assertDrawAmount($draws, $n1->id, 50000); // $500 base + $this->assertDrawAmount($draws, $n2->id, 20000); // $200 base + $this->assertDrawAmount($draws, $w1->id, 20000); // partial $200 of $400 + } + + public function test_full_pipeline_priority_mode(): void + { + $scenario = Scenario::factory()->priority()->create(); + + // Need1: $500 base, 0.5x buffer → $750 effective + // Need2: $400 base, no buffer → $400 effective + // Want1: $300 base, no buffer → $300 effective + // Overflow + // $600 income — not enough to fill all need bases + $n1 = $this->createNeedBucket(50000, priority: 1, buffer: 0.5, scenarioId: $scenario->id); + $n2 = $this->createNeedBucket(40000, priority: 2, scenarioId: $scenario->id); + $w1 = $this->createWantBucket(30000, priority: 3, scenarioId: $scenario->id); + $overflow = $this->createOverflowBucket(priority: 4, scenarioId: $scenario->id); + + $draws = $this->service->allocateInflow($scenario, 60000); + + // Phase 1 (needs base, priority): n1=$500, n2=$100, remaining=$0 + // Even mode would split $600/2=$300 each — priority concentrates on n1 first + $this->assertCount(2, $draws); + $this->assertDrawAmount($draws, $n1->id, 50000); + $this->assertDrawAmount($draws, $n2->id, 10000); + } + + public function test_even_and_priority_modes_produce_different_results(): void + { + // 2 need buckets: $500 and $300 base, $600 income + // Even: $300 each → both fit. n1=$300, n2=$300. + // Priority: n1 gets $500, n2 gets $100. + $n1Even = $this->createNeedBucket(50000, priority: 1); + $n2Even = $this->createNeedBucket(30000, priority: 2); + + $evenDraws = $this->service->allocateInflow($this->scenario, 60000); + + $this->assertCount(2, $evenDraws); + $this->assertDrawAmount($evenDraws, $n1Even->id, 30000); // even: $300 each + $this->assertDrawAmount($evenDraws, $n2Even->id, 30000); + + // Same setup with priority mode + $priorityScenario = Scenario::factory()->priority()->create(); + $n1Prio = $this->createNeedBucket(50000, priority: 1, scenarioId: $priorityScenario->id); + $n2Prio = $this->createNeedBucket(30000, priority: 2, scenarioId: $priorityScenario->id); + + $prioDraws = $this->service->allocateInflow($priorityScenario, 60000); + + $this->assertCount(2, $prioDraws); + $this->assertDrawAmount($prioDraws, $n1Prio->id, 50000); // priority: fills first + $this->assertDrawAmount($prioDraws, $n2Prio->id, 10000); // gets remainder + } + + public function test_full_pipeline_flows_through_all_five_phases(): void + { + // Need: $200 base, 1x buffer → $400 effective + // Want: $100 base, 1x buffer → $200 effective + // Overflow + // $800 income → enough to fill everything and hit overflow + $n = $this->createNeedBucket(20000, priority: 1, buffer: 1.0); + $w = $this->createWantBucket(10000, priority: 2, buffer: 1.0); + $overflow = $this->createOverflowBucket(priority: 3); + + $draws = $this->service->allocateInflow($this->scenario, 80000); + + // Phase 1 (needs base): n=$200, remaining=$600 + // Phase 2 (wants base): w=$100, remaining=$500 + // Phase 3 (needs buffer): n=$200 buffer, remaining=$300 + // Phase 4 (wants buffer): w=$100 buffer, remaining=$200 + // Phase 5 (overflow): $200 + $this->assertCount(3, $draws); + $this->assertDrawAmount($draws, $n->id, 40000); // $200 base + $200 buffer + $this->assertDrawAmount($draws, $w->id, 20000); // $100 base + $100 buffer + $this->assertDrawAmount($draws, $overflow->id, 20000); // remainder + } + + // ────────────────────────────────────────────────────── + // Helpers + // ────────────────────────────────────────────────────── + + private function createNeedBucket( + int $allocationValue, + int $priority, + float $buffer = 0, + int $startingAmount = 0, + ?int $scenarioId = null, + ): Bucket { + return Bucket::factory()->need()->fixedLimit($allocationValue)->create([ + 'scenario_id' => $scenarioId ?? $this->scenario->id, + 'priority' => $priority, + 'buffer_multiplier' => $buffer, + 'starting_amount' => $startingAmount, + ]); + } + + private function createWantBucket( + int $allocationValue, + int $priority, + float $buffer = 0, + int $startingAmount = 0, + ?int $scenarioId = null, + ): Bucket { + return Bucket::factory()->want()->fixedLimit($allocationValue)->create([ + 'scenario_id' => $scenarioId ?? $this->scenario->id, + 'priority' => $priority, + 'buffer_multiplier' => $buffer, + 'starting_amount' => $startingAmount, + ]); + } + + private function createOverflowBucket(int $priority, ?int $scenarioId = null): Bucket + { + return Bucket::factory()->overflow()->create([ + 'scenario_id' => $scenarioId ?? $this->scenario->id, + 'priority' => $priority, + ]); + } + + private function assertDrawAmount($draws, int $bucketId, int $expectedAmount): void + { + $draw = $draws->first(fn ($d) => $d->bucket_id === $bucketId); + $this->assertNotNull($draw, "No draw found for bucket {$bucketId}"); + $this->assertEquals($expectedAmount, $draw->amount, "Draw amount mismatch for bucket {$bucketId}"); } } diff --git a/tests/Unit/ProjectionGeneratorServiceTest.php b/tests/Unit/ProjectionGeneratorServiceTest.php index 1412154..c8eb596 100644 --- a/tests/Unit/ProjectionGeneratorServiceTest.php +++ b/tests/Unit/ProjectionGeneratorServiceTest.php @@ -99,18 +99,14 @@ public function test_generates_weekly_expense_stream_projections() public function test_allocates_income_immediately_to_buckets() { // Arrange: Income stream and buckets with priority - $bucket1 = Bucket::factory()->create([ + $bucket1 = Bucket::factory()->need()->fixedLimit(30000)->create([ 'scenario_id' => $this->scenario->id, - 'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT, - 'allocation_value' => 30000, 'starting_amount' => 0, 'priority' => 1, ]); - $bucket2 = Bucket::factory()->create([ + $bucket2 = Bucket::factory()->overflow()->create([ 'scenario_id' => $this->scenario->id, - 'allocation_type' => BucketAllocationTypeEnum::UNLIMITED, - 'allocation_value' => null, 'starting_amount' => 0, 'priority' => 2, ]); @@ -196,9 +192,8 @@ public function test_handles_monthly_streams_correctly() public function test_processes_multiple_streams_on_same_day() { // Arrange: Two income streams that fire on the same day - $bucket = Bucket::factory()->create([ + $bucket = Bucket::factory()->overflow()->create([ 'scenario_id' => $this->scenario->id, - 'allocation_type' => BucketAllocationTypeEnum::UNLIMITED, 'starting_amount' => 0, 'priority' => 1, ]); @@ -235,9 +230,8 @@ public function test_processes_multiple_streams_on_same_day() public function test_handles_mixed_income_and_expense_streams() { // Arrange: Income and expense streams - $bucket = Bucket::factory()->create([ + $bucket = Bucket::factory()->overflow()->create([ 'scenario_id' => $this->scenario->id, - 'allocation_type' => BucketAllocationTypeEnum::UNLIMITED, 'starting_amount' => 0, 'priority' => 1, ]); @@ -277,9 +271,8 @@ public function test_handles_mixed_income_and_expense_streams() public function test_summary_calculations_are_accurate() { // Arrange: Income and expense streams - $bucket = Bucket::factory()->create([ + $bucket = Bucket::factory()->overflow()->create([ 'scenario_id' => $this->scenario->id, - 'allocation_type' => BucketAllocationTypeEnum::UNLIMITED, 'starting_amount' => 0, 'priority' => 1, ]);