diff --git a/src/engine/functions/descriptive.ts b/src/engine/functions/descriptive.ts index ff7f304..32a485b 100644 --- a/src/engine/functions/descriptive.ts +++ b/src/engine/functions/descriptive.ts @@ -9,7 +9,7 @@ import { toScalarNumber } from "../numericCoercion"; import { DIV_ZERO_ERROR, isSpreadsheetErrorValue, type SpreadsheetError } from "../spreadsheet-errors"; import type { CellValue } from "../types"; import { collectArgumentValues, collectNumbersOrError, overNumbers } from "./statistical"; -import { pairedHandler, pairsFrom, scalarArg } from "./statistical-analysis"; +import { pairedHandler, pairsFrom, rankedHandler, rankingHandler, scalarArg } from "./statistical-analysis"; import { averageDeviation, averageRank, @@ -27,16 +27,6 @@ import { const MAX_AGGREGATE_ARGS = 255; const DEFAULT_SIGNIFICANCE = 3; -/** `(range, k)`: the numbers in the range, read first, and then k as a number. */ -const rangeAndNumber = - (rule: (values: number[], k: number) => number | SpreadsheetError): FunctionHandler => - (args, context) => { - const values = collectNumbersOrError([requiredArg(context, args, 0)], context); - if (!Array.isArray(values)) return values; - const k = scalarArg(args, context, 1); - return typeof k === "number" ? rule(values, k) : k; - }; - const percentRankExclusiveHandler: FunctionHandler = (args, context) => { const values = collectNumbersOrError([requiredArg(context, args, 0)], context); if (!Array.isArray(values)) return values; @@ -46,14 +36,7 @@ const percentRankExclusiveHandler: FunctionHandler = (args, context) => { return typeof significance === "number" ? percentRankExclusive(values, x, significance) : significance; }; -const averageRankHandler: FunctionHandler = (args, context) => { - const target = scalarArg(args, context, 0); - if (typeof target !== "number") return target; - const values = collectNumbersOrError([requiredArg(context, args, 1)], context); - if (!Array.isArray(values)) return values; - const order = args.length > 2 ? scalarArg(args, context, 2) : 0; - return typeof order === "number" ? averageRank(target, values, order !== 0) : order; -}; +const averageRankHandler = rankingHandler(averageRank); /** PROB(x_range, prob_range, lower, [upper]): the pairs, then the bounds; without an upper bound, * the probability of exactly `lower`. */ @@ -116,7 +99,7 @@ interface DescriptiveFunction { const DESCRIPTIVE_FUNCTIONS: DescriptiveFunction[] = [ { name: "TRIMMEAN", - handler: rangeAndNumber(trimmedMean), + handler: rankedHandler(trimmedMean), minArgs: 2, maxArgs: 2, description: "Returns the mean of the values with a share of the largest and smallest left out", @@ -148,7 +131,7 @@ const DESCRIPTIVE_FUNCTIONS: DescriptiveFunction[] = [ }, { name: "PERCENTILE.EXC", - handler: rangeAndNumber(percentileExclusive), + handler: rankedHandler(percentileExclusive), minArgs: 2, maxArgs: 2, description: "Returns the k-th percentile, k strictly between 0 and 1, excluding the ends", @@ -156,7 +139,7 @@ const DESCRIPTIVE_FUNCTIONS: DescriptiveFunction[] = [ }, { name: "QUARTILE.EXC", - handler: rangeAndNumber(quartileExclusive), + handler: rankedHandler(quartileExclusive), minArgs: 2, maxArgs: 2, description: "Returns a quartile (1 to 3), interpolated like PERCENTILE.EXC", diff --git a/src/engine/functions/statistical-analysis.ts b/src/engine/functions/statistical-analysis.ts index 114a1d4..0ecbb61 100644 --- a/src/engine/functions/statistical-analysis.ts +++ b/src/engine/functions/statistical-analysis.ts @@ -48,8 +48,8 @@ export const pairedHandler = return "ys" in pairs ? rule(pairs) : pairs; }; -/** `(range, k)`: the numbers in the range, and k evaluated as a scalar. */ -const rankedHandler = +/** `(range, k)`: the numbers in the range, read first, and then k as a number. */ +export const rankedHandler = (rule: (values: number[], k: number) => number | SpreadsheetError): FunctionHandler => (args, context) => { const values = collectNumbersOrError([requiredArg(context, args, 0)], context); @@ -58,15 +58,20 @@ const rankedHandler = return typeof k === "number" ? rule(values, k) : k; }; -const rankHandler: FunctionHandler = (args, context) => { - const target = scalarArg(args, context, 0); - if (typeof target !== "number") return target; - const values = collectNumbersOrError([requiredArg(context, args, 1)], context); - if (!Array.isArray(values)) return values; - const order = args.length > 2 ? scalarArg(args, context, 2) : 0; - if (typeof order !== "number") return order; - return rankOf(target, values, order !== 0); -}; +/** `(number, ref, [order])`: where the number stands among the numbers in ref, ascending when order + * is given and not 0, descending otherwise. */ +export const rankingHandler = + (rule: (target: number, values: number[], ascending: boolean) => number | SpreadsheetError): FunctionHandler => + (args, context) => { + const target = scalarArg(args, context, 0); + if (typeof target !== "number") return target; + const values = collectNumbersOrError([requiredArg(context, args, 1)], context); + if (!Array.isArray(values)) return values; + const order = args.length > 2 ? scalarArg(args, context, 2) : 0; + return typeof order === "number" ? rule(target, values, order !== 0) : order; + }; + +const rankHandler = rankingHandler(rankOf); /** The `range, criteria` pairs starting at `first`, or the first error a * criteria evaluated to. A range without its criteria is an arity error, diff --git a/src/engine/nameBinding.ts b/src/engine/nameBinding.ts index 9b9b5e7..5a0cafd 100644 --- a/src/engine/nameBinding.ts +++ b/src/engine/nameBinding.ts @@ -33,20 +33,30 @@ function endOfQuoted(text: string, start: number): number { return close === -1 ? text.length : start + close + 2; } -/** Index just past the bracket that closes the one opening at `start`, skipping quoted text. */ -function endOfBracket(text: string, start: number): number { - const state = { depth: 0, index: start }; +/** The first index from `from`, outside quoted text, at which `found` holds; -1 when there is none. */ +function findOutsideQuotes(text: string, from: number, found: (index: number) => boolean): number { + const state = { index: from }; while (state.index < text.length) { const char = text[state.index] ?? ""; if (char === '"' || char === "'") { state.index = endOfQuoted(text, state.index); continue; } - if (char === "(" || char === "{") state.depth += 1; - if ((char === ")" || char === "}") && --state.depth === 0) return state.index + 1; + if (found(state.index)) return state.index; state.index += 1; } - return text.length; + return -1; +} + +/** Index just past the bracket that closes the one opening at `start`, skipping quoted text. */ +function endOfBracket(text: string, start: number): number { + const depth = { value: 0 }; + const close = findOutsideQuotes(text, start, (index) => { + const char = text[index]; + if (char === "(" || char === "{") depth.value += 1; + return (char === ")" || char === "}") && --depth.value === 0; + }); + return close === -1 ? text.length : close + 1; } /** `inner` split on its top-level commas, each part trimmed. */ @@ -160,18 +170,7 @@ export const substituteNames = (text: string, bindings: readonly Binding[]): str /** Where the first `LAMBDA(…)` written outside quotes starts, from `from`, or -1. */ function nextLambda(text: string, from: number): number { - const state = { index: from }; - while (state.index < text.length) { - const char = text[state.index] ?? ""; - if (char === '"' || char === "'") { - state.index = endOfQuoted(text, state.index); - continue; - } - const startsWord = !IDENTIFIER_BEFORE.test(text[state.index - 1] ?? ""); - if (startsWord && /^LAMBDA\s*\(/i.test(text.slice(state.index))) return state.index; - state.index += 1; - } - return -1; + return findOutsideQuotes(text, from, (index) => !IDENTIFIER_BEFORE.test(text[index - 1] ?? "") && /^LAMBDA\s*\(/i.test(text.slice(index))); } /** `text` with every `LAMBDA(…)(…)` call replaced by the LAMBDA's body over the arguments, or null diff --git a/tests/engine/closeness.ts b/tests/engine/closeness.ts new file mode 100644 index 0000000..0472bb9 --- /dev/null +++ b/tests/engine/closeness.ts @@ -0,0 +1,18 @@ +// Comparing a calculated number with the one expected. + +import assert from "node:assert/strict"; + +export const RELATIVE_TOLERANCE = 1e-9; + +/** `actual` is a number within a relative error of `tolerance` of `expected` (absolute below 1). */ +export function assertRelativelyClose(actual: unknown, expected: number, label: string, tolerance = RELATIVE_TOLERANCE): void { + assert.equal(typeof actual, "number", `${label}: ${String(actual)}`); + const error = Math.abs(Number(actual) - expected) / Math.max(Math.abs(expected), 1); + assert.ok(error <= tolerance, `${label}: ${String(actual)} vs ${expected} (error ${error.toExponential(2)})`); +} + +/** `actual` is a number less than `tolerance` from `expected`. */ +export function assertWithin(actual: unknown, expected: number, tolerance: number): void { + assert.equal(typeof actual, "number", `expected a number, got ${String(actual)}`); + assert.ok(Math.abs(Number(actual) - expected) < tolerance, `${String(actual)} is not ${expected}`); +} diff --git a/tests/engine/formulaAnswers.ts b/tests/engine/formulaAnswers.ts new file mode 100644 index 0000000..ef4ff5c --- /dev/null +++ b/tests/engine/formulaAnswers.ts @@ -0,0 +1,28 @@ +// What a formula answers, calculated in the small sheets the engine tests build around it. + +import { SpreadsheetEngine, type SheetData } from "../../src/engine/index.ts"; +import type { CellValue } from "../../src/engine/types.ts"; +import { cellAt } from "./cellAccess.ts"; + +/** What `formula` answers alone in A1. */ +export const formulaValue = (formula: string): CellValue => + cellAt(new SpreadsheetEngine().calculate({ name: "S", data: [[{ v: formula }]] } satisfies SheetData).data, 0, 0); + +/** What `formula` answers in column A of the row after `rows`. */ +export function valueBelow(rows: (string | number)[][], formula: string): CellValue { + const data = rows.map((row) => row.map((value) => ({ v: value }))); + data.push([{ v: formula }]); + return cellAt(new SpreadsheetEngine().calculate({ name: "S", data }).data, data.length - 1, 0); +} + +/** What `formula`, written beside `rows` (a column apart, in the first row), spills. */ +export function spilledBeside(formula: string, rows: CellValue[][]): CellValue[][] { + const width = rows[0]?.length ?? 0; + const data = rows.map((row, r) => [...row, "", r === 0 ? formula : ""].map((v) => ({ v }))); + const [sheet] = new SpreadsheetEngine().calculateWorkbook([{ name: "S", data }]); + return (sheet?.data ?? []).map((row) => row.slice(width + 1)).filter((row) => row.some((cell) => cell !== "")); +} + +/** The cells of a `rows` × `columns` block from A1, row by row. */ +export const rowMajorCells = (rows: number, columns: number): { row: number; col: number }[] => + Array.from({ length: rows }, (_, row) => Array.from({ length: columns }, (_cell, col) => ({ row, col }))).flat(); diff --git a/tests/engine/formulaColumns.ts b/tests/engine/formulaColumns.ts index 05e9e24..8f68348 100644 --- a/tests/engine/formulaColumns.ts +++ b/tests/engine/formulaColumns.ts @@ -1,7 +1,7 @@ // A sheet with data columns from column A and one formula per row in the column after them. import { SpreadsheetEngine, type SheetData } from "../../src/engine/index.ts"; -import type { CellValue } from "../../src/engine/types.ts"; +import type { CellValue, SpreadsheetCell } from "../../src/engine/types.ts"; import { cellAt } from "./cellAccess.ts"; /** Each formula's value, with `columns` from column A and the formulas in the column after them. */ @@ -11,3 +11,10 @@ export function valuesOf(formulas: string[], columns: (number | string)[][] = [] const grid = new SpreadsheetEngine().calculate({ name: "S", data } satisfies SheetData).data; return formulas.map((_, row) => cellAt(grid, row, columns.length)); } + +/** Each formula's value, with `cells` down column A and the formulas down column B. */ +export function valuesBesideCells(formulas: string[], cells: SpreadsheetCell[]): CellValue[] { + const rows = Array.from({ length: Math.max(cells.length, formulas.length) }, (_, row) => [cells[row] ?? { v: "" }, { v: formulas[row] ?? "" }]); + const grid = new SpreadsheetEngine().calculate({ name: "S", data: rows } satisfies SheetData).data; + return formulas.map((_, row) => cellAt(grid, row, 1)); +} diff --git a/tests/engine/referenceFixture.ts b/tests/engine/referenceFixture.ts new file mode 100644 index 0000000..97a5ae6 --- /dev/null +++ b/tests/engine/referenceFixture.ts @@ -0,0 +1,12 @@ +// The reference fixtures the generator scripts write: `{ "cases": [...] }` under tests/engine/fixtures. + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +/** The cases of `fixtures/`, each checked by `isCase`; the fixture must hold at least one. */ +export function referenceCases(fileName: string, isCase: (value: unknown) => value is T): T[] { + const parsed: unknown = JSON.parse(readFileSync(new URL(`./fixtures/${fileName}`, import.meta.url), "utf8")); + const cases: unknown = typeof parsed === "object" && parsed !== null ? Reflect.get(parsed, "cases") : undefined; + assert.ok(Array.isArray(cases) && cases.length > 0 && cases.every(isCase), `${fileName} holds cases`); + return cases.filter(isCase); +} diff --git a/tests/engine/run-calculator-tests.ts b/tests/engine/run-calculator-tests.ts index b518e8c..74f09e1 100644 --- a/tests/engine/run-calculator-tests.ts +++ b/tests/engine/run-calculator-tests.ts @@ -6,24 +6,9 @@ import { calculateSheet, calculateWorkbook } from "../../src/engine/calculator"; import { createSheet, expectSheetOutput } from "./test-utils"; import "../../src/engine/functions"; // Load all functions +import { scriptRunner } from "./scriptRunner"; -let passedTests = 0; -let failedTests = 0; -const failures: string[] = []; - -function test(name: string, fn: () => void) { - try { - fn(); - passedTests++; - console.log(`✓ ${name}`); - } catch (error) { - failedTests++; - const message = error instanceof Error ? error.message : String(error); - failures.push(`✗ ${name}: ${message}`); - console.log(`✗ ${name}`); - console.error(` ${message}`); - } -} +const { test, finish } = scriptRunner({ printErrors: true }); console.log("\n=== Calculator Tests - Array Output Comparison ===\n"); @@ -320,16 +305,4 @@ test("detects circular references", () => { } }); -// Summary -console.log("\n=== Test Summary ==="); -console.log(`Passed: ${passedTests}`); -console.log(`Failed: ${failedTests}`); - -if (failures.length > 0) { - console.log("\n=== Failures ==="); - failures.forEach((f) => console.log(f)); - process.exit(1); -} else { - console.log("\n✓ All tests passed!"); - process.exit(0); -} +finish(); diff --git a/tests/engine/run-evaluator-tests.ts b/tests/engine/run-evaluator-tests.ts index 3f59f11..a83ca66 100644 --- a/tests/engine/run-evaluator-tests.ts +++ b/tests/engine/run-evaluator-tests.ts @@ -5,40 +5,9 @@ import { parseFunctionArgs, evaluateFormula, type EvaluatorContext } from "../../src/engine/evaluator"; import "../../src/engine/functions"; // Load all functions +import { expect, scriptRunner } from "./scriptRunner"; -let passedTests = 0; -let failedTests = 0; -const failures: string[] = []; - -function test(name: string, fn: () => void) { - try { - fn(); - passedTests++; - console.log(`✓ ${name}`); - } catch (error) { - failedTests++; - const message = error instanceof Error ? error.message : String(error); - failures.push(`✗ ${name}: ${message}`); - console.log(`✗ ${name}`); - } -} - -function expect(actual: any) { - return { - toBe(expected: any) { - if (actual !== expected) { - throw new Error(`Expected "${expected}", got "${actual}"`); - } - }, - toEqual(expected: any) { - const actualStr = JSON.stringify(actual); - const expectedStr = JSON.stringify(expected); - if (actualStr !== expectedStr) { - throw new Error(`Expected ${expectedStr}, got ${actualStr}`); - } - }, - }; -} +const { test, finish } = scriptRunner(); // Helper to create mock context function createContext( @@ -211,16 +180,4 @@ test("evaluates SQRT function", () => { expect(evaluateFormula("SQRT(16)", ctx)).toBe(4); }); -// Summary -console.log("\n=== Test Summary ==="); -console.log(`Passed: ${passedTests}`); -console.log(`Failed: ${failedTests}`); - -if (failures.length > 0) { - console.log("\n=== Failures ==="); - failures.forEach((f) => console.log(f)); - process.exit(1); -} else { - console.log("\n✓ All tests passed!"); - process.exit(0); -} +finish(); diff --git a/tests/engine/run-formatter-tests.ts b/tests/engine/run-formatter-tests.ts index 94612fc..a324add 100644 --- a/tests/engine/run-formatter-tests.ts +++ b/tests/engine/run-formatter-tests.ts @@ -4,33 +4,9 @@ */ import { formatNumber } from "../../src/engine/formatter"; +import { expect, scriptRunner } from "./scriptRunner"; -let passedTests = 0; -let failedTests = 0; -const failures: string[] = []; - -function test(name: string, fn: () => void) { - try { - fn(); - passedTests++; - console.log(`✓ ${name}`); - } catch (error) { - failedTests++; - const message = error instanceof Error ? error.message : String(error); - failures.push(`✗ ${name}: ${message}`); - console.log(`✗ ${name}`); - } -} - -function expect(actual: any) { - return { - toBe(expected: any) { - if (actual !== expected) { - throw new Error(`Expected "${expected}", got "${actual}"`); - } - }, - }; -} +const { test, finish } = scriptRunner(); console.log("\n=== Formatter Tests ===\n"); @@ -145,16 +121,4 @@ test("0.00000: very small decimal", () => { expect(formatNumber(0.00001, "0.00000")).toBe("0.00001"); }); -// Summary -console.log("\n=== Test Summary ==="); -console.log(`Passed: ${passedTests}`); -console.log(`Failed: ${failedTests}`); - -if (failures.length > 0) { - console.log("\n=== Failures ==="); - failures.forEach((f) => console.log(f)); - process.exit(1); -} else { - console.log("\n✓ All tests passed!"); - process.exit(0); -} +finish(); diff --git a/tests/engine/run-lowercase-test.ts b/tests/engine/run-lowercase-test.ts index 359e27d..ed0f592 100644 --- a/tests/engine/run-lowercase-test.ts +++ b/tests/engine/run-lowercase-test.ts @@ -6,24 +6,9 @@ import { calculateSheet } from "../../src/engine/calculator"; import { createSheet, expectSheetOutput } from "./test-utils"; import "../../src/engine/functions"; // Load all functions +import { scriptRunner } from "./scriptRunner"; -let passedTests = 0; -let failedTests = 0; -const failures: string[] = []; - -function test(name: string, fn: () => void) { - try { - fn(); - passedTests++; - console.log(`✓ ${name}`); - } catch (error) { - failedTests++; - const message = error instanceof Error ? error.message : String(error); - failures.push(`✗ ${name}: ${message}`); - console.log(`✗ ${name}`); - console.error(` ${message}`); - } -} +const { test, finish } = scriptRunner({ printErrors: true }); console.log("\n=== Lowercase Function Names Test ===\n"); @@ -83,16 +68,4 @@ test("PV Analysis with potential lowercase SUM", () => { expectSheetOutput(result, expected); }); -// Summary -console.log("\n=== Test Summary ==="); -console.log(`Passed: ${passedTests}`); -console.log(`Failed: ${failedTests}`); - -if (failures.length > 0) { - console.log("\n=== Failures ==="); - failures.forEach((f) => console.log(f)); - process.exit(1); -} else { - console.log("\n✓ All tests passed!"); - process.exit(0); -} +finish(); diff --git a/tests/engine/run-pv-analysis-complete-test.ts b/tests/engine/run-pv-analysis-complete-test.ts index f80159f..818e7e1 100644 --- a/tests/engine/run-pv-analysis-complete-test.ts +++ b/tests/engine/run-pv-analysis-complete-test.ts @@ -4,35 +4,19 @@ */ import { calculateSheet } from "../../src/engine/calculator"; -import { toStringArray, expectSheetOutput, expectedPvAnalysisMonth, expectedPvAnalysisTotal } from "./test-utils"; +import { pvAnalysisData, toStringArray, expectSheetOutput, expectedPvAnalysisMonth, expectedPvAnalysisTotal } from "./test-utils"; import "../../src/engine/functions"; // Load all functions +/** Each row on its own line, its cells quoted. */ +function printRows(rows: string[][]): void { + rows.forEach((row, i) => console.log(`Row ${i + 1}: [${row.map((c) => `"${c}"`).join(", ")}]`)); +} + console.log("\n=== PV Analysis - Complete Array Output Test ===\n"); const pvAnalysisSheet = { name: "PV Analysis", - data: [ - [{ v: "Parameter" }, { v: "Value" }, { v: "" }], - [{ v: "Monthly Income" }, { f: "$#,##0.00", v: 1000 }, { v: "Change this value" }], - [{ v: "Annual Discount Rate" }, { f: "0.00%", v: 0.05 }, { v: "Change this value" }], - [{ v: "Monthly Discount Rate" }, { f: "0.00%", v: "=B3/12" }, { v: "Calculated automatically" }], - [{ v: "Total Present Value" }, { v: "=SUM(C9:C20)", f: "$#,##0.00" }, { v: "Sum of discounted cash flows" }], - [{ v: "" }, { v: "" }, { v: "" }], - [{ v: "" }, { v: "" }, { v: "" }], - [{ v: "Month" }, { v: "Cash Flow" }, { v: "Present Value" }], - [{ v: 1 }, { f: "$#,##0.00", v: "=$B$2" }, { f: "$#,##0.00", v: "=B9/(1+$B$4)^A9" }], - [{ v: 2 }, { f: "$#,##0.00", v: "=$B$2" }, { v: "=B10/(1+$B$4)^A10", f: "$#,##0.00" }], - [{ v: 3 }, { v: "=$B$2", f: "$#,##0.00" }, { v: "=B11/(1+$B$4)^A11", f: "$#,##0.00" }], - [{ v: 4 }, { f: "$#,##0.00", v: "=$B$2" }, { v: "=B12/(1+$B$4)^A12", f: "$#,##0.00" }], - [{ v: 5 }, { v: "=$B$2", f: "$#,##0.00" }, { v: "=B13/(1+$B$4)^A13", f: "$#,##0.00" }], - [{ v: 6 }, { f: "$#,##0.00", v: "=$B$2" }, { v: "=B14/(1+$B$4)^A14", f: "$#,##0.00" }], - [{ v: 7 }, { v: "=$B$2", f: "$#,##0.00" }, { v: "=B15/(1+$B$4)^A15", f: "$#,##0.00" }], - [{ v: 8 }, { v: "=$B$2", f: "$#,##0.00" }, { v: "=B16/(1+$B$4)^A16", f: "$#,##0.00" }], - [{ v: 9 }, { f: "$#,##0.00", v: "=$B$2" }, { f: "$#,##0.00", v: "=B17/(1+$B$4)^A17" }], - [{ v: 10 }, { f: "$#,##0.00", v: "=$B$2" }, { f: "$#,##0.00", v: "=B18/(1+$B$4)^A18" }], - [{ v: 11 }, { f: "$#,##0.00", v: "=$B$2" }, { f: "$#,##0.00", v: "=B19/(1+$B$4)^A19" }], - [{ v: 12 }, { v: "=$B$2", f: "$#,##0.00" }, { v: "=B20/(1+$B$4)^A20", f: "$#,##0.00" }], - ], + data: pvAnalysisData, }; try { @@ -69,18 +53,12 @@ try { console.log("Expected Output:"); console.log("================"); - expected.forEach((row, i) => { - const cells = row.map((c) => `"${c}"`).join(", "); - console.log(`Row ${i + 1}: [${cells}]`); - }); + printRows(expected); console.log("\n\nActual Output:"); console.log("=============="); const actual = toStringArray(result.data); - actual.forEach((row, i) => { - const cells = row.map((c) => `"${c}"`).join(", "); - console.log(`Row ${i + 1}: [${cells}]`); - }); + printRows(actual); console.log("\n\nComparison:"); console.log("==========="); diff --git a/tests/engine/run-pv-analysis-test.ts b/tests/engine/run-pv-analysis-test.ts index 67546d0..d6f9a12 100644 --- a/tests/engine/run-pv-analysis-test.ts +++ b/tests/engine/run-pv-analysis-test.ts @@ -4,35 +4,14 @@ import assert from "node:assert/strict"; import { calculateSheet } from "../../src/engine/calculator"; -import { expectedPvAnalysisTotal } from "./test-utils"; +import { pvAnalysisData, expectedPvAnalysisTotal } from "./test-utils"; import "../../src/engine/functions"; // Load all functions console.log("\n=== PV Analysis Test ===\n"); const pvAnalysisSheet = { name: "PV Analysis", - data: [ - [{ v: "Parameter" }, { v: "Value" }, { v: "" }], - [{ v: "Monthly Income" }, { f: "$#,##0.00", v: 1000 }, { v: "Change this value" }], - [{ v: "Annual Discount Rate" }, { f: "0.00%", v: 0.05 }, { v: "Change this value" }], - [{ v: "Monthly Discount Rate" }, { f: "0.00%", v: "=B3/12" }, { v: "Calculated automatically" }], - [{ v: "Total Present Value" }, { v: "=SUM(C9:C20)", f: "$#,##0.00" }, { v: "Sum of discounted cash flows" }], - [{ v: "" }, { v: "" }, { v: "" }], - [{ v: "" }, { v: "" }, { v: "" }], - [{ v: "Month" }, { v: "Cash Flow" }, { v: "Present Value" }], - [{ v: 1 }, { f: "$#,##0.00", v: "=$B$2" }, { f: "$#,##0.00", v: "=B9/(1+$B$4)^A9" }], - [{ v: 2 }, { f: "$#,##0.00", v: "=$B$2" }, { v: "=B10/(1+$B$4)^A10", f: "$#,##0.00" }], - [{ v: 3 }, { v: "=$B$2", f: "$#,##0.00" }, { v: "=B11/(1+$B$4)^A11", f: "$#,##0.00" }], - [{ v: 4 }, { f: "$#,##0.00", v: "=$B$2" }, { v: "=B12/(1+$B$4)^A12", f: "$#,##0.00" }], - [{ v: 5 }, { v: "=$B$2", f: "$#,##0.00" }, { v: "=B13/(1+$B$4)^A13", f: "$#,##0.00" }], - [{ v: 6 }, { f: "$#,##0.00", v: "=$B$2" }, { v: "=B14/(1+$B$4)^A14", f: "$#,##0.00" }], - [{ v: 7 }, { v: "=$B$2", f: "$#,##0.00" }, { v: "=B15/(1+$B$4)^A15", f: "$#,##0.00" }], - [{ v: 8 }, { v: "=$B$2", f: "$#,##0.00" }, { v: "=B16/(1+$B$4)^A16", f: "$#,##0.00" }], - [{ v: 9 }, { f: "$#,##0.00", v: "=$B$2" }, { f: "$#,##0.00", v: "=B17/(1+$B$4)^A17" }], - [{ v: 10 }, { f: "$#,##0.00", v: "=$B$2" }, { f: "$#,##0.00", v: "=B18/(1+$B$4)^A18" }], - [{ v: 11 }, { f: "$#,##0.00", v: "=$B$2" }, { f: "$#,##0.00", v: "=B19/(1+$B$4)^A19" }], - [{ v: 12 }, { v: "=$B$2", f: "$#,##0.00" }, { v: "=B20/(1+$B$4)^A20", f: "$#,##0.00" }], - ], + data: pvAnalysisData, }; try { diff --git a/tests/engine/run-recalc-test.ts b/tests/engine/run-recalc-test.ts index 28ab080..f6ea70a 100644 --- a/tests/engine/run-recalc-test.ts +++ b/tests/engine/run-recalc-test.ts @@ -4,35 +4,12 @@ import assert from "node:assert/strict"; import { SpreadsheetEngine } from "../../src/engine/engine"; -import { expectedPvAnalysisTotal } from "./test-utils"; +import { pvAnalysisData, expectedPvAnalysisTotal } from "./test-utils"; import type { SheetData } from "../../src/engine/types"; import "../../src/engine/functions"; console.log("\n=== Re-calculation Test ===\n"); -const pvAnalysisData = [ - [{ v: "Parameter" }, { v: "Value" }, { v: "" }], - [{ v: "Monthly Income" }, { f: "$#,##0.00", v: 1000 }, { v: "Change this value" }], - [{ v: "Annual Discount Rate" }, { f: "0.00%", v: 0.05 }, { v: "Change this value" }], - [{ v: "Monthly Discount Rate" }, { f: "0.00%", v: "=B3/12" }, { v: "Calculated automatically" }], - [{ v: "Total Present Value" }, { v: "=SUM(C9:C20)", f: "$#,##0.00" }, { v: "Sum of discounted cash flows" }], - [{ v: "" }, { v: "" }, { v: "" }], - [{ v: "" }, { v: "" }, { v: "" }], - [{ v: "Month" }, { v: "Cash Flow" }, { v: "Present Value" }], - [{ v: 1 }, { f: "$#,##0.00", v: "=$B$2" }, { f: "$#,##0.00", v: "=B9/(1+$B$4)^A9" }], - [{ v: 2 }, { f: "$#,##0.00", v: "=$B$2" }, { v: "=B10/(1+$B$4)^A10", f: "$#,##0.00" }], - [{ v: 3 }, { v: "=$B$2", f: "$#,##0.00" }, { v: "=B11/(1+$B$4)^A11", f: "$#,##0.00" }], - [{ v: 4 }, { f: "$#,##0.00", v: "=$B$2" }, { v: "=B12/(1+$B$4)^A12", f: "$#,##0.00" }], - [{ v: 5 }, { v: "=$B$2", f: "$#,##0.00" }, { v: "=B13/(1+$B$4)^A13", f: "$#,##0.00" }], - [{ v: 6 }, { f: "$#,##0.00", v: "=$B$2" }, { v: "=B14/(1+$B$4)^A14", f: "$#,##0.00" }], - [{ v: 7 }, { v: "=$B$2", f: "$#,##0.00" }, { v: "=B15/(1+$B$4)^A15", f: "$#,##0.00" }], - [{ v: 8 }, { v: "=$B$2", f: "$#,##0.00" }, { v: "=B16/(1+$B$4)^A16", f: "$#,##0.00" }], - [{ v: 9 }, { f: "$#,##0.00", v: "=$B$2" }, { f: "$#,##0.00", v: "=B17/(1+$B$4)^A17" }], - [{ v: 10 }, { f: "$#,##0.00", v: "=$B$2" }, { f: "$#,##0.00", v: "=B18/(1+$B$4)^A18" }], - [{ v: 11 }, { f: "$#,##0.00", v: "=$B$2" }, { f: "$#,##0.00", v: "=B19/(1+$B$4)^A19" }], - [{ v: 12 }, { v: "=$B$2", f: "$#,##0.00" }, { v: "=B20/(1+$B$4)^A20", f: "$#,##0.00" }], -]; - const engine = new SpreadsheetEngine(); // Simulate what the Vue component does diff --git a/tests/engine/scriptRunner.ts b/tests/engine/scriptRunner.ts new file mode 100644 index 0000000..b726711 --- /dev/null +++ b/tests/engine/scriptRunner.ts @@ -0,0 +1,54 @@ +// The plain runner the tsx scripts share: each test prints ✓ or ✗, and the summary exits 1 when any +// failed. + +export interface ScriptRunner { + test(name: string, fn: () => void): void; + /** Print the totals and the failures, then exit: 1 when a test failed, 0 otherwise. */ + finish(): never; +} + +/** A runner; with `printErrors`, a failure's message is printed under its name as it happens. */ +export function scriptRunner({ printErrors = false } = {}): ScriptRunner { + const tally: { passed: number; failed: number; failures: string[] } = { passed: 0, failed: 0, failures: [] }; + return { + test(name, fn) { + try { + fn(); + tally.passed++; + console.log(`✓ ${name}`); + } catch (error) { + tally.failed++; + const message = error instanceof Error ? error.message : String(error); + tally.failures.push(`✗ ${name}: ${message}`); + console.log(`✗ ${name}`); + if (printErrors) console.error(` ${message}`); + } + }, + finish() { + console.log("\n=== Test Summary ==="); + console.log(`Passed: ${tally.passed}`); + console.log(`Failed: ${tally.failed}`); + if (tally.failures.length > 0) { + console.log("\n=== Failures ==="); + tally.failures.forEach((failure) => console.log(failure)); + process.exit(1); + } + console.log("\n✓ All tests passed!"); + process.exit(0); + }, + }; +} + +/** `expect(actual).toBe(expected)` (the same value) and `.toEqual(expected)` (the same JSON), throwing + * when they differ. */ +export function expect(actual: unknown) { + return { + toBe(expected: unknown) { + if (actual !== expected) throw new Error(`Expected "${String(expected)}", got "${String(actual)}"`); + }, + toEqual(expected: unknown) { + const [actualJson, expectedJson] = [JSON.stringify(actual), JSON.stringify(expected)]; + if (actualJson !== expectedJson) throw new Error(`Expected ${expectedJson}, got ${actualJson}`); + }, + }; +} diff --git a/tests/engine/seededRandom.ts b/tests/engine/seededRandom.ts index 02ffc8b..5d18a58 100644 --- a/tests/engine/seededRandom.ts +++ b/tests/engine/seededRandom.ts @@ -22,3 +22,16 @@ export const pick = (random: Random, items: readonly T[]): T => { if (item === undefined) throw new Error("pick needs at least one item"); return item; }; + +const LCG_MULTIPLIER = 1103515245; +const LCG_INCREMENT = 12345; +const LCG_MODULUS = 2147483648; + +/** The linear congruential stream the older generated tests draw from, kept so their seeds replay. */ +export function lcgRandom(seed: number): Random { + const state = { value: seed }; + return () => { + state.value = (state.value * LCG_MULTIPLIER + LCG_INCREMENT) % LCG_MODULUS; + return state.value / LCG_MODULUS; + }; +} diff --git a/tests/engine/test-utils.ts b/tests/engine/test-utils.ts index feb809f..005fef4 100644 --- a/tests/engine/test-utils.ts +++ b/tests/engine/test-utils.ts @@ -102,3 +102,27 @@ export function expectedPvAnalysisTotal(): string { const total = Array.from({ length: PV_MONTHS }, (_, index) => presentValueOfMonth(index + 1)).reduce((sum, value) => sum + value, 0); return formatDollars(total); } + +/** The PV Analysis sheet the scripts calculate: twelve months of cash flow, each discounted. */ +export const pvAnalysisData: SpreadsheetCell[][] = [ + [{ v: "Parameter" }, { v: "Value" }, { v: "" }], + [{ v: "Monthly Income" }, { f: "$#,##0.00", v: 1000 }, { v: "Change this value" }], + [{ v: "Annual Discount Rate" }, { f: "0.00%", v: 0.05 }, { v: "Change this value" }], + [{ v: "Monthly Discount Rate" }, { f: "0.00%", v: "=B3/12" }, { v: "Calculated automatically" }], + [{ v: "Total Present Value" }, { v: "=SUM(C9:C20)", f: "$#,##0.00" }, { v: "Sum of discounted cash flows" }], + [{ v: "" }, { v: "" }, { v: "" }], + [{ v: "" }, { v: "" }, { v: "" }], + [{ v: "Month" }, { v: "Cash Flow" }, { v: "Present Value" }], + [{ v: 1 }, { f: "$#,##0.00", v: "=$B$2" }, { f: "$#,##0.00", v: "=B9/(1+$B$4)^A9" }], + [{ v: 2 }, { f: "$#,##0.00", v: "=$B$2" }, { v: "=B10/(1+$B$4)^A10", f: "$#,##0.00" }], + [{ v: 3 }, { v: "=$B$2", f: "$#,##0.00" }, { v: "=B11/(1+$B$4)^A11", f: "$#,##0.00" }], + [{ v: 4 }, { f: "$#,##0.00", v: "=$B$2" }, { v: "=B12/(1+$B$4)^A12", f: "$#,##0.00" }], + [{ v: 5 }, { v: "=$B$2", f: "$#,##0.00" }, { v: "=B13/(1+$B$4)^A13", f: "$#,##0.00" }], + [{ v: 6 }, { f: "$#,##0.00", v: "=$B$2" }, { v: "=B14/(1+$B$4)^A14", f: "$#,##0.00" }], + [{ v: 7 }, { v: "=$B$2", f: "$#,##0.00" }, { v: "=B15/(1+$B$4)^A15", f: "$#,##0.00" }], + [{ v: 8 }, { v: "=$B$2", f: "$#,##0.00" }, { v: "=B16/(1+$B$4)^A16", f: "$#,##0.00" }], + [{ v: 9 }, { f: "$#,##0.00", v: "=$B$2" }, { f: "$#,##0.00", v: "=B17/(1+$B$4)^A17" }], + [{ v: 10 }, { f: "$#,##0.00", v: "=$B$2" }, { f: "$#,##0.00", v: "=B18/(1+$B$4)^A18" }], + [{ v: 11 }, { f: "$#,##0.00", v: "=$B$2" }, { f: "$#,##0.00", v: "=B19/(1+$B$4)^A19" }], + [{ v: 12 }, { v: "=$B$2", f: "$#,##0.00" }, { v: "=B20/(1+$B$4)^A20", f: "$#,##0.00" }], +]; diff --git a/tests/engine/test_analysisMath.ts b/tests/engine/test_analysisMath.ts index 311f0e3..7049e9b 100644 --- a/tests/engine/test_analysisMath.ts +++ b/tests/engine/test_analysisMath.ts @@ -21,13 +21,11 @@ import { type NumericPairs, } from "../../src/engine/functions/analysis-math.ts"; import { DIV_ZERO_ERROR, NA_ERROR, NUM_ERROR, VALUE_ERROR } from "../../src/engine/spreadsheet-errors.ts"; +import { assertWithin } from "./closeness.ts"; const TOLERANCE = 1e-6; -const assertClose = (actual: unknown, expected: number): void => { - assert.equal(typeof actual, "number", `expected a number, got ${String(actual)}`); - assert.ok(Math.abs(Number(actual) - expected) < TOLERANCE, `${String(actual)} is not ${expected}`); -}; +const assertClose = (actual: unknown, expected: number): void => assertWithin(actual, expected, TOLERANCE); const pairsOf = (ys: number[], xs: number[]): NumericPairs => ({ ys, xs }); diff --git a/tests/engine/test_blankArguments.ts b/tests/engine/test_blankArguments.ts index d5a0d50..f9b7e62 100644 --- a/tests/engine/test_blankArguments.ts +++ b/tests/engine/test_blankArguments.ts @@ -7,19 +7,14 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { blankBeside, withBlankSides } from "../../src/engine/blankOperands.ts"; -import { SpreadsheetEngine, type SheetData } from "../../src/engine/index.ts"; import type { SpreadsheetCell } from "../../src/engine/types.ts"; -import { cellAt } from "./cellAccess.ts"; import { pick, seededRandom } from "./seededRandom.ts"; +import { valuesBesideCells } from "./formulaColumns.ts"; // A1 is blank, A2 holds 0, A3 holds "x", A4 is a {} cell; the formulas go in column B. const DATA: SpreadsheetCell[] = [{ v: "" }, { v: 0 }, { v: "x" }, {}]; -function valuesOf(formulas: string[]): unknown[] { - const rows = Array.from({ length: Math.max(DATA.length, formulas.length) }, (_, row) => [DATA[row] ?? { v: "" }, { v: formulas[row] ?? "" }]); - const grid = new SpreadsheetEngine().calculate({ name: "S", data: rows } satisfies SheetData).data; - return formulas.map((_, row) => cellAt(grid, row, 1)); -} +const valuesOf = (formulas: string[]): unknown[] => valuesBesideCells(formulas, DATA); describe("blankBeside and withBlankSides", () => { it('reads a blank as 0 beside a number, FALSE beside a logical value, and "" otherwise', () => { diff --git a/tests/engine/test_crossSheetReference.ts b/tests/engine/test_crossSheetReference.ts index 7dfe311..3022c25 100644 --- a/tests/engine/test_crossSheetReference.ts +++ b/tests/engine/test_crossSheetReference.ts @@ -72,28 +72,32 @@ describe("cross-sheet range aggregation stays numeric", () => { // A cell that depends on itself through other sheets is a circular reference like one within a sheet: // it reads 0 and is reported as circular. (Before #130 it read "#ERROR!", because the other sheet was // read mid-calculation, its formula still text.) +/** The first sheet of `sheets`, calculated with the others, is 0 with one circular-reference error. */ +function assertCircular(sheets: SheetData[]): void { + const [first] = sheets; + assert.ok(first); + const result = calculateSheet(first, sheets); + assert.equal(result.data[0]?.[0], 0); + assert.deepEqual( + result.errors.map((error) => error.type), + ["circular"], + ); +} + describe("cyclic cross-sheet references terminate instead of hanging", () => { it("a 2-sheet cycle (A!A1=B!A1, B!A1=A!A1) is a circular reference", () => { - const sheetA: SheetData = { name: "A", data: [[{ v: "=B!A1" }]] }; - const sheetB: SheetData = { name: "B", data: [[{ v: "=A!A1" }]] }; - const result = calculateSheet(sheetA, [sheetA, sheetB]); - assert.equal(result.data[0]?.[0], 0); - assert.deepEqual( - result.errors.map((error) => error.type), - ["circular"], - ); + assertCircular([ + { name: "A", data: [[{ v: "=B!A1" }]] }, + { name: "B", data: [[{ v: "=A!A1" }]] }, + ]); }); it("a 3-sheet cycle (A->B->C->A) is a circular reference too", () => { - const sheetA: SheetData = { name: "A", data: [[{ v: "=B!A1" }]] }; - const sheetB: SheetData = { name: "B", data: [[{ v: "=C!A1" }]] }; - const sheetC: SheetData = { name: "C", data: [[{ v: "=A!A1" }]] }; - const result = calculateSheet(sheetA, [sheetA, sheetB, sheetC]); - assert.equal(result.data[0]?.[0], 0); - assert.deepEqual( - result.errors.map((error) => error.type), - ["circular"], - ); + assertCircular([ + { name: "A", data: [[{ v: "=B!A1" }]] }, + { name: "B", data: [[{ v: "=C!A1" }]] }, + { name: "C", data: [[{ v: "=A!A1" }]] }, + ]); }); it("a valid cross-sheet reference next to the cycle still resolves", () => { diff --git a/tests/engine/test_customFunctions.ts b/tests/engine/test_customFunctions.ts index f2f9ad3..699cc51 100644 --- a/tests/engine/test_customFunctions.ts +++ b/tests/engine/test_customFunctions.ts @@ -287,6 +287,15 @@ describe("calling a workbook's functions", () => { }); }); +/** A broken body is the one problem reported, and FINE beside it still loads. */ +const assertOnlyBodyRejected = async (name: string, body: string): Promise => { + const set = await loadCustomFunctions([{ functions: [spec(name, [], body), spec("FINE", [], "return 1;")] }]); + assert.equal(set.problems.length, 1); + assert.match(set.problems[0]?.reason ?? "", /does not compile/); + assert.equal(set.definitions.has("FINE"), true); + set.dispose(); +}; + describe("the functions are the workbook's alone", () => { it("are unknown outside calculateWith, also after a calculation that threw", async () => { const set = await loadCustomFunctions([sheetWith([])]); @@ -327,20 +336,12 @@ describe("the functions are the workbook's alone", () => { }); it("reports a body that does not compile, and keeps the others", async () => { - const set = await loadCustomFunctions([{ functions: [spec("BROKEN", [], "return ("), spec("FINE", [], "return 1;")] }]); - assert.equal(set.problems.length, 1); - assert.match(set.problems[0]?.reason ?? "", /does not compile/); - assert.equal(set.definitions.has("FINE"), true); - set.dispose(); + await assertOnlyBodyRejected("BROKEN", "return ("); }); // A body can close its Function early and run code while it loads; that code is held to a deadline. it("stops code a body runs while it loads, and keeps the other functions", async () => { - const set = await loadCustomFunctions([{ functions: [spec("ESCAPE", [], "}); while (true) {} (function () {"), spec("FINE", [], "return 1;")] }]); - assert.equal(set.problems.length, 1); - assert.match(set.problems[0]?.reason ?? "", /does not compile/); - assert.equal(set.definitions.has("FINE"), true); - set.dispose(); + await assertOnlyBodyRejected("ESCAPE", "}); while (true) {} (function () {"); }); it("does not load QuickJS for a workbook without functions", async () => { diff --git a/tests/engine/test_descriptive.ts b/tests/engine/test_descriptive.ts index af2c6f3..4529b77 100644 --- a/tests/engine/test_descriptive.ts +++ b/tests/engine/test_descriptive.ts @@ -6,7 +6,6 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; import { averageDeviation, averageRank, @@ -20,14 +19,8 @@ import { } from "../../src/engine/functions/descriptive-math.ts"; import { valuesOf } from "./formulaColumns.ts"; import { pick, seededRandom } from "./seededRandom.ts"; - -const RELATIVE_TOLERANCE = 1e-9; - -const assertClose = (actual: unknown, expected: number, label: string): void => { - assert.equal(typeof actual, "number", `${label}: ${String(actual)}`); - const error = Math.abs(Number(actual) - expected) / Math.max(Math.abs(expected), 1); - assert.ok(error <= RELATIVE_TOLERANCE, `${label}: ${String(actual)} vs ${expected}`); -}; +import { assertRelativelyClose as assertClose } from "./closeness.ts"; +import { referenceCases } from "./referenceFixture.ts"; const rounded = (digits: number, value: unknown): number => Number(Number(value).toFixed(digits)); @@ -186,10 +179,7 @@ interface ReferenceCase { const isReferenceCase = (value: unknown): value is ReferenceCase => typeof value === "object" && value !== null && Array.isArray(Reflect.get(value, "xs")) && typeof Reflect.get(value, "kendall") === "number"; -const parsed: unknown = JSON.parse(readFileSync(new URL("./fixtures/descriptive-reference.json", import.meta.url), "utf8")); -const referenceCases: unknown = typeof parsed === "object" && parsed !== null ? Reflect.get(parsed, "cases") : undefined; -assert.ok(Array.isArray(referenceCases) && referenceCases.every(isReferenceCase), "the reference fixture holds cases"); -const cases: ReferenceCase[] = referenceCases.filter(isReferenceCase); +const cases: ReferenceCase[] = referenceCases("descriptive-reference.json", isReferenceCase); describe("against numpy and scipy", () => { it("PERCENTILE.EXC, SKEW.P, AVEDEV and DEVSQ", () => { diff --git a/tests/engine/test_directArguments.ts b/tests/engine/test_directArguments.ts index f0c1961..bf17971 100644 --- a/tests/engine/test_directArguments.ts +++ b/tests/engine/test_directArguments.ts @@ -5,17 +5,11 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { SpreadsheetEngine, type SheetData } from "../../src/engine/index.ts"; -import { cellAt } from "./cellAccess.ts"; import { pick, seededRandom } from "./seededRandom.ts"; +import { valuesBesideCells } from "./formulaColumns.ts"; // A1 holds "x", A2 TRUE, A3 5; the formulas go down column B. -function valuesOf(formulas: string[]): unknown[] { - const cells = [{ v: "x" }, { v: true }, { v: 5 }]; - const rows = Array.from({ length: Math.max(cells.length, formulas.length) }, (_, row) => [cells[row] ?? { v: "" }, { v: formulas[row] ?? "" }]); - const grid = new SpreadsheetEngine().calculate({ name: "S", data: rows } satisfies SheetData).data; - return formulas.map((_, row) => cellAt(grid, row, 1)); -} +const valuesOf = (formulas: string[]): unknown[] => valuesBesideCells(formulas, [{ v: "x" }, { v: true }, { v: 5 }]); describe("a value written as an aggregate's argument", () => { it("answers the issue's formulas as Excel does", () => { diff --git a/tests/engine/test_distributionFamilies.ts b/tests/engine/test_distributionFamilies.ts index 97feb04..5cbdaf1 100644 --- a/tests/engine/test_distributionFamilies.ts +++ b/tests/engine/test_distributionFamilies.ts @@ -7,10 +7,10 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; import { SpreadsheetEngine, type SheetData } from "../../src/engine/index.ts"; import { cellAt } from "./cellAccess.ts"; import { pick, seededRandom } from "./seededRandom.ts"; +import { referenceCases } from "./referenceFixture.ts"; const RELATIVE_TOLERANCE = 1e-9; // A reference far below the doubles' resolution near 1 has no relative error to speak of. @@ -31,10 +31,7 @@ interface ReferenceCase { const isReferenceCase = (value: unknown): value is ReferenceCase => typeof value === "object" && value !== null && typeof Reflect.get(value, "formula") === "string"; -const parsed: unknown = JSON.parse(readFileSync(new URL("./fixtures/distribution-families-reference.json", import.meta.url), "utf8")); -const referenceCases: unknown = typeof parsed === "object" && parsed !== null ? Reflect.get(parsed, "cases") : undefined; -assert.ok(Array.isArray(referenceCases) && referenceCases.every(isReferenceCase), "the reference fixture holds cases"); -const cases: ReferenceCase[] = referenceCases.filter(isReferenceCase); +const cases: ReferenceCase[] = referenceCases("distribution-families-reference.json", isReferenceCase); describe("against 50-digit references", () => { it("answers every formula of the fixture within 1e-9", () => { diff --git a/tests/engine/test_expandRangeOrCell.ts b/tests/engine/test_expandRangeOrCell.ts index 437cc2b..27105b6 100644 --- a/tests/engine/test_expandRangeOrCell.ts +++ b/tests/engine/test_expandRangeOrCell.ts @@ -6,15 +6,11 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { expandRangeOrCell } from "../../src/engine/formulaRefs.ts"; +import { rowMajorCells } from "./formulaAnswers.ts"; describe("expandRangeOrCell — ranges", () => { it("expands a simple range top-to-bottom, left-to-right", () => { - assert.deepEqual(expandRangeOrCell("A1:B2"), [ - { row: 0, col: 0 }, - { row: 0, col: 1 }, - { row: 1, col: 0 }, - { row: 1, col: 1 }, - ]); + assert.deepEqual(expandRangeOrCell("A1:B2"), rowMajorCells(2, 2)); }); it("expands a single-column range", () => { diff --git a/tests/engine/test_filter.ts b/tests/engine/test_filter.ts index f34647d..8fd44e5 100644 --- a/tests/engine/test_filter.ts +++ b/tests/engine/test_filter.ts @@ -7,13 +7,11 @@ import { SpreadsheetEngine, type CellValue } from "../../src/engine/index.ts"; import { filterTable } from "../../src/engine/functions/array-functions-math.ts"; import { NA_ERROR, VALUE_ERROR } from "../../src/engine/spreadsheet-errors.ts"; import { seededRandom } from "./seededRandom.ts"; +import { spilledBeside } from "./formulaAnswers.ts"; /** What `formula`, written beside `rows` (a column apart), spills. */ function answer(formula: string, rows: CellValue[][]): CellValue[][] { - const width = rows[0]?.length ?? 0; - const data = rows.map((row, r) => [...row, "", r === 0 ? formula : ""].map((v) => ({ v }))); - const [sheet] = new SpreadsheetEngine().calculateWorkbook([{ name: "S", data }]); - return (sheet?.data ?? []).map((row) => row.slice(width + 1)).filter((row) => row.some((cell) => cell !== "")); + return spilledBeside(formula, rows); } const sales: CellValue[][] = [ diff --git a/tests/engine/test_formulaRefs.ts b/tests/engine/test_formulaRefs.ts index edaf274..8a3e050 100644 --- a/tests/engine/test_formulaRefs.ts +++ b/tests/engine/test_formulaRefs.ts @@ -16,6 +16,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { stripFormulaPrefix, expandRange, parseSingleCellRef, extractCellReferences } from "../../src/engine/formulaRefs.js"; +import { rowMajorCells } from "./formulaAnswers.ts"; describe("stripFormulaPrefix", () => { it("strips a leading =", () => { @@ -41,12 +42,7 @@ describe("stripFormulaPrefix", () => { describe("expandRange", () => { it("expands a small square range", () => { - assert.deepEqual(expandRange("A1:B2"), [ - { row: 0, col: 0 }, - { row: 0, col: 1 }, - { row: 1, col: 0 }, - { row: 1, col: 1 }, - ]); + assert.deepEqual(expandRange("A1:B2"), rowMajorCells(2, 2)); }); it("expands a single-cell range (A1:A1)", () => { @@ -211,12 +207,7 @@ describe("extractCellReferences — happy path", () => { describe("extractCellReferences — range handling", () => { it("expands a SUM(A1:B2) range into 4 cells", () => { - assert.deepEqual(extractCellReferences("=SUM(A1:B2)"), [ - { row: 0, col: 0 }, - { row: 0, col: 1 }, - { row: 1, col: 0 }, - { row: 1, col: 1 }, - ]); + assert.deepEqual(extractCellReferences("=SUM(A1:B2)"), rowMajorCells(2, 2)); }); it("does NOT emit the range endpoints as standalone cells", () => { @@ -249,12 +240,7 @@ describe("extractCellReferences — range handling", () => { }); it("handles absolute-reference ranges", () => { - assert.deepEqual(extractCellReferences("=SUM($A$1:$B$2)"), [ - { row: 0, col: 0 }, - { row: 0, col: 1 }, - { row: 1, col: 0 }, - { row: 1, col: 1 }, - ]); + assert.deepEqual(extractCellReferences("=SUM($A$1:$B$2)"), rowMajorCells(2, 2)); }); }); diff --git a/tests/engine/test_groupBy.ts b/tests/engine/test_groupBy.ts index dd995a7..1611872 100644 --- a/tests/engine/test_groupBy.ts +++ b/tests/engine/test_groupBy.ts @@ -3,9 +3,10 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { SpreadsheetEngine, type CellValue } from "../../src/engine/index.ts"; +import { type CellValue } from "../../src/engine/index.ts"; import { groupsOf, sortedRows } from "../../src/engine/functions/group-by-math.ts"; import { seededRandom } from "./seededRandom.ts"; +import { spilledBeside } from "./formulaAnswers.ts"; const SALES: CellValue[][] = [ ["Region", "Rep", "Sales"], @@ -18,10 +19,7 @@ const SALES: CellValue[][] = [ /** What `formula`, written a column beside `rows`, spills. */ function answer(formula: string, rows: CellValue[][] = SALES): CellValue[][] { - const width = rows[0]?.length ?? 0; - const data = rows.map((row, r) => [...row, "", r === 0 ? formula : ""].map((v) => ({ v }))); - const [sheet] = new SpreadsheetEngine().calculateWorkbook([{ name: "S", data }]); - return (sheet?.data ?? []).map((row) => row.slice(width + 1)).filter((row) => row.some((cell) => cell !== "")); + return spilledBeside(formula, rows); } describe("GROUPBY", () => { diff --git a/tests/engine/test_groupsAndTextArithmetic.ts b/tests/engine/test_groupsAndTextArithmetic.ts index a63cb54..096b643 100644 --- a/tests/engine/test_groupsAndTextArithmetic.ts +++ b/tests/engine/test_groupsAndTextArithmetic.ts @@ -6,11 +6,10 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { textOperandsAsNumbers } from "../../src/engine/textArithmetic.ts"; -import { SpreadsheetEngine, type SheetData } from "../../src/engine/index.ts"; import { VALUE_ERROR } from "../../src/engine/spreadsheet-errors.ts"; import type { SpreadsheetCell } from "../../src/engine/types.ts"; -import { cellAt } from "./cellAccess.ts"; import { pick, seededRandom } from "./seededRandom.ts"; +import { valuesBesideCells } from "./formulaColumns.ts"; describe("textOperandsAsNumbers", () => { it("reads text beside an arithmetic operator as its number", () => { @@ -37,11 +36,7 @@ describe("textOperandsAsNumbers", () => { // A1 holds "abc", A2 holds "3", A3 holds TRUE, A4 is blank; the formulas go down column B. const CELLS: SpreadsheetCell[] = [{ v: "abc" }, { v: "3" }, { v: true }, { v: "" }]; -function valuesOf(formulas: string[]): unknown[] { - const rows = Array.from({ length: Math.max(CELLS.length, formulas.length) }, (_, row) => [CELLS[row] ?? { v: "" }, { v: formulas[row] ?? "" }]); - const grid = new SpreadsheetEngine().calculate({ name: "S", data: rows } satisfies SheetData).data; - return formulas.map((_, row) => cellAt(grid, row, 1)); -} +const valuesOf = (formulas: string[]): unknown[] => valuesBesideCells(formulas, CELLS); describe("a bracketed part (#94)", () => { it("answers the issue's formulas as Excel does", () => { diff --git a/tests/engine/test_logicalFunctions.ts b/tests/engine/test_logicalFunctions.ts index e05035a..366bdc4 100644 --- a/tests/engine/test_logicalFunctions.ts +++ b/tests/engine/test_logicalFunctions.ts @@ -5,9 +5,8 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { SpreadsheetEngine, type SheetData } from "../../src/engine/index.ts"; import { coerceToBoolean } from "../../src/engine/coerce-boolean.ts"; -import { cellAt } from "./cellAccess.ts"; +import { formulaValue } from "./formulaAnswers.ts"; describe("coerceToBoolean", () => { it("passes booleans through", () => { @@ -51,8 +50,7 @@ describe("coerceToBoolean", () => { }); }); -const evalFormula = (formula: string): unknown => - cellAt(new SpreadsheetEngine().calculate({ name: "S", data: [[{ v: formula }]] } satisfies SheetData).data, 0, 0); +const evalFormula = formulaValue; describe("IF and AND/OR/NOT agree on the same value", () => { // Each value should send IF down the false branch exactly when AND/OR/NOT read diff --git a/tests/engine/test_lookupFunctions.ts b/tests/engine/test_lookupFunctions.ts index 698be42..93f30a1 100644 --- a/tests/engine/test_lookupFunctions.ts +++ b/tests/engine/test_lookupFunctions.ts @@ -8,14 +8,9 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { SpreadsheetEngine, type SheetData } from "../../src/engine/index.ts"; import { cellAt } from "./cellAccess.ts"; +import { valueBelow } from "./formulaAnswers.ts"; -/** Calculate `formula` in cell A-after-the-data of a single sheet built from `rows`. */ -const evalInSheet = (rows: (string | number)[][], formula: string): unknown => { - const data = rows.map((row) => row.map((value) => ({ v: value }))); - data.push([{ v: formula }]); - const result = new SpreadsheetEngine().calculate({ name: "S", data }); - return cellAt(result.data, data.length - 1, 0); -}; +const evalInSheet = valueBelow; describe("VLOOKUP — same sheet", () => { const table: (string | number)[][] = [ diff --git a/tests/engine/test_mathematicalFunctions.ts b/tests/engine/test_mathematicalFunctions.ts index 6f21a5b..797df17 100644 --- a/tests/engine/test_mathematicalFunctions.ts +++ b/tests/engine/test_mathematicalFunctions.ts @@ -22,6 +22,7 @@ import { import { SpreadsheetEngine, type SheetData } from "../../src/engine/index.ts"; import { DIV_ZERO_ERROR, NUM_ERROR } from "../../src/engine/spreadsheet-errors.ts"; import { cellAt } from "./cellAccess.ts"; +import { formulaValue } from "./formulaAnswers.ts"; const closeTo = (actual: number, expected: number, eps = 1e-9): boolean => Math.abs(actual - expected) <= eps; @@ -135,8 +136,7 @@ describe("logWithBase — number and base domain", () => { }); }); -const evalFormula = (formula: string): unknown => - cellAt(new SpreadsheetEngine().calculate({ name: "S", data: [[{ v: formula }]] } satisfies SheetData).data, 0, 0); +const evalFormula = formulaValue; describe("the handlers surface the errors end-to-end", () => { it("displays the Excel error codes through the engine", () => { diff --git a/tests/engine/test_regression.ts b/tests/engine/test_regression.ts index 90894df..9679a12 100644 --- a/tests/engine/test_regression.ts +++ b/tests/engine/test_regression.ts @@ -5,18 +5,11 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; import { forecastLinear, populationCovariance, sampleCovariance, standardErrorOfEstimate, trendAt } from "../../src/engine/functions/regression-math.ts"; import { valuesOf } from "./formulaColumns.ts"; import { seededRandom } from "./seededRandom.ts"; - -const RELATIVE_TOLERANCE = 1e-9; - -const assertClose = (actual: unknown, expected: number, label: string): void => { - assert.equal(typeof actual, "number", `${label}: ${String(actual)}`); - const error = Math.abs(Number(actual) - expected) / Math.max(Math.abs(expected), 1); - assert.ok(error <= RELATIVE_TOLERANCE, `${label}: ${String(actual)} vs ${expected} (error ${error.toExponential(2)})`); -}; +import { RELATIVE_TOLERANCE, assertRelativelyClose as assertClose } from "./closeness.ts"; +import { referenceCases } from "./referenceFixture.ts"; const roundedTo = (digits: number, value: unknown): number => Number(Number(value).toFixed(digits)); @@ -84,10 +77,7 @@ const isReferenceCase = (value: unknown): value is ReferenceCase => ["ys", "xs"].every((key) => Array.isArray(Reflect.get(value, key))) && typeof Reflect.get(value, "newX") === "number"; -const parsed: unknown = JSON.parse(readFileSync(new URL("./fixtures/regression-reference.json", import.meta.url), "utf8")); -const referenceCases: unknown = typeof parsed === "object" && parsed !== null ? Reflect.get(parsed, "cases") : undefined; -assert.ok(Array.isArray(referenceCases) && referenceCases.length > 0 && referenceCases.every(isReferenceCase), "the reference fixture holds cases"); -const cases: ReferenceCase[] = referenceCases.filter(isReferenceCase); +const cases: ReferenceCase[] = referenceCases("regression-reference.json", isReferenceCase); describe("the regression rules against exact references", () => { it("matches FORECAST.LINEAR and TREND with a constant", () => { diff --git a/tests/engine/test_roundingPrecision.ts b/tests/engine/test_roundingPrecision.ts index 3d6221e..b318fbc 100644 --- a/tests/engine/test_roundingPrecision.ts +++ b/tests/engine/test_roundingPrecision.ts @@ -10,6 +10,7 @@ import { ceilingToSignificance, floorToSignificance, power, roundDownTo, roundTo import { DIV_ZERO_ERROR, NUM_ERROR, type SpreadsheetError } from "../../src/engine/spreadsheet-errors.ts"; import { SpreadsheetEngine, type SheetData } from "../../src/engine/index.ts"; import { cellAt } from "./cellAccess.ts"; +import { lcgRandom } from "./seededRandom.ts"; describe("rounding works on the written decimal", () => { it("ROUND breaks a written half away from zero", () => { @@ -88,13 +89,7 @@ const MAX_DECIMALS = 6; const MAX_NEGATIVE_DIGITS = 3; const SEED = Number(process.env.ROUNDING_SEED ?? Date.now() % 1_000_000); -const random = (() => { - let state = SEED; - return (): number => { - state = (state * 1103515245 + 12345) % 2147483648; - return state / 2147483648; - }; -})(); +const random = lcgRandom(SEED); const randomInt = (below: number): number => Math.floor(random() * below); type Direction = "half" | "up" | "down"; diff --git a/tests/engine/test_sheetsReadingEachOther.ts b/tests/engine/test_sheetsReadingEachOther.ts index 484cd65..fc53db0 100644 --- a/tests/engine/test_sheetsReadingEachOther.ts +++ b/tests/engine/test_sheetsReadingEachOther.ts @@ -7,19 +7,14 @@ import assert from "node:assert/strict"; import { calculateSheet, type SheetData } from "../../src/engine/index.ts"; import { indexToColumn } from "../../src/engine/parser.ts"; import { functionRegistry, type FunctionDefinition } from "../../src/engine/registry.ts"; +import { lcgRandom } from "./seededRandom.ts"; const ROUNDS = 60; const MAX_ROWS = 8; const MAX_COLUMNS = 6; const SEED = Number(process.env.SHEETS_SEED ?? Date.now() % 1_000_000); -const random = (() => { - let state = SEED; - return (): number => { - state = (state * 1103515245 + 12345) % 2147483648; - return state / 2147483648; - }; -})(); +const random = lcgRandom(SEED); const below = (n: number): number => Math.floor(random() * n); interface Grid { diff --git a/tests/engine/test_statisticalAnalysis.ts b/tests/engine/test_statisticalAnalysis.ts index 19f0b98..f506d4f 100644 --- a/tests/engine/test_statisticalAnalysis.ts +++ b/tests/engine/test_statisticalAnalysis.ts @@ -7,6 +7,7 @@ import assert from "node:assert/strict"; import { SpreadsheetEngine, type SheetData } from "../../src/engine/index.ts"; import { functionRegistry } from "../../src/engine/functions/index.ts"; import { cellAt } from "./cellAccess.ts"; +import { assertWithin } from "./closeness.ts"; const TOLERANCE = 1e-6; @@ -27,10 +28,7 @@ const evaluate = (formula: string): unknown => { return cellAt(new SpreadsheetEngine().calculate(sheet).data, 0, 4); }; -const assertClose = (actual: unknown, expected: number): void => { - assert.equal(typeof actual, "number", `expected a number, got ${String(actual)}`); - assert.ok(Math.abs(Number(actual) - expected) < TOLERANCE, `${String(actual)} is not ${expected}`); -}; +const assertClose = (actual: unknown, expected: number): void => assertWithin(actual, expected, TOLERANCE); describe("analysis functions are registered as Statistical", () => { it("each name is callable", () => { diff --git a/tests/engine/test_statisticalFunctions.ts b/tests/engine/test_statisticalFunctions.ts index c5f8a98..98fa36f 100644 --- a/tests/engine/test_statisticalFunctions.ts +++ b/tests/engine/test_statisticalFunctions.ts @@ -5,16 +5,14 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { SpreadsheetEngine } from "../../src/engine/index.ts"; -import { cellAt } from "./cellAccess.ts"; +import { valueBelow } from "./formulaAnswers.ts"; /** Calculate `formula` in the cell just below a single column of `values`. */ -const evalOverColumn = (values: (string | number)[], formula: string): unknown => { - const data: { v: string | number }[][] = values.map((value) => [{ v: value }]); - data.push([{ v: formula }]); - const result = new SpreadsheetEngine().calculate({ name: "S", data }); - return cellAt(result.data, data.length - 1, 0); -}; +const evalOverColumn = (values: (string | number)[], formula: string): unknown => + valueBelow( + values.map((value) => [value]), + formula, + ); // {2,4,4,4,5,5,7,9}: mean 5, Σ(x-μ)² = 32. // Sample: 32 / (8-1) = 4.5714… → stdev 2.1380… diff --git a/tests/engine/test_statisticsGenerated.ts b/tests/engine/test_statisticsGenerated.ts index 406e027..7204b05 100644 --- a/tests/engine/test_statisticsGenerated.ts +++ b/tests/engine/test_statisticsGenerated.ts @@ -8,19 +8,14 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { SpreadsheetEngine, type SheetData } from "../../src/engine/index.ts"; import type { StoredCellValue } from "../../src/engine/types.ts"; +import { lcgRandom } from "./seededRandom.ts"; const ROUNDS = 150; const MAX_ROWS = 12; const RELATIVE_TOLERANCE = 1e-9; const SEED = Number(process.env.STATISTICS_SEED ?? Date.now() % 1_000_000); -const random = (() => { - let state = SEED; - return (): number => { - state = (state * 1103515245 + 12345) % 2147483648; - return state / 2147483648; - }; -})(); +const random = lcgRandom(SEED); const pick = (items: [T, ...T[]]): T => items[Math.floor(random() * items.length)] ?? items[0]; const generateCell = (): StoredCellValue => { diff --git a/tests/engine/test_textFormat.ts b/tests/engine/test_textFormat.ts index 96b365a..4802d02 100644 --- a/tests/engine/test_textFormat.ts +++ b/tests/engine/test_textFormat.ts @@ -15,9 +15,9 @@ import { formatWithPattern, parseNumberPattern } from "../../src/engine/textForm import { groupThousands } from "../../src/engine/formatter.ts"; import { SpreadsheetEngine } from "../../src/engine/index.ts"; import { cellAt } from "./cellAccess.ts"; +import { formulaValue } from "./formulaAnswers.ts"; -const engine = new SpreadsheetEngine(); -const evalFormula = (formula: string): unknown => cellAt(engine.calculate(engine.createSheet("S", [[`=${formula}`]])).data, 0, 0); +const evalFormula = (formula: string): unknown => formulaValue(`=${formula}`); describe("formatWithPattern — the cases reported in #2360", () => { it("groups digits for a currency pattern", () => { @@ -240,6 +240,7 @@ describe("TEXT — end to end through SpreadsheetEngine", () => { }); it("formats a cell reference the same way", () => { + const engine = new SpreadsheetEngine(); const sheet = engine.createSheet("S", [[{ v: 1234.5 }, { v: '=TEXT(A1,"$#,##0.00")' }]]); assert.equal(cellAt(engine.calculate(sheet).data, 0, 1), "$1,234.50"); }); diff --git a/tests/engine/test_textFunctions.ts b/tests/engine/test_textFunctions.ts index 599abc5..067c971 100644 --- a/tests/engine/test_textFunctions.ts +++ b/tests/engine/test_textFunctions.ts @@ -12,16 +12,14 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { substituteText, takeLeft, takeRight, toProperCase } from "../../src/engine/functions/text.ts"; -import { SpreadsheetEngine } from "../../src/engine/index.ts"; import { VALUE_ERROR } from "../../src/engine/spreadsheet-errors.ts"; -import { cellAt } from "./cellAccess.ts"; +import { formulaValue } from "./formulaAnswers.ts"; // The engine's display pass renders an error VALUE back to its code, so the // end-to-end assertions compare against the string a cell shows. const VALUE_ERROR_TEXT = VALUE_ERROR.code; -const engine = new SpreadsheetEngine(); -const evalFormula = (formula: string): unknown => cellAt(engine.calculate(engine.createSheet("S", [[`=${formula}`]])).data, 0, 0); +const evalFormula = (formula: string): unknown => formulaValue(`=${formula}`); describe("substituteText — empty old_text", () => { it("returns the text unchanged instead of inserting between characters", () => {