felt/internal/financial/payout_test.go
Mikkel Georgsen 56a7ef1e31 docs(01-13): complete Layout Shell plan
- SUMMARY.md with all accomplishments and deviation documentation
- STATE.md updated: plan 8/14, 50% progress, decisions, session
- ROADMAP.md updated: 7/14 plans complete
- REQUIREMENTS.md: UI-01 through UI-04, UI-07, UI-08 marked complete

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 04:15:37 +01:00

329 lines
9.5 KiB
Go

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
}