clone

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

feat/app-decomposition
puzzletea / store / store_test.go
store_test.go 17.9K
1package store23import (4 "database/sql"5 "path/filepath"6 "testing"7 "time"89 "github.com/FelineStateMachine/puzzletea/weekly"10)1112func openTestStore(t *testing.T) *Store {13 t.Helper()14 s, err := Open(filepath.Join(t.TempDir(), "test.db"))15 if err != nil {16 t.Fatal(err)17 }18 t.Cleanup(func() { s.Close() })19 return s20}2122func newTestRecord(name string) *GameRecord {23 return &GameRecord{24 Name: name,25 GameType: "Sudoku",26 Mode: "Easy",27 InitialState: `{"grid":"..."}`,28 SaveState: `{"grid":"..."}`,29 Status: StatusNew,30 RunKind: RunKindNormal,31 }32}3334func newDailyTestRecord(name string, date time.Time) *GameRecord {35 rec := newTestRecord(name)36 day := dayOnly(date)37 rec.RunKind = RunKindDaily38 rec.RunDate = &day39 return rec40}4142func newWeeklyTestRecord(name string, year, weekNumber, index int) *GameRecord {43 rec := newTestRecord(name)44 rec.RunKind = RunKindWeekly45 rec.WeekYear = year46 rec.WeekNumber = weekNumber47 rec.WeekIndex = index48 return rec49}5051func TestOpen(t *testing.T) {52 t.Run("creates directory and database", func(t *testing.T) {53 dir := t.TempDir()54 dbPath := filepath.Join(dir, "sub", "nested", "test.db")55 s, err := Open(dbPath)56 if err != nil {57 t.Fatal(err)58 }59 defer s.Close()60 })6162 t.Run("opens existing database", func(t *testing.T) {63 dir := t.TempDir()64 dbPath := filepath.Join(dir, "test.db")6566 s1, err := Open(dbPath)67 if err != nil {68 t.Fatal(err)69 }70 rec := newTestRecord("alpha")71 if err := s1.CreateGame(rec); err != nil {72 t.Fatal(err)73 }74 s1.Close()7576 s2, err := Open(dbPath)77 if err != nil {78 t.Fatal(err)79 }80 defer s2.Close()8182 got, err := s2.GetGameByName("alpha")83 if err != nil {84 t.Fatal(err)85 }86 if got == nil {87 t.Fatal("expected record to persist across reopen")88 }89 })9091 t.Run("records current schema version", func(t *testing.T) {92 s := openTestStore(t)9394 version, err := schemaVersion(s.db)95 if err != nil {96 t.Fatal(err)97 }98 if got, want := version, currentSchemaVersion; got != want {99 t.Fatalf("schema version = %d, want %d", got, want)100 }101 })102103 t.Run("upgrades databases missing run metadata and backfills it", func(t *testing.T) {104 dbPath := filepath.Join(t.TempDir(), "legacy.db")105106 raw, err := sql.Open("sqlite", dbPath)107 if err != nil {108 t.Fatal(err)109 }110 t.Cleanup(func() { raw.Close() })111112 _, err = raw.Exec(`113CREATE TABLE games (114 id INTEGER PRIMARY KEY AUTOINCREMENT,115 name TEXT NOT NULL UNIQUE,116 game_type TEXT NOT NULL,117 mode TEXT NOT NULL,118 initial_state TEXT NOT NULL,119 save_state TEXT,120 status TEXT NOT NULL DEFAULT 'new'121 CHECK(status IN ('new','in_progress','completed','abandoned')),122 created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,123 updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,124 completed_at DATETIME125);`)126 if err != nil {127 t.Fatal(err)128 }129130 _, err = raw.Exec(`131INSERT INTO games (name, game_type, mode, initial_state, save_state, status)132VALUES (?, ?, ?, ?, ?, ?)`,133 "Daily Feb 16 26 - amber-fox",134 "Sudoku",135 "Easy",136 `{"grid":"initial"}`,137 `{"grid":"save"}`,138 string(StatusNew),139 )140 if err != nil {141 t.Fatal(err)142 }143 if err := raw.Close(); err != nil {144 t.Fatal(err)145 }146147 s, err := Open(dbPath)148 if err != nil {149 t.Fatal(err)150 }151 defer s.Close()152153 version, err := schemaVersion(s.db)154 if err != nil {155 t.Fatal(err)156 }157 if got, want := version, currentSchemaVersion; got != want {158 t.Fatalf("schema version = %d, want %d", got, want)159 }160161 rec, err := s.GetGameByName("Daily Feb 16 26 - amber-fox")162 if err != nil {163 t.Fatal(err)164 }165 if rec == nil {166 t.Fatal("expected migrated record")167 }168 if got, want := rec.GameID, "sudoku"; got != want {169 t.Fatalf("GameID = %q, want %q", got, want)170 }171 if got, want := rec.ModeID, "easy"; got != want {172 t.Fatalf("ModeID = %q, want %q", got, want)173 }174 if got, want := rec.RunKind, RunKindDaily; got != want {175 t.Fatalf("RunKind = %q, want %q", got, want)176 }177 if rec.RunDate == nil {178 t.Fatal("expected RunDate to be backfilled")179 }180181 if _, err := s.GetCategoryStats(); err != nil {182 t.Fatalf("GetCategoryStats() error after migration: %v", err)183 }184 })185}186187func TestCreateGame(t *testing.T) {188 t.Run("sets ID", func(t *testing.T) {189 s := openTestStore(t)190 rec := newTestRecord("alpha")191 if err := s.CreateGame(rec); err != nil {192 t.Fatal(err)193 }194 if rec.ID <= 0 {195 t.Fatalf("expected positive ID, got %d", rec.ID)196 }197 })198199 t.Run("sequential IDs", func(t *testing.T) {200 s := openTestStore(t)201 r1 := newTestRecord("alpha")202 r2 := newTestRecord("beta")203 if err := s.CreateGame(r1); err != nil {204 t.Fatal(err)205 }206 if err := s.CreateGame(r2); err != nil {207 t.Fatal(err)208 }209 if r2.ID <= r1.ID {210 t.Fatalf("expected r2.ID (%d) > r1.ID (%d)", r2.ID, r1.ID)211 }212 })213214 t.Run("preserves fields", func(t *testing.T) {215 s := openTestStore(t)216 rec := &GameRecord{217 Name: "gamma",218 GameType: "Nonogram",219 Mode: "Hard 10x10",220 InitialState: `{"init":true}`,221 SaveState: `{"save":true}`,222 Status: StatusNew,223 RunKind: RunKindNormal,224 }225 if err := s.CreateGame(rec); err != nil {226 t.Fatal(err)227 }228229 got, err := s.GetGameByName("gamma")230 if err != nil {231 t.Fatal(err)232 }233 if got == nil {234 t.Fatal("expected record")235 }236 if got.Name != "gamma" {237 t.Errorf("Name = %q, want %q", got.Name, "gamma")238 }239 if got.GameType != "Nonogram" {240 t.Errorf("GameType = %q, want %q", got.GameType, "Nonogram")241 }242 if got.Mode != "Hard 10x10" {243 t.Errorf("Mode = %q, want %q", got.Mode, "Hard 10x10")244 }245 if got.InitialState != `{"init":true}` {246 t.Errorf("InitialState = %q, want %q", got.InitialState, `{"init":true}`)247 }248 if got.SaveState != `{"save":true}` {249 t.Errorf("SaveState = %q, want %q", got.SaveState, `{"save":true}`)250 }251 if got.Status != StatusNew {252 t.Errorf("Status = %q, want %q", got.Status, StatusNew)253 }254 })255256 t.Run("rejects duplicate name", func(t *testing.T) {257 s := openTestStore(t)258 r1 := newTestRecord("dup")259 if err := s.CreateGame(r1); err != nil {260 t.Fatal(err)261 }262 r2 := newTestRecord("dup")263 if err := s.CreateGame(r2); err == nil {264 t.Fatal("expected error for duplicate name")265 }266 })267268 t.Run("does not infer deterministic metadata from name", func(t *testing.T) {269 s := openTestStore(t)270 rec := newTestRecord("Daily Feb 16 26 - amber-fox")271 if err := s.CreateGame(rec); err != nil {272 t.Fatal(err)273 }274275 got, err := s.GetGameByName(rec.Name)276 if err != nil {277 t.Fatal(err)278 }279 if got == nil {280 t.Fatal("expected record")281 }282 if got.RunKind != RunKindNormal {283 t.Fatalf("RunKind = %q, want %q", got.RunKind, RunKindNormal)284 }285 if got.RunDate != nil {286 t.Fatalf("RunDate = %v, want nil", got.RunDate)287 }288 if got.SeedText != "" {289 t.Fatalf("SeedText = %q, want empty", got.SeedText)290 }291 if got.WeekYear != 0 || got.WeekNumber != 0 || got.WeekIndex != 0 {292 t.Fatalf("weekly metadata = (%d, %d, %d), want zero values", got.WeekYear, got.WeekNumber, got.WeekIndex)293 }294 })295}296297func TestUpdateSaveState(t *testing.T) {298 t.Run("updates save state", func(t *testing.T) {299 s := openTestStore(t)300 rec := newTestRecord("alpha")301 if err := s.CreateGame(rec); err != nil {302 t.Fatal(err)303 }304305 if err := s.UpdateSaveState(rec.ID, `{"new":"state"}`); err != nil {306 t.Fatal(err)307 }308309 got, err := s.GetGameByName("alpha")310 if err != nil {311 t.Fatal(err)312 }313 if got.SaveState != `{"new":"state"}` {314 t.Errorf("SaveState = %q, want %q", got.SaveState, `{"new":"state"}`)315 }316 })317318 t.Run("advances updated_at", func(t *testing.T) {319 s := openTestStore(t)320 rec := newTestRecord("alpha")321 if err := s.CreateGame(rec); err != nil {322 t.Fatal(err)323 }324325 before, _ := s.GetGameByName("alpha")326 time.Sleep(10 * time.Millisecond)327328 if err := s.UpdateSaveState(rec.ID, `{"v2":true}`); err != nil {329 t.Fatal(err)330 }331332 after, _ := s.GetGameByName("alpha")333 if !after.UpdatedAt.After(before.UpdatedAt) {334 t.Errorf("UpdatedAt not advanced: before=%v, after=%v", before.UpdatedAt, after.UpdatedAt)335 }336 })337}338339func TestUpdateStatus(t *testing.T) {340 t.Run("new to in_progress", func(t *testing.T) {341 s := openTestStore(t)342 rec := newTestRecord("alpha")343 if err := s.CreateGame(rec); err != nil {344 t.Fatal(err)345 }346 if err := s.UpdateStatus(rec.ID, StatusInProgress); err != nil {347 t.Fatal(err)348 }349350 got, _ := s.GetGameByName("alpha")351 if got.Status != StatusInProgress {352 t.Errorf("Status = %q, want %q", got.Status, StatusInProgress)353 }354 if got.CompletedAt != nil {355 t.Error("CompletedAt should be nil for in_progress")356 }357 })358359 t.Run("in_progress to completed sets completed_at", func(t *testing.T) {360 s := openTestStore(t)361 rec := newTestRecord("alpha")362 if err := s.CreateGame(rec); err != nil {363 t.Fatal(err)364 }365 if err := s.UpdateStatus(rec.ID, StatusCompleted); err != nil {366 t.Fatal(err)367 }368369 got, _ := s.GetGameByName("alpha")370 if got.Status != StatusCompleted {371 t.Errorf("Status = %q, want %q", got.Status, StatusCompleted)372 }373 if got.CompletedAt == nil {374 t.Fatal("CompletedAt should be set for completed")375 }376 if time.Since(*got.CompletedAt) > 5*time.Second {377 t.Errorf("CompletedAt too old: %v", *got.CompletedAt)378 }379 })380381 t.Run("in_progress to abandoned", func(t *testing.T) {382 s := openTestStore(t)383 rec := newTestRecord("alpha")384 if err := s.CreateGame(rec); err != nil {385 t.Fatal(err)386 }387 if err := s.UpdateStatus(rec.ID, StatusAbandoned); err != nil {388 t.Fatal(err)389 }390391 // GetGameByName excludes abandoned, so query directly.392 all, _ := s.ListAllGames()393 if len(all) != 1 {394 t.Fatalf("expected 1 game, got %d", len(all))395 }396 if all[0].Status != StatusAbandoned {397 t.Errorf("Status = %q, want %q", all[0].Status, StatusAbandoned)398 }399 if all[0].CompletedAt != nil {400 t.Error("CompletedAt should be nil for abandoned")401 }402 })403}404405func TestNameExists(t *testing.T) {406 t.Run("returns true for existing name", func(t *testing.T) {407 s := openTestStore(t)408 rec := newTestRecord("alpha")409 if err := s.CreateGame(rec); err != nil {410 t.Fatal(err)411 }412413 exists, err := s.NameExists("alpha")414 if err != nil {415 t.Fatal(err)416 }417 if !exists {418 t.Error("expected true for existing name")419 }420 })421422 t.Run("returns false for missing name", func(t *testing.T) {423 s := openTestStore(t)424425 exists, err := s.NameExists("nope")426 if err != nil {427 t.Fatal(err)428 }429 if exists {430 t.Error("expected false for missing name")431 }432 })433434 t.Run("includes abandoned games", func(t *testing.T) {435 s := openTestStore(t)436 rec := newTestRecord("alpha")437 if err := s.CreateGame(rec); err != nil {438 t.Fatal(err)439 }440 if err := s.UpdateStatus(rec.ID, StatusAbandoned); err != nil {441 t.Fatal(err)442 }443444 exists, err := s.NameExists("alpha")445 if err != nil {446 t.Fatal(err)447 }448 if !exists {449 t.Error("NameExists should include abandoned games")450 }451 })452453 t.Run("empty database", func(t *testing.T) {454 s := openTestStore(t)455456 exists, err := s.NameExists("anything")457 if err != nil {458 t.Fatal(err)459 }460 if exists {461 t.Error("expected false for empty database")462 }463 })464}465466func TestListGames(t *testing.T) {467 t.Run("excludes abandoned", func(t *testing.T) {468 s := openTestStore(t)469 for _, name := range []string{"a", "b", "c"} {470 rec := newTestRecord(name)471 if err := s.CreateGame(rec); err != nil {472 t.Fatal(err)473 }474 if name == "b" {475 if err := s.UpdateStatus(rec.ID, StatusAbandoned); err != nil {476 t.Fatal(err)477 }478 }479 }480481 games, err := s.ListGames()482 if err != nil {483 t.Fatal(err)484 }485 if len(games) != 2 {486 t.Fatalf("expected 2 games, got %d", len(games))487 }488 for _, g := range games {489 if g.Status == StatusAbandoned {490 t.Error("ListGames should not include abandoned games")491 }492 }493 })494495 t.Run("ordered by updated_at DESC", func(t *testing.T) {496 s := openTestStore(t)497 for _, name := range []string{"first", "second", "third"} {498 rec := newTestRecord(name)499 if err := s.CreateGame(rec); err != nil {500 t.Fatal(err)501 }502 time.Sleep(20 * time.Millisecond)503 }504 // Update "first" to make it most recent.505 first, _ := s.GetGameByName("first")506 time.Sleep(20 * time.Millisecond)507 if err := s.UpdateSaveState(first.ID, `{"updated":true}`); err != nil {508 t.Fatal(err)509 }510511 games, err := s.ListGames()512 if err != nil {513 t.Fatal(err)514 }515 if len(games) < 3 {516 t.Fatalf("expected 3 games, got %d", len(games))517 }518 if games[0].Name != "first" {519 t.Errorf("expected first game to be 'first', got %q", games[0].Name)520 }521 })522523 t.Run("empty database returns empty slice", func(t *testing.T) {524 s := openTestStore(t)525526 games, err := s.ListGames()527 if err != nil {528 t.Fatal(err)529 }530 if len(games) != 0 {531 t.Errorf("expected empty slice, got %d games", len(games))532 }533 })534535 t.Run("includes all non-abandoned statuses", func(t *testing.T) {536 s := openTestStore(t)537 for _, name := range []string{"new-game", "progress-game", "done-game"} {538 rec := newTestRecord(name)539 if err := s.CreateGame(rec); err != nil {540 t.Fatal(err)541 }542 }543 pg, _ := s.GetGameByName("progress-game")544 if err := s.UpdateStatus(pg.ID, StatusInProgress); err != nil {545 t.Fatal(err)546 }547 dg, _ := s.GetGameByName("done-game")548 if err := s.UpdateStatus(dg.ID, StatusCompleted); err != nil {549 t.Fatal(err)550 }551552 games, err := s.ListGames()553 if err != nil {554 t.Fatal(err)555 }556 if len(games) != 3 {557 t.Fatalf("expected 3 games, got %d", len(games))558 }559 })560}561562func TestListAllGames(t *testing.T) {563 t.Run("includes abandoned", func(t *testing.T) {564 s := openTestStore(t)565 for _, name := range []string{"a", "b", "c"} {566 rec := newTestRecord(name)567 if err := s.CreateGame(rec); err != nil {568 t.Fatal(err)569 }570 if name == "b" {571 if err := s.UpdateStatus(rec.ID, StatusAbandoned); err != nil {572 t.Fatal(err)573 }574 }575 }576577 games, err := s.ListAllGames()578 if err != nil {579 t.Fatal(err)580 }581 if len(games) != 3 {582 t.Fatalf("expected 3 games, got %d", len(games))583 }584 })585}586587func TestGetGameByName(t *testing.T) {588 t.Run("finds by name", func(t *testing.T) {589 s := openTestStore(t)590 rec := newTestRecord("alpha")591 if err := s.CreateGame(rec); err != nil {592 t.Fatal(err)593 }594595 got, err := s.GetGameByName("alpha")596 if err != nil {597 t.Fatal(err)598 }599 if got == nil {600 t.Fatal("expected record")601 }602 if got.Name != "alpha" {603 t.Errorf("Name = %q, want %q", got.Name, "alpha")604 }605 })606607 t.Run("excludes abandoned", func(t *testing.T) {608 s := openTestStore(t)609 rec := newTestRecord("alpha")610 if err := s.CreateGame(rec); err != nil {611 t.Fatal(err)612 }613 if err := s.UpdateStatus(rec.ID, StatusAbandoned); err != nil {614 t.Fatal(err)615 }616617 got, err := s.GetGameByName("alpha")618 if err != nil {619 t.Fatal(err)620 }621 if got != nil {622 t.Error("expected nil for abandoned game")623 }624 })625626 t.Run("returns nil for missing name", func(t *testing.T) {627 s := openTestStore(t)628629 got, err := s.GetGameByName("nope")630 if err != nil {631 t.Fatal(err)632 }633 if got != nil {634 t.Error("expected nil for missing name")635 }636 })637638 t.Run("preserves completed_at", func(t *testing.T) {639 s := openTestStore(t)640 rec := newTestRecord("alpha")641 if err := s.CreateGame(rec); err != nil {642 t.Fatal(err)643 }644 if err := s.UpdateStatus(rec.ID, StatusCompleted); err != nil {645 t.Fatal(err)646 }647648 got, _ := s.GetGameByName("alpha")649 if got.CompletedAt == nil {650 t.Fatal("expected CompletedAt to be set")651 }652 })653}654655func TestGetDeterministicGame(t *testing.T) {656 s := openTestStore(t)657 rec := newWeeklyTestRecord("Week 01-2026 - #01", 2026, 1, 1)658 if err := s.CreateGame(rec); err != nil {659 t.Fatal(err)660 }661 if err := s.UpdateStatus(rec.ID, StatusAbandoned); err != nil {662 t.Fatal(err)663 }664665 got, err := s.GetDeterministicGame(rec.Name)666 if err != nil {667 t.Fatal(err)668 }669 if got == nil {670 t.Fatal("expected deterministic record")671 }672 if got.Status != StatusAbandoned {673 t.Fatalf("status = %q, want %q", got.Status, StatusAbandoned)674 }675}676677func TestWeeklyQueries(t *testing.T) {678 s := openTestStore(t)679680 records := []*GameRecord{681 newWeeklyTestRecord(weekly.Name(2026, 1, 1), 2026, 1, 1),682 newWeeklyTestRecord(weekly.Name(2026, 1, 2), 2026, 1, 2),683 newWeeklyTestRecord(weekly.Name(2026, 2, 1), 2026, 2, 1),684 newTestRecord("Week 1-2026 - #01"),685 }686 for _, rec := range records {687 if err := s.CreateGame(rec); err != nil {688 t.Fatal(err)689 }690 }691692 got, err := s.GetWeeklyGame(2026, 1, 2)693 if err != nil {694 t.Fatal(err)695 }696 if got == nil || got.Name != weekly.Name(2026, 1, 2) {697 t.Fatalf("GetWeeklyGame() = %#v, want %q", got, weekly.Name(2026, 1, 2))698 }699700 weeklyGames, err := s.ListWeeklyGames(2026, 1)701 if err != nil {702 t.Fatal(err)703 }704 if len(weeklyGames) != 2 {705 t.Fatalf("ListWeeklyGames() len = %d, want 2", len(weeklyGames))706 }707 for _, rec := range weeklyGames {708 info, ok := weekly.ParseName(rec.Name)709 if !ok {710 t.Fatalf("ListWeeklyGames() included non-canonical name %q", rec.Name)711 }712 if info.Year != 2026 || info.Week != 1 {713 t.Fatalf("ListWeeklyGames() included wrong week %#v", info)714 }715 }716}717718func TestFullLifecycle(t *testing.T) {719 s := openTestStore(t)720721 rec := newTestRecord("lifecycle")722 if err := s.CreateGame(rec); err != nil {723 t.Fatal(err)724 }725 if rec.Status != StatusNew {726 t.Errorf("initial status = %q, want %q", rec.Status, StatusNew)727 }728729 if err := s.UpdateStatus(rec.ID, StatusInProgress); err != nil {730 t.Fatal(err)731 }732733 if err := s.UpdateSaveState(rec.ID, `{"progress":"halfway"}`); err != nil {734 t.Fatal(err)735 }736737 mid, _ := s.GetGameByName("lifecycle")738 if mid.Status != StatusInProgress {739 t.Errorf("mid-game status = %q, want %q", mid.Status, StatusInProgress)740 }741 if mid.SaveState != `{"progress":"halfway"}` {742 t.Errorf("mid-game SaveState = %q", mid.SaveState)743 }744745 if err := s.UpdateStatus(rec.ID, StatusCompleted); err != nil {746 t.Fatal(err)747 }748749 done, _ := s.GetGameByName("lifecycle")750 if done.Status != StatusCompleted {751 t.Errorf("final status = %q, want %q", done.Status, StatusCompleted)752 }753 if done.CompletedAt == nil {754 t.Error("expected CompletedAt to be set")755 }756757 games, _ := s.ListGames()758 found := false759 for _, g := range games {760 if g.Name == "lifecycle" {761 found = true762 }763 }764 if !found {765 t.Error("completed game should appear in ListGames")766 }767}