1package fileio23import (4 "context"5 "os"6 "path/filepath"7 "strings"8 "testing"910 "src.rest/cat/012/internal/sheet"11)1213func addr(t testing.TB, s string) sheet.Addr {14 t.Helper()15 a, ok := sheet.ParseAddr(s)16 if !ok {17 t.Fatalf("bad address %q", s)18 }19 return a20}2122// build makes a sheet from typed entries.23func build(t testing.TB, entries map[string]string) *sheet.Sheet {24 t.Helper()25 s := sheet.New()26 for a, in := range entries {27 if err := s.Set(addr(t, a), in); err != nil {28 t.Fatalf("%s %q: %v", a, in, err)29 }30 }31 return s32}3334// shown is what a cell displays, without a width limit.35func shown(s *sheet.Sheet, a sheet.Addr) string {36 return sheet.FormatText(s.Value(a), s.DisplayFormat(a))37}3839// sameShown checks that every cell of src shows the same in got.40func sameShown(t *testing.T, src, got *sheet.Sheet) {41 t.Helper()42 for _, a := range src.Addrs() {43 if want, g := shown(src, a), shown(got, a); g != want {44 t.Errorf("%s shows %q after the round trip, want %q", a, g, want)45 }46 }47}4849func input(s *sheet.Sheet, a sheet.Addr) string {50 if c := s.Cell(a); c != nil {51 return c.Input52 }53 return ""54}5556func TestSniff(t *testing.T) {57 for _, tc := range []struct {58 name, text string59 want rune60 }{61 {"commas", "a,b,c\n1,2,3\n", ','},62 {"semicolons with decimal commas", "name;price\napple;1,5\npear;2,25\n", ';'},63 {"tabs", "a\tb\n1\t2\n", '\t'},64 {"bars", "a|b|c\n1|2|3\n", '|'},65 {"quoted delimiters", "\"a;b\",c\n\"1;2\",3\n", ','},66 {"quoted line breaks", "a,\"b\nc\"\n1,2\n", ','},67 {"one column", "a\nb\nc\n", ','},68 {"empty", "", ','},69 } {70 t.Run(tc.name, func(t *testing.T) {71 if got := sniff([]byte(tc.text), false, ','); got != tc.want {72 t.Errorf("sniff = %q, want %q", got, tc.want)73 }74 })75 }76}7778func readText(t *testing.T, k Kind, text string) (*sheet.Sheet, []string) {79 t.Helper()80 s, _, notes, err := readDelimited(context.Background(), strings.NewReader(text), k, 0, nil, func(int) {})81 if err != nil {82 t.Fatal(err)83 }84 return s, notes85}8687func TestReadDelimited(t *testing.T) {88 for _, tc := range []struct {89 name string90 kind Kind91 text string92 cells map[string]string // cell -> input93 shown map[string]string // cell -> displayed94 note string95 }{96 {97 name: "entries become values with formats, as typed",98 kind: CSV,99 text: "Item,Due,Amount,Share\nRent,9/26/2026,\"$1,450.00\",12%\n",100 cells: map[string]string{"A1": "Item", "B2": "9/26/2026", "C2": "$1,450.00"},101 shown: map[string]string{"B2": "9/26/2026", "C2": "$1,450.00", "D2": "12%"},102 },103 {104 name: "formulas stay text",105 kind: CSV,106 text: "=1+2,+A1,'quoted,@SUM(A1)\n",107 cells: map[string]string{"A1": "'=1+2", "B1": "'+A1", "C1": "''quoted"},108 shown: map[string]string{"A1": "=1+2", "B1": "+A1", "C1": "'quoted", "D1": "@SUM(A1)"},109 },110 {111 name: "byte order mark",112 kind: CSV,113 text: "\xEF\xBB\xBFname,qty\nx,1\n",114 cells: map[string]string{"A1": "name", "B2": "1"},115 },116 {117 name: "UTF-16 with BOM",118 kind: TSV,119 text: "\xFF\xFEa\x00\t\x00\xE9\x00\n\x001\x00",120 cells: map[string]string{"A1": "a", "B1": "é", "A2": "1"},121 note: "read as UTF-16",122 },123 {124 name: "UTF-16 big-endian, surrogate pair",125 kind: CSV,126 text: "\xFE\xFF\x00a\x00,\xD8\x3D\xDE\x00\x00\n",127 cells: map[string]string{"A1": "a", "B1": "😀"},128 note: "read as UTF-16",129 },130 {131 name: "Windows-1252",132 kind: CSV,133 text: "caf\xE9,\x80 5\n",134 cells: map[string]string{"A1": "café", "B1": "€ 5"},135 note: "read as Windows-1252",136 },137 {138 name: "semicolons",139 kind: CSV,140 text: "a;b\n1;2\n",141 cells: map[string]string{"A1": "a", "B2": "2"},142 note: "separated by semicolons",143 },144 {145 name: "quoted line breaks become spaces",146 kind: CSV,147 text: "\"two\nlines\",\"say \"\"hi\"\"\"\n",148 cells: map[string]string{"A1": "two lines", "B1": `say "hi"`},149 },150 {151 name: "ragged rows and bare quotes",152 kind: CSV,153 text: "a,b,c\n1\n2,x\"y\n",154 cells: map[string]string{"C1": "c", "A2": "1", "B3": `x"y`},155 },156 {157 name: "TSV keeps commas",158 kind: TSV,159 text: "1,5\tTRUE\n",160 cells: map[string]string{"A1": "1,5", "B1": "TRUE"},161 shown: map[string]string{"B1": "TRUE"},162 },163 } {164 t.Run(tc.name, func(t *testing.T) {165 s, notes := readText(t, tc.kind, tc.text)166 for a, want := range tc.cells {167 if got := input(s, addr(t, a)); got != want {168 t.Errorf("%s input = %q, want %q", a, got, want)169 }170 }171 for a, want := range tc.shown {172 if got := shown(s, addr(t, a)); got != want {173 t.Errorf("%s shows %q, want %q", a, got, want)174 }175 }176 if tc.note != "" && !strings.Contains(strings.Join(notes, "; "), tc.note) {177 t.Errorf("notes %q, want %q", notes, tc.note)178 }179 })180 }181}182183// Past the edges of the grid, and past the max-cells budget, imports184// keep what fits (whole rows, for the budget) and say what they left out.185func TestReadDelimitedTruncates(t *testing.T) {186 var b strings.Builder187 b.WriteString("x," + strings.Repeat("y,", sheet.MaxCols+2) + "\n")188 for range sheet.MaxRows + 4 {189 b.WriteString("x\n")190 }191 s, _, notes, err := readDelimited(context.Background(), strings.NewReader(b.String()), CSV, sheet.MaxRows+sheet.MaxCols, nil, func(int) {})192 if err != nil {193 t.Fatal(err)194 }195 if got := input(s, sheet.Addr{Col: sheet.MaxCols - 1}) + input(s, sheet.Addr{Row: sheet.MaxRows - 1}); got != "yx" {196 t.Errorf("last cells = %q", got)197 }198 joined := strings.Join(notes, "; ")199 for _, want := range []string{"only the first 1,048,576 rows fit; 5 rows left out", "only columns A to XFD fit; 3 columns left out"} {200 if !strings.Contains(joined, want) {201 t.Errorf("notes %q, want %q", joined, want)202 }203 }204205 b.Reset()206 for range 100 {207 b.WriteString(strings.Repeat("7,", 9) + "7\n")208 }209 s, _, notes, err = readDelimited(context.Background(), strings.NewReader(b.String()), CSV, 255, nil, func(int) {})210 if err != nil {211 t.Fatal(err)212 }213 if n := s.Len(); n != 250 || input(s, sheet.Addr{Col: 9, Row: 24}) != "7" {214 t.Errorf("kept %d cells, want 25 whole rows", n)215 }216 if want := "only the first 25 rows fit in max-cells (255 cells); 75 rows left out"; strings.Join(notes, "; ") != want {217 t.Errorf("notes %q, want %q", notes, want)218 }219}220221func TestDelimitedRoundTrip(t *testing.T) {222 src := build(t, map[string]string{223 "A1": "Item", "B1": "Due", "C1": "Amount", "D1": "Share", "E1": "Note",224 "A2": "Rent", "B2": "10/1/2026", "C2": "$1,450.00", "D2": "=C2/C4", "E2": "comma, \"quoted\"",225 "A3": "Food", "B3": "9/28/2026", "C3": "612.4", "D3": "=C3/C4", "E3": "tab\there",226 "A4": "Total", "C4": "=SUM(C2:C3)", "E4": "ok",227 })228 src.SetFormat(sheet.NewRect(addr(t, "D2"), addr(t, "D3")), sheet.Preset(sheet.FmtPercent))229 for _, k := range []Kind{CSV, TSV} {230 t.Run(k.String(), func(t *testing.T) { roundTripDelimited(t, src, k) })231 }232}233234// roundTripDelimited exports src as k, imports it back and compares.235func roundTripDelimited(t *testing.T, src *sheet.Sheet, k Kind) {236 name := filepath.Join(t.TempDir(), "out"+k.Ext())237 res, err := Export(context.Background(), name, k, Snap(src, sheet.Rect{}, "out"), ExportOptions{})238 if err != nil {239 t.Fatal(err)240 }241 if res.Rows != 4 || len(res.Notes) != 1 || res.Notes[0] != "3 formulas saved as values" {242 t.Errorf("result %+v", res)243 }244 got, err := Import(context.Background(), name, Options{})245 if err != nil {246 t.Fatal(err)247 }248 if n := got.Sheet.Name(); n != "out" || got.Sheet.CanUndo() {249 t.Errorf("imported sheet named %q, undoable %v", n, got.Sheet.CanUndo())250 }251 sameShown(t, src, got.Sheet)252 // Values come back as numbers with their formats, not text.253 if v := got.Sheet.Value(addr(t, "C4")); v.Kind != sheet.Number || v.Num != 2062.4 {254 t.Errorf("C4 = %+v", v)255 }256 if f := got.Sheet.DisplayFormat(addr(t, "D2")); f.Kind != sheet.FmtPercent {257 t.Errorf("D2 format = %+v", f)258 }259}260261func TestExportDelimitedText(t *testing.T) {262 src := build(t, map[string]string{"A1": "a", "C1": "1/0", "B2": "=1/0", "A3": "TRUE"})263 name := filepath.Join(t.TempDir(), "x.csv")264 if _, err := Export(context.Background(), name, CSV, Snap(src, sheet.Rect{}, "x"), ExportOptions{}); err != nil {265 t.Fatal(err)266 }267 data, _ := os.ReadFile(name)268 want := "a,,1/0\n,#DIV/0!,\nTRUE,,\n"269 if string(data) != want {270 t.Errorf("wrote %q, want %q", data, want)271 }272}273274func FuzzReadDelimited(f *testing.F) {275 for _, s := range []string{"a,b\n1,2\n", "\"a\"\"b\",\n", "\xFF\xFEa\x00", "\xEF\xBB\xBF=1", "x;\"y\n", "\x00\x01,\"", "1|2|3"} {276 f.Add([]byte(s), true)277 }278 f.Fuzz(func(t *testing.T, data []byte, tsv bool) {279 k := CSV280 if tsv {281 k = TSV282 }283 readDelimited(context.Background(), strings.NewReader(string(data)), k, 0, nil, func(int) {})284 })285}