clone

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

feat/app-decomposition
puzzletea / games / rippleeffect / rippleeffect_test.go
rippleeffect_test.go 20.2K
1package rippleeffect23import (4 "fmt"5 "image"6 "image/color"7 "math/rand/v2"8 "strings"9 "testing"1011 "charm.land/lipgloss/v2"12 "github.com/FelineStateMachine/puzzletea/game"13 "github.com/FelineStateMachine/puzzletea/theme"14 "github.com/charmbracelet/x/ansi"15)1617func sampleCages() []Cage {18 return []Cage{19 {ID: 0, Size: 3, Cells: []Cell{{0, 0}, {1, 0}, {2, 0}}},20 {ID: 1, Size: 3, Cells: []Cell{{0, 1}, {1, 1}, {2, 1}}},21 {ID: 2, Size: 3, Cells: []Cell{{0, 2}, {1, 2}, {2, 2}}},22 }23}2425func sampleSolution() grid {26 return grid{27 {1, 2, 3},28 {2, 3, 1},29 {3, 1, 2},30 }31}3233func TestBuildGeometryBoundaries(t *testing.T) {34 geo, err := buildGeometry(3, 3, sampleCages())35 if err != nil {36 t.Fatal(err)37 }3839 if !geo.boundaries[0][0].has(boundaryTop) || !geo.boundaries[0][0].has(boundaryLeft) {40 t.Fatal("expected top-left cell to have outer top/left boundaries")41 }42 if geo.boundaries[0][0].has(boundaryRight) {43 t.Fatal("expected shared cage edge on the right to be open")44 }45 if !geo.boundaries[1][1].has(boundaryTop) {46 t.Fatal("expected cage boundary between first and second rows")47 }48 if !geo.boundaries[2][2].has(boundaryBottom) || !geo.boundaries[2][2].has(boundaryRight) {49 t.Fatal("expected bottom-right outer boundaries")50 }51}5253func TestValidateGridState(t *testing.T) {54 geo, err := buildGeometry(3, 3, sampleCages())55 if err != nil {56 t.Fatal(err)57 }5859 tests := []struct {60 name string61 grid grid62 solved bool63 }{64 {65 name: "valid solved board",66 grid: sampleSolution(),67 solved: true,68 },69 {70 name: "duplicate within cage",71 grid: grid{72 {1, 1, 3},73 {2, 3, 1},74 {3, 1, 2},75 },76 solved: false,77 },78 {79 name: "ripple conflict",80 grid: grid{81 {2, 1, 3},82 {2, 3, 1},83 {3, 1, 2},84 },85 solved: false,86 },87 {88 name: "incomplete board",89 grid: grid{90 {1, 2, 3},91 {2, 0, 1},92 {3, 1, 2},93 },94 solved: false,95 },96 }9798 for _, tt := range tests {99 t.Run(tt.name, func(t *testing.T) {100 result := validateGridState(tt.grid, geo)101 if result.solved != tt.solved {102 t.Fatalf("solved = %v, want %v", result.solved, tt.solved)103 }104 })105 }106}107108func TestCountSolutions(t *testing.T) {109 geo, err := buildGeometry(3, 3, sampleCages())110 if err != nil {111 t.Fatal(err)112 }113114 tests := []struct {115 name string116 givens grid117 want int118 }{119 {120 name: "unique solution",121 givens: grid{122 {1, 2, 3},123 {2, 3, 1},124 {3, 1, 2},125 },126 want: 1,127 },128 {129 name: "ambiguous puzzle",130 givens: grid{131 {0, 0, 0},132 {0, 0, 0},133 {0, 0, 0},134 },135 want: 2,136 },137 {138 name: "impossible puzzle",139 givens: grid{140 {2, 2, 3},141 {0, 0, 0},142 {0, 0, 0},143 },144 want: 0,145 },146 }147148 for _, tt := range tests {149 t.Run(tt.name, func(t *testing.T) {150 got := countSolutions(geo, tt.givens, 2)151 if got != tt.want {152 t.Fatalf("countSolutions() = %d, want %d", got, tt.want)153 }154 })155 }156}157158func TestGeneratePuzzleSeeded(t *testing.T) {159 mode := Modes[0].(Mode)160 rngA := rand.New(rand.NewPCG(11, 22))161 rngB := rand.New(rand.NewPCG(11, 22))162163 a, err := GeneratePuzzleSeeded(mode.Size, mode.Size, mode.MaxCage, mode.GivenRatio, rngA)164 if err != nil {165 t.Fatal(err)166 }167 b, err := GeneratePuzzleSeeded(mode.Size, mode.Size, mode.MaxCage, mode.GivenRatio, rngB)168 if err != nil {169 t.Fatal(err)170 }171172 if encodeGrid(a.Givens) != encodeGrid(b.Givens) {173 t.Fatalf("seeded givens differ:\n%s\n!=\n%s", encodeGrid(a.Givens), encodeGrid(b.Givens))174 }175 if len(a.Cages) != len(b.Cages) {176 t.Fatalf("seeded cage count mismatch: %d != %d", len(a.Cages), len(b.Cages))177 }178 geo, err := buildGeometry(a.Width, a.Height, a.Cages)179 if err != nil {180 t.Fatal(err)181 }182 if !validateGridState(a.Solution, geo).solved {183 t.Fatal("generated solution is not solved")184 }185 if got := countSolutions(geo, a.Givens, 2); got != 1 {186 t.Fatalf("generated puzzle solutions = %d, want 1", got)187 }188}189190func TestMinGivensForCageAllowsZero(t *testing.T) {191 profile := generationProfile{minGivensByCage: []int{0, 0, 0, 0}}192193 if got := minGivensForCage(profile, 1); got != 0 {194 t.Fatalf("minGivensForCage(..., 1) = %d, want 0", got)195 }196 if got := minGivensForCage(profile, 3); got != 0 {197 t.Fatalf("minGivensForCage(..., 3) = %d, want 0", got)198 }199}200201func TestNormalizeSingletonCagesCapsSingletons(t *testing.T) {202 rng := rand.New(rand.NewPCG(77, 99))203 cages := []Cage{204 {ID: 0, Size: 1, Cells: []Cell{{0, 0}}},205 {ID: 1, Size: 1, Cells: []Cell{{1, 0}}},206 {ID: 2, Size: 2, Cells: []Cell{{2, 0}, {2, 1}}},207 {ID: 3, Size: 2, Cells: []Cell{{0, 1}, {0, 2}}},208 {ID: 4, Size: 3, Cells: []Cell{{1, 1}, {1, 2}, {2, 2}}},209 }210211 normalized := normalizeSingletonCages(3, 3, 3, cages, generationProfile{maxSingletonCages: 1}, rng)212 if got := countSingletonCages(normalized); got > 1 {213 t.Fatalf("singleton cages = %d, want <= 1", got)214 }215216 geo, err := buildGeometry(3, 3, normalized)217 if err != nil {218 t.Fatalf("buildGeometry() after normalization: %v", err)219 }220 if got := len(geo.cages); got != len(normalized) {221 t.Fatalf("geometry cages = %d, want %d", got, len(normalized))222 }223}224225func TestSparseModesGenerateUniquePuzzlesWithLimitedSingletons(t *testing.T) {226 sampleCounts := map[int]int{227 2: 6,228 3: 6,229 // Expert 9x9 has much costlier seeded generation paths, so one230 // deterministic sample is enough to cover the post-generation invariants.231 4: 1,232 }233 sawZeroGivenCage := false234235 for _, idx := range []int{2, 3, 4} {236 mode := Modes[idx].(Mode)237238 for sample := range sampleCounts[idx] {239 rng := rand.New(rand.NewPCG(uint64(7000+idx*31+sample), uint64(8000+idx*37+sample)))240 puzzle, err := mode.generatePuzzleSeeded(rng)241 if err != nil {242 t.Fatalf("%s generatePuzzleSeeded failed: %v", mode.Title(), err)243 }244245 geo, err := buildGeometry(puzzle.Width, puzzle.Height, puzzle.Cages)246 if err != nil {247 t.Fatalf("%s buildGeometry failed: %v", mode.Title(), err)248 }249 if got := countSolutions(geo, puzzle.Givens, 2); got != 1 {250 t.Fatalf("%s solutions = %d, want 1", mode.Title(), got)251 }252253 if got := countSingletonCages(puzzle.Cages); got > 1 {254 t.Fatalf("%s singleton cages = %d, want <= 1", mode.Title(), got)255 }256 if hasOversizeCage(puzzle.Cages, mode.MaxCage) {257 t.Fatalf("%s generated a cage larger than max size %d", mode.Title(), mode.MaxCage)258 }259260 if countZeroGivenCages(puzzle) > 0 {261 sawZeroGivenCage = true262 }263 }264 }265266 if !sawZeroGivenCage {267 t.Fatal("expected sampled medium+ puzzles to include at least one zero-given cage")268 }269}270271func TestSampledGivenValueDistributionMatchesSolutions(t *testing.T) {272 if testing.Short() {273 t.Skip("skipping sampled ripple given-value distribution test in short mode")274 }275276 sampleCounts := []int{8, 6, 4, 2, 1}277 shareDriftTolerances := []float64{0.12, 0.10, 0.10, 0.12, 0.16}278279 for i, item := range Modes {280 mode := item.(Mode)281 samples := sampleCounts[i]282 tolerance := shareDriftTolerances[i]283284 t.Run(fmt.Sprintf("%s-%d", mode.Title(), samples), func(t *testing.T) {285 solutionCounts := make([]int, mode.MaxCage+1)286 givenCounts := make([]int, mode.MaxCage+1)287 totalSolutions := 0288 totalGivens := 0289290 for sample := range samples {291 rng := rand.New(rand.NewPCG(uint64(3000+i*37+sample), uint64(4000+i*41+sample)))292 puzzle, err := mode.generatePuzzleSeeded(rng)293 if err != nil {294 t.Fatalf("generatePuzzleSeeded failed: %v", err)295 }296297 totalSolutions += accumulateValueCounts(solutionCounts, puzzle.Solution)298 totalGivens += accumulateValueCounts(givenCounts, puzzle.Givens)299 }300301 if totalSolutions == 0 {302 t.Fatal("expected sampled solutions to contain values")303 }304 if totalGivens == 0 {305 t.Fatal("expected sampled puzzles to contain givens")306 }307308 t.Logf("sampled solution values: %s", formatValueDistribution(solutionCounts))309 t.Logf("sampled given values: %s", formatValueDistribution(givenCounts))310311 for value := 1; value < len(solutionCounts); value++ {312 if solutionCounts[value] == 0 {313 continue314 }315316 solutionShare := float64(solutionCounts[value]) / float64(totalSolutions)317 givenShare := float64(givenCounts[value]) / float64(totalGivens)318 if diff := givenShare - solutionShare; diff < -tolerance || diff > tolerance {319 t.Fatalf(320 "value %d share %.2f outside %.2f +/- %.2f",321 value,322 givenShare,323 solutionShare,324 tolerance,325 )326 }327 }328 })329 }330}331332func TestSaveRoundTrip(t *testing.T) {333 puzzle := Puzzle{334 Width: 3,335 Height: 3,336 Cages: sampleCages(),337 Givens: grid{{1, 0, 3}, {0, 3, 0}, {3, 0, 2}},338 Solution: sampleSolution(),339 }340 g, err := New(NewMode("Test", "desc", 3, 3, 1), puzzle)341 if err != nil {342 t.Fatal(err)343 }344 model := g.(Model)345 model.cursor = pointCursor(1, 0)346 model.setCell(2)347348 save, err := model.GetSave()349 if err != nil {350 t.Fatal(err)351 }352 loaded, err := ImportModel(save)353 if err != nil {354 t.Fatal(err)355 }356357 if got, want := loaded.grid[0][1], 2; got != want {358 t.Fatalf("loaded grid[0][1] = %d, want %d", got, want)359 }360 if got, want := loaded.geo.cageGrid[2][2], 2; got != want {361 t.Fatalf("loaded cage grid[2][2] = %d, want %d", got, want)362 }363}364365func TestChooseVisualKindPriority(t *testing.T) {366 if got := chooseVisualKind(true, false, true, true, true); got != visualConflictCursor {367 t.Fatalf("conflicted cursor should win, got %v", got)368 }369 if got := chooseVisualKind(true, true, false, true, true); got != visualSolved {370 t.Fatalf("solved should beat cursor, got %v", got)371 }372 if got := chooseVisualKind(true, false, false, true, true); got != visualCursor {373 t.Fatalf("cursor should beat cage highlight, got %v", got)374 }375 if got := chooseVisualKind(false, false, false, true, true); got != visualCompleted {376 t.Fatalf("completed should beat transient cage highlight, got %v", got)377 }378}379380func TestCursorText(t *testing.T) {381 if got, want := cursorText(0), game.CursorLeft+"·"+game.CursorRight; got != want {382 t.Fatalf("cursorText(0) = %q, want %q", got, want)383 }384 if got, want := cursorText(3), game.CursorLeft+"3"+game.CursorRight; got != want {385 t.Fatalf("cursorText(3) = %q, want %q", got, want)386 }387}388389func TestCellViewConflictedCursorIsDistinct(t *testing.T) {390 geo, err := buildGeometry(3, 3, sampleCages())391 if err != nil {392 t.Fatal(err)393 }394395 m := Model{396 width: 3,397 height: 3,398 grid: sampleSolution(),399 givens: grid{{0, 0, 0}, {0, 0, 0}, {0, 0, 0}},400 conflicts: newConflictGrid(3, 3),401 cursor: pointCursor(1, 1),402 geo: geo,403 }404 m.conflicts[1][1] = true405406 conflict := cellView(m, 0, 0, 0, nil)407 cursorConflict := cellView(m, 1, 1, 0, nil)408409 if conflict == cursorConflict {410 t.Fatal("conflicted cursor should render differently from plain conflicted cell")411 }412 if !strings.Contains(cursorConflict, game.CursorLeft+"3"+game.CursorRight) {413 t.Fatal("conflicted cursor should include cursor markers")414 }415}416417func TestCellViewUsesGivenTintForImmutableGivens(t *testing.T) {418 geo, err := buildGeometry(3, 3, sampleCages())419 if err != nil {420 t.Fatal(err)421 }422423 m := Model{424 width: 3,425 height: 3,426 grid: sampleSolution(),427 givens: grid{{1, 0, 0}, {0, 0, 0}, {0, 0, 0}},428 conflicts: newConflictGrid(3, 3),429 cursor: pointCursor(2, 2),430 geo: geo,431 }432433 got := cellView(m, 0, 0, -1, nil)434 want := lipgloss.NewStyle().435 Width(cellWidth).436 AlignHorizontal(lipgloss.Center).437 Bold(true).438 Foreground(theme.Current().FG).439 Background(theme.GivenTint(theme.Current().BG)).440 Render(" 1 ")441 if got != want {442 t.Fatalf("given cellView() = %q, want %q", got, want)443 }444}445446func TestCellViewDoesNotHighlightCrosshairAxis(t *testing.T) {447 geo, err := buildGeometry(3, 3, sampleCages())448 if err != nil {449 t.Fatal(err)450 }451452 m := Model{453 width: 3,454 height: 3,455 grid: newGrid(3, 3),456 givens: newGrid(3, 3),457 conflicts: newConflictGrid(3, 3),458 cursor: pointCursor(0, 0),459 geo: geo,460 }461462 got := cellView(m, 0, 1, geo.cageGrid[m.cursor.Y][m.cursor.X], nil)463 want := lipgloss.NewStyle().464 Width(cellWidth).465 AlignHorizontal(lipgloss.Center).466 Foreground(theme.Current().FG).467 Background(theme.Current().BG).468 Render(" · ")469 if got != want {470 t.Fatalf("crosshair-axis cellView() = %q, want %q", got, want)471 }472}473474func TestGridViewUsesUniformRowsForBronzeBirchState(t *testing.T) {475 model, err := ImportModel([]byte(`{"width":5,"height":5,"state":". 2 1 . 3\n2 1 1 1 .\n1 1 3 . .\n1 . 1 3 2\n2 3 2 1 1","givens":". 2 1 . 3\n2 1 1 1 .\n1 1 3 . .\n1 . 1 3 2\n2 3 2 1 1","cages":[{"id":0,"size":3,"cells":[{"x":0,"y":0},{"x":1,"y":0},{"x":1,"y":1}]},{"id":1,"size":2,"cells":[{"x":2,"y":0},{"x":3,"y":0}]},{"id":2,"size":3,"cells":[{"x":4,"y":0},{"x":4,"y":1},{"x":4,"y":2}]},{"id":3,"size":2,"cells":[{"x":0,"y":1},{"x":0,"y":2}]},{"id":4,"size":1,"cells":[{"x":2,"y":1}]},{"id":5,"size":3,"cells":[{"x":3,"y":1},{"x":3,"y":2},{"x":3,"y":3}]},{"id":6,"size":3,"cells":[{"x":1,"y":2},{"x":1,"y":3},{"x":2,"y":2}]},{"id":7,"size":2,"cells":[{"x":0,"y":3},{"x":0,"y":4}]},{"id":8,"size":3,"cells":[{"x":2,"y":3},{"x":2,"y":4},{"x":1,"y":4}]},{"id":9,"size":2,"cells":[{"x":4,"y":3},{"x":4,"y":4}]},{"id":10,"size":1,"cells":[{"x":3,"y":4}]}],"mode_title":"bronze-birch"}`))476 if err != nil {477 t.Fatal(err)478 }479480 view := gridView(*model)481 lines := strings.Split(view, "\n")482 if got, want := len(lines), model.height*2+1; got != want {483 t.Fatalf("grid line count = %d, want %d", got, want)484 }485486 wantWidth := lipgloss.Width(lines[0])487 for i, line := range lines {488 if width := lipgloss.Width(line); width != wantWidth {489 t.Fatalf("line %d width = %d, want %d", i, width, wantWidth)490 }491 }492}493494func TestCompletedCageBackgrounds(t *testing.T) {495 geo, err := buildGeometry(3, 3, sampleCages())496 if err != nil {497 t.Fatal(err)498 }499500 m := Model{501 width: 3,502 height: 3,503 geo: geo,504 grid: sampleSolution(),505 givens: newGrid(3, 3),506 conflicts: newConflictGrid(3, 3),507 }508509 completed := completedCageBackgrounds(m)510 if len(completed) != len(sampleCages()) {511 t.Fatalf("completed cages = %d, want %d", len(completed), len(sampleCages()))512 }513514 m.conflicts[0][0] = true515 completed = completedCageBackgrounds(m)516 if _, ok := completed[0]; ok {517 t.Fatal("conflicted cage should not get completed background")518 }519}520521func TestSolvedBridgeBackgroundsUseSolvedColor(t *testing.T) {522 geo, err := buildGeometry(3, 3, sampleCages())523 if err != nil {524 t.Fatal(err)525 }526527 m := Model{528 width: 3,529 height: 3,530 geo: geo,531 solved: true,532 }533534 backgrounds := solvedBridgeBackgrounds(m)535 if len(backgrounds) != len(sampleCages()) {536 t.Fatalf("solved bridge backgrounds = %d, want %d", len(backgrounds), len(sampleCages()))537 }538539 want := theme.Current().SuccessBG540 for cageIdx, bg := range backgrounds {541 if !sameColor(bg, want) {542 t.Fatalf("cage %d bridge background does not use solved color", cageIdx)543 }544 }545}546547func TestGridViewUsesCageBoundaries(t *testing.T) {548 geo, err := buildGeometry(3, 3, sampleCages())549 if err != nil {550 t.Fatal(err)551 }552553 m := Model{554 width: 3,555 height: 3,556 grid: newGrid(3, 3),557 givens: newGrid(3, 3),558 conflicts: newConflictGrid(3, 3),559 cursor: pointCursor(0, 0),560 geo: geo,561 }562563 lines := strings.Split(ansi.Strip(gridView(m)), "\n")564 content := []rune(lines[1])565 if got := content[4]; got != ' ' {566 t.Fatalf("interior separator inside cage = %q, want space", got)567 }568 boundary := []rune(lines[2])569 if got := boundary[1]; got != '─' {570 t.Fatalf("separator between cages = %q, want horizontal wall", got)571 }572}573574func TestBridgeFillDefersToUniformZoneBackground(t *testing.T) {575 m := Model{}576 bridgeBG := map[int]color.Color{7: theme.Current().SelectionBG}577578 if got := bridgeFill(m, bridgeBG, game.DynamicGridBridge{579 Kind: game.DynamicGridBridgeVertical,580 Zone: 7,581 Uniform: true,582 Count: 2,583 Cells: [4]image.Point{584 {X: 0, Y: 0},585 {X: 1, Y: 0},586 },587 }); got != nil {588 t.Fatal("expected uniform bridge to defer to zone background")589 }590}591592func TestBridgeFillLeavesOpenAnchoredBridgeUnfilled(t *testing.T) {593 geo, err := buildGeometry(3, 2, []Cage{594 {ID: 0, Size: 2, Cells: []Cell{{0, 0}, {0, 1}}},595 {ID: 1, Size: 2, Cells: []Cell{{1, 0}, {2, 0}}},596 {ID: 2, Size: 1, Cells: []Cell{{1, 1}}},597 {ID: 3, Size: 1, Cells: []Cell{{2, 1}}},598 })599 if err != nil {600 t.Fatal(err)601 }602603 m := Model{604 width: 3,605 height: 2,606 grid: newGrid(3, 2),607 givens: newGrid(3, 2),608 conflicts: newConflictGrid(3, 2),609 cursor: pointCursor(0, 0),610 geo: geo,611 }612 bridgeBG := bridgeBackgrounds(m, nil)613614 got := bridgeFill(m, bridgeBG, game.DynamicGridBridge{615 Kind: game.DynamicGridBridgeVertical,616 X: 2,617 Y: 0,618 Count: 2,619 Zone: geo.cageGrid[0][1],620 Uniform: true,621 Cells: [4]image.Point{622 {X: 1, Y: 0},623 {X: 2, Y: 0},624 },625 })626 if got != nil {627 t.Fatal("expected anchored open bridge to remain unfilled")628 }629}630631func TestBridgeFillDoesNotExpandIntoOpenInterior(t *testing.T) {632 geo, err := buildGeometry(4, 3, []Cage{633 {ID: 0, Size: 1, Cells: []Cell{{0, 1}}},634 {ID: 1, Size: 9, Cells: []Cell{{1, 0}, {2, 0}, {3, 0}, {1, 1}, {2, 1}, {3, 1}, {1, 2}, {2, 2}, {3, 2}}},635 {ID: 2, Size: 1, Cells: []Cell{{0, 0}}},636 {ID: 3, Size: 1, Cells: []Cell{{0, 2}}},637 })638 if err != nil {639 t.Fatal(err)640 }641642 m := Model{643 width: 4,644 height: 3,645 grid: newGrid(4, 3),646 givens: newGrid(4, 3),647 conflicts: newConflictGrid(4, 3),648 cursor: pointCursor(0, 1),649 geo: geo,650 }651 bridgeBG := bridgeBackgrounds(m, nil)652653 if got := bridgeFill(m, bridgeBG, game.DynamicGridBridge{654 Kind: game.DynamicGridBridgeVertical,655 X: 2,656 Y: 1,657 Count: 2,658 Zone: geo.cageGrid[1][1],659 Uniform: true,660 Cells: [4]image.Point{661 {X: 1, Y: 1},662 {X: 2, Y: 1},663 },664 }); got != nil {665 t.Fatal("expected fully open interior bridge to remain unfilled")666 }667}668669func TestBridgeFillVerticalSeparatorStaysUnfilled(t *testing.T) {670 m := Model{cursor: pointCursor(0, 1)}671672 if got := bridgeFill(m, nil, game.DynamicGridBridge{673 Kind: game.DynamicGridBridgeVertical,674 X: 1,675 Y: 0,676 }); got != nil {677 t.Fatal("expected vertical separator to remain unfilled")678 }679}680681func TestBridgeFillHorizontalSeparatorStaysUnfilled(t *testing.T) {682 m := Model{cursor: pointCursor(1, 0)}683684 if got := bridgeFill(m, nil, game.DynamicGridBridge{685 Kind: game.DynamicGridBridgeHorizontal,686 X: 0,687 Y: 1,688 }); got != nil {689 t.Fatal("expected horizontal separator to remain unfilled")690 }691}692693func TestBridgeFillClosedVerticalSeparatorStaysUnfilled(t *testing.T) {694 m := Model{cursor: pointCursor(0, 0)}695696 got := bridgeFill(m, nil, game.DynamicGridBridge{697 Kind: game.DynamicGridBridgeVertical,698 X: 1,699 Y: 0,700 })701 if got != nil {702 t.Fatal("expected closed vertical separator to remain unfilled")703 }704}705706func TestBridgeFillClosedHorizontalSeparatorStaysUnfilled(t *testing.T) {707 m := Model{cursor: pointCursor(0, 0)}708709 got := bridgeFill(m, nil, game.DynamicGridBridge{710 Kind: game.DynamicGridBridgeHorizontal,711 X: 0,712 Y: 1,713 })714 if got != nil {715 t.Fatal("expected closed horizontal separator to remain unfilled")716 }717}718719func sameColor(a, b color.Color) bool {720 if a == nil || b == nil {721 return a == b722 }723 ar, ag, ab, aa := a.RGBA()724 br, bg, bb, ba := b.RGBA()725 return ar == br && ag == bg && ab == bb && aa == ba726}727728func TestImportRejectsInvalidGivens(t *testing.T) {729 _, err := ImportModel([]byte(`{"width":3,"height":3,"state":"1 2 3\n2 3 1\n3 1 2","givens":"1 2 3\n2 4 1\n3 1 2","cages":[{"id":0,"size":3,"cells":[{"x":0,"y":0},{"x":1,"y":0},{"x":2,"y":0}]},{"id":1,"size":3,"cells":[{"x":0,"y":1},{"x":1,"y":1},{"x":2,"y":1}]},{"id":2,"size":3,"cells":[{"x":0,"y":2},{"x":1,"y":2},{"x":2,"y":2}]}],"mode_title":"Test"}`))730 if err == nil {731 t.Fatal("expected import error")732 }733}734735func pointCursor(x, y int) game.Cursor {736 return game.Cursor{X: x, Y: y}737}738739func accumulateValueCounts(counts []int, state grid) int {740 total := 0741 for y := range len(state) {742 for x := range len(state[y]) {743 value := state[y][x]744 if value <= 0 {745 continue746 }747 counts[value]++748 total++749 }750 }751 return total752}753754func formatValueDistribution(counts []int) string {755 total := 0756 for value := 1; value < len(counts); value++ {757 total += counts[value]758 }759 if total == 0 {760 return "none"761 }762763 parts := make([]string, 0, len(counts)-1)764 for value := 1; value < len(counts); value++ {765 if counts[value] == 0 {766 continue767 }768 parts = append(parts, fmt.Sprintf("%d=%d(%.1f%%)", value, counts[value], 100*float64(counts[value])/float64(total)))769 }770 return strings.Join(parts, ", ")771}772773func countZeroGivenCages(puzzle Puzzle) int {774 count := 0775 for _, cage := range puzzle.Cages {776 givens := 0777 for _, cell := range cage.Cells {778 if puzzle.Givens[cell.Y][cell.X] != 0 {779 givens++780 }781 }782 if givens == 0 {783 count++784 }785 }786 return count787}