cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

cat/module-path
012 / internal / ui / shell_test.go
shell_test.go 9.7K
1package ui23import (4 "strings"5 "testing"67 tea "charm.land/bubbletea/v2"8 "github.com/charmbracelet/x/ansi"9)1011// fakeCommand replaces (or adds) a command for the length of a test and12// returns a run counter. The real command, if any, is restored afterwards.13func fakeCommand(t *testing.T, id, title string, enabled func(*Model) bool) *int {14 t.Helper()15 runs := new(int)16 prev, had := commands[id]17 commands[id] = &command{id: id, title: title, desc: title + " (test)", enabled: enabled,18 run: func(*Model) tea.Cmd { *runs++; return nil }}19 t.Cleanup(func() {20 if had {21 commands[id] = prev22 } else {23 delete(commands, id)24 }25 })26 return runs27}2829func mouseAt(m *Model, msg tea.MouseMsg) { send(m, msg) }3031func leftClick(m *Model, x, y int) {32 mouseAt(m, tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})33 mouseAt(m, tea.MouseReleaseMsg{X: x, Y: y, Button: tea.MouseLeft})34}3536// menu returns the open menu overlay, failing if there is none.37func openMenu(t *testing.T, m *Model) *menuOverlay {38 t.Helper()39 o, ok := m.overlay.(*menuOverlay)40 if !ok {41 t.Fatalf("no menu open (mode %v, overlay %T)", m.mode, m.overlay)42 }43 return o44}4546func highlighted(t *testing.T, m *Model) string {47 t.Helper()48 l := openMenu(t, m).top()49 if l.sel < 0 {50 return ""51 }52 return l.items[l.sel].label()53}5455func TestControlPanelLayout(t *testing.T) {56 m := newModel()57 if l := line(m, menuLine); !strings.HasPrefix(l, " File Edit") || !strings.HasSuffix(l, "READY") {58 t.Errorf("menu bar %q", l)59 }60 press(t, m, "<shift+down>", "<shift+right>")61 if l := line(m, formulaLine); strings.TrimSpace(l) != "A1:B2" {62 t.Errorf("name box should show the selection: %q", l)63 }64 press(t, m, "<esc>", "=1+")65 if x, y, _ := m.cursorPos(); y != formulaLine || x != formulaBarTextX()+3 {66 t.Errorf("entry cursor at %d,%d", x, y)67 }68 if !strings.Contains(line(m, contextLine), "Enter accept") {69 t.Errorf("context line %q", line(m, contextLine))70 }71 press(t, m, "<esc>", "<ctrl+g>")72 if l := line(m, contextLine); !strings.HasPrefix(l, "Go to: A1") || !strings.HasSuffix(l, "Esc cancel") {73 t.Errorf("prompt line %q", l)74 }75 if x, y, _ := m.cursorPos(); y != contextLine || x != len("Go to: A1") {76 t.Errorf("prompt cursor at %d,%d", x, y)77 }78}7980func TestVisibleItemsHidesMissingCommands(t *testing.T) {81 items := []menuItem{82 sep, {cmd: "nope.one"}, {cmd: "clear"}, sep, sep, {cmd: "nope.two"}, sep,83 {title: "Empty", items: []menuItem{{cmd: "nope.three"}}},84 {cmd: "goto"}, sep,85 }86 var got []string87 for _, it := range visibleItems(items) {88 if it.sep {89 got = append(got, "-")90 } else {91 got = append(got, it.label())92 }93 }94 if s := strings.Join(got, ","); s != "Clear,-,Go to" {95 t.Errorf("visible items %s", s)96 }97 // Menus only show once they have a registered command; Data's first98 // is Ask JEV again.99 found := false100 for _, bm := range barMenus() {101 found = found || bm.def.title == "Data"102 }103 if !found {104 t.Error("Data menu hidden")105 }106}107108func TestMenuBarKeyboard(t *testing.T) {109 m := newModel()110 press(t, m, "<alt+f>")111 if o := openMenu(t, m); barMenus()[o.bar].def.title != "File" || highlighted(t, m) != "New" {112 t.Fatalf("Alt+F opened %d at %q", o.bar, highlighted(t, m))113 }114 if !strings.HasSuffix(line(m, menuLine), "MENU") {115 t.Errorf("indicator %q", line(m, menuLine))116 }117 // Up wraps to the last item, skipping the separator.118 press(t, m, "<up>")119 if highlighted(t, m) != "Quit" {120 t.Errorf("up highlighted %q", highlighted(t, m))121 }122 if !strings.Contains(line(m, m.height-1), "Close 012") {123 t.Errorf("status line should describe the item: %q", line(m, m.height-1))124 }125 press(t, m, "<right>")126 if barMenus()[openMenu(t, m).bar].def.title != "Edit" {127 t.Error("Right did not move to Edit")128 }129 press(t, m, "<left>", "<left>")130 if barMenus()[openMenu(t, m).bar].def.title != "Help" {131 t.Error("Left from File did not wrap to Help")132 }133 press(t, m, "<esc>")134 if m.mode != modeReady || m.overlay != nil {135 t.Errorf("Esc left mode %v", m.mode)136 }137 press(t, m, "<f10>")138 if barMenus()[openMenu(t, m).bar].def.title != "File" {139 t.Error("F10 did not open File")140 }141 press(t, m, "<f10>")142 if m.overlay != nil {143 t.Error("F10 did not close the menu")144 }145}146147func TestMenuLetterRunsCommand(t *testing.T) {148 m := newModel()149 // Format has two items starting with C: the first press highlights150 // Column width, Enter runs it.151 press(t, m, "<shift+right>", "<alt+o>", "c")152 if highlighted(t, m) != "Column width" {153 t.Fatalf("c highlighted %q", highlighted(t, m))154 }155 press(t, m, "<enter>", "15", "<enter>")156 if m.mode != modeReady || m.sheet.ColWidth(0) != 15 || m.sheet.ColWidth(1) != 15 {157 t.Errorf("mode %v widths %d %d", m.mode, m.sheet.ColWidth(0), m.sheet.ColWidth(1))158 }159 // With several items starting with the letter, it cycles instead.160 press(t, m, "<alt+f>", "s")161 if highlighted(t, m) != "Save" {162 t.Errorf("s highlighted %q", highlighted(t, m))163 }164 press(t, m, "s")165 if highlighted(t, m) != "Save as" {166 t.Errorf("second s highlighted %q", highlighted(t, m))167 }168 press(t, m, "<enter>")169 if m.mode != modePrompt || m.prompt.label != "Save as:" {170 t.Errorf("Save as did not prompt: mode %v", m.mode)171 }172}173174func TestSubmenuAndDisabledItems(t *testing.T) {175 percent := fakeCommand(t, "format.percent", "Percent", nil)176 off := func(*Model) bool { return false }177 fakeCommand(t, "format.automatic", "Automatic", off)178 fakeCommand(t, "format.plain_text", "Plain text", off)179 fakeCommand(t, "format.number", "Number", off)180 m := newModel()181 press(t, m, "<alt+o>")182 if l := line(m, 2); highlighted(t, m) != "Number" || !strings.Contains(l, "│ Number") || !strings.Contains(l, "› │") {183 t.Fatalf("Format opened at %q:\n%s", highlighted(t, m), screen(m))184 }185 press(t, m, "<right>")186 // The unavailable formats before Percent are skipped.187 if o := openMenu(t, m); len(o.levels) != 2 || highlighted(t, m) != "Percent" {188 t.Fatalf("submenu at %q", highlighted(t, m))189 }190 press(t, m, "<down>", "<up>")191 if highlighted(t, m) != "Percent" {192 t.Errorf("down and up moved onto %q", highlighted(t, m))193 }194 press(t, m, "<left>")195 if len(openMenu(t, m).levels) != 1 {196 t.Error("Left did not close the submenu")197 }198 press(t, m, "<enter>", "<esc>")199 if len(openMenu(t, m).levels) != 1 {200 t.Error("Esc closed more than the submenu")201 }202 press(t, m, "<enter>", "<enter>")203 if *percent != 1 || m.overlay != nil || m.mode != modeReady {204 t.Errorf("percent ran %d times, overlay %T", *percent, m.overlay)205 }206}207208func TestMenuMouse(t *testing.T) {209 m := newModel()210 before := strings.Split(screen(m), "\n")211 leftClick(m, 2, menuLine) // "File"212 o := openMenu(t, m)213 if barMenus()[o.bar].def.title != "File" {214 t.Fatalf("clicked File, opened %d", o.bar)215 }216 if !strings.Contains(line(m, menuLine+1), "┌") || m.View().MouseMode != tea.MouseModeAllMotion {217 t.Errorf("dropdown not drawn under the title: %q", line(m, menuLine+1))218 }219 // The dropdown covers the grid without moving it.220 after := strings.Split(screen(m), "\n")221 w := o.Layout()[0].Width()222 for y := gridTop; y < m.height-1; y++ {223 a, b := ansi.Cut(after[y], w, m.width), ansi.Cut(before[y], w, m.width)224 if strings.TrimRight(a, " ") != strings.TrimRight(b, " ") {225 t.Errorf("row %d moved:\n%q\n%q", y, a, b)226 }227 }228 box := o.Layout()[0]229 // Hovering highlights; the Save item is the fifth row, after New,230 // Open, Import and a separator.231 mouseAt(m, tea.MouseMotionMsg{X: box.X + 3, Y: box.Y + 5})232 if highlighted(t, m) != "Save" {233 t.Errorf("hover highlighted %q", highlighted(t, m))234 }235 // Hovering another title switches menus.236 mouseAt(m, tea.MouseMotionMsg{X: barMenus()[1].x + 1, Y: menuLine})237 if openMenu(t, m).bar != 1 {238 t.Error("hovering Edit did not switch menus")239 }240 // Clicking the open title closes it; clicking outside closes too.241 leftClick(m, barMenus()[1].x+1, menuLine)242 if m.overlay != nil {243 t.Error("clicking the open title did not close it")244 }245 leftClick(m, 2, menuLine)246 leftClick(m, cellX(5), gridTop+10)247 if m.overlay != nil || m.cur != addr("A1") {248 t.Errorf("outside click: overlay %T, cur %v (the click should only close)", m.overlay, m.cur)249 }250 // Clicking an item runs it.251 t.Chdir(t.TempDir())252 leftClick(m, 2, menuLine)253 box = openMenu(t, m).Layout()[0]254 leftClick(m, box.X+3, box.Y+6) // Save as255 if m.mode != modePrompt || m.prompt.label != "Save as:" {256 t.Errorf("click on Save as: mode %v", m.mode)257 }258}259260func TestQuitAsksWithChoiceBar(t *testing.T) {261 m := newModel()262 if _, ok := press(t, m, "<ctrl+q>").(tea.QuitMsg); !ok {263 t.Fatal("Ctrl+Q with no changes did not quit")264 }265 m = newModel()266 press(t, m, "1", "<enter>")267 if msg := press(t, m, "<ctrl+q>"); msg != nil || m.mode != modeMenu {268 t.Fatalf("Ctrl+Q with changes: %v mode %v", msg, m.mode)269 }270 if l := line(m, contextLine); !strings.HasPrefix(l, "You have unsaved changes.") || !strings.Contains(l, "D Discard") {271 t.Errorf("choice bar %q", l)272 }273 press(t, m, "<esc>")274 if m.mode != modeReady {275 t.Errorf("Esc: mode %v", m.mode)276 }277 press(t, m, "<ctrl+q>")278 if _, ok := press(t, m, "d").(tea.QuitMsg); !ok {279 t.Error("D did not discard and quit")280 }281}282283func TestSaveAndQuit(t *testing.T) {284 t.Chdir(t.TempDir())285 m := newModel()286 press(t, m, "1", "<enter>", "<ctrl+q>", "<enter>")287 // Untitled: Save and quit asks for a name first; Esc cancels both.288 if m.mode != modePrompt {289 t.Fatalf("mode %v", m.mode)290 }291 press(t, m, "<esc>")292 if m.quitAfterSave {293 t.Error("cancelling Save as left Save and quit pending")294 }295 press(t, m, "<ctrl+q>", "<enter>")296 if _, ok := press(t, m, "sheet", "<enter>").(tea.QuitMsg); !ok || m.changed {297 t.Errorf("did not save and quit: changed %v err %q", m.changed, m.errMsg)298 }299 // Clicking a choice on the context line works too.300 m = newModel()301 press(t, m, "1", "<enter>", "<ctrl+q>")302 x := strings.Index(line(m, contextLine), "Discard")303 if msg, _ := m.shellMouse(tea.MouseClickMsg{X: x, Y: contextLine, Button: tea.MouseLeft}); msg == nil {304 t.Error("clicking Discard did not quit")305 }306}