Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'fix(sheets): dispatch registry, cross-sheet cycles, ref adjustment (#555 #522 #508)' (#335) from fix/dispatch-and-crosssheet into main

scott c51eba22 254a51a2

+1342 -62
+4
src/sheets/formula-array.ts
··· 7 7 import type { RangeArray } from './types.js'; 8 8 import { toNum } from './formula-helpers.js'; 9 9 10 + export const ARRAY_FUNCTION_NAMES = [ 11 + 'FILTER','SORT','UNIQUE','SEQUENCE', 12 + ] as const; 13 + 10 14 export function callArrayFunction(name: string, args: unknown[]): unknown | undefined { 11 15 switch (name) { 12 16 case 'FILTER': {
+5
src/sheets/formula-date.ts
··· 6 6 7 7 import { toNum } from './formula-helpers.js'; 8 8 9 + export const DATE_FUNCTION_NAMES = [ 10 + 'NOW','TODAY','DATE','YEAR','MONTH','DAY','HOUR','MINUTE','SECOND', 11 + 'WEEKDAY','EDATE','EOMONTH','DAYS','NETWORKDAYS', 12 + ] as const; 13 + 9 14 export function callDateFunction(name: string, args: unknown[]): unknown | undefined { 10 15 switch (name) { 11 16 case 'NOW': return new Date();
+4
src/sheets/formula-financial.ts
··· 6 6 7 7 import { toNum } from './formula-helpers.js'; 8 8 9 + export const FINANCIAL_FUNCTION_NAMES = [ 10 + 'PMT','FV','PV','NPV','IRR', 11 + ] as const; 12 + 9 13 export function callFinancialFunction(name: string, args: unknown[]): unknown | undefined { 10 14 switch (name) { 11 15 case 'PMT': {
+6
src/sheets/formula-logical.ts
··· 6 6 7 7 import { toNum, toBool, flat, valuesEqual } from './formula-helpers.js'; 8 8 9 + export const LOGICAL_FUNCTION_NAMES = [ 10 + 'IF','AND','OR','NOT','IFERROR','SWITCH', 11 + 'ISNUMBER','ISTEXT','ISBLANK','ISLOGICAL','ISERROR','ISNA', 12 + 'TYPE','N','T', 13 + ] as const; 14 + 9 15 export function callLogicalFunction(name: string, args: unknown[]): unknown | undefined { 10 16 switch (name) { 11 17 case 'IF': return toBool(args[0]) ? args[1] : (args[2] ?? false);
+5
src/sheets/formula-lookup.ts
··· 7 7 import type { RangeArray } from './types.js'; 8 8 import { toNum, compareValues, valuesEqual, wildcardToRegex, colToLetter } from './formula-helpers.js'; 9 9 10 + 11 + export const LOOKUP_FUNCTION_NAMES = [ 12 + 'VLOOKUP','HLOOKUP','INDEX','MATCH','XLOOKUP','ADDRESS','CHOOSE', 13 + ] as const; 14 + 10 15 // --- VLOOKUP / HLOOKUP helpers --- 11 16 12 17 function vlookup(needle: unknown, flatRange: RangeArray, rows: number, cols: number, colIdx: number, rangeLookup: boolean): unknown {
+11
src/sheets/formula-math.ts
··· 6 6 7 7 import { toNum, flat, nums, matchCriteria, matchCriteriaWild } from './formula-helpers.js'; 8 8 9 + export const MATH_FUNCTION_NAMES = [ 10 + 'SUM','AVERAGE','COUNT','COUNTA','MIN','MAX','MEDIAN','LARGE','SMALL','RANK', 11 + 'STDEV','STDEVP','VAR','VARP','PERCENTILE','PRODUCT','SUMPRODUCT', 12 + 'SUMIF','SUMIFS','COUNTIF','COUNTIFS','AVERAGEIF','AVERAGEIFS', 13 + 'ROUND','ROUNDUP','ROUNDDOWN','CEILING','FLOOR','INT','EVEN','ODD','SIGN', 14 + 'ABS','SQRT','POWER','EXP','LN','LOG','MOD','QUOTIENT','GCD','LCM','FACT','COMBIN', 15 + 'RAND','RANDBETWEEN','PI', 16 + 'SIN','COS','TAN','ASIN','ACOS','ATAN','ATAN2','DEGREES','RADIANS', 17 + 'ADD','MINUS','MULTIPLY','DIVIDE', 18 + ] as const; 19 + 9 20 export function callMathFunction(name: string, args: unknown[]): unknown | undefined { 10 21 switch (name) { 11 22 case 'SUM': return nums(args).reduce((a, b) => a + b, 0);
+6
src/sheets/formula-text.ts
··· 6 6 7 7 import { toNum, escapeRegex, flat, formatValue } from './formula-helpers.js'; 8 8 9 + export const TEXT_FUNCTION_NAMES = [ 10 + 'CONCATENATE','CONCAT','TEXTJOIN','LEN','LEFT','RIGHT','MID', 11 + 'UPPER','LOWER','PROPER','TRIM','CLEAN','SUBSTITUTE','REPLACE', 12 + 'FIND','SEARCH','REPT','CHAR','CODE','EXACT','TEXT','VALUE', 13 + ] as const; 14 + 9 15 export function callTextFunction(name: string, args: unknown[]): unknown | undefined { 10 16 switch (name) { 11 17 case 'CONCATENATE': return flat(args).map(String).join('');
+44 -25
src/sheets/formulas.ts
··· 22 22 toNum, toBool, coerceForComparison, flat, 23 23 parseRef as _parseRef, colToLetter as _colToLetter, letterToCol as _letterToCol, cellId as _cellId, 24 24 } from './formula-helpers.js'; 25 - import { callMathFunction } from './formula-math.js'; 26 - import { callTextFunction } from './formula-text.js'; 27 - import { callDateFunction } from './formula-date.js'; 28 - import { callLookupFunction } from './formula-lookup.js'; 29 - import { callLogicalFunction } from './formula-logical.js'; 30 - import { callFinancialFunction } from './formula-financial.js'; 31 - import { callArrayFunction } from './formula-array.js'; 25 + import { callMathFunction, MATH_FUNCTION_NAMES } from './formula-math.js'; 26 + import { callTextFunction, TEXT_FUNCTION_NAMES } from './formula-text.js'; 27 + import { callDateFunction, DATE_FUNCTION_NAMES } from './formula-date.js'; 28 + import { callLookupFunction, LOOKUP_FUNCTION_NAMES } from './formula-lookup.js'; 29 + import { callLogicalFunction, LOGICAL_FUNCTION_NAMES } from './formula-logical.js'; 30 + import { callFinancialFunction, FINANCIAL_FUNCTION_NAMES } from './formula-financial.js'; 31 + import { callArrayFunction, ARRAY_FUNCTION_NAMES } from './formula-array.js'; 32 32 import { tokenize, TokenType } from './formula-tokenizer.js'; 33 33 import { Parser } from './formula-parser.js'; 34 34 ··· 38 38 export const letterToCol = _letterToCol; 39 39 export const cellId = _cellId; 40 40 41 - // --- Function dispatch --- 41 + // --- Function dispatch registry --- 42 + // Maps each function name to its dispatcher. Built at module load to detect 43 + // collisions eagerly — a duplicate name throws immediately instead of 44 + // silently shadowing at call time (see issue #555). 42 45 43 - function callFunction(name: string, args: unknown[]): unknown { 44 - let result: unknown; 46 + type Dispatcher = (name: string, args: unknown[]) => unknown | undefined; 45 47 46 - result = callMathFunction(name, args); 47 - if (result !== undefined) return result; 48 + const MODULES: Array<{ names: readonly string[]; call: Dispatcher; label: string }> = [ 49 + { names: MATH_FUNCTION_NAMES, call: callMathFunction, label: 'math' }, 50 + { names: LOGICAL_FUNCTION_NAMES, call: callLogicalFunction, label: 'logical' }, 51 + { names: TEXT_FUNCTION_NAMES, call: callTextFunction, label: 'text' }, 52 + { names: DATE_FUNCTION_NAMES, call: callDateFunction, label: 'date' }, 53 + { names: LOOKUP_FUNCTION_NAMES, call: callLookupFunction, label: 'lookup' }, 54 + { names: FINANCIAL_FUNCTION_NAMES, call: callFinancialFunction, label: 'financial' }, 55 + { names: ARRAY_FUNCTION_NAMES, call: callArrayFunction, label: 'array' }, 56 + ]; 48 57 49 - result = callLogicalFunction(name, args); 50 - if (result !== undefined) return result; 58 + const functionRegistry = new Map<string, Dispatcher>(); 51 59 52 - result = callTextFunction(name, args); 53 - if (result !== undefined) return result; 54 - 55 - result = callDateFunction(name, args); 56 - if (result !== undefined) return result; 60 + for (const mod of MODULES) { 61 + for (const name of mod.names) { 62 + const existing = functionRegistry.get(name); 63 + if (existing) { 64 + const prevLabel = MODULES.find(m => m.call === existing)?.label ?? 'unknown'; 65 + throw new Error( 66 + `Formula function collision: "${name}" is registered in both "${prevLabel}" and "${mod.label}" modules. ` + 67 + 'Each function name must be unique across all formula modules.' 68 + ); 69 + } 70 + functionRegistry.set(name, mod.call); 71 + } 72 + } 57 73 58 - result = callLookupFunction(name, args); 59 - if (result !== undefined) return result; 74 + /** Exported for testing — the complete set of registered function names. */ 75 + export const REGISTERED_FUNCTION_NAMES: ReadonlySet<string> = new Set(functionRegistry.keys()); 60 76 61 - result = callFinancialFunction(name, args); 62 - if (result !== undefined) return result; 77 + // --- Function dispatch --- 63 78 64 - result = callArrayFunction(name, args); 65 - if (result !== undefined) return result; 79 + function callFunction(name: string, args: unknown[]): unknown { 80 + const dispatcher = functionRegistry.get(name); 81 + if (dispatcher) { 82 + const result = dispatcher(name, args); 83 + if (result !== undefined) return result; 84 + } 66 85 67 86 // Functions that remain here due to local imports (sparkline, query) 68 87 switch (name) {
+91 -6
src/sheets/recalc.ts
··· 8 8 * - Tracks volatile functions (NOW, TODAY, RAND, RANDBETWEEN) 9 9 * - Supports incremental graph updates when a cell's formula changes 10 10 * - Works with cross-sheet references (SheetName!CellId keys) 11 + * - Detects cross-sheet circular dependencies via crossSheetFormulaDeps 11 12 * 12 13 * This module is DOM-free and operates on an abstract cell store interface. 13 14 */ ··· 41 42 volatileCells: Set<string>; 42 43 _cyclePaths: string[][]; 43 44 44 - // Spill tracking: source cell → list of spilled target cells 45 + // Spill tracking: source cell -> list of spilled target cells 45 46 _spillRanges: Map<string, string[]>; 46 - // Reverse map: spill target cell → source cell 47 + // Reverse map: spill target cell -> source cell 47 48 _spillTargets: Map<string, string>; 48 49 49 50 constructor(store: CellStore, options: RecalcOptions = {}) { ··· 70 71 /** 71 72 * Build the full dependency graph from scratch. 72 73 * Call this once at initialization or when the entire sheet changes. 74 + * 75 + * When crossSheetFormulaDeps and currentSheetName are provided, also 76 + * traverses cross-sheet references to detect circular dependencies 77 + * that span multiple sheets. 73 78 */ 74 79 buildFullGraph(): void { 75 80 this.precedents.clear(); ··· 80 85 if (!cell.f) continue; 81 86 this._addCellEdges(id, cell.f); 82 87 } 88 + 89 + // Expand graph with cross-sheet formula dependencies for cycle detection 90 + this._expandCrossSheetEdges(); 83 91 } 84 92 85 93 /** ··· 255 263 } 256 264 257 265 /** 266 + * Check if a cell key is a cross-sheet reference (contains '!'). 267 + */ 268 + _isCrossSheetKey(key: string): boolean { 269 + return key.includes('!'); 270 + } 271 + 272 + /** 273 + * Expand the dependency graph by traversing cross-sheet references. 274 + * 275 + * When crossSheetFormulaDeps is available, for each cross-sheet ref in the 276 + * graph (e.g. "Sheet2!B1"), we query its formula dependencies and add those 277 + * edges to the graph. This continues transitively until all reachable 278 + * cross-sheet cells have been explored. 279 + * 280 + * This allows Kahn's algorithm to detect cycles that span multiple sheets. 281 + */ 282 + _expandCrossSheetEdges(): void { 283 + const resolver = this.options.crossSheetFormulaDeps; 284 + const currentSheet = this.options.currentSheetName; 285 + if (!resolver || !currentSheet) return; 286 + 287 + // Collect all cross-sheet refs currently in the graph 288 + const visited = new Set<string>(); 289 + const queue: string[] = []; 290 + 291 + for (const [, precs] of this.precedents) { 292 + for (const ref of precs) { 293 + if (this._isCrossSheetKey(ref) && !visited.has(ref)) { 294 + queue.push(ref); 295 + } 296 + } 297 + } 298 + 299 + // BFS: resolve each cross-sheet ref's own dependencies 300 + while (queue.length > 0) { 301 + const crossRef = queue.shift()!; 302 + if (visited.has(crossRef)) continue; 303 + visited.add(crossRef); 304 + 305 + const deps = resolver(crossRef); 306 + if (!deps || deps.size === 0) continue; 307 + 308 + // Map deps: if a dep points back to our sheet, map to local key 309 + const mappedDeps = new Set<string>(); 310 + for (const dep of deps) { 311 + if (dep.startsWith(currentSheet + '!')) { 312 + const localId = dep.slice(currentSheet.length + 1); 313 + mappedDeps.add(localId); 314 + } else { 315 + mappedDeps.add(dep); 316 + } 317 + } 318 + 319 + // Add edges: crossRef depends on mappedDeps 320 + this.precedents.set(crossRef, mappedDeps); 321 + for (const dep of mappedDeps) { 322 + if (!this.dependents.has(dep)) { 323 + this.dependents.set(dep, new Set()); 324 + } 325 + this.dependents.get(dep)!.add(crossRef); 326 + 327 + // If dep is a new cross-sheet ref, queue it for further expansion 328 + if (this._isCrossSheetKey(dep) && !visited.has(dep)) { 329 + queue.push(dep); 330 + } 331 + } 332 + } 333 + } 334 + 335 + /** 258 336 * Resolve named range identifiers in a formula to actual cell references. 259 337 * Returns the union of extractRefs results and named range cell refs. 260 338 * @param {string} formula ··· 278 356 const rangeStr = entry.range; 279 357 const parts = rangeStr.split(':'); 280 358 if (parts.length === 2) { 281 - // Range — expand to individual cells 359 + // Range - expand to individual cells 282 360 const start = parseRef(parts[0]); 283 361 const end = parseRef(parts[1]); 284 362 if (start && end) { ··· 341 419 this._cyclePaths = []; 342 420 const changed = new Set<string>(); 343 421 344 - // Filter to only formula cells that need recalculation 422 + // Filter to only formula cells that need recalculation. 423 + // Also include cross-sheet cells that have precedents in the graph 424 + // (added by _expandCrossSheetEdges) -- these participate in cycle 425 + // detection but are not evaluated locally. 345 426 const formulaCells = new Set<string>(); 427 + const crossSheetFormulaCells = new Set<string>(); 346 428 for (const cellId of dirty) { 347 429 const cell = this.store.get(cellId); 348 430 if (cell && cell.f) { 349 431 formulaCells.add(cellId); 432 + } else if (this._isCrossSheetKey(cellId) && this.precedents.has(cellId)) { 433 + formulaCells.add(cellId); 434 + crossSheetFormulaCells.add(cellId); 350 435 } 351 436 } 352 437 ··· 547 632 548 633 const targetCell = this.store.get(targetId); 549 634 if (targetCell && (targetCell.f || (targetCell.v !== '' && targetCell.v !== undefined))) { 550 - // Collision — set #SPILL! and don't spill 635 + // Collision - set #SPILL! and don't spill 551 636 this._clearSpill(sourceId, changed); 552 637 const cell = this.store.get(sourceId); 553 638 if (cell) { ··· 637 722 */ 638 723 _dfsFindCycle(cellId: string, cycleCells: Set<string>, path: string[], inStack: Set<string>, visited: Set<string>): string[] | null { 639 724 if (inStack.has(cellId)) { 640 - // Found a cycle — extract path from the first occurrence to here 725 + // Found a cycle - extract path from the first occurrence to here 641 726 const cycleStart = path.indexOf(cellId); 642 727 const cyclePath = path.slice(cycleStart); 643 728 cyclePath.push(cellId);
+11 -8
src/sheets/row-col-ops.ts
··· 18 18 * - Handles single cell refs (A1, $A1, A$1, $A$1) 19 19 * - Handles range refs (A1:B10) 20 20 * - Leaves cross-sheet refs (Sheet1!A1) untouched 21 - * - Absolute refs ($) are NOT shifted 21 + * - Absolute refs ($A$1) ARE adjusted for structural changes (insert/delete). 22 + * The $ sign only prevents adjustment during copy/paste, not structural ops. 22 23 * - References to deleted rows/cols become #REF! 23 24 */ 24 25 export function adjustFormulaRefs( ··· 40 41 41 42 /** 42 43 * Adjust a single cell reference like A1, $A$1, $A1, A$1. 44 + * 45 + * Absolute refs ($) ARE adjusted for structural changes (insert/delete row/col). 46 + * The $ sign only prevents adjustment during copy/paste operations. 43 47 */ 44 48 function adjustSingleRef( 45 49 ref: string, ··· 52 56 const col = letterToColNum(colLetter); 53 57 54 58 if (change.type === 'row') { 55 - if (rowAbs) return ref; // $1 => absolute, don't shift 59 + // Absolute refs ($) ARE adjusted for structural insert/delete. 56 60 if (change.delta > 0) { 57 61 // Inserting rows: shift refs at or below index down 58 62 if (row >= change.index) { 59 - return buildRef(colAbs, colLetter, false, row + change.delta); 63 + return buildRef(colAbs, colLetter, rowAbs, row + change.delta); 60 64 } 61 65 } else { 62 66 // Deleting rows (delta is negative) ··· 67 71 return '#REF!'; 68 72 } 69 73 if (row > deleteEnd) { 70 - return buildRef(colAbs, colLetter, false, row + change.delta); 74 + return buildRef(colAbs, colLetter, rowAbs, row + change.delta); 71 75 } 72 76 } 73 77 } else { 74 - // Column change 75 - if (colAbs) return ref; // $A => absolute, don't shift 78 + // Column change — absolute refs ARE adjusted for structural changes 76 79 if (change.delta > 0) { 77 80 // Inserting columns: shift refs at or right of index 78 81 if (col >= change.index) { 79 - return buildRef(false, colToLetter(col + change.delta), rowAbs, row); 82 + return buildRef(colAbs, colToLetter(col + change.delta), rowAbs, row); 80 83 } 81 84 } else { 82 85 // Deleting columns ··· 87 90 return '#REF!'; 88 91 } 89 92 if (col > deleteEnd) { 90 - return buildRef(false, colToLetter(col + change.delta), rowAbs, row); 93 + return buildRef(colAbs, colToLetter(col + change.delta), rowAbs, row); 91 94 } 92 95 } 93 96 }
+12
src/sheets/types.ts
··· 124 124 onEvaluate?: (cellId: string) => void; 125 125 namedRanges?: NamedRangesMap; 126 126 crossSheetResolver?: CrossSheetResolver; 127 + /** 128 + * Resolve cross-sheet formula dependencies for cycle detection. 129 + * Given a fully-qualified cell key (e.g. "Sheet2!B1"), returns the set of 130 + * fully-qualified cell keys that cell's formula depends on. 131 + * Returns null/undefined if the cell has no formula or the sheet doesn't exist. 132 + */ 133 + crossSheetFormulaDeps?: (qualifiedCellId: string) => Set<string> | null | undefined; 134 + /** 135 + * The name of the current sheet. Used to map local cell IDs to fully-qualified 136 + * keys when detecting cross-sheet circular dependencies. 137 + */ 138 + currentSheetName?: string; 127 139 } 128 140 129 141 // --- Chart Types ---
+412
tests/cross-sheet-circular.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { RecalcEngine } from '../src/sheets/recalc.js'; 3 + 4 + /** 5 + * Cross-sheet circular dependency detection tests. 6 + * 7 + * Issue #522: The recalculation engine must detect circular dependencies 8 + * that span multiple sheets. For example, Sheet1!A1 references Sheet2!B1, 9 + * which references Sheet1!A1 — this is a cycle that must produce #CIRCULAR!. 10 + * 11 + * The dependency graph uses fully-qualified keys like "Sheet2!B1" for 12 + * cross-sheet refs. The crossSheetFormulaDeps option allows the engine 13 + * to resolve formula dependencies on other sheets for cycle detection. 14 + */ 15 + 16 + // --- Helpers --- 17 + 18 + /** 19 + * Create a simple cell store from a plain object. 20 + * Each entry: cellId -> { v, f } where f is formula string (without '='). 21 + */ 22 + function makeCellStore(data: Record<string, { v: unknown; f: string }>) { 23 + const store = new Map<string, { v: unknown; f: string }>(); 24 + for (const [id, cell] of Object.entries(data)) { 25 + store.set(id, { ...cell }); 26 + } 27 + return { 28 + get(id: string) { return store.get(id) || null; }, 29 + set(id: string, cell: { v: unknown; f: string }) { store.set(id, { ...cell }); }, 30 + has(id: string) { return store.has(id); }, 31 + entries() { return store.entries(); }, 32 + getAllFormulaCells() { 33 + const result: Array<[string, { v: unknown; f: string }]> = []; 34 + for (const [id, cell] of store.entries()) { 35 + if (cell.f) result.push([id, cell]); 36 + } 37 + return result; 38 + }, 39 + }; 40 + } 41 + 42 + /** 43 + * Build a crossSheetFormulaDeps resolver from a multi-sheet data structure. 44 + * sheetsFormulas: { sheetName: { cellId: Set<qualifiedRef> } } 45 + * 46 + * Given "Sheet2!B1", looks up sheetsFormulas["Sheet2"]["B1"] to get deps. 47 + */ 48 + function makeCrossSheetFormulaDeps( 49 + sheetsFormulas: Record<string, Record<string, Set<string>>>, 50 + ) { 51 + return (qualifiedCellId: string): Set<string> | null => { 52 + const bangIdx = qualifiedCellId.indexOf('!'); 53 + if (bangIdx === -1) return null; 54 + const sheetName = qualifiedCellId.slice(0, bangIdx); 55 + const cellId = qualifiedCellId.slice(bangIdx + 1); 56 + const sheet = sheetsFormulas[sheetName]; 57 + if (!sheet) return null; 58 + return sheet[cellId] || null; 59 + }; 60 + } 61 + 62 + // ===================================================================== 63 + // 1. SIMPLE CROSS-SHEET CYCLE: Sheet1!A1 -> Sheet2!B1 -> Sheet1!A1 64 + // ===================================================================== 65 + 66 + describe('Cross-sheet circular dependency detection', () => { 67 + it('detects simple two-sheet cycle: Sheet1!A1 -> Sheet2!B1 -> Sheet1!A1', () => { 68 + // Sheet1: A1 has formula referencing Sheet2!B1 69 + const store = makeCellStore({ 70 + A1: { v: '', f: 'Sheet2!B1+1' }, 71 + }); 72 + 73 + // Sheet2: B1 has formula referencing Sheet1!A1 74 + const crossSheetFormulaDeps = makeCrossSheetFormulaDeps({ 75 + Sheet2: { 76 + B1: new Set(['Sheet1!A1']), 77 + }, 78 + }); 79 + 80 + const engine = new RecalcEngine(store, { 81 + currentSheetName: 'Sheet1', 82 + crossSheetFormulaDeps, 83 + }); 84 + engine.buildFullGraph(); 85 + 86 + const changed = engine.recalculate('A1'); 87 + 88 + // A1 should be marked as circular 89 + expect(store.get('A1')!.v).toBe('#CIRCULAR!'); 90 + expect(changed.has('A1')).toBe(true); 91 + }); 92 + 93 + it('provides cycle path for cross-sheet cycle', () => { 94 + const store = makeCellStore({ 95 + A1: { v: '', f: 'Sheet2!B1+1' }, 96 + }); 97 + 98 + const crossSheetFormulaDeps = makeCrossSheetFormulaDeps({ 99 + Sheet2: { 100 + B1: new Set(['Sheet1!A1']), 101 + }, 102 + }); 103 + 104 + const engine = new RecalcEngine(store, { 105 + currentSheetName: 'Sheet1', 106 + crossSheetFormulaDeps, 107 + }); 108 + engine.buildFullGraph(); 109 + 110 + engine.recalculate('A1'); 111 + 112 + const cyclePaths = engine.getCyclePaths(); 113 + expect(cyclePaths.length).toBeGreaterThan(0); 114 + const path = cyclePaths[0]; 115 + // Cycle should form a loop: first === last 116 + expect(path[0]).toBe(path[path.length - 1]); 117 + // Path should include both the local cell and the cross-sheet cell 118 + expect(path.length).toBeGreaterThanOrEqual(3); 119 + }); 120 + 121 + // ===================================================================== 122 + // 2. LONGER CROSS-SHEET CYCLE THROUGH 3+ SHEETS 123 + // ===================================================================== 124 + 125 + it('detects cycle through 3 sheets: Sheet1!A1 -> Sheet2!A1 -> Sheet3!A1 -> Sheet1!A1', () => { 126 + // Sheet1: A1 has formula referencing Sheet2!A1 127 + const store = makeCellStore({ 128 + A1: { v: '', f: 'Sheet2!A1+1' }, 129 + }); 130 + 131 + // Sheet2!A1 depends on Sheet3!A1, Sheet3!A1 depends on Sheet1!A1 132 + const crossSheetFormulaDeps = makeCrossSheetFormulaDeps({ 133 + Sheet2: { 134 + A1: new Set(['Sheet3!A1']), 135 + }, 136 + Sheet3: { 137 + A1: new Set(['Sheet1!A1']), 138 + }, 139 + }); 140 + 141 + const engine = new RecalcEngine(store, { 142 + currentSheetName: 'Sheet1', 143 + crossSheetFormulaDeps, 144 + }); 145 + engine.buildFullGraph(); 146 + 147 + const changed = engine.recalculate('A1'); 148 + 149 + expect(store.get('A1')!.v).toBe('#CIRCULAR!'); 150 + expect(changed.has('A1')).toBe(true); 151 + }); 152 + 153 + it('detects cycle through 4 sheets', () => { 154 + // Sheet1!A1 -> Sheet2!B2 -> Sheet3!C3 -> Sheet4!D4 -> Sheet1!A1 155 + const store = makeCellStore({ 156 + A1: { v: '', f: 'Sheet2!B2*2' }, 157 + }); 158 + 159 + const crossSheetFormulaDeps = makeCrossSheetFormulaDeps({ 160 + Sheet2: { 161 + B2: new Set(['Sheet3!C3']), 162 + }, 163 + Sheet3: { 164 + C3: new Set(['Sheet4!D4']), 165 + }, 166 + Sheet4: { 167 + D4: new Set(['Sheet1!A1']), 168 + }, 169 + }); 170 + 171 + const engine = new RecalcEngine(store, { 172 + currentSheetName: 'Sheet1', 173 + crossSheetFormulaDeps, 174 + }); 175 + engine.buildFullGraph(); 176 + 177 + const changed = engine.recalculate('A1'); 178 + 179 + expect(store.get('A1')!.v).toBe('#CIRCULAR!'); 180 + }); 181 + 182 + // ===================================================================== 183 + // 3. VALID CROSS-SHEET REFERENCE (NOT CIRCULAR) 184 + // ===================================================================== 185 + 186 + it('does NOT flag valid cross-sheet reference as circular', () => { 187 + // Sheet1!A1 -> Sheet2!B1 -> Sheet2!C1 (no cycle back to Sheet1) 188 + const store = makeCellStore({ 189 + A1: { v: '', f: 'Sheet2!B1+1' }, 190 + }); 191 + 192 + const crossSheetFormulaDeps = makeCrossSheetFormulaDeps({ 193 + Sheet2: { 194 + B1: new Set(['Sheet2!C1']), // depends on another Sheet2 cell, no cycle 195 + }, 196 + }); 197 + 198 + const engine = new RecalcEngine(store, { 199 + currentSheetName: 'Sheet1', 200 + crossSheetFormulaDeps, 201 + crossSheetResolver: { 202 + sheetExists: () => true, 203 + getSheetCellValue: () => 42, 204 + }, 205 + }); 206 + engine.buildFullGraph(); 207 + 208 + const changed = engine.recalculate('A1'); 209 + 210 + // A1 should NOT be circular 211 + expect(store.get('A1')!.v).not.toBe('#CIRCULAR!'); 212 + expect(engine.getCyclePaths().length).toBe(0); 213 + }); 214 + 215 + it('evaluates valid cross-sheet chain correctly', () => { 216 + // Sheet1!A1 references Sheet2!B1 which has no back-reference. 217 + // A1's formula evaluates using crossSheetResolver. 218 + const store = makeCellStore({ 219 + A1: { v: '', f: 'Sheet2!B1+10' }, 220 + }); 221 + 222 + const crossSheetFormulaDeps = makeCrossSheetFormulaDeps({ 223 + Sheet2: { 224 + B1: new Set([]), // B1 is a plain value, no deps 225 + }, 226 + }); 227 + 228 + const engine = new RecalcEngine(store, { 229 + currentSheetName: 'Sheet1', 230 + crossSheetFormulaDeps, 231 + crossSheetResolver: { 232 + sheetExists: () => true, 233 + getSheetCellValue: (_sheet: string, _ref: string) => 5, 234 + }, 235 + }); 236 + engine.buildFullGraph(); 237 + 238 + engine.recalculate('A1'); 239 + 240 + // Should evaluate: 5 + 10 = 15 241 + expect(store.get('A1')!.v).toBe(15); 242 + }); 243 + 244 + it('handles multiple local cells referencing different sheets without cycles', () => { 245 + const store = makeCellStore({ 246 + A1: { v: '', f: 'Sheet2!A1+1' }, 247 + B1: { v: '', f: 'Sheet3!A1+2' }, 248 + C1: { v: '', f: 'A1+B1' }, 249 + }); 250 + 251 + const crossSheetFormulaDeps = makeCrossSheetFormulaDeps({ 252 + Sheet2: { A1: new Set([]) }, 253 + Sheet3: { A1: new Set([]) }, 254 + }); 255 + 256 + const engine = new RecalcEngine(store, { 257 + currentSheetName: 'Sheet1', 258 + crossSheetFormulaDeps, 259 + crossSheetResolver: { 260 + sheetExists: () => true, 261 + getSheetCellValue: () => 10, 262 + }, 263 + }); 264 + engine.buildFullGraph(); 265 + 266 + engine.recalculate('A1'); 267 + engine.recalculate('B1'); 268 + 269 + expect(store.get('A1')!.v).not.toBe('#CIRCULAR!'); 270 + expect(store.get('B1')!.v).not.toBe('#CIRCULAR!'); 271 + expect(engine.getCyclePaths().length).toBe(0); 272 + }); 273 + 274 + // ===================================================================== 275 + // 4. SELF-REFERENCING CELL 276 + // ===================================================================== 277 + 278 + it('detects self-referencing cell (same as existing test, ensures no regression)', () => { 279 + const store = makeCellStore({ 280 + A1: { v: '', f: 'A1+1' }, 281 + }); 282 + const engine = new RecalcEngine(store); 283 + engine.buildFullGraph(); 284 + 285 + engine.recalculate('A1'); 286 + expect(store.get('A1')!.v).toBe('#CIRCULAR!'); 287 + }); 288 + 289 + it('detects self-referencing cell with cross-sheet options enabled', () => { 290 + // Ensure the cross-sheet machinery doesn't break self-reference detection 291 + const store = makeCellStore({ 292 + A1: { v: '', f: 'A1+1' }, 293 + }); 294 + 295 + const engine = new RecalcEngine(store, { 296 + currentSheetName: 'Sheet1', 297 + crossSheetFormulaDeps: () => null, 298 + }); 299 + engine.buildFullGraph(); 300 + 301 + engine.recalculate('A1'); 302 + expect(store.get('A1')!.v).toBe('#CIRCULAR!'); 303 + }); 304 + 305 + // ===================================================================== 306 + // 5. MIXED SCENARIOS 307 + // ===================================================================== 308 + 309 + it('marks only cyclic cells, not unrelated cells on the same sheet', () => { 310 + // A1 is in a cross-sheet cycle; B1 is a normal formula 311 + const store = makeCellStore({ 312 + A1: { v: '', f: 'Sheet2!A1+1' }, 313 + B1: { v: 10, f: '' }, 314 + C1: { v: '', f: 'B1*2' }, 315 + }); 316 + 317 + const crossSheetFormulaDeps = makeCrossSheetFormulaDeps({ 318 + Sheet2: { 319 + A1: new Set(['Sheet1!A1']), // cycle! 320 + }, 321 + }); 322 + 323 + const engine = new RecalcEngine(store, { 324 + currentSheetName: 'Sheet1', 325 + crossSheetFormulaDeps, 326 + }); 327 + engine.buildFullGraph(); 328 + 329 + engine.recalculate('A1'); 330 + engine.recalculate('B1'); 331 + 332 + // A1 is circular 333 + expect(store.get('A1')!.v).toBe('#CIRCULAR!'); 334 + // C1 is not circular -- it depends only on B1 335 + expect(store.get('C1')!.v).toBe(20); 336 + }); 337 + 338 + it('detects cycle introduced by cross-sheet dependency after incremental update', () => { 339 + // Initially A1 is a plain value, no cycle 340 + const store = makeCellStore({ 341 + A1: { v: 10, f: '' }, 342 + }); 343 + 344 + const crossSheetFormulaDeps = makeCrossSheetFormulaDeps({ 345 + Sheet2: { 346 + B1: new Set(['Sheet1!A1']), 347 + }, 348 + }); 349 + 350 + const engine = new RecalcEngine(store, { 351 + currentSheetName: 'Sheet1', 352 + crossSheetFormulaDeps, 353 + }); 354 + engine.buildFullGraph(); 355 + 356 + // No cycle yet 357 + engine.recalculate('A1'); 358 + expect(store.get('A1')!.v).toBe(10); 359 + 360 + // Now edit A1 to create a formula that references Sheet2!B1 -> cycle 361 + store.set('A1', { v: '', f: 'Sheet2!B1+1' }); 362 + engine.updateCell('A1'); 363 + // Rebuild to pick up cross-sheet edges 364 + engine.buildFullGraph(); 365 + 366 + const changed = engine.recalculate('A1'); 367 + expect(store.get('A1')!.v).toBe('#CIRCULAR!'); 368 + }); 369 + 370 + it('works without crossSheetFormulaDeps (backwards compatibility)', () => { 371 + // When crossSheetFormulaDeps is not provided, cross-sheet cycles 372 + // won't be detected, but existing same-sheet behavior must work 373 + const store = makeCellStore({ 374 + A1: { v: '', f: 'B1+1' }, 375 + B1: { v: '', f: 'A1+1' }, 376 + }); 377 + 378 + const engine = new RecalcEngine(store); 379 + engine.buildFullGraph(); 380 + 381 + engine.recalculate('A1'); 382 + expect(store.get('A1')!.v).toBe('#CIRCULAR!'); 383 + expect(store.get('B1')!.v).toBe('#CIRCULAR!'); 384 + }); 385 + 386 + it('handles cross-sheet ref to a cell with no formula (leaf node)', () => { 387 + // Sheet1!A1 -> Sheet2!B1 where Sheet2!B1 is a plain value (no formula deps) 388 + const store = makeCellStore({ 389 + A1: { v: '', f: 'Sheet2!B1+1' }, 390 + }); 391 + 392 + const crossSheetFormulaDeps = makeCrossSheetFormulaDeps({ 393 + Sheet2: {}, // B1 has no formula entry at all 394 + }); 395 + 396 + const engine = new RecalcEngine(store, { 397 + currentSheetName: 'Sheet1', 398 + crossSheetFormulaDeps, 399 + crossSheetResolver: { 400 + sheetExists: () => true, 401 + getSheetCellValue: () => 7, 402 + }, 403 + }); 404 + engine.buildFullGraph(); 405 + 406 + engine.recalculate('A1'); 407 + 408 + // Should evaluate normally: 7 + 1 = 8 409 + expect(store.get('A1')!.v).toBe(8); 410 + expect(engine.getCyclePaths().length).toBe(0); 411 + }); 412 + });
+98
tests/formula-dispatch.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { evaluate, REGISTERED_FUNCTION_NAMES } from '../src/sheets/formulas.js'; 3 + import { MATH_FUNCTION_NAMES } from '../src/sheets/formula-math.js'; 4 + import { TEXT_FUNCTION_NAMES } from '../src/sheets/formula-text.js'; 5 + import { DATE_FUNCTION_NAMES } from '../src/sheets/formula-date.js'; 6 + import { LOOKUP_FUNCTION_NAMES } from '../src/sheets/formula-lookup.js'; 7 + import { LOGICAL_FUNCTION_NAMES } from '../src/sheets/formula-logical.js'; 8 + import { FINANCIAL_FUNCTION_NAMES } from '../src/sheets/formula-financial.js'; 9 + import { ARRAY_FUNCTION_NAMES } from '../src/sheets/formula-array.js'; 10 + 11 + // Helper: evaluate with a simple cell map 12 + function evalWith(formula: string, cells: Record<string, unknown> = {}) { 13 + return evaluate(formula, (ref) => cells[ref] ?? ''); 14 + } 15 + 16 + // All module name lists and their labels (mirrors the registry in formulas.ts) 17 + const ALL_MODULES: Array<{ names: readonly string[]; label: string }> = [ 18 + { names: MATH_FUNCTION_NAMES, label: 'math' }, 19 + { names: LOGICAL_FUNCTION_NAMES, label: 'logical' }, 20 + { names: TEXT_FUNCTION_NAMES, label: 'text' }, 21 + { names: DATE_FUNCTION_NAMES, label: 'date' }, 22 + { names: LOOKUP_FUNCTION_NAMES, label: 'lookup' }, 23 + { names: FINANCIAL_FUNCTION_NAMES, label: 'financial' }, 24 + { names: ARRAY_FUNCTION_NAMES, label: 'array' }, 25 + ]; 26 + 27 + describe('Formula dispatch registry — collision prevention (#555)', () => { 28 + it('has no duplicate function names across modules', () => { 29 + const seen = new Map<string, string>(); // name -> module label 30 + const duplicates: string[] = []; 31 + 32 + for (const mod of ALL_MODULES) { 33 + for (const name of mod.names) { 34 + const existing = seen.get(name); 35 + if (existing) { 36 + duplicates.push(`"${name}" in both "${existing}" and "${mod.label}"`); 37 + } else { 38 + seen.set(name, mod.label); 39 + } 40 + } 41 + } 42 + 43 + expect(duplicates, `Colliding function names found: ${duplicates.join(', ')}`).toEqual([]); 44 + }); 45 + 46 + it('has no duplicate names within a single module', () => { 47 + for (const mod of ALL_MODULES) { 48 + const unique = new Set(mod.names); 49 + expect( 50 + unique.size, 51 + `Module "${mod.label}" has ${mod.names.length - unique.size} internal duplicates` 52 + ).toBe(mod.names.length); 53 + } 54 + }); 55 + 56 + it('REGISTERED_FUNCTION_NAMES contains all module functions', () => { 57 + for (const mod of ALL_MODULES) { 58 + for (const name of mod.names) { 59 + expect( 60 + REGISTERED_FUNCTION_NAMES.has(name), 61 + `"${name}" from ${mod.label} missing from registry` 62 + ).toBe(true); 63 + } 64 + } 65 + }); 66 + 67 + it('every registered name resolves (not #NAME?)', () => { 68 + // Test a representative sample from each module to ensure dispatch works. 69 + const sampleFunctions: Array<{ fn: string; formula: string }> = [ 70 + { fn: 'SUM', formula: 'SUM(1,2,3)' }, 71 + { fn: 'IF', formula: 'IF(TRUE,1,0)' }, 72 + { fn: 'LEN', formula: 'LEN("hello")' }, 73 + { fn: 'YEAR', formula: 'YEAR("2024-01-15")' }, 74 + { fn: 'INDEX', formula: 'INDEX(A1:A3,1)' }, 75 + { fn: 'PMT', formula: 'PMT(0.05,12,1000)' }, 76 + { fn: 'SEQUENCE', formula: 'SEQUENCE(3)' }, 77 + ]; 78 + 79 + for (const { fn, formula } of sampleFunctions) { 80 + const result = evalWith(formula, { A1: 10, A2: 20, A3: 30 }); 81 + expect( 82 + typeof result === 'string' && result.startsWith('#NAME?'), 83 + `${fn} dispatch failed — got #NAME? for ${formula}` 84 + ).toBe(false); 85 + } 86 + }); 87 + 88 + it('non-module functions (QUERY, SPARKLINE) still dispatch correctly', () => { 89 + // SPARKLINE with valid data should not return #NAME? 90 + const sparkResult = evalWith('SPARKLINE(A1:A3)', { A1: 1, A2: 2, A3: 3 }); 91 + expect(typeof sparkResult === 'string' && sparkResult.startsWith('#NAME?')).toBe(false); 92 + }); 93 + 94 + it('unknown function names still return #NAME?', () => { 95 + const result = evalWith('NOTAFUNCTION(1,2)'); 96 + expect(result).toBe('#NAME? (NOTAFUNCTION)'); 97 + }); 98 + });
+610
tests/formula-ref-adjust.test.ts
··· 1 + /** 2 + * Comprehensive tests for formula reference adjustment on row/col insert/delete. 3 + * 4 + * Issue #508: Formula refs must be adjusted when rows/columns are structurally 5 + * modified. This includes absolute refs ($A$1) which are only "absolute" for 6 + * copy/paste, not for structural changes. 7 + */ 8 + 9 + import { describe, it, expect } from 'vitest'; 10 + import { 11 + adjustFormulaRefs, 12 + insertRow, 13 + deleteRow, 14 + insertColumn, 15 + deleteColumn, 16 + } from '../src/sheets/row-col-ops.js'; 17 + 18 + // ============================================================ 19 + // Helper: simple in-memory cell map for integration tests 20 + // ============================================================ 21 + 22 + interface MockCellData { 23 + v: unknown; 24 + f: string; 25 + s: unknown; 26 + } 27 + 28 + function createMockCellMap(initial: Record<string, MockCellData> = {}) { 29 + const store = new Map<string, MockCell>(); 30 + 31 + class MockCell { 32 + private data = new Map<string, unknown>(); 33 + constructor(cellData?: MockCellData) { 34 + if (cellData) { 35 + this.data.set('v', cellData.v); 36 + this.data.set('f', cellData.f); 37 + this.data.set('s', cellData.s ?? ''); 38 + } 39 + } 40 + get(key: string) { return this.data.get(key); } 41 + set(key: string, value: unknown) { this.data.set(key, value); } 42 + } 43 + 44 + for (const [id, data] of Object.entries(initial)) { 45 + store.set(id, new MockCell(data)); 46 + } 47 + 48 + const cellMap = { 49 + get(key: string) { return store.get(key); }, 50 + set(key: string, value: unknown) { store.set(key, value as MockCell); }, 51 + has(key: string) { return store.has(key); }, 52 + delete(key: string) { store.delete(key); }, 53 + forEach(cb: (value: unknown, key: string) => void) { store.forEach(cb); }, 54 + }; 55 + 56 + function getCells() { return cellMap; } 57 + 58 + function setCellData(id: string, data: { v?: unknown; f?: string; s?: unknown }) { 59 + let cell: MockCell; 60 + if (store.has(id)) { 61 + cell = store.get(id)!; 62 + } else { 63 + cell = new MockCell(); 64 + store.set(id, cell); 65 + } 66 + if (data.v !== undefined) cell.set('v', data.v); 67 + if (data.f !== undefined) cell.set('f', data.f); 68 + if (data.s !== undefined) cell.set('s', typeof data.s === 'object' ? JSON.stringify(data.s) : data.s); 69 + } 70 + 71 + function getCellData(id: string): MockCellData | null { 72 + const cell = store.get(id); 73 + if (!cell) return null; 74 + return { 75 + v: cell.get('v') ?? '', 76 + f: (cell.get('f') as string) ?? '', 77 + s: cell.get('s') ?? '', 78 + }; 79 + } 80 + 81 + return { getCells, setCellData, getCellData, store }; 82 + } 83 + 84 + // ============================================================ 85 + // Row insertion — relative refs 86 + // ============================================================ 87 + 88 + describe('Row insert — relative refs', () => { 89 + it('shifts ref at insert point down by 1', () => { 90 + expect(adjustFormulaRefs('A5', { type: 'row', index: 5, delta: 1 })).toBe('A6'); 91 + }); 92 + 93 + it('shifts ref below insert point down by 1', () => { 94 + expect(adjustFormulaRefs('A10', { type: 'row', index: 5, delta: 1 })).toBe('A11'); 95 + }); 96 + 97 + it('does not shift ref above insert point', () => { 98 + expect(adjustFormulaRefs('A2', { type: 'row', index: 5, delta: 1 })).toBe('A2'); 99 + }); 100 + 101 + it('shifts multiple refs in a formula', () => { 102 + expect(adjustFormulaRefs('A5+B7+C3', { type: 'row', index: 5, delta: 1 })) 103 + .toBe('A6+B8+C3'); 104 + }); 105 + 106 + it('shifts refs in SUM with range', () => { 107 + expect(adjustFormulaRefs('SUM(A5:A10)', { type: 'row', index: 5, delta: 1 })) 108 + .toBe('SUM(A6:A11)'); 109 + }); 110 + 111 + it('insert at row 1 shifts everything', () => { 112 + expect(adjustFormulaRefs('A1+B1', { type: 'row', index: 1, delta: 1 })) 113 + .toBe('A2+B2'); 114 + }); 115 + }); 116 + 117 + // ============================================================ 118 + // Row insertion — absolute refs ($) 119 + // ============================================================ 120 + 121 + describe('Row insert — absolute refs', () => { 122 + it('shifts A$5 down when inserting at row 5 (absolute row, structural change)', () => { 123 + expect(adjustFormulaRefs('A$5', { type: 'row', index: 5, delta: 1 })).toBe('A$6'); 124 + }); 125 + 126 + it('shifts A$5 down when inserting above (row 3)', () => { 127 + expect(adjustFormulaRefs('A$5', { type: 'row', index: 3, delta: 1 })).toBe('A$6'); 128 + }); 129 + 130 + it('does not shift A$5 when inserting below (row 10)', () => { 131 + expect(adjustFormulaRefs('A$5', { type: 'row', index: 10, delta: 1 })).toBe('A$5'); 132 + }); 133 + 134 + it('shifts $A5 down (absolute col, relative row)', () => { 135 + expect(adjustFormulaRefs('$A5', { type: 'row', index: 3, delta: 1 })).toBe('$A6'); 136 + }); 137 + 138 + it('shifts $A$5 down (fully absolute ref)', () => { 139 + expect(adjustFormulaRefs('$A$5', { type: 'row', index: 3, delta: 1 })).toBe('$A$6'); 140 + }); 141 + 142 + it('preserves $ signs in the output', () => { 143 + expect(adjustFormulaRefs('$B$10', { type: 'row', index: 5, delta: 1 })).toBe('$B$11'); 144 + }); 145 + 146 + it('shifts mixed refs correctly in one formula', () => { 147 + expect(adjustFormulaRefs('A1+$B$5+C$3', { type: 'row', index: 3, delta: 1 })) 148 + .toBe('A1+$B$6+C$4'); 149 + }); 150 + }); 151 + 152 + // ============================================================ 153 + // Row deletion — relative and absolute refs 154 + // ============================================================ 155 + 156 + describe('Row delete — relative refs', () => { 157 + it('ref to deleted row becomes #REF!', () => { 158 + expect(adjustFormulaRefs('A5', { type: 'row', index: 5, delta: -1 })).toBe('#REF!'); 159 + }); 160 + 161 + it('ref below deleted row shifts up', () => { 162 + expect(adjustFormulaRefs('A10', { type: 'row', index: 5, delta: -1 })).toBe('A9'); 163 + }); 164 + 165 + it('ref above deleted row unchanged', () => { 166 + expect(adjustFormulaRefs('A2', { type: 'row', index: 5, delta: -1 })).toBe('A2'); 167 + }); 168 + 169 + it('multiple refs: deleted becomes #REF!, others shift', () => { 170 + expect(adjustFormulaRefs('A5+A10+A2', { type: 'row', index: 5, delta: -1 })) 171 + .toBe('#REF!+A9+A2'); 172 + }); 173 + }); 174 + 175 + describe('Row delete — absolute refs', () => { 176 + it('A$5 to deleted row becomes #REF!', () => { 177 + expect(adjustFormulaRefs('A$5', { type: 'row', index: 5, delta: -1 })).toBe('#REF!'); 178 + }); 179 + 180 + it('$A$5 to deleted row becomes #REF!', () => { 181 + expect(adjustFormulaRefs('$A$5', { type: 'row', index: 5, delta: -1 })).toBe('#REF!'); 182 + }); 183 + 184 + it('$A$10 shifts up when row 5 deleted', () => { 185 + expect(adjustFormulaRefs('$A$10', { type: 'row', index: 5, delta: -1 })).toBe('$A$9'); 186 + }); 187 + 188 + it('A$10 shifts up when row 5 deleted, preserving $ on row', () => { 189 + expect(adjustFormulaRefs('A$10', { type: 'row', index: 5, delta: -1 })).toBe('A$9'); 190 + }); 191 + 192 + it('$A$3 unchanged when row 5 deleted (above deleted row)', () => { 193 + expect(adjustFormulaRefs('$A$3', { type: 'row', index: 5, delta: -1 })).toBe('$A$3'); 194 + }); 195 + }); 196 + 197 + // ============================================================ 198 + // Column insertion — relative and absolute refs 199 + // ============================================================ 200 + 201 + describe('Column insert — relative refs', () => { 202 + it('shifts ref at insert col right', () => { 203 + expect(adjustFormulaRefs('C1', { type: 'col', index: 3, delta: 1 })).toBe('D1'); 204 + }); 205 + 206 + it('shifts ref right of insert col right', () => { 207 + expect(adjustFormulaRefs('E1', { type: 'col', index: 3, delta: 1 })).toBe('F1'); 208 + }); 209 + 210 + it('does not shift ref left of insert col', () => { 211 + expect(adjustFormulaRefs('A1', { type: 'col', index: 3, delta: 1 })).toBe('A1'); 212 + }); 213 + }); 214 + 215 + describe('Column insert — absolute refs', () => { 216 + it('shifts $C1 right on structural insert (absolute col)', () => { 217 + expect(adjustFormulaRefs('$C1', { type: 'col', index: 3, delta: 1 })).toBe('$D1'); 218 + }); 219 + 220 + it('shifts C$1 right (relative col, absolute row)', () => { 221 + expect(adjustFormulaRefs('C$1', { type: 'col', index: 3, delta: 1 })).toBe('D$1'); 222 + }); 223 + 224 + it('shifts $C$1 right (fully absolute)', () => { 225 + expect(adjustFormulaRefs('$C$1', { type: 'col', index: 3, delta: 1 })).toBe('$D$1'); 226 + }); 227 + 228 + it('does not shift $B1 when insert is at col 3 (left of insert)', () => { 229 + expect(adjustFormulaRefs('$B1', { type: 'col', index: 3, delta: 1 })).toBe('$B1'); 230 + }); 231 + 232 + it('shifts $E$5 right when insert is at col 3', () => { 233 + expect(adjustFormulaRefs('$E$5', { type: 'col', index: 3, delta: 1 })).toBe('$F$5'); 234 + }); 235 + }); 236 + 237 + // ============================================================ 238 + // Column deletion — relative and absolute refs 239 + // ============================================================ 240 + 241 + describe('Column delete — relative refs', () => { 242 + it('ref to deleted col becomes #REF!', () => { 243 + expect(adjustFormulaRefs('C1', { type: 'col', index: 3, delta: -1 })).toBe('#REF!'); 244 + }); 245 + 246 + it('ref right of deleted col shifts left', () => { 247 + expect(adjustFormulaRefs('E1', { type: 'col', index: 3, delta: -1 })).toBe('D1'); 248 + }); 249 + 250 + it('ref left of deleted col unchanged', () => { 251 + expect(adjustFormulaRefs('A1', { type: 'col', index: 3, delta: -1 })).toBe('A1'); 252 + }); 253 + }); 254 + 255 + describe('Column delete — absolute refs', () => { 256 + it('$C1 to deleted col becomes #REF!', () => { 257 + expect(adjustFormulaRefs('$C1', { type: 'col', index: 3, delta: -1 })).toBe('#REF!'); 258 + }); 259 + 260 + it('$C$1 to deleted col becomes #REF!', () => { 261 + expect(adjustFormulaRefs('$C$1', { type: 'col', index: 3, delta: -1 })).toBe('#REF!'); 262 + }); 263 + 264 + it('$E$1 shifts left when col 3 deleted', () => { 265 + expect(adjustFormulaRefs('$E$1', { type: 'col', index: 3, delta: -1 })).toBe('$D$1'); 266 + }); 267 + 268 + it('$B$1 unchanged when col 3 deleted (left of deleted)', () => { 269 + expect(adjustFormulaRefs('$B$1', { type: 'col', index: 3, delta: -1 })).toBe('$B$1'); 270 + }); 271 + }); 272 + 273 + // ============================================================ 274 + // Range refs (A1:B5) 275 + // ============================================================ 276 + 277 + describe('Range refs — row operations', () => { 278 + it('insert inside range expands the range', () => { 279 + expect(adjustFormulaRefs('SUM(A1:A10)', { type: 'row', index: 5, delta: 1 })) 280 + .toBe('SUM(A1:A11)'); 281 + }); 282 + 283 + it('insert above range shifts both ends', () => { 284 + expect(adjustFormulaRefs('SUM(A5:A10)', { type: 'row', index: 2, delta: 1 })) 285 + .toBe('SUM(A6:A11)'); 286 + }); 287 + 288 + it('insert below range does not change it', () => { 289 + expect(adjustFormulaRefs('SUM(A1:A5)', { type: 'row', index: 10, delta: 1 })) 290 + .toBe('SUM(A1:A5)'); 291 + }); 292 + 293 + it('delete row at range endpoint: endpoint becomes #REF!', () => { 294 + expect(adjustFormulaRefs('SUM(A1:A10)', { type: 'row', index: 10, delta: -1 })) 295 + .toBe('SUM(A1:#REF!)'); 296 + }); 297 + 298 + it('delete row above range shifts both endpoints up', () => { 299 + expect(adjustFormulaRefs('SUM(A5:A10)', { type: 'row', index: 2, delta: -1 })) 300 + .toBe('SUM(A4:A9)'); 301 + }); 302 + 303 + it('delete row inside range shifts lower endpoint', () => { 304 + expect(adjustFormulaRefs('SUM(A3:A10)', { type: 'row', index: 5, delta: -1 })) 305 + .toBe('SUM(A3:A9)'); 306 + }); 307 + 308 + it('multi-column range adjusts on row insert', () => { 309 + expect(adjustFormulaRefs('SUM(A1:C10)', { type: 'row', index: 5, delta: 1 })) 310 + .toBe('SUM(A1:C11)'); 311 + }); 312 + }); 313 + 314 + describe('Range refs — column operations', () => { 315 + it('insert inside column range shifts right endpoint', () => { 316 + expect(adjustFormulaRefs('SUM(A1:E1)', { type: 'col', index: 3, delta: 1 })) 317 + .toBe('SUM(A1:F1)'); 318 + }); 319 + 320 + it('insert left of range shifts both endpoints', () => { 321 + expect(adjustFormulaRefs('SUM(C1:E1)', { type: 'col', index: 2, delta: 1 })) 322 + .toBe('SUM(D1:F1)'); 323 + }); 324 + 325 + it('delete col at range endpoint becomes #REF!', () => { 326 + expect(adjustFormulaRefs('SUM(C1:E1)', { type: 'col', index: 3, delta: -1 })) 327 + .toBe('SUM(#REF!:D1)'); 328 + }); 329 + }); 330 + 331 + describe('Range refs with absolute markers', () => { 332 + it('shifts $A$1:$A$10 range on row insert', () => { 333 + expect(adjustFormulaRefs('SUM($A$1:$A$10)', { type: 'row', index: 5, delta: 1 })) 334 + .toBe('SUM($A$1:$A$11)'); 335 + }); 336 + 337 + it('shifts $A$5:$C$10 on row insert above range', () => { 338 + expect(adjustFormulaRefs('SUM($A$5:$C$10)', { type: 'row', index: 2, delta: 1 })) 339 + .toBe('SUM($A$6:$C$11)'); 340 + }); 341 + 342 + it('shifts $C$1:$E$1 on column insert', () => { 343 + expect(adjustFormulaRefs('SUM($C$1:$E$1)', { type: 'col', index: 3, delta: 1 })) 344 + .toBe('SUM($D$1:$F$1)'); 345 + }); 346 + 347 + it('absolute range endpoint on deleted row becomes #REF!', () => { 348 + expect(adjustFormulaRefs('SUM($A$5:$A$5)', { type: 'row', index: 5, delta: -1 })) 349 + .toBe('SUM(#REF!:#REF!)'); 350 + }); 351 + }); 352 + 353 + // ============================================================ 354 + // Cross-sheet refs — should NOT be adjusted 355 + // ============================================================ 356 + 357 + describe('Cross-sheet refs are not adjusted', () => { 358 + it('unquoted cross-sheet ref unchanged on row insert', () => { 359 + expect(adjustFormulaRefs('Sheet2!A5', { type: 'row', index: 3, delta: 1 })) 360 + .toBe('Sheet2!A5'); 361 + }); 362 + 363 + it('quoted cross-sheet ref unchanged on row insert', () => { 364 + expect(adjustFormulaRefs("'My Sheet'!A5", { type: 'row', index: 3, delta: 1 })) 365 + .toBe("'My Sheet'!A5"); 366 + }); 367 + 368 + it('cross-sheet ref unchanged but local ref shifts', () => { 369 + expect(adjustFormulaRefs('Sheet2!A5+A5', { type: 'row', index: 3, delta: 1 })) 370 + .toBe('Sheet2!A5+A6'); 371 + }); 372 + 373 + it('cross-sheet ref unchanged on column delete', () => { 374 + expect(adjustFormulaRefs('Sheet1!C1+D1', { type: 'col', index: 3, delta: -1 })) 375 + .toBe('Sheet1!C1+C1'); 376 + }); 377 + }); 378 + 379 + // ============================================================ 380 + // Complex formula scenarios 381 + // ============================================================ 382 + 383 + describe('Complex formulas', () => { 384 + it('IF formula with multiple refs', () => { 385 + expect(adjustFormulaRefs('IF(A5>0,B5,C5)', { type: 'row', index: 3, delta: 1 })) 386 + .toBe('IF(A6>0,B6,C6)'); 387 + }); 388 + 389 + it('nested SUM + single ref', () => { 390 + expect(adjustFormulaRefs('SUM(A1:A10)+B5', { type: 'row', index: 5, delta: -1 })) 391 + .toBe('SUM(A1:A9)+#REF!'); 392 + }); 393 + 394 + it('VLOOKUP formula adjusts table range', () => { 395 + expect(adjustFormulaRefs('VLOOKUP(A1,B1:D10,3)', { type: 'row', index: 5, delta: 1 })) 396 + .toBe('VLOOKUP(A1,B1:D11,3)'); 397 + }); 398 + 399 + it('INDEX/MATCH formula adjusts all refs', () => { 400 + expect(adjustFormulaRefs('INDEX(B1:B10,MATCH(A1,A1:A10,0))', { type: 'row', index: 5, delta: 1 })) 401 + .toBe('INDEX(B1:B11,MATCH(A1,A1:A11,0))'); 402 + }); 403 + 404 + it('formula with no cell refs unchanged', () => { 405 + expect(adjustFormulaRefs('42+10', { type: 'row', index: 1, delta: 1 })).toBe('42+10'); 406 + }); 407 + 408 + it('empty formula unchanged', () => { 409 + expect(adjustFormulaRefs('', { type: 'row', index: 1, delta: 1 })).toBe(''); 410 + }); 411 + 412 + it('formula with concatenation operator', () => { 413 + expect(adjustFormulaRefs('A5&" - "&B5', { type: 'row', index: 3, delta: 1 })) 414 + .toBe('A6&" - "&B6'); 415 + }); 416 + 417 + it('multiple deletions produce multiple #REF!', () => { 418 + expect(adjustFormulaRefs('A3+A4+A5', { type: 'row', index: 3, delta: -1 })) 419 + .toBe('#REF!+A3+A4'); 420 + }); 421 + }); 422 + 423 + // ============================================================ 424 + // Integration: insertRow with absolute refs in formulas 425 + // ============================================================ 426 + 427 + describe('insertRow — absolute ref integration', () => { 428 + it('adjusts absolute refs in formulas of non-shifted cells', () => { 429 + const { getCells, setCellData, getCellData } = createMockCellMap({ 430 + 'A1': { v: '', f: '$A$3+$B$4', s: '' }, 431 + 'A3': { v: 100, f: '', s: '' }, 432 + 'B4': { v: 200, f: '', s: '' }, 433 + }); 434 + 435 + insertRow(getCells, setCellData, 2, 2); 436 + 437 + expect(getCellData('A1')?.f).toBe('$A$4+$B$5'); 438 + }); 439 + 440 + it('adjusts absolute refs in formulas of shifted cells', () => { 441 + const { getCells, setCellData, getCellData } = createMockCellMap({ 442 + 'A3': { v: '', f: '$A$1+$B$2', s: '' }, 443 + 'A1': { v: 10, f: '', s: '' }, 444 + 'B2': { v: 20, f: '', s: '' }, 445 + }); 446 + 447 + insertRow(getCells, setCellData, 3, 2); 448 + 449 + expect(getCellData('A4')?.f).toBe('$A$1+$B$2'); 450 + }); 451 + 452 + it('adjusts mixed absolute/relative refs on insert', () => { 453 + const { getCells, setCellData, getCellData } = createMockCellMap({ 454 + 'A1': { v: '', f: 'A5+$B$5+C$5', s: '' }, 455 + 'A5': { v: 10, f: '', s: '' }, 456 + 'B5': { v: 20, f: '', s: '' }, 457 + 'C5': { v: 30, f: '', s: '' }, 458 + }); 459 + 460 + insertRow(getCells, setCellData, 3, 3); 461 + 462 + expect(getCellData('A1')?.f).toBe('A6+$B$6+C$6'); 463 + }); 464 + }); 465 + 466 + // ============================================================ 467 + // Integration: deleteRow with absolute refs in formulas 468 + // ============================================================ 469 + 470 + describe('deleteRow — absolute ref integration', () => { 471 + it('absolute ref to deleted row becomes #REF!', () => { 472 + const { getCells, setCellData, getCellData } = createMockCellMap({ 473 + 'A1': { v: '', f: '$A$3*2', s: '' }, 474 + 'A3': { v: 100, f: '', s: '' }, 475 + }); 476 + 477 + deleteRow(getCells, setCellData, 3, 1); 478 + 479 + expect(getCellData('A1')?.f).toBe('#REF!*2'); 480 + }); 481 + 482 + it('absolute ref below deleted row shifts up', () => { 483 + const { getCells, setCellData, getCellData } = createMockCellMap({ 484 + 'A1': { v: '', f: '$A$5', s: '' }, 485 + 'A3': { v: 'delete me', f: '', s: '' }, 486 + 'A5': { v: 50, f: '', s: '' }, 487 + }); 488 + 489 + deleteRow(getCells, setCellData, 3, 1); 490 + 491 + expect(getCellData('A1')?.f).toBe('$A$4'); 492 + }); 493 + }); 494 + 495 + // ============================================================ 496 + // Integration: insertColumn with absolute refs in formulas 497 + // ============================================================ 498 + 499 + describe('insertColumn — absolute ref integration', () => { 500 + it('adjusts absolute column refs in non-shifted cells', () => { 501 + const { getCells, setCellData, getCellData } = createMockCellMap({ 502 + 'A1': { v: '', f: '$C$1+$D$1', s: '' }, 503 + 'C1': { v: 10, f: '', s: '' }, 504 + 'D1': { v: 20, f: '', s: '' }, 505 + }); 506 + 507 + insertColumn(getCells, setCellData, 2, 1); 508 + 509 + expect(getCellData('A1')?.f).toBe('$D$1+$E$1'); 510 + }); 511 + 512 + it('mixed refs adjusted on column insert', () => { 513 + const { getCells, setCellData, getCellData } = createMockCellMap({ 514 + 'A1': { v: '', f: 'C1+$C$1+$C1+C$1', s: '' }, 515 + 'C1': { v: 10, f: '', s: '' }, 516 + }); 517 + 518 + insertColumn(getCells, setCellData, 3, 1); 519 + 520 + expect(getCellData('A1')?.f).toBe('D1+$D$1+$D1+D$1'); 521 + }); 522 + }); 523 + 524 + // ============================================================ 525 + // Integration: deleteColumn with absolute refs in formulas 526 + // ============================================================ 527 + 528 + describe('deleteColumn — absolute ref integration', () => { 529 + it('absolute ref to deleted column becomes #REF!', () => { 530 + const { getCells, setCellData, getCellData } = createMockCellMap({ 531 + 'A1': { v: '', f: '$C$1+$D$1', s: '' }, 532 + 'C1': { v: 10, f: '', s: '' }, 533 + 'D1': { v: 20, f: '', s: '' }, 534 + }); 535 + 536 + deleteColumn(getCells, setCellData, 3, 1); 537 + 538 + expect(getCellData('A1')?.f).toBe('#REF!+$C$1'); 539 + }); 540 + 541 + it('absolute ref right of deleted col shifts left', () => { 542 + const { getCells, setCellData, getCellData } = createMockCellMap({ 543 + 'A1': { v: '', f: '$E$1', s: '' }, 544 + 'B1': { v: 'delete me', f: '', s: '' }, 545 + 'E1': { v: 50, f: '', s: '' }, 546 + }); 547 + 548 + deleteColumn(getCells, setCellData, 2, 1); 549 + 550 + expect(getCellData('A1')?.f).toBe('$D$1'); 551 + }); 552 + }); 553 + 554 + // ============================================================ 555 + // Edge cases 556 + // ============================================================ 557 + 558 + describe('Edge cases', () => { 559 + it('row 1 insert at row 1 shifts all refs', () => { 560 + expect(adjustFormulaRefs('A1+B1+C1', { type: 'row', index: 1, delta: 1 })) 561 + .toBe('A2+B2+C2'); 562 + }); 563 + 564 + it('delete row 1 shifts everything up', () => { 565 + expect(adjustFormulaRefs('A2+A3', { type: 'row', index: 1, delta: -1 })) 566 + .toBe('A1+A2'); 567 + }); 568 + 569 + it('column A insert at col 1 shifts all column refs right', () => { 570 + expect(adjustFormulaRefs('A1+B1+C1', { type: 'col', index: 1, delta: 1 })) 571 + .toBe('B1+C1+D1'); 572 + }); 573 + 574 + it('delete column A shifts all refs left', () => { 575 + expect(adjustFormulaRefs('B1+C1', { type: 'col', index: 1, delta: -1 })) 576 + .toBe('A1+B1'); 577 + }); 578 + 579 + it('large row number shifts correctly', () => { 580 + expect(adjustFormulaRefs('A999', { type: 'row', index: 500, delta: 1 })).toBe('A1000'); 581 + }); 582 + 583 + it('multi-letter column (AA) shifts correctly', () => { 584 + expect(adjustFormulaRefs('AA1', { type: 'col', index: 27, delta: 1 })).toBe('AB1'); 585 + }); 586 + 587 + it('formula with only operators and numbers is unchanged', () => { 588 + expect(adjustFormulaRefs('1+2*3/4', { type: 'row', index: 1, delta: 1 })).toBe('1+2*3/4'); 589 + }); 590 + 591 + it('sequential insert/delete operations', () => { 592 + let formula = 'A1+A5+A10'; 593 + 594 + formula = adjustFormulaRefs(formula, { type: 'row', index: 3, delta: 1 }); 595 + expect(formula).toBe('A1+A6+A11'); 596 + 597 + formula = adjustFormulaRefs(formula, { type: 'row', index: 6, delta: -1 }); 598 + expect(formula).toBe('A1+#REF!+A10'); 599 + }); 600 + 601 + it('preserves all $ combinations through row adjustment', () => { 602 + const result = adjustFormulaRefs('A5+$A5+A$5+$A$5', { type: 'row', index: 3, delta: 1 }); 603 + expect(result).toBe('A6+$A6+A$6+$A$6'); 604 + }); 605 + 606 + it('preserves all $ combinations through column adjustment', () => { 607 + const result = adjustFormulaRefs('C1+$C1+C$1+$C$1', { type: 'col', index: 3, delta: 1 }); 608 + expect(result).toBe('D1+$D1+D$1+$D$1'); 609 + }); 610 + });
+4 -4
tests/row-col-ops-comprehensive.test.ts
··· 166 166 .toBe('SUM(A1:F1)'); 167 167 }); 168 168 169 - it('absolute column ref $C1 does not shift', () => { 169 + it('absolute column ref $C1 shifts on structural insert', () => { 170 170 expect(adjustFormulaRefs('$C1', { type: 'col', index: 3, delta: 1 })) 171 - .toBe('$C1'); 171 + .toBe('$D1'); 172 172 }); 173 173 174 174 it('mixed ref: C$1 shifts column but not row', () => { ··· 446 446 describe('adjustFormulaRefs — complex scenarios', () => { 447 447 it('mixed absolute and relative refs in same formula', () => { 448 448 // A1 + $B$5 + C3: insert row at 3 449 - // A1 stays, $B$5 stays (fully absolute), C3 -> C4 449 + // A1 stays, $B$5 -> $B$6 (absolute refs shift on structural changes), C3 -> C4 450 450 const result = adjustFormulaRefs('A1+$B$5+C3', { type: 'row', index: 3, delta: 1 }); 451 - expect(result).toBe('A1+$B$5+C4'); 451 + expect(result).toBe('A1+$B$6+C4'); 452 452 }); 453 453 454 454 it('formula with IF and multiple refs', () => {
+15 -15
tests/row-col-ops.test.ts
··· 99 99 .toBe('B6+C6'); 100 100 }); 101 101 102 - it('does not shift absolute row refs ($)', () => { 102 + it('shifts absolute row refs on structural insert ($ only prevents copy-paste shift)', () => { 103 103 expect(adjustFormulaRefs('A$5', { type: 'row', index: 3, delta: 1 })) 104 - .toBe('A$5'); 104 + .toBe('A$6'); 105 105 }); 106 106 107 107 it('shifts non-absolute column with absolute row unchanged', () => { ··· 136 136 .toBe('A1+A2'); 137 137 }); 138 138 139 - it('does not shift absolute row ref ($)', () => { 139 + it('shifts absolute row ref on structural delete', () => { 140 140 expect(adjustFormulaRefs('A$3', { type: 'row', index: 3, delta: -1 })) 141 - .toBe('A$3'); 141 + .toBe('#REF!'); 142 142 }); 143 143 }); 144 144 ··· 157 157 .toBe('A1+B1'); 158 158 }); 159 159 160 - it('does not shift absolute column ref ($)', () => { 160 + it('shifts absolute column ref on structural insert', () => { 161 161 expect(adjustFormulaRefs('$C1', { type: 'col', index: 3, delta: 1 })) 162 - .toBe('$C1'); 162 + .toBe('$D1'); 163 163 }); 164 164 165 165 it('shifts relative column with absolute row', () => { ··· 236 236 // adjustFormulaRefs — Fully absolute refs ($A$1) 237 237 // ============================================================ 238 238 239 - describe('adjustFormulaRefs — fully absolute refs', () => { 240 - it('does not shift $A$1 on row insert', () => { 239 + describe('adjustFormulaRefs — fully absolute refs (structural changes DO adjust)', () => { 240 + it('shifts $A$1 on row insert at row 1', () => { 241 241 expect(adjustFormulaRefs('$A$1', { type: 'row', index: 1, delta: 1 })) 242 - .toBe('$A$1'); 242 + .toBe('$A$2'); 243 243 }); 244 244 245 - it('does not shift $A$1 on column insert', () => { 245 + it('shifts $A$1 on column insert at col 1', () => { 246 246 expect(adjustFormulaRefs('$A$1', { type: 'col', index: 1, delta: 1 })) 247 - .toBe('$A$1'); 247 + .toBe('$B$1'); 248 248 }); 249 249 250 - it('does not shift $C$5 on row delete', () => { 250 + it('$C$5 becomes #REF! on row 5 delete', () => { 251 251 expect(adjustFormulaRefs('$C$5', { type: 'row', index: 5, delta: -1 })) 252 - .toBe('$C$5'); 252 + .toBe('#REF!'); 253 253 }); 254 254 255 - it('does not shift $C$5 on column delete', () => { 255 + it('$C$5 becomes #REF! on col 3 delete', () => { 256 256 expect(adjustFormulaRefs('$C$5', { type: 'col', index: 3, delta: -1 })) 257 - .toBe('$C$5'); 257 + .toBe('#REF!'); 258 258 }); 259 259 }); 260 260
+4 -4
tests/ux-iteration-3.test.ts
··· 110 110 expect(result).toContain('D2'); 111 111 }); 112 112 113 - it('does not shift absolute row refs ($)', () => { 113 + it('shifts absolute row refs on structural insert', () => { 114 114 const result = adjustFormulaRefs('=A$1', { type: 'row', index: 1, delta: 1 }); 115 - expect(result).toBe('=A$1'); 115 + expect(result).toBe('=A$2'); 116 116 }); 117 117 118 - it('does not shift absolute column refs ($)', () => { 118 + it('shifts absolute column refs on structural insert', () => { 119 119 const result = adjustFormulaRefs('=$B1', { type: 'col', index: 1, delta: 1 }); 120 - expect(result).toBe('=$B1'); 120 + expect(result).toBe('=$C1'); 121 121 }); 122 122 123 123 it('does not modify cross-sheet references', () => {