- commit
- e1fea99a06167bce69481fd5ec4d49fef7646ab0
- parent
- 094eed941c1472c9904b0652b44191a5d61b1168
- author
- Dami <dami.etoile@gmail.com>
| A | namegen/ | 100 | +++++ |
| A | resolve_test.go | 228 | ++++++++++++ |
| A | store/ | 566 | ++++++++++++++++++++++++++++++ |
@@ -0,0 +1,100 @@
1+package namegen2+3+import (4+ "strings"5+ "testing"6+)7+8+func TestGenerate_Format(t *testing.T) {9+ for range 50 {10+ name := Generate()11+12+ if strings.Count(name, "-") < 1 {13+ t.Errorf("expected at least one hyphen in %q", name)14+ }15+16+ parts := strings.SplitN(name, "-", 2)17+ if len(parts) != 2 {18+ t.Fatalf("expected 2 parts, got %d for %q", len(parts), name)19+ }20+ if parts[0] == "" {21+ t.Errorf("adjective part is empty in %q", name)22+ }23+ if parts[1] == "" {24+ t.Errorf("noun part is empty in %q", name)25+ }26+ if name != strings.ToLower(name) {27+ t.Errorf("expected all lowercase, got %q", name)28+ }29+ if strings.Contains(name, " ") {30+ t.Errorf("expected no spaces in %q", name)31+ }32+ }33+}34+35+func TestGenerate_ValidWords(t *testing.T) {36+ adjSet := make(map[string]bool, len(adjectives))37+ for _, a := range adjectives {38+ adjSet[a] = true39+ }40+ nounSet := make(map[string]bool, len(nouns))41+ for _, n := range nouns {42+ nounSet[n] = true43+ }44+45+ for range 50 {46+ name := Generate()47+ parts := strings.SplitN(name, "-", 2)48+49+ if !adjSet[parts[0]] {50+ t.Errorf("adjective %q not in adjectives list", parts[0])51+ }52+ if !nounSet[parts[1]] {53+ t.Errorf("noun %q not in nouns list", parts[1])54+ }55+ }56+}57+58+func TestGenerate_Variety(t *testing.T) {59+ seen := make(map[string]bool)60+ for range 20 {61+ seen[Generate()] = true62+ }63+ if len(seen) < 2 {64+ t.Errorf("expected at least 2 distinct names from 20 generations, got %d", len(seen))65+ }66+}67+68+func TestWordLists(t *testing.T) {69+ t.Run("adjectives not empty", func(t *testing.T) {70+ if len(adjectives) == 0 {71+ t.Fatal("adjectives list is empty")72+ }73+ })74+75+ t.Run("nouns not empty", func(t *testing.T) {76+ if len(nouns) == 0 {77+ t.Fatal("nouns list is empty")78+ }79+ })80+81+ t.Run("no duplicate adjectives", func(t *testing.T) {82+ seen := make(map[string]bool)83+ for _, a := range adjectives {84+ if seen[a] {85+ t.Errorf("duplicate adjective: %q", a)86+ }87+ seen[a] = true88+ }89+ })90+91+ t.Run("no duplicate nouns", func(t *testing.T) {92+ seen := make(map[string]bool)93+ for _, n := range nouns {94+ if seen[n] {95+ t.Errorf("duplicate noun: %q", n)96+ }97+ seen[n] = true98+ }99+ })100+}@@ -0,0 +1,228 @@
1+package main2+3+import (4+ "strings"5+ "testing"6+7+ "github.com/FelineStateMachine/puzzletea/game"8+ "github.com/charmbracelet/bubbles/list"9+)10+11+func TestNormalize(t *testing.T) {12+ tests := []struct {13+ name string14+ input string15+ want string16+ }{17+ {"lowercase", "Sudoku", "sudoku"},18+ {"all caps", "SUDOKU", "sudoku"},19+ {"hyphens to spaces", "lights-out", "lights out"},20+ {"underscores to spaces", "word_search", "word search"},21+ {"mixed separators", "Word-Search_Test", "word search test"},22+ {"already normalized", "sudoku", "sudoku"},23+ {"leading trailing space", " sudoku ", "sudoku"},24+ {"empty string", "", ""},25+ {"only hyphens", "---", ""},26+ {"only underscores", "___", ""},27+ {"mixed with spaces", " lights-out ", "lights out"},28+ }29+30+ for _, tt := range tests {31+ t.Run(tt.name, func(t *testing.T) {32+ got := normalize(tt.input)33+ if got != tt.want {34+ t.Errorf("normalize(%q) = %q, want %q", tt.input, got, tt.want)35+ }36+ })37+ }38+}39+40+func TestResolveCategory(t *testing.T) {41+ // Exact and case-insensitive matches.42+ exactTests := []struct {43+ name string44+ input string45+ wantName string46+ }{47+ {"exact Nonogram", "Nonogram", "Nonogram"},48+ {"exact Sudoku", "Sudoku", "Sudoku"},49+ {"exact Lights Out", "Lights Out", "Lights Out"},50+ {"exact Word Search", "Word Search", "Word Search"},51+ {"exact Hashiwokakero", "Hashiwokakero", "Hashiwokakero"},52+ {"exact Takuzu", "Takuzu", "Takuzu"},53+ {"lowercase", "nonogram", "Nonogram"},54+ {"all caps", "SUDOKU", "Sudoku"},55+ {"hyphenated", "lights-out", "Lights Out"},56+ {"underscored", "lights_out", "Lights Out"},57+ {"mixed case hyphen", "Word-Search", "Word Search"},58+ }59+60+ for _, tt := range exactTests {61+ t.Run(tt.name, func(t *testing.T) {62+ cat, err := resolveCategory(tt.input)63+ if err != nil {64+ t.Fatalf("resolveCategory(%q) error: %v", tt.input, err)65+ }66+ if cat.Name != tt.wantName {67+ t.Errorf("resolveCategory(%q).Name = %q, want %q", tt.input, cat.Name, tt.wantName)68+ }69+ })70+ }71+72+ // All aliases.73+ aliasTests := []struct {74+ alias string75+ wantName string76+ }{77+ {"hashi", "Hashiwokakero"},78+ {"bridges", "Hashiwokakero"},79+ {"lights", "Lights Out"},80+ {"ws", "Word Search"},81+ {"words", "Word Search"},82+ {"wordsearch", "Word Search"},83+ {"binairo", "Takuzu"},84+ {"binary", "Takuzu"},85+ {"nonogram", "Nonogram"},86+ {"sudoku", "Sudoku"},87+ {"takuzu", "Takuzu"},88+ }89+90+ for _, tt := range aliasTests {91+ t.Run("alias_"+tt.alias, func(t *testing.T) {92+ cat, err := resolveCategory(tt.alias)93+ if err != nil {94+ t.Fatalf("resolveCategory(%q) error: %v", tt.alias, err)95+ }96+ if cat.Name != tt.wantName {97+ t.Errorf("resolveCategory(%q).Name = %q, want %q", tt.alias, cat.Name, tt.wantName)98+ }99+ })100+ }101+102+ // Error cases.103+ t.Run("unknown game", func(t *testing.T) {104+ _, err := resolveCategory("chess")105+ if err == nil {106+ t.Fatal("expected error for unknown game")107+ }108+ if !strings.Contains(err.Error(), "unknown game") {109+ t.Errorf("error = %q, want it to contain %q", err.Error(), "unknown game")110+ }111+ })112+113+ t.Run("empty string", func(t *testing.T) {114+ _, err := resolveCategory("")115+ if err == nil {116+ t.Fatal("expected error for empty string")117+ }118+ })119+}120+121+func TestResolveMode(t *testing.T) {122+ // Find a known category with modes for testing.123+ sudoku, err := resolveCategory("Sudoku")124+ if err != nil {125+ t.Fatal(err)126+ }127+128+ t.Run("empty name returns default (first) mode", func(t *testing.T) {129+ spawner, title, err := resolveMode(sudoku, "")130+ if err != nil {131+ t.Fatal(err)132+ }133+ if spawner == nil {134+ t.Fatal("expected non-nil spawner")135+ }136+ // First Sudoku mode is "Beginner".137+ if title != "Beginner" {138+ t.Errorf("default mode title = %q, want %q", title, "Beginner")139+ }140+ })141+142+ t.Run("exact mode title", func(t *testing.T) {143+ _, title, err := resolveMode(sudoku, "Easy")144+ if err != nil {145+ t.Fatal(err)146+ }147+ if title != "Easy" {148+ t.Errorf("title = %q, want %q", title, "Easy")149+ }150+ })151+152+ t.Run("case insensitive", func(t *testing.T) {153+ _, title, err := resolveMode(sudoku, "easy")154+ if err != nil {155+ t.Fatal(err)156+ }157+ if title != "Easy" {158+ t.Errorf("title = %q, want %q", title, "Easy")159+ }160+ })161+162+ t.Run("unknown mode", func(t *testing.T) {163+ _, _, err := resolveMode(sudoku, "impossible")164+ if err == nil {165+ t.Fatal("expected error for unknown mode")166+ }167+ if !strings.Contains(err.Error(), "unknown mode") {168+ t.Errorf("error = %q, want it to contain %q", err.Error(), "unknown mode")169+ }170+ })171+172+ t.Run("empty modes list", func(t *testing.T) {173+ emptyCat := game.Category{Name: "Empty", Modes: []list.Item{}}174+ _, _, err := resolveMode(emptyCat, "any")175+ if err == nil {176+ t.Fatal("expected error for empty modes")177+ }178+ if !strings.Contains(err.Error(), "no available modes") {179+ t.Errorf("error = %q, want it to contain %q", err.Error(), "no available modes")180+ }181+ })182+}183+184+func TestListCategoryNames(t *testing.T) {185+ names := listCategoryNames()186+ if len(names) != len(GameCategories) {187+ t.Fatalf("len(listCategoryNames()) = %d, want %d", len(names), len(GameCategories))188+ }189+190+ // Verify all expected games are present.191+ expected := map[string]bool{192+ "Hashiwokakero": true,193+ "Lights Out": true,194+ "Nonogram": true,195+ "Sudoku": true,196+ "Takuzu": true,197+ "Word Search": true,198+ }199+ for _, n := range names {200+ if !expected[n] {201+ t.Errorf("unexpected category name: %q", n)202+ }203+ delete(expected, n)204+ }205+ for n := range expected {206+ t.Errorf("missing category name: %q", n)207+ }208+}209+210+func TestListModeNames(t *testing.T) {211+ cat, err := resolveCategory("Lights Out")212+ if err != nil {213+ t.Fatal(err)214+ }215+216+ names := listModeNames(cat)217+ if len(names) != len(cat.Modes) {218+ t.Fatalf("len(listModeNames) = %d, want %d", len(names), len(cat.Modes))219+ }220+221+ // Lights Out modes: Easy, Medium, Hard, Extreme.222+ expected := []string{"Easy", "Medium", "Hard", "Extreme"}223+ for i, want := range expected {224+ if names[i] != want {225+ t.Errorf("mode[%d] = %q, want %q", i, names[i], want)226+ }227+ }228+}@@ -0,0 +1,566 @@
1+package store2+3+import (4+ "path/filepath"5+ "testing"6+ "time"7+)8+9+func openTestStore(t *testing.T) *Store {10+ t.Helper()11+ s, err := Open(filepath.Join(t.TempDir(), "test.db"))12+ if err != nil {13+ t.Fatal(err)14+ }15+ t.Cleanup(func() { s.Close() })16+ return s17+}18+19+func newTestRecord(name string) *GameRecord {20+ return &GameRecord{21+ Name: name,22+ GameType: "Sudoku",23+ Mode: "Easy",24+ InitialState: `{"grid":"..."}`,25+ SaveState: `{"grid":"..."}`,26+ Status: StatusNew,27+ }28+}29+30+func TestOpen(t *testing.T) {31+ t.Run("creates directory and database", func(t *testing.T) {32+ dir := t.TempDir()33+ dbPath := filepath.Join(dir, "sub", "nested", "test.db")34+ s, err := Open(dbPath)35+ if err != nil {36+ t.Fatal(err)37+ }38+ defer s.Close()39+ })40+41+ t.Run("opens existing database", func(t *testing.T) {42+ dir := t.TempDir()43+ dbPath := filepath.Join(dir, "test.db")44+45+ s1, err := Open(dbPath)46+ if err != nil {47+ t.Fatal(err)48+ }49+ rec := newTestRecord("alpha")50+ if err := s1.CreateGame(rec); err != nil {51+ t.Fatal(err)52+ }53+ s1.Close()54+55+ s2, err := Open(dbPath)56+ if err != nil {57+ t.Fatal(err)58+ }59+ defer s2.Close()60+61+ got, err := s2.GetGameByName("alpha")62+ if err != nil {63+ t.Fatal(err)64+ }65+ if got == nil {66+ t.Fatal("expected record to persist across reopen")67+ }68+ })69+}70+71+func TestCreateGame(t *testing.T) {72+ t.Run("sets ID", func(t *testing.T) {73+ s := openTestStore(t)74+ rec := newTestRecord("alpha")75+ if err := s.CreateGame(rec); err != nil {76+ t.Fatal(err)77+ }78+ if rec.ID <= 0 {79+ t.Fatalf("expected positive ID, got %d", rec.ID)80+ }81+ })82+83+ t.Run("sequential IDs", func(t *testing.T) {84+ s := openTestStore(t)85+ r1 := newTestRecord("alpha")86+ r2 := newTestRecord("beta")87+ if err := s.CreateGame(r1); err != nil {88+ t.Fatal(err)89+ }90+ if err := s.CreateGame(r2); err != nil {91+ t.Fatal(err)92+ }93+ if r2.ID <= r1.ID {94+ t.Fatalf("expected r2.ID (%d) > r1.ID (%d)", r2.ID, r1.ID)95+ }96+ })97+98+ t.Run("preserves fields", func(t *testing.T) {99+ s := openTestStore(t)100+ rec := &GameRecord{101+ Name: "gamma",102+ GameType: "Nonogram",103+ Mode: "Hard 10x10",104+ InitialState: `{"init":true}`,105+ SaveState: `{"save":true}`,106+ Status: StatusNew,107+ }108+ if err := s.CreateGame(rec); err != nil {109+ t.Fatal(err)110+ }111+112+ got, err := s.GetGameByName("gamma")113+ if err != nil {114+ t.Fatal(err)115+ }116+ if got == nil {117+ t.Fatal("expected record")118+ }119+ if got.Name != "gamma" {120+ t.Errorf("Name = %q, want %q", got.Name, "gamma")121+ }122+ if got.GameType != "Nonogram" {123+ t.Errorf("GameType = %q, want %q", got.GameType, "Nonogram")124+ }125+ if got.Mode != "Hard 10x10" {126+ t.Errorf("Mode = %q, want %q", got.Mode, "Hard 10x10")127+ }128+ if got.InitialState != `{"init":true}` {129+ t.Errorf("InitialState = %q, want %q", got.InitialState, `{"init":true}`)130+ }131+ if got.SaveState != `{"save":true}` {132+ t.Errorf("SaveState = %q, want %q", got.SaveState, `{"save":true}`)133+ }134+ if got.Status != StatusNew {135+ t.Errorf("Status = %q, want %q", got.Status, StatusNew)136+ }137+ })138+139+ t.Run("rejects duplicate name", func(t *testing.T) {140+ s := openTestStore(t)141+ r1 := newTestRecord("dup")142+ if err := s.CreateGame(r1); err != nil {143+ t.Fatal(err)144+ }145+ r2 := newTestRecord("dup")146+ if err := s.CreateGame(r2); err == nil {147+ t.Fatal("expected error for duplicate name")148+ }149+ })150+}151+152+func TestUpdateSaveState(t *testing.T) {153+ t.Run("updates save state", func(t *testing.T) {154+ s := openTestStore(t)155+ rec := newTestRecord("alpha")156+ if err := s.CreateGame(rec); err != nil {157+ t.Fatal(err)158+ }159+160+ if err := s.UpdateSaveState(rec.ID, `{"new":"state"}`); err != nil {161+ t.Fatal(err)162+ }163+164+ got, err := s.GetGameByName("alpha")165+ if err != nil {166+ t.Fatal(err)167+ }168+ if got.SaveState != `{"new":"state"}` {169+ t.Errorf("SaveState = %q, want %q", got.SaveState, `{"new":"state"}`)170+ }171+ })172+173+ t.Run("advances updated_at", func(t *testing.T) {174+ s := openTestStore(t)175+ rec := newTestRecord("alpha")176+ if err := s.CreateGame(rec); err != nil {177+ t.Fatal(err)178+ }179+180+ before, _ := s.GetGameByName("alpha")181+ time.Sleep(10 * time.Millisecond)182+183+ if err := s.UpdateSaveState(rec.ID, `{"v2":true}`); err != nil {184+ t.Fatal(err)185+ }186+187+ after, _ := s.GetGameByName("alpha")188+ if !after.UpdatedAt.After(before.UpdatedAt) {189+ t.Errorf("UpdatedAt not advanced: before=%v, after=%v", before.UpdatedAt, after.UpdatedAt)190+ }191+ })192+}193+194+func TestUpdateStatus(t *testing.T) {195+ t.Run("new to in_progress", func(t *testing.T) {196+ s := openTestStore(t)197+ rec := newTestRecord("alpha")198+ if err := s.CreateGame(rec); err != nil {199+ t.Fatal(err)200+ }201+ if err := s.UpdateStatus(rec.ID, StatusInProgress); err != nil {202+ t.Fatal(err)203+ }204+205+ got, _ := s.GetGameByName("alpha")206+ if got.Status != StatusInProgress {207+ t.Errorf("Status = %q, want %q", got.Status, StatusInProgress)208+ }209+ if got.CompletedAt != nil {210+ t.Error("CompletedAt should be nil for in_progress")211+ }212+ })213+214+ t.Run("in_progress to completed sets completed_at", func(t *testing.T) {215+ s := openTestStore(t)216+ rec := newTestRecord("alpha")217+ if err := s.CreateGame(rec); err != nil {218+ t.Fatal(err)219+ }220+ if err := s.UpdateStatus(rec.ID, StatusCompleted); err != nil {221+ t.Fatal(err)222+ }223+224+ got, _ := s.GetGameByName("alpha")225+ if got.Status != StatusCompleted {226+ t.Errorf("Status = %q, want %q", got.Status, StatusCompleted)227+ }228+ if got.CompletedAt == nil {229+ t.Fatal("CompletedAt should be set for completed")230+ }231+ if time.Since(*got.CompletedAt) > 5*time.Second {232+ t.Errorf("CompletedAt too old: %v", *got.CompletedAt)233+ }234+ })235+236+ t.Run("in_progress to abandoned", func(t *testing.T) {237+ s := openTestStore(t)238+ rec := newTestRecord("alpha")239+ if err := s.CreateGame(rec); err != nil {240+ t.Fatal(err)241+ }242+ if err := s.UpdateStatus(rec.ID, StatusAbandoned); err != nil {243+ t.Fatal(err)244+ }245+246+ // GetGameByName excludes abandoned, so query directly.247+ all, _ := s.ListAllGames()248+ if len(all) != 1 {249+ t.Fatalf("expected 1 game, got %d", len(all))250+ }251+ if all[0].Status != StatusAbandoned {252+ t.Errorf("Status = %q, want %q", all[0].Status, StatusAbandoned)253+ }254+ if all[0].CompletedAt != nil {255+ t.Error("CompletedAt should be nil for abandoned")256+ }257+ })258+}259+260+func TestNameExists(t *testing.T) {261+ t.Run("returns true for existing name", func(t *testing.T) {262+ s := openTestStore(t)263+ rec := newTestRecord("alpha")264+ if err := s.CreateGame(rec); err != nil {265+ t.Fatal(err)266+ }267+268+ exists, err := s.NameExists("alpha")269+ if err != nil {270+ t.Fatal(err)271+ }272+ if !exists {273+ t.Error("expected true for existing name")274+ }275+ })276+277+ t.Run("returns false for missing name", func(t *testing.T) {278+ s := openTestStore(t)279+280+ exists, err := s.NameExists("nope")281+ if err != nil {282+ t.Fatal(err)283+ }284+ if exists {285+ t.Error("expected false for missing name")286+ }287+ })288+289+ t.Run("includes abandoned games", func(t *testing.T) {290+ s := openTestStore(t)291+ rec := newTestRecord("alpha")292+ if err := s.CreateGame(rec); err != nil {293+ t.Fatal(err)294+ }295+ if err := s.UpdateStatus(rec.ID, StatusAbandoned); err != nil {296+ t.Fatal(err)297+ }298+299+ exists, err := s.NameExists("alpha")300+ if err != nil {301+ t.Fatal(err)302+ }303+ if !exists {304+ t.Error("NameExists should include abandoned games")305+ }306+ })307+308+ t.Run("empty database", func(t *testing.T) {309+ s := openTestStore(t)310+311+ exists, err := s.NameExists("anything")312+ if err != nil {313+ t.Fatal(err)314+ }315+ if exists {316+ t.Error("expected false for empty database")317+ }318+ })319+}320+321+func TestListGames(t *testing.T) {322+ t.Run("excludes abandoned", func(t *testing.T) {323+ s := openTestStore(t)324+ for _, name := range []string{"a", "b", "c"} {325+ rec := newTestRecord(name)326+ if err := s.CreateGame(rec); err != nil {327+ t.Fatal(err)328+ }329+ if name == "b" {330+ if err := s.UpdateStatus(rec.ID, StatusAbandoned); err != nil {331+ t.Fatal(err)332+ }333+ }334+ }335+336+ games, err := s.ListGames()337+ if err != nil {338+ t.Fatal(err)339+ }340+ if len(games) != 2 {341+ t.Fatalf("expected 2 games, got %d", len(games))342+ }343+ for _, g := range games {344+ if g.Status == StatusAbandoned {345+ t.Error("ListGames should not include abandoned games")346+ }347+ }348+ })349+350+ t.Run("ordered by updated_at DESC", func(t *testing.T) {351+ s := openTestStore(t)352+ for _, name := range []string{"first", "second", "third"} {353+ rec := newTestRecord(name)354+ if err := s.CreateGame(rec); err != nil {355+ t.Fatal(err)356+ }357+ time.Sleep(20 * time.Millisecond)358+ }359+ // Update "first" to make it most recent.360+ first, _ := s.GetGameByName("first")361+ time.Sleep(20 * time.Millisecond)362+ if err := s.UpdateSaveState(first.ID, `{"updated":true}`); err != nil {363+ t.Fatal(err)364+ }365+366+ games, err := s.ListGames()367+ if err != nil {368+ t.Fatal(err)369+ }370+ if len(games) < 3 {371+ t.Fatalf("expected 3 games, got %d", len(games))372+ }373+ if games[0].Name != "first" {374+ t.Errorf("expected first game to be 'first', got %q", games[0].Name)375+ }376+ })377+378+ t.Run("empty database returns empty slice", func(t *testing.T) {379+ s := openTestStore(t)380+381+ games, err := s.ListGames()382+ if err != nil {383+ t.Fatal(err)384+ }385+ if len(games) != 0 {386+ t.Errorf("expected empty slice, got %d games", len(games))387+ }388+ })389+390+ t.Run("includes all non-abandoned statuses", func(t *testing.T) {391+ s := openTestStore(t)392+ for _, name := range []string{"new-game", "progress-game", "done-game"} {393+ rec := newTestRecord(name)394+ if err := s.CreateGame(rec); err != nil {395+ t.Fatal(err)396+ }397+ }398+ pg, _ := s.GetGameByName("progress-game")399+ if err := s.UpdateStatus(pg.ID, StatusInProgress); err != nil {400+ t.Fatal(err)401+ }402+ dg, _ := s.GetGameByName("done-game")403+ if err := s.UpdateStatus(dg.ID, StatusCompleted); err != nil {404+ t.Fatal(err)405+ }406+407+ games, err := s.ListGames()408+ if err != nil {409+ t.Fatal(err)410+ }411+ if len(games) != 3 {412+ t.Fatalf("expected 3 games, got %d", len(games))413+ }414+ })415+}416+417+func TestListAllGames(t *testing.T) {418+ t.Run("includes abandoned", func(t *testing.T) {419+ s := openTestStore(t)420+ for _, name := range []string{"a", "b", "c"} {421+ rec := newTestRecord(name)422+ if err := s.CreateGame(rec); err != nil {423+ t.Fatal(err)424+ }425+ if name == "b" {426+ if err := s.UpdateStatus(rec.ID, StatusAbandoned); err != nil {427+ t.Fatal(err)428+ }429+ }430+ }431+432+ games, err := s.ListAllGames()433+ if err != nil {434+ t.Fatal(err)435+ }436+ if len(games) != 3 {437+ t.Fatalf("expected 3 games, got %d", len(games))438+ }439+ })440+}441+442+func TestGetGameByName(t *testing.T) {443+ t.Run("finds by name", func(t *testing.T) {444+ s := openTestStore(t)445+ rec := newTestRecord("alpha")446+ if err := s.CreateGame(rec); err != nil {447+ t.Fatal(err)448+ }449+450+ got, err := s.GetGameByName("alpha")451+ if err != nil {452+ t.Fatal(err)453+ }454+ if got == nil {455+ t.Fatal("expected record")456+ }457+ if got.Name != "alpha" {458+ t.Errorf("Name = %q, want %q", got.Name, "alpha")459+ }460+ })461+462+ t.Run("excludes abandoned", func(t *testing.T) {463+ s := openTestStore(t)464+ rec := newTestRecord("alpha")465+ if err := s.CreateGame(rec); err != nil {466+ t.Fatal(err)467+ }468+ if err := s.UpdateStatus(rec.ID, StatusAbandoned); err != nil {469+ t.Fatal(err)470+ }471+472+ got, err := s.GetGameByName("alpha")473+ if err != nil {474+ t.Fatal(err)475+ }476+ if got != nil {477+ t.Error("expected nil for abandoned game")478+ }479+ })480+481+ t.Run("returns nil for missing name", func(t *testing.T) {482+ s := openTestStore(t)483+484+ got, err := s.GetGameByName("nope")485+ if err != nil {486+ t.Fatal(err)487+ }488+ if got != nil {489+ t.Error("expected nil for missing name")490+ }491+ })492+493+ t.Run("preserves completed_at", func(t *testing.T) {494+ s := openTestStore(t)495+ rec := newTestRecord("alpha")496+ if err := s.CreateGame(rec); err != nil {497+ t.Fatal(err)498+ }499+ if err := s.UpdateStatus(rec.ID, StatusCompleted); err != nil {500+ t.Fatal(err)501+ }502+503+ got, _ := s.GetGameByName("alpha")504+ if got.CompletedAt == nil {505+ t.Fatal("expected CompletedAt to be set")506+ }507+ })508+}509+510+func TestFullLifecycle(t *testing.T) {511+ s := openTestStore(t)512+513+ // Create a game.514+ rec := newTestRecord("lifecycle")515+ if err := s.CreateGame(rec); err != nil {516+ t.Fatal(err)517+ }518+ if rec.Status != StatusNew {519+ t.Errorf("initial status = %q, want %q", rec.Status, StatusNew)520+ }521+522+ // Start playing.523+ if err := s.UpdateStatus(rec.ID, StatusInProgress); err != nil {524+ t.Fatal(err)525+ }526+527+ // Save progress.528+ if err := s.UpdateSaveState(rec.ID, `{"progress":"halfway"}`); err != nil {529+ t.Fatal(err)530+ }531+532+ // Verify mid-game state.533+ mid, _ := s.GetGameByName("lifecycle")534+ if mid.Status != StatusInProgress {535+ t.Errorf("mid-game status = %q, want %q", mid.Status, StatusInProgress)536+ }537+ if mid.SaveState != `{"progress":"halfway"}` {538+ t.Errorf("mid-game SaveState = %q", mid.SaveState)539+ }540+541+ // Complete the game.542+ if err := s.UpdateStatus(rec.ID, StatusCompleted); err != nil {543+ t.Fatal(err)544+ }545+546+ // Verify completed state.547+ done, _ := s.GetGameByName("lifecycle")548+ if done.Status != StatusCompleted {549+ t.Errorf("final status = %q, want %q", done.Status, StatusCompleted)550+ }551+ if done.CompletedAt == nil {552+ t.Error("expected CompletedAt to be set")553+ }554+555+ // Appears in list.556+ games, _ := s.ListGames()557+ found := false558+ for _, g := range games {559+ if g.Name == "lifecycle" {560+ found = true561+ }562+ }563+ if !found {564+ t.Error("completed game should appear in ListGames")565+ }566+}