go_games_collection/tui_test.go
2026-07-15 18:02:34 +03:00

432 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}
}