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

1590 lines
No EOL
63 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"strings"
"testing"
"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)
}
}
}