clone

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

Lights out and Word Search tests

Dami committed dee3a53
commit
dee3a53964aff46038fa03b8caad78babeeb474f
parent
e1fea99a06167bce69481fd5ec4d49fef7646ab0
author
Dami <dami.etoile@gmail.com>
2 files changed, +1024 -0
Alightsout/lightsout_test.go374+++++++++++++++++
Awordsearch/wordsearch_test.go650++++++++++++++++++++++++++++++
A lightsout/lightsout_test.go +374 -0
@@ -0,0 +1,374 @@
1+package lightsout2+3+import (4+ "encoding/json"5+ "testing"6+7+ "github.com/FelineStateMachine/puzzletea/game"8+)9+10+// --- helpers ---11+12+func copyGrid(g [][]bool) [][]bool {13+ c := make([][]bool, len(g))14+ for i, row := range g {15+ c[i] = make([]bool, len(row))16+ copy(c[i], row)17+ }18+ return c19+}20+21+func countDiffs(a, b [][]bool) int {22+ n := 023+ for y := range a {24+ for x := range a[y] {25+ if a[y][x] != b[y][x] {26+ n++27+ }28+ }29+ }30+ return n31+}32+33+func makeGrid(h, w int, val bool) [][]bool {34+ g := make([][]bool, h)35+ for y := range g {36+ g[y] = make([]bool, w)37+ if val {38+ for x := range g[y] {39+ g[y][x] = true40+ }41+ }42+ }43+ return g44+}45+46+// --- Toggle (P0) ---47+48+func TestToggle_Center(t *testing.T) {49+ t.Run("all false to true", func(t *testing.T) {50+ g := makeGrid(3, 3, false)51+ Toggle(g, 1, 1)52+53+ // Center + 4 neighbors should be true.54+ for _, pos := range [][2]int{{1, 1}, {0, 1}, {2, 1}, {1, 0}, {1, 2}} {55+ if !g[pos[1]][pos[0]] {56+ t.Errorf("expected (%d,%d) to be true", pos[0], pos[1])57+ }58+ }59+ // Corners should remain false.60+ for _, pos := range [][2]int{{0, 0}, {2, 0}, {0, 2}, {2, 2}} {61+ if g[pos[1]][pos[0]] {62+ t.Errorf("expected corner (%d,%d) to be false", pos[0], pos[1])63+ }64+ }65+ })66+67+ t.Run("all true to false", func(t *testing.T) {68+ g := makeGrid(3, 3, true)69+ Toggle(g, 1, 1)70+71+ for _, pos := range [][2]int{{1, 1}, {0, 1}, {2, 1}, {1, 0}, {1, 2}} {72+ if g[pos[1]][pos[0]] {73+ t.Errorf("expected (%d,%d) to be false", pos[0], pos[1])74+ }75+ }76+ for _, pos := range [][2]int{{0, 0}, {2, 0}, {0, 2}, {2, 2}} {77+ if !g[pos[1]][pos[0]] {78+ t.Errorf("expected corner (%d,%d) to be true", pos[0], pos[1])79+ }80+ }81+ })82+83+ t.Run("mixed state flips exactly 5", func(t *testing.T) {84+ // Checkerboard pattern.85+ g := [][]bool{86+ {true, false, true},87+ {false, true, false},88+ {true, false, true},89+ }90+ before := copyGrid(g)91+ Toggle(g, 1, 1)92+93+ if d := countDiffs(before, g); d != 5 {94+ t.Errorf("countDiffs = %d, want 5", d)95+ }96+ })97+}98+99+func TestToggle_Edges(t *testing.T) {100+ tests := []struct {101+ name string102+ x, y int103+ wantDiffs int104+ }{105+ {"top-left corner", 0, 0, 3},106+ {"top-right corner", 4, 0, 3},107+ {"bottom-left corner", 0, 4, 3},108+ {"bottom-right corner", 4, 4, 3},109+ {"top edge", 2, 0, 4},110+ {"left edge", 0, 2, 4},111+ {"bottom edge", 2, 4, 4},112+ {"right edge", 4, 2, 4},113+ {"center", 2, 2, 5},114+ }115+116+ for _, tt := range tests {117+ t.Run(tt.name, func(t *testing.T) {118+ g := makeGrid(5, 5, false)119+ before := copyGrid(g)120+ Toggle(g, tt.x, tt.y)121+122+ if d := countDiffs(before, g); d != tt.wantDiffs {123+ t.Errorf("countDiffs = %d, want %d", d, tt.wantDiffs)124+ }125+ })126+ }127+}128+129+func TestToggle_DoubleToggleCancels(t *testing.T) {130+ g := makeGrid(3, 3, false)131+ g[0][0] = true132+ before := copyGrid(g)133+134+ Toggle(g, 1, 1)135+ Toggle(g, 1, 1)136+137+ if d := countDiffs(before, g); d != 0 {138+ t.Errorf("double toggle should restore grid, got %d diffs", d)139+ }140+}141+142+func TestToggle_EmptyGrid(t *testing.T) {143+ g := [][]bool{}144+ Toggle(g, 0, 0) // must not panic145+}146+147+func TestToggle_1x1(t *testing.T) {148+ g := [][]bool{{false}}149+ Toggle(g, 0, 0)150+ if !g[0][0] {151+ t.Error("expected single cell to be true after toggle")152+ }153+}154+155+func TestToggle_Rectangular(t *testing.T) {156+ g := makeGrid(5, 3, false) // 5 rows, 3 cols157+ before := copyGrid(g)158+ Toggle(g, 1, 2) // center of grid, all neighbors in bounds159+160+ if d := countDiffs(before, g); d != 5 {161+ t.Errorf("countDiffs = %d, want 5", d)162+ }163+}164+165+// --- IsSolved (P0) ---166+167+func TestIsSolved(t *testing.T) {168+ tests := []struct {169+ name string170+ grid [][]bool171+ want bool172+ }{173+ {"all false 3x3", makeGrid(3, 3, false), true},174+ {"one true", func() [][]bool {175+ g := makeGrid(3, 3, false)176+ g[1][1] = true177+ return g178+ }(), false},179+ {"all true", makeGrid(3, 3, true), false},180+ {"empty grid", [][]bool{}, true},181+ {"nil grid", nil, true},182+ {"single cell false", [][]bool{{false}}, true},183+ {"single cell true", [][]bool{{true}}, false},184+ }185+186+ for _, tt := range tests {187+ t.Run(tt.name, func(t *testing.T) {188+ if got := IsSolved(tt.grid); got != tt.want {189+ t.Errorf("IsSolved = %v, want %v", got, tt.want)190+ }191+ })192+ }193+}194+195+// --- Generate (P1) ---196+197+func TestGenerate_NotSolved(t *testing.T) {198+ for range 10 {199+ g := Generate(5, 5)200+ if IsSolved(g) {201+ t.Fatal("Generate(5,5) returned a solved grid")202+ }203+ }204+}205+206+func TestGenerate_Dimensions(t *testing.T) {207+ g := Generate(3, 4)208+ if len(g) != 4 {209+ t.Fatalf("len(grid) = %d, want 4", len(g))210+ }211+ for y, row := range g {212+ if len(row) != 3 {213+ t.Errorf("len(grid[%d]) = %d, want 3", y, len(row))214+ }215+ }216+}217+218+func TestGenerate_HasLightsOn(t *testing.T) {219+ for range 10 {220+ g := Generate(5, 5)221+ found := false222+ for _, row := range g {223+ for _, cell := range row {224+ if cell {225+ found = true226+ }227+ }228+ }229+ if !found {230+ t.Fatal("Generate produced a grid with no lights on")231+ }232+ }233+}234+235+// --- New (P1) ---236+237+func TestNew(t *testing.T) {238+ t.Run("returns valid model", func(t *testing.T) {239+ m, err := New(5, 5)240+ if err != nil {241+ t.Fatal(err)242+ }243+ if len(m.grid) != 5 {244+ t.Errorf("grid height = %d, want 5", len(m.grid))245+ }246+ for y, row := range m.grid {247+ if len(row) != 5 {248+ t.Errorf("grid[%d] width = %d, want 5", y, len(row))249+ }250+ }251+ })252+253+ t.Run("cursor centered odd", func(t *testing.T) {254+ m, _ := New(5, 5)255+ if m.cursor.X != 2 || m.cursor.Y != 2 {256+ t.Errorf("cursor = (%d,%d), want (2,2)", m.cursor.X, m.cursor.Y)257+ }258+ })259+260+ t.Run("cursor centered even", func(t *testing.T) {261+ m, _ := New(4, 4)262+ if m.cursor.X != 2 || m.cursor.Y != 2 {263+ t.Errorf("cursor = (%d,%d), want (2,2)", m.cursor.X, m.cursor.Y)264+ }265+ })266+267+ t.Run("grid not solved", func(t *testing.T) {268+ m, _ := New(3, 3)269+ if m.IsSolved() {270+ t.Error("new game should not be solved")271+ }272+ })273+}274+275+// --- Mode.Spawn (P1) ---276+277+func TestModeSpawn(t *testing.T) {278+ mode := NewMode("Easy", "3x3 grid", 3, 3)279+ g, err := mode.Spawn()280+ if err != nil {281+ t.Fatal(err)282+ }283+ if g == nil {284+ t.Fatal("Spawn returned nil")285+ }286+}287+288+// --- Save/Load round-trip (P1) ---289+290+func TestSaveLoadRoundTrip(t *testing.T) {291+ m := Model{292+ grid: [][]bool{{true, false, true}, {false, true, false}},293+ width: 3,294+ height: 2,295+ cursor: game.Cursor{X: 2, Y: 1},296+ modeTitle: "Medium",297+ keys: DefaultKeyMap,298+ }299+300+ data, err := m.GetSave()301+ if err != nil {302+ t.Fatal(err)303+ }304+305+ got, err := ImportModel(data)306+ if err != nil {307+ t.Fatal(err)308+ }309+310+ // Grid content.311+ if len(got.grid) != len(m.grid) {312+ t.Fatalf("grid height = %d, want %d", len(got.grid), len(m.grid))313+ }314+ for y := range m.grid {315+ for x := range m.grid[y] {316+ if got.grid[y][x] != m.grid[y][x] {317+ t.Errorf("grid[%d][%d] = %v, want %v", y, x, got.grid[y][x], m.grid[y][x])318+ }319+ }320+ }321+322+ // Dimensions.323+ if got.width != 3 {324+ t.Errorf("width = %d, want 3", got.width)325+ }326+ if got.height != 2 {327+ t.Errorf("height = %d, want 2", got.height)328+ }329+330+ // Cursor.331+ if got.cursor.X != 2 || got.cursor.Y != 1 {332+ t.Errorf("cursor = (%d,%d), want (2,1)", got.cursor.X, got.cursor.Y)333+ }334+335+ // ModeTitle.336+ if got.modeTitle != "Medium" {337+ t.Errorf("modeTitle = %q, want %q", got.modeTitle, "Medium")338+ }339+}340+341+func TestImportModel_EmptyGrid(t *testing.T) {342+ data, _ := json.Marshal(Save{Grid: [][]bool{}})343+ _, err := ImportModel(data)344+ if err == nil {345+ t.Fatal("expected error for empty grid")346+ }347+}348+349+func TestImportModel_InvalidJSON(t *testing.T) {350+ _, err := ImportModel([]byte("not json"))351+ if err == nil {352+ t.Fatal("expected error for invalid JSON")353+ }354+}355+356+func TestImportModel_DimensionsDerived(t *testing.T) {357+ // 4 rows, 5 cols → height=4, width=5.358+ grid := make([][]bool, 4)359+ for i := range grid {360+ grid[i] = make([]bool, 5)361+ }362+363+ data, _ := json.Marshal(Save{Grid: grid})364+ got, err := ImportModel(data)365+ if err != nil {366+ t.Fatal(err)367+ }368+ if got.width != 5 {369+ t.Errorf("width = %d, want 5", got.width)370+ }371+ if got.height != 4 {372+ t.Errorf("height = %d, want 4", got.height)373+ }374+}
A wordsearch/wordsearch_test.go +650 -0
@@ -0,0 +1,650 @@
1+package wordsearch2+3+import (4+ "encoding/json"5+ "testing"6+7+ "github.com/FelineStateMachine/puzzletea/game"8+)9+10+// --- Direction.Vector (P0) ---11+12+func TestDirectionVector(t *testing.T) {13+ tests := []struct {14+ name string15+ dir Direction16+ wantDX int17+ wantDY int18+ }{19+ {"Right", Right, 1, 0},20+ {"Down", Down, 0, 1},21+ {"DownRight", DownRight, 1, 1},22+ {"DownLeft", DownLeft, -1, 1},23+ {"Left", Left, -1, 0},24+ {"Up", Up, 0, -1},25+ {"UpRight", UpRight, 1, -1},26+ {"UpLeft", UpLeft, -1, -1},27+ {"invalid", Direction(99), 0, 0},28+ }29+30+ for _, tt := range tests {31+ t.Run(tt.name, func(t *testing.T) {32+ dx, dy := tt.dir.Vector()33+ if dx != tt.wantDX || dy != tt.wantDY {34+ t.Errorf("Direction(%d).Vector() = (%d,%d), want (%d,%d)", tt.dir, dx, dy, tt.wantDX, tt.wantDY)35+ }36+ })37+ }38+}39+40+// --- Word.Contains (P0) ---41+42+func TestWordContains(t *testing.T) {43+ hello := Word{Text: "HELLO", Start: Position{0, 0}, End: Position{4, 0}, Direction: Right}44+ cat := Word{Text: "CAT", Start: Position{1, 1}, End: Position{3, 3}, Direction: DownRight}45+ single := Word{Text: "A", Start: Position{3, 3}, End: Position{3, 3}, Direction: Right}46+47+ tests := []struct {48+ name string49+ word *Word50+ pos Position51+ want bool52+ }{53+ {"start position", &hello, Position{0, 0}, true},54+ {"end position", &hello, Position{4, 0}, true},55+ {"middle position", &hello, Position{2, 0}, true},56+ {"off-path position", &hello, Position{0, 1}, false},57+ {"past-end position", &hello, Position{5, 0}, false},58+ {"diagonal word hit", &cat, Position{2, 2}, true},59+ {"diagonal start", &cat, Position{1, 1}, true},60+ {"diagonal off-path", &cat, Position{1, 2}, false},61+ {"single-letter hit", &single, Position{3, 3}, true},62+ {"single-letter miss", &single, Position{3, 4}, false},63+ }64+65+ for _, tt := range tests {66+ t.Run(tt.name, func(t *testing.T) {67+ if got := tt.word.Contains(tt.pos); got != tt.want {68+ t.Errorf("Contains(%v) = %v, want %v", tt.pos, got, tt.want)69+ }70+ })71+ }72+}73+74+// --- Word.Positions (P0) ---75+76+func TestWordPositions(t *testing.T) {77+ tests := []struct {78+ name string79+ word Word80+ want []Position81+ }{82+ {83+ "horizontal 5-letter",84+ Word{Text: "HELLO", Start: Position{0, 0}, Direction: Right},85+ []Position{{0, 0}, {1, 0}, {2, 0}, {3, 0}, {4, 0}},86+ },87+ {88+ "vertical 3-letter",89+ Word{Text: "CAT", Start: Position{2, 0}, Direction: Down},90+ []Position{{2, 0}, {2, 1}, {2, 2}},91+ },92+ {93+ "diagonal 2-letter",94+ Word{Text: "AB", Start: Position{0, 0}, Direction: DownRight},95+ []Position{{0, 0}, {1, 1}},96+ },97+ {98+ "single letter",99+ Word{Text: "A", Start: Position{3, 3}, Direction: Right},100+ []Position{{3, 3}},101+ },102+ }103+104+ for _, tt := range tests {105+ t.Run(tt.name, func(t *testing.T) {106+ got := tt.word.Positions()107+ if len(got) != len(tt.want) {108+ t.Fatalf("len(Positions()) = %d, want %d", len(got), len(tt.want))109+ }110+ for i := range tt.want {111+ if got[i] != tt.want[i] {112+ t.Errorf("Positions()[%d] = %v, want %v", i, got[i], tt.want[i])113+ }114+ }115+ })116+ }117+118+ t.Run("count matches text length", func(t *testing.T) {119+ w := Word{Text: "TESTING", Start: Position{0, 0}, Direction: Right}120+ if got := len(w.Positions()); got != len(w.Text) {121+ t.Errorf("len(Positions()) = %d, want %d", got, len(w.Text))122+ }123+ })124+}125+126+// --- Grid operations (P1) ---127+128+func TestGridGetSet(t *testing.T) {129+ g := createEmptyGrid(5, 5)130+131+ t.Run("Set then Get", func(t *testing.T) {132+ g.Set(2, 3, 'A')133+ if got := g.Get(2, 3); got != 'A' {134+ t.Errorf("Get(2,3) = %c, want A", got)135+ }136+ })137+138+ t.Run("Get out of bounds negative x", func(t *testing.T) {139+ if got := g.Get(-1, 0); got != 0 {140+ t.Errorf("Get(-1,0) = %d, want 0", got)141+ }142+ })143+144+ t.Run("Get out of bounds negative y", func(t *testing.T) {145+ if got := g.Get(0, -1); got != 0 {146+ t.Errorf("Get(0,-1) = %d, want 0", got)147+ }148+ })149+150+ t.Run("Get out of bounds over x", func(t *testing.T) {151+ if got := g.Get(5, 0); got != 0 {152+ t.Errorf("Get(5,0) = %d, want 0", got)153+ }154+ })155+156+ t.Run("Get out of bounds over y", func(t *testing.T) {157+ if got := g.Get(0, 5); got != 0 {158+ t.Errorf("Get(0,5) = %d, want 0", got)159+ }160+ })161+162+ t.Run("Set out of bounds no panic", func(t *testing.T) {163+ g.Set(-1, 0, 'X') // must not panic164+ g.Set(5, 0, 'X') // must not panic165+ })166+167+ t.Run("overwrite existing", func(t *testing.T) {168+ g.Set(0, 0, 'A')169+ g.Set(0, 0, 'B')170+ if got := g.Get(0, 0); got != 'B' {171+ t.Errorf("Get(0,0) = %c, want B", got)172+ }173+ })174+}175+176+func TestGridSerialization(t *testing.T) {177+ t.Run("round-trip", func(t *testing.T) {178+ original := newGrid(state("ABC\nDEF\nGHI"))179+ roundTripped := newGrid(state(original.String()))180+181+ if len(roundTripped) != len(original) {182+ t.Fatalf("row count = %d, want %d", len(roundTripped), len(original))183+ }184+ for y := range original {185+ for x := range original[y] {186+ if roundTripped.Get(x, y) != original.Get(x, y) {187+ t.Errorf("cell (%d,%d) = %c, want %c", x, y, roundTripped.Get(x, y), original.Get(x, y))188+ }189+ }190+ }191+ })192+193+ t.Run("empty state", func(t *testing.T) {194+ g := newGrid(state(""))195+ if len(g) != 0 {196+ t.Errorf("len(newGrid empty) = %d, want 0", len(g))197+ }198+ })199+200+ t.Run("createEmptyGrid dimensions", func(t *testing.T) {201+ g := createEmptyGrid(5, 5)202+ if len(g) != 5 {203+ t.Fatalf("height = %d, want 5", len(g))204+ }205+ for y := range g {206+ if len(g[y]) != 5 {207+ t.Fatalf("width of row %d = %d, want 5", y, len(g[y]))208+ }209+ for x := range g[y] {210+ if g[y][x] != ' ' {211+ t.Errorf("cell (%d,%d) = %c, want space", x, y, g[y][x])212+ }213+ }214+ }215+ })216+217+ t.Run("single row", func(t *testing.T) {218+ g := newGrid(state("HELLO"))219+ if len(g) != 1 {220+ t.Fatalf("row count = %d, want 1", len(g))221+ }222+ if len(g[0]) != 5 {223+ t.Errorf("col count = %d, want 5", len(g[0]))224+ }225+ })226+227+ t.Run("single column", func(t *testing.T) {228+ g := newGrid(state("A\nB\nC"))229+ if len(g) != 3 {230+ t.Fatalf("row count = %d, want 3", len(g))231+ }232+ for _, row := range g {233+ if len(row) != 1 {234+ t.Errorf("col count = %d, want 1", len(row))235+ }236+ }237+ })238+}239+240+// --- lineDirection (P0) ---241+242+func TestLineDirection(t *testing.T) {243+ tests := []struct {244+ name string245+ start game.Cursor246+ end game.Cursor247+ wantDX int248+ wantDY int249+ wantValid bool250+ }{251+ {"horizontal right", game.Cursor{X: 0, Y: 0}, game.Cursor{X: 4, Y: 0}, 1, 0, true},252+ {"horizontal left", game.Cursor{X: 4, Y: 0}, game.Cursor{X: 0, Y: 0}, -1, 0, true},253+ {"vertical down", game.Cursor{X: 0, Y: 0}, game.Cursor{X: 0, Y: 4}, 0, 1, true},254+ {"vertical up", game.Cursor{X: 0, Y: 4}, game.Cursor{X: 0, Y: 0}, 0, -1, true},255+ {"diagonal down-right", game.Cursor{X: 0, Y: 0}, game.Cursor{X: 3, Y: 3}, 1, 1, true},256+ {"diagonal up-left", game.Cursor{X: 3, Y: 3}, game.Cursor{X: 0, Y: 0}, -1, -1, true},257+ {"diagonal down-left", game.Cursor{X: 3, Y: 0}, game.Cursor{X: 0, Y: 3}, -1, 1, true},258+ {"diagonal up-right", game.Cursor{X: 0, Y: 3}, game.Cursor{X: 3, Y: 0}, 1, -1, true},259+ {"invalid slope", game.Cursor{X: 0, Y: 0}, game.Cursor{X: 2, Y: 3}, 0, 0, false},260+ {"invalid steep", game.Cursor{X: 0, Y: 0}, game.Cursor{X: 1, Y: 3}, 0, 0, false},261+ {"same point", game.Cursor{X: 2, Y: 2}, game.Cursor{X: 2, Y: 2}, 0, 0, false},262+ {"adjacent horizontal", game.Cursor{X: 0, Y: 0}, game.Cursor{X: 1, Y: 0}, 1, 0, true},263+ {"adjacent diagonal", game.Cursor{X: 0, Y: 0}, game.Cursor{X: 1, Y: 1}, 1, 1, true},264+ }265+266+ for _, tt := range tests {267+ t.Run(tt.name, func(t *testing.T) {268+ dx, dy, valid := lineDirection(tt.start, tt.end)269+ if valid != tt.wantValid {270+ t.Fatalf("valid = %v, want %v", valid, tt.wantValid)271+ }272+ if dx != tt.wantDX || dy != tt.wantDY {273+ t.Errorf("dx,dy = (%d,%d), want (%d,%d)", dx, dy, tt.wantDX, tt.wantDY)274+ }275+ })276+ }277+}278+279+// --- walkLine (P0) ---280+281+func TestWalkLine(t *testing.T) {282+ tests := []struct {283+ name string284+ start game.Cursor285+ end game.Cursor286+ wantOK bool287+ wantPos []Position288+ }{289+ {290+ "horizontal line",291+ game.Cursor{X: 0, Y: 0}, game.Cursor{X: 3, Y: 0},292+ true,293+ []Position{{0, 0}, {1, 0}, {2, 0}, {3, 0}},294+ },295+ {296+ "vertical line",297+ game.Cursor{X: 2, Y: 0}, game.Cursor{X: 2, Y: 3},298+ true,299+ []Position{{2, 0}, {2, 1}, {2, 2}, {2, 3}},300+ },301+ {302+ "diagonal line",303+ game.Cursor{X: 0, Y: 0}, game.Cursor{X: 2, Y: 2},304+ true,305+ []Position{{0, 0}, {1, 1}, {2, 2}},306+ },307+ {308+ "reverse diagonal",309+ game.Cursor{X: 2, Y: 2}, game.Cursor{X: 0, Y: 0},310+ true,311+ []Position{{2, 2}, {1, 1}, {0, 0}},312+ },313+ {314+ "adjacent cells",315+ game.Cursor{X: 0, Y: 0}, game.Cursor{X: 1, Y: 0},316+ true,317+ []Position{{0, 0}, {1, 0}},318+ },319+ {320+ "invalid line",321+ game.Cursor{X: 0, Y: 0}, game.Cursor{X: 1, Y: 2},322+ false,323+ nil,324+ },325+ {326+ "same point",327+ game.Cursor{X: 1, Y: 1}, game.Cursor{X: 1, Y: 1},328+ false,329+ nil,330+ },331+ }332+333+ for _, tt := range tests {334+ t.Run(tt.name, func(t *testing.T) {335+ var visited []Position336+ ok := walkLine(tt.start, tt.end, func(x, y int) {337+ visited = append(visited, Position{x, y})338+ })339+340+ if ok != tt.wantOK {341+ t.Fatalf("walkLine returned %v, want %v", ok, tt.wantOK)342+ }343+344+ if !tt.wantOK {345+ if len(visited) != 0 {346+ t.Errorf("fn was called %d times on invalid line", len(visited))347+ }348+ return349+ }350+351+ if len(visited) != len(tt.wantPos) {352+ t.Fatalf("visited %d positions, want %d", len(visited), len(tt.wantPos))353+ }354+ for i := range tt.wantPos {355+ if visited[i] != tt.wantPos[i] {356+ t.Errorf("visited[%d] = %v, want %v", i, visited[i], tt.wantPos[i])357+ }358+ }359+ })360+ }361+}362+363+// --- reverseString (P1) ---364+365+func TestReverseString(t *testing.T) {366+ tests := []struct {367+ name string368+ input string369+ want string370+ }{371+ {"normal", "HELLO", "OLLEH"},372+ {"single char", "A", "A"},373+ {"empty", "", ""},374+ {"palindrome", "ABBA", "ABBA"},375+ {"two chars", "AB", "BA"},376+ }377+378+ for _, tt := range tests {379+ t.Run(tt.name, func(t *testing.T) {380+ if got := reverseString(tt.input); got != tt.want {381+ t.Errorf("reverseString(%q) = %q, want %q", tt.input, got, tt.want)382+ }383+ })384+ }385+}386+387+// --- tryPlaceWord (P2) ---388+389+func TestTryPlaceWord(t *testing.T) {390+ t.Run("places word in empty grid", func(t *testing.T) {391+ g := createEmptyGrid(10, 10)392+ w := tryPlaceWord(g, "HELLO", []Direction{Right, Down, DownRight}, 1000)393+ if w == nil {394+ t.Fatal("expected word to be placed")395+ }396+397+ // Verify letters are in the grid at word positions.398+ for i, pos := range w.Positions() {399+ got := g.Get(pos.X, pos.Y)400+ want := rune(w.Text[i])401+ if got != want {402+ t.Errorf("grid(%d,%d) = %c, want %c", pos.X, pos.Y, got, want)403+ }404+ }405+ })406+407+ t.Run("respects allowed directions", func(t *testing.T) {408+ g := createEmptyGrid(10, 10)409+ w := tryPlaceWord(g, "HELLO", []Direction{Right}, 1000)410+ if w == nil {411+ t.Fatal("expected word to be placed")412+ }413+ if w.Direction != Right {414+ t.Errorf("direction = %d, want Right(%d)", w.Direction, Right)415+ }416+ })417+418+ t.Run("allows letter overlap", func(t *testing.T) {419+ g := createEmptyGrid(1, 5)420+ g.Set(0, 0, 'H')421+ w := tryPlaceWord(g, "HELLO", []Direction{Right}, 1000)422+ if w == nil {423+ t.Fatal("expected word to be placed with matching overlap")424+ }425+ })426+427+ t.Run("rejects conflicting overlap", func(t *testing.T) {428+ g := createEmptyGrid(1, 5)429+ for x := range 5 {430+ g.Set(x, 0, 'X')431+ }432+ w := tryPlaceWord(g, "HELLO", []Direction{Right}, 100)433+ if w != nil {434+ t.Error("expected nil for conflicting overlap")435+ }436+ })437+438+ t.Run("returns nil when impossible", func(t *testing.T) {439+ g := createEmptyGrid(3, 3)440+ w := tryPlaceWord(g, "TOOLONGWORD", []Direction{Right, Down}, 100)441+ if w != nil {442+ t.Error("expected nil for word that cannot fit")443+ }444+ })445+446+ t.Run("uppercases text", func(t *testing.T) {447+ g := createEmptyGrid(10, 10)448+ w := tryPlaceWord(g, "hello", []Direction{Right}, 1000)449+ if w == nil {450+ t.Fatal("expected word to be placed")451+ }452+ if w.Text != "HELLO" {453+ t.Errorf("Text = %q, want %q", w.Text, "HELLO")454+ }455+ })456+}457+458+// --- selectWords (P2) ---459+460+func TestSelectWords(t *testing.T) {461+ t.Run("respects count", func(t *testing.T) {462+ words := selectWords(5, 4, 6)463+ if len(words) > 5 {464+ t.Errorf("len(selectWords) = %d, want <= 5", len(words))465+ }466+ })467+468+ t.Run("respects length filter", func(t *testing.T) {469+ words := selectWords(20, 4, 6)470+ for _, w := range words {471+ if len(w) < 4 || len(w) > 6 {472+ t.Errorf("word %q has length %d, want 4-6", w, len(w))473+ }474+ }475+ })476+477+ t.Run("clamps when too few", func(t *testing.T) {478+ words := selectWords(99999, 4, 4)479+ // Should return all 4-letter words, not 99999.480+ if len(words) > 99999 {481+ t.Error("returned more than requested")482+ }483+ if len(words) == 0 {484+ t.Error("expected some words")485+ }486+ })487+}488+489+// --- fillEmptyCells (P2) ---490+491+func TestFillEmptyCells(t *testing.T) {492+ t.Run("fills all spaces", func(t *testing.T) {493+ g := createEmptyGrid(5, 5)494+ fillEmptyCells(g)495+ for y := range g {496+ for x := range g[y] {497+ if g[y][x] == ' ' {498+ t.Errorf("cell (%d,%d) is still a space", x, y)499+ }500+ }501+ }502+ })503+504+ t.Run("preserves existing letters", func(t *testing.T) {505+ g := createEmptyGrid(5, 5)506+ g.Set(0, 0, 'Z')507+ fillEmptyCells(g)508+ if got := g.Get(0, 0); got != 'Z' {509+ t.Errorf("cell (0,0) = %c, want Z", got)510+ }511+ })512+513+ t.Run("only uppercase letters", func(t *testing.T) {514+ g := createEmptyGrid(5, 5)515+ fillEmptyCells(g)516+ for y := range g {517+ for x := range g[y] {518+ c := g[y][x]519+ if c < 'A' || c > 'Z' {520+ t.Errorf("cell (%d,%d) = %c, not in A-Z", x, y, c)521+ }522+ }523+ }524+ })525+}526+527+// --- GenerateWordSearch (P2) ---528+529+func TestGenerateWordSearch(t *testing.T) {530+ t.Run("returns populated grid", func(t *testing.T) {531+ g, _ := GenerateWordSearch(15, 15, 10, 4, 7, []Direction{Right, Down, DownRight})532+ for y := range g {533+ for x := range g[y] {534+ if g[y][x] == ' ' {535+ t.Errorf("cell (%d,%d) is empty", x, y)536+ }537+ }538+ }539+ })540+541+ t.Run("placed words match grid", func(t *testing.T) {542+ g, words := GenerateWordSearch(15, 15, 10, 4, 7, []Direction{Right, Down, DownRight})543+ for _, w := range words {544+ for i, pos := range w.Positions() {545+ got := g.Get(pos.X, pos.Y)546+ want := rune(w.Text[i])547+ if got != want {548+ t.Errorf("word %q pos %d: grid(%d,%d) = %c, want %c", w.Text, i, pos.X, pos.Y, got, want)549+ }550+ }551+ }552+ })553+554+ t.Run("word count within requested", func(t *testing.T) {555+ _, words := GenerateWordSearch(15, 15, 10, 4, 7, []Direction{Right, Down, DownRight})556+ if len(words) > 10 {557+ t.Errorf("len(words) = %d, want <= 10", len(words))558+ }559+ })560+}561+562+// --- Save/Load round-trip (P1) ---563+564+func TestSaveLoadRoundTrip(t *testing.T) {565+ words := []Word{566+ {Text: "HELLO", Start: Position{0, 0}, End: Position{4, 0}, Direction: Right, Found: true},567+ {Text: "WORLD", Start: Position{0, 1}, End: Position{4, 1}, Direction: Right, Found: false},568+ }569+570+ m := Model{571+ width: 5,572+ height: 5,573+ grid: newGrid(state("HELLO\nWORLD\nABCDE\nFGHIJ\nKLMNO")),574+ words: words,575+ cursor: game.Cursor{X: 3, Y: 2},576+ selection: startSelected,577+ selectionStart: game.Cursor{X: 1, Y: 1},578+ keys: DefaultKeyMap,579+ solved: false,580+ foundCells: buildFoundCells(5, 5, words),581+ }582+583+ data, err := m.GetSave()584+ if err != nil {585+ t.Fatal(err)586+ }587+588+ got, err := ImportModel(data)589+ if err != nil {590+ t.Fatal(err)591+ }592+593+ // Dimensions.594+ if got.width != m.width || got.height != m.height {595+ t.Errorf("dimensions = %dx%d, want %dx%d", got.width, got.height, m.width, m.height)596+ }597+598+ // Grid content.599+ if got.grid.String() != m.grid.String() {600+ t.Errorf("grid mismatch")601+ }602+603+ // Cursor.604+ if got.cursor.X != 3 || got.cursor.Y != 2 {605+ t.Errorf("cursor = (%d,%d), want (3,2)", got.cursor.X, got.cursor.Y)606+ }607+608+ // Selection state.609+ if got.selection != startSelected {610+ t.Errorf("selection = %d, want %d", got.selection, startSelected)611+ }612+ if got.selectionStart.X != 1 || got.selectionStart.Y != 1 {613+ t.Errorf("selectionStart = (%d,%d), want (1,1)", got.selectionStart.X, got.selectionStart.Y)614+ }615+616+ // Solved.617+ if got.solved != false {618+ t.Errorf("solved = %v, want false", got.solved)619+ }620+621+ // Words and found status.622+ if len(got.words) != 2 {623+ t.Fatalf("word count = %d, want 2", len(got.words))624+ }625+ if !got.words[0].Found {626+ t.Error("words[0].Found = false, want true")627+ }628+ if got.words[1].Found {629+ t.Error("words[1].Found = true, want false")630+ }631+}632+633+func TestImportModel_InvalidJSON(t *testing.T) {634+ _, err := ImportModel([]byte("not json"))635+ if err == nil {636+ t.Fatal("expected error for invalid JSON")637+ }638+}639+640+func TestImportModel_EmptyData(t *testing.T) {641+ data, _ := json.Marshal(Save{})642+ got, err := ImportModel(data)643+ if err != nil {644+ t.Fatal(err)645+ }646+ // Should produce a model with zero dimensions and empty grid.647+ if got.width != 0 || got.height != 0 {648+ t.Errorf("dimensions = %dx%d, want 0x0", got.width, got.height)649+ }650+}