import { describe, it, expect, beforeAll } from "vitest"; import { SumEngine } from "./index"; import { qty, EvalError, XRefError } from "./types"; import type { XRefResolution, EvalCtx } from "./evaluator"; import { evaluate } from "./evaluator"; import { formatValue } from "./formatter"; let eng: SumEngine; beforeAll(() => { eng = new SumEngine(); // deterministic rates for tests eng.setRates({ USD: 1, EUR: 0.5, RUB: 100, GBP: 0.25, BTC: 0.00001 }); }); function calc(expr: string): string | null { const r = eng.evaluateDocument(expr); return r[r.length - 1].text; } function calcDoc(doc: string): (string | null)[] { return eng.evaluateDocument(doc).map((r) => r.text); } describe("arithmetic", () => { it("basic ops", () => { expect(calc("2 + 2")).toBe("4"); expect(calc("7 - 10")).toBe("-3"); expect(calc("6 * 7")).toBe("42"); expect(calc("1 / 8")).toBe("0.13"); expect(calc("2 ^ 10")).toBe("1,024"); expect(calc("10 mod 3")).toBe("1"); expect(calc("5!")).toBe("120"); }); it("decimal precision (no float artifacts)", () => { expect(calc("0.1 + 0.2")).toBe("0.3"); }); it("parens and precedence", () => { expect(calc("2 + 3 * 4")).toBe("14"); expect(calc("(2 + 3) * 4")).toBe("20"); expect(calc("2(3+4)")).toBe("14"); }); it("word operators", () => { expect(calc("5 plus 3")).toBe("8"); expect(calc("10 minus 4")).toBe("6"); expect(calc("3 times 4")).toBe("12"); expect(calc("20 divided by 4")).toBe("5"); expect(calc("5 и 3")).toBe("8"); // ru plus }); it("thousands separators in input", () => { expect(calc("1,000 + 234")).toBe("1,234"); }); it("scales", () => { expect(calc("1.5 million")).toBe("1,500,000"); expect(calc("5k")).toBe("5,000"); expect(calc("2 trillion")).toBe("2,000,000,000,000"); expect(calc("1.5 трлн")).toBe("1,500,000,000,000"); expect(calc("5к")).toBe("5,000"); expect(calc("5кк")).toBe("5,000,000"); expect(calc("2ккк")).toBe("2,000,000,000"); expect(calc("2 dozen")).toBe("2"); // dozen is not in the vocab — noise }); it("free text around math is ignored", () => { expect(calc("I spent 20 on pizza plus 5 on coffee")).toBe("25"); }); it("constants", () => { expect(calc("pi * 2")).toBe("6.28"); }); it("functions", () => { expect(calc("sqrt(16)")).toBe("4"); expect(calc("sqrt 16")).toBe("4"); expect(calc("square root of 16")).toBe("4"); expect(calc("cbrt(27)")).toBe("3"); expect(calc("abs(-5)")).toBe("5"); expect(calc("round(2.6)")).toBe("3"); expect(calc("log(1000)")).toBe("3"); expect(calc("ln(1)")).toBe("0"); // russian aliases expect(calc("квадратный корень из 16")).toBe("4"); expect(calc("кубический корень из 27")).toBe("3"); expect(calc("модуль(-5)")).toBe("5"); expect(calc("логарифм(1000)")).toBe("3"); expect(calc("натуральный логарифм(1)")).toBe("0"); expect(calc("округлить(2.6)")).toBe("3"); }); it("fraction constants", () => { expect(calc("a half")).toBe("0.5"); expect(calc("one half")).toBe("0.5"); expect(calc("половина")).toBe("0.5"); expect(calc("a half million")).toBe("500,000"); expect(calc("one and a half")).toBe("1.5"); expect(calc("полтора")).toBe("1.5"); expect(calc("полтора миллиона")).toBe("1,500,000"); expect(calc("полторы тысячи")).toBe("1,500"); }); }); describe("percent", () => { it("sample: 8 / (45 - 20%)", () => { expect(calc("8 / (45 - 20%)")).toBe("0.22"); }); it("plus/minus percent", () => { expect(calc("100 + 20%")).toBe("120"); expect(calc("100 - 20%")).toBe("80"); }); it("of / off / on", () => { expect(calc("20% of 100")).toBe("20"); expect(calc("20% off 100")).toBe("80"); expect(calc("20% on 100")).toBe("120"); }); it("as a % of", () => { expect(calc("5 as a % of 25")).toBe("20%"); }); it("of what is", () => { expect(calc("20% of what is 30")).toBe("150"); expect(calc("25% off what is 60")).toBe("80"); }); it("percent arithmetic", () => { expect(calc("5% + 5%")).toBe("10%"); expect(calc("100 * 20%")).toBe("20"); }); }); describe("units", () => { it("sample: 5 inches in cm", () => { expect(calc("5 inches in cm")).toBe("12.7 cm"); }); it("length", () => { expect(calc("1 mile in km")).toBe("1.61 km"); expect(calc("100 km in miles")).toBe("62.14 mi."); expect(calc("6 feet in meters")).toBe("1.83 m"); }); it("prefixed symbols", () => { expect(calc("10 mm in cm")).toBe("1 cm"); expect(calc("1 km in m")).toBe("1,000 m"); }); it("weight", () => { expect(calc("1 kg in pounds")).toBe("2.2 lb"); expect(calc("16 oz in pounds")).toBe("1 lb"); }); it("temperature", () => { expect(calc("100 C in F")).toBe("212°F"); expect(calc("32 F in C")).toBe("0°C"); expect(calc("0 C in K")).toBe("273.15 K"); }); it("volume", () => { expect(calc("1 gallon in liters")).toBe("3.79 L"); }); it("area", () => { expect(calc("1 hectare in acres")).toBe("2.47 acre"); expect(calc("2 m * 3 m")).toBe("6 m²"); }); it("data with SI and IEC prefixes", () => { expect(calc("1 GB in MB")).toBe("1,000 MB"); expect(calc("1 GiB in MiB")).toBe("1,024 MiB"); expect(calc("1 GB in MiB")).toBe("953.67 MiB"); expect(calc("1 byte in bits")).toBe("8 b"); }); it("css", () => { expect(calc("32 px in em")).toBe("2 em"); expect(calc("1 inch in px")).toBe("96 px"); }); it("time units", () => { expect(calc("90 minutes in hours")).toBe("1.5 h"); expect(calc("2 hours 30 minutes in minutes")).toBe("150 min"); }); it("unit math keeps the left unit", () => { expect(calc("1 m + 50 cm")).toBe("1.5 m"); expect(calc("5 ft 4 in in cm")).toBe("162.56 cm"); }); it("angle trig", () => { expect(calc("sin(90 degrees)")).toBe("1"); }); it("russian unit phrases", () => { expect(calc("5 метров в см")).toBe("500 cm"); }); }); describe("currency", () => { it("sample: $9 in Euro (test rate 1 USD = 0.5 EUR)", () => { expect(calc("$9 in Euro")).toBe("€ 4.5"); }); it("postfix and codes", () => { expect(calc("100 USD in RUB")).toBe("10,000 ₽"); expect(calc("100 рублей в долларах")).toBe("$1"); }); it("currency arithmetic", () => { expect(calc("$10 + $5")).toBe("$15"); expect(calc("$10 + 20%")).toBe("$12"); }); it("GBP↔RUB via natural phrases", () => { // "sterling" and "quid" as English shortcuts (bare "pounds" is weight, not GBP) expect(calc("100 sterling in RUB")).toBe("40,000 ₽"); expect(calc("100 quid in rubles")).toBe("40,000 ₽"); // Russian: "стерлинг" (bare "фунт" is weight unit, not GBP) expect(calc("100 стерлингов в рублях")).toBe("40,000 ₽"); // existing multi-word forms still work expect(calc("100 GBP in RUB")).toBe("40,000 ₽"); expect(calc("£100 in RUB")).toBe("40,000 ₽"); }); }); describe("numeral systems", () => { it("input", () => { expect(calc("0x1F")).toBe("0x1F"); expect(calc("0b101 in decimal")).toBe("5"); }); it("conversion", () => { expect(calc("255 in hex")).toBe("0xFF"); expect(calc("5 in binary")).toBe("0b101"); expect(calc("8 in octal")).toBe("0o10"); }); it("hex arithmetic keeps repr of the left operand", () => { expect(calc("0x10 + 0x10 in decimal")).toBe("32"); }); }); describe("variables and document context", () => { it("sequence error recovery", () => { eng.addFunction({ id: "throw_err", phrases: "throw_err" }, () => { throw new EvalError("test error"); }); expect(calc("throw_err(1) 2 hours")).toBe("2 h"); }); it("assignment and reuse", () => { expect(calcDoc("x = 5\nx * 2")).toEqual(["5", "10"]); }); it("is-assignment", () => { expect(calcDoc("price is 100\nprice + 20%")).toEqual(["100", "120"]); }); it("sum / total", () => { expect(calcDoc("10\n20\n30\nsum")).toEqual(["10", "20", "30", "60"]); expect(calcDoc("10\n20\n\n30\nsum")).toEqual(["10", "20", null, "30", "30"]); }); it("sum with units", () => { expect(calcDoc("$10\n$20\ntotal")).toEqual(["$10", "$20", "$30"]); }); it("avg", () => { expect(calcDoc("10\n20\naverage")).toEqual(["10", "20", "15"]); }); it("prev", () => { expect(calcDoc("42\nprev + 1")).toEqual(["42", "43"]); }); it("count", () => { expect(calcDoc("10\n20\n30\ncount")).toEqual(["10", "20", "30", "3"]); expect(calcDoc("10\n20\n\n30\ncount")).toEqual(["10", "20", null, "30", "1"]); }); it("min / max", () => { expect(calcDoc("10\n20\n5\nmin")).toEqual(["10", "20", "5", "5"]); expect(calcDoc("10\n20\n5\nmax")).toEqual(["10", "20", "5", "20"]); }); it("min / max with units", () => { expect(calcDoc("10 m\n5 m\n20 m\nmin")).toEqual(["10 m", "5 m", "20 m", "5 m"]); expect(calcDoc("10 m\n5 m\n20 m\nmax")).toEqual(["10 m", "5 m", "20 m", "20 m"]); }); it("product", () => { expect(calcDoc("2\n3\n4\nproduct")).toEqual(["2", "3", "4", "24"]); }); it("random — deterministic per line text", () => { const [a] = calcDoc("random(1, 6)"); const [b] = calcDoc("random(1, 6)"); expect(a).toBe(b); // same text → same seed → same result const [c] = calcDoc("random(1, 6) "); expect(c).not.toBe(a); // different text → different seed }); it("random — stays in range", () => { const v = Number(calc("random(1, 100)")); expect(v).toBeGreaterThanOrEqual(1); expect(v).toBeLessThanOrEqual(100); }); it("headers split blocks and produce no result", () => { expect(calcDoc("# Food\n10\n20\nsum")).toEqual([null, "10", "20", "30"]); }); it("comments", () => { expect(calcDoc("// just a comment\n5 + 5 // inline")).toEqual([null, "10"]); }); it("label lines produce no result", () => { expect(calc("Monthly costs")).toBe(null); }); }); describe("dates", () => { it("today + 2 weeks lands 14 days ahead", () => { const r = eng.evaluateDocument("today + 2 weeks - today"); // (today + 2w) - today = 14 days expect(r[0].text).toBe("14 day"); }); it("tomorrow - today = 1 day", () => { expect(calc("tomorrow - today")).toBe("1 day"); }); it("time in a timezone returns a formatted time", () => { const t = calc("time in Tokyo"); expect(t).toMatch(/^\d{2}:\d{2}$/); }); it("ISO date literal produces a formatted date", () => { const r = calc("2024-01-01"); expect(r).not.toBeNull(); expect(typeof r).toBe("string"); }); it("ISO date subtraction: 2026-01-01 - 2024-01-01 = 731 days", () => { expect(calc("2026-01-01 - 2024-01-01")).toBe("731 day"); }); it("days until future date returns positive days", () => { const r = calc("days until 2099-01-01"); expect(r).not.toBeNull(); expect(Number(r!.replace(/[^\d.-]/g, ""))).toBeGreaterThan(0); }); it("days since past date returns positive days", () => { const r = calc("days since 2000-01-01"); expect(r).not.toBeNull(); expect(Number(r!.replace(/[^\d.-]/g, ""))).toBeGreaterThan(0); }); }); describe("historical rates", () => { it("in EUR on date uses injected historical rate", () => { eng.setHistoricalRates("2024-01-01", { EUR: 0.9, USD: 1 }); expect(calc("1000 USD in EUR on 2024-01-01")).toBe("€ 900"); }); it("missing historical date returns null", () => { expect(calc("500 USD in EUR on 1800-01-01")).toBeNull(); }); }); describe("goal seek", () => { it("? * 1.2 = 1000", () => expect(calc("? * 1.2 = 1000")).toBe("833.33")); it("? + 50 = 200", () => expect(calc("? + 50 = 200")).toBe("150")); it("100 - ? = 30", () => expect(calc("100 - ? = 30")).toBe("70")); it("? / 4 = 5", () => expect(calc("? / 4 = 5")).toBe("20")); it("? - ? = 5 has no solution", () => expect(calc("? - ? = 5")).toBeNull()); }); describe("extension API", () => { it("addUnit (horse from Sample.js)", () => { eng.addUnit({ id: "horse", phrases: "horse, horses, hrs", baseUnitId: "meter", format: "hrs", ratio: 2.4 }); expect(calc("2 horses in meters")).toBe("4.8 m"); }); it("addUnit throws on id collision instead of silently corrupting unitsById", () => { expect(() => eng.addUnit({ id: "horse", phrases: "nag", baseUnitId: "meter", format: "nag", ratio: 99 }) ).toThrow(/horse/); // the original registration must survive the failed collision expect(calc("2 horses in meters")).toBe("4.8 m"); }); it("addFunction (zum from Sample.js)", () => { eng.addFunction({ id: "zum", phrases: "zum" }, (values) => ({ double: values[0].double + values[1].double })); expect(calc("zum(2;3)")).toBe("5"); }); it("setVariable", () => { eng.setVariable("myvar", { double: 5, unitId: "USD" }); expect(calc("myvar * 2")).toBe("$10"); }); }); function calcWithResolver( expr: string, resolver: (sheet: string, key: string) => XRefResolution, ): string | null { const r = eng.evaluateDocument(expr, resolver); return r[r.length - 1].text; } describe("grand total", () => { it("totalValueOf's raw value formats identically to totalOf's string", () => { const results = eng.evaluateDocument("$100\n$50"); const raw = eng.totalValueOf(results); expect(raw).not.toBeNull(); expect(formatValue(raw!, eng.settings)).toBe(eng.totalOf(results)); }); }); describe("cross-sheet references", () => { function resolver(sheet: string, key: string): XRefResolution { const s = sheet.toLowerCase(); if (s === "budget") { if (key === "total") return { ok: true, value: qty(150) }; if (key === "rent") return { ok: true, value: qty(500, eng.reg.unitsById.get("meter")!) }; if (key === "last") return { ok: true, value: qty(7) }; return { ok: false, reason: `no variable "${key}" in "${sheet}"` }; } if (s === "trip to lisbon") { if (key === "food") return { ok: true, value: qty(25) }; return { ok: false, reason: `no variable "${key}" in "${sheet}"` }; } return { ok: false, reason: `sheet "${sheet}" not found` }; } it("resolves @Sheet.total", () => { expect(calcWithResolver("@Budget.total", resolver)).toBe("150"); }); it("resolves @Sheet.var and participates in arithmetic", () => { expect(calcWithResolver("@Budget.rent + 1 m", resolver)).toBe("501 m"); }); it("resolves @Sheet.last", () => { expect(calcWithResolver("@Budget.last", resolver)).toBe("7"); }); it("unit conversion over a resolved reference", () => { expect(calcWithResolver("@Budget.rent in cm", resolver)).toBe("50,000 cm"); }); it("bracket form for titles with spaces", () => { expect(calcWithResolver("@[Trip to Lisbon].food", resolver)).toBe("25"); }); it("unresolved sheet sets a line error instead of crashing", () => { const r = eng.evaluateDocument("@Nope.total", resolver); expect(r[0].value).toBeNull(); expect(r[0].error).toBe('sheet "Nope" not found'); }); it("unresolved key sets a line error", () => { const r = eng.evaluateDocument("@Budget.nope", resolver); expect(r[0].value).toBeNull(); expect(r[0].error).toBe('no variable "nope" in "Budget"'); }); it("ordinary successful line leaves LineResult.error undefined", () => { const r = eng.evaluateDocument("$100", resolver); expect(r[0].error).toBeUndefined(); }); it("propagates a resolver-reported circular reference", () => { const cyc = (): XRefResolution => ({ ok: false, reason: "circular reference" }); const r = eng.evaluateDocument("@A.total", cyc); expect(r[0].value).toBeNull(); expect(r[0].error).toBe("circular reference"); }); it("no resolver given: resolves to null without throwing", () => { expect(() => eng.evaluateDocument("@Budget.total")).not.toThrow(); const r = eng.evaluateDocument("@Budget.total"); expect(r[0].value).toBeNull(); expect(r[0].error).toBe('sheet "Budget" not found'); }); it("an assignment line reports the assigned name via LineResult.assign", () => { const r = eng.evaluateDocument("rent = $500"); expect(r[0].assign).toBe("rent"); }); }); describe("evaluation errors", () => { it("throws EvalError for unknown variable", () => { const ctx = { vars: new Map(), reg: eng.reg, historicalRates: {}, } as unknown as EvalCtx; expect(() => evaluate({ k: "var", name: "unknown" }, ctx)).toThrowError(EvalError); }); it("throws XRefError for unresolved xref", () => { const ctx = { vars: new Map(), reg: eng.reg, historicalRates: {}, resolveXRef: () => ({ ok: false, reason: "xref error" }), } as unknown as EvalCtx; expect(() => evaluate({ k: "xref", sheet: "Sheet", key: "key" }, ctx)).toThrowError(XRefError); }); });