package main import ( "strings" "testing" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/muesli/termenv" ) // 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("партия завершилась без результата") } } func TestRenderCardBoxContentShowsRankAndSuitSeparately(t *testing.T) { box := renderCardBox(c(Ace, Diamonds), CardNormal) rankLine := stripANSI(box[1]) suitLine := stripANSI(box[2]) if !strings.Contains(rankLine, "A") || strings.Contains(rankLine, "♦") { t.Errorf("строка ранга должна содержать только ранг, получено %q", rankLine) } if !strings.Contains(suitLine, "♦") || strings.Contains(suitLine, "A") { t.Errorf("строка масти должна содержать только масть, получено %q", suitLine) } } func TestRenderCardBoxHasFourLines(t *testing.T) { box := renderCardBox(c(Ace, Spades), CardNormal) if len(box) != 4 { t.Fatalf("ожидалось 4 строки (верх/ранг/масть/низ), получено %d", len(box)) } } func TestRenderCardBoxAlignsTenSameWidthAsSingleDigit(t *testing.T) { tenBox := renderCardBox(c(Ten, Spades), CardNormal) aceBox := renderCardBox(c(Ace, Spades), CardNormal) for row := 0; row < 4; row++ { wTen := len([]rune(stripANSI(tenBox[row]))) wAce := len([]rune(stripANSI(aceBox[row]))) if wTen != wAce { t.Errorf("строка %d: карта с рангом '10' (ширина %d) должна была совпадать по ширине с картой ранга 'A' (ширина %d)", row, wTen, wAce) } } } func TestRenderCardBoxCursorHighlightsBorderNotText(t *testing.T) { oldProfile := lipgloss.ColorProfile() lipgloss.SetColorProfile(termenv.ANSI256) defer lipgloss.SetColorProfile(oldProfile) normal := renderCardBox(c(Ace, Spades), CardNormal) cursor := renderCardBox(c(Ace, Spades), CardCursor) // текст (ранг/масть, строки 1-2) не должен меняться от курсора — // подсвечивается только рамка (строки 0 и 3) if stripANSI(normal[1]) != stripANSI(cursor[1]) || stripANSI(normal[2]) != stripANSI(cursor[2]) { t.Errorf("курсор не должен менять видимый текст ранга/масти, только рамку") } if normal[0] == cursor[0] { t.Errorf("курсор должен был визуально отличать верхнюю рамку (по стилю), получены одинаковые строки") } } func TestRenderCardBoxStagedUnderlinesTextKeepsBorder(t *testing.T) { oldProfile := lipgloss.ColorProfile() lipgloss.SetColorProfile(termenv.ANSI256) defer lipgloss.SetColorProfile(oldProfile) normal := renderCardBox(c(Ace, Spades), CardNormal) staged := renderCardBox(c(Ace, Spades), CardStaged) if stripANSI(normal[0]) != stripANSI(staged[0]) || stripANSI(normal[3]) != stripANSI(staged[3]) { t.Errorf("отложенная карта не должна менять рамку, только текст") } if normal[1] == staged[1] { t.Errorf("отложенная карта должна была визуально отличаться (подчёркивание) в строке ранга") } } func TestRenderCardBoxRowJoinsMultipleCardsWithEqualWidth(t *testing.T) { hand := []Card{c(Ace, Spades), c(Ten, Hearts), c(King, Clubs)} row := renderCardBoxRow(hand, nil) lines := strings.Split(row, "\n") if len(lines) != 4 { t.Fatalf("ожидалось 4 строки в ряду карт, получено %d", len(lines)) } width0 := len([]rune(stripANSI(lines[0]))) for i, line := range lines { if w := len([]rune(stripANSI(line))); w != width0 { t.Errorf("строка %d ряда карт имеет ширину %d, ожидалась %d (все строки должны быть выровнены по прямоугольнику)", i, w, width0) } } } func TestRenderCardBoxRowEmptyHand(t *testing.T) { if got := renderCardBoxRow(nil, nil); got != "" { t.Errorf("для пустой руки ожидалась пустая строка, получено %q", got) } } // TestWrapTextNeverExceedsRealTerminalWidth — регрессия: раньше на // узких терминалах (реальная ширина < 44) wrapText принудительно // расширял перенос до 60 символов, что оказывалось ШИРЕ самого // терминала — строка переносилась терминалом ещё раз незаметно для // построчного учёта Bubble Tea, что ломало перерисовку следующего // кадра (старый текст оставался виден вперемешку с новым). func TestWrapTextNeverExceedsRealTerminalWidth(t *testing.T) { longText := strings.Repeat("слово ", 40) for _, termWidth := range []int{20, 25, 30, 35, 40, 44, 50} { wrapped := wrapText(longText, termWidth) for _, line := range strings.Split(wrapped, "\n") { plain := stripANSI(line) if w := len([]rune(plain)); w > termWidth { t.Errorf("при termWidth=%d получена строка шириной %d символов (шире терминала): %q", termWidth, w, plain) } } } } func TestCursorHighlightsMarksOnlyCursorPosition(t *testing.T) { hl := cursorHighlights(5, 2) for i, h := range hl { want := CardNormal if i == 2 { want = CardCursor } if h != want { t.Errorf("позиция %d: ожидалось %v, получено %v", i, want, h) } } } func TestRenderCardBoxHandWrapsAfterSixCards(t *testing.T) { hand := make([]Card, 8) for i := range hand { hand[i] = c(Rank(2+i%9), Clubs) } out := renderCardBoxHand(hand, cursorHighlights(len(hand), 0)) // два ряда по 4 строки, разделённые пустой строкой между ними lines := strings.Split(out, "\n") if len(lines) != 9 { t.Fatalf("ожидалось 9 строк (4+пустая+4) для переноса 8 карт по 6 в ряд, получено %d:\n%s", len(lines), out) } if lines[4] != "" { t.Errorf("между рядами должна быть пустая строка-разделитель, получено %q", lines[4]) } } func TestRenderCardBoxHandNoWrapWithinLimit(t *testing.T) { hand := make([]Card, cardsPerBoxRow) for i := range hand { hand[i] = c(Rank(2+i), Clubs) } out := renderCardBoxHand(hand, cursorHighlights(len(hand), 0)) lines := strings.Split(out, "\n") if len(lines) != 4 { t.Fatalf("ровно %d карт должны были уместиться в один ряд (4 строки), получено %d строк", cardsPerBoxRow, len(lines)) } } func TestRenderCardBoxHandEmpty(t *testing.T) { if got := renderCardBoxHand(nil, nil); got != "" { t.Errorf("для пустой руки ожидалась пустая строка, получено %q", got) } }