Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 5 additions & 22 deletions src/engine/functions/descriptive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -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`. */
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -148,15 +131,15 @@ 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",
examples: ["PERCENTILE.EXC(A2:A100, 0.9)"],
},
{
name: "QUARTILE.EXC",
handler: rangeAndNumber(quartileExclusive),
handler: rankedHandler(quartileExclusive),
minArgs: 2,
maxArgs: 2,
description: "Returns a quartile (1 to 3), interpolated like PERCENTILE.EXC",
Expand Down
27 changes: 16 additions & 11 deletions src/engine/functions/statistical-analysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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,
Expand Down
35 changes: 17 additions & 18 deletions src/engine/nameBinding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions tests/engine/closeness.ts
Original file line number Diff line number Diff line change
@@ -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}`);
}
28 changes: 28 additions & 0 deletions tests/engine/formulaAnswers.ts
Original file line number Diff line number Diff line change
@@ -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();
9 changes: 8 additions & 1 deletion tests/engine/formulaColumns.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand All @@ -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));
}
12 changes: 12 additions & 0 deletions tests/engine/referenceFixture.ts
Original file line number Diff line number Diff line change
@@ -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/<fileName>`, each checked by `isCase`; the fixture must hold at least one. */
export function referenceCases<T>(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);
}
33 changes: 3 additions & 30 deletions tests/engine/run-calculator-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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();
49 changes: 3 additions & 46 deletions tests/engine/run-evaluator-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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();
Loading
Loading