clone

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

main
puzzletea / games / wordsearch / wordsearch_test.go
wordsearch_test.go 29.1K
1package wordsearch23import (4 "encoding/json"5 "math/rand/v2"6 "testing"78 tea "charm.land/bubbletea/v2"9 "github.com/FelineStateMachine/puzzletea/game"10)1112func testRNG(seed uint64) *rand.Rand {13 return rand.New(rand.NewPCG(seed, seed+1))14}1516// --- Direction.Vector (P0) ---1718func TestDirectionVector(t *testing.T) {19 tests := []struct {20 name string21 dir Direction22 wantDX int23 wantDY int24 }{25 {"Right", Right, 1, 0},26 {"Down", Down, 0, 1},27 {"DownRight", DownRight, 1, 1},28 {"DownLeft", DownLeft, -1, 1},29 {"Left", Left, -1, 0},30 {"Up", Up, 0, -1},31 {"UpRight", UpRight, 1, -1},32 {"UpLeft", UpLeft, -1, -1},33 {"invalid", Direction(99), 0, 0},34 }3536 for _, tt := range tests {37 t.Run(tt.name, func(t *testing.T) {38 dx, dy := tt.dir.Vector()39 if dx != tt.wantDX || dy != tt.wantDY {40 t.Errorf("Direction(%d).Vector() = (%d,%d), want (%d,%d)", tt.dir, dx, dy, tt.wantDX, tt.wantDY)41 }42 })43 }44}4546// --- Word.Contains (P0) ---4748func TestWordContains(t *testing.T) {49 hello := Word{Text: "HELLO", Start: Position{0, 0}, End: Position{4, 0}, Direction: Right}50 cat := Word{Text: "CAT", Start: Position{1, 1}, End: Position{3, 3}, Direction: DownRight}51 single := Word{Text: "A", Start: Position{3, 3}, End: Position{3, 3}, Direction: Right}5253 tests := []struct {54 name string55 word *Word56 pos Position57 want bool58 }{59 {"start position", &hello, Position{0, 0}, true},60 {"end position", &hello, Position{4, 0}, true},61 {"middle position", &hello, Position{2, 0}, true},62 {"off-path position", &hello, Position{0, 1}, false},63 {"past-end position", &hello, Position{5, 0}, false},64 {"diagonal word hit", &cat, Position{2, 2}, true},65 {"diagonal start", &cat, Position{1, 1}, true},66 {"diagonal off-path", &cat, Position{1, 2}, false},67 {"single-letter hit", &single, Position{3, 3}, true},68 {"single-letter miss", &single, Position{3, 4}, false},69 }7071 for _, tt := range tests {72 t.Run(tt.name, func(t *testing.T) {73 if got := tt.word.Contains(tt.pos); got != tt.want {74 t.Errorf("Contains(%v) = %v, want %v", tt.pos, got, tt.want)75 }76 })77 }78}7980// --- Word.Positions (P0) ---8182func TestWordPositions(t *testing.T) {83 tests := []struct {84 name string85 word Word86 want []Position87 }{88 {89 "horizontal 5-letter",90 Word{Text: "HELLO", Start: Position{0, 0}, Direction: Right},91 []Position{{0, 0}, {1, 0}, {2, 0}, {3, 0}, {4, 0}},92 },93 {94 "vertical 3-letter",95 Word{Text: "CAT", Start: Position{2, 0}, Direction: Down},96 []Position{{2, 0}, {2, 1}, {2, 2}},97 },98 {99 "diagonal 2-letter",100 Word{Text: "AB", Start: Position{0, 0}, Direction: DownRight},101 []Position{{0, 0}, {1, 1}},102 },103 {104 "single letter",105 Word{Text: "A", Start: Position{3, 3}, Direction: Right},106 []Position{{3, 3}},107 },108 }109110 for _, tt := range tests {111 t.Run(tt.name, func(t *testing.T) {112 got := tt.word.Positions()113 if len(got) != len(tt.want) {114 t.Fatalf("len(Positions()) = %d, want %d", len(got), len(tt.want))115 }116 for i := range tt.want {117 if got[i] != tt.want[i] {118 t.Errorf("Positions()[%d] = %v, want %v", i, got[i], tt.want[i])119 }120 }121 })122 }123124 t.Run("count matches text length", func(t *testing.T) {125 w := Word{Text: "TESTING", Start: Position{0, 0}, Direction: Right}126 if got := len(w.Positions()); got != len(w.Text) {127 t.Errorf("len(Positions()) = %d, want %d", got, len(w.Text))128 }129 })130}131132func TestBackspaceCancelsSelection(t *testing.T) {133 m := Model{134 width: 3,135 height: 3,136 grid: createEmptyGrid(3, 3),137 selection: startSelected,138 keys: DefaultKeyMap,139 foundCells: buildFoundCells(3, 3, nil),140 }141142 next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyBackspace})143 got := next.(Model)144 if got.selection != noSelection {145 t.Fatal("expected backspace to clear the current selection")146 }147}148149func TestSelectionMoveSnapsToNearestRay(t *testing.T) {150 m := Model{151 width: 5,152 height: 5,153 grid: createEmptyGrid(5, 5),154 cursor: game.Cursor{X: 1, Y: 1},155 keys: DefaultKeyMap,156 foundCells: buildFoundCells(5, 5, nil),157 }158159 m.handleSelect()160161 next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyRight})162 got := next.(Model)163 if got.cursor != (game.Cursor{X: 2, Y: 1}) {164 t.Fatalf("after right, cursor = (%d,%d), want (2,1)", got.cursor.X, got.cursor.Y)165 }166167 next, _ = got.Update(tea.KeyPressMsg{Code: tea.KeyDown})168 got = next.(Model)169 if got.cursor != (game.Cursor{X: 2, Y: 2}) {170 t.Fatalf("after down, cursor = (%d,%d), want (2,2)", got.cursor.X, got.cursor.Y)171 }172}173174func TestSelectionMoveShrinksDiagonalWithArrowKeys(t *testing.T) {175 m := Model{176 width: 5,177 height: 5,178 grid: createEmptyGrid(5, 5),179 cursor: game.Cursor{X: 3, Y: 3},180 selection: startSelected,181 selectionStart: game.Cursor{X: 1, Y: 1},182 keys: DefaultKeyMap,183 foundCells: buildFoundCells(5, 5, nil),184 }185186 next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyLeft})187 got := next.(Model)188 if got.cursor != (game.Cursor{X: 2, Y: 2}) {189 t.Fatalf("after left, cursor = (%d,%d), want (2,2)", got.cursor.X, got.cursor.Y)190 }191192 next, _ = got.Update(tea.KeyPressMsg{Code: tea.KeyUp})193 got = next.(Model)194 if got.cursor != (game.Cursor{X: 1, Y: 1}) {195 t.Fatalf("after up, cursor = (%d,%d), want (1,1)", got.cursor.X, got.cursor.Y)196 }197198 next, _ = got.Update(tea.KeyPressMsg{Code: tea.KeyDown})199 got = next.(Model)200 if got.cursor != (game.Cursor{X: 1, Y: 2}) {201 t.Fatalf("after down from collapsed selection, cursor = (%d,%d), want (1,2)", got.cursor.X, got.cursor.Y)202 }203}204205// --- Grid operations (P1) ---206207func TestGridGetSet(t *testing.T) {208 g := createEmptyGrid(5, 5)209210 t.Run("Set then Get", func(t *testing.T) {211 g.Set(2, 3, 'A')212 if got := g.Get(2, 3); got != 'A' {213 t.Errorf("Get(2,3) = %c, want A", got)214 }215 })216217 t.Run("Get out of bounds negative x", func(t *testing.T) {218 if got := g.Get(-1, 0); got != 0 {219 t.Errorf("Get(-1,0) = %d, want 0", got)220 }221 })222223 t.Run("Get out of bounds negative y", func(t *testing.T) {224 if got := g.Get(0, -1); got != 0 {225 t.Errorf("Get(0,-1) = %d, want 0", got)226 }227 })228229 t.Run("Get out of bounds over x", func(t *testing.T) {230 if got := g.Get(5, 0); got != 0 {231 t.Errorf("Get(5,0) = %d, want 0", got)232 }233 })234235 t.Run("Get out of bounds over y", func(t *testing.T) {236 if got := g.Get(0, 5); got != 0 {237 t.Errorf("Get(0,5) = %d, want 0", got)238 }239 })240241 t.Run("Set out of bounds no panic", func(t *testing.T) {242 g.Set(-1, 0, 'X') // must not panic243 g.Set(5, 0, 'X') // must not panic244 })245246 t.Run("overwrite existing", func(t *testing.T) {247 g.Set(0, 0, 'A')248 g.Set(0, 0, 'B')249 if got := g.Get(0, 0); got != 'B' {250 t.Errorf("Get(0,0) = %c, want B", got)251 }252 })253}254255func TestGridSerialization(t *testing.T) {256 t.Run("round-trip", func(t *testing.T) {257 original := newGrid(state("ABC\nDEF\nGHI"))258 roundTripped := newGrid(state(original.String()))259260 if len(roundTripped) != len(original) {261 t.Fatalf("row count = %d, want %d", len(roundTripped), len(original))262 }263 for y := range original {264 for x := range original[y] {265 if roundTripped.Get(x, y) != original.Get(x, y) {266 t.Errorf("cell (%d,%d) = %c, want %c", x, y, roundTripped.Get(x, y), original.Get(x, y))267 }268 }269 }270 })271272 t.Run("empty state", func(t *testing.T) {273 g := newGrid(state(""))274 if len(g) != 0 {275 t.Errorf("len(newGrid empty) = %d, want 0", len(g))276 }277 })278279 t.Run("createEmptyGrid dimensions", func(t *testing.T) {280 g := createEmptyGrid(5, 5)281 if len(g) != 5 {282 t.Fatalf("height = %d, want 5", len(g))283 }284 for y := range g {285 if len(g[y]) != 5 {286 t.Fatalf("width of row %d = %d, want 5", y, len(g[y]))287 }288 for x := range g[y] {289 if g[y][x] != ' ' {290 t.Errorf("cell (%d,%d) = %c, want space", x, y, g[y][x])291 }292 }293 }294 })295296 t.Run("single row", func(t *testing.T) {297 g := newGrid(state("HELLO"))298 if len(g) != 1 {299 t.Fatalf("row count = %d, want 1", len(g))300 }301 if len(g[0]) != 5 {302 t.Errorf("col count = %d, want 5", len(g[0]))303 }304 })305306 t.Run("single column", func(t *testing.T) {307 g := newGrid(state("A\nB\nC"))308 if len(g) != 3 {309 t.Fatalf("row count = %d, want 3", len(g))310 }311 for _, row := range g {312 if len(row) != 1 {313 t.Errorf("col count = %d, want 1", len(row))314 }315 }316 })317}318319// --- lineDirection (P0) ---320321func TestLineDirection(t *testing.T) {322 tests := []struct {323 name string324 start game.Cursor325 end game.Cursor326 wantDX int327 wantDY int328 wantValid bool329 }{330 {"horizontal right", game.Cursor{X: 0, Y: 0}, game.Cursor{X: 4, Y: 0}, 1, 0, true},331 {"horizontal left", game.Cursor{X: 4, Y: 0}, game.Cursor{X: 0, Y: 0}, -1, 0, true},332 {"vertical down", game.Cursor{X: 0, Y: 0}, game.Cursor{X: 0, Y: 4}, 0, 1, true},333 {"vertical up", game.Cursor{X: 0, Y: 4}, game.Cursor{X: 0, Y: 0}, 0, -1, true},334 {"diagonal down-right", game.Cursor{X: 0, Y: 0}, game.Cursor{X: 3, Y: 3}, 1, 1, true},335 {"diagonal up-left", game.Cursor{X: 3, Y: 3}, game.Cursor{X: 0, Y: 0}, -1, -1, true},336 {"diagonal down-left", game.Cursor{X: 3, Y: 0}, game.Cursor{X: 0, Y: 3}, -1, 1, true},337 {"diagonal up-right", game.Cursor{X: 0, Y: 3}, game.Cursor{X: 3, Y: 0}, 1, -1, true},338 {"invalid slope", game.Cursor{X: 0, Y: 0}, game.Cursor{X: 2, Y: 3}, 0, 0, false},339 {"invalid steep", game.Cursor{X: 0, Y: 0}, game.Cursor{X: 1, Y: 3}, 0, 0, false},340 {"same point", game.Cursor{X: 2, Y: 2}, game.Cursor{X: 2, Y: 2}, 0, 0, false},341 {"adjacent horizontal", game.Cursor{X: 0, Y: 0}, game.Cursor{X: 1, Y: 0}, 1, 0, true},342 {"adjacent diagonal", game.Cursor{X: 0, Y: 0}, game.Cursor{X: 1, Y: 1}, 1, 1, true},343 }344345 for _, tt := range tests {346 t.Run(tt.name, func(t *testing.T) {347 dx, dy, valid := lineDirection(tt.start, tt.end)348 if valid != tt.wantValid {349 t.Fatalf("valid = %v, want %v", valid, tt.wantValid)350 }351 if dx != tt.wantDX || dy != tt.wantDY {352 t.Errorf("dx,dy = (%d,%d), want (%d,%d)", dx, dy, tt.wantDX, tt.wantDY)353 }354 })355 }356}357358// --- walkLine (P0) ---359360func TestWalkLine(t *testing.T) {361 tests := []struct {362 name string363 start game.Cursor364 end game.Cursor365 wantOK bool366 wantPos []Position367 }{368 {369 "horizontal line",370 game.Cursor{X: 0, Y: 0},371 game.Cursor{X: 3, Y: 0},372 true,373 []Position{{0, 0}, {1, 0}, {2, 0}, {3, 0}},374 },375 {376 "vertical line",377 game.Cursor{X: 2, Y: 0},378 game.Cursor{X: 2, Y: 3},379 true,380 []Position{{2, 0}, {2, 1}, {2, 2}, {2, 3}},381 },382 {383 "diagonal line",384 game.Cursor{X: 0, Y: 0},385 game.Cursor{X: 2, Y: 2},386 true,387 []Position{{0, 0}, {1, 1}, {2, 2}},388 },389 {390 "reverse diagonal",391 game.Cursor{X: 2, Y: 2},392 game.Cursor{X: 0, Y: 0},393 true,394 []Position{{2, 2}, {1, 1}, {0, 0}},395 },396 {397 "adjacent cells",398 game.Cursor{X: 0, Y: 0},399 game.Cursor{X: 1, Y: 0},400 true,401 []Position{{0, 0}, {1, 0}},402 },403 {404 "invalid line",405 game.Cursor{X: 0, Y: 0},406 game.Cursor{X: 1, Y: 2},407 false,408 nil,409 },410 {411 "same point",412 game.Cursor{X: 1, Y: 1},413 game.Cursor{X: 1, Y: 1},414 false,415 nil,416 },417 }418419 for _, tt := range tests {420 t.Run(tt.name, func(t *testing.T) {421 var visited []Position422 ok := walkLine(tt.start, tt.end, func(x, y int) {423 visited = append(visited, Position{x, y})424 })425426 if ok != tt.wantOK {427 t.Fatalf("walkLine returned %v, want %v", ok, tt.wantOK)428 }429430 if !tt.wantOK {431 if len(visited) != 0 {432 t.Errorf("fn was called %d times on invalid line", len(visited))433 }434 return435 }436437 if len(visited) != len(tt.wantPos) {438 t.Fatalf("visited %d positions, want %d", len(visited), len(tt.wantPos))439 }440 for i := range tt.wantPos {441 if visited[i] != tt.wantPos[i] {442 t.Errorf("visited[%d] = %v, want %v", i, visited[i], tt.wantPos[i])443 }444 }445 })446 }447}448449func TestSnapSelectionCursor(t *testing.T) {450 tests := []struct {451 name string452 start game.Cursor453 target game.Cursor454 maxX int455 maxY int456 want game.Cursor457 }{458 {459 name: "keeps exact horizontal target",460 start: game.Cursor{X: 2, Y: 2},461 target: game.Cursor{X: 4, Y: 2},462 maxX: 4,463 maxY: 4,464 want: game.Cursor{X: 4, Y: 2},465 },466 {467 name: "snaps off-axis target to diagonal",468 start: game.Cursor{X: 2, Y: 2},469 target: game.Cursor{X: 3, Y: 4},470 maxX: 4,471 maxY: 4,472 want: game.Cursor{X: 4, Y: 4},473 },474 {475 name: "snaps closer to vertical than diagonal",476 start: game.Cursor{X: 2, Y: 2},477 target: game.Cursor{X: 2, Y: 4},478 maxX: 4,479 maxY: 4,480 want: game.Cursor{X: 2, Y: 4},481 },482 }483484 for _, tt := range tests {485 t.Run(tt.name, func(t *testing.T) {486 got := snapSelectionCursor(tt.start, tt.target, tt.maxX, tt.maxY)487 if got != tt.want {488 t.Fatalf("snapSelectionCursor(%v, %v) = %v, want %v", tt.start, tt.target, got, tt.want)489 }490 })491 }492}493494func TestCurrentSelectionPreview(t *testing.T) {495 m := Model{496 width: 5,497 height: 5,498 grid: newGrid(state(499 "HELLO\n" +500 "AAAAA\n" +501 "BBBBB\n" +502 "CCCCC\n" +503 "DDDDD",504 )),505 words: []Word{506 {Text: "HELLO", Start: Position{0, 0}, End: Position{4, 0}, Direction: Right},507 {Text: "HELL", Start: Position{0, 1}, End: Position{3, 1}, Direction: Right, Found: true},508 },509 cursor: game.Cursor{X: 2, Y: 0},510 selection: startSelected,511 selectionStart: game.Cursor{X: 0, Y: 0},512 keys: DefaultKeyMap,513 foundCells: buildFoundCells(5, 5, nil),514 }515516 preview := m.currentSelectionPreview()517 if !preview.Valid {518 t.Fatal("expected preview to be valid")519 }520 if preview.Letters != "HEL" {521 t.Fatalf("Letters = %q, want HEL", preview.Letters)522 }523 if preview.Direction != "right" {524 t.Fatalf("Direction = %q, want right", preview.Direction)525 }526 if preview.NearWord != "HELLO" {527 t.Fatalf("NearWord = %q, want HELLO", preview.NearWord)528 }529 if preview.ExactWord != "" {530 t.Fatalf("ExactWord = %q, want empty", preview.ExactWord)531 }532533 m.cursor = game.Cursor{X: 4, Y: 0}534 preview = m.currentSelectionPreview()535 if preview.ExactWord != "HELLO" {536 t.Fatalf("ExactWord = %q, want HELLO", preview.ExactWord)537 }538}539540// --- reverseString (P1) ---541542func TestReverseString(t *testing.T) {543 tests := []struct {544 name string545 input string546 want string547 }{548 {"normal", "HELLO", "OLLEH"},549 {"single char", "A", "A"},550 {"empty", "", ""},551 {"palindrome", "ABBA", "ABBA"},552 {"two chars", "AB", "BA"},553 }554555 for _, tt := range tests {556 t.Run(tt.name, func(t *testing.T) {557 if got := reverseString(tt.input); got != tt.want {558 t.Errorf("reverseString(%q) = %q, want %q", tt.input, got, tt.want)559 }560 })561 }562}563564// --- tryPlaceWord (P2) ---565566func TestTryPlaceWord(t *testing.T) {567 t.Run("places word in empty grid", func(t *testing.T) {568 g := createEmptyGrid(10, 10)569 w := tryPlaceWordSeeded(g, "HELLO", []Direction{Right, Down, DownRight}, 1000, testRNG(1))570 if w == nil {571 t.Fatal("expected word to be placed")572 }573574 // Verify letters are in the grid at word positions.575 for i, pos := range w.Positions() {576 got := g.Get(pos.X, pos.Y)577 want := rune(w.Text[i])578 if got != want {579 t.Errorf("grid(%d,%d) = %c, want %c", pos.X, pos.Y, got, want)580 }581 }582 })583584 t.Run("respects allowed directions", func(t *testing.T) {585 g := createEmptyGrid(10, 10)586 w := tryPlaceWordSeeded(g, "HELLO", []Direction{Right}, 1000, testRNG(2))587 if w == nil {588 t.Fatal("expected word to be placed")589 }590 if w.Direction != Right {591 t.Errorf("direction = %d, want Right(%d)", w.Direction, Right)592 }593 })594595 t.Run("allows letter overlap", func(t *testing.T) {596 g := createEmptyGrid(1, 5)597 g.Set(0, 0, 'H')598 w := tryPlaceWordSeeded(g, "HELLO", []Direction{Right}, 1000, testRNG(3))599 if w == nil {600 t.Fatal("expected word to be placed with matching overlap")601 }602 })603604 t.Run("rejects conflicting overlap", func(t *testing.T) {605 g := createEmptyGrid(1, 5)606 for x := range 5 {607 g.Set(x, 0, 'X')608 }609 w := tryPlaceWordSeeded(g, "HELLO", []Direction{Right}, 100, testRNG(4))610 if w != nil {611 t.Error("expected nil for conflicting overlap")612 }613 })614615 t.Run("returns nil when impossible", func(t *testing.T) {616 g := createEmptyGrid(3, 3)617 w := tryPlaceWordSeeded(g, "TOOLONGWORD", []Direction{Right, Down}, 100, testRNG(5))618 if w != nil {619 t.Error("expected nil for word that cannot fit")620 }621 })622623 t.Run("uppercases text", func(t *testing.T) {624 g := createEmptyGrid(10, 10)625 w := tryPlaceWordSeeded(g, "hello", []Direction{Right}, 1000, testRNG(6))626 if w == nil {627 t.Fatal("expected word to be placed")628 }629 if w.Text != "HELLO" {630 t.Errorf("Text = %q, want %q", w.Text, "HELLO")631 }632 })633}634635func TestTryPlaceWordSeededWithFallback(t *testing.T) {636 g := createEmptyGrid(1, 5)637 rng := testRNG(123)638639 word, stats := tryPlaceWordSeededWithFallback(g, "HELLO", []Direction{Right}, 0, rng)640 if word == nil {641 t.Fatal("expected fallback to place word")642 }643 if !stats.UsedFallback {644 t.Fatal("expected UsedFallback to be true")645 }646 if stats.RandomAttempts != 0 {647 t.Fatalf("RandomAttempts = %d, want 0", stats.RandomAttempts)648 }649 if stats.FallbackAttempts == 0 {650 t.Fatal("expected fallback attempts to be > 0")651 }652 if word.Start != (Position{X: 0, Y: 0}) || word.End != (Position{X: 4, Y: 0}) {653 t.Fatalf("unexpected placement: start=%v end=%v", word.Start, word.End)654 }655}656657// --- selectWords (P2) ---658659func TestSelectWords(t *testing.T) {660 t.Run("respects count", func(t *testing.T) {661 words := selectWordsSeeded(5, 4, 6, testRNG(7))662 if len(words) > 5 {663 t.Errorf("len(selectWords) = %d, want <= 5", len(words))664 }665 })666667 t.Run("respects length filter", func(t *testing.T) {668 words := selectWordsSeeded(20, 4, 6, testRNG(8))669 for _, w := range words {670 if len(w) < 4 || len(w) > 6 {671 t.Errorf("word %q has length %d, want 4-6", w, len(w))672 }673 }674 })675676 t.Run("clamps when too few", func(t *testing.T) {677 words := selectWordsSeeded(99999, 4, 4, testRNG(9))678 // Should return all 4-letter words, not 99999.679 if len(words) > 99999 {680 t.Error("returned more than requested")681 }682 if len(words) == 0 {683 t.Error("expected some words")684 }685 })686687 t.Run("no duplicates", func(t *testing.T) {688 rng := testRNG(42)689 words := selectWordsSeeded(500, 3, 10, rng)690 seen := make(map[string]struct{}, len(words))691692 for _, w := range words {693 if _, ok := seen[w]; ok {694 t.Fatalf("duplicate word selected: %q", w)695 }696 seen[w] = struct{}{}697 }698 })699}700701func TestOrderWordsForPlacement(t *testing.T) {702 words := []string{"AA", "BBBB", "CCC", "DDDD", "E"}703 orderWordsForPlacement(words)704705 want := []string{"BBBB", "DDDD", "CCC", "AA", "E"}706 for i := range want {707 if words[i] != want[i] {708 t.Fatalf("words[%d]=%q, want %q", i, words[i], want[i])709 }710 }711}712713// --- fillEmptyCells (P2) ---714715func TestFillEmptyCells(t *testing.T) {716 t.Run("fills all spaces", func(t *testing.T) {717 g := createEmptyGrid(5, 5)718 fillEmptyCellsSeeded(g, testRNG(10))719 for y := range g {720 for x := range g[y] {721 if g[y][x] == ' ' {722 t.Errorf("cell (%d,%d) is still a space", x, y)723 }724 }725 }726 })727728 t.Run("preserves existing letters", func(t *testing.T) {729 g := createEmptyGrid(5, 5)730 g.Set(0, 0, 'Z')731 fillEmptyCellsSeeded(g, testRNG(11))732 if got := g.Get(0, 0); got != 'Z' {733 t.Errorf("cell (0,0) = %c, want Z", got)734 }735 })736737 t.Run("only uppercase letters", func(t *testing.T) {738 g := createEmptyGrid(5, 5)739 fillEmptyCellsSeeded(g, testRNG(12))740 for y := range g {741 for x := range g[y] {742 c := g[y][x]743 if c < 'A' || c > 'Z' {744 t.Errorf("cell (%d,%d) = %c, not in A-Z", x, y, c)745 }746 }747 }748 })749}750751// --- GenerateWordSearch (P2) ---752753func TestGenerateWordSearch(t *testing.T) {754 t.Run("returns populated grid", func(t *testing.T) {755 g, _ := GenerateWordSearch(15, 15, 10, 4, 7, []Direction{Right, Down, DownRight})756 for y := range g {757 for x := range g[y] {758 if g[y][x] == ' ' {759 t.Errorf("cell (%d,%d) is empty", x, y)760 }761 }762 }763 })764765 t.Run("placed words match grid", func(t *testing.T) {766 g, words := GenerateWordSearch(15, 15, 10, 4, 7, []Direction{Right, Down, DownRight})767 for _, w := range words {768 for i, pos := range w.Positions() {769 got := g.Get(pos.X, pos.Y)770 want := rune(w.Text[i])771 if got != want {772 t.Errorf("word %q pos %d: grid(%d,%d) = %c, want %c", w.Text, i, pos.X, pos.Y, got, want)773 }774 }775 }776 })777778 t.Run("word count within requested", func(t *testing.T) {779 _, words := GenerateWordSearch(15, 15, 10, 4, 7, []Direction{Right, Down, DownRight})780 if len(words) > 10 {781 t.Errorf("len(words) = %d, want <= 10", len(words))782 }783 })784}785786// --- generation integrity regression (P0) ---787788func TestGenerateWordSearchSeeded_WordCountRegression(t *testing.T) {789 tests := []struct {790 name string791 width int792 height int793 wordCount int794 minWordLen int795 maxWordLen int796 allowedDirs []Direction797 minPlaced int798 }{799 {800 name: "easy_10x10",801 width: 10,802 height: 10,803 wordCount: 6,804 minWordLen: 3,805 maxWordLen: 5,806 allowedDirs: []Direction{Right, Down, DownRight},807 minPlaced: 6,808 },809 {810 name: "medium_15x15",811 width: 15,812 height: 15,813 wordCount: 10,814 minWordLen: 4,815 maxWordLen: 7,816 allowedDirs: []Direction{Right, Down, DownRight, DownLeft, Left, Up},817 minPlaced: 10,818 },819 {820 name: "hard_20x20",821 width: 20,822 height: 20,823 wordCount: 15,824 minWordLen: 5,825 maxWordLen: 10,826 allowedDirs: []Direction{Right, Down, DownRight, DownLeft, Left, Up, UpRight, UpLeft},827 minPlaced: 13,828 },829 }830831 for _, tt := range tests {832 t.Run(tt.name, func(t *testing.T) {833 for seed := range 128 {834 rng := testRNG(uint64(seed + 1))835 _, words := GenerateWordSearchSeeded(836 tt.width,837 tt.height,838 tt.wordCount,839 tt.minWordLen,840 tt.maxWordLen,841 tt.allowedDirs,842 rng,843 )844845 if len(words) > tt.wordCount {846 t.Fatalf("seed %d: len(words)=%d, want <= %d", seed, len(words), tt.wordCount)847 }848 if len(words) < tt.minPlaced {849 t.Fatalf("seed %d: len(words)=%d, want >= %d", seed, len(words), tt.minPlaced)850 }851 }852 })853 }854}855856func TestGenerateWordSearchSeeded_PlacementValidityRegression(t *testing.T) {857 tests := []struct {858 name string859 width int860 height int861 wordCount int862 minWordLen int863 maxWordLen int864 allowedDirs []Direction865 }{866 {867 name: "easy_10x10",868 width: 10,869 height: 10,870 wordCount: 6,871 minWordLen: 3,872 maxWordLen: 5,873 allowedDirs: []Direction{Right, Down, DownRight},874 },875 {876 name: "medium_15x15",877 width: 15,878 height: 15,879 wordCount: 10,880 minWordLen: 4,881 maxWordLen: 7,882 allowedDirs: []Direction{Right, Down, DownRight, DownLeft, Left, Up},883 },884 {885 name: "hard_20x20",886 width: 20,887 height: 20,888 wordCount: 15,889 minWordLen: 5,890 maxWordLen: 10,891 allowedDirs: []Direction{Right, Down, DownRight, DownLeft, Left, Up, UpRight, UpLeft},892 },893 }894895 for _, tt := range tests {896 t.Run(tt.name, func(t *testing.T) {897 for seed := range 64 {898 rng := testRNG(uint64(seed + 1))899 g, words := GenerateWordSearchSeeded(900 tt.width,901 tt.height,902 tt.wordCount,903 tt.minWordLen,904 tt.maxWordLen,905 tt.allowedDirs,906 rng,907 )908 assertWordPlacementsValid(t, g, words)909 }910 })911 }912}913914func assertWordPlacementsValid(t *testing.T, g grid, words []Word) {915 t.Helper()916917 type cell struct {918 x int919 y int920 }921922 occupied := map[cell]rune{}923924 for wi, w := range words {925 positions := w.Positions()926 if len(positions) != len(w.Text) {927 t.Fatalf("word[%d]=%q positions=%d, want %d", wi, w.Text, len(positions), len(w.Text))928 }929 if positions[0] != w.Start {930 t.Fatalf("word[%d]=%q start=%v, want %v", wi, w.Text, w.Start, positions[0])931 }932 if positions[len(positions)-1] != w.End {933 t.Fatalf("word[%d]=%q end=%v, want %v", wi, w.Text, w.End, positions[len(positions)-1])934 }935936 for i, pos := range positions {937 if pos.X < 0 || pos.X >= len(g[0]) || pos.Y < 0 || pos.Y >= len(g) {938 t.Fatalf("word[%d]=%q pos[%d]=(%d,%d) out of bounds", wi, w.Text, i, pos.X, pos.Y)939 }940941 want := rune(w.Text[i])942 if got := g.Get(pos.X, pos.Y); got != want {943 t.Fatalf(944 "word[%d]=%q pos[%d]=(%d,%d) grid=%q, want %q",945 wi,946 w.Text,947 i,948 pos.X,949 pos.Y,950 got,951 want,952 )953 }954955 key := cell{x: pos.X, y: pos.Y}956 if prev, ok := occupied[key]; ok && prev != want {957 t.Fatalf(958 "conflicting overlap at (%d,%d): had %q, word[%d]=%q wants %q",959 pos.X,960 pos.Y,961 prev,962 wi,963 w.Text,964 want,965 )966 }967 occupied[key] = want968 }969 }970}971972func TestGenerateWordSearchSeeded_PlacementStats(t *testing.T) {973 rng := testRNG(99)974 _, words, stats := generateWordSearchSeededWithStats(975 20,976 20,977 15,978 5,979 10,980 []Direction{Right, Down, DownRight, DownLeft, Left, Up, UpRight, UpLeft},981 rng,982 )983984 if stats.TargetWords != 15 {985 t.Fatalf("TargetWords = %d, want 15", stats.TargetWords)986 }987 if stats.PlacedWords != len(words) {988 t.Fatalf("PlacedWords = %d, want %d", stats.PlacedWords, len(words))989 }990 if stats.PlacedWords+stats.FailedWords != stats.TargetWords {991 t.Fatalf(992 "placed+failed = %d, want %d",993 stats.PlacedWords+stats.FailedWords,994 stats.TargetWords,995 )996 }997 if stats.RandomAttempts < stats.TargetWords {998 t.Fatalf("RandomAttempts = %d, want >= %d", stats.RandomAttempts, stats.TargetWords)999 }1000 if stats.FallbackPlaced > stats.FallbackUsed {1001 t.Fatalf("FallbackPlaced = %d, want <= FallbackUsed = %d", stats.FallbackPlaced, stats.FallbackUsed)1002 }1003 if got := stats.SuccessRate(); got <= 0 || got > 1 {1004 t.Fatalf("SuccessRate = %.4f, want (0,1]", got)1005 }1006 if got := stats.AttemptsPerWord(); got <= 0 {1007 t.Fatalf("AttemptsPerWord = %.4f, want > 0", got)1008 }1009}10101011// --- screenToGrid (P1) ---10121013func TestScreenToGrid(t *testing.T) {1014 m := &Model{1015 width: 10,1016 height: 10,1017 grid: createEmptyGrid(10, 10),1018 words: nil,1019 keys: DefaultKeyMap,1020 modeTitle: "Test",1021 foundCells: buildFoundCells(10, 10, nil),1022 termWidth: 120,1023 termHeight: 40,1024 }10251026 ox, oy := m.gridOrigin()10271028 tests := []struct {1029 name string1030 screenX int1031 screenY int1032 wantCol int1033 wantRow int1034 wantOk bool1035 }{1036 {"origin cell", ox, oy, 0, 0, true},1037 {"cell (1,0)", ox + cellWidth, oy, 1, 0, true},1038 {"cell (0,1)", ox, oy + 1, 0, 1, true},1039 {"cell (2,3)", ox + 2*cellWidth, oy + 3, 2, 3, true},1040 {"cell (9,9) last", ox + 9*cellWidth, oy + 9, 9, 9, true},1041 {"outside left", ox - 1, oy, 0, 0, false},1042 {"outside top", ox, oy - 1, 0, 0, false},1043 {"outside right", ox + 10*cellWidth, oy, 0, 0, false},1044 {"outside bottom", ox, oy + 10, 0, 0, false},1045 {"far outside", 0, 0, 0, 0, false},1046 }10471048 for _, tt := range tests {1049 t.Run(tt.name, func(t *testing.T) {1050 col, row, ok := m.screenToGrid(tt.screenX, tt.screenY)1051 if ok != tt.wantOk {1052 t.Errorf("ok = %v, want %v", ok, tt.wantOk)1053 }1054 if ok && (col != tt.wantCol || row != tt.wantRow) {1055 t.Errorf("screenToGrid(%d, %d) = (%d, %d), want (%d, %d)",1056 tt.screenX, tt.screenY, col, row, tt.wantCol, tt.wantRow)1057 }1058 })1059 }1060}10611062func TestHelpToggleInvalidatesOriginCache(t *testing.T) {1063 m := Model{1064 width: 10,1065 height: 10,1066 grid: createEmptyGrid(10, 10),1067 keys: DefaultKeyMap,1068 modeTitle: "Test",1069 foundCells: buildFoundCells(10, 10, nil),1070 originX: 9,1071 originY: 13,1072 originValid: true,1073 }10741075 next, _ := m.Update(game.HelpToggleMsg{Show: true})1076 got := next.(Model)10771078 if !got.showFullHelp {1079 t.Fatal("expected showFullHelp to be true")1080 }1081 if got.originValid {1082 t.Fatal("expected origin cache to be invalidated")1083 }1084}10851086// --- Save/Load round-trip (P1) ---10871088func TestSaveLoadRoundTrip(t *testing.T) {1089 words := []Word{1090 {Text: "HELLO", Start: Position{0, 0}, End: Position{4, 0}, Direction: Right, Found: true},1091 {Text: "WORLD", Start: Position{0, 1}, End: Position{4, 1}, Direction: Right, Found: false},1092 }10931094 m := Model{1095 width: 5,1096 height: 5,1097 grid: newGrid(state("HELLO\nWORLD\nABCDE\nFGHIJ\nKLMNO")),1098 words: words,1099 cursor: game.Cursor{X: 3, Y: 2},1100 selection: startSelected,1101 selectionStart: game.Cursor{X: 1, Y: 1},1102 keys: DefaultKeyMap,1103 solved: false,1104 foundCells: buildFoundCells(5, 5, words),1105 }11061107 data, err := m.GetSave()1108 if err != nil {1109 t.Fatal(err)1110 }11111112 got, err := ImportModel(data)1113 if err != nil {1114 t.Fatal(err)1115 }11161117 // Dimensions.1118 if got.width != m.width || got.height != m.height {1119 t.Errorf("dimensions = %dx%d, want %dx%d", got.width, got.height, m.width, m.height)1120 }11211122 // Grid content.1123 if got.grid.String() != m.grid.String() {1124 t.Errorf("grid mismatch")1125 }11261127 // Cursor.1128 if got.cursor.X != 3 || got.cursor.Y != 2 {1129 t.Errorf("cursor = (%d,%d), want (3,2)", got.cursor.X, got.cursor.Y)1130 }11311132 // Selection state.1133 if got.selection != startSelected {1134 t.Errorf("selection = %d, want %d", got.selection, startSelected)1135 }1136 if got.selectionStart.X != 1 || got.selectionStart.Y != 1 {1137 t.Errorf("selectionStart = (%d,%d), want (1,1)", got.selectionStart.X, got.selectionStart.Y)1138 }11391140 // Solved.1141 if got.solved != false {1142 t.Errorf("solved = %v, want false", got.solved)1143 }11441145 // Words and found status.1146 if len(got.words) != 2 {1147 t.Fatalf("word count = %d, want 2", len(got.words))1148 }1149 if !got.words[0].Found {1150 t.Error("words[0].Found = false, want true")1151 }1152 if got.words[1].Found {1153 t.Error("words[1].Found = true, want false")1154 }1155}11561157func TestImportModel_InvalidJSON(t *testing.T) {1158 _, err := ImportModel([]byte("not json"))1159 if err == nil {1160 t.Fatal("expected error for invalid JSON")1161 }1162}11631164func TestImportModel_EmptyData(t *testing.T) {1165 data, _ := json.Marshal(Save{})1166 got, err := ImportModel(data)1167 if err != nil {1168 t.Fatal(err)1169 }1170 // Should produce a model with zero dimensions and empty grid.1171 if got.width != 0 || got.height != 0 {1172 t.Errorf("dimensions = %dx%d, want 0x0", got.width, got.height)1173 }1174}