clone

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

refactor/root-tui-model
puzzletea / store / stats_test.go
stats_test.go 12.1K
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 })224225 t.Run("difficulty elo averages completed records", func(t *testing.T) {226 s := openTestStore(t)227 first := newTestRecord("elo-a")228 second := newTestRecord("elo-b")229 incomplete := newTestRecord("elo-c")230 for _, rec := range []*GameRecord{first, second, incomplete} {231 rec.GameType = "Word Search"232 rec.Mode = "Easy 10x10"233 }234 firstActual := 1200235 secondTarget := 1800236 incompleteActual := 3000237 first.ActualDifficultyElo = &firstActual238 second.TargetDifficultyElo = &secondTarget239 incomplete.ActualDifficultyElo = &incompleteActual240241 for _, rec := range []*GameRecord{first, second, incomplete} {242 if err := s.CreateGame(rec); err != nil {243 t.Fatal(err)244 }245 }246 for _, id := range []int64{first.ID, second.ID} {247 if err := s.UpdateStatus(id, StatusCompleted); err != nil {248 t.Fatal(err)249 }250 }251252 stats, err := s.GetModeStats()253 if err != nil {254 t.Fatal(err)255 }256 if len(stats) != 1 {257 t.Fatalf("stats length = %d, want 1", len(stats))258 }259 if stats[0].DifficultyElo == nil || *stats[0].DifficultyElo != 1500 {260 t.Fatalf("DifficultyElo = %v, want 1500", stats[0].DifficultyElo)261 }262 })263}264265// --- GetDailyStreakDates (P1) ---266267func TestGetDailyStreakDates(t *testing.T) {268 t.Run("empty database", func(t *testing.T) {269 s := openTestStore(t)270 dates, err := s.GetDailyStreakDates()271 if err != nil {272 t.Fatal(err)273 }274 if len(dates) != 0 {275 t.Errorf("expected 0 dates, got %d", len(dates))276 }277 })278279 t.Run("returns distinct dates descending", func(t *testing.T) {280 s := openTestStore(t)281 now := time.Now().UTC()282 yesterday := now.AddDate(0, 0, -1)283284 games := []*GameRecord{285 newDailyTestRecord("Daily Feb 16 26 - a", time.Date(2026, time.February, 16, 12, 0, 0, 0, time.Local)),286 newDailyTestRecord("Daily Feb 15 26 - b", time.Date(2026, time.February, 15, 12, 0, 0, 0, time.Local)),287 {Name: "regular-game", GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew},288 }289 games[0].GameType, games[0].Mode = "Sudoku", "Easy"290 games[0].InitialState, games[0].SaveState, games[0].Status = "{}", "{}", StatusNew291 games[1].GameType, games[1].Mode = "Nonogram", "Mini"292 games[1].InitialState, games[1].SaveState, games[1].Status = "{}", "{}", StatusNew293 for _, g := range games {294 if err := s.CreateGame(g); err != nil {295 t.Fatal(err)296 }297 }298299 // Complete both daily games with specific completed_at times.300 if _, err := s.db.Exec(`UPDATE games SET status = 'completed', completed_at = ? WHERE id = ?`, now, games[0].ID); err != nil {301 t.Fatal(err)302 }303 if _, err := s.db.Exec(`UPDATE games SET status = 'completed', completed_at = ? WHERE id = ?`, yesterday, games[1].ID); err != nil {304 t.Fatal(err)305 }306 // Complete regular game too (should not appear in results).307 if _, err := s.db.Exec(`UPDATE games SET status = 'completed', completed_at = ? WHERE id = ?`, now, games[2].ID); err != nil {308 t.Fatal(err)309 }310311 dates, err := s.GetDailyStreakDates()312 if err != nil {313 t.Fatal(err)314 }315 if len(dates) != 2 {316 t.Fatalf("expected 2 dates, got %d", len(dates))317 }318 // Most recent first.319 if !dates[0].After(dates[1]) {320 t.Errorf("expected descending order: %v, %v", dates[0], dates[1])321 }322 })323324 t.Run("excludes non-completed dailies", func(t *testing.T) {325 s := openTestStore(t)326 games := []*GameRecord{327 newDailyTestRecord("Daily Feb 16 26 - a", time.Date(2026, time.February, 16, 12, 0, 0, 0, time.Local)),328 newDailyTestRecord("Daily Feb 15 26 - b", time.Date(2026, time.February, 15, 12, 0, 0, 0, time.Local)),329 }330 for _, g := range games {331 g.GameType = "Sudoku"332 g.Mode = "Easy"333 g.InitialState = "{}"334 g.SaveState = "{}"335 g.Status = StatusNew336 }337 for _, g := range games {338 if err := s.CreateGame(g); err != nil {339 t.Fatal(err)340 }341 }342 // Only abandon, don't complete.343 if err := s.UpdateStatus(games[0].ID, StatusAbandoned); err != nil {344 t.Fatal(err)345 }346347 dates, err := s.GetDailyStreakDates()348 if err != nil {349 t.Fatal(err)350 }351 if len(dates) != 0 {352 t.Errorf("expected 0 dates for non-completed dailies, got %d", len(dates))353 }354 })355}356357func TestGetCompletedWeeklyGauntlets(t *testing.T) {358 s := openTestStore(t)359360 games := []*GameRecord{361 newWeeklyTestRecord(weekly.Name(2026, 1, 98), 2026, 1, 98),362 newWeeklyTestRecord(weekly.Name(2026, 1, 99), 2026, 1, 99),363 newWeeklyTestRecord(weekly.Name(2026, 2, 50), 2026, 2, 50),364 newWeeklyTestRecord(weekly.Name(2026, 3, 99), 2026, 3, 99),365 {Name: "Week 3-2026 - #99", GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew},366 }367 for _, g := range games[:4] {368 g.GameType = "Sudoku"369 g.Mode = "Easy"370 g.InitialState = "{}"371 g.SaveState = "{}"372 g.Status = StatusNew373 }374 for _, g := range games {375 if err := s.CreateGame(g); err != nil {376 t.Fatal(err)377 }378 if err := s.UpdateStatus(g.ID, StatusCompleted); err != nil {379 t.Fatal(err)380 }381 }382383 got, err := s.GetCompletedWeeklyGauntlets()384 if err != nil {385 t.Fatal(err)386 }387 if got != 2 {388 t.Fatalf("GetCompletedWeeklyGauntlets() = %d, want 2", got)389 }390}391392func TestGetCurrentWeeklyHighestCompletedIndex(t *testing.T) {393 s := openTestStore(t)394 games := []*GameRecord{395 newWeeklyTestRecord(weekly.Name(2026, 10, 1), 2026, 10, 1),396 newWeeklyTestRecord(weekly.Name(2026, 10, 17), 2026, 10, 17),397 newWeeklyTestRecord(weekly.Name(2026, 10, 9), 2026, 10, 9),398 newWeeklyTestRecord(weekly.Name(2026, 11, 30), 2026, 11, 30),399 }400 for _, g := range games {401 g.GameType = "Sudoku"402 g.Mode = "Easy"403 g.InitialState = "{}"404 g.SaveState = "{}"405 g.Status = StatusNew406 }407 for _, g := range games {408 if err := s.CreateGame(g); err != nil {409 t.Fatal(err)410 }411 if err := s.UpdateStatus(g.ID, StatusCompleted); err != nil {412 t.Fatal(err)413 }414 }415416 got, err := s.GetCurrentWeeklyHighestCompletedIndex(2026, 10)417 if err != nil {418 t.Fatal(err)419 }420 if got != 17 {421 t.Fatalf("GetCurrentWeeklyHighestCompletedIndex() = %d, want 17", got)422 }423}