1package nbview23import (4 "context"5 "fmt"6 "strings"7 "testing"8 "time"910 tea "charm.land/bubbletea/v2"11 "github.com/charmbracelet/x/ansi"1213 "src.rest/cat/012/internal/fileio"14 "src.rest/cat/012/internal/locale"15 "src.rest/cat/012/internal/notebook"16 "src.rest/cat/012/internal/nuon"17 "src.rest/cat/012/internal/ui/theme"18)1920// fakeHost is a notebook of cells and outputs; commands run are noted.21type fakeHost struct {22 th theme.Theme23 cells []notebook.Cell24 outs map[int]*notebook.Output25 states map[int]State26 ran []string27 edits []string28 onRun func(id string)29 kernel Kernel30 grids []*fakeGrid31}3233func newHost(srcs ...string) *fakeHost {34 h := &fakeHost{th: theme.New(true), outs: map[int]*notebook.Output{}, states: map[int]State{}}35 for i, s := range srcs {36 h.cells = append(h.cells, notebook.Cell{ID: i + 1, Source: s})37 }38 return h39}4041func (h *fakeHost) Theme() *theme.Theme { return &h.th }42func (h *fakeHost) Locale() *locale.Locale { return locale.Canonical }43func (h *fakeHost) Cells() []notebook.Cell { return h.cells }44func (h *fakeHost) Output(id int) *notebook.Output { return h.outs[id] }45func (h *fakeHost) State(id int) State { return h.states[id] }46func (h *fakeHost) Kernel() Kernel { return h.kernel }47func (h *fakeHost) Run(id string) tea.Cmd {48 h.ran = append(h.ran, id)49 if h.onRun != nil {50 h.onRun(id)51 }52 return nil53}54func (h *fakeHost) Edit(id int, src string) {55 h.edits = append(h.edits, src)56 for i := range h.cells {57 if h.cells[i].ID == id {58 h.cells[i].Source = src59 }60 }61}6263// Grid is a fake grid: its column names, then its rows' values, spaced.64func (h *fakeHost) Grid(id int, data []byte, rows int) Grid {65 g := &fakeGrid{}66 g.Append(data, rows, data)67 h.grids = append(h.grids, g)68 return g69}7071// Append adds the table's rows.72func (g *fakeGrid) Append(more []byte, _ int, _ []byte) {73 v, err := nuon.Parse(more)74 if err != nil {75 panic(err)76 }77 for _, rec := range v.List {78 var row []string79 for i, f := range rec.Fields {80 if len(g.rows) == 0 && i >= len(g.cols) {81 g.cols = append(g.cols, f.Key)82 }83 row = append(row, cellText(fileio.NUONCell(f.Value), locale.Canonical))84 }85 g.rows = append(g.rows, strings.Join(row, " "))86 }87}8889type fakeGrid struct {90 cols, rows []string91 top, win int92 in bool93}9495func (g *fakeGrid) Rows() int { return len(g.rows) }96func (g *fakeGrid) Top() int { return g.top }97func (g *fakeGrid) Entered() bool { return g.in }98func (g *fakeGrid) Hidden(int) int {99 return 0100}101102func (g *fakeGrid) Scroll(d int) bool {103 to := min(max(g.top+d, 0), max(len(g.rows)-g.win, 0))104 moved := to != g.top105 g.top = to106 return moved107}108109func (g *fakeGrid) Line(i, from, rows, width int) string {110 g.win = rows111 if i == 0 {112 return strings.Join(g.cols, " ")113 }114 if from+i-1 < len(g.rows) {115 return fmt.Sprintf("%d %s", from+i, g.rows[from+i-1])116 }117 return ""118}119120func newView(h *fakeHost, w, ht int) *View {121 v := New(h)122 v.Keys = map[string]string{"enter": "nb.edit", "d d": "nb.delete", "o": "nb.toggle_output", "shift+enter": "nb.run_next"}123 v.EditKeys = map[string]string{"esc": "nb.command_mode", "shift+enter": "nb.run_next"}124 v.Providers = Providers{Highlighter: Tokens{}}125 v.After = func(_ time.Duration, msg tea.Msg) tea.Cmd { return func() tea.Msg { return paused{msg} } }126 v.Resize(w, ht)127 return v128}129130// paused is a message the view asked for once typing pauses: no test131// waits for the pause; settle ends it at once, and the others never.132type paused struct{ msg tea.Msg }133134func text(v *View) string { return ansi.Strip(strings.Join(v.Lines(), "\n")) }135136func key(s string) tea.KeyPressMsg {137 named := map[string]tea.Key{"enter": {Code: tea.KeyEnter}, "esc": {Code: tea.KeyEscape}, "up": {Code: tea.KeyUp},138 "down": {Code: tea.KeyDown}, "tab": {Code: tea.KeyTab}, "home": {Code: tea.KeyHome}, "end": {Code: tea.KeyEnd},139 "left": {Code: tea.KeyLeft}, "right": {Code: tea.KeyRight}}140 if k, ok := named[s]; ok {141 return tea.KeyPressMsg(k)142 }143 return tea.KeyPressMsg{Code: rune(s[0]), Text: s}144}145146func TestOutputsShow(t *testing.T) {147 h := newHost("files = ls", "{a: 1}", `"x"`, "nope", "big", "[1 2]")148 h.outs[1] = ¬ebook.Output{NUON: []byte("[[name, size, ok]; [a.txt, 2kb, true], [bb.txt, 10b, false]]"), Count: 1}149 h.outs[2] = ¬ebook.Output{NUON: []byte("{a: 1, long_key: \"two words\"}"), Count: 2}150 h.outs[3] = ¬ebook.Output{NUON: []byte(`"line one\nline two"`), Count: 3}151 h.outs[4] = ¬ebook.Output{Err: "Command `nope` not found", Detail: "help: try ls", Count: 4}152 h.outs[5] = ¬ebook.Output{Unsaved: true}153 h.outs[6] = ¬ebook.Output{NUON: []byte("[1, 2]"), Count: 5}154 v := newView(h, 80, 60)155 got := text(v)156 for _, want := range []string{157 "[1]:", "─ files ─", "name size ok", "1 a.txt 2.0 kB TRUE", "2 bb.txt 10 B FALSE",158 "field value", "1 a 1", "2 long_key two words", "line one", "line two",159 "× Command `nope` not found", "help: try ls", "not saved; run to see", "0 1", "1 2",160 } {161 if !strings.Contains(got, want) {162 t.Errorf("lacks %q:\n%s", want, got)163 }164 }165}166167func TestLongOutputWindow(t *testing.T) {168 h := newHost("ls")169 var b strings.Builder170 b.WriteString("[[n]; ")171 for i := range 10000 {172 fmt.Fprintf(&b, "[%d], ", i)173 }174 b.WriteString("[x]]")175 h.outs[1] = ¬ebook.Output{NUON: []byte(b.String()), Count: 1}176 v := newView(h, 60, 30)177 if got := text(v); !strings.Contains(got, "9,991 more rows") || strings.Contains(got, " 10\n") {178 t.Errorf("collapsed:\n%s", got)179 }180 v.ToggleWhole()181 v.Wheel(-1, 100)182 if got := text(v); strings.Contains(got, "more rows") || !strings.Contains(got, " 98\n") {183 t.Errorf("expanded, scrolled:\n%s", got)184 }185}186187func TestMovesBetweenCellsAndOutputs(t *testing.T) {188 h := newHost("a", "b", "c")189 h.outs[2] = ¬ebook.Output{NUON: []byte("1"), Count: 1}190 v := newView(h, 80, 30)191 steps := []struct {192 key string193 sel int194 out bool195 }{{"down", 1, false}, {"j", 1, true}, {"down", 2, false}, {"up", 1, true}, {"k", 1, false}, {"end", 2, false}, {"home", 0, false}}196 for _, s := range steps {197 v.Key(key(s.key))198 if i, out := v.Selected(); i != s.sel || out != s.out {199 t.Fatalf("after %s: %d %v, want %d %v", s.key, i, out, s.sel, s.out)200 }201 }202 // A pair: d d runs Delete; d then another key doesn't.203 v.Key(key("d"))204 if v.Pending() != "d" || len(h.ran) != 0 {205 t.Fatalf("d: pending %q ran %v", v.Pending(), h.ran)206 }207 v.Key(key("d"))208 if len(h.ran) != 1 || h.ran[0] != "nb.delete" {209 t.Errorf("d d ran %v", h.ran)210 }211 if _, ok := v.Key(key("q")); ok {212 t.Error("a key the notebook doesn't know was taken")213 }214}215216func TestEditsWithWrapAndCompletes(t *testing.T) {217 h := newHost("ls")218 v := newView(h, 38, 20)219 v.Providers.Completer = Words(func() []Word { return []Word{{Text: "$files", Desc: "cell"}, {Text: "sort-by", Desc: "command"}} })220 h.onRun = func(id string) {221 if id == "nb.command_mode" {222 v.StopEdit()223 }224 }225 v.StartEdit()226 if !v.Editing() {227 t.Fatal("not editing")228 }229 for _, r := range " | where size > 1kb | so" {230 v.Key(key(string(r)))231 }232 msg := v.askCompletions()()233 v.Update(msg)234 if got := v.edit.text(); got != "ls | where size > 1kb | sort-by" {235 t.Errorf("completed %q", got)236 }237 x, y, ok := v.Cursor()238 if !ok || y != 2 || x != textX+2+len("| sort-by") {239 t.Errorf("caret %d,%d %v:\n%s", x, y, ok, text(v))240 }241 lines := strings.Split(text(v), "\n")242 if !strings.Contains(lines[1], "[ ]: ┃ ls | where size > 1kb ┃ ▶") || !strings.Contains(lines[2], "┃ | sort-by") {243 t.Errorf("wrapped:\n%s", text(v))244 }245 v.Key(key("esc"))246 if v.Editing() || h.cells[0].Source != "ls | where size > 1kb | sort-by" {247 t.Errorf("after Esc: editing %v, source %q", v.Editing(), h.cells[0].Source)248 }249}250251func TestHighlights(t *testing.T) {252 spans := Tokens{}.Highlight(context.Background(), `x = ls | where size > 1kb and name =~ "a" # big`)253 var got []string254 src := `x = ls | where size > 1kb and name =~ "a" # big`255 for _, s := range spans {256 got = append(got, fmt.Sprintf("%s:%d", src[s.From:s.To], s.Kind))257 }258 want := fmt.Sprint([]string{"x:2", "=:5", "ls:0", "|:5", "where:0", ">:5", "1kb:3", "and:5", "=~:5", `"a":1`, "# big:6"})259 if fmt.Sprint(got) != want {260 t.Errorf("spans %v\nwant %v", got, want)261 }262}263264func TestMarkdown(t *testing.T) {265 th := theme.New(true)266 lines := markdown(&th, "# Title\nSome **bold** and *it* with `code` and [a link](https://x.y).\n- one\n1. first\n> quoted", 40)267 got := ansi.Strip(strings.Join(lines, "\n"))268 want := "Title\nSome bold and it with code and a link.\n• one\n1. first\n│ quoted"269 if got != want {270 t.Errorf("markdown:\n%s\nwant\n%s", got, want)271 }272 if !strings.Contains(strings.Join(lines, ""), "https://x.y") {273 t.Error("the link isn't a hyperlink")274 }275}276277// A table's output is the host's grid: its window, then full-screen at278// the body's size; text full-screen scrolls its lines.279func TestGridOutputs(t *testing.T) {280 h := newHost("ls", "text")281 h.outs[1] = ¬ebook.Output{NUON: []byte("[[name, n]; [b, 2], [a, 10], [c, 1]]"), Count: 1}282 h.outs[2] = ¬ebook.Output{NUON: []byte(`"one\ntwo"`), Count: 2}283 v := newView(h, 60, 12)284 if len(h.grids) != 1 || !strings.Contains(text(v), "name n\n") || !strings.Contains(text(v), "1 b 2") {285 t.Fatalf("the grid's window:\n%s", text(v))286 }287 v.Select(0, true)288 if v.SelectedGrid() != Grid(h.grids[0]) || !v.Shows(h.grids[0]) {289 t.Error("the output selected isn't its grid")290 }291 if g, gx, gy, ok := v.GridAt(textX+3, 4); !ok || g != Grid(h.grids[0]) || gx != 3 || gy != 1 {292 t.Errorf("GridAt: %v %d %d %v", g, gx, gy, ok)293 }294 if x, y, ok := v.GridOrigin(h.grids[0]); !ok || x != textX || y != 3 {295 t.Errorf("the grid starts at %d, %d", x, y)296 }297 h.grids[0].in = true298 if bar := v.Lines()[4]; !strings.Contains(bar, "▌") {299 t.Errorf("the grid entered has no bar: %q", bar)300 }301 v.OpenFull()302 if got := text(v); h.grids[0].win != 11 || !strings.HasPrefix(got, "name n\n1 b 2") {303 t.Errorf("full-screen, %d rows:\n%s", h.grids[0].win, got)304 }305 v.Key(key("esc"))306 v.Select(1, true)307 v.OpenFull()308 v.Key(key("down"))309 if got := text(v); !strings.Contains(got, " one\n▌two") {310 t.Errorf("text full-screen:\n%s", got)311 }312}