clone

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

chore/regenerate-vhs-gifs
puzzletea / store / stats_test.go
stats_test.go 11.0K
1package store23import (4 "testing"5 "time"67 "github.com/FelineStateMachine/puzzletea/weekly"8)910// --- GetCategoryStats (P1) ---1112func TestGetCategoryStats(t *testing.T) {13 t.Run("empty database", func(t *testing.T) {14 s := openTestStore(t)15 stats, err := s.GetCategoryStats()16 if err != nil {17 t.Fatal(err)18 }19 if len(stats) != 0 {20 t.Errorf("expected 0 stats, got %d", len(stats))21 }22 })2324 t.Run("counts by category", func(t *testing.T) {25 s := openTestStore(t)26 games := []*GameRecord{27 {Name: "a", GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew},28 {Name: "b", GameType: "Sudoku", Mode: "Medium", InitialState: "{}", SaveState: "{}", Status: StatusNew},29 {Name: "c", GameType: "Nonogram", Mode: "Mini", InitialState: "{}", SaveState: "{}", Status: StatusNew},30 }31 for _, g := range games {32 if err := s.CreateGame(g); err != nil {33 t.Fatal(err)34 }35 }36 // Complete one Sudoku.37 if err := s.UpdateStatus(games[0].ID, StatusCompleted); err != nil {38 t.Fatal(err)39 }40 // Abandon one Sudoku.41 if err := s.UpdateStatus(games[1].ID, StatusAbandoned); err != nil {42 t.Fatal(err)43 }4445 stats, err := s.GetCategoryStats()46 if err != nil {47 t.Fatal(err)48 }4950 byType := make(map[string]CategoryStat)51 for _, cs := range stats {52 byType[cs.GameType] = cs53 }5455 sudoku := byType["Sudoku"]56 if sudoku.TotalAttempts != 2 {57 t.Errorf("Sudoku TotalAttempts = %d, want 2", sudoku.TotalAttempts)58 }59 if sudoku.TotalVictories != 1 {60 t.Errorf("Sudoku TotalVictories = %d, want 1", sudoku.TotalVictories)61 }62 if sudoku.TotalAbandoned != 1 {63 t.Errorf("Sudoku TotalAbandoned = %d, want 1", sudoku.TotalAbandoned)64 }65 if sudoku.PreferredMode != "Easy" {66 t.Errorf("Sudoku PreferredMode = %q, want %q", sudoku.PreferredMode, "Easy")67 }6869 nonogram := byType["Nonogram"]70 if nonogram.TotalAttempts != 1 {71 t.Errorf("Nonogram TotalAttempts = %d, want 1", nonogram.TotalAttempts)72 }73 if nonogram.TotalVictories != 0 {74 t.Errorf("Nonogram TotalVictories = %d, want 0", nonogram.TotalVictories)75 }76 if nonogram.PreferredMode != "" {77 t.Errorf("Nonogram PreferredMode = %q, want empty", nonogram.PreferredMode)78 }79 })8081 t.Run("daily games counted", func(t *testing.T) {82 s := openTestStore(t)83 games := []*GameRecord{84 newDailyTestRecord("Daily Feb 16 26 - amber-fox", time.Date(2026, time.February, 16, 12, 0, 0, 0, time.Local)),85 newDailyTestRecord("Daily Feb 15 26 - blue-cat", time.Date(2026, time.February, 15, 12, 0, 0, 0, time.Local)),86 {Name: "regular-game", GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew},87 }88 games[0].GameType, games[0].Mode = "Sudoku", "Easy"89 games[0].InitialState, games[0].SaveState, games[0].Status = "{}", "{}", StatusNew90 games[1].GameType, games[1].Mode = "Sudoku", "Medium"91 games[1].InitialState, games[1].SaveState, games[1].Status = "{}", "{}", StatusNew92 for _, g := range games {93 if err := s.CreateGame(g); err != nil {94 t.Fatal(err)95 }96 }97 if err := s.UpdateStatus(games[0].ID, StatusCompleted); err != nil {98 t.Fatal(err)99 }100101 stats, err := s.GetCategoryStats()102 if err != nil {103 t.Fatal(err)104 }105 if len(stats) != 1 {106 t.Fatalf("expected 1 category, got %d", len(stats))107 }108 cs := stats[0]109 if cs.TimesDaily != 2 {110 t.Errorf("TimesDaily = %d, want 2", cs.TimesDaily)111 }112 if cs.DailyVictories != 1 {113 t.Errorf("DailyVictories = %d, want 1", cs.DailyVictories)114 }115 })116}117118// --- GetModeStats (P1) ---119120func TestGetModeStats(t *testing.T) {121 t.Run("empty database", func(t *testing.T) {122 s := openTestStore(t)123 stats, err := s.GetModeStats()124 if err != nil {125 t.Fatal(err)126 }127 if len(stats) != 0 {128 t.Errorf("expected 0 stats, got %d", len(stats))129 }130 })131132 t.Run("victory counts per mode", func(t *testing.T) {133 s := openTestStore(t)134 games := []*GameRecord{135 {Name: "a", GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew},136 {Name: "b", GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew},137 {Name: "c", GameType: "Sudoku", Mode: "Medium", InitialState: "{}", SaveState: "{}", Status: StatusNew},138 newDailyTestRecord("Daily Feb 16 26 - x", time.Date(2026, time.February, 16, 12, 0, 0, 0, time.Local)),139 }140 games[3].GameType, games[3].Mode = "Sudoku", "Easy"141 games[3].InitialState, games[3].SaveState, games[3].Status = "{}", "{}", StatusNew142 for _, g := range games {143 if err := s.CreateGame(g); err != nil {144 t.Fatal(err)145 }146 }147 // Complete two Easy (one daily) and one Medium.148 for _, id := range []int64{games[0].ID, games[2].ID, games[3].ID} {149 if err := s.UpdateStatus(id, StatusCompleted); err != nil {150 t.Fatal(err)151 }152 }153154 stats, err := s.GetModeStats()155 if err != nil {156 t.Fatal(err)157 }158159 type key struct{ gt, mode string }160 byKey := make(map[key]ModeStat)161 for _, ms := range stats {162 byKey[key{ms.GameType, ms.Mode}] = ms163 }164165 easy := byKey[key{"Sudoku", "Easy"}]166 if easy.Victories != 2 {167 t.Errorf("Easy Victories = %d, want 2", easy.Victories)168 }169 if easy.DailyVictories != 1 {170 t.Errorf("Easy DailyVictories = %d, want 1", easy.DailyVictories)171 }172173 medium := byKey[key{"Sudoku", "Medium"}]174 if medium.Victories != 1 {175 t.Errorf("Medium Victories = %d, want 1", medium.Victories)176 }177 if medium.DailyVictories != 0 {178 t.Errorf("Medium DailyVictories = %d, want 0", medium.DailyVictories)179 }180 })181182 t.Run("weekly bonus xp is aggregated by parsed name", func(t *testing.T) {183 s := openTestStore(t)184 games := []*GameRecord{185 newWeeklyTestRecord(weekly.Name(2026, 1, 10), 2026, 1, 10),186 newWeeklyTestRecord(weekly.Name(2026, 1, 25), 2026, 1, 25),187 }188 for _, g := range games {189 g.GameType = "Sudoku"190 g.Mode = "Easy"191 g.InitialState = "{}"192 g.SaveState = "{}"193 g.Status = StatusNew194 }195 for _, g := range games {196 if err := s.CreateGame(g); err != nil {197 t.Fatal(err)198 }199 if err := s.UpdateStatus(g.ID, StatusCompleted); err != nil {200 t.Fatal(err)201 }202 }203204 stats, err := s.GetModeStats()205 if err != nil {206 t.Fatal(err)207 }208209 found := false210 for _, ms := range stats {211 if ms.GameType != "Sudoku" || ms.Mode != "Easy" {212 continue213 }214 found = true215 want := weekly.BonusXP(10) + weekly.BonusXP(25)216 if ms.WeeklyBonusXP != want {217 t.Fatalf("WeeklyBonusXP = %d, want %d", ms.WeeklyBonusXP, want)218 }219 }220 if !found {221 t.Fatal("expected Sudoku/Easy mode stats")222 }223 })224}225226// --- GetDailyStreakDates (P1) ---227228func TestGetDailyStreakDates(t *testing.T) {229 t.Run("empty database", func(t *testing.T) {230 s := openTestStore(t)231 dates, err := s.GetDailyStreakDates()232 if err != nil {233 t.Fatal(err)234 }235 if len(dates) != 0 {236 t.Errorf("expected 0 dates, got %d", len(dates))237 }238 })239240 t.Run("returns distinct dates descending", func(t *testing.T) {241 s := openTestStore(t)242 now := time.Now().UTC()243 yesterday := now.AddDate(0, 0, -1)244245 games := []*GameRecord{246 newDailyTestRecord("Daily Feb 16 26 - a", time.Date(2026, time.February, 16, 12, 0, 0, 0, time.Local)),247 newDailyTestRecord("Daily Feb 15 26 - b", time.Date(2026, time.February, 15, 12, 0, 0, 0, time.Local)),248 {Name: "regular-game", GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew},249 }250 games[0].GameType, games[0].Mode = "Sudoku", "Easy"251 games[0].InitialState, games[0].SaveState, games[0].Status = "{}", "{}", StatusNew252 games[1].GameType, games[1].Mode = "Nonogram", "Mini"253 games[1].InitialState, games[1].SaveState, games[1].Status = "{}", "{}", StatusNew254 for _, g := range games {255 if err := s.CreateGame(g); err != nil {256 t.Fatal(err)257 }258 }259260 // Complete both daily games with specific completed_at times.261 if _, err := s.db.Exec(`UPDATE games SET status = 'completed', completed_at = ? WHERE id = ?`, now, games[0].ID); err != nil {262 t.Fatal(err)263 }264 if _, err := s.db.Exec(`UPDATE games SET status = 'completed', completed_at = ? WHERE id = ?`, yesterday, games[1].ID); err != nil {265 t.Fatal(err)266 }267 // Complete regular game too (should not appear in results).268 if _, err := s.db.Exec(`UPDATE games SET status = 'completed', completed_at = ? WHERE id = ?`, now, games[2].ID); err != nil {269 t.Fatal(err)270 }271272 dates, err := s.GetDailyStreakDates()273 if err != nil {274 t.Fatal(err)275 }276 if len(dates) != 2 {277 t.Fatalf("expected 2 dates, got %d", len(dates))278 }279 // Most recent first.280 if !dates[0].After(dates[1]) {281 t.Errorf("expected descending order: %v, %v", dates[0], dates[1])282 }283 })284285 t.Run("excludes non-completed dailies", func(t *testing.T) {286 s := openTestStore(t)287 games := []*GameRecord{288 newDailyTestRecord("Daily Feb 16 26 - a", time.Date(2026, time.February, 16, 12, 0, 0, 0, time.Local)),289 newDailyTestRecord("Daily Feb 15 26 - b", time.Date(2026, time.February, 15, 12, 0, 0, 0, time.Local)),290 }291 for _, g := range games {292 g.GameType = "Sudoku"293 g.Mode = "Easy"294 g.InitialState = "{}"295 g.SaveState = "{}"296 g.Status = StatusNew297 }298 for _, g := range games {299 if err := s.CreateGame(g); err != nil {300 t.Fatal(err)301 }302 }303 // Only abandon, don't complete.304 if err := s.UpdateStatus(games[0].ID, StatusAbandoned); err != nil {305 t.Fatal(err)306 }307308 dates, err := s.GetDailyStreakDates()309 if err != nil {310 t.Fatal(err)311 }312 if len(dates) != 0 {313 t.Errorf("expected 0 dates for non-completed dailies, got %d", len(dates))314 }315 })316}317318func TestGetCompletedWeeklyGauntlets(t *testing.T) {319 s := openTestStore(t)320321 games := []*GameRecord{322 newWeeklyTestRecord(weekly.Name(2026, 1, 98), 2026, 1, 98),323 newWeeklyTestRecord(weekly.Name(2026, 1, 99), 2026, 1, 99),324 newWeeklyTestRecord(weekly.Name(2026, 2, 50), 2026, 2, 50),325 newWeeklyTestRecord(weekly.Name(2026, 3, 99), 2026, 3, 99),326 {Name: "Week 3-2026 - #99", GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew},327 }328 for _, g := range games[:4] {329 g.GameType = "Sudoku"330 g.Mode = "Easy"331 g.InitialState = "{}"332 g.SaveState = "{}"333 g.Status = StatusNew334 }335 for _, g := range games {336 if err := s.CreateGame(g); err != nil {337 t.Fatal(err)338 }339 if err := s.UpdateStatus(g.ID, StatusCompleted); err != nil {340 t.Fatal(err)341 }342 }343344 got, err := s.GetCompletedWeeklyGauntlets()345 if err != nil {346 t.Fatal(err)347 }348 if got != 2 {349 t.Fatalf("GetCompletedWeeklyGauntlets() = %d, want 2", got)350 }351}352353func TestGetCurrentWeeklyHighestCompletedIndex(t *testing.T) {354 s := openTestStore(t)355 games := []*GameRecord{356 newWeeklyTestRecord(weekly.Name(2026, 10, 1), 2026, 10, 1),357 newWeeklyTestRecord(weekly.Name(2026, 10, 17), 2026, 10, 17),358 newWeeklyTestRecord(weekly.Name(2026, 10, 9), 2026, 10, 9),359 newWeeklyTestRecord(weekly.Name(2026, 11, 30), 2026, 11, 30),360 }361 for _, g := range games {362 g.GameType = "Sudoku"363 g.Mode = "Easy"364 g.InitialState = "{}"365 g.SaveState = "{}"366 g.Status = StatusNew367 }368 for _, g := range games {369 if err := s.CreateGame(g); err != nil {370 t.Fatal(err)371 }372 if err := s.UpdateStatus(g.ID, StatusCompleted); err != nil {373 t.Fatal(err)374 }375 }376377 got, err := s.GetCurrentWeeklyHighestCompletedIndex(2026, 10)378 if err != nil {379 t.Fatal(err)380 }381 if got != 17 {382 t.Fatalf("GetCurrentWeeklyHighestCompletedIndex() = %d, want 17", got)383 }384}