- TDA-compliant balance engine with live-adaptive suggestions - Break table distributes players evenly across remaining tables - Stale suggestion detection and invalidation on state changes - Full REST API for tables, seating, balancing, blueprints, hand-for-hand - 15 tests covering balance, break table, auto-seat, and dealer button Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
576 lines
17 KiB
Go
576 lines
17 KiB
Go
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)
|
|
}
|
|
}
|