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>
This commit is contained in:
Mikkel Georgsen 2026-03-01 03:59:34 +01:00
parent dd2f9bbfd9
commit 7dbb4cab1a
3 changed files with 786 additions and 0 deletions

View file

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

View file

@ -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);

View file

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