1package ui23import (4 "slices"5 "strings"6 "testing"78 tea "charm.land/bubbletea/v2"910 "src.rest/cat/012/internal/ui/picker"11)1213func openPicker(t *testing.T, m *Model) *picker.Picker {14 t.Helper()15 p, ok := m.overlay.(*picker.Picker)16 if !ok {17 t.Fatalf("no picker open (mode %v, overlay %T)", m.mode, m.overlay)18 }19 return p20}2122func selected(t *testing.T, m *Model) string {23 t.Helper()24 p := openPicker(t, m)25 if p.Sel >= len(p.Shown()) {26 return ""27 }28 return p.Shown()[p.Sel].Item.Title29}3031func TestPaletteKeys(t *testing.T) {32 for _, key := range []string{"<ctrl+k>", "<alt+/>", "<ctrl+shift+p>"} {33 m := newModel()34 press(t, m, key)35 if p := openPicker(t, m); len(p.Shown()) != len(p.Items) || !strings.HasSuffix(line(m, menuLine), "MENU") {36 t.Errorf("%s: %d of %d shown", key, len(p.Shown()), len(p.Items))37 }38 }39}4041func TestPaletteSearchRunsCommand(t *testing.T) {42 m := newModel()43 press(t, m, "<ctrl+k>", "colwid")44 p := openPicker(t, m)45 if got := selected(t, m); got != "Column width" {46 t.Fatalf("best match %q", got)47 }48 if len(p.Shown()[0].InTitle) != len("colwid") {49 t.Errorf("matched %v", p.Shown()[0].InTitle)50 }51 if !strings.Contains(screen(m), "Column width") || !strings.Contains(screen(m), "Format") {52 t.Errorf("result should show its menu path:\n%s", screen(m))53 }54 x, y, _ := m.cursorPos()55 if b := p.Layout()[0]; y != b.Y+1 || x != b.X+1+3+len("colwid") {56 t.Errorf("cursor at %d,%d, box at %d,%d", x, y, b.X, b.Y)57 }58 press(t, m, "<enter>")59 if m.overlay != nil || m.mode != modePrompt || m.prompt.kind != promptWidth {60 t.Errorf("Enter: mode %v overlay %T", m.mode, m.overlay)61 }62}6364func TestPaletteMatchesMenuPath(t *testing.T) {65 m := newModel()66 // "file" matches "Open config file" by its title first, then every67 // File menu command through its path.68 press(t, m, "<ctrl+k>", "file")69 var got []string70 for _, pm := range openPicker(t, m).Shown() {71 got = append(got, pm.Item.Title)72 }73 if got[0] != "Open config file" {74 t.Errorf("first match for file: %q", got[0])75 }76 slices.Sort(got[1:7])77 if s := strings.Join(got[1:7], ","); s != "Import,New,Open,Quit,Save,Save as" {78 t.Errorf("file matched %s", strings.Join(got, ","))79 }80 press(t, m, "<down>", "<down>", "<down>")81 if selected(t, m) != "Save" || !strings.Contains(line(m, m.height-1), "Save the sheet") {82 t.Errorf("selected %q, status %q", selected(t, m), line(m, m.height-1))83 }84 // Up from the top wraps to the bottom.85 press(t, m, "<up>", "<up>", "<up>", "<up>")86 if p := openPicker(t, m); p.Sel != len(p.Shown())-1 {87 t.Errorf("wrapped to %d of %d", p.Sel, len(p.Shown()))88 }89}9091func TestPaletteTitleMatchesFirst(t *testing.T) {92 m := newModel()93 // "sel" also matches "Save" through the l of its File path, but94 // titles that match on their own rank first.95 press(t, m, "<ctrl+k>", "sel")96 shown := openPicker(t, m).Shown()97 seenPathOnly := false98 for _, pm := range shown {99 inTitle := strings.Contains(strings.ToLower(pm.Item.Title), "sel")100 // Titles matching letter by letter ("Move sheet left") are101 // title matches too, just not literal ones.102 pathOnly := len(pm.InTitle) < len("sel")103 if pathOnly && pm.Item.Title != "Select all" {104 seenPathOnly = true105 } else if seenPathOnly && inTitle {106 t.Errorf("%q ranked after a path-only match", pm.Item.Title)107 }108 }109}110111// wordPrefix reports whether a word of title starts with q, ignoring case.112func wordPrefix(title, q string) bool {113 for w := range strings.FieldsSeq(strings.ToLower(title)) {114 if strings.HasPrefix(w, strings.ToLower(q)) {115 return true116 }117 }118 return false119}120121func TestPaletteWordPrefixFirst(t *testing.T) {122 m := newModel()123 // "col" starts a word in the column commands; "Command line" only124 // spells it across two words, so it ranks after them.125 press(t, m, "<ctrl+k>", "col")126 shown := openPicker(t, m).Shown()127 seenOther := false128 for _, pm := range shown {129 prefix := wordPrefix(pm.Item.Title[:pm.Item.Name], "col")130 if !prefix {131 seenOther = true132 } else if seenOther {133 t.Errorf("%q, a word-prefix match, ranked after other matches", pm.Item.Title)134 }135 }136 if len(shown) == 0 || !wordPrefix(shown[0].Item.Title, "col") {137 t.Errorf("first match for col: %+v", shown[0].Item.Title)138 }139}140141func TestPaletteNoMatchAndEsc(t *testing.T) {142 m := newModel()143 press(t, m, "<ctrl+k>", "zzqx")144 if !strings.Contains(screen(m), "No matches") || !strings.Contains(screen(m), "0 of ") {145 t.Errorf("no-match screen:\n%s", screen(m))146 }147 press(t, m, "<enter>")148 openPicker(t, m)149 press(t, m, "<backspace>", "<backspace>", "<backspace>", "<backspace>")150 if p := openPicker(t, m); len(p.Shown()) != len(p.Items) {151 t.Error("clearing the search did not show everything")152 }153 send(m, tea.PasteMsg{Content: "goto"})154 if selected(t, m) != "Go to" {155 t.Errorf("paste searched for %q", selected(t, m))156 }157 press(t, m, "<esc>")158 if m.overlay != nil || m.mode != modeReady || len(m.line.Buf) != 0 {159 t.Errorf("Esc: mode %v buf %q", m.mode, m.line.Text())160 }161}162163func TestPaletteUnavailableCommand(t *testing.T) {164 runs := fakeCommand(t, "edit.undo", "Undo", func(*Model) bool { return false })165 m := newModel()166 press(t, m, "<ctrl+k>", "undo")167 if selected(t, m) != "Undo" {168 t.Fatalf("selected %q", selected(t, m))169 }170 press(t, m, "<enter>")171 if *runs != 0 || m.overlay == nil {172 t.Error("an unavailable command ran")173 }174 if !strings.Contains(line(m, m.height-1), "not available") {175 t.Errorf("status %q", line(m, m.height-1))176 }177}178179func TestPaletteMouse(t *testing.T) {180 m := newModel()181 press(t, m, "<ctrl+k>", "go")182 p := openPicker(t, m)183 b := p.Layout()[0]184 var row int185 for i, pm := range p.Shown() {186 if pm.Item.Title == "Go to" {187 row = i188 }189 }190 mouseAt(m, tea.MouseMotionMsg{X: b.X + 4, Y: b.Y + picker.FirstRow + row})191 if selected(t, m) != "Go to" {192 t.Fatalf("hover selected %q", selected(t, m))193 }194 leftClick(m, b.X+4, b.Y+picker.FirstRow+row)195 if m.mode != modePrompt || m.prompt.label != "Go to:" {196 t.Errorf("click ran: mode %v", m.mode)197 }198 press(t, m, "<esc>", "<ctrl+k>")199 leftClick(m, 1, m.height-2)200 if m.overlay != nil {201 t.Error("click outside did not close the palette")202 }203}