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 / notebook_test.go
notebook_test.go 11.7K
1package ui23import (4 "bytes"5 "context"6 "io"7 "strings"8 "testing"910 tea "charm.land/bubbletea/v2"1112 "src.rest/cat/012/internal/notebook"13 "src.rest/cat/012/internal/nushell"14 "src.rest/cat/012/internal/sheet"15)1617// fakeNu answers pipelines from a table of outputs, failing those it18// lacks, and keeps the jobs it ran.19type fakeNu struct {20 out map[string]string21 jobs []nushell.Job22}2324func (f *fakeNu) Run(_ context.Context, job nushell.Job, _ string, stdout io.Writer) error {25 if len(job.IDE) > 0 {26 return nushell.ErrMissing // the code editor's questions: the built-ins answer27 }28 if job.Command == "help commands | get name" {29 _, err := io.WriteString(stdout, `["ls", "where", "sort-by", "str join"]`)30 return err31 }32 f.jobs = append(f.jobs, job)33 out, ok := f.out[job.Command]34 if !ok {35 return &nushell.Error{Msg: "Command `" + job.Command + "` not found", Stderr: " × not found\n help: check the spelling\n"}36 }37 _, err := io.WriteString(stdout, out)38 return err39}4041// ran is the pipelines the fake ran, in order.42func (f *fakeNu) ran() []string {43 var out []string44 for _, j := range f.jobs {45 out = append(out, j.Command)46 }47 return out48}4950const lsOut = "[[name, size]; [a.txt, 2kb], [b.txt, 10b], [c.txt, 5kb]]"5152// notebookModel is a model on a new notebook, cells running with a fake53// nu answering from out.54func notebookModel(t *testing.T, out map[string]string) (*Model, *fakeNu) {55 t.Helper()56 m := newModel()57 nu := &fakeNu{out: out}58 m.SetShellRunner(nu)59 m.SetMachine("here")60 m.nb.still = true61 run(m, m.runCommand("nb.open"))62 if !m.sheet.IsNotebook() {63 t.Fatal("Open notebook didn't show a notebook")64 }65 return m, nu66}6768// write adds a cell below the selected one holding src, leaving edit69// mode.70func write(t *testing.T, m *Model, src string) {71 t.Helper()72 press(t, m, "b", "<enter>")73 send(m, pasteMsg(src))74 press(t, m, "<esc>")75}7677func pasteMsg(s string) tea.PasteMsg { return tea.PasteMsg{Content: s} }7879func cells(m *Model) []string {80 var out []string81 for _, c := range m.sheet.NotebookCells() {82 out = append(out, c.Source)83 }84 return out85}8687func TestNotebookWritesAndRuns(t *testing.T) {88 m, nu := notebookModel(t, map[string]string{"ls": lsOut})89 if got := line(m, menuLine); !strings.Contains(got, "NOTEBOOK") {90 t.Errorf("indicator: %q", got)91 }92 press(t, m, "b", "<enter>")93 if got := line(m, menuLine); !strings.HasSuffix(got, " EDIT") {94 t.Errorf("editing: %q", got)95 }96 press(t, m, "files = ls", "<shift+enter>")97 if got := cells(m); len(got) != 2 || got[0] != "files = ls" || got[1] != "" {98 t.Fatalf("cells %q", got)99 }100 if len(nu.jobs) != 1 {101 t.Fatalf("ran %v", nu.ran())102 }103 s := screen(m)104 for _, want := range []string{"[1]:", "─ files ─", "Out[1]:", "a.txt", "2.0 kB", "c.txt"} {105 if !strings.Contains(s, want) {106 t.Errorf("screen lacks %q:\n%s", want, s)107 }108 }109 // The new cell is selected, and a run count shows on the first.110 if i, _ := m.nbView().Selected(); i != 1 {111 t.Errorf("selected %d after Shift+Enter", i)112 }113 // Grid commands don't act on a notebook tab.114 run(m, m.runCommand("clear"))115 if !strings.Contains(m.note, "notebook") {116 t.Errorf("clear on a notebook: %q", m.note)117 }118}119120func TestNotebookReadsNames(t *testing.T) {121 m, nu := notebookModel(t, map[string]string{"ls": lsOut, "$files | where size > 1kb": "[[name]; [a.txt]]"})122 write(t, m, "files = ls")123 write(t, m, "$files | where size > 1kb")124 // Running the second runs the first, which it reads and which hasn't125 // run, first.126 press(t, m, "<ctrl+enter>")127 if got := nu.ran(); len(got) != 2 || got[0] != "ls" {128 t.Fatalf("ran %q", got)129 }130 if got := string(nu.jobs[1].Tables["files"]); got != lsOut {131 t.Errorf("$files reached nu as %q", got)132 }133 // Running files again makes the cell reading it stale; a reactive134 // notebook runs it too.135 press(t, m, "<up>", "<up>", "<up>")136 if i, _ := m.nbView().Selected(); i != 0 {137 t.Fatalf("selected %d", i)138 }139 cs := m.sheet.NotebookCells()140 if st := m.cellState(m.sheet, cs[1].ID); st.Stale {141 t.Error("stale before anything changed")142 }143 press(t, m, "<ctrl+enter>")144 if st := m.cellState(m.sheet, cs[1].ID); !st.Stale {145 t.Error("a cell reading one that ran again isn't stale")146 }147 if !strings.Contains(screen(m), "stale") {148 t.Error("stale isn't on the screen")149 }150 run(m, m.runCommand("nb.reactive"))151 press(t, m, "<ctrl+enter>")152 if got := nu.ran(); len(got) != 5 || got[4] != "$files | where size > 1kb" {153 t.Errorf("reactive ran %q", got)154 }155 if st := m.cellState(m.sheet, cs[1].ID); st.Stale {156 t.Error("stale after the reactive run")157 }158}159160func TestNotebookRunOrders(t *testing.T) {161 m, nu := notebookModel(t, map[string]string{"1": "1", "2": "2", "3": "3"})162 for _, s := range []string{"1", "2", "3"} {163 write(t, m, s)164 }165 press(t, m, "<up>")166 if i, _ := m.nbView().Selected(); i != 1 {167 t.Fatalf("selected %d", i)168 }169 run(m, m.runCommand("nb.run_above"))170 run(m, m.runCommand("nb.run_below"))171 run(m, m.runCommand("nb.run_all"))172 if got := strings.Join(nu.ran(), " "); got != "1 2 3 1 2 3" {173 t.Errorf("ran %q", got)174 }175}176177func TestNotebookFailureStopsTheQueue(t *testing.T) {178 m, nu := notebookModel(t, map[string]string{"ls": lsOut})179 write(t, m, "nope")180 write(t, m, "ls")181 run(m, m.runCommand("nb.run_all"))182 if got := nu.ran(); len(got) != 1 {183 t.Errorf("ran %q after a failure", got)184 }185 s := screen(m)186 if !strings.Contains(s, "× Command `nope` not found") || !strings.Contains(s, "help: check the spelling") || !strings.Contains(s, "failed") {187 t.Errorf("the error isn't shown:\n%s", s)188 }189}190191func TestNotebookCellKeys(t *testing.T) {192 m, _ := notebookModel(t, nil)193 write(t, m, "a")194 write(t, m, "b")195 want := func(step string, srcs ...string) {196 t.Helper()197 if got := cells(m); strings.Join(got, ",") != strings.Join(srcs, ",") {198 t.Fatalf("after %s: %q, want %q", step, got, srcs)199 }200 }201 press(t, m, "d", "d")202 want("dd", "a")203 press(t, m, "z")204 want("z", "a", "b")205 press(t, m, "<end>", "c", "v")206 want("c v", "a", "b", "b")207 press(t, m, "m")208 if m.sheet.NotebookCells()[2].Kind != notebook.Note {209 t.Error("m didn't make a note")210 }211 press(t, m, "y")212 if m.sheet.NotebookCells()[2].Kind != notebook.Code {213 t.Error("y didn't make code")214 }215 press(t, m, "x")216 want("x", "a", "b")217 press(t, m, "a")218 want("a", "a", "", "b")219 press(t, m, "n")220 press(t, m, "<ctrl+a>", "empty", "<enter>")221 want("n", "a", "empty = ", "b")222}223224func TestNotebookRestartAndToggle(t *testing.T) {225 long := "[" + strings.Repeat("{n: 1}, ", 40) + "{n: 2}]"226 m, _ := notebookModel(t, map[string]string{"long": long})227 write(t, m, "long")228 press(t, m, "<ctrl+enter>")229 if s := screen(m); !strings.Contains(s, "31 more rows") {230 t.Errorf("a long output isn't cut to its window:\n%s", s)231 }232 press(t, m, "o")233 if s := screen(m); strings.Contains(s, "more rows") {234 t.Errorf("o didn't show all of it:\n%s", s)235 }236 press(t, m, "0", "0")237 if m.book().Output(m.sheet.NotebookCells()[0].ID) != nil || m.nb.runs.count != 0 {238 t.Error("0 0 left outputs")239 }240}241242// A 200-character pipeline at 80 columns wraps: every character of it243// is on the screen, in edit mode and out of it.244func TestNotebookLongPipelineWraps(t *testing.T) {245 m, _ := notebookModel(t, nil)246 var b strings.Builder247 for i := 0; b.Len() < 200; i++ {248 b.WriteString("where col" + string(rune('a'+i%26)) + " > " + strings.Repeat("9", i%5+1) + " | ")249 }250 src := "ls | " + b.String() + "first"251 press(t, m, "b", "<enter>")252 send(m, pasteMsg(src))253 check := func(when string) {254 s := strings.ReplaceAll(screen(m), "\n", "")255 var joined strings.Builder256 for _, l := range strings.Split(screen(m), "\n") {257 joined.WriteString(boxText(l) + " ")258 }259 got := strings.Join(strings.Fields(joined.String()), " ")260 if got != strings.Join(strings.Fields(src), " ") {261 t.Errorf("%s: the source on screen is\n%q\nwant\n%q\n%s", when, got, src, s)262 }263 }264 check("editing")265 press(t, m, "<esc>")266 check("in command mode")267 if _, _, ok := m.cursorPos(); ok {268 t.Error("a caret out of edit mode")269 }270}271272func TestNotebookSelectionAndRanges(t *testing.T) {273 m, nu := notebookModel(t, map[string]string{"$selection | math sum": "7", "$__sheet1 | length": "2"})274 grid := m.book().Sheet(0)275 grid.Set(addr("A1"), "3")276 grid.Set(addr("A2"), "4")277 m.showSheet(grid)278 m.cur, m.ext, m.selecting = addr("A1"), addr("A2"), true279 run(m, m.runCommand("nb.open"))280 write(t, m, "$selection | math sum")281 write(t, m, "$sheet.Sheet1!A1:A2 | length")282 run(m, m.runCommand("nb.run_all"))283 if len(nu.jobs) != 2 {284 t.Fatalf("ran %q", nu.ran())285 }286 if sel := string(nu.jobs[0].Tables["selection"]); !strings.Contains(sel, "3") || !strings.Contains(sel, "4") {287 t.Errorf("$selection reached nu as %q", sel)288 }289 if r := string(nu.jobs[1].Tables["__sheet1"]); !strings.Contains(r, "4") {290 t.Errorf("$sheet.Sheet1!A1:A2 reached nu as %q", r)291 }292 if o := m.book().Output(m.sheet.NotebookCells()[0].ID); o.Selection != "Sheet1!A1:A2" {293 t.Errorf("the output read %q", o.Selection)294 }295}296297func TestNotebookSendsToSheet(t *testing.T) {298 m, nu := notebookModel(t, map[string]string{"ls": lsOut})299 write(t, m, "files = ls")300 press(t, m, "<ctrl+enter>", "G", "<enter>") // the picker's first choice: a new sheet301 t2 := m.book().Lookup("files")302 if t2 == nil {303 t.Fatalf("no sheet files; note %q", m.note)304 }305 if got := t2.Value(addr("A1")).String(); got != "name" {306 t.Errorf("A1 %q", got)307 }308 t2.Set(addr("D1"), "=ROWS(nu.files)")309 if got := t2.Value(addr("D1")).Num; got != 4 {310 t.Errorf("ROWS(nu.files) = %v", got)311 }312 // Running again updates the sheet through the change stream.313 nu.out["ls"] = "[[name]; [x]]"314 press(t, m, "<ctrl+enter>")315 if got := t2.Value(addr("D1")).Num; got != 2 {316 t.Errorf("after running again ROWS = %v", got)317 }318 // Undo the region's removal: it's fed again from the output.319 m.showSheet(t2)320 m.cur = addr("A1")321 run(m, m.runCommand("region.delete"))322 run(m, m.runCommand("edit.undo"))323 send(m, tea.WindowSizeMsg{Width: 80, Height: 20})324 if got := t2.Value(addr("A2")).String(); got != "x" {325 t.Errorf("after undo A2 = %q", got)326 }327}328329func TestNotebookTrust(t *testing.T) {330 m, nu := notebookModel(t, map[string]string{"ls": lsOut})331 write(t, m, "ls")332 var b bytes.Buffer333 if err := m.book().Write(&b); err != nil {334 t.Fatal(err)335 }336 text := strings.Replace(b.String(), `"macroOrigin": "here"`, `"macroOrigin": "elsewhere"`, 1)337 s, err := sheet.Read(strings.NewReader(text))338 if err != nil {339 t.Fatal(err)340 }341 m2 := New(s, "")342 m2.Update(tea.WindowSizeMsg{Width: 80, Height: 20})343 m2.SetShellRunner(nu)344 m2.SetMachine("here")345 m2.nb.still = true346 run(m2, m2.runCommand("nb.open"))347 run(m2, m2.runCommand("nb.run_all"))348 if len(nu.jobs) != 0 || !strings.Contains(line(m2, contextLine), "Run this file's notebook cells?") {349 t.Fatalf("ran without asking: %q", line(m2, contextLine))350 }351 press(t, m2, "<enter>")352 if len(nu.jobs) != 1 {353 t.Errorf("didn't run after agreeing")354 }355 m2.nb.served = true356 run(m2, m2.runCommand("nb.run_all"))357 if len(nu.jobs) != 1 || !strings.Contains(m2.errMsg, "012 serve") {358 t.Errorf("ran served: %q", m2.errMsg)359 }360}361362func TestNotebookBangAndTab(t *testing.T) {363 m := newModel()364 press(t, m, "!")365 if m.mode != modeEnter || m.line.Text() != "!" {366 t.Fatalf("on a sheet, ! starts an entry; mode %v, entry %q", m.mode, m.line.Text())367 }368 press(t, m, "<esc>")369 run(m, m.runCommand("nb.open"))370 press(t, m, "!")371 if !m.nbView().Editing() || len(m.sheet.NotebookCells()) != 1 {372 t.Errorf("! on a notebook adds a cell to edit")373 }374 if got := line(m, m.height-1); !strings.Contains(got, "❯ Notebook") {375 t.Errorf("the tab: %q", got)376 }377}378379// boxText is the text inside a code cell's box on screen line l, or "".380func boxText(l string) string {381 rs := []rune(l)382 const at = 10 // the box's left side, after the bar and the prompt383 if len(rs) <= at || rs[at] != '│' && rs[at] != '┃' {384 return ""385 }386 rest := string(rs[at+1:])387 if j := strings.LastIndexAny(rest, "│┃"); j >= 0 {388 rest = rest[:j]389 }390 return strings.TrimSpace(rest)391}