go_games_collection/chess_tui_test.go
2026-07-16 09:53:25 +03:00

301 lines
12 KiB
Go
Raw Permalink 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"
"github.com/charmbracelet/lipgloss"
"github.com/muesli/termenv"
)
func TestChessModelSelectAndMoveViaKeys(t *testing.T) {
m := NewChessModel(0, ColorChoiceA)
m.cursor = ChessPos{Row: 6, Col: 4}
next, _ := m.Update(key("enter"))
m = next.(ChessModel)
if m.isError {
t.Fatalf("неожиданная ошибка выбора: %s", m.message)
}
if m.selected == nil {
t.Fatalf("ожидался выбор фигуры")
}
m.cursor = ChessPos{Row: 4, Col: 4}
next, _ = m.Update(key("enter"))
m = next.(ChessModel)
if m.isError {
t.Fatalf("неожиданная ошибка хода: %s", m.message)
}
if m.game.Board[4][4] == nil {
t.Fatalf("пешка должна была переместиться на e4")
}
if m.selected != nil {
t.Errorf("выбор должен был сброситься после хода")
}
}
func TestChessModelCastlingViaKeys(t *testing.T) {
m := NewChessModel(0, ColorChoiceA)
m.game = &ChessGameState{Turn: ChessWhite}
m.game.Board[7][4] = &ChessPiece{Color: ChessWhite, Type: ChessKing}
m.game.Board[7][7] = &ChessPiece{Color: ChessWhite, Type: ChessRook}
m.game.Board[0][4] = &ChessPiece{Color: ChessBlack, Type: ChessKing}
m.cursor = ChessPos{Row: 7, Col: 4}
next, _ := m.Update(key("enter"))
m = next.(ChessModel)
if m.isError {
t.Fatalf("неожиданная ошибка выбора короля: %s", m.message)
}
m.cursor = ChessPos{Row: 7, Col: 6}
next, _ = m.Update(key("enter"))
m = next.(ChessModel)
if m.isError {
t.Fatalf("неожиданная ошибка рокировки: %s", m.message)
}
if m.game.Board[7][6] == nil || m.game.Board[7][6].Type != ChessKing {
t.Errorf("король должен был оказаться на g1")
}
if m.game.Board[7][5] == nil || m.game.Board[7][5].Type != ChessRook {
t.Errorf("ладья должна была оказаться на f1")
}
}
// TestChessModelCursorVisibleOnLegalDestination проверяет исправление
// бага: раньше подсветка "легальный ход" рендерилась поверх курсора,
// делая его невидимым, стоило навести курсор на клетку, куда можно
// сходить (именно эта клетка обычно и нужна пользователю).
// TestChessCursorOnPieceUsesOneCombinedStyle — тот же баг и та же
// проверка, что и в Шашках: цвет фигуры и фон клетки должны
// составлять один комбинированный стиль, а не вложенные Render().
func TestChessCursorOnPieceUsesOneCombinedStyle(t *testing.T) {
oldProfile := lipgloss.ColorProfile()
lipgloss.SetColorProfile(termenv.ANSI256)
defer lipgloss.SetColorProfile(oldProfile)
piece := &ChessPiece{Color: ChessBlack, Type: ChessKnight}
text := chessGlyphChar(piece)
fg, ok := chessPieceForegroundColor(piece)
if !ok {
t.Fatalf("ожидался цвет для непустой клетки")
}
combined := checkersCursorStyle.Foreground(fg).Bold(true)
rendered := combined.Render(" " + text + " ")
resets := strings.Count(rendered, "\x1b[0m")
if resets != 1 {
t.Errorf("ожидался ровно один сброс ANSI на всю клетку (единый стиль), получено %d: %q", resets, rendered)
}
opens := strings.Count(rendered, "\x1b[")
if opens != 2 {
t.Errorf("ожидалось ровно 2 вхождения ESC (открытие+закрытие, без вложенности), получено %d: %q", opens, rendered)
}
}
func TestChessModelCursorVisibleOnLegalDestination(t *testing.T) {
oldProfile := lipgloss.ColorProfile()
lipgloss.SetColorProfile(termenv.ANSI256)
defer lipgloss.SetColorProfile(oldProfile)
m := NewChessModel(0, ColorChoiceA)
m.cursor = ChessPos{Row: 6, Col: 4}
next, _ := m.Update(key("enter")) // выбираем пешку e2
m = next.(ChessModel)
m.cursor = ChessPos{Row: 4, Col: 4} // e4 — легальный ход этой пешки
dests := m.legalDestinationSet()
if !dests[m.cursor] {
t.Fatalf("тест предполагает, что курсор наведён именно на легальный ход")
}
board := m.renderBoard()
cursorTag := checkersCursorStyle.Render("X")
// вытаскиваем именно код фона курсора из отрендеренной "X"
start := strings.Index(cursorTag, "\x1b[")
end := strings.Index(cursorTag, "m")
cursorColorCode := cursorTag[start : end+1]
if !strings.Contains(board, cursorColorCode) {
t.Errorf("цвет курсора должен присутствовать в отрисовке доски даже на клетке легального хода")
}
}
func TestChessModelCursorMovement(t *testing.T) {
m := NewChessModel(0, ColorChoiceA)
m.cursor = ChessPos{Row: 4, Col: 4}
next, _ := m.Update(key("up"))
m = next.(ChessModel)
if m.cursor.Row != 3 {
t.Errorf("курсор должен был сдвинуться вверх")
}
next, _ = m.Update(key("right"))
m = next.(ChessModel)
if m.cursor.Col != 5 {
t.Errorf("курсор должен был сдвинуться вправо")
}
}
func TestChessModelEscClearsSelection(t *testing.T) {
m := NewChessModel(0, ColorChoiceA)
m.cursor = ChessPos{Row: 6, Col: 4}
next, _ := m.Update(key("enter"))
m = next.(ChessModel)
if m.selected == nil {
t.Fatalf("ожидался выбор")
}
next, _ = m.Update(key("esc"))
m = next.(ChessModel)
if m.selected != nil {
t.Errorf("esc должен был сбросить выбор")
}
}
func TestChessModelCannotSelectEmptySquare(t *testing.T) {
m := NewChessModel(0, ColorChoiceA)
m.cursor = ChessPos{Row: 4, Col: 4}
next, _ := m.Update(key("enter"))
m = next.(ChessModel)
if !m.isError {
t.Fatalf("ожидалась ошибка выбора пустой клетки")
}
}
func TestChessModelQReturnsToMenu(t *testing.T) {
m := NewChessModel(0, ColorChoiceA)
next, _ := m.Update(key("q"))
m = next.(ChessModel)
if !m.backToMenu {
t.Fatalf("ожидался флаг backToMenu после 'q'")
}
if m.quitting {
t.Fatalf("'q' не должен закрывать всё приложение")
}
}
func TestChessModelNewGameAfterResult(t *testing.T) {
m := NewChessModel(0, ColorChoiceA)
m.game.Result = &ChessResult{Winner: ChessWhite, Checkmate: true}
next, _ := m.Update(key("n"))
m = next.(ChessModel)
if m.game.Result != nil {
t.Errorf("новая партия не должна была начаться с результатом")
}
}
// TestChessModelFullAutoPlaythrough прогоняет TUI-модель от начала
// до конца, где "человек" отыгрывает свои ходы через бота
// (небольшая глубина — ради скорости), проверяя, что весь путь
// через Update не паникует и партия завершается.
func TestChessModelFullAutoPlaythrough(t *testing.T) {
m := NewChessModel(1, ColorChoiceA)
helperBot := ChessBot{Depth: 1}
const maxSteps = 600
for i := 0; i < maxSteps && m.game.Result == nil; i++ {
if m.game.Turn != m.human {
next, _ := m.Update(botMoveMsg{})
m = next.(ChessModel)
continue
}
mv, ok := helperBot.DecideMove(m.game)
if !ok {
break
}
m.cursor = mv.From
next, _ := m.Update(key("enter"))
m = next.(ChessModel)
if m.isError {
t.Fatalf("неожиданная ошибка выбора хода человеком: %s", m.message)
}
m.cursor = mv.To
next, _ = m.Update(key("enter"))
m = next.(ChessModel)
if m.isError {
t.Fatalf("неожиданная ошибка хода человеком: %s", m.message)
}
}
if m.game.Result == nil {
t.Fatalf("партия через TUI не завершилась за %d шагов", maxSteps)
}
}
func TestNewChessModelRespectsColorChoiceWhite(t *testing.T) {
m := NewChessModel(0, ColorChoiceA)
if m.human != ChessWhite {
t.Errorf("при выборе ColorChoiceA человек должен был играть белыми, получено %v", m.human)
}
}
func TestNewChessModelRespectsColorChoiceBlack(t *testing.T) {
m := NewChessModel(0, ColorChoiceB)
if m.human != ChessBlack {
t.Errorf("при выборе ColorChoiceB человек должен был играть чёрными, получено %v", m.human)
}
}
func TestNewChessModelRandomColorGivesValidColor(t *testing.T) {
seenWhite, seenBlack := false, false
for i := 0; i < 50 && !(seenWhite && seenBlack); i++ {
m := NewChessModel(0, ColorChoiceRandom)
if m.human == ChessWhite {
seenWhite = true
} else {
seenBlack = true
}
}
if !seenWhite || !seenBlack {
t.Errorf("случайный выбор цвета должен был давать оба варианта за 50 попыток: белые=%v чёрные=%v", seenWhite, seenBlack)
}
}
func TestChessBoardNotFlippedForWhite(t *testing.T) {
m := NewChessModel(0, ColorChoiceA) // белые
if m.cursor.Row != 7 {
t.Errorf("за белых курсор должен был стартовать в ряду 7 (своя сторона), получено %d", m.cursor.Row)
}
view := m.renderBoard()
lines := strings.Split(view, "\n")
// последняя содержательная строка доски (перед пустой) должна
// начинаться с "1" (ряд белых снизу) без разворота
if !strings.HasPrefix(lines[8], "1 ") {
t.Errorf("за белых нижний ряд доски должен был быть подписан 1, получено: %q", lines[8])
}
}
func TestChessBoardFlippedForBlack(t *testing.T) {
m := NewChessModel(0, ColorChoiceB) // чёрные
if m.cursor.Row != 0 {
t.Errorf("за чёрных курсор должен был стартовать в ряду 0 (своя сторона), получено %d", m.cursor.Row)
}
view := m.renderBoard()
lines := strings.Split(view, "\n")
// доска развёрнута — нижний ряд теперь подписан 8 (ряд чёрных)
if !strings.HasPrefix(lines[8], "8 ") {
t.Errorf("за чёрных нижний ряд доски должен был быть подписан 8 (развёрнуто), получено: %q", lines[8])
}
// и заголовок с буквами файлов тоже развёрнут: h идёт первой
if !strings.Contains(lines[0], "h") || strings.Index(lines[0], "h") > strings.Index(lines[0], "a") {
t.Errorf("за чёрных буквы файлов в заголовке тоже должны были развернуться, получено: %q", lines[0])
}
}
func TestChessCursorMovementFlippedForBlack(t *testing.T) {
m := NewChessModel(0, ColorChoiceB)
m.game.Turn = m.human // ход человека, чтобы клавиши обрабатывались
startRow := m.cursor.Row
next, _ := m.Update(key("up"))
m2 := next.(ChessModel)
// на развёрнутой доске "вверх" должно визуально уводить курсор
// туда же, куда он был бы направлен на обычной доске — то есть
// в сторону дальнего от игрока ряда, что при развороте означает
// увеличение Row, а не уменьшение
if m2.cursor.Row != startRow+1 {
t.Errorf("за чёрных 'вверх' должен был увеличивать Row (развёрнутая доска), получено %d (было %d)", m2.cursor.Row, startRow)
}
}