clone

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

chore/regenerate-vhs-gifs
puzzletea / nonogram / nonogram_test.go
nonogram_test.go 39.2K
1package nonogram23import (4 "context"5 "math/rand/v2"6 "reflect"7 "strings"8 "testing"9 "time"1011 tea "charm.land/bubbletea/v2"12 "github.com/FelineStateMachine/puzzletea/game"13)1415// --- generateTomography (P0) ---1617func TestGenerateTomography(t *testing.T) {18 tests := []struct {19 name string20 grid grid21 wantRows TomographyDefinition22 wantCols TomographyDefinition23 }{24 {25 name: "all filled row",26 grid: grid{[]rune{filledTile, filledTile, filledTile}},27 wantRows: TomographyDefinition{{3}},28 wantCols: TomographyDefinition{{1}, {1}, {1}},29 },30 {31 name: "all empty row",32 grid: grid{[]rune{emptyTile, emptyTile, emptyTile}},33 wantRows: TomographyDefinition{{0}},34 wantCols: TomographyDefinition{{0}, {0}, {0}},35 },36 {37 name: "single block",38 grid: grid{[]rune{filledTile, filledTile, emptyTile, emptyTile}},39 wantRows: TomographyDefinition{{2}},40 wantCols: TomographyDefinition{{1}, {1}, {0}, {0}},41 },42 {43 name: "two blocks",44 grid: grid{[]rune{filledTile, filledTile, emptyTile, filledTile}},45 wantRows: TomographyDefinition{{2, 1}},46 wantCols: TomographyDefinition{{1}, {1}, {0}, {1}},47 },48 {49 name: "alternating",50 grid: grid{[]rune{filledTile, emptyTile, filledTile, emptyTile, filledTile}},51 wantRows: TomographyDefinition{{1, 1, 1}},52 wantCols: TomographyDefinition{{1}, {0}, {1}, {0}, {1}},53 },54 {55 name: "column hints vertical pattern",56 grid: grid{57 []rune{filledTile, emptyTile},58 []rune{filledTile, emptyTile},59 []rune{emptyTile, filledTile},60 },61 wantRows: TomographyDefinition{{1}, {1}, {1}},62 wantCols: TomographyDefinition{{2}, {1}},63 },64 {65 name: "1x1 filled",66 grid: grid{[]rune{filledTile}},67 wantRows: TomographyDefinition{{1}},68 wantCols: TomographyDefinition{{1}},69 },70 {71 name: "1x1 empty",72 grid: grid{[]rune{emptyTile}},73 wantRows: TomographyDefinition{{0}},74 wantCols: TomographyDefinition{{0}},75 },76 {77 name: "empty grid",78 grid: grid{},79 wantRows: TomographyDefinition{},80 wantCols: TomographyDefinition{},81 },82 {83 name: "rectangular grid 3x5",84 grid: grid{85 []rune{filledTile, emptyTile, filledTile, emptyTile, filledTile},86 []rune{emptyTile, emptyTile, emptyTile, emptyTile, emptyTile},87 []rune{filledTile, filledTile, filledTile, filledTile, filledTile},88 },89 wantRows: TomographyDefinition{{1, 1, 1}, {0}, {5}},90 wantCols: TomographyDefinition{{1, 1}, {1}, {1, 1}, {1}, {1, 1}},91 },92 }9394 for _, tt := range tests {95 t.Run(tt.name, func(t *testing.T) {96 hints := generateTomography(tt.grid)97 if !reflect.DeepEqual([][]int(hints.rows), [][]int(tt.wantRows)) {98 t.Errorf("rows = %v, want %v", hints.rows, tt.wantRows)99 }100 if !reflect.DeepEqual([][]int(hints.cols), [][]int(tt.wantCols)) {101 t.Errorf("cols = %v, want %v", hints.cols, tt.wantCols)102 }103 })104 }105}106107// --- TomographyDefinition.equal (P0) ---108109func TestTomographyDefinitionEqual(t *testing.T) {110 tests := []struct {111 name string112 a TomographyDefinition113 b TomographyDefinition114 want bool115 }{116 {117 name: "identical single hints",118 a: TomographyDefinition{{1, 2}, {3}},119 b: TomographyDefinition{{1, 2}, {3}},120 want: true,121 },122 {123 name: "different values",124 a: TomographyDefinition{{1, 2}},125 b: TomographyDefinition{{1, 3}},126 want: false,127 },128 {129 name: "different inner lengths",130 a: TomographyDefinition{{1, 2}},131 b: TomographyDefinition{{1, 2, 3}},132 want: false,133 },134 {135 name: "different outer lengths",136 a: TomographyDefinition{{1}, {2}},137 b: TomographyDefinition{{1}},138 want: false,139 },140 {141 name: "both empty",142 a: TomographyDefinition{},143 b: TomographyDefinition{},144 want: true,145 },146 {147 name: "nil vs empty",148 a: nil,149 b: TomographyDefinition{},150 want: true,151 },152 }153154 for _, tt := range tests {155 t.Run(tt.name, func(t *testing.T) {156 if got := tt.a.equal(tt.b); got != tt.want {157 t.Errorf("equal() = %v, want %v", got, tt.want)158 }159 })160 }161}162163// --- TomographyDefinition.RequiredLen (P1) ---164165func TestTomographyDefinitionRequiredLen(t *testing.T) {166 tests := []struct {167 name string168 td TomographyDefinition169 want int170 }{171 {"varying lengths", TomographyDefinition{{1}, {2, 3}, {4}}, 2},172 {"single element each", TomographyDefinition{{5}, {3}}, 1},173 {"empty definition", TomographyDefinition{}, 0},174 }175176 for _, tt := range tests {177 t.Run(tt.name, func(t *testing.T) {178 if got := tt.td.RequiredLen(); got != tt.want {179 t.Errorf("RequiredLen() = %d, want %d", got, tt.want)180 }181 })182 }183}184185// --- Grid serialization round-trip (P1) ---186187func TestGridRoundTrip(t *testing.T) {188 t.Run("5x5 round-trip", func(t *testing.T) {189 g := grid{190 []rune{filledTile, emptyTile, filledTile, emptyTile, filledTile},191 []rune{emptyTile, filledTile, emptyTile, filledTile, emptyTile},192 []rune{filledTile, filledTile, filledTile, filledTile, filledTile},193 []rune{emptyTile, emptyTile, emptyTile, emptyTile, emptyTile},194 []rune{filledTile, emptyTile, emptyTile, emptyTile, filledTile},195 }196 s := g.String()197 g2 := newGrid(state(s))198 s2 := g2.String()199 if s != s2 {200 t.Errorf("round-trip mismatch:\n got: %q\n want: %q", s2, s)201 }202 })203204 t.Run("preserves all tile types", func(t *testing.T) {205 g := grid{206 []rune{filledTile, markedTile, emptyTile},207 }208 s := g.String()209 g2 := newGrid(state(s))210 s2 := g2.String()211 if s != s2 {212 t.Errorf("tile types not preserved:\n got: %q\n want: %q", s2, s)213 }214 })215216 t.Run("single row", func(t *testing.T) {217 g := grid{218 []rune{filledTile, emptyTile, filledTile},219 }220 s := g.String()221 g2 := newGrid(state(s))222 if len(g2) != 1 {223 t.Fatalf("expected 1 row, got %d", len(g2))224 }225 if g2.String() != s {226 t.Error("single row round-trip mismatch")227 }228 })229230 t.Run("single column", func(t *testing.T) {231 g := grid{232 []rune{filledTile},233 []rune{emptyTile},234 []rune{filledTile},235 }236 s := g.String()237 g2 := newGrid(state(s))238 if len(g2) != 3 {239 t.Fatalf("expected 3 rows, got %d", len(g2))240 }241 if g2.String() != s {242 t.Error("single column round-trip mismatch")243 }244 })245}246247// --- createEmptyState (P1) ---248249func TestCreateEmptyState(t *testing.T) {250 t.Run("5x5", func(t *testing.T) {251 s := createEmptyState(5, 5)252 rows := strings.Split(string(s), "\n")253 if len(rows) != 5 {254 t.Fatalf("expected 5 rows, got %d", len(rows))255 }256 for i, row := range rows {257 if len([]rune(row)) != 5 {258 t.Errorf("row %d: expected 5 runes, got %d", i, len([]rune(row)))259 }260 for _, r := range row {261 if r != emptyTile {262 t.Errorf("row %d: expected emptyTile, got %q", i, r)263 }264 }265 }266 })267268 t.Run("1x1", func(t *testing.T) {269 s := createEmptyState(1, 1)270 if string(s) != string(emptyTile) {271 t.Errorf("expected single emptyTile, got %q", s)272 }273 if strings.Contains(string(s), "\n") {274 t.Error("1x1 should not contain newline")275 }276 })277278 t.Run("0 dimension", func(t *testing.T) {279 if s := createEmptyState(0, 5); s != "" {280 t.Errorf("expected empty string for h=0, got %q", s)281 }282 if s := createEmptyState(5, 0); s != "" {283 t.Errorf("expected empty string for w=0, got %q", s)284 }285 })286287 t.Run("negative dimension", func(t *testing.T) {288 if s := createEmptyState(-1, 5); s != "" {289 t.Errorf("expected empty string for h=-1, got %q", s)290 }291 })292}293294// --- generateRandomState (P2) ---295296func TestGenerateRandomState(t *testing.T) {297 t.Run("density clamping low", func(t *testing.T) {298 // Use a larger grid and multiple runs to avoid flakiness from per-row jitter.299 totalFilled := 0300 for i := range 10 {301 s := generateRandomStateSeeded(10, 10, 0.0, rand.New(rand.NewPCG(uint64(i+1), uint64(i+101))))302 g := newGrid(s)303 for _, row := range g {304 for _, r := range row {305 if r == filledTile {306 totalFilled++307 }308 }309 }310 }311 if totalFilled == 0 {312 t.Error("expected some filled cells with density clamped from 0.0")313 }314 })315316 t.Run("density clamping high", func(t *testing.T) {317 totalEmpty := 0318 for i := range 10 {319 s := generateRandomStateSeeded(10, 10, 1.0, rand.New(rand.NewPCG(uint64(i+21), uint64(i+121))))320 g := newGrid(s)321 for _, row := range g {322 for _, r := range row {323 if r == emptyTile {324 totalEmpty++325 }326 }327 }328 }329 if totalEmpty == 0 {330 t.Error("expected some empty cells with density clamped from 1.0")331 }332 })333334 t.Run("correct dimensions", func(t *testing.T) {335 s := generateRandomStateSeeded(5, 10, 0.5, rand.New(rand.NewPCG(41, 141)))336 g := newGrid(s)337 if len(g) != 5 {338 t.Fatalf("expected 5 rows, got %d", len(g))339 }340 for i, row := range g {341 if len(row) != 10 {342 t.Errorf("row %d: expected 10 cols, got %d", i, len(row))343 }344 }345 })346347 t.Run("0 dimensions", func(t *testing.T) {348 if s := generateRandomStateSeeded(0, 5, 0.5, rand.New(rand.NewPCG(51, 151))); s != "" {349 t.Errorf("expected empty string for h=0, got %q", s)350 }351 if s := generateRandomStateSeeded(5, 0, 0.5, rand.New(rand.NewPCG(52, 152))); s != "" {352 t.Errorf("expected empty string for w=0, got %q", s)353 }354 })355356 t.Run("approximate density", func(t *testing.T) {357 totalFilled := 0358 totalCells := 0359 runs := 20360 for i := range runs {361 s := generateRandomStateSeeded(10, 10, 0.5, rand.New(rand.NewPCG(uint64(i+61), uint64(i+161))))362 g := newGrid(s)363 for _, row := range g {364 for _, r := range row {365 totalCells++366 if r == filledTile {367 totalFilled++368 }369 }370 }371 }372 ratio := float64(totalFilled) / float64(totalCells)373 if ratio < 0.2 || ratio > 0.8 {374 t.Errorf("average density = %.2f, expected roughly 0.5 (within 0.2–0.8)", ratio)375 }376 })377}378379// --- isValidPuzzle (P0) ---380381func TestIsValidPuzzle(t *testing.T) {382 tests := []struct {383 name string384 hints Hints385 height int386 width int387 want bool388 }{389 {390 name: "all-zero row",391 hints: Hints{392 rows: TomographyDefinition{{0}, {1, 2}},393 cols: TomographyDefinition{{1}, {2}},394 },395 height: 2, width: 2,396 want: false,397 },398 {399 name: "full-width row",400 hints: Hints{401 rows: TomographyDefinition{{5}, {1, 2}},402 cols: TomographyDefinition{{1}, {1}, {1}, {1}, {1}},403 },404 height: 2, width: 5,405 want: false,406 },407 {408 name: "all-zero column",409 hints: Hints{410 rows: TomographyDefinition{{1}, {1}},411 cols: TomographyDefinition{{0}, {2}},412 },413 height: 2, width: 2,414 want: false,415 },416 {417 name: "full-height column",418 hints: Hints{419 rows: TomographyDefinition{{1}, {1}, {1}, {1}, {1}},420 cols: TomographyDefinition{{5}, {1, 2}},421 },422 height: 5, width: 2,423 want: false,424 },425 {426 name: "valid mixed hints",427 hints: Hints{428 rows: TomographyDefinition{{1, 1}, {2}},429 cols: TomographyDefinition{{1}, {1, 1}, {1}},430 },431 height: 2, width: 3,432 want: true,433 },434 {435 name: "multi-segment rows valid",436 hints: Hints{437 rows: TomographyDefinition{{2, 1}, {1, 1}},438 cols: TomographyDefinition{{1}, {1, 1}, {1}, {1}, {1}},439 },440 height: 2, width: 5,441 want: true,442 },443 }444445 for _, tt := range tests {446 t.Run(tt.name, func(t *testing.T) {447 if got := isValidPuzzle(tt.hints, tt.height, tt.width); got != tt.want {448 t.Errorf("isValidPuzzle() = %v, want %v", got, tt.want)449 }450 })451 }452}453454// --- GenerateRandomTomography (P2) ---455456func TestGenerateRandomTomography(t *testing.T) {457 mode := NewMode("Test", "test mode", 5, 5, 0.5)458459 t.Run("produces valid puzzle", func(t *testing.T) {460 hints := GenerateRandomTomography(mode)461 if !isValidPuzzle(hints, mode.Height, mode.Width) {462 t.Error("GenerateRandomTomography produced invalid puzzle")463 }464 })465466 t.Run("hints match dimensions", func(t *testing.T) {467 hints := GenerateRandomTomography(mode)468 if len(hints.rows) != mode.Height {469 t.Errorf("len(rows) = %d, want %d", len(hints.rows), mode.Height)470 }471 if len(hints.cols) != mode.Width {472 t.Errorf("len(cols) = %d, want %d", len(hints.cols), mode.Width)473 }474 })475}476477// --- Save/Load round-trip (P1) ---478479func TestSaveLoadRoundTrip(t *testing.T) {480 t.Run("round-trip preserves state", func(t *testing.T) {481 g := grid{482 []rune{filledTile, emptyTile, markedTile, filledTile, emptyTile},483 []rune{emptyTile, filledTile, filledTile, emptyTile, filledTile},484 []rune{filledTile, filledTile, emptyTile, emptyTile, filledTile},485 []rune{emptyTile, emptyTile, filledTile, filledTile, emptyTile},486 []rune{filledTile, emptyTile, filledTile, emptyTile, filledTile},487 }488 hints := Hints{489 rows: TomographyDefinition{{2, 1}, {1, 2}, {2, 1}, {2}, {1, 1, 1}},490 cols: TomographyDefinition{{1, 1, 1}, {1, 1}, {1, 1, 1}, {1, 1}, {1, 1, 1}},491 }492 curr := generateTomography(g)493494 m := Model{495 width: 5,496 height: 5,497 rowHints: hints.rows,498 colHints: hints.cols,499 grid: g,500 keys: DefaultKeyMap,501 currentHints: curr,502 solved: curr.rows.equal(hints.rows) && curr.cols.equal(hints.cols),503 }504505 data, err := m.GetSave()506 if err != nil {507 t.Fatal(err)508 }509510 got, err := ImportModel(data)511 if err != nil {512 t.Fatal(err)513 }514515 // Grid content.516 if got.grid.String() != m.grid.String() {517 t.Errorf("grid mismatch:\n got: %q\n want: %q", got.grid.String(), m.grid.String())518 }519520 // Dimensions.521 if got.width != m.width {522 t.Errorf("width = %d, want %d", got.width, m.width)523 }524 if got.height != m.height {525 t.Errorf("height = %d, want %d", got.height, m.height)526 }527528 // Hints.529 if !got.rowHints.equal(m.rowHints) {530 t.Errorf("rowHints mismatch: got %v, want %v", got.rowHints, m.rowHints)531 }532 if !got.colHints.equal(m.colHints) {533 t.Errorf("colHints mismatch: got %v, want %v", got.colHints, m.colHints)534 }535536 // Solved state.537 if got.solved != m.solved {538 t.Errorf("solved = %v, want %v", got.solved, m.solved)539 }540 })541542 t.Run("preserves solved state", func(t *testing.T) {543 // Create a grid that matches its own tomography (solved).544 g := grid{545 []rune{filledTile, emptyTile},546 []rune{emptyTile, filledTile},547 }548 hints := generateTomography(g)549550 m := Model{551 width: 2,552 height: 2,553 rowHints: hints.rows,554 colHints: hints.cols,555 grid: g,556 keys: DefaultKeyMap,557 currentHints: hints,558 solved: true,559 }560561 data, err := m.GetSave()562 if err != nil {563 t.Fatal(err)564 }565566 got, err := ImportModel(data)567 if err != nil {568 t.Fatal(err)569 }570571 if !got.solved {572 t.Error("expected imported model to be solved")573 }574 })575576 t.Run("invalid JSON", func(t *testing.T) {577 _, err := ImportModel([]byte("not json"))578 if err == nil {579 t.Fatal("expected error for invalid JSON")580 }581 })582}583584// --- countSolutions (P0) ---585586func TestCountSolutions_Unique(t *testing.T) {587 tests := []struct {588 name string589 hints Hints590 w, h int591 want int592 }{593 {594 name: "3x3 permutation matrix has 6 solutions",595 hints: Hints{596 rows: TomographyDefinition{{1}, {1}, {1}},597 cols: TomographyDefinition{{1}, {1}, {1}},598 },599 w: 3, h: 3,600 want: 6,601 },602 {603 name: "2x2 diagonal has 2 solutions",604 hints: Hints{605 rows: TomographyDefinition{{1}, {1}},606 cols: TomographyDefinition{{1}, {1}},607 },608 w: 2, h: 2,609 want: 2,610 },611 {612 name: "single cell filled",613 hints: Hints{614 rows: TomographyDefinition{{1}},615 cols: TomographyDefinition{{1}},616 },617 w: 1, h: 1,618 want: 1,619 },620 {621 name: "single cell empty",622 hints: Hints{623 rows: TomographyDefinition{{0}},624 cols: TomographyDefinition{{0}},625 },626 w: 1, h: 1,627 want: 1,628 },629 {630 name: "full row constraint",631 hints: Hints{632 rows: TomographyDefinition{{3}},633 cols: TomographyDefinition{{1}, {1}, {1}},634 },635 w: 3, h: 1,636 want: 1,637 },638 {639 name: "full 2x2 grid unique",640 hints: Hints{641 rows: TomographyDefinition{{2}, {2}},642 cols: TomographyDefinition{{2}, {2}},643 },644 w: 2, h: 2,645 want: 1,646 },647 {648 name: "empty 2x2 grid unique",649 hints: Hints{650 rows: TomographyDefinition{{0}, {0}},651 cols: TomographyDefinition{{0}, {0}},652 },653 w: 2, h: 2,654 want: 1,655 },656 {657 name: "L-shape unique",658 hints: Hints{659 rows: TomographyDefinition{{2}, {1}},660 cols: TomographyDefinition{{1}, {2}},661 },662 w: 2, h: 2,663 want: 1,664 },665 }666667 for _, tt := range tests {668 t.Run(tt.name, func(t *testing.T) {669 ctx := context.Background()670 got := countSolutions(tt.hints, tt.w, tt.h, 20, ctx)671 if got != tt.want {672 t.Errorf("countSolutions = %d, want %d", got, tt.want)673 }674 })675 }676}677678func TestCountSolutions_Multiple(t *testing.T) {679 t.Run("symmetric hints with multiple solutions", func(t *testing.T) {680 hints := Hints{681 rows: TomographyDefinition{{1}, {1}, {1}},682 cols: TomographyDefinition{{1}, {1}, {1}},683 }684 ctx := context.Background()685 got := countSolutions(hints, 3, 3, 10, ctx)686 if got < 2 {687 t.Errorf("expected multiple solutions, got %d", got)688 }689 })690691 t.Run("ambiguous pattern", func(t *testing.T) {692 hints := Hints{693 rows: TomographyDefinition{{1}, {1}},694 cols: TomographyDefinition{{1}, {1}},695 }696 ctx := context.Background()697 got := countSolutions(hints, 2, 2, 10, ctx)698 if got != 2 {699 t.Errorf("expected 2 solutions, got %d", got)700 }701 })702}703704func TestCountSolutions_Limit(t *testing.T) {705 t.Run("respects limit", func(t *testing.T) {706 hints := Hints{707 rows: TomographyDefinition{{1}, {1}, {1}},708 cols: TomographyDefinition{{1}, {1}, {1}},709 }710 ctx := context.Background()711 got := countSolutions(hints, 3, 3, 2, ctx)712 if got > 2 {713 t.Errorf("countSolutions = %d, should not exceed limit 2", got)714 }715 })716}717718func TestCountSolutions_Timeout(t *testing.T) {719 t.Run("returns on timeout", func(t *testing.T) {720 ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)721 defer cancel()722 time.Sleep(1 * time.Millisecond)723724 hints := Hints{725 rows: TomographyDefinition{{1}, {1}, {1}},726 cols: TomographyDefinition{{1}, {1}, {1}},727 }728 got := countSolutions(hints, 3, 3, 10, ctx)729 if got != -1 {730 t.Errorf("expected -1 on timeout, got %d", got)731 }732 })733}734735// --- propagateLine (P0) ---736737func TestPropagateLine(t *testing.T) {738 tests := []struct {739 name string740 line []cellState741 hint []int742 want []cellState743 wantOK bool744 }{745 {746 name: "empty line with zero hint",747 line: []cellState{cellUnknown, cellUnknown, cellUnknown},748 hint: []int{0},749 want: []cellState{cellEmpty, cellEmpty, cellEmpty},750 wantOK: true,751 },752 {753 name: "full line",754 line: []cellState{cellUnknown, cellUnknown, cellUnknown},755 hint: []int{3},756 want: []cellState{cellFilled, cellFilled, cellFilled},757 wantOK: true,758 },759 {760 name: "single block in middle of 5",761 line: []cellState{cellUnknown, cellUnknown, cellUnknown, cellUnknown, cellUnknown},762 hint: []int{3},763 want: []cellState{cellUnknown, cellUnknown, cellFilled, cellUnknown, cellUnknown},764 wantOK: true,765 },766 {767 name: "contradiction",768 line: []cellState{cellFilled, cellUnknown, cellEmpty},769 hint: []int{3},770 want: nil,771 wantOK: false,772 },773 {774 name: "partial fill forced with [2,1]",775 line: []cellState{cellFilled, cellUnknown, cellUnknown, cellUnknown, cellUnknown},776 hint: []int{2, 1},777 want: []cellState{cellFilled, cellFilled, cellEmpty, cellUnknown, cellUnknown},778 wantOK: true,779 },780 }781782 for _, tt := range tests {783 t.Run(tt.name, func(t *testing.T) {784 got, ok := propagateLine(tt.line, tt.hint)785 if ok != tt.wantOK {786 t.Errorf("propagateLine ok = %v, want %v", ok, tt.wantOK)787 return788 }789 if !tt.wantOK {790 return791 }792 if len(got) != len(tt.want) {793 t.Errorf("got length %d, want %d", len(got), len(tt.want))794 return795 }796 for i := range got {797 if got[i] != tt.want[i] {798 t.Errorf("got[%d] = %v, want %v", i, got[i], tt.want[i])799 }800 }801 })802 }803}804805// --- generateLinePossibilities (P0) ---806807func TestGenerateLinePossibilities(t *testing.T) {808 tests := []struct {809 name string810 length int811 hint []int812 want int813 }{814 {"empty line", 3, []int{0}, 1},815 {"full line", 3, []int{3}, 1},816 {"single in 3", 3, []int{1}, 3},817 {"two blocks", 5, []int{1, 1}, 6},818 {"block of 2 in 5", 5, []int{2}, 4},819 {"exact fit", 5, []int{2, 2}, 1},820 {"impossible", 3, []int{4}, 0},821 }822823 for _, tt := range tests {824 t.Run(tt.name, func(t *testing.T) {825 got := generateLinePossibilities(tt.length, tt.hint)826 if len(got) != tt.want {827 t.Errorf("got %d possibilities, want %d", len(got), tt.want)828 }829 })830 }831}832833// --- GenerateRandomTomography uniqueness (P0) ---834835func TestGenerateRandomTomography_AllModes_Unique(t *testing.T) {836 if testing.Short() {837 t.Skip("skipping slow generator test in short mode")838 }839 modes := []struct {840 name string841 width int842 height int843 density float64844 runs int845 }{846 {"Easy 5x5", 5, 5, 0.65, 5},847 {"Medium 5x5", 5, 5, 0.50, 5},848 {"Hard 5x5", 5, 5, 0.35, 5},849 {"Easy 10x10", 10, 10, 0.65, 3},850 {"Medium 10x10", 10, 10, 0.50, 3},851 {"Hard 10x10", 10, 10, 0.35, 3},852 {"Easy 15x15", 15, 15, 0.65, 1},853 {"Medium 15x15", 15, 15, 0.50, 1},854 {"Easy 20x20", 20, 20, 0.65, 1},855 {"Medium 20x20", 20, 20, 0.50, 1},856 }857858 for _, m := range modes {859 t.Run(m.name, func(t *testing.T) {860 if m.width >= 15 && testing.Short() {861 t.Skip("skipping large grids in short mode")862 }863864 mode := NewMode(m.name, "test mode", m.width, m.height, m.density)865866 for range m.runs {867 hints := GenerateRandomTomography(mode)868 if len(hints.rows) == 0 || len(hints.cols) == 0 {869 t.Error("GenerateRandomTomography returned empty hints")870 return871 }872 if len(hints.rows) != m.height || len(hints.cols) != m.width {873 t.Errorf("hints dimensions wrong: got rows=%d, cols=%d, want rows=%d, cols=%d",874 len(hints.rows), len(hints.cols), m.height, m.width)875 return876 }877 }878 })879 }880}881882func TestGenerateRandomTomography_5x5_VerifyUnique(t *testing.T) {883 mode := NewMode("Test 5x5", "test mode", 5, 5, 0.5)884885 hints := GenerateRandomTomography(mode)886 if len(hints.rows) == 0 {887 t.Fatal("GenerateRandomTomography returned empty hints")888 }889890 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)891 defer cancel()892893 count := countSolutions(hints, mode.Width, mode.Height, 2, ctx)894 if count != 1 {895 t.Errorf("puzzle has %d solutions, want 1", count)896 }897}898899func TestGenerateRandomTomography_LargeModes_Unique(t *testing.T) {900 if testing.Short() {901 t.Skip("skipping large-mode uniqueness regression in short mode")902 }903904 cases := []struct {905 name string906 mode NonogramMode907 seedA uint64908 seedB uint64909 }{910 {name: "Epic", mode: NewMode("Epic", "test", 20, 20, 0.71), seedA: 3101, seedB: 8101},911 {name: "Massive", mode: NewMode("Massive", "test", 20, 20, 0.56), seedA: 4703, seedB: 9109},912 }913914 for _, tc := range cases {915 t.Run(tc.name, func(t *testing.T) {916 rng := rand.New(rand.NewPCG(tc.seedA, tc.seedB))917 hints := GenerateRandomTomographySeeded(tc.mode, rng)918 if len(hints.rows) != tc.mode.Height || len(hints.cols) != tc.mode.Width {919 t.Fatalf(920 "invalid hints dimensions rows=%d cols=%d want rows=%d cols=%d",921 len(hints.rows),922 len(hints.cols),923 tc.mode.Height,924 tc.mode.Width,925 )926 }927928 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)929 defer cancel()930 count := countSolutions(hints, tc.mode.Width, tc.mode.Height, 2, ctx)931 if count != 1 {932 t.Fatalf("expected unique solution for %s, got %d", tc.name, count)933 }934 })935 }936}937938func TestGenerateRandomTomography_LargeModes_PerfBudget(t *testing.T) {939 if testing.Short() {940 t.Skip("skipping large-mode perf regression in short mode")941 }942943 const maxDuration = 30 * time.Second944 cases := []struct {945 name string946 mode NonogramMode947 seedA uint64948 seedB uint64949 }{950 {name: "Epic", mode: NewMode("Epic", "test", 20, 20, 0.71), seedA: 5209, seedB: 8219},951 {name: "Massive", mode: NewMode("Massive", "test", 20, 20, 0.56), seedA: 5303, seedB: 12203},952 }953954 for _, tc := range cases {955 t.Run(tc.name, func(t *testing.T) {956 start := time.Now()957 rng := rand.New(rand.NewPCG(tc.seedA, tc.seedB))958 hints := GenerateRandomTomographySeeded(tc.mode, rng)959 elapsed := time.Since(start)960961 if len(hints.rows) == 0 || len(hints.cols) == 0 {962 t.Fatalf("GenerateRandomTomographySeeded returned empty hints for %s", tc.name)963 }964 if elapsed > maxDuration {965 t.Fatalf("%s generation exceeded %s: %s", tc.name, maxDuration, elapsed)966 }967 })968 }969}970971var _ game.Gamer = Model{}972973// --- Spawn Performance Benchmark (P2) ---974975func TestSpawnPerformance(t *testing.T) {976 if testing.Short() {977 t.Skip("skipping slow performance test in short mode")978 }979 const numRuns = 20980981 modeConfigs := []struct {982 name string983 mode NonogramMode984 }{985 {"Mini", NewMode("Mini", "test", 5, 5, 0.65)},986 {"Pocket", NewMode("Pocket", "test", 5, 5, 0.50)},987 {"Teaser", NewMode("Teaser", "test", 5, 5, 0.35)},988 {"Standard", NewMode("Standard", "test", 10, 10, 0.67)},989 {"Classic", NewMode("Classic", "test", 10, 10, 0.52)},990 {"Tricky", NewMode("Tricky", "test", 10, 10, 0.37)},991 {"Large", NewMode("Large", "test", 15, 15, 0.69)},992 {"Grand", NewMode("Grand", "test", 15, 15, 0.54)},993 {"Epic", NewMode("Epic", "test", 20, 20, 0.71)},994 {"Massive", NewMode("Massive", "test", 20, 20, 0.56)},995 }996997 t.Run("average spawn time across all modes", func(t *testing.T) {998 for _, cfg := range modeConfigs {999 t.Run(cfg.name, func(t *testing.T) {1000 if cfg.mode.Width >= 15 && testing.Short() {1001 t.Skip("skipping large grids in short mode")1002 }10031004 var totalDuration time.Duration1005 for range numRuns {1006 start := time.Now()1007 _, err := cfg.mode.Spawn()1008 if err != nil {1009 t.Fatalf("Spawn failed: %v", err)1010 }1011 totalDuration += time.Since(start)1012 }10131014 avgNs := totalDuration.Nanoseconds() / numRuns1015 avgµs := float64(avgNs) / 10001016 t.Logf("Average spawn time for %s: %.2fµs", cfg.name, avgµs)1017 })1018 }1019 })1020}10211022func TestReset(t *testing.T) {1023 mode := NonogramMode{Width: 5, Height: 5}1024 hints := Hints{1025 rows: TomographyDefinition{{1}, {1}, {1}, {1}, {1}},1026 cols: TomographyDefinition{{1}, {1}, {1}, {1}, {1}},1027 }1028 m, err := New(mode, hints)1029 if err != nil {1030 t.Fatal(err)1031 }1032 model := m.(Model)10331034 originalGrid := model.grid.String()10351036 model.grid[0][0] = filledTile1037 model.grid[0][1] = filledTile10381039 if model.grid.String() == originalGrid {1040 t.Fatal("grid should be modified before reset")1041 }10421043 reset := model.Reset().(Model)10441045 if reset.grid.String() != originalGrid {1046 t.Errorf("grid after reset = %q, want %q", reset.grid.String(), originalGrid)1047 }10481049 if reset.cursor != (game.Cursor{}) {1050 t.Errorf("cursor after reset = %v, want zero cursor", reset.cursor)1051 }10521053 if reset.solved {1054 t.Error("solved should be false after reset")1055 }1056}10571058func buildMouseTestModel(t *testing.T, width, height int) Model {1059 t.Helper()10601061 mode := NonogramMode{1062 BaseMode: game.NewBaseMode("Test", "grid"),1063 Width: width,1064 Height: height,1065 }1066 hints := Hints{1067 rows: repeatedHints(height, []int{1}),1068 cols: repeatedHints(width, []int{1}),1069 }1070 g, err := New(mode, hints)1071 if err != nil {1072 t.Fatal(err)1073 }1074 m := g.(Model)1075 m.termWidth = 1201076 m.termHeight = 401077 return m1078}10791080func nonogramCellScreenCoords(t *testing.T, m *Model, wantCol, wantRow int) (int, int) {1081 t.Helper()10821083 ox, oy := m.gridOrigin()1084 maxX := ox + m.width*(cellWidth+1) + 41085 maxY := oy + m.height*2 + 41086 for y := oy; y < maxY; y++ {1087 for x := ox; x < maxX; x++ {1088 col, row, ok := m.screenToGrid(x, y, false)1089 if ok && col == wantCol && row == wantRow {1090 return x, y1091 }1092 }1093 }10941095 t.Fatalf("screen coordinates not found for cell (%d,%d)", wantCol, wantRow)1096 return 0, 01097}10981099func updateNonogramModel(t *testing.T, m Model, msg tea.Msg) Model {1100 t.Helper()11011102 next, _ := m.Update(msg)1103 got, ok := next.(Model)1104 if !ok {1105 t.Fatal("expected nonogram model update to return Model")1106 }1107 return got1108}11091110func TestMouseDragAxisLockHorizontal(t *testing.T) {1111 m := buildMouseTestModel(t, 5, 5)1112 startX, startY := nonogramCellScreenCoords(t, &m, 1, 1)1113 nextX, nextY := nonogramCellScreenCoords(t, &m, 2, 1)1114 jitterX, jitterY := nonogramCellScreenCoords(t, &m, 3, 2)11151116 m = updateNonogramModel(t, m, tea.MouseClickMsg{X: startX, Y: startY, Button: tea.MouseLeft})1117 m = updateNonogramModel(t, m, tea.MouseMotionMsg{X: nextX, Y: nextY, Button: tea.MouseLeft})1118 m = updateNonogramModel(t, m, tea.MouseMotionMsg{X: jitterX, Y: jitterY, Button: tea.MouseLeft})11191120 if m.dragAxis != dragAxisHorizontal {1121 t.Fatalf("dragAxis = %v, want horizontal", m.dragAxis)1122 }1123 if m.cursor != (game.Cursor{X: 3, Y: 1}) {1124 t.Fatalf("cursor = %v, want (3,1)", m.cursor)1125 }1126 if got := m.grid[1][1]; got != filledTile {1127 t.Fatalf("start cell = %q, want filled", got)1128 }1129 if got := m.grid[1][2]; got != filledTile {1130 t.Fatalf("locked row cell = %q, want filled", got)1131 }1132 if got := m.grid[1][3]; got != filledTile {1133 t.Fatalf("jitter target cell = %q, want filled", got)1134 }1135 if got := m.grid[2][3]; got != emptyTile {1136 t.Fatalf("off-axis cell = %q, want empty", got)1137 }1138}11391140func TestMouseDragAxisLockVertical(t *testing.T) {1141 m := buildMouseTestModel(t, 5, 5)1142 startX, startY := nonogramCellScreenCoords(t, &m, 1, 1)1143 nextX, nextY := nonogramCellScreenCoords(t, &m, 1, 2)1144 jitterX, jitterY := nonogramCellScreenCoords(t, &m, 2, 3)11451146 m = updateNonogramModel(t, m, tea.MouseClickMsg{X: startX, Y: startY, Button: tea.MouseLeft})1147 m = updateNonogramModel(t, m, tea.MouseMotionMsg{X: nextX, Y: nextY, Button: tea.MouseLeft})1148 m = updateNonogramModel(t, m, tea.MouseMotionMsg{X: jitterX, Y: jitterY, Button: tea.MouseLeft})11491150 if m.dragAxis != dragAxisVertical {1151 t.Fatalf("dragAxis = %v, want vertical", m.dragAxis)1152 }1153 if m.cursor != (game.Cursor{X: 1, Y: 3}) {1154 t.Fatalf("cursor = %v, want (1,3)", m.cursor)1155 }1156 if got := m.grid[1][1]; got != filledTile {1157 t.Fatalf("start cell = %q, want filled", got)1158 }1159 if got := m.grid[2][1]; got != filledTile {1160 t.Fatalf("locked column cell = %q, want filled", got)1161 }1162 if got := m.grid[3][1]; got != filledTile {1163 t.Fatalf("jitter target cell = %q, want filled", got)1164 }1165 if got := m.grid[3][2]; got != emptyTile {1166 t.Fatalf("off-axis cell = %q, want empty", got)1167 }1168}11691170func TestMouseDragAxisLockLargestDelta(t *testing.T) {1171 m := buildMouseTestModel(t, 5, 5)1172 startX, startY := nonogramCellScreenCoords(t, &m, 1, 1)1173 diagX, diagY := nonogramCellScreenCoords(t, &m, 3, 2)11741175 m = updateNonogramModel(t, m, tea.MouseClickMsg{X: startX, Y: startY, Button: tea.MouseLeft})1176 m = updateNonogramModel(t, m, tea.MouseMotionMsg{X: diagX, Y: diagY, Button: tea.MouseLeft})11771178 if m.dragAxis != dragAxisHorizontal {1179 t.Fatalf("dragAxis = %v, want horizontal", m.dragAxis)1180 }1181 if m.cursor != (game.Cursor{X: 3, Y: 1}) {1182 t.Fatalf("cursor = %v, want (3,1)", m.cursor)1183 }1184 if got := m.grid[2][3]; got != emptyTile {1185 t.Fatalf("off-axis diagonal cell = %q, want empty", got)1186 }1187}11881189func TestMouseDragAxisLockTieBreaksLater(t *testing.T) {1190 m := buildMouseTestModel(t, 5, 5)1191 startX, startY := nonogramCellScreenCoords(t, &m, 1, 1)1192 tieX, tieY := nonogramCellScreenCoords(t, &m, 2, 2)1193 breakX, breakY := nonogramCellScreenCoords(t, &m, 3, 2)11941195 m = updateNonogramModel(t, m, tea.MouseClickMsg{X: startX, Y: startY, Button: tea.MouseLeft})1196 m = updateNonogramModel(t, m, tea.MouseMotionMsg{X: tieX, Y: tieY, Button: tea.MouseLeft})11971198 if m.dragAxis != dragAxisNone {1199 t.Fatalf("dragAxis after tie = %v, want none", m.dragAxis)1200 }1201 if m.cursor != (game.Cursor{X: 1, Y: 1}) {1202 t.Fatalf("cursor after tie = %v, want anchor", m.cursor)1203 }1204 if got := m.grid[2][2]; got != emptyTile {1205 t.Fatalf("tie cell = %q, want empty", got)1206 }12071208 m = updateNonogramModel(t, m, tea.MouseMotionMsg{X: breakX, Y: breakY, Button: tea.MouseLeft})12091210 if m.dragAxis != dragAxisHorizontal {1211 t.Fatalf("dragAxis after tie-break = %v, want horizontal", m.dragAxis)1212 }1213 if m.cursor != (game.Cursor{X: 3, Y: 1}) {1214 t.Fatalf("cursor after tie-break = %v, want (3,1)", m.cursor)1215 }1216 if got := m.grid[1][3]; got != filledTile {1217 t.Fatalf("tie-break cell = %q, want filled", got)1218 }1219}12201221func TestMouseDragSeparatorSnapAfterLock(t *testing.T) {1222 m := buildMouseTestModel(t, 10, 10)1223 startX, startY := nonogramCellScreenCoords(t, &m, 4, 0)1224 ox, oy := m.gridOrigin()1225 separatorX := ox + cellWidth*51226 jitterX, jitterY := nonogramCellScreenCoords(t, &m, 6, 1)12271228 m = updateNonogramModel(t, m, tea.MouseClickMsg{X: startX, Y: startY, Button: tea.MouseLeft})1229 m = updateNonogramModel(t, m, tea.MouseMotionMsg{X: separatorX, Y: oy, Button: tea.MouseLeft})12301231 if m.dragAxis != dragAxisHorizontal {1232 t.Fatalf("dragAxis at separator = %v, want horizontal", m.dragAxis)1233 }1234 if m.cursor != (game.Cursor{X: 5, Y: 0}) {1235 t.Fatalf("cursor at separator = %v, want (5,0)", m.cursor)1236 }12371238 m = updateNonogramModel(t, m, tea.MouseMotionMsg{X: jitterX, Y: jitterY, Button: tea.MouseLeft})12391240 if m.cursor != (game.Cursor{X: 6, Y: 0}) {1241 t.Fatalf("cursor after separator jitter = %v, want (6,0)", m.cursor)1242 }1243 if got := m.grid[0][6]; got != filledTile {1244 t.Fatalf("separator clamped cell = %q, want filled", got)1245 }1246 if got := m.grid[1][6]; got != emptyTile {1247 t.Fatalf("off-axis separator cell = %q, want empty", got)1248 }1249}12501251func TestMouseClickWithoutMotionTogglesOnlyStartCell(t *testing.T) {1252 m := buildMouseTestModel(t, 5, 5)1253 startX, startY := nonogramCellScreenCoords(t, &m, 2, 2)12541255 m = updateNonogramModel(t, m, tea.MouseClickMsg{X: startX, Y: startY, Button: tea.MouseLeft})1256 m = updateNonogramModel(t, m, tea.MouseReleaseMsg{X: startX, Y: startY, Button: tea.MouseLeft})12571258 if got := m.grid[2][2]; got != filledTile {1259 t.Fatalf("start cell = %q, want filled", got)1260 }1261 for y := range m.height {1262 for x := range m.width {1263 if x == 2 && y == 2 {1264 continue1265 }1266 if got := m.grid[y][x]; got != emptyTile {1267 t.Fatalf("grid[%d][%d] = %q, want empty", y, x, got)1268 }1269 }1270 }1271}12721273func TestMouseReleaseClearsAxisLockState(t *testing.T) {1274 m := buildMouseTestModel(t, 5, 5)1275 startX, startY := nonogramCellScreenCoords(t, &m, 1, 1)1276 nextX, nextY := nonogramCellScreenCoords(t, &m, 2, 1)12771278 m = updateNonogramModel(t, m, tea.MouseClickMsg{X: startX, Y: startY, Button: tea.MouseLeft})1279 m = updateNonogramModel(t, m, tea.MouseMotionMsg{X: nextX, Y: nextY, Button: tea.MouseLeft})1280 m = updateNonogramModel(t, m, tea.MouseReleaseMsg{X: nextX, Y: nextY, Button: tea.MouseLeft})12811282 if m.dragging != 0 {1283 t.Fatalf("dragging = %d, want 0", m.dragging)1284 }1285 if m.dragAxis != dragAxisNone {1286 t.Fatalf("dragAxis = %v, want none", m.dragAxis)1287 }1288 if m.dragStartCol != 0 || m.dragStartRow != 0 {1289 t.Fatalf("drag start = (%d,%d), want cleared", m.dragStartCol, m.dragStartRow)1290 }1291}12921293func TestResetClearsAxisLockState(t *testing.T) {1294 m := buildMouseTestModel(t, 5, 5)1295 m.dragging = filledTile1296 m.dragStartCol = 21297 m.dragStartRow = 31298 m.dragAxis = dragAxisVertical12991300 reset := m.Reset().(Model)13011302 if reset.dragging != 0 {1303 t.Fatalf("dragging = %d, want 0", reset.dragging)1304 }1305 if reset.dragAxis != dragAxisNone {1306 t.Fatalf("dragAxis = %v, want none", reset.dragAxis)1307 }1308 if reset.dragStartCol != 0 || reset.dragStartRow != 0 {1309 t.Fatalf("drag start = (%d,%d), want cleared", reset.dragStartCol, reset.dragStartRow)1310 }1311}13121313// --- screenToGrid (P0) ---13141315func TestScreenToGrid(t *testing.T) {1316 buildModel := func(width, height int) Model {1317 mode := NonogramMode{1318 BaseMode: game.NewBaseMode("Test", "grid"),1319 Width: width,1320 Height: height,1321 }1322 hints := Hints{1323 rows: repeatedHints(height, []int{1}),1324 cols: repeatedHints(width, []int{1}),1325 }1326 g, err := New(mode, hints)1327 if err != nil {1328 t.Fatal(err)1329 }1330 m := g.(Model)1331 m.termWidth = 801332 m.termHeight = 401333 return m1334 }13351336 t.Run("basic 5x5 hit-testing", func(t *testing.T) {1337 m := buildModel(5, 5)1338 ox, oy := m.gridOrigin()13391340 col, row, ok := m.screenToGrid(ox, oy, false)1341 if !ok {1342 t.Fatal("expected ok for first cell")1343 }1344 if col != 0 || row != 0 {1345 t.Errorf("got (%d,%d), want (0,0)", col, row)1346 }13471348 col, row, ok = m.screenToGrid(ox+4*cellWidth, oy+4, false)1349 if !ok {1350 t.Fatal("expected ok for last cell")1351 }1352 if col != 4 || row != 4 {1353 t.Errorf("got (%d,%d), want (4,4)", col, row)1354 }13551356 _, _, ok = m.screenToGrid(ox-1, oy, false)1357 if ok {1358 t.Error("expected not ok for click left of grid")1359 }13601361 _, _, ok = m.screenToGrid(ox, oy-1, false)1362 if ok {1363 t.Error("expected not ok for click above grid")1364 }1365 })13661367 t.Run("10x10 separators and hint bands", func(t *testing.T) {1368 m := buildModel(10, 10)1369 ox, oy := m.gridOrigin()13701371 separatorX := ox + cellWidth*51372 separatorY := oy + 513731374 _, _, ok := m.screenToGrid(separatorX, oy, false)1375 if ok {1376 t.Error("expected separator click to miss")1377 }13781379 col, row, ok := m.screenToGrid(separatorX, oy, true)1380 if !ok {1381 t.Fatal("expected separator drag to snap")1382 }1383 if col != 5 || row != 0 {1384 t.Errorf("got (%d,%d), want (5,0)", col, row)1385 }13861387 _, _, ok = m.screenToGrid(ox, separatorY, false)1388 if ok {1389 t.Error("expected row separator click to miss")1390 }13911392 col, row, ok = m.screenToGrid(ox, separatorY, true)1393 if !ok {1394 t.Fatal("expected row separator drag to snap")1395 }1396 if col != 0 || row != 5 {1397 t.Errorf("got (%d,%d), want (0,5)", col, row)1398 }13991400 _, _, ok = m.screenToGrid(ox-cellWidth, oy, false)1401 if ok {1402 t.Error("expected hint band click to miss")1403 }1404 })1405}14061407func TestHelpToggleInvalidatesOriginCache(t *testing.T) {1408 mode := NonogramMode{1409 BaseMode: game.NewBaseMode("Test", "5x5"),1410 Width: 5,1411 Height: 5,1412 }1413 hints := Hints{1414 rows: TomographyDefinition{{1}, {1}, {1}, {1}, {1}},1415 cols: TomographyDefinition{{1}, {1}, {1}, {1}, {1}},1416 }1417 g, err := New(mode, hints)1418 if err != nil {1419 t.Fatal(err)1420 }14211422 m := g.(Model)1423 m.originValid = true1424 m.originX = 31425 m.originY = 414261427 next, _ := m.Update(game.HelpToggleMsg{Show: true})1428 got := next.(Model)1429 if !got.showFullHelp {1430 t.Fatal("expected showFullHelp to be true")1431 }1432 if got.originValid {1433 t.Fatal("expected origin cache to be invalidated")1434 }1435}14361437// --- Benchmarks (P2) ---14381439func skipBenchmarkInShortMode(b *testing.B) {1440 b.Helper()1441 if testing.Short() {1442 b.Skip("skipping benchmark in short mode")1443 }1444}14451446func BenchmarkGenerateRandomTomographyModes(b *testing.B) {1447 skipBenchmarkInShortMode(b)14481449 modeConfigs := []struct {1450 name string1451 mode NonogramMode1452 seedA uint641453 }{1454 {name: "5x5-Mini", mode: NewMode("Mini", "bench", 5, 5, 0.65), seedA: 100000},1455 {name: "10x10-Classic", mode: NewMode("Classic", "bench", 10, 10, 0.52), seedA: 200000},1456 {name: "15x15-Grand", mode: NewMode("Grand", "bench", 15, 15, 0.54), seedA: 300000},1457 {name: "20x20-Epic", mode: NewMode("Epic", "bench", 20, 20, 0.71), seedA: 350000},1458 {name: "20x20-Massive", mode: NewMode("Massive", "bench", 20, 20, 0.56), seedA: 400000},1459 }14601461 for _, cfg := range modeConfigs {1462 b.Run(cfg.name, func(b *testing.B) {1463 b.ReportAllocs()1464 for n := 0; n < b.N; n++ {1465 seedA := cfg.seedA1466 seedB := uint64((n + 1) * 1777)1467 rng := rand.New(rand.NewPCG(seedA, seedB))1468 hints := GenerateRandomTomographySeeded(cfg.mode, rng)1469 if len(hints.rows) != cfg.mode.Height || len(hints.cols) != cfg.mode.Width {1470 b.Fatalf(1471 "invalid hint dimensions rows=%d cols=%d want rows=%d cols=%d",1472 len(hints.rows),1473 len(hints.cols),1474 cfg.mode.Height,1475 cfg.mode.Width,1476 )1477 }1478 }1479 })1480 }1481}14821483func BenchmarkCountSolutionsLarge(b *testing.B) {1484 skipBenchmarkInShortMode(b)14851486 cases := []struct {1487 name string1488 mode NonogramMode1489 seedA uint641490 seedB uint641491 }{1492 {name: "15x15-Grand", mode: NewMode("Grand", "bench", 15, 15, 0.54), seedA: 7301, seedB: 8101},1493 {name: "20x20-Massive", mode: NewMode("Massive", "bench", 20, 20, 0.56), seedA: 9103, seedB: 10429},1494 }14951496 for _, tc := range cases {1497 b.Run(tc.name, func(b *testing.B) {1498 rng := rand.New(rand.NewPCG(tc.seedA, tc.seedB))1499 hints := GenerateRandomTomographySeeded(tc.mode, rng)1500 if len(hints.rows) == 0 || len(hints.cols) == 0 {1501 b.Fatal("GenerateRandomTomographySeeded returned empty hints")1502 }15031504 b.ReportAllocs()1505 b.ResetTimer()1506 for n := 0; n < b.N; n++ {1507 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)1508 count := countSolutions(hints, tc.mode.Width, tc.mode.Height, 2, ctx)1509 cancel()1510 if count < 1 {1511 b.Fatalf("expected at least one solution, got %d", count)1512 }1513 }1514 })1515 }1516}