summarum/src/engine/edge.test.ts
Horizontal42 cba2286371
🧹 [Extract evaluateText helper in tests] (#109)
Extracted `evaluateText` helper function in `src/engine/edge.test.ts` to replace the repeated mapping `eng.evaluateDocument(doc).map((x) => x.text)`.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-08-19 16:35:37 +03:00

329 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, it, expect, beforeAll } from "vitest";
import { SumEngine } from "./index";
let eng: SumEngine;
beforeAll(() => {
eng = new SumEngine();
eng.setRates({ USD: 1, EUR: 0.5, RUB: 100, GBP: 0.25 });
});
function calc(expr: string): string | null {
const r = eng.evaluateDocument(expr);
return r[r.length - 1].text;
}
function evaluateText(doc: string): (string | null)[] {
return eng.evaluateDocument(doc).map((x) => x.text);
}
describe("edge cases", () => {
it("russian mixed sentence", () => {
expect(calc("потратил 20$ и ещё 5$")).toBe("$25");
});
it("russian conversion to currency", () => {
expect(calc("20$ в рублях")).toBe("2,000 ₽");
});
it("generic 'доллар' means USD, not TTD", () => {
expect(calc("1 доллар")).toBe("$1");
expect(calc("1 доллар в рублях")).toBe("100 ₽");
});
it("chained percent discounts", () => {
expect(calc("100 - 10% - 10%")).toBe("81");
});
it("conversion chain", () => {
expect(calc("1 km in m in cm")).toBe("100,000 cm");
});
it("euro symbol", () => {
expect(calc("€10 + €5")).toBe("€ 15");
});
it("pow precedence", () => {
expect(calc("2 * 3^2")).toBe("18");
expect(calc("-2^2")).toBe("-4");
});
it("last expression wins for unrelated numbers", () => {
expect(calc("page 10 results 2 + 3")).toBe("5");
});
it("empty and label lines give no result", () => {
expect(calc("")).toBe(null);
expect(calc("hello world")).toBe(null);
});
it("sum stops at header", () => {
const r = evaluateText("5\n# section\n10\n20\nsum");
expect(r).toEqual(["5", null, "10", "20", "30"]);
});
it("variables with units survive math", () => {
const r = evaluateText("rent = $500\nfood = $200\nrent + food");
expect(r).toEqual(["$500", "$200", "$700"]);
});
it("big numbers with grouping", () => {
expect(calc("1000000 * 2")).toBe("2,000,000");
});
it("division by zero yields no result", () => {
expect(calc("5 / 0")).toBe(null);
});
it("trailing garbage does not break expression", () => {
expect(calc("2 + 2 =")).toBe("4");
});
it("scientific output", () => {
expect(calc("123456 in scientific")).toBe("1.23e5"); // mantissa follows precision setting
});
it("GiB in GB", () => {
expect(calc("1 GiB in GB")).toBe("1.07 GB");
});
it("Kelvin word", () => {
expect(calc("300 kelvin in celsius")).toBe("26.85°C");
});
it("milliseconds", () => {
expect(calc("1500 ms in seconds")).toBe("1.5 s");
});
it("square meters word form", () => {
expect(calc("3 square meters in sq ft")).toBe("32.29 ft²");
});
it("multiplication of money by count", () => {
expect(calc("$12 * 3")).toBe("$36");
});
});
describe("extra units", () => {
it("speed", () => {
expect(calc("100 km/h in mph")).toBe("62.14 mph");
expect(calc("60 mph in km/h")).toBe("96.56 km/h");
expect(calc("10 m/s in km/h")).toBe("36 km/h");
});
it("pressure", () => {
expect(calc("1 atm in mmHg")).toBe("760 mmHg");
expect(calc("2 bar in psi")).toBe("29.01 psi");
});
it("energy", () => {
expect(calc("1 kwh in kJ")).toBe("3,600 kJ");
expect(calc("500 kcal in kJ")).toBe("2,092 kJ");
});
it("power", () => {
expect(calc("150 hp in kW")).toBe("111.85 kW");
expect(calc("100 лс в kW")).toBe("73.55 kW");
});
it("frequency", () => {
expect(calc("3000 rpm in Hz")).toBe("50 Hz");
});
it("fuel (reciprocal mpg)", () => {
expect(calc("8 l/100km in mpg")).toBe("29.4 mpg");
expect(calc("30 mpg in l/100km")).toBe("7.84 L/100km");
});
});
describe("bitwise and unix", () => {
it("bit ops", () => {
expect(calc("0xF0 | 0x0F")).toBe("0xFF");
expect(calc("0xFF & 0x0F")).toBe("0xF");
expect(calc("5 xor 3")).toBe("6");
expect(calc("1 << 4")).toBe("16");
expect(calc("1 shl 4")).toBe("16");
expect(calc("256 >> 4")).toBe("16");
});
it("unix timestamps", () => {
expect(calc("today in unix")).toMatch(/^\d{10}$/);
const r = eng.evaluateDocument("1750000000 as date");
expect(r[0].value?.kind).toBe("date");
});
});
describe("fraction and roman", () => {
it("fractions", () => {
expect(calc("0.75 in fraction")).toBe("3/4");
expect(calc("1.5 in fraction")).toBe("1 1/2");
expect(calc("1/3 + 1/3 in fraction")).toBe("2/3");
});
it("roman", () => {
expect(calc("2026 in roman")).toBe("MMXXVI");
expect(calc("49 in roman")).toBe("XLIX");
});
});
describe("crypto", () => {
it("eth via snapshot/test rates", () => {
const r = calc("1 eth in USD");
expect(r).toMatch(/^\$/);
});
});
describe("regressions", () => {
it("a broken line does not kill the document (BigInt from NaN)", () => {
const r = evaluateText("2+2\nasin(5) & 3\n3+3");
expect(r).toEqual(["4", null, "6"]);
});
it("non-finite results are suppressed", () => {
expect(calc("100 / 0%")).toBe(null);
expect(calc("asin(5)")).toBe(null);
expect(calc("sqrt(-1)")).toBe(null);
});
it("arithmetic continues after a line-level conversion", () => {
expect(calc("100 USD in EUR + 20")).toBe("€ 70"); // 50 € + 20
expect(calc("100 USD in EUR - 20")).toBe("€ 30");
expect(calc("100 USD in EUR * 2")).toBe("€ 100");
expect(calc("1 km in m + 1")).toBe("1,001 m");
});
it("uppercase prefixed symbols are accepted when unambiguous", () => {
expect(calc("5 KM in miles")).toBe("3.11 mi.");
expect(calc("2 KG in pounds")).toBe("4.41 lb");
expect(calc("5 КМ в метрах")).toBe("5,000 m");
});
it("ambiguous case stays strict: MM is not megameters", () => {
expect(calc("5 MM in m")).toBe("5 m"); // unrecognized, bare 5 converted
});
it("space-grouped numbers", () => {
expect(calc("1 000 + 234")).toBe("1,234");
expect(calc("1 000 000 / 2")).toBe("500,000");
expect(calc("1 000,5 + 0,5")).toBe("1,001");
});
it("decimal comma", () => {
expect(calc("1,23 + 1")).toBe("2.23");
expect(calc("0,5 * 4")).toBe("2");
expect(calc("1,234 + 0")).toBe("1,234"); // exactly 3 digits = thousands
});
it("single-letter unit names can be variables", () => {
expect(evaluateText("m = 5\nm * 2")).toEqual(["5", "10"]);
expect(evaluateText("t = 100\nt + 20%")).toEqual(["100", "120"]);
});
it("tiny conversion results show significant digits, not 0", () => {
expect(calc("1 mm in km")).toBe("0.000001 km");
expect(calc("32 F in C")).toBe("0°C"); // conversion noise stays zero
});
it("log with a space is log10", () => {
expect(calc("log 1000")).toBe("3");
expect(calc("log2(8)")).toBe("3");
expect(calc("log 2 + 1")).toBe("1.3");
});
it("percent of a percent stays a percent", () => {
expect(calc("50% of 50%")).toBe("25%");
expect(calc("10% off 50%")).toBe("45%");
});
it("percent base on the left of ^", () => {
expect(calc("50% ^ 2")).toBe("0.25");
expect(calc("4 ^ 50%")).toBe("2");
});
it("avg skips incompatible lines in the denominator", () => {
expect(evaluateText("10 kg\n20 kg\n5 hours\navg")).toEqual(["10 kg", "20 kg", "5 h", "15 kg"]);
});
it("calendar month add clamps to month end", () => {
const doc = eng.evaluateDocument("today + 1 month");
const v = doc[0].value;
expect(v?.kind).toBe("date");
});
});
describe("review fixes", () => {
it("random() with no arguments returns a value in [0,1]", () => {
const r = calc("random()");
expect(r).not.toBeNull();
const v = Number(r);
expect(v).toBeGreaterThanOrEqual(0);
expect(v).toBeLessThanOrEqual(1);
});
it("random() participates in arithmetic", () => {
expect(calc("random() + 1")).not.toBeNull();
});
it("multiplying same-dimension quantities converts the right operand", () => {
expect(calc("2 kg * 500 g")).toBe("1 kg");
expect(calc("$12 * 3")).toBe("$36");
expect(calc("2 m * 3 m")).toBe("6 m²");
});
it("exp(x) is Euler's number to the power x, not scientific notation", () => {
expect(calc("exp(1)")).toBe("2.72");
expect(calc("exp(0)")).toBe("1");
});
it("scientific notation still works via sci/exponent/exponential", () => {
expect(calc("123456 in scientific")).toBe("1.23e5");
expect(calc("123456 in exponent")).toBe("1.23e5");
});
it("timezone names with a slash resolve as IANA ids", () => {
expect(calc("time in Europe/Berlin")).toMatch(/^\d{2}:\d{2}$/);
});
it("a URL in a line is not mistaken for a comment", () => {
const r = eng.evaluateDocument("see https://example.com");
expect(r[0].commentStart).toBeNull();
});
it("an inline comment after whitespace still works", () => {
const r = eng.evaluateDocument("5 + 5 // note");
expect(r[0].text).toBe("10");
expect(r[0].commentStart).toBe(6);
});
it("invalid ISO date does not roll over — falls back to arithmetic", () => {
expect(calc("2026-13-45")).toBe("1,968");
});
describe("date literal formats", () => {
const dmyEng = new SumEngine({ dateFormat: "dmy" });
const mdyEng = new SumEngine({ dateFormat: "mdy" });
const isoEng = new SumEngine({ dateFormat: "iso" });
const calcWith = (e: SumEngine, expr: string) => {
const r = e.evaluateDocument(expr);
return r[r.length - 1].text;
};
it("dot-separated date respects the configured day/month order", () => {
expect(calcWith(dmyEng, "31.12.2024 - 30.12.2024")).toBe("1 day");
});
it("slash-separated date respects the configured day/month order", () => {
expect(calcWith(mdyEng, "12/31/2024 - 12/30/2024")).toBe("1 day");
});
it("ambiguous date (both parts <=12) follows the engine's dateFormat", () => {
// dmy: 01/02/2024 = 1 Feb; mdy: 01/02/2024 = 2 Jan
expect(calcWith(dmyEng, "01/02/2024 - 2024-01-01")).toBe("31 day");
expect(calcWith(mdyEng, "01/02/2024 - 2024-01-01")).toBe("1 day");
});
it("an unambiguous component (>12) wins regardless of dateFormat", () => {
expect(calcWith(mdyEng, "31.12.2024 - 30.12.2024")).toBe("1 day");
});
it("an invalid date (both components out of range) is not parsed as a date", () => {
const r = dmyEng.evaluateDocument("32.13.2024");
expect(r[0].value?.kind).not.toBe("date");
});
it("display format overrides the OS locale layout", () => {
expect(calcWith(isoEng, "2024-01-15")).toBe("2024-01-15");
expect(calcWith(dmyEng, "2024-01-15")).toBe("15.01.2024");
expect(calcWith(mdyEng, "2024-01-15")).toBe("01/15/2024");
});
});
});
describe("regression 2026-08-12", () => {
it("a deeply nested-parens line nulls out just that line, not the whole document", () => {
const deep = "(".repeat(50000) + "1" + ")".repeat(50000);
const r = evaluateText(`1+1\n${deep}\n2+2`);
expect(r[0]).toBe("2");
expect(r[1]).toBeNull();
expect(r[2]).toBe("4");
});
it("'5 min' means 5 minutes, not the min() aggregate", () => {
expect(calc("5 min")).toBe("5 min");
expect(calc("5 мин")).toBe("5 min");
});
it("standalone 'min'/'max' aggregate lines still work", () => {
const r = evaluateText("10\n20\n5\nmin");
expect(r).toEqual(["10", "20", "5", "5"]);
const r2 = evaluateText("10\n20\n5\nmax");
expect(r2).toEqual(["10", "20", "5", "20"]);
});
function unitIdOf(expr: string): string | null {
const v = eng.evaluateDocument(expr)[0].value;
return v?.kind === "quantity" ? v.unit?.id ?? null : null;
}
it("ambiguous currency aliases resolve to one deliberate currency; the other needs its full name", () => {
expect(unitIdOf("100 Manat")).toBe("AZN");
expect(unitIdOf("100 Turkmenistan Manat")).toBe("TMT");
expect(unitIdOf("100 манат")).toBe("AZN");
expect(unitIdOf("100 туркменский манат")).toBe("TMT");
expect(unitIdOf("100 FC")).toBe("CDF");
expect(unitIdOf("100 Comorian Franc")).toBe("KMF");
// "Som"/"Kwacha" go to the larger economy (UZS/ZMW); the smaller one needs its full name
expect(unitIdOf("100 Som")).toBe("UZS");
expect(unitIdOf("100 Kyrgyzstani Som")).toBe("KGS");
expect(unitIdOf("100 Kwacha")).toBe("ZMW");
expect(unitIdOf("100 Malawian Kwacha")).toBe("MWK");
expect(unitIdOf("100 квача")).toBe("ZMW");
expect(unitIdOf("100 малавийская квача")).toBe("MWK");
});
});