package main import ( "strings" "testing" "github.com/charmbracelet/lipgloss" "github.com/muesli/termenv" ) // TestCheckersModelCursorVisibleOnLegalDestination проверяет тот же // баг, что и в Шахматах: курсор не должен теряться под подсветкой // легального хода. func TestCheckersModelCursorVisibleOnLegalDestination(t *testing.T) { oldProfile := lipgloss.ColorProfile() lipgloss.SetColorProfile(termenv.ANSI256) defer lipgloss.SetColorProfile(oldProfile) m := NewCheckersModel(0, ColorChoiceA) m.cursor = CheckersPos{Row: 5, Col: 2} next, _ := m.Update(key("enter")) // выбираем шашку m = next.(CheckersModel) dests := m.legalDestinationSet() var destPos CheckersPos for pos := range dests { destPos = pos break } m.cursor = destPos board := m.renderBoard() cursorTag := checkersCursorStyle.Render("X") start := strings.Index(cursorTag, "\x1b[") end := strings.Index(cursorTag, "m") cursorColorCode := cursorTag[start : end+1] if !strings.Contains(board, cursorColorCode) { t.Errorf("цвет курсора должен присутствовать в отрисовке доски даже на клетке легального хода") } } // TestCheckersCursorOnPieceUsesOneCombinedStyle проверяет исправление // более глубокого бага: раньше цвет фигуры применялся ВНУТРИ уже // готового Render() вызова, что при последующем оборачивании в фон // клетки (курсор/подсветка) давало вложенные ANSI-последовательности // с преждевременным сбросом — фон обрывался до конца клетки (тот же // класс бага, что раньше был в отрисовке карт Тонка). func TestCheckersCursorOnPieceUsesOneCombinedStyle(t *testing.T) { oldProfile := lipgloss.ColorProfile() lipgloss.SetColorProfile(termenv.ANSI256) defer lipgloss.SetColorProfile(oldProfile) piece := &CheckersPiece{Color: CheckersBlack} text := pieceGlyphChar(piece) fg, ok := pieceForegroundColor(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 TestCheckersModelSelectAndMoveViaKeys(t *testing.T) { m := NewCheckersModel(0, ColorChoiceA) m.cursor = CheckersPos{Row: 5, Col: 2} next, _ := m.Update(key("enter")) m = next.(CheckersModel) if m.isError { t.Fatalf("неожиданная ошибка выбора: %s", m.message) } if m.selected == nil { t.Fatalf("ожидался выбор шашки") } m.cursor = CheckersPos{Row: 4, Col: 1} next, _ = m.Update(key("enter")) m = next.(CheckersModel) if m.isError { t.Fatalf("неожиданная ошибка хода: %s", m.message) } if m.game.Board[4][1] == nil { t.Fatalf("шашка должна была переместиться") } if m.selected != nil { t.Errorf("выбор должен был сброситься после хода") } } func TestCheckersModelCursorMovement(t *testing.T) { m := NewCheckersModel(0, ColorChoiceA) m.cursor = CheckersPos{Row: 4, Col: 4} next, _ := m.Update(key("up")) m = next.(CheckersModel) if m.cursor.Row != 3 { t.Errorf("курсор должен был сдвинуться вверх") } next, _ = m.Update(key("right")) m = next.(CheckersModel) if m.cursor.Col != 5 { t.Errorf("курсор должен был сдвинуться вправо") } } func TestCheckersModelEscClearsSelection(t *testing.T) { m := NewCheckersModel(0, ColorChoiceA) m.cursor = CheckersPos{Row: 5, Col: 2} next, _ := m.Update(key("enter")) m = next.(CheckersModel) if m.selected == nil { t.Fatalf("ожидался выбор") } next, _ = m.Update(key("esc")) m = next.(CheckersModel) if m.selected != nil { t.Errorf("esc должен был сбросить выбор") } } func TestCheckersModelCannotSelectEmptySquare(t *testing.T) { m := NewCheckersModel(0, ColorChoiceA) m.cursor = CheckersPos{Row: 3, Col: 2} next, _ := m.Update(key("enter")) m = next.(CheckersModel) if !m.isError { t.Fatalf("ожидалась ошибка выбора пустой клетки") } } func TestCheckersModelQReturnsToMenu(t *testing.T) { m := NewCheckersModel(0, ColorChoiceA) next, _ := m.Update(key("q")) m = next.(CheckersModel) if !m.backToMenu { t.Fatalf("ожидался флаг backToMenu после 'q'") } if m.quitting { t.Fatalf("'q' не должен закрывать всё приложение") } } func TestCheckersModelNewGameAfterResult(t *testing.T) { m := NewCheckersModel(0, ColorChoiceA) m.game.Result = &CheckersResult{Winner: CheckersWhite} next, _ := m.Update(key("n")) m = next.(CheckersModel) if m.game.Result != nil { t.Errorf("новая партия не должна была начаться с результатом") } } // TestCheckersModelFullAutoPlaythrough прогоняет TUI-модель от // начала до конца, где "человек" отыгрывает свои ходы через бота // (небольшая глубина — ради скорости), проверяя, что весь путь // через Update не паникует и партия завершается. func TestCheckersModelFullAutoPlaythrough(t *testing.T) { m := NewCheckersModel(0, ColorChoiceA) m.bot = CheckersBot{Depth: 2} helperBot := CheckersBot{Depth: 2} const maxSteps = 400 for i := 0; i < maxSteps && m.game.Result == nil; i++ { if m.game.Turn != m.human { next, _ := m.Update(botMoveMsg{}) m = next.(CheckersModel) continue } mv, ok := helperBot.DecideMove(m.game) if !ok { break } m.cursor = mv.From next, _ := m.Update(key("enter")) m = next.(CheckersModel) if m.isError { t.Fatalf("неожиданная ошибка выбора хода человеком: %s", m.message) } m.cursor = mv.To next, _ = m.Update(key("enter")) m = next.(CheckersModel) if m.isError { t.Fatalf("неожиданная ошибка хода человеком: %s", m.message) } } if m.game.Result == nil { t.Fatalf("партия через TUI не завершилась за %d шагов", maxSteps) } } func TestNewCheckersModelRespectsColorChoiceWhite(t *testing.T) { m := NewCheckersModel(0, ColorChoiceA) if m.human != CheckersWhite { t.Errorf("при выборе ColorChoiceA человек должен был играть белыми, получено %v", m.human) } } func TestNewCheckersModelRespectsColorChoiceBlack(t *testing.T) { m := NewCheckersModel(0, ColorChoiceB) if m.human != CheckersBlack { t.Errorf("при выборе ColorChoiceB человек должен был играть чёрными, получено %v", m.human) } } func TestNewCheckersModelRandomColorGivesValidColor(t *testing.T) { seenWhite, seenBlack := false, false for i := 0; i < 50 && !(seenWhite && seenBlack); i++ { m := NewCheckersModel(0, ColorChoiceRandom) if m.human == CheckersWhite { seenWhite = true } else { seenBlack = true } } if !seenWhite || !seenBlack { t.Errorf("случайный выбор цвета должен был давать оба варианта за 50 попыток: белые=%v чёрные=%v", seenWhite, seenBlack) } } func TestCheckersBoardNotFlippedForWhite(t *testing.T) { m := NewCheckersModel(0, ColorChoiceA) if m.cursor.Row != 5 { t.Errorf("за белых курсор должен был стартовать в ряду 5 (своя сторона), получено %d", m.cursor.Row) } view := m.renderBoard() lines := strings.Split(view, "\n") if !strings.HasPrefix(lines[8], "1 ") { t.Errorf("за белых нижний ряд доски должен был быть подписан 1, получено: %q", lines[8]) } } func TestCheckersBoardFlippedForBlack(t *testing.T) { m := NewCheckersModel(0, ColorChoiceB) if m.cursor.Row != 2 { t.Errorf("за чёрных курсор должен был стартовать в ряду 2 (своя сторона), получено %d", m.cursor.Row) } view := m.renderBoard() lines := strings.Split(view, "\n") if !strings.HasPrefix(lines[8], "8 ") { t.Errorf("за чёрных нижний ряд доски должен был быть подписан 8 (развёрнуто), получено: %q", lines[8]) } } func TestCheckersCursorMovementFlippedForBlack(t *testing.T) { m := NewCheckersModel(0, ColorChoiceB) m.game.Turn = m.human startRow := m.cursor.Row next, _ := m.Update(key("up")) m2 := next.(CheckersModel) if m2.cursor.Row != startRow+1 { t.Errorf("за чёрных 'вверх' должен был увеличивать Row (развёрнутая доска), получено %d (было %d)", m2.cursor.Row, startRow) } }