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 / fillomino / fillomino_test.go
fillomino_test.go 16.1K
1package fillomino23import (4 "image"5 "image/color"6 "math/rand/v2"7 "strings"8 "testing"910 tea "charm.land/bubbletea/v2"11 "charm.land/lipgloss/v2"12 "github.com/FelineStateMachine/puzzletea/game"13 "github.com/FelineStateMachine/puzzletea/theme"14 "github.com/charmbracelet/x/ansi"15)1617func TestValidateGridState(t *testing.T) {18 tests := []struct {19 name string20 grid grid21 solved bool22 }{23 {24 name: "connected equal-number region is valid",25 grid: grid{26 {2, 2, 1},27 {3, 3, 3},28 },29 solved: true,30 },31 {32 name: "disconnected equal-number cells can coexist",33 grid: grid{34 {2, 2, 1},35 {3, 0, 3},36 },37 solved: false,38 },39 {40 name: "region size mismatch is invalid",41 grid: grid{42 {2, 2, 1},43 {3, 3, 1},44 },45 solved: false,46 },47 {48 name: "incomplete grid is not solved",49 grid: grid{50 {1, 0},51 {0, 1},52 },53 solved: false,54 },55 }5657 for _, tt := range tests {58 t.Run(tt.name, func(t *testing.T) {59 got := validateGridState(tt.grid)60 if got.solved != tt.solved {61 t.Fatalf("solved = %v, want %v", got.solved, tt.solved)62 }63 })64 }65}6667func TestCountSolutions(t *testing.T) {68 tests := []struct {69 name string70 givens grid71 maxValue int72 want int73 }{74 {75 name: "unique solution puzzle",76 givens: grid{77 {2, 2, 1},78 {3, 3, 3},79 {2, 2, 1},80 },81 maxValue: 3,82 want: 1,83 },84 {85 name: "ambiguous puzzle",86 givens: grid{87 {0, 0, 0},88 {0, 0, 0},89 },90 maxValue: 3,91 want: 2,92 },93 {94 name: "invalid fixed clues",95 givens: grid{96 {1, 1, 0},97 {0, 0, 0},98 },99 maxValue: 3,100 want: 0,101 },102 }103104 for _, tt := range tests {105 t.Run(tt.name, func(t *testing.T) {106 got := countSolutions(tt.givens, tt.maxValue, 2)107 if got != tt.want {108 t.Fatalf("countSolutions() = %d, want %d", got, tt.want)109 }110 })111 }112}113114func TestGeneratePuzzleSeeded(t *testing.T) {115 mode := Modes[0].(Mode)116 rngA := rand.New(rand.NewPCG(11, 22))117 rngB := rand.New(rand.NewPCG(11, 22))118119 a, err := GeneratePuzzleSeeded(mode.Size, mode.Size, mode.MaxRegion, mode.GivenRatio, rngA)120 if err != nil {121 t.Fatal(err)122 }123 b, err := GeneratePuzzleSeeded(mode.Size, mode.Size, mode.MaxRegion, mode.GivenRatio, rngB)124 if err != nil {125 t.Fatal(err)126 }127128 if encodeGrid(a.Givens) != encodeGrid(b.Givens) {129 t.Fatalf("seeded givens differ:\n%s\n!=\n%s", encodeGrid(a.Givens), encodeGrid(b.Givens))130 }131 if validateGridState(a.Solution).solved != true {132 t.Fatal("generated solution is not solved")133 }134 if got := countSolutions(a.Givens, mode.MaxRegion, 2); got != 1 {135 t.Fatalf("generated puzzle solutions = %d, want 1", got)136 }137}138139func TestSaveRoundTrip(t *testing.T) {140 puzzle := Puzzle{141 Width: 3,142 Height: 3,143 Givens: grid{{2, 0, 1}, {0, 3, 0}, {2, 0, 1}},144 }145 g, err := New(NewMode("Test", "desc", 3, 3, 1), puzzle)146 if err != nil {147 t.Fatal(err)148 }149 model := g.(Model)150 model.cursor = pointCursor(1, 0)151 model.setCell(2)152153 save, err := model.GetSave()154 if err != nil {155 t.Fatal(err)156 }157 loaded, err := ImportModel(save)158 if err != nil {159 t.Fatal(err)160 }161162 if got, want := loaded.grid[0][1], 2; got != want {163 t.Fatalf("loaded grid[0][1] = %d, want %d", got, want)164 }165 if !loaded.provided[0][0] || !loaded.provided[0][2] || !loaded.provided[1][1] || !loaded.provided[2][2] {166 t.Fatal("expected provided mask to persist")167 }168 if got, want := loaded.maxCellValue, 3; got != want {169 t.Fatalf("loaded maxCellValue = %d, want %d", got, want)170 }171}172173func TestImportLegacySaveRestoresDigitCapFromState(t *testing.T) {174 loaded, err := ImportModel([]byte(`{"width":3,"height":3,"state":"2 . 1\n. 3 .\n2 . 1","provided":"#.#\n.#.\n#.#","mode_title":"Test"}`))175 if err != nil {176 t.Fatal(err)177 }178179 if got, want := loaded.maxCellValue, 3; got != want {180 t.Fatalf("loaded maxCellValue = %d, want %d", got, want)181 }182}183184func TestCellViewConflictedCursorIsDistinct(t *testing.T) {185 conflict := cellView(3, false, false, false, false, false, false, true, nil)186 cursorConflict := cellView(3, false, true, false, false, false, false, true, nil)187188 if conflict == cursorConflict {189 t.Fatal("conflicted cursor should render differently from plain conflicted cell")190 }191 if !strings.Contains(cursorConflict, game.CursorLeft+"3"+game.CursorRight) {192 t.Fatal("conflicted cursor should include cursor markers")193 }194}195196func TestCellViewUsesGivenTintForProvidedDigits(t *testing.T) {197 p := theme.Current()198 text := lipgloss.NewStyle().Width(cellWidth).AlignHorizontal(lipgloss.Center).Render("3")199200 got := cellView(3, true, false, false, false, false, false, false, nil)201 want := lipgloss.NewStyle().202 Bold(true).203 Foreground(p.FG).204 Background(theme.GivenTint(p.BG)).205 Width(cellWidth).206 AlignHorizontal(lipgloss.Center).207 Render(text)208 if got != want {209 t.Fatalf("provided cellView() = %q, want %q", got, want)210 }211}212213func TestGridViewRendersRegionBoundaries(t *testing.T) {214 m := Model{215 width: 3,216 height: 2,217 grid: grid{{2, 2, 1}, {2, 3, 1}},218 provided: newProvidedMask(grid{{2, 2, 1}, {2, 3, 1}}),219 conflicts: validateGridState(grid{{2, 2, 1}, {2, 3, 1}}).conflicts,220 }221222 lines := strings.Split(ansi.Strip(gridView(m)), "\n")223 content := []rune(lines[1])224 if got := content[4]; got != ' ' {225 t.Fatalf("interior separator inside region = %q, want space", got)226 }227 if got := content[8]; got != '│' {228 t.Fatalf("separator between regions = %q, want vertical wall", got)229 }230231 boundary := []rune(lines[2])232 if got := boundary[1]; got != ' ' {233 t.Fatalf("row separator inside region = %q, want space", got)234 }235 if got := boundary[5]; got != '─' {236 t.Fatalf("row separator between regions = %q, want horizontal wall", got)237 }238}239240func TestBridgeFillUsesCrosshairColorAcrossEmptyBoundary(t *testing.T) {241 m := Model{242 width: 2,243 height: 1,244 grid: grid{{0, 0}},245 conflicts: validateGridState(grid{{0, 0}}).conflicts,246 cursor: pointCursor(0, 0),247 }248 renderState := buildRenderGridState(m)249250 got := bridgeFill(m, renderState, game.DynamicGridBridge{251 Kind: game.DynamicGridBridgeVertical,252 X: 1,253 Y: 0,254 Count: 2,255 Zone: renderState.zones[0][0],256 Uniform: true,257 Cells: [4]image.Point{258 {X: 0, Y: 0},259 {X: 1, Y: 0},260 },261 })262 if !sameColor(got, theme.Current().Surface) {263 t.Fatal("expected empty bridge on cursor crosshair to use surface background")264 }265}266267func TestBridgeFillDoesNotExpandIntoOpenInterior(t *testing.T) {268 m := Model{269 width: 3,270 height: 3,271 grid: grid{{0, 0, 0}, {0, 0, 0}, {0, 0, 0}},272 conflicts: validateGridState(grid{{0, 0, 0}, {0, 0, 0}, {0, 0, 0}}).conflicts,273 cursor: pointCursor(1, 1),274 }275 renderState := buildRenderGridState(m)276277 if got := bridgeFill(m, renderState, game.DynamicGridBridge{278 Kind: game.DynamicGridBridgeVertical,279 X: 1,280 Y: 1,281 Count: 2,282 Zone: renderState.zones[1][0],283 Uniform: true,284 Cells: [4]image.Point{285 {X: 0, Y: 1},286 {X: 1, Y: 1},287 },288 }); got != nil {289 t.Fatal("expected fully open interior bridge to remain unfilled")290 }291}292293func TestBridgeFillVerticalUsesRowCrosshairOnly(t *testing.T) {294 m := Model{295 width: 2,296 height: 2,297 grid: grid{{0, 0}, {0, 0}},298 conflicts: validateGridState(grid{{0, 0}, {0, 0}}).conflicts,299 cursor: pointCursor(0, 1),300 }301 renderState := buildRenderGridState(m)302303 if got := bridgeFill(m, renderState, game.DynamicGridBridge{304 Kind: game.DynamicGridBridgeVertical,305 X: 1,306 Y: 0,307 Count: 2,308 Zone: renderState.zones[0][0],309 Uniform: true,310 Cells: [4]image.Point{311 {X: 0, Y: 0},312 {X: 1, Y: 0},313 },314 }); got != nil {315 t.Fatal("expected vertical bridge to ignore column-only crosshair match")316 }317}318319func TestBridgeFillHorizontalUsesColumnCrosshairOnly(t *testing.T) {320 m := Model{321 width: 2,322 height: 2,323 grid: grid{{0, 0}, {0, 0}},324 conflicts: validateGridState(grid{{0, 0}, {0, 0}}).conflicts,325 cursor: pointCursor(1, 0),326 }327 renderState := buildRenderGridState(m)328329 if got := bridgeFill(m, renderState, game.DynamicGridBridge{330 Kind: game.DynamicGridBridgeHorizontal,331 X: 0,332 Y: 1,333 Count: 2,334 Zone: renderState.zones[0][0],335 Uniform: true,336 Cells: [4]image.Point{337 {X: 0, Y: 0},338 {X: 0, Y: 1},339 },340 }); got != nil {341 t.Fatal("expected horizontal bridge to ignore row-only crosshair match")342 }343}344345func TestBridgeFillExtendsThroughClosedVerticalSeparator(t *testing.T) {346 m := Model{347 width: 2,348 height: 1,349 grid: grid{{1, 2}},350 conflicts: validateGridState(grid{{1, 2}}).conflicts,351 cursor: pointCursor(0, 0),352 }353 renderState := buildRenderGridState(m)354355 got := bridgeFill(m, renderState, game.DynamicGridBridge{356 Kind: game.DynamicGridBridgeVertical,357 X: 1,358 Y: 0,359 })360 if !sameColor(got, theme.Current().Surface) {361 t.Fatal("expected closed vertical separator on cursor row to use surface background")362 }363}364365func TestBridgeFillExtendsThroughClosedHorizontalSeparator(t *testing.T) {366 m := Model{367 width: 1,368 height: 2,369 grid: grid{{1}, {2}},370 conflicts: validateGridState(grid{{1}, {2}}).conflicts,371 cursor: pointCursor(0, 0),372 }373 renderState := buildRenderGridState(m)374375 got := bridgeFill(m, renderState, game.DynamicGridBridge{376 Kind: game.DynamicGridBridgeHorizontal,377 X: 0,378 Y: 1,379 })380 if !sameColor(got, theme.Current().Surface) {381 t.Fatal("expected closed horizontal separator on cursor column to use surface background")382 }383}384385func TestBridgeFillPreservesActiveRegionBackground(t *testing.T) {386 m := Model{387 width: 2,388 height: 1,389 grid: grid{{2, 2}},390 conflicts: validateGridState(grid{{2, 2}}).conflicts,391 cursor: pointCursor(0, 0),392 }393 renderState := buildRenderGridState(m)394395 if got := bridgeFill(m, renderState, game.DynamicGridBridge{396 Kind: game.DynamicGridBridgeVertical,397 X: 1,398 Y: 0,399 Count: 2,400 Zone: renderState.zones[0][0],401 Uniform: true,402 Cells: [4]image.Point{403 {X: 0, Y: 0},404 {X: 1, Y: 0},405 },406 }); got != nil {407 t.Fatal("expected active region bridge to defer to region background")408 }409}410411func TestGridViewUsesUniformRows(t *testing.T) {412 conflicts := validateGridState(grid{{2, 2, 1}, {3, 3, 3}, {1, 2, 2}}).conflicts413 m := Model{414 width: 3,415 height: 3,416 grid: grid{{2, 2, 1}, {3, 3, 3}, {1, 2, 2}},417 provided: newProvidedMask(grid{{2, 2, 1}, {3, 3, 3}, {1, 2, 2}}),418 conflicts: conflicts,419 cursor: pointCursor(1, 1),420 }421422 view := gridView(m)423 lines := strings.Split(view, "\n")424 if got, want := len(lines), m.height*2+1; got != want {425 t.Fatalf("grid line count = %d, want %d", got, want)426 }427428 wantWidth := lipgloss.Width(lines[0])429 for i, line := range lines {430 if width := lipgloss.Width(line); width != wantWidth {431 t.Fatalf("line %d width = %d, want %d", i, width, wantWidth)432 }433 }434}435436func TestGridViewOmitsBorderBetweenAdjacentEmptyCells(t *testing.T) {437 m := Model{438 width: 2,439 height: 1,440 grid: grid{{0, 0}},441 provided: newProvidedMask(grid{{0, 0}}),442 conflicts: validateGridState(grid{{0, 0}}).conflicts,443 }444445 view := ansi.Strip(gridView(m))446 lines := strings.Split(view, "\n")447 if len(lines) < 2 {448 t.Fatalf("expected at least 2 lines, got %d", len(lines))449 }450451 content := []rune(lines[1])452 if got := content[4]; got != ' ' {453 t.Fatalf("separator between adjacent empty cells = %q, want space", got)454 }455}456457func TestBuildRenderGridStateColorsIncompleteRegions(t *testing.T) {458 base := grid{459 {3, 3},460 {0, 1},461 }462 m := Model{463 width: 2,464 height: 2,465 grid: base,466 provided: newProvidedMask(base),467 conflicts: validateGridState(base).conflicts,468 }469470 renderState := buildRenderGridState(m)471 incompleteZone := renderState.zones[0][0]472 if got := renderState.zones[0][1]; got != incompleteZone {473 t.Fatalf("connected incomplete cells should share a zone: got %d and %d", incompleteZone, got)474 }475 if renderState.zoneFill[incompleteZone] == nil {476 t.Fatal("expected incomplete zone to receive a background color")477 }478 if got := renderState.zoneFill[renderState.zones[1][0]]; got != nil {479 t.Fatal("expected empty zone to remain transparent")480 }481}482483func TestCursorRegionInfoViewNumberedCell(t *testing.T) {484 m := Model{485 width: 3,486 height: 3,487 grid: grid{{2, 2, 1}, {3, 0, 0}, {0, 0, 1}},488 provided: newProvidedMask(grid{{2, 2, 1}, {3, 0, 0}, {0, 0, 1}}),489 conflicts: validateGridState(grid{{2, 2, 1}, {3, 0, 0}, {0, 0, 1}}).conflicts,490 cursor: pointCursor(0, 0),491 }492493 if got, want := ansi.Strip(cursorRegionInfoView(m)), "region size: 2/2"; got != want {494 t.Fatalf("cursorRegionInfoView() = %q, want %q", got, want)495 }496}497498func TestCursorRegionInfoViewEmptyRegion(t *testing.T) {499 m := Model{500 width: 3,501 height: 3,502 grid: grid{{2, 2, 0}, {3, 0, 0}, {0, 0, 1}},503 provided: newProvidedMask(grid{{2, 2, 0}, {3, 0, 0}, {0, 0, 1}}),504 conflicts: validateGridState(grid{{2, 2, 0}, {3, 0, 0}, {0, 0, 1}}).conflicts,505 cursor: pointCursor(2, 1),506 }507508 if got, want := ansi.Strip(cursorRegionInfoView(m)), "region size: 5/-"; got != want {509 t.Fatalf("cursorRegionInfoView() = %q, want %q", got, want)510 }511}512513func TestViewKeepsStableHeightAcrossCursorRegionInfoChanges(t *testing.T) {514 baseGrid := grid{515 {2, 2, 1},516 {3, 0, 0},517 {0, 0, 1},518 }519 m := Model{520 width: 3,521 height: 3,522 grid: baseGrid,523 provided: newProvidedMask(baseGrid),524 conflicts: validateGridState(baseGrid).conflicts,525 modeTitle: "Test",526 maxCellValue: 3,527 }528529 numbered := m530 numbered.cursor = pointCursor(0, 0)531532 empty := m533 empty.cursor = pointCursor(2, 1)534535 if got, want := lipgloss.Height(empty.View()), lipgloss.Height(numbered.View()); got != want {536 t.Fatalf("height(empty) = %d, want %d", got, want)537 }538}539540func TestScreenToGrid(t *testing.T) {541 m := Model{542 width: 3,543 height: 3,544 grid: grid{{2, 2, 1}, {3, 3, 3}, {1, 2, 2}},545 provided: newProvidedMask(grid{{2, 2, 1}, {3, 3, 3}, {1, 2, 2}}),546 conflicts: validateGridState(grid{{2, 2, 1}, {3, 3, 3}, {1, 2, 2}}).conflicts,547 keys: DefaultKeyMap,548 modeTitle: "Test",549 termWidth: 120,550 termHeight: 40,551 }552553 ox, oy := m.gridOrigin()554 tests := []struct {555 name string556 screenX int557 screenY int558 wantCol int559 wantRow int560 wantOK bool561 }{562 {"origin cell", ox, oy, 0, 0, true},563 {"cell (1,0)", ox + cellWidth + 1, oy, 1, 0, true},564 {"cell (0,1)", ox, oy + 2, 0, 1, true},565 {"vertical border miss", ox + cellWidth, oy, 0, 0, false},566 {"horizontal border miss", ox, oy + 1, 0, 0, false},567 {"outside left", ox - 1, oy, 0, 0, false},568 {"outside bottom", ox, oy + 6, 0, 0, false},569 }570571 for _, tt := range tests {572 t.Run(tt.name, func(t *testing.T) {573 col, row, ok := m.screenToGrid(tt.screenX, tt.screenY)574 if ok != tt.wantOK {575 t.Fatalf("ok = %v, want %v", ok, tt.wantOK)576 }577 if ok && (col != tt.wantCol || row != tt.wantRow) {578 t.Fatalf("screenToGrid(%d, %d) = (%d, %d), want (%d, %d)", tt.screenX, tt.screenY, col, row, tt.wantCol, tt.wantRow)579 }580 })581 }582}583584func TestMouseClickMovesCursor(t *testing.T) {585 puzzle := Puzzle{586 Width: 3,587 Height: 3,588 Givens: grid{{2, 0, 1}, {0, 3, 0}, {2, 0, 1}},589 }590 g, err := New(NewMode("Test", "desc", 3, 3, 3), puzzle)591 if err != nil {592 t.Fatal(err)593 }594 m := g.(Model)595 m.termWidth = 120596 m.termHeight = 40597598 x, y := fillominoCellScreenCoords(&m, 2, 1)599 next, _ := m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})600 got := next.(Model)601602 if got.cursor.X != 2 || got.cursor.Y != 1 {603 t.Fatalf("cursor = (%d,%d), want (2,1)", got.cursor.X, got.cursor.Y)604 }605}606607func TestMouseClickOnBorderDoesNotMoveCursor(t *testing.T) {608 puzzle := Puzzle{609 Width: 3,610 Height: 3,611 Givens: grid{{2, 0, 1}, {0, 3, 0}, {2, 0, 1}},612 }613 g, err := New(NewMode("Test", "desc", 3, 3, 3), puzzle)614 if err != nil {615 t.Fatal(err)616 }617 m := g.(Model)618 m.termWidth = 120619 m.termHeight = 40620 m.cursor = pointCursor(1, 1)621622 x, y := fillominoCellScreenCoords(&m, 1, 1)623 next, _ := m.Update(tea.MouseClickMsg{X: x + cellWidth, Y: y, Button: tea.MouseLeft})624 got := next.(Model)625626 if got.cursor.X != 1 || got.cursor.Y != 1 {627 t.Fatalf("cursor moved to (%d,%d), want unchanged (1,1)", got.cursor.X, got.cursor.Y)628 }629}630631func pointCursor(x, y int) game.Cursor {632 return game.Cursor{X: x, Y: y}633}634635func sameColor(a, b color.Color) bool {636 if a == nil || b == nil {637 return a == b638 }639 ar, ag, ab, aa := a.RGBA()640 br, bg, bb, ba := b.RGBA()641 return ar == br && ag == bg && ab == bb && aa == ba642}643644func fillominoCellScreenCoords(m *Model, col, row int) (int, int) {645 ox, oy := m.gridOrigin()646 return ox + col*(cellWidth+1), oy + row*2647}