clone

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

chore/regenerate-vhs-gifs
puzzletea / ui / ui_test.go
ui_test.go 6.3K
1package ui23import (4 "strings"5 "testing"6 "unicode"78 "charm.land/bubbles/v2/table"9 "github.com/FelineStateMachine/puzzletea/store"10 "github.com/FelineStateMachine/puzzletea/theme"1112 "charm.land/bubbles/v2/list"13 "github.com/charmbracelet/x/ansi"14)1516// --- MenuItem (P0) ---1718func TestMenuItemTitle(t *testing.T) {19 item := MenuItem{ItemTitle: "Generate", Desc: "a new puzzle."}20 if got := item.Title(); got != "Generate" {21 t.Errorf("Title() = %q, want %q", got, "Generate")22 }23}2425func TestMenuItemDescription(t *testing.T) {26 item := MenuItem{ItemTitle: "Generate", Desc: "a new puzzle."}27 if got := item.Description(); got != "a new puzzle." {28 t.Errorf("Description() = %q, want %q", got, "a new puzzle.")29 }30}3132func TestMenuItemFilterValue(t *testing.T) {33 item := MenuItem{ItemTitle: "Generate", Desc: "a new puzzle."}34 if got := item.FilterValue(); got != "Generate" {35 t.Errorf("FilterValue() = %q, want %q", got, "Generate")36 }37}3839func TestMenuItemActionID(t *testing.T) {40 t.Run("uses stable action when present", func(t *testing.T) {41 item := MenuItem{ItemTitle: "Generate", Action: "create"}42 if got := item.ActionID(); got != "create" {43 t.Fatalf("ActionID() = %q, want %q", got, "create")44 }45 })4647 t.Run("falls back to title when action is unset", func(t *testing.T) {48 item := MenuItem{ItemTitle: "Generate"}49 if got := item.ActionID(); got != "Generate" {50 t.Fatalf("ActionID() = %q, want %q", got, "Generate")51 }52 })53}5455// --- FormatStatus (P0) ---5657func TestFormatStatus(t *testing.T) {58 tests := []struct {59 status store.GameStatus60 want string61 }{62 {store.StatusNew, "New"},63 {store.StatusInProgress, "In Progress"},64 {store.StatusCompleted, "Completed"},65 {store.StatusAbandoned, "abandoned"},66 }6768 for _, tt := range tests {69 t.Run(string(tt.status), func(t *testing.T) {70 got := FormatStatus(tt.status)71 if got != tt.want {72 t.Errorf("FormatStatus(%q) = %q, want %q", tt.status, got, tt.want)73 }74 })75 }76}7778func TestInitWeeklyTableKeepsFixedHeight(t *testing.T) {79 short := InitWeeklyTable([]table.Row{{"#01", "Sudoku", "Easy", "+0", "New"}}, 40)80 longRows := make([]table.Row, MaxTableRows)81 for i := range longRows {82 longRows[i] = table.Row{"#01", "Sudoku", "Easy", "+0", "New"}83 }84 long := InitWeeklyTable(longRows, 40)8586 if got, want := short.Height(), long.Height(); got != want {87 t.Fatalf("short Height() = %d, want %d", got, want)88 }89 if short.Height() <= 1 {90 t.Fatalf("Height() = %d, want > 1", short.Height())91 }92}9394// --- InitList (P1) ---9596func TestInitList(t *testing.T) {97 items := []list.Item{98 MenuItem{ItemTitle: "One", Desc: "first"},99 MenuItem{ItemTitle: "Two", Desc: "second"},100 }101102 l := InitList(items, "Test Menu")103104 if l.Title != "Test Menu" {105 t.Errorf("Title = %q, want %q", l.Title, "Test Menu")106 }107108 allItems := l.Items()109 if len(allItems) != 2 {110 t.Fatalf("Items count = %d, want 2", len(allItems))111 }112113 first := allItems[0].(MenuItem)114 if first.Title() != "One" {115 t.Errorf("first item Title = %q, want %q", first.Title(), "One")116 }117}118119func TestInitListDisablesQuitAndFilter(t *testing.T) {120 l := InitList(nil, "Empty")121122 // Filtering should be disabled.123 if l.FilteringEnabled() {124 t.Error("expected filtering to be disabled")125 }126127 // ShowHelp should be disabled.128 if l.ShowHelp() {129 t.Error("expected help to be disabled")130 }131}132133func TestInitCategoryListEnablesFilterAndHidesDescriptions(t *testing.T) {134 items := []list.Item{135 MenuItem{ItemTitle: "Sudoku", Desc: "number puzzle"},136 }137 l := InitCategoryList(items, "Categories")138139 if !l.FilteringEnabled() {140 t.Error("expected filtering to be enabled")141 }142143 if l.ShowHelp() {144 t.Error("expected help to be disabled")145 }146147 rendered := ansi.Strip(l.View())148 if strings.Contains(rendered, "number puzzle") {149 t.Fatalf("expected category list to hide item descriptions\nview:\n%s", rendered)150 }151}152153func TestThemeListFilterDoesNotSplitTitle(t *testing.T) {154 longest := longestThemeName(t)155156 items := []list.Item{157 MenuItem{ItemTitle: "Short", Desc: "dark theme"},158 MenuItem{ItemTitle: longest, Desc: "dark theme"},159 }160 l := InitThemeList(items, theme.MaxNameLen+4, 12)161 l.Select(1)162 l.SetFilterText(alternatingCasePrefix(longest, 6))163164 rendered := ansi.Strip(l.View())165 if !strings.Contains(strings.ToLower(rendered), strings.ToLower(longest)) {166 t.Fatalf("expected rendered output to contain full longest theme name %q\nview:\n%s", longest, rendered)167 }168169 nameRunes := []rune(longest)170 if len(nameRunes) >= 8 {171 head := string(nameRunes[:4])172 tail := string(nameRunes[4:8])173 if strings.Contains(strings.ToLower(rendered), strings.ToLower(head+"\n"+tail)) {174 t.Fatalf("expected longest theme title not to split across lines\nview:\n%s", rendered)175 }176 }177}178179func TestThemeListWidthStableWithSelectionAndFilter(t *testing.T) {180 longest := longestThemeName(t)181182 items := []list.Item{183 MenuItem{ItemTitle: "Dracula", Desc: "dark theme"},184 MenuItem{ItemTitle: longest, Desc: "dark theme"},185 }186 l := InitThemeList(items, theme.MaxNameLen+4, 12)187188 baseline := renderedWidth(l.View())189 if baseline <= 0 {190 t.Fatalf("expected positive rendered width, got %d", baseline)191 }192193 l.Select(1)194 if got := renderedWidth(l.View()); got != baseline {195 t.Fatalf("expected stable width after selecting longest theme, got %d want %d", got, baseline)196 }197198 l.SetFilterText(alternatingCasePrefix(longest, 6))199 if got := renderedWidth(l.View()); got != baseline {200 t.Fatalf("expected stable width with filter applied, got %d want %d", got, baseline)201 }202203 l.Select(0)204 if got := renderedWidth(l.View()); got != baseline {205 t.Fatalf("expected stable width after selecting short theme while filtered, got %d want %d", got, baseline)206 }207}208209func longestThemeName(t *testing.T) string {210 t.Helper()211212 longest := theme.DefaultThemeName213 themes := theme.AllThemes()214 if len(themes) == 0 {215 t.Fatal("expected at least one embedded theme")216 }217 for _, th := range themes {218 if len(th.Name) > len(longest) {219 longest = th.Name220 }221 }222 return longest223}224225func alternatingCasePrefix(s string, n int) string {226 r := []rune(s)227 if n > len(r) {228 n = len(r)229 }230 r = r[:n]231 for i, ch := range r {232 if i%2 == 0 {233 r[i] = unicode.ToLower(ch)234 } else {235 r[i] = unicode.ToUpper(ch)236 }237 }238 return string(r)239}240241func renderedWidth(s string) int {242 maxW := 0243 for line := range strings.SplitSeq(s, "\n") {244 if w := ansi.StringWidth(line); w > maxW {245 maxW = w246 }247 }248 return maxW249}