2025-12-31 01:56:50 +01:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace Tests\Unit;
|
|
|
|
|
|
2025-12-31 02:34:30 +01:00
|
|
|
use App\Enums\BucketAllocationTypeEnum;
|
2026-03-21 23:55:21 +01:00
|
|
|
use App\Enums\BucketTypeEnum;
|
2025-12-31 01:56:50 +01:00
|
|
|
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;
|
2026-03-19 01:09:47 +01:00
|
|
|
|
2025-12-31 01:56:50 +01:00
|
|
|
private Scenario $scenario;
|
|
|
|
|
|
|
|
|
|
protected function setUp(): void
|
|
|
|
|
{
|
|
|
|
|
parent::setUp();
|
2026-03-19 01:09:47 +01:00
|
|
|
$this->service = new PipelineAllocationService;
|
2025-12-31 01:56:50 +01:00
|
|
|
$this->scenario = Scenario::factory()->create();
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-21 23:55:21 +01:00
|
|
|
// ──────────────────────────────────────────────────────
|
|
|
|
|
// Guard clauses
|
|
|
|
|
// ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
public function test_returns_empty_collection_when_no_buckets(): void
|
2025-12-31 01:56:50 +01:00
|
|
|
{
|
2026-03-21 23:55:21 +01:00
|
|
|
$draws = $this->service->allocateInflow($this->scenario, 100000);
|
2025-12-31 01:56:50 +01:00
|
|
|
|
2026-03-21 23:55:21 +01:00
|
|
|
$this->assertEmpty($draws);
|
|
|
|
|
}
|
2025-12-31 01:56:50 +01:00
|
|
|
|
2026-03-21 23:55:21 +01:00
|
|
|
public function test_returns_empty_collection_when_amount_is_zero(): void
|
|
|
|
|
{
|
|
|
|
|
$this->createNeedBucket(50000, priority: 1);
|
|
|
|
|
|
|
|
|
|
$draws = $this->service->allocateInflow($this->scenario, 0);
|
|
|
|
|
|
|
|
|
|
$this->assertEmpty($draws);
|
2025-12-31 01:56:50 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-21 23:55:21 +01:00
|
|
|
public function test_returns_empty_collection_when_amount_is_negative(): void
|
2025-12-31 01:56:50 +01:00
|
|
|
{
|
2026-03-21 23:55:21 +01:00
|
|
|
$this->createNeedBucket(50000, priority: 1);
|
2025-12-31 01:56:50 +01:00
|
|
|
|
2026-03-21 23:55:21 +01:00
|
|
|
$draws = $this->service->allocateInflow($this->scenario, -10000);
|
|
|
|
|
|
|
|
|
|
$this->assertEmpty($draws);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ──────────────────────────────────────────────────────
|
|
|
|
|
// Even mode — base phase
|
|
|
|
|
// ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
public function test_even_mode_splits_evenly_across_need_buckets(): void
|
|
|
|
|
{
|
|
|
|
|
// 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);
|
|
|
|
|
|
|
|
|
|
$draws = $this->service->allocateInflow($this->scenario, 90000);
|
2025-12-31 01:56:50 +01:00
|
|
|
|
|
|
|
|
$this->assertCount(3, $draws);
|
2026-03-21 23:55:21 +01:00
|
|
|
$this->assertDrawAmount($draws, $b1->id, 30000);
|
|
|
|
|
$this->assertDrawAmount($draws, $b2->id, 30000);
|
|
|
|
|
$this->assertDrawAmount($draws, $b3->id, 30000);
|
|
|
|
|
}
|
2025-12-31 01:56:50 +01:00
|
|
|
|
2026-03-21 23:55:21 +01:00
|
|
|
public function test_even_mode_redistributes_when_bucket_fills_up(): void
|
|
|
|
|
{
|
|
|
|
|
// 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);
|
2025-12-31 01:56:50 +01:00
|
|
|
|
2026-03-21 23:55:21 +01:00
|
|
|
$draws = $this->service->allocateInflow($this->scenario, 90000);
|
2025-12-31 01:56:50 +01:00
|
|
|
|
2026-03-21 23:55:21 +01:00
|
|
|
$this->assertCount(3, $draws);
|
|
|
|
|
$this->assertDrawAmount($draws, $a->id, 20000);
|
|
|
|
|
$this->assertDrawAmount($draws, $b->id, 35000);
|
|
|
|
|
$this->assertDrawAmount($draws, $c->id, 35000);
|
2025-12-31 01:56:50 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-21 23:55:21 +01:00
|
|
|
public function test_even_mode_needs_filled_before_wants(): void
|
2025-12-31 01:56:50 +01:00
|
|
|
{
|
2026-03-21 23:55:21 +01:00
|
|
|
// 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);
|
2025-12-31 01:56:50 +01:00
|
|
|
|
2026-03-21 23:55:21 +01:00
|
|
|
$draws = $this->service->allocateInflow($this->scenario, 80000);
|
2025-12-31 01:56:50 +01:00
|
|
|
|
|
|
|
|
$this->assertCount(2, $draws);
|
2026-03-21 23:55:21 +01:00
|
|
|
$this->assertDrawAmount($draws, $n1->id, 40000);
|
|
|
|
|
$this->assertDrawAmount($draws, $n2->id, 40000);
|
2025-12-31 01:56:50 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-21 23:55:21 +01:00
|
|
|
public function test_even_mode_wants_filled_after_needs_base(): void
|
2025-12-31 01:56:50 +01:00
|
|
|
{
|
2026-03-21 23:55:21 +01:00
|
|
|
// 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);
|
2025-12-31 01:56:50 +01:00
|
|
|
|
2026-03-21 23:55:21 +01:00
|
|
|
$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);
|
2025-12-31 01:56:50 +01:00
|
|
|
|
|
|
|
|
$this->assertCount(2, $draws);
|
2026-03-21 23:55:21 +01:00
|
|
|
$this->assertDrawAmount($draws, $b1->id, 1);
|
|
|
|
|
$this->assertDrawAmount($draws, $b2->id, 1);
|
2025-12-31 01:56:50 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-21 23:55:21 +01:00
|
|
|
// ──────────────────────────────────────────────────────
|
|
|
|
|
// Priority mode — base phase
|
|
|
|
|
// ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
public function test_priority_mode_fills_highest_priority_first(): void
|
2025-12-31 01:56:50 +01:00
|
|
|
{
|
2026-03-21 23:55:21 +01:00
|
|
|
$scenario = Scenario::factory()->priority()->create();
|
2025-12-31 01:56:50 +01:00
|
|
|
|
2026-03-21 23:55:21 +01:00
|
|
|
$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);
|
2025-12-31 01:56:50 +01:00
|
|
|
|
2026-03-21 23:55:21 +01:00
|
|
|
// $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);
|
2025-12-31 01:56:50 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-21 23:55:21 +01:00
|
|
|
public function test_priority_mode_needs_before_wants(): void
|
2025-12-31 01:56:50 +01:00
|
|
|
{
|
2026-03-21 23:55:21 +01:00
|
|
|
$scenario = Scenario::factory()->priority()->create();
|
2025-12-31 01:56:50 +01:00
|
|
|
|
2026-03-21 23:55:21 +01:00
|
|
|
$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);
|
2025-12-31 01:56:50 +01:00
|
|
|
|
2026-03-21 23:55:21 +01:00
|
|
|
// $1000 → need1 $500, need2 $300, want1 gets remaining $200
|
|
|
|
|
$draws = $this->service->allocateInflow($scenario, 100000);
|
2025-12-31 01:56:50 +01:00
|
|
|
|
2026-03-21 23:55:21 +01:00
|
|
|
$this->assertCount(3, $draws);
|
|
|
|
|
$this->assertDrawAmount($draws, $n1->id, 50000);
|
|
|
|
|
$this->assertDrawAmount($draws, $n2->id, 30000);
|
|
|
|
|
$this->assertDrawAmount($draws, $w1->id, 20000);
|
|
|
|
|
}
|
2025-12-31 01:56:50 +01:00
|
|
|
|
2026-03-21 23:55:21 +01:00
|
|
|
// ──────────────────────────────────────────────────────
|
|
|
|
|
// Buffer phases
|
|
|
|
|
// ──────────────────────────────────────────────────────
|
2025-12-31 01:56:50 +01:00
|
|
|
|
2026-03-21 23:55:21 +01:00
|
|
|
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);
|
2025-12-31 01:56:50 +01:00
|
|
|
|
2026-03-21 23:55:21 +01:00
|
|
|
$draws = $this->service->allocateInflow($this->scenario, 200000);
|
2025-12-31 01:56:50 +01:00
|
|
|
|
2026-03-21 23:55:21 +01:00
|
|
|
$this->assertCount(2, $draws);
|
|
|
|
|
$this->assertDrawAmount($draws, $n->id, 75000); // base $500 + buffer $250
|
|
|
|
|
$this->assertDrawAmount($draws, $overflow->id, 125000);
|
2025-12-31 01:56:50 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-21 23:55:21 +01:00
|
|
|
public function test_need_buffer_filled_before_want_buffer(): void
|
2025-12-31 01:56:50 +01:00
|
|
|
{
|
2026-03-21 23:55:21 +01:00
|
|
|
// 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);
|
2025-12-31 01:56:50 +01:00
|
|
|
|
2026-03-21 23:55:21 +01:00
|
|
|
$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
|
2025-12-31 01:56:50 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-21 23:55:21 +01:00
|
|
|
public function test_priority_mode_buffer_fills_by_priority(): void
|
2025-12-31 01:56:50 +01:00
|
|
|
{
|
2026-03-21 23:55:21 +01:00
|
|
|
$scenario = Scenario::factory()->priority()->create();
|
2025-12-31 01:56:50 +01:00
|
|
|
|
2026-03-21 23:55:21 +01:00
|
|
|
// 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);
|
2025-12-31 01:56:50 +01:00
|
|
|
|
2026-03-21 23:55:21 +01:00
|
|
|
$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
|
2025-12-31 01:56:50 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-21 23:55:21 +01:00
|
|
|
public function test_no_buffer_when_multiplier_is_zero(): void
|
2025-12-31 01:56:50 +01:00
|
|
|
{
|
2026-03-21 23:55:21 +01:00
|
|
|
// 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);
|
2025-12-31 01:56:50 +01:00
|
|
|
|
2026-03-21 23:55:21 +01:00
|
|
|
$draws = $this->service->allocateInflow($this->scenario, 80000);
|
2025-12-31 01:56:50 +01:00
|
|
|
|
2026-03-21 23:55:21 +01:00
|
|
|
$this->assertCount(2, $draws);
|
|
|
|
|
$this->assertDrawAmount($draws, $n->id, 50000);
|
|
|
|
|
$this->assertDrawAmount($draws, $overflow->id, 30000);
|
2025-12-31 01:56:50 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-21 23:55:21 +01:00
|
|
|
public function test_partially_filled_bucket_respects_existing_balance(): void
|
2025-12-31 01:56:50 +01:00
|
|
|
{
|
2026-03-21 23:55:21 +01:00
|
|
|
// 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);
|
2025-12-31 01:56:50 +01:00
|
|
|
|
2026-03-21 23:55:21 +01:00
|
|
|
$draws = $this->service->allocateInflow($this->scenario, 80000);
|
2025-12-31 01:56:50 +01:00
|
|
|
|
|
|
|
|
$this->assertCount(2, $draws);
|
2026-03-21 23:55:21 +01:00
|
|
|
$this->assertDrawAmount($draws, $n->id, 20000);
|
|
|
|
|
$this->assertDrawAmount($draws, $overflow->id, 60000);
|
2025-12-31 01:56:50 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-21 23:55:21 +01:00
|
|
|
// ──────────────────────────────────────────────────────
|
|
|
|
|
// Overflow
|
|
|
|
|
// ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
public function test_overflow_captures_remainder(): void
|
2025-12-31 01:56:50 +01:00
|
|
|
{
|
2026-03-21 23:55:21 +01:00
|
|
|
$n = $this->createNeedBucket(30000, priority: 1);
|
|
|
|
|
$overflow = $this->createOverflowBucket(priority: 2);
|
2025-12-31 01:56:50 +01:00
|
|
|
|
2025-12-31 02:34:30 +01:00
|
|
|
$draws = $this->service->allocateInflow($this->scenario, 100000);
|
2025-12-31 01:56:50 +01:00
|
|
|
|
|
|
|
|
$this->assertCount(2, $draws);
|
2026-03-21 23:55:21 +01:00
|
|
|
$this->assertDrawAmount($draws, $n->id, 30000);
|
|
|
|
|
$this->assertDrawAmount($draws, $overflow->id, 70000);
|
2025-12-31 01:56:50 +01:00
|
|
|
}
|
2026-03-20 00:28:20 +01:00
|
|
|
|
2026-03-21 23:55:21 +01:00
|
|
|
public function test_all_buckets_full_everything_to_overflow(): void
|
2026-03-20 00:28:20 +01:00
|
|
|
{
|
2026-03-21 23:55:21 +01:00
|
|
|
$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);
|
2026-03-20 00:28:20 +01:00
|
|
|
|
|
|
|
|
$draws = $this->service->allocateInflow($this->scenario, 100000);
|
|
|
|
|
|
|
|
|
|
$this->assertCount(1, $draws);
|
2026-03-21 23:55:21 +01:00
|
|
|
$this->assertDrawAmount($draws, $n->id, 100000);
|
2026-03-20 00:28:20 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-21 23:55:21 +01:00
|
|
|
public function test_skips_buckets_with_zero_allocation_value(): void
|
2026-03-20 00:28:20 +01:00
|
|
|
{
|
2026-03-21 23:55:21 +01:00
|
|
|
$zero = $this->createNeedBucket(0, priority: 1);
|
|
|
|
|
$normal = $this->createNeedBucket(30000, priority: 2);
|
2026-03-20 00:28:20 +01:00
|
|
|
|
2026-03-21 23:55:21 +01:00
|
|
|
$draws = $this->service->allocateInflow($this->scenario, 20000);
|
2026-03-20 00:28:20 +01:00
|
|
|
|
|
|
|
|
$this->assertCount(1, $draws);
|
2026-03-21 23:55:21 +01:00
|
|
|
$this->assertDrawAmount($draws, $normal->id, 20000);
|
2026-03-20 00:28:20 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-21 23:55:21 +01:00
|
|
|
public function test_non_fixed_limit_buckets_are_skipped_in_phases(): void
|
2026-03-20 00:28:20 +01:00
|
|
|
{
|
2026-03-21 23:55:21 +01:00
|
|
|
// Percentage bucket as need → gets nothing (no meaningful cap in phased distribution)
|
2026-03-20 00:28:20 +01:00
|
|
|
Bucket::factory()->create([
|
|
|
|
|
'scenario_id' => $this->scenario->id,
|
2026-03-21 23:55:21 +01:00
|
|
|
'type' => BucketTypeEnum::NEED,
|
2026-03-20 00:28:20 +01:00
|
|
|
'allocation_type' => BucketAllocationTypeEnum::PERCENTAGE,
|
2026-03-21 23:55:21 +01:00
|
|
|
'allocation_value' => 2500,
|
2026-03-20 00:28:20 +01:00
|
|
|
'starting_amount' => 0,
|
|
|
|
|
'priority' => 1,
|
|
|
|
|
]);
|
2026-03-21 23:55:21 +01:00
|
|
|
$overflow = $this->createOverflowBucket(priority: 2);
|
2026-03-20 00:28:20 +01:00
|
|
|
|
2026-03-21 23:55:21 +01:00
|
|
|
$draws = $this->service->allocateInflow($this->scenario, 50000);
|
2026-03-20 00:28:20 +01:00
|
|
|
|
|
|
|
|
$this->assertCount(1, $draws);
|
2026-03-21 23:55:21 +01:00
|
|
|
$this->assertDrawAmount($draws, $overflow->id, 50000);
|
2026-03-20 00:28:20 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-21 23:55:21 +01:00
|
|
|
public function test_surplus_is_lost_when_no_overflow_bucket(): void
|
2026-03-20 00:28:20 +01:00
|
|
|
{
|
2026-03-21 23:55:21 +01:00
|
|
|
// $500 income, only $300 capacity → $200 surplus has nowhere to go
|
|
|
|
|
$n = $this->createNeedBucket(30000, priority: 1);
|
2026-03-20 00:28:20 +01:00
|
|
|
|
2026-03-21 23:55:21 +01:00
|
|
|
$draws = $this->service->allocateInflow($this->scenario, 50000);
|
2026-03-20 00:28:20 +01:00
|
|
|
|
|
|
|
|
$this->assertCount(1, $draws);
|
2026-03-21 23:55:21 +01:00
|
|
|
$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}");
|
2026-03-20 00:28:20 +01:00
|
|
|
}
|
2025-12-31 01:56:50 +01:00
|
|
|
}
|