package main import ( "strings" "testing" "time" ) func TestStoryDifficultySelectionGoesStraightToOnboardingHub(t *testing.T) { withTempConfigDir(t) m := NewAppModel() m.state = stateStoryDifficulty m.storyDifficultyCursor = 2 // "Сложная" next, _ := m.Update(key("enter")) m = next.(AppModel) if m.state != stateStoryHub { t.Fatalf("после выбора сложности ожидался переход сразу в stateStoryHub (этап знакомства), получено %v", m.state) } if m.story == nil { t.Fatalf("ожидалось созданное состояние сторимода") } if m.story.Difficulty != StoryDifficultyHard { t.Errorf("ожидалась сложность StoryDifficultyHard, получено %v", m.story.Difficulty) } if m.story.Onboarding != StoryOnboardingTicTacToeOnly { t.Errorf("новая партия должна была начаться с этапа знакомства (только крестики-нолики), получено %v", m.story.Onboarding) } } func TestStoryDifficultySelectionPersistsToSave(t *testing.T) { withTempConfigDir(t) m := NewAppModel() m.state = stateStoryDifficulty m.storyDifficultyCursor = 1 next, _ := m.Update(key("enter")) _ = next.(AppModel) loaded, err := LoadStoryModeState() if err != nil { t.Fatalf("неожиданная ошибка загрузки: %v", err) } if loaded.Difficulty != StoryDifficultyMedium { t.Errorf("сохранённая сложность должна была быть Medium, получено %v", loaded.Difficulty) } } func TestModeSelectStoryWithExistingSaveShowsGreetingThenHub(t *testing.T) { withTempConfigDir(t) pre := NewStoryModeState(StoryDifficultyEasy, time.Now()) pre.Onboarding = StoryOnboardingDone // приветствие дня показывается только после знакомства if err := SaveStoryModeState(pre); err != nil { t.Fatalf("неожиданная ошибка сохранения: %v", err) } m := NewAppModel() 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 != stateStoryMorning { t.Fatalf("при входе через меню режимов ожидалось приветствие дня, получено %v", m.state) } if m.storyGreeting != storyGreetingPlayingAgain { t.Errorf("при входе в тот же день ожидалось приветствие storyGreetingPlayingAgain, получено %v", m.storyGreeting) } next, _ = m.Update(key(" ")) m = next.(AppModel) if m.state != stateStoryHub { t.Fatalf("после приветствия ожидался переход в stateStoryHub, получено %v", m.state) } if m.story == nil || m.story.Difficulty != StoryDifficultyEasy { t.Fatalf("ожидалось загруженное состояние с сохранённой сложностью, получено %+v", m.story) } } func TestStoryHubLaunchesPlayableGame(t *testing.T) { withTempConfigDir(t) m := NewAppModel() m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) m.state = stateStoryHub m.storyHubCursor = 0 // Крестики-нолики — единственная видимая игра на этапе знакомства next, _ := m.Update(key("enter")) m = next.(AppModel) if m.state != statePlaying { t.Fatalf("ожидался переход в statePlaying после запуска игры, получено %v", m.state) } if m.storyActiveResource == nil || *m.storyActiveResource != StoryResTicTacToe { t.Fatalf("ожидался отмеченный активный ресурс StoryResTicTacToe") } if _, ok := m.activeGame.(TicTacToeModel); !ok { t.Errorf("ожидалась запущенная модель TicTacToeModel, получено %T", m.activeGame) } } func TestAllStoryHubGamesAreWiredInStoryGameDefs(t *testing.T) { for _, id := range storyHubOrder { if _, ok := storyGameDefs[id]; !ok { t.Errorf("ресурс %v есть в storyHubOrder, но не подключён в storyGameDefs", id) } } } func TestStoryHubStillLaunchesLockedGame(t *testing.T) { withTempConfigDir(t) m := NewAppModel() m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) m.story.Onboarding = StoryOnboardingDone // тест про полный хаб, не про этап знакомства m.story.Locked[StoryResTicTacToe] = true m.state = stateStoryHub m.storyHubCursor = indexOfStoryResource(t, StoryResTicTacToe) next, _ := m.Update(key("enter")) m = next.(AppModel) if m.activeGame == nil { t.Errorf("уже зафиксированная (запертая) игра должна была оставаться играбельной") } } func TestWinningStoryGameAwardsResourceAndReturnsToHub(t *testing.T) { withTempConfigDir(t) m := NewAppModel() m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) before := m.story.Resources[StoryResTicTacToe] tm := NewTicTacToeModel(TicTacToeDifficultyEasy) tm.game.Phase = TicTacToePhaseOver tm.game.Result = &TicTacToeResult{Winner: tm.game.HumanMark} tm.backToMenu = true resource := StoryResTicTacToe m.storyActiveResource = &resource m.activeGame = tm m.state = statePlaying next, _ := m.Update(key("q")) // ключ не важен — уже готовый backToMenu=true проверяется в Update верхнего уровня m2, ok := next.(AppModel) if !ok { t.Fatalf("ожидался AppModel в ответе") } if m2.state != stateStoryHub { t.Fatalf("после победы в игре сторимода ожидался возврат в stateStoryHub, получено %v", m2.state) } if m2.story.Resources[StoryResTicTacToe] != before+1 { t.Errorf("ожидалось начисление +1 ресурса за победу, получено %d (было %d)", m2.story.Resources[StoryResTicTacToe], before) } if m2.storyActiveResource != nil { t.Errorf("отметка активного ресурса должна была сброситься после возврата") } } func TestLosingStoryGameDoesNotAwardResource(t *testing.T) { withTempConfigDir(t) m := NewAppModel() m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) before := m.story.Resources[StoryResTicTacToe] tm := NewTicTacToeModel(TicTacToeDifficultyEasy) tm.game.Phase = TicTacToePhaseOver loser := TicTacToeX if tm.game.HumanMark == TicTacToeX { loser = TicTacToeO } tm.game.Result = &TicTacToeResult{Winner: loser} tm.backToMenu = true resource := StoryResTicTacToe m.storyActiveResource = &resource m.activeGame = tm m.state = statePlaying next, _ := m.Update(key("q")) m2 := next.(AppModel) if m2.story.Resources[StoryResTicTacToe] != before { t.Errorf("поражение не должно было начислять ресурс, получено %d (было %d)", m2.story.Resources[StoryResTicTacToe], before) } if m2.state != stateStoryHub { t.Errorf("после поражения тоже ожидался возврат в хаб, получено %v", m2.state) } } func TestStoryResourceLocksExactlyAtTarget(t *testing.T) { s := NewStoryModeState(StoryDifficultyEasy, time.Now()) s.Resources[StoryResChess] = StoryTargetChess - 1 s.RecordWin(StoryResChess) if !s.Locked[StoryResChess] { t.Fatalf("ресурс должен был зафиксироваться по достижении цели") } } // indexOfStoryResource находит позицию ресурса в storyHubOrder — не // хардкодим индекс напрямую, чтобы тест не сломался при изменении // порядка показа в хабе. func indexOfStoryResource(t *testing.T, id StoryResourceID) int { t.Helper() for i, r := range storyHubOrder { if r == id { return i } } t.Fatalf("ресурс %v не найден в storyHubOrder", id) return -1 } func TestStoryOutcomeBlackjackRequiresThreeRoundsMinimum(t *testing.T) { bm := NewBlackjackModel(1, 500, 25) for bm.game.Phase == BJPhasePlayerTurn { bm.game.Stand() } bm.game.RoundsPlayed = 2 bm.game.Players[bm.humanIdx].Balance = bm.game.StartingCapital + 1000 // в большом плюсе, но раундов мало finished, won := storyOutcomeBlackjack(bm) if !finished { t.Fatalf("после завершения раунда партия должна была считаться finished") } if won { t.Errorf("меньше трёх раундов не должно засчитываться победой, даже при явном плюсе") } } func TestStoryOutcomeBlackjackWinsWithThreeRoundsAndProfit(t *testing.T) { bm := NewBlackjackModel(1, 500, 25) for bm.game.Phase == BJPhasePlayerTurn { bm.game.Stand() } bm.game.RoundsPlayed = 3 bm.game.Players[bm.humanIdx].Balance = bm.game.StartingCapital + 1 _, won := storyOutcomeBlackjack(bm) if !won { t.Errorf("три раунда и баланс выше стартового должны были засчитаться победой") } } func TestStoryOutcomeBlackjackNoWinWithoutProfit(t *testing.T) { bm := NewBlackjackModel(1, 500, 25) for bm.game.Phase == BJPhasePlayerTurn { bm.game.Stand() } bm.game.RoundsPlayed = 5 bm.game.Players[bm.humanIdx].Balance = bm.game.StartingCapital // ровно столько же, не "выше" _, won := storyOutcomeBlackjack(bm) if won { t.Errorf("баланс не выше стартового (даже после многих раундов) не должен засчитываться победой") } } func TestStoryOutcomeBlackjackNotFinishedMidRound(t *testing.T) { bm := NewBlackjackModel(1, 500, 25) bm.game.Phase = BJPhasePlayerTurn // принудительно, чтобы не зависеть от случайного натурального блэкджека с раздачи finished, _ := storyOutcomeBlackjack(bm) if finished { t.Errorf("партия в процессе раунда не должна была считаться завершённой") } } func TestTicTacToeDrawCountsAsStoryWin(t *testing.T) { tm := NewTicTacToeModel(TicTacToeDifficultyEasy) tm.game.Phase = TicTacToePhaseOver tm.game.Result = &TicTacToeResult{Winner: TicTacToeEmpty} // ничья finished, won := storyOutcomeTicTacToe(tm) if !finished || !won { t.Errorf("ничья должна была засчитаться как победа в сторимоде, получено finished=%v won=%v", finished, won) } } func TestTicTacToeStoryTargetIsTen(t *testing.T) { if StoryTargetTicTacToe != 10 { t.Errorf("ожидалась цель 10 для крестиков-ноликов, получено %d", StoryTargetTicTacToe) } } func TestEnterStoryHubFromModeSelectShowsMorningScreenAfterADay(t *testing.T) { withTempConfigDir(t) m := NewAppModel() past := time.Now().Add(-25 * time.Hour) m.story = NewStoryModeState(StoryDifficultyEasy, past) m.story.Onboarding = StoryOnboardingDone m = m.enterStoryHubFromModeSelect() if m.state != stateStoryMorning { t.Fatalf("после прошедших суток ожидался утренний экран, получено %v", m.state) } if m.storyGreeting != storyGreetingWokeUp { t.Errorf("после ровно одних прошедших суток ожидалось приветствие storyGreetingWokeUp, получено %v", m.storyGreeting) } // возможные случайно сработавшие события (выброс/стук) не относятся // к тому, что здесь проверяется — своя логика очереди уже // покрыта отдельными тестами m.story.PendingNeurotoxinAlert = false m.story.PendingDoorKnockAlert = false next, _ := m.Update(key(" ")) m2 := next.(AppModel) if m2.state != stateStoryHub { t.Errorf("после утреннего экрана ожидался переход в хаб, получено %v", m2.state) } } func TestEnterStoryHubFromModeSelectSkipsGreetingDuringOnboarding(t *testing.T) { withTempConfigDir(t) m := NewAppModel() m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) // по умолчанию ещё на этапе знакомства m = m.enterStoryHubFromModeSelect() if m.state != stateStoryHub { t.Errorf("во время знакомства (ИИ ещё не представилась) приветствие дня показываться не должно, получено %v", m.state) } } func TestEnterStoryHubFromModeSelectShowsGreetingAfterOnboarding(t *testing.T) { withTempConfigDir(t) m := NewAppModel() m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) m.story.Onboarding = StoryOnboardingDone m = m.enterStoryHubFromModeSelect() if m.state != stateStoryMorning { t.Errorf("после завершения знакомства вход через меню режимов должен был показывать одно из приветствий, получено %v", m.state) } if m.storyGreeting != storyGreetingPlayingAgain { t.Errorf("в тот же день ожидалось приветствие storyGreetingPlayingAgain, получено %v", m.storyGreeting) } } func TestEnterStoryHubAfterGameNeverShowsGreeting(t *testing.T) { withTempConfigDir(t) m := NewAppModel() past := time.Now().Add(-50 * time.Hour) // прошло несколько суток m.story = NewStoryModeState(StoryDifficultyEasy, past) m = m.enterStoryHub() if m.state != stateStoryHub { t.Errorf("возврат из партии не должен показывать приветствие дня даже после нескольких прошедших суток, получено %v", m.state) } } func TestStoryOutcomeTonkRequiresThreeRoundsAndMaxBalance(t *testing.T) { tm := NewModel() tm.game.Phase = PhaseGameOver tm.game.RoundsPlayed = 2 tm.game.Players[tm.humanIdx].Balance = 999999 // в большом плюсе, но раундов мало if _, won := storyOutcomeTonk(tm); won { t.Errorf("меньше трёх раундов не должно засчитываться победой") } tm.game.RoundsPlayed = 3 for _, p := range tm.game.Players { p.Balance = 0 } tm.game.Players[tm.humanIdx].Balance = 100 if _, won := storyOutcomeTonk(tm); !won { t.Errorf("три раунда и максимальный баланс должны были засчитаться победой") } } func TestStoryOutcomeDurakStreakResetsOnLoss(t *testing.T) { dm := NewDurakModel(4) dm.storyNonFoolStreak = 2 dm.game.Result = &DurakResult{Loser: dm.humanIdx} if _, won := storyOutcomeDurak(dm); won { t.Errorf("поражение в решающей партии должно было сбросить серию, а не засчитать победу") } } func TestStoryOutcomeDurakStreakCountsCurrentGame(t *testing.T) { dm := NewDurakModel(4) dm.storyNonFoolStreak = 2 // уже 2 подряд без дурака other := (dm.humanIdx + 1) % len(dm.game.Players) dm.game.Result = &DurakResult{Loser: other} // в этой партии дураком стал не человек if _, won := storyOutcomeDurak(dm); !won { t.Errorf("третья подряд партия без дурака должна была засчитаться победой") } } func TestStoryOutcomeMinesweeperStreakResetsOnLoss(t *testing.T) { mm := NewMinesweeperModel(9, 9, 10) mm.storyWinStreak = 2 mm.game.Result = &MinesweeperResult{Won: false} if _, won := storyOutcomeMinesweeper(mm); won { t.Errorf("взрыв в решающей партии должен был сбросить серию, а не засчитать победу") } } func TestStoryOutcomeMinesweeperStreakCountsCurrentGame(t *testing.T) { mm := NewMinesweeperModel(9, 9, 10) mm.storyWinStreak = 2 mm.game.Result = &MinesweeperResult{Won: true} if _, won := storyOutcomeMinesweeper(mm); !won { t.Errorf("третья подряд победа без взрыва должна была засчитаться") } } func TestStoryOutcomeOneOhOneRequiresMatchOverNotJustRound(t *testing.T) { om := NewOneOhOneModel(3) om.game.Phase = OneOhOnePhaseRoundOver om.game.Result = &OneOhOneRoundResult{Winner: om.humanIdx, MatchOver: false} if finished, _ := storyOutcomeOneOhOne(om); finished { t.Errorf("отдельный раунд не должен засчитываться завершением партии — нужен весь матч") } om.game.Result.MatchOver = true om.game.Result.MatchWinner = om.humanIdx if finished, won := storyOutcomeOneOhOne(om); !finished || !won { t.Errorf("выигранный матч должен был засчитаться победой, получено finished=%v won=%v", finished, won) } } func TestStoryOutcomeThousandRequiresThreeHandsAndTopScore(t *testing.T) { tm := NewThousandModel() tm.game.Phase = ThousandPhaseHandOver tm.game.HandNumber = 2 tm.game.Players[tm.humanIdx].Score = 999999 if _, won := storyOutcomeThousand(tm); won { t.Errorf("меньше трёх раздач не должно засчитываться победой") } tm.game.HandNumber = 3 for _, p := range tm.game.Players { p.Score = 0 } tm.game.Players[tm.humanIdx].Score = 50 if _, won := storyOutcomeThousand(tm); !won { t.Errorf("три раздачи и первое место по очкам должны были засчитаться победой") } } func TestStoryOutcomeKlondikeJustReportsWinFlag(t *testing.T) { km := NewKlondikeModel() km.game.Won = false if finished, _ := storyOutcomeKlondike(km); finished { t.Errorf("незавершённая партия не должна была считаться выигранной") } km.game.Won = true if finished, won := storyOutcomeKlondike(km); !finished || !won { t.Errorf("выигранная партия должна была вернуть finished=true won=true") } } func TestKlondikeDailyCounterAwardsResourceOnThirdWinOfDay(t *testing.T) { withTempConfigDir(t) m := NewAppModel() m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) km := NewKlondikeModel() km.game.Won = true km.backToMenu = true for i := 0; i < 3; i++ { resource := StoryResKlondike m.storyActiveResource = &resource m.activeGame = km m.state = statePlaying next, _ := m.Update(key("q")) m = next.(AppModel) } if m.story.Resources[StoryResKlondike] != storyStartingAmount+1 { t.Errorf("после третьей победы за день ожидалось начисление ровно +1 ресурса, получено %d (было %d)", m.story.Resources[StoryResKlondike], storyStartingAmount) } if m.story.KlondikeWinsToday != 3 { t.Errorf("ожидался счётчик побед за день = 3, получено %d", m.story.KlondikeWinsToday) } } func TestStoryOutcomePokerRequiresTournamentOverAndNotEliminated(t *testing.T) { pm := NewPokerModel(4) if finished, _ := storyOutcomePoker(pm); finished { t.Errorf("турнир в процессе не должен считаться завершённым") } pm.game.Phase = PokerPhaseTournamentOver pm.game.Players[pm.humanSeat].Eliminated = false if finished, won := storyOutcomePoker(pm); !finished || !won { t.Errorf("невыбывший человек при завершении турнира должен был засчитаться победителем") } pm.game.Players[pm.humanSeat].Eliminated = true if _, won := storyOutcomePoker(pm); won { t.Errorf("выбывший человек не должен был засчитаться победителем турнира") } } func TestExistingSaveSkipsWelcomeScreen(t *testing.T) { withTempConfigDir(t) pre := NewStoryModeState(StoryDifficultyEasy, time.Now()) pre.Onboarding = StoryOnboardingDone if err := SaveStoryModeState(pre); err != nil { t.Fatalf("неожиданная ошибка сохранения: %v", err) } m := NewAppModel() 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 != stateStoryMorning { t.Fatalf("ожидалось ежедневное приветствие (не одноразовое приветствие ИИ), получено %v", m.state) } if m.state == stateStoryWelcome { t.Fatalf("одноразовое приветствие ИИ не должно было показаться повторно") } } func TestStoryConditionsScreenListsAllGames(t *testing.T) { withTempConfigDir(t) m := NewAppModel() m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) m.story.Onboarding = StoryOnboardingDone // клавиша c недоступна во время знакомства m.state = stateStoryHub next, _ := m.Update(key("c")) m = next.(AppModel) if m.state != stateStoryConditions { t.Fatalf("клавиша c должна была открыть экран условий, получено %v", m.state) } view := m.View() for _, id := range storyHubOrder { resDef := storyResourceDefs[id] if !strings.Contains(view, T(resDef.NameKey)) { t.Errorf("экран условий должен был упоминать %q", T(resDef.NameKey)) } } next, _ = m.Update(key("esc")) m = next.(AppModel) if m.state != stateStoryHub { t.Errorf("любая клавиша на экране условий должна была вернуть в хаб, получено %v", m.state) } } func TestStoryEscapeOptionHiddenUntilConditionMet(t *testing.T) { withTempConfigDir(t) m := NewAppModel() m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) m.state = stateStoryHub if m.storyEscapeUnlocked() { t.Fatalf("условие побега не должно было выполниться при стартовых ресурсах") } view := m.View() if strings.Contains(view, T("story.hub.escape_option")) { t.Errorf("пункт побега не должен был показываться до выполнения условия") } } func TestStoryEscapeRequiresAllThreeConditions(t *testing.T) { withTempConfigDir(t) m := NewAppModel() m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) m.story.Onboarding = StoryOnboardingDone // стресс высокий, но запасов не хватает — не должно сработать m.story.Stress = 60 if m.storyEscapeUnlocked() { t.Errorf("побег не должен был разблокироваться без достаточных запасов мыла/верёвок, даже при высоком стрессе") } // запасов хватает, но стресс низкий — тоже не должно сработать m.story.Stress = 20 m.story.Resources[StoryResTicTacToe] = 50 m.story.Resources[StoryResSenet] = 50 if m.storyEscapeUnlocked() { t.Errorf("побег не должен был разблокироваться при низком стрессе, даже с достаточными запасами") } // все три условия разом — теперь должно сработать m.story.Stress = 60 if !m.storyEscapeUnlocked() { t.Errorf("побег должен был разблокироваться при выполнении всех трёх условий разом") } } func TestStoryEscapeStillLockedBelowStressThreshold(t *testing.T) { withTempConfigDir(t) m := NewAppModel() m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) m.story.Onboarding = StoryOnboardingDone m.story.Resources[StoryResTicTacToe] = 50 m.story.Resources[StoryResSenet] = 50 m.story.Stress = 59 // чуть ниже порога, запасов уже достаточно if m.storyEscapeUnlocked() { t.Errorf("побег не должен был разблокироваться при стрессе ниже 60%%, даже с достаточными запасами") } } func TestStoryEscapeOptionAppearsAndLeadsToAttemptScreen(t *testing.T) { withTempConfigDir(t) m := NewAppModel() m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) m.story.Onboarding = StoryOnboardingDone // пункт побега скрыт во время знакомства m.story.Resources[StoryResTicTacToe] = 50 m.story.Resources[StoryResSenet] = 50 m.story.Stress = 60 m.state = stateStoryHub m.storyHubCursor = len(storyHubOrder) // дополнительный пункт в конце списка if !m.storyEscapeUnlocked() { t.Fatalf("условие побега должно было выполниться при 50/50/60") } view := m.View() if !strings.Contains(view, T("story.hub.escape_option")) { t.Errorf("пункт побега должен был появиться в хабе") } next, _ := m.Update(key("enter")) m = next.(AppModel) if m.state != stateStoryEscapeAttempt { t.Fatalf("выбор пункта побега должен был вести на экран уговоров, получено %v", m.state) } } func TestStoryEscapeAttemptTakingPillResetsStressAndReturnsToHub(t *testing.T) { withTempConfigDir(t) m := AppModel{state: stateStoryEscapeAttempt, story: NewStoryModeState(StoryDifficultyEasy, time.Now())} m.story.Stress = 80 next, _ := m.Update(key("1")) m = next.(AppModel) if m.state != stateStoryHub { t.Errorf("выбор таблетки должен был вернуть в хаб, получено %v", m.state) } if m.story.Stress != 0 { t.Errorf("таблетка должна была сбросить стресс до 0, получено %d", m.story.Stress) } } func TestStoryEscapeAttemptEscReturnsToHubWithoutTakingPill(t *testing.T) { m := AppModel{state: stateStoryEscapeAttempt, story: NewStoryModeState(StoryDifficultyEasy, time.Now())} m.story.Stress = 80 next, _ := m.Update(key("esc")) m = next.(AppModel) if m.state != stateStoryHub { t.Errorf("esc должен был вернуть в хаб, получено %v", m.state) } if m.story.Stress != 80 { t.Errorf("esc не должен был трогать стресс, получено %d", m.story.Stress) } } func TestStoryEscapeAttemptLeavingGoesToEnding(t *testing.T) { m := AppModel{state: stateStoryEscapeAttempt} next, _ := m.Update(key("2")) m = next.(AppModel) if m.state != stateStoryEscapeEnding { t.Errorf("выбор 'сбежать' должен был вести на экран концовки, получено %v", m.state) } } func TestStoryEscapeEndingReturnsToModeSelect(t *testing.T) { m := AppModel{state: stateStoryEscapeEnding} next, _ := m.Update(key(" ")) m = next.(AppModel) if m.state != stateModeSelect { t.Errorf("экран концовки должен был вести в меню режимов, получено %v", m.state) } } func TestFreshStoryStartsOnboardingWithTicTacToeOnly(t *testing.T) { withTempConfigDir(t) m := NewAppModel() m.state = stateStoryDifficulty next, _ := m.Update(key("enter")) m = next.(AppModel) if m.state != stateStoryHub { t.Fatalf("ожидался прямой переход в хаб, получено %v", m.state) } if m.story.Onboarding != StoryOnboardingTicTacToeOnly { t.Fatalf("ожидался этап знакомства TicTacToeOnly, получено %v", m.story.Onboarding) } visible := m.storyVisibleGames() if len(visible) != 1 || visible[0] != StoryResTicTacToe { t.Fatalf("на этапе знакомства должны были быть видны только крестики-нолики, получено %v", visible) } view := m.View() if strings.Contains(view, "/10") || strings.Contains(view, "Сложность:") { t.Errorf("на этапе знакомства не должно быть чисел ресурсов или строки сложности, получено:\n%s", view) } if strings.Contains(view, T(storyGameDefs[StoryResCheckers].NameKey)) { t.Errorf("шашки не должны быть видны на самом первом этапе знакомства") } } func TestOnboardingShowsGameNameNotResourceName(t *testing.T) { withTempConfigDir(t) m := NewAppModel() m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) m.state = stateStoryHub view := m.View() if !strings.Contains(view, T("game.tictactoe.name")) { t.Errorf("на этапе знакомства должно было показываться чистое название игры %q, получено:\n%s", T("game.tictactoe.name"), view) } if strings.Contains(view, T(storyResourceDefs[StoryResTicTacToe].NameKey)) { t.Errorf("на этапе знакомства не должно было показываться название ресурса (%q) — оно ещё ничего не значит для игрока, получено:\n%s", T(storyResourceDefs[StoryResTicTacToe].NameKey), view) } } func TestThreeTicTacToeWinsUnlockCheckers(t *testing.T) { withTempConfigDir(t) m := NewAppModel() m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) m.state = stateStoryHub tm := NewTicTacToeModel(TicTacToeDifficultyEasy) tm.game.Phase = TicTacToePhaseOver tm.game.Result = &TicTacToeResult{Winner: tm.game.HumanMark} tm.backToMenu = true for i := 0; i < 3; i++ { resource := StoryResTicTacToe m.storyActiveResource = &resource m.activeGame = tm m.state = statePlaying next, _ := m.Update(key("q")) m = next.(AppModel) } if m.story.Onboarding != StoryOnboardingWithCheckers { t.Fatalf("после 3 побед ожидался переход на этап WithCheckers, получено %v", m.story.Onboarding) } if m.state != stateStoryHub { t.Fatalf("ожидался возврат в хаб (не приветствие) — знакомство ещё не завершено, получено %v", m.state) } visible := m.storyVisibleGames() if len(visible) != 2 { t.Fatalf("ожидалось 2 видимые игры (крестики-нолики + шашки), получено %v", visible) } } func TestOneCheckersWinCompletesOnboardingAndShowsWelcome(t *testing.T) { withTempConfigDir(t) m := NewAppModel() m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) m.story.Onboarding = StoryOnboardingWithCheckers m.story.Resources[StoryResTicTacToe] = storyStartingAmount + StoryOnboardingTicTacToeWinsNeeded m.state = stateStoryHub cm := NewCheckersModel(0, ColorChoiceA) cm.game.Result = &CheckersResult{Winner: cm.human} cm.backToMenu = true resource := StoryResCheckers m.storyActiveResource = &resource m.activeGame = cm m.state = statePlaying next, _ := m.Update(key("q")) m = next.(AppModel) if m.story.Onboarding != StoryOnboardingDone { t.Fatalf("после победы в шашках знакомство должно было завершиться, получено %v", m.story.Onboarding) } if m.state != stateStoryWelcome { t.Fatalf("ровно в момент завершения знакомства ожидалось приветствие, получено %v", m.state) } next, _ = m.Update(key(" ")) // показать текст полностью (пропустить печать) m = next.(AppModel) next, _ = m.Update(key(" ")) // продолжить в хаб m = next.(AppModel) if m.state != stateStoryHub { t.Fatalf("после приветствия ожидался переход в полный хаб, получено %v", m.state) } view := m.View() if !strings.Contains(view, "Сложность:") { t.Errorf("после завершения знакомства хаб должен был показывать обычный вид со строкой сложности, получено:\n%s", view) } visible := m.storyVisibleGames() if len(visible) != len(storyHubOrder) { t.Errorf("после завершения знакомства должны были стать видны все игры, получено %d из %d", len(visible), len(storyHubOrder)) } } func TestWelcomeScreenNeverShownAgainAfterOnboarding(t *testing.T) { withTempConfigDir(t) s := NewStoryModeState(StoryDifficultyEasy, time.Now()) s.Onboarding = StoryOnboardingDone if err := SaveStoryModeState(s); err != nil { t.Fatalf("неожиданная ошибка сохранения: %v", err) } m := NewAppModel() 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 != stateStoryMorning { t.Fatalf("при завершённом знакомстве и существующем сохранении ожидалось ежедневное приветствие, получено %v", m.state) } } func TestStoryRevealPartialTextPreservesParagraphs(t *testing.T) { text := "Раз два три.\n\nЧетыре пять шесть семь." // первые 3 слова — только первый абзац целиком, второй ещё не начат got := storyRevealPartialText(text, 3) if got != "Раз два три." { t.Errorf("ожидался только первый абзац целиком, получено %q", got) } // 3+2=5 слов — первый абзац целиком плюс первые 2 слова второго got = storyRevealPartialText(text, 5) if got != "Раз два три.\n\nЧетыре пять" { t.Errorf("ожидался первый абзац целиком плюс частичный второй, получено %q", got) } // все слова — весь текст целиком, без изменений got = storyRevealPartialText(text, storyTotalWords(text)) if got != text { t.Errorf("при показе всех слов текст должен был совпасть с оригиналом, получено %q, ожидалось %q", got, text) } } func TestStoryRevealPartialTextZeroWordsIsEmpty(t *testing.T) { if got := storyRevealPartialText("Раз два три", 0); got != "" { t.Errorf("при 0 слов ожидалась пустая строка, получено %q", got) } } func TestStoryWelcomeTicksRevealWordsGradually(t *testing.T) { withTempConfigDir(t) m := AppModel{state: stateStoryWelcome, storyWelcomeWordsShown: 0} total := storyTotalWords(T("story.welcome.text")) next, cmd := m.Update(storyWelcomeTickMsg{}) m = next.(AppModel) if m.storyWelcomeWordsShown != 1 { t.Fatalf("после одного тика ожидалось показанное 1 слово, получено %d", m.storyWelcomeWordsShown) } if cmd == nil { t.Errorf("должен был запланироваться следующий тик — текст ещё не показан целиком (всего слов: %d)", total) } } func TestStoryWelcomeTicksStopAtTotalWords(t *testing.T) { m := AppModel{state: stateStoryWelcome} total := storyTotalWords(T("story.welcome.text")) m.storyWelcomeWordsShown = total - 1 next, cmd := m.Update(storyWelcomeTickMsg{}) m = next.(AppModel) if m.storyWelcomeWordsShown != total { t.Fatalf("ожидалось, что счётчик достигнет total=%d, получено %d", total, m.storyWelcomeWordsShown) } if cmd != nil { t.Errorf("после показа всего текста дальнейший тик планироваться не должен") } } func TestStoryWelcomeKeyPressSkipsTypingFirst(t *testing.T) { m := AppModel{state: stateStoryWelcome, storyWelcomeWordsShown: 2} total := storyTotalWords(T("story.welcome.text")) next, _ := m.Update(key(" ")) m = next.(AppModel) if m.state != stateStoryWelcome { t.Fatalf("первое нажатие во время печати должно было остаться на экране приветствия, получено %v", m.state) } if m.storyWelcomeWordsShown != total { t.Errorf("первое нажатие должно было сразу показать весь текст, получено %d из %d слов", m.storyWelcomeWordsShown, total) } } func TestStoryWelcomeKeyPressContinuesWhenFullyShown(t *testing.T) { withTempConfigDir(t) total := storyTotalWords(T("story.welcome.text")) m := AppModel{state: stateStoryWelcome, storyWelcomeWordsShown: total, story: NewStoryModeState(StoryDifficultyEasy, time.Now())} next, _ := m.Update(key(" ")) m = next.(AppModel) if m.state != stateStoryHub { t.Errorf("нажатие после полного показа текста должно было продолжить в хаб, получено %v", m.state) } } func TestStoryWelcomeViewShowsTypingHintWhileIncomplete(t *testing.T) { m := AppModel{state: stateStoryWelcome, storyWelcomeWordsShown: 1} view := m.viewStoryWelcome() if !strings.Contains(view, T("story.welcome.hint.typing")) { t.Errorf("во время печати должна была показываться подсказка 'показать сразу', получено:\n%s", view) } } func TestStoryWelcomeViewShowsContinueHintWhenComplete(t *testing.T) { total := storyTotalWords(T("story.welcome.text")) m := AppModel{state: stateStoryWelcome, storyWelcomeWordsShown: total} view := m.viewStoryWelcome() if !strings.Contains(view, T("story.welcome.hint")) || strings.Contains(view, T("story.welcome.hint.typing")) { t.Errorf("после полного показа текста должна была показываться обычная подсказка 'продолжить', получено:\n%s", view) } } func TestStoryHubShowsVitalsWhenOnboardingDone(t *testing.T) { withTempConfigDir(t) m := NewAppModel() m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) m.story.Onboarding = StoryOnboardingDone m.story.Stress = 45 m.story.Poisoning = 10 m.story.ToxinLevel = 5 m.state = stateStoryHub view := m.View() if !strings.Contains(view, "45") || !strings.Contains(view, "10") || !strings.Contains(view, "5") { t.Errorf("хаб должен был показывать значения стресса/отравления/токсина, получено:\n%s", view) } } func TestStoryHubHidesVitalsDuringOnboarding(t *testing.T) { withTempConfigDir(t) m := NewAppModel() m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) m.state = stateStoryHub view := m.View() if strings.Contains(view, T("story.hub.difficulty_label")) { t.Errorf("на этапе знакомства показатели выживания не должны показываться") } } func TestStoryHubUKeyOpensUseItemScreen(t *testing.T) { withTempConfigDir(t) m := NewAppModel() m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) m.story.Onboarding = StoryOnboardingDone m.state = stateStoryHub next, _ := m.Update(key("u")) m = next.(AppModel) if m.state != stateStoryUseItem { t.Fatalf("клавиша u должна была открыть экран использования, получено %v", m.state) } } func TestStoryHubUKeyDisabledDuringOnboarding(t *testing.T) { withTempConfigDir(t) m := NewAppModel() m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) m.state = stateStoryHub next, _ := m.Update(key("u")) m = next.(AppModel) if m.state != stateStoryHub { t.Errorf("клавиша u не должна была работать на этапе знакомства, получено %v", m.state) } } func TestStoryUseItemSpendsResourceAndAppliesEffect(t *testing.T) { withTempConfigDir(t) m := NewAppModel() m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) m.story.Stress = 40 m.story.Resources[StoryResKlondike] = 5 m.state = stateStoryUseItem next, _ := m.Update(key("1")) // антидепрессант — первый пункт m = next.(AppModel) if m.story.Stress != 20 { t.Errorf("ожидался стресс 20 после траты антидепрессанта, получено %d", m.story.Stress) } if m.story.Resources[StoryResKlondike] != 4 { t.Errorf("ожидалось списание 1 антидепрессанта, получено %d", m.story.Resources[StoryResKlondike]) } if m.state != stateStoryUseItem { t.Errorf("после использования должны были остаться на этом же экране, получено %v", m.state) } } func TestStoryUseItemEscReturnsToHub(t *testing.T) { m := AppModel{state: stateStoryUseItem} next, _ := m.Update(key("esc")) m = next.(AppModel) if m.state != stateStoryHub { t.Errorf("esc на экране использования должен был вернуть в хаб, получено %v", m.state) } } func TestStoryGreetingForDaysMapping(t *testing.T) { cases := []struct { days int want storyGreetingKind }{ {0, storyGreetingPlayingAgain}, {1, storyGreetingWokeUp}, {2, storyGreetingWorried}, {5, storyGreetingWorried}, } for _, c := range cases { if got := storyGreetingForDays(c.days); got != c.want { t.Errorf("для %d дней ожидалось %v, получено %v", c.days, c.want, got) } } } func TestRandomStoryPostGameFlavorReturnsValidKey(t *testing.T) { valid := make(map[string]bool) for _, k := range storyPostGameFlavorKeys { valid[k] = true } for i := 0; i < 30; i++ { got := randomStoryPostGameFlavor() if !valid[got] { t.Fatalf("randomStoryPostGameFlavor вернул неизвестный ключ %q", got) } } } func TestPostGameFlavorPoolHasTwelveDistinctPhrases(t *testing.T) { if len(storyPostGameFlavorKeys) != 12 { t.Fatalf("ожидалось 12 фраз в пуле, получено %d", len(storyPostGameFlavorKeys)) } seen := make(map[string]bool) for _, k := range storyPostGameFlavorKeys { text := T(k) if text == "" || text == k { t.Errorf("ключ %q не имеет перевода", k) } if seen[text] { t.Errorf("повторяющийся текст фразы: %q", text) } seen[text] = true } } func TestPostGameFlavorShownAfterReturningFromGame(t *testing.T) { withTempConfigDir(t) m := NewAppModel() m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) m.story.Onboarding = StoryOnboardingDone tm := NewTicTacToeModel(TicTacToeDifficultyEasy) tm.game.Phase = TicTacToePhaseOver loser := TicTacToeX if tm.game.HumanMark == TicTacToeX { loser = TicTacToeO } tm.game.Result = &TicTacToeResult{Winner: loser} tm.backToMenu = true resource := StoryResTicTacToe m.storyActiveResource = &resource m.activeGame = tm m.state = statePlaying next, _ := m.Update(key("q")) m = next.(AppModel) if m.storyPostGameFlavor == "" { t.Fatalf("после возврата из партии ожидалась выбранная фраза-наблюдение") } view := m.View() if !strings.Contains(view, T(m.storyPostGameFlavor)) { t.Errorf("хаб должен был показывать выбранную фразу, получено:\n%s", view) } } func TestStoryHubNKeyShowsResetConfirmation(t *testing.T) { withTempConfigDir(t) m := NewAppModel() m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) m.story.Onboarding = StoryOnboardingDone m.state = stateStoryHub next, _ := m.Update(key("N")) m = next.(AppModel) if !m.storyConfirmingReset { t.Fatalf("клавиша N должна была включить режим подтверждения сброса") } view := m.View() if !strings.Contains(view, T("story.hub.reset_confirm")) { t.Errorf("ожидался текст подтверждения сброса, получено:\n%s", view) } } func TestStoryHubResetConfirmYDeletesSaveAndGoesToDifficulty(t *testing.T) { withTempConfigDir(t) m := NewAppModel() m.story = NewStoryModeState(StoryDifficultyHard, time.Now()) m.story.Resources[StoryResChess] = 50 if err := SaveStoryModeState(m.story); err != nil { t.Fatalf("неожиданная ошибка сохранения: %v", err) } m.state = stateStoryHub m.storyConfirmingReset = true next, _ := m.Update(key("y")) m = next.(AppModel) if m.state != stateStoryDifficulty { t.Fatalf("после подтверждения ожидался переход на выбор сложности, получено %v", m.state) } if m.storyConfirmingReset { t.Errorf("режим подтверждения должен был сброситься") } if _, err := LoadStoryModeState(); err != ErrStoryModeNoSave { t.Errorf("сохранение должно было быть удалено, получено err=%v", err) } } func TestStoryHubResetConfirmNCancelsAndKeepsProgress(t *testing.T) { withTempConfigDir(t) m := NewAppModel() m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) m.story.Resources[StoryResChess] = 77 m.state = stateStoryHub m.storyConfirmingReset = true next, _ := m.Update(key("n")) m = next.(AppModel) if m.storyConfirmingReset { t.Errorf("отмена должна была выключить режим подтверждения") } if m.state != stateStoryHub { t.Errorf("после отмены ожидался возврат в хаб, получено %v", m.state) } if m.story.Resources[StoryResChess] != 77 { t.Errorf("прогресс не должен был пострадать при отмене, получено %d", m.story.Resources[StoryResChess]) } } func TestStoryHubResetConfirmEscCancels(t *testing.T) { m := AppModel{state: stateStoryHub, storyConfirmingReset: true, story: NewStoryModeState(StoryDifficultyEasy, time.Now())} next, _ := m.Update(key("esc")) m = next.(AppModel) if m.storyConfirmingReset { t.Errorf("esc должен был отменить подтверждение сброса") } } func TestStoryHubResetAvailableDuringOnboarding(t *testing.T) { withTempConfigDir(t) m := NewAppModel() m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) // по умолчанию этап знакомства m.state = stateStoryHub next, _ := m.Update(key("N")) m = next.(AppModel) if !m.storyConfirmingReset { t.Errorf("сброс должен быть доступен и во время знакомства, получено storyConfirmingReset=%v", m.storyConfirmingReset) } } func TestAdvanceStoryEventQueueOrdering(t *testing.T) { m := AppModel{story: NewStoryModeState(StoryDifficultyEasy, time.Now())} m.story.PendingNeurotoxinAlert = true m.story.PendingDoorKnockAlert = true if got := m.advanceStoryEventQueue(); got != stateStoryNeurotoxinAlert { t.Errorf("при обоих ожидающих событиях сначала должен был идти выброс, получено %v", got) } m.story.PendingNeurotoxinAlert = false if got := m.advanceStoryEventQueue(); got != stateStoryDoorKnockAlert { t.Errorf("после выброса должен был идти стук, получено %v", got) } m.story.PendingDoorKnockAlert = false if got := m.advanceStoryEventQueue(); got != stateStoryHub { t.Errorf("без ожидающих событий должен был идти хаб, получено %v", got) } } func TestNeurotoxinAlertDismissAdvancesToKnockThenHub(t *testing.T) { withTempConfigDir(t) m := AppModel{state: stateStoryNeurotoxinAlert, story: NewStoryModeState(StoryDifficultyEasy, time.Now())} m.story.PendingNeurotoxinAlert = true m.story.PendingDoorKnockAlert = true m.story.ToxinLevel = 45 next, _ := m.Update(key(" ")) m = next.(AppModel) if m.story.PendingNeurotoxinAlert { t.Errorf("после показа флаг выброса должен был сброситься") } if m.state != stateStoryDoorKnockAlert { t.Fatalf("ожидался переход к экрану стука в дверь, получено %v", m.state) } next, _ = m.Update(key(" ")) m = next.(AppModel) if m.story.PendingDoorKnockAlert { t.Errorf("после показа флаг стука должен был сброситься") } if m.state != stateStoryHub { t.Errorf("после обоих уведомлений ожидался переход в хаб, получено %v", m.state) } } func TestNeurotoxinAlertViewShowsToxinLevel(t *testing.T) { m := AppModel{story: NewStoryModeState(StoryDifficultyEasy, time.Now())} m.story.ToxinLevel = 55 view := m.viewStoryNeurotoxinAlert() if !strings.Contains(view, "55") { t.Errorf("экран выброса должен был показывать текущий уровень токсина, получено:\n%s", view) } } func TestDoorKnockAlertDismissGoesToHubWhenNothingElsePending(t *testing.T) { withTempConfigDir(t) m := AppModel{state: stateStoryDoorKnockAlert, story: NewStoryModeState(StoryDifficultyEasy, time.Now())} m.story.PendingDoorKnockAlert = true next, _ := m.Update(key(" ")) m = next.(AppModel) if m.story.PendingDoorKnockAlert { t.Errorf("флаг стука должен был сброситься после показа") } if m.state != stateStoryHub { t.Errorf("ожидался переход в хаб, получено %v", m.state) } } // TestOnboardingCompletesInOneDayDespiteDailyGrantsLimit — регрессия: // раньше суточный лимит начислений (StoryDailyGrantsLimit = 3) // применялся и во время знакомства, а оно требует 4 начисления // подряд (3 победы в крестики-нолики + 1 в шашки) — если сыграть всё // это за один день, четвёртое начисление (победа в шашках) просто не // проходило, и знакомство зависало навсегда на этапе "+ Шашки". func TestOnboardingCompletesInOneDayDespiteDailyGrantsLimit(t *testing.T) { withTempConfigDir(t) m := NewAppModel() m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) m.state = stateStoryHub ttt := NewTicTacToeModel(TicTacToeDifficultyEasy) ttt.game.Phase = TicTacToePhaseOver ttt.game.Result = &TicTacToeResult{Winner: ttt.game.HumanMark} ttt.backToMenu = true for i := 0; i < 3; i++ { resource := StoryResTicTacToe m.storyActiveResource = &resource m.activeGame = ttt m.state = statePlaying next, _ := m.Update(key("q")) m = next.(AppModel) } if m.story.Onboarding != StoryOnboardingWithCheckers { t.Fatalf("после 3 побед в крестики-нолики ожидался переход на этап WithCheckers, получено %v", m.story.Onboarding) } if m.story.DailyGrantsUsed[StoryResTicTacToe] != StoryDailyGrantsLimit { t.Fatalf("три победы должны были исчерпать суточный лимит именно крестиков-ноликов (%d), получено %d", StoryDailyGrantsLimit, m.story.DailyGrantsUsed[StoryResTicTacToe]) } cm := NewCheckersModel(0, ColorChoiceA) cm.game.Result = &CheckersResult{Winner: cm.human} cm.backToMenu = true resource := StoryResCheckers m.storyActiveResource = &resource m.activeGame = cm m.state = statePlaying next, _ := m.Update(key("q")) m = next.(AppModel) if m.story.Onboarding != StoryOnboardingDone { t.Errorf("победа в шашках в тот же день должна была завершить знакомство несмотря на исчерпанный суточный лимит, получено %v", m.story.Onboarding) } } func TestCheatCodeArmsOnlyDuringActiveStoryGame(t *testing.T) { withTempConfigDir(t) m := NewAppModel() m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) m.state = stateStoryHub // не во время партии next, _ := m.Update(key(":")) m = next.(AppModel) if m.cheatArmed { t.Errorf("двоеточие вне активной партии не должно было вооружить чит-код") } } func TestCheatCodeTripleSevenForcesWin(t *testing.T) { withTempConfigDir(t) m := NewAppModel() m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) m.story.Onboarding = StoryOnboardingDone before := m.story.Resources[StoryResChess] resource := StoryResChess m.storyActiveResource = &resource m.activeGame = NewChessModel(0, ColorChoiceA) // партия и близко не завершена m.state = statePlaying next, _ := m.Update(key(":")) m = next.(AppModel) if !m.cheatArmed { t.Fatalf("двоеточие во время активной партии сторимода должно было вооружить чит-код") } for i := 0; i < 3; i++ { next, _ = m.Update(key("7")) m = next.(AppModel) } if m.cheatArmed { t.Errorf("после ввода 777 чит-код должен был сработать и разоружиться") } if m.state != stateStoryHub { t.Fatalf("после чит-кода ожидался возврат в хаб, получено %v", m.state) } if m.story.Resources[StoryResChess] != before+1 { t.Errorf("чит-код должен был засчитать победу в шахматах, получено %d (было %d)", m.story.Resources[StoryResChess], before) } if m.storyActiveResource != nil { t.Errorf("отметка активного ресурса должна была сброситься после чит-кода") } } func TestCheatCodeWrongDigitCancels(t *testing.T) { withTempConfigDir(t) m := NewAppModel() m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) before := m.story.Resources[StoryResChess] resource := StoryResChess m.storyActiveResource = &resource m.activeGame = NewChessModel(0, ColorChoiceA) m.state = statePlaying next, _ := m.Update(key(":")) m = next.(AppModel) next, _ = m.Update(key("7")) m = next.(AppModel) next, _ = m.Update(key("8")) // не та цифра — отменяет попытку m = next.(AppModel) if m.cheatArmed { t.Errorf("неверная цифра должна была отменить вооружённый чит-код") } if m.story.Resources[StoryResChess] != before { t.Errorf("отменённая попытка не должна была засчитать победу, получено %d (было %d)", m.story.Resources[StoryResChess], before) } } func TestCheatCodeOnboardingAlsoAdvances(t *testing.T) { withTempConfigDir(t) m := NewAppModel() m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) m.story.Onboarding = StoryOnboardingWithCheckers m.story.Resources[StoryResTicTacToe] = storyStartingAmount + StoryOnboardingTicTacToeWinsNeeded resource := StoryResCheckers m.storyActiveResource = &resource m.activeGame = NewCheckersModel(0, ColorChoiceA) m.state = statePlaying next, _ := m.Update(key(":")) m = next.(AppModel) for i := 0; i < 3; i++ { next, _ = m.Update(key("7")) m = next.(AppModel) } if m.story.Onboarding != StoryOnboardingDone { t.Errorf("чит-код тоже должен был продвинуть знакомство, получено %v", m.story.Onboarding) } if m.state != stateStoryWelcome { t.Errorf("завершение знакомства через чит-код тоже должно было показать приветствие, получено %v", m.state) } } func TestSurvivalEndingRequiresAllSevenSpecificResources(t *testing.T) { withTempConfigDir(t) m := NewAppModel() m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) m.story.Onboarding = StoryOnboardingDone // запираем 7 ЛЮБЫХ ресурсов, но не тех самых семи — не должно сработать m.story.Locked[StoryResNardy] = true m.story.Locked[StoryResBlackjack] = true m.story.Locked[StoryResUr] = true m.story.Locked[StoryResSenet] = true m.story.Locked[StoryResOneOhOne] = true m.story.Locked[StoryResThousand] = true m.story.Locked[StoryResTicTacToe] = true if m.storySurvivalEndingUnlocked() { t.Fatalf("секретная концовка не должна была разблокироваться от произвольных 7 ресурсов") } // теперь запираем именно нужные семь for _, id := range storySurvivalResources { m.story.Locked[id] = true } if !m.storySurvivalEndingUnlocked() { t.Errorf("секретная концовка должна была разблокироваться при запертых Тонк/Шашки/Шахматы/Сапёр/Го/Дурак/Косынка") } } func TestSurvivalOptionAppearsInHubAndLeadsToAttemptScreen(t *testing.T) { withTempConfigDir(t) m := NewAppModel() m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) m.story.Onboarding = StoryOnboardingDone for _, id := range storySurvivalResources { m.story.Locked[id] = true } m.state = stateStoryHub m.storyHubCursor = len(storyHubOrder) // единственный дополнительный пункт пока view := m.View() if !strings.Contains(view, T("story.hub.survival_option")) { t.Fatalf("пункт 'Выйти из бункера' должен был появиться в хабе") } next, _ := m.Update(key("enter")) m = next.(AppModel) if m.state != stateStorySurvivalAttempt { t.Fatalf("выбор пункта должен был вести на экран уговоров остаться, получено %v", m.state) } } func TestSurvivalAttemptStayingReturnsToHub(t *testing.T) { m := AppModel{state: stateStorySurvivalAttempt} next, _ := m.Update(key("1")) m = next.(AppModel) if m.state != stateStoryHub { t.Errorf("выбор 'остаться' должен был вернуть в хаб, получено %v", m.state) } } func TestSurvivalAttemptLeavingGoesToEnding(t *testing.T) { m := AppModel{state: stateStorySurvivalAttempt} next, _ := m.Update(key("2")) m = next.(AppModel) if m.state != stateStoryEscapeEnding { t.Errorf("выбор 'выйти' должен был вести на экран концовки, получено %v", m.state) } } func TestBothSecretEndingsCanAppearTogether(t *testing.T) { withTempConfigDir(t) m := NewAppModel() m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) m.story.Onboarding = StoryOnboardingDone for _, id := range storySurvivalResources { m.story.Locked[id] = true } m.story.Resources[StoryResTicTacToe] = 50 m.story.Resources[StoryResSenet] = 50 m.story.Stress = 60 m.state = stateStoryHub extras := m.storyExtraHubItems() if len(extras) != 2 { t.Fatalf("ожидалось 2 дополнительных пункта одновременно, получено %d", len(extras)) } if extras[0].State != stateStorySurvivalAttempt { t.Errorf("первым должен был идти пункт выживания, получено %v", extras[0].State) } if extras[1].State != stateStoryEscapeAttempt { t.Errorf("вторым должен был идти пункт побега от отчаяния, получено %v", extras[1].State) } // второй пункт (побег) должен быть доступен по курсору len(visible)+1 m.storyHubCursor = len(storyHubOrder) + 1 next, _ := m.Update(key("enter")) m = next.(AppModel) if m.state != stateStoryEscapeAttempt { t.Errorf("второй по счёту дополнительный пункт должен был вести на экран побега, получено %v", m.state) } } func TestAllFifteenLockedTriggersTrueEnding(t *testing.T) { withTempConfigDir(t) m := NewAppModel() m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) m.story.Onboarding = StoryOnboardingDone // запираем 14 из 15 заранее — победа в оставшейся должна замкнуть круг for i := 0; i < int(storyResourceCount); i++ { if StoryResourceID(i) != StoryResChess { m.story.Locked[i] = true } } m.story.Resources[StoryResChess] = StoryTargetChess - 1 tm := NewChessModel(0, ColorChoiceA) tm.game.Result = &ChessResult{Winner: tm.human} tm.backToMenu = true resource := StoryResChess m.storyActiveResource = &resource m.activeGame = tm m.state = statePlaying next, _ := m.Update(key("q")) m = next.(AppModel) if m.state != stateStoryTrueEnding { t.Fatalf("после запирания последнего, 15-го ресурса ожидалась истинная концовка, получено %v", m.state) } } func TestTrueEndingDeletesSaveAndReturnsToModeSelect(t *testing.T) { withTempConfigDir(t) s := NewStoryModeState(StoryDifficultyEasy, time.Now()) if err := SaveStoryModeState(s); err != nil { t.Fatalf("неожиданная ошибка сохранения: %v", err) } m := AppModel{state: stateStoryTrueEnding, story: s} next, _ := m.Update(key(" ")) m = next.(AppModel) if m.state != stateModeSelect { t.Errorf("после истинной концовки ожидался переход в меню режимов, получено %v", m.state) } if _, err := LoadStoryModeState(); err != ErrStoryModeNoSave { t.Errorf("сохранение должно было быть удалено после истинной концовки, получено err=%v", err) } } func TestEscapeEndingAlsoDeletesSave(t *testing.T) { withTempConfigDir(t) s := NewStoryModeState(StoryDifficultyEasy, time.Now()) if err := SaveStoryModeState(s); err != nil { t.Fatalf("неожиданная ошибка сохранения: %v", err) } m := AppModel{state: stateStoryEscapeEnding, story: s} next, _ := m.Update(key(" ")) m = next.(AppModel) if m.state != stateModeSelect { t.Errorf("ожидался переход в меню режимов, получено %v", m.state) } if _, err := LoadStoryModeState(); err != ErrStoryModeNoSave { t.Errorf("сохранение должно было быть удалено (общий экран для обеих концовок 'выйти'), получено err=%v", err) } } func TestModeSelectStartsFreshAfterAnyEnding(t *testing.T) { withTempConfigDir(t) // симулируем, что концовка уже случилась и сохранение удалено _ = DeleteStoryModeSave() m := NewAppModel() 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 != stateStoryDifficulty { t.Errorf("после любой концовки следующий заход должен был начинаться с выбора сложности заново, получено %v", m.state) } } func TestEnterStoryHubFromModeSelectRedirectsIfAlreadyComplete(t *testing.T) { withTempConfigDir(t) m := NewAppModel() m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) for i := range m.story.Locked { m.story.Locked[i] = true } m = m.enterStoryHubFromModeSelect() if m.state != stateStoryTrueEnding { t.Errorf("уже полностью собранное сохранение должно было сразу вести на истинную концовку, получено %v", m.state) } } // TestStoryHubHintWrapsInsteadOfOverflowing — регрессия: длинная // подсказка хаба (7 разных клавиш) раньше выводилась без переноса и // просто обрезалась терминалом на узком экране вместо переноса на // следующую строку. func TestStoryHubHintWrapsInsteadOfOverflowing(t *testing.T) { withTempConfigDir(t) m := AppModel{story: NewStoryModeState(StoryDifficultyEasy, time.Now()), termWidth: 60} m.story.Onboarding = StoryOnboardingDone m.state = stateStoryHub view := m.View() for _, line := range strings.Split(view, "\n") { plain := stripANSI(line) if w := len([]rune(plain)); w > 60 { t.Errorf("строка шириной %d превышает ширину терминала 60: %q", w, plain) } } }