package main import ( "strings" "testing" tea "github.com/charmbracelet/bubbletea" ) func TestMenuNavigation(t *testing.T) { m := NewAppModel() if m.state != stateMenu { t.Fatalf("ожидалось начальное состояние stateMenu") } if len(m.options) != len(gameRegistry)+2 { t.Fatalf("ожидалось %d пунктов меню, получено %d", len(gameRegistry)+2, len(m.options)) } next, _ := m.Update(key("down")) nm := next.(AppModel) if nm.cursor != 1 { t.Fatalf("ожидался курсор на позиции 1, получено %d", nm.cursor) } next, _ = nm.Update(key("up")) nm = next.(AppModel) if nm.cursor != 0 { t.Fatalf("ожидался курсор на позиции 0 после возврата, получено %d", nm.cursor) } } func TestMenuLeadsToStakeSettings(t *testing.T) { m := NewAppModel() // первый пункт меню — первая (и пока единственная) игра, которая // использует ставки -> должен открыться экран настроек, а не игра next, _ := m.Update(key("enter")) m = next.(AppModel) if m.state != stateSettings { t.Fatalf("ожидалось состояние stateSettings после выбора игры со ставками") } if m.activeGame != nil { t.Fatalf("игра не должна была запуститься до подтверждения настроек") } } func TestStakeSettingsAdjustAndStart(t *testing.T) { m := NewAppModel() next, _ := m.Update(key("enter")) // входим в настройки m = next.(AppModel) initialCapital := m.settings.capital() next, _ = m.Update(key("right")) // увеличиваем капитал на шаг пресета m = next.(AppModel) if m.settings.capital() == initialCapital { t.Fatalf("значение капитала должно было измениться после [right]") } next, _ = m.Update(key("down")) // переходим к полю анте m = next.(AppModel) if m.settings.fieldCursor != 1 { t.Fatalf("ожидался курсор на поле анте (1), получено %d", m.settings.fieldCursor) } chosenCapital := m.settings.capital() chosenAnte := m.settings.ante() chosenMult := m.settings.multiplier() next, _ = m.Update(key("enter")) // подтверждаем и запускаем игру m = next.(AppModel) if m.state != statePlaying { t.Fatalf("ожидалось состояние statePlaying после подтверждения настроек") } tm, ok := m.activeGame.(Model) if !ok { t.Fatalf("активная игра должна быть моделью Тонка") } if tm.game.StartingCapital != chosenCapital { t.Errorf("стартовый капитал в игре (%d) не совпадает с выбранным (%d)", tm.game.StartingCapital, chosenCapital) } if tm.game.AnteAmount != chosenAnte { t.Errorf("анте в игре (%d) не совпадает с выбранным (%d)", tm.game.AnteAmount, chosenAnte) } if tm.game.TonkMultiplier != chosenMult { t.Errorf("множитель в игре (%d) не совпадает с выбранным (%d)", tm.game.TonkMultiplier, chosenMult) } } func TestStakeSettingsEscReturnsToMenu(t *testing.T) { m := NewAppModel() next, _ := m.Update(key("enter")) m = next.(AppModel) if m.state != stateSettings { t.Fatalf("ожидался экран настроек") } next, _ = m.Update(key("esc")) m = next.(AppModel) if m.state != stateMenu { t.Fatalf("esc на экране настроек должен вернуть в меню") } } func TestStakeSettingsCycleClampsAtBounds(t *testing.T) { m := NewAppModel() next, _ := m.Update(key("enter")) m = next.(AppModel) // уводим значение влево много раз — не должно уйти за границу // списка пресетов (индекс не может стать отрицательным) for i := 0; i < 10; i++ { next, _ = m.Update(key("left")) m = next.(AppModel) } if m.settings.capitalIdx != 0 { t.Errorf("индекс капитала должен был остановиться на 0, получено %d", m.settings.capitalIdx) } } func TestMenuShowsRules(t *testing.T) { m := NewAppModel() // переходим к пункту "Правила" (сразу после списка игр) for i := 0; i < len(gameRegistry); i++ { next, _ := m.Update(key("down")) m = next.(AppModel) } next, _ := m.Update(key("enter")) m = next.(AppModel) if m.state != stateRulesMenu { t.Fatalf("ожидалось состояние stateRulesMenu (подменю выбора игры)") } // в подменю правил курсор по умолчанию на первой игре (Тонк) next, _ = m.Update(key("enter")) m = next.(AppModel) if m.state != stateRules { t.Fatalf("ожидалось состояние stateRules") } if !strings.Contains(m.rulesText, "ТОНК") { t.Fatalf("текст правил должен содержать описание Тонка") } // esc из текста правил возвращает в подменю выбора игры, а не сразу в меню next, _ = m.Update(key("esc")) m = next.(AppModel) if m.state != stateRulesMenu { t.Fatalf("ожидалось возвращение в stateRulesMenu после esc из текста правил") } // esc из подменю возвращает в главное меню next, _ = m.Update(key("esc")) m = next.(AppModel) if m.state != stateMenu { t.Fatalf("ожидалось возвращение в stateMenu после esc из подменю правил") } } func TestMenuShowsDurakRules(t *testing.T) { m := NewAppModel() for i := 0; i < len(gameRegistry); i++ { next, _ := m.Update(key("down")) m = next.(AppModel) } next, _ := m.Update(key("enter")) // подменю правил m = next.(AppModel) next, _ = m.Update(key("down")) // выбираем вторую игру (Дурак) m = next.(AppModel) next, _ = m.Update(key("enter")) m = next.(AppModel) if m.state != stateRules { t.Fatalf("ожидалось состояние stateRules") } if !strings.Contains(m.rulesText, "ДУРАК") { t.Fatalf("текст правил должен содержать описание Дурака, получено начало: %.50s", m.rulesText) } } func TestMenuLeadsToPlayerCountForDurak(t *testing.T) { m := NewAppModel() next, _ := m.Update(key("down")) // второй пункт меню — Дурак m = next.(AppModel) next, _ = m.Update(key("enter")) m = next.(AppModel) if m.state != statePlayerCount { t.Fatalf("ожидалось состояние statePlayerCount после выбора Дурака") } if m.activeGame != nil { t.Fatalf("игра не должна была запуститься до подтверждения числа игроков") } } func TestPlayerCountAdjustAndStart(t *testing.T) { m := NewAppModel() next, _ := m.Update(key("down")) m = next.(AppModel) next, _ = m.Update(key("enter")) // экран выбора числа игроков m = next.(AppModel) if m.playerCount.count != 4 { t.Fatalf("ожидалось значение по умолчанию 4, получено %d", m.playerCount.count) } next, _ = m.Update(key("right")) m = next.(AppModel) if m.playerCount.count != 5 { t.Fatalf("ожидалось 5 после [right], получено %d", m.playerCount.count) } next, _ = m.Update(key("enter")) m = next.(AppModel) if m.state != statePlaying { t.Fatalf("ожидалось statePlaying после подтверждения числа игроков") } dm, ok := m.activeGame.(DurakModel) if !ok { t.Fatalf("активная игра должна быть моделью Дурака") } if len(dm.game.Players) != 5 { t.Errorf("ожидалось 5 игроков в запущенной партии, получено %d", len(dm.game.Players)) } } func TestPlayerCountClampsAtBounds(t *testing.T) { m := NewAppModel() next, _ := m.Update(key("down")) m = next.(AppModel) next, _ = m.Update(key("enter")) m = next.(AppModel) for i := 0; i < 10; i++ { next, _ = m.Update(key("left")) m = next.(AppModel) } if m.playerCount.count != 2 { t.Errorf("число игроков должно было остановиться на 2, получено %d", m.playerCount.count) } for i := 0; i < 10; i++ { next, _ = m.Update(key("right")) m = next.(AppModel) } if m.playerCount.count != 6 { t.Errorf("число игроков должно было остановиться на 6, получено %d", m.playerCount.count) } } func TestPlayerCountEscReturnsToMenu(t *testing.T) { m := NewAppModel() next, _ := m.Update(key("down")) m = next.(AppModel) next, _ = m.Update(key("enter")) m = next.(AppModel) next, _ = m.Update(key("esc")) m = next.(AppModel) if m.state != stateMenu { t.Fatalf("esc на экране выбора числа игроков должен вернуть в меню") } } func TestDurakQKeyReturnsToMenuNotQuitsApp(t *testing.T) { m := NewAppModel() next, _ := m.Update(key("down")) m = next.(AppModel) next, _ = m.Update(key("enter")) // экран числа игроков m = next.(AppModel) next, _ = m.Update(key("enter")) // запускаем Дурака m = next.(AppModel) if m.state != statePlaying { t.Fatalf("ожидалось statePlaying") } next, _ = m.Update(key("q")) m = next.(AppModel) if m.state != stateMenu { t.Fatalf("нажатие 'q' в игре должно вернуть в меню") } if m.quitting { t.Fatalf("приложение не должно закрываться") } } func TestMenuShowsBlackjackRules(t *testing.T) { m := NewAppModel() for i := 0; i < len(gameRegistry); i++ { next, _ := m.Update(key("down")) m = next.(AppModel) } next, _ := m.Update(key("enter")) // подменю правил m = next.(AppModel) // Блэкджек — третья запись в реестре (индекс 2) next, _ = m.Update(key("down")) m = next.(AppModel) next, _ = m.Update(key("down")) m = next.(AppModel) next, _ = m.Update(key("enter")) m = next.(AppModel) if m.state != stateRules { t.Fatalf("ожидалось состояние stateRules") } if !strings.Contains(m.rulesText, "БЛЭКДЖЕК") { t.Fatalf("текст правил должен содержать описание Блэкджека, получено начало: %.50s", m.rulesText) } } func TestMenuLeadsToBlackjackSettings(t *testing.T) { m := NewAppModel() // Блэкджек — третий пункт меню (после Тонка и Дурака) next, _ := m.Update(key("down")) m = next.(AppModel) next, _ = m.Update(key("down")) m = next.(AppModel) next, _ = m.Update(key("enter")) m = next.(AppModel) if m.state != stateBlackjackSettings { t.Fatalf("ожидалось состояние stateBlackjackSettings после выбора Блэкджека") } if m.activeGame != nil { t.Fatalf("игра не должна была запуститься до подтверждения настроек") } } func TestBlackjackSettingsAdjustAndStart(t *testing.T) { m := NewAppModel() next, _ := m.Update(key("down")) m = next.(AppModel) next, _ = m.Update(key("down")) m = next.(AppModel) next, _ = m.Update(key("enter")) // экран настроек m = next.(AppModel) if m.blackjack.playerCount != 1 { t.Fatalf("ожидалось значение по умолчанию 1 игрок, получено %d", m.blackjack.playerCount) } next, _ = m.Update(key("right")) m = next.(AppModel) if m.blackjack.playerCount != 2 { t.Fatalf("ожидалось 2 после [right], получено %d", m.blackjack.playerCount) } next, _ = m.Update(key("down")) // поле капитала m = next.(AppModel) if m.blackjack.fieldCursor != 1 { t.Fatalf("ожидался курсор на поле капитала (1), получено %d", m.blackjack.fieldCursor) } chosenPlayers := m.blackjack.playerCount chosenCapital := m.blackjack.capital() chosenBet := m.blackjack.bet() next, _ = m.Update(key("enter")) m = next.(AppModel) if m.state != statePlaying { t.Fatalf("ожидалось statePlaying после подтверждения настроек") } bm, ok := m.activeGame.(BlackjackModel) if !ok { t.Fatalf("активная игра должна быть моделью Блэкджека") } if len(bm.game.Players) != chosenPlayers { t.Errorf("число игроков в игре (%d) не совпадает с выбранным (%d)", len(bm.game.Players), chosenPlayers) } if bm.game.StartingCapital != chosenCapital { t.Errorf("капитал в игре (%d) не совпадает с выбранным (%d)", bm.game.StartingCapital, chosenCapital) } if bm.game.Bet != chosenBet { t.Errorf("ставка в игре (%d) не совпадает с выбранной (%d)", bm.game.Bet, chosenBet) } } func TestBlackjackSettingsEscReturnsToMenu(t *testing.T) { m := NewAppModel() next, _ := m.Update(key("down")) m = next.(AppModel) next, _ = m.Update(key("down")) m = next.(AppModel) next, _ = m.Update(key("enter")) m = next.(AppModel) next, _ = m.Update(key("esc")) m = next.(AppModel) if m.state != stateMenu { t.Fatalf("esc на экране настроек Блэкджека должен вернуть в меню") } } func TestBlackjackQKeyReturnsToMenuNotQuitsApp(t *testing.T) { m := NewAppModel() next, _ := m.Update(key("down")) m = next.(AppModel) next, _ = m.Update(key("down")) m = next.(AppModel) next, _ = m.Update(key("enter")) // настройки m = next.(AppModel) next, _ = m.Update(key("enter")) // запускаем игру m = next.(AppModel) if m.state != statePlaying { t.Fatalf("ожидалось statePlaying") } next, _ = m.Update(key("q")) m = next.(AppModel) if m.state != stateMenu { t.Fatalf("нажатие 'q' в игре должно вернуть в меню") } if m.quitting { t.Fatalf("приложение не должно закрываться") } } func TestMenuShowsOneOhOneRules(t *testing.T) { m := NewAppModel() for i := 0; i < len(gameRegistry); i++ { next, _ := m.Update(key("down")) m = next.(AppModel) } next, _ := m.Update(key("enter")) // подменю правил m = next.(AppModel) // 101 — четвёртая запись в реестре (индекс 3) for i := 0; i < 3; i++ { next, _ = m.Update(key("down")) m = next.(AppModel) } next, _ = m.Update(key("enter")) m = next.(AppModel) if m.state != stateRules { t.Fatalf("ожидалось состояние stateRules") } if !strings.Contains(m.rulesText, "101") { t.Fatalf("текст правил должен содержать описание 101, получено начало: %.50s", m.rulesText) } } func TestMenuLeadsToPlayerCountForOneOhOne(t *testing.T) { m := NewAppModel() // 101 — четвёртый пункт меню for i := 0; i < 3; i++ { next, _ := m.Update(key("down")) m = next.(AppModel) } next, _ := m.Update(key("enter")) m = next.(AppModel) if m.state != statePlayerCount { t.Fatalf("ожидалось состояние statePlayerCount после выбора 101") } } func TestOneOhOneQKeyReturnsToMenuNotQuitsApp(t *testing.T) { m := NewAppModel() for i := 0; i < 3; i++ { next, _ := m.Update(key("down")) m = next.(AppModel) } next, _ := m.Update(key("enter")) // экран числа игроков m = next.(AppModel) next, _ = m.Update(key("enter")) // запускаем игру m = next.(AppModel) if m.state != statePlaying { t.Fatalf("ожидалось statePlaying") } next, _ = m.Update(key("q")) m = next.(AppModel) if m.state != stateMenu { t.Fatalf("нажатие 'q' в игре должно вернуть в меню") } if m.quitting { t.Fatalf("приложение не должно закрываться") } } func TestMenuShowsThousandRules(t *testing.T) { m := NewAppModel() for i := 0; i < len(gameRegistry); i++ { next, _ := m.Update(key("down")) m = next.(AppModel) } next, _ := m.Update(key("enter")) // подменю правил m = next.(AppModel) // 1000 — пятая запись в реестре (индекс 4) for i := 0; i < 4; i++ { next, _ = m.Update(key("down")) m = next.(AppModel) } next, _ = m.Update(key("enter")) m = next.(AppModel) if m.state != stateRules { t.Fatalf("ожидалось состояние stateRules") } if !strings.Contains(m.rulesText, "ТЫСЯЧА") { t.Fatalf("текст правил должен содержать описание Тысячи, получено начало: %.50s", m.rulesText) } } func TestThousandStartsDirectlyWithoutSettingsScreen(t *testing.T) { m := NewAppModel() // 1000 — пятый пункт меню, запускается сразу (без экрана настроек) for i := 0; i < 4; i++ { next, _ := m.Update(key("down")) m = next.(AppModel) } next, _ := m.Update(key("enter")) m = next.(AppModel) if m.state != statePlaying { t.Fatalf("ожидалось немедленное statePlaying для 1000, получено %v", m.state) } if _, ok := m.activeGame.(ThousandModel); !ok { t.Fatalf("активная игра должна быть моделью 1000") } } func TestThousandQKeyReturnsToMenuNotQuitsApp(t *testing.T) { m := NewAppModel() for i := 0; i < 4; i++ { next, _ := m.Update(key("down")) m = next.(AppModel) } next, _ := m.Update(key("enter")) m = next.(AppModel) if m.state != statePlaying { t.Fatalf("ожидалось statePlaying") } next, _ = m.Update(key("q")) m = next.(AppModel) if m.state != stateMenu { t.Fatalf("нажатие 'q' в игре должно вернуть в меню") } if m.quitting { t.Fatalf("приложение не должно закрываться") } } func TestMenuShowsKlondikeRules(t *testing.T) { m := NewAppModel() for i := 0; i < len(gameRegistry); i++ { next, _ := m.Update(key("down")) m = next.(AppModel) } next, _ := m.Update(key("enter")) // подменю правил m = next.(AppModel) // Косынка — шестая запись в реестре (индекс 5) for i := 0; i < 5; i++ { next, _ = m.Update(key("down")) m = next.(AppModel) } next, _ = m.Update(key("enter")) m = next.(AppModel) if m.state != stateRules { t.Fatalf("ожидалось состояние stateRules") } if !strings.Contains(m.rulesText, "КОСЫНКА") { t.Fatalf("текст правил должен содержать описание Косынки, получено начало: %.50s", m.rulesText) } } func TestKlondikeStartsDirectlyWithoutSettingsScreen(t *testing.T) { m := NewAppModel() for i := 0; i < 5; i++ { next, _ := m.Update(key("down")) m = next.(AppModel) } next, _ := m.Update(key("enter")) m = next.(AppModel) if m.state != statePlaying { t.Fatalf("ожидалось немедленное statePlaying для Косынки, получено %v", m.state) } if _, ok := m.activeGame.(KlondikeModel); !ok { t.Fatalf("активная игра должна быть моделью Косынки") } } func TestKlondikeQKeyReturnsToMenuNotQuitsApp(t *testing.T) { m := NewAppModel() for i := 0; i < 5; i++ { next, _ := m.Update(key("down")) m = next.(AppModel) } next, _ := m.Update(key("enter")) m = next.(AppModel) if m.state != statePlaying { t.Fatalf("ожидалось statePlaying") } next, _ = m.Update(key("q")) m = next.(AppModel) if m.state != stateMenu { t.Fatalf("нажатие 'q' в игре должно вернуть в меню") } if m.quitting { t.Fatalf("приложение не должно закрываться") } } func TestMenuShowsCheckersRules(t *testing.T) { m := NewAppModel() for i := 0; i < len(gameRegistry); i++ { next, _ := m.Update(key("down")) m = next.(AppModel) } next, _ := m.Update(key("enter")) // подменю правил m = next.(AppModel) // Шашки — седьмая запись в реестре (индекс 6) for i := 0; i < 6; i++ { next, _ = m.Update(key("down")) m = next.(AppModel) } next, _ = m.Update(key("enter")) m = next.(AppModel) if m.state != stateRules { t.Fatalf("ожидалось состояние stateRules") } if !strings.Contains(m.rulesText, "ШАШКИ") { t.Fatalf("текст правил должен содержать описание Шашек, получено начало: %.50s", m.rulesText) } } func TestCheckersLeadsToDifficultySettings(t *testing.T) { m := NewAppModel() for i := 0; i < 6; i++ { next, _ := m.Update(key("down")) m = next.(AppModel) } next, _ := m.Update(key("enter")) m = next.(AppModel) if m.state != stateCheckersSettings { t.Fatalf("ожидалось состояние stateCheckersSettings для Шашек, получено %v", m.state) } if m.activeGame != nil { t.Fatalf("игра не должна была запуститься до подтверждения уровня сложности") } } func TestCheckersDifficultySelectionAndStart(t *testing.T) { m := NewAppModel() for i := 0; i < 6; i++ { next, _ := m.Update(key("down")) m = next.(AppModel) } next, _ := m.Update(key("enter")) // экран выбора сложности m = next.(AppModel) if m.checkers.levelIdx != 0 { t.Fatalf("ожидался уровень по умолчанию 0 (Простой), получено %d", m.checkers.levelIdx) } next, _ = m.Update(key("down")) // переключаемся на "Сильный" m = next.(AppModel) if m.checkers.levelIdx != 1 { t.Fatalf("ожидался уровень 1 (Сильный) после переключения, получено %d", m.checkers.levelIdx) } next, _ = m.Update(key("enter")) m = next.(AppModel) if m.state != statePlaying { t.Fatalf("ожидалось statePlaying после подтверждения уровня") } cm, ok := m.activeGame.(CheckersModel) if !ok { t.Fatalf("активная игра должна быть моделью Шашек") } if cm.bot.Depth != CheckersDifficultyHard { t.Errorf("ожидалась глубина бота %d (Сильный), получено %d", CheckersDifficultyHard, cm.bot.Depth) } } func TestCheckersDifficultyEscReturnsToMenu(t *testing.T) { m := NewAppModel() for i := 0; i < 6; i++ { next, _ := m.Update(key("down")) m = next.(AppModel) } next, _ := m.Update(key("enter")) m = next.(AppModel) next, _ = m.Update(key("esc")) m = next.(AppModel) if m.state != stateMenu { t.Fatalf("esc на экране сложности должен вернуть в меню") } } func TestCheckersQKeyReturnsToMenuNotQuitsApp(t *testing.T) { m := NewAppModel() for i := 0; i < 6; i++ { next, _ := m.Update(key("down")) m = next.(AppModel) } next, _ := m.Update(key("enter")) // экран сложности m = next.(AppModel) next, _ = m.Update(key("enter")) // запускаем игру m = next.(AppModel) if m.state != statePlaying { t.Fatalf("ожидалось statePlaying") } next, _ = m.Update(key("q")) m = next.(AppModel) if m.state != stateMenu { t.Fatalf("нажатие 'q' в игре должно вернуть в меню") } if m.quitting { t.Fatalf("приложение не должно закрываться") } } func TestMenuQuit(t *testing.T) { m := NewAppModel() // последний пункт меню — "Выход" for i := 0; i < len(m.options)-1; i++ { next, _ := m.Update(key("down")) m = next.(AppModel) } next, cmd := m.Update(key("enter")) m = next.(AppModel) if !m.quitting { t.Fatalf("ожидался флаг quitting после выбора пункта 'Выход'") } if cmd == nil { t.Fatalf("ожидалась команда tea.Quit") } } func TestGameQKeyReturnsToMenuNotQuitsApp(t *testing.T) { m := NewAppModel() next, _ := m.Update(key("enter")) // открываем настройки ставки m = next.(AppModel) next, _ = m.Update(key("enter")) // подтверждаем настройки и запускаем Тонк m = next.(AppModel) if m.state != statePlaying { t.Fatalf("ожидалось statePlaying") } next, cmd := m.Update(key("q")) m = next.(AppModel) if m.state != stateMenu { t.Fatalf("нажатие 'q' в игре должно вернуть в меню, а не закрыть приложение") } if m.quitting { t.Fatalf("приложение не должно закрываться при возврате в меню") } if cmd != nil { // допустима nil-команда — важно лишь, что это не tea.Quit _ = cmd } } func TestAppModelImplementsTeaModel(t *testing.T) { var _ tea.Model = AppModel{} }