test: unit and end-to-end suites
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
27fafa0bcd
commit
9b00e21c44
49 files changed
+6694
-1
No files matched your search
@@ -0,0 +1,266 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
isDatabase, defaultDatabase, parseDatabase, databaseToMd, keyFor, read, write, select, columnsFor,
|
||||
monthGrid, byDay, timeline, matches, dayNum, isoOfDay, valueOf, formatNumber, barFraction, weekOf, optionColor, parseEmbedAlias, embedAlias, computeFormulas, withFormulas, formulaDisplay, aggregate, aggregatesFor, AGGREGATES, opsFor
|
||||
} from '../src/lib/database.js';
|
||||
|
||||
const props = [
|
||||
{ key: 'status', name: 'Status', type: 'select', options: ['Todo', 'Doing', 'Done'] },
|
||||
{ key: 'due', name: 'Due', type: 'date' },
|
||||
{ key: 'end', name: 'End', type: 'date' },
|
||||
{ key: 'points', name: 'Points', type: 'number' },
|
||||
{ key: 'done', name: 'Done', type: 'checkbox' },
|
||||
{ key: 'who', name: 'Who', type: 'people' }
|
||||
];
|
||||
const rec = (title, p = {}) => ({ id: 'DB/' + title, title, props: p });
|
||||
|
||||
test('marker and schema round trip', () => {
|
||||
assert.equal(isDatabase({ 'ink-database': 'basic' }), true);
|
||||
assert.equal(isDatabase({}), false);
|
||||
const db = defaultDatabase();
|
||||
const md = databaseToMd(db);
|
||||
assert.match(md, /^%% ink-database\n/);
|
||||
const back = parseDatabase(md);
|
||||
assert.deepEqual(back.properties, db.properties);
|
||||
assert.deepEqual(back.views.map((v) => v.type), ['table', 'calendar']); // a board is added by hand, with +
|
||||
assert.equal(databaseToMd(back), md);
|
||||
});
|
||||
|
||||
test('text around the schema block is kept; damaged JSON falls back to a table', () => {
|
||||
const md = 'Intro line\n\n%% ink-database\n{"properties":[{"key":"a","name":"A","type":"text"}],"views":[]}\n%%\n\nTail';
|
||||
const db = parseDatabase(md);
|
||||
assert.equal(db.properties[0].key, 'a');
|
||||
assert.equal(db.views.length, 1);
|
||||
assert.match(databaseToMd(db), /Intro line\n\nTail/);
|
||||
const bad = parseDatabase('%% ink-database\n{nope\n%%\n');
|
||||
assert.deepEqual(bad.properties, []);
|
||||
assert.equal(bad.views[0].type, 'table');
|
||||
assert.equal(parseDatabase('').properties.length, 2); // a new empty page gets the default schema
|
||||
});
|
||||
|
||||
test('keys are file-safe, unique and not reserved', () => {
|
||||
assert.equal(keyFor('Due date'), 'due-date');
|
||||
assert.equal(keyFor('Due date', ['due-date']), 'due-date-2');
|
||||
assert.equal(keyFor('Tags'), 'tags-2');
|
||||
assert.equal(keyFor('???'), 'property');
|
||||
});
|
||||
|
||||
test('values read and write by type', () => {
|
||||
assert.equal(read('3,5', 'number'), 3.5);
|
||||
assert.equal(read('abc', 'number'), null);
|
||||
assert.equal(read('true', 'checkbox'), true);
|
||||
assert.equal(read('', 'checkbox'), false);
|
||||
assert.deepEqual(read('a, b', 'multiselect'), ['a', 'b']);
|
||||
assert.deepEqual(read(['a'], 'people'), ['a']);
|
||||
assert.equal(write(true, 'checkbox'), 'true');
|
||||
assert.equal(write(null, 'number'), '');
|
||||
assert.equal(write(12, 'number'), '12');
|
||||
assert.deepEqual(write(['x', ''], 'people'), ['x']);
|
||||
});
|
||||
|
||||
test('filters and sort', () => {
|
||||
const rs = [
|
||||
rec('B', { status: 'Doing', points: '5', due: '2026-03-02', done: 'false' }),
|
||||
rec('A', { status: 'Todo', points: '2', due: '2026-03-01', done: 'true' }),
|
||||
rec('C', { points: '9' }),
|
||||
rec('D', { status: 'Done', who: ['Ana'], due: '2026-04-01' })
|
||||
];
|
||||
const v = (o) => ({ filters: [], sort: null, ...o });
|
||||
assert.deepEqual(select(rs, v({}), props).map((r) => r.title), ['A', 'B', 'C', 'D']);
|
||||
assert.deepEqual(select(rs, v({ sort: { key: 'status', dir: 'asc' } }), props).map((r) => r.title), ['A', 'B', 'D', 'C']); // option order, empty last
|
||||
assert.deepEqual(select(rs, v({ sort: { key: 'points', dir: 'desc' } }), props).map((r) => r.title), ['C', 'B', 'A', 'D']);
|
||||
assert.deepEqual(select(rs, v({ filters: [{ key: 'points', op: '≥', value: '5' }] }), props).map((r) => r.title), ['B', 'C']);
|
||||
assert.deepEqual(select(rs, v({ filters: [{ key: 'due', op: 'before', value: '2026-03-15' }] }), props).map((r) => r.title), ['A', 'B']);
|
||||
assert.deepEqual(select(rs, v({ filters: [{ key: 'who', op: 'contains', value: 'ana' }] }), props).map((r) => r.title), ['D']);
|
||||
assert.deepEqual(select(rs, v({ filters: [{ key: 'done', op: 'is checked' }] }), props).map((r) => r.title), ['A']);
|
||||
assert.deepEqual(select(rs, v({ filters: [{ key: 'status', op: 'is empty' }] }), props).map((r) => r.title), ['C']);
|
||||
assert.deepEqual(select(rs, v({ filters: [{ key: 'title', op: 'contains', value: 'b' }] }), props).map((r) => r.title), ['B']);
|
||||
assert.equal(matches(rs[0], { key: 'status', op: 'is', value: '' }, props), true); // half-made filter
|
||||
});
|
||||
|
||||
test('board columns: none first, options in order, strays last', () => {
|
||||
const rs = [rec('a', { status: 'Done' }), rec('b'), rec('c', { status: 'Later' }), rec('d', { status: 'Todo' })];
|
||||
const cols = columnsFor(rs, props[0]);
|
||||
assert.deepEqual(cols.map((c) => c.title), ['No status', 'Todo', 'Doing', 'Done', 'Later']);
|
||||
assert.deepEqual(cols.map((c) => c.records.map((r) => r.title)), [['b'], ['d'], [], ['a'], ['c']]);
|
||||
const chk = columnsFor([rec('x', { done: 'true' }), rec('y')], props[4]);
|
||||
assert.deepEqual(chk.map((c) => c.records.length), [1, 1]);
|
||||
});
|
||||
|
||||
test('calendar grid covers whole weeks, Monday first', () => {
|
||||
const g = monthGrid(2026, 1); // February 2026 starts on a Sunday
|
||||
assert.equal(g.length, 5);
|
||||
assert.equal(g[0][0].iso, '2026-01-26');
|
||||
assert.equal(g[0][6].iso, '2026-02-01');
|
||||
assert.equal(g[4][6].iso, '2026-03-01');
|
||||
assert.equal(g[0][0].inMonth, false);
|
||||
assert.equal(g[1][0].inMonth, true);
|
||||
assert.equal(monthGrid(2026, 2).length, 6); // March 2026: Sunday the 1st, 31 days
|
||||
assert.equal(monthGrid(2025, 5).length, 6);
|
||||
});
|
||||
|
||||
test('records land on the days they cover', () => {
|
||||
const rs = [rec('one', { due: '2026-03-01' }), rec('span', { due: '2026-03-02', end: '2026-03-04' }), rec('none')];
|
||||
const m = byDay(rs, props[1], props[2]);
|
||||
assert.deepEqual([...m.keys()], ['2026-03-01', '2026-03-02', '2026-03-03', '2026-03-04']);
|
||||
assert.equal(m.get('2026-03-03')[0].title, 'span');
|
||||
assert.equal(isoOfDay(dayNum('2026-03-01') + 31), '2026-04-01');
|
||||
});
|
||||
|
||||
test('timeline bars and undated records', () => {
|
||||
const rs = [rec('b', { due: '2026-03-10' }), rec('a', { due: '2026-03-02', end: '2026-03-05' }), rec('u')];
|
||||
const t = timeline(rs, props[1], props[2]);
|
||||
assert.deepEqual(t.bars.map((b) => b.rec.title), ['a', 'b']);
|
||||
assert.equal(t.bars[0].end - t.bars[0].start, 3);
|
||||
assert.deepEqual(t.undated.map((r) => r.title), ['u']);
|
||||
assert.ok(t.from < t.bars[0].start && t.to > t.bars[1].end);
|
||||
assert.equal(valueOf(rs[0], 'title'), 'b');
|
||||
});
|
||||
|
||||
test('number formats show the plain number as currency, percent or a bar', () => {
|
||||
assert.equal(formatNumber(1234.5, { format: 'commas' }), '1,234.5');
|
||||
assert.equal(formatNumber(42, { format: 'percent' }), '42%');
|
||||
assert.equal(formatNumber(1234.5, { format: 'dollar' }), '$1,234.50');
|
||||
assert.equal(formatNumber(7, { format: 'euro' }), '€7.00');
|
||||
assert.equal(formatNumber(3, {}), '3');
|
||||
assert.equal(formatNumber(null, { format: 'percent' }), '');
|
||||
assert.equal(barFraction(50, { format: 'bar' }), 0.5);
|
||||
assert.equal(barFraction(3, { format: 'bar', max: 4 }), 0.75);
|
||||
assert.equal(barFraction(500, { format: 'bar' }), 1);
|
||||
assert.equal(barFraction(-5, { format: 'bar' }), 0);
|
||||
});
|
||||
|
||||
test('format, max, option colours and column widths survive the file; bad ones are dropped', () => {
|
||||
const db = {
|
||||
properties: [
|
||||
{ key: 'n', name: 'N', type: 'number', format: 'bar', max: 20 },
|
||||
{ key: 'm', name: 'M', type: 'number', format: 'nonsense', max: 5 },
|
||||
{ key: 's', name: 'S', type: 'select', options: ['a', 'b'], colors: { a: 'green', b: 'chartreuse' } }
|
||||
],
|
||||
views: [{ id: 'v', name: 'T', type: 'table', widths: { n: 300, s: 5, title: 9000, x: 'wide' } }]
|
||||
};
|
||||
const back = parseDatabase(databaseToMd(parseDatabase(databaseToMd({ ...db, rest: '' }))));
|
||||
assert.equal(back.properties[0].format, 'bar');
|
||||
assert.equal(back.properties[0].max, 20);
|
||||
assert.equal(back.properties[1].format, undefined);
|
||||
assert.equal(back.properties[1].max, undefined);
|
||||
assert.deepEqual(back.properties[2].colors, { a: 'green' });
|
||||
assert.deepEqual(back.views[0].widths, { n: 300, s: 80, title: 720 });
|
||||
assert.equal(optionColor('a', back.properties[2]), '#44ad6e');
|
||||
assert.equal(optionColor('b', back.properties[2]), optionColor('b'));
|
||||
});
|
||||
|
||||
test('a week is Monday to Sunday around any day; relations read as lists', () => {
|
||||
const w = weekOf('2026-03-04'); // a Wednesday
|
||||
assert.equal(w[0].iso, '2026-03-02');
|
||||
assert.equal(w[6].iso, '2026-03-08');
|
||||
assert.deepEqual(weekOf('2026-03-01').map((d) => d.iso)[0], '2026-02-23'); // Sunday belongs to the week before
|
||||
assert.deepEqual(read('A, B', 'relation'), ['A', 'B']);
|
||||
assert.deepEqual(weekOf('nope'), []);
|
||||
});
|
||||
|
||||
test('an embed keeps its own view, sort, filters and hidden properties in its alias', () => {
|
||||
const o = {
|
||||
view: 'My board',
|
||||
sort: { key: 'due', dir: 'desc' },
|
||||
filters: [{ key: 'status', op: 'is', value: 'In progress' }, { key: 'points', op: '≥', value: '5' }, { key: 'who', op: 'contains', value: 'a;b|c]d:e' }],
|
||||
hidden: ['owner', 'done']
|
||||
};
|
||||
const alias = embedAlias(o);
|
||||
assert.ok(!/[|\]\[]/.test(alias.replace(/%7C|%5D/g, '')), alias);
|
||||
assert.deepEqual(parseEmbedAlias(alias), o);
|
||||
assert.equal(embedAlias({ view: '', sort: null, filters: [], hidden: [] }), '');
|
||||
assert.deepEqual(parseEmbedAlias('a display name'), { view: '', sort: null, filters: [], hidden: [] });
|
||||
assert.deepEqual(parseEmbedAlias('sort=due').sort, { key: 'due', dir: 'asc' });
|
||||
});
|
||||
|
||||
const fprops = [
|
||||
{ key: 'price', name: 'Price', type: 'number' },
|
||||
{ key: 'qty', name: 'Quantity', type: 'number' },
|
||||
{ key: 'opened', name: 'Opened', type: 'date' },
|
||||
{ key: 'expiry', name: 'Expiry', type: 'date' },
|
||||
{ key: 'total', name: 'Total', type: 'formula', expression: 'prop("Price") * prop("Quantity")' },
|
||||
{ key: 'days', name: 'Days left', type: 'formula', expression: 'if(prop("Expiry"), dateBetween(prop("Expiry"), today(), "days"), "")' },
|
||||
{ key: 'label', name: 'Label', type: 'formula', expression: 'concat(prop("Name"), ": ", prop("Total"))' },
|
||||
{ key: 'bad', name: 'Bad', type: 'formula', expression: 'prop("Price") / 0' },
|
||||
{ key: 'loop', name: 'Loop', type: 'formula', expression: 'prop("Loop2")' },
|
||||
{ key: 'loop2', name: 'Loop2', type: 'formula', expression: 'prop("Loop")' }
|
||||
];
|
||||
const frec = (title, p) => ({ id: 'DB/' + title, title, props: p });
|
||||
|
||||
test('formula properties are computed from the other values, including other formulas', () => {
|
||||
const r = frec('Serum', { price: '12.5', qty: '3', expiry: '2026-04-10' });
|
||||
const c = computeFormulas(fprops, r, '2026-03-31');
|
||||
assert.equal(c.total.value, 37.5);
|
||||
assert.equal(c.days.value, 10);
|
||||
assert.equal(c.label.text, 'Serum: 37.5'); // title by name, and a formula using a formula
|
||||
assert.match(c.bad.error, /Division by zero/);
|
||||
assert.match(c.loop.error, /refers to itself|Loop/);
|
||||
const none = computeFormulas(fprops, frec('Empty', {}), '2026-03-31');
|
||||
assert.equal(none.total.value, 0); // empty numbers count as 0
|
||||
assert.equal(none.days.text, ''); // no expiry: the if() gives nothing
|
||||
});
|
||||
|
||||
test('withFormulas puts the results into props as text and keeps errors apart', () => {
|
||||
const recs = [frec('A', { price: '2', qty: '5' }), frec('B', { price: '4', qty: '1' })];
|
||||
const out = withFormulas(recs, fprops, '2026-03-31');
|
||||
assert.equal(out[0].props.total, '10');
|
||||
assert.equal(out[1].props.total, '4');
|
||||
assert.ok(out[0].fx.bad);
|
||||
assert.equal(recs[0].props.total, undefined); // the originals are untouched
|
||||
assert.equal(withFormulas(recs, fprops.slice(0, 4)), recs); // no formulas: the same list
|
||||
// sorting and filtering work on the results, numerically
|
||||
const sorted = select(withFormulas([frec('A', { price: '9', qty: '1' }), frec('B', { price: '2', qty: '10' }), frec('C', { price: '1', qty: '1' })], fprops, '2026-03-31'), { filters: [], sort: { key: 'total', dir: 'desc' } }, fprops);
|
||||
assert.deepEqual(sorted.map((r) => r.title), ['B', 'A', 'C']); // 20, 9, 1 (not "9" > "20" as text)
|
||||
const big = select(withFormulas(recs, fprops, '2026-03-31'), { filters: [{ key: 'total', op: '>', value: '5' }], sort: null }, fprops);
|
||||
assert.deepEqual(big.map((r) => r.title), ['A']);
|
||||
assert.ok(opsFor('formula').includes('≥'));
|
||||
});
|
||||
|
||||
test('a formula shows numbers in its format and survives the file', () => {
|
||||
assert.equal(formulaDisplay({ format: 'dollar' }, '1234.5'), '$1,234.50');
|
||||
assert.equal(formulaDisplay({}, '1234.5'), '1234.5');
|
||||
assert.equal(formulaDisplay({ format: 'dollar' }, '2026-03-01'), '2026-03-01'); // a date is not a price
|
||||
assert.equal(formulaDisplay({ format: 'percent' }, 'abc'), 'abc');
|
||||
const md = databaseToMd({ properties: [{ key: 't', name: 'T', type: 'formula', expression: 'prop("A") + 1', format: 'euro' }], views: [], rest: '' });
|
||||
const back = parseDatabase(md).properties[0];
|
||||
assert.equal(back.expression, 'prop("A") + 1');
|
||||
assert.equal(back.format, 'euro');
|
||||
});
|
||||
|
||||
test('totals: the aggregates for each kind of column', () => {
|
||||
const agg = (kind, values, type, p) => aggregate(kind, values, type, p).text;
|
||||
const nums = [10, 20, null, 30];
|
||||
assert.equal(agg('count', nums, 'number'), '4');
|
||||
assert.equal(agg('filled', nums, 'number'), '3');
|
||||
assert.equal(agg('empty', nums, 'number'), '1');
|
||||
assert.equal(agg('sum', nums, 'number'), '60');
|
||||
assert.equal(agg('avg', nums, 'number'), '20');
|
||||
assert.equal(agg('median', [1, 5, 3, 7], 'number'), '4');
|
||||
assert.equal(agg('min', nums, 'number'), '10');
|
||||
assert.equal(agg('max', nums, 'number'), '30');
|
||||
assert.equal(agg('range', nums, 'number'), '20');
|
||||
assert.equal(agg('sum', [], 'number'), '0');
|
||||
assert.equal(agg('avg', [], 'number'), '');
|
||||
assert.equal(agg('sum', [10, 20.5], 'number', { type: 'number', format: 'euro' }), '€30.50'); // in the property's format
|
||||
assert.equal(agg('sum', ['1.5', '2', '', '2026-01-01'], 'formula'), '3.5'); // numeric text from a formula
|
||||
assert.equal(agg('percentFilled', ['a', '', 'b', ''], 'text'), '50%');
|
||||
assert.equal(agg('unique', [['a', 'b'], ['B'], []], 'multiselect'), '2');
|
||||
assert.equal(agg('checked', [true, false, true], 'checkbox'), '2');
|
||||
assert.equal(agg('percentChecked', [true, false, true, true], 'checkbox'), '75%');
|
||||
assert.equal(agg('earliest', ['2026-03-05', '', '2026-01-02'], 'date'), '2026-01-02');
|
||||
assert.equal(agg('latest', ['2026-03-05', '', '2026-01-02'], 'date'), '2026-03-05');
|
||||
assert.equal(agg('span', ['2026-03-05', '2026-01-02'], 'date'), '62');
|
||||
assert.deepEqual(aggregatesFor('number').map((a) => a.value).slice(0, 5), ['count', 'filled', 'empty', 'unique', 'percentFilled']);
|
||||
assert.ok(aggregatesFor('number').some((a) => a.value === 'sum'));
|
||||
assert.ok(!aggregatesFor('text').some((a) => a.value === 'sum'));
|
||||
assert.ok(aggregatesFor('checkbox').some((a) => a.value === 'percentChecked'));
|
||||
assert.ok(AGGREGATES.length >= 15);
|
||||
});
|
||||
|
||||
test('a view keeps its totals in the file, and drops unknown ones', () => {
|
||||
const db = { properties: [{ key: 'p', name: 'P', type: 'number' }], views: [{ id: 'v', name: 'T', type: 'table', calc: { p: 'sum', title: 'count', x: 'nonsense' } }], rest: '' };
|
||||
const back = parseDatabase(databaseToMd(db));
|
||||
assert.deepEqual(back.views[0].calc, { p: 'sum', title: 'count' });
|
||||
});
|
||||
Reference in new issue
Block a user