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 TestSampledGivenValueDistributionMatchesSolutions(t *testing.T) {191 if testing.Short() {192 t.Skip("skipping sampled ripple given-value distribution test in short mode")193 }194195 sampleCounts := []int{8, 6, 4, 2, 1}196 shareDriftTolerances := []float64{0.12, 0.10, 0.10, 0.12, 0.16}197198 for i, item := range Modes {199 mode := item.(Mode)200 samples := sampleCounts[i]201 tolerance := shareDriftTolerances[i]202203 t.Run(fmt.Sprintf("%s-%d", mode.Title(), samples), func(t *testing.T) {204 solutionCounts := make([]int, mode.MaxCage+1)205 givenCounts := make([]int, mode.MaxCage+1)206 totalSolutions := 0207 totalGivens := 0208209 for sample := range samples {210 rng := rand.New(rand.NewPCG(uint64(3000+i*37+sample), uint64(4000+i*41+sample)))211 puzzle, err := mode.generatePuzzleSeeded(rng)212 if err != nil {213 t.Fatalf("generatePuzzleSeeded failed: %v", err)214 }215216 totalSolutions += accumulateValueCounts(solutionCounts, puzzle.Solution)217 totalGivens += accumulateValueCounts(givenCounts, puzzle.Givens)218 }219220 if totalSolutions == 0 {221 t.Fatal("expected sampled solutions to contain values")222 }223 if totalGivens == 0 {224 t.Fatal("expected sampled puzzles to contain givens")225 }226227 t.Logf("sampled solution values: %s", formatValueDistribution(solutionCounts))228 t.Logf("sampled given values: %s", formatValueDistribution(givenCounts))229230 for value := 1; value < len(solutionCounts); value++ {231 if solutionCounts[value] == 0 {232 continue233 }234235 solutionShare := float64(solutionCounts[value]) / float64(totalSolutions)236 givenShare := float64(givenCounts[value]) / float64(totalGivens)237 if diff := givenShare - solutionShare; diff < -tolerance || diff > tolerance {238 t.Fatalf(239 "value %d share %.2f outside %.2f +/- %.2f",240 value,241 givenShare,242 solutionShare,243 tolerance,244 )245 }246 }247 })248 }249}250251func TestSaveRoundTrip(t *testing.T) {252 puzzle := Puzzle{253 Width: 3,254 Height: 3,255 Cages: sampleCages(),256 Givens: grid{{1, 0, 3}, {0, 3, 0}, {3, 0, 2}},257 Solution: sampleSolution(),258 }259 g, err := New(NewMode("Test", "desc", 3, 3, 1), puzzle)260 if err != nil {261 t.Fatal(err)262 }263 model := g.(Model)264 model.cursor = pointCursor(1, 0)265 model.setCell(2)266267 save, err := model.GetSave()268 if err != nil {269 t.Fatal(err)270 }271 loaded, err := ImportModel(save)272 if err != nil {273 t.Fatal(err)274 }275276 if got, want := loaded.grid[0][1], 2; got != want {277 t.Fatalf("loaded grid[0][1] = %d, want %d", got, want)278 }279 if got, want := loaded.geo.cageGrid[2][2], 2; got != want {280 t.Fatalf("loaded cage grid[2][2] = %d, want %d", got, want)281 }282}283284func TestChooseVisualKindPriority(t *testing.T) {285 if got := chooseVisualKind(true, false, true, true, true); got != visualConflictCursor {286 t.Fatalf("conflicted cursor should win, got %v", got)287 }288 if got := chooseVisualKind(true, true, false, true, true); got != visualSolved {289 t.Fatalf("solved should beat cursor, got %v", got)290 }291 if got := chooseVisualKind(true, false, false, true, true); got != visualCursor {292 t.Fatalf("cursor should beat cage highlight, got %v", got)293 }294 if got := chooseVisualKind(false, false, false, true, true); got != visualCompleted {295 t.Fatalf("completed should beat transient cage highlight, got %v", got)296 }297}298299func TestCursorText(t *testing.T) {300 if got, want := cursorText(0), game.CursorLeft+"·"+game.CursorRight; got != want {301 t.Fatalf("cursorText(0) = %q, want %q", got, want)302 }303 if got, want := cursorText(3), game.CursorLeft+"3"+game.CursorRight; got != want {304 t.Fatalf("cursorText(3) = %q, want %q", got, want)305 }306}307308func TestCellViewConflictedCursorIsDistinct(t *testing.T) {309 geo, err := buildGeometry(3, 3, sampleCages())310 if err != nil {311 t.Fatal(err)312 }313314 m := Model{315 width: 3,316 height: 3,317 grid: sampleSolution(),318 givens: grid{{0, 0, 0}, {0, 0, 0}, {0, 0, 0}},319 conflicts: newConflictGrid(3, 3),320 cursor: pointCursor(1, 1),321 geo: geo,322 }323 m.conflicts[1][1] = true324325 conflict := cellView(m, 0, 0, 0, nil)326 cursorConflict := cellView(m, 1, 1, 0, nil)327328 if conflict == cursorConflict {329 t.Fatal("conflicted cursor should render differently from plain conflicted cell")330 }331 if !strings.Contains(cursorConflict, game.CursorLeft+"3"+game.CursorRight) {332 t.Fatal("conflicted cursor should include cursor markers")333 }334}335336func TestCellViewUsesGivenTintForImmutableGivens(t *testing.T) {337 geo, err := buildGeometry(3, 3, sampleCages())338 if err != nil {339 t.Fatal(err)340 }341342 m := Model{343 width: 3,344 height: 3,345 grid: sampleSolution(),346 givens: grid{{1, 0, 0}, {0, 0, 0}, {0, 0, 0}},347 conflicts: newConflictGrid(3, 3),348 cursor: pointCursor(2, 2),349 geo: geo,350 }351352 got := cellView(m, 0, 0, -1, nil)353 want := lipgloss.NewStyle().354 Width(cellWidth).355 AlignHorizontal(lipgloss.Center).356 Bold(true).357 Foreground(theme.Current().FG).358 Background(theme.GivenTint(theme.Current().BG)).359 Render(" 1 ")360 if got != want {361 t.Fatalf("given cellView() = %q, want %q", got, want)362 }363}364365func TestCellViewDoesNotHighlightCrosshairAxis(t *testing.T) {366 geo, err := buildGeometry(3, 3, sampleCages())367 if err != nil {368 t.Fatal(err)369 }370371 m := Model{372 width: 3,373 height: 3,374 grid: newGrid(3, 3),375 givens: newGrid(3, 3),376 conflicts: newConflictGrid(3, 3),377 cursor: pointCursor(0, 0),378 geo: geo,379 }380381 got := cellView(m, 0, 1, geo.cageGrid[m.cursor.Y][m.cursor.X], nil)382 want := lipgloss.NewStyle().383 Width(cellWidth).384 AlignHorizontal(lipgloss.Center).385 Foreground(theme.Current().FG).386 Background(theme.Current().BG).387 Render(" · ")388 if got != want {389 t.Fatalf("crosshair-axis cellView() = %q, want %q", got, want)390 }391}392393func TestGridViewUsesUniformRowsForBronzeBirchState(t *testing.T) {394 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"}`))395 if err != nil {396 t.Fatal(err)397 }398399 view := gridView(*model)400 lines := strings.Split(view, "\n")401 if got, want := len(lines), model.height*2+1; got != want {402 t.Fatalf("grid line count = %d, want %d", got, want)403 }404405 wantWidth := lipgloss.Width(lines[0])406 for i, line := range lines {407 if width := lipgloss.Width(line); width != wantWidth {408 t.Fatalf("line %d width = %d, want %d", i, width, wantWidth)409 }410 }411}412413func TestCompletedCageBackgrounds(t *testing.T) {414 geo, err := buildGeometry(3, 3, sampleCages())415 if err != nil {416 t.Fatal(err)417 }418419 m := Model{420 width: 3,421 height: 3,422 geo: geo,423 grid: sampleSolution(),424 givens: newGrid(3, 3),425 conflicts: newConflictGrid(3, 3),426 }427428 completed := completedCageBackgrounds(m)429 if len(completed) != len(sampleCages()) {430 t.Fatalf("completed cages = %d, want %d", len(completed), len(sampleCages()))431 }432433 m.conflicts[0][0] = true434 completed = completedCageBackgrounds(m)435 if _, ok := completed[0]; ok {436 t.Fatal("conflicted cage should not get completed background")437 }438}439440func TestSolvedBridgeBackgroundsUseSolvedColor(t *testing.T) {441 geo, err := buildGeometry(3, 3, sampleCages())442 if err != nil {443 t.Fatal(err)444 }445446 m := Model{447 width: 3,448 height: 3,449 geo: geo,450 solved: true,451 }452453 backgrounds := solvedBridgeBackgrounds(m)454 if len(backgrounds) != len(sampleCages()) {455 t.Fatalf("solved bridge backgrounds = %d, want %d", len(backgrounds), len(sampleCages()))456 }457458 want := theme.Current().SuccessBG459 for cageIdx, bg := range backgrounds {460 if !sameColor(bg, want) {461 t.Fatalf("cage %d bridge background does not use solved color", cageIdx)462 }463 }464}465466func TestGridViewUsesCageBoundaries(t *testing.T) {467 geo, err := buildGeometry(3, 3, sampleCages())468 if err != nil {469 t.Fatal(err)470 }471472 m := Model{473 width: 3,474 height: 3,475 grid: newGrid(3, 3),476 givens: newGrid(3, 3),477 conflicts: newConflictGrid(3, 3),478 cursor: pointCursor(0, 0),479 geo: geo,480 }481482 lines := strings.Split(ansi.Strip(gridView(m)), "\n")483 content := []rune(lines[1])484 if got := content[4]; got != ' ' {485 t.Fatalf("interior separator inside cage = %q, want space", got)486 }487 boundary := []rune(lines[2])488 if got := boundary[1]; got != '─' {489 t.Fatalf("separator between cages = %q, want horizontal wall", got)490 }491}492493func TestBridgeFillDefersToUniformZoneBackground(t *testing.T) {494 m := Model{}495 bridgeBG := map[int]color.Color{7: theme.Current().SelectionBG}496497 if got := bridgeFill(m, bridgeBG, game.DynamicGridBridge{498 Kind: game.DynamicGridBridgeVertical,499 Zone: 7,500 Uniform: true,501 Count: 2,502 Cells: [4]image.Point{503 {X: 0, Y: 0},504 {X: 1, Y: 0},505 },506 }); got != nil {507 t.Fatal("expected uniform bridge to defer to zone background")508 }509}510511func TestBridgeFillLeavesOpenAnchoredBridgeUnfilled(t *testing.T) {512 geo, err := buildGeometry(3, 2, []Cage{513 {ID: 0, Size: 2, Cells: []Cell{{0, 0}, {0, 1}}},514 {ID: 1, Size: 2, Cells: []Cell{{1, 0}, {2, 0}}},515 {ID: 2, Size: 1, Cells: []Cell{{1, 1}}},516 {ID: 3, Size: 1, Cells: []Cell{{2, 1}}},517 })518 if err != nil {519 t.Fatal(err)520 }521522 m := Model{523 width: 3,524 height: 2,525 grid: newGrid(3, 2),526 givens: newGrid(3, 2),527 conflicts: newConflictGrid(3, 2),528 cursor: pointCursor(0, 0),529 geo: geo,530 }531 bridgeBG := bridgeBackgrounds(m, nil)532533 got := bridgeFill(m, bridgeBG, game.DynamicGridBridge{534 Kind: game.DynamicGridBridgeVertical,535 X: 2,536 Y: 0,537 Count: 2,538 Zone: geo.cageGrid[0][1],539 Uniform: true,540 Cells: [4]image.Point{541 {X: 1, Y: 0},542 {X: 2, Y: 0},543 },544 })545 if got != nil {546 t.Fatal("expected anchored open bridge to remain unfilled")547 }548}549550func TestBridgeFillDoesNotExpandIntoOpenInterior(t *testing.T) {551 geo, err := buildGeometry(4, 3, []Cage{552 {ID: 0, Size: 1, Cells: []Cell{{0, 1}}},553 {ID: 1, Size: 9, Cells: []Cell{{1, 0}, {2, 0}, {3, 0}, {1, 1}, {2, 1}, {3, 1}, {1, 2}, {2, 2}, {3, 2}}},554 {ID: 2, Size: 1, Cells: []Cell{{0, 0}}},555 {ID: 3, Size: 1, Cells: []Cell{{0, 2}}},556 })557 if err != nil {558 t.Fatal(err)559 }560561 m := Model{562 width: 4,563 height: 3,564 grid: newGrid(4, 3),565 givens: newGrid(4, 3),566 conflicts: newConflictGrid(4, 3),567 cursor: pointCursor(0, 1),568 geo: geo,569 }570 bridgeBG := bridgeBackgrounds(m, nil)571572 if got := bridgeFill(m, bridgeBG, game.DynamicGridBridge{573 Kind: game.DynamicGridBridgeVertical,574 X: 2,575 Y: 1,576 Count: 2,577 Zone: geo.cageGrid[1][1],578 Uniform: true,579 Cells: [4]image.Point{580 {X: 1, Y: 1},581 {X: 2, Y: 1},582 },583 }); got != nil {584 t.Fatal("expected fully open interior bridge to remain unfilled")585 }586}587588func TestBridgeFillVerticalSeparatorStaysUnfilled(t *testing.T) {589 m := Model{cursor: pointCursor(0, 1)}590591 if got := bridgeFill(m, nil, game.DynamicGridBridge{592 Kind: game.DynamicGridBridgeVertical,593 X: 1,594 Y: 0,595 }); got != nil {596 t.Fatal("expected vertical separator to remain unfilled")597 }598}599600func TestBridgeFillHorizontalSeparatorStaysUnfilled(t *testing.T) {601 m := Model{cursor: pointCursor(1, 0)}602603 if got := bridgeFill(m, nil, game.DynamicGridBridge{604 Kind: game.DynamicGridBridgeHorizontal,605 X: 0,606 Y: 1,607 }); got != nil {608 t.Fatal("expected horizontal separator to remain unfilled")609 }610}611612func TestBridgeFillClosedVerticalSeparatorStaysUnfilled(t *testing.T) {613 m := Model{cursor: pointCursor(0, 0)}614615 got := bridgeFill(m, nil, game.DynamicGridBridge{616 Kind: game.DynamicGridBridgeVertical,617 X: 1,618 Y: 0,619 })620 if got != nil {621 t.Fatal("expected closed vertical separator to remain unfilled")622 }623}624625func TestBridgeFillClosedHorizontalSeparatorStaysUnfilled(t *testing.T) {626 m := Model{cursor: pointCursor(0, 0)}627628 got := bridgeFill(m, nil, game.DynamicGridBridge{629 Kind: game.DynamicGridBridgeHorizontal,630 X: 0,631 Y: 1,632 })633 if got != nil {634 t.Fatal("expected closed horizontal separator to remain unfilled")635 }636}637638func sameColor(a, b color.Color) bool {639 if a == nil || b == nil {640 return a == b641 }642 ar, ag, ab, aa := a.RGBA()643 br, bg, bb, ba := b.RGBA()644 return ar == br && ag == bg && ab == bb && aa == ba645}646647func TestImportRejectsInvalidGivens(t *testing.T) {648 _, 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"}`))649 if err == nil {650 t.Fatal("expected import error")651 }652}653654func pointCursor(x, y int) game.Cursor {655 return game.Cursor{X: x, Y: y}656}657658func accumulateValueCounts(counts []int, state grid) int {659 total := 0660 for y := range len(state) {661 for x := range len(state[y]) {662 value := state[y][x]663 if value <= 0 {664 continue665 }666 counts[value]++667 total++668 }669 }670 return total671}672673func formatValueDistribution(counts []int) string {674 total := 0675 for value := 1; value < len(counts); value++ {676 total += counts[value]677 }678 if total == 0 {679 return "none"680 }681682 parts := make([]string, 0, len(counts)-1)683 for value := 1; value < len(counts); value++ {684 if counts[value] == 0 {685 continue686 }687 parts = append(parts, fmt.Sprintf("%d=%d(%.1f%%)", value, counts[value], 100*float64(counts[value])/float64(total)))688 }689 return strings.Join(parts, ", ")690}