clone

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

bug/architecture-test-trim-path
puzzletea / games / sudoku / sudoku_test.go
sudoku_test.go 15.1K
1package sudoku23import (4 "testing"56 "github.com/FelineStateMachine/puzzletea/game"7)89// validCompleteGrid returns a known valid 9x9 Sudoku solution.10func validCompleteGrid() grid {11 rows := [9][9]int{12 {5, 3, 4, 6, 7, 8, 9, 1, 2},13 {6, 7, 2, 1, 9, 5, 3, 4, 8},14 {1, 9, 8, 3, 4, 2, 5, 6, 7},15 {8, 5, 9, 7, 6, 1, 4, 2, 3},16 {4, 2, 6, 8, 5, 3, 7, 9, 1},17 {7, 1, 3, 9, 2, 4, 8, 5, 6},18 {9, 6, 1, 5, 3, 7, 2, 8, 4},19 {2, 8, 7, 4, 1, 9, 6, 3, 5},20 {3, 4, 5, 2, 8, 6, 1, 7, 9},21 }22 var g grid23 for y := range gridSize {24 for x := range gridSize {25 g[y][x] = cell{x: x, y: y, v: rows[y][x]}26 }27 }28 return g29}3031// emptyGrid returns a 9x9 grid with all cells set to 0 and correct x/y.32func emptyGrid() grid {33 return newGrid(nil)34}3536// --- isValid (P0) ---3738func TestIsValid(t *testing.T) {39 t.Run("valid placement on empty board", func(t *testing.T) {40 g := emptyGrid()41 if !isValid(&g, 5, 0, 0) {42 t.Error("expected valid placement on empty grid")43 }44 })4546 t.Run("row conflict", func(t *testing.T) {47 g := emptyGrid()48 g[0][3].v = 5 // place 5 in row 0, col 349 if isValid(&g, 5, 0, 0) {50 t.Error("expected row conflict")51 }52 })5354 t.Run("column conflict", func(t *testing.T) {55 g := emptyGrid()56 g[3][0].v = 5 // place 5 in col 0, row 357 if isValid(&g, 5, 0, 0) {58 t.Error("expected column conflict")59 }60 })6162 t.Run("box conflict", func(t *testing.T) {63 g := emptyGrid()64 g[1][1].v = 5 // same box as (0,0)65 if isValid(&g, 5, 0, 0) {66 t.Error("expected box conflict")67 }68 })6970 t.Run("no self-conflict", func(t *testing.T) {71 g := emptyGrid()72 g[2][2].v = 573 // Checking isValid for 5 at (2,2) should skip self.74 if !isValid(&g, 5, 2, 2) {75 t.Error("expected no self-conflict")76 }77 })7879 t.Run("different value same position", func(t *testing.T) {80 g := emptyGrid()81 g[0][0].v = 382 if !isValid(&g, 5, 0, 0) {83 t.Error("expected no conflict for different value")84 }85 })8687 t.Run("box boundary (2,2) vs (3,3) different boxes", func(t *testing.T) {88 g := emptyGrid()89 g[3][3].v = 5 // box (1,1), different from (0,0) box90 if !isValid(&g, 5, 2, 2) {91 t.Error("(2,2) and (3,3) are in different boxes, no conflict expected")92 }93 })9495 t.Run("box boundary (2,2) vs (0,0) same box", func(t *testing.T) {96 g := emptyGrid()97 g[0][0].v = 5 // same box as (2,2)98 if isValid(&g, 5, 2, 2) {99 t.Error("(0,0) and (2,2) are in the same box, conflict expected")100 }101 })102103 t.Run("all 9 boxes center placement", func(t *testing.T) {104 // Place a distinct value in the center of each 3x3 box so that no row,105 // column, or box conflict exists, then verify isValid agrees.106 g := emptyGrid()107 type centerVal struct {108 x, y, v int109 }110 centers := []centerVal{111 {1, 1, 1},112 {4, 1, 2},113 {7, 1, 3},114 {1, 4, 4},115 {4, 4, 5},116 {7, 4, 6},117 {1, 7, 7},118 {4, 7, 8},119 {7, 7, 9},120 }121 for _, c := range centers {122 g[c.y][c.x].v = c.v123 }124 // Each center should be valid (unique per box, row, and column).125 for _, c := range centers {126 if !isValid(&g, c.v, c.x, c.y) {127 t.Errorf("expected valid at center (%d,%d) with value %d", c.x, c.y, c.v)128 }129 }130 })131}132133// --- fillGrid (P1) ---134135func TestFillGrid(t *testing.T) {136 t.Run("fills empty grid", func(t *testing.T) {137 g := emptyGrid()138 if !fillGrid(&g) {139 t.Fatal("fillGrid returned false")140 }141 for y := range gridSize {142 for x := range gridSize {143 if g[y][x].v < 1 || g[y][x].v > 9 {144 t.Errorf("cell (%d,%d) = %d, want 1-9", x, y, g[y][x].v)145 }146 }147 }148 })149150 t.Run("result is valid", func(t *testing.T) {151 g := emptyGrid()152 fillGrid(&g)153 for y := range gridSize {154 for x := range gridSize {155 if !isValid(&g, g[y][x].v, x, y) {156 t.Errorf("cell (%d,%d)=%d fails isValid", x, y, g[y][x].v)157 }158 }159 }160 })161162 t.Run("fills partial grid", func(t *testing.T) {163 provided := []cell{164 {x: 0, y: 0, v: 5},165 {x: 1, y: 0, v: 3},166 }167 g := newGrid(provided)168 if !fillGrid(&g) {169 t.Fatal("fillGrid returned false on partial grid")170 }171 if g[0][0].v != 5 {172 t.Errorf("provided cell (0,0) changed to %d", g[0][0].v)173 }174 if g[0][1].v != 3 {175 t.Errorf("provided cell (1,0) changed to %d", g[0][1].v)176 }177 })178179 t.Run("produces different results", func(t *testing.T) {180 var grids [5]grid181 for i := range grids {182 grids[i] = emptyGrid()183 fillGrid(&grids[i])184 }185 allSame := true186 for i := 1; i < len(grids); i++ {187 for y := range gridSize {188 for x := range gridSize {189 if grids[i][y][x].v != grids[0][y][x].v {190 allSame = false191 break192 }193 }194 if !allSame {195 break196 }197 }198 if !allSame {199 break200 }201 }202 if allSame {203 t.Error("fillGrid produced identical grids across 5 runs")204 }205 })206}207208// --- countSolutions (P0) ---209210// almostCompleteGrid returns a valid grid with four empty cells forming a211// swap rectangle, giving exactly 2 solutions. The removed cells are (3,0),212// (4,0), (3,3), (4,3) whose original values (6,7,7,6) can be swapped.213func almostCompleteGrid() grid {214 g := validCompleteGrid()215 g[0][3].v = 0216 g[0][4].v = 0217 g[3][3].v = 0218 g[3][4].v = 0219 return g220}221222func TestCountSolutions(t *testing.T) {223 t.Run("complete grid has 1 solution", func(t *testing.T) {224 g := validCompleteGrid()225 if count := countSolutions(&g, 10); count != 1 {226 t.Errorf("countSolutions = %d, want 1", count)227 }228 })229230 t.Run("one empty cell has 1 solution", func(t *testing.T) {231 g := validCompleteGrid()232 g[0][0].v = 0233 if count := countSolutions(&g, 10); count != 1 {234 t.Errorf("countSolutions = %d, want 1", count)235 }236 })237238 t.Run("impossible grid has 0 solutions", func(t *testing.T) {239 g := emptyGrid()240 g[0][0].v = 1241 g[0][1].v = 1242 if count := countSolutions(&g, 10); count != 0 {243 t.Errorf("countSolutions = %d, want 0", count)244 }245 })246247 t.Run("respects limit", func(t *testing.T) {248 g := almostCompleteGrid()249 // This grid has more than 2 solutions.250 count := countSolutions(&g, 2)251 if count != 2 {252 t.Errorf("countSolutions = %d, want 2 (limit)", count)253 }254 })255256 t.Run("ambiguous grid has multiple solutions", func(t *testing.T) {257 g := almostCompleteGrid()258 count := countSolutions(&g, 10)259 if count <= 1 {260 t.Errorf("countSolutions = %d, want > 1 for ambiguous grid", count)261 }262 })263}264265// --- computeConflicts (P0) ---266267func TestComputeConflicts(t *testing.T) {268 t.Run("no conflicts", func(t *testing.T) {269 g := validCompleteGrid()270 conflicts := computeConflicts(g)271 for y := range gridSize {272 for x := range gridSize {273 if conflicts[y][x] {274 t.Errorf("unexpected conflict at (%d,%d)", x, y)275 }276 }277 }278 })279280 t.Run("row conflict", func(t *testing.T) {281 g := validCompleteGrid()282 // Create a row conflict: set (0,1) to same value as (0,0).283 g[0][1].v = g[0][0].v284 conflicts := computeConflicts(g)285 if !conflicts[0][0] {286 t.Error("expected conflict at (0,0)")287 }288 if !conflicts[0][1] {289 t.Error("expected conflict at (1,0)")290 }291 })292293 t.Run("column conflict", func(t *testing.T) {294 g := validCompleteGrid()295 // Create a column conflict: set (0,1) row to same as (0,0).296 g[1][0].v = g[0][0].v297 conflicts := computeConflicts(g)298 if !conflicts[0][0] {299 t.Error("expected conflict at (0,0)")300 }301 if !conflicts[1][0] {302 t.Error("expected conflict at (0,1)")303 }304 })305306 t.Run("box conflict", func(t *testing.T) {307 g := validCompleteGrid()308 // Create a box conflict: set (1,1) to same value as (0,0).309 g[1][1].v = g[0][0].v310 conflicts := computeConflicts(g)311 if !conflicts[0][0] {312 t.Error("expected conflict at (0,0)")313 }314 if !conflicts[1][1] {315 t.Error("expected conflict at (1,1)")316 }317 })318319 t.Run("empty cells no false positives", func(t *testing.T) {320 g := emptyGrid()321 // Place a single value.322 g[0][0].v = 5323 conflicts := computeConflicts(g)324 for y := range gridSize {325 for x := range gridSize {326 if conflicts[y][x] {327 t.Errorf("unexpected conflict at (%d,%d) on mostly empty grid", x, y)328 }329 }330 }331 })332333 t.Run("multiple conflicts", func(t *testing.T) {334 g := validCompleteGrid()335 // Row conflict in row 0.336 g[0][1].v = g[0][0].v337 // Column conflict in column 5.338 g[3][5].v = g[0][5].v339 conflicts := computeConflicts(g)340 if !conflicts[0][0] || !conflicts[0][1] {341 t.Error("expected row conflict at (0,0) and (1,0)")342 }343 if !conflicts[0][5] || !conflicts[3][5] {344 t.Error("expected column conflict at (5,0) and (5,3)")345 }346 })347}348349// --- isSolvedWith (P0) ---350351func TestIsSolvedWith(t *testing.T) {352 t.Run("valid complete grid", func(t *testing.T) {353 g := validCompleteGrid()354 conflicts := computeConflicts(g)355 if !isSolvedWith(g, conflicts) {356 t.Error("expected solved")357 }358 })359360 t.Run("has empty cell", func(t *testing.T) {361 g := validCompleteGrid()362 g[4][4].v = 0363 conflicts := computeConflicts(g)364 if isSolvedWith(g, conflicts) {365 t.Error("expected not solved with empty cell")366 }367 })368369 t.Run("has conflict", func(t *testing.T) {370 g := validCompleteGrid()371 g[0][1].v = g[0][0].v // duplicate in row372 conflicts := computeConflicts(g)373 if isSolvedWith(g, conflicts) {374 t.Error("expected not solved with conflict")375 }376 })377378 t.Run("all zeros", func(t *testing.T) {379 g := emptyGrid()380 conflicts := computeConflicts(g)381 if isSolvedWith(g, conflicts) {382 t.Error("expected not solved for blank grid")383 }384 })385}386387// --- newGrid (P1) ---388389func TestNewGrid(t *testing.T) {390 t.Run("with provided cells", func(t *testing.T) {391 provided := []cell{392 {x: 0, y: 0, v: 5},393 {x: 4, y: 3, v: 9},394 {x: 8, y: 8, v: 1},395 }396 g := newGrid(provided)397 for _, c := range provided {398 if g[c.y][c.x].v != c.v {399 t.Errorf("cell (%d,%d) = %d, want %d", c.x, c.y, g[c.y][c.x].v, c.v)400 }401 }402 })403404 t.Run("without provided cells", func(t *testing.T) {405 g := newGrid(nil)406 for y := range gridSize {407 for x := range gridSize {408 if g[y][x].v != 0 {409 t.Errorf("cell (%d,%d) = %d, want 0", x, y, g[y][x].v)410 }411 }412 }413 })414415 t.Run("all cells have correct positions", func(t *testing.T) {416 g := newGrid(nil)417 for y := range gridSize {418 for x := range gridSize {419 if g[y][x].x != x || g[y][x].y != y {420 t.Errorf("cell position = (%d,%d), want (%d,%d)", g[y][x].x, g[y][x].y, x, y)421 }422 }423 }424 })425}426427// --- gridToString (P1) ---428429func TestGridToString(t *testing.T) {430 t.Run("round-trip", func(t *testing.T) {431 g := validCompleteGrid()432 s := gridToString(g)433434 // Parse it back.435 g2 := emptyGrid()436 rows := splitLines(s)437 for y := range gridSize {438 for x := range gridSize {439 g2[y][x].v = int(rows[y][x] - '0')440 }441 }442 for y := range gridSize {443 for x := range gridSize {444 if g2[y][x].v != g[y][x].v {445 t.Errorf("round-trip mismatch at (%d,%d): got %d, want %d", x, y, g2[y][x].v, g[y][x].v)446 }447 }448 }449 })450451 t.Run("encodes zeros", func(t *testing.T) {452 g := emptyGrid()453 s := gridToString(g)454 for _, ch := range s {455 if ch != '0' && ch != '\n' {456 t.Errorf("expected only '0' and '\\n', got %q", ch)457 }458 }459 })460461 t.Run("encodes 1-9", func(t *testing.T) {462 g := validCompleteGrid()463 s := gridToString(g)464 for _, ch := range s {465 if ch == '\n' {466 continue467 }468 if ch < '1' || ch > '9' {469 t.Errorf("expected digit 1-9, got %q", ch)470 }471 }472 })473}474475// splitLines splits a string by newlines (helper for test).476func splitLines(s string) []string {477 var lines []string478 start := 0479 for i := range len(s) {480 if s[i] == '\n' {481 lines = append(lines, s[start:i])482 start = i + 1483 }484 }485 lines = append(lines, s[start:])486 return lines487}488489// --- GenerateProvidedCells (P2) ---490491func TestGenerateProvidedCells(t *testing.T) {492 mode := NewMode("Test", "test", 30)493494 t.Run("produces solvable puzzle", func(t *testing.T) {495 cells := GenerateProvidedCells(mode)496 g := newGrid(cells)497 count := countSolutions(&g, 2)498 if count != 1 {499 t.Errorf("countSolutions = %d, want 1", count)500 }501 })502503 t.Run("approximate count", func(t *testing.T) {504 cells := GenerateProvidedCells(mode)505 // May be slightly higher than target if removal would break uniqueness.506 if len(cells) < mode.ProvidedCount {507 t.Errorf("len(cells) = %d, want >= %d", len(cells), mode.ProvidedCount)508 }509 })510511 t.Run("all provided cells valid", func(t *testing.T) {512 cells := GenerateProvidedCells(mode)513 g := newGrid(cells)514 for _, c := range cells {515 if !isValid(&g, c.v, c.x, c.y) {516 t.Errorf("provided cell (%d,%d)=%d fails isValid", c.x, c.y, c.v)517 }518 }519 })520521 t.Run("cells have correct positions", func(t *testing.T) {522 cells := GenerateProvidedCells(mode)523 for _, c := range cells {524 if c.x < 0 || c.x > 8 || c.y < 0 || c.y > 8 {525 t.Errorf("cell position (%d,%d) out of bounds", c.x, c.y)526 }527 if c.v < 1 || c.v > 9 {528 t.Errorf("cell value %d out of range at (%d,%d)", c.v, c.x, c.y)529 }530 }531 })532}533534// --- Save/Load round-trip (P1) ---535536func TestSaveLoadRoundTrip(t *testing.T) {537 t.Run("preserves provided and user cells", func(t *testing.T) {538 provided := []cell{539 {x: 0, y: 0, v: 5},540 {x: 1, y: 0, v: 3},541 {x: 4, y: 4, v: 9},542 }543 g := newGrid(provided)544 // Add user-entered values.545 g[1][0].v = 6546 g[2][2].v = 8547548 m := Model{549 grid: g,550 provided: provided,551 providedGrid: buildProvidedGrid(provided),552 keys: DefaultKeyMap,553 cursor: game.Cursor{X: 2, Y: 3},554 modeTitle: "Hard",555 }556557 data, err := m.GetSave()558 if err != nil {559 t.Fatal(err)560 }561562 got, err := ImportModel(data)563 if err != nil {564 t.Fatal(err)565 }566567 // Provided cells preserved.568 for _, c := range provided {569 if got.grid[c.y][c.x].v != c.v {570 t.Errorf("provided cell (%d,%d) = %d, want %d", c.x, c.y, got.grid[c.y][c.x].v, c.v)571 }572 if !got.providedGrid[c.y][c.x] {573 t.Errorf("providedGrid[%d][%d] = false, want true", c.y, c.x)574 }575 }576577 // User-entered values preserved.578 if got.grid[1][0].v != 6 {579 t.Errorf("user cell (0,1) = %d, want 6", got.grid[1][0].v)580 }581 if got.grid[2][2].v != 8 {582 t.Errorf("user cell (2,2) = %d, want 8", got.grid[2][2].v)583 }584585 // Non-provided cells should not be in providedGrid.586 if got.providedGrid[1][0] {587 t.Error("user cell (0,1) should not be marked as provided")588 }589 })590591 t.Run("provided vs user distinction", func(t *testing.T) {592 provided := []cell{593 {x: 0, y: 0, v: 5},594 }595 m := Model{596 grid: newGrid(provided),597 provided: provided,598 providedGrid: buildProvidedGrid(provided),599 keys: DefaultKeyMap,600 }601602 data, err := m.GetSave()603 if err != nil {604 t.Fatal(err)605 }606607 got, err := ImportModel(data)608 if err != nil {609 t.Fatal(err)610 }611612 if !got.providedGrid[0][0] {613 t.Error("expected (0,0) to be marked as provided")614 }615 if got.providedGrid[0][1] {616 t.Error("expected (1,0) to not be marked as provided")617 }618 })619620 t.Run("empty user input", func(t *testing.T) {621 provided := []cell{622 {x: 0, y: 0, v: 5},623 {x: 8, y: 8, v: 1},624 }625 m := Model{626 grid: newGrid(provided),627 provided: provided,628 providedGrid: buildProvidedGrid(provided),629 keys: DefaultKeyMap,630 }631632 data, err := m.GetSave()633 if err != nil {634 t.Fatal(err)635 }636637 got, err := ImportModel(data)638 if err != nil {639 t.Fatal(err)640 }641642 // All non-provided cells should be 0.643 for y := range gridSize {644 for x := range gridSize {645 if !got.providedGrid[y][x] && got.grid[y][x].v != 0 {646 t.Errorf("non-provided cell (%d,%d) = %d, want 0", x, y, got.grid[y][x].v)647 }648 }649 }650 })651652 t.Run("invalid JSON", func(t *testing.T) {653 _, err := ImportModel([]byte("not json"))654 if err == nil {655 t.Fatal("expected error for invalid JSON")656 }657 })658}