clone

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

chore/regenerate-vhs-gifs
puzzletea / app / update_test.go
update_test.go 14.9K
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/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 next, _ := m.handleSeedConfirm()212 got := next.(model)213 if got.state != seedInputView {214 t.Fatalf("state = %d, want %d (seedInputView)", got.state, seedInputView)215 }216217 saved, err := s.GetDailyGame(name)218 if err != nil {219 t.Fatal(err)220 }221 if saved == nil {222 t.Fatal("expected seeded record to exist")223 }224 if saved.Status != store.StatusAbandoned {225 t.Fatalf("status = %q, want %q", saved.Status, store.StatusAbandoned)226 }227}228229func TestSeedInputSelectorCyclesAndPersistsDefault(t *testing.T) {230 options := buildSeedModeOptions(registry.Definitions())231 if len(options) < 2 {232 t.Fatal("expected at least one seeded game option in addition to Random")233 }234235 m := model{236 state: seedInputView,237 seed: seedState{238 modeOptions: options,239 focus: seedFocusMode,240 },241 }242243 next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyRight})244 got := next.(model)245246 if got.seed.modeIndex != 1 {247 t.Fatalf("seedModeIndex = %d, want 1", got.seed.modeIndex)248 }249 if got.seed.lastModeKey != options[1].key {250 t.Fatalf("lastSeedModeKey = %q, want %q", got.seed.lastModeKey, options[1].key)251 }252253 reopenedModel, _ := (model{254 state: playMenuView,255 seed: seedState{lastModeKey: got.seed.lastModeKey},256 }).enterSeedInputView()257258 if reopenedModel.state != seedInputView {259 t.Fatalf("state = %d, want %d (seedInputView)", reopenedModel.state, seedInputView)260 }261 if reopenedModel.currentSeedMode().key != options[1].key {262 t.Fatalf("reopened seed mode key = %q, want %q", reopenedModel.currentSeedMode().key, options[1].key)263 }264}265266func TestHandleSeedConfirmUsesSelectedSpecificMode(t *testing.T) {267 s := openAppTestStore(t)268 options := buildSeedModeOptions(registry.Definitions())269 if len(options) < 2 {270 t.Fatal("expected at least one seeded game option in addition to Random")271 }272273 ti := textinput.New()274 ti.SetValue("specific-mode-seed")275276 m := model{277 state: seedInputView,278 store: s,279 seed: seedState{280 input: ti,281 modeOptions: options,282 modeIndex: 1,283 },284 }285286 next, _ := m.handleSeedConfirm()287 got := next.(model)288289 if got.state != generatingView {290 t.Fatalf("state = %d, want %d (generatingView)", got.state, generatingView)291 }292 if got.session.spawn == nil {293 t.Fatal("expected spawn request to be populated")294 }295 if got.session.spawn.gameType != options[1].gameType {296 t.Fatalf("gameType = %q, want %q", got.session.spawn.gameType, options[1].gameType)297 }298 if got.session.spawn.modeTitle == "" {299 t.Fatal("expected a deterministic mode to be selected within the chosen game")300 }301 if got.session.spawn.name == sessionflow.SeededName("specific-mode-seed") {302 t.Fatal("specific seeded game should not reuse the random seeded name")303 }304}305306func TestHandleDailyPuzzleDoesNotResumeStatusWhenImportFails(t *testing.T) {307 s := openAppTestStore(t)308 now := time.Now()309 dates := []time.Time{310 now.AddDate(0, 0, -1),311 now,312 now.AddDate(0, 0, 1),313 }314315 seen := map[string]bool{}316 for _, d := range dates {317 name := daily.Name(d)318 if seen[name] {319 continue320 }321 seen[name] = true322 day := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, d.Location())323 rec := &store.GameRecord{324 Name: name,325 GameType: "NoSuchGameType",326 Mode: "Any",327 InitialState: "{}",328 SaveState: "{}",329 Status: store.StatusAbandoned,330 RunKind: store.RunKindDaily,331 RunDate: &day,332 }333 if err := s.CreateGame(rec); err != nil {334 t.Fatal(err)335 }336 }337338 m := model{339 state: playMenuView,340 store: s,341 }342343 next, _ := m.handleDailyPuzzle()344 got := next.(model)345 if got.state != playMenuView {346 t.Fatalf("state = %d, want %d (playMenuView)", got.state, playMenuView)347 }348349 for name := range seen {350 saved, err := s.GetDailyGame(name)351 if err != nil {352 t.Fatal(err)353 }354 if saved == nil {355 t.Fatalf("expected daily record %q to exist", name)356 }357 if saved.Status != store.StatusAbandoned {358 t.Fatalf("record %q status = %q, want %q", name, saved.Status, store.StatusAbandoned)359 }360 }361}362363func TestGameSelectEscapeClearsAppliedFilterBeforeLeavingView(t *testing.T) {364 l := ui.InitCategoryList(GameCategories, "Select Category")365 l.SetFilterText("tak")366 l.SetFilterState(list.FilterApplied)367368 m := model{369 state: gameSelectView,370 nav: navigationState{371 gameSelectList: l,372 },373 }374375 next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})376 got := next.(model)377378 if got.state != gameSelectView {379 t.Fatalf("state = %d, want %d (gameSelectView)", got.state, gameSelectView)380 }381 if got.nav.gameSelectList.FilterState() != list.Unfiltered {382 t.Fatalf("filter state = %s, want %s", got.nav.gameSelectList.FilterState(), list.Unfiltered)383 }384}385386func TestBuildCurrentWeeklyRowsStartsAtOne(t *testing.T) {387 rows := buildCurrentWeeklyRows(2026, 1, nil)388 if len(rows) != 1 {389 t.Fatalf("len(rows) = %d, want 1", len(rows))390 }391 if rows[0].Index != 1 || !rows[0].Playable || rows[0].Status != store.StatusNew {392 t.Fatalf("row[0] = %+v, want index=1 playable status=new", rows[0])393 }394}395396func TestBuildCurrentWeeklyRowsUnlocksSequentially(t *testing.T) {397 games := []store.GameRecord{398 {Name: weekly.Name(2026, 1, 1), GameType: "Sudoku", Mode: "Easy", Status: store.StatusCompleted},399 {Name: weekly.Name(2026, 1, 2), GameType: "Sudoku", Mode: "Easy", Status: store.StatusInProgress},400 {Name: weekly.Name(2026, 1, 4), GameType: "Sudoku", Mode: "Easy", Status: store.StatusCompleted},401 }402403 rows := buildCurrentWeeklyRows(2026, 1, games)404 if len(rows) != 2 {405 t.Fatalf("len(rows) = %d, want 2", len(rows))406 }407 if rows[0].Index != 2 || !rows[0].Playable {408 t.Fatalf("row[0] = %+v, want playable #2", rows[0])409 }410 if rows[1].Index != 1 || !rows[1].ReadOnly {411 t.Fatalf("row[1] = %+v, want read-only #1", rows[1])412 }413}414415func TestBuildReviewWeeklyRowsOnlyIncludesCompleted(t *testing.T) {416 games := []store.GameRecord{417 {Name: weekly.Name(2026, 1, 1), GameType: "Sudoku", Mode: "Easy", Status: store.StatusCompleted},418 {Name: weekly.Name(2026, 1, 2), GameType: "Sudoku", Mode: "Easy", Status: store.StatusInProgress},419 {Name: weekly.Name(2026, 1, 3), GameType: "Sudoku", Mode: "Easy", Status: store.StatusCompleted},420 }421422 rows := buildReviewWeeklyRows(games)423 if len(rows) != 2 {424 t.Fatalf("len(rows) = %d, want 2", len(rows))425 }426 if rows[0].Index != 3 || rows[1].Index != 1 {427 t.Fatalf("rows = %+v, want descending completed rows", rows)428 }429}430431func TestImportAndActivateWeeklyReviewRecordSkipsSaveBinding(t *testing.T) {432 loadedGame, err := lightsout.New(3, 3)433 if err != nil {434 t.Fatal(err)435 }436 save, err := loadedGame.GetSave()437 if err != nil {438 t.Fatal(err)439 }440441 rec := store.GameRecord{442 ID: 42,443 Name: weekly.Name(2026, 1, 1),444 GameType: "Lights Out",445 SaveState: string(save),446 Status: store.StatusCompleted,447 }448449 next, ok := (model{450 state: weeklyView,451 width: 80,452 height: 24,453 }).importAndActivateRecordWithOptions(rec, gameOpenOptions{454 readOnly: true,455 returnState: weeklyView,456 })457 if !ok {458 t.Fatal("expected import to succeed")459 }460 if next.session.activeGameID != 0 {461 t.Fatalf("activeGameID = %d, want 0", next.session.activeGameID)462 }463 if next.session.returnState != weeklyView {464 t.Fatalf("returnState = %d, want %d", next.session.returnState, weeklyView)465 }466}467468func TestEnterOnSolvedLatestWeeklyCompletesAndQueuesNext(t *testing.T) {469 s := openAppTestStore(t)470 now := time.Now()471 year, weekNumber := now.ISOWeek()472 currentName := weekly.Name(year, weekNumber, 1)473474 rec := &store.GameRecord{475 Name: currentName,476 GameType: "Lights Out",477 Mode: "Easy",478 InitialState: "{}",479 SaveState: "{}",480 Status: store.StatusInProgress,481 RunKind: store.RunKindWeekly,482 WeekYear: year,483 WeekNumber: weekNumber,484 WeekIndex: 1,485 }486 if err := s.CreateGame(rec); err != nil {487 t.Fatal(err)488 }489490 m := model{491 state: gameView,492 store: s,493 spinner: newSpinner(),494 session: sessionState{495 game: &solvedWeeklyGame{save: []byte(`{"done":true}`)},496 activeGameID: rec.ID,497 returnState: weeklyView,498 weeklyAdvance: &weekly.Info{Year: year, Week: weekNumber, Index: 1},499 completionSaved: false,500 },501 weekly: weeklyState{502 cursor: weekly.StartOfWeek(year, weekNumber, time.Local),503 },504 }505506 next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})507 got := next.(model)508509 if got.state != generatingView {510 t.Fatalf("state = %d, want %d (generatingView)", got.state, generatingView)511 }512 if got.session.spawn == nil {513 t.Fatal("expected spawn request")514 }515 if got.session.spawn.name != weekly.Name(year, weekNumber, 2) {516 t.Fatalf("spawn name = %q, want %q", got.session.spawn.name, weekly.Name(year, weekNumber, 2))517 }518519 saved, err := s.GetWeeklyGame(year, weekNumber, 1)520 if err != nil {521 t.Fatal(err)522 }523 if saved == nil {524 t.Fatal("expected weekly record")525 }526 if saved.Status != store.StatusCompleted {527 t.Fatalf("status = %q, want %q", saved.Status, store.StatusCompleted)528 }529}530531func TestMoveWeeklyWeekDoesNotAdvancePastCurrentWeek(t *testing.T) {532 s := openAppTestStore(t)533 current := weekly.Current(time.Now())534 currentCursor := weekly.StartOfWeek(current.Year, current.Week, time.Local)535536 m := model{537 store: s,538 weekly: weeklyState{539 cursor: currentCursor,540 },541 }542543 got := m.moveWeeklyWeek(1)544 if !got.weekly.cursor.Equal(currentCursor) {545 t.Fatalf("weeklyCursor = %v, want %v", got.weekly.cursor, currentCursor)546 }547}548549func TestCurrentWeeklyMenuIndexTracksNextPlayableSlot(t *testing.T) {550 s := openAppTestStore(t)551 year, weekNumber := time.Now().ISOWeek()552553 for index := 1; index <= 2; index++ {554 rec := &store.GameRecord{555 Name: weekly.Name(year, weekNumber, index),556 GameType: "Nonogram",557 Mode: "Easy",558 InitialState: "{}",559 SaveState: "{}",560 Status: store.StatusCompleted,561 RunKind: store.RunKindWeekly,562 WeekYear: year,563 WeekNumber: weekNumber,564 WeekIndex: index,565 }566 if err := s.CreateGame(rec); err != nil {567 t.Fatal(err)568 }569 }570571 m := model{store: s}572 if got := m.currentWeeklyMenuIndex(); got != 3 {573 t.Fatalf("currentWeeklyMenuIndex() = %d, want 3", got)574 }575}576577func TestBuildPlayMenuItemsShowsCurrentWeeklyChallenge(t *testing.T) {578 now := time.Date(2026, time.March, 7, 12, 0, 0, 0, time.Local)579580 items := buildPlayMenuItems(now, 7)581 if got := items[3].Desc; got != "Week 10-2026 # 7" {582 t.Fatalf("weekly desc = %q, want %q", got, "Week 10-2026 # 7")583 }584}585586func openAppTestStore(t *testing.T) *store.Store {587 t.Helper()588 s, err := store.Open(filepath.Join(t.TempDir(), "test.db"))589 if err != nil {590 t.Fatal(err)591 }592 t.Cleanup(func() { _ = s.Close() })593 return s594}