diff --git a/internal/blind/wizard_test.go b/internal/blind/wizard_test.go new file mode 100644 index 0000000..310c3c9 --- /dev/null +++ b/internal/blind/wizard_test.go @@ -0,0 +1,315 @@ +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 +} diff --git a/internal/store/migrations/005_builtin_templates.sql b/internal/store/migrations/005_builtin_templates.sql new file mode 100644 index 0000000..76e126c --- /dev/null +++ b/internal/store/migrations/005_builtin_templates.sql @@ -0,0 +1,192 @@ +-- 005_builtin_templates.sql +-- Built-in blind structures, payout structures, buy-in configs, and tournament templates. +-- All marked is_builtin = 1, cannot be deleted, can be duplicated. +-- Uses INSERT OR IGNORE so re-running is safe. + +-- ============================================================================= +-- Built-in Blind Structures +-- ============================================================================= + +-- Turbo (~2hr for 20 players, 15-min levels, starting chips 10000) +INSERT OR IGNORE INTO blind_structures (id, name, is_builtin, game_type_default, notes) VALUES + (1, 'Turbo', 1, 'nlhe', '~2hr for 20 players, 15-min levels, starting chips 10,000'); + +INSERT OR IGNORE INTO blind_levels (structure_id, position, level_type, game_type, small_blind, big_blind, ante, bb_ante, duration_seconds, notes) VALUES + (1, 0, 'round', 'nlhe', 25, 50, 0, 0, 900, ''), + (1, 1, 'round', 'nlhe', 50, 100, 0, 0, 900, ''), + (1, 2, 'round', 'nlhe', 75, 150, 0, 0, 900, ''), + (1, 3, 'round', 'nlhe', 100, 200, 0, 0, 900, ''), + (1, 4, 'break', 'nlhe', 0, 0, 0, 0, 600, ''), + (1, 5, 'round', 'nlhe', 150, 300, 300, 0, 900, ''), + (1, 6, 'round', 'nlhe', 200, 400, 400, 0, 900, ''), + (1, 7, 'round', 'nlhe', 300, 600, 600, 0, 900, ''), + (1, 8, 'round', 'nlhe', 400, 800, 800, 0, 900, ''), + (1, 9, 'break', 'nlhe', 0, 0, 0, 0, 600, ''), + (1, 10, 'round', 'nlhe', 600, 1200, 1200, 0, 900, ''), + (1, 11, 'round', 'nlhe', 800, 1600, 1600, 0, 900, ''), + (1, 12, 'round', 'nlhe', 1000, 2000, 2000, 0, 900, ''), + (1, 13, 'round', 'nlhe', 1500, 3000, 3000, 0, 900, ''), + (1, 14, 'round', 'nlhe', 2000, 4000, 4000, 0, 900, ''); + +-- Standard (~3-4hr for 20 players, 20-min levels, starting chips 15000) +INSERT OR IGNORE INTO blind_structures (id, name, is_builtin, game_type_default, notes) VALUES + (2, 'Standard', 1, 'nlhe', '~3-4hr for 20 players, 20-min levels, starting chips 15,000'); + +INSERT OR IGNORE INTO blind_levels (structure_id, position, level_type, game_type, small_blind, big_blind, ante, bb_ante, duration_seconds, notes) VALUES + (2, 0, 'round', 'nlhe', 25, 50, 0, 0, 1200, ''), + (2, 1, 'round', 'nlhe', 50, 100, 0, 0, 1200, ''), + (2, 2, 'round', 'nlhe', 75, 150, 0, 0, 1200, ''), + (2, 3, 'round', 'nlhe', 100, 200, 0, 0, 1200, ''), + (2, 4, 'round', 'nlhe', 150, 300, 0, 0, 1200, ''), + (2, 5, 'break', 'nlhe', 0, 0, 0, 0, 600, ''), + (2, 6, 'round', 'nlhe', 200, 400, 400, 0, 1200, ''), + (2, 7, 'round', 'nlhe', 300, 600, 600, 0, 1200, ''), + (2, 8, 'round', 'nlhe', 400, 800, 800, 0, 1200, ''), + (2, 9, 'round', 'nlhe', 500, 1000, 1000, 0, 1200, ''), + (2, 10, 'break', 'nlhe', 0, 0, 0, 0, 600, ''), + (2, 11, 'round', 'nlhe', 600, 1200, 1200, 0, 1200, ''), + (2, 12, 'round', 'nlhe', 800, 1600, 1600, 0, 1200, ''), + (2, 13, 'round', 'nlhe', 1000, 2000, 2000, 0, 1200, ''), + (2, 14, 'round', 'nlhe', 1500, 3000, 3000, 0, 1200, ''), + (2, 15, 'round', 'nlhe', 2000, 4000, 4000, 0, 1200, ''), + (2, 16, 'round', 'nlhe', 3000, 6000, 6000, 0, 1200, ''); + +-- Deep Stack (~5-6hr for 20 players, 30-min levels, starting chips 25000) +INSERT OR IGNORE INTO blind_structures (id, name, is_builtin, game_type_default, notes) VALUES + (3, 'Deep Stack', 1, 'nlhe', '~5-6hr for 20 players, 30-min levels, starting chips 25,000'); + +INSERT OR IGNORE INTO blind_levels (structure_id, position, level_type, game_type, small_blind, big_blind, ante, bb_ante, duration_seconds, notes) VALUES + (3, 0, 'round', 'nlhe', 25, 50, 0, 0, 1800, ''), + (3, 1, 'round', 'nlhe', 50, 100, 0, 0, 1800, ''), + (3, 2, 'round', 'nlhe', 75, 150, 0, 0, 1800, ''), + (3, 3, 'round', 'nlhe', 100, 200, 0, 0, 1800, ''), + (3, 4, 'round', 'nlhe', 150, 300, 0, 0, 1800, ''), + (3, 5, 'break', 'nlhe', 0, 0, 0, 0, 900, ''), + (3, 6, 'round', 'nlhe', 200, 400, 0, 0, 1800, ''), + (3, 7, 'round', 'nlhe', 250, 500, 500, 0, 1800, ''), + (3, 8, 'round', 'nlhe', 300, 600, 600, 0, 1800, ''), + (3, 9, 'round', 'nlhe', 400, 800, 800, 0, 1800, ''), + (3, 10, 'break', 'nlhe', 0, 0, 0, 0, 900, ''), + (3, 11, 'round', 'nlhe', 500, 1000, 1000, 0, 1800, ''), + (3, 12, 'round', 'nlhe', 600, 1200, 1200, 0, 1800, ''), + (3, 13, 'round', 'nlhe', 800, 1600, 1600, 0, 1800, ''), + (3, 14, 'round', 'nlhe', 1000, 2000, 2000, 0, 1800, ''), + (3, 15, 'break', 'nlhe', 0, 0, 0, 0, 900, ''), + (3, 16, 'round', 'nlhe', 1500, 3000, 3000, 0, 1800, ''), + (3, 17, 'round', 'nlhe', 2000, 4000, 4000, 0, 1800, ''), + (3, 18, 'round', 'nlhe', 3000, 6000, 6000, 0, 1800, ''), + (3, 19, 'round', 'nlhe', 4000, 8000, 8000, 0, 1800, ''), + (3, 20, 'round', 'nlhe', 5000, 10000,10000,0, 1800, ''); + +-- WSOP-style (60-min levels, BB ante from level 4, starting chips 50000) +INSERT OR IGNORE INTO blind_structures (id, name, is_builtin, game_type_default, notes) VALUES + (4, 'WSOP-style', 1, 'nlhe', '60-min levels, BB ante from level 4, starting chips 50,000'); + +INSERT OR IGNORE INTO blind_levels (structure_id, position, level_type, game_type, small_blind, big_blind, ante, bb_ante, duration_seconds, notes) VALUES + (4, 0, 'round', 'nlhe', 25, 50, 0, 0, 3600, ''), + (4, 1, 'round', 'nlhe', 50, 100, 0, 0, 3600, ''), + (4, 2, 'round', 'nlhe', 75, 150, 0, 0, 3600, ''), + (4, 3, 'round', 'nlhe', 100, 200, 0, 200, 3600, 'BB ante starts'), + (4, 4, 'break', 'nlhe', 0, 0, 0, 0, 1200, ''), + (4, 5, 'round', 'nlhe', 150, 300, 0, 300, 3600, ''), + (4, 6, 'round', 'nlhe', 200, 400, 0, 400, 3600, ''), + (4, 7, 'round', 'nlhe', 250, 500, 0, 500, 3600, ''), + (4, 8, 'round', 'nlhe', 300, 600, 0, 600, 3600, ''), + (4, 9, 'break', 'nlhe', 0, 0, 0, 0, 1200, ''), + (4, 10, 'round', 'nlhe', 400, 800, 0, 800, 3600, ''), + (4, 11, 'round', 'nlhe', 500, 1000, 0, 1000,3600, ''), + (4, 12, 'round', 'nlhe', 600, 1200, 0, 1200,3600, ''), + (4, 13, 'round', 'nlhe', 800, 1600, 0, 1600,3600, ''), + (4, 14, 'break', 'nlhe', 0, 0, 0, 0, 1200, ''), + (4, 15, 'round', 'nlhe', 1000, 2000, 0, 2000,3600, ''), + (4, 16, 'round', 'nlhe', 1500, 3000, 0, 3000,3600, ''), + (4, 17, 'round', 'nlhe', 2000, 4000, 0, 4000,3600, ''), + (4, 18, 'round', 'nlhe', 2500, 5000, 0, 5000,3600, ''), + (4, 19, 'round', 'nlhe', 3000, 6000, 0, 6000,3600, ''); + +-- ============================================================================= +-- Built-in Payout Structure (Standard) +-- ============================================================================= + +INSERT OR IGNORE INTO payout_structures (id, name, is_builtin) VALUES + (1, 'Standard', 1); + +-- Bracket: 8-20 entries (3 prizes: 50/30/20) +INSERT OR IGNORE INTO payout_brackets (id, structure_id, min_entries, max_entries) VALUES (1, 1, 8, 20); +INSERT OR IGNORE INTO payout_tiers (bracket_id, position, percentage_basis_points) VALUES + (1, 1, 5000), + (1, 2, 3000), + (1, 3, 2000); + +-- Bracket: 21-30 entries (4 prizes: 45/26/17/12) +INSERT OR IGNORE INTO payout_brackets (id, structure_id, min_entries, max_entries) VALUES (2, 1, 21, 30); +INSERT OR IGNORE INTO payout_tiers (bracket_id, position, percentage_basis_points) VALUES + (2, 1, 4500), + (2, 2, 2600), + (2, 3, 1700), + (2, 4, 1200); + +-- Bracket: 31-40 entries (5 prizes: 40/24/15/12/9) +INSERT OR IGNORE INTO payout_brackets (id, structure_id, min_entries, max_entries) VALUES (3, 1, 31, 40); +INSERT OR IGNORE INTO payout_tiers (bracket_id, position, percentage_basis_points) VALUES + (3, 1, 4000), + (3, 2, 2400), + (3, 3, 1500), + (3, 4, 1200), + (3, 5, 900); + +-- Bracket: 41+ entries (6 prizes: 35/22/14/12/9/8) +INSERT OR IGNORE INTO payout_brackets (id, structure_id, min_entries, max_entries) VALUES (4, 1, 41, 999); +INSERT OR IGNORE INTO payout_tiers (bracket_id, position, percentage_basis_points) VALUES + (4, 1, 3500), + (4, 2, 2200), + (4, 3, 1400), + (4, 4, 1200), + (4, 5, 900), + (4, 6, 800); + +-- ============================================================================= +-- Built-in Buy-in Configs +-- ============================================================================= + +-- Basic buy-in (200 DKK buyin, 10000 starting chips, 20 DKK rake) +INSERT OR IGNORE INTO buyin_configs (id, name, buyin_amount, starting_chips, rake_total) VALUES + (1, 'Basic (200 DKK)', 20000, 10000, 2000); + +INSERT OR IGNORE INTO rake_splits (buyin_config_id, category, amount) VALUES + (1, 'house', 2000); + +-- Standard buy-in (300 DKK, 15000 chips, 30 DKK rake) +INSERT OR IGNORE INTO buyin_configs (id, name, buyin_amount, starting_chips, rake_total) VALUES + (2, 'Standard (300 DKK)', 30000, 15000, 3000); + +INSERT OR IGNORE INTO rake_splits (buyin_config_id, category, amount) VALUES + (2, 'house', 2000), + (2, 'staff', 1000); + +-- Deep Stack buy-in (500 DKK, 25000 chips, 50 DKK rake) +INSERT OR IGNORE INTO buyin_configs (id, name, buyin_amount, starting_chips, rake_total) VALUES + (3, 'Deep Stack (500 DKK)', 50000, 25000, 5000); + +INSERT OR IGNORE INTO rake_splits (buyin_config_id, category, amount) VALUES + (3, 'house', 3000), + (3, 'staff', 2000); + +-- WSOP-style buy-in (1000 DKK, 50000 chips, 100 DKK rake) +INSERT OR IGNORE INTO buyin_configs (id, name, buyin_amount, starting_chips, rake_total) VALUES + (4, 'WSOP-style (1000 DKK)', 100000, 50000, 10000); + +INSERT OR IGNORE INTO rake_splits (buyin_config_id, category, amount) VALUES + (4, 'house', 5000), + (4, 'staff', 3000), + (4, 'league', 2000); + +-- ============================================================================= +-- Built-in Tournament Templates +-- ============================================================================= + +INSERT OR IGNORE INTO tournament_templates (id, name, description, chip_set_id, blind_structure_id, payout_structure_id, buyin_config_id, min_players, is_builtin) VALUES + (1, 'Turbo', 'Fast tournament ~2hr for 20 players', 1, 1, 1, 1, 6, 1), + (2, 'Standard', 'Standard tournament ~3-4hr for 20 players', 1, 2, 1, 2, 6, 1), + (3, 'Deep Stack', 'Deep stack tournament ~5-6hr for 20 players', 1, 3, 1, 3, 6, 1), + (4, 'WSOP-style', 'Long tournament with BB ante and slow progression', 1, 4, 1, 4, 6, 1); diff --git a/internal/template/tournament_test.go b/internal/template/tournament_test.go new file mode 100644 index 0000000..a823e81 --- /dev/null +++ b/internal/template/tournament_test.go @@ -0,0 +1,279 @@ +package template + +import ( + "context" + "database/sql" + "os" + "path/filepath" + "testing" + + _ "github.com/tursodatabase/go-libsql" +) + +// setupTestDB creates a temporary test database with required schema and seed data. +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() }) + + 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 payout_structures (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 payout_brackets (id INTEGER PRIMARY KEY AUTOINCREMENT, structure_id INTEGER NOT NULL, min_entries INTEGER NOT NULL, max_entries INTEGER NOT NULL)`, + `CREATE TABLE payout_tiers (id INTEGER PRIMARY KEY AUTOINCREMENT, bracket_id INTEGER NOT NULL, position INTEGER NOT NULL, percentage_basis_points INTEGER NOT NULL)`, + `CREATE TABLE buyin_configs (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, buyin_amount INTEGER DEFAULT 0, starting_chips INTEGER DEFAULT 0, rake_total INTEGER DEFAULT 0, bounty_amount INTEGER DEFAULT 0, bounty_chip INTEGER DEFAULT 0, rebuy_allowed INTEGER DEFAULT 0, rebuy_cost INTEGER DEFAULT 0, rebuy_chips INTEGER DEFAULT 0, rebuy_rake INTEGER DEFAULT 0, rebuy_limit INTEGER DEFAULT 0, rebuy_level_cutoff INTEGER, rebuy_time_cutoff_seconds INTEGER, rebuy_chip_threshold INTEGER, addon_allowed INTEGER DEFAULT 0, addon_cost INTEGER DEFAULT 0, addon_chips INTEGER DEFAULT 0, addon_rake INTEGER DEFAULT 0, addon_level_start INTEGER, addon_level_end INTEGER, reentry_allowed INTEGER DEFAULT 0, reentry_limit INTEGER DEFAULT 0, late_reg_level_cutoff INTEGER, late_reg_time_cutoff_seconds INTEGER, created_at INTEGER DEFAULT 0, updated_at INTEGER DEFAULT 0)`, + `CREATE TABLE rake_splits (id INTEGER PRIMARY KEY AUTOINCREMENT, buyin_config_id INTEGER NOT NULL, category TEXT NOT NULL, amount INTEGER NOT NULL DEFAULT 0)`, + `CREATE TABLE points_formulas (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, expression TEXT DEFAULT '', variables TEXT DEFAULT '{}', is_builtin INTEGER DEFAULT 0, created_at INTEGER DEFAULT 0, updated_at INTEGER DEFAULT 0)`, + `CREATE TABLE tournament_templates (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, description TEXT DEFAULT '', chip_set_id INTEGER NOT NULL, blind_structure_id INTEGER NOT NULL, payout_structure_id INTEGER NOT NULL, buyin_config_id INTEGER NOT NULL, points_formula_id INTEGER, 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, is_builtin INTEGER DEFAULT 0, created_at INTEGER DEFAULT 0, updated_at INTEGER DEFAULT 0)`, + `CREATE TABLE tournaments (id TEXT PRIMARY KEY, name TEXT, template_id INTEGER, chip_set_id INTEGER NOT NULL, blind_structure_id INTEGER NOT NULL, payout_structure_id INTEGER NOT NULL, buyin_config_id INTEGER NOT NULL, 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\nSQL: %s", err, stmt) + } + } + + // Seed building blocks + db.Exec(`INSERT INTO chip_sets (id, name) VALUES (1, 'Standard')`) + db.Exec(`INSERT INTO chip_denominations (chip_set_id, value, color_hex) VALUES (1, 25, '#FFF'), (1, 100, '#F00')`) + + db.Exec(`INSERT INTO blind_structures (id, name) VALUES (1, 'Test Structure')`) + db.Exec(`INSERT INTO blind_levels (structure_id, position, level_type, small_blind, big_blind, duration_seconds) VALUES (1, 0, 'round', 25, 50, 900)`) + + db.Exec(`INSERT INTO payout_structures (id, name) VALUES (1, 'Test Payout')`) + db.Exec(`INSERT INTO payout_brackets (id, structure_id, min_entries, max_entries) VALUES (1, 1, 2, 20)`) + db.Exec(`INSERT INTO payout_tiers (bracket_id, position, percentage_basis_points) VALUES (1, 1, 6000), (1, 2, 4000)`) + + db.Exec(`INSERT INTO buyin_configs (id, name, buyin_amount, starting_chips, rake_total) VALUES (1, 'Test Buy-in', 20000, 10000, 2000)`) + + return db +} + +func TestCreateTemplate_ValidReferences(t *testing.T) { + if os.Getenv("CGO_ENABLED") == "0" { + t.Skip("requires CGO for libsql") + } + + db := setupTestDB(t) + svc := NewTournamentTemplateService(db) + ctx := context.Background() + + tmpl, err := svc.CreateTemplate(ctx, &TournamentTemplate{ + Name: "My Tournament", + Description: "Test template", + ChipSetID: 1, + BlindStructureID: 1, + PayoutStructureID: 1, + BuyinConfigID: 1, + MinPlayers: 6, + }) + if err != nil { + t.Fatalf("create template: %v", err) + } + + if tmpl.ID == 0 { + t.Error("expected non-zero ID") + } + if tmpl.Name != "My Tournament" { + t.Errorf("expected name 'My Tournament', got %q", tmpl.Name) + } + if tmpl.ChipSetName != "Standard" { + t.Errorf("expected chip set name 'Standard', got %q", tmpl.ChipSetName) + } + if tmpl.BlindStructureName != "Test Structure" { + t.Errorf("expected blind structure name 'Test Structure', got %q", tmpl.BlindStructureName) + } +} + +func TestCreateTemplate_InvalidReference(t *testing.T) { + if os.Getenv("CGO_ENABLED") == "0" { + t.Skip("requires CGO for libsql") + } + + db := setupTestDB(t) + svc := NewTournamentTemplateService(db) + ctx := context.Background() + + _, err := svc.CreateTemplate(ctx, &TournamentTemplate{ + Name: "Bad Template", + ChipSetID: 999, // doesn't exist + BlindStructureID: 1, + PayoutStructureID: 1, + BuyinConfigID: 1, + }) + if err == nil { + t.Fatal("expected error for invalid chip set reference") + } +} + +func TestGetTemplateExpanded(t *testing.T) { + if os.Getenv("CGO_ENABLED") == "0" { + t.Skip("requires CGO for libsql") + } + + db := setupTestDB(t) + svc := NewTournamentTemplateService(db) + ctx := context.Background() + + tmpl, err := svc.CreateTemplate(ctx, &TournamentTemplate{ + Name: "Expandable", + ChipSetID: 1, + BlindStructureID: 1, + PayoutStructureID: 1, + BuyinConfigID: 1, + MinPlayers: 6, + }) + if err != nil { + t.Fatalf("create template: %v", err) + } + + expanded, err := svc.GetTemplateExpanded(ctx, tmpl.ID) + if err != nil { + t.Fatalf("get expanded: %v", err) + } + + if expanded.ChipSet == nil { + t.Fatal("expected chip set to be populated") + } + if expanded.ChipSet.Name != "Standard" { + t.Errorf("expected chip set name 'Standard', got %q", expanded.ChipSet.Name) + } + if len(expanded.ChipSet.Denominations) != 2 { + t.Errorf("expected 2 denominations, got %d", len(expanded.ChipSet.Denominations)) + } + + if expanded.BlindStructure == nil { + t.Fatal("expected blind structure to be populated") + } + if len(expanded.BlindStructure.Levels) != 1 { + t.Errorf("expected 1 level, got %d", len(expanded.BlindStructure.Levels)) + } + + if expanded.PayoutStructure == nil { + t.Fatal("expected payout structure to be populated") + } + if len(expanded.PayoutStructure.Brackets) != 1 { + t.Errorf("expected 1 bracket, got %d", len(expanded.PayoutStructure.Brackets)) + } + + if expanded.BuyinConfig == nil { + t.Fatal("expected buyin config to be populated") + } + if expanded.BuyinConfig.BuyinAmount != 20000 { + t.Errorf("expected buyin amount 20000, got %d", expanded.BuyinConfig.BuyinAmount) + } +} + +func TestSaveAsTemplate(t *testing.T) { + if os.Getenv("CGO_ENABLED") == "0" { + t.Skip("requires CGO for libsql") + } + + db := setupTestDB(t) + svc := NewTournamentTemplateService(db) + ctx := context.Background() + + // Create a tournament + db.Exec(`INSERT INTO tournaments (id, name, chip_set_id, blind_structure_id, payout_structure_id, buyin_config_id, min_players, is_pko) VALUES ('t1', 'Live Tournament', 1, 1, 1, 1, 8, 0)`) + + tmpl, err := svc.SaveAsTemplate(ctx, "t1", "Saved From Live") + if err != nil { + t.Fatalf("save as template: %v", err) + } + + if tmpl.Name != "Saved From Live" { + t.Errorf("expected name 'Saved From Live', got %q", tmpl.Name) + } + if tmpl.ChipSetID != 1 { + t.Errorf("expected chip_set_id 1, got %d", tmpl.ChipSetID) + } + if tmpl.MinPlayers != 8 { + t.Errorf("expected min_players 8, got %d", tmpl.MinPlayers) + } +} + +func TestDuplicateTemplate(t *testing.T) { + if os.Getenv("CGO_ENABLED") == "0" { + t.Skip("requires CGO for libsql") + } + + db := setupTestDB(t) + svc := NewTournamentTemplateService(db) + ctx := context.Background() + + original, err := svc.CreateTemplate(ctx, &TournamentTemplate{ + Name: "Original", + ChipSetID: 1, + BlindStructureID: 1, + PayoutStructureID: 1, + BuyinConfigID: 1, + MinPlayers: 6, + }) + if err != nil { + t.Fatalf("create template: %v", err) + } + + dup, err := svc.DuplicateTemplate(ctx, original.ID, "Duplicate") + if err != nil { + t.Fatalf("duplicate: %v", err) + } + + if dup.ID == original.ID { + t.Error("duplicate should have different ID") + } + if dup.Name != "Duplicate" { + t.Errorf("expected name 'Duplicate', got %q", dup.Name) + } + if dup.ChipSetID != original.ChipSetID { + t.Error("duplicate should have same chip set") + } +} + +func TestDeleteBuiltinTemplate(t *testing.T) { + if os.Getenv("CGO_ENABLED") == "0" { + t.Skip("requires CGO for libsql") + } + + db := setupTestDB(t) + svc := NewTournamentTemplateService(db) + ctx := context.Background() + + // Insert a builtin template directly + db.Exec(`INSERT INTO tournament_templates (id, name, chip_set_id, blind_structure_id, payout_structure_id, buyin_config_id, is_builtin) VALUES (99, 'Built-in', 1, 1, 1, 1, 1)`) + + err := svc.DeleteTemplate(ctx, 99) + if err == nil { + t.Fatal("expected error deleting built-in template") + } +} + +func TestListTemplates(t *testing.T) { + if os.Getenv("CGO_ENABLED") == "0" { + t.Skip("requires CGO for libsql") + } + + db := setupTestDB(t) + svc := NewTournamentTemplateService(db) + ctx := context.Background() + + // Create two templates + svc.CreateTemplate(ctx, &TournamentTemplate{Name: "A", ChipSetID: 1, BlindStructureID: 1, PayoutStructureID: 1, BuyinConfigID: 1}) + svc.CreateTemplate(ctx, &TournamentTemplate{Name: "B", ChipSetID: 1, BlindStructureID: 1, PayoutStructureID: 1, BuyinConfigID: 1}) + + list, err := svc.ListTemplates(ctx) + if err != nil { + t.Fatalf("list templates: %v", err) + } + if len(list) < 2 { + t.Errorf("expected at least 2 templates, got %d", len(list)) + } +}