clone

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

refactor/root-tui-model
puzzletea / games / sudoku / dynamic_board_test.go
dynamic_board_test.go 9.3K
1package sudoku23import (4 "image"5 "image/color"6 "strings"7 "testing"89 tea "charm.land/bubbletea/v2"10 "charm.land/lipgloss/v2"11 "github.com/FelineStateMachine/puzzletea/game"12 "github.com/FelineStateMachine/puzzletea/theme"13 "github.com/charmbracelet/x/ansi"14)1516func TestRenderGridUsesBoxBoundaries(t *testing.T) {17 m := Model{18 grid: emptyGrid(),19 conflicts: computeConflicts(emptyGrid()),20 keys: DefaultKeyMap,21 }2223 lines := strings.Split(ansi.Strip(renderGrid(m, false)), "\n")24 if got, want := len(lines), 19; got != want {25 t.Fatalf("line count = %d, want %d", got, want)26 }2728 contentRow := []rune(lines[1])29 if got := contentRow[4]; got != ' ' {30 t.Fatalf("interior bridge rune = %q, want %q", got, ' ')31 }32 if got := contentRow[12]; got != '│' {33 t.Fatalf("box boundary rune = %q, want %q", got, '│')34 }3536 innerBoundary := []rune(lines[2])37 if got := innerBoundary[1]; got != ' ' {38 t.Fatalf("interior row separator = %q, want %q", got, ' ')39 }4041 boxBoundary := []rune(lines[6])42 if got := boxBoundary[1]; got != '─' {43 t.Fatalf("box row separator = %q, want %q", got, '─')44 }45}4647func TestBridgeFillUsesAxisSpillOnly(t *testing.T) {48 m := Model{cursor: game.Cursor{X: 1, Y: 1}}49 surface := theme.Current().Surface5051 tests := []struct {52 name string53 bridge game.DynamicGridBridge54 want color.Color55 }{56 {57 name: "vertical bridge spills left right on cursor row",58 bridge: game.DynamicGridBridge{59 Kind: game.DynamicGridBridgeVertical,60 X: 3,61 Y: 1,62 Cells: [4]image.Point{63 image.Pt(2, 1),64 image.Pt(3, 1),65 },66 Count: 2,67 },68 want: surface,69 },70 {71 name: "horizontal bridge spills up down on cursor column",72 bridge: game.DynamicGridBridge{73 Kind: game.DynamicGridBridgeHorizontal,74 X: 1,75 Y: 3,76 Cells: [4]image.Point{77 image.Pt(1, 2),78 image.Pt(1, 3),79 },80 Count: 2,81 },82 want: surface,83 },84 {85 name: "off axis bridge stays empty",86 bridge: game.DynamicGridBridge{87 Kind: game.DynamicGridBridgeVertical,88 X: 3,89 Y: 4,90 Cells: [4]image.Point{91 image.Pt(2, 4),92 image.Pt(3, 4),93 },94 Count: 2,95 },96 want: nil,97 },98 {99 name: "junction stays empty",100 bridge: game.DynamicGridBridge{101 Kind: game.DynamicGridBridgeJunction,102 X: 2,103 Y: 1,104 Cells: [4]image.Point{image.Pt(1, 0), image.Pt(1, 1), image.Pt(2, 0), image.Pt(2, 1)},105 Count: 4,106 Zone: 0,107 Uniform: true,108 },109 want: nil,110 },111 }112113 for _, tt := range tests {114 t.Run(tt.name, func(t *testing.T) {115 if got := bridgeFill(m, false, tt.bridge); !sameColor(got, tt.want) {116 t.Fatalf("bridgeFill() = %v, want %v", got, tt.want)117 }118 })119 }120}121122func TestZoneFillHighlightsOnlyActiveBox(t *testing.T) {123 m := Model{cursor: game.Cursor{X: 1, Y: 1}}124 surface := theme.Current().Surface125126 inBoxZone := 0127 if got := activeBoxZoneFill(m.cursor, false, inBoxZone); !sameColor(got, surface) {128 t.Fatalf("in-box bridge background = %v, want %v", got, surface)129 }130131 if got := activeBoxZoneFill(m.cursor, false, inBoxZone); !sameColor(got, surface) {132 t.Fatalf("in-box junction background = %v, want %v", got, surface)133 }134135 outOfBoxZone := 1136 if got := activeBoxZoneFill(m.cursor, false, outOfBoxZone); got != nil {137 t.Fatalf("out-of-box bridge background = %v, want nil", got)138 }139140 if got := activeBoxZoneFill(m.cursor, true, inBoxZone); got != nil {141 t.Fatalf("solved in-box bridge background = %v, want nil", got)142 }143}144145func TestCellViewSameNumberOverridesProvidedCrosshair(t *testing.T) {146 g := validCompleteGrid()147 m := Model{148 cursor: game.Cursor{X: 0, Y: 0},149 grid: g,150 keys: DefaultKeyMap,151 }152 m.providedGrid[4][4] = true153154 got := cellView(m, 4, 4, false)155 want := sameNumberStyle(5).Width(cellWidth).AlignHorizontal(lipgloss.Center).Render("5")156 if got != want {157 t.Fatalf("cellView() = %q, want %q", got, want)158 }159}160161func TestCellViewUsesSemanticDigitColors(t *testing.T) {162 p := theme.Current()163 g := emptyGrid()164 g[0][0].v = 5165 g[0][1].v = 7166 m := Model{167 cursor: game.Cursor{X: 8, Y: 8},168 grid: g,169 keys: DefaultKeyMap,170 }171 m.providedGrid[0][1] = true172173 gotUser := cellView(m, 0, 0, false)174 wantUser := lipgloss.NewStyle().175 Foreground(digitColor(5)).176 Background(p.BG).177 Width(cellWidth).178 AlignHorizontal(lipgloss.Center).179 Render("5")180 if gotUser != wantUser {181 t.Fatalf("user cellView() = %q, want %q", gotUser, wantUser)182 }183184 gotProvided := cellView(m, 1, 0, false)185 wantProvided := lipgloss.NewStyle().186 Bold(true).187 Foreground(digitColor(7)).188 Background(theme.GivenTint(p.BG)).189 Width(cellWidth).190 AlignHorizontal(lipgloss.Center).191 Render("7")192 if gotProvided != wantProvided {193 t.Fatalf("provided cellView() = %q, want %q", gotProvided, wantProvided)194 }195}196197func TestCellViewUsesDigitColorForFilledCursor(t *testing.T) {198 g := emptyGrid()199 g[1][1].v = 6200 m := Model{201 cursor: game.Cursor{X: 1, Y: 1},202 grid: g,203 keys: DefaultKeyMap,204 }205206 got := cellView(m, 1, 1, false)207 want := digitCursorStyle(6).208 Width(cellWidth).209 AlignHorizontal(lipgloss.Center).210 Render(game.CursorLeft + "6" + game.CursorRight)211 if got != want {212 t.Fatalf("filled cursor cellView() = %q, want %q", got, want)213 }214}215216func TestCellViewKeepsDefaultCursorStyleForEmptyCell(t *testing.T) {217 m := Model{218 cursor: game.Cursor{X: 1, Y: 1},219 grid: emptyGrid(),220 keys: DefaultKeyMap,221 }222223 got := cellView(m, 1, 1, false)224 want := game.CursorStyle().225 Width(cellWidth).226 AlignHorizontal(lipgloss.Center).227 Render(game.CursorLeft + "·" + game.CursorRight)228 if got != want {229 t.Fatalf("empty cursor cellView() = %q, want %q", got, want)230 }231}232233func TestScreenToGrid(t *testing.T) {234 m := testDynamicBoardModel()235 ox, oy := m.gridOrigin()236237 tests := []struct {238 name string239 screenX int240 screenY int241 wantCol int242 wantRow int243 wantOK bool244 }{245 {"origin cell", ox, oy, 0, 0, true},246 {"next cell", ox + cellWidth + 1, oy, 1, 0, true},247 {"next row", ox, oy + 2, 0, 1, true},248 {"vertical separator", ox + cellWidth, oy, 0, 0, false},249 {"horizontal separator", ox, oy + 1, 0, 0, false},250 {"outside left", ox - 1, oy, 0, 0, false},251 {"outside bottom", ox, oy + 18, 0, 0, false},252 }253254 for _, tt := range tests {255 t.Run(tt.name, func(t *testing.T) {256 col, row, ok := m.screenToGrid(tt.screenX, tt.screenY)257 if ok != tt.wantOK {258 t.Fatalf("ok = %v, want %v", ok, tt.wantOK)259 }260 if ok && (col != tt.wantCol || row != tt.wantRow) {261 t.Fatalf("screenToGrid(%d, %d) = (%d, %d), want (%d, %d)", tt.screenX, tt.screenY, col, row, tt.wantCol, tt.wantRow)262 }263 })264 }265}266267func TestMouseClickMovesCursor(t *testing.T) {268 m := testDynamicBoardModel()269270 x, y := sudokuCellScreenCoords(&m, 2, 1)271 next, _ := m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})272 got := next.(Model)273274 if got.cursor.X != 2 || got.cursor.Y != 1 {275 t.Fatalf("cursor = (%d,%d), want (2,1)", got.cursor.X, got.cursor.Y)276 }277}278279func TestMouseClickOnSeparatorDoesNotMoveCursor(t *testing.T) {280 m := testDynamicBoardModel()281 m.cursor = game.Cursor{X: 1, Y: 1}282283 x, y := sudokuCellScreenCoords(&m, 1, 1)284 next, _ := m.Update(tea.MouseClickMsg{X: x + cellWidth, Y: y, Button: tea.MouseLeft})285 got := next.(Model)286287 if got.cursor.X != 1 || got.cursor.Y != 1 {288 t.Fatalf("cursor = (%d,%d), want unchanged (1,1)", got.cursor.X, got.cursor.Y)289 }290}291292func TestMouseClickSameCellDoesNotMutateValue(t *testing.T) {293 m := testDynamicBoardModel()294 m.cursor = game.Cursor{X: 1, Y: 1}295 m.grid[1][1].v = 4296297 x, y := sudokuCellScreenCoords(&m, 1, 1)298 next, _ := m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})299 got := next.(Model)300301 if got.grid[1][1].v != 4 {302 t.Fatalf("cell value = %d, want unchanged 4", got.grid[1][1].v)303 }304}305306func TestOriginInvalidation(t *testing.T) {307 t.Run("help toggle", func(t *testing.T) {308 m := testDynamicBoardModel()309 m.originValid = true310311 next, _ := m.Update(game.HelpToggleMsg{Show: true})312 got := next.(Model)313 if got.originValid {314 t.Fatal("expected help toggle to invalidate origin cache")315 }316 })317318 t.Run("window size", func(t *testing.T) {319 m := testDynamicBoardModel()320 m.originValid = true321322 next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})323 got := next.(Model)324 if got.originValid {325 t.Fatal("expected window resize to invalidate origin cache")326 }327 })328329 t.Run("reset", func(t *testing.T) {330 m := testDynamicBoardModel()331 m.originValid = true332333 got := m.Reset().(Model)334 if got.originValid {335 t.Fatal("expected reset to invalidate origin cache")336 }337 })338339 t.Run("solved transition", func(t *testing.T) {340 g := validCompleteGrid()341 g[0][0].v = 0342343 m := Model{344 grid: g,345 conflicts: computeConflicts(g),346 keys: DefaultKeyMap,347 originValid: true,348 }349350 m.updateCell(5)351 if m.originValid {352 t.Fatal("expected solving move to invalidate origin cache")353 }354 if !m.isSolved() {355 t.Fatal("expected board to be solved after filling final cell")356 }357 })358}359360func testDynamicBoardModel() Model {361 g := emptyGrid()362 return Model{363 grid: g,364 conflicts: computeConflicts(g),365 keys: DefaultKeyMap,366 modeTitle: "Test",367 termWidth: 120,368 termHeight: 40,369 }370}371372func sudokuCellScreenCoords(m *Model, col, row int) (int, int) {373 ox, oy := m.gridOrigin()374 return ox + col*(cellWidth+1), oy + row*2375}376377func sameColor(a, b color.Color) bool {378 if a == nil || b == nil {379 return a == b380 }381382 ar, ag, ab, aa := a.RGBA()383 br, bg, bb, ba := b.RGBA()384 return ar == br && ag == bg && ab == bb && aa == ba385}