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 / macro_test.go
macro_test.go 11.4K
1package ui23import (4 "bytes"5 "os"6 "path/filepath"7 "strings"8 "testing"910 tea "charm.land/bubbletea/v2"1112 "src.rest/cat/012/internal/sheet"13)1415// record records keys as a macro named name with the shortcut key, from16// wherever m is, and returns its script.17func record(t *testing.T, m *Model, relative bool, name, key string, keys ...string) string {18 t.Helper()19 id := "macro.record"20 if relative {21 id = "macro.record_relative"22 }23 send(m, nil) // an idle update, as between keys24 run(m, m.runCommand(id))25 if m.rec == nil {26 t.Fatal("not recording")27 }28 if !strings.Contains(line(m, menuLine), "REC") {29 t.Errorf("no REC chip: %q", line(m, menuLine))30 }31 press(t, m, keys...)32 run(m, m.runCommand("macro.stop"))33 if m.mode != modePrompt || !strings.HasPrefix(m.prompt.label, "Save macro as") {34 t.Fatalf("no name prompt: mode %v", m.mode)35 }36 m.line.Set(name)37 press(t, m, "<enter>")38 m.line.Set(key)39 press(t, m, "<enter>")40 if m.rec != nil {41 t.Fatalf("still recording: %q %q", m.note, m.errMsg)42 }43 mc, ok := m.book().Macro(name)44 if !ok {45 t.Fatalf("no macro %s: %q", name, m.errMsg)46 }47 return mc.Source48}4950// body is a script without its header comment.51func body(src string) string {52 var out []string53 for _, l := range strings.Split(strings.TrimSpace(src), "\n") {54 if !strings.HasPrefix(l, "# Recorded") && !strings.HasPrefix(l, "# Edit it") {55 out = append(out, l)56 }57 }58 return strings.Join(out, "\n")59}6061// fileOf is the workbook as saved, for comparing two of them.62func fileOf(t *testing.T, m *Model) string {63 t.Helper()64 var b bytes.Buffer65 if err := m.book().Write(&b); err != nil {66 t.Fatal(err)67 }68 return b.String()69}7071// withMacro gives a fresh model the macro from another, trusted.72func withMacro(t *testing.T, from *Model, name string) *Model {73 t.Helper()74 m := newModel()75 mc, _ := from.book().Macro(name)76 if err := m.book().SaveMacro("", mc, "add"); err != nil {77 t.Fatal(err)78 }79 m.trustHere()80 return m81}8283func TestRecordAbsoluteAndReplay(t *testing.T) {84 m := newModel()85 src := record(t, m, false, "Header", "1",86 "<down>", "Rent", "<tab>", "1450", "<enter>",87 "<right>", "<right>", "=B2*12", "<enter>",88 "<up>", "<shift+left>", "<ctrl+b>",89 "<ctrl+home>")90 want := `select("A2")91enter("Rent")92select("B2")93enter("1450")94select("C3")95enter("=B2*12")96select("B3:C3", active="C3")97run("format.bold")98select("A1")`99 // The selection is recorded when something acts on it, so Enter's100 // move to A3 and the arrows to C3 are one select.101 if got := body(src); got != want {102 t.Fatalf("recorded:\n%s\nwant:\n%s", got, want)103 }104 if !strings.HasPrefix(src, "# Recorded with absolute references") {105 t.Errorf("header: %q", src)106 }107108 // Replaying on a new sheet from anywhere does the same.109 r := withMacro(t, m, "Header")110 press(t, r, "<down>", "<down>", "<right>")111 press(t, r, "<ctrl+alt+shift+1>")112 if r.note != "Ran Header" {113 t.Fatalf("note %q warn %q err %q", r.note, r.warn, r.errMsg)114 }115 if fileOf(t, r) != fileOf(t, m) {116 t.Errorf("replay differs:\n%s\nrecorded:\n%s", fileOf(t, r), fileOf(t, m))117 }118 if r.cur != m.cur || r.selection() != m.selection() {119 t.Errorf("replay left %v, recording %v", r.selection(), m.selection())120 }121}122123func TestRecordRelativeReplaysFromTheActiveCell(t *testing.T) {124 m := newModel()125 press(t, m, "<right>", "<down>") // B2126 src := record(t, m, true, "Pair", "",127 "Name", "<tab>", "=LEN(B2)", "<enter>",128 "<shift+right>", "<ctrl+i>", "<ctrl+down>")129 want := `enter("Name")130move(1, 0)131enter("=LEN(B2)", origin="C2")132move(-1, 1)133extend(1, 0)134run("format.italic")135jump("down")`136 if got := body(src); got != want {137 t.Fatalf("recorded:\n%s\nwant:\n%s", got, want)138 }139140 r := withMacro(t, m, "Pair")141 press(t, r, "<down>", "<down>", "<down>", "<right>", "<right>", "<right>") // D4142 run(r, r.runMacro(mustMacro(t, r, "Pair")))143 if got := input(r, "D4") + "|" + input(r, "E4"); got != "Name|=LEN(D4)" {144 t.Errorf("replayed at D4: %q", got)145 }146 if c := r.sheet.Cell(addr("E5")); c == nil || !c.Style.Italic {147 t.Error("E5 isn't italic")148 }149 if r.cur != addr("D1048576") {150 t.Errorf("jump down ended at %v", r.cur)151 }152}153154func mustMacro(t *testing.T, m *Model, name string) sheet.Macro {155 t.Helper()156 mc, ok := m.book().Macro(name)157 if !ok {158 t.Fatalf("no macro %s", name)159 }160 return mc161}162163func TestReplayIsOneUndoStep(t *testing.T) {164 m := newModel()165 record(t, m, false, "Three", "2", "1", "<enter>", "2", "<enter>", "=A1+A2", "<enter>", "<shift+up>", "<ctrl+i>")166 r := withMacro(t, m, "Three")167 before := r.book().UndoLabel()168 press(t, r, "<ctrl+alt+shift+2>")169 if input(r, "A3") != "=A1+A2" || r.sheet.Value(addr("A3")).Num != 3 {170 t.Fatalf("A3 %q = %v", input(r, "A3"), r.sheet.Value(addr("A3")))171 }172 if got := r.book().UndoLabel(); got != "run macro Three" {173 t.Errorf("undo step %q (before %q)", got, before)174 }175 press(t, r, "<ctrl+z>")176 if r.sheet.Len() != 0 || r.note != "Undid: run macro Three" {177 t.Errorf("after undo: %d cells, note %q", r.sheet.Len(), r.note)178 }179 if r.book().UndoLabel() != before {180 t.Errorf("more than one step: %q", r.book().UndoLabel())181 }182 press(t, r, "<ctrl+y>")183 if input(r, "A2") != "2" {184 t.Error("redo")185 }186}187188func TestRecordingAnswersAndSkips(t *testing.T) {189 m := newModel()190 press(t, m, "x", "<enter>")191 src := record(t, m, false, "Widths", "",192 "<f10>", // a menu opened and closed records nothing193 "<esc>",194 "<ctrl+z>", // undo isn't recorded; the macro says so195 )196 run(m, nil)197 if !strings.Contains(src, "# Not recorded: undid: enter x in a1") && !strings.Contains(src, "# Not recorded: undid") {198 t.Errorf("recorded:\n%s", src)199 }200201 m = newModel()202 run(m, m.runCommand("macro.record"))203 run(m, m.runCommand("column.width"))204 m.line.Set("15")205 press(t, m, "<enter>")206 run(m, m.runCommand("macro.stop"))207 press(t, m, "<enter>", "<enter>")208 if got := body(mustMacro(t, m, "Macro 1").Source); got != `run("column.width", answer="15")` {209 t.Errorf("recorded %q", got)210 }211 r := withMacro(t, m, "Macro 1")212 run(r, r.runMacro(mustMacro(t, r, "Macro 1")))213 if r.sheet.ColWidth(0) != 15 || r.mode != modeReady {214 t.Errorf("width %d mode %v %q", r.sheet.ColWidth(0), r.mode, r.warn)215 }216}217218func TestRecordingTheMouse(t *testing.T) {219 for _, relative := range []bool{false, true} {220 m := newModel()221 press(t, m, "1", "<enter>", "2", "<enter>", "<up>", "<up>", "<shift+down>")222 run(m, m.runCommand(map[bool]string{false: "macro.record", true: "macro.record_relative"}[relative]))223 m.startFill() // drag the fill handle from A1:A2 down to A5224 m.dragFillTo(addr("A5"))225 m.finishFill()226 m.autofit(1) // double-click column B's border227 run(m, m.runCommand("macro.stop"))228 press(t, m, "<enter>", "<enter>")229 want := `fill(to="A1:A5")` + "\n" + `set_width("B", 3)`230 if relative {231 want = `fill(rows=3)` + "\n" + `set_width("B", 3)`232 }233 if got := body(mustMacro(t, m, "Macro 1").Source); got != want {234 t.Errorf("relative %v: recorded\n%s\nwant\n%s", relative, got, want)235 }236 r := withMacro(t, m, "Macro 1")237 press(t, r, "1", "<enter>", "2", "<enter>", "<up>", "<up>", "<shift+down>")238 run(r, r.runMacro(mustMacro(t, r, "Macro 1")))239 if input(r, "A5") != "5" || r.sheet.ColWidth(1) != 3 {240 t.Errorf("relative %v: A5 %q, width %d, %q", relative, input(r, "A5"), r.sheet.ColWidth(1), r.warn)241 }242 }243}244245func TestStopWithNothingRecorded(t *testing.T) {246 m := newModel()247 run(m, m.runCommand("macro.record"))248 run(m, m.runCommand("macro.stop"))249 if m.rec != nil || !strings.Contains(m.note, "Nothing was recorded") {250 t.Errorf("rec %v note %q", m.rec, m.note)251 }252 // Esc on the name keeps recording.253 run(m, m.runCommand("macro.record"))254 press(t, m, "a", "<enter>")255 run(m, m.runCommand("macro.stop"))256 press(t, m, "<esc>")257 if m.rec == nil || m.note != "Still recording" {258 t.Errorf("rec %v note %q", m.rec, m.note)259 }260}261262func TestMacrosFromElsewhereAskOnce(t *testing.T) {263 w := sheet.NewBook()264 w.SaveMacro("", sheet.Macro{Name: "Hi", Key: "3", Source: `set("A1", "hi")`}, "add")265 w.SetMacroOrigin("someone-else")266 var b bytes.Buffer267 w.Write(&b)268 s, err := sheet.Read(&b)269 if err != nil {270 t.Fatal(err)271 }272 m := New(s, "hi.012")273 m.SetMachine("this-one")274 m.Update(tea.WindowSizeMsg{Width: 100, Height: 20})275 if m.sheet.Len() != 0 {276 t.Fatal("opening ran a macro")277 }278 press(t, m, "<ctrl+alt+#>") // Ctrl+Alt+Shift+3 without the kitty protocol279 if !strings.Contains(line(m, contextLine), "Trust this file's macros?") || !strings.Contains(line(m, m.height-1), "made on another computer") {280 t.Fatalf("no trust question: %q", line(m, contextLine))281 }282 press(t, m, "<esc>")283 if m.sheet.Len() != 0 {284 t.Fatal("ran after cancel")285 }286 press(t, m, "<ctrl+alt+shift+3>", "<enter>")287 if input(m, "A1") != "hi" || m.book().MacroOrigin() != "this-one" {288 t.Fatalf("A1 %q origin %q", input(m, "A1"), m.book().MacroOrigin())289 }290 press(t, m, "<delete>", "<ctrl+alt+shift+3>")291 if input(m, "A1") != "hi" {292 t.Error("asked twice")293 }294}295296func TestPaletteAndPickerRunMacros(t *testing.T) {297 m := newModel()298 record(t, m, false, "Stamp", "5", "stamp", "<enter>")299 press(t, m, "<ctrl+k>", "stamp")300 if !strings.Contains(screen(m), "Ctrl+Alt+Shift+5") {301 t.Errorf("palette:\n%s", screen(m))302 }303 press(t, m, "<enter>")304 if m.note != "Ran Stamp" {305 t.Errorf("note %q", m.note)306 }307 run(m, m.runCommand("macro.run"))308 press(t, m, "<enter>")309 if m.note != "Ran Stamp" || m.overlay != nil {310 t.Errorf("Picker: note %q", m.note)311 }312}313314func TestManageMacros(t *testing.T) {315 m := newModel()316 m.AllowEditor()317 record(t, m, false, "One", "1", "1", "<enter>")318 run(m, m.runCommand("macro.manage"))319 if !strings.Contains(screen(m), "+ Write a macro") || !strings.Contains(screen(m), "One") {320 t.Fatalf("manager:\n%s", screen(m))321 }322 press(t, m, "<down>", "<f2>")323 m.line.Set("Uno")324 press(t, m, "<enter>")325 press(t, m, "<f3>")326 m.line.Set("7")327 press(t, m, "<enter>")328 mc := mustMacro(t, m, "Uno")329 if mc.Key != "7" {330 t.Errorf("key %q", mc.Key)331 }332 press(t, m, "<ctrl+d>")333 if len(m.book().Macros()) != 0 || !strings.Contains(line(m, m.height-1), "Deleted Uno") {334 t.Errorf("after delete: %v %q", m.book().Macros(), line(m, m.height-1))335 }336 press(t, m, "<esc>", "<ctrl+z>")337 if _, ok := m.book().Macro("Uno"); !ok {338 t.Error("undo didn't bring it back")339 }340}341342func TestWithoutAnEditorScriptsAreReadOnly(t *testing.T) {343 m := newModel()344 record(t, m, false, "One", "", "1", "<enter>")345 if commands["macro.new"].available(m) {346 t.Error("Write a macro is available without an editor")347 }348 run(m, m.runCommand("macro.manage"))349 if strings.Contains(screen(m), "+ Write a macro") {350 t.Error("the manager offers to write a macro")351 }352 if cmd := m.overlay.Key(tea.KeyPressMsg{Code: tea.KeyF4}); cmd != nil || !strings.Contains(line(m, m.height-1), "No editor in this session") {353 t.Errorf("F4: %v %q", cmd, line(m, m.height-1))354 }355}356357func TestEditedScriptsAreSavedAndChecked(t *testing.T) {358 m := newModel()359 m.AllowEditor()360 record(t, m, false, "Ed", "", "a", "<enter>")361 path := filepath.Join(t.TempDir(), "ed.star")362 os.WriteFile(path, []byte("set(\"B1\", 2)\nset(\"B2\",\n"), 0o600)363 m.Update(macroEditedMsg{name: "Ed", path: path, orig: "x"})364 if !strings.Contains(m.warn, "Saved Ed, but it has a mistake at Ed:3:1:") {365 t.Errorf("warn %q", m.warn)366 }367 if mustMacro(t, m, "Ed").Source != "set(\"B1\", 2)\nset(\"B2\",\n" {368 t.Error("not saved")369 }370 if _, err := os.Stat(path); !os.IsNotExist(err) {371 t.Error("temp file left")372 }373374 // A new script is trusted, and runs.375 path = filepath.Join(t.TempDir(), "new.star")376 os.WriteFile(path, []byte("set(\"C1\", 3)\n"), 0o600)377 m.Update(macroEditedMsg{name: "New", path: path, orig: newMacroTemplate, isNew: true})378 run(m, m.runMacro(mustMacro(t, m, "New")))379 if input(m, "C1") != "3" {380 t.Errorf("C1 %q warn %q", input(m, "C1"), m.warn)381 }382}