Files
buzz-sheet/lib/formula/engine.test.ts
T

133 lines
4.6 KiB
TypeScript

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"],
});
});
});