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 / mcp / books_test.go
books_test.go 6.1K
1package mcp23import (4 "context"5 "errors"6 "os"7 "path/filepath"8 "slices"9 "strings"10 "testing"1112 sdk "github.com/modelcontextprotocol/go-sdk/mcp"1314 "src.rest/cat/012/internal/confine"15 "src.rest/cat/012/internal/headless"16)1718// folder makes a folder of workbooks: book.012 and sub/data.csv, a19// hidden .secret.012, a note that isn't a workbook, and a link leading20// out to another folder's workbook.21func folder(t *testing.T) (dir, outsideBook string) {22 t.Helper()23 dir = t.TempDir()24 out := t.TempDir()25 outsideBook = filepath.Join(out, "other.012")26 for _, p := range []string{"book.012", ".secret.012", outsideBook} {27 if !filepath.IsAbs(p) {28 p = filepath.Join(dir, p)29 }30 data, _ := os.ReadFile(bookFile(t))31 if err := os.WriteFile(p, data, 0o644); err != nil {32 t.Fatal(err)33 }34 }35 if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil {36 t.Fatal(err)37 }38 files := map[string]string{"sub/data.csv": "Name,Score\nAda,3\nBo,5\n", "notes.txt": "hello"}39 for name, text := range files {40 if err := os.WriteFile(filepath.Join(dir, name), []byte(text), 0o644); err != nil {41 t.Fatal(err)42 }43 }44 if err := os.Symlink(outsideBook, filepath.Join(dir, "link.012")); err != nil {45 t.Fatal(err)46 }47 return dir, outsideBook48}4950func rootsFor(t *testing.T, dirs ...string) []confine.Root {51 t.Helper()52 roots, err := NewRoots(dirs)53 if err != nil {54 t.Fatal(err)55 }56 return roots57}5859func TestResolve(t *testing.T) {60 dir, outsideBook := folder(t)61 roots := rootsFor(t, dir)62 real := roots[0].Dir()63 for name, want := range map[string]string{64 "book.012": real + "/book.012",65 "sub/data.csv": real + "/sub/data.csv",66 filepath.Join(dir, "book.012"): real + "/book.012",67 "file://" + filepath.Join(dir, "x.012"): real + "/x.012",68 "new.012": real + "/new.012",69 } {70 if got, err := resolve(roots, name); err != nil || got != filepath.FromSlash(want) {71 t.Errorf("%s: %s, %v; want %s", name, got, err, want)72 }73 }74 for _, name := range []string{"../x.012", outsideBook, ".secret.012", "link.012", "/etc/passwd", filepath.Join(dir, "..", "x.012")} {75 if got, err := resolve(roots, name); !errors.Is(err, confine.ErrOutside) {76 t.Errorf("%s: %s, %v; want it refused", name, got, err)77 }78 }79 // A relative path is looked for in each root in turn.80 other := filepath.Dir(outsideBook)81 if got, err := resolve(rootsFor(t, dir, other), "other.012"); err != nil || filepath.Base(got) != "other.012" {82 t.Errorf("second root: %s, %v", got, err)83 }84}8586func TestDescribeListsWorkbooks(t *testing.T) {87 dir, _ := folder(t)88 cs := connect(t, Options{Roots: rootsFor(t, dir)})89 var d describeOut90 call(t, cs, "describe", map[string]any{}, &d)91 var paths []string92 for _, f := range d.Workbooks {93 paths = append(paths, f.Path+" "+f.Format)94 }95 slices.Sort(paths)96 if !slices.Equal(paths, []string{"book.012 012", "sub/data.csv CSV"}) || len(d.Roots) != 1 {97 t.Errorf("describe without a path: %v in %v", paths, d.Roots)98 }99 call(t, cs, "describe", map[string]any{"path": "sub/data.csv"}, &d)100 if d.Path != "sub/data.csv" || d.Description == nil || d.Sheets[0].Name != "data" || d.Sheets[0].Columns[1] != "Score" {101 t.Errorf("describe a CSV: %+v", d)102 }103 for tl, err := range cs.Tools(context.Background(), nil) {104 if err != nil {105 t.Fatal(err)106 }107 list, _ := tl.InputSchema.(map[string]any)["required"].([]any)108 required := slices.Contains(list, any("path"))109 if tl.Name != "describe" && !required {110 t.Errorf("%s doesn't require path", tl.Name)111 }112 }113 if res := call(t, cs, "read_range", map[string]any{"path": "link.012", "ref": "A1"}, nil); !strings.Contains(errorText(res), "outside") {114 t.Errorf("a link leading out: %q", errorText(res))115 }116 if res := call(t, cs, "read_range", map[string]any{"path": "notes.txt", "ref": "A1"}, nil); !strings.Contains(errorText(res), "isn't a workbook") {117 t.Errorf("a text file: %q", errorText(res))118 }119 var r RangeRead120 call(t, cs, "read_range", map[string]any{"path": filepath.Join(dir, "book.012"), "ref": "B2"}, &r)121 if string(r.Values[0][0]) != "3" {122 t.Errorf("absolute path: %+v", r)123 }124}125126func TestCreateWorkbook(t *testing.T) {127 dir, _ := folder(t)128 cs := connect(t, Options{Roots: rootsFor(t, dir)})129 var d describeOut130 call(t, cs, "create_workbook", map[string]any{"path": "empty.012"}, &d)131 if d.Path != "empty.012" || len(d.Sheets) != 1 {132 t.Errorf("empty: %+v", d)133 }134 if _, err := headless.Open(filepath.Join(dir, "empty.012"), false); err != nil {135 t.Errorf("not saved: %v", err)136 }137 call(t, cs, "create_workbook", map[string]any{"path": "sub/scores.012", "from": "sub/data.csv"}, &d)138 if d.Sheets[0].Used != "A1:B3" {139 t.Errorf("from a CSV: %+v", d.Sheets)140 }141 for args, want := range map[[2]string]string{142 {"book.012", ""}: "exists",143 {"x.csv", ""}: "ends in .012",144 {"../x.012", ""}: "outside",145 {"y.012", "notes.txt"}: "isn't a file 012 imports",146 } {147 res := call(t, cs, "create_workbook", map[string]any{"path": args[0], "from": args[1]}, nil)148 if !strings.Contains(errorText(res), want) {149 t.Errorf("%v: %q, want %s", args, errorText(res), want)150 }151 }152 res := call(t, cs, "write_cells", map[string]any{"path": "sub/data.csv", "entries": []map[string]string{{"ref": "A1", "input": "x"}}}, nil)153 if !strings.Contains(errorText(res), "read only") {154 t.Errorf("writing a CSV: %q", errorText(res))155 }156 var ch Changed157 call(t, cs, "write_cells", map[string]any{"path": "sub/scores.012", "entries": []map[string]string{{"ref": "C1", "input": "Rank"}}}, &ch)158 if !ch.Saved {159 t.Errorf("writing the new workbook: %+v", ch)160 }161}162163func TestPathRequiredWithoutDefault(t *testing.T) {164 dir, _ := folder(t)165 cs := connect(t, Options{Roots: rootsFor(t, dir)})166 res, err := cs.CallTool(context.Background(), &sdk.CallToolParams{Name: "read_range", Arguments: map[string]any{"ref": "A1"}})167 if err == nil && !res.IsError {168 t.Error("read_range without a path")169 }170 // With a default, it's optional and names the default.171 cs = connect(t, Options{Default: filepath.Join(dir, "book.012")})172 var r RangeRead173 call(t, cs, "read_range", map[string]any{"ref": "B2"}, &r)174 if string(r.Values[0][0]) != "3" {175 t.Errorf("the default: %+v", r)176 }177}