felt/internal/blind/wizard_test.go
Mikkel Georgsen 7dbb4cab1a feat(01-05): add built-in templates, seed data, wizard tests, and template tests
- Built-in blind structures: Turbo (15min), Standard (20min), Deep Stack (30min), WSOP-style (60min, BB ante)
- Built-in payout structure: Standard with 4 entry-count brackets (8-20, 21-30, 31-40, 41+)
- Built-in buy-in configs: Basic 200 DKK through WSOP 1000 DKK with rake splits
- 4 built-in tournament templates composing above building blocks
- 005_builtin_templates.sql seed migration (INSERT OR IGNORE, safe to re-run)
- Wizard tests: standard, various player counts, denomination alignment, edge cases
- Template tests: create/expand/duplicate/save-as/delete-builtin/list

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

315 lines
9.2 KiB
Go

package blind
import (
"context"
"database/sql"
"os"
"path/filepath"
"testing"
_ "github.com/tursodatabase/go-libsql"
)
// setupTestDB creates a temporary test database with the required schema.
func setupTestDB(t *testing.T) *sql.DB {
t.Helper()
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")
db, err := sql.Open("libsql", "file:"+dbPath)
if err != nil {
t.Fatalf("open test db: %v", err)
}
t.Cleanup(func() { db.Close() })
// Create required tables
stmts := []string{
`CREATE TABLE chip_sets (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, is_builtin INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL DEFAULT 0)`,
`CREATE TABLE chip_denominations (id INTEGER PRIMARY KEY AUTOINCREMENT, chip_set_id INTEGER NOT NULL, value INTEGER NOT NULL, color_hex TEXT NOT NULL DEFAULT '#FFF', label TEXT NOT NULL DEFAULT '', sort_order INTEGER NOT NULL DEFAULT 0)`,
`CREATE TABLE blind_structures (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, is_builtin INTEGER NOT NULL DEFAULT 0, game_type_default TEXT NOT NULL DEFAULT 'nlhe', notes TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL DEFAULT 0)`,
`CREATE TABLE blind_levels (id INTEGER PRIMARY KEY AUTOINCREMENT, structure_id INTEGER NOT NULL, position INTEGER NOT NULL, level_type TEXT NOT NULL DEFAULT 'round', game_type TEXT NOT NULL DEFAULT 'nlhe', small_blind INTEGER NOT NULL DEFAULT 0, big_blind INTEGER NOT NULL DEFAULT 0, ante INTEGER NOT NULL DEFAULT 0, bb_ante INTEGER NOT NULL DEFAULT 0, duration_seconds INTEGER NOT NULL DEFAULT 900, chip_up_denomination_value INTEGER, notes TEXT NOT NULL DEFAULT '')`,
`CREATE TABLE tournaments (id TEXT PRIMARY KEY, name TEXT, template_id INTEGER, chip_set_id INTEGER, blind_structure_id INTEGER, payout_structure_id INTEGER, buyin_config_id INTEGER, points_formula_id INTEGER, status TEXT DEFAULT 'created', min_players INTEGER DEFAULT 2, max_players INTEGER, early_signup_bonus_chips INTEGER DEFAULT 0, early_signup_cutoff TEXT, punctuality_bonus_chips INTEGER DEFAULT 0, is_pko INTEGER DEFAULT 0, current_level INTEGER DEFAULT 0, clock_state TEXT DEFAULT 'stopped', clock_remaining_ns INTEGER DEFAULT 0, total_elapsed_ns INTEGER DEFAULT 0, hand_for_hand INTEGER DEFAULT 0, started_at INTEGER, ended_at INTEGER, created_at INTEGER DEFAULT 0, updated_at INTEGER DEFAULT 0)`,
}
for _, stmt := range stmts {
if _, err := db.Exec(stmt); err != nil {
t.Fatalf("create table: %v", err)
}
}
// Insert standard chip set
if _, err := db.Exec(`INSERT INTO chip_sets (id, name) VALUES (1, 'Standard')`); err != nil {
t.Fatalf("insert chip set: %v", err)
}
denominations := []struct {
value int64
colorHex string
}{
{25, "#FFFFFF"},
{100, "#FF0000"},
{500, "#00AA00"},
{1000, "#000000"},
{5000, "#0000FF"},
}
for _, d := range denominations {
if _, err := db.Exec(`INSERT INTO chip_denominations (chip_set_id, value, color_hex) VALUES (1, ?, ?)`, d.value, d.colorHex); err != nil {
t.Fatalf("insert denomination: %v", err)
}
}
return db
}
func TestWizardGenerate_StandardTournament(t *testing.T) {
if os.Getenv("CGO_ENABLED") == "0" {
t.Skip("requires CGO for libsql")
}
db := setupTestDB(t)
ws := NewWizardService(db)
ctx := context.Background()
levels, err := ws.Generate(ctx, WizardInput{
PlayerCount: 20,
StartingChips: 15000,
TargetDurationMinutes: 240,
ChipSetID: 1,
})
if err != nil {
t.Fatalf("generate: %v", err)
}
if len(levels) == 0 {
t.Fatal("expected at least 1 level")
}
// Check all levels have valid types
hasRound := false
hasBreak := false
for _, l := range levels {
if l.LevelType == "round" {
hasRound = true
}
if l.LevelType == "break" {
hasBreak = true
}
if l.LevelType != "round" && l.LevelType != "break" {
t.Errorf("invalid level type: %s", l.LevelType)
}
}
if !hasRound {
t.Error("expected at least one round level")
}
if !hasBreak {
t.Error("expected at least one break")
}
// Check positions are contiguous
for i, l := range levels {
if l.Position != i {
t.Errorf("level %d: expected position %d, got %d", i, i, l.Position)
}
}
// Check blinds are increasing for round levels
prevBB := int64(0)
for _, l := range levels {
if l.LevelType == "round" {
if l.BigBlind <= 0 {
t.Errorf("level %d: big blind must be positive, got %d", l.Position, l.BigBlind)
}
if l.BigBlind < prevBB {
t.Errorf("level %d: big blind %d not increasing from previous %d", l.Position, l.BigBlind, prevBB)
}
if l.SmallBlind >= l.BigBlind {
t.Errorf("level %d: small blind %d >= big blind %d", l.Position, l.SmallBlind, l.BigBlind)
}
prevBB = l.BigBlind
}
}
}
func TestWizardGenerate_VariousPlayerCounts(t *testing.T) {
if os.Getenv("CGO_ENABLED") == "0" {
t.Skip("requires CGO for libsql")
}
db := setupTestDB(t)
ws := NewWizardService(db)
ctx := context.Background()
for _, pc := range []int{10, 20, 40, 80} {
t.Run(stringFromInt(pc)+"players", func(t *testing.T) {
levels, err := ws.Generate(ctx, WizardInput{
PlayerCount: pc,
StartingChips: 15000,
TargetDurationMinutes: 240,
ChipSetID: 1,
})
if err != nil {
t.Fatalf("generate for %d players: %v", pc, err)
}
if len(levels) < 5 {
t.Errorf("expected at least 5 levels for %d players, got %d", pc, len(levels))
}
// Verify structure is increasing
prevBB := int64(0)
for _, l := range levels {
if l.LevelType == "round" && l.BigBlind < prevBB {
t.Errorf("level %d: big blind %d < previous %d for %d players", l.Position, l.BigBlind, prevBB, pc)
}
if l.LevelType == "round" {
prevBB = l.BigBlind
}
}
})
}
}
func TestWizardGenerate_BlindsAlignWithDenominations(t *testing.T) {
if os.Getenv("CGO_ENABLED") == "0" {
t.Skip("requires CGO for libsql")
}
db := setupTestDB(t)
ws := NewWizardService(db)
ctx := context.Background()
levels, err := ws.Generate(ctx, WizardInput{
PlayerCount: 20,
StartingChips: 15000,
TargetDurationMinutes: 240,
ChipSetID: 1,
})
if err != nil {
t.Fatalf("generate: %v", err)
}
denoms := []int64{25, 100, 500, 1000, 5000}
for _, l := range levels {
if l.LevelType != "round" {
continue
}
if !isDenomAligned(l.SmallBlind, denoms) {
t.Errorf("level %d: SB %d not aligned with denominations", l.Position, l.SmallBlind)
}
if !isDenomAligned(l.BigBlind, denoms) {
t.Errorf("level %d: BB %d not aligned with denominations", l.Position, l.BigBlind)
}
if l.Ante > 0 && !isDenomAligned(l.Ante, denoms) {
t.Errorf("level %d: ante %d not aligned with denominations", l.Position, l.Ante)
}
}
}
func TestWizardGenerate_ShortTournament(t *testing.T) {
if os.Getenv("CGO_ENABLED") == "0" {
t.Skip("requires CGO for libsql")
}
db := setupTestDB(t)
ws := NewWizardService(db)
ctx := context.Background()
levels, err := ws.Generate(ctx, WizardInput{
PlayerCount: 10,
StartingChips: 10000,
TargetDurationMinutes: 60,
ChipSetID: 1,
})
if err != nil {
t.Fatalf("generate: %v", err)
}
if len(levels) < 3 {
t.Errorf("expected at least 3 levels for 1hr tournament, got %d", len(levels))
}
}
func TestWizardGenerate_LongTournament(t *testing.T) {
if os.Getenv("CGO_ENABLED") == "0" {
t.Skip("requires CGO for libsql")
}
db := setupTestDB(t)
ws := NewWizardService(db)
ctx := context.Background()
levels, err := ws.Generate(ctx, WizardInput{
PlayerCount: 20,
StartingChips: 50000,
TargetDurationMinutes: 480,
ChipSetID: 1,
})
if err != nil {
t.Fatalf("generate: %v", err)
}
if len(levels) < 5 {
t.Errorf("expected at least 5 levels for 8hr tournament, got %d", len(levels))
}
// Check breaks are present
breakCount := 0
for _, l := range levels {
if l.LevelType == "break" {
breakCount++
}
}
if breakCount == 0 {
t.Error("expected at least one break in 8hr tournament")
}
}
func TestWizardGenerate_InvalidInputs(t *testing.T) {
if os.Getenv("CGO_ENABLED") == "0" {
t.Skip("requires CGO for libsql")
}
db := setupTestDB(t)
ws := NewWizardService(db)
ctx := context.Background()
tests := []struct {
name string
input WizardInput
}{
{"too few players", WizardInput{PlayerCount: 1, StartingChips: 10000, TargetDurationMinutes: 120, ChipSetID: 1}},
{"zero chips", WizardInput{PlayerCount: 10, StartingChips: 0, TargetDurationMinutes: 120, ChipSetID: 1}},
{"too short", WizardInput{PlayerCount: 10, StartingChips: 10000, TargetDurationMinutes: 10, ChipSetID: 1}},
{"missing chip set", WizardInput{PlayerCount: 10, StartingChips: 10000, TargetDurationMinutes: 120, ChipSetID: 999}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := ws.Generate(ctx, tc.input)
if err == nil {
t.Error("expected error for invalid input")
}
})
}
}
// isDenomAligned checks if a value is a multiple of any denomination.
func isDenomAligned(value int64, denoms []int64) bool {
for _, d := range denoms {
if value%d == 0 {
return true
}
}
return false
}
// stringFromInt converts int to string for test names.
func stringFromInt(n int) string {
s := ""
if n == 0 {
return "0"
}
for n > 0 {
s = string(rune('0'+n%10)) + s
n /= 10
}
return s
}