102 lines
5.7 KiB
JavaScript
102 lines
5.7 KiB
JavaScript
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'] });
|
||
});
|