clone

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

chore/app-model-decomp
puzzletea / game / game_test.go
game_test.go 10.0K
1package game23import (4 "strings"5 "testing"67 tea "charm.land/bubbletea/v2"8 "charm.land/lipgloss/v2"9)1011// --- Cursor.Move (P0) ---1213func TestCursorMove(t *testing.T) {14 keys := DefaultCursorKeyMap1516 keyMsg := func(k string) tea.KeyPressMsg {17 return tea.KeyPressMsg{Code: rune(k[0]), Text: k}18 }19 specialKeyMsg := func(k rune) tea.KeyPressMsg {20 return tea.KeyPressMsg{Code: k}21 }2223 t.Run("movement within bounds", func(t *testing.T) {24 tests := []struct {25 name string26 startX int27 startY int28 msg tea.KeyPressMsg29 maxX int30 maxY int31 wantX int32 wantY int33 wantMoved bool34 }{35 {"move up within bounds", 2, 2, specialKeyMsg(tea.KeyUp), 4, 4, 2, 1, true},36 {"move down within bounds", 2, 2, specialKeyMsg(tea.KeyDown), 4, 4, 2, 3, true},37 {"move left within bounds", 2, 2, specialKeyMsg(tea.KeyLeft), 4, 4, 1, 2, true},38 {"move right within bounds", 2, 2, specialKeyMsg(tea.KeyRight), 4, 4, 3, 2, true},39 }4041 for _, tt := range tests {42 t.Run(tt.name, func(t *testing.T) {43 c := Cursor{X: tt.startX, Y: tt.startY}44 moved := c.Move(keys, tt.msg, tt.maxX, tt.maxY)45 if moved != tt.wantMoved {46 t.Errorf("Move() = %v, want %v", moved, tt.wantMoved)47 }48 if c.X != tt.wantX || c.Y != tt.wantY {49 t.Errorf("cursor = (%d,%d), want (%d,%d)", c.X, c.Y, tt.wantX, tt.wantY)50 }51 })52 }53 })5455 t.Run("blocked at edges", func(t *testing.T) {56 tests := []struct {57 name string58 startX int59 startY int60 msg tea.KeyPressMsg61 maxX int62 maxY int63 }{64 {"blocked at top edge", 2, 0, specialKeyMsg(tea.KeyUp), 4, 4},65 {"blocked at bottom edge", 2, 4, specialKeyMsg(tea.KeyDown), 4, 4},66 {"blocked at left edge", 0, 2, specialKeyMsg(tea.KeyLeft), 4, 4},67 {"blocked at right edge", 4, 2, specialKeyMsg(tea.KeyRight), 4, 4},68 }6970 for _, tt := range tests {71 t.Run(tt.name, func(t *testing.T) {72 c := Cursor{X: tt.startX, Y: tt.startY}73 moved := c.Move(keys, tt.msg, tt.maxX, tt.maxY)74 if moved {75 t.Error("Move() = true, want false at edge")76 }77 if c.X != tt.startX || c.Y != tt.startY {78 t.Errorf("cursor moved to (%d,%d), want (%d,%d)", c.X, c.Y, tt.startX, tt.startY)79 }80 })81 }82 })8384 t.Run("1x1 grid no movement", func(t *testing.T) {85 directions := []tea.KeyPressMsg{86 specialKeyMsg(tea.KeyUp),87 specialKeyMsg(tea.KeyDown),88 specialKeyMsg(tea.KeyLeft),89 specialKeyMsg(tea.KeyRight),90 }91 for _, msg := range directions {92 c := Cursor{X: 0, Y: 0}93 moved := c.Move(keys, msg, 0, 0)94 if moved {95 t.Errorf("Move() = true on 1x1 grid for key %v", msg)96 }97 if c.X != 0 || c.Y != 0 {98 t.Errorf("cursor = (%d,%d), want (0,0)", c.X, c.Y)99 }100 }101 })102103 t.Run("arrow keys work", func(t *testing.T) {104 tests := []struct {105 msg tea.KeyPressMsg106 wantX int107 wantY int108 }{109 {specialKeyMsg(tea.KeyUp), 2, 1},110 {specialKeyMsg(tea.KeyDown), 2, 3},111 {specialKeyMsg(tea.KeyLeft), 1, 2},112 {specialKeyMsg(tea.KeyRight), 3, 2},113 }114 for _, tt := range tests {115 c := Cursor{X: 2, Y: 2}116 c.Move(keys, tt.msg, 4, 4)117 if c.X != tt.wantX || c.Y != tt.wantY {118 t.Errorf("arrow key: cursor = (%d,%d), want (%d,%d)", c.X, c.Y, tt.wantX, tt.wantY)119 }120 }121 })122123 t.Run("WASD keys work", func(t *testing.T) {124 tests := []struct {125 key string126 wantX int127 wantY int128 }{129 {"w", 2, 1},130 {"s", 2, 3},131 {"a", 1, 2},132 {"d", 3, 2},133 }134 for _, tt := range tests {135 c := Cursor{X: 2, Y: 2}136 c.Move(keys, keyMsg(tt.key), 4, 4)137 if c.X != tt.wantX || c.Y != tt.wantY {138 t.Errorf("WASD %q: cursor = (%d,%d), want (%d,%d)", tt.key, c.X, c.Y, tt.wantX, tt.wantY)139 }140 }141 })142143 t.Run("vim keys work", func(t *testing.T) {144 tests := []struct {145 key string146 wantX int147 wantY int148 }{149 {"k", 2, 1},150 {"j", 2, 3},151 {"h", 1, 2},152 {"l", 3, 2},153 }154 for _, tt := range tests {155 c := Cursor{X: 2, Y: 2}156 c.Move(keys, keyMsg(tt.key), 4, 4)157 if c.X != tt.wantX || c.Y != tt.wantY {158 t.Errorf("vim %q: cursor = (%d,%d), want (%d,%d)", tt.key, c.X, c.Y, tt.wantX, tt.wantY)159 }160 }161 })162163 t.Run("unrelated key no movement", func(t *testing.T) {164 c := Cursor{X: 2, Y: 2}165 moved := c.Move(keys, specialKeyMsg(tea.KeyEnter), 4, 4)166 if moved {167 t.Error("Move() = true for unrelated key, want false")168 }169 if c.X != 2 || c.Y != 2 {170 t.Errorf("cursor = (%d,%d), want (2,2)", c.X, c.Y)171 }172 })173}174175// --- BaseMode (P1) ---176177func TestBaseMode(t *testing.T) {178 t.Run("normal values", func(t *testing.T) {179 b := NewBaseMode("Easy", "5x5 grid")180 if got := b.Title(); got != "Easy" {181 t.Errorf("Title() = %q, want %q", got, "Easy")182 }183 if got := b.Description(); got != "5x5 grid" {184 t.Errorf("Description() = %q, want %q", got, "5x5 grid")185 }186 if got := b.FilterValue(); got != "Easy 5x5 grid" {187 t.Errorf("FilterValue() = %q, want %q", got, "Easy 5x5 grid")188 }189 })190191 t.Run("empty strings", func(t *testing.T) {192 b := NewBaseMode("", "")193 if got := b.Title(); got != "" {194 t.Errorf("Title() = %q, want %q", got, "")195 }196 if got := b.Description(); got != "" {197 t.Errorf("Description() = %q, want %q", got, "")198 }199 if got := b.FilterValue(); got != " " {200 t.Errorf("FilterValue() = %q, want %q", got, " ")201 }202 })203}204205func TestNormalizeName(t *testing.T) {206 tests := []struct {207 input string208 want string209 }{210 {input: "Word Search", want: "word search"},211 {input: "lights-out", want: "lights out"},212 {input: "word_search", want: "word search"},213 {input: " Sudoku ", want: "sudoku"},214 }215216 for _, tt := range tests {217 if got := NormalizeName(tt.input); got != tt.want {218 t.Fatalf("NormalizeName(%q) = %q, want %q", tt.input, got, tt.want)219 }220 }221}222223// --- TitleBarView (P3) ---224225func TestTitleBarView(t *testing.T) {226 t.Run("unsolved game", func(t *testing.T) {227 result := TitleBarView("Sudoku", "Easy", false)228 if !strings.Contains(result, "Sudoku") {229 t.Error("expected result to contain game name")230 }231 if !strings.Contains(result, "Easy") {232 t.Error("expected result to contain mode name")233 }234 if strings.Contains(result, "SOLVED") {235 t.Error("expected result to not contain SOLVED")236 }237 })238239 t.Run("solved game", func(t *testing.T) {240 result := TitleBarView("Sudoku", "Easy", true)241 if !strings.Contains(result, "SOLVED") {242 t.Error("expected result to contain SOLVED")243 }244 })245}246247// --- ComposeGameView (P1) ---248249func TestComposeGameView(t *testing.T) {250 t.Run("secondary width is fixed at 150 percent of primary", func(t *testing.T) {251 primary := lipgloss.NewStyle().Width(12).Render("grid")252 view := ComposeGameView(253 "title-width",254 primary,255 "this secondary row is much wider than the primary area",256 )257258 if got, want := lipgloss.Width(view), 18; got != want {259 t.Errorf("width = %d, want %d", got, want)260 }261 })262263 t.Run("long secondary wraps and increases height", func(t *testing.T) {264 primary := lipgloss.NewStyle().Width(10).Render("grid")265266 short := ComposeGameView("title-wrap", primary, "short row")267 long := ComposeGameView(268 "title-wrap",269 primary,270 "this secondary row should wrap to multiple lines when constrained",271 )272273 if got, want := lipgloss.Width(long), 15; got != want {274 t.Errorf("width = %d, want %d", got, want)275 }276 if got, want := lipgloss.Width(short), 15; got != want {277 t.Errorf("width = %d, want %d", got, want)278 }279 if lipgloss.Height(long) <= lipgloss.Height(short) {280 t.Errorf("height = %d, want > %d", lipgloss.Height(long), lipgloss.Height(short))281 }282 })283284 t.Run("wrap prefers double-space boundaries", func(t *testing.T) {285 row := "alpha: one beta: two gamma: three"286 got := wrapRowOnDoubleSpace(row, 18)287 want := "alpha: one\nbeta: two\ngamma: three"288 if got != want {289 t.Fatalf("wrapRowOnDoubleSpace() = %q, want %q", got, want)290 }291 })292293 t.Run("stable row reserves max variant height", func(t *testing.T) {294 primary := lipgloss.NewStyle().Width(10).Render("grid")295 longCurrent := ComposeGameViewRows(296 "title-pad",297 primary,298 StableRow(299 "alpha: one beta: two gamma: three",300 "alpha: one",301 "alpha: one beta: two gamma: three",302 ),303 )304 shortCurrent := ComposeGameViewRows(305 "title-pad",306 primary,307 StableRow(308 "alpha: one",309 "alpha: one",310 "alpha: one beta: two gamma: three",311 ),312 )313314 if got, want := lipgloss.Height(shortCurrent), lipgloss.Height(longCurrent); got != want {315 t.Fatalf("height(short) = %d, want %d", got, want)316 }317 })318}319320// --- DebugHeader (P3) ---321322func TestDebugHeader(t *testing.T) {323 t.Run("with rows", func(t *testing.T) {324 rows := [][2]string{325 {"Status", "In Progress"},326 {"Size", "5x5"},327 }328 result := DebugHeader("TestGame", rows)329 if !strings.Contains(result, "TestGame") {330 t.Error("expected result to contain title")331 }332 if !strings.Contains(result, "Status") || !strings.Contains(result, "In Progress") {333 t.Error("expected result to contain first key-value pair")334 }335 if !strings.Contains(result, "Size") || !strings.Contains(result, "5x5") {336 t.Error("expected result to contain second key-value pair")337 }338 })339340 t.Run("empty rows", func(t *testing.T) {341 result := DebugHeader("TestGame", nil)342 if !strings.Contains(result, "TestGame") {343 t.Error("expected result to contain title")344 }345 })346}347348// --- DebugTable (P3) ---349350func TestDebugTable(t *testing.T) {351 t.Run("normal table", func(t *testing.T) {352 headers := []string{"Col", "Hint", "Current"}353 rows := [][]string{354 {"0", "1,2", "1,2"},355 {"1", "3", "3"},356 }357 result := DebugTable("Tomography", headers, rows)358 if !strings.Contains(result, "Tomography") {359 t.Error("expected result to contain heading")360 }361 for _, h := range headers {362 if !strings.Contains(result, h) {363 t.Errorf("expected result to contain header %q", h)364 }365 }366 if !strings.Contains(result, "1,2") {367 t.Error("expected result to contain row values")368 }369 })370371 t.Run("empty rows", func(t *testing.T) {372 headers := []string{"Col", "Hint"}373 result := DebugTable("Heading", headers, nil)374 if !strings.Contains(result, "Heading") {375 t.Error("expected result to contain heading")376 }377 for _, h := range headers {378 if !strings.Contains(result, h) {379 t.Errorf("expected result to contain header %q", h)380 }381 }382 })383}