- commit
- dee3a53964aff46038fa03b8caad78babeeb474f
- parent
- e1fea99a06167bce69481fd5ec4d49fef7646ab0
- author
- Dami <dami.etoile@gmail.com>
| A | lightsout/ | 374 | +++++++++++++++++ |
| A | wordsearch/ | 650 | ++++++++++++++++++++++++++++++ |
@@ -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+}@@ -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+}