felt/internal/template/tournament_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

279 lines
12 KiB
Go

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))
}
}