cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

main
012 / internal / fileio / nuon_test.go
nuon_test.go 11.1K
1package fileio23import (4 "bytes"5 "context"6 "os"7 "os/exec"8 "path/filepath"9 "strings"10 "testing"11 "time"1213 "src.rest/cat/012/internal/nuon"14 "src.rest/cat/012/internal/sheet"15)1617// inZone shows dates in loc for the test.18func inZone(t *testing.T, loc *time.Location) {19 t.Helper()20 old := zone21 zone = func() *time.Location { return loc }22 t.Cleanup(func() { zone = old })23}2425var mountain = time.FixedZone("MDT", -6*3600)2627func readNUON(t *testing.T, text string) *Result {28 t.Helper()29 res, err := ImportReader(context.Background(), "stdin", strings.NewReader(text), Options{})30 if err != nil {31 t.Fatalf("%s: %v", text, err)32 }33 return res34}3536func TestNUONCells(t *testing.T) {37 inZone(t, mountain)38 res := readNUON(t, `[[name, size, modified, took, ok, n, x, tags, meta, raw];39 ["CLAUDE.md", 1646b, 2026-09-27T17:27:31+00:00, 90sec, true, 1.5, "12", [a, b], {k: 1}, 0x[DEAD]],40 [go.mod, 2mb, 2026-01-02T03:04:05.250-06:00, 1500ms, false, -3, null, [], {}, 0x[]]]`)41 s := res.Sheet42 if res.Kind != NUON || s.Name() != "stdin" || res.Rows != 3 {43 t.Errorf("kind %v, sheet %q, rows %d", res.Kind, s.Name(), res.Rows)44 }45 for a, want := range map[string]string{46 "A1": "name", "B1": "size", "J1": "raw",47 "A2": "CLAUDE.md", "B2": "1.6 kB", "C2": "9/27/2026 11:27:31", "D2": "0:01:30", "E2": "TRUE", "F2": "1.5",48 "G2": "12", "H2": "[a, b]", "I2": "{k: 1}", "J2": "0x[DEAD]",49 "B3": "2.0 MB", "C3": "1/2/2026 3:04:05", "D3": "0:00:01.500", "E3": "FALSE", "F3": "-3", "G3": "", "H3": "[]",50 } {51 if got := shown(s, addr(t, a)); got != want {52 t.Errorf("%s shows %q, want %q", a, got, want)53 }54 }55 if v := s.Value(addr(t, "G2")); v.Kind != sheet.Text {56 t.Errorf("the string 12 became %v", v)57 }58 if f := s.CellFormat(addr(t, "B2")); f.Kind != sheet.FmtSize {59 t.Errorf("size format %+v", f)60 }61}6263// TestNUONColumnsFromRecords names columns as records bring them.64func TestNUONColumnsFromRecords(t *testing.T) {65 s := readNUON(t, `[{a: 1}, {b: 2, a: 3}] {c: x}`).Sheet66 for a, want := range map[string]string{"A1": "a", "B1": "b", "C1": "c", "A2": "1", "A3": "3", "B3": "2", "C4": "x"} {67 if got := shown(s, addr(t, a)); got != want {68 t.Errorf("%s shows %q, want %q", a, got, want)69 }70 }71}7273func TestImportReaderSniffs(t *testing.T) {74 for text, want := range map[string]Kind{75 `[{"a": 1}]`: JSON,76 "\n {\"a\": 1}\n": JSON,77 `[{a: 1}]`: NUON,78 `[[a]; [1]]`: NUON,79 "a,b\n1,2\n": CSV,80 "a\tb\n1\t2\n": TSV,81 "\xEF\xBB\xBF[1]": JSON,82 "": CSV,83 "name;qty\nx;1\n": CSV,84 } {85 if res := readNUON(t, text); res.Kind != want {86 t.Errorf("%q read as %v, want %v", text, res.Kind, want)87 }88 }89 if res := readNUON(t, "a\tb\n1\t2\n"); len(res.Notes) != 0 {90 t.Errorf("TSV notes %q", res.Notes)91 }92 if _, err := ImportReader(context.Background(), "stdin", strings.NewReader("[1, 2"), Options{}); err == nil {93 t.Error("an unterminated list read")94 }95}9697func TestNUONMaxCells(t *testing.T) {98 var b strings.Builder99 b.WriteString("[[a, b];")100 for i := range 10 {101 b.WriteString(" [" + string(rune('0'+i)) + ", x]")102 }103 b.WriteString("]")104 res, err := ImportReader(context.Background(), "stdin", strings.NewReader(b.String()), Options{MaxCells: 9})105 if err != nil {106 t.Fatal(err)107 }108 if n := res.Sheet.Len(); n != 8 {109 t.Errorf("%d cells kept, want the header and 3 rows", n)110 }111 if len(res.Notes) != 1 || !strings.Contains(res.Notes[0], "only the first 4 rows fit in max-cells (9 cells); 7 rows left out") {112 t.Errorf("notes %q", res.Notes)113 }114}115116// encode writes the whole sheet as k.117func encode(t *testing.T, s *sheet.Sheet, k Kind) string {118 t.Helper()119 var b bytes.Buffer120 if _, err := Encode(&b, k, Snap(s, sheet.Rect{}, s.Name())); err != nil {121 t.Fatal(err)122 }123 return b.String()124}125126func TestEncodeKeepsTypes(t *testing.T) {127 inZone(t, mountain)128 s := build(t, map[string]string{129 "A1": "name", "B1": "when", "C1": "n", "D1": "ok", "E1": "", "F1": "t",130 "A2": "a b", "B2": "9/27/2026 11:27:31", "C2": "1.5", "D2": "TRUE", "E2": "=1/0", "F2": "3:00 PM",131 "A3": "'12", "B3": "9/27/2026", "C3": "42", "F3": "[1, 2]",132 })133 s.SetFormat(sheet.Rect{From: addr(t, "C3"), To: addr(t, "C3")}, sheet.Preset(sheet.FmtSize))134 want := `[[name, when, n, ok, E, t]; ["a b", 2026-09-27T11:27:31-06:00, 1.5, true, "#DIV/0!", 54000000000000ns],135["12", 2026-09-27T00:00:00-06:00, 42b, null, null, [1, 2]]]136`137 if got := encode(t, s, NUON); got != want {138 t.Errorf("NUON:\n%s\nwant\n%s", got, want)139 }140 wantJSON := `[141{"name":"a b","when":"2026-09-27T11:27:31-06:00","n":1.5,"ok":true,"E":"#DIV/0!","t":54000000000000},142{"name":"12","when":"2026-09-27T00:00:00-06:00","n":42,"ok":null,"E":null,"t":[1,2]}143]144`145 if got := encode(t, s, JSON); got != wantJSON {146 t.Errorf("JSON:\n%s\nwant\n%s", got, wantJSON)147 }148 if got := encode(t, sheet.New(), NUON); got != "[]\n" {149 t.Errorf("empty sheet %q", got)150 }151 if got := encode(t, s, CSV); !strings.HasPrefix(got, "name,when,n,ok,,t\n") {152 t.Errorf("CSV %q", got)153 }154 if _, err := Encode(&bytes.Buffer{}, XLSX, Snap(s, sheet.Rect{}, "x")); err == nil {155 t.Error("XLSX encoded to a stream")156 }157}158159// Text formats send the rows a filter shows, as Sheets copies a filtered160// range.161func TestEncodeFiltered(t *testing.T) {162 s := build(t, map[string]string{"A1": "name", "B1": "n", "A2": "a", "B2": "1", "A3": "b", "B3": "2", "A4": "c", "B4": "3"})163 s.CreateFilter(sheet.Rect{To: addr(t, "B4")})164 s.FilterColumn(0, sheet.Criteria{Hidden: []string{"b"}})165 if got := encode(t, s, NUON); got != "[[name, n]; [a, 1],\n[c, 3]]\n" {166 t.Errorf("NUON %q", got)167 }168 if got := encode(t, s, CSV); got != "name,n\na,1\nc,3\n" {169 t.Errorf("CSV %q", got)170 }171 if got := encode(t, s, JSON); strings.Contains(got, `"b"`) {172 t.Errorf("JSON %q", got)173 }174}175176// A notebook cell's output sent to a sheet downloads as its values.177func TestExportRegion(t *testing.T) {178 s := sheet.New()179 if err := s.AddRegion(sheet.Region{Name: "r1", Output: true}); err != nil {180 t.Fatal(err)181 }182 rows, _, err := NUONRows(context.Background(), []byte("[[name, n]; [a, 1]]"), 0)183 if err != nil {184 t.Fatal(err)185 }186 if err := s.Book().ApplyLive(sheet.LiveOp{Region: "r1", Reset: true, Header: rows.Header, Rows: rows.Rows}); err != nil {187 t.Fatal(err)188 }189 if got := encode(t, s, CSV); got != "name,n\na,1\n" {190 t.Errorf("CSV %q", got)191 }192 path := filepath.Join(t.TempDir(), "nb.xlsx")193 if _, err := Export(context.Background(), path, XLSX, SnapBook(s), ExportOptions{}); err != nil {194 t.Fatal(err)195 }196 res, err := Import(context.Background(), path, Options{})197 if err != nil {198 t.Fatal(err)199 }200 if c := res.Sheet.Cell(addr(t, "B2")); c == nil || c.IsFormula() || c.Value.Num != 1 {201 t.Errorf("B2 read back as %+v", c)202 }203}204205// NUONRows reads any value nu prints as a region's rows.206func TestNUONRows(t *testing.T) {207 for _, c := range []struct {208 out string209 want string210 }{211 {"[[name, size]; [a, 1kb], [b, 2b]]", "name size|a 1.0 kB|b 2 B"},212 {"{a: 1, b: true}", "a b|1 TRUE"},213 {"3", "value|3"},214 {`"hello"`, "value|hello"},215 {"null", "value"},216 {"[1, 2]", "value|1|2"},217 {"", ""},218 } {219 rows, _, err := NUONRows(context.Background(), []byte(c.out), 0)220 if err != nil {221 t.Errorf("%q: %v", c.out, err)222 continue223 }224 var lines []string225 for _, r := range append([]sheet.LiveRow{rows.Header}, rows.Rows...) {226 var texts []string227 for _, lc := range r {228 texts = append(texts, sheet.FormatText(lc.V, lc.F))229 }230 if len(texts) > 0 {231 lines = append(lines, strings.TrimSpace(strings.Join(texts, " ")))232 }233 }234 if got := strings.Join(lines, "|"); got != c.want {235 t.Errorf("%q read as %q, want %q", c.out, got, c.want)236 }237 }238 // Past max-cells, whole rows are kept and the note says so.239 rows, note, err := NUONRows(context.Background(), []byte("[[a, b]; [1, 2], [3, 4], [5, 6]]"), 4)240 if err != nil || len(rows.Rows) != 1 || note == "" {241 t.Errorf("capped: %+v %q %v", rows, note, err)242 }243}244245// TestNUONRoundTrip reads a table and writes it back as the same NUON.246func TestNUONRoundTrip(t *testing.T) {247 inZone(t, mountain)248 for _, text := range []string{249 `[[name, size, modified, took, ok, n, tags, meta, raw]; ["CLAUDE.md", 1646b, 2026-09-27T11:27:31.351890-06:00, 90000000000ns, true, 1.5, [a, b], {k: 1}, 0x[DEAD]], [x, 0b, 2026-01-02T03:04:05-06:00, 1500000000ns, false, -3, [], {}, 0x[]]]`,250 `[[a, b]; [1, null], ["x y", "true"]]`, // an empty string would come back null: a blank cell251 } {252 s := readNUON(t, text).Sheet253 got, want := parse(t, encode(t, s, NUON)), parse(t, text)254 if !nuon.Equal(got, want) {255 t.Errorf("read\n%s\nwrote\n%s", want, got)256 }257 }258}259260func parse(t *testing.T, text string) nuon.Value {261 t.Helper()262 v, err := nuon.Parse([]byte(text))263 if err != nil {264 t.Fatalf("%s: %v", text, err)265 }266 return v267}268269// TestNUONThroughNu sends what nushell writes through a sheet and back270// to nushell, which reads the same table, when nu is installed.271func TestNUONThroughNu(t *testing.T) {272 nu, err := exec.LookPath("nu")273 if err != nil {274 t.Skip("nu isn't installed")275 }276 inZone(t, time.Local)277 run := func(script, in string) string {278 cmd := exec.Command(nu, "--no-config-file", "--stdin", "-c", script)279 cmd.Stdin = strings.NewReader(in)280 out, err := cmd.Output()281 if err != nil {282 t.Fatalf("nu -c %q: %v", script, err)283 }284 return strings.TrimSpace(string(out))285 }286 table := `[[name, size, modified, took, ok, n]; [a, 1646b, 2026-09-27T11:27:31.351890163-06:00, 90sec, true, 1.5], [b, 2mb, 2026-01-02, 1.5sec, false, -3]]`287 // Dates come back in the local zone, to the microsecond.288 norm := ` | update modified {|r| $r.modified | date to-timezone local | format date "%+" }`289 theirs := run(table+" | to nuon", "")290 s := readNUON(t, theirs).Sheet291 if got, want := run("from nuon"+norm+" | to nuon", encode(t, s, NUON)), run(table+norm+" | to nuon", ""); got != strings.ReplaceAll(want, ".351890163", ".351890") {292 t.Errorf("nu reads\n%s\nwant\n%s", got, want)293 }294 if got := run("from json | length", encode(t, s, JSON)); got != "2" {295 t.Errorf("nu reads %s JSON rows", got)296 }297}298299func TestNUONFiles(t *testing.T) {300 dir := t.TempDir()301 path := filepath.Join(dir, "files.nuon")302 os.WriteFile(path, []byte(`[[name, size]; [a, 1kb]]`), 0o600)303 res, err := Import(context.Background(), path, Options{})304 if err != nil || res.Kind != NUON || res.Sheet.Name() != "files" || shown(res.Sheet, addr(t, "B2")) != "1.0 kB" {305 t.Fatalf("import: %v %+v", err, res)306 }307 out := filepath.Join(dir, "out.json")308 if _, err := Export(context.Background(), out, JSON, Snap(res.Sheet, sheet.Rect{}, "files"), ExportOptions{}); err != nil {309 t.Fatal(err)310 }311 back, err := Import(context.Background(), out, Options{})312 if err != nil || back.Kind != JSON || shown(back.Sheet, addr(t, "B2")) != "1000" {313 t.Errorf("JSON back: %v, B2 %q", err, shown(back.Sheet, addr(t, "B2")))314 }315}316317// FuzzReadNUON holds the table reader to never panicking and to318// storing only what fits.319func FuzzReadNUON(f *testing.F) {320 for _, s := range []string{`[[a, b]; [1, 2kb]]`, `[{a: [1 2]}, {b: 2026-09-27T11:27:31Z}]`, `{"a": 1}` + "\n" + `{"a": 2}`, "a,b\n1,2"} {321 f.Add([]byte(s))322 }323 f.Fuzz(func(t *testing.T, data []byte) {324 res, err := ImportReader(context.Background(), "stdin", bytes.NewReader(data), Options{MaxCells: 1000})325 if err == nil && res.Sheet.Len() > 1000 {326 t.Fatalf("%d cells", res.Sheet.Len())327 }328 })329}