import { describe, expect, it } from "vitest"; import { RestrictedFormulaEngine } from "./engine"; const engine = new RestrictedFormulaEngine(); describe("RestrictedFormulaEngine", () => { it("uses arithmetic precedence and parentheses", () => { const result = engine.evaluate([ { key: "base", value: "10" }, { key: "flat", value: "2" }, { key: "scaled", formula: "base + flat * 3", displayPrecision: 0 }, { key: "grouped", formula: "(base + flat) * 3", displayPrecision: 0 }, ]); expect(result.fields.scaled.canonical).toBe("16"); expect(result.fields.grouped.canonical).toBe("36"); expect(result.order).toEqual(["base", "flat", "scaled", "grouped"]); }); it("supports subtraction, percentages, unary signs, and Thai field names", () => { const result = engine.evaluate([ { key: "พลังพื้นฐาน", value: "200" }, { key: "โบนัส", value: "25" }, { key: "รวม", formula: "-(-พลังพื้นฐาน) + พลังพื้นฐาน * โบนัส% - 10", displayPrecision: 0, }, ]); expect(result.fields["รวม"].canonical).toBe("240"); expect(result.fields["รวม"].display).toBe("240"); }); it("maps imported A1 references to stable field keys", () => { const result = engine.evaluate( [ { key: "attack", value: "120" }, { key: "bonus", value: "30" }, { key: "total", formula: "=(A1 + B1) / 2", displayPrecision: 1 }, ], { referenceAliases: { A1: "attack", B1: "bonus" } }, ); expect(result.fields.total.canonical).toBe("75"); expect(result.fields.total.display).toBe("75.0"); expect(result.fields.total.dependencies).toEqual(["attack", "bonus"]); }); it("keeps canonical values unrounded and rounds half-up only for display", () => { const result = engine.evaluate([ { key: "source", value: "1.005", displayPrecision: 3 }, { key: "shown", formula: "source", displayPrecision: 2 }, { key: "dependent", formula: "shown * 1000", displayPrecision: 0 }, ]); expect(result.fields.shown.canonical).toBe("1.005"); expect(result.fields.shown.display).toBe("1.01"); expect(result.fields.dependent.canonical).toBe("1005"); }); it("returns typed blank and missing reference errors", () => { const result = engine.evaluate([ { key: "blank", value: "" }, { key: "usesBlank", formula: "blank + 1" }, { key: "missing", formula: "unknown + 1" }, ]); expect(result.fields.blank.error?.code).toBe("blank-reference"); expect(result.fields.usesBlank.error).toMatchObject({ code: "blank-reference", reference: "blank", }); expect(result.fields.missing.error).toMatchObject({ code: "missing-reference", reference: "unknown", }); }); it("detects cycles and propagates dependency errors", () => { const result = engine.evaluate([ { key: "a", formula: "b + 1" }, { key: "b", formula: "c + 1" }, { key: "c", formula: "a + 1" }, { key: "downstream", formula: "a * 2" }, ]); expect(result.fields.a.error?.code).toBe("circular-reference"); expect(result.fields.b.error?.code).toBe("circular-reference"); expect(result.fields.c.error?.code).toBe("circular-reference"); expect(result.fields.downstream.error).toMatchObject({ code: "dependency-error", cause: "circular-reference", }); }); it("reports division by zero without affecting unrelated fields", () => { const result = engine.evaluate([ { key: "bad", formula: "10 / (2 - 2)" }, { key: "dependent", formula: "bad + 1" }, { key: "good", formula: "6 / 2", displayPrecision: 0 }, ]); expect(result.fields.bad.error?.code).toBe("division-by-zero"); expect(result.fields.dependent.error).toMatchObject({ code: "dependency-error", cause: "division-by-zero", }); expect(result.fields.good.display).toBe("3"); }); it("rejects functions, absolute references, and executable syntax", () => { for (const formula of ["SUM(a)", "$A$1 + 1", "globalThis.value", "a ** 2"]) { const result = engine.evaluate([{ key: "value", formula }]); expect(result.fields.value.error?.code).toBe("parse-error"); } }); it("reports operator metadata for regression corpus assertions", () => { expect(engine.analyze("=(A1 + 0.5) / -B1%")).toEqual({ additions: 1, subtractions: 0, multiplications: 0, divisions: 1, percentages: 1, unarySigns: 1, decimalLiterals: 1, references: ["A1", "B1"], }); }); });