clone

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

bug/architecture-test-trim-path
puzzletea / games / rippleeffect / rippleeffect_test.go
rippleeffect_test.go 23.3K
1package rippleeffect23import (4 "context"5 "fmt"6 "image"7 "image/color"8 "math/rand/v2"9 "reflect"10 "strings"11 "testing"1213 "charm.land/lipgloss/v2"14 "github.com/FelineStateMachine/puzzletea/difficulty"15 "github.com/FelineStateMachine/puzzletea/game"16 "github.com/FelineStateMachine/puzzletea/theme"17 "github.com/charmbracelet/x/ansi"18)1920func sampleCages() []Cage {21 return []Cage{22 {ID: 0, Size: 3, Cells: []Cell{{0, 0}, {1, 0}, {2, 0}}},23 {ID: 1, Size: 3, Cells: []Cell{{0, 1}, {1, 1}, {2, 1}}},24 {ID: 2, Size: 3, Cells: []Cell{{0, 2}, {1, 2}, {2, 2}}},25 }26}2728func TestSpawnEloContextCanceled(t *testing.T) {29 mode := NewMode("Elo", "test", 5, 3, 0.6)30 ctx, cancel := context.WithCancel(context.Background())31 cancel()3233 gamer, report, err := mode.SpawnEloContext(ctx, "seed", 1200)34 if err == nil {35 t.Fatal("SpawnEloContext returned nil error for canceled context")36 }37 if gamer != nil {38 t.Fatalf("gamer = %#v, want nil", gamer)39 }40 if !reflect.DeepEqual(report, difficulty.Report{}) {41 t.Fatalf("report = %#v, want zero report", report)42 }43}4445func sampleSolution() grid {46 return grid{47 {1, 2, 3},48 {2, 3, 1},49 {3, 1, 2},50 }51}5253func TestBuildGeometryBoundaries(t *testing.T) {54 geo, err := buildGeometry(3, 3, sampleCages())55 if err != nil {56 t.Fatal(err)57 }5859 if !geo.boundaries[0][0].has(boundaryTop) || !geo.boundaries[0][0].has(boundaryLeft) {60 t.Fatal("expected top-left cell to have outer top/left boundaries")61 }62 if geo.boundaries[0][0].has(boundaryRight) {63 t.Fatal("expected shared cage edge on the right to be open")64 }65 if !geo.boundaries[1][1].has(boundaryTop) {66 t.Fatal("expected cage boundary between first and second rows")67 }68 if !geo.boundaries[2][2].has(boundaryBottom) || !geo.boundaries[2][2].has(boundaryRight) {69 t.Fatal("expected bottom-right outer boundaries")70 }71}7273func TestValidateGridState(t *testing.T) {74 geo, err := buildGeometry(3, 3, sampleCages())75 if err != nil {76 t.Fatal(err)77 }7879 tests := []struct {80 name string81 grid grid82 solved bool83 }{84 {85 name: "valid solved board",86 grid: sampleSolution(),87 solved: true,88 },89 {90 name: "duplicate within cage",91 grid: grid{92 {1, 1, 3},93 {2, 3, 1},94 {3, 1, 2},95 },96 solved: false,97 },98 {99 name: "ripple conflict",100 grid: grid{101 {2, 1, 3},102 {2, 3, 1},103 {3, 1, 2},104 },105 solved: false,106 },107 {108 name: "incomplete board",109 grid: grid{110 {1, 2, 3},111 {2, 0, 1},112 {3, 1, 2},113 },114 solved: false,115 },116 }117118 for _, tt := range tests {119 t.Run(tt.name, func(t *testing.T) {120 result := validateGridState(tt.grid, geo)121 if result.solved != tt.solved {122 t.Fatalf("solved = %v, want %v", result.solved, tt.solved)123 }124 })125 }126}127128func TestCountSolutions(t *testing.T) {129 geo, err := buildGeometry(3, 3, sampleCages())130 if err != nil {131 t.Fatal(err)132 }133134 tests := []struct {135 name string136 givens grid137 want int138 }{139 {140 name: "unique solution",141 givens: grid{142 {1, 2, 3},143 {2, 3, 1},144 {3, 1, 2},145 },146 want: 1,147 },148 {149 name: "ambiguous puzzle",150 givens: grid{151 {0, 0, 0},152 {0, 0, 0},153 {0, 0, 0},154 },155 want: 2,156 },157 {158 name: "impossible puzzle",159 givens: grid{160 {2, 2, 3},161 {0, 0, 0},162 {0, 0, 0},163 },164 want: 0,165 },166 }167168 for _, tt := range tests {169 t.Run(tt.name, func(t *testing.T) {170 got := countSolutions(geo, tt.givens, 2)171 if got != tt.want {172 t.Fatalf("countSolutions() = %d, want %d", got, tt.want)173 }174 })175 }176}177178func TestGeneratePuzzleSeeded(t *testing.T) {179 mode := Modes[0].(Mode)180 rngA := rand.New(rand.NewPCG(11, 22))181 rngB := rand.New(rand.NewPCG(11, 22))182183 a, err := GeneratePuzzleSeeded(mode.Size, mode.Size, mode.MaxCage, mode.GivenRatio, rngA)184 if err != nil {185 t.Fatal(err)186 }187 b, err := GeneratePuzzleSeeded(mode.Size, mode.Size, mode.MaxCage, mode.GivenRatio, rngB)188 if err != nil {189 t.Fatal(err)190 }191192 if encodeGrid(a.Givens) != encodeGrid(b.Givens) {193 t.Fatalf("seeded givens differ:\n%s\n!=\n%s", encodeGrid(a.Givens), encodeGrid(b.Givens))194 }195 if len(a.Cages) != len(b.Cages) {196 t.Fatalf("seeded cage count mismatch: %d != %d", len(a.Cages), len(b.Cages))197 }198 geo, err := buildGeometry(a.Width, a.Height, a.Cages)199 if err != nil {200 t.Fatal(err)201 }202 if !validateGridState(a.Solution, geo).solved {203 t.Fatal("generated solution is not solved")204 }205 if got := countSolutions(geo, a.Givens, 2); got != 1 {206 t.Fatalf("generated puzzle solutions = %d, want 1", got)207 }208}209210func TestSpawnEloRejectsInvalidElo(t *testing.T) {211 mode := Modes[1].(Mode)212213 gamer, report, err := mode.SpawnElo("seed", difficulty.SoftCapElo+1)214 if err == nil {215 t.Fatal("SpawnElo returned nil error for invalid Elo")216 }217 if gamer != nil {218 t.Fatalf("SpawnElo gamer = %#v, want nil", gamer)219 }220 if !reflect.DeepEqual(report, difficulty.Report{}) {221 t.Fatalf("SpawnElo report = %#v, want zero report", report)222 }223}224225func TestSpawnEloDeterministicForSameSeedAndElo(t *testing.T) {226 mode := Modes[1].(Mode)227228 gamerA, reportA, err := mode.SpawnElo("same-seed", 1500)229 if err != nil {230 t.Fatalf("first SpawnElo returned error: %v", err)231 }232 gamerB, reportB, err := mode.SpawnElo("same-seed", 1500)233 if err != nil {234 t.Fatalf("second SpawnElo returned error: %v", err)235 }236 if !reflect.DeepEqual(reportA, reportB) {237 t.Fatalf("reports differ for same seed and Elo:\n%#v\n%#v", reportA, reportB)238 }239240 saveA, err := gamerA.GetSave()241 if err != nil {242 t.Fatalf("first GetSave returned error: %v", err)243 }244 saveB, err := gamerB.GetSave()245 if err != nil {246 t.Fatalf("second GetSave returned error: %v", err)247 }248 if string(saveA) != string(saveB) {249 t.Fatalf("saves differ for same seed and Elo:\n%s\n%s", saveA, saveB)250 }251}252253func TestSpawnEloPopulatesDifficultyReport(t *testing.T) {254 mode := Modes[1].(Mode)255256 gamer, report, err := mode.SpawnElo("report-fields", 1500)257 if err != nil {258 t.Fatalf("SpawnElo returned error: %v", err)259 }260 if gamer == nil {261 t.Fatal("SpawnElo returned nil gamer")262 }263 if report.TargetElo != 1500 {264 t.Fatalf("TargetElo = %d, want 1500", report.TargetElo)265 }266 if report.ActualElo < difficulty.MinElo || report.ActualElo > difficulty.SoftCapElo {267 t.Fatalf("ActualElo = %d, want valid Elo", report.ActualElo)268 }269 if report.Confidence == "" {270 t.Fatal("Confidence is empty")271 }272273 required := []string{274 "width",275 "height",276 "cells",277 "max_cage",278 "actual_max_cage",279 "cage_count",280 "avg_cage_size",281 "singleton_cages",282 "given_count",283 "empty_count",284 "given_density",285 "target_givens",286 "solution_count",287 "geometry_cages",288 }289 for _, key := range required {290 if _, ok := report.Metrics[key]; !ok {291 t.Fatalf("metric %q missing from report %#v", key, report.Metrics)292 }293 }294 if report.Metrics["cells"] <= 0 {295 t.Fatalf("cells = %.2f, want > 0", report.Metrics["cells"])296 }297 if report.Metrics["given_density"] <= 0 || report.Metrics["given_density"] > 1 {298 t.Fatalf("given_density = %.2f, want 0 < density <= 1", report.Metrics["given_density"])299 }300 if report.Metrics["solution_count"] != 1 {301 t.Fatalf("solution_count = %.2f, want 1", report.Metrics["solution_count"])302 }303}304305func TestMinGivensForCageAllowsZero(t *testing.T) {306 profile := generationProfile{minGivensByCage: []int{0, 0, 0, 0}}307308 if got := minGivensForCage(profile, 1); got != 0 {309 t.Fatalf("minGivensForCage(..., 1) = %d, want 0", got)310 }311 if got := minGivensForCage(profile, 3); got != 0 {312 t.Fatalf("minGivensForCage(..., 3) = %d, want 0", got)313 }314}315316func TestNormalizeSingletonCagesCapsSingletons(t *testing.T) {317 rng := rand.New(rand.NewPCG(77, 99))318 cages := []Cage{319 {ID: 0, Size: 1, Cells: []Cell{{0, 0}}},320 {ID: 1, Size: 1, Cells: []Cell{{1, 0}}},321 {ID: 2, Size: 2, Cells: []Cell{{2, 0}, {2, 1}}},322 {ID: 3, Size: 2, Cells: []Cell{{0, 1}, {0, 2}}},323 {ID: 4, Size: 3, Cells: []Cell{{1, 1}, {1, 2}, {2, 2}}},324 }325326 normalized := normalizeSingletonCages(3, 3, 3, cages, generationProfile{maxSingletonCages: 1}, rng)327 if got := countSingletonCages(normalized); got > 1 {328 t.Fatalf("singleton cages = %d, want <= 1", got)329 }330331 geo, err := buildGeometry(3, 3, normalized)332 if err != nil {333 t.Fatalf("buildGeometry() after normalization: %v", err)334 }335 if got := len(geo.cages); got != len(normalized) {336 t.Fatalf("geometry cages = %d, want %d", got, len(normalized))337 }338}339340func TestSparseModesGenerateUniquePuzzlesWithLimitedSingletons(t *testing.T) {341 sampleCounts := map[int]int{342 2: 6,343 3: 6,344 // Expert 9x9 has much costlier seeded generation paths, so one345 // deterministic sample is enough to cover the post-generation invariants.346 4: 1,347 }348 sawZeroGivenCage := false349350 for _, idx := range []int{2, 3, 4} {351 mode := Modes[idx].(Mode)352353 for sample := range sampleCounts[idx] {354 rng := rand.New(rand.NewPCG(uint64(7000+idx*31+sample), uint64(8000+idx*37+sample)))355 puzzle, err := mode.generatePuzzleSeeded(rng)356 if err != nil {357 t.Fatalf("%s generatePuzzleSeeded failed: %v", mode.Title(), err)358 }359360 geo, err := buildGeometry(puzzle.Width, puzzle.Height, puzzle.Cages)361 if err != nil {362 t.Fatalf("%s buildGeometry failed: %v", mode.Title(), err)363 }364 if got := countSolutions(geo, puzzle.Givens, 2); got != 1 {365 t.Fatalf("%s solutions = %d, want 1", mode.Title(), got)366 }367368 if got := countSingletonCages(puzzle.Cages); got > 1 {369 t.Fatalf("%s singleton cages = %d, want <= 1", mode.Title(), got)370 }371 if hasOversizeCage(puzzle.Cages, mode.MaxCage) {372 t.Fatalf("%s generated a cage larger than max size %d", mode.Title(), mode.MaxCage)373 }374375 if countZeroGivenCages(puzzle) > 0 {376 sawZeroGivenCage = true377 }378 }379 }380381 if !sawZeroGivenCage {382 t.Fatal("expected sampled medium+ puzzles to include at least one zero-given cage")383 }384}385386func TestSampledGivenValueDistributionMatchesSolutions(t *testing.T) {387 if testing.Short() {388 t.Skip("skipping sampled ripple given-value distribution test in short mode")389 }390391 sampleCounts := []int{8, 6, 4, 2, 1}392 shareDriftTolerances := []float64{0.12, 0.10, 0.10, 0.12, 0.16}393394 for i, item := range Modes {395 mode := item.(Mode)396 samples := sampleCounts[i]397 tolerance := shareDriftTolerances[i]398399 t.Run(fmt.Sprintf("%s-%d", mode.Title(), samples), func(t *testing.T) {400 solutionCounts := make([]int, mode.MaxCage+1)401 givenCounts := make([]int, mode.MaxCage+1)402 totalSolutions := 0403 totalGivens := 0404405 for sample := range samples {406 rng := rand.New(rand.NewPCG(uint64(3000+i*37+sample), uint64(4000+i*41+sample)))407 puzzle, err := mode.generatePuzzleSeeded(rng)408 if err != nil {409 t.Fatalf("generatePuzzleSeeded failed: %v", err)410 }411412 totalSolutions += accumulateValueCounts(solutionCounts, puzzle.Solution)413 totalGivens += accumulateValueCounts(givenCounts, puzzle.Givens)414 }415416 if totalSolutions == 0 {417 t.Fatal("expected sampled solutions to contain values")418 }419 if totalGivens == 0 {420 t.Fatal("expected sampled puzzles to contain givens")421 }422423 t.Logf("sampled solution values: %s", formatValueDistribution(solutionCounts))424 t.Logf("sampled given values: %s", formatValueDistribution(givenCounts))425426 for value := 1; value < len(solutionCounts); value++ {427 if solutionCounts[value] == 0 {428 continue429 }430431 solutionShare := float64(solutionCounts[value]) / float64(totalSolutions)432 givenShare := float64(givenCounts[value]) / float64(totalGivens)433 if diff := givenShare - solutionShare; diff < -tolerance || diff > tolerance {434 t.Fatalf(435 "value %d share %.2f outside %.2f +/- %.2f",436 value,437 givenShare,438 solutionShare,439 tolerance,440 )441 }442 }443 })444 }445}446447func TestSaveRoundTrip(t *testing.T) {448 puzzle := Puzzle{449 Width: 3,450 Height: 3,451 Cages: sampleCages(),452 Givens: grid{{1, 0, 3}, {0, 3, 0}, {3, 0, 2}},453 Solution: sampleSolution(),454 }455 g, err := New(NewMode("Test", "desc", 3, 3, 1), puzzle)456 if err != nil {457 t.Fatal(err)458 }459 model := g.(Model)460 model.cursor = pointCursor(1, 0)461 model.setCell(2)462463 save, err := model.GetSave()464 if err != nil {465 t.Fatal(err)466 }467 loaded, err := ImportModel(save)468 if err != nil {469 t.Fatal(err)470 }471472 if got, want := loaded.grid[0][1], 2; got != want {473 t.Fatalf("loaded grid[0][1] = %d, want %d", got, want)474 }475 if got, want := loaded.geo.cageGrid[2][2], 2; got != want {476 t.Fatalf("loaded cage grid[2][2] = %d, want %d", got, want)477 }478}479480func TestChooseVisualKindPriority(t *testing.T) {481 if got := chooseVisualKind(true, false, true, true, true); got != visualConflictCursor {482 t.Fatalf("conflicted cursor should win, got %v", got)483 }484 if got := chooseVisualKind(true, true, false, true, true); got != visualSolved {485 t.Fatalf("solved should beat cursor, got %v", got)486 }487 if got := chooseVisualKind(true, false, false, true, true); got != visualCursor {488 t.Fatalf("cursor should beat cage highlight, got %v", got)489 }490 if got := chooseVisualKind(false, false, false, true, true); got != visualCompleted {491 t.Fatalf("completed should beat transient cage highlight, got %v", got)492 }493}494495func TestCursorText(t *testing.T) {496 if got, want := cursorText(0), game.CursorLeft+"·"+game.CursorRight; got != want {497 t.Fatalf("cursorText(0) = %q, want %q", got, want)498 }499 if got, want := cursorText(3), game.CursorLeft+"3"+game.CursorRight; got != want {500 t.Fatalf("cursorText(3) = %q, want %q", got, want)501 }502}503504func TestCellViewConflictedCursorIsDistinct(t *testing.T) {505 geo, err := buildGeometry(3, 3, sampleCages())506 if err != nil {507 t.Fatal(err)508 }509510 m := Model{511 width: 3,512 height: 3,513 grid: sampleSolution(),514 givens: grid{{0, 0, 0}, {0, 0, 0}, {0, 0, 0}},515 conflicts: newConflictGrid(3, 3),516 cursor: pointCursor(1, 1),517 geo: geo,518 }519 m.conflicts[1][1] = true520521 conflict := cellView(m, 0, 0, 0, nil)522 cursorConflict := cellView(m, 1, 1, 0, nil)523524 if conflict == cursorConflict {525 t.Fatal("conflicted cursor should render differently from plain conflicted cell")526 }527 if !strings.Contains(cursorConflict, game.CursorLeft+"3"+game.CursorRight) {528 t.Fatal("conflicted cursor should include cursor markers")529 }530}531532func TestCellViewUsesGivenTintForImmutableGivens(t *testing.T) {533 geo, err := buildGeometry(3, 3, sampleCages())534 if err != nil {535 t.Fatal(err)536 }537538 m := Model{539 width: 3,540 height: 3,541 grid: sampleSolution(),542 givens: grid{{1, 0, 0}, {0, 0, 0}, {0, 0, 0}},543 conflicts: newConflictGrid(3, 3),544 cursor: pointCursor(2, 2),545 geo: geo,546 }547548 got := cellView(m, 0, 0, -1, nil)549 want := lipgloss.NewStyle().550 Width(cellWidth).551 AlignHorizontal(lipgloss.Center).552 Bold(true).553 Foreground(theme.Current().FG).554 Background(theme.GivenTint(theme.Current().BG)).555 Render(" 1 ")556 if got != want {557 t.Fatalf("given cellView() = %q, want %q", got, want)558 }559}560561func TestCellViewDoesNotHighlightCrosshairAxis(t *testing.T) {562 geo, err := buildGeometry(3, 3, sampleCages())563 if err != nil {564 t.Fatal(err)565 }566567 m := Model{568 width: 3,569 height: 3,570 grid: newGrid(3, 3),571 givens: newGrid(3, 3),572 conflicts: newConflictGrid(3, 3),573 cursor: pointCursor(0, 0),574 geo: geo,575 }576577 got := cellView(m, 0, 1, geo.cageGrid[m.cursor.Y][m.cursor.X], nil)578 want := lipgloss.NewStyle().579 Width(cellWidth).580 AlignHorizontal(lipgloss.Center).581 Foreground(theme.Current().FG).582 Background(theme.Current().BG).583 Render(" · ")584 if got != want {585 t.Fatalf("crosshair-axis cellView() = %q, want %q", got, want)586 }587}588589func TestGridViewUsesUniformRowsForBronzeBirchState(t *testing.T) {590 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"}`))591 if err != nil {592 t.Fatal(err)593 }594595 view := gridView(*model)596 lines := strings.Split(view, "\n")597 if got, want := len(lines), model.height*2+1; got != want {598 t.Fatalf("grid line count = %d, want %d", got, want)599 }600601 wantWidth := lipgloss.Width(lines[0])602 for i, line := range lines {603 if width := lipgloss.Width(line); width != wantWidth {604 t.Fatalf("line %d width = %d, want %d", i, width, wantWidth)605 }606 }607}608609func TestCompletedCageBackgrounds(t *testing.T) {610 geo, err := buildGeometry(3, 3, sampleCages())611 if err != nil {612 t.Fatal(err)613 }614615 m := Model{616 width: 3,617 height: 3,618 geo: geo,619 grid: sampleSolution(),620 givens: newGrid(3, 3),621 conflicts: newConflictGrid(3, 3),622 }623624 completed := completedCageBackgrounds(m)625 if len(completed) != len(sampleCages()) {626 t.Fatalf("completed cages = %d, want %d", len(completed), len(sampleCages()))627 }628629 m.conflicts[0][0] = true630 completed = completedCageBackgrounds(m)631 if _, ok := completed[0]; ok {632 t.Fatal("conflicted cage should not get completed background")633 }634}635636func TestSolvedBridgeBackgroundsUseSolvedColor(t *testing.T) {637 geo, err := buildGeometry(3, 3, sampleCages())638 if err != nil {639 t.Fatal(err)640 }641642 m := Model{643 width: 3,644 height: 3,645 geo: geo,646 solved: true,647 }648649 backgrounds := solvedBridgeBackgrounds(m)650 if len(backgrounds) != len(sampleCages()) {651 t.Fatalf("solved bridge backgrounds = %d, want %d", len(backgrounds), len(sampleCages()))652 }653654 want := theme.Current().SuccessBG655 for cageIdx, bg := range backgrounds {656 if !sameColor(bg, want) {657 t.Fatalf("cage %d bridge background does not use solved color", cageIdx)658 }659 }660}661662func TestGridViewUsesCageBoundaries(t *testing.T) {663 geo, err := buildGeometry(3, 3, sampleCages())664 if err != nil {665 t.Fatal(err)666 }667668 m := Model{669 width: 3,670 height: 3,671 grid: newGrid(3, 3),672 givens: newGrid(3, 3),673 conflicts: newConflictGrid(3, 3),674 cursor: pointCursor(0, 0),675 geo: geo,676 }677678 lines := strings.Split(ansi.Strip(gridView(m)), "\n")679 content := []rune(lines[1])680 if got := content[4]; got != ' ' {681 t.Fatalf("interior separator inside cage = %q, want space", got)682 }683 boundary := []rune(lines[2])684 if got := boundary[1]; got != '─' {685 t.Fatalf("separator between cages = %q, want horizontal wall", got)686 }687}688689func TestBridgeFillDefersToUniformZoneBackground(t *testing.T) {690 m := Model{}691 bridgeBG := map[int]color.Color{7: theme.Current().SelectionBG}692693 if got := bridgeFill(m, bridgeBG, game.DynamicGridBridge{694 Kind: game.DynamicGridBridgeVertical,695 Zone: 7,696 Uniform: true,697 Count: 2,698 Cells: [4]image.Point{699 {X: 0, Y: 0},700 {X: 1, Y: 0},701 },702 }); got != nil {703 t.Fatal("expected uniform bridge to defer to zone background")704 }705}706707func TestBridgeFillLeavesOpenAnchoredBridgeUnfilled(t *testing.T) {708 geo, err := buildGeometry(3, 2, []Cage{709 {ID: 0, Size: 2, Cells: []Cell{{0, 0}, {0, 1}}},710 {ID: 1, Size: 2, Cells: []Cell{{1, 0}, {2, 0}}},711 {ID: 2, Size: 1, Cells: []Cell{{1, 1}}},712 {ID: 3, Size: 1, Cells: []Cell{{2, 1}}},713 })714 if err != nil {715 t.Fatal(err)716 }717718 m := Model{719 width: 3,720 height: 2,721 grid: newGrid(3, 2),722 givens: newGrid(3, 2),723 conflicts: newConflictGrid(3, 2),724 cursor: pointCursor(0, 0),725 geo: geo,726 }727 bridgeBG := bridgeBackgrounds(m, nil)728729 got := bridgeFill(m, bridgeBG, game.DynamicGridBridge{730 Kind: game.DynamicGridBridgeVertical,731 X: 2,732 Y: 0,733 Count: 2,734 Zone: geo.cageGrid[0][1],735 Uniform: true,736 Cells: [4]image.Point{737 {X: 1, Y: 0},738 {X: 2, Y: 0},739 },740 })741 if got != nil {742 t.Fatal("expected anchored open bridge to remain unfilled")743 }744}745746func TestBridgeFillDoesNotExpandIntoOpenInterior(t *testing.T) {747 geo, err := buildGeometry(4, 3, []Cage{748 {ID: 0, Size: 1, Cells: []Cell{{0, 1}}},749 {ID: 1, Size: 9, Cells: []Cell{{1, 0}, {2, 0}, {3, 0}, {1, 1}, {2, 1}, {3, 1}, {1, 2}, {2, 2}, {3, 2}}},750 {ID: 2, Size: 1, Cells: []Cell{{0, 0}}},751 {ID: 3, Size: 1, Cells: []Cell{{0, 2}}},752 })753 if err != nil {754 t.Fatal(err)755 }756757 m := Model{758 width: 4,759 height: 3,760 grid: newGrid(4, 3),761 givens: newGrid(4, 3),762 conflicts: newConflictGrid(4, 3),763 cursor: pointCursor(0, 1),764 geo: geo,765 }766 bridgeBG := bridgeBackgrounds(m, nil)767768 if got := bridgeFill(m, bridgeBG, game.DynamicGridBridge{769 Kind: game.DynamicGridBridgeVertical,770 X: 2,771 Y: 1,772 Count: 2,773 Zone: geo.cageGrid[1][1],774 Uniform: true,775 Cells: [4]image.Point{776 {X: 1, Y: 1},777 {X: 2, Y: 1},778 },779 }); got != nil {780 t.Fatal("expected fully open interior bridge to remain unfilled")781 }782}783784func TestBridgeFillVerticalSeparatorStaysUnfilled(t *testing.T) {785 m := Model{cursor: pointCursor(0, 1)}786787 if got := bridgeFill(m, nil, game.DynamicGridBridge{788 Kind: game.DynamicGridBridgeVertical,789 X: 1,790 Y: 0,791 }); got != nil {792 t.Fatal("expected vertical separator to remain unfilled")793 }794}795796func TestBridgeFillHorizontalSeparatorStaysUnfilled(t *testing.T) {797 m := Model{cursor: pointCursor(1, 0)}798799 if got := bridgeFill(m, nil, game.DynamicGridBridge{800 Kind: game.DynamicGridBridgeHorizontal,801 X: 0,802 Y: 1,803 }); got != nil {804 t.Fatal("expected horizontal separator to remain unfilled")805 }806}807808func TestBridgeFillClosedVerticalSeparatorStaysUnfilled(t *testing.T) {809 m := Model{cursor: pointCursor(0, 0)}810811 got := bridgeFill(m, nil, game.DynamicGridBridge{812 Kind: game.DynamicGridBridgeVertical,813 X: 1,814 Y: 0,815 })816 if got != nil {817 t.Fatal("expected closed vertical separator to remain unfilled")818 }819}820821func TestBridgeFillClosedHorizontalSeparatorStaysUnfilled(t *testing.T) {822 m := Model{cursor: pointCursor(0, 0)}823824 got := bridgeFill(m, nil, game.DynamicGridBridge{825 Kind: game.DynamicGridBridgeHorizontal,826 X: 0,827 Y: 1,828 })829 if got != nil {830 t.Fatal("expected closed horizontal separator to remain unfilled")831 }832}833834func sameColor(a, b color.Color) bool {835 if a == nil || b == nil {836 return a == b837 }838 ar, ag, ab, aa := a.RGBA()839 br, bg, bb, ba := b.RGBA()840 return ar == br && ag == bg && ab == bb && aa == ba841}842843func TestImportRejectsInvalidGivens(t *testing.T) {844 _, 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"}`))845 if err == nil {846 t.Fatal("expected import error")847 }848}849850func pointCursor(x, y int) game.Cursor {851 return game.Cursor{X: x, Y: y}852}853854func accumulateValueCounts(counts []int, state grid) int {855 total := 0856 for y := range len(state) {857 for x := range len(state[y]) {858 value := state[y][x]859 if value <= 0 {860 continue861 }862 counts[value]++863 total++864 }865 }866 return total867}868869func formatValueDistribution(counts []int) string {870 total := 0871 for value := 1; value < len(counts); value++ {872 total += counts[value]873 }874 if total == 0 {875 return "none"876 }877878 parts := make([]string, 0, len(counts)-1)879 for value := 1; value < len(counts); value++ {880 if counts[value] == 0 {881 continue882 }883 parts = append(parts, fmt.Sprintf("%d=%d(%.1f%%)", value, counts[value], 100*float64(counts[value])/float64(total)))884 }885 return strings.Join(parts, ", ")886}887888func countZeroGivenCages(puzzle Puzzle) int {889 count := 0890 for _, cage := range puzzle.Cages {891 givens := 0892 for _, cell := range cage.Cells {893 if puzzle.Givens[cell.Y][cell.X] != 0 {894 givens++895 }896 }897 if givens == 0 {898 count++899 }900 }901 return count902}