413 lines
15 KiB
Go
413 lines
15 KiB
Go
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)
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|