package main import ( "testing" tea "github.com/charmbracelet/bubbletea" ) // key — маленький хелпер для конструирования tea.KeyMsg, которые // соответствуют строкам, разбираемым в handleActionKey/handleDrawKey. func key(s string) tea.KeyMsg { switch s { case "left": return tea.KeyMsg{Type: tea.KeyLeft} case "right": return tea.KeyMsg{Type: tea.KeyRight} case "up": return tea.KeyMsg{Type: tea.KeyUp} case "down": return tea.KeyMsg{Type: tea.KeyDown} case "esc": return tea.KeyMsg{Type: tea.KeyEsc} case " ": return tea.KeyMsg{Type: tea.KeySpace} case "enter": return tea.KeyMsg{Type: tea.KeyEnter} case "ctrl+c": return tea.KeyMsg{Type: tea.KeyCtrlC} default: return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} } } func TestTUIDrawAndDiscardCycle(t *testing.T) { m := NewModel() // делаем раунд предсказуемым: у человека маленькая рука без // готовых комбинаций m.game.Players[0].Hand = []Card{c(Two, Clubs), c(Nine, Hearts), c(King, Spades)} m.game.CurrentPlayer = 0 m.game.Phase = PhaseDraw next, _ := m.Update(key("d")) m = next.(Model) if m.game.Phase != PhaseAction { t.Fatalf("после взятия карты ожидалась фаза PhaseAction, получена %v", m.game.Phase) } if len(m.game.current().Hand) != 4 { t.Fatalf("ожидалось 4 карты после взятия, получено %d", len(m.game.current().Hand)) } next, _ = m.Update(key("enter")) m = next.(Model) if m.game.CurrentPlayer == m.humanIdx && m.game.Phase != PhaseGameOver { t.Fatalf("после сброса ход должен был перейти к боту") } } func TestTUILayMeldViaKeys(t *testing.T) { m := NewModel() m.game.Players[0].Hand = []Card{ c(Seven, Clubs), c(Seven, Hearts), c(Seven, Spades), c(Two, Diamonds), } m.game.CurrentPlayer = 0 m.game.Phase = PhaseAction // отмечаем три семёрки пробелом, двигая курсор for i := 0; i < 3; i++ { next, _ := m.Update(key(" ")) m = next.(Model) if i < 2 { next, _ = m.Update(key("right")) m = next.(Model) } } if len(m.staged) != 3 { t.Fatalf("ожидалось 3 отмеченные карты, получено %d", len(m.staged)) } next, _ := m.Update(key("m")) m = next.(Model) if m.isError { t.Fatalf("неожиданная ошибка при выкладке комбинации: %s", m.message) } if len(m.game.Table) != 1 { t.Fatalf("ожидалась 1 комбинация на столе, получено %d", len(m.game.Table)) } if len(m.game.current().Hand) != 1 { t.Fatalf("в руке должна была остаться 1 карта, получено %d", len(m.game.current().Hand)) } if len(m.staged) != 0 { t.Fatalf("отметки должны сброситься после выкладки комбинации") } } func TestTUILayMeldRejectsFewerThanThree(t *testing.T) { m := NewModel() m.game.Players[0].Hand = []Card{c(Seven, Clubs), c(Seven, Hearts), c(Two, Diamonds)} m.game.CurrentPlayer = 0 m.game.Phase = PhaseAction next, _ := m.Update(key(" ")) m = next.(Model) next, _ = m.Update(key("m")) m = next.(Model) if !m.isError { t.Fatalf("ожидалась ошибка при попытке выложить меньше 3 карт") } } func TestTUIAddToMeld(t *testing.T) { m := NewModel() // комбинацию на столе готовит бот (игрок 1) — напрямую через // GameState, это лишь подготовка сцены для теста m.game.Players[1].Hand = []Card{c(Seven, Clubs), c(Seven, Hearts), c(Seven, Spades)} m.game.CurrentPlayer = 1 m.game.Phase = PhaseAction if err := m.game.LayNewMeld(m.game.Players[1].Hand); err != nil { t.Fatalf("не удалось подготовить стол: %v", err) } // а вот подкладывает карту уже человек — через реальный путь // TUI (Update + нажатие клавиши), именно это здесь и проверяем m.game.Players[0].Hand = []Card{c(Seven, Diamonds)} m.game.CurrentPlayer = 0 m.game.Phase = PhaseAction m.cursor = 0 next, _ := m.Update(key("1")) m = next.(Model) if m.isError { t.Fatalf("неожиданная ошибка подкладки: %s", m.message) } if len(m.game.Table[0].Cards) != 4 { t.Fatalf("ожидалось 4 карты в комбинации, получено %d", len(m.game.Table[0].Cards)) } } func TestTUIDropSucceeds(t *testing.T) { m := NewModel() m.game.Players[0].Hand = []Card{c(Two, Clubs)} m.game.Players[1].Hand = []Card{c(King, Hearts)} m.game.Players[2].Hand = []Card{c(Queen, Spades)} m.game.Players[3].Hand = []Card{c(King, Diamonds)} m.game.CurrentPlayer = 0 m.game.Phase = PhaseDraw next, _ := m.Update(key("k")) m = next.(Model) if m.isError { t.Fatalf("неожиданная ошибка дропа: %s", m.message) } if m.game.Phase != PhaseGameOver || !m.game.Result.DropCall || m.game.Result.DropCaught { t.Fatalf("ожидался успешный дроп: %+v", m.game.Result) } } func TestTUIDropCaught(t *testing.T) { m := NewModel() m.game.Players[0].Hand = []Card{c(King, Hearts)} m.game.Players[1].Hand = []Card{c(Ace, Clubs)} // реально ниже m.game.Players[2].Hand = []Card{c(King, Spades)} m.game.Players[3].Hand = []Card{c(King, Diamonds)} m.game.CurrentPlayer = 0 m.game.Phase = PhaseDraw next, _ := m.Update(key("k")) m = next.(Model) if m.isError { t.Fatalf("неожиданная ошибка дропа: %s", m.message) } if !m.game.Result.DropCaught { t.Fatalf("ожидался неудачный дроп (попался)") } } func TestTUIDropOnlyInDrawPhase(t *testing.T) { m := NewModel() m.game.CurrentPlayer = 0 m.game.Phase = PhaseAction next, _ := m.Update(key("k")) m = next.(Model) if m.game.Phase == PhaseGameOver { t.Fatalf("дроп после взятия карты должен быть запрещён — клавиша 'k' здесь не должна ни на что влиять") } } func TestTUIDeclareTonk(t *testing.T) { m := NewModel() m.game.Players[0].Hand = []Card{c(Ace, Clubs), c(Two, Hearts)} m.game.CurrentPlayer = 0 m.game.Phase = PhaseAction next, _ := m.Update(key("t")) m = next.(Model) if m.isError { t.Fatalf("неожиданная ошибка объявления Тонка: %s", m.message) } if m.game.Phase != PhaseGameOver || !m.game.Result.TonkCall { t.Fatalf("раунд должен был завершиться объявлением Тонка") } } func TestTUINewRoundCarriesOverBalance(t *testing.T) { m := NewModel() m.game.Players[1].Hand = []Card{c(Ace, Clubs)} m.game.CurrentPlayer = 1 m.game.Phase = PhaseAction if err := m.game.Discard(c(Ace, Clubs)); err != nil { t.Fatalf("не удалось завершить раунд для теста: %v", err) } if m.game.Phase != PhaseGameOver { t.Fatalf("раунд должен был завершиться") } balanceAfterRound := m.game.Players[0].Balance next, _ := m.Update(key("n")) m = next.(Model) if m.game.Phase == PhaseGameOver { t.Fatalf("новый раунд должен был начаться") } // капитал переносится, но в новом раунде заново списывается // анте — итоговый капитал должен быть меньше на размер ставки want := balanceAfterRound - m.game.AnteAmount if m.game.Players[0].Balance != want { t.Fatalf("капитал должен был перенестись с учётом нового анте: ожидалось %d, получено %d", want, m.game.Players[0].Balance) } } // TestTUIFullAutoPlaythrough прогоняет TUI-модель от начала до // конца, но подменяет человека ботом на уровне вызовов Update: // вместо реальных нажатий клавиш скрипт запрашивает у SimpleBot // решение и переводит его в нажатия клавиш. Так мы проверяем, что // весь путь через Update (а не только через GameState напрямую) // стабильно доводит партию до конца без паники и зависаний. func TestTUIFullAutoPlaythrough(t *testing.T) { m := NewModel() const maxSteps = 2000 steps := 0 for m.game.Phase != PhaseGameOver && steps < maxSteps { steps++ if m.game.CurrentPlayer != m.humanIdx { next, _ := m.Update(botMoveMsg{}) m = next.(Model) continue } // "человек" в этом тесте играет как простой бот, но через // реальные нажатия клавиш, а не напрямую через GameState switch m.game.Phase { case PhaseDraw: top, ok := m.game.PeekDiscard() k := "d" if ok && wouldFormMeld(m.game.current().Hand, top) { k = "p" } next, _ := m.Update(key(k)) m = next.(Model) case PhaseAction: discard := chooseDiscardSimple(m.game.current().Hand) for i, cardInHand := range m.game.current().Hand { if cardInHand == discard { m.cursor = i break } } next, _ := m.Update(key("enter")) m = next.(Model) } } if steps >= maxSteps { t.Fatalf("партия через TUI не завершилась за %d шагов", maxSteps) } if m.game.Result == nil { t.Fatalf("партия завершилась без результата") } }