import { describe, expect, it } from "vitest"; import { evalExpr, applyCalculations } from "./calc"; import type { Field } from "./types"; describe("evalExpr — arithmetic", () => { it("evaluates simple arithmetic", () => { expect(evalExpr("1 + 2", {})).toBe(3); expect(evalExpr("10 - 4", {})).toBe(6); expect(evalExpr("3 * 4", {})).toBe(12); expect(evalExpr("12 / 4", {})).toBe(3); expect(evalExpr("10 % 3", {})).toBe(1); }); it("honors operator precedence", () => { expect(evalExpr("2 + 3 * 4", {})).toBe(14); expect(evalExpr("(2 + 3) * 4", {})).toBe(20); expect(evalExpr("10 - 2 - 3", {})).toBe(5); // left-associative }); it("handles unary minus", () => { expect(evalExpr("-5 + 10", {})).toBe(5); expect(evalExpr("-(2 + 3)", {})).toBe(-5); }); it("returns 0 on divide by zero (graceful, not NaN)", () => { expect(evalExpr("5 / 0", {})).toBe(0); }); it("concatenates strings with +", () => { expect(evalExpr("'hello ' + 'world'", {})).toBe("hello world"); expect(evalExpr("'count: ' + 5", {})).toBe("count: 5"); }); }); describe("evalExpr — variables", () => { it("looks up bare identifiers from the values map", () => { expect(evalExpr("a + b", { a: 3, b: 4 })).toBe(7); }); it("returns null for missing variables", () => { expect(evalExpr("nonexistent", {})).toBeNull(); }); it("recognizes true/false/null constants", () => { expect(evalExpr("true", {})).toBe(true); expect(evalExpr("false", {})).toBe(false); expect(evalExpr("null", {})).toBeNull(); }); }); describe("evalExpr — comparisons & logic", () => { it("compares numbers", () => { expect(evalExpr("5 > 3", {})).toBe(true); expect(evalExpr("3 >= 3", {})).toBe(true); expect(evalExpr("2 < 1", {})).toBe(false); expect(evalExpr("4 == 4", {})).toBe(true); expect(evalExpr("4 != 5", {})).toBe(true); }); it("short-circuits && and ||", () => { expect(evalExpr("true && false", {})).toBe(false); expect(evalExpr("true || false", {})).toBe(true); expect(evalExpr("1 && 2", {})).toBe(true); // truthy && truthy → true }); it("handles ! unary", () => { expect(evalExpr("!true", {})).toBe(false); expect(evalExpr("!false", {})).toBe(true); expect(evalExpr("!0", {})).toBe(true); }); }); describe("evalExpr — functions", () => { it("if(condition, then, else)", () => { expect(evalExpr("if(true, 1, 2)", {})).toBe(1); expect(evalExpr("if(false, 1, 2)", {})).toBe(2); expect(evalExpr("if(0, 'a', 'b')", {})).toBe("b"); }); it("sum, avg, min, max flatten and tolerate non-numbers", () => { expect(evalExpr("sum(1, 2, 3)", {})).toBe(6); expect(evalExpr("avg(2, 4, 6)", {})).toBe(4); expect(evalExpr("min(5, 2, 8)", {})).toBe(2); expect(evalExpr("max(5, 2, 8)", {})).toBe(8); }); it("avg on empty input returns 0, not NaN", () => { expect(evalExpr("avg()", {})).toBe(0); }); it("count skips empty values", () => { expect(evalExpr("count(1, '', 2, null, 3)", {})).toBe(3); }); it("len on strings and arrays", () => { expect(evalExpr("len('hello')", {})).toBe(5); expect(evalExpr("len(items)", { items: [1, 2, 3] })).toBe(3); }); it("num coerces to a number", () => { expect(evalExpr("num('42')", {})).toBe(42); expect(evalExpr("num(null)", {})).toBe(0); }); }); describe("evalExpr — safety", () => { it("returns null on syntax errors (doesn't throw)", () => { expect(evalExpr("1 +", {})).toBeNull(); expect(evalExpr("(((", {})).toBeNull(); expect(evalExpr("@@@", {})).toBeNull(); }); it("rejects unknown functions safely", () => { expect(evalExpr("nope(1, 2)", {})).toBeNull(); }); it("does not let identifiers be eval'd as JS (no prototype, no global)", () => { expect(evalExpr("constructor", {})).toBeNull(); expect(evalExpr("__proto__", {})).toBeNull(); expect(evalExpr("globalThis", {})).toBeNull(); }); }); describe("applyCalculations", () => { const fields: Field[] = [ { id: "a", type: "number", label: "A" }, { id: "b", type: "number", label: "B" }, { id: "sum_ab", type: "calculated", label: "Sum", expression: "a + b" }, { id: "doubled", type: "calculated", label: "Doubled", expression: "sum_ab * 2" }, ]; it("computes and injects calculated values", () => { const out = applyCalculations(fields, { a: 2, b: 3 }); expect(out.sum_ab).toBe(5); expect(out.doubled).toBe(10); }); it("computes downstream calculations after upstream ones (in field order)", () => { const out = applyCalculations(fields, { a: 10, b: 1 }); expect(out.sum_ab).toBe(11); expect(out.doubled).toBe(22); }); it("returns null for calculated fields without an expression", () => { const f: Field[] = [{ id: "x", type: "calculated", label: "x" }]; expect(applyCalculations(f, {}).x).toBeNull(); }); it("leaves non-calculated fields untouched", () => { const out = applyCalculations(fields, { a: 1, b: 2, extra: "kept" }); expect(out.extra).toBe("kept"); expect(out.a).toBe(1); }); });