Full document, spreadsheet, slideshow, and diagram tooling
1import { describe, it, expect } from 'vitest';
2import { exportToCsv, escapeField } from '../src/sheets/csv-export.js';
3
4// UTF-8 BOM character
5const BOM = '\uFEFF';
6
7/**
8 * Helper: create a getCellValue function from a 2D array.
9 * Array is 0-indexed; getCellValue is 1-based (row, col).
10 */
11function gridFromArray(data) {
12 return (row, col) => {
13 const r = row - 1;
14 const c = col - 1;
15 if (r < 0 || r >= data.length) return '';
16 if (c < 0 || c >= data[r].length) return '';
17 return String(data[r][c] ?? '');
18 };
19}
20
21// ============================================================
22// escapeField
23// ============================================================
24describe('CSV Export — escapeField', () => {
25 it('returns empty string for empty input', () => {
26 expect(escapeField('', ',')).toBe('');
27 });
28
29 it('returns plain string unchanged when no special chars', () => {
30 expect(escapeField('hello', ',')).toBe('hello');
31 });
32
33 it('quotes fields containing the comma delimiter', () => {
34 expect(escapeField('a,b', ',')).toBe('"a,b"');
35 });
36
37 it('quotes fields containing double quotes and escapes them', () => {
38 expect(escapeField('say "hi"', ',')).toBe('"say ""hi"""');
39 });
40
41 it('quotes fields containing newlines', () => {
42 expect(escapeField('line1\nline2', ',')).toBe('"line1\nline2"');
43 });
44
45 it('quotes fields containing carriage returns', () => {
46 expect(escapeField('line1\rline2', ',')).toBe('"line1\rline2"');
47 });
48
49 it('handles tab delimiter — does not quote commas when delimiter is tab', () => {
50 expect(escapeField('a,b', '\t')).toBe('a,b');
51 });
52
53 it('quotes fields containing tab when delimiter is tab', () => {
54 expect(escapeField('a\tb', '\t')).toBe('"a\tb"');
55 });
56
57 it('handles field with all special characters', () => {
58 expect(escapeField('a,"b"\nc', ',')).toBe('"a,""b""\nc"');
59 });
60});
61
62// ============================================================
63// exportToCsv — basic output
64// ============================================================
65describe('CSV Export — exportToCsv basic', () => {
66 it('generates CSV from a simple 2x2 grid', () => {
67 const data = [['A', 'B'], ['1', '2']];
68 const result = exportToCsv(gridFromArray(data), 2, 2);
69 expect(result).toBe(BOM + 'A,B\r\n1,2');
70 });
71
72 it('starts with UTF-8 BOM', () => {
73 const data = [['x']];
74 const result = exportToCsv(gridFromArray(data), 1, 1);
75 expect(result.charCodeAt(0)).toBe(0xFEFF);
76 });
77
78 it('uses CRLF line endings (RFC 4180)', () => {
79 const data = [['a'], ['b'], ['c']];
80 const result = exportToCsv(gridFromArray(data), 3, 1);
81 expect(result).toBe(BOM + 'a\r\nb\r\nc');
82 });
83
84 it('handles empty cells', () => {
85 const data = [['', 'B'], ['', '']];
86 const result = exportToCsv(gridFromArray(data), 2, 2);
87 expect(result).toBe(BOM + ',B\r\n,');
88 });
89
90 it('handles single cell', () => {
91 const data = [['only']];
92 const result = exportToCsv(gridFromArray(data), 1, 1);
93 expect(result).toBe(BOM + 'only');
94 });
95
96 it('handles numeric values', () => {
97 const data = [['42', '3.14']];
98 const result = exportToCsv(gridFromArray(data), 1, 2);
99 expect(result).toBe(BOM + '42,3.14');
100 });
101});
102
103// ============================================================
104// exportToCsv — RFC 4180 escaping
105// ============================================================
106describe('CSV Export — RFC 4180 escaping', () => {
107 it('quotes fields with commas', () => {
108 const data = [['a,b', 'c']];
109 const result = exportToCsv(gridFromArray(data), 1, 2);
110 expect(result).toBe(BOM + '"a,b",c');
111 });
112
113 it('escapes double quotes within fields', () => {
114 const data = [['say "hello"']];
115 const result = exportToCsv(gridFromArray(data), 1, 1);
116 expect(result).toBe(BOM + '"say ""hello"""');
117 });
118
119 it('quotes fields with embedded newlines', () => {
120 const data = [['line1\nline2']];
121 const result = exportToCsv(gridFromArray(data), 1, 1);
122 expect(result).toBe(BOM + '"line1\nline2"');
123 });
124
125 it('handles field with commas, quotes, and newlines together', () => {
126 const data = [['a,"b"\nc']];
127 const result = exportToCsv(gridFromArray(data), 1, 1);
128 expect(result).toBe(BOM + '"a,""b""\nc"');
129 });
130});
131
132// ============================================================
133// exportToCsv — TSV (tab delimiter)
134// ============================================================
135describe('CSV Export — TSV variant', () => {
136 it('uses tab delimiter', () => {
137 const data = [['A', 'B'], ['1', '2']];
138 const result = exportToCsv(gridFromArray(data), 2, 2, { delimiter: '\t' });
139 expect(result).toBe(BOM + 'A\tB\r\n1\t2');
140 });
141
142 it('does not quote fields with commas in TSV mode', () => {
143 const data = [['a,b', 'c']];
144 const result = exportToCsv(gridFromArray(data), 1, 2, { delimiter: '\t' });
145 expect(result).toBe(BOM + 'a,b\tc');
146 });
147
148 it('quotes fields with tabs in TSV mode', () => {
149 const data = [['a\tb', 'c']];
150 const result = exportToCsv(gridFromArray(data), 1, 2, { delimiter: '\t' });
151 expect(result).toBe(BOM + '"a\tb"\tc');
152 });
153});
154
155// ============================================================
156// exportToCsv — includeHeaders option
157// ============================================================
158describe('CSV Export — includeHeaders', () => {
159 it('prepends column letter headers when includeHeaders is true', () => {
160 const data = [['val1', 'val2']];
161 const result = exportToCsv(gridFromArray(data), 1, 2, { includeHeaders: true });
162 expect(result).toBe(BOM + 'A,B\r\nval1,val2');
163 });
164
165 it('generates correct headers for 27+ columns (AA, AB, ...)', () => {
166 const row = [];
167 for (let i = 0; i < 28; i++) row.push(String(i));
168 const result = exportToCsv(gridFromArray([row]), 1, 28, { includeHeaders: true });
169 const lines = result.slice(1).split('\r\n'); // skip BOM
170 const headers = lines[0].split(',');
171 expect(headers[0]).toBe('A');
172 expect(headers[25]).toBe('Z');
173 expect(headers[26]).toBe('AA');
174 expect(headers[27]).toBe('AB');
175 });
176});
177
178// ============================================================
179// exportToCsv — range export
180// ============================================================
181describe('CSV Export — range export', () => {
182 it('exports only the specified range', () => {
183 const data = [
184 ['A1', 'B1', 'C1'],
185 ['A2', 'B2', 'C2'],
186 ['A3', 'B3', 'C3'],
187 ];
188 const result = exportToCsv(gridFromArray(data), 3, 3, {
189 range: { startRow: 2, endRow: 3, startCol: 2, endCol: 3 },
190 });
191 expect(result).toBe(BOM + 'B2,C2\r\nB3,C3');
192 });
193
194 it('exports a single cell range', () => {
195 const data = [['A1', 'B1'], ['A2', 'B2']];
196 const result = exportToCsv(gridFromArray(data), 2, 2, {
197 range: { startRow: 1, endRow: 1, startCol: 2, endCol: 2 },
198 });
199 expect(result).toBe(BOM + 'B1');
200 });
201
202 it('exports full range when range is null', () => {
203 const data = [['X', 'Y']];
204 const result = exportToCsv(gridFromArray(data), 1, 2, { range: null });
205 expect(result).toBe(BOM + 'X,Y');
206 });
207
208 it('combines range with includeHeaders', () => {
209 const data = [['A1', 'B1'], ['A2', 'B2']];
210 const result = exportToCsv(gridFromArray(data), 2, 2, {
211 range: { startRow: 1, endRow: 2, startCol: 2, endCol: 2 },
212 includeHeaders: true,
213 });
214 expect(result).toBe(BOM + 'B\r\nB1\r\nB2');
215 });
216});
217
218// ============================================================
219// exportToCsv — edge cases
220// ============================================================
221describe('CSV Export — edge cases', () => {
222 it('handles a completely empty grid', () => {
223 const result = exportToCsv(() => '', 2, 2);
224 // All cells empty => two rows of ","
225 expect(result).toBe(BOM + ',\r\n,');
226 });
227
228 it('preserves leading/trailing whitespace in values', () => {
229 const data = [[' spaced ']];
230 const result = exportToCsv(gridFromArray(data), 1, 1);
231 expect(result).toBe(BOM + ' spaced ');
232 });
233
234 it('handles unicode characters', () => {
235 const data = [['\u00e9', '\u00fc']];
236 const result = exportToCsv(gridFromArray(data), 1, 2);
237 expect(result).toBe(BOM + '\u00e9,\u00fc');
238 });
239
240 it('handles formulas as string values', () => {
241 const data = [['=SUM(A1:A10)']];
242 const result = exportToCsv(gridFromArray(data), 1, 1);
243 expect(result).toBe(BOM + '=SUM(A1:A10)');
244 });
245
246 it('handles very long string values', () => {
247 const longStr = 'x'.repeat(10000);
248 const data = [[longStr]];
249 const result = exportToCsv(gridFromArray(data), 1, 1);
250 expect(result).toBe(BOM + longStr);
251 });
252});