package seating import ( "context" "database/sql" "fmt" "testing" _ "github.com/tursodatabase/go-libsql" ) // testDB creates an in-memory SQLite database with the required schema. func testDB(t *testing.T) *sql.DB { t.Helper() db, err := sql.Open("libsql", "file::memory:?cache=shared") if err != nil { t.Fatalf("open test db: %v", err) } t.Cleanup(func() { db.Close() }) stmts := []string{ `CREATE TABLE IF NOT EXISTS tournaments ( id TEXT PRIMARY KEY, name TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'created', buyin_config_id INTEGER NOT NULL DEFAULT 1, chip_set_id INTEGER NOT NULL DEFAULT 1, blind_structure_id INTEGER NOT NULL DEFAULT 1, payout_structure_id INTEGER NOT NULL DEFAULT 1, min_players INTEGER NOT NULL DEFAULT 2, current_level INTEGER NOT NULL DEFAULT 0, clock_state TEXT NOT NULL DEFAULT 'stopped', clock_remaining_ns INTEGER NOT NULL DEFAULT 0, total_elapsed_ns INTEGER NOT NULL DEFAULT 0, hand_for_hand INTEGER NOT NULL DEFAULT 0, hand_for_hand_hand_number INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL DEFAULT 0 )`, `CREATE TABLE IF NOT EXISTS tables ( id INTEGER PRIMARY KEY AUTOINCREMENT, tournament_id TEXT NOT NULL, name TEXT NOT NULL, seat_count INTEGER NOT NULL DEFAULT 9, dealer_button_position INTEGER, is_active INTEGER NOT NULL DEFAULT 1, hand_completed INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL DEFAULT 0 )`, `CREATE TABLE IF NOT EXISTS players ( id TEXT PRIMARY KEY, name TEXT NOT NULL, created_at INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL DEFAULT 0 )`, `CREATE TABLE IF NOT EXISTS tournament_players ( id INTEGER PRIMARY KEY AUTOINCREMENT, tournament_id TEXT NOT NULL, player_id TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'registered', seat_table_id INTEGER, seat_position INTEGER, current_chips INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL DEFAULT 0, UNIQUE(tournament_id, player_id) )`, `CREATE TABLE IF NOT EXISTS balance_suggestions ( id INTEGER PRIMARY KEY AUTOINCREMENT, tournament_id TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', from_table_id INTEGER NOT NULL, to_table_id INTEGER NOT NULL, player_id TEXT, from_seat INTEGER, to_seat INTEGER, reason TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL DEFAULT 0, resolved_at INTEGER )`, `CREATE TABLE IF NOT EXISTS table_blueprints ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, table_configs TEXT NOT NULL DEFAULT '[]', created_at INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL DEFAULT 0 )`, `CREATE TABLE IF NOT EXISTS audit_entries ( id TEXT PRIMARY KEY, tournament_id TEXT, timestamp INTEGER NOT NULL DEFAULT 0, operator_id TEXT NOT NULL DEFAULT 'system', action TEXT NOT NULL DEFAULT '', target_type TEXT NOT NULL DEFAULT '', target_id TEXT NOT NULL DEFAULT '', previous_state TEXT, new_state TEXT, metadata TEXT, undone_by TEXT )`, } for _, stmt := range stmts { if _, err := db.Exec(stmt); err != nil { t.Fatalf("exec schema: %v: %s", err, stmt[:60]) } } return db } // seedTournament creates a test tournament. func seedTournament(t *testing.T, db *sql.DB, id string) { t.Helper() _, err := db.Exec(`INSERT INTO tournaments (id, name) VALUES (?, ?)`, id, "Test Tournament") if err != nil { t.Fatalf("seed tournament: %v", err) } } // seedTable creates a test table and returns its ID. func seedTable(t *testing.T, db *sql.DB, tournamentID, name string, seatCount int) int { t.Helper() result, err := db.Exec( `INSERT INTO tables (tournament_id, name, seat_count, is_active) VALUES (?, ?, ?, 1)`, tournamentID, name, seatCount, ) if err != nil { t.Fatalf("seed table: %v", err) } id, _ := result.LastInsertId() return int(id) } // seedPlayer creates a test player and returns their ID. func seedPlayer(t *testing.T, db *sql.DB, id, name string) { t.Helper() _, err := db.Exec(`INSERT INTO players (id, name) VALUES (?, ?)`, id, name) if err != nil { t.Fatalf("seed player: %v", err) } } // seatPlayer creates a tournament_players entry with a specific seat. func seatPlayer(t *testing.T, db *sql.DB, tournamentID, playerID string, tableID, seatPos int) { t.Helper() _, err := db.Exec( `INSERT INTO tournament_players (tournament_id, player_id, status, seat_table_id, seat_position, current_chips) VALUES (?, ?, 'active', ?, ?, 10000)`, tournamentID, playerID, tableID, seatPos, ) if err != nil { t.Fatalf("seat player: %v", err) } } // ---------- Balance Tests ---------- func TestCheckBalance_TwoTables_Balanced(t *testing.T) { db := testDB(t) ctx := context.Background() tournamentID := "t1" seedTournament(t, db, tournamentID) tbl1 := seedTable(t, db, tournamentID, "Table 1", 9) tbl2 := seedTable(t, db, tournamentID, "Table 2", 9) // 7 and 6 players: diff = 1, balanced for i := 1; i <= 7; i++ { pid := fmt.Sprintf("p1_%d", i) seedPlayer(t, db, pid, fmt.Sprintf("Player %d", i)) seatPlayer(t, db, tournamentID, pid, tbl1, i) } for i := 1; i <= 6; i++ { pid := fmt.Sprintf("p2_%d", i) seedPlayer(t, db, pid, fmt.Sprintf("Player %d", i+7)) seatPlayer(t, db, tournamentID, pid, tbl2, i) } engine := NewBalanceEngine(db, nil, nil) status, err := engine.CheckBalance(ctx, tournamentID) if err != nil { t.Fatalf("check balance: %v", err) } if !status.IsBalanced { t.Errorf("expected balanced, got unbalanced with diff=%d", status.MaxDifference) } if status.MaxDifference != 1 { t.Errorf("expected max difference 1, got %d", status.MaxDifference) } } func TestCheckBalance_TwoTables_Unbalanced(t *testing.T) { db := testDB(t) ctx := context.Background() tournamentID := "t1" seedTournament(t, db, tournamentID) tbl1 := seedTable(t, db, tournamentID, "Table 1", 9) tbl2 := seedTable(t, db, tournamentID, "Table 2", 9) // 8 and 6 players: diff = 2, unbalanced for i := 1; i <= 8; i++ { pid := fmt.Sprintf("p1_%d", i) seedPlayer(t, db, pid, fmt.Sprintf("Player %d", i)) seatPlayer(t, db, tournamentID, pid, tbl1, i) } for i := 1; i <= 6; i++ { pid := fmt.Sprintf("p2_%d", i) seedPlayer(t, db, pid, fmt.Sprintf("Player %d", i+8)) seatPlayer(t, db, tournamentID, pid, tbl2, i) } engine := NewBalanceEngine(db, nil, nil) status, err := engine.CheckBalance(ctx, tournamentID) if err != nil { t.Fatalf("check balance: %v", err) } if status.IsBalanced { t.Error("expected unbalanced, got balanced") } if status.MaxDifference != 2 { t.Errorf("expected max difference 2, got %d", status.MaxDifference) } if status.NeedsMoves != 1 { t.Errorf("expected 1 move needed, got %d", status.NeedsMoves) } } func TestCheckBalance_SingleTable(t *testing.T) { db := testDB(t) ctx := context.Background() tournamentID := "t1" seedTournament(t, db, tournamentID) tbl := seedTable(t, db, tournamentID, "Table 1", 9) seedPlayer(t, db, "p1", "Player 1") seatPlayer(t, db, tournamentID, "p1", tbl, 1) engine := NewBalanceEngine(db, nil, nil) status, err := engine.CheckBalance(ctx, tournamentID) if err != nil { t.Fatalf("check balance: %v", err) } if !status.IsBalanced { t.Error("single table should always be balanced") } } func TestSuggestMoves_PicksFromLargestTable(t *testing.T) { db := testDB(t) ctx := context.Background() tournamentID := "t1" seedTournament(t, db, tournamentID) tbl1 := seedTable(t, db, tournamentID, "Table 1", 9) tbl2 := seedTable(t, db, tournamentID, "Table 2", 9) // 8 vs 6: need 1 move from tbl1 to tbl2 for i := 1; i <= 8; i++ { pid := fmt.Sprintf("p1_%d", i) seedPlayer(t, db, pid, fmt.Sprintf("Player 1-%d", i)) seatPlayer(t, db, tournamentID, pid, tbl1, i) } for i := 1; i <= 6; i++ { pid := fmt.Sprintf("p2_%d", i) seedPlayer(t, db, pid, fmt.Sprintf("Player 2-%d", i)) seatPlayer(t, db, tournamentID, pid, tbl2, i) } engine := NewBalanceEngine(db, nil, nil) suggestions, err := engine.SuggestMoves(ctx, tournamentID) if err != nil { t.Fatalf("suggest moves: %v", err) } if len(suggestions) != 1 { t.Fatalf("expected 1 suggestion, got %d", len(suggestions)) } sugg := suggestions[0] if sugg.FromTableID != tbl1 { t.Errorf("expected move from table %d, got %d", tbl1, sugg.FromTableID) } if sugg.ToTableID != tbl2 { t.Errorf("expected move to table %d, got %d", tbl2, sugg.ToTableID) } if sugg.Status != "pending" { t.Errorf("expected status pending, got %s", sugg.Status) } if sugg.PlayerID == nil { t.Error("expected a player to be suggested") } } func TestAcceptSuggestion_StaleDetection(t *testing.T) { db := testDB(t) ctx := context.Background() tournamentID := "t1" seedTournament(t, db, tournamentID) tbl1 := seedTable(t, db, tournamentID, "Table 1", 9) tbl2 := seedTable(t, db, tournamentID, "Table 2", 9) // Setup: 8 vs 6, generate suggestion for i := 1; i <= 8; i++ { pid := fmt.Sprintf("p1_%d", i) seedPlayer(t, db, pid, fmt.Sprintf("Player 1-%d", i)) seatPlayer(t, db, tournamentID, pid, tbl1, i) } for i := 1; i <= 6; i++ { pid := fmt.Sprintf("p2_%d", i) seedPlayer(t, db, pid, fmt.Sprintf("Player 2-%d", i)) seatPlayer(t, db, tournamentID, pid, tbl2, i) } engine := NewBalanceEngine(db, nil, nil) suggestions, err := engine.SuggestMoves(ctx, tournamentID) if err != nil { t.Fatalf("suggest moves: %v", err) } if len(suggestions) == 0 { t.Fatal("expected at least 1 suggestion") } suggID := suggestions[0].ID // Simulate state change: move a player manually so tables are 7/7 _, _ = db.Exec( `UPDATE tournament_players SET seat_table_id = ?, seat_position = 7 WHERE tournament_id = ? AND player_id = 'p1_8'`, tbl2, tournamentID, ) // Accept should fail as stale err = engine.AcceptSuggestion(ctx, tournamentID, suggID, 8, 7) if err == nil { t.Fatal("expected error for stale suggestion") } // Verify suggestion was expired var status string db.QueryRow(`SELECT status FROM balance_suggestions WHERE id = ?`, suggID).Scan(&status) if status != "expired" { t.Errorf("expected suggestion status 'expired', got '%s'", status) } } func TestInvalidateStaleSuggestions_BustDuringPending(t *testing.T) { db := testDB(t) ctx := context.Background() tournamentID := "t1" seedTournament(t, db, tournamentID) tbl1 := seedTable(t, db, tournamentID, "Table 1", 9) tbl2 := seedTable(t, db, tournamentID, "Table 2", 9) // Setup: 8 vs 6 for i := 1; i <= 8; i++ { pid := fmt.Sprintf("p1_%d", i) seedPlayer(t, db, pid, fmt.Sprintf("Player 1-%d", i)) seatPlayer(t, db, tournamentID, pid, tbl1, i) } for i := 1; i <= 6; i++ { pid := fmt.Sprintf("p2_%d", i) seedPlayer(t, db, pid, fmt.Sprintf("Player 2-%d", i)) seatPlayer(t, db, tournamentID, pid, tbl2, i) } engine := NewBalanceEngine(db, nil, nil) suggestions, _ := engine.SuggestMoves(ctx, tournamentID) if len(suggestions) == 0 { t.Fatal("expected suggestions") } // Simulate bust: player busts from tbl1, making it 7 vs 6 (balanced) _, _ = db.Exec( `UPDATE tournament_players SET status = 'busted', seat_table_id = NULL, seat_position = NULL WHERE tournament_id = ? AND player_id = 'p1_8'`, tournamentID, ) // Invalidate stale suggestions err := engine.InvalidateStaleSuggestions(ctx, tournamentID) if err != nil { t.Fatalf("invalidate: %v", err) } // All pending suggestions should be expired var pendingCount int db.QueryRow(`SELECT COUNT(*) FROM balance_suggestions WHERE tournament_id = ? AND status = 'pending'`, tournamentID).Scan(&pendingCount) if pendingCount != 0 { t.Errorf("expected 0 pending suggestions after invalidation, got %d", pendingCount) } } func TestCheckBalance_ThreeTables_NeedsTwoMoves(t *testing.T) { db := testDB(t) ctx := context.Background() tournamentID := "t1" seedTournament(t, db, tournamentID) tbl1 := seedTable(t, db, tournamentID, "Table 1", 9) tbl2 := seedTable(t, db, tournamentID, "Table 2", 9) tbl3 := seedTable(t, db, tournamentID, "Table 3", 9) // 9, 5, 4 players across 3 tables -> need to redistribute to ~6,6,6 for i := 1; i <= 9; i++ { pid := fmt.Sprintf("p1_%d", i) seedPlayer(t, db, pid, fmt.Sprintf("Player 1-%d", i)) seatPlayer(t, db, tournamentID, pid, tbl1, i) } for i := 1; i <= 5; i++ { pid := fmt.Sprintf("p2_%d", i) seedPlayer(t, db, pid, fmt.Sprintf("Player 2-%d", i)) seatPlayer(t, db, tournamentID, pid, tbl2, i) } for i := 1; i <= 4; i++ { pid := fmt.Sprintf("p3_%d", i) seedPlayer(t, db, pid, fmt.Sprintf("Player 3-%d", i)) seatPlayer(t, db, tournamentID, pid, tbl3, i) } engine := NewBalanceEngine(db, nil, nil) status, err := engine.CheckBalance(ctx, tournamentID) if err != nil { t.Fatalf("check balance: %v", err) } if status.IsBalanced { t.Error("expected unbalanced") } if status.MaxDifference != 5 { t.Errorf("expected max difference 5, got %d", status.MaxDifference) } // 18 total / 3 tables = 6 each. Table 1 has 3 surplus. if status.NeedsMoves != 3 { t.Errorf("expected 3 moves needed, got %d", status.NeedsMoves) } } func TestCancelSuggestion(t *testing.T) { db := testDB(t) ctx := context.Background() tournamentID := "t1" seedTournament(t, db, tournamentID) tbl1 := seedTable(t, db, tournamentID, "Table 1", 9) tbl2 := seedTable(t, db, tournamentID, "Table 2", 9) for i := 1; i <= 8; i++ { pid := fmt.Sprintf("p1_%d", i) seedPlayer(t, db, pid, fmt.Sprintf("Player 1-%d", i)) seatPlayer(t, db, tournamentID, pid, tbl1, i) } for i := 1; i <= 6; i++ { pid := fmt.Sprintf("p2_%d", i) seedPlayer(t, db, pid, fmt.Sprintf("Player 2-%d", i)) seatPlayer(t, db, tournamentID, pid, tbl2, i) } engine := NewBalanceEngine(db, nil, nil) suggestions, _ := engine.SuggestMoves(ctx, tournamentID) if len(suggestions) == 0 { t.Fatal("expected suggestions") } err := engine.CancelSuggestion(ctx, tournamentID, suggestions[0].ID) if err != nil { t.Fatalf("cancel suggestion: %v", err) } var status string db.QueryRow(`SELECT status FROM balance_suggestions WHERE id = ?`, suggestions[0].ID).Scan(&status) if status != "cancelled" { t.Errorf("expected status 'cancelled', got '%s'", status) } } // ---------- Auto-seat Tests ---------- func TestAutoSeat_FillsEvenly(t *testing.T) { db := testDB(t) ctx := context.Background() tournamentID := "t1" seedTournament(t, db, tournamentID) tbl1 := seedTable(t, db, tournamentID, "Table 1", 6) tbl2 := seedTable(t, db, tournamentID, "Table 2", 6) // Seat 3 at table 1, 1 at table 2 for i := 1; i <= 3; i++ { pid := fmt.Sprintf("p1_%d", i) seedPlayer(t, db, pid, fmt.Sprintf("Player 1-%d", i)) seatPlayer(t, db, tournamentID, pid, tbl1, i) } seedPlayer(t, db, "p2_1", "Player 2-1") seatPlayer(t, db, tournamentID, "p2_1", tbl2, 1) // Auto-seat should pick table 2 (fewest players) ts := NewTableService(db, nil, nil, nil) seedPlayer(t, db, "new_player", "New Player") _, _ = db.Exec(`INSERT INTO tournament_players (tournament_id, player_id, status, current_chips) VALUES (?, ?, 'registered', 0)`, tournamentID, "new_player") assignment, err := ts.AutoAssignSeat(ctx, tournamentID, "new_player") if err != nil { t.Fatalf("auto assign: %v", err) } if assignment.TableID != tbl2 { t.Errorf("expected assignment to table %d (fewer players), got %d", tbl2, assignment.TableID) } } // ---------- Dealer Button Tests ---------- func TestDealerButton_AdvancesSkippingEmpty(t *testing.T) { db := testDB(t) ctx := context.Background() tournamentID := "t1" seedTournament(t, db, tournamentID) tbl := seedTable(t, db, tournamentID, "Table 1", 9) // Seat players at positions 1, 3, 7 (skip 2, 4-6, 8-9) seedPlayer(t, db, "p1", "Player 1") seatPlayer(t, db, tournamentID, "p1", tbl, 1) seedPlayer(t, db, "p3", "Player 3") seatPlayer(t, db, tournamentID, "p3", tbl, 3) seedPlayer(t, db, "p7", "Player 7") seatPlayer(t, db, tournamentID, "p7", tbl, 7) ts := NewTableService(db, nil, nil, nil) // Set button at position 1 if err := ts.SetDealerButton(ctx, tournamentID, tbl, 1); err != nil { t.Fatalf("set button: %v", err) } // Advance should skip to position 3 (next occupied) if err := ts.AdvanceDealerButton(ctx, tournamentID, tbl); err != nil { t.Fatalf("advance button: %v", err) } var btnPos sql.NullInt64 db.QueryRow(`SELECT dealer_button_position FROM tables WHERE id = ?`, tbl).Scan(&btnPos) if !btnPos.Valid || int(btnPos.Int64) != 3 { t.Errorf("expected button at position 3, got %v", btnPos) } // Advance again: should skip to position 7 if err := ts.AdvanceDealerButton(ctx, tournamentID, tbl); err != nil { t.Fatalf("advance button: %v", err) } db.QueryRow(`SELECT dealer_button_position FROM tables WHERE id = ?`, tbl).Scan(&btnPos) if !btnPos.Valid || int(btnPos.Int64) != 7 { t.Errorf("expected button at position 7, got %v", btnPos) } // Advance again: should wrap to position 1 if err := ts.AdvanceDealerButton(ctx, tournamentID, tbl); err != nil { t.Fatalf("advance button: %v", err) } db.QueryRow(`SELECT dealer_button_position FROM tables WHERE id = ?`, tbl).Scan(&btnPos) if !btnPos.Valid || int(btnPos.Int64) != 1 { t.Errorf("expected button at position 1 (wrap), got %v", btnPos) } }