package financial import ( "math/rand" "testing" "github.com/felt-app/felt/internal/template" ) // TestCalculatePayoutsFromPool_Basic tests basic payout calculation. func TestCalculatePayoutsFromPool_Basic(t *testing.T) { tiers := []template.PayoutTier{ {Position: 1, PercentageBasisPoints: 5000}, // 50% {Position: 2, PercentageBasisPoints: 3000}, // 30% {Position: 3, PercentageBasisPoints: 2000}, // 20% } payouts, err := CalculatePayoutsFromPool(100000, tiers, 100) // 1000.00 pool, round to 1.00 if err != nil { t.Fatalf("calculate payouts: %v", err) } if len(payouts) != 3 { t.Fatalf("expected 3 payouts, got %d", len(payouts)) } // Sum must equal prize pool var sum int64 for _, p := range payouts { sum += p.Amount } if sum != 100000 { t.Errorf("sum mismatch: got %d, expected 100000", sum) } // Verify individual amounts // 50% of 100000 = 50000, 30% = 30000, 20% = 20000 if payouts[0].Amount != 50000 { t.Errorf("1st place: expected 50000, got %d", payouts[0].Amount) } if payouts[1].Amount != 30000 { t.Errorf("2nd place: expected 30000, got %d", payouts[1].Amount) } if payouts[2].Amount != 20000 { t.Errorf("3rd place: expected 20000, got %d", payouts[2].Amount) } } // TestCalculatePayoutsFromPool_RoundingRemainder tests that rounding remainder goes to 1st. func TestCalculatePayoutsFromPool_RoundingRemainder(t *testing.T) { tiers := []template.PayoutTier{ {Position: 1, PercentageBasisPoints: 5000}, // 50% {Position: 2, PercentageBasisPoints: 3000}, // 30% {Position: 3, PercentageBasisPoints: 2000}, // 20% } // 333.33 pool with rounding to 5.00 -- this creates remainder payouts, err := CalculatePayoutsFromPool(33333, tiers, 500) if err != nil { t.Fatalf("calculate payouts: %v", err) } var sum int64 for _, p := range payouts { sum += p.Amount } if sum != 33333 { t.Errorf("sum mismatch: got %d, expected 33333", sum) } // 50% of 33333 = 16666.5 -> floor to 16500 (round to 500) // 30% of 33333 = 9999.9 -> floor to 9500 // 20% of 33333 = 6666.6 -> floor to 6500 // Total rounded = 16500 + 9500 + 6500 = 32500 // Remainder = 33333 - 32500 = 833 -> goes to 1st // 1st gets: 16500 + 833 = 17333 if payouts[0].Amount != 17333 { t.Errorf("1st place: expected 17333, got %d", payouts[0].Amount) } } // TestCalculatePayoutsFromPool_NeverRoundsUp tests that rounding never creates money. func TestCalculatePayoutsFromPool_NeverRoundsUp(t *testing.T) { tiers := []template.PayoutTier{ {Position: 1, PercentageBasisPoints: 4000}, {Position: 2, PercentageBasisPoints: 3000}, {Position: 3, PercentageBasisPoints: 2000}, {Position: 4, PercentageBasisPoints: 1000}, } denominations := []int64{100, 500, 1000, 5000} for _, denom := range denominations { payouts, err := CalculatePayoutsFromPool(77777, tiers, denom) if err != nil { t.Fatalf("denom %d: %v", denom, err) } var sum int64 for _, p := range payouts { sum += p.Amount // Each payout (except 1st which gets remainder) should be at most floor(raw) if p.Position != 1 { raw := int64(77777) * p.Percentage / 10000 if p.Amount > raw { t.Errorf("denom %d, pos %d: amount %d > raw %d (rounding UP detected)", denom, p.Position, p.Amount, raw) } } } if sum != 77777 { t.Errorf("denom %d: sum %d != 77777", denom, sum) } } } // TestCalculatePayoutsFromPool_CIGate is the CI gate test. // It generates 10,000+ random prize pools and verifies sum(payouts) == prizePool // in every case. This is the financial integrity gate. func TestCalculatePayoutsFromPool_CIGate(t *testing.T) { rng := rand.New(rand.NewSource(42)) // Deterministic for reproducibility roundingDenominations := []int64{100, 500, 1000, 5000} // Generate diverse payout structures (2-20 positions) structures := generateRandomStructures(rng, 50) iterations := 0 for _, denom := range roundingDenominations { for _, structure := range structures { for i := 0; i < 50; i++ { // Random prize pool: 1000 cents (10.00) to 10,000,000 cents (100,000.00) prizePool := int64(rng.Intn(9999000)) + 1000 payouts, err := CalculatePayoutsFromPool(prizePool, structure, denom) if err != nil { t.Fatalf("iteration %d: pool=%d denom=%d positions=%d: %v", iterations, prizePool, denom, len(structure), err) } // CRITICAL ASSERTION: sum(payouts) == prizePool var sum int64 for _, p := range payouts { sum += p.Amount // No negative payouts if p.Amount < 0 { t.Fatalf("iteration %d: negative payout: position=%d amount=%d", iterations, p.Position, p.Amount) } } if sum != prizePool { t.Fatalf("CI GATE FAILURE at iteration %d: sum=%d prizePool=%d diff=%d (denom=%d, positions=%d)", iterations, sum, prizePool, sum-prizePool, denom, len(structure)) } iterations++ } } } if iterations < 10000 { t.Fatalf("CI gate: only ran %d iterations, expected at least 10000", iterations) } t.Logf("CI gate PASSED: %d iterations, zero sum deviations", iterations) } // TestCalculatePayoutsFromPool_GuaranteedPot tests guaranteed pot logic. func TestCalculatePayoutsFromPool_GuaranteedPot(t *testing.T) { // When pool < guarantee, house covers shortfall // FinalPrizePool should be max(pool, guarantee) tiers := []template.PayoutTier{ {Position: 1, PercentageBasisPoints: 6000}, {Position: 2, PercentageBasisPoints: 4000}, } // If guarantee is 50000 but pool is only 30000, house contributes 20000 // FinalPrizePool = 50000 payouts, err := CalculatePayoutsFromPool(50000, tiers, 100) if err != nil { t.Fatalf("calculate payouts: %v", err) } var sum int64 for _, p := range payouts { sum += p.Amount } if sum != 50000 { t.Errorf("guaranteed pot: sum %d != 50000", sum) } } // TestCalculatePayoutsFromPool_EntryCountBracketSelection tests unique entries bracket selection. func TestCalculatePayoutsFromPool_EntryCountBracketSelection(t *testing.T) { brackets := []template.PayoutBracket{ {MinEntries: 2, MaxEntries: 5, Tiers: []template.PayoutTier{ {Position: 1, PercentageBasisPoints: 10000}, }}, {MinEntries: 6, MaxEntries: 10, Tiers: []template.PayoutTier{ {Position: 1, PercentageBasisPoints: 6000}, {Position: 2, PercentageBasisPoints: 4000}, }}, {MinEntries: 11, MaxEntries: 20, Tiers: []template.PayoutTier{ {Position: 1, PercentageBasisPoints: 5000}, {Position: 2, PercentageBasisPoints: 3000}, {Position: 3, PercentageBasisPoints: 2000}, }}, } // 3 entries -> first bracket (winner take all) b := selectBracket(brackets, 3) if b == nil { t.Fatal("expected bracket for 3 entries") } if len(b.Tiers) != 1 { t.Errorf("expected 1 tier for 3 entries, got %d", len(b.Tiers)) } // 8 entries -> second bracket b = selectBracket(brackets, 8) if b == nil { t.Fatal("expected bracket for 8 entries") } if len(b.Tiers) != 2 { t.Errorf("expected 2 tiers for 8 entries, got %d", len(b.Tiers)) } // 15 entries -> third bracket b = selectBracket(brackets, 15) if b == nil { t.Fatal("expected bracket for 15 entries") } if len(b.Tiers) != 3 { t.Errorf("expected 3 tiers for 15 entries, got %d", len(b.Tiers)) } // 25 entries -> exceeds all brackets, use last b = selectBracket(brackets, 25) if b == nil { t.Fatal("expected bracket for 25 entries (overflow)") } if len(b.Tiers) != 3 { t.Errorf("expected 3 tiers for overflow, got %d", len(b.Tiers)) } } // TestRedistributeForBubble tests bubble prize redistribution. func TestRedistributeForBubble(t *testing.T) { payouts := []Payout{ {Position: 1, Amount: 50000}, {Position: 2, Amount: 30000}, {Position: 3, Amount: 20000}, } funding, adjusted, err := redistributeForBubble(payouts, 5000) if err != nil { t.Fatalf("redistribute: %v", err) } // Verify funding sources exist if len(funding) == 0 { t.Fatal("expected funding sources") } // Verify total shaved equals bubble amount var totalShaved int64 for _, f := range funding { totalShaved += f.ReductionAmount } if totalShaved != 5000 { t.Errorf("expected total shaved 5000, got %d", totalShaved) } // Verify adjusted payouts sum = original sum - bubble amount var originalSum, adjustedSum int64 for _, p := range payouts { originalSum += p.Amount } for _, p := range adjusted { adjustedSum += p.Amount } if adjustedSum != originalSum-5000 { t.Errorf("adjusted sum %d != original %d - bubble 5000 = %d", adjustedSum, originalSum, originalSum-5000) } } // generateRandomStructures creates diverse payout structures for property testing. func generateRandomStructures(rng *rand.Rand, count int) [][]template.PayoutTier { structures := make([][]template.PayoutTier, 0, count) for i := 0; i < count; i++ { positions := rng.Intn(19) + 2 // 2-20 positions tiers := make([]template.PayoutTier, positions) // Generate random percentages that sum to 10000 remaining := int64(10000) for j := 0; j < positions; j++ { tiers[j].Position = j + 1 if j == positions-1 { tiers[j].PercentageBasisPoints = remaining } else { // Min 1 basis point per remaining position minPer := int64(positions - j) maxPer := remaining - minPer if maxPer < 1 { maxPer = 1 } // Weighted toward higher positions getting more pct := int64(rng.Intn(int(maxPer/int64(positions-j)))) + 1 if pct > remaining-int64(positions-j-1) { pct = remaining - int64(positions-j-1) } tiers[j].PercentageBasisPoints = pct remaining -= pct } } // Verify sum var sum int64 for _, tier := range tiers { sum += tier.PercentageBasisPoints } if sum == 10000 { structures = append(structures, tiers) } } return structures }