1package lightsout23import (4 "encoding/json"5 "math/rand/v2"6 "testing"78 "github.com/FelineStateMachine/puzzletea/game"9)1011// --- helpers ---1213func copyGrid(g [][]bool) [][]bool {14 c := make([][]bool, len(g))15 for i, row := range g {16 c[i] = make([]bool, len(row))17 copy(c[i], row)18 }19 return c20}2122func countDiffs(a, b [][]bool) int {23 n := 024 for y := range a {25 for x := range a[y] {26 if a[y][x] != b[y][x] {27 n++28 }29 }30 }31 return n32}3334func makeGrid(h, w int, val bool) [][]bool {35 g := make([][]bool, h)36 for y := range g {37 g[y] = make([]bool, w)38 if val {39 for x := range g[y] {40 g[y][x] = true41 }42 }43 }44 return g45}4647func countLightsOn(g [][]bool) int {48 total := 049 for y := range g {50 for x := range g[y] {51 if g[y][x] {52 total++53 }54 }55 }56 return total57}5859// --- Toggle (P0) ---6061func TestToggle_Center(t *testing.T) {62 t.Run("all false to true", func(t *testing.T) {63 g := makeGrid(3, 3, false)64 Toggle(g, 1, 1)6566 // Center + 4 neighbors should be true.67 for _, pos := range [][2]int{{1, 1}, {0, 1}, {2, 1}, {1, 0}, {1, 2}} {68 if !g[pos[1]][pos[0]] {69 t.Errorf("expected (%d,%d) to be true", pos[0], pos[1])70 }71 }72 // Corners should remain false.73 for _, pos := range [][2]int{{0, 0}, {2, 0}, {0, 2}, {2, 2}} {74 if g[pos[1]][pos[0]] {75 t.Errorf("expected corner (%d,%d) to be false", pos[0], pos[1])76 }77 }78 })7980 t.Run("all true to false", func(t *testing.T) {81 g := makeGrid(3, 3, true)82 Toggle(g, 1, 1)8384 for _, pos := range [][2]int{{1, 1}, {0, 1}, {2, 1}, {1, 0}, {1, 2}} {85 if g[pos[1]][pos[0]] {86 t.Errorf("expected (%d,%d) to be false", pos[0], pos[1])87 }88 }89 for _, pos := range [][2]int{{0, 0}, {2, 0}, {0, 2}, {2, 2}} {90 if !g[pos[1]][pos[0]] {91 t.Errorf("expected corner (%d,%d) to be true", pos[0], pos[1])92 }93 }94 })9596 t.Run("mixed state flips exactly 5", func(t *testing.T) {97 // Checkerboard pattern.98 g := [][]bool{99 {true, false, true},100 {false, true, false},101 {true, false, true},102 }103 before := copyGrid(g)104 Toggle(g, 1, 1)105106 if d := countDiffs(before, g); d != 5 {107 t.Errorf("countDiffs = %d, want 5", d)108 }109 })110}111112func TestToggle_Edges(t *testing.T) {113 tests := []struct {114 name string115 x, y int116 wantDiffs int117 }{118 {"top-left corner", 0, 0, 3},119 {"top-right corner", 4, 0, 3},120 {"bottom-left corner", 0, 4, 3},121 {"bottom-right corner", 4, 4, 3},122 {"top edge", 2, 0, 4},123 {"left edge", 0, 2, 4},124 {"bottom edge", 2, 4, 4},125 {"right edge", 4, 2, 4},126 {"center", 2, 2, 5},127 }128129 for _, tt := range tests {130 t.Run(tt.name, func(t *testing.T) {131 g := makeGrid(5, 5, false)132 before := copyGrid(g)133 Toggle(g, tt.x, tt.y)134135 if d := countDiffs(before, g); d != tt.wantDiffs {136 t.Errorf("countDiffs = %d, want %d", d, tt.wantDiffs)137 }138 })139 }140}141142func TestToggle_DoubleToggleCancels(t *testing.T) {143 g := makeGrid(3, 3, false)144 g[0][0] = true145 before := copyGrid(g)146147 Toggle(g, 1, 1)148 Toggle(g, 1, 1)149150 if d := countDiffs(before, g); d != 0 {151 t.Errorf("double toggle should restore grid, got %d diffs", d)152 }153}154155func TestToggle_EmptyGrid(t *testing.T) {156 g := [][]bool{}157 Toggle(g, 0, 0) // must not panic158}159160func TestToggle_1x1(t *testing.T) {161 g := [][]bool{{false}}162 Toggle(g, 0, 0)163 if !g[0][0] {164 t.Error("expected single cell to be true after toggle")165 }166}167168func TestToggle_Rectangular(t *testing.T) {169 g := makeGrid(5, 3, false) // 5 rows, 3 cols170 before := copyGrid(g)171 Toggle(g, 1, 2) // center of grid, all neighbors in bounds172173 if d := countDiffs(before, g); d != 5 {174 t.Errorf("countDiffs = %d, want 5", d)175 }176}177178// --- IsSolved (P0) ---179180func TestIsSolved(t *testing.T) {181 tests := []struct {182 name string183 grid [][]bool184 want bool185 }{186 {"all false 3x3", makeGrid(3, 3, false), true},187 {"one true", func() [][]bool {188 g := makeGrid(3, 3, false)189 g[1][1] = true190 return g191 }(), false},192 {"all true", makeGrid(3, 3, true), false},193 {"empty grid", [][]bool{}, true},194 {"nil grid", nil, true},195 {"single cell false", [][]bool{{false}}, true},196 {"single cell true", [][]bool{{true}}, false},197 }198199 for _, tt := range tests {200 t.Run(tt.name, func(t *testing.T) {201 if got := IsSolved(tt.grid); got != tt.want {202 t.Errorf("IsSolved = %v, want %v", got, tt.want)203 }204 })205 }206}207208// --- Generate (P1) ---209210func TestGenerate_NotSolved(t *testing.T) {211 for range 10 {212 g := Generate(5, 5)213 if IsSolved(g) {214 t.Fatal("Generate(5,5) returned a solved grid")215 }216 }217}218219func TestGenerate_Dimensions(t *testing.T) {220 g := Generate(3, 4)221 if len(g) != 4 {222 t.Fatalf("len(grid) = %d, want 4", len(g))223 }224 for y, row := range g {225 if len(row) != 3 {226 t.Errorf("len(grid[%d]) = %d, want 3", y, len(row))227 }228 }229}230231func TestGenerate_HasLightsOn(t *testing.T) {232 for range 10 {233 g := Generate(5, 5)234 found := false235 for _, row := range g {236 for _, cell := range row {237 if cell {238 found = true239 }240 }241 }242 if !found {243 t.Fatal("Generate produced a grid with no lights on")244 }245 }246}247248func TestGenerateSeeded_DistributionNotSolved(t *testing.T) {249 tests := []struct {250 name string251 w int252 h int253 }{254 {name: "easy_3x3", w: 3, h: 3},255 {name: "medium_5x5", w: 5, h: 5},256 {name: "hard_7x7", w: 7, h: 7},257 {name: "extreme_9x9", w: 9, h: 9},258 }259260 for _, tt := range tests {261 t.Run(tt.name, func(t *testing.T) {262 minLightsOn := tt.w * tt.h263 maxLightsOn := 0264265 for seed := range 128 {266 rng := rand.New(rand.NewPCG(uint64(seed+1), uint64(seed+2)))267 g := GenerateSeeded(tt.w, tt.h, rng)268269 if IsSolved(g) {270 t.Fatalf("GenerateSeeded(%d,%d) produced solved grid at seed %d", tt.w, tt.h, seed)271 }272273 lightsOn := countLightsOn(g)274 if lightsOn < minLightsOn {275 minLightsOn = lightsOn276 }277 if lightsOn > maxLightsOn {278 maxLightsOn = lightsOn279 }280 }281282 if minLightsOn <= 0 {283 t.Fatalf("min lights on = %d, want > 0", minLightsOn)284 }285 if minLightsOn == maxLightsOn {286 t.Fatalf("lights-on distribution did not vary: min=max=%d", minLightsOn)287 }288 })289 }290}291292// --- New (P1) ---293294func TestNew(t *testing.T) {295 t.Run("returns valid model", func(t *testing.T) {296 m, err := New(5, 5)297 if err != nil {298 t.Fatal(err)299 }300 if len(m.grid) != 5 {301 t.Errorf("grid height = %d, want 5", len(m.grid))302 }303 for y, row := range m.grid {304 if len(row) != 5 {305 t.Errorf("grid[%d] width = %d, want 5", y, len(row))306 }307 }308 })309310 t.Run("cursor centered odd", func(t *testing.T) {311 m, _ := New(5, 5)312 if m.cursor.X != 2 || m.cursor.Y != 2 {313 t.Errorf("cursor = (%d,%d), want (2,2)", m.cursor.X, m.cursor.Y)314 }315 })316317 t.Run("cursor centered even", func(t *testing.T) {318 m, _ := New(4, 4)319 if m.cursor.X != 2 || m.cursor.Y != 2 {320 t.Errorf("cursor = (%d,%d), want (2,2)", m.cursor.X, m.cursor.Y)321 }322 })323324 t.Run("grid not solved", func(t *testing.T) {325 m, _ := New(3, 3)326 if m.IsSolved() {327 t.Error("new game should not be solved")328 }329 })330}331332// --- Mode.Spawn (P1) ---333334func TestModeSpawn(t *testing.T) {335 mode := NewMode("Easy", "3x3 grid", 3, 3)336 g, err := mode.Spawn()337 if err != nil {338 t.Fatal(err)339 }340 if g == nil {341 t.Fatal("Spawn returned nil")342 }343}344345// --- Save/Load round-trip (P1) ---346347func TestSaveLoadRoundTrip(t *testing.T) {348 m := Model{349 grid: [][]bool{{true, false, true}, {false, true, false}},350 width: 3,351 height: 2,352 cursor: game.Cursor{X: 2, Y: 1},353 modeTitle: "Medium",354 keys: DefaultKeyMap,355 }356357 data, err := m.GetSave()358 if err != nil {359 t.Fatal(err)360 }361362 got, err := ImportModel(data)363 if err != nil {364 t.Fatal(err)365 }366367 // Grid content.368 if len(got.grid) != len(m.grid) {369 t.Fatalf("grid height = %d, want %d", len(got.grid), len(m.grid))370 }371 for y := range m.grid {372 for x := range m.grid[y] {373 if got.grid[y][x] != m.grid[y][x] {374 t.Errorf("grid[%d][%d] = %v, want %v", y, x, got.grid[y][x], m.grid[y][x])375 }376 }377 }378379 // Dimensions.380 if got.width != 3 {381 t.Errorf("width = %d, want 3", got.width)382 }383 if got.height != 2 {384 t.Errorf("height = %d, want 2", got.height)385 }386387 // Cursor.388 if got.cursor.X != 2 || got.cursor.Y != 1 {389 t.Errorf("cursor = (%d,%d), want (2,1)", got.cursor.X, got.cursor.Y)390 }391392 // ModeTitle.393 if got.modeTitle != "Medium" {394 t.Errorf("modeTitle = %q, want %q", got.modeTitle, "Medium")395 }396}397398func TestImportModel_EmptyGrid(t *testing.T) {399 data, _ := json.Marshal(Save{Grid: [][]bool{}})400 _, err := ImportModel(data)401 if err == nil {402 t.Fatal("expected error for empty grid")403 }404}405406func TestImportModel_InvalidJSON(t *testing.T) {407 _, err := ImportModel([]byte("not json"))408 if err == nil {409 t.Fatal("expected error for invalid JSON")410 }411}412413func TestImportModel_DimensionsDerived(t *testing.T) {414 // 4 rows, 5 cols → height=4, width=5.415 grid := make([][]bool, 4)416 for i := range grid {417 grid[i] = make([]bool, 5)418 }419420 data, _ := json.Marshal(Save{Grid: grid})421 got, err := ImportModel(data)422 if err != nil {423 t.Fatal(err)424 }425 if got.width != 5 {426 t.Errorf("width = %d, want 5", got.width)427 }428 if got.height != 4 {429 t.Errorf("height = %d, want 4", got.height)430 }431}