clone

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

chore/regenerate-vhs-gifs
puzzletea / takuzuplus / takuzuplus_test.go
takuzuplus_test.go 18.2K
1package takuzuplus23import (4 "encoding/json"5 "fmt"6 "image"7 "image/color"8 "math/rand/v2"9 "strings"10 "testing"1112 tea "charm.land/bubbletea/v2"13 "charm.land/lipgloss/v2"14 "github.com/FelineStateMachine/puzzletea/game"15 "github.com/FelineStateMachine/puzzletea/theme"16 "github.com/charmbracelet/x/ansi"17)1819func makeGrid4(rows ...string) grid {20 if len(rows) != 4 {21 panic("makeGrid4 requires exactly 4 rows")22 }23 g := make(grid, 4)24 for i, row := range rows {25 g[i] = []rune(row)26 }27 return g28}2930func validGrid4() grid {31 return makeGrid4(32 "0011",33 "1100",34 "1010",35 "0101",36 )37}3839func TestCanPlaceWithRelations(t *testing.T) {40 rels := newRelations(4)41 rels.horizontal[0][0] = relationSame42 rels.horizontal[0][1] = relationDiff4344 g := makeGrid4(45 "0...",46 "....",47 "....",48 "....",49 )5051 if canPlaceWithRelations(g, 4, 1, 0, oneCell, rels) {52 t.Fatal("expected same relation to reject opposite value")53 }54 if !canPlaceWithRelations(g, 4, 1, 0, zeroCell, rels) {55 t.Fatal("expected same relation to allow equal value")56 }5758 g[0][1] = zeroCell59 if canPlaceWithRelations(g, 4, 2, 0, zeroCell, rels) {60 t.Fatal("expected opposite relation to reject equal value")61 }62 if !canPlaceWithRelations(g, 4, 2, 0, oneCell, rels) {63 t.Fatal("expected opposite relation to allow different value")64 }65}6667func TestCountSolutionsWithRelations(t *testing.T) {68 g := makeGrid4(69 "001.",70 "1100",71 "1010",72 "0101",73 )74 rels := newRelations(4)75 rels.horizontal[0][2] = relationSame7677 if got := countSolutions(g.clone(), 4, 2, rels); got != 1 {78 t.Fatalf("countSolutions = %d, want 1", got)79 }80}8182func TestGeneratePuzzle(t *testing.T) {83 rng := rand.New(rand.NewPCG(1, 2))84 complete := generateCompleteSeeded(6, rng)85 mode := Modes[1].(TakuzuPlusMode)86 puzzle, provided, rels := generatePuzzleSeeded(complete, 6, 0.40, mode.profile, rng)8788 if got := countSolutions(puzzle.clone(), 6, 2, rels); got != 1 {89 t.Fatalf("generated puzzle has %d solutions, want 1", got)90 }9192 relationCount := 093 for y, row := range rels.horizontal {94 for x, rel := range row {95 if rel == relationNone {96 continue97 }98 relationCount++99 if want := relationForValues(complete[y][x], complete[y][x+1]); rel != want {100 t.Fatalf("horizontal relation (%d,%d) = %q, want %q", x, y, rel, want)101 }102 }103 }104 for y, row := range rels.vertical {105 for x, rel := range row {106 if rel == relationNone {107 continue108 }109 relationCount++110 if want := relationForValues(complete[y][x], complete[y+1][x]); rel != want {111 t.Fatalf("vertical relation (%d,%d) = %q, want %q", x, y, rel, want)112 }113 }114 }115 if relationCount == 0 {116 t.Fatal("expected seeded Takuzu+ generation to emit at least one relation clue")117 }118119 for y := range puzzle {120 for x := range puzzle[y] {121 isEmpty := puzzle[y][x] == emptyCell122 if isEmpty == provided[y][x] {123 t.Fatalf("provided mismatch at (%d,%d): empty=%v provided=%v", x, y, isEmpty, provided[y][x])124 }125 }126 }127}128129func TestRelationChoiceScorePrefersMixedOverTwoProvided(t *testing.T) {130 profile := modeProfiles[0]131 complete := makeGrid4(132 "0011",133 "1100",134 "1010",135 "0101",136 )137 provided := [][]bool{138 {true, true, true, true},139 {true, true, false, true},140 {true, true, true, true},141 {true, true, true, true},142 }143 rels := newRelations(4)144145 choices := additiveRelationChoices(complete, provided, rels, 4, profile)146 if len(choices) == 0 {147 t.Fatal("expected additive relation choices")148 }149 if choices[0].endpointClass != endpointOneProvided {150 t.Fatalf("top additive choice endpoint class = %d, want mixed(1)", choices[0].endpointClass)151 }152}153154func TestRelationChoiceScorePrefersUnderrepresentedSymbolAndOrientation(t *testing.T) {155 profile := modeProfiles[3]156 provided := [][]bool{157 {true, true, true, true},158 {true, true, true, true},159 {true, true, true, true},160 {true, true, true, true},161 }162 rels := newRelations(4)163 rels.horizontal[0][0] = relationSame164 rels.horizontal[1][0] = relationSame165 metrics := analyzeRelations(rels, provided, 4)166167 diffVertical := relationChoice{168 candidate: relationCandidate{horizontal: false, x: 2, y: 0},169 endpointClass: endpointTwoProvided,170 symbol: relationDiff,171 }172 diffVertical.score = relationChoiceScore(diffVertical, profile, metrics, 4)173174 sameHorizontal := relationChoice{175 candidate: relationCandidate{horizontal: true, x: 2, y: 2},176 endpointClass: endpointTwoProvided,177 symbol: relationSame,178 }179 sameHorizontal.score = relationChoiceScore(sameHorizontal, profile, metrics, 4)180181 if diffVertical.score <= sameHorizontal.score {182 t.Fatalf("expected underrepresented diff/vertical clue score %d to exceed same/horizontal score %d", diffVertical.score, sameHorizontal.score)183 }184}185186func TestRelationChoiceScoreRespectsSpacingPenalty(t *testing.T) {187 profile := modeProfiles[0]188 provided := [][]bool{189 {true, true, true, true},190 {true, true, true, true},191 {true, true, true, true},192 {true, true, true, true},193 }194 rels := newRelations(4)195 rels.horizontal[0][0] = relationSame196 metrics := analyzeRelations(rels, provided, 4)197198 closeChoice := relationChoice{199 candidate: relationCandidate{horizontal: true, x: 1, y: 0},200 endpointClass: endpointTwoProvided,201 symbol: relationDiff,202 }203 closeChoice.score = relationChoiceScore(closeChoice, profile, metrics, 4)204205 farChoice := relationChoice{206 candidate: relationCandidate{horizontal: true, x: 2, y: 3},207 endpointClass: endpointTwoProvided,208 symbol: relationDiff,209 }210 farChoice.score = relationChoiceScore(farChoice, profile, metrics, 4)211212 if farChoice.score <= closeChoice.score {213 t.Fatalf("expected far clue score %d to exceed clustered clue score %d", farChoice.score, closeChoice.score)214 }215}216217func TestGeneratePuzzleSeeded_ModeRelationBands(t *testing.T) {218 for i, item := range Modes {219 mode := item.(TakuzuPlusMode)220 t.Run(mode.Title(), func(t *testing.T) {221 rng := rand.New(rand.NewPCG(uint64(101+i), uint64(701+i)))222 complete := generateCompleteSeeded(mode.Size, rng)223 _, _, rels := generatePuzzleSeeded(complete, mode.Size, mode.Prefilled, mode.profile, rng)224225 if count := countRelations(rels); count < mode.profile.MinRelations || count > mode.profile.MaxRelations {226 t.Fatalf("relation count = %d, want %d..%d", count, mode.profile.MinRelations, mode.profile.MaxRelations)227 }228 })229 }230}231232func TestSampledModeDistributionStaysNearProfile(t *testing.T) {233 if testing.Short() {234 t.Skip("skipping sampled distribution test in short mode")235 }236237 sampleCounts := []int{4, 4, 4, 3, 2, 1, 0}238 for i, item := range Modes {239 mode := item.(TakuzuPlusMode)240 samples := sampleCounts[i]241 if samples == 0 {242 continue243 }244245 t.Run(fmt.Sprintf("%s-%d", mode.Title(), samples), func(t *testing.T) {246 totalRelations := 0247 totalHorizontal := 0248 totalSame := 0249250 for sample := range samples {251 rng := rand.New(rand.NewPCG(uint64(1000+i*37+sample), uint64(2000+i*41+sample)))252 complete := generateCompleteSeeded(mode.Size, rng)253 _, provided, rels := generatePuzzleSeeded(complete, mode.Size, mode.Prefilled, mode.profile, rng)254 metrics := analyzeRelations(rels, provided, mode.Size)255 totalRelations += metrics.Total256 totalHorizontal += metrics.HorizontalCount257 totalSame += metrics.SameCount258 }259260 avgRelations := float64(totalRelations) / float64(samples)261 targetMid := float64(mode.profile.MinRelations+mode.profile.MaxRelations) / 2262 if diff := avgRelations - targetMid; diff < -2.0 || diff > 2.0 {263 t.Fatalf("average relations %.2f too far from target midpoint %.2f", avgRelations, targetMid)264 }265266 if totalRelations >= 5 {267 horizontalRatio := float64(totalHorizontal) / float64(totalRelations)268 sameRatio := float64(totalSame) / float64(totalRelations)269 if horizontalRatio < 0.30 || horizontalRatio > 0.70 {270 t.Fatalf("horizontal ratio %.2f too skewed", horizontalRatio)271 }272 if sameRatio < 0.30 || sameRatio > 0.70 {273 t.Fatalf("same-symbol ratio %.2f too skewed", sameRatio)274 }275 }276 })277 }278}279280func TestSaveLoadRoundTrip(t *testing.T) {281 rels := newRelations(4)282 rels.horizontal[0][0] = relationSame283 rels.vertical[1][2] = relationDiff284285 puzzle := makeGrid4(286 "0.11",287 "1.0.",288 ".0.1",289 "0101",290 )291 provided := [][]bool{292 {true, false, true, true},293 {true, false, true, false},294 {false, true, false, true},295 {true, true, true, true},296 }297298 model := Model{299 size: 4,300 grid: puzzle,301 initialGrid: puzzle.clone(),302 provided: provided,303 relations: rels,304 keys: DefaultKeyMap,305 modeTitle: "Medium",306 }307308 data, err := model.GetSave()309 if err != nil {310 t.Fatal(err)311 }312313 loaded, err := ImportModel(data)314 if err != nil {315 t.Fatal(err)316 }317 if loaded.relations.horizontal[0][0] != relationSame {318 t.Fatal("horizontal relation not preserved")319 }320 if loaded.relations.vertical[1][2] != relationDiff {321 t.Fatal("vertical relation not preserved")322 }323 if loaded.provided[0][1] {324 t.Fatal("provided mask should preserve editable cell")325 }326}327328func TestGridViewRendersRelationClues(t *testing.T) {329 rels := newRelations(4)330 rels.horizontal[0][0] = relationDiff331 rels.vertical[0][1] = relationSame332333 m := Model{334 size: 4,335 grid: validGrid4(),336 provided: [][]bool{337 {true, true, true, true},338 {true, true, true, true},339 {true, true, true, true},340 {true, true, true, true},341 },342 relations: rels,343 keys: DefaultKeyMap,344 }345346 view := ansi.Strip(gridView(m))347 if !strings.Contains(view, "x") {348 t.Fatal("expected vertical relation clue in view")349 }350 if !strings.Contains(view, "=") {351 t.Fatal("expected horizontal relation clue in view")352 }353}354355func TestSaveJSONFields(t *testing.T) {356 m := Model{357 size: 4,358 grid: validGrid4(),359 initialGrid: validGrid4(),360 provided: [][]bool{361 {true, true, true, true},362 {true, true, true, true},363 {true, true, true, true},364 {true, true, true, true},365 },366 relations: newRelations(4),367 keys: DefaultKeyMap,368 }369370 data, err := m.GetSave()371 if err != nil {372 t.Fatal(err)373 }374375 var save Save376 if err := json.Unmarshal(data, &save); err != nil {377 t.Fatal(err)378 }379 if save.HorizontalRelations == "" {380 t.Fatal("expected horizontal_relations to be serialized")381 }382 if save.VerticalRelations == "" {383 t.Fatal("expected vertical_relations to be serialized")384 }385}386387func TestMouseClickMovesCursor(t *testing.T) {388 m := testMouseModel()389390 x, y := takuzuPlusCellScreenCoords(&m, 2, 1)391 next, _ := m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})392 got := next.(Model)393394 if got.cursor.X != 2 || got.cursor.Y != 1 {395 t.Fatalf("cursor = (%d,%d), want (2,1)", got.cursor.X, got.cursor.Y)396 }397 if got.grid[1][2] != emptyCell {398 t.Fatalf("clicked destination cell changed to %q, want unchanged empty", got.grid[1][2])399 }400}401402func TestMouseClickSameCellCyclesEditableCell(t *testing.T) {403 m := testMouseModel()404 m.cursor.X, m.cursor.Y = 1, 1405406 x, y := takuzuPlusCellScreenCoords(&m, 1, 1)407408 next, _ := m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})409 got := next.(Model)410 if got.grid[1][1] != zeroCell {411 t.Fatalf("first click = %q, want %q", got.grid[1][1], zeroCell)412 }413414 next, _ = got.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})415 got = next.(Model)416 if got.grid[1][1] != oneCell {417 t.Fatalf("second click = %q, want %q", got.grid[1][1], oneCell)418 }419420 next, _ = got.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})421 got = next.(Model)422 if got.grid[1][1] != emptyCell {423 t.Fatalf("third click = %q, want %q", got.grid[1][1], emptyCell)424 }425}426427func TestMouseClickSameCellDoesNotCycleProvidedCell(t *testing.T) {428 m := testMouseModel()429 m.cursor.X, m.cursor.Y = 0, 0430431 x, y := takuzuPlusCellScreenCoords(&m, 0, 0)432 next, _ := m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})433 got := next.(Model)434435 if got.grid[0][0] != zeroCell {436 t.Fatalf("provided cell changed to %q, want %q", got.grid[0][0], zeroCell)437 }438}439440func TestRelationBridgeBackgroundSatisfied(t *testing.T) {441 m := testMouseModel()442 m.grid[1][1] = zeroCell443 m.grid[1][2] = zeroCell444 m.relations.horizontal[1][1] = relationSame445446 got := relationBridgeBackground(m, game.DynamicGridBridge{447 Kind: game.DynamicGridBridgeVertical,448 X: 2,449 Y: 1,450 Count: 2,451 })452453 if !sameColor(got, theme.Current().SuccessBG) {454 t.Fatal("expected satisfied relation clue to use success background")455 }456}457458func TestRelationBridgeBackgroundConflict(t *testing.T) {459 m := testMouseModel()460 m.grid[0][0] = zeroCell461 m.grid[1][0] = zeroCell462 m.relations.vertical[0][0] = relationDiff463464 got := relationBridgeBackground(m, game.DynamicGridBridge{465 Kind: game.DynamicGridBridgeHorizontal,466 X: 0,467 Y: 1,468 Count: 2,469 })470471 if !sameColor(got, game.ConflictBG()) {472 t.Fatal("expected conflicting relation clue to use conflict background")473 }474}475476func TestRelationBridgeBackgroundIncompleteIsNeutral(t *testing.T) {477 m := testMouseModel()478 m.grid[1][1] = emptyCell479 m.grid[1][2] = oneCell480 m.relations.horizontal[1][1] = relationDiff481482 got := relationBridgeBackground(m, game.DynamicGridBridge{483 Kind: game.DynamicGridBridgeVertical,484 X: 2,485 Y: 1,486 Count: 2,487 })488489 if got != nil {490 t.Fatal("expected incomplete relation clue to stay neutral")491 }492}493494func TestCellViewUsesGivenTintForProvidedCells(t *testing.T) {495 p := theme.Current()496497 gotZero := cellView(zeroCell, true, false, false, false, false, false, false)498 wantZero := lipgloss.NewStyle().499 Bold(true).500 Foreground(p.Accent).501 Background(theme.GivenTint(p.BG)).502 Width(cellWidth).503 AlignHorizontal(lipgloss.Center).504 Render(renderRuneMap[zeroCell])505 if gotZero != wantZero {506 t.Fatalf("provided zero cellView() = %q, want %q", gotZero, wantZero)507 }508509 gotOne := cellView(oneCell, true, false, false, false, false, false, false)510 wantOne := lipgloss.NewStyle().511 Bold(true).512 Foreground(p.Secondary).513 Background(theme.GivenTint(p.BG)).514 Width(cellWidth).515 AlignHorizontal(lipgloss.Center).516 Render(renderRuneMap[oneCell])517 if gotOne != wantOne {518 t.Fatalf("provided one cellView() = %q, want %q", gotOne, wantOne)519 }520}521522func TestCellViewCursorUsesGlyphsWithoutChangingEmptyCellColors(t *testing.T) {523 p := theme.Current()524525 got := cellView(emptyCell, false, true, false, false, false, false, false)526 want := lipgloss.NewStyle().527 Foreground(p.TextDim).528 Background(p.BG).529 Width(cellWidth).530 AlignHorizontal(lipgloss.Center).531 Render(game.CursorLeft + "·" + game.CursorRight)532533 if got != want {534 t.Fatalf("cursor empty cellView() = %q, want %q", got, want)535 }536}537538func TestCellViewCursorPreservesProvidedCellColors(t *testing.T) {539 p := theme.Current()540541 got := cellView(zeroCell, true, true, true, false, false, false, false)542 want := lipgloss.NewStyle().543 Bold(true).544 Foreground(p.Accent).545 Background(theme.GivenTint(p.BG)).546 Width(cellWidth).547 AlignHorizontal(lipgloss.Center).548 Render(game.CursorLeft + "●" + game.CursorRight)549550 if got != want {551 t.Fatalf("cursor provided zero cellView() = %q, want %q", got, want)552 }553}554555func TestKeyBindingsUseZeroAndOneForPlacement(t *testing.T) {556 m := testMouseModel()557 m.cursor = game.Cursor{X: 1, Y: 1}558559 next, _ := m.Update(tea.KeyPressMsg{Code: '0', Text: "0"})560 got := next.(Model)561 if got.grid[1][1] != zeroCell {562 t.Fatalf("key 0 cell = %q, want %q", got.grid[1][1], zeroCell)563 }564565 next, _ = got.Update(tea.KeyPressMsg{Code: '1', Text: "1"})566 got = next.(Model)567 if got.grid[1][1] != oneCell {568 t.Fatalf("key 1 cell = %q, want %q", got.grid[1][1], oneCell)569 }570}571572func TestBridgeFillLeavesOpenInteriorUnfilledWithoutRelationCue(t *testing.T) {573 m := testMouseModel()574 m.cursor = game.Cursor{X: 0, Y: 1}575576 got := bridgeFill(m, game.DynamicGridBridge{577 Kind: game.DynamicGridBridgeVertical,578 X: 1,579 Y: 1,580 Count: 2,581 Cells: [4]image.Point{582 {X: 0, Y: 1},583 {X: 1, Y: 1},584 },585 })586 if got != nil {587 t.Fatal("expected relation-free bridge to remain unfilled")588 }589}590591func TestBuildCountContextUsesCursorRowAndColumn(t *testing.T) {592 m := testMouseModel()593 m.cursor = game.Cursor{X: 3, Y: 0}594595 ctx := buildCountContext(m.grid, m.cursor, m.size)596 if got, want := ctx.row, (countPair{zeros: 1, ones: 1}); got != want {597 t.Fatalf("row counts = %+v, want %+v", got, want)598 }599 if got, want := ctx.col, (countPair{zeros: 1, ones: 1}); got != want {600 t.Fatalf("col counts = %+v, want %+v", got, want)601 }602 if got, want := ctx.target, 2; got != want {603 t.Fatalf("target = %d, want %d", got, want)604 }605}606607func TestCountContextViewHighlightsMetAndOverGoals(t *testing.T) {608 m := Model{609 size: 4,610 grid: makeGrid4(611 "0011",612 "000.",613 "....",614 "....",615 ),616 cursor: game.Cursor{X: 0, Y: 1},617 }618619 view := countContextView(m)620 wantMet := metGoalCountStyle(zeroCell).Width(3).AlignHorizontal(lipgloss.Right).Render("2/2")621 if !strings.Contains(view, wantMet) {622 t.Fatalf("countContextView() = %q, want met-goal chip %q", view, wantMet)623 }624 wantOver := overGoalCountStyle().Width(3).AlignHorizontal(lipgloss.Right).Render("3/2")625 if !strings.Contains(view, wantOver) {626 t.Fatalf("countContextView() = %q, want over-goal chip %q", view, wantOver)627 }628 if !strings.Contains(view, string([]rune(renderRuneMap[zeroCell])[1])+":") {629 t.Fatalf("countContextView() = %q, want zero symbol label", view)630 }631 if !strings.Contains(view, string([]rune(renderRuneMap[oneCell])[1])+":") {632 t.Fatalf("countContextView() = %q, want one symbol label", view)633 }634 wantZeroLabel := countLabelStyle(zeroCell).Render(string([]rune(renderRuneMap[zeroCell])[1]) + ":")635 if !strings.Contains(view, wantZeroLabel) {636 t.Fatalf("countContextView() = %q, want colored zero label %q", view, wantZeroLabel)637 }638 wantOneLabel := countLabelStyle(oneCell).Render(string([]rune(renderRuneMap[oneCell])[1]) + ":")639 if !strings.Contains(view, wantOneLabel) {640 t.Fatalf("countContextView() = %q, want colored one label %q", view, wantOneLabel)641 }642}643644func testMouseModel() Model {645 return Model{646 size: 4,647 grid: makeGrid4(648 "0..1",649 "....",650 "....",651 "1..0",652 ),653 initialGrid: makeGrid4(654 "0..1",655 "....",656 "....",657 "1..0",658 ),659 provided: [][]bool{660 {true, false, false, true},661 {false, false, false, false},662 {false, false, false, false},663 {true, false, false, true},664 },665 relations: newRelations(4),666 keys: DefaultKeyMap,667 modeTitle: "Test",668 termWidth: 120,669 termHeight: 40,670 originValid: false,671 }672}673674func takuzuPlusCellScreenCoords(m *Model, col, row int) (int, int) {675 ox, oy := m.gridOrigin()676 return ox + col*(cellWidth+1), oy + row*2677}678679func sameColor(a, b color.Color) bool {680 if a == nil || b == nil {681 return a == b682 }683 ar, ag, ab, aa := a.RGBA()684 br, bg, bb, ba := b.RGBA()685 return ar == br && ag == bg && ab == bb && aa == ba686}