clone

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

refactor/root-tui-model
puzzletea / games / sudokurgb / sudokurgb_test.go
sudokurgb_test.go 7.2K
1package sudokurgb23import (4 "math/rand/v2"5 "strings"6 "testing"78 tea "charm.land/bubbletea/v2"9 "github.com/FelineStateMachine/puzzletea/game"10 "github.com/charmbracelet/x/ansi"11)1213func validCompleteGrid() grid {14 rows := [gridSize][gridSize]int{15 {1, 2, 3, 1, 2, 3, 1, 2, 3},16 {2, 3, 1, 2, 3, 1, 2, 3, 1},17 {3, 1, 2, 3, 1, 2, 3, 1, 2},18 {1, 2, 3, 1, 2, 3, 1, 2, 3},19 {2, 3, 1, 2, 3, 1, 2, 3, 1},20 {3, 1, 2, 3, 1, 2, 3, 1, 2},21 {1, 2, 3, 1, 2, 3, 1, 2, 3},22 {2, 3, 1, 2, 3, 1, 2, 3, 1},23 {3, 1, 2, 3, 1, 2, 3, 1, 2},24 }2526 var g grid27 for y := range gridSize {28 for x := range gridSize {29 g[y][x] = cell{x: x, y: y, v: rows[y][x]}30 }31 }32 return g33}3435func TestFillGridSeededProducesValidBoard(t *testing.T) {36 g := newGrid(nil)37 if !fillGridSeeded(&g, rand.New(rand.NewPCG(7, 11))) {38 t.Fatal("fillGridSeeded returned false")39 }40 if analysis := analyzeGrid(g); !isSolvedWith(g, analysis) {41 t.Fatal("generated board is not solved")42 }43}4445func TestCountSolutionsRejectsOverQuotaState(t *testing.T) {46 g := validCompleteGrid()47 g[0][1].v = 148 if got := countSolutions(&g, 2); got != 0 {49 t.Fatalf("countSolutions(overquota) = %d, want 0", got)50 }51}5253func TestGenerateProvidedCellsSeededMatchesModeTargets(t *testing.T) {54 for i, item := range Modes {55 mode := item.(SudokuRGBMode)56 t.Run(mode.Title(), func(t *testing.T) {57 rng := rand.New(rand.NewPCG(uint64(i+1), uint64(i+101)))58 cells := GenerateProvidedCellsSeeded(mode, rng)59 if got, want := len(cells), mode.ProvidedCount; got != want {60 t.Fatalf("provided count = %d, want %d", got, want)61 }6263 g := newGrid(cells)64 if got := countSolutions(&g, 2); got != 1 {65 t.Fatalf("countSolutions() = %d, want 1", got)66 }67 })68 }69}7071func TestModelUpdateRestrictsInputsAndRespectsProvidedCells(t *testing.T) {72 model := Model{73 grid: newGrid([]cell{{x: 0, y: 0, v: 1}}),74 provided: []cell{{x: 0, y: 0, v: 1}},75 providedGrid: buildProvidedGrid([]cell{{x: 0, y: 0, v: 1}}),76 analysis: analyzeGrid(newGrid([]cell{{x: 0, y: 0, v: 1}})),77 keys: DefaultKeyMap,78 cursor: game.Cursor{X: 1, Y: 1},79 }8081 next, _ := model.Update(tea.KeyPressMsg{Code: '1', Text: "1"})82 got := next.(Model)83 if got.grid[1][1].v != 1 {84 t.Fatalf("value after 1 = %d, want 1", got.grid[1][1].v)85 }8687 next, _ = got.Update(tea.KeyPressMsg{Code: '4', Text: "4"})88 got = next.(Model)89 if got.grid[1][1].v != 1 {90 t.Fatalf("value after ignored 4 = %d, want 1", got.grid[1][1].v)91 }9293 next, _ = got.Update(tea.KeyPressMsg{Code: tea.KeyBackspace})94 got = next.(Model)95 if got.grid[1][1].v != 0 {96 t.Fatalf("value after backspace = %d, want 0", got.grid[1][1].v)97 }9899 got.cursor.X, got.cursor.Y = 0, 0100 next, _ = got.Update(tea.KeyPressMsg{Code: '2', Text: "2"})101 got = next.(Model)102 if got.grid[0][0].v != 1 {103 t.Fatalf("provided cell mutated to %d, want 1", got.grid[0][0].v)104 }105}106107func TestCellViewUsesUnicodeSymbolsWithCursorMarkers(t *testing.T) {108 g := newGrid(nil)109 g[0][0].v = 3110 m := Model{111 grid: g,112 keys: DefaultKeyMap,113 cursor: game.Cursor{X: 0, Y: 0},114 analysis: analyzeGrid(g),115 }116117 got := ansi.Strip(cellView(m, 0, 0, false))118 if !strings.Contains(got, game.CursorLeft+"●"+game.CursorRight) {119 t.Fatalf("filled cursor cell = %q, want marker-wrapped unicode symbol", got)120 }121122 g[0][0].v = 0123 m.grid = g124 got = ansi.Strip(cellView(m, 0, 0, false))125 if !strings.Contains(got, game.CursorLeft+"·"+game.CursorRight) {126 t.Fatalf("empty cursor cell = %q, want marker-wrapped dot", got)127 }128}129130func TestScreenToGridUsesStandardCellWidth(t *testing.T) {131 m := Model{132 grid: newGrid(nil),133 analysis: analyzeGrid(newGrid(nil)),134 keys: DefaultKeyMap,135 }136137 next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})138 got := next.(Model)139 x, y := got.cachedGridOrigin()140141 if col, row, ok := got.screenToGrid(x, y); !ok || col != 0 || row != 0 {142 t.Fatalf("screenToGrid(origin) = (%d,%d,%v), want (0,0,true)", col, row, ok)143 }144 if _, _, ok := got.screenToGrid(x+cellWidth, y); ok {145 t.Fatal("screenToGrid(separator) = ok, want false")146 }147 if _, _, ok := got.screenToGrid(x-rowHintWidth, y); ok {148 t.Fatal("screenToGrid(row hint band) = ok, want false")149 }150 if _, _, ok := got.screenToGrid(x, y-colHintHeight); ok {151 t.Fatal("screenToGrid(column hint band) = ok, want false")152 }153}154155func TestSaveRoundTrip(t *testing.T) {156 g := newGrid([]cell{{x: 0, y: 0, v: 1}})157 g[1][1].v = 3158159 saveModel := Model{160 grid: g,161 provided: []cell{{x: 0, y: 0, v: 1}},162 providedGrid: buildProvidedGrid([]cell{{x: 0, y: 0, v: 1}}),163 analysis: analyzeGrid(g),164 keys: DefaultKeyMap,165 modeTitle: "Easy",166 }167168 save, err := saveModel.GetSave()169 if err != nil {170 t.Fatal(err)171 }172173 loaded, err := ImportModel(save)174 if err != nil {175 t.Fatal(err)176 }177 if loaded.grid[0][0].v != 1 || loaded.grid[1][1].v != 3 {178 t.Fatalf("loaded grid values = (%d,%d), want (1,3)", loaded.grid[0][0].v, loaded.grid[1][1].v)179 }180 if loaded.modeTitle != "Easy" {181 t.Fatalf("mode title = %q, want Easy", loaded.modeTitle)182 }183}184185func TestAnalyzeGridTracksRowColumnAndBoxState(t *testing.T) {186 t.Run("counts solved board", func(t *testing.T) {187 analysis := analyzeGrid(validCompleteGrid())188 for row := range gridSize {189 if got := analysis.rowCountsString(row); got != "3/3/3" {190 t.Fatalf("rowCountsString(%d) = %q, want 3/3/3", row, got)191 }192 }193 for col := range gridSize {194 if got := analysis.colCountsString(col); got != "3/3/3" {195 t.Fatalf("colCountsString(%d) = %q, want 3/3/3", col, got)196 }197 }198 if analysis.hasConflicts() {199 t.Fatal("solved board unexpectedly has conflicts")200 }201 })202203 t.Run("row over-quota stays off cells", func(t *testing.T) {204 g := newGrid(nil)205 for x := 0; x < 4; x++ {206 g[0][x].v = 1207 }208209 analysis := analyzeGrid(g)210 if !analysis.rowOverQuota[0][1] {211 t.Fatal("expected row over-quota for red")212 }213 if analysis.colOverQuota[0][1] {214 t.Fatal("unexpected column over-quota")215 }216 for y := range gridSize {217 for x := range gridSize {218 if analysis.boxConflictCells[y][x] {219 t.Fatalf("unexpected box conflict at (%d,%d)", x, y)220 }221 }222 }223 })224225 t.Run("column over-quota stays off cells", func(t *testing.T) {226 g := newGrid(nil)227 for _, y := range []int{0, 3, 6, 8} {228 g[y][0].v = 2229 }230231 analysis := analyzeGrid(g)232 if !analysis.colOverQuota[0][2] {233 t.Fatal("expected column over-quota for green")234 }235 if analysis.rowOverQuota[0][2] {236 t.Fatal("unexpected row over-quota")237 }238 for y := range gridSize {239 for x := range gridSize {240 if analysis.boxConflictCells[y][x] {241 t.Fatalf("unexpected box conflict at (%d,%d)", x, y)242 }243 }244 }245 })246247 t.Run("box over-quota marks only box cells", func(t *testing.T) {248 g := newGrid(nil)249 for _, cell := range []cell{250 {x: 0, y: 0, v: 3},251 {x: 1, y: 0, v: 3},252 {x: 2, y: 1, v: 3},253 {x: 0, y: 2, v: 3},254 } {255 g[cell.y][cell.x] = cell256 }257258 analysis := analyzeGrid(g)259 if analysis.rowOverQuota[0][3] || analysis.colOverQuota[0][3] {260 t.Fatal("box-only conflict leaked into row or column state")261 }262 for _, cell := range []cell{263 {x: 0, y: 0},264 {x: 1, y: 0},265 {x: 2, y: 1},266 {x: 0, y: 2},267 } {268 if !analysis.boxConflictCells[cell.y][cell.x] {269 t.Fatalf("expected box conflict at (%d,%d)", cell.x, cell.y)270 }271 }272 if analysis.boxConflictCells[3][3] {273 t.Fatal("unexpected box conflict outside offending box")274 }275 })276}