1package app23import (4 "testing"56 tea "charm.land/bubbletea/v2"7 "github.com/FelineStateMachine/puzzletea/game"8 "github.com/FelineStateMachine/puzzletea/store"9)1011// --- Window resize ---1213func TestWindowResizeUpdatesDimensions(t *testing.T) {14 m := model{state: mainMenuView, width: 40, height: 10}15 m = m.initScreen(mainMenuView)1617 m = m.handleWindowSize(tea.WindowSizeMsg{Width: 80, Height: 24})1819 if m.width != 80 || m.height != 24 {20 t.Fatalf("dimensions = %dx%d, want 80x24", m.width, m.height)21 }22}2324func TestWindowResizeResizesActiveScreen(t *testing.T) {25 m := model{state: mainMenuView, width: 40, height: 10}26 m = m.initScreen(mainMenuView)27 before := m.screens[mainMenuView].(mainMenuScreen)28 if before.width != 40 || before.height != 10 {29 t.Fatalf("before = %dx%d, want 40x10", before.width, before.height)30 }3132 m = m.handleWindowSize(tea.WindowSizeMsg{Width: 80, Height: 24})3334 after := m.screens[mainMenuView].(mainMenuScreen)35 if after.width != 80 || after.height != 24 {36 t.Fatalf("after = %dx%d, want 80x24", after.width, after.height)37 }38}3940func TestWindowResizeSkipsScreenResizeInGameView(t *testing.T) {41 // In gameView, the game handles its own sizing — the screen cache42 // should not be touched by handleWindowSize.43 screen := safeScreen{}44 m := model{45 state: gameView,46 width: 40,47 height: 10,48 screens: map[viewState]screenModel{gameView: screen},49 }5051 m = m.handleWindowSize(tea.WindowSizeMsg{Width: 80, Height: 24})5253 if m.width != 80 || m.height != 24 {54 t.Fatalf("dimensions = %dx%d, want 80x24", m.width, m.height)55 }56 // Screen should be the exact same value (not replaced).57 if m.screens[gameView] != screen {58 t.Fatal("expected screen cache to be untouched in gameView")59 }60}6162// --- Screen cache survival ---6364func TestScreenCacheSurvivesRouteTransition(t *testing.T) {65 m := model{state: mainMenuView, width: 80, height: 24}66 m = m.initScreen(mainMenuView)67 if m.screens[mainMenuView] == nil {68 t.Fatal("expected mainMenuView screen to be cached")69 }7071 // Transition to play menu via action.72 next, _ := openPlayMenuAction{}.applyToModel(m)73 got := next7475 // Main menu screen should still be cached (not evicted).76 if got.screens[mainMenuView] == nil {77 t.Fatal("expected mainMenuView screen to survive route transition")78 }79 // Play menu screen should now be cached.80 if got.screens[playMenuView] == nil {81 t.Fatal("expected playMenuView screen to be cached after transition")82 }83}8485// --- Game view message delegation ---8687func TestGameViewNonKeyMessageDelegatesToGame(t *testing.T) {88 tracker := &escapeTrackingGame{}89 m := model{90 state: gameView,91 session: sessionState{game: tracker},92 }9394 // A non-key message in gameView should reach the game via updateActiveGame.95 m.Update(tea.MouseClickMsg{})9697 if tracker.updateCalls == 0 {98 t.Fatal("expected game.Update to be called for non-key messages in gameView")99 }100}101102// --- Completion persistence ---103104func TestPersistCompletionIfSolvedSavesOnce(t *testing.T) {105 s := openAppTestStore(t)106107 rec := &store.GameRecord{108 Name: "solved-test",109 GameType: "Nonogram",110 Mode: "Easy",111 InitialState: `{}`,112 SaveState: `{}`,113 Status: store.StatusInProgress,114 }115 if err := s.CreateGame(rec); err != nil {116 t.Fatal(err)117 }118119 m := model{120 state: gameView,121 store: s,122 session: sessionState{123 game: &solvedWeeklyGame{save: []byte(`{"done":true}`)},124 activeGameID: rec.ID,125 completionSaved: false,126 },127 }128129 newSessionController(&m).persistCompletionIfSolved()130131 if !m.session.completionSaved {132 t.Fatal("expected completionSaved to be true after persisting")133 }134135 // Verify the record was updated to completed.136 all, err := s.ListAllGames()137 if err != nil {138 t.Fatal(err)139 }140 var saved *store.GameRecord141 for i := range all {142 if all[i].Name == "solved-test" {143 saved = &all[i]144 break145 }146 }147 if saved == nil {148 t.Fatal("expected solved-test record to exist")149 }150 if saved.Status != store.StatusCompleted {151 t.Fatalf("status = %q, want %q", saved.Status, store.StatusCompleted)152 }153154 // Calling again should be a no-op (idempotent).155 callsBefore := 0156 _ = callsBefore157 newSessionController(&m).persistCompletionIfSolved()158 // No panic, no double-save — completionSaved remains true.159 if !m.session.completionSaved {160 t.Fatal("expected completionSaved to remain true")161 }162}163164func TestPersistCompletionDoesNotSaveUnsolvedGame(t *testing.T) {165 s := openAppTestStore(t)166167 rec := &store.GameRecord{168 Name: "unsolved-test",169 GameType: "Nonogram",170 Mode: "Easy",171 InitialState: `{}`,172 SaveState: `{}`,173 Status: store.StatusInProgress,174 }175 if err := s.CreateGame(rec); err != nil {176 t.Fatal(err)177 }178179 m := model{180 state: gameView,181 store: s,182 session: sessionState{183 game: &escapeTrackingGame{}, // IsSolved() returns false184 activeGameID: rec.ID,185 completionSaved: false,186 },187 }188189 newSessionController(&m).persistCompletionIfSolved()190191 if m.session.completionSaved {192 t.Fatal("expected completionSaved to remain false for unsolved game")193 }194}195196// --- Session lifecycle: clearActiveGame resets state ---197198func TestClearActiveGameResetsSession(t *testing.T) {199 m := model{200 state: gameView,201 session: sessionState{202 game: &escapeTrackingGame{},203 activeGameID: 42,204 completionSaved: true,205 returnState: weeklyView,206 },207 }208209 newSessionController(&m).clearActiveGame()210211 if m.session.activeGameID != 0 {212 t.Fatalf("activeGameID = %d, want 0", m.session.activeGameID)213 }214 if m.session.game != nil {215 t.Fatal("expected game to be nil")216 }217 if m.session.completionSaved {218 t.Fatal("expected completionSaved to be false")219 }220 if m.session.returnState != mainMenuView {221 t.Fatalf("returnState = %d, want %d", m.session.returnState, mainMenuView)222 }223}224225// --- Game key: enter on non-solved weekly does not advance ---226227func TestGameViewEnterOnUnsolvedGameDoesNotAdvance(t *testing.T) {228 m := model{229 state: gameView,230 session: sessionState{game: &escapeTrackingGame{}}, // IsSolved() = false231 }232233 next, _, handled := newSessionController(&m).handleGameKey(tea.KeyPressMsg{Code: tea.KeyEnter})234235 if handled {236 t.Fatal("expected enter on unsolved game to not be handled")237 }238 if next.state != gameView {239 t.Fatalf("state = %d, want %d (gameView)", next.state, gameView)240 }241}242243// --- Generating view swallows non-escape/quit keys ---244245func TestGeneratingViewSwallowsOtherKeys(t *testing.T) {246 m := model{247 state: generatingView,248 session: sessionState{generating: true},249 }250251 _, _, handled := newSessionController(&m).handleGeneratingKey(tea.KeyPressMsg{Code: 'x'})252253 if !handled {254 t.Fatal("expected all keys to be swallowed in generating view")255 }256}257258// --- Export running view swallows non-escape/quit keys ---259260func TestExportRunningViewSwallowsOtherKeys(t *testing.T) {261 m := model{262 state: exportRunningView,263 screens: map[viewState]screenModel{exportView: safeScreen{}},264 }265266 _, _, handled := m.handleExportRunningKey(tea.KeyPressMsg{Code: 'x'})267268 if !handled {269 t.Fatal("expected all keys to be swallowed in export running view")270 }271}272273// Ensure game import is used (for game.HelpToggleMsg etc. if tests expand).274var _ game.Gamer = (*escapeTrackingGame)(nil)