import { test } from 'node:test'; import assert from 'node:assert/strict'; import { parseCsv, toCsv, databaseCsv, parseNumber, parseDate, inferType, autoMap, planImport } from '../src/lib/csv.js'; test('CSV: quotes, embedded commas and newlines, CRLF, BOM and other delimiters', () => { const t = 'Name,Note\r\n"Smith, J","He said ""hi""\nand left"\r\nPlain,\r\n\r\n'; const { rows, delimiter } = parseCsv(t); assert.equal(delimiter, ','); assert.deepEqual(rows, [['Name', 'Note'], ['Smith, J', 'He said "hi"\nand left'], ['Plain', '']]); assert.equal(parseCsv('a;b;c\n1;2;3').delimiter, ';'); assert.deepEqual(parseCsv('a\tb\n1\t2').rows, [['a', 'b'], ['1', '2']]); assert.deepEqual(parseCsv('a,b\n1,2').rows, [['a', 'b'], ['1', '2']]); // no trailing newline assert.deepEqual(parseCsv('').rows, []); }); test('CSV: what toCsv writes parseCsv reads back', () => { const rows = [['Name', 'Tags'], ['A, B', 'x"y'], [' pad ', 'line\nbreak'], ['', 'ü ☃']]; assert.deepEqual(parseCsv(toCsv(rows)).rows, rows.map((r) => r.map((c) => c))); }); test('numbers and dates come in many shapes', () => { assert.equal(parseNumber('$1,234.50'), 1234.5); assert.equal(parseNumber('12%'), 12); assert.equal(parseNumber('1.234,5'), 1234.5); assert.equal(parseNumber('12,5'), 12.5); assert.equal(parseNumber('5,200'), 5200); assert.equal(parseNumber('$1,234,567'), 1234567); assert.equal(parseNumber('abc'), null); assert.equal(parseNumber(''), null); assert.equal(parseDate('2026-03-01T10:00:00Z'), '2026-03-01'); assert.equal(parseDate('3/1/2026'), '2026-03-01'); assert.equal(parseDate('25/12/2026'), '2026-12-25'); assert.equal(parseDate('1.3.2026'), '2026-03-01'); assert.equal(parseDate('Mar 5, 2026'), '2026-03-05'); assert.equal(parseDate('2026-02-31'), ''); assert.equal(parseDate('soon'), ''); }); test('a column\'s type is guessed from its values', () => { assert.equal(inferType(['1', '2.5', '', '10']).type, 'number'); assert.equal(inferType(['2026-01-01', '2026-02-03']).type, 'date'); assert.equal(inferType(['yes', 'no', 'yes']).type, 'checkbox'); assert.equal(inferType(['1', '0', '1']).type, 'number'); // only 0/1 stays a number assert.equal(inferType(['https://a.b', 'http://c.d']).type, 'url'); assert.equal(inferType(['Todo', 'Done', 'Todo', 'Done', 'Doing', 'Done']).type, 'select'); assert.equal(inferType(['ui, api', 'api', 'ui, docs', 'api, docs', 'ui']).type, 'multiselect'); assert.equal(inferType(['one thing', 'another', 'a third', 'and more text here', 'x']).type, 'text'); assert.equal(inferType(['', '']).type, 'text'); }); test('importing maps columns, makes properties and gathers options', () => { const { rows } = parseCsv('Title,Status,Due,Points,Done,Labels,Extra\nA,Todo,2026-03-01,5,yes,"ui, api",\nB,Done,3/2/2026,"1,200",no,api,\nC,Todo,,x,,,\nD,Todo,,,,ui,'); const [header, ...data] = rows; const existing = [{ key: 'status', name: 'Status', type: 'select', options: ['Todo'] }]; const cols = autoMap(header, data, existing); assert.deepEqual(cols.map((c) => c.target), ['title', 'existing', 'new', 'new', 'new', 'new', 'skip']); assert.equal(cols[2].type, 'date'); assert.equal(cols[3].type, 'text'); // "x" is in the numbers const plan = planImport({ rows: data, columns: cols, existing }); assert.deepEqual(plan.properties.map((p) => [p.key, p.type]), [['due', 'date'], ['points', 'text'], ['done', 'checkbox'], ['labels', 'multiselect']]); assert.deepEqual(plan.options, { status: ['Done'] }); // Todo was there already assert.deepEqual(plan.properties[3].options, ['ui', 'api']); assert.deepEqual(plan.items[0], { title: 'A', meta: { status: 'Todo', due: '2026-03-01', points: '5', done: 'true', labels: ['ui', 'api'] } }); assert.equal(plan.items[1].meta.due, '2026-03-02'); assert.equal(plan.items[1].meta.points, '1,200'); assert.deepEqual(plan.items[2].meta, { status: 'Todo', points: 'x' }); }); test('a number column imports as numbers and names that clash get their own keys', () => { const rows = [['Name', 'Tags', 'Due date', 'Due-date'], ['A', 't', '2026-01-01', '2026-02-02'], ['B', '', '2026-01-03', '']]; const cols = autoMap(rows[0], rows.slice(1), []); const plan = planImport({ rows: rows.slice(1), columns: cols, existing: [] }); assert.deepEqual(plan.properties.map((p) => p.key), ['tags-2', 'due-date', 'due-date-2']); assert.equal(plan.items[0].meta['due-date-2'], '2026-02-02'); const n = planImport({ rows: [['A', '7.5'], ['B', '']], columns: [{ index: 0, target: 'title' }, { index: 1, target: 'new', name: 'Score', type: 'number' }], existing: [] }); assert.deepEqual(n.items.map((i) => i.meta.score), ['7.5', undefined]); }); test('export writes a Name column and one per property, and imports back the same', () => { const db = { properties: [ { key: 'status', name: 'Status', type: 'select', options: ['Todo'] }, { key: 'points', name: 'Points', type: 'number' }, { key: 'done', name: 'Done', type: 'checkbox' }, { key: 'who', name: 'Who', type: 'people' } ] }; const recs = [ { title: 'Write, docs', props: { status: 'Todo', points: '5', done: 'true', who: ['Ana', 'Ben'] } }, { title: 'Plan', props: {} } ]; const csv = databaseCsv(db, recs); assert.deepEqual(parseCsv(csv).rows, [ ['Name', 'Status', 'Points', 'Done', 'Who'], ['Write, docs', 'Todo', '5', 'true', 'Ana, Ben'], ['Plan', '', '', 'false', ''] ]); const [h, ...data] = parseCsv(csv).rows; const cols = autoMap(h, data, db.properties); assert.deepEqual(cols.map((c) => c.target), ['title', 'existing', 'existing', 'existing', 'existing']); const plan = planImport({ rows: data, columns: cols, existing: db.properties }); assert.deepEqual(plan.items[0].meta, { status: 'Todo', points: '5', done: 'true', who: ['Ana', 'Ben'] }); });