package main import "testing" func bc(rank Rank, suit Suit) Card { return Card{Rank: rank, Suit: suit} } // resetForManualBout приводит только что созданную игру в чистое // состояние перед ручной подстановкой рук в тесте. Это нужно, // потому что NewBlackjackGame раздаёт случайные карты — и если // кому-то случайно выпадает натуральный блэкджек, конструктор сам // доигрывает и разрешает раунд ещё до того, как тест успевает // подставить нужные карты. Без сброса такой "случайный" исход // остаётся в g.Result и в Balance, и повторный вызов // advanceToNextPlayerOrResolve в тесте задваивает выплату. func resetForManualBout(g *BlackjackGameState) { g.Result = nil g.Phase = BJPhasePlayerTurn for i, p := range g.Players { p.Status = BJStatusPlaying p.Balance = g.preRoundBalance[i] - p.Bet } } func TestHandTotalBasic(t *testing.T) { cases := []struct { hand []Card want int }{ {[]Card{bc(King, Clubs), bc(Seven, Hearts)}, 17}, {[]Card{bc(Ace, Clubs), bc(King, Hearts)}, 21}, {[]Card{bc(Ace, Clubs), bc(Ace, Hearts)}, 12}, // 11+1 {[]Card{bc(Ace, Clubs), bc(Ace, Hearts), bc(Nine, Diamonds)}, 21}, // 1+1+9+... нет: 11+1+9=21 {[]Card{bc(King, Clubs), bc(Queen, Hearts), bc(Two, Diamonds)}, 22}, } for i, tc := range cases { got := handTotal(tc.hand) if got != tc.want { t.Errorf("случай %d: ожидалось %d, получено %d (рука %v)", i, tc.want, got, tc.hand) } } } func TestIsNaturalBlackjack(t *testing.T) { if !isNaturalBlackjack([]Card{bc(Ace, Clubs), bc(King, Hearts)}) { t.Errorf("туз+король должен быть натуральным блэкджеком") } if isNaturalBlackjack([]Card{bc(Seven, Clubs), bc(Seven, Hearts), bc(Seven, Diamonds)}) { t.Errorf("21 из трёх карт не должен считаться натуральным блэкджеком") } if isNaturalBlackjack([]Card{bc(King, Clubs), bc(Queen, Hearts)}) { t.Errorf("20 не должен считаться блэкджеком") } } func TestNewBlackjackGameDealsAndCollectsBet(t *testing.T) { g := NewBlackjackGame([]string{"A", "B"}, -1, 100, 10) for i, p := range g.Players { // ровно 2 карты игроку раздаются всегда, независимо от // исхода — даже если у кого-то мгновенно блэкджек и раунд // уже разрешился, руки игроков от этого не меняются if len(p.Hand) != 2 { t.Errorf("игрок %d: ожидалось 2 карты, получено %d", i, len(p.Hand)) } if p.Bet != 10 { t.Errorf("игрок %d: ожидалась ставка 10, получено %d", i, p.Bet) } // капитал сразу после ставки (90) проверяем только пока // раунд ещё не разрешился сам — если кому-то выпал // натуральный блэкджек, конструктор уже доиграл дилера и // начислил выплату, и Balance закономерно отличается от 90 if p.Status == BJStatusPlaying && g.Phase == BJPhasePlayerTurn { if p.Balance != 90 { t.Errorf("игрок %d: ожидался капитал 90 после ставки, получено %d", i, p.Balance) } } } if len(g.Dealer) < 2 { t.Errorf("у дилера должно быть минимум 2 карты, получено %d", len(g.Dealer)) } } func TestHitAddsCardAndBustEndsTurn(t *testing.T) { g := NewBlackjackGame([]string{"A", "B"}, -1, 100, 10) resetForManualBout(g) g.Players[0].Hand = []Card{bc(King, Clubs), bc(Queen, Hearts)} // 20 g.Players[0].Status = BJStatusPlaying g.CurrentPlayerIdx = 0 g.Phase = BJPhasePlayerTurn // подменяем колоду, чтобы гарантированно добрать карту с перебором g.Deck = &Deck{cards: []Card{bc(Five, Diamonds)}} if err := g.Hit(); err != nil { t.Fatalf("неожиданная ошибка: %v", err) } if g.Players[0].Status != BJStatusBusted { t.Fatalf("ожидался перебор, статус: %v", g.Players[0].Status) } if g.CurrentPlayerIdx != 1 { t.Fatalf("ход должен был перейти к игроку 1, получено %d", g.CurrentPlayerIdx) } } func TestStandMovesToNextPlayer(t *testing.T) { g := NewBlackjackGame([]string{"A", "B"}, -1, 100, 10) resetForManualBout(g) g.Players[0].Status = BJStatusPlaying g.Players[1].Status = BJStatusPlaying g.CurrentPlayerIdx = 0 g.Phase = BJPhasePlayerTurn if err := g.Stand(); err != nil { t.Fatalf("неожиданная ошибка: %v", err) } if g.Players[0].Status != BJStatusStood { t.Fatalf("ожидался статус stood") } if g.CurrentPlayerIdx != 1 { t.Fatalf("ход должен был перейти к игроку 1") } } func TestDoubleDownDoublesbetAndDrawsOneCard(t *testing.T) { g := NewBlackjackGame([]string{"A", "B"}, -1, 100, 10) resetForManualBout(g) g.Players[0].Hand = []Card{bc(Five, Clubs), bc(Six, Hearts)} // 11 g.Players[0].Status = BJStatusPlaying g.Players[0].Balance = 90 g.Players[0].Bet = 10 g.CurrentPlayerIdx = 0 g.Phase = BJPhasePlayerTurn g.Deck = &Deck{cards: []Card{bc(King, Diamonds)}} if err := g.DoubleDown(); err != nil { t.Fatalf("неожиданная ошибка: %v", err) } if g.Players[0].Bet != 20 { t.Errorf("ставка должна была удвоиться, получено %d", g.Players[0].Bet) } if g.Players[0].Balance != 80 { t.Errorf("ожидался капитал 80 после доплаты ставки, получено %d", g.Players[0].Balance) } if len(g.Players[0].Hand) != 3 { t.Errorf("после удвоения должна была добавиться ровно 1 карта, получено %d карт", len(g.Players[0].Hand)) } if g.Players[0].Status != BJStatusStood { t.Errorf("после удвоения (11+король=21, не перебор) статус должен быть stood, получено %v", g.Players[0].Status) } } func TestDoubleDownRejectsWithMoreThanTwoCards(t *testing.T) { g := NewBlackjackGame([]string{"A", "B"}, -1, 100, 10) resetForManualBout(g) g.Players[0].Hand = []Card{bc(Five, Clubs), bc(Six, Hearts), bc(Two, Diamonds)} g.Players[0].Status = BJStatusPlaying g.CurrentPlayerIdx = 0 g.Phase = BJPhasePlayerTurn if err := g.DoubleDown(); err != ErrBJCannotDouble { t.Errorf("ожидалась ошибка ErrBJCannotDouble, получено: %v", err) } } func TestDoubleDownRejectsInsufficientFunds(t *testing.T) { g := NewBlackjackGame([]string{"A", "B"}, -1, 100, 10) resetForManualBout(g) g.Players[0].Hand = []Card{bc(Five, Clubs), bc(Six, Hearts)} g.Players[0].Status = BJStatusPlaying g.Players[0].Balance = 5 // меньше ставки g.Players[0].Bet = 10 g.CurrentPlayerIdx = 0 g.Phase = BJPhasePlayerTurn if err := g.DoubleDown(); err != ErrBJNotEnoughFunds { t.Errorf("ожидалась ошибка ErrBJNotEnoughFunds, получено: %v", err) } } func TestPlayerBlackjackBeatsDealerNonBlackjack(t *testing.T) { g := NewBlackjackGame([]string{"A"}, -1, 100, 10) resetForManualBout(g) g.Players[0].Hand = []Card{bc(Ace, Clubs), bc(King, Hearts)} g.Players[0].Status = BJStatusBlackjack g.Dealer = []Card{bc(Nine, Clubs), bc(Seven, Hearts)} // 16, дилер должен добрать g.Deck = &Deck{cards: []Card{bc(Two, Diamonds)}} // 16+2=18, останов g.CurrentPlayerIdx = len(g.Players) g.advanceToNextPlayerOrResolve() if g.Phase != BJPhaseRoundOver { t.Fatalf("раунд должен был завершиться") } res := g.Result.PlayerResults[0] if res.Outcome != "blackjack" { t.Fatalf("ожидался исход blackjack, получено %s", res.Outcome) } // ставка 10: блэкджек платит 3:2 -> получает 10+15=25, было 90 (после // списания ставки) -> дельта относительно ДО раунда (100): +15 if res.Delta != 15 { t.Errorf("ожидалась дельта +15, получено %d", res.Delta) } } func TestPushWhenBothBlackjack(t *testing.T) { g := NewBlackjackGame([]string{"A"}, -1, 100, 10) resetForManualBout(g) g.Players[0].Hand = []Card{bc(Ace, Clubs), bc(King, Hearts)} g.Players[0].Status = BJStatusBlackjack g.Dealer = []Card{bc(Ace, Diamonds), bc(Queen, Spades)} g.CurrentPlayerIdx = len(g.Players) g.advanceToNextPlayerOrResolve() res := g.Result.PlayerResults[0] if res.Outcome != "push" { t.Fatalf("ожидался push при блэкджеке у обоих, получено %s", res.Outcome) } if res.Delta != 0 { t.Errorf("ожидалась нулевая дельта при push, получено %d", res.Delta) } } func TestDealerBustPaysStandingPlayer(t *testing.T) { g := NewBlackjackGame([]string{"A"}, -1, 100, 10) resetForManualBout(g) g.Players[0].Hand = []Card{bc(King, Clubs), bc(Nine, Hearts)} // 19 g.Players[0].Status = BJStatusStood g.Dealer = []Card{bc(King, Diamonds), bc(Six, Spades)} // 16, обязан добрать g.Deck = &Deck{cards: []Card{bc(King, Hearts)}} // 16+10=26, перебор g.CurrentPlayerIdx = len(g.Players) g.advanceToNextPlayerOrResolve() if !g.Result.DealerBusted { t.Fatalf("дилер должен был перебрать") } res := g.Result.PlayerResults[0] if res.Outcome != "win" { t.Fatalf("ожидался win при переборе дилера, получено %s", res.Outcome) } if res.Delta != 10 { t.Errorf("ожидалась дельта +10 (выигрыш 1:1), получено %d", res.Delta) } } func TestBustedPlayerLosesRegardlessOfDealer(t *testing.T) { g := NewBlackjackGame([]string{"A"}, -1, 100, 10) resetForManualBout(g) g.Players[0].Hand = []Card{bc(King, Clubs), bc(Queen, Hearts), bc(Five, Diamonds)} // 25 g.Players[0].Status = BJStatusBusted g.Dealer = []Card{bc(King, Diamonds), bc(Nine, Spades)} // 19 g.CurrentPlayerIdx = len(g.Players) g.advanceToNextPlayerOrResolve() res := g.Result.PlayerResults[0] if res.Outcome != "bust" { t.Fatalf("ожидался исход bust, получено %s", res.Outcome) } if res.Delta != -10 { t.Errorf("ожидалась дельта -10 (потеря ставки), получено %d", res.Delta) } } func TestPushOnEqualTotals(t *testing.T) { g := NewBlackjackGame([]string{"A"}, -1, 100, 10) resetForManualBout(g) g.Players[0].Hand = []Card{bc(King, Clubs), bc(Nine, Hearts)} // 19 g.Players[0].Status = BJStatusStood g.Dealer = []Card{bc(King, Diamonds), bc(Nine, Spades)} // 19, останов без добора g.CurrentPlayerIdx = len(g.Players) g.advanceToNextPlayerOrResolve() res := g.Result.PlayerResults[0] if res.Outcome != "push" { t.Fatalf("ожидался push при равных суммах, получено %s", res.Outcome) } if res.Delta != 0 { t.Errorf("ожидалась нулевая дельта, получено %d", res.Delta) } } func TestDealerStandsOnAllSeventeens(t *testing.T) { g := NewBlackjackGame([]string{"A"}, -1, 100, 10) resetForManualBout(g) g.Players[0].Hand = []Card{bc(King, Clubs), bc(Nine, Hearts)} // 19 g.Players[0].Status = BJStatusStood // мягкие 17: туз + 6 = 17. Дилер должен остановиться, не добирая. g.Dealer = []Card{bc(Ace, Diamonds), bc(Six, Spades)} g.Deck = &Deck{cards: []Card{bc(King, Hearts)}} // если бы дилер тянул — перебор g.CurrentPlayerIdx = len(g.Players) g.advanceToNextPlayerOrResolve() if len(g.Dealer) != 2 { t.Fatalf("дилер не должен был тянуть карту на мягких 17, рука: %v", g.Dealer) } if g.Result.DealerTotal != 17 { t.Errorf("ожидалась сумма дилера 17, получено %d", g.Result.DealerTotal) } } func TestNextRoundCarriesOverBalance(t *testing.T) { g := NewBlackjackGame([]string{"A", "B"}, 0, 100, 10) g.Players[0].Balance = 150 g.Players[1].Balance = 42 next := g.NextRound() // Капитал должен перенестись, а новая ставка — списаться заново. // Проверяем именно preRoundBalance (капитал ДО ставки нового // раунда), а не текущий Balance: если кому-то в новом раунде // случайно выпадет блэкджек, конструктор сам мгновенно разрешит // раунд и Balance уже отразит и ставку, и выплату — это ожидаемое // поведение движка, но не то, что здесь проверяется. if next.preRoundBalance[0] != 150 { t.Errorf("ожидался перенесённый капитал 150 до ставки, получено %d", next.preRoundBalance[0]) } if next.preRoundBalance[1] != 42 { t.Errorf("ожидался перенесённый капитал 42 до ставки, получено %d", next.preRoundBalance[1]) } if next.Players[0].Balance != 140 && next.Phase == BJPhasePlayerTurn { t.Errorf("до какого-либо разрешения раунда ожидался капитал 140 (150-10), получено %d", next.Players[0].Balance) } if !next.Players[0].IsHuman { t.Errorf("флаг IsHuman должен был сохраниться при переносе") } } // TestBlackjackFullSimulation прогоняет много полных раундов с // ботами разного числа игроков, проверяя отсутствие паник и // корректность (капитал не должен непредсказуемо телепортироваться: // сумма всех дельт равна сумме (dealer-neutral, т.е. каждый платится // независимо от банка, поэтому проверяем только отсутствие паник и // что результат всегда присутствует). func TestBlackjackFullSimulation(t *testing.T) { bot := BlackjackBot{} for numPlayers := 1; numPlayers <= 6; numPlayers++ { for round := 0; round < 50; round++ { names := make([]string, numPlayers) for i := range names { names[i] = "Bot" } g := NewBlackjackGame(names, -1, 1000, 10) const maxSteps = 100 steps := 0 for ; steps < maxSteps && g.Phase != BJPhaseRoundOver; steps++ { if err := bot.PlayFullTurn(g); err != nil { t.Fatalf("игроков %d, раунд %d: ошибка бота: %v", numPlayers, round, err) } } if steps >= maxSteps { t.Fatalf("игроков %d, раунд %d: раунд не завершился за %d шагов", numPlayers, round, maxSteps) } if g.Result == nil { t.Fatalf("игроков %d, раунд %d: раунд завершился без результата", numPlayers, round) } if len(g.Result.PlayerResults) != numPlayers { t.Fatalf("игроков %d, раунд %d: ожидалось %d результатов, получено %d", numPlayers, round, numPlayers, len(g.Result.PlayerResults)) } } } } func TestNewBlackjackRoundClampsInsufficientBet(t *testing.T) { g := NewBlackjackRound([]string{"Вы", "Бот"}, 0, 100, 10, []int{5, 100}) if g.Players[0].Bet != 5 { t.Errorf("ставка должна была урезаться до всего оставшегося баланса (5), получено %d", g.Players[0].Bet) } if g.Players[0].Balance != 0 { t.Errorf("после урезанной ставки баланс должен был стать ровно 0, получено %d", g.Players[0].Balance) } if !g.RoundBetReduced[0] { t.Errorf("ожидался флаг RoundBetReduced[0]=true") } if g.RoundBetReduced[1] { t.Errorf("у игрока с достаточным балансом флаг урезания ставки быть не должно") } } func TestNewBlackjackRoundNeverGoesNegative(t *testing.T) { g := NewBlackjackRound([]string{"Вы"}, 0, 100, 10, []int{0}) if g.Players[0].Balance < 0 { t.Fatalf("баланс не должен был уйти в минус, получено %d", g.Players[0].Balance) } if g.Players[0].Balance != 0 { t.Errorf("баланс при отсутствии денег должен был остаться 0, получено %d", g.Players[0].Balance) } if g.Players[0].Bet != 0 { t.Errorf("ставка при отсутствии денег должна была стать 0, получено %d", g.Players[0].Bet) } } func TestBlackjackZeroBetRoundNeverChangesBalance(t *testing.T) { g := NewBlackjackRound([]string{"Вы"}, 0, 100, 10, []int{0}) // доигрываем раунд ботом (человек тоже отыгрывает автоматически через Stand) for g.Phase == BJPhasePlayerTurn { if err := g.Stand(); err != nil { t.Fatalf("неожиданная ошибка: %v", err) } } if g.Players[0].Balance != 0 { t.Errorf("при нулевой ставке баланс не должен был измениться ни при каком исходе, получено %d", g.Players[0].Balance) } } func TestBlackjackMultipleRoundsNeverGoNegativeWithLowCapital(t *testing.T) { names := []string{"Вы", "Бот1", "Бот2"} g := NewBlackjackGame(names, 0, 15, 10) // капитала хватит всего на 1-2 полные ставки bot := BlackjackBot{} for round := 0; round < 20; round++ { for steps := 0; steps < 100 && g.Phase != BJPhaseRoundOver; steps++ { if err := bot.PlayFullTurn(g); err != nil { t.Fatalf("раунд %d: неожиданная ошибка бота: %v", round, err) } } for _, p := range g.Players { if p.Balance < 0 { t.Fatalf("раунд %d: баланс игрока %s ушёл в минус: %d", round, p.Name, p.Balance) } } g = g.NextRound() } }