package main import ( "strings" "testing" tea "github.com/charmbracelet/bubbletea" ) // newTestAppModel создаёт AppModel и сразу подтверждает язык по // умолчанию (первый пункт — русский), чтобы остальные тесты меню // могли считать, что стартуют сразу в stateMenu, как и раньше. // TestQReturnsToMenuForAllGames проверяет, что после запуска КАЖДОЙ // игры коллекции нажатие 'q' возвращает в главное меню. Существует // специально из-за реальной регрессии: переключатель типов, // проверяющий backToMenu после m.activeGame.Update(...), несколько // раз "забывал" недавно добавленные игры (крестики-нолики, нарды, // Сенет, Ур) — сама модель корректно выставляла backToMenu=true, но // AppModel никогда этого не замечал, так что 'q' в этих играх молча // ничего не делал. func TestQReturnsToMenuForAllGames(t *testing.T) { games := []struct { name string build func() tea.Model }{ {"tonk", func() tea.Model { return NewModel() }}, {"durak", func() tea.Model { return NewDurakModel(2) }}, {"blackjack", func() tea.Model { return NewBlackjackModel(1, 500, 10) }}, {"101", func() tea.Model { return NewOneOhOneModel(2) }}, {"1000", func() tea.Model { return NewThousandModel() }}, {"klondike", func() tea.Model { return NewKlondikeModel() }}, {"checkers", func() tea.Model { return NewCheckersModel(CheckersDifficultyEasy, ColorChoiceA) }}, {"chess", func() tea.Model { return NewChessModel(CheckersDifficultyEasy, ColorChoiceA) }}, {"corpsestarch", func() tea.Model { return NewCorpseStarchModel() }}, {"go", func() tea.Model { return NewGoModel(9, GoDifficultyEasy, ColorChoiceA) }}, {"minesweeper", func() tea.Model { return NewMinesweeperModel(9, 9, 10) }}, {"wayfarer", func() tea.Model { return NewWayfarerModel() }}, {"tictactoe", func() tea.Model { return NewTicTacToeModel(TicTacToeDifficultyEasy) }}, {"nardy", func() tea.Model { return NewNardyModel(NardyShort, NardyDifficultyEasy, ColorChoiceA) }}, {"senet", func() tea.Model { return NewSenetModel(SenetDifficultyEasy, ColorChoiceA) }}, {"ur", func() tea.Model { return NewUrModel(UrDifficultyEasy, ColorChoiceA) }}, {"poker", func() tea.Model { return NewPokerModel(3) }}, } for _, g := range games { t.Run(g.name, func(t *testing.T) { m := newTestAppModel() m.state = statePlaying m.activeGame = g.build() next, _ := m.Update(key("q")) m2 := next.(AppModel) if m2.state != stateMenu { t.Errorf("%s: 'q' должен был вернуть в главное меню, получено state=%v", g.name, m2.state) } }) } } func newTestAppModel() AppModel { m := NewAppModel() next, _ := m.Update(key("enter")) return next.(AppModel) } func TestLanguageScreenIsInitialState(t *testing.T) { SetLanguage(LangRU) m := NewAppModel() if m.state != stateLanguage { t.Fatalf("ожидалось начальное состояние stateLanguage, получено %v", m.state) } } func TestLanguageScreenSelectEnglish(t *testing.T) { defer SetLanguage(LangRU) // не влияем на остальные тесты пакета m := NewAppModel() if m.langCursor != 0 { t.Fatalf("ожидался курсор на первом пункте (русский), получено %d", m.langCursor) } next, _ := m.Update(key("down")) m = next.(AppModel) if m.langCursor != 1 { t.Fatalf("ожидался курсор на английском после down, получено %d", m.langCursor) } next, _ = m.Update(key("enter")) m = next.(AppModel) if m.state != stateMenu { t.Fatalf("ожидался переход в stateMenu после выбора языка") } if CurrentLang() != LangEN { t.Fatalf("ожидался установленный язык LangEN") } if T("menu.quit") != "Quit" { t.Errorf("после выбора английского T(\"menu.quit\") должен вернуть 'Quit', получено %q", T("menu.quit")) } } func TestLanguageScreenDefaultsToRussian(t *testing.T) { defer SetLanguage(LangRU) m := NewAppModel() next, _ := m.Update(key("enter")) m = next.(AppModel) if m.state != stateMenu { t.Fatalf("ожидался переход в stateMenu") } if CurrentLang() != LangRU { t.Fatalf("ожидался язык по умолчанию LangRU") } } func TestMenuHasLanguageOption(t *testing.T) { m := newTestAppModel() found := false for _, opt := range m.options { if opt.kind == "language" { found = true } } if !found { t.Fatalf("ожидался пункт меню для смены языка") } } func TestMenuScrollFollowsCursorDown(t *testing.T) { m := newTestAppModel() m.termHeight = 20 // тесный терминал — гарантированно меньше пунктов, чем видно целиком visible := m.menuVisibleItems() if visible >= len(m.options) { t.Fatalf("тест предполагает, что не все пункты помещаются сразу") } for i := 0; i < len(m.options)-1; i++ { next, _ := m.Update(key("down")) m = next.(AppModel) if m.cursor < m.menuScroll || m.cursor >= m.menuScroll+visible { t.Fatalf("курсор %d выпал из видимого окна [%d,%d) после шага %d", m.cursor, m.menuScroll, m.menuScroll+visible, i) } } } func TestMenuScrollFollowsCursorUp(t *testing.T) { m := newTestAppModel() m.termHeight = 20 visible := m.menuVisibleItems() for i := 0; i < len(m.options)-1; i++ { next, _ := m.Update(key("down")) m = next.(AppModel) } for i := 0; i < len(m.options)-1; i++ { next, _ := m.Update(key("up")) m = next.(AppModel) if m.cursor < m.menuScroll || m.cursor >= m.menuScroll+visible { t.Fatalf("курсор %d выпал из видимого окна [%d,%d) при прокрутке вверх", m.cursor, m.menuScroll, m.menuScroll+visible) } } if m.menuScroll != 0 { t.Errorf("после возврата к самому первому пункту прокрутка должна была вернуться к 0, получено %d", m.menuScroll) } } func TestMenuScrollIndicatorsAppearWhenScrolled(t *testing.T) { m := newTestAppModel() m.termHeight = 20 view := m.viewMenu() if strings.Contains(view, "▲") { t.Errorf("в самом начале списка индикатор 'выше' не должен показываться") } if !strings.Contains(view, "▼") { t.Errorf("при неполном списке индикатор 'ниже' должен показываться") } for i := 0; i < len(m.options)-1; i++ { next, _ := m.Update(key("down")) m = next.(AppModel) } view = m.viewMenu() if !strings.Contains(view, "▲") { t.Errorf("в конце списка индикатор 'выше' должен показываться") } if strings.Contains(view, "▼") { t.Errorf("в конце списка индикатор 'ниже' не должен показываться") } } func TestMenuCompactBannerOnSmallTerminal(t *testing.T) { m := newTestAppModel() m.termHeight = 20 if !m.menuUsesCompactBanner() { t.Errorf("на тесном терминале должен использоваться компактный баннер") } view := m.viewMenu() if !strings.Contains(view, "GO GAMES COLLECTION") { t.Errorf("компактный заголовок должен присутствовать в рендере") } } func TestMenuFullBannerOnLargeTerminal(t *testing.T) { m := newTestAppModel() m.termHeight = 60 if m.menuUsesCompactBanner() { t.Errorf("на просторном терминале должен использоваться полный баннер") } if m.menuVisibleItems() < len(m.options) { t.Errorf("на просторном терминале весь список должен помещаться целиком") } } func TestRulesMenuScrollFollowsCursor(t *testing.T) { m := newTestAppModel() m.termHeight = 12 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") } visible := m.rulesMenuVisibleItems() if visible >= len(gameRegistry) { t.Fatalf("тест предполагает, что не все игры помещаются сразу при высоте 12") } for i := 0; i < len(gameRegistry)-1; i++ { next, _ := m.Update(key("down")) m = next.(AppModel) if m.rulesCursor < m.rulesMenuScroll || m.rulesCursor >= m.rulesMenuScroll+visible { t.Fatalf("курсор правил %d выпал из видимого окна [%d,%d)", m.rulesCursor, m.rulesMenuScroll, m.rulesMenuScroll+visible) } } } func TestMenuScrollRecalculatedOnResize(t *testing.T) { m := newTestAppModel() m.termHeight = 50 // просторный терминал — весь список виден, курсор можно увести глубоко for i := 0; i < len(m.options)-1; i++ { next, _ := m.Update(key("down")) m = next.(AppModel) } if m.cursor != len(m.options)-1 { t.Fatalf("ожидался курсор на последнем пункте") } // пользователь на лету уменьшает терминал — курсор должен остаться видимым next, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 15}) m = next.(AppModel) visible := m.menuVisibleItems() if m.cursor < m.menuScroll || m.cursor >= m.menuScroll+visible { t.Errorf("после уменьшения терминала курсор %d должен был остаться в видимом окне [%d,%d)", m.cursor, m.menuScroll, m.menuScroll+visible) } } func TestMenuNavigation(t *testing.T) { m := newTestAppModel() if m.state != stateMenu { t.Fatalf("ожидалось начальное состояние stateMenu") } if len(m.options) != len(gameRegistry)+3 { t.Fatalf("ожидалось %d пунктов меню, получено %d", len(gameRegistry)+3, 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 := newTestAppModel() // первый пункт меню — первая (и пока единственная) игра, которая // использует ставки -> должен открыться экран настроек, а не игра 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 := newTestAppModel() 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 := newTestAppModel() 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 := newTestAppModel() 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 := newTestAppModel() // переходим к пункту "Правила" (сразу после списка игр) 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 := newTestAppModel() 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 := newTestAppModel() 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 := newTestAppModel() 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 := newTestAppModel() 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 := newTestAppModel() 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 := newTestAppModel() 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 := newTestAppModel() 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 := newTestAppModel() // Блэкджек — третий пункт меню (после Тонка и Дурака) 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 := newTestAppModel() 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 := newTestAppModel() 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 := newTestAppModel() 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 := newTestAppModel() 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 := newTestAppModel() // 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 := newTestAppModel() 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 := newTestAppModel() 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 := newTestAppModel() // 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 := newTestAppModel() 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 := newTestAppModel() 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 := newTestAppModel() 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 := newTestAppModel() 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 := newTestAppModel() 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 := newTestAppModel() 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 := newTestAppModel() 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("right")) // переключаемся на "Сильный" 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 := newTestAppModel() 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 := newTestAppModel() 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 TestMenuShowsCorpseStarchRules(t *testing.T) { m := newTestAppModel() for i := 0; i < len(gameRegistry); i++ { next, _ := m.Update(key("down")) m = next.(AppModel) } next, _ := m.Update(key("enter")) // подменю правил m = next.(AppModel) // Трупные батончики — девятая запись в реестре (индекс 8) for i := 0; i < 8; 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 TestCorpseStarchStartsDirectlyWithoutSettingsScreen(t *testing.T) { withTempConfigDir(t) m := newTestAppModel() for i := 0; i < 8; 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.(CorpseStarchModel); !ok { t.Fatalf("активная игра должна быть моделью Трупных батончиков") } } func TestCorpseStarchQKeyReturnsToMenuNotQuitsApp(t *testing.T) { withTempConfigDir(t) m := newTestAppModel() for i := 0; i < 8; 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 TestMenuShowsWayfarerRules(t *testing.T) { m := newTestAppModel() for i := 0; i < len(gameRegistry); i++ { next, _ := m.Update(key("down")) m = next.(AppModel) } next, _ := m.Update(key("enter")) // подменю правил m = next.(AppModel) // Странник — двенадцатая запись в реестре (индекс 11) for i := 0; i < 11; 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 TestWayfarerStartsDirectlyWithoutSettingsScreen(t *testing.T) { withTempConfigDir(t) m := newTestAppModel() for i := 0; i < 11; 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.(WayfarerModel); !ok { t.Fatalf("активная игра должна быть моделью Странника") } } func TestWayfarerQKeyReturnsToMenuNotQuitsApp(t *testing.T) { withTempConfigDir(t) m := newTestAppModel() for i := 0; i < 11; 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 TestMenuShowsMinesweeperRules(t *testing.T) { m := newTestAppModel() for i := 0; i < len(gameRegistry); i++ { next, _ := m.Update(key("down")) m = next.(AppModel) } next, _ := m.Update(key("enter")) // подменю правил m = next.(AppModel) // Сапёр — одиннадцатая запись в реестре (индекс 10) for i := 0; i < 10; 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 TestMinesweeperLeadsToSettings(t *testing.T) { m := newTestAppModel() for i := 0; i < 10; i++ { next, _ := m.Update(key("down")) m = next.(AppModel) } next, _ := m.Update(key("enter")) m = next.(AppModel) if m.state != stateMinesweeperSettings { t.Fatalf("ожидалось состояние stateMinesweeperSettings, получено %v", m.state) } if m.activeGame != nil { t.Fatalf("игра не должна была запуститься до подтверждения пресета") } } func TestMinesweeperSettingsSelectionAndStart(t *testing.T) { m := newTestAppModel() for i := 0; i < 10; i++ { next, _ := m.Update(key("down")) m = next.(AppModel) } next, _ := m.Update(key("enter")) // экран настроек m = next.(AppModel) if m.minesweeper.presetIdx != 0 { t.Fatalf("ожидался пресет по умолчанию 0 (Новичок), получено %d", m.minesweeper.presetIdx) } next, _ = m.Update(key("down")) // Новичок -> Любитель m = next.(AppModel) if m.minesweeper.presetIdx != 1 { t.Fatalf("ожидался пресет 1 (Любитель) после переключения, получено %d", m.minesweeper.presetIdx) } next, _ = m.Update(key("enter")) m = next.(AppModel) if m.state != statePlaying { t.Fatalf("ожидалось statePlaying после подтверждения пресета") } mm, ok := m.activeGame.(MinesweeperModel) if !ok { t.Fatalf("активная игра должна быть моделью Сапёра") } if mm.game.Width != 16 || mm.game.Height != 16 || mm.game.MineCount != 40 { t.Errorf("ожидался пресет 'Любитель' (16x16, 40 мин) в запущенной игре, получено %dx%d/%d", mm.game.Width, mm.game.Height, mm.game.MineCount) } } func TestMinesweeperSettingsEscReturnsToMenu(t *testing.T) { m := newTestAppModel() for i := 0; i < 10; 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 TestMinesweeperQKeyReturnsToMenuNotQuitsApp(t *testing.T) { m := newTestAppModel() for i := 0; i < 10; 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 TestMenuShowsGoRules(t *testing.T) { m := newTestAppModel() for i := 0; i < len(gameRegistry); i++ { next, _ := m.Update(key("down")) m = next.(AppModel) } next, _ := m.Update(key("enter")) // подменю правил m = next.(AppModel) // Го — десятая запись в реестре (индекс 9) for i := 0; i < 9; 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, "ГО (GO)") { t.Fatalf("текст правил должен содержать описание Го, получено начало: %.50s", m.rulesText) } } func TestGoLeadsToSettings(t *testing.T) { m := newTestAppModel() for i := 0; i < 9; i++ { next, _ := m.Update(key("down")) m = next.(AppModel) } next, _ := m.Update(key("enter")) m = next.(AppModel) if m.state != stateGoSettings { t.Fatalf("ожидалось состояние stateGoSettings для Го, получено %v", m.state) } if m.activeGame != nil { t.Fatalf("игра не должна была запуститься до подтверждения настроек") } } func TestGoSettingsSelectionAndStart(t *testing.T) { m := newTestAppModel() for i := 0; i < 9; i++ { next, _ := m.Update(key("down")) m = next.(AppModel) } next, _ := m.Update(key("enter")) // экран настроек m = next.(AppModel) if m.goSetup.size() != 9 { t.Fatalf("ожидался размер доски по умолчанию 9, получено %d", m.goSetup.size()) } next, _ = m.Update(key("right")) // 9 -> 13 m = next.(AppModel) if m.goSetup.size() != 13 { t.Fatalf("ожидался размер доски 13 после переключения, получено %d", m.goSetup.size()) } next, _ = m.Update(key("down")) // переходим к полю сложности m = next.(AppModel) if m.goSetup.fieldCursor != 1 { t.Fatalf("ожидался курсор на поле сложности (1), получено %d", m.goSetup.fieldCursor) } next, _ = m.Update(key("right")) // Простой -> Средний m = next.(AppModel) if m.goSetup.difficulty() != GoDifficultyMedium { t.Fatalf("ожидалась сложность Средний после переключения") } next, _ = m.Update(key("enter")) m = next.(AppModel) if m.state != statePlaying { t.Fatalf("ожидалось statePlaying после подтверждения настроек") } gm, ok := m.activeGame.(GoModel) if !ok { t.Fatalf("активная игра должна быть моделью Го") } if gm.game.Size != 13 { t.Errorf("ожидался размер доски 13 в запущенной игре, получено %d", gm.game.Size) } if gm.bot.Difficulty != GoDifficultyMedium { t.Errorf("ожидалась сложность Средний в запущенной игре") } } func TestGoSettingsEscReturnsToMenu(t *testing.T) { m := newTestAppModel() for i := 0; i < 9; 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 TestGoQKeyReturnsToMenuNotQuitsApp(t *testing.T) { m := newTestAppModel() for i := 0; i < 9; 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 TestMenuShowsChessRules(t *testing.T) { m := newTestAppModel() for i := 0; i < len(gameRegistry); i++ { next, _ := m.Update(key("down")) m = next.(AppModel) } next, _ := m.Update(key("enter")) // подменю правил m = next.(AppModel) // Шахматы — восьмая запись в реестре (индекс 7) for i := 0; i < 7; 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 TestChessLeadsToDifficultySettings(t *testing.T) { m := newTestAppModel() for i := 0; i < 7; i++ { next, _ := m.Update(key("down")) m = next.(AppModel) } next, _ := m.Update(key("enter")) m = next.(AppModel) if m.state != stateChessSettings { t.Fatalf("ожидалось состояние stateChessSettings для Шахмат, получено %v", m.state) } if m.activeGame != nil { t.Fatalf("игра не должна была запуститься до подтверждения уровня сложности") } } func TestChessDifficultySelectionAndStart(t *testing.T) { m := newTestAppModel() for i := 0; i < 7; i++ { next, _ := m.Update(key("down")) m = next.(AppModel) } next, _ := m.Update(key("enter")) // экран выбора сложности m = next.(AppModel) if m.chess.levelIdx != 0 { t.Fatalf("ожидался уровень по умолчанию 0 (Простой), получено %d", m.chess.levelIdx) } next, _ = m.Update(key("right")) // переключаемся на "Сильный" m = next.(AppModel) if m.chess.levelIdx != 1 { t.Fatalf("ожидался уровень 1 (Сильный) после переключения, получено %d", m.chess.levelIdx) } next, _ = m.Update(key("enter")) m = next.(AppModel) if m.state != statePlaying { t.Fatalf("ожидалось statePlaying после подтверждения уровня") } cm, ok := m.activeGame.(ChessModel) if !ok { t.Fatalf("активная игра должна быть моделью Шахмат") } if cm.bot.Depth != ChessDifficultyHard { t.Errorf("ожидалась глубина бота %d (Сильный), получено %d", ChessDifficultyHard, cm.bot.Depth) } } func TestChessDifficultyEscReturnsToMenu(t *testing.T) { m := newTestAppModel() for i := 0; i < 7; 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 TestChessQKeyReturnsToMenuNotQuitsApp(t *testing.T) { m := newTestAppModel() for i := 0; i < 7; 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 TestRulesScrollDownAndUp(t *testing.T) { m := newTestAppModel() m.termHeight = 10 // маленький экран, чтобы правила точно не поместились m.rulesText = strings.Repeat("строка\n", 50) m.state = stateRules m.rulesScroll = 0 next, _ := m.Update(key("down")) m = next.(AppModel) if m.rulesScroll != 1 { t.Fatalf("ожидалась прокрутка на 1 строку вниз, получено %d", m.rulesScroll) } next, _ = m.Update(key("up")) m = next.(AppModel) if m.rulesScroll != 0 { t.Fatalf("ожидался возврат к строке 0, получено %d", m.rulesScroll) } next, _ = m.Update(key("up")) m = next.(AppModel) if m.rulesScroll != 0 { t.Errorf("прокрутка не должна была уйти в отрицательные значения, получено %d", m.rulesScroll) } } func TestRulesScrollClampsAtBottom(t *testing.T) { m := newTestAppModel() m.termHeight = 10 m.rulesText = strings.Repeat("строка\n", 20) m.state = stateRules for i := 0; i < 100; i++ { next, _ := m.Update(key("down")) m = next.(AppModel) } maxScroll := m.rulesMaxScroll() if m.rulesScroll != maxScroll { t.Errorf("прокрутка должна была остановиться на максимуме %d, получено %d", maxScroll, m.rulesScroll) } } func TestRulesPageDownMovesByVisibleLines(t *testing.T) { m := newTestAppModel() m.termHeight = 14 m.rulesText = strings.Repeat("строка\n", 100) m.state = stateRules next, _ := m.Update(key("pgdown")) m = next.(AppModel) if m.rulesScroll != m.rulesVisibleLines() { t.Errorf("ожидалась прокрутка на %d строк за страницу, получено %d", m.rulesVisibleLines(), m.rulesScroll) } } func TestRulesHomeAndEndKeys(t *testing.T) { m := newTestAppModel() m.termHeight = 10 m.rulesText = strings.Repeat("строка\n", 50) m.state = stateRules next, _ := m.Update(key("G")) m = next.(AppModel) if m.rulesScroll != m.rulesMaxScroll() { t.Fatalf("'G' должна была прокрутить в самый конец") } next, _ = m.Update(key("g")) m = next.(AppModel) if m.rulesScroll != 0 { t.Errorf("'g' должна была прокрутить в самое начало") } } func TestRulesScrollResetsWhenReopened(t *testing.T) { m := newTestAppModel() m.termHeight = 10 for i := 0; i < len(gameRegistry); i++ { n, _ := m.Update(key("down")) m = n.(AppModel) } n, _ := m.Update(key("enter")) // подменю правил m = n.(AppModel) n, _ = m.Update(key("enter")) // открываем правила первой игры m = n.(AppModel) if m.state != stateRules { t.Fatalf("ожидалось состояние stateRules") } n, _ = m.Update(key("down")) m = n.(AppModel) if m.rulesScroll == 0 { t.Fatalf("ожидалась прокрутка вниз перед проверкой сброса") } n, _ = m.Update(key("esc")) // назад в подменю правил m = n.(AppModel) n, _ = m.Update(key("enter")) // снова открываем те же правила m = n.(AppModel) if m.rulesScroll != 0 { t.Errorf("прокрутка должна была сброситься при повторном открытии правил, получено %d", m.rulesScroll) } } func TestWindowSizeMsgUpdatesDimensions(t *testing.T) { m := newTestAppModel() next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) m = next.(AppModel) if m.termWidth != 100 || m.termHeight != 40 { t.Errorf("ожидались обновлённые размеры терминала 100x40, получено %dx%d", m.termWidth, m.termHeight) } } func TestMenuQuit(t *testing.T) { m := newTestAppModel() // последний пункт меню — "Выход" 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 := newTestAppModel() 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{} }