1package app23import (4 "testing"5 "time"67 tea "charm.land/bubbletea/v2"8 "github.com/FelineStateMachine/puzzletea/store"9 "github.com/FelineStateMachine/puzzletea/weekly"10)1112// ---------------------------------------------------------------------------13// Normal screen global keys14// ---------------------------------------------------------------------------1516func TestNormalScreenQuitReturnsQuitCmd(t *testing.T) {17 m := model{state: mainMenuView}1819 next, cmd := m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl})20 got := next.(model)2122 if got.state != mainMenuView {23 t.Fatalf("state = %d, want %d (mainMenuView)", got.state, mainMenuView)24 }25 if cmd == nil {26 t.Fatal("expected Quit command, got nil")27 }28 if _, isQuit := cmd().(tea.QuitMsg); !isQuit {29 t.Fatal("expected tea.Quit, got different command")30 }31}3233func TestNormalScreenDebugToggles(t *testing.T) {34 m := model{state: playMenuView}3536 next, cmd := m.Update(tea.KeyPressMsg{Code: 'e', Mod: tea.ModCtrl})37 got := next.(model)3839 if !got.debug.enabled {40 t.Fatal("expected debug to be enabled")41 }42 if cmd != nil {43 t.Fatal("expected nil cmd after debug toggle")44 }4546 next2, _ := got.Update(tea.KeyPressMsg{Code: 'e', Mod: tea.ModCtrl})47 got2 := next2.(model)48 if got2.debug.enabled {49 t.Fatal("expected debug to be disabled after second toggle")50 }51}5253func TestNormalScreenFullHelpToggles(t *testing.T) {54 m := model{state: optionsMenuView}5556 next, cmd := m.Update(tea.KeyPressMsg{Code: 'h', Mod: tea.ModCtrl})57 got := next.(model)5859 if !got.help.showFull {60 t.Fatal("expected full help to be enabled")61 }62 if cmd != nil {63 t.Fatal("expected nil cmd after fullHelp toggle")64 }6566 next2, _ := got.Update(tea.KeyPressMsg{Code: 'h', Mod: tea.ModCtrl})67 got2 := next2.(model)68 if got2.help.showFull {69 t.Fatal("expected full help to be disabled after second toggle")70 }71}7273// ---------------------------------------------------------------------------74// Pending generation global keys75// ---------------------------------------------------------------------------7677func TestGeneratingScreenQuitReturnsQuitCmd(t *testing.T) {78 m := model{79 state: generatingView,80 session: sessionState{generating: true},81 }8283 next, cmd := m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl})84 got := next.(model)8586 if got.state != generatingView {87 t.Fatalf("state = %d, want %d (generatingView)", got.state, generatingView)88 }89 if _, isQuit := cmd().(tea.QuitMsg); !isQuit {90 t.Fatal("expected tea.Quit in generating view")91 }92}9394// ---------------------------------------------------------------------------95// Pending export global keys96// ---------------------------------------------------------------------------9798func TestExportRunningEscapeReturnsToExportView(t *testing.T) {99 // Use handleGlobalKey directly to focus on the key dispatch, not screen100 // resize. A safeScreen avoids nil-pointer panics from an uninitialized101 // exportScreen viewport during resizeActiveScreen.102 m := model{103 state: exportRunningView,104 screens: map[viewState]screenModel{105 exportView: safeScreen{},106 },107 }108109 next, cmd, handled := m.handleGlobalKey(tea.KeyPressMsg{Code: tea.KeyEscape})110 if !handled {111 t.Fatal("expected handleGlobalKey to handle escape in export running view")112 }113 if next.state != exportView {114 t.Fatalf("state = %d, want %d (exportView)", next.state, exportView)115 }116 if cmd != nil {117 t.Fatalf("expected nil cmd, got %v", cmd)118 }119}120121func TestExportRunningQuitReturnsQuitCmd(t *testing.T) {122 m := model{123 state: exportRunningView,124 screens: map[viewState]screenModel{125 exportView: exportScreen{},126 },127 }128129 next, cmd := m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl})130 got := next.(model)131132 if got.state != exportRunningView {133 t.Fatalf("state = %d, want %d (exportRunningView)", got.state, exportRunningView)134 }135 if _, isQuit := cmd().(tea.QuitMsg); !isQuit {136 t.Fatal("expected tea.Quit in export running view")137 }138}139140// ---------------------------------------------------------------------------141// Active game global keys (quit → save + quit)142// ---------------------------------------------------------------------------143144func TestGameViewQuitSavesAndReturnsQuit(t *testing.T) {145 s := openAppTestStore(t)146147 rec := &store.GameRecord{148 Name: "quit-test",149 GameType: "Nonogram",150 Mode: "Easy",151 InitialState: `{}`,152 SaveState: `{}`,153 Status: store.StatusInProgress,154 }155 if err := s.CreateGame(rec); err != nil {156 t.Fatal(err)157 }158159 m := model{160 state: gameView,161 store: s,162 session: sessionState{163 game: &escapeTrackingGame{},164 activeGameID: rec.ID,165 returnState: mainMenuView,166 },167 }168169 next, cmd := m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl})170 _ = next.(model)171172 if _, isQuit := cmd().(tea.QuitMsg); !isQuit {173 t.Fatal("expected tea.Quit when quitting from game view")174 }175176 // The game should have been saved as abandoned.177 // GetGameByName excludes abandoned, so use ListAllGames.178 all, err := s.ListAllGames()179 if err != nil {180 t.Fatal(err)181 }182 var saved *store.GameRecord183 for i := range all {184 if all[i].Name == "quit-test" {185 saved = &all[i]186 break187 }188 }189 if saved == nil {190 t.Fatal("expected quit-test record to exist")191 }192 if saved.Status != store.StatusAbandoned {193 t.Fatalf("status = %q, want %q", saved.Status, store.StatusAbandoned)194 }195}196197// ---------------------------------------------------------------------------198// Active game: escape → save + return199// ---------------------------------------------------------------------------200201func TestGameViewEscapeSavesAndReturnsToReturnState(t *testing.T) {202 s := openAppTestStore(t)203204 rec := &store.GameRecord{205 Name: "escape-test",206 GameType: "Nonogram",207 Mode: "Easy",208 InitialState: `{}`,209 SaveState: `{}`,210 Status: store.StatusInProgress,211 }212 if err := s.CreateGame(rec); err != nil {213 t.Fatal(err)214 }215216 m := model{217 state: gameView,218 store: s,219 session: sessionState{220 game: &escapeTrackingGame{},221 activeGameID: rec.ID,222 returnState: playMenuView,223 },224 }225226 next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})227 got := next.(model)228229 if got.state != playMenuView {230 t.Fatalf("state = %d, want %d (playMenuView)", got.state, playMenuView)231 }232233 saved, err := s.GetGameByName("escape-test")234 if err != nil {235 t.Fatal(err)236 }237 if saved == nil {238 t.Fatal("expected escape-test record to exist")239 }240 if saved.Status != store.StatusInProgress {241 t.Fatalf("status = %q, want %q", saved.Status, store.StatusInProgress)242 }243}244245// ---------------------------------------------------------------------------246// Active game: debug toggle247// ---------------------------------------------------------------------------248249func TestGameViewDebugToggles(t *testing.T) {250 m := model{251 state: gameView,252 session: sessionState{game: &escapeTrackingGame{}},253 }254255 next, _ := m.Update(tea.KeyPressMsg{Code: 'e', Mod: tea.ModCtrl})256 got := next.(model)257258 if !got.debug.enabled {259 t.Fatal("expected debug to be enabled")260 }261262 next2, _ := got.Update(tea.KeyPressMsg{Code: 'e', Mod: tea.ModCtrl})263 got2 := next2.(model)264 if got2.debug.enabled {265 t.Fatal("expected debug to be disabled")266 }267}268269// ---------------------------------------------------------------------------270// Active game: fullHelp toggle propagates to game271// ---------------------------------------------------------------------------272273func TestGameViewFullHelpTogglesAndPropagates(t *testing.T) {274 tracker := &escapeTrackingGame{}275 m := model{276 state: gameView,277 session: sessionState{game: tracker},278 }279280 next, _ := m.Update(tea.KeyPressMsg{Code: 'h', Mod: tea.ModCtrl})281 got := next.(model)282283 if !got.help.showFull {284 t.Fatal("expected full help to be enabled")285 }286 if tracker.updateCalls == 0 {287 t.Fatal("expected game.Update to be called for HelpToggleMsg propagation")288 }289290 next2, _ := got.Update(tea.KeyPressMsg{Code: 'h', Mod: tea.ModCtrl})291 got2 := next2.(model)292 if got2.help.showFull {293 t.Fatal("expected full help to be disabled after second toggle")294 }295}296297// ---------------------------------------------------------------------------298// Active game: reset calls game.Reset299// ---------------------------------------------------------------------------300301func TestGameViewResetCallsGameReset(t *testing.T) {302 tracker := &escapeTrackingGame{}303 m := model{304 state: gameView,305 session: sessionState{game: tracker},306 }307308 gotAfter, _ := m.Update(tea.KeyPressMsg{Code: 'r', Mod: tea.ModCtrl})309 got := gotAfter.(model)310311 if got.session.game == nil {312 t.Fatal("expected game to still be set after reset")313 }314}315316// ---------------------------------------------------------------------------317// Active game: enter on solved weekly advances318// ---------------------------------------------------------------------------319320func TestGameViewEnterOnSolvedWeeklyAdvances(t *testing.T) {321 s := openAppTestStore(t)322323 now := time.Now()324 info := weekly.Current(now)325 year, weekNumber := info.Year, info.Week326 currentName := weekly.Name(year, weekNumber, 1)327328 rec := &store.GameRecord{329 Name: currentName,330 GameType: "Nonogram",331 Mode: "Easy",332 InitialState: `{}`,333 SaveState: `{}`,334 Status: store.StatusInProgress,335 RunKind: store.RunKindWeekly,336 WeekYear: year,337 WeekNumber: weekNumber,338 WeekIndex: 1,339 }340 if err := s.CreateGame(rec); err != nil {341 t.Fatal(err)342 }343344 m := model{345 state: gameView,346 store: s,347 spinner: newSpinner(),348 session: sessionState{349 game: &solvedWeeklyGame{save: []byte(`{"done":true}`)},350 activeGameID: rec.ID,351 returnState: weeklyView,352 weeklyAdvance: &info,353 completionSaved: false,354 },355 weekly: weeklyState{356 cursor: weekly.StartOfWeek(year, weekNumber, time.Local),357 },358 }359360 next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})361 got := next.(model)362363 if got.state != generatingView {364 t.Fatalf("state = %d, want %d (generatingView)", got.state, generatingView)365 }366367 // Verify the first weekly was marked as completed368 saved, err := s.GetWeeklyGame(year, weekNumber, 1)369 if err != nil {370 t.Fatal(err)371 }372 if saved == nil {373 t.Fatal("expected weekly record")374 }375 if saved.Status != store.StatusCompleted {376 t.Fatalf("status = %q, want %q", saved.Status, store.StatusCompleted)377 }378}379380// ---------------------------------------------------------------------------381// Non-key messages pass through handleGlobalKey unchanged382// ---------------------------------------------------------------------------383384func TestHandleGlobalKeyIgnoresNonKeyMessages(t *testing.T) {385 m := model{state: mainMenuView}386387 _, cmd, handled := m.handleGlobalKey(tea.WindowSizeMsg{Width: 100, Height: 40})388 if handled {389 t.Fatal("expected non-key message to not be handled by handleGlobalKey")390 }391 if cmd != nil {392 t.Fatal("expected nil cmd for non-key message")393 }394}