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 / nbview / nu_test.go
nu_test.go 11.8K
1package nbview23import (4 "context"5 "fmt"6 "io"7 "os"8 "os/exec"9 "path/filepath"10 "slices"11 "strings"12 "sync"13 "testing"14 "time"1516 tea "charm.land/bubbletea/v2"1718 "src.rest/cat/012/internal/nushell"19 "src.rest/cat/012/internal/ui/theme"20)2122// testdata/nu holds what nu 0.116 said (the .json and .jsonl files)23// about the text 012 asks it about a cell (the .nu beside each: the24// prelude, then the source masked), so these tests pin both the text25// asked and the answers read.2627// fakeRunner answers nu's IDE questions from recorded answers, by the28// flag and the text asked about, and notes each question.29type fakeRunner struct {30 mu sync.Mutex31 version string32 answers map[string]string // flags, a line, the text asked about → answer33 err error // every question fails so34 block bool // questions wait until they're stopped35 asked []string36}3738func (f *fakeRunner) Run(ctx context.Context, job nushell.Job, script string, stdout io.Writer) error {39 f.mu.Lock()40 f.asked = append(f.asked, job.IDE[0])41 f.mu.Unlock()42 switch {43 case f.err != nil:44 return f.err45 case job.IDE[0] == "--version":46 _, err := io.WriteString(stdout, f.version)47 return err48 case f.block:49 <-ctx.Done()50 return context.Cause(ctx)51 }52 out, ok := f.answers[asked(job.IDE, script)]53 if !ok {54 return &nushell.Error{Msg: "not recorded: " + script}55 }56 _, err := io.WriteString(stdout, out)57 return err58}5960// asked is how fakeRunner files a question.61func asked(flags []string, script string) string { return strings.Join(flags, " ") + "\n" + script }6263func (f *fakeRunner) questions() []string {64 f.mu.Lock()65 defer f.mu.Unlock()66 return slices.Clone(f.asked)67}6869// recording is a question to nu and its answer, as testdata/nu keeps70// them.71type recording struct {72 flags []string73 script, answer string74}7576// recordings are testdata/nu's questions and answers.77func recordings(t *testing.T) []recording {78 t.Helper()79 read := func(name string) string {80 data, err := os.ReadFile(filepath.Join("testdata", "nu", name))81 if err != nil {82 t.Fatal(err)83 }84 return string(data)85 }86 var out []recording87 for _, r := range []struct{ name, answer, flag string }{88 {"highlight", "highlight.json", "--ide-ast"},89 {"sheet", "sheet.json", "--ide-ast"},90 {"check", "check.jsonl", "--ide-check"},91 {"complete", "complete.json", "--ide-complete"},92 } {93 script := read(r.name + ".nu")94 flags := []string{r.flag}95 switch r.flag {96 case "--ide-check":97 flags = append(flags, fmt.Sprint(maxProblems))98 case "--ide-complete":99 flags = append(flags, fmt.Sprint(len(script))) // the caret at the end100 }101 out = append(out, recording{flags: flags, script: script, answer: read(r.answer)})102 }103 script := read("hover.nu")104 for _, at := range hoverAsked {105 out = append(out, recording{flags: []string{"--ide-hover", fmt.Sprint(at)}, script: script, answer: read(fmt.Sprintf("hover-%d.json", at))})106 }107 return out108}109110// hoverAsked are the bytes of hover.nu nu's hover is recorded at: the111// sort-by, -r, into string, --decimals and $n of hoverSrc.112var hoverAsked = []int{83, 96, 101, 113, 135}113114// hoverSrc is the cell hover.nu asks about.115const hoverSrc = "let n = 4\n$files | sort-by size -r | into string --decimals 2 | append $n"116117// recordedNu is a fake nu answering what testdata/nu records.118func recordedNu(t *testing.T) *fakeRunner {119 t.Helper()120 f := &fakeRunner{version: "0.116.0\n", answers: map[string]string{}}121 for _, r := range recordings(t) {122 f.answers[asked(r.flags, r.script)] = r.answer123 }124 return f125}126127// nuFor is a notebook's Nu over r, allowed, knowing the cell $files.128func nuFor(r nushell.Runner) (*NuSession, *Nu) {129 s := NewNuSession(r)130 s.SetOn(true)131 n := s.For()132 n.SetWords([]string{"files"}, []Word{{Text: "$files", Desc: "a cell's output"}, {Text: "sort-by", Desc: "command"}})133 return s, n134}135136var roleNames = [...]string{"command", "string", "variable", "number", "keyword", "operator", "comment"}137138func describe(src string, spans []Span) []string {139 roles := rolesOf(src, spans)140 var out []string141 rs := []rune(src)142 for i := 0; i < len(rs); {143 j := i + 1144 for j < len(rs) && roles[j] == roles[i] {145 j++146 }147 if roles[i] >= 0 && strings.TrimSpace(string(rs[i:j])) != "" {148 out = append(out, strings.TrimSpace(string(rs[i:j]))+":"+roleNames[roles[i]])149 }150 i = j151 }152 return out153}154155func TestNuHighlights(t *testing.T) {156 _, n := nuFor(recordedNu(t))157 src := "big = $files | where size > 1kb | sort-by size --reverse # largest"158 got := describe(src, n.Highlight(context.Background(), src))159 want := []string{"big:variable", "=:operator", "$files:variable", "|:operator", "where:command", "size:variable", ">:operator",160 "1kb:number", "|:operator", "sort-by:command", "size:string", "# largest:comment"}161 if !slices.Equal(got, want) {162 t.Errorf("roles\n got %q\nwant %q", got, want)163 }164 src = "$sheet.A1:C9 | append $selection | first 2"165 got = describe(src, n.Highlight(context.Background(), src))166 want = []string{"$sheet.A1:C9:variable", "|:operator", "append:command", "$selection:variable", "|:operator", "first:command", "2:number"}167 if !slices.Equal(got, want) {168 t.Errorf("a range of a sheet\n got %q\nwant %q", got, want)169 }170}171172func TestNuChecks(t *testing.T) {173 _, n := nuFor(recordedNu(t))174 src := "$files | sort-by size --revrse | append $nope"175 var got []string176 for _, d := range n.Check(context.Background(), src) {177 got = append(got, src[d.From:d.To]+": "+d.Msg)178 }179 want := []string{"--revrse: The `sort-by` command doesn't have flag `revrse`.", "$nope: Variable not found."}180 if !slices.Equal(got, want) {181 t.Errorf("problems\n got %q\nwant %q", got, want)182 }183}184185func TestNuCompletes(t *testing.T) {186 _, n := nuFor(recordedNu(t))187 src := "$files | so"188 var got []string189 for _, c := range n.Complete(context.Background(), src, len(src)) {190 got = append(got, fmt.Sprintf("%s %d-%d", c.Text, c.From, c.To))191 }192 want := []string{"sort-by 9-11", "sort 9-11", "source 9-11", "source-env 9-11"}193 if !slices.Equal(got, want) {194 t.Errorf("completions\n got %q\nwant %q", got, want)195 }196}197198func TestReplaced(t *testing.T) {199 for _, c := range []struct {200 src, w string201 want int202 }{203 {"ls | str jo", "str join", 5},204 {"$env.PA", "PATH", 5},205 {"ls | sort-by size -", "--reverse", 18},206 {"ls ~/Dev", "~/Developer/", 3},207 {"ls ", "a.txt", 3},208 {"open 'my fi", "`my file.txt`", 5},209 } {210 if got := replaced(c.src, len(c.src), c.w); got != c.want {211 t.Errorf("replaced(%q, %q) = %d, want %d", c.src, c.w, got, c.want)212 }213 }214}215216// nu missing, too old, slow, or not allowed: the built-ins answer, and217// nu isn't asked again.218func TestNuFallsBack(t *testing.T) {219 src := "ls | where size > 1kb # big"220 builtin := describe(src, Tokens{}.Highlight(context.Background(), src))221 for name, f := range map[string]*fakeRunner{222 "missing": {err: nushell.ErrMissing},223 "old": {version: "0.99.0"},224 "slow": {version: "0.116.0", block: true},225 } {226 s, n := nuFor(f)227 s.Timeout = 20 * time.Millisecond228 for range slowAfter {229 if got := describe(src, n.Highlight(context.Background(), src)); !slices.Equal(got, builtin) {230 t.Errorf("%s: roles %q", name, got)231 }232 if d := n.Check(context.Background(), src); d != nil {233 t.Errorf("%s: problems %v", name, d)234 }235 }236 if !n.Instant() {237 t.Errorf("%s: nu is still asked", name)238 }239 asked := len(f.questions())240 n.Highlight(context.Background(), src)241 if _, ok := n.Hover(context.Background(), src, 6); ok {242 t.Errorf("%s: a hover", name)243 }244 if got := n.Complete(context.Background(), "$fi", 3); len(got) != 1 || got[0].Text != "$files" || len(f.questions()) != asked {245 t.Errorf("%s: completions %v, asked %v", name, got, f.questions())246 }247 }248 f := recordedNu(t)249 s, n := nuFor(f)250 s.SetOn(false) // 012 serve without serve-shell, or a file not trusted251 n.Highlight(context.Background(), src)252 n.Check(context.Background(), src)253 n.Complete(context.Background(), src, 2)254 n.Hover(context.Background(), src, 6)255 if q := f.questions(); len(q) != 0 {256 t.Errorf("asked nu while it isn't allowed: %v", q)257 }258}259260// drain runs cmd and what it batches, returning their messages.261func drain(cmd tea.Cmd) []tea.Msg {262 if cmd == nil {263 return nil264 }265 switch msg := cmd().(type) {266 case tea.BatchMsg:267 var out []tea.Msg268 for _, c := range msg {269 out = append(out, drain(c)...)270 }271 return out272 case nil:273 return nil274 default:275 return []tea.Msg{msg}276 }277}278279// Typing asks nu nothing; once typing pauses, nu is asked about the280// text as it is then, and its answers draw: the roles, the underline281// and, with the caret on it, the problem on the context line.282func TestEditAsksNuOncePaused(t *testing.T) {283 f := recordedNu(t)284 h := newHost("")285 v := newView(h, 80, 20)286 _, n := nuFor(f)287 v.Providers = Providers{Highlighter: n, Completer: n, Checker: n}288 v.StartEdit()289 src := "$files | sort-by size --revrse | append $nope"290 for _, r := range src {291 v.Key(key(string(r)))292 }293 text(v)294 if q := f.questions(); len(q) != 0 {295 t.Fatalf("typing asked nu: %v", q)296 }297 if cmd, _ := v.Update(pausedMsg{view: v, version: v.edit.version - 1}); cmd != nil {298 t.Error("a pause at an older text asked nu")299 }300 cmd, _ := v.Update(pausedMsg{view: v, version: v.edit.version})301 for _, msg := range drain(cmd) {302 v.Update(msg)303 }304 if v.edit.diagFor != src || len(v.edit.diags) != 2 {305 t.Fatalf("problems %v about %q", v.edit.diags, v.edit.diagFor)306 }307 if got := v.Diagnostic(); got != "Variable not found." {308 t.Errorf("the caret after $nope: %q", got)309 }310 v.Key(key("home"))311 if got := v.Diagnostic(); got != "" {312 t.Errorf("the caret on no problem: %q", got)313 }314 line := strings.Split(text(v), "\n")[1]315 if !strings.Contains(line, "--revrse") {316 t.Errorf("line %q", line)317 }318 marks := v.edit.marks()319 if i := strings.Index(src, "--revrse"); !marks[i] || marks[i-1] {320 t.Errorf("the underline: %v", marks)321 }322}323324// A question out when the text changes is stopped, and its answer325// dropped.326func TestStaleAnswersDropped(t *testing.T) {327 f := &fakeRunner{version: "0.116.0", block: true}328 h := newHost("ls")329 v := newView(h, 80, 20)330 _, n := nuFor(f)331 v.Providers = Providers{Highlighter: n, Completer: n, Checker: n}332 v.StartEdit()333 cmd, _ := v.Update(pausedMsg{view: v, version: v.edit.version})334 v.Key(key("x")) // stops the questions out335 done := make(chan []tea.Msg)336 go func() { done <- drain(cmd) }()337 select {338 case msgs := <-done:339 if len(msgs) != 0 {340 t.Errorf("stale answers: %v", msgs)341 }342 case <-time.After(5 * time.Second):343 t.Fatal("the questions weren't stopped")344 }345}346347// Between keys, the text being edited keeps nu's roles where it's348// unchanged, the tokenizer's only where it changed.349func TestEditKeepsRolesBetweenKeys(t *testing.T) {350 h := newHost("big = $files | where size > 1kb | sort-by size --reverse # largest")351 v := newView(h, 80, 20)352 _, n := nuFor(recordedNu(t))353 v.Providers = Providers{Highlighter: n, Completer: n, Checker: n}354 v.StartEdit()355 cmd, _ := v.Update(pausedMsg{view: v, version: v.edit.version})356 for _, msg := range drain(cmd) {357 v.Update(msg)358 }359 answered := v.spansFor(v.edit.text(), h.cells[0].Kind, true)360 v.Key(key("x"))361 now := v.spansFor(v.edit.text(), h.cells[0].Kind, true)362 if !slices.Equal(now[:len(answered)], answered) || now[len(answered)] != int8(theme.SyntaxComment) {363 t.Errorf("roles\n was %v\n now %v", answered, now)364 }365}366367// The real nu, when it's the version recorded, says what testdata/nu368// records about the same text.369func TestRecordedAnswersAreNus(t *testing.T) {370 if _, err := exec.LookPath("nu"); err != nil {371 t.Skip("nu isn't installed")372 }373 if v, err := nushell.Version(context.Background(), nushell.Nu{}); err != nil || v != "0.116.0" {374 t.Skipf("nu %s %v isn't the version recorded", v, err)375 }376 for _, r := range recordings(t) {377 var b strings.Builder378 if err := (nushell.Nu{}).Run(context.Background(), nushell.Job{IDE: r.flags}, r.script, &b); err != nil {379 t.Fatal(err)380 }381 if strings.TrimSpace(b.String()) != strings.TrimSpace(r.answer) {382 t.Errorf("nu %v now says\n%s\nrecorded\n%s", r.flags, b.String(), r.answer)383 }384 }385}