1package mcp23import (4 "encoding/json"5 "os/exec"6 "path/filepath"7 "strings"8 "testing"9 "time"1011 "src.rest/cat/012/internal/headless"12 "src.rest/cat/012/internal/notebook"13 "src.rest/cat/012/internal/nushell"14 "src.rest/cat/012/internal/sheet"15)1617// groceries are rows of every type, as a model writes them, in records18// and as a list in columns' order.19var groceries = []json.RawMessage{20 json.RawMessage(`{"Item": "Tea", "Price": {"currency": 3.5}, "Share": {"percent": 0.12}, "Bought": {"date": "2026-09-29"}, "Steep": {"duration": "4min"}, "Size": {"size": 1500}, "Code": "00123"}`),21 json.RawMessage(`["Cake", {"currency": 2}, {"percent": 0.5}, {"date": "2026-09-28"}, {"duration": "90sec"}, {"size": "250kb"}, "7"]`),22}2324// columns order the table: the SDK hands a tool a record's fields in25// another order than written.26var columns = []string{"Item", "Price", "Share", "Bought", "Steep", "Size", "Code"}2728// wantValues are groceries as read_range reads them back, header first.29const wantValues = `[["Item","Price","Share","Bought","Steep","Size","Code"],` +30 `["Tea",{"currency":3.5},{"percent":0.12},{"date":"2026-09-29"},{"duration":"4min"},{"size":1500},"00123"],` +31 `["Cake",{"currency":2},{"percent":0.5},{"date":"2026-09-28"},{"duration":"90sec"},{"size":250000},"7"]]`3233// Values written with their types read back as the same values, shown34// as their formats show them, and keep their formats in the file.35func TestTypedValuesRoundTrip(t *testing.T) {36 dir := t.TempDir()37 path := filepath.Join(dir, "groceries.012")38 cs := connect(t, Options{Roots: rootsFor(t, dir)})39 if res := call(t, cs, "create_workbook", map[string]any{"path": path, "columns": columns, "data": groceries}, nil); res.IsError {40 t.Fatal(errorText(res))41 }42 var r headless.Range43 call(t, cs, "read_range", map[string]any{"path": path, "ref": "A1:G3"}, &r)44 if got, _ := json.Marshal(r.Values); string(got) != wantValues {45 t.Errorf("values:\n%s\nwant\n%s", got, wantValues)46 }47 if got := strings.Join(r.Text[1], "|"); got != "Tea|$3.50|12%|9/29/2026|0:04:00|1.5 kB|00123" {48 t.Errorf("text: %s", got)49 }50 var changed Changed51 call(t, cs, "write_cells", map[string]any{"path": path, "entries": []map[string]any{52 {"ref": "B4", "value": map[string]any{"currency": 4.25, "symbol": "€"}},53 {"ref": "D4", "input": "2026-10-01"},54 {"ref": "A4", "value": "0042"},55 {"ref": "B2:B3", "format": "$#,##0"},56 }}, &changed)57 if !changed.Saved {58 t.Fatalf("write_cells: %+v", changed)59 }60 call(t, cs, "read_range", map[string]any{"path": path, "ref": "A2:D4"}, &r)61 want := `[["Tea",{"currency":3.5,"decimals":0},{"percent":0.12},{"date":"2026-09-29"}],` +62 `["Cake",{"currency":2,"decimals":0},{"percent":0.5},{"date":"2026-09-28"}],` +63 `["0042",{"currency":4.25,"symbol":"€"},null,{"date":"2026-10-01","format":"yyyy-mm-dd"}]]`64 if got, _ := json.Marshal(r.Values); string(got) != want {65 t.Errorf("after write_cells:\n%s\nwant\n%s", got, want)66 }67 f, err := headless.Open(path, false)68 if err != nil {69 t.Fatal(err)70 }71 s := f.Book.Sheet(0)72 for a, k := range map[string]sheet.FormatKind{"B3": sheet.FmtCurrency, "C2": sheet.FmtPercent, "D2": sheet.FmtDate, "E2": sheet.FmtDuration, "F2": sheet.FmtSize} {73 at, _ := sheet.ParseAddr(a)74 if got := s.DisplayFormat(at).Kind; got != k {75 t.Errorf("%s is formatted %v in the file, want %v", a, got, k)76 }77 }78}7980// Values written through MCP go to nushell with their types as a81// notebook cell reads them ($sheet), and its output sent back to a82// sheet keeps the formats: currency, percentages, dates, durations and83// sizes.84func TestTypedValuesThroughNotebook(t *testing.T) {85 if _, err := exec.LookPath("nu"); err != nil {86 t.Skip("nu isn't installed")87 }88 w := sheet.NewBook()89 out, _ := w.AddSheet("Out", 1)90 nb, _ := w.AddNotebook("Notes", out)91 nb.SetNotebookCells("add cell", []notebook.Cell{{Kind: notebook.Code,92 Source: "dear = $sheet.Sheet1!A1:G3 | where Price > 3 | insert Kind {|r| $r.Size | describe} | insert Took {|r| $r.Steep | describe}"}})93 if err := out.AddRegion(sheet.Region{Name: "dear", At: sheet.Addr{}, Output: true}); err != nil {94 t.Fatal(err)95 }96 w.SetActive(w.Sheet(0))97 path := filepath.Join(t.TempDir(), "nb.012")98 writeBook(t, w, path)99 cs := connect(t, Options{Default: path, Notebooks: &headless.NotebookOptions{Runner: nushell.Nu{}, Timeout: 30 * time.Second}})100 call(t, cs, "write_table", map[string]any{"at": "Sheet1!A1", "columns": columns, "rows": groceries}, nil)101 var run headless.CellRun102 call(t, cs, "run_notebook_cell", map[string]any{"notebook": "Notes", "cell": 1}, &run)103 if run.State != "ran" {104 t.Fatalf("run: %+v", run)105 }106 var r headless.Range107 call(t, cs, "read_range", map[string]any{"ref": "Out!A1:I2"}, &r)108 want := `[["Item","Price","Share","Bought","Steep","Size","Code","Kind","Took"],` +109 `["Tea",{"currency":3.5},{"percent":0.12},{"date":"2026-09-29"},{"duration":"4min"},{"size":1500},"00123","filesize","duration"]]`110 if got, _ := json.Marshal(r.Values); string(got) != want {111 t.Errorf("the output sent to Out:\n%s\nwant\n%s\n%s", got, want, run.Output)112 }113}