1import { describe, expect, it } from "vitest";
2
3import {
4 parseFormula,
5 validateFormula,
6 evalFormulaValue,
7 evaluateFormula,
8 FormulaError,
9 MAX_STEPS,
10} from "./index.js";
11import type { FormulaContext } from "./types.js";
12
13const NOW = Date.parse("2025-06-15T12:00:00Z");
14const ctx = (props: Record<string, unknown>, types?: Record<string, string>): FormulaContext => ({
15 now: NOW,
16 properties: props,
17 propertyTypes: types,
18});
19
20const run = (
21 src: string,
22 properties: Record<string, unknown> = {},
23 types?: Record<string, string>,
24): unknown => evalFormulaValue(parseFormula(src), ctx(properties, types));
25
26const expectErr = (
27 code: string,
28 src: string,
29 properties: Record<string, unknown> = {},
30 types?: Record<string, string>,
31): void => {
32 let caught: unknown;
33 try {
34 evalFormulaValue(parseFormula(src), ctx(properties, types));
35 } catch (e) {
36 caught = e;
37 }
38 expect(caught, `expected FormulaError ${code} for "${src}"`).toBeInstanceOf(FormulaError);
39 expect((caught as FormulaError).code).toBe(code);
40};
41
42describe("formula: spec example", () => {
43 it('if(prop("Status") == "Done", 100, 0) → 100 / 0', () => {
44 expect(run('if(prop("Status") == "Done", 100, 0)', { Status: "Done" })).toBe(100);
45 expect(run('if(prop("Status") == "Done", 100, 0)', { Status: "Open" })).toBe(0);
46 });
47
48 it("ternary sugar parses to the same If node", () => {
49 const a = parseFormula("1 ? 2 : 3");
50 expect(a.type).toBe("If");
51 expect(run("true ? 5 : 6")).toBe(5);
52 });
53});
54
55describe("formula: functions (goldens)", () => {
56 it("sum/average/min/max empty-set rules (recorded asymmetry)", () => {
57 expect(run("sum([])")).toBe(0);
58 expect(run("average([])")).toBeNull();
59 expect(run("min([])")).toBeNull();
60 expect(run("max([])")).toBeNull();
61 expect(run("sum([1, 2, 3])")).toBe(6);
62 expect(run("average([1, 2, 3])")).toBe(2);
63 expect(run("max([1, 9, 4])")).toBe(9);
64 expect(run("min([5, 2, 8])")).toBe(2);
65 expect(run("sum([1, null, 2])")).toBe(3);
66 });
67
68 it("dateAdd month/year clamping + negative amounts", () => {
69 expect(run('dateAdd(2025-01-31, 1, "months")', {}, {})).toBe("2025-02-28");
70 expect(run('dateAdd(2025-01-31, 1, "years")')).toBe("2026-01-31");
71 expect(run('dateAdd(2025-06-15, -1, "months")')).toBe("2025-05-15");
72 expect(run('dateAdd(2025-06-15, 2, "weeks")')).toBe("2025-06-29");
73 expectErr("TYPE", 'dateAdd(2025-06-15, 1, "fortnights")');
74 });
75
76 it("dateBetween (b − a, negative allowed)", () => {
77 expect(run("dateBetween(2025-06-01, 2025-06-15)")).toBe(14);
78 expect(run("dateBetween(2025-06-15, 2025-06-01)")).toBe(-14);
79 });
80
81 it("now() reads ctx.now (injectable, documented nondeterminism)", () => {
82 expect(run("now()")).toBe("2025-06-15");
83 expect(run("now()", {}, undefined)).toBe("2025-06-15");
84 const { value } = evaluateFormula(parseFormula("now()"), {
85 now: Date.parse("2030-01-02T00:00:00Z"),
86 properties: {},
87 });
88 expect(value.v).toBe("2030-01-02");
89 });
90
91 it("concat stringify rules (number as-is, date ISO, null → '', array comma)", () => {
92 expect(run('concat("a", "b")')).toBe("ab");
93 expect(run("concat(1, 2)")).toBe("12");
94 expect(run('concat(null, "x")')).toBe("x");
95 expect(run('concat([1, 2], "!")')).toBe("1,2!");
96 expect(run('concat(2025-06-01, "!")', {}, {})).toBe("2025-06-01!");
97 });
98
99 it("replace: literal, all occurrences (not regex)", () => {
100 expect(run('replace("a.b.c", ".", "-")')).toBe("a-b-c");
101 expect(run('replace("aaa", "a", "b")')).toBe("bbb");
102 expect(run('replace("a*b", "*", "x")')).toBe("axb");
103 });
104
105 it("contains: case-sensitive (single function)", () => {
106 expect(run('contains("Hello", "ell")')).toBe(true);
107 expect(run('contains("Hello", "ELL")')).toBe(false);
108 });
109
110 it("length: string/array/null → length; number/date/bool → TYPE", () => {
111 expect(run('length("abcd")')).toBe(4);
112 expect(run("length([1, 2, 3])")).toBe(3);
113 expect(run("length(null)")).toBe(0);
114 expectErr("TYPE", "length(5)");
115 expectErr("TYPE", "length(true)");
116 expectErr("TYPE", "length(2025-06-01)");
117 });
118
119 it("and/or/not + laziness (untaken branch never errors)", () => {
120 expect(run("and(true, false)")).toBe(false);
121 expect(run("or(false, true)")).toBe(true);
122 expect(run("not(true)")).toBe(false);
123
124 expect(run("if(true, 1, 1/0)")).toBe(1);
125 expect(run("if(false, 1/0, 2)")).toBe(2);
126 expect(run("and(false, prop('Nope') == 1)")).toBe(false);
127 expectErr("TYPE", "and(1, true)");
128 });
129});
130
131describe("formula: null semantics matrix", () => {
132 const p = { A: null };
133 it("arithmetic null propagation", () => {
134 expect(run("prop('A') + 1", p)).toBeNull();
135 expect(run("1 + prop('A')", p)).toBeNull();
136 expect(run("prop('A') * 5", p)).toBeNull();
137 expect(run("prop('A') / 5", p)).toBeNull();
138 });
139 it("equality: null == null true, null == 0 false", () => {
140 expect(run("prop('A') == null", p)).toBe(true);
141 expect(run("prop('A') == 0", p)).toBe(false);
142 expect(run("prop('A') != 0", p)).toBe(true);
143 });
144 it("ordering with null → false; and/or null propagation", () => {
145 expect(run("prop('A') < 1", p)).toBe(false);
146 expect(run("null < 1")).toBe(false);
147 expect(run("and(null, true)")).toBeNull();
148 expect(run("or(null, false)")).toBeNull();
149 expect(run("not(null)")).toBeNull();
150 });
151});
152
153describe("formula: type matrix (4×4 for +, ==, <)", () => {
154 const types: Record<string, string> = { A: "number", B: "text", C: "date", D: "checkbox" };
155 const vals: Record<string, unknown> = { A: 1, B: "x", C: "2025-06-01", D: true };
156
157
158
159
160 it("4×4 matrix for ==, +, <", () => {
161 for (const la of ["A", "B", "C", "D"]) {
162 for (const rb of ["A", "B", "C", "D"]) {
163 const lt = types[la]!;
164 const rt = types[rb]!;
165
166
167 if (lt !== rt) expect(run(`prop('${la}') == prop('${rb}')`, vals, types)).toBe(false);
168 else expect(run(`prop('${la}') == prop('${rb}')`, vals, types)).toBe(true);
169
170 const okPlus = lt === "number" && rt === "number";
171 if (okPlus) expect(run(`prop('${la}') + prop('${rb}')`, vals, types)).toBe(2);
172 else expectErr("TYPE", `prop('${la}') + prop('${rb}')`, vals, types);
173
174 const okLt = lt === rt;
175 if (okLt) expect(typeof run(`prop('${la}') < prop('${rb}')`, vals, types)).toBe("boolean");
176 else expectErr("TYPE", `prop('${la}') < prop('${rb}')`, vals, types);
177 }
178 }
179 });
180 it("cross-type specifics", () => {
181 expect(run(`prop('A') == prop('B')`, vals, types)).toBe(false);
182 expectErr("TYPE", `prop('A') < prop('B')`, vals, types);
183 expectErr("TYPE", `prop('A') < prop('C')`, vals, types);
184 expectErr("TYPE", `prop('C') + 1`, vals, types);
185
186
187 expect(run(`prop('B') < "y"`, vals, types)).toBe(true);
188
189 expectErr("TYPE", `prop('C') < "2025-07-01"`, vals, types);
190 });
191});
192
193describe("formula: error classification", () => {
194 it("PARSE: unterminated, bad char, 17-deep nesting, trailing junk", () => {
195 expectErr("PARSE", '"abc');
196 expectErr("PARSE", "1 @ 2");
197 expectErr("PARSE", "(" + "(".repeat(17) + "1" + ")".repeat(17));
198 expectErr("PARSE", "1 + 2 3");
199 expectErr("PARSE", "unknownIdent");
200 });
201
202 it("UNKNOWN_FUNCTION at parse (closed table)", () => {
203 expectErr("UNKNOWN_FUNCTION", "foo(1)");
204 expectErr("UNKNOWN_FUNCTION", "eval('1+1')");
205 });
206
207 it("TYPE: unknown property (with Levenshtein suggestion)", () => {
208 let caught: unknown;
209 try {
210 run('prop("Statu")', { Status: 1 });
211 } catch (e) {
212 caught = e;
213 }
214 expect(caught).toBeInstanceOf(FormulaError);
215 expect((caught as FormulaError).code).toBe("TYPE");
216 expect((caught as FormulaError).message).toContain("Did you mean 'Status'?");
217 });
218
219 it("FORMULA_TIMEOUT: op limit (100k node visits) via a wide tree", () => {
220
221
222
223
224
225
226
227
228
229 let src = "and(";
230 for (let i = 0; i < 100_500; i++) src += "true, ";
231 src = src.slice(0, -2) + ")";
232 const t0 = Date.now();
233 let caught: unknown;
234 try {
235 evaluateFormula(parseFormula(src), ctx({}), { now: () => 0 });
236 } catch (e) {
237 caught = e;
238 }
239 expect(caught).toBeInstanceOf(FormulaError);
240 expect((caught as FormulaError).code).toBe("FORMULA_TIMEOUT");
241 expect((caught as FormulaError).message).toMatch(/op limit/i);
242 expect(Date.now() - t0).toBeLessThan(2000);
243 });
244
245 it("FORMULA_TIMEOUT: wall-clock budget (50 ms) via an injected clock", () => {
246
247
248
249
250
251
252 const ast = parseFormula("1 + 1");
253 const T0 = 1_000_000;
254 let calls = 0;
255 const opts = {
256 now: () => {
257 calls += 1;
258 return calls === 1 ? T0 : T0 + 51;
259 },
260 };
261 let caught: unknown;
262 try {
263 evaluateFormula(ast, ctx({}), opts);
264 } catch (e) {
265 caught = e;
266 }
267 expect(caught).toBeInstanceOf(FormulaError);
268 expect((caught as FormulaError).code).toBe("FORMULA_TIMEOUT");
269 expect((caught as FormulaError).message).toMatch(/wall-clock/i);
270 });
271
272 it("wall-clock budget does NOT trip for a fast eval (injected steady clock)", () => {
273
274
275 const ast = parseFormula("1 + 1");
276 const startedAt = 1_000_000;
277 const { value } = evaluateFormula(ast, ctx({}), { now: () => startedAt });
278 expect(value.v).toBe(2);
279 });
280
281 it("budget: steps counted per node (under the 100k op limit)", () => {
282 const { steps } = evaluateFormula(parseFormula("1 + 1"), ctx({}));
283 expect(steps).toBeGreaterThan(0);
284 expect(steps).toBeLessThan(MAX_STEPS);
285 });
286});
287
288describe("formula: validateFormula (save-time)", () => {
289 it("spec example: returnType number, deps [Status]", () => {
290 const v = validateFormula(
291 'if(prop("Status") == "Done", 100, 0)',
292 { Status: "Done" },
293 { Status: "select" },
294 );
295 expect(v.returnType).toBe("number");
296 expect(v.dependencies).toEqual(["Status"]);
297 expect(v.usesNow).toBe(false);
298 });
299
300 it("concat infers string, deps sorted", () => {
301 const v = validateFormula(
302 'concat(prop("B"), prop("A"))',
303 { A: "x", B: "y" },
304 { A: "text", B: "text" },
305 );
306 expect(v.returnType).toBe("string");
307 expect(v.dependencies).toEqual(["A", "B"]);
308 });
309
310 it("now() sets usesNow", () => {
311 const v = validateFormula("now()", {});
312 expect(v.usesNow).toBe(true);
313 expect(v.returnType).toBe("date");
314 });
315
316 it("divergent if branches rejected at save (never stored)", () => {
317 expect(() => validateFormula('if(true, 1, "x")', {}, {})).toThrow(FormulaError);
318 });
319
320 it("round-trip: AST is JSON-serializable", () => {
321 const v = validateFormula("sum([1, 2]) + prop('A')", { A: 3 }, { A: "number" });
322 const json = JSON.parse(JSON.stringify(v.ast));
323 expect(json).toEqual(v.ast);
324 });
325});
326
327describe("formula: fuzz invariants (seeded random ASTs)", () => {
328
329 function mulberry32(seed: number) {
330 return () => {
331 seed |= 0;
332 seed = (seed + 0x6d2b79f5) | 0;
333 let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
334 t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
335 return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
336 };
337 }
338 const rand = mulberry32(20250615);
339 const pick = <T>(arr: readonly T[]): T => arr[Math.floor(rand() * arr.length)]!;
340
341 function randomExpr(depth: number): string {
342 if (depth > 3)
343 return pick(["1", '"x"', "true", "null", "2025-06-01", "prop('A')", "[]", "[1, 2]"]);
344 const kind = Math.floor(rand() * 8);
345 if (kind === 0)
346 return pick(["1", "2.5", "1e5", '"hello"', '""', "true", "false", "null", "2025-06-01"]);
347 if (kind === 1) return "prop('A')";
348 if (kind === 2) return "prop('B')";
349 if (kind === 3)
350 return `(${randomExpr(depth + 1)} ${pick(["+", "-", "*", "/"])} ${randomExpr(depth + 1)})`;
351 if (kind === 4)
352 return `(${randomExpr(depth + 1)} ${pick(["==", "!=", "<", "<=", ">", ">="])} ${randomExpr(depth + 1)})`;
353 if (kind === 5)
354 return `${pick(["and", "or"])}(${randomExpr(depth + 1)}, ${randomExpr(depth + 1)})`;
355 if (kind === 6)
356 return `${pick(["sum", "average", "min", "max", "length"])}(${pick([randomExpr(depth + 1), "[1, 2, 3]", "[]", "null"])})`;
357 return `${pick(["if", "not"])}(${randomExpr(depth + 1)}${pick(["", `, ${randomExpr(depth + 1)}, ${randomExpr(depth + 1)}`])})`;
358 }
359
360 it("100 random expressions: never throws uncaught, never hangs", () => {
361 const props = { A: 1, B: "2025-06-01" };
362 const types = { A: "number", B: "date" };
363 for (let i = 0; i < 100; i++) {
364 const src = randomExpr(0);
365 const t0 = Date.now();
366 try {
367 const ast = parseFormula(src);
368 try {
369 evalFormulaValue(ast, ctx(props, types));
370 } catch (e) {
371 expect(e).toBeInstanceOf(FormulaError);
372 const code = (e as FormulaError).code;
373 expect(["PARSE", "TYPE", "RUNTIME", "UNKNOWN_FUNCTION", "FORMULA_TIMEOUT"]).toContain(code);
374 }
375 } catch (e) {
376 expect(e).toBeInstanceOf(FormulaError);
377 } finally {
378 expect(Date.now() - t0).toBeLessThan(1000);
379 }
380 }
381 });
382});
383