import { test } from 'node:test'; import assert from 'node:assert/strict'; import { evaluateFormula, parseFormula, FormulaError, show } from '../src/lib/formula.js'; const vars = { Price: 12.5, Qty: 4, Name: 'Cleanser', Brand: 'Balea', Opened: '2026-03-01', Expiry: '2026-09-01', Done: true, Tags: ['a', 'b'], Empty: null }; const ctx = { today: '2026-03-31', prop: (n) => { if (!(n in vars)) throw new FormulaError(`Unknown property "${n}"`); return vars[n]; } }; const ev = (s) => evaluateFormula(s, ctx); test('arithmetic, precedence and properties', () => { assert.equal(ev('1 + 2 * 3'), 7); assert.equal(ev('(1 + 2) * 3'), 9); assert.equal(ev('2 ^ 3 ^ 2'), 512); // right-associative assert.equal(ev('-2 ^ 2'), -4); // as in maths: the power first assert.equal(ev('prop("Price") * prop("Qty")'), 50); assert.equal(ev('{Price} * {Qty}'), 50); assert.equal(ev('10 % 4'), 2); assert.equal(ev('1 / 4'), 0.25); assert.equal(ev('prop("Empty") + 5'), 5); // empty counts as 0 assert.equal(show(ev('0.1 + 0.2')), '0.3'); }); test('comparisons, logic and if', () => { assert.equal(ev('3 > 2 and 2 >= 2'), true); assert.equal(ev('1 > 2 or not false'), true); assert.equal(ev('!(1 == 1)'), false); assert.equal(ev('prop("Name") == "Cleanser"'), true); assert.equal(ev('prop("Empty") == ""'), true); assert.equal(ev('if(prop("Price") > 10, "pricey", "cheap")'), 'pricey'); assert.equal(ev('if(false, 1)'), null); assert.equal(ev('if(prop("Empty"), "x", "y")'), 'y'); assert.equal(ev('and(true, 1, "x")'), true); assert.equal(ev('if(true, 1, 1 / 0)'), 1); // the other branch is not evaluated }); test('text and lists', () => { assert.equal(ev('concat(prop("Brand"), " – ", prop("Name"))'), 'Balea – Cleanser'); assert.equal(ev('"a" + "b"'), 'ab'); assert.equal(ev('"Total: " + 5'), 'Total: 5'); assert.equal(ev('upper(prop("Name"))'), 'CLEANSER'); assert.equal(ev('length(prop("Tags"))'), 2); assert.equal(ev('join(prop("Tags"), "/")'), 'a/b'); assert.equal(ev('contains(prop("Tags"), "B")'), true); assert.equal(ev('contains("Hello", "ell")'), true); assert.equal(ev('replace("a-b-c", "-", "+")'), 'a+b+c'); }); test('numbers functions', () => { assert.equal(ev('round(2.567, 1)'), 2.6); assert.equal(ev('round(2.5)'), 3); assert.equal(ev('floor(2.9) + ceil(2.1)'), 5); assert.equal(ev('abs(-3)'), 3); assert.equal(ev('min(3, 1, 2)'), 1); assert.equal(ev('max(prop("Tags"), 5)'.replace('prop("Tags"), ', '')), 5); assert.equal(ev('sum(1, 2, 3)'), 6); assert.equal(ev('average(2, 4)'), 3); assert.equal(ev('toNumber("€12.50")'), 12.5); }); test('dates', () => { assert.equal(ev('today()'), '2026-03-31'); assert.equal(ev('dateBetween(prop("Expiry"), today(), "days")'), 154); assert.equal(ev('dateBetween(prop("Expiry"), prop("Opened"), "months")'), 6); assert.equal(ev('dateBetween(today(), prop("Expiry"), "days")'), -154); assert.equal(ev('dateAdd(prop("Opened"), 12, "months")'), '2027-03-01'); assert.equal(ev('dateAdd("2026-01-31", 1, "months")'), '2026-02-28'); assert.equal(ev('dateAdd(prop("Opened"), 2, "weeks")'), '2026-03-15'); assert.equal(ev('prop("Expiry") - prop("Opened")'), 184); // date - date = days assert.equal(ev('prop("Opened") + 30'), '2026-03-31'); // date + number = days later assert.equal(ev('prop("Expiry") > today()'), true); assert.equal(ev('year(prop("Opened")) * 100 + month(prop("Opened"))'), 202603); assert.equal(ev('formatDate(prop("Opened"), "MMM D, YYYY")'), 'Mar 1, 2026'); }); test('mistakes are reported, not thrown as anything else', () => { const msg = (s) => { try { ev(s); return null; } catch (e) { assert.ok(e instanceof FormulaError, s + ' -> ' + e); return e.message; } }; assert.match(msg('1 +'), /ends too soon/); assert.match(msg('1 / 0'), /Division by zero/); assert.match(msg('prop("Nope")'), /Unknown property/); assert.match(msg('nope(1)'), /no function/); assert.match(msg('Price * 2'), /not a function or a value/); assert.match(msg('"abc" * 2'), /can't be used as a number/); assert.match(msg('(1 + 2'), /Expected “\)”/); assert.match(msg('1 $ 2'), /Unexpected/); assert.match(msg('"open'), /not closed/); assert.match(msg(''), /empty/); assert.match(msg('dateBetween("x", today(), "days")'), /can't be used as a date/); assert.match(msg('dateBetween(today(), today(), "decades")'), /Unknown unit/); assert.throws(() => parseFormula('1 +'), FormulaError); });