import { describe, it, expect } from 'vitest'; import { exportToCsv, escapeField } from '../src/sheets/csv-export.js'; // UTF-8 BOM character const BOM = '\uFEFF'; /** * Helper: create a getCellValue function from a 2D array. * Array is 0-indexed; getCellValue is 1-based (row, col). */ function gridFromArray(data) { return (row, col) => { const r = row - 1; const c = col - 1; if (r < 0 || r >= data.length) return ''; if (c < 0 || c >= data[r].length) return ''; return String(data[r][c] ?? ''); }; } // ============================================================ // escapeField // ============================================================ describe('CSV Export — escapeField', () => { it('returns empty string for empty input', () => { expect(escapeField('', ',')).toBe(''); }); it('returns plain string unchanged when no special chars', () => { expect(escapeField('hello', ',')).toBe('hello'); }); it('quotes fields containing the comma delimiter', () => { expect(escapeField('a,b', ',')).toBe('"a,b"'); }); it('quotes fields containing double quotes and escapes them', () => { expect(escapeField('say "hi"', ',')).toBe('"say ""hi"""'); }); it('quotes fields containing newlines', () => { expect(escapeField('line1\nline2', ',')).toBe('"line1\nline2"'); }); it('quotes fields containing carriage returns', () => { expect(escapeField('line1\rline2', ',')).toBe('"line1\rline2"'); }); it('handles tab delimiter — does not quote commas when delimiter is tab', () => { expect(escapeField('a,b', '\t')).toBe('a,b'); }); it('quotes fields containing tab when delimiter is tab', () => { expect(escapeField('a\tb', '\t')).toBe('"a\tb"'); }); it('handles field with all special characters', () => { expect(escapeField('a,"b"\nc', ',')).toBe('"a,""b""\nc"'); }); }); // ============================================================ // exportToCsv — basic output // ============================================================ describe('CSV Export — exportToCsv basic', () => { it('generates CSV from a simple 2x2 grid', () => { const data = [['A', 'B'], ['1', '2']]; const result = exportToCsv(gridFromArray(data), 2, 2); expect(result).toBe(BOM + 'A,B\r\n1,2'); }); it('starts with UTF-8 BOM', () => { const data = [['x']]; const result = exportToCsv(gridFromArray(data), 1, 1); expect(result.charCodeAt(0)).toBe(0xFEFF); }); it('uses CRLF line endings (RFC 4180)', () => { const data = [['a'], ['b'], ['c']]; const result = exportToCsv(gridFromArray(data), 3, 1); expect(result).toBe(BOM + 'a\r\nb\r\nc'); }); it('handles empty cells', () => { const data = [['', 'B'], ['', '']]; const result = exportToCsv(gridFromArray(data), 2, 2); expect(result).toBe(BOM + ',B\r\n,'); }); it('handles single cell', () => { const data = [['only']]; const result = exportToCsv(gridFromArray(data), 1, 1); expect(result).toBe(BOM + 'only'); }); it('handles numeric values', () => { const data = [['42', '3.14']]; const result = exportToCsv(gridFromArray(data), 1, 2); expect(result).toBe(BOM + '42,3.14'); }); }); // ============================================================ // exportToCsv — RFC 4180 escaping // ============================================================ describe('CSV Export — RFC 4180 escaping', () => { it('quotes fields with commas', () => { const data = [['a,b', 'c']]; const result = exportToCsv(gridFromArray(data), 1, 2); expect(result).toBe(BOM + '"a,b",c'); }); it('escapes double quotes within fields', () => { const data = [['say "hello"']]; const result = exportToCsv(gridFromArray(data), 1, 1); expect(result).toBe(BOM + '"say ""hello"""'); }); it('quotes fields with embedded newlines', () => { const data = [['line1\nline2']]; const result = exportToCsv(gridFromArray(data), 1, 1); expect(result).toBe(BOM + '"line1\nline2"'); }); it('handles field with commas, quotes, and newlines together', () => { const data = [['a,"b"\nc']]; const result = exportToCsv(gridFromArray(data), 1, 1); expect(result).toBe(BOM + '"a,""b""\nc"'); }); }); // ============================================================ // exportToCsv — TSV (tab delimiter) // ============================================================ describe('CSV Export — TSV variant', () => { it('uses tab delimiter', () => { const data = [['A', 'B'], ['1', '2']]; const result = exportToCsv(gridFromArray(data), 2, 2, { delimiter: '\t' }); expect(result).toBe(BOM + 'A\tB\r\n1\t2'); }); it('does not quote fields with commas in TSV mode', () => { const data = [['a,b', 'c']]; const result = exportToCsv(gridFromArray(data), 1, 2, { delimiter: '\t' }); expect(result).toBe(BOM + 'a,b\tc'); }); it('quotes fields with tabs in TSV mode', () => { const data = [['a\tb', 'c']]; const result = exportToCsv(gridFromArray(data), 1, 2, { delimiter: '\t' }); expect(result).toBe(BOM + '"a\tb"\tc'); }); }); // ============================================================ // exportToCsv — includeHeaders option // ============================================================ describe('CSV Export — includeHeaders', () => { it('prepends column letter headers when includeHeaders is true', () => { const data = [['val1', 'val2']]; const result = exportToCsv(gridFromArray(data), 1, 2, { includeHeaders: true }); expect(result).toBe(BOM + 'A,B\r\nval1,val2'); }); it('generates correct headers for 27+ columns (AA, AB, ...)', () => { const row = []; for (let i = 0; i < 28; i++) row.push(String(i)); const result = exportToCsv(gridFromArray([row]), 1, 28, { includeHeaders: true }); const lines = result.slice(1).split('\r\n'); // skip BOM const headers = lines[0].split(','); expect(headers[0]).toBe('A'); expect(headers[25]).toBe('Z'); expect(headers[26]).toBe('AA'); expect(headers[27]).toBe('AB'); }); }); // ============================================================ // exportToCsv — range export // ============================================================ describe('CSV Export — range export', () => { it('exports only the specified range', () => { const data = [ ['A1', 'B1', 'C1'], ['A2', 'B2', 'C2'], ['A3', 'B3', 'C3'], ]; const result = exportToCsv(gridFromArray(data), 3, 3, { range: { startRow: 2, endRow: 3, startCol: 2, endCol: 3 }, }); expect(result).toBe(BOM + 'B2,C2\r\nB3,C3'); }); it('exports a single cell range', () => { const data = [['A1', 'B1'], ['A2', 'B2']]; const result = exportToCsv(gridFromArray(data), 2, 2, { range: { startRow: 1, endRow: 1, startCol: 2, endCol: 2 }, }); expect(result).toBe(BOM + 'B1'); }); it('exports full range when range is null', () => { const data = [['X', 'Y']]; const result = exportToCsv(gridFromArray(data), 1, 2, { range: null }); expect(result).toBe(BOM + 'X,Y'); }); it('combines range with includeHeaders', () => { const data = [['A1', 'B1'], ['A2', 'B2']]; const result = exportToCsv(gridFromArray(data), 2, 2, { range: { startRow: 1, endRow: 2, startCol: 2, endCol: 2 }, includeHeaders: true, }); expect(result).toBe(BOM + 'B\r\nB1\r\nB2'); }); }); // ============================================================ // exportToCsv — edge cases // ============================================================ describe('CSV Export — edge cases', () => { it('handles a completely empty grid', () => { const result = exportToCsv(() => '', 2, 2); // All cells empty => two rows of "," expect(result).toBe(BOM + ',\r\n,'); }); it('preserves leading/trailing whitespace in values', () => { const data = [[' spaced ']]; const result = exportToCsv(gridFromArray(data), 1, 1); expect(result).toBe(BOM + ' spaced '); }); it('handles unicode characters', () => { const data = [['\u00e9', '\u00fc']]; const result = exportToCsv(gridFromArray(data), 1, 2); expect(result).toBe(BOM + '\u00e9,\u00fc'); }); it('handles formulas as string values', () => { const data = [['=SUM(A1:A10)']]; const result = exportToCsv(gridFromArray(data), 1, 1); expect(result).toBe(BOM + '=SUM(A1:A10)'); }); it('handles very long string values', () => { const longStr = 'x'.repeat(10000); const data = [[longStr]]; const result = exportToCsv(gridFromArray(data), 1, 1); expect(result).toBe(BOM + longStr); }); });