1package nurikabe23import (4 "context"5 "errors"6 "image"7 "image/color"8 "math/rand/v2"9 "runtime"10 "strings"11 "testing"12 "time"1314 tea "charm.land/bubbletea/v2"15 "github.com/FelineStateMachine/puzzletea/game"16 "github.com/FelineStateMachine/puzzletea/theme"17 "github.com/charmbracelet/x/ansi"18)1920func makeClues(rows ...[]int) clueGrid {21 out := make(clueGrid, len(rows))22 for y, row := range rows {23 out[y] = make([]int, len(row))24 copy(out[y], row)25 }26 return out27}2829func makeMarksFromClues(clues clueGrid) grid {30 h := len(clues)31 w := len(clues[0])32 g := newGrid(w, h, seaCell)33 for y := range h {34 for x := range w {35 if clues[y][x] > 0 {36 g[y][x] = islandCell37 }38 }39 }40 return g41}4243func uniquePuzzleFixture() Puzzle {44 clues := makeClues(45 []int{1, 0, 1, 0, 1},46 []int{0, 0, 0, 0, 0},47 []int{0, 1, 0, 1, 0},48 []int{0, 0, 0, 0, 0},49 []int{1, 0, 1, 0, 1},50 )51 return Puzzle{Width: 5, Height: 5, Clues: clues}52}5354// --- Grid and serialization (P0) ---5556func TestGridStringAndParse(t *testing.T) {57 g := grid{58 []cellState{unknownCell, seaCell, islandCell},59 []cellState{seaCell, islandCell, unknownCell},60 }61 data := g.String()62 round, err := parseGrid(data, 3, 2)63 if err != nil {64 t.Fatalf("parseGrid error: %v", err)65 }66 if round.String() != data {67 t.Errorf("round-trip mismatch: got %q want %q", round.String(), data)68 }69}7071func TestParseGridMapsRequiredLandMarkerToIsland(t *testing.T) {72 g, err := parseGrid("?!\n~o", 2, 2)73 if err != nil {74 t.Fatalf("parseGrid error: %v", err)75 }76 if g[0][1] != islandCell {77 t.Fatalf("required-land marker should load as island, got %q", rune(g[0][1]))78 }79}8081func TestGridClone(t *testing.T) {82 g := newGrid(2, 2, unknownCell)83 g[0][0] = seaCell84 c := g.clone()85 c[0][0] = islandCell86 if g[0][0] == islandCell {87 t.Fatal("clone shares memory with original")88 }89}9091func TestClueSerializationRoundTrip(t *testing.T) {92 clues := makeClues(93 []int{0, 12, 0},94 []int{3, 0, 1},95 )96 enc := serializeClues(clues)97 dec, err := parseClues(enc, 3, 2)98 if err != nil {99 t.Fatalf("parseClues error: %v", err)100 }101 if serializeClues(dec) != enc {102 t.Errorf("clue round-trip mismatch: got %q want %q", serializeClues(dec), enc)103 }104}105106// --- Rule checks (P0) ---107108func TestHasSeaSquare(t *testing.T) {109 g := grid{110 []cellState{seaCell, seaCell, islandCell},111 []cellState{seaCell, seaCell, islandCell},112 []cellState{islandCell, islandCell, islandCell},113 }114 if !hasSeaSquare(g) {115 t.Fatal("expected 2x2 sea block")116 }117}118119func TestSeaSquareCellDetection(t *testing.T) {120 g := grid{121 []cellState{seaCell, seaCell, islandCell},122 []cellState{seaCell, seaCell, islandCell},123 []cellState{islandCell, islandCell, islandCell},124 }125126 for _, p := range []point{{0, 0}, {1, 0}, {0, 1}, {1, 1}} {127 if !isSeaSquareCell(g, p.x, p.y) {128 t.Fatalf("expected (%d,%d) to be part of a 2x2 sea block", p.x, p.y)129 }130 }131132 if isSeaSquareCell(g, 2, 2) {133 t.Fatal("non-sea cell should not be part of a 2x2 sea block")134 }135136 lShape := grid{137 []cellState{seaCell, seaCell, unknownCell},138 []cellState{seaCell, islandCell, unknownCell},139 []cellState{unknownCell, unknownCell, unknownCell},140 }141 if isSeaSquareCell(lShape, 0, 0) {142 t.Fatal("L-shape sea should not be flagged as 2x2")143 }144}145146func TestSeaConnectivity(t *testing.T) {147 connected := grid{148 []cellState{seaCell, seaCell, islandCell},149 []cellState{islandCell, seaCell, islandCell},150 []cellState{islandCell, seaCell, seaCell},151 }152 if !isSeaConnectedWithScratch(connected, newSolverScratch(len(connected[0]), len(connected))) {153 t.Fatal("expected connected sea")154 }155156 disconnected := grid{157 []cellState{seaCell, islandCell, seaCell},158 []cellState{islandCell, islandCell, islandCell},159 []cellState{seaCell, islandCell, seaCell},160 }161 if isSeaConnectedWithScratch(disconnected, newSolverScratch(len(disconnected[0]), len(disconnected))) {162 t.Fatal("expected disconnected sea")163 }164}165166func TestIsSolvedGrid(t *testing.T) {167 p := uniquePuzzleFixture()168 marks := makeMarksFromClues(p.Clues)169 if !isSolvedGrid(marks, p.Clues) {170 t.Fatal("expected solved fixture")171 }172}173174func TestIsSolvedGridAllowsImplicitIslandCells(t *testing.T) {175 clues := makeClues(176 []int{2, 0, 1, 0},177 []int{0, 0, 0, 0},178 )179 marks := grid{180 []cellState{islandCell, seaCell, islandCell, seaCell},181 []cellState{unknownCell, seaCell, seaCell, seaCell},182 }183184 if !isSolvedGrid(marks, clues) {185 t.Fatal("expected unknown non-sea cells to count as island for solved check")186 }187}188189// --- Solver counts (P0) ---190191func TestCountSolutions(t *testing.T) {192 tests := []struct {193 name string194 p Puzzle195 want int196 }{197 {198 name: "zero solutions",199 p: Puzzle{Width: 1, Height: 1, Clues: makeClues(200 []int{1},201 )},202 want: 0,203 },204 {205 name: "one solution",206 p: Puzzle{Width: 2, Height: 2, Clues: makeClues(207 []int{1, 0},208 []int{0, 0},209 )},210 want: 1,211 },212 {213 name: "two solutions",214 p: Puzzle{Width: 2, Height: 2, Clues: makeClues(215 []int{2, 0},216 []int{0, 0},217 )},218 want: 2,219 },220 }221222 for _, tt := range tests {223 t.Run(tt.name, func(t *testing.T) {224 got, _, err := CountSolutions(tt.p, 3, 50000)225 if err != nil {226 t.Fatalf("CountSolutions error: %v", err)227 }228 if got != tt.want {229 t.Fatalf("CountSolutions = %d, want %d", got, tt.want)230 }231 })232 }233}234235// --- Save/load round-trip (P1) ---236237func TestSaveLoadRoundTrip(t *testing.T) {238 mode := NewMode("Mini", "test", 5, 5, 0.25, 5)239 p := uniquePuzzleFixture()240 g, err := New(mode, p)241 if err != nil {242 t.Fatalf("New error: %v", err)243 }244 m := g.(Model)245246 m.cursor = pointToCursor(1, 1)247 m.setCellAtCursor(islandCell)248 m.cursor = pointToCursor(3, 3)249 m.setCellAtCursor(seaCell)250251 data, err := m.GetSave()252 if err != nil {253 t.Fatalf("GetSave error: %v", err)254 }255 loaded, err := ImportModel(data)256 if err != nil {257 t.Fatalf("ImportModel error: %v", err)258 }259260 if loaded.width != m.width || loaded.height != m.height {261 t.Fatalf("size mismatch: got %dx%d want %dx%d", loaded.width, loaded.height, m.width, m.height)262 }263 if serializeClues(loaded.clues) != serializeClues(m.clues) {264 t.Fatal("clues mismatch after round-trip")265 }266 if loaded.marks.String() != m.marks.String() {267 t.Fatal("marks mismatch after round-trip")268 }269}270271func pointToCursor(x, y int) game.Cursor {272 return game.Cursor{X: x, Y: y}273}274275func cellPoint(originX, originY, col, row int) (x, y int) {276 return originX + col*(cellWidth+1), originY + row*2277}278279func separatorPoint(originX, originY, col, row int) (x, y int) {280 x, y = cellPoint(originX, originY, col, row)281 return x - 1, y282}283284func sameColor(left, right color.Color) bool {285 if left == nil || right == nil {286 return left == nil && right == nil287 }288 lr, lg, lb, la := left.RGBA()289 rr, rg, rb, ra := right.RGBA()290 return lr == rr && lg == rg && lb == rb && la == ra291}292293// --- Model input behavior (P1) ---294295func TestModelKeyboardUpdate(t *testing.T) {296 mode := NewMode("Mini", "test", 3, 3, 0.3, 5)297 p := Puzzle{Width: 3, Height: 3, Clues: makeClues(298 []int{2, 0, 0},299 []int{0, 0, 0},300 []int{0, 0, 1},301 )}302 g, err := New(mode, p)303 if err != nil {304 t.Fatalf("New error: %v", err)305 }306 m := g.(Model)307 m.cursor = pointToCursor(1, 1)308309 next, _ := m.Update(tea.KeyPressMsg{Code: 'x', Text: "x"})310 m = next.(Model)311 if m.marks[1][1] != seaCell {312 t.Fatalf("expected sea at (1,1), got %q", rune(m.marks[1][1]))313 }314315 next, _ = m.Update(tea.KeyPressMsg{Code: 'z', Text: "z"})316 m = next.(Model)317 if m.marks[1][1] != islandCell {318 t.Fatalf("expected island at (1,1), got %q", rune(m.marks[1][1]))319 }320321 next, _ = m.Update(tea.KeyPressMsg{Code: 'z', Text: "z"})322 m = next.(Model)323 if m.marks[1][1] != unknownCell {324 t.Fatalf("expected unknown at (1,1), got %q", rune(m.marks[1][1]))325 }326}327328func TestModelMouseDragPaint(t *testing.T) {329 mode := NewMode("Mini", "test", 3, 3, 0.3, 5)330 p := Puzzle{Width: 3, Height: 3, Clues: makeClues(331 []int{2, 0, 0},332 []int{0, 0, 0},333 []int{0, 0, 1},334 )}335 g, err := New(mode, p)336 if err != nil {337 t.Fatalf("New error: %v", err)338 }339 m := g.(Model)340341 next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})342 m = next.(Model)343 ox, oy := m.gridOrigin()344 clickX, clickY := cellPoint(ox, oy, 1, 1)345 dragX, dragY := separatorPoint(ox, oy, 2, 1)346347 next, _ = m.Update(tea.MouseClickMsg{X: clickX, Y: clickY, Button: tea.MouseLeft})348 m = next.(Model)349350 click := tea.MouseClickMsg{X: clickX, Y: clickY, Button: tea.MouseLeft}351 next, _ = m.Update(click)352 m = next.(Model)353354 drag := tea.MouseMotionMsg{X: dragX, Y: dragY, Button: tea.MouseLeft}355 next, _ = m.Update(drag)356 m = next.(Model)357358 next, _ = m.Update(tea.MouseReleaseMsg{})359 m = next.(Model)360361 if m.marks[1][1] != seaCell || m.marks[1][2] != seaCell {362 t.Fatalf("drag paint failed: row= %q", string([]rune{rune(m.marks[1][0]), rune(m.marks[1][1]), rune(m.marks[1][2])}))363 }364}365366func TestModelMouseClickMovesCursorWithoutPainting(t *testing.T) {367 mode := NewMode("Mini", "test", 3, 3, 0.3, 5)368 p := Puzzle{Width: 3, Height: 3, Clues: makeClues(369 []int{2, 0, 0},370 []int{0, 0, 0},371 []int{0, 0, 1},372 )}373 g, err := New(mode, p)374 if err != nil {375 t.Fatalf("New error: %v", err)376 }377 m := g.(Model)378379 next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})380 m = next.(Model)381 ox, oy := m.gridOrigin()382 cellX, cellY := cellPoint(ox, oy, 2, 1)383384 next, _ = m.Update(tea.MouseClickMsg{X: cellX, Y: cellY, Button: tea.MouseLeft})385 m = next.(Model)386387 if m.cursor.X != 2 || m.cursor.Y != 1 {388 t.Fatalf("cursor = (%d,%d), want (2,1)", m.cursor.X, m.cursor.Y)389 }390 if m.marks[1][2] != unknownCell {391 t.Fatalf("mouse click should not paint destination cell, got %q", rune(m.marks[1][2]))392 }393 if m.dragging {394 t.Fatal("mouse click on a new cell should not start dragging")395 }396}397398func TestModelMouseClickMirrorsKeyboard(t *testing.T) {399 mode := NewMode("Mini", "test", 3, 3, 0.3, 5)400 p := Puzzle{Width: 3, Height: 3, Clues: makeClues(401 []int{2, 0, 0},402 []int{0, 0, 0},403 []int{0, 0, 1},404 )}405 g, err := New(mode, p)406 if err != nil {407 t.Fatalf("New error: %v", err)408 }409 m := g.(Model)410411 next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})412 m = next.(Model)413 ox, oy := m.gridOrigin()414415 cellX, cellY := cellPoint(ox, oy, 1, 1)416417 next, _ = m.Update(tea.MouseClickMsg{X: cellX, Y: cellY, Button: tea.MouseLeft})418 m = next.(Model)419 if m.cursor.X != 1 || m.cursor.Y != 1 {420 t.Fatalf("cursor = (%d,%d), want (1,1)", m.cursor.X, m.cursor.Y)421 }422 if m.marks[1][1] != unknownCell {423 t.Fatalf("left click on a new cell should only move cursor, got %q", rune(m.marks[1][1]))424 }425426 next, _ = m.Update(tea.MouseClickMsg{X: cellX, Y: cellY, Button: tea.MouseLeft})427 m = next.(Model)428 if m.marks[1][1] != seaCell {429 t.Fatalf("left click on the current cell should set sea, got %q", rune(m.marks[1][1]))430 }431432 next, _ = m.Update(tea.MouseClickMsg{X: cellX, Y: cellY, Button: tea.MouseRight})433 m = next.(Model)434 if m.marks[1][1] != islandCell {435 t.Fatalf("right click should set island, got %q", rune(m.marks[1][1]))436 }437438 next, _ = m.Update(tea.MouseClickMsg{X: cellX, Y: cellY, Button: tea.MouseRight})439 m = next.(Model)440 if m.marks[1][1] != unknownCell {441 t.Fatalf("right click from island should set unknown, got %q", rune(m.marks[1][1]))442 }443444 next, _ = m.Update(tea.MouseClickMsg{X: cellX, Y: cellY, Button: tea.MouseLeft})445 m = next.(Model)446 if m.marks[1][1] != seaCell {447 t.Fatalf("left click from unknown should set sea, got %q", rune(m.marks[1][1]))448 }449}450451func TestModelMouseRightClickTogglesIsland(t *testing.T) {452 mode := NewMode("Mini", "test", 3, 3, 0.3, 5)453 p := Puzzle{Width: 3, Height: 3, Clues: makeClues(454 []int{2, 0, 0},455 []int{0, 0, 0},456 []int{0, 0, 1},457 )}458 g, err := New(mode, p)459 if err != nil {460 t.Fatalf("New error: %v", err)461 }462 m := g.(Model)463464 next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})465 m = next.(Model)466 ox, oy := m.gridOrigin()467468 cellX, cellY := cellPoint(ox, oy, 1, 1)469470 next, _ = m.Update(tea.MouseClickMsg{X: cellX, Y: cellY, Button: tea.MouseRight})471 m = next.(Model)472 if m.marks[1][1] != islandCell {473 t.Fatalf("expected island after first right click, got %q", rune(m.marks[1][1]))474 }475476 next, _ = m.Update(tea.MouseClickMsg{X: cellX, Y: cellY, Button: tea.MouseRight})477 m = next.(Model)478 if m.marks[1][1] != unknownCell {479 t.Fatalf("expected unknown after second right click, got %q", rune(m.marks[1][1]))480 }481}482483func TestModelMouseRightDragPaintIsland(t *testing.T) {484 mode := NewMode("Mini", "test", 3, 3, 0.3, 5)485 p := Puzzle{Width: 3, Height: 3, Clues: makeClues(486 []int{2, 0, 0},487 []int{0, 0, 0},488 []int{0, 0, 1},489 )}490 g, err := New(mode, p)491 if err != nil {492 t.Fatalf("New error: %v", err)493 }494 m := g.(Model)495496 next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})497 m = next.(Model)498 ox, oy := m.gridOrigin()499 clickX, clickY := cellPoint(ox, oy, 1, 1)500 dragX, dragY := separatorPoint(ox, oy, 2, 1)501502 next, _ = m.Update(tea.MouseClickMsg{X: clickX, Y: clickY, Button: tea.MouseRight})503 m = next.(Model)504505 next, _ = m.Update(tea.MouseMotionMsg{X: dragX, Y: dragY, Button: tea.MouseRight})506 m = next.(Model)507508 next, _ = m.Update(tea.MouseReleaseMsg{})509 m = next.(Model)510511 if m.marks[1][1] != islandCell || m.marks[1][2] != islandCell {512 t.Fatalf("right drag should paint island: row= %q", string([]rune{rune(m.marks[1][0]), rune(m.marks[1][1]), rune(m.marks[1][2])}))513 }514}515516func TestModelMouseRightDragClearsIsland(t *testing.T) {517 mode := NewMode("Mini", "test", 3, 3, 0.3, 5)518 p := Puzzle{Width: 3, Height: 3, Clues: makeClues(519 []int{2, 0, 0},520 []int{0, 0, 0},521 []int{0, 0, 1},522 )}523 g, err := New(mode, p)524 if err != nil {525 t.Fatalf("New error: %v", err)526 }527 m := g.(Model)528 m.cursor = pointToCursor(1, 1)529 m.setCellAtCursor(islandCell)530 m.cursor = pointToCursor(2, 1)531 m.setCellAtCursor(islandCell)532533 next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})534 m = next.(Model)535 ox, oy := m.gridOrigin()536 clickX, clickY := cellPoint(ox, oy, 1, 1)537 dragX, dragY := separatorPoint(ox, oy, 2, 1)538539 next, _ = m.Update(tea.MouseClickMsg{X: clickX, Y: clickY, Button: tea.MouseRight})540 m = next.(Model)541542 next, _ = m.Update(tea.MouseMotionMsg{X: dragX, Y: dragY, Button: tea.MouseRight})543 m = next.(Model)544545 next, _ = m.Update(tea.MouseReleaseMsg{})546 m = next.(Model)547548 if m.marks[1][1] != unknownCell || m.marks[1][2] != unknownCell {549 t.Fatalf("right drag from island should clear to unknown: row= %q", string([]rune{rune(m.marks[1][0]), rune(m.marks[1][1]), rune(m.marks[1][2])}))550 }551}552553func TestModelMouseLeftDragPaintSeaFromSea(t *testing.T) {554 mode := NewMode("Mini", "test", 3, 3, 0.3, 5)555 p := Puzzle{Width: 3, Height: 3, Clues: makeClues(556 []int{2, 0, 0},557 []int{0, 0, 0},558 []int{0, 0, 1},559 )}560 g, err := New(mode, p)561 if err != nil {562 t.Fatalf("New error: %v", err)563 }564 m := g.(Model)565 m.cursor = pointToCursor(1, 1)566 m.setCellAtCursor(seaCell)567568 next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})569 m = next.(Model)570 ox, oy := m.gridOrigin()571 clickX, clickY := cellPoint(ox, oy, 1, 1)572 dragX, dragY := separatorPoint(ox, oy, 2, 1)573574 next, _ = m.Update(tea.MouseClickMsg{X: clickX, Y: clickY, Button: tea.MouseLeft})575 m = next.(Model)576577 next, _ = m.Update(tea.MouseMotionMsg{X: dragX, Y: dragY, Button: tea.MouseLeft})578 m = next.(Model)579580 next, _ = m.Update(tea.MouseReleaseMsg{})581 m = next.(Model)582583 if m.marks[1][1] != seaCell || m.marks[1][2] != seaCell {584 t.Fatalf("left drag from sea should paint sea: row= %q", string([]rune{rune(m.marks[1][0]), rune(m.marks[1][1]), rune(m.marks[1][2])}))585 }586}587588func TestHelpToggleInvalidatesOriginCache(t *testing.T) {589 mode := NewMode("Mini", "test", 3, 3, 0.3, 5)590 p := Puzzle{Width: 3, Height: 3, Clues: makeClues(591 []int{2, 0, 0},592 []int{0, 0, 0},593 []int{0, 0, 1},594 )}595 g, err := New(mode, p)596 if err != nil {597 t.Fatalf("New error: %v", err)598 }599 m := g.(Model)600 m.originValid = true601 m.originX = 5602 m.originY = 7603604 next, _ := m.Update(game.HelpToggleMsg{Show: true})605 got := next.(Model)606607 if !got.showFullHelp {608 t.Fatal("expected showFullHelp to be true")609 }610 if got.originValid {611 t.Fatal("expected origin cache to be invalidated")612 }613}614615func TestWindowSizeInvalidatesOriginCache(t *testing.T) {616 mode := NewMode("Mini", "test", 3, 3, 0.3, 5)617 p := Puzzle{Width: 3, Height: 3, Clues: makeClues(618 []int{2, 0, 0},619 []int{0, 0, 0},620 []int{0, 0, 1},621 )}622 g, err := New(mode, p)623 if err != nil {624 t.Fatalf("New error: %v", err)625 }626 m := g.(Model)627 m.originValid = true628 m.originX = 5629 m.originY = 7630631 next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})632 got := next.(Model)633634 if got.originValid {635 t.Fatal("expected origin cache to be invalidated")636 }637}638639func TestResetInvalidatesOriginCache(t *testing.T) {640 mode := NewMode("Mini", "test", 3, 3, 0.3, 5)641 p := Puzzle{Width: 3, Height: 3, Clues: makeClues(642 []int{2, 0, 0},643 []int{0, 0, 0},644 []int{0, 0, 1},645 )}646 g, err := New(mode, p)647 if err != nil {648 t.Fatalf("New error: %v", err)649 }650 m := g.(Model)651 m.originValid = true652653 got := m.Reset().(Model)654 if got.originValid {655 t.Fatal("expected reset to invalidate origin cache")656 }657}658659func TestSolvedStateTransitionInvalidatesOriginCache(t *testing.T) {660 mode := NewMode("Mini", "test", 5, 5, 0.3, 5)661 p := uniquePuzzleFixture()662 g, err := New(mode, p)663 if err != nil {664 t.Fatalf("New error: %v", err)665 }666 m := g.(Model)667 m.originValid = true668 m.marks = makeMarksFromClues(p.Clues)669670 m.afterBoardChange()671672 if !m.solved {673 t.Fatal("expected puzzle to be solved")674 }675 if m.originValid {676 t.Fatal("expected solved transition to invalidate origin cache")677 }678}679680func TestScreenToGridRejectsSeparatorClicks(t *testing.T) {681 mode := NewMode("Mini", "test", 3, 3, 0.3, 5)682 p := Puzzle{Width: 3, Height: 3, Clues: makeClues(683 []int{2, 0, 0},684 []int{0, 0, 0},685 []int{0, 0, 1},686 )}687 g, err := New(mode, p)688 if err != nil {689 t.Fatalf("New error: %v", err)690 }691 m := g.(Model)692693 next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})694 m = next.(Model)695 ox, oy := m.gridOrigin()696 separatorX, separatorY := separatorPoint(ox, oy, 2, 1)697698 if _, _, ok := m.screenToGrid(separatorX, separatorY); ok {699 t.Fatal("expected separator click to miss")700 }701}702703func TestScreenToGridDragSnapsAcrossSeparator(t *testing.T) {704 mode := NewMode("Mini", "test", 3, 3, 0.3, 5)705 p := Puzzle{Width: 3, Height: 3, Clues: makeClues(706 []int{2, 0, 0},707 []int{0, 0, 0},708 []int{0, 0, 1},709 )}710 g, err := New(mode, p)711 if err != nil {712 t.Fatalf("New error: %v", err)713 }714 m := g.(Model)715716 next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})717 m = next.(Model)718 ox, oy := m.gridOrigin()719 separatorX, separatorY := separatorPoint(ox, oy, 2, 1)720721 col, row, ok := m.screenToGridDrag(separatorX, separatorY)722 if !ok {723 t.Fatal("expected separator drag to snap to adjacent cell")724 }725 if col != 2 || row != 1 {726 t.Fatalf("drag snapped to (%d,%d), want (2,1)", col, row)727 }728}729730func TestBuildRenderGridStateAssignsIslandZonesAndColors(t *testing.T) {731 mode := NewMode("Mini", "test", 4, 2, 0.3, 5)732 p := Puzzle{Width: 4, Height: 2, Clues: makeClues(733 []int{2, 0, 1, 0},734 []int{0, 0, 0, 0},735 )}736 g, err := New(mode, p)737 if err != nil {738 t.Fatalf("New error: %v", err)739 }740 m := g.(Model)741 m.cursor = pointToCursor(0, 1)742 m.setCellAtCursor(islandCell)743 m.cursor = pointToCursor(3, 1)744 m.setCellAtCursor(islandCell)745 m.cursor = pointToCursor(1, 0)746 m.setCellAtCursor(seaCell)747748 renderState := buildRenderGridState(m)749 themeColors := theme.Current().ThemeColors()750751 if got := renderState.zones[0][0]; got != renderState.zones[1][0] {752 t.Fatalf("connected island cells should share a zone: got %d and %d", renderState.zones[0][0], renderState.zones[1][0])753 }754 if got := renderState.zones[0][0]; got == renderState.zones[0][2] {755 t.Fatalf("separate clue islands should not share a zone: got %d", got)756 }757 if got := renderState.zones[0][2]; got == renderState.zones[1][3] {758 t.Fatalf("orphan island component should get its own zone: got %d", got)759 }760 if got := renderState.zones[0][1]; got != neutralZone {761 t.Fatalf("sea cell zone = %d, want %d", got, neutralZone)762 }763 if got := renderState.zones[1][1]; got != neutralZone {764 t.Fatalf("unknown cell zone = %d, want %d", got, neutralZone)765 }766767 for _, zone := range []int{768 renderState.zones[0][0],769 renderState.zones[0][2],770 renderState.zones[1][3],771 } {772 want := themeColors[zone%len(themeColors)]773 if !sameColor(renderState.zoneColors[zone], want) {774 t.Fatalf("zone %d color mismatch", zone)775 }776 }777}778779func TestGridViewUsesIslandZonesForInteriorBoundaries(t *testing.T) {780 mode := NewMode("Mini", "test", 3, 1, 0.3, 5)781 p := Puzzle{Width: 3, Height: 1, Clues: makeClues(782 []int{2, 0, 0},783 )}784 g, err := New(mode, p)785 if err != nil {786 t.Fatalf("New error: %v", err)787 }788 m := g.(Model)789 m.cursor = pointToCursor(1, 0)790 m.setCellAtCursor(islandCell)791792 lines := strings.Split(ansi.Strip(gridView(m)), "\n")793 if got, want := len(lines), m.height*2+1; got != want {794 t.Fatalf("line count = %d, want %d", got, want)795 }796797 content := []rune(lines[1])798 if got := content[4]; got != ' ' {799 t.Fatalf("connected island bridge = %q, want space", got)800 }801 if got := content[8]; got != '│' {802 t.Fatalf("island boundary bridge = %q, want vertical border", got)803 }804}805806func TestBridgeFillBlendsMixedTerrainBackgrounds(t *testing.T) {807 mode := NewMode("Mini", "test", 3, 1, 0.3, 5)808 p := Puzzle{Width: 3, Height: 1, Clues: makeClues(809 []int{1, 0, 0},810 )}811 g, err := New(mode, p)812 if err != nil {813 t.Fatalf("New error: %v", err)814 }815 m := g.(Model)816 m.cursor = pointToCursor(1, 0)817 m.setCellAtCursor(seaCell)818 m.cursor = pointToCursor(2, 0)819820 got := bridgeFill(m, game.DynamicGridBridge{821 Kind: game.DynamicGridBridgeVertical,822 X: 1,823 Y: 0,824 Count: 2,825 Uniform: false,826 Cells: [4]image.Point{827 {X: 0, Y: 0},828 {X: 1, Y: 0},829 },830 })831832 palette := theme.Current()833 renderState := buildRenderGridState(m)834 want := color.NRGBA{}835 island := zoneBackground(renderState, renderState.zones[0][0])836 sea := theme.Blend(palette.BG, palette.Secondary, 0.24)837 lr, lg, lb, la := island.RGBA()838 sr, sg, sb, sa := sea.RGBA()839 want.R = uint8(((lr >> 8) + (sr >> 8)) / 2)840 want.G = uint8(((lg >> 8) + (sg >> 8)) / 2)841 want.B = uint8(((lb >> 8) + (sb >> 8)) / 2)842 want.A = uint8(((la >> 8) + (sa >> 8)) / 2)843844 if !sameColor(got, want) {845 t.Fatal("expected mixed island/sea bridge to use blended background")846 }847}848849func TestBridgeFillDefersToZoneFillForUniformIslandBridge(t *testing.T) {850 mode := NewMode("Mini", "test", 2, 1, 0.3, 5)851 p := Puzzle{Width: 2, Height: 1, Clues: makeClues(852 []int{2, 0},853 )}854 g, err := New(mode, p)855 if err != nil {856 t.Fatalf("New error: %v", err)857 }858 m := g.(Model)859 m.cursor = pointToCursor(1, 0)860 m.setCellAtCursor(islandCell)861 renderState := buildRenderGridState(m)862863 got := bridgeFillWithState(m, renderState, game.DynamicGridBridge{864 Kind: game.DynamicGridBridgeVertical,865 X: 1,866 Y: 0,867 Count: 2,868 Uniform: true,869 Zone: renderState.zones[0][0],870 Cells: [4]image.Point{871 {X: 0, Y: 0},872 {X: 1, Y: 0},873 },874 })875876 if got != nil {877 t.Fatal("expected uniform island bridge to defer to zone fill")878 }879}880881func TestBridgeFillDoesNotExpandCursorBackground(t *testing.T) {882 mode := NewMode("Mini", "test", 3, 1, 0.3, 5)883 p := Puzzle{Width: 3, Height: 1, Clues: makeClues(884 []int{1, 0, 0},885 )}886 g, err := New(mode, p)887 if err != nil {888 t.Fatalf("New error: %v", err)889 }890 m := g.(Model)891 m.cursor = pointToCursor(1, 0)892 m.setCellAtCursor(seaCell)893 got := bridgeFill(m, game.DynamicGridBridge{894 Kind: game.DynamicGridBridgeVertical,895 X: 1,896 Y: 0,897 Count: 2,898 Uniform: false,899 Cells: [4]image.Point{900 {X: 0, Y: 0},901 {X: 1, Y: 0},902 },903 })904905 if sameColor(got, game.CursorBG()) {906 t.Fatal("expected cursor color to stay inside the cell body")907 }908}909910func TestResolveCellVisualShowsImplicitIslandWhenSolved(t *testing.T) {911 mode := NewMode("Mini", "test", 4, 2, 0.3, 5)912 p := Puzzle{Width: 4, Height: 2, Clues: makeClues(913 []int{2, 0, 1, 0},914 []int{0, 0, 0, 0},915 )}916 g, err := New(mode, p)917 if err != nil {918 t.Fatalf("New error: %v", err)919 }920 m := g.(Model)921 m.cursor = pointToCursor(1, 0)922 m.setCellAtCursor(seaCell)923 m.cursor = pointToCursor(1, 1)924 m.setCellAtCursor(seaCell)925 m.cursor = pointToCursor(2, 1)926 m.setCellAtCursor(seaCell)927 m.cursor = pointToCursor(3, 0)928 m.setCellAtCursor(seaCell)929 m.cursor = pointToCursor(3, 1)930 m.setCellAtCursor(seaCell)931932 if !m.solved {933 t.Fatal("expected puzzle to be solved")934 }935936 visual := resolveCellVisual(m, 0, 1)937 if visual.text != " · " {938 t.Fatalf("implicit solved island text = %q, want %q", visual.text, " · ")939 }940}941942func TestResolveCellVisualUsesIslandZoneColor(t *testing.T) {943 mode := NewMode("Mini", "test", 3, 2, 0.3, 5)944 p := Puzzle{Width: 3, Height: 2, Clues: makeClues(945 []int{2, 0, 1},946 []int{0, 0, 0},947 )}948 g, err := New(mode, p)949 if err != nil {950 t.Fatalf("New error: %v", err)951 }952 m := g.(Model)953 m.cursor = pointToCursor(0, 1)954 m.setCellAtCursor(islandCell)955 m.cursor = pointToCursor(1, 1)956957 renderState := buildRenderGridState(m)958 clue := resolveCellVisualWithState(m, renderState, 0, 0)959 island := resolveCellVisualWithState(m, renderState, 0, 1)960961 if !sameColor(clue.bg, island.bg) {962 t.Fatal("expected clue and connected island cell to share zone background")963 }964 if sameColor(clue.bg, theme.Current().BG) {965 t.Fatal("expected island zone background to differ from neutral background")966 }967}968969func TestResolveCellVisualZoneOverrides(t *testing.T) {970 t.Run("cursor stays cell-local", func(t *testing.T) {971 mode := NewMode("Mini", "test", 2, 1, 0.3, 5)972 p := Puzzle{Width: 2, Height: 1, Clues: makeClues(973 []int{2, 0},974 )}975 g, err := New(mode, p)976 if err != nil {977 t.Fatalf("New error: %v", err)978 }979 m := g.(Model)980 m.cursor = pointToCursor(1, 0)981 m.setCellAtCursor(islandCell)982 m.cursor = pointToCursor(0, 0)983984 renderState := buildRenderGridState(m)985 visual := resolveCellVisualWithState(m, renderState, 0, 0)986 if !sameColor(visual.bg, game.CursorBG()) {987 t.Fatal("expected cursor background to override zone background")988 }989 if sameColor(visual.bridgeBG, game.CursorBG()) {990 t.Fatal("expected cursor background to remain cell-local")991 }992 })993994 t.Run("conflict beats zone color", func(t *testing.T) {995 mode := NewMode("Mini", "test", 2, 1, 0.3, 5)996 p := Puzzle{Width: 2, Height: 1, Clues: makeClues(997 []int{1, 0},998 )}999 g, err := New(mode, p)1000 if err != nil {1001 t.Fatalf("New error: %v", err)1002 }1003 m := g.(Model)1004 m.cursor = pointToCursor(1, 0)1005 m.setCellAtCursor(islandCell)10061007 renderState := buildRenderGridState(m)1008 visual := resolveCellVisualWithState(m, renderState, 0, 0)1009 if !sameColor(visual.bg, game.ConflictBG()) {1010 t.Fatal("expected conflict background to override zone background")1011 }1012 })10131014 t.Run("solved beats zone color", func(t *testing.T) {1015 mode := NewMode("Mini", "test", 2, 1, 0.3, 5)1016 p := Puzzle{Width: 2, Height: 1, Clues: makeClues(1017 []int{1, 0},1018 )}1019 g, err := New(mode, p)1020 if err != nil {1021 t.Fatalf("New error: %v", err)1022 }1023 m := g.(Model)1024 m.cursor = pointToCursor(1, 0)1025 m.setCellAtCursor(seaCell)1026 if !m.solved {1027 t.Fatal("expected puzzle to be solved")1028 }10291030 renderState := buildRenderGridState(m)1031 visual := resolveCellVisualWithState(m, renderState, 0, 0)1032 if !sameColor(visual.bg, theme.Current().SuccessBG) {1033 t.Fatal("expected solved background to override zone background")1034 }1035 })1036}10371038func TestResolveCellVisualCursorUsesCursorMarkers(t *testing.T) {1039 mode := NewMode("Mini", "test", 3, 2, 0.3, 5)1040 p := Puzzle{Width: 3, Height: 2, Clues: makeClues(1041 []int{1, 0, 0},1042 []int{0, 0, 0},1043 )}1044 g, err := New(mode, p)1045 if err != nil {1046 t.Fatalf("New error: %v", err)1047 }1048 m := g.(Model)10491050 clue := resolveCellVisual(m, 0, 0)1051 if clue.text != game.CursorLeft+"1"+game.CursorRight {1052 t.Fatalf("cursor clue text = %q, want %q", clue.text, game.CursorLeft+"1"+game.CursorRight)1053 }10541055 m.cursor = pointToCursor(1, 0)1056 m.setCellAtCursor(seaCell)1057 sea := resolveCellVisual(m, 1, 0)1058 if sea.text != game.CursorLeft+"~"+game.CursorRight {1059 t.Fatalf("cursor sea text = %q, want %q", sea.text, game.CursorLeft+"~"+game.CursorRight)1060 }10611062 m.cursor = pointToCursor(2, 0)1063 unknown := resolveCellVisual(m, 2, 0)1064 if unknown.text != game.CursorLeft+" "+game.CursorRight {1065 t.Fatalf("cursor unknown text = %q, want %q", unknown.text, game.CursorLeft+" "+game.CursorRight)1066 }1067}10681069func TestResolveCellVisualTwoDigitClueKeepsFullLabel(t *testing.T) {1070 mode := NewMode("Mini", "test", 1, 1, 0.3, 12)1071 p := Puzzle{Width: 1, Height: 1, Clues: makeClues(1072 []int{12},1073 )}1074 g, err := New(mode, p)1075 if err != nil {1076 t.Fatalf("New error: %v", err)1077 }1078 m := g.(Model)10791080 visual := resolveCellVisual(m, 0, 0)1081 if visual.text != "12 " {1082 t.Fatalf("cursor two-digit clue text = %q, want %q", visual.text, "12 ")1083 }1084}10851086// --- Seeded generation determinism (P1) ---10871088func TestGenerateSeededDeterministicConcurrent(t *testing.T) {1089 mode := Modes[0].(NurikabeMode)1090 rng1 := rand.New(rand.NewPCG(11, 29))1091 rng2 := rand.New(rand.NewPCG(11, 29))1092 rng3 := rand.New(rand.NewPCG(11, 29))10931094 p1, err := GenerateSeeded(mode, rng1)1095 if err != nil {1096 t.Fatalf("GenerateSeeded #1 error: %v", err)1097 }1098 p2, err := GenerateSeeded(mode, rng2)1099 if err != nil {1100 t.Fatalf("GenerateSeeded #2 error: %v", err)1101 }1102 p3, err := GenerateSeeded(mode, rng3)1103 if err != nil {1104 t.Fatalf("GenerateSeeded #3 error: %v", err)1105 }11061107 if serializeClues(p1.Clues) != serializeClues(p2.Clues) {1108 t.Fatal("seeded generation is not deterministic")1109 }1110 if serializeClues(p1.Clues) != serializeClues(p3.Clues) {1111 t.Fatal("seeded generation diverged across repeated runs")1112 }1113}11141115// --- Generator runtime and validity (P2) ---11161117func TestGenerateReturnsWithin10SecondsAllModes(t *testing.T) {1118 if testing.Short() {1119 t.Skip("skipping slow generator budget test in short mode")1120 }11211122 for i, item := range Modes {1123 mode := item.(NurikabeMode)1124 t.Run(mode.Title(), func(t *testing.T) {1125 start := time.Now()1126 rng := rand.New(rand.NewPCG(uint64(100+i), uint64(999+i)))1127 _, err := GenerateSeeded(mode, rng)1128 if err != nil {1129 t.Fatalf("GenerateSeeded error: %v", err)1130 }1131 if elapsed := time.Since(start); elapsed > generationHardTimeout+500*time.Millisecond {1132 t.Fatalf("generation exceeded budget for %s: %s", mode.Title(), elapsed)1133 }1134 })1135 }1136}11371138func TestConstructedCandidateValidCompletedBoard(t *testing.T) {1139 mode := Modes[2].(NurikabeMode) // Medium1140 profile := modeIslandProfile(mode)1141 clueTarget := int(float64(mode.Width*mode.Height) * mode.ClueDensity)1142 clueTarget = max(clueTarget, 2)11431144 var (1145 candidate candidateResult1146 err error1147 )11481149 // Keep this deterministic while avoiding reliance on a single RNG path.1150 for attempt := range 24 {1151 seedA := uint64(901 + attempt*17)1152 seedB := uint64(1777 + attempt*29)1153 rng := rand.New(rand.NewPCG(seedA, seedB))1154 candidate, err = buildCandidateByCarving(mode, clueTarget, profile, rng, attempt)1155 if err == nil {1156 break1157 }1158 }1159 if err != nil {1160 t.Fatalf("buildCandidateByCarving failed across deterministic attempts: %v", err)1161 }11621163 if err := validateClues(candidate.puzzle.Clues, candidate.puzzle.Width, candidate.puzzle.Height); err != nil {1164 t.Fatalf("invalid candidate clues: %v", err)1165 }11661167 solved, _, err := CountSolutions(candidate.puzzle, 1, 2000000)1168 if err != nil {1169 t.Fatalf("CountSolutions error: %v", err)1170 }1171 if solved < 1 {1172 t.Fatalf("expected constructed candidate to be solvable, got %d", solved)1173 }1174}11751176func TestGenerateSeededReturnsSolvable(t *testing.T) {1177 if testing.Short() {1178 t.Skip("skipping slow generator solvability test in short mode")1179 }11801181 for i, item := range Modes {1182 mode := item.(NurikabeMode)1183 t.Run(mode.Title(), func(t *testing.T) {1184 rng := rand.New(rand.NewPCG(uint64(500+i), uint64(1200+i)))1185 p, err := GenerateSeeded(mode, rng)1186 if err != nil {1187 t.Fatalf("GenerateSeeded error: %v", err)1188 }1189 if err := validateClues(p.Clues, p.Width, p.Height); err != nil {1190 t.Fatalf("invalid generated puzzle clues: %v", err)1191 }11921193 nodeLimit := max(300000, modeGenerationBudget(mode).solvabilityNodes*8)1194 count, _, err := CountSolutionsContext(context.Background(), p, 1, nodeLimit)1195 if err != nil {1196 if errors.Is(err, errNodeLimit) {1197 t.Skipf("solver node cap reached at %d for %s; generation remains solvable-by-construction", nodeLimit, mode.Title())1198 }1199 t.Fatalf("CountSolutionsContext error: %v", err)1200 }1201 if count < 1 {1202 t.Fatalf("expected at least one solution, got %d", count)1203 }1204 })1205 }1206}12071208func TestGenerateSeededAvoidsSingletonFlood(t *testing.T) {1209 if testing.Short() {1210 t.Skip("skipping slow singleton distribution test in short mode")1211 }12121213 for i, item := range Modes {1214 mode := item.(NurikabeMode)1215 t.Run(mode.Title(), func(t *testing.T) {1216 rng := rand.New(rand.NewPCG(uint64(700+i), uint64(1700+i)))1217 p, err := GenerateSeeded(mode, rng)1218 if err != nil {1219 t.Fatalf("GenerateSeeded error: %v", err)1220 }12211222 clueCount := 01223 singletons := 01224 for y := 0; y < p.Height; y++ {1225 for x := 0; x < p.Width; x++ {1226 if p.Clues[y][x] <= 0 {1227 continue1228 }1229 clueCount++1230 if p.Clues[y][x] == 1 {1231 singletons++1232 }1233 }1234 }1235 if clueCount == 0 {1236 t.Fatal("expected at least one clue")1237 }12381239 profile := modeIslandProfile(mode)1240 ratio := float64(singletons) / float64(clueCount)1241 // Keep a small tolerance for fallback cases under the 10s SLA.1242 if ratio > profile.maxSingletonRatio+0.12 {1243 t.Fatalf(1244 "singleton ratio too high for %s: got %.2f (%d/%d), target <= %.2f",1245 mode.Title(),1246 ratio,1247 singletons,1248 clueCount,1249 profile.maxSingletonRatio+0.12,1250 )1251 }1252 })1253 }1254}12551256func TestUniqueFirstFallbackPolicy(t *testing.T) {1257 mode := Modes[1].(NurikabeMode) // Easy1258 rng := rand.New(rand.NewPCG(2026, 219))1259 p, err := GenerateSeeded(mode, rng)1260 if err != nil {1261 t.Fatalf("GenerateSeeded error: %v", err)1262 }12631264 count, _, err := CountSolutionsContext(context.Background(), p, 2, modeGenerationBudget(mode).solvabilityNodes)1265 if err != nil {1266 t.Fatalf("CountSolutionsContext error: %v", err)1267 }1268 if count < 1 {1269 t.Fatalf("expected generated puzzle to be solvable, got %d", count)1270 }1271 if count > 2 {1272 t.Fatalf("solution count should be capped by limit: got %d", count)1273 }1274}12751276func TestGenerateSeededCancellationFast(t *testing.T) {1277 mode := Modes[4].(NurikabeMode) // Expert gives enough work to exercise cancel path.1278 ctx, cancel := context.WithCancel(context.Background())1279 cancel()12801281 start := time.Now()1282 rng := rand.New(rand.NewPCG(33, 77))1283 _, err := GenerateSeededWithContext(ctx, mode, rng)1284 if err == nil {1285 t.Fatal("expected cancellation error, got nil")1286 }1287 if !errors.Is(err, context.Canceled) {1288 t.Fatalf("expected context.Canceled, got %v", err)1289 }1290 if elapsed := time.Since(start); elapsed > 150*time.Millisecond {1291 t.Fatalf("cancellation should return quickly, took %s", elapsed)1292 }1293}12941295func TestGenerateSeededDeadlineExceededFast(t *testing.T) {1296 mode := Modes[4].(NurikabeMode) // Expert keeps deadline path realistic.1297 ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)1298 defer cancel()1299 time.Sleep(1 * time.Millisecond)13001301 start := time.Now()1302 rng := rand.New(rand.NewPCG(44, 88))1303 _, err := GenerateSeededWithContext(ctx, mode, rng)1304 if err == nil {1305 t.Fatal("expected deadline exceeded error, got nil")1306 }1307 if !errors.Is(err, context.DeadlineExceeded) {1308 t.Fatalf("expected context.DeadlineExceeded, got %v", err)1309 }1310 if elapsed := time.Since(start); elapsed > 200*time.Millisecond {1311 t.Fatalf("deadline cancellation should return quickly, took %s", elapsed)1312 }1313}13141315func TestNoGoroutineLeakOnCancel(t *testing.T) {1316 mode := Modes[4].(NurikabeMode)1317 before := runtime.NumGoroutine()13181319 for i := range 8 {1320 ctx, cancel := context.WithCancel(context.Background())1321 cancel()1322 rng := rand.New(rand.NewPCG(uint64(400+i), uint64(900+i)))1323 _, _ = GenerateSeededWithContext(ctx, mode, rng)1324 }13251326 time.Sleep(80 * time.Millisecond)1327 after := runtime.NumGoroutine()1328 if delta := after - before; delta > 6 {1329 t.Fatalf("possible goroutine leak: before=%d after=%d delta=%d", before, after, delta)1330 }1331}13321333func TestNoGoroutineLeakOnDeadlineTimeout(t *testing.T) {1334 mode := Modes[4].(NurikabeMode)1335 before := runtime.NumGoroutine()13361337 for i := range 8 {1338 ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)1339 rng := rand.New(rand.NewPCG(uint64(1400+i), uint64(1900+i)))1340 _, _ = GenerateSeededWithContext(ctx, mode, rng)1341 cancel()1342 }13431344 time.Sleep(120 * time.Millisecond)1345 after := runtime.NumGoroutine()1346 if delta := after - before; delta > 8 {1347 t.Fatalf("possible goroutine leak on deadline timeout: before=%d after=%d delta=%d", before, after, delta)1348 }1349}13501351// --- Benchmarks (P2) ---13521353func skipBenchmarkInShortMode(b *testing.B) {1354 b.Helper()1355 if testing.Short() {1356 b.Skip("skipping benchmark in short mode")1357 }1358}13591360func BenchmarkGenerateSeededModes(b *testing.B) {1361 skipBenchmarkInShortMode(b)13621363 for i, item := range Modes {1364 mode := item.(NurikabeMode)1365 modeIdx := i1366 b.Run(mode.Title(), func(b *testing.B) {1367 b.ReportAllocs()1368 for n := 0; n < b.N; n++ {1369 seedA := uint64(100 + modeIdx)1370 seedB := uint64(1000 + modeIdx)1371 rng := rand.New(rand.NewPCG(seedA, seedB))1372 if _, err := GenerateSeeded(mode, rng); err != nil {1373 b.Fatalf("GenerateSeeded error: %v", err)1374 }1375 }1376 })1377 }1378}13791380func BenchmarkCountSolutions(b *testing.B) {1381 skipBenchmarkInShortMode(b)13821383 easyMode := Modes[1].(NurikabeMode)1384 easyRNG := rand.New(rand.NewPCG(7001, 9001))1385 easyPuzzle, err := GenerateSeeded(easyMode, easyRNG)1386 if err != nil {1387 b.Fatalf("GenerateSeeded setup error: %v", err)1388 }13891390 cases := []struct {1391 name string1392 puzzle Puzzle1393 nodeLimit int1394 }{1395 {1396 name: "Fixture5x5",1397 puzzle: uniquePuzzleFixture(),1398 nodeLimit: 120000,1399 },1400 {1401 name: "GeneratedEasy",1402 puzzle: easyPuzzle,1403 nodeLimit: modeGenerationBudget(easyMode).solvabilityNodes * 4,1404 },1405 }14061407 for _, tc := range cases {1408 b.Run(tc.name, func(b *testing.B) {1409 b.ReportAllocs()1410 b.ResetTimer()1411 for n := 0; n < b.N; n++ {1412 _, _, solveErr := CountSolutionsContext(context.Background(), tc.puzzle, 2, tc.nodeLimit)1413 if solveErr != nil && !errors.Is(solveErr, errNodeLimit) {1414 b.Fatalf("CountSolutionsContext error: %v", solveErr)1415 }1416 }1417 })1418 }1419}