diff --git a/README.md b/README.md index c149b3e..5775a48 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,8 @@ yarn add @gui-chat-plugin/spreadsheet gui-chat-protocol > `gui-chat-protocol` is a peer dependency — install it alongside the plugin; the host application provides the runtime and this plugin only declares the compatible range. +> The package runs on an ES2023 runtime: Node 22 or later, Chrome 110, Safari 16, Firefox 115 or later. It uses ES2023's array methods (`toSorted`, `toReversed`) as they are, with no polyfill. + ## Usage diff --git a/src/core/functionList.ts b/src/core/functionList.ts index 23300a8..7d258fd 100644 --- a/src/core/functionList.ts +++ b/src/core/functionList.ts @@ -6,7 +6,7 @@ import { functionRegistry } from "../engine/functions/index"; /** Each category's function names, sorted; the engine's internal functions are left out. */ export const registeredFunctionsByCategory = (): Record => Object.fromEntries( - [...functionRegistry.getFunctionsByCategory()].map(([category, definitions]) => [category, definitions.map((definition) => definition.name).sort()]), + [...functionRegistry.getFunctionsByCategory()].map(([category, definitions]) => [category, definitions.map((definition) => definition.name).toSorted()]), ); /** Whether `name` is one of the engine's own functions, in any case. A workbook's own functions are not. */ diff --git a/src/engine/calculator.ts b/src/engine/calculator.ts index 389a564..387583a 100644 --- a/src/engine/calculator.ts +++ b/src/engine/calculator.ts @@ -254,8 +254,8 @@ function openSheet( // How far a sheet reaches, for bounding `A:A` and `1:1`; null for a sheet that does not exist. const extentOf = (name: string | null, reach?: ExtentReach): SheetExtent | null => { - const sheet = name === null || name === sheetName ? self : (opened.get(name) ?? openOther(name)); - return sheet ? sheet.extent(reach) : null; + const named = name === null || name === sheetName ? self : (opened.get(name) ?? openOther(name)); + return named ? named.extent(reach) : null; }; const formulaAt = (row: number, col: number): string | null => { @@ -425,14 +425,14 @@ function openSheet( const locateCell = (ref: string): { cell: unknown; position: CellPosition; read: CellReader } | null => { const resolved = resolveSheetData(ref); if (!resolved) throw invalidRefError(ref); // Sheet not found → #REF! - const { sheetData, ref: cellRef, read, sheet } = resolved; + const { sheetData, ref: cellRef, read, sheet: owner } = resolved; // `$` symbols and the A1 shape are parsed by the shared single-cell reader. const coord = parseSingleCellRef(cellRef); if (!coord) return null; const { row, col } = coord; - sheet.ensureSpillsReaching(row, col); + owner.ensureSpillsReaching(row, col); const gridRow = row >= 0 ? sheetData[row] : undefined; if (!gridRow || col < 0 || col >= gridRow.length) return null; @@ -472,11 +472,11 @@ function openSheet( if (answersTable(range)) return calledRangeValues(range, rangeOptions); const resolved = resolveSheetData(range); if (!resolved) return []; // Sheet not found → empty range - const { sheetData, ref: rangeRef, read, sheet } = resolved; + const { sheetData, ref: rangeRef, read, sheet: owner } = resolved; const coords = expandRangeOrCell(rangeRef); if (!coords) return []; - ensureSpillsCovering(sheet, coords); + ensureSpillsCovering(owner, coords); const sourceRows = sourceSheetOf(range); const values: CellValue[] = []; @@ -586,7 +586,7 @@ function openSheet( const info: FormulaInfo = { cell: { row: position.row, col: position.col }, formula: value, - dependencies: [], // TODO: Extract dependencies from formula + dependencies: [], // not tracked: recalculation follows references as it reads them result: 0, // Will be updated below }; formulas.push(info); diff --git a/src/engine/cellArray.ts b/src/engine/cellArray.ts index 2dc9d5a..ff12578 100644 --- a/src/engine/cellArray.ts +++ b/src/engine/cellArray.ts @@ -12,7 +12,7 @@ import type { CellArray, CellValue, FormulaValue } from "./types"; * a function building one from numbers it is given (SEQUENCE, MUNIT) refuses a size past it. */ export const MAX_TABLE_ROWS = 1_048_576; export const MAX_TABLE_COLUMNS = 16_384; -export const MAX_TABLE_CELLS = 1_048_576; +const MAX_TABLE_CELLS = 1_048_576; export const fitsInSheet = (rows: number, columns: number): boolean => rows <= MAX_TABLE_ROWS && columns <= MAX_TABLE_COLUMNS && rows * columns <= MAX_TABLE_CELLS; diff --git a/src/engine/evaluator.ts b/src/engine/evaluator.ts index e75aeda..b2d1fec 100644 --- a/src/engine/evaluator.ts +++ b/src/engine/evaluator.ts @@ -195,7 +195,7 @@ export function findCellRefs(expr: string): CellRefSpan[] { * `=A1+A10` had its `A10` broken into `0` and produced a plausible * wrong number (#2357). */ export function substituteCellRefs(expr: string, cellRefs: CellRefSpan[], render: (ref: string, start: number) => string): string { - return [...cellRefs].reverse().reduce((text, { ref, start }) => text.slice(0, start) + render(ref, start) + text.slice(start + ref.length), expr); + return cellRefs.toReversed().reduce((text, { ref, start }) => text.slice(0, start) + render(ref, start) + text.slice(start + ref.length), expr); } /** @@ -318,7 +318,7 @@ function blankComparisonSides(expr: string, context: EvaluatorContext): boolean[ } /** The reference an operand is, perhaps in brackets, or null when it is anything more. */ -export function loneReference(operand: string): string | null { +function loneReference(operand: string): string | null { const inner = stripOuterParens(operand).trim(); const [only, ...rest] = findCellRefs(inner); return only && rest.length === 0 && only.start === 0 && only.ref.length === inner.length ? only.ref : null; diff --git a/src/engine/functions/analysis-math.ts b/src/engine/functions/analysis-math.ts index 13ec58c..8e530ed 100644 --- a/src/engine/functions/analysis-math.ts +++ b/src/engine/functions/analysis-math.ts @@ -81,7 +81,7 @@ export function rSquared(pairs: NumericPairs): number | SpreadsheetError { */ export function percentileInclusive(values: number[], k: number): number | SpreadsheetError { if (values.length === 0 || k < 0 || k > PERCENT_MAX) return NUM_ERROR; - const sorted = [...values].sort((a, b) => a - b); + const sorted = values.toSorted((a, b) => a - b); const position = k * (sorted.length - 1); const lower = sorted[Math.floor(position)] ?? 0; const upper = sorted[Math.ceil(position)] ?? lower; @@ -100,14 +100,14 @@ export function quartileInclusive(values: number[], quarter: number): number | S export function kthLargest(values: number[], k: number): number | SpreadsheetError { const position = Math.ceil(k); if (position < 1 || position > values.length) return NUM_ERROR; - return [...values].sort((a, b) => b - a)[position - 1] ?? NUM_ERROR; + return values.toSorted((a, b) => b - a)[position - 1] ?? NUM_ERROR; } /** The k-th smallest value (SMALL), under the same rules as `kthLargest`. */ export function kthSmallest(values: number[], k: number): number | SpreadsheetError { const position = Math.ceil(k); if (position < 1 || position > values.length) return NUM_ERROR; - return [...values].sort((a, b) => a - b)[position - 1] ?? NUM_ERROR; + return values.toSorted((a, b) => a - b)[position - 1] ?? NUM_ERROR; } /** RANK: 1 + how many values beat `target`, so equal values share the top rank. diff --git a/src/engine/functions/array-functions-math.ts b/src/engine/functions/array-functions-math.ts index b664251..3441cf2 100644 --- a/src/engine/functions/array-functions-math.ts +++ b/src/engine/functions/array-functions-math.ts @@ -40,7 +40,7 @@ function sortedByKeys(rows: CellArray, keys: SortKey[]): CellArray { const positions = rows.map((_, index) => index); const byKeys = (left: number, right: number): number => keys.reduce((decided, { values, order }) => decided || compareForSort(values[left] ?? "", values[right] ?? "", order), 0); - return [...positions].sort(byKeys).map((index) => rows[index] ?? []); + return positions.toSorted(byKeys).map((index) => rows[index] ?? []); } /** SORT: by the `index`-th column (1-based), or the `index`-th row when `byColumn`. */ @@ -97,7 +97,7 @@ export function sequence(rows: number, columns: number, start: number, step: num const [height, width] = [Math.trunc(rows), Math.trunc(columns)]; if (height < 0 || width < 0 || !fitsInSheet(height, width)) return VALUE_ERROR; if (height === 0 || width === 0) return CALC_ERROR; - return Array.from({ length: height }, (_, row) => Array.from({ length: width }, (_, col) => start + (row * width + col) * step)); + return Array.from({ length: height }, (_, row) => Array.from({ length: width }, (_cell, col) => start + (row * width + col) * step)); } /** The 0-based positions CHOOSEROWS / CHOOSECOLS pick: 1 is the first, -1 the last; 0 or past the @@ -121,7 +121,7 @@ export function chooseColumns(table: CellArray, picks: number[]): TableResult { export function stackHorizontally(tables: CellArray[]): CellArray { const height = tables.reduce((tallest, table) => Math.max(tallest, table.length), 0); return Array.from({ length: height }, (_, row) => - tables.flatMap((table) => Array.from({ length: widthOf(table) }, (_, col) => table[row]?.[col] ?? NA_ERROR)), + tables.flatMap((table) => Array.from({ length: widthOf(table) }, (_cell, col) => table[row]?.[col] ?? NA_ERROR)), ); } diff --git a/src/engine/functions/descriptive-math.ts b/src/engine/functions/descriptive-math.ts index af7d46b..e835f35 100644 --- a/src/engine/functions/descriptive-math.ts +++ b/src/engine/functions/descriptive-math.ts @@ -15,7 +15,7 @@ const MIN_SKEWNESS_VALUES = 3; const PROBABILITY_SUM_TOLERANCE = 1e-9; // A Float64Array sorts by value; a plain array's default sort compares text. -const ascending = (values: readonly number[]): number[] => Array.from(Float64Array.from(values).sort()); +const ascending = (values: readonly number[]): number[] => Array.from(Float64Array.from(values).toSorted()); /** TRIMMEAN: the mean once ⌊n·percent / 2⌋ values are dropped from each end, as Excel rounds the * dropped count down to an even number. A percent outside 0 ≤ p < 1, or no values: #NUM!. */ diff --git a/src/engine/functions/distribution-families-math.ts b/src/engine/functions/distribution-families-math.ts index 8d25798..3da6f2e 100644 --- a/src/engine/functions/distribution-families-math.ts +++ b/src/engine/functions/distribution-families-math.ts @@ -20,7 +20,7 @@ const logChoose = (n: number, k: number): number => logGamma(n + 1) - logGamma(k /** The x in [low, high] where the increasing `cdf` reaches `p`, the bracket widened upward until * it holds the answer, then bisected until it is as narrow as a double can tell. */ -export function invertIncreasing(cdf: (x: number) => number, p: number, low: number, high: number): number { +function invertIncreasing(cdf: (x: number) => number, p: number, low: number, high: number): number { const bracket = { low, high }; while (cdf(bracket.high) < p && bracket.high < Number.MAX_VALUE / 2) bracket.high *= 2; for (let step = 0; step < MAX_BISECTIONS; step += 1) { diff --git a/src/engine/functions/forecast-ets-math.ts b/src/engine/functions/forecast-ets-math.ts index 52e1b2b..2b4bfe1 100644 --- a/src/engine/functions/forecast-ets-math.ts +++ b/src/engine/functions/forecast-ets-math.ts @@ -25,7 +25,7 @@ const sum = (values: readonly number[]): number => values.reduce((total, value) const mean = (values: readonly number[]): number => sum(values) / values.length; function median(values: readonly number[]): number { - const sorted = [...values].sort((left, right) => left - right); + const sorted = values.toSorted((left, right) => left - right); const middle = Math.floor(sorted.length / 2); return sorted.length % 2 === 1 ? (sorted[middle] ?? 0) : ((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2; } @@ -62,7 +62,7 @@ export interface RegularSeries { /** Each distinct time with the values at it, in time order. */ function groupedByTime(points: readonly TimedValue[]): { time: number; values: number[] }[] { - const sorted = [...points].sort((left, right) => left.time - right.time); + const sorted = points.toSorted((left, right) => left.time - right.time); return sorted.reduce<{ time: number; values: number[] }[]>((groups, point) => { const last = groups.at(-1); const group = last && last.time === point.time ? last : { time: point.time, values: [] }; @@ -217,12 +217,13 @@ const squaredErrorOf = (values: readonly number[], period: number, smoothing: Sm * one-step errors, least first (a tie in grid order). */ function scoredGrid(values: readonly number[], period: number): Scored[] { const candidates = grid(1).flatMap((alpha) => grid(1).flatMap((beta) => (period === 0 ? [0] : grid(1 - alpha)).map((gamma) => ({ alpha, beta, gamma })))); - return candidates.map((smoothing) => ({ smoothing, error: squaredErrorOf(values, period, smoothing) })).sort((left, right) => left.error - right.error); + return candidates.map((smoothing) => ({ smoothing, error: squaredErrorOf(values, period, smoothing) })).toSorted((left, right) => left.error - right.error); } +const clip = (value: number, limit: number): number => Math.min(Math.max(value, 0), limit); + /** The smoothings one `step` from `at` along each parameter, kept within 0 ≤ α, β ≤ 1 and 0 ≤ γ ≤ 1 − α. */ function neighbours(at: Smoothing, step: number, period: number): Smoothing[] { - const clip = (value: number, limit: number): number => Math.min(Math.max(value, 0), limit); const moved = [-step, step].flatMap((delta) => { const alpha = clip(at.alpha + delta, 1); const alongAlpha = { ...at, alpha, gamma: Math.min(at.gamma, 1 - alpha) }; diff --git a/src/engine/functions/group-by-math.ts b/src/engine/functions/group-by-math.ts index 5255e9b..778bc2e 100644 --- a/src/engine/functions/group-by-math.ts +++ b/src/engine/functions/group-by-math.ts @@ -51,7 +51,7 @@ export function sortedRows(rows: CellArray, sortOrder: readonly number[]): CellA }); return decided.find((difference) => difference !== 0) ?? 0; }; - return positions.sort(compare).map((index) => rows[index] ?? []); + return positions.toSorted(compare).map((index) => rows[index] ?? []); } export interface GroupByOptions { @@ -115,7 +115,7 @@ function withSubtotals(leaves: Leaf[], prefix: number, layout: SubtotalLayout): block.flatMap((leaf) => leaf.dataRows), layout.aggregate, ); - return layout.atTop ? [subtotal, ...inner] : [...inner, subtotal]; + return layout.atTop ? [subtotal].concat(inner) : inner.concat([subtotal]); }); } @@ -151,14 +151,12 @@ export function pivotTable( const columns: (Group | null)[] = withTotal(columnGroups, options.columnTotals); const rows: (Group | null)[] = withTotal(rowGroups, options.rowTotals); const blankRowKey = Array.from({ length: rowWidth }, (): CellValue => ""); - const headerRows = Array.from({ length: columnDepth }, (_, level) => [ - ...blankRowKey, - ...columns.map((group) => (group === null ? (level === 0 ? TOTAL : "") : (group.key[level] ?? ""))), - ]); - const bodyRows = rows.map((rowGroup) => [ - ...(rowGroup === null ? [TOTAL, ...blankRowKey.slice(1)] : rowGroup.key), - ...columns.map((columnGroup) => cell(both(rowGroup, columnGroup))), - ]); + const headerRows = Array.from({ length: columnDepth }, (_, level) => + blankRowKey.concat(columns.map((group) => (group === null ? (level === 0 ? TOTAL : "") : (group.key[level] ?? "")))), + ); + const bodyRows = rows.map((rowGroup) => + (rowGroup === null ? [TOTAL, ...blankRowKey.slice(1)] : rowGroup.key).concat(columns.map((columnGroup) => cell(both(rowGroup, columnGroup)))), + ); return [...headerRows, ...bodyRows]; } diff --git a/src/engine/functions/group-by.ts b/src/engine/functions/group-by.ts index b327e29..e6b8407 100644 --- a/src/engine/functions/group-by.ts +++ b/src/engine/functions/group-by.ts @@ -57,6 +57,8 @@ function tableOfHeight(args: string[], context: FunctionContext, index: number, const split = (table: CellArray, hasHeaders: boolean): { data: CellArray; header: CellValue[] } => hasHeaders ? { data: table.slice(1), header: table[0] ?? [] } : { data: table, header: [] }; +const generated = (prefix: string, count: number): CellValue[] => Array.from({ length: count }, (_, index) => `${prefix} ${index + 1}`); + // GROUPBY(row_fields, values, function, [field_headers], [total_depth], [sort_order], [filter_array], [field_relationship]) const groupByHandler: FunctionHandler = (args, context) => { const keysTable = tableArg(args, context, 0); @@ -76,7 +78,6 @@ const groupByHandler: FunctionHandler = (args, context) => { if (typeof totalDepth !== "number" || isSpreadsheetErrorValue(sortOrder) || isSpreadsheetErrorValue(included)) { return [totalDepth, sortOrder, included].find(isSpreadsheetErrorValue) ?? VALUE_ERROR; } - const generated = (prefix: string, count: number): CellValue[] => Array.from({ length: count }, (_, index) => `${prefix} ${index + 1}`); const headers = mode === 3 ? [...keys.header, ...values.header] : mode === 2 ? [...generated("Row Field", keyWidth), ...generated("Value", width - keyWidth)] : null; return groupByTable(keys.data, values.data, included, aggregate, { headers, totalDepth: Math.trunc(totalDepth), sortOrder }); diff --git a/src/engine/functions/hypothesis-tests-math.ts b/src/engine/functions/hypothesis-tests-math.ts index c79dd36..a516f24 100644 --- a/src/engine/functions/hypothesis-tests-math.ts +++ b/src/engine/functions/hypothesis-tests-math.ts @@ -86,8 +86,8 @@ interface Ranked { tieSizes: number[]; } -export function midRanks(values: readonly number[]): Ranked { - const order = values.map((value, index) => ({ value, index })).sort((left, right) => left.value - right.value); +function midRanks(values: readonly number[]): Ranked { + const order = values.map((value, index) => ({ value, index })).toSorted((left, right) => left.value - right.value); const starts = order.flatMap((entry, position) => (position === 0 || entry.value !== order[position - 1]?.value ? [position] : [])); const ranks = Array.from({ length: values.length }, () => 0); const tieSizes = starts.map((start, group) => { @@ -192,8 +192,8 @@ const KOLMOGOROV_SMALL_LAMBDA = 1; /** The largest gap between the two empirical distribution functions, in units of 1 / (m·n) so * that it is an integer: max |i·n − j·m| after each distinct value. */ function largestGap(first: number[], second: number[]): number { - const [left, right] = [Float64Array.from(first).sort(), Float64Array.from(second).sort()]; - const pooled = Float64Array.from([...left, ...right]).sort(); + const [left, right] = [Float64Array.from(first).toSorted(), Float64Array.from(second).toSorted()]; + const pooled = Float64Array.from([...left, ...right]).toSorted(); const counter = { i: 0, j: 0, gap: 0 }; pooled.forEach((value, position) => { if (pooled[position + 1] === value) return; @@ -245,7 +245,7 @@ export function kolmogorovSmirnov(first: number[], second: number[]): Result { const gap = largestGap(first, second); if (gap === 0) return 1; if (m * n > KS_EXACT_MAX_PRODUCT) return kolmogorovUpper((gap / (m * n)) * Math.sqrt((m * n) / (m + n))); - const pooled = Float64Array.from([...first, ...second]).sort(); + const pooled = Float64Array.from([...first, ...second]).toSorted(); const observed = Array.from({ length: m + n + 1 }, (_, count) => count > 0 && pooled[count - 1] !== pooled[count]); return clampProbability(ksExactOutside(m, n, gap, observed)); } @@ -328,7 +328,7 @@ function shapiroProbability(oneMinusW: number, n: number): number { export function shapiroWilk(values: number[]): Result { const n = values.length; if (n < SW_MIN_VALUES) return DIV_ZERO_ERROR; - const sorted = Float64Array.from(values).sort(); + const sorted = Float64Array.from(values).toSorted(); if ((sorted[n - 1] ?? 0) - (sorted[0] ?? 0) === 0) return DIV_ZERO_ERROR; const center = sorted[Math.floor(n / 2)] ?? 0; const centered = sorted.map((value) => value - center); diff --git a/src/engine/functions/let-lambda.ts b/src/engine/functions/let-lambda.ts index 1f6f720..4aab916 100644 --- a/src/engine/functions/let-lambda.ts +++ b/src/engine/functions/let-lambda.ts @@ -71,10 +71,10 @@ const letHandler: FunctionHandler = (args, context) => { const pairs = Array.from({ length: (args.length - 1) / 2 }, (_, index) => ({ name: args[2 * index]?.trim() ?? "", written: args[2 * index + 1] ?? "" })); if (!pairs.every(({ name }) => isValidName(name))) return NAME_ERROR; return withHoldings((holdings) => { - const bindings = pairs.reduce( - (bound, { name, written }) => [...bound, bindingFor(name, substituteNames(written, bound), context, holdings)], - [], - ); + const bindings = pairs.reduce((bound, { name, written }) => { + bound.push(bindingFor(name, substituteNames(written, bound), context, holdings)); + return bound; + }, []); return valueOf(substituteNames(args.at(-1) ?? "", bindings), context); }); }; @@ -168,7 +168,7 @@ const mapHandler: FunctionHandler = (args, context) => { const sources = args.slice(0, -1).map((arg) => elementSource(arg, context, holdings)); const shape = combinedShape(sources.map((source) => ({ rows: source.rows, columns: source.columns }))); return Array.from({ length: shape.rows }, (_, row) => - Array.from({ length: shape.columns }, (_, col) => { + Array.from({ length: shape.columns }, (_cell, col) => { const positions = sources.map((source) => ({ source, sourceRow: alignedIndex(source.rows, row), sourceCol: alignedIndex(source.columns, col) })); if (positions.some(({ sourceRow, sourceCol }) => sourceRow < 0 || sourceCol < 0)) return NA_ERROR; const texts = positions.map(({ source, sourceRow, sourceCol }) => source.textAt(sourceRow, sourceCol)); diff --git a/src/engine/functions/linest-math.ts b/src/engine/functions/linest-math.ts index f075df5..5c42e3f 100644 --- a/src/engine/functions/linest-math.ts +++ b/src/engine/functions/linest-math.ts @@ -98,7 +98,7 @@ function orthogonalise(design: number[][]): Orthogonalised { /** The inverse of a unit upper-triangular matrix, by back substitution. */ function unitUpperInverse(r: number[][]): number[][] { const size = r.length; - const inverse = r.map((_, row) => Array.from({ length: size }, (_, col): number => (row === col ? 1 : 0))); + const inverse = r.map((_, row) => Array.from({ length: size }, (_cell, col): number => (row === col ? 1 : 0))); Array.from({ length: size }, (_, offset) => size - 1 - offset).forEach((row) => { Array.from({ length: size - row - 1 }, (_, step) => row + 1 + step).forEach((col) => { const known = Array.from({ length: col - row }, (_, step) => row + 1 + step).reduce((sum, k) => sum + (r[row]?.[k] ?? 0) * (inverse[k]?.[col] ?? 0), 0); @@ -155,7 +155,7 @@ const ratio = (numerator: number, denominator: number): number | SpreadsheetErro * freedom, the regression and residual sums of squares — with #N/A where a row has no more values. */ export function linestTable(fit: Regression, data: RegressionData, stats: boolean): CellValue[][] { const width = fit.coefficients.length + 1; - const coefficients = [...[...fit.coefficients].reverse(), fit.intercept]; + const coefficients = [...fit.coefficients.toReversed(), fit.intercept]; if (!stats) return [coefficients]; const fitted = fittedValues(fit, data); const mean = data.ys.reduce((sum, y) => sum + y, 0) / data.ys.length; @@ -170,7 +170,7 @@ export function linestTable(fit: Regression, data: RegressionData, stats: boolea if (!isKept) return 0; return isSpreadsheetErrorValue(sey) ? sey : sey * Math.sqrt(inverseDiagonal); }; - const errors = fit.coefficients.map((_, index) => errorOf(fit.inverseDiagonal[index] ?? 0, fit.kept[index] ?? false)).reverse(); + const errors = fit.coefficients.map((_, index) => errorOf(fit.inverseDiagonal[index] ?? 0, fit.kept[index] ?? false)).toReversed(); const interceptError = fit.interceptInverseDiagonal === null ? NA_ERROR : errorOf(fit.interceptInverseDiagonal, true); const regressionMeanSquare = ratio(ssreg, keptCount); const f = isSpreadsheetErrorValue(regressionMeanSquare) || isSpreadsheetErrorValue(variance) ? NUM_ERROR : ratio(regressionMeanSquare, variance); diff --git a/src/engine/functions/matrix-math.ts b/src/engine/functions/matrix-math.ts index c42876a..e97c7c0 100644 --- a/src/engine/functions/matrix-math.ts +++ b/src/engine/functions/matrix-math.ts @@ -19,7 +19,7 @@ export function multiply(left: Matrix, right: Matrix): Matrix | SpreadsheetError return left.map((row) => Array.from({ length: width }, (_, col) => row.reduce((sum, value, k) => sum + value * (right[k]?.[col] ?? 0), 0))); } -export const identity = (size: number): Matrix => Array.from({ length: size }, (_, row) => Array.from({ length: size }, (_, col) => (row === col ? 1 : 0))); +export const identity = (size: number): Matrix => Array.from({ length: size }, (_, row) => Array.from({ length: size }, (_cell, col) => (row === col ? 1 : 0))); interface Decomposition { /** L below the diagonal (its unit diagonal left out) and U on and above it, in one matrix. */ @@ -111,9 +111,9 @@ export function determinant(matrix: Matrix): number | SpreadsheetError { /** FREQUENCY: how many values fall at or below each bin and above the one before it, in the bins' * own order, and then how many lie above every bin. With no bins, the count of the values. */ export function frequency(values: number[], bins: number[]): number[] { - const sortedBins = bins.map((bin, index) => ({ bin, index })).sort((left, right) => left.bin - right.bin); + const sortedBins = bins.map((bin, index) => ({ bin, index })).toSorted((left, right) => left.bin - right.bin); const counts = Array.from({ length: bins.length + 1 }, () => 0); - const sortedValues = [...values].sort((left, right) => left - right); + const sortedValues = values.toSorted((left, right) => left - right); const counted = sortedBins.reduce((cursor, { bin, index }) => { const reached = sortedValues.findIndex((value, position) => position >= cursor && value > bin); const next = reached === -1 ? sortedValues.length : reached; @@ -124,6 +124,11 @@ export function frequency(values: number[], bins: number[]): number[] { return counts; } +const numberAt = (table: CellValue[][], row: number, col: number): number => { + const cell = table[row]?.[col]; + return typeof cell === "number" ? cell : 0; +}; + /** SUMPRODUCT: the sum, over every position, of the product of the tables' cells there. The tables * must have one shape (#VALUE! otherwise); a cell that is not a number counts as 0, and the first * error cell, in argument order, is the answer. */ @@ -134,10 +139,6 @@ export function sumOfProducts(tables: CellValue[][][]): number | SpreadsheetErro if (!sameShape) return VALUE_ERROR; const error = tables.flatMap((table) => table.flat()).find(isSpreadsheetErrorValue); if (error) return error; - const numberAt = (table: CellValue[][], row: number, col: number): number => { - const cell = table[row]?.[col]; - return typeof cell === "number" ? cell : 0; - }; return first.reduce( (sum, row, r) => sum + row.reduce((rowSum, _, c) => rowSum + tables.reduce((product, table) => product * numberAt(table, r, c), 1), 0), 0, diff --git a/src/engine/functions/shape-math.ts b/src/engine/functions/shape-math.ts index ac79253..696a724 100644 --- a/src/engine/functions/shape-math.ts +++ b/src/engine/functions/shape-math.ts @@ -68,7 +68,7 @@ export function percentRank(values: number[], x: number, significance: number): const digits = Math.trunc(significance); if (digits < 1 || values.length === 0) return NUM_ERROR; // A Float64Array sorts by value; a plain array's default sort compares text. - const sorted = Array.from(Float64Array.from(values).sort()); + const sorted = Array.from(Float64Array.from(values).toSorted()); const [smallest, largest] = [sorted[0], sorted.at(-1)]; if (smallest === undefined || largest === undefined || x < smallest || x > largest) return NA_ERROR; if (sorted.length === 1) return 1; diff --git a/src/engine/functions/statistical-math.ts b/src/engine/functions/statistical-math.ts index cd0ad94..ade5a32 100644 --- a/src/engine/functions/statistical-math.ts +++ b/src/engine/functions/statistical-math.ts @@ -29,7 +29,7 @@ export function computeAverage(values: number[]): number | SpreadsheetError { */ export function computeMedian(values: number[]): number | SpreadsheetError { if (values.length === 0) return NUM_ERROR; - return arithmeticMean(middleValues([...values].sort((a, b) => a - b))); + return arithmeticMean(middleValues(values.toSorted((a, b) => a - b))); } /** The one or two values sitting in the middle of a sorted list — two when the diff --git a/src/engine/functions/statistical.ts b/src/engine/functions/statistical.ts index d3a70dc..dc74dc6 100644 --- a/src/engine/functions/statistical.ts +++ b/src/engine/functions/statistical.ts @@ -116,11 +116,8 @@ export const overNumbers = return Array.isArray(values) ? rule(values) : values; }; -export const collectNumericValues = (args: string[], context: FunctionContext): number[] => - collectArgumentValues(args, context, context.getRangeValues).map(({ value }) => toNumber(value)); - -// Same walk as `collectNumericValues`, but keeping each cell as it is: COUNTA -// counts non-empty cells, so text must survive the trip. +// Every value the arguments hold, each cell as it is: COUNTA counts non-empty cells, so text must +// survive the trip. const collectRawValues = (args: string[], context: FunctionContext): CellValue[] => collectArgumentValues(args, context, blankAwareRangeReader(context)).map(({ value }) => value); diff --git a/src/engine/nameBinding.ts b/src/engine/nameBinding.ts index 5ca5e8c..9b9b5e7 100644 --- a/src/engine/nameBinding.ts +++ b/src/engine/nameBinding.ts @@ -83,7 +83,7 @@ const sameName = (left: string, right: string): boolean => left.toUpperCase() == /** A LAMBDA's body with each parameter replaced by the bracketed argument written for it, or null when * the arguments do not match the parameters one for one. */ -export function inlinedCall(lambda: Lambda, argumentTexts: string[]): string | null { +function inlinedCall(lambda: Lambda, argumentTexts: string[]): string | null { if (argumentTexts.length !== lambda.params.length) return null; const bindings = lambda.params.map((name, index): Binding => ({ name, text: `(${argumentTexts[index] ?? ""})` })); return `(${substituteNames(lambda.body, bindings)})`; @@ -156,7 +156,7 @@ export function substituteName(text: string, binding: Binding): string { /** `text` with each binding written in, the latest first, so a name bound twice takes its latest value. */ export const substituteNames = (text: string, bindings: readonly Binding[]): string => - [...bindings].reverse().reduce((written, binding) => substituteName(written, binding), text); + bindings.toReversed().reduce((written, binding) => substituteName(written, binding), text); /** Where the first `LAMBDA(…)` written outside quotes starts, from `from`, or -1. */ function nextLambda(text: string, from: number): number { diff --git a/src/engine/spreadsheet-errors.ts b/src/engine/spreadsheet-errors.ts index 9fd40c8..9ce481a 100644 --- a/src/engine/spreadsheet-errors.ts +++ b/src/engine/spreadsheet-errors.ts @@ -26,7 +26,7 @@ export class SpreadsheetError { } } -export const NULL_ERROR = new SpreadsheetError("#NULL!"); +const NULL_ERROR = new SpreadsheetError("#NULL!"); export const DIV_ZERO_ERROR = new SpreadsheetError("#DIV/0!"); export const VALUE_ERROR = new SpreadsheetError("#VALUE!"); export const REF_ERROR = new SpreadsheetError("#REF!"); @@ -37,7 +37,7 @@ export const NA_ERROR = new SpreadsheetError("#N/A"); export const SPILL_ERROR = new SpreadsheetError("#SPILL!"); /** A table function whose answer would have no cells (an UNIQUE that finds nothing, a TAKE of 0). */ export const CALC_ERROR = new SpreadsheetError("#CALC!"); -export const UNKNOWN_ERROR = new SpreadsheetError("#ERROR!"); +const UNKNOWN_ERROR = new SpreadsheetError("#ERROR!"); // One instance per code: two errors of the same kind compare equal, which is how // error strings behaved and what the condition/comparison paths still rely on. diff --git a/src/engine/textArithmetic.ts b/src/engine/textArithmetic.ts index 2bbee28..08aed28 100644 --- a/src/engine/textArithmetic.ts +++ b/src/engine/textArithmetic.ts @@ -25,7 +25,7 @@ function endOfLiteral(expr: string, start: number): number { function isEscaped(chars: readonly string[], offset: number): boolean { const run = chars .slice(0, offset) - .reverse() + .toReversed() .findIndex((char) => char !== "\\"); const backslashes = run === -1 ? offset : run; return backslashes % 2 === 1; diff --git a/src/engine/textDateFormat.ts b/src/engine/textDateFormat.ts index 5bb10b2..9c20adc 100644 --- a/src/engine/textDateFormat.ts +++ b/src/engine/textDateFormat.ts @@ -75,7 +75,7 @@ const readLiteral = (pattern: string, scan: Scan): number => { /** The nearest date or time part from `start` stepping by `step`, past any text. */ const neighbour = (tokens: DateToken[], start: number, step: number): DateToken | undefined => { - const rest = step < 0 ? tokens.slice(0, start + 1).reverse() : tokens.slice(start); + const rest = step < 0 ? tokens.slice(0, start + 1).toReversed() : tokens.slice(start); return rest.find((token) => token.kind !== "literal"); }; diff --git a/tests/core/test_calculationErrors.ts b/tests/core/test_calculationErrors.ts index b2ab670..896e6fe 100644 --- a/tests/core/test_calculationErrors.ts +++ b/tests/core/test_calculationErrors.ts @@ -84,13 +84,18 @@ describe("presentSpreadsheet's reply", () => { describe("a workbook that ran QuickJS out of time", () => { const workbook: SpreadsheetSheet[] = JSON.parse(readFileSync(path.join(import.meta.dirname, "fixtures/rfm-mutual-sheets.json"), "utf8")); + // One read after another, each loading and disposing its own functions. + const readInTurn = async ([range, ...rest]: string[]): Promise => { + if (range === undefined) return; + const functions = await loadCustomFunctions(workbook); + const rows = calculatedRange(workbook, range, functions); + assert.doesNotThrow(() => functions.dispose()); + assert.ok(rows.length > 0, `${range} came back`); + return readInTurn(rest); + }; + it("can be read again and again without taking later reads down", async () => { - for (const range of ["RFM!A1:J121", "Settings!A1:H11", "Summary!A1:D8"]) { - const functions = await loadCustomFunctions(workbook); - const rows = calculatedRange(workbook, range, functions); - assert.doesNotThrow(() => functions.dispose()); - assert.ok(rows.length > 0, `${range} came back`); - } + await readInTurn(["RFM!A1:J121", "Settings!A1:H11", "Summary!A1:D8"]); const fresh = await loadCustomFunctions([{ functions: [{ name: "AFTER", params: [], body: "return 7;" }] }]); assert.equal(fresh.definitions.has("AFTER"), true); fresh.dispose(); diff --git a/tests/core/test_execute.ts b/tests/core/test_execute.ts index 7ece20d..d49f0fc 100644 --- a/tests/core/test_execute.ts +++ b/tests/core/test_execute.ts @@ -190,11 +190,11 @@ describe("pluginCore.execute routing", () => { }); }); +const sheet = (functions: CustomFunctionSpec[]): SpreadsheetSheet[] => [{ name: "S", functions, data: [[{ v: 1 }]] }]; + // A function the agent defined that cannot be used is said in the reply (#114): otherwise its // cells show #NAME? with nothing saying why. describe("executeSpreadsheet — a workbook's functions", () => { - const sheet = (functions: CustomFunctionSpec[]): SpreadsheetSheet[] => [{ name: "S", functions, data: [[{ v: 1 }]] }]; - it("says which functions cannot be used, and why", async () => { const result = await executeSpreadsheet( {}, diff --git a/tests/core/test_functionList.ts b/tests/core/test_functionList.ts index 12d4d17..3660b51 100644 --- a/tests/core/test_functionList.ts +++ b/tests/core/test_functionList.ts @@ -9,9 +9,9 @@ const listedNames = (): string[] => Object.values(registeredFunctionsByCategory( describe("registeredFunctionsByCategory", () => { it("lists every documented registered function once, sorted within its category", () => { const documented = functionRegistry.getAllFunctions().filter((definition) => definition.internal !== true); - assert.deepEqual(listedNames().sort(), documented.map((definition) => definition.name).sort()); + assert.deepEqual(listedNames().toSorted(), documented.map((definition) => definition.name).toSorted()); assert.equal(new Set(listedNames()).size, listedNames().length); - Object.values(registeredFunctionsByCategory()).forEach((names) => assert.deepEqual(names, [...names].sort())); + Object.values(registeredFunctionsByCategory()).forEach((names) => assert.deepEqual(names, names.toSorted())); }); it("files each function under its own category", () => { diff --git a/tests/engine/test_analysisMath.ts b/tests/engine/test_analysisMath.ts index 7b0cb50..311f0e3 100644 --- a/tests/engine/test_analysisMath.ts +++ b/tests/engine/test_analysisMath.ts @@ -205,10 +205,11 @@ describe("population spread", () => { }); }); -describe("rowsMeetingAll", () => { - const isEast = (value: unknown): boolean => value === "East"; - const overTen = (value: unknown): boolean => typeof value === "number" && value > 10; +const isEast = (value: unknown): boolean => value === "East"; + +const overTen = (value: unknown): boolean => typeof value === "number" && value > 10; +describe("rowsMeetingAll", () => { it("marks the rows that meet every condition", () => { const conditions = [ { range: ["East", "West", "East", "East"], matches: isEast }, diff --git a/tests/engine/test_arrayExpressions.ts b/tests/engine/test_arrayExpressions.ts index 7ddd048..1f2b661 100644 --- a/tests/engine/test_arrayExpressions.ts +++ b/tests/engine/test_arrayExpressions.ts @@ -12,9 +12,10 @@ import { seededRandom } from "./seededRandom.ts"; const operandTexts = (formula: string): string[] => arrayOperands(formula, CALL_NAMES).map(({ kind, text }) => `${kind}:${text}`); +const toSheet = (name: string, data: CellValue[][]) => ({ name, data: data.map((row) => row.map((v) => ({ v }))) }); + /** The sheet `rows` calculates to; a string starting with "=" is a formula. */ function calculated(rows: CellValue[][], extraSheets: { name: string; data: CellValue[][] }[] = []): CellValue[][] { - const toSheet = (name: string, data: CellValue[][]) => ({ name, data: data.map((row) => row.map((v) => ({ v }))) }); const [first] = new SpreadsheetEngine().calculateWorkbook([toSheet("S", rows), ...extraSheets.map(({ name, data }) => toSheet(name, data))]); return first?.data ?? []; } @@ -204,7 +205,7 @@ describe(`properties over generated formulas (seed ${baseSeed})`, () => { const operators = ["+", "-", "*", "/", "&", ">", "="]; const operator = operators[Math.floor(random() * operators.length)] ?? "*"; const label = `seed ${baseSeed}: ${JSON.stringify(rows)} ${operator}`; - const sheet = calculated(rows.map((row, r) => [...row, r === 0 ? `=A1:A${height}${operator}B1:B${height}` : "", `=A${r + 1}${operator}B${r + 1}`])); + const sheet = calculated(rows.map((row, r) => row.concat([r === 0 ? `=A1:A${height}${operator}B1:B${height}` : "", `=A${r + 1}${operator}B${r + 1}`]))); assert.deepEqual( sheet.map((row) => row[2]), sheet.map((row) => row[3]), diff --git a/tests/engine/test_arrayFunctions.ts b/tests/engine/test_arrayFunctions.ts index af05d8e..dcc4c00 100644 --- a/tests/engine/test_arrayFunctions.ts +++ b/tests/engine/test_arrayFunctions.ts @@ -252,7 +252,7 @@ describe(`properties over generated tables (seed ${baseSeed})`, () => { const range = rangeOf(columns); const label = `seed ${baseSeed}: ${JSON.stringify(columns)}`; const sorted = spilled(`=SORT(${range})`, columns); - assert.deepEqual(sorted.map(sortKey).sort(), rowsOf(columns).map(sortKey).sort(), label); + assert.deepEqual(sorted.map(sortKey).toSorted(), rowsOf(columns).map(sortKey).toSorted(), label); const unique = spilled(`=UNIQUE(${range})`, columns); assert.equal(new Set(unique.map(sortKey)).size, unique.length, label); assert.deepEqual(new Set(unique.map(sortKey)), new Set(rowsOf(columns).map(sortKey)), label); diff --git a/tests/engine/test_condition.ts b/tests/engine/test_condition.ts index 61d07ba..1ee12c0 100644 --- a/tests/engine/test_condition.ts +++ b/tests/engine/test_condition.ts @@ -246,23 +246,23 @@ describe("evaluateCondition — code is data", () => { // the formula. They must now be read as operands and nothing more. it("does not execute an assignment", () => { const marker = globalThis as Record; - marker.__conditionProbe = false; - assert.equal(evaluateCondition("globalThis.__conditionProbe=true"), false, "an assignment is text, and text is not a comparison match"); - assert.equal(marker.__conditionProbe, false, "nothing ran"); + marker.spreadsheetConditionProbe = false; + assert.equal(evaluateCondition("globalThis.spreadsheetConditionProbe=true"), false, "an assignment is text, and text is not a comparison match"); + assert.equal(marker.spreadsheetConditionProbe, false, "nothing ran"); }); it("does not execute a call or a sequence", () => { const marker = globalThis as Record; - marker.__conditionProbe2 = false; - evaluateCondition("(globalThis.__conditionProbe2=true, 1)>0"); - assert.equal(marker.__conditionProbe2, false); + marker.spreadsheetConditionProbe2 = false; + evaluateCondition("(globalThis.spreadsheetConditionProbe2=true, 1)>0"); + assert.equal(marker.spreadsheetConditionProbe2, false); }); it("does not honour a logical operator smuggled into the condition", () => { const marker = globalThis as Record; - marker.__conditionProbe3 = false; - evaluateCondition("1>0&&(globalThis.__conditionProbe3=true)"); - assert.equal(marker.__conditionProbe3, false); + marker.spreadsheetConditionProbe3 = false; + evaluateCondition("1>0&&(globalThis.spreadsheetConditionProbe3=true)"); + assert.equal(marker.spreadsheetConditionProbe3, false); }); it("never throws on syntactically broken input", () => { diff --git a/tests/engine/test_criteriaExpressions.ts b/tests/engine/test_criteriaExpressions.ts index ebe069d..6d9b0d6 100644 --- a/tests/engine/test_criteriaExpressions.ts +++ b/tests/engine/test_criteriaExpressions.ts @@ -53,14 +53,14 @@ describe("criteria written as a literal still work", () => { }); }); +const countIn = (range: string, criteria: string): unknown => { + const sheet: SheetData = { name: "S", data: [[{ v: "2020-01-01" }, { v: `=COUNTIF(${range},${criteria})` }], [{ v: 1 }], [{ v: "2020-01-02" }]] }; + return cellAt(new SpreadsheetEngine().calculate(sheet).data, 0, 1); +}; + // A date written as a literal is a date, as in Excel. Read as source text it was // parseFloat'ed to 1 and matched the NUMBER 1 instead of the date. describe("criteria written as a date", () => { - const countIn = (range: string, criteria: string): unknown => { - const sheet: SheetData = { name: "S", data: [[{ v: "2020-01-01" }, { v: `=COUNTIF(${range},${criteria})` }], [{ v: 1 }], [{ v: "2020-01-02" }]] }; - return cellAt(new SpreadsheetEngine().calculate(sheet).data, 0, 1); - }; - it("matches the date, not the number its first digits spell", () => { assert.equal(countIn("A1", '"1/1/2020"'), 1); assert.equal(countIn("A2", '"1/1/2020"'), 0); diff --git a/tests/engine/test_dateCalendar.ts b/tests/engine/test_dateCalendar.ts index 0ca4926..c317ece 100644 --- a/tests/engine/test_dateCalendar.ts +++ b/tests/engine/test_dateCalendar.ts @@ -111,6 +111,8 @@ const evaluateInSheet = (formula: string): unknown => { return cellAt(new SpreadsheetEngine().calculate(sheet).data, 0, 4); }; +const isWeekday = (day: number): boolean => weekdayOf(day, 2) !== NUM_ERROR && Number(weekdayOf(day, 2)) <= 5; + describe("edges of the calendar rules", () => { it("covers every WEEKDAY return type on a Thursday", () => { const thursday = serial(2008, 2, 14); @@ -130,7 +132,6 @@ describe("edges of the calendar rules", () => { }); it("agrees with a day-by-day count over generated spans", () => { - const isWeekday = (day: number): boolean => weekdayOf(day, 2) !== NUM_ERROR && Number(weekdayOf(day, 2)) <= 5; const seed = { value: 11 }; const next = (): number => (seed.value = (seed.value * 16807) % 2147483647) / 2147483647; Array.from({ length: 300 }, (): [number, number] => [Math.floor(40000 + next() * 4000), Math.floor(next() * 400)]).forEach(([start, length]) => { diff --git a/tests/engine/test_documentedFunctions.ts b/tests/engine/test_documentedFunctions.ts index eb304ce..45edde0 100644 --- a/tests/engine/test_documentedFunctions.ts +++ b/tests/engine/test_documentedFunctions.ts @@ -28,7 +28,7 @@ const readmeFunctionsByCategory = (readme: string): Map => { .slice(1) .map((block): [string, string[]] => { const [heading = "", ...lines] = block.split("\n"); - return [heading.trim(), [...lines.join("\n").matchAll(/`([A-Za-z][A-Za-z0-9]*(?:\.[A-Za-z0-9]+)*)`/g)].map((match) => match[1] ?? "").sort()]; + return [heading.trim(), [...lines.join("\n").matchAll(/`([A-Za-z][A-Za-z0-9]*(?:\.[A-Za-z0-9]+)*)`/g)].map((match) => match[1] ?? "").toSorted()]; }); const headings = entries.map(([heading]) => heading); assert.deepEqual(headings, [...new Set(headings)], "each category appears once"); diff --git a/tests/engine/test_emptyCellConcat.ts b/tests/engine/test_emptyCellConcat.ts index 12e5022..7d61894 100644 --- a/tests/engine/test_emptyCellConcat.ts +++ b/tests/engine/test_emptyCellConcat.ts @@ -72,6 +72,8 @@ const calcRow = (row: unknown[], formula: string, otherSheets: SheetData[] = []) const blankAfter = (blank: unknown): unknown => cellAt(new SpreadsheetEngine().calculate({ name: "S", data: normalizeData([[{ v: '=B1&"x"' }, blank, { v: "=B1+1" }]]) }).data, 0, 0); +const joined = (cells: SheetData["data"][number], col: number): unknown => cellAt(new SpreadsheetEngine().calculate({ name: "S", data: [cells] }).data, 0, col); + describe("a blank cell in a & join", () => { // A1 blank, B1 = 3 it("joins as nothing, as in Excel", () => { @@ -106,8 +108,6 @@ describe("a blank cell in a & join", () => { // A formula cell is judged by its answer whether it is calculated before or after the join. it("joins a formula's answer the same whichever cell comes first", () => { - const joined = (cells: SheetData["data"][number], col: number): unknown => - cellAt(new SpreadsheetEngine().calculate({ name: "S", data: [cells] }).data, 0, col); ['=""', '=" "', '="y"', "=0"].forEach((answer) => { const after = joined([{ v: '=B1&"x"' }, { v: answer }], 0); const before = joined([{ v: answer }, { v: '=A1&"x"' }], 1); diff --git a/tests/engine/test_forecastEts.ts b/tests/engine/test_forecastEts.ts index ef4cf21..ccd2e15 100644 --- a/tests/engine/test_forecastEts.ts +++ b/tests/engine/test_forecastEts.ts @@ -72,7 +72,7 @@ describe("the chosen smoothing", () => { const jump = next() * 40; const values = Array.from( { length }, - (_, t) => (t > length / 2 ? jump : 0) + (period ? 15 * Math.sin((2 * Math.PI * t) / period) * (1 + t / 10) : 0) + next() * 3, + (_value, t) => (t > length / 2 ? jump : 0) + (period ? 15 * Math.sin((2 * Math.PI * t) / period) * (1 + t / 10) : 0) + next() * 3, ); const fit = fitHoltWinters(values, period); assert.ok(!isSpreadsheetErrorValue(fit)); @@ -122,9 +122,9 @@ describe("the lag products", () => { }); }); -describe("the season's length", () => { - const steps = (count: number): number[] => Array.from({ length: count }, (_, index) => index); +const steps = (count: number): number[] => Array.from({ length: count }, (_, index) => index); +describe("the season's length", () => { it("is found under a steep trend, which is removed first", () => { assert.equal(detectedSeasonality(steps(24).map((t) => 5 * t + ([1, -1, -1, 1][t % 4] ?? 0))), 4); }); @@ -150,6 +150,10 @@ const range = (count: number): string => `B1:B${count},A1:A${count}`; const sequence = (count: number, from = 1, step = 1): number[] => Array.from({ length: count }, (_, index) => from + index * step); const seasonal = (count: number): number[] => sequence(count, 0).map((t) => 50 + 0.5 * t + [6, -2, -7, 3][t % 4] + ((t * 37) % 7) / 10); +const tenPointForecast = (completion: string): string => `=FORECAST.ETS(13,${range(10)},0${completion})`; + +const fourPointForecast = (completion: string): string => `=FORECAST.ETS(6,${range(4)},0${completion})`; + describe("FORECAST.ETS", () => { it("continues a straight line exactly, with no interval around it", () => { const times = sequence(10); @@ -214,7 +218,7 @@ describe("FORECAST.ETS", () => { const values = seasonal(16); const daily = answers(sequence(16), values, [`=FORECAST.ETS(18,${range(16)},4)`]); const weekly = answers(sequence(16, 45000, 7), values, [`=FORECAST.ETS(${45000 + 17 * 7},${range(16)},4)`]); - const order = sequence(16, 0).reverse(); + const order = sequence(16, 0).toReversed(); const reversed = answers( order.map((index) => index + 1), order.map((index) => values[index] ?? 0), @@ -238,8 +242,7 @@ describe("FORECAST.ETS", () => { it("fills a missing point on the line between its neighbours, or with 0, and reads a blank value as missing", () => { const times = [1, 2, 3, 5, 6, 7, 8, 9, 10, 11]; const values = [4, 6, 5, 9, 8, 10, 12, 11, 13, 14]; - const formula = (completion: string): string => `=FORECAST.ETS(13,${range(10)},0${completion})`; - const [interpolated, zeros] = answers(times, values, [formula(""), formula(",0")]); + const [interpolated, zeros] = answers(times, values, [tenPointForecast(""), tenPointForecast(",0")]); const full = sequence(11); const [byLine, byZero] = [7, 0].map((fill) => answers(full, [4, 6, 5, fill, 9, 8, 10, 12, 11, 13, 14], [`=FORECAST.ETS(13,${range(11)},0)`])[0]); const [blank] = answers(full, [4, 6, 5, "", 9, 8, 10, 12, 11, 13, 14], [`=FORECAST.ETS(13,${range(11)},0)`]); @@ -247,13 +250,15 @@ describe("FORECAST.ETS", () => { }); it("keeps a blank value's time, inside the series or at either end, as a missing point to fill", () => { - const formula = (completion: string): string => `=FORECAST.ETS(6,${range(4)},0${completion})`; - const [inside, insideZero] = answers(sequence(4), [10, "", 20, 25], [formula(""), formula(",0")]); - assert.deepEqual([inside, insideZero], [answers(sequence(4), [10, 15, 20, 25], [formula("")])[0], answers(sequence(4), [10, 0, 20, 25], [formula("")])[0]]); + const [inside, insideZero] = answers(sequence(4), [10, "", 20, 25], [fourPointForecast(""), fourPointForecast(",0")]); + assert.deepEqual( + [inside, insideZero], + [answers(sequence(4), [10, 15, 20, 25], [fourPointForecast("")])[0], answers(sequence(4), [10, 0, 20, 25], [fourPointForecast("")])[0]], + ); const atEnd = (values: CellValue[]): CellValue => answers(sequence(7), values, [`=FORECAST.ETS(9,${range(7)},0)`])[0] ?? ""; assert.equal(atEnd([4, 6, 5, 9, 8, 10, ""]), atEnd([4, 6, 5, 9, 8, 10, 10])); assert.equal(atEnd(["", 6, 5, 9, 8, 10, 12]), atEnd([6, 6, 5, 9, 8, 10, 12])); - assert.deepEqual(answers(sequence(4), [10, "", "", 25], [formula("")]), ["#NUM!"]); + assert.deepEqual(answers(sequence(4), [10, "", "", 25], [fourPointForecast("")]), ["#NUM!"]); }); it("answers #NUM! for a step that is not constant, too many missing points, or a target before the end", () => { diff --git a/tests/engine/test_formulaRefs.ts b/tests/engine/test_formulaRefs.ts index a300347..edaf274 100644 --- a/tests/engine/test_formulaRefs.ts +++ b/tests/engine/test_formulaRefs.ts @@ -240,7 +240,7 @@ describe("extractCellReferences — range handling", () => { it("expands multiple ranges in one formula", () => { const refs = extractCellReferences("=SUM(A1:A2)+SUM(C1:C2)"); assert.equal(refs.length, 4); - assert.deepEqual(refs.slice().sort(cmpCoord), [ + assert.deepEqual(refs.toSorted(cmpCoord), [ { row: 0, col: 0 }, { row: 0, col: 2 }, { row: 1, col: 0 }, diff --git a/tests/engine/test_ifsInjection.ts b/tests/engine/test_ifsInjection.ts index 0de499e..7edb73b 100644 --- a/tests/engine/test_ifsInjection.ts +++ b/tests/engine/test_ifsInjection.ts @@ -51,24 +51,24 @@ describe("IFS — a cell's contents are data, not code", () => { // Typing this string into a cell used to execute it, because the cell value // was substituted into the condition and the result handed to `eval`. it("does not execute an assignment stored in a cell", () => { - marker.__ifsProbe = false; - calculate("globalThis.__ifsProbe=true", '=IFS(A1>0, "hit")'); - assert.equal(marker.__ifsProbe, false, "the cell's contents must not run"); + marker.spreadsheetIfsProbe = false; + calculate("globalThis.spreadsheetIfsProbe=true", '=IFS(A1>0, "hit")'); + assert.equal(marker.spreadsheetIfsProbe, false, "the cell's contents must not run"); }); it("does not execute a cell used as a bare condition", () => { - marker.__ifsProbe2 = false; - calculate("globalThis.__ifsProbe2=true", '=IFS(A1, "hit")'); - assert.equal(marker.__ifsProbe2, false); + marker.spreadsheetIfsProbe2 = false; + calculate("globalThis.spreadsheetIfsProbe2=true", '=IFS(A1, "hit")'); + assert.equal(marker.spreadsheetIfsProbe2, false); }); // A payload starting with a digit was already neutralised by accident — // `getRawValue` reads its numeric prefix — so it is NOT evidence the hole is // closed. Pinned so nobody mistakes it for coverage. it("also refuses a payload whose numeric prefix used to mask it", () => { - marker.__ifsProbe3 = false; - calculate("1)||(globalThis.__ifsProbe3=true", '=IFS(A1>0, "hit")'); - assert.equal(marker.__ifsProbe3, false); + marker.spreadsheetIfsProbe3 = false; + calculate("1)||(globalThis.spreadsheetIfsProbe3=true", '=IFS(A1>0, "hit")'); + assert.equal(marker.spreadsheetIfsProbe3, false); }); }); @@ -197,15 +197,15 @@ describe("IFS — arithmetic operands are computed, not compared as text", () => describe("IFS — the formula itself is data too", () => { it("does not execute an expression written into the condition", () => { - marker.__ifsProbe4 = false; - calculate("1", '=IFS(A1>0&&(globalThis.__ifsProbe4=true), "hit")'); - assert.equal(marker.__ifsProbe4, false); + marker.spreadsheetIfsProbe4 = false; + calculate("1", '=IFS(A1>0&&(globalThis.spreadsheetIfsProbe4=true), "hit")'); + assert.equal(marker.spreadsheetIfsProbe4, false); }); it("does not execute a call in the condition", () => { - marker.__ifsProbe5 = false; - calculate("1", '=IFS((globalThis.__ifsProbe5=true)>0, "hit")'); - assert.equal(marker.__ifsProbe5, false); + marker.spreadsheetIfsProbe5 = false; + calculate("1", '=IFS((globalThis.spreadsheetIfsProbe5=true)>0, "hit")'); + assert.equal(marker.spreadsheetIfsProbe5, false); }); // A crash here would take the whole sheet's calculation with it. Broken @@ -213,9 +213,9 @@ describe("IFS — the formula itself is data too", () => { // swallow-everything behaviour (#2359), not something this change decides; // what matters here is that it neither throws nor runs. it("survives a syntactically broken condition", () => { - marker.__ifsProbe6 = false; - const result = calculate("1", '=IFS(((((globalThis.__ifsProbe6=true, "hit")'); + marker.spreadsheetIfsProbe6 = false; + const result = calculate("1", '=IFS(((((globalThis.spreadsheetIfsProbe6=true, "hit")'); assert.equal(typeof result, "string"); - assert.equal(marker.__ifsProbe6, false); + assert.equal(marker.spreadsheetIfsProbe6, false); }); }); diff --git a/tests/engine/test_lookupRangeAnchors.ts b/tests/engine/test_lookupRangeAnchors.ts index c5881e0..65984bb 100644 --- a/tests/engine/test_lookupRangeAnchors.ts +++ b/tests/engine/test_lookupRangeAnchors.ts @@ -8,7 +8,7 @@ import assert from "node:assert/strict"; import { parseRangeBounds } from "../../src/engine/formulaRefs.ts"; import { SpreadsheetEngine, type SheetData } from "../../src/engine/index.ts"; import { cellAt } from "./cellAccess.ts"; -import { pick, seededRandom, type Random } from "./seededRandom.ts"; +import { seededRandom, type Random } from "./seededRandom.ts"; describe("parseRangeBounds", () => { it("reads $ anchors and lowercase as the plain range", () => { @@ -78,7 +78,7 @@ const writtenCell = (random: Random, column: string, row: number): string => { describe(`a range written every way (seed ${baseSeed})`, () => { it("has the bounds of the plain range, and the lookups answer alike", () => { const random = seededRandom(baseSeed); - const data = Array.from({ length: 4 }, (_, row) => COLUMNS.map((_, col) => ({ v: row * 10 + col }))); + const data = Array.from({ length: 4 }, (_, row) => COLUMNS.map((_column, col) => ({ v: row * 10 + col }))); Array.from({ length: GENERATED_CASES }).forEach(() => { const [first, last] = [Math.floor(random() * 2), 2 + Math.floor(random() * 2)]; const [top, bottom] = [1 + Math.floor(random() * 2), 3 + Math.floor(random() * 2)]; diff --git a/tests/engine/test_mathematicalFunctions.ts b/tests/engine/test_mathematicalFunctions.ts index 5c9aa31..6f21a5b 100644 --- a/tests/engine/test_mathematicalFunctions.ts +++ b/tests/engine/test_mathematicalFunctions.ts @@ -33,12 +33,12 @@ describe("roundTo / roundUpTo / roundDownTo — direction", () => { }); it("rounds up away from zero", () => { - assert.ok(closeTo(roundUpTo(-3.14159, 2), -3.15)); - assert.ok(closeTo(roundUpTo(3.14159, 2), 3.15)); + assert.ok(closeTo(roundUpTo(-5.14159, 2), -5.15)); + assert.ok(closeTo(roundUpTo(5.14159, 2), 5.15)); }); it("rounds down toward zero", () => { - assert.ok(closeTo(roundDownTo(-3.14159, 2), -3.14)); + assert.ok(closeTo(roundDownTo(-5.14159, 2), -5.14)); assert.ok(closeTo(roundDownTo(3.19, 1), 3.1)); }); }); diff --git a/tests/engine/test_matrixFunctions.ts b/tests/engine/test_matrixFunctions.ts index c759b90..bd3839e 100644 --- a/tests/engine/test_matrixFunctions.ts +++ b/tests/engine/test_matrixFunctions.ts @@ -201,10 +201,10 @@ describe(`properties over generated matrices (seed ${baseSeed})`, () => { if (typeof detA !== "number" || Math.abs(detA) < 1e-6) return; assertClose( spilled(`=MMULT(A1:${last}${size},MINVERSE(A1:${last}${size}))`, fromRows(a)), - Array.from({ length: size }, (_, r) => Array.from({ length: size }, (_, c) => (r === c ? 1 : 0))), + Array.from({ length: size }, (_, r) => Array.from({ length: size }, (_cell, c) => (r === c ? 1 : 0))), label, ); - const both = fromRows(a.map((row, r) => [...row, ...(b[r] ?? [])])); + const both = fromRows(a.map((row, r) => row.concat(b[r] ?? []))); const [left, right] = [`A1:${last}${size}`, `${columnLetter(size)}1:${columnLetter(2 * size - 1)}${size}`]; const [detProduct] = spilled(`=MDETERM(MMULT(${left},${right}))`, both)[0] ?? []; const [detB] = spilled(`=MDETERM(${right})`, both)[0] ?? []; diff --git a/tests/engine/test_random.ts b/tests/engine/test_random.ts index 1b8ca6d..07becf1 100644 --- a/tests/engine/test_random.ts +++ b/tests/engine/test_random.ts @@ -65,6 +65,8 @@ describe("RAND, RANDBETWEEN and RANDARRAY in a sheet", () => { }); }); +const fixed = (): number => 0.5; + describe("the rules", () => { it("randomBetween includes both bounds, rounding bottom up and top down", () => { assert.equal(randomBetween(1, 6, 0), 1); @@ -75,7 +77,6 @@ describe("the rules", () => { }); it("randomTable's sizes and bounds", () => { - const fixed = (): number => 0.5; assert.deepEqual(randomTable(1, 2, 0, 10, false, fixed), [[5, 5]]); assert.deepEqual(randomTable(2.9, 1, 1, 3, true, fixed), [[2], [2]]); // Each whole number takes an equal third of [0, 1): 0.3 falls in the first. diff --git a/tests/engine/test_rangeErrors.ts b/tests/engine/test_rangeErrors.ts index eae770d..b6f47f9 100644 --- a/tests/engine/test_rangeErrors.ts +++ b/tests/engine/test_rangeErrors.ts @@ -130,18 +130,19 @@ describe("reading an aggregate's arguments", () => { }); }); +const host = (raw: unknown[]): FunctionContext => ({ + functionName: "SUM", + getCellValue: () => 0, + getRangeValues: () => [3, 5], + getRangeValuesRaw: () => raw.filter(isCellValueForTest), + evaluateFormula: (formula) => { + if (formula === "1/0") return DIV_ZERO_ERROR; + return formula.startsWith('"') ? formula.slice(1, -1) : Number(formula); + }, +}); + describe("a host without the blank-aware read", () => { // The numeric read keeps 3 and 5; the raw read shows the blank as 0 and the error. - const host = (raw: unknown[]): FunctionContext => ({ - functionName: "SUM", - getCellValue: () => 0, - getRangeValues: () => [3, 5], - getRangeValuesRaw: () => raw.filter(isCellValueForTest), - evaluateFormula: (formula) => { - if (formula === "1/0") return DIV_ZERO_ERROR; - return formula.startsWith('"') ? formula.slice(1, -1) : Number(formula); - }, - }); it("still answers a range error, read from the raw read", () => { const sum = functionRegistry.get("SUM"); diff --git a/tests/engine/test_sheetsReadingEachOther.ts b/tests/engine/test_sheetsReadingEachOther.ts index a04c784..484cd65 100644 --- a/tests/engine/test_sheetsReadingEachOther.ts +++ b/tests/engine/test_sheetsReadingEachOther.ts @@ -40,7 +40,7 @@ function generateGrid(): Grid { const rows = 2 + below(MAX_ROWS - 1); const columns = 2 + below(MAX_COLUMNS - 1); const cells = Array.from({ length: rows }, (_, row) => - Array.from({ length: columns }, (_, col) => { + Array.from({ length: columns }, (_cell, col) => { if ((row === 0 && col === 0) || random() < 0.35) return String(below(20) - 5); const a = earlierRef(row, col, columns); const b = earlierRef(row, col, columns); @@ -62,19 +62,23 @@ const oneSheet = (grid: Grid): SheetData => ({ ), }); +const sheetOf = (col: number): string => (col % 2 === 0 ? "A" : "B"); + +// A range names its sheet once: `B!A1:A3`. Ranges here are one column, so one sheet. +const local = (r: number, c: number): string => `${indexToColumn(Math.floor(c / 2))}${r + 1}`; + +const place = (r: number, c: number): string => `${sheetOf(c)}!${indexToColumn(Math.floor(c / 2))}${r + 1}`; + +const render = (cell: string): string => + cell + .replace( + /\{(\d+),(\d+)\}:\{(\d+),(\d+)\}/g, + (_, r1: string, c: string, r2: string) => `${sheetOf(Number(c))}!${local(Number(r1), Number(c))}:${local(Number(r2), Number(c))}`, + ) + .replace(/\{(\d+),(\d+)\}/g, (_, r: string, c: string) => place(Number(r), Number(c))); + /** Render it across sheets A and B, alternating columns, every reference to its cell's new home. */ function twoSheets(grid: Grid): SheetData[] { - const sheetOf = (col: number): string => (col % 2 === 0 ? "A" : "B"); - const place = (r: number, c: number): string => `${sheetOf(c)}!${indexToColumn(Math.floor(c / 2))}${r + 1}`; - // A range names its sheet once: `B!A1:A3`. Ranges here are one column, so one sheet. - const local = (r: number, c: number): string => `${indexToColumn(Math.floor(c / 2))}${r + 1}`; - const render = (cell: string): string => - cell - .replace( - /\{(\d+),(\d+)\}:\{(\d+),(\d+)\}/g, - (_, r1: string, c: string, r2: string) => `${sheetOf(Number(c))}!${local(Number(r1), Number(c))}:${local(Number(r2), Number(c))}`, - ) - .replace(/\{(\d+),(\d+)\}/g, (_, r: string, c: string) => place(Number(r), Number(c))); return ["A", "B"].map((name) => ({ name, data: grid.cells.map((row) => row.flatMap((cell, col) => (sheetOf(col) === name ? [{ v: render(cell) }] : []))), diff --git a/tests/engine/test_spill.ts b/tests/engine/test_spill.ts index 954ff3f..72c3040 100644 --- a/tests/engine/test_spill.ts +++ b/tests/engine/test_spill.ts @@ -18,7 +18,7 @@ functionRegistry.register({ maxArgs: 2, handler: ([rows = "0", columns = "0"], context) => Array.from({ length: numberOf(rows, context.evaluateFormula) }, (_, i) => - Array.from({ length: numberOf(columns, context.evaluateFormula) }, (_, j) => (i + 1) * 10 + j + 1), + Array.from({ length: numberOf(columns, context.evaluateFormula) }, (_cell, j) => (i + 1) * 10 + j + 1), ), }); // TEST.ECHO(range): the range as rows of cells. diff --git a/tests/engine/test_statisticalAnalysis.ts b/tests/engine/test_statisticalAnalysis.ts index 2125d5f..19f0b98 100644 --- a/tests/engine/test_statisticalAnalysis.ts +++ b/tests/engine/test_statisticalAnalysis.ts @@ -22,7 +22,7 @@ const ROWS: (string | number)[][] = [ /** The data in A1:C6 with `formula` in E1, calculated. */ const evaluate = (formula: string): unknown => { - const data = ROWS.map((row, index) => [...row.map((v) => ({ v })), { v: "" }, { v: index === 0 ? formula : "" }]); + const data = ROWS.map((row, index) => row.map((v) => ({ v })).concat([{ v: "" }, { v: index === 0 ? formula : "" }])); const sheet: SheetData = { name: "S", data }; return cellAt(new SpreadsheetEngine().calculate(sheet).data, 0, 4); }; diff --git a/tests/vue/test_sheetSession.ts b/tests/vue/test_sheetSession.ts index 5caf692..5b50dbb 100644 --- a/tests/vue/test_sheetSession.ts +++ b/tests/vue/test_sheetSession.ts @@ -11,13 +11,10 @@ interface Deferred { } function deferred(): Deferred { - let resolve: (value: T) => void = () => {}; - let reject: (error: unknown) => void = () => {}; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; + const settle: Partial, "resolve" | "reject">> = {}; + // The executor runs before the constructor returns, so both are set from here on. + const promise = new Promise((resolve, reject) => Object.assign(settle, { resolve, reject })); + return { promise, resolve: (value) => settle.resolve?.(value), reject: (error) => settle.reject?.(error) }; } const sheet = (name: string): SpreadsheetSheet[] => [{ name, data: [[{ v: name }]] }]; diff --git a/tests/vue/test_useCustomFunctions.ts b/tests/vue/test_useCustomFunctions.ts index d4f9f2d..4da58fe 100644 --- a/tests/vue/test_useCustomFunctions.ts +++ b/tests/vue/test_useCustomFunctions.ts @@ -11,12 +11,11 @@ const POLL_MS = 10; const WAIT_LIMIT_MS = 10_000; /** Wait until `done` holds — QuickJS's first load takes a while on a busy machine — or fail. */ -const until = async (done: () => boolean): Promise => { - const deadline = Date.now() + WAIT_LIMIT_MS; - while (!done()) { - if (Date.now() > deadline) throw new Error("timed out waiting for the functions to load"); - await new Promise((resolve) => setTimeout(resolve, POLL_MS)); - } +const until = async (done: () => boolean, deadline = Date.now() + WAIT_LIMIT_MS): Promise => { + if (done()) return; + if (Date.now() > deadline) throw new Error("timed out waiting for the functions to load"); + await new Promise((resolve) => setTimeout(resolve, POLL_MS)); + return until(done, deadline); }; /** Let a watcher that should NOT fire have its chance to. */ diff --git a/tsconfig.json b/tsconfig.json index 85a4feb..0f97194 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "target": "ES2020", "useDefineForClassFields": true, "module": "ESNext", - "lib": ["ES2022", "DOM", "DOM.Iterable"], + "lib": ["ES2023", "DOM", "DOM.Iterable"], "skipLibCheck": true, "moduleResolution": "bundler", "allowImportingTsExtensions": true,