import { describe, expect, it } from "vitest"; import { parseFormula, validateFormula, evalFormulaValue, evaluateFormula, FormulaError, MAX_STEPS, } from "./index.js"; import type { FormulaContext } from "./types.js"; const NOW = Date.parse("2025-06-15T12:00:00Z"); const ctx = (props: Record, types?: Record): FormulaContext => ({ now: NOW, properties: props, propertyTypes: types, }); const run = ( src: string, properties: Record = {}, types?: Record, ): unknown => evalFormulaValue(parseFormula(src), ctx(properties, types)); const expectErr = ( code: string, src: string, properties: Record = {}, types?: Record, ): void => { let caught: unknown; try { evalFormulaValue(parseFormula(src), ctx(properties, types)); } catch (e) { caught = e; } expect(caught, `expected FormulaError ${code} for "${src}"`).toBeInstanceOf(FormulaError); expect((caught as FormulaError).code).toBe(code); }; describe("formula: spec example", () => { it('if(prop("Status") == "Done", 100, 0) → 100 / 0', () => { expect(run('if(prop("Status") == "Done", 100, 0)', { Status: "Done" })).toBe(100); expect(run('if(prop("Status") == "Done", 100, 0)', { Status: "Open" })).toBe(0); }); it("ternary sugar parses to the same If node", () => { const a = parseFormula("1 ? 2 : 3"); expect(a.type).toBe("If"); expect(run("true ? 5 : 6")).toBe(5); }); }); describe("formula: functions (goldens)", () => { it("sum/average/min/max empty-set rules (recorded asymmetry)", () => { expect(run("sum([])")).toBe(0); // sum([]) = 0 expect(run("average([])")).toBeNull(); // average([]) = null expect(run("min([])")).toBeNull(); expect(run("max([])")).toBeNull(); expect(run("sum([1, 2, 3])")).toBe(6); expect(run("average([1, 2, 3])")).toBe(2); expect(run("max([1, 9, 4])")).toBe(9); expect(run("min([5, 2, 8])")).toBe(2); expect(run("sum([1, null, 2])")).toBe(3); // nulls excluded }); it("dateAdd month/year clamping + negative amounts", () => { expect(run('dateAdd(2025-01-31, 1, "months")', {}, {})).toBe("2025-02-28"); expect(run('dateAdd(2025-01-31, 1, "years")')).toBe("2026-01-31"); expect(run('dateAdd(2025-06-15, -1, "months")')).toBe("2025-05-15"); expect(run('dateAdd(2025-06-15, 2, "weeks")')).toBe("2025-06-29"); // 2×7=14 days expectErr("TYPE", 'dateAdd(2025-06-15, 1, "fortnights")'); }); it("dateBetween (b − a, negative allowed)", () => { expect(run("dateBetween(2025-06-01, 2025-06-15)")).toBe(14); expect(run("dateBetween(2025-06-15, 2025-06-01)")).toBe(-14); }); it("now() reads ctx.now (injectable, documented nondeterminism)", () => { expect(run("now()")).toBe("2025-06-15"); expect(run("now()", {}, undefined)).toBe("2025-06-15"); const { value } = evaluateFormula(parseFormula("now()"), { now: Date.parse("2030-01-02T00:00:00Z"), properties: {}, }); expect(value.v).toBe("2030-01-02"); }); it("concat stringify rules (number as-is, date ISO, null → '', array comma)", () => { expect(run('concat("a", "b")')).toBe("ab"); expect(run("concat(1, 2)")).toBe("12"); expect(run('concat(null, "x")')).toBe("x"); expect(run('concat([1, 2], "!")')).toBe("1,2!"); expect(run('concat(2025-06-01, "!")', {}, {})).toBe("2025-06-01!"); }); it("replace: literal, all occurrences (not regex)", () => { expect(run('replace("a.b.c", ".", "-")')).toBe("a-b-c"); expect(run('replace("aaa", "a", "b")')).toBe("bbb"); expect(run('replace("a*b", "*", "x")')).toBe("axb"); // literal, no regex }); it("contains: case-sensitive (single function)", () => { expect(run('contains("Hello", "ell")')).toBe(true); expect(run('contains("Hello", "ELL")')).toBe(false); }); it("length: string/array/null → length; number/date/bool → TYPE", () => { expect(run('length("abcd")')).toBe(4); expect(run("length([1, 2, 3])")).toBe(3); expect(run("length(null)")).toBe(0); expectErr("TYPE", "length(5)"); expectErr("TYPE", "length(true)"); expectErr("TYPE", "length(2025-06-01)"); }); it("and/or/not + laziness (untaken branch never errors)", () => { expect(run("and(true, false)")).toBe(false); expect(run("or(false, true)")).toBe(true); expect(run("not(true)")).toBe(false); // laziness: 1/0 in the untaken branch does not error (and 1/0 = null anyway) expect(run("if(true, 1, 1/0)")).toBe(1); expect(run("if(false, 1/0, 2)")).toBe(2); expect(run("and(false, prop('Nope') == 1)")).toBe(false); // right side never walked expectErr("TYPE", "and(1, true)"); }); }); describe("formula: null semantics matrix", () => { const p = { A: null }; it("arithmetic null propagation", () => { expect(run("prop('A') + 1", p)).toBeNull(); expect(run("1 + prop('A')", p)).toBeNull(); expect(run("prop('A') * 5", p)).toBeNull(); expect(run("prop('A') / 5", p)).toBeNull(); }); it("equality: null == null true, null == 0 false", () => { expect(run("prop('A') == null", p)).toBe(true); expect(run("prop('A') == 0", p)).toBe(false); expect(run("prop('A') != 0", p)).toBe(true); }); it("ordering with null → false; and/or null propagation", () => { expect(run("prop('A') < 1", p)).toBe(false); expect(run("null < 1")).toBe(false); expect(run("and(null, true)")).toBeNull(); expect(run("or(null, false)")).toBeNull(); expect(run("not(null)")).toBeNull(); }); }); describe("formula: type matrix (4×4 for +, ==, <)", () => { const types: Record = { A: "number", B: "text", C: "date", D: "checkbox" }; const vals: Record = { A: 1, B: "x", C: "2025-06-01", D: true }; // The matrix is asserted INSIDE an `it` (not at describe-collection scope): // top-level `expect` in a describe body runs during collection, so a single // failure there aborts the whole file with "no tests" (the historical // PARSE/TYPE crash — TASK-0134 recorded + fixed). it("4×4 matrix for ==, +, <", () => { for (const la of ["A", "B", "C", "D"]) { for (const rb of ["A", "B", "C", "D"]) { const lt = types[la]!; const rt = types[rb]!; // == : total (never throws) — different types are simply unequal // (null == null is the one true case; null == 0 is false) if (lt !== rt) expect(run(`prop('${la}') == prop('${rb}')`, vals, types)).toBe(false); else expect(run(`prop('${la}') == prop('${rb}')`, vals, types)).toBe(true); // + : strict — only number + number const okPlus = lt === "number" && rt === "number"; if (okPlus) expect(run(`prop('${la}') + prop('${rb}')`, vals, types)).toBe(2); else expectErr("TYPE", `prop('${la}') + prop('${rb}')`, vals, types); // < : strict ordering — only same-type comparable pairs const okLt = lt === rt; if (okLt) expect(typeof run(`prop('${la}') < prop('${rb}')`, vals, types)).toBe("boolean"); else expectErr("TYPE", `prop('${la}') < prop('${rb}')`, vals, types); } } }); it("cross-type specifics", () => { expect(run(`prop('A') == prop('B')`, vals, types)).toBe(false); // number == string → false (total) expectErr("TYPE", `prop('A') < prop('B')`, vals, types); // number < string expectErr("TYPE", `prop('A') < prop('C')`, vals, types); // number < date expectErr("TYPE", `prop('C') + 1`, vals, types); // date + 1 → "use dateAdd" // string < string: total ordering — "x" < "y" is true (the historical // expectation was false and never ran; the file aborted at collection). expect(run(`prop('B') < "y"`, vals, types)).toBe(true); // date < string: cross-type ordering is not comparable → TYPE expectErr("TYPE", `prop('C') < "2025-07-01"`, vals, types); }); }); describe("formula: error classification", () => { it("PARSE: unterminated, bad char, 17-deep nesting, trailing junk", () => { expectErr("PARSE", '"abc'); expectErr("PARSE", "1 @ 2"); expectErr("PARSE", "(" + "(".repeat(17) + "1" + ")".repeat(17)); expectErr("PARSE", "1 + 2 3"); expectErr("PARSE", "unknownIdent"); // bare ident — use prop() }); it("UNKNOWN_FUNCTION at parse (closed table)", () => { expectErr("UNKNOWN_FUNCTION", "foo(1)"); expectErr("UNKNOWN_FUNCTION", "eval('1+1')"); // the name eval itself is not a function }); it("TYPE: unknown property (with Levenshtein suggestion)", () => { let caught: unknown; try { run('prop("Statu")', { Status: 1 }); } catch (e) { caught = e; } expect(caught).toBeInstanceOf(FormulaError); expect((caught as FormulaError).code).toBe("TYPE"); expect((caught as FormulaError).message).toContain("Did you mean 'Status'?"); }); it("FORMULA_TIMEOUT: op limit (100k node visits) via a wide tree", () => { // Lazy short-circuiting means or(true, …) returns after ONE node, so the // op limit needs a formula that actually walks every arg: an all-true // and() of 100_500 `true` literals (arity [2,∞]; parse depth stays 1). // Each literal is one node visit → one op charge → 100_500 > 100_000. // (TASK-0134 raised the limit 10k → 100k and renamed the code RUNTIME → // FORMULA_TIMEOUT; the guard is now op-limit OR wall-clock, see // evaluator.ts.) The clock is FROZEN so only the op counter can fire — // this isolates the op budget from the wall-clock budget (which would // otherwise trip first on a >50 ms real-time walk). let src = "and("; for (let i = 0; i < 100_500; i++) src += "true, "; src = src.slice(0, -2) + ")"; const t0 = Date.now(); let caught: unknown; try { evaluateFormula(parseFormula(src), ctx({}), { now: () => 0 }); // frozen clock } catch (e) { caught = e; } expect(caught).toBeInstanceOf(FormulaError); expect((caught as FormulaError).code).toBe("FORMULA_TIMEOUT"); expect((caught as FormulaError).message).toMatch(/op limit/i); expect(Date.now() - t0).toBeLessThan(2000); // never hangs }); it("FORMULA_TIMEOUT: wall-clock budget (50 ms) via an injected clock", () => { // Deterministic wall-clock guard: the FIRST `now()` call sets the // deadline (T0 + 50 ms); the SECOND and later calls jump to T0 + 51 ms, // tripping the clock budget on the first charge. A trivially cheap // formula proves it's the clock, not the op count, that fires. // (In production `now` is `Date.now`; a genuinely slow expression hits // this the same way — it just needs 50 ms of real wall time.) const ast = parseFormula("1 + 1"); const T0 = 1_000_000; let calls = 0; const opts = { now: () => { calls += 1; return calls === 1 ? T0 : T0 + 51; // 1st sets deadline, rest are 51 ms later }, }; let caught: unknown; try { evaluateFormula(ast, ctx({}), opts); } catch (e) { caught = e; } expect(caught).toBeInstanceOf(FormulaError); expect((caught as FormulaError).code).toBe("FORMULA_TIMEOUT"); expect((caught as FormulaError).message).toMatch(/wall-clock/i); }); it("wall-clock budget does NOT trip for a fast eval (injected steady clock)", () => { // A steady (non-advancing) clock stays within 50 ms → no timeout, the // value is computed normally. Confirms the guard is clock-relative. const ast = parseFormula("1 + 1"); const startedAt = 1_000_000; const { value } = evaluateFormula(ast, ctx({}), { now: () => startedAt }); expect(value.v).toBe(2); }); it("budget: steps counted per node (under the 100k op limit)", () => { const { steps } = evaluateFormula(parseFormula("1 + 1"), ctx({})); expect(steps).toBeGreaterThan(0); expect(steps).toBeLessThan(MAX_STEPS); }); }); describe("formula: validateFormula (save-time)", () => { it("spec example: returnType number, deps [Status]", () => { const v = validateFormula( 'if(prop("Status") == "Done", 100, 0)', { Status: "Done" }, { Status: "select" }, ); expect(v.returnType).toBe("number"); expect(v.dependencies).toEqual(["Status"]); expect(v.usesNow).toBe(false); }); it("concat infers string, deps sorted", () => { const v = validateFormula( 'concat(prop("B"), prop("A"))', { A: "x", B: "y" }, { A: "text", B: "text" }, ); expect(v.returnType).toBe("string"); expect(v.dependencies).toEqual(["A", "B"]); }); it("now() sets usesNow", () => { const v = validateFormula("now()", {}); expect(v.usesNow).toBe(true); expect(v.returnType).toBe("date"); }); it("divergent if branches rejected at save (never stored)", () => { expect(() => validateFormula('if(true, 1, "x")', {}, {})).toThrow(FormulaError); }); it("round-trip: AST is JSON-serializable", () => { const v = validateFormula("sum([1, 2]) + prop('A')", { A: 3 }, { A: "number" }); const json = JSON.parse(JSON.stringify(v.ast)); expect(json).toEqual(v.ast); }); }); describe("formula: fuzz invariants (seeded random ASTs)", () => { // Deterministic PRNG (mulberry32) — the committed generator pattern. function mulberry32(seed: number) { return () => { seed |= 0; seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } const rand = mulberry32(20250615); const pick = (arr: readonly T[]): T => arr[Math.floor(rand() * arr.length)]!; function randomExpr(depth: number): string { if (depth > 3) return pick(["1", '"x"', "true", "null", "2025-06-01", "prop('A')", "[]", "[1, 2]"]); const kind = Math.floor(rand() * 8); if (kind === 0) return pick(["1", "2.5", "1e5", '"hello"', '""', "true", "false", "null", "2025-06-01"]); if (kind === 1) return "prop('A')"; if (kind === 2) return "prop('B')"; if (kind === 3) return `(${randomExpr(depth + 1)} ${pick(["+", "-", "*", "/"])} ${randomExpr(depth + 1)})`; if (kind === 4) return `(${randomExpr(depth + 1)} ${pick(["==", "!=", "<", "<=", ">", ">="])} ${randomExpr(depth + 1)})`; if (kind === 5) return `${pick(["and", "or"])}(${randomExpr(depth + 1)}, ${randomExpr(depth + 1)})`; if (kind === 6) return `${pick(["sum", "average", "min", "max", "length"])}(${pick([randomExpr(depth + 1), "[1, 2, 3]", "[]", "null"])})`; return `${pick(["if", "not"])}(${randomExpr(depth + 1)}${pick(["", `, ${randomExpr(depth + 1)}, ${randomExpr(depth + 1)}`])})`; } it("100 random expressions: never throws uncaught, never hangs", () => { const props = { A: 1, B: "2025-06-01" }; const types = { A: "number", B: "date" }; for (let i = 0; i < 100; i++) { const src = randomExpr(0); const t0 = Date.now(); try { const ast = parseFormula(src); try { evalFormulaValue(ast, ctx(props, types)); } catch (e) { expect(e).toBeInstanceOf(FormulaError); // only FormulaError may escape const code = (e as FormulaError).code; expect(["PARSE", "TYPE", "RUNTIME", "UNKNOWN_FUNCTION", "FORMULA_TIMEOUT"]).toContain(code); } } catch (e) { expect(e).toBeInstanceOf(FormulaError); } finally { expect(Date.now() - t0).toBeLessThan(1000); // 1 s watchdog } } }); });