clone

fifteen puzzle games for the terminal, with daily puzzles, weekly ladders and xp, built on bubble tea

main
puzzletea / app / update_test.go
update_test.go 15.1K
1package app23import (4 "path/filepath"5 "testing"6 "time"78 "charm.land/bubbles/v2/key"9 "charm.land/bubbles/v2/list"10 "charm.land/bubbles/v2/textinput"11 tea "charm.land/bubbletea/v2"12 "github.com/FelineStateMachine/puzzletea/daily"13 "github.com/FelineStateMachine/puzzletea/game"14 "github.com/FelineStateMachine/puzzletea/games/lightsout"15 "github.com/FelineStateMachine/puzzletea/registry"16 sessionflow "github.com/FelineStateMachine/puzzletea/session"17 "github.com/FelineStateMachine/puzzletea/store"18 "github.com/FelineStateMachine/puzzletea/ui"19 "github.com/FelineStateMachine/puzzletea/weekly"20)2122type escapeTrackingGame struct {23 updateCalls int24 sawEscape bool25}2627type solvedWeeklyGame struct {28 save []byte29}3031func (g *escapeTrackingGame) GetDebugInfo() string {32 return ""33}3435func (g *escapeTrackingGame) GetFullHelp() [][]key.Binding {36 return nil37}3839func (g *escapeTrackingGame) GetSave() ([]byte, error) {40 return nil, nil41}4243func (g *escapeTrackingGame) IsSolved() bool {44 return false45}4647func (g *escapeTrackingGame) Reset() game.Gamer {48 return g49}5051func (g *escapeTrackingGame) SetTitle(string) game.Gamer {52 return g53}5455func (g *escapeTrackingGame) Init() tea.Cmd {56 return nil57}5859func (g *escapeTrackingGame) View() string {60 return ""61}6263func (g *escapeTrackingGame) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {64 g.updateCalls++65 keyMsg, ok := msg.(tea.KeyPressMsg)66 if ok && keyMsg.Code == tea.KeyEscape {67 g.sawEscape = true68 }69 return g, nil70}7172func (g *solvedWeeklyGame) GetDebugInfo() string {73 return ""74}7576func (g *solvedWeeklyGame) GetFullHelp() [][]key.Binding {77 return nil78}7980func (g *solvedWeeklyGame) GetSave() ([]byte, error) {81 return g.save, nil82}8384func (g *solvedWeeklyGame) IsSolved() bool {85 return true86}8788func (g *solvedWeeklyGame) Reset() game.Gamer {89 return g90}9192func (g *solvedWeeklyGame) SetTitle(string) game.Gamer {93 return g94}9596func (g *solvedWeeklyGame) Init() tea.Cmd {97 return nil98}99100func (g *solvedWeeklyGame) View() string {101 return ""102}103104func (g *solvedWeeklyGame) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {105 return g, nil106}107108func TestGameViewEscapeReturnsToMainMenu(t *testing.T) {109 tracker := &escapeTrackingGame{}110 m := model{111 state: gameView,112 session: sessionState{game: tracker},113 }114115 next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})116 got := next.(model)117118 if got.state != mainMenuView {119 t.Fatalf("expected to return to mainMenuView, got %d", got.state)120 }121 if tracker.updateCalls != 0 {122 t.Fatalf("expected game Update not to be called, got %d", tracker.updateCalls)123 }124 if tracker.sawEscape {125 t.Fatal("expected escape to be handled by the app before reaching the game")126 }127}128129func TestImportAndActivateRecordSuccessFlag(t *testing.T) {130 unknown := model{state: playMenuView}131 nextUnknown, ok := unknown.importAndActivateRecord(store.GameRecord{132 Name: "broken-save",133 GameType: "NoSuchGameType",134 })135 if ok {136 t.Fatal("expected unknown game type import to fail")137 }138 if nextUnknown.notice.message == "" {139 t.Fatal("expected failed import to surface a user-visible notice")140 }141142 loadedGame, err := lightsout.New(3, 3)143 if err != nil {144 t.Fatal(err)145 }146 save, err := loadedGame.GetSave()147 if err != nil {148 t.Fatal(err)149 }150151 rec := store.GameRecord{152 ID: 42,153 Name: "resume-me",154 GameType: "Lights Out",155 SaveState: string(save),156 Status: store.StatusCompleted,157 }158159 next, ok := (model{160 state: playMenuView,161 width: 80,162 height: 24,163 help: helpState{showFull: true},164 }).importAndActivateRecord(rec)165 if !ok {166 t.Fatal("expected known game type import to succeed")167 }168 if next.state != gameView {169 t.Fatalf("state = %d, want %d (gameView)", next.state, gameView)170 }171 if next.session.activeGameID != rec.ID {172 t.Fatalf("activeGameID = %d, want %d", next.session.activeGameID, rec.ID)173 }174 if !next.session.completionSaved {175 t.Fatal("expected completionSaved to be true for completed record")176 }177 if next.session.game == nil {178 t.Fatal("expected game to be activated")179 }180 if next.notice.message != "" {181 t.Fatalf("notice = %q, want empty after successful import", next.notice.message)182 }183}184185func TestHandleSeedConfirmDoesNotResumeStatusWhenImportFails(t *testing.T) {186 s := openAppTestStore(t)187 seed := "import-failure-seed"188 name := sessionflow.SeededName(seed)189 rec := &store.GameRecord{190 Name: name,191 GameType: "NoSuchGameType",192 Mode: "Any",193 InitialState: "{}",194 SaveState: "{}",195 Status: store.StatusAbandoned,196 RunKind: store.RunKindSeeded,197 SeedText: seed,198 }199 if err := s.CreateGame(rec); err != nil {200 t.Fatal(err)201 }202203 ti := textinput.New()204 ti.SetValue(seed)205 m := model{206 state: seedInputView,207 store: s,208 seed: seedState{input: ti},209 }210211 got, _ := m.handleSeedConfirm()212 if got.state != seedInputView {213 t.Fatalf("state = %d, want %d (seedInputView)", got.state, seedInputView)214 }215216 saved, err := s.GetDailyGame(name)217 if err != nil {218 t.Fatal(err)219 }220 if saved == nil {221 t.Fatal("expected seeded record to exist")222 }223 if saved.Status != store.StatusAbandoned {224 t.Fatalf("status = %q, want %q", saved.Status, store.StatusAbandoned)225 }226}227228func TestSeedInputSelectorCyclesAndPersistsDefault(t *testing.T) {229 options := buildSeedModeOptions(registry.Definitions())230 if len(options) < 2 {231 t.Fatal("expected at least one seeded game option in addition to Random")232 }233234 m := model{235 state: seedInputView,236 seed: seedState{237 modeOptions: options,238 focus: seedFocusMode,239 },240 }241 m = m.initScreen(seedInputView)242243 next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyRight})244 got := next.(model)245246 si, ok := got.screens[seedInputView].(seedInputScreen)247 if !ok {248 t.Fatal("expected seedInputView screen to be cached")249 }250 if si.seed.modeIndex != 1 {251 t.Fatalf("seedModeIndex = %d, want 1", si.seed.modeIndex)252 }253 if si.seed.lastModeKey != options[1].key {254 t.Fatalf("lastSeedModeKey = %q, want %q", si.seed.lastModeKey, options[1].key)255 }256257 reopenedModel, _ := (model{258 state: playMenuView,259 seed: seedState{lastModeKey: si.seed.lastModeKey},260 }).enterSeedInputView()261262 if reopenedModel.state != seedInputView {263 t.Fatalf("state = %d, want %d (seedInputView)", reopenedModel.state, seedInputView)264 }265 if reopenedModel.currentSeedMode().key != options[1].key {266 t.Fatalf("reopened seed mode key = %q, want %q", reopenedModel.currentSeedMode().key, options[1].key)267 }268}269270func TestHandleSeedConfirmUsesSelectedSpecificMode(t *testing.T) {271 s := openAppTestStore(t)272 options := buildSeedModeOptions(registry.Definitions())273 if len(options) < 2 {274 t.Fatal("expected at least one seeded game option in addition to Random")275 }276277 ti := textinput.New()278 ti.SetValue("specific-mode-seed")279280 m := model{281 state: seedInputView,282 store: s,283 seed: seedState{284 input: ti,285 modeOptions: options,286 modeIndex: 1,287 },288 }289290 got, _ := m.handleSeedConfirm()291292 if got.state != generatingView {293 t.Fatalf("state = %d, want %d (generatingView)", got.state, generatingView)294 }295 if got.session.spawn == nil {296 t.Fatal("expected spawn request to be populated")297 }298 if got.session.spawn.gameType != options[1].gameType {299 t.Fatalf("gameType = %q, want %q", got.session.spawn.gameType, options[1].gameType)300 }301 if got.session.spawn.modeTitle == "" {302 t.Fatal("expected a deterministic mode to be selected within the chosen game")303 }304 if got.session.spawn.name == sessionflow.SeededName("specific-mode-seed") {305 t.Fatal("specific seeded game should not reuse the random seeded name")306 }307}308309func TestHandleDailyPuzzleDoesNotResumeStatusWhenImportFails(t *testing.T) {310 s := openAppTestStore(t)311 now := time.Now()312 dates := []time.Time{313 now.AddDate(0, 0, -1),314 now,315 now.AddDate(0, 0, 1),316 }317318 seen := map[string]bool{}319 for _, d := range dates {320 name := daily.Name(d)321 if seen[name] {322 continue323 }324 seen[name] = true325 day := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, d.Location())326 rec := &store.GameRecord{327 Name: name,328 GameType: "NoSuchGameType",329 Mode: "Any",330 InitialState: "{}",331 SaveState: "{}",332 Status: store.StatusAbandoned,333 RunKind: store.RunKindDaily,334 RunDate: &day,335 }336 if err := s.CreateGame(rec); err != nil {337 t.Fatal(err)338 }339 }340341 m := model{342 state: playMenuView,343 store: s,344 }345346 got, _ := m.handleDailyPuzzle()347 if got.state != playMenuView {348 t.Fatalf("state = %d, want %d (playMenuView)", got.state, playMenuView)349 }350351 for name := range seen {352 saved, err := s.GetDailyGame(name)353 if err != nil {354 t.Fatal(err)355 }356 if saved == nil {357 t.Fatalf("expected daily record %q to exist", name)358 }359 if saved.Status != store.StatusAbandoned {360 t.Fatalf("record %q status = %q, want %q", name, saved.Status, store.StatusAbandoned)361 }362 }363}364365func TestGameSelectEscapeClearsAppliedFilterBeforeLeavingView(t *testing.T) {366 l := ui.InitCategoryList(GameCategories, "Select Category")367 l.SetFilterText("tak")368 l.SetFilterState(list.FilterApplied)369370 m := model{371 state: gameSelectView,372 nav: navigationState{373 gameSelectList: l,374 },375 }376 m = m.initScreen(gameSelectView)377378 next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})379 got := next.(model)380381 if got.state != gameSelectView {382 t.Fatalf("state = %d, want %d (gameSelectView)", got.state, gameSelectView)383 }384 gs, ok := got.screens[gameSelectView].(gameSelectScreen)385 if !ok {386 t.Fatal("expected gameSelectView screen to be cached")387 }388 if gs.list.FilterState() != list.Unfiltered {389 t.Fatalf("filter state = %s, want %s", gs.list.FilterState(), list.Unfiltered)390 }391}392393func TestBuildCurrentWeeklyRowsStartsAtOne(t *testing.T) {394 rows := buildCurrentWeeklyRows(2026, 1, nil)395 if len(rows) != 1 {396 t.Fatalf("len(rows) = %d, want 1", len(rows))397 }398 if rows[0].Index != 1 || !rows[0].Playable || rows[0].Status != store.StatusNew {399 t.Fatalf("row[0] = %+v, want index=1 playable status=new", rows[0])400 }401}402403func TestBuildCurrentWeeklyRowsUnlocksSequentially(t *testing.T) {404 games := []store.GameRecord{405 {Name: weekly.Name(2026, 1, 1), GameType: "Sudoku", Mode: "Easy", Status: store.StatusCompleted},406 {Name: weekly.Name(2026, 1, 2), GameType: "Sudoku", Mode: "Easy", Status: store.StatusInProgress},407 {Name: weekly.Name(2026, 1, 4), GameType: "Sudoku", Mode: "Easy", Status: store.StatusCompleted},408 }409410 rows := buildCurrentWeeklyRows(2026, 1, games)411 if len(rows) != 2 {412 t.Fatalf("len(rows) = %d, want 2", len(rows))413 }414 if rows[0].Index != 2 || !rows[0].Playable {415 t.Fatalf("row[0] = %+v, want playable #2", rows[0])416 }417 if rows[1].Index != 1 || !rows[1].ReadOnly {418 t.Fatalf("row[1] = %+v, want read-only #1", rows[1])419 }420}421422func TestBuildReviewWeeklyRowsOnlyIncludesCompleted(t *testing.T) {423 games := []store.GameRecord{424 {Name: weekly.Name(2026, 1, 1), GameType: "Sudoku", Mode: "Easy", Status: store.StatusCompleted},425 {Name: weekly.Name(2026, 1, 2), GameType: "Sudoku", Mode: "Easy", Status: store.StatusInProgress},426 {Name: weekly.Name(2026, 1, 3), GameType: "Sudoku", Mode: "Easy", Status: store.StatusCompleted},427 }428429 rows := buildReviewWeeklyRows(games)430 if len(rows) != 2 {431 t.Fatalf("len(rows) = %d, want 2", len(rows))432 }433 if rows[0].Index != 3 || rows[1].Index != 1 {434 t.Fatalf("rows = %+v, want descending completed rows", rows)435 }436}437438func TestImportAndActivateWeeklyReviewRecordSkipsSaveBinding(t *testing.T) {439 loadedGame, err := lightsout.New(3, 3)440 if err != nil {441 t.Fatal(err)442 }443 save, err := loadedGame.GetSave()444 if err != nil {445 t.Fatal(err)446 }447448 rec := store.GameRecord{449 ID: 42,450 Name: weekly.Name(2026, 1, 1),451 GameType: "Lights Out",452 SaveState: string(save),453 Status: store.StatusCompleted,454 }455456 next, ok := (model{457 state: weeklyView,458 width: 80,459 height: 24,460 }).importAndActivateRecordWithOptions(rec, gameOpenOptions{461 readOnly: true,462 returnState: weeklyView,463 })464 if !ok {465 t.Fatal("expected import to succeed")466 }467 if next.session.activeGameID != 0 {468 t.Fatalf("activeGameID = %d, want 0", next.session.activeGameID)469 }470 if next.session.returnState != weeklyView {471 t.Fatalf("returnState = %d, want %d", next.session.returnState, weeklyView)472 }473}474475func TestEnterOnSolvedLatestWeeklyCompletesAndQueuesNext(t *testing.T) {476 s := openAppTestStore(t)477 now := time.Now()478 year, weekNumber := now.ISOWeek()479 currentName := weekly.Name(year, weekNumber, 1)480481 rec := &store.GameRecord{482 Name: currentName,483 GameType: "Lights Out",484 Mode: "Easy",485 InitialState: "{}",486 SaveState: "{}",487 Status: store.StatusInProgress,488 RunKind: store.RunKindWeekly,489 WeekYear: year,490 WeekNumber: weekNumber,491 WeekIndex: 1,492 }493 if err := s.CreateGame(rec); err != nil {494 t.Fatal(err)495 }496497 m := model{498 state: gameView,499 store: s,500 spinner: newSpinner(),501 session: sessionState{502 game: &solvedWeeklyGame{save: []byte(`{"done":true}`)},503 activeGameID: rec.ID,504 returnState: weeklyView,505 weeklyAdvance: &weekly.Info{Year: year, Week: weekNumber, Index: 1},506 completionSaved: false,507 },508 weekly: weeklyState{509 cursor: weekly.StartOfWeek(year, weekNumber, time.Local),510 },511 }512513 next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})514 got := next.(model)515516 if got.state != generatingView {517 t.Fatalf("state = %d, want %d (generatingView)", got.state, generatingView)518 }519 if got.session.spawn == nil {520 t.Fatal("expected spawn request")521 }522 if got.session.spawn.name != weekly.Name(year, weekNumber, 2) {523 t.Fatalf("spawn name = %q, want %q", got.session.spawn.name, weekly.Name(year, weekNumber, 2))524 }525526 saved, err := s.GetWeeklyGame(year, weekNumber, 1)527 if err != nil {528 t.Fatal(err)529 }530 if saved == nil {531 t.Fatal("expected weekly record")532 }533 if saved.Status != store.StatusCompleted {534 t.Fatalf("status = %q, want %q", saved.Status, store.StatusCompleted)535 }536}537538func TestMoveWeeklyWeekDoesNotAdvancePastCurrentWeek(t *testing.T) {539 s := openAppTestStore(t)540 current := weekly.Current(time.Now())541 currentCursor := weekly.StartOfWeek(current.Year, current.Week, time.Local)542543 m := model{544 store: s,545 weekly: weeklyState{546 cursor: currentCursor,547 },548 }549550 got := m.moveWeeklyWeek(1)551 if !got.weekly.cursor.Equal(currentCursor) {552 t.Fatalf("weeklyCursor = %v, want %v", got.weekly.cursor, currentCursor)553 }554}555556func TestCurrentWeeklyMenuIndexTracksNextPlayableSlot(t *testing.T) {557 s := openAppTestStore(t)558 year, weekNumber := time.Now().ISOWeek()559560 for index := 1; index <= 2; index++ {561 rec := &store.GameRecord{562 Name: weekly.Name(year, weekNumber, index),563 GameType: "Nonogram",564 Mode: "Easy",565 InitialState: "{}",566 SaveState: "{}",567 Status: store.StatusCompleted,568 RunKind: store.RunKindWeekly,569 WeekYear: year,570 WeekNumber: weekNumber,571 WeekIndex: index,572 }573 if err := s.CreateGame(rec); err != nil {574 t.Fatal(err)575 }576 }577578 m := model{store: s}579 if got := m.currentWeeklyMenuIndex(); got != 3 {580 t.Fatalf("currentWeeklyMenuIndex() = %d, want 3", got)581 }582}583584func TestBuildPlayMenuItemsShowsCurrentWeeklyChallenge(t *testing.T) {585 now := time.Date(2026, time.March, 7, 12, 0, 0, 0, time.Local)586587 items := buildPlayMenuItems(now, 7)588 if got := items[3].Desc; got != "Week 10-2026 # 7" {589 t.Fatalf("weekly desc = %q, want %q", got, "Week 10-2026 # 7")590 }591}592593func openAppTestStore(t *testing.T) *store.Store {594 t.Helper()595 s, err := store.Open(filepath.Join(t.TempDir(), "test.db"))596 if err != nil {597 t.Fatal(err)598 }599 t.Cleanup(func() { _ = s.Close() })600 return s601}