1package ui23import (4 "os"5 "path/filepath"6 "strings"7 "testing"89 tea "charm.land/bubbletea/v2"1011 "src.rest/cat/012/internal/ui/shortcuts"12)1314// vimModel is a model with vim keys on and a small table: A1:C6 filled15// with their own addresses, row 1 as a header.16func vimModel(t *testing.T) *Model {17 t.Helper()18 m := newModel()19 for _, col := range "ABC" {20 for row := 1; row <= 6; row++ {21 a := string(col) + string(rune('0'+row))22 if err := m.sheet.Set(addr(a), a); err != nil {23 t.Fatal(err)24 }25 }26 }27 m.sheet.ClearHistory()28 m.saved, m.changed = m.sheet.StateID(), false29 m.runCommand("settings.vim")30 return m31}3233func indicator(m *Model) string {34 f := strings.Fields(line(m, menuLine))35 return f[len(f)-1]36}3738// File > Settings > Vim keys turns NORMAL mode on and off; letters move39// instead of typing, and the setting survives File > New.40func TestVimSetting(t *testing.T) {41 m := newModel()42 press(t, m, "<alt+f>", "<up>", "<up>", "<right>")43 if s := screen(m); !strings.Contains(s, "Vim keys") {44 t.Fatalf("settings submenu:\n%s", s)45 }46 press(t, m, "<down>", "<down>", "<enter>")47 if !m.prefs.vim || indicator(m) != "NORMAL" || !strings.Contains(line(m, contextLine), "Vim keys on") {48 t.Fatalf("vim %v, indicator %q, context %q", m.prefs.vim, indicator(m), line(m, contextLine))49 }50 press(t, m, "j", "l", "x")51 if m.cur != addr("B2") || m.mode != modeReady || m.sheet.Len() != 0 {52 t.Errorf("hjkl: cur %v mode %v cells %d", m.cur, m.mode, m.sheet.Len())53 }54 if !strings.Contains(line(m, m.height-1), ": command") {55 t.Errorf("status line: %q", line(m, m.height-1))56 }57 m.runCommand("file.new")58 if !m.prefs.vim || indicator(m) != "NORMAL" {59 t.Errorf("File > New dropped vim keys")60 }61 m.runCommand("settings.vim")62 press(t, m, "j")63 if m.prefs.vim || indicator(m) != "ENTER" || bar(m) != "j" {64 t.Errorf("off: indicator %q, bar %q", indicator(m), bar(m))65 }66}6768func TestVimMotionsAndCounts(t *testing.T) {69 m := vimModel(t)70 steps := []struct {71 keys []string72 want string73 }{74 {[]string{"3j"}, "A4"},75 {[]string{"2l"}, "C4"},76 {[]string{"k"}, "C3"},77 {[]string{"h"}, "B3"},78 {[]string{"gg"}, "B1"},79 {[]string{"G"}, "B6"},80 {[]string{"3G"}, "B3"},81 {[]string{"5gg"}, "B5"},82 {[]string{"0"}, "A5"},83 {[]string{"$"}, "C5"},84 {[]string{"b"}, "A5"},85 {[]string{"w"}, "C5"},86 {[]string{"<down>"}, "C6"},87 {[]string{"10k"}, "C1"},88 {[]string{"<ctrl+d>"}, "C8"},89 {[]string{"<ctrl+u>"}, "C1"},90 {[]string{"L"}, "C15"},91 {[]string{"M"}, "C8"},92 {[]string{"H"}, "C1"},93 {[]string{"j", "20", "<esc>", "j"}, "C3"},94 }95 for _, s := range steps {96 press(t, m, s.keys...)97 if m.cur.String() != s.want || m.hasRange() {98 t.Fatalf("%v: at %v (range %v), want %s", s.keys, m.cur, m.hasRange(), s.want)99 }100 }101 // A count or a sequence being typed shows on the context line.102 press(t, m, "3d")103 if l := line(m, contextLine); !strings.HasPrefix(l, "3d") || indicator(m) != "NORMAL" {104 t.Errorf("pending: %q", l)105 }106 press(t, m, "<esc>")107 if m.vim.pending() != "" || strings.HasPrefix(line(m, contextLine), "3d") {108 t.Errorf("Esc kept %q", m.vim.pending())109 }110 // A broken sequence does nothing.111 press(t, m, "dz", "j")112 if m.cur != addr("C4") || m.sheet.Len() != 18 {113 t.Errorf("dz: at %v, %d cells", m.cur, m.sheet.Len())114 }115}116117func TestVimRowOperators(t *testing.T) {118 m := vimModel(t)119 press(t, m, "j", "2dd")120 if input(m, "A2") != "A4" || m.sheet.Len() != 12 || m.cur != addr("A2") {121 t.Fatalf("2dd: A2 %q, %d cells, at %v", input(m, "A2"), m.sheet.Len(), m.cur)122 }123 if !strings.Contains(line(m, contextLine), "Deleted 2 rows") {124 t.Errorf("context: %q", line(m, contextLine))125 }126 // p puts the rows back below, P above; each is one undo step.127 press(t, m, "p")128 if input(m, "A3") != "A2" || input(m, "A4") != "A3" || input(m, "C4") != "C3" || m.cur != addr("A3") {129 t.Fatalf("p: A3 %q A4 %q C4 %q at %v", input(m, "A3"), input(m, "A4"), input(m, "C4"), m.cur)130 }131 press(t, m, "u")132 if input(m, "A3") != "A5" || m.sheet.Len() != 12 {133 t.Fatalf("u: A3 %q", input(m, "A3"))134 }135 press(t, m, "gg", "P")136 if input(m, "A1") != "A2" || input(m, "A3") != "A1" {137 t.Fatalf("P: A1 %q A3 %q", input(m, "A1"), input(m, "A3"))138 }139 press(t, m, "u", "u")140 if input(m, "A2") != "A2" || m.sheet.Len() != 18 {141 t.Fatalf("uu: A2 %q, %d cells", input(m, "A2"), m.sheet.Len())142 }143 press(t, m, "<ctrl+r>")144 if input(m, "A2") != "A4" {145 t.Fatalf("Ctrl+R: A2 %q", input(m, "A2"))146 }147 press(t, m, "u")148 // yy copies without deleting; 2p pastes twice.149 press(t, m, "G", "yy", "2p")150 if input(m, "B7") != "B6" || input(m, "B8") != "B6" || m.sheet.Len() != 24 {151 t.Fatalf("yy 2p: B7 %q B8 %q, %d cells", input(m, "B7"), input(m, "B8"), m.sheet.Len())152 }153 // x clears cells to the right, keeping rows.154 press(t, m, "gg", "2x")155 if input(m, "A1") != "" || input(m, "B1") != "" || input(m, "C1") != "C1" || m.hasRange() {156 t.Fatalf("2x: %q %q %q", input(m, "A1"), input(m, "B1"), input(m, "C1"))157 }158 // o and O open a row and start typing in it.159 press(t, m, "o", "new", "<enter>")160 if input(m, "A2") != "new" || input(m, "A3") != "A2" {161 t.Fatalf("o: A2 %q A3 %q", input(m, "A2"), input(m, "A3"))162 }163 press(t, m, "gg", "O")164 if m.mode != modeEnter || m.cur != addr("A1") || input(m, "C2") != "C1" {165 t.Fatalf("O: mode %v at %v, C2 %q", m.mode, m.cur, input(m, "C2"))166 }167 press(t, m, "<esc>")168 if indicator(m) != "NORMAL" {169 t.Errorf("after Esc: %q", indicator(m))170 }171}172173func TestVimEditKeys(t *testing.T) {174 m := vimModel(t)175 press(t, m, "i", "x", "<enter>")176 if input(m, "A1") != "xA1" || m.cur != addr("A2") {177 t.Fatalf("i: A1 %q at %v", input(m, "A1"), m.cur)178 }179 press(t, m, "a", "y", "<enter>")180 if input(m, "A2") != "A2y" {181 t.Fatalf("a: A2 %q", input(m, "A2"))182 }183 press(t, m, "=", "1+1", "<enter>")184 if m.sheet.Value(addr("A3")).Num != 2 {185 t.Fatalf("=: A3 %q", input(m, "A3"))186 }187 // Sheets keys vim doesn't take still work.188 press(t, m, "<enter>")189 if m.mode != modeEdit {190 t.Errorf("Enter: mode %v", m.mode)191 }192 press(t, m, "<esc>", "<delete>")193 if input(m, "A4") != "" {194 t.Errorf("Del: A4 %q", input(m, "A4"))195 }196}197198func TestVimVisual(t *testing.T) {199 m := vimModel(t)200 press(t, m, "v", "j", "l")201 if indicator(m) != "VISUAL" || m.selection().String() != "A1:B2" {202 t.Fatalf("v: %q %v", indicator(m), m.selection())203 }204 if !strings.Contains(line(m, contextLine), "other corner") {205 t.Errorf("context: %q", line(m, contextLine))206 }207 press(t, m, "o")208 if m.cur != addr("B2") || m.selection().String() != "A1:B2" {209 t.Errorf("o: at %v, %v", m.cur, m.selection())210 }211 press(t, m, "y")212 if indicator(m) != "NORMAL" || m.hasRange() || m.copied.clip == nil || m.copied.clip.Src.String() != "A1:B2" {213 t.Fatalf("y: %q range %v", indicator(m), m.hasRange())214 }215 press(t, m, "G", "p")216 if input(m, "B6") != "A1" || input(m, "C7") != "B2" {217 t.Fatalf("p: B6 %q C7 %q", input(m, "B6"), input(m, "C7"))218 }219 press(t, m, "gg", "0", "v", "d")220 if input(m, "A1") != "" || indicator(m) != "NORMAL" {221 t.Fatalf("v d: A1 %q", input(m, "A1"))222 }223 press(t, m, "p")224 if input(m, "A1") != "A1" {225 t.Fatalf("p after d: A1 %q", input(m, "A1"))226 }227 // V selects whole rows; motions stretch them and d deletes them.228 press(t, m, "j", "V", "2j")229 if indicator(m) != "VISUAL" || m.whole != wholeRows || m.selection().From.Row != 1 || m.selection().To.Row != 3 {230 t.Fatalf("V: %v whole %v", m.selection(), m.whole)231 }232 press(t, m, "d")233 if input(m, "A2") != "A5" || !m.rowsCopied() {234 t.Fatalf("V d: A2 %q", input(m, "A2"))235 }236 // Esc leaves VISUAL, keeping the active cell.237 press(t, m, "v", "l", "<esc>")238 if indicator(m) != "NORMAL" || m.hasRange() || m.cur != addr("A2") {239 t.Errorf("Esc: %q %v at %v", indicator(m), m.hasRange(), m.cur)240 }241}242243func TestVimFind(t *testing.T) {244 m := vimModel(t)245 press(t, m, "n")246 if !strings.Contains(line(m, contextLine), "No search yet") {247 t.Errorf("n before a search: %q", line(m, contextLine))248 }249 press(t, m, "/", "B", "<enter>")250 if m.overlay != nil || m.cur != addr("B1") || indicator(m) != "NORMAL" {251 t.Fatalf("/B: overlay %v at %v", m.overlay, m.cur)252 }253 press(t, m, "n", "n")254 if m.cur != addr("B3") || !strings.Contains(line(m, contextLine), "Match 3 of 6") {255 t.Fatalf("nn: at %v, %q", m.cur, line(m, contextLine))256 }257 press(t, m, "N")258 if m.cur != addr("B2") {259 t.Errorf("N: at %v", m.cur)260 }261 press(t, m, "gg", "N")262 if m.cur != addr("B6") {263 t.Errorf("N wraps: at %v", m.cur)264 }265}266267func TestVimCommandLine(t *testing.T) {268 m := vimModel(t)269 press(t, m, ":")270 if indicator(m) != "COMMAND" || !strings.HasPrefix(line(m, contextLine), ":") {271 t.Fatalf(": %q %q", indicator(m), line(m, contextLine))272 }273 press(t, m, "B12", "<enter>")274 if m.cur != addr("B12") || m.overlay != nil || indicator(m) != "NORMAL" {275 t.Fatalf(":B12 at %v", m.cur)276 }277 press(t, m, ":40", "<enter>")278 if m.cur != addr("B40") {279 t.Fatalf(":40 at %v", m.cur)280 }281 press(t, m, ":Sheet1!C2", "<enter>")282 if m.cur != addr("C2") {283 t.Fatalf(":Sheet1!C2 at %v", m.cur)284 }285 // Completion comes from the command registry.286 press(t, m, ":fill")287 s := screen(m)288 for _, want := range []string{"Commands", "Fill down", "edit.fill_down", "Fill right"} {289 if !strings.Contains(s, want) {290 t.Fatalf(":fill completions lack %q:\n%s", want, s)291 }292 }293 if !strings.Contains(line(m, m.height-1), "Copy the top row") {294 t.Errorf("status: %q", line(m, m.height-1))295 }296 press(t, m, "<tab>")297 if m.line.Text() != "edit.fill_down" {298 t.Errorf("Tab: %q", m.line.Text())299 }300 press(t, m, "<tab>")301 if m.line.Text() != "edit.fill_right" {302 t.Errorf("Tab Tab: %q", m.line.Text())303 }304 press(t, m, "<esc>")305 // A title runs its command; so does the first completion of a prefix.306 press(t, m, "v", "j", ":", "fill down", "<enter>")307 if input(m, "C3") != "C2" {308 t.Errorf(":fill down: C3 %q", input(m, "C3"))309 }310 press(t, m, "<esc>", ":edit.undo", "<enter>")311 if input(m, "C3") != "C3" {312 t.Errorf(":edit.undo: C3 %q", input(m, "C3"))313 }314 press(t, m, ":keyboard sh", "<enter>")315 if _, ok := m.overlay.(*shortcuts.View); !ok {316 t.Errorf(":keyboard sh opened %T", m.overlay)317 }318 press(t, m, "<esc>", ":nonsense", "<enter>")319 if m.mode != modeError || !strings.Contains(line(m, m.height-1), "Not a command, cell or range: nonsense") {320 t.Errorf(":nonsense: %q", line(m, m.height-1))321 }322 press(t, m, "<esc>", ":", "<backspace>")323 if m.overlay != nil {324 t.Errorf("Backspace on an empty line left %T open", m.overlay)325 }326}327328func TestVimFileCommands(t *testing.T) {329 dir := t.TempDir()330 m := vimModel(t)331 name := filepath.Join(dir, "book")332 press(t, m, "x", ":w "+name, "<enter>")333 if _, err := os.Stat(name + ".012"); err != nil || m.filename != name+".012" || m.changed {334 t.Fatalf(":w: %v, filename %q, changed %v", err, m.filename, m.changed)335 }336 press(t, m, ":w "+filepath.Join(dir, "out.csv"), "<enter>")337 if data, err := os.ReadFile(filepath.Join(dir, "out.csv")); err != nil || !strings.Contains(string(data), "B1,C1") {338 t.Fatalf(":w out.csv: %v %q", err, data)339 }340 press(t, m, "j", "x", ":e other", "<enter>")341 if m.mode != modeError || !strings.Contains(line(m, m.height-1), "Unsaved changes") {342 t.Fatalf(":e with changes: %q", line(m, m.height-1))343 }344 press(t, m, "<esc>", ":e! "+name, "<enter>")345 if m.changed || input(m, "A2") != "A2" {346 t.Fatalf(":e!: changed %v, A2 %q", m.changed, input(m, "A2"))347 }348 if _, ok := press(t, m, ":q", "<enter>").(tea.QuitMsg); !ok {349 t.Error(":q didn't quit a saved sheet")350 }351 press(t, m, "x", ":q", "<enter>")352 if !strings.Contains(line(m, contextLine), "unsaved changes") {353 t.Errorf(":q with changes: %q", line(m, contextLine))354 }355 press(t, m, "<esc>")356 if _, ok := press(t, m, ":wq", "<enter>").(tea.QuitMsg); !ok || m.changed {357 t.Errorf(":wq didn't save and quit")358 }359}360361// Help lists the vim keys first, and the palette and menus show vim keys362// where Sheets' are taken.363func TestVimHelpAndShortcuts(t *testing.T) {364 m := vimModel(t)365 press(t, m, "<f1>")366 s := screen(m)367 if !strings.Contains(s, "Vim keys: NORMAL") || !strings.Contains(s, "Left, down, up, right") {368 t.Errorf("help:\n%s", s)369 }370 found := false371 for _, r := range helpRows(true) {372 found = found || r.action == "Cut rows" && strings.Join(r.keys, " ") == "dd"373 }374 if !found {375 t.Error("help lacks dd for Cut rows")376 }377 if m.shortcut("row.cut") != "dd" || m.shortcut("edit.fill_down") != "" || m.shortcut("edit.undo") != "Ctrl+Z" {378 t.Errorf("shortcuts: %q %q %q", m.shortcut("row.cut"), m.shortcut("edit.fill_down"), m.shortcut("edit.undo"))379 }380 if shortcut("edit.fill_down") != "Ctrl+D" {381 t.Errorf("Sheets shortcut: %q", shortcut("edit.fill_down"))382 }383 rows := helpRows(true)384 if rows[0].heading != "Vim keys: NORMAL" {385 t.Errorf("first help group %q", rows[0].heading)386 }387 for _, r := range helpRows(false) {388 if strings.HasPrefix(r.heading, "Vim") {389 t.Error("vim keys in help with vim keys off")390 }391 }392}