- 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>
315 lines
9.2 KiB
Go
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
|
|
}
|