clone

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

feat/elo-difficulty
puzzletea / games / takuzu / takuzu_test.go
takuzu_test.go 30.3K
1package takuzu23import (4 "encoding/json"5 "image"6 "math/rand/v2"7 "strings"8 "testing"910 tea "charm.land/bubbletea/v2"11 "charm.land/lipgloss/v2"12 "github.com/FelineStateMachine/puzzletea/game"13 "github.com/FelineStateMachine/puzzletea/theme"14)1516// --- Helpers ---1718func makeGrid6(rows ...string) grid {19 if len(rows) != 6 {20 panic("makeGrid6 requires exactly 6 rows")21 }22 g := make(grid, 6)23 for i, row := range rows {24 g[i] = []rune(row)25 }26 return g27}2829func validGrid6() grid {30 // A valid 6x6 Takuzu grid (3 zeros, 3 ones per line, no triples, unique lines)31 return makeGrid6(32 "001011",33 "110100",34 "010101",35 "101010",36 "100110",37 "011001",38 )39}4041// --- canPlace (P0) ---4243func TestCanPlace_Horizontal_Triples(t *testing.T) {44 tests := []struct {45 name string46 row string47 x int48 val rune49 want bool50 }{51 {"00. placing 0 at x=2", "00....", 2, zeroCell, false},52 {"00. placing 1 at x=2", "00....", 2, oneCell, true},53 {".00 placing 0 at x=0", ".00...", 0, zeroCell, false},54 {".00 placing 1 at x=0", ".00...", 0, oneCell, true},55 {"0.0 placing 0 at x=1", "0.0...", 1, zeroCell, false},56 {"0.0 placing 1 at x=1", "0.0...", 1, oneCell, true},57 {"01. placing 0 at x=2", "01....", 2, zeroCell, true},58 {"11. placing 1 at x=2", "11....", 2, oneCell, false},59 {".11 placing 1 at x=0", ".11...", 0, oneCell, false},60 {"1.1 placing 1 at x=1", "1.1...", 1, oneCell, false},61 }6263 for _, tt := range tests {64 t.Run(tt.name, func(t *testing.T) {65 g := makeGrid6(tt.row, "......", "......", "......", "......", "......")66 if got := canPlace(g, 6, tt.x, 0, tt.val); got != tt.want {67 t.Errorf("canPlace = %v, want %v", got, tt.want)68 }69 })70 }71}7273func TestCanPlace_Vertical_Triples(t *testing.T) {74 tests := []struct {75 name string76 col string // column 0 values by row index; '.' = leave empty77 y int78 val rune79 want bool80 }{81 {"y-2,y-1 match: 00. placing 0 at y=2", "00....", 2, zeroCell, false},82 {"y-2,y-1 match: 00. placing 1 at y=2", "00....", 2, oneCell, true},83 {"y+1,y+2 match: .00 placing 0 at y=0", ".00...", 0, zeroCell, false},84 {"y-1,y+1 match: 0.0 placing 0 at y=1", "0.0...", 1, zeroCell, false},85 {"y-1,y+1 match: 0.0 placing 1 at y=1", "0.0...", 1, oneCell, true},86 {"no triple: 01. placing 0 at y=2", "01....", 2, zeroCell, true},87 {"y-2,y-1 match ones: 11. placing 1 at y=2", "11....", 2, oneCell, false},88 {"y+1,y+2 match ones: .11 placing 1 at y=0", ".11...", 0, oneCell, false},89 {"y-1,y+1 match ones: 1.1 placing 1 at y=1", "1.1...", 1, oneCell, false},90 {"bottom edge: ....00 placing 0 at y=3", "....00", 3, zeroCell, false},91 }9293 for _, tt := range tests {94 t.Run(tt.name, func(t *testing.T) {95 g := make(grid, 6)96 for y := range 6 {97 g[y] = []rune("......")98 if y < len(tt.col) && tt.col[y] != '.' {99 g[y][0] = rune(tt.col[y])100 }101 }102 if got := canPlace(g, 6, 0, tt.y, tt.val); got != tt.want {103 t.Errorf("canPlace = %v, want %v", got, tt.want)104 }105 })106 }107}108109func TestCanPlace_EqualCounts(t *testing.T) {110 t.Run("row at limit for zeros", func(t *testing.T) {111 g := makeGrid6("000...", "......", "......", "......", "......", "......")112 // Row already has 3 zeros, can't place another.113 if got := canPlace(g, 6, 3, 0, zeroCell); got != false {114 t.Error("canPlace should reject when row has size/2 zeros")115 }116 })117118 t.Run("row at limit for ones", func(t *testing.T) {119 g := makeGrid6("111...", "......", "......", "......", "......", "......")120 if got := canPlace(g, 6, 3, 0, oneCell); got != false {121 t.Error("canPlace should reject when row has size/2 ones")122 }123 })124125 t.Run("row has room for both", func(t *testing.T) {126 g := makeGrid6("01....", "......", "......", "......", "......", "......")127 // Only 1 zero and 1 one, room for both.128 if got := canPlace(g, 6, 2, 0, zeroCell); got != true {129 t.Error("canPlace should allow when row has room")130 }131 if got := canPlace(g, 6, 2, 0, oneCell); got != true {132 t.Error("canPlace should allow when row has room")133 }134 })135136 t.Run("column at limit", func(t *testing.T) {137 g := make(grid, 6)138 for y := range 6 {139 g[y] = []rune("......")140 }141 // Put 3 zeros in column 0.142 g[0][0] = zeroCell143 g[1][0] = zeroCell144 g[2][0] = zeroCell145146 if got := canPlace(g, 6, 0, 3, zeroCell); got != false {147 t.Error("canPlace should reject when column has size/2 zeros")148 }149 })150}151152func TestCanPlace_UniqueLines(t *testing.T) {153 t.Run("completing row creates duplicate", func(t *testing.T) {154 g := makeGrid6(155 "00101.", // Will complete to 001011156 "001011", // Already exists157 "......",158 "......",159 "......",160 "......",161 )162 // Placing '1' at (5, 0) would make row 0 identical to row 1.163 if got := canPlace(g, 6, 5, 0, oneCell); got != false {164 t.Error("canPlace should reject when completing row creates duplicate")165 }166 })167168 t.Run("completing row is unique", func(t *testing.T) {169 g := makeGrid6(170 "01101.", // Will complete to 011010 (3 zeros, 3 ones)171 "001011", // Different172 "......",173 "......",174 "......",175 "......",176 )177 // Placing '0' at (5, 0) makes row 0 unique and has valid counts.178 if got := canPlace(g, 6, 5, 0, zeroCell); got != true {179 t.Error("canPlace should allow unique row completion")180 }181 })182183 t.Run("completing column creates duplicate", func(t *testing.T) {184 g := make(grid, 6)185 for y := range 6 {186 g[y] = []rune("......")187 }188 // Column 0: 00101. Column 1: 001011 (complete)189 g[0][0], g[1][0], g[2][0], g[3][0], g[4][0] = '0', '0', '1', '0', '1'190 g[0][1], g[1][1], g[2][1], g[3][1], g[4][1], g[5][1] = '0', '0', '1', '0', '1', '1'191192 // Placing '1' at (0, 5) would duplicate column 1.193 if got := canPlace(g, 6, 0, 5, oneCell); got != false {194 t.Error("canPlace should reject when completing column creates duplicate")195 }196 })197198 t.Run("row not yet complete no uniqueness check", func(t *testing.T) {199 g := makeGrid6(200 "0010..", // Not complete201 "001011",202 "......",203 "......",204 "......",205 "......",206 )207 // Placing at (4, 0) doesn't trigger uniqueness check (row still incomplete after).208 if got := canPlace(g, 6, 4, 0, oneCell); got != true {209 t.Error("canPlace should allow when row not fully complete")210 }211 })212}213214// --- rowEqual / colEqual (P0) ---215216func TestRowEqual(t *testing.T) {217 tests := []struct {218 name string219 a, b []rune220 want bool221 }{222 {"identical", []rune{'0', '1', '0', '1'}, []rune{'0', '1', '0', '1'}, true},223 {"different", []rune{'0', '1', '0', '1'}, []rune{'0', '1', '1', '0'}, false},224 {"different lengths", []rune{'0', '1'}, []rune{'0', '1', '0'}, false},225 {"both empty", []rune{}, []rune{}, true},226 }227228 for _, tt := range tests {229 t.Run(tt.name, func(t *testing.T) {230 if got := rowEqual(tt.a, tt.b); got != tt.want {231 t.Errorf("rowEqual = %v, want %v", got, tt.want)232 }233 })234 }235}236237func TestColEqual(t *testing.T) {238 g := makeGrid6(239 "001011",240 "010101",241 "101010",242 "110100",243 "001011",244 "010001",245 )246247 t.Run("identical columns", func(t *testing.T) {248 // Columns 2 and 4 are both [1,0,1,0,1,0].249 if !colEqual(g, 6, 2, 4) {250 t.Error("colEqual should return true for identical columns")251 }252 })253254 t.Run("different columns", func(t *testing.T) {255 if colEqual(g, 6, 0, 1) {256 t.Error("colEqual should return false for different columns")257 }258 })259}260261// --- rowEqualWith / colEqualWith (P0) ---262263func TestRowEqualWith(t *testing.T) {264 a := []rune{'0', '0', '1', '0', '1', '1'}265 b := []rune{'0', '0', '1', '0', '1', '1'}266267 t.Run("override makes no difference when already equal", func(t *testing.T) {268 // a[3]='0', override with '0', should still match b[3]='0'269 if !rowEqualWith(a, b, 3, '0') {270 t.Error("rowEqualWith should return true")271 }272 })273274 t.Run("override fixes difference", func(t *testing.T) {275 b2 := []rune{'0', '0', '1', '1', '1', '1'} // Differs at index 3276 if !rowEqualWith(a, b2, 3, '1') {277 t.Error("rowEqualWith should return true when override fixes it")278 }279 })280281 t.Run("override doesn't help", func(t *testing.T) {282 b2 := []rune{'1', '0', '1', '0', '1', '1'} // Differs at index 0283 if rowEqualWith(a, b2, 3, '1') {284 t.Error("rowEqualWith should return false when override doesn't match")285 }286 })287288 t.Run("different lengths", func(t *testing.T) {289 b2 := []rune{'0', '0', '1'}290 if rowEqualWith(a, b2, 0, '0') {291 t.Error("rowEqualWith should return false for different lengths")292 }293 })294}295296func TestColEqualWith(t *testing.T) {297 g := makeGrid6(298 "001011",299 "001011",300 "101010",301 "110100",302 "001011",303 "001001",304 )305306 t.Run("override fixes difference", func(t *testing.T) {307 // Column 0 and 1 differ only at row 2 (g[2][0]='1', g[2][1]='0')308 // If we treat g[2][0] as '0', they'd be equal.309 if !colEqualWith(g, 6, 0, 1, 2, '0') {310 t.Error("colEqualWith should return true when override matches")311 }312 })313314 t.Run("override doesn't help", func(t *testing.T) {315 if colEqualWith(g, 6, 0, 2, 1, '0') {316 t.Error("colEqualWith should return false when columns differ elsewhere")317 }318 })319}320321// --- rowFilled / colFilled (P1) ---322323func TestRowFilled(t *testing.T) {324 g := makeGrid6(325 "001011",326 "01....",327 "......",328 "101010",329 "......",330 "......",331 )332333 tests := []struct {334 name string335 y int336 want bool337 }{338 {"all filled", 0, true},339 {"has empty", 1, false},340 {"all empty", 2, false},341 {"all filled again", 3, true},342 }343344 for _, tt := range tests {345 t.Run(tt.name, func(t *testing.T) {346 if got := rowFilled(g, tt.y, 6); got != tt.want {347 t.Errorf("rowFilled(row %d) = %v, want %v", tt.y, got, tt.want)348 }349 })350 }351}352353func TestColFilled(t *testing.T) {354 g := makeGrid6(355 "0.....",356 "0.....",357 "1.....",358 "0.....",359 "1.....",360 "1.....",361 )362363 t.Run("all filled", func(t *testing.T) {364 if !colFilled(g, 0, 6) {365 t.Error("colFilled should return true for filled column")366 }367 })368369 t.Run("has empty", func(t *testing.T) {370 if colFilled(g, 1, 6) {371 t.Error("colFilled should return false for empty column")372 }373 })374}375376// --- checkConstraints (P0) ---377378func TestCheckConstraints(t *testing.T) {379 t.Run("valid grid", func(t *testing.T) {380 g := validGrid6()381 if !checkConstraints(g, 6) {382 t.Error("checkConstraints should return true for valid grid")383 }384 })385386 t.Run("triple in row", func(t *testing.T) {387 g := makeGrid6(388 "000111", // Triple 0s and triple 1s389 "010101",390 "101010",391 "110100",392 "100110",393 "011001",394 )395 if checkConstraints(g, 6) {396 t.Error("checkConstraints should reject grid with triple in row")397 }398 })399400 t.Run("triple in column", func(t *testing.T) {401 g := makeGrid6(402 "001011",403 "001011",404 "001011", // Column 0 has triple 0s405 "110100",406 "100110",407 "011001",408 )409 if checkConstraints(g, 6) {410 t.Error("checkConstraints should reject grid with triple in column")411 }412 })413414 t.Run("unequal row count", func(t *testing.T) {415 g := makeGrid6(416 "000011", // Valid count417 "010101",418 "101010",419 "111000", // Valid count420 "100110",421 "011001",422 )423 // Modify to break count: row 0 now has 4 zeros, 2 ones.424 g[0] = []rune("000001")425 if checkConstraints(g, 6) {426 t.Error("checkConstraints should reject unequal row count")427 }428 })429430 t.Run("unequal column count", func(t *testing.T) {431 g := validGrid6()432 // Break column 0 count.433 g[0][0] = oneCell434 g[1][0] = oneCell435 g[2][0] = oneCell436 g[3][0] = oneCell // Now column has 4 ones437 if checkConstraints(g, 6) {438 t.Error("checkConstraints should reject unequal column count")439 }440 })441}442443// --- hasUniqueLines (P0) ---444445func TestHasUniqueLines(t *testing.T) {446 t.Run("all unique", func(t *testing.T) {447 g := validGrid6()448 if !hasUniqueLines(g, 6) {449 t.Error("hasUniqueLines should return true for unique grid")450 }451 })452453 t.Run("duplicate rows", func(t *testing.T) {454 g := makeGrid6(455 "001011",456 "010101",457 "001011", // Duplicate of row 0458 "110100",459 "100110",460 "011001",461 )462 if hasUniqueLines(g, 6) {463 t.Error("hasUniqueLines should return false for duplicate rows")464 }465 })466467 t.Run("duplicate columns", func(t *testing.T) {468 g := makeGrid6(469 "001011",470 "001101",471 "101010",472 "101100",473 "001011",474 "001001",475 )476 // Columns 0 and 3 are both [0,0,1,1,0,0].477 if hasUniqueLines(g, 6) {478 t.Error("hasUniqueLines should return false for duplicate columns")479 }480 })481482 t.Run("partial duplicate still unique", func(t *testing.T) {483 g := makeGrid6(484 "001011",485 "001101", // Shares prefix with row 0 but differs at end486 "101010",487 "110100",488 "100110",489 "011001",490 )491 if !hasUniqueLines(g, 6) {492 t.Error("hasUniqueLines should return true when rows only partially match")493 }494 })495}496497// --- Grid serialization (P1) ---498499func TestGridSerialization(t *testing.T) {500 t.Run("6x6 round-trip", func(t *testing.T) {501 g := makeGrid6("001011", "010101", "101010", "110100", "100110", "011001")502 s := g.String()503 g2 := newGrid(state(s))504505 if len(g2) != len(g) {506 t.Fatalf("row count = %d, want %d", len(g2), len(g))507 }508 for y := range g {509 for x := range g[y] {510 if g2[y][x] != g[y][x] {511 t.Errorf("cell (%d,%d) = %c, want %c", x, y, g2[y][x], g[y][x])512 }513 }514 }515 })516517 t.Run("8x8 round-trip", func(t *testing.T) {518 s := createEmptyState(8)519 g := newGrid(s)520 s2 := g.String()521 if string(s) != s2 {522 t.Error("8x8 round-trip failed")523 }524 })525526 t.Run("all empty", func(t *testing.T) {527 s := createEmptyState(6)528 g := newGrid(s)529 for y := range g {530 for x := range g[y] {531 if g[y][x] != emptyCell {532 t.Errorf("cell (%d,%d) = %c, want emptyCell", x, y, g[y][x])533 }534 }535 }536 })537}538539func TestGridClone(t *testing.T) {540 original := makeGrid6("001011", "010101", "101010", "110100", "100110", "011001")541 cloned := original.clone()542543 t.Run("content match", func(t *testing.T) {544 for y := range original {545 for x := range original[y] {546 if cloned[y][x] != original[y][x] {547 t.Errorf("cloned[%d][%d] = %c, want %c", y, x, cloned[y][x], original[y][x])548 }549 }550 }551 })552553 t.Run("deep copy", func(t *testing.T) {554 cloned[0][0] = 'X'555 if original[0][0] == 'X' {556 t.Error("clone modified original grid")557 }558 })559}560561func TestCreateEmptyState(t *testing.T) {562 t.Run("size 6", func(t *testing.T) {563 s := createEmptyState(6)564 g := newGrid(s)565 if len(g) != 6 {566 t.Fatalf("row count = %d, want 6", len(g))567 }568 for y := range g {569 if len(g[y]) != 6 {570 t.Fatalf("col count at row %d = %d, want 6", y, len(g[y]))571 }572 for x := range g[y] {573 if g[y][x] != emptyCell {574 t.Errorf("cell (%d,%d) = %c, want emptyCell", x, y, g[y][x])575 }576 }577 }578 })579580 t.Run("size 0", func(t *testing.T) {581 s := createEmptyState(0)582 if s != "" {583 t.Errorf("createEmptyState(0) = %q, want empty", s)584 }585 })586}587588// --- serializeProvided / deserializeProvided (P1) ---589590func TestProvidedSerialization(t *testing.T) {591 t.Run("round-trip", func(t *testing.T) {592 p := [][]bool{593 {true, false, true, false, true, false},594 {false, true, false, true, false, true},595 {true, true, false, false, true, true},596 {false, false, true, true, false, false},597 {true, false, true, false, true, false},598 {false, true, false, true, false, true},599 }600 s := serializeProvided(p)601 p2 := deserializeProvided(s, 6)602603 if len(p2) != 6 {604 t.Fatalf("row count = %d, want 6", len(p2))605 }606 for y := range p {607 for x := range p[y] {608 if p2[y][x] != p[y][x] {609 t.Errorf("provided[%d][%d] = %v, want %v", y, x, p2[y][x], p[y][x])610 }611 }612 }613 })614615 t.Run("all provided", func(t *testing.T) {616 p := make([][]bool, 3)617 for y := range p {618 p[y] = []bool{true, true, true}619 }620 s := serializeProvided(p)621 if s != "###\n###\n###" {622 t.Errorf("serializeProvided = %q, want ###\\n###\\n###", s)623 }624 })625626 t.Run("none provided", func(t *testing.T) {627 p := make([][]bool, 3)628 for y := range p {629 p[y] = []bool{false, false, false}630 }631 s := serializeProvided(p)632 if s != "...\n...\n..." {633 t.Errorf("serializeProvided = %q, want ...\\n...\\n...", s)634 }635 })636637 t.Run("checkerboard", func(t *testing.T) {638 p := [][]bool{639 {true, false},640 {false, true},641 }642 s := serializeProvided(p)643 expected := "#.\n.#"644 if s != expected {645 t.Errorf("serializeProvided = %q, want %q", s, expected)646 }647 p2 := deserializeProvided(s, 2)648 for y := range p {649 for x := range p[y] {650 if p2[y][x] != p[y][x] {651 t.Errorf("deserialized[%d][%d] = %v, want %v", y, x, p2[y][x], p[y][x])652 }653 }654 }655 })656}657658// --- fillGrid (P2) ---659660func TestFillGrid(t *testing.T) {661 t.Run("fills 6x6", func(t *testing.T) {662 g := newGrid(createEmptyState(6))663 ok := fillGrid(g, 6)664 if !ok {665 t.Fatal("fillGrid returned false")666 }667668 // Check no empty cells.669 for y := range g {670 for x := range g[y] {671 if g[y][x] == emptyCell {672 t.Errorf("cell (%d,%d) is still empty", x, y)673 }674 }675 }676 })677678 t.Run("result valid", func(t *testing.T) {679 g := newGrid(createEmptyState(6))680 fillGrid(g, 6)681 if !checkConstraints(g, 6) {682 t.Error("fillGrid produced grid that fails checkConstraints")683 }684 })685686 t.Run("result has unique lines", func(t *testing.T) {687 g := newGrid(createEmptyState(6))688 fillGrid(g, 6)689 if !hasUniqueLines(g, 6) {690 t.Error("fillGrid produced grid with duplicate lines")691 }692 })693}694695// --- countSolutions (P2) ---696697func TestCountSolutions(t *testing.T) {698 t.Run("complete valid grid", func(t *testing.T) {699 g := validGrid6()700 count := countSolutions(g, 6, 10)701 if count != 1 {702 t.Errorf("countSolutions = %d, want 1", count)703 }704 })705706 t.Run("one empty cell unique", func(t *testing.T) {707 g := validGrid6()708 // Remove one cell.709 saved := g[0][0]710 g[0][0] = emptyCell711 count := countSolutions(g, 6, 10)712 if count != 1 {713 t.Errorf("countSolutions = %d, want 1", count)714 }715 g[0][0] = saved716 })717718 t.Run("multiple solutions with limit", func(t *testing.T) {719 g := newGrid(createEmptyState(6))720 // Empty grid has many solutions, should hit limit.721 count := countSolutions(g, 6, 2)722 if count < 2 {723 t.Errorf("countSolutions = %d, want >= 2", count)724 }725 })726727 t.Run("respects limit", func(t *testing.T) {728 g := newGrid(createEmptyState(6))729 count := countSolutions(g, 6, 3)730 if count > 3 {731 t.Errorf("countSolutions = %d, should not exceed limit 3", count)732 }733 })734}735736// --- generatePuzzle (P2) ---737738func TestGeneratePuzzle(t *testing.T) {739 complete := generateComplete(6)740741 t.Run("unique solution", func(t *testing.T) {742 puzzle, _ := generatePuzzle(complete, 6, 0.5)743 count := countSolutions(puzzle, 6, 2)744 if count != 1 {745 t.Errorf("generated puzzle has %d solutions, want 1", count)746 }747 })748749 t.Run("provided mask consistent with empties", func(t *testing.T) {750 puzzle, provided := generatePuzzle(complete, 6, 0.5)751 for y := range puzzle {752 for x := range puzzle[y] {753 isEmpty := puzzle[y][x] == emptyCell754 isProvided := provided[y][x]755 if isEmpty == isProvided {756 t.Errorf("cell (%d,%d): isEmpty=%v but isProvided=%v", x, y, isEmpty, isProvided)757 }758 }759 }760 })761762 t.Run("approximate prefill", func(t *testing.T) {763 _, provided := generatePuzzle(complete, 6, 0.5)764 count := 0765 for y := range provided {766 for x := range provided[y] {767 if provided[y][x] {768 count++769 }770 }771 }772 target := 18 // 50% of 36773 if count < target-5 || count > target+5 {774 t.Errorf("provided cell count = %d, want ~%d (±5)", count, target)775 }776 })777}778779// --- Save/Load round-trip (P1) ---780781func TestSaveLoadRoundTrip(t *testing.T) {782 puzzle := makeGrid6("00101.", "01....", "......", "......", "......", "......")783 provided := make([][]bool, 6)784 for y := range 6 {785 provided[y] = make([]bool, 6)786 for x := range 6 {787 provided[y][x] = puzzle[y][x] != emptyCell788 }789 }790791 m := Model{792 size: 6,793 grid: puzzle,794 provided: provided,795 cursor: game.Cursor{X: 2, Y: 3},796 keys: DefaultKeyMap,797 modeTitle: "Medium",798 solved: false,799 }800801 data, err := m.GetSave()802 if err != nil {803 t.Fatal(err)804 }805806 got, err := ImportModel(data)807 if err != nil {808 t.Fatal(err)809 }810811 // Size.812 if got.size != 6 {813 t.Errorf("size = %d, want 6", got.size)814 }815816 // Grid content.817 if got.grid.String() != m.grid.String() {818 t.Error("grid mismatch")819 }820821 // Provided matrix.822 for y := range provided {823 for x := range provided[y] {824 if got.provided[y][x] != provided[y][x] {825 t.Errorf("provided[%d][%d] = %v, want %v", y, x, got.provided[y][x], provided[y][x])826 }827 }828 }829830 // Mode title.831 if got.modeTitle != "Medium" {832 t.Errorf("modeTitle = %q, want %q", got.modeTitle, "Medium")833 }834}835836func TestImportModel_InvalidJSON(t *testing.T) {837 _, err := ImportModel([]byte("not json"))838 if err == nil {839 t.Fatal("expected error for invalid JSON")840 }841}842843func TestImportModel_InvalidSize(t *testing.T) {844 data, _ := json.Marshal(Save{Size: 0})845 _, err := ImportModel(data)846 if err == nil {847 t.Fatal("expected error for invalid size")848 }849}850851func TestImportModel_SolvedRecalculated(t *testing.T) {852 g := validGrid6()853 provided := make([][]bool, 6)854 for y := range 6 {855 provided[y] = make([]bool, 6)856 }857858 data, _ := json.Marshal(Save{859 Size: 6,860 State: g.String(),861 Provided: serializeProvided(provided),862 })863864 got, err := ImportModel(data)865 if err != nil {866 t.Fatal(err)867 }868869 if !got.solved {870 t.Error("ImportModel should set solved=true for complete valid grid")871 }872}873874// --- Uniqueness across all modes (P0) ---875876func TestGeneratePuzzle_AllModes_Unique(t *testing.T) {877 if testing.Short() {878 t.Skip("skipping slow generator test in short mode")879 }880 modes := []struct {881 name string882 size int883 prefilled float64884 runs int885 }{886 {"Beginner 6x6", 6, 0.50, 3},887 {"Easy 6x6", 6, 0.40, 3},888 {"Medium 8x8", 8, 0.40, 3},889 {"Tricky 10x10", 10, 0.38, 2},890 {"Hard 10x10", 10, 0.32, 2},891 {"Very Hard 12x12", 12, 0.30, 1},892 {"Extreme 14x14", 14, 0.28, 1},893 }894895 for _, m := range modes {896 t.Run(m.name, func(t *testing.T) {897 for range m.runs {898 complete := generateComplete(m.size)899 puzzle, _ := generatePuzzle(complete, m.size, m.prefilled)900 count := countSolutions(puzzle, m.size, 2)901 if count != 1 {902 t.Errorf("puzzle has %d solutions, want 1", count)903 return904 }905 }906 })907 }908}909910func TestGeneratePuzzle_AllModes_SeededRegression(t *testing.T) {911 if testing.Short() {912 t.Skip("skipping slow generator test in short mode")913 }914915 modes := []struct {916 name string917 size int918 prefilled float64919 seeds []uint64920 }{921 {"Beginner 6x6", 6, 0.50, []uint64{11}},922 {"Easy 6x6", 6, 0.40, []uint64{23}},923 {"Medium 8x8", 8, 0.40, []uint64{31}},924 {"Tricky 10x10", 10, 0.38, []uint64{41}},925 {"Hard 10x10", 10, 0.32, []uint64{43}},926 {"Very Hard 12x12", 12, 0.30, []uint64{47}},927 {"Extreme 14x14", 14, 0.28, []uint64{53}},928 }929930 for _, mode := range modes {931 t.Run(mode.name, func(t *testing.T) {932 for _, seed := range mode.seeds {933 rng := rand.New(rand.NewPCG(seed, seed*13+1))934 complete := generateCompleteSeeded(mode.size, rng)935936 if !checkConstraints(complete, mode.size) {937 t.Fatalf("seed %d produced invalid complete grid", seed)938 }939 if !hasUniqueLines(complete, mode.size) {940 t.Fatalf("seed %d produced duplicate lines in complete grid", seed)941 }942943 puzzle, _ := generatePuzzleSeeded(complete, mode.size, mode.prefilled, rng)944 if count := countSolutions(puzzle, mode.size, 2); count != 1 {945 t.Fatalf("seed %d produced puzzle with %d solutions, want 1", seed, count)946 }947 }948 })949 }950}951952func TestMouseClickMovesCursor(t *testing.T) {953 m := testMouseModel()954955 x, y := takuzuCellScreenCoords(&m, 2, 1)956 next, _ := m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})957 got := next.(Model)958959 if got.cursor.X != 2 || got.cursor.Y != 1 {960 t.Fatalf("cursor = (%d,%d), want (2,1)", got.cursor.X, got.cursor.Y)961 }962 if got.grid[1][2] != emptyCell {963 t.Fatalf("clicked destination cell changed to %q, want unchanged empty", got.grid[1][2])964 }965}966967func TestMouseClickSameCellCyclesEditableCell(t *testing.T) {968 m := testMouseModel()969 m.cursor.X, m.cursor.Y = 1, 1970971 x, y := takuzuCellScreenCoords(&m, 1, 1)972973 next, _ := m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})974 got := next.(Model)975 if got.grid[1][1] != zeroCell {976 t.Fatalf("first click = %q, want %q", got.grid[1][1], zeroCell)977 }978979 next, _ = got.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})980 got = next.(Model)981 if got.grid[1][1] != oneCell {982 t.Fatalf("second click = %q, want %q", got.grid[1][1], oneCell)983 }984985 next, _ = got.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})986 got = next.(Model)987 if got.grid[1][1] != emptyCell {988 t.Fatalf("third click = %q, want %q", got.grid[1][1], emptyCell)989 }990}991992func TestMouseClickSameCellDoesNotCycleProvidedCell(t *testing.T) {993 m := testMouseModel()994 m.cursor.X, m.cursor.Y = 0, 0995996 x, y := takuzuCellScreenCoords(&m, 0, 0)997 next, _ := m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})998 got := next.(Model)9991000 if got.grid[0][0] != zeroCell {1001 t.Fatalf("provided cell changed to %q, want %q", got.grid[0][0], zeroCell)1002 }1003}10041005func TestCellViewUsesGivenTintForProvidedCells(t *testing.T) {1006 p := theme.Current()10071008 gotZero := cellView(zeroCell, true, false, false, false, false, false, false)1009 wantZero := lipgloss.NewStyle().1010 Bold(true).1011 Foreground(p.Accent).1012 Background(theme.GivenTint(p.BG)).1013 Width(cellWidth).1014 AlignHorizontal(lipgloss.Center).1015 Render(renderRuneMap[zeroCell])1016 if gotZero != wantZero {1017 t.Fatalf("provided zero cellView() = %q, want %q", gotZero, wantZero)1018 }10191020 gotOne := cellView(oneCell, true, false, false, false, false, false, false)1021 wantOne := lipgloss.NewStyle().1022 Bold(true).1023 Foreground(p.Secondary).1024 Background(theme.GivenTint(p.BG)).1025 Width(cellWidth).1026 AlignHorizontal(lipgloss.Center).1027 Render(renderRuneMap[oneCell])1028 if gotOne != wantOne {1029 t.Fatalf("provided one cellView() = %q, want %q", gotOne, wantOne)1030 }1031}10321033func TestCellViewCursorUsesGlyphsWithoutChangingEmptyCellColors(t *testing.T) {1034 p := theme.Current()10351036 got := cellView(emptyCell, false, true, false, false, false, false, false)1037 want := lipgloss.NewStyle().1038 Foreground(p.TextDim).1039 Background(p.BG).1040 Width(cellWidth).1041 AlignHorizontal(lipgloss.Center).1042 Render(game.CursorLeft + "·" + game.CursorRight)10431044 if got != want {1045 t.Fatalf("cursor empty cellView() = %q, want %q", got, want)1046 }1047}10481049func TestCellViewCursorPreservesProvidedCellColors(t *testing.T) {1050 p := theme.Current()10511052 got := cellView(zeroCell, true, true, true, false, false, false, false)1053 want := lipgloss.NewStyle().1054 Bold(true).1055 Foreground(p.Accent).1056 Background(theme.GivenTint(p.BG)).1057 Width(cellWidth).1058 AlignHorizontal(lipgloss.Center).1059 Render(game.CursorLeft + "●" + game.CursorRight)10601061 if got != want {1062 t.Fatalf("cursor provided zero cellView() = %q, want %q", got, want)1063 }1064}10651066func TestKeyBindingsUseZeroAndOneForPlacement(t *testing.T) {1067 m := testMouseModel()1068 m.cursor = game.Cursor{X: 1, Y: 1}10691070 next, _ := m.Update(tea.KeyPressMsg{Code: '0', Text: "0"})1071 got := next.(Model)1072 if got.grid[1][1] != zeroCell {1073 t.Fatalf("key 0 cell = %q, want %q", got.grid[1][1], zeroCell)1074 }10751076 next, _ = got.Update(tea.KeyPressMsg{Code: '1', Text: "1"})1077 got = next.(Model)1078 if got.grid[1][1] != oneCell {1079 t.Fatalf("key 1 cell = %q, want %q", got.grid[1][1], oneCell)1080 }1081}10821083func TestBuildCountContextUsesCursorRowAndColumn(t *testing.T) {1084 m := testMouseModel()1085 m.cursor = game.Cursor{X: 3, Y: 0}10861087 ctx := buildCountContext(m.grid, m.cursor, m.size)1088 if got, want := ctx.row, (countPair{zeros: 1, ones: 1}); got != want {1089 t.Fatalf("row counts = %+v, want %+v", got, want)1090 }1091 if got, want := ctx.col, (countPair{zeros: 1, ones: 1}); got != want {1092 t.Fatalf("col counts = %+v, want %+v", got, want)1093 }1094 if got, want := ctx.target, 2; got != want {1095 t.Fatalf("target = %d, want %d", got, want)1096 }1097}10981099func TestCountContextViewHighlightsMetAndOverGoals(t *testing.T) {1100 m := Model{1101 size: 4,1102 grid: grid{1103 []rune("0011"),1104 []rune("000."),1105 []rune("...."),1106 []rune("...."),1107 },1108 cursor: game.Cursor{X: 0, Y: 1},1109 }11101111 view := countContextView(m)1112 wantMet := metGoalCountStyle(zeroCell).Width(3).AlignHorizontal(lipgloss.Right).Render("2/2")1113 if !strings.Contains(view, wantMet) {1114 t.Fatalf("countContextView() = %q, want met-goal chip %q", view, wantMet)1115 }1116 wantOver := overGoalCountStyle().Width(3).AlignHorizontal(lipgloss.Right).Render("3/2")1117 if !strings.Contains(view, wantOver) {1118 t.Fatalf("countContextView() = %q, want over-goal chip %q", view, wantOver)1119 }1120 if !strings.Contains(view, string([]rune(renderRuneMap[zeroCell])[1])+":") {1121 t.Fatalf("countContextView() = %q, want zero symbol label", view)1122 }1123 if !strings.Contains(view, string([]rune(renderRuneMap[oneCell])[1])+":") {1124 t.Fatalf("countContextView() = %q, want one symbol label", view)1125 }1126 wantZeroLabel := countLabelStyle(zeroCell).Render(string([]rune(renderRuneMap[zeroCell])[1]) + ":")1127 if !strings.Contains(view, wantZeroLabel) {1128 t.Fatalf("countContextView() = %q, want colored zero label %q", view, wantZeroLabel)1129 }1130 wantOneLabel := countLabelStyle(oneCell).Render(string([]rune(renderRuneMap[oneCell])[1]) + ":")1131 if !strings.Contains(view, wantOneLabel) {1132 t.Fatalf("countContextView() = %q, want colored one label %q", view, wantOneLabel)1133 }1134}11351136func TestBridgeFillLeavesVerticalOpenInteriorUnfilled(t *testing.T) {1137 m := testMouseModel()1138 m.cursor = game.Cursor{X: 0, Y: 1}11391140 got := bridgeFill(m, game.DynamicGridBridge{1141 Kind: game.DynamicGridBridgeVertical,1142 X: 1,1143 Y: 1,1144 Count: 2,1145 Uniform: true,1146 Cells: [4]image.Point{1147 {X: 0, Y: 1},1148 {X: 1, Y: 1},1149 },1150 })1151 if got != nil {1152 t.Fatal("expected open vertical bridge on cursor row to remain unfilled")1153 }1154}11551156func TestBridgeFillLeavesHorizontalOpenInteriorUnfilled(t *testing.T) {1157 m := testMouseModel()1158 m.cursor = game.Cursor{X: 0, Y: 1}11591160 got := bridgeFill(m, game.DynamicGridBridge{1161 Kind: game.DynamicGridBridgeHorizontal,1162 X: 0,1163 Y: 1,1164 Count: 2,1165 Uniform: true,1166 Cells: [4]image.Point{1167 {X: 0, Y: 0},1168 {X: 0, Y: 1},1169 },1170 })1171 if got != nil {1172 t.Fatal("expected open horizontal bridge on cursor column to remain unfilled")1173 }1174}11751176func TestBridgeFillLeavesInteriorJunctionOpen(t *testing.T) {1177 m := testMouseModel()1178 m.cursor = game.Cursor{X: 0, Y: 1}11791180 got := bridgeFill(m, game.DynamicGridBridge{1181 Kind: game.DynamicGridBridgeJunction,1182 X: 1,1183 Y: 1,1184 Count: 4,1185 Uniform: true,1186 Cells: [4]image.Point{1187 {X: 0, Y: 0},1188 {X: 1, Y: 0},1189 {X: 0, Y: 1},1190 {X: 1, Y: 1},1191 },1192 })1193 if got != nil {1194 t.Fatal("expected open interior junction to remain unfilled")1195 }1196}11971198func testMouseModel() Model {1199 return Model{1200 size: 4,1201 grid: grid{1202 []rune("0..1"),1203 []rune("...."),1204 []rune("...."),1205 []rune("1..0"),1206 },1207 initialGrid: grid{1208 []rune("0..1"),1209 []rune("...."),1210 []rune("...."),1211 []rune("1..0"),1212 },1213 provided: [][]bool{1214 {true, false, false, true},1215 {false, false, false, false},1216 {false, false, false, false},1217 {true, false, false, true},1218 },1219 keys: DefaultKeyMap,1220 modeTitle: "Test",1221 termWidth: 120,1222 termHeight: 40,1223 }1224}12251226func takuzuCellScreenCoords(m *Model, col, row int) (int, int) {1227 ox, oy := m.gridOrigin()1228 return ox + col*(cellWidth+1), oy + row*21229}