clone

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

refactor/root-tui-model
puzzletea / game / dynamic_grid_test.go
dynamic_grid_test.go 11.9K
1package game23import (4 "image"5 "image/color"6 "strings"7 "testing"89 "charm.land/lipgloss/v2"10 "github.com/FelineStateMachine/puzzletea/theme"11 "github.com/charmbracelet/x/ansi"12)1314func TestRenderDynamicGridCenterJunctionRune(t *testing.T) {15 tests := []struct {16 name string17 zones [][]int18 wantRune rune19 }{20 {21 name: "open interior",22 zones: [][]int{{0, 0}, {0, 0}},23 wantRune: ' ',24 },25 {26 name: "vertical wall",27 zones: [][]int{{0, 1}, {0, 1}},28 wantRune: '│',29 },30 {31 name: "cross",32 zones: [][]int{{0, 1}, {2, 3}},33 wantRune: '┼',34 },35 }3637 for _, tt := range tests {38 t.Run(tt.name, func(t *testing.T) {39 view := RenderDynamicGrid(DynamicGridSpec{40 Width: 2,41 Height: 2,42 Cell: func(_, _ int) string {43 return " "44 },45 ZoneAt: func(x, y int) int {46 return tt.zones[y][x]47 },48 })4950 lines := strings.Split(ansi.Strip(view), "\n")51 got := []rune(lines[2])[4]52 if got != tt.wantRune {53 t.Fatalf("center junction = %q, want %q", got, tt.wantRune)54 }55 })56 }57}5859func TestRenderDynamicGridUsesUniformRows(t *testing.T) {60 view := RenderDynamicGrid(DynamicGridSpec{61 Width: 3,62 Height: 3,63 Cell: func(_, _ int) string {64 return " "65 },66 ZoneAt: func(x, y int) int {67 return y*10 + x68 },69 })7071 lines := strings.Split(view, "\n")72 if got, want := len(lines), 7; got != want {73 t.Fatalf("line count = %d, want %d", got, want)74 }7576 wantWidth := lipgloss.Width(lines[0])77 for i, line := range lines {78 if width := lipgloss.Width(line); width != wantWidth {79 t.Fatalf("line %d width = %d, want %d", i, width, wantWidth)80 }81 }82}8384func TestRenderDynamicGridSupportsCustomCellWidth(t *testing.T) {85 view := RenderDynamicGrid(DynamicGridSpec{86 Width: 2,87 Height: 1,88 CellWidth: 5,89 Cell: func(_, _ int) string {90 return " "91 },92 ZoneAt: func(_, _ int) int {93 return 094 },95 VerticalBridgeText: func(x, y int) string {96 if x == 1 && y == 0 {97 return "x"98 }99 return ""100 },101 })102103 lines := strings.Split(ansi.Strip(view), "\n")104 if got := len([]rune(lines[0])); got != 13 {105 t.Fatalf("top row width = %d, want %d", got, 13)106 }107 if !strings.Contains(lines[1], "x") {108 t.Fatalf("content row = %q, want custom-width bridge text", lines[1])109 }110}111112func TestRenderDynamicGridCollapsesSparseBridges(t *testing.T) {113 view := RenderDynamicGrid(DynamicGridSpec{114 Width: 2,115 Height: 2,116 Cell: func(_, _ int) string {117 return " "118 },119 ZoneAt: func(_, _ int) int {120 return 0121 },122 HasVerticalEdge: func(x, _ int) bool {123 return x <= 0 || x >= 2124 },125 HasHorizontalEdge: func(_, y int) bool {126 return y <= 0 || y >= 2127 },128 VerticalBridgeWidth: func(x int) int {129 if x <= 0 || x >= 2 {130 return 1131 }132 return 0133 },134 HorizontalBridgeHeight: func(y int) int {135 if y <= 0 || y >= 2 {136 return 1137 }138 return 0139 },140 })141142 lines := strings.Split(ansi.Strip(view), "\n")143 if got, want := len(lines), 4; got != want {144 t.Fatalf("line count = %d, want %d", got, want)145 }146 if got, want := lines[0], "┌──────┐"; got != want {147 t.Fatalf("top row = %q, want %q", got, want)148 }149 if got := strings.Count(lines[1], "│"); got != 2 {150 t.Fatalf("content row border count = %d, want 2", got)151 }152}153154func TestRenderDynamicGridEdgeOverridesSuppressInteriorBorders(t *testing.T) {155 view := RenderDynamicGrid(DynamicGridSpec{156 Width: 2,157 Height: 2,158 Cell: func(_, _ int) string {159 return " "160 },161 ZoneAt: func(x, y int) int {162 return y*10 + x163 },164 HasVerticalEdge: func(x, _ int) bool {165 return x <= 0 || x >= 2166 },167 HasHorizontalEdge: func(_, y int) bool {168 return y <= 0 || y >= 2169 },170 })171172 lines := strings.Split(ansi.Strip(view), "\n")173 if got := []rune(lines[1])[4]; got != ' ' {174 t.Fatalf("interior vertical bridge rune = %q, want %q", got, ' ')175 }176 if got := []rune(lines[2])[4]; got != ' ' {177 t.Fatalf("interior junction rune = %q, want %q", got, ' ')178 }179}180181func TestRenderDynamicGridDefaultsToZoneEdgesWithoutOverrides(t *testing.T) {182 view := RenderDynamicGrid(DynamicGridSpec{183 Width: 2,184 Height: 2,185 Cell: func(_, _ int) string {186 return " "187 },188 ZoneAt: func(x, y int) int {189 return y*10 + x190 },191 })192193 lines := strings.Split(ansi.Strip(view), "\n")194 if got := []rune(lines[1])[4]; got != '│' {195 t.Fatalf("interior vertical bridge rune = %q, want %q", got, '│')196 }197 if got := []rune(lines[2])[4]; got != '┼' {198 t.Fatalf("interior junction rune = %q, want %q", got, '┼')199 }200}201202func TestDynamicGridRenderBorderCharSolvedDefaults(t *testing.T) {203 got := dynamicGridRenderBorderChar('│', DefaultBorderColors(), true, nil)204 want := lipgloss.NewStyle().205 Foreground(theme.Current().SuccessBorder).206 Background(theme.Current().SuccessBG).207 Render("│")208 if got != want {209 t.Fatal("expected solved border char to use solved colors")210 }211}212213func TestDynamicGridRenderBorderCharUsesTextOnTintedBackground(t *testing.T) {214 bg := theme.Current().Surface215 got := dynamicGridRenderBorderChar('│', DefaultBorderColors(), false, bg)216 want := lipgloss.NewStyle().217 Foreground(theme.TextOnBG(bg)).218 Background(bg).219 Render("│")220 if got != want {221 t.Fatal("expected tinted border char to use contrast-aware foreground")222 }223}224225func TestBorderCharHighlightUsesTextOnCrosshairBackground(t *testing.T) {226 colors := DefaultBorderColors()227 got := BorderChar("│", colors, false, true)228 want := lipgloss.NewStyle().229 Foreground(theme.TextOnBG(colors.CrosshairBG)).230 Background(colors.CrosshairBG).231 Render("│")232 if got != want {233 t.Fatal("expected highlighted border char to use contrast-aware foreground")234 }235}236237func TestDynamicGridScreenToCell(t *testing.T) {238 metrics := DynamicGridMetrics{Width: 3, Height: 3, CellWidth: DynamicGridCellWidth}239 originX, originY := 10, 20240241 tests := []struct {242 name string243 screenX int244 screenY int245 includeSeparators bool246 wantCol int247 wantRow int248 wantOK bool249 }{250 {"cell origin", originX, originY, false, 0, 0, true},251 {"next cell", originX + DynamicGridCellWidth + 1, originY, false, 1, 0, true},252 {"separator rejected", originX + DynamicGridCellWidth, originY, false, 0, 0, false},253 {"separator snaps during drag", originX + DynamicGridCellWidth, originY, true, 1, 0, true},254 {"outside right", originX + 3*(DynamicGridCellWidth+1), originY, false, 0, 0, false},255 }256257 for _, tt := range tests {258 t.Run(tt.name, func(t *testing.T) {259 col, row, ok := DynamicGridScreenToCell(260 metrics,261 originX,262 originY,263 tt.screenX,264 tt.screenY,265 tt.includeSeparators,266 )267 if ok != tt.wantOK {268 t.Fatalf("ok = %v, want %v", ok, tt.wantOK)269 }270 if ok && (col != tt.wantCol || row != tt.wantRow) {271 t.Fatalf("cell = (%d,%d), want (%d,%d)", col, row, tt.wantCol, tt.wantRow)272 }273 })274 }275}276277func TestDynamicGridScreenToCellSupportsSparseBridges(t *testing.T) {278 metrics := DynamicGridMetrics{279 Width: 3,280 Height: 3,281 CellWidth: DynamicGridCellWidth,282 VerticalBridgeWidth: func(x int) int {283 if x == 2 {284 return 1285 }286 return 0287 },288 HorizontalBridgeHeight: func(y int) int {289 if y == 2 {290 return 1291 }292 return 0293 },294 }295 originX, originY := 10, 20296297 tests := []struct {298 name string299 screenX int300 screenY int301 includeSeparators bool302 wantCol int303 wantRow int304 wantOK bool305 }{306 {"flush next cell", originX + DynamicGridCellWidth, originY, false, 1, 0, true},307 {"major vertical separator rejected", originX + 2*DynamicGridCellWidth, originY, false, 0, 0, false},308 {"major vertical separator snaps", originX + 2*DynamicGridCellWidth, originY, true, 2, 0, true},309 {"major horizontal separator rejected", originX, originY + 2, false, 0, 0, false},310 {"major horizontal separator snaps", originX, originY + 2, true, 0, 2, true},311 {"outside right", originX + 3*DynamicGridCellWidth + 1, originY, false, 0, 0, false},312 }313314 for _, tt := range tests {315 t.Run(tt.name, func(t *testing.T) {316 col, row, ok := DynamicGridScreenToCell(317 metrics,318 originX,319 originY,320 tt.screenX,321 tt.screenY,322 tt.includeSeparators,323 )324 if ok != tt.wantOK {325 t.Fatalf("ok = %v, want %v", ok, tt.wantOK)326 }327 if ok && (col != tt.wantCol || row != tt.wantRow) {328 t.Fatalf("cell = (%d,%d), want (%d,%d)", col, row, tt.wantCol, tt.wantRow)329 }330 })331 }332}333334func TestDynamicGridBridgeOnCrosshairAxis(t *testing.T) {335 cursor := Cursor{X: 2, Y: 1}336337 tests := []struct {338 name string339 bridge DynamicGridBridge340 want bool341 }{342 {343 name: "vertical bridge matches cursor row via cells",344 bridge: DynamicGridBridge{345 Kind: DynamicGridBridgeVertical,346 Cells: [4]image.Point{{X: 1, Y: 1}, {X: 2, Y: 1}},347 Count: 2,348 },349 want: true,350 },351 {352 name: "horizontal bridge matches cursor column via cells",353 bridge: DynamicGridBridge{354 Kind: DynamicGridBridgeHorizontal,355 Cells: [4]image.Point{{X: 2, Y: 0}, {X: 2, Y: 1}},356 Count: 2,357 },358 want: true,359 },360 {361 name: "separator vertical bridge matches cursor row without cells",362 bridge: DynamicGridBridge{363 Kind: DynamicGridBridgeVertical,364 Y: 1,365 },366 want: true,367 },368 {369 name: "separator horizontal bridge matches cursor column without cells",370 bridge: DynamicGridBridge{371 Kind: DynamicGridBridgeHorizontal,372 X: 2,373 },374 want: true,375 },376 {377 name: "junction does not participate in axis highlighting",378 bridge: DynamicGridBridge{379 Kind: DynamicGridBridgeJunction,380 Cells: [4]image.Point{{X: 2, Y: 1}},381 Count: 1,382 },383 want: false,384 },385 }386387 for _, tt := range tests {388 t.Run(tt.name, func(t *testing.T) {389 if got := DynamicGridBridgeOnCrosshairAxis(cursor, tt.bridge); got != tt.want {390 t.Fatalf("DynamicGridBridgeOnCrosshairAxis() = %v, want %v", got, tt.want)391 }392 })393 }394}395396func TestSameColor(t *testing.T) {397 if !SameColor(color.NRGBA{R: 10, G: 20, B: 30, A: 40}, color.NRGBA{R: 10, G: 20, B: 30, A: 40}) {398 t.Fatal("expected identical colors to match")399 }400 if SameColor(color.NRGBA{R: 10, G: 20, B: 30, A: 40}, color.NRGBA{R: 10, G: 20, B: 30, A: 41}) {401 t.Fatal("expected different colors not to match")402 }403 if !SameColor(nil, nil) {404 t.Fatal("expected nil colors to match")405 }406 if SameColor(color.NRGBA{R: 10, G: 20, B: 30, A: 40}, nil) {407 t.Fatal("expected color and nil not to match")408 }409}410411func TestDynamicGridOriginStableAcrossSecondaryRows(t *testing.T) {412 grid := RenderDynamicGrid(DynamicGridSpec{413 Width: 3,414 Height: 2,415 Cell: func(_, _ int) string {416 return " "417 },418 ZoneAt: func(_, _ int) int {419 return 0420 },421 })422 title := TitleBarView("Test", "Mode", false)423 shortView := ComposeGameViewRows(title, grid, StaticRow("1-9: place"))424 longView := ComposeGameViewRows(title, grid, StableRow("1-9: place", strings.Repeat("long help ", 6)))425426 shortX, shortY := DynamicGridOrigin(120, 40, shortView, title, grid)427 longX, longY := DynamicGridOrigin(120, 40, longView, title, grid)428 if shortX != longX || shortY != longY {429 t.Fatalf("origin changed from (%d,%d) to (%d,%d)", shortX, shortY, longX, longY)430 }431}432433func TestRenderDynamicGridVerticalBridgeText(t *testing.T) {434 view := RenderDynamicGrid(DynamicGridSpec{435 Width: 2,436 Height: 1,437 Cell: func(_, _ int) string {438 return " "439 },440 ZoneAt: func(_, _ int) int {441 return 0442 },443 VerticalBridgeText: func(x, y int) string {444 if x == 1 && y == 0 {445 return "x"446 }447 return ""448 },449 })450451 lines := strings.Split(ansi.Strip(view), "\n")452 if got := []rune(lines[1])[4]; got != 'x' {453 t.Fatalf("vertical bridge rune = %q, want %q", got, 'x')454 }455}456457func TestRenderDynamicGridHorizontalBridgeText(t *testing.T) {458 view := RenderDynamicGrid(DynamicGridSpec{459 Width: 1,460 Height: 2,461 Cell: func(_, _ int) string {462 return " "463 },464 ZoneAt: func(_, _ int) int {465 return 0466 },467 HorizontalBridgeText: func(x, y int) string {468 if x == 0 && y == 1 {469 return "="470 }471 return ""472 },473 })474475 lines := strings.Split(ansi.Strip(view), "\n")476 if got := []rune(lines[2])[2]; got != '=' {477 t.Fatalf("horizontal bridge rune = %q, want %q", got, '=')478 }479}