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 'feat: cross-sheet refs, named ranges, and formula tracer' (#28) from feat/sheets-formulas into main

scott 38cca2d2 b8d34ec6

+1007 -11
+43
src/sheets/cross-sheet.js
··· 1 + /** 2 + * Cross-sheet reference resolution. 3 + * 4 + * Syntax: Sheet2!A1, Sheet2!A1:B5, 'Sheet Name'!A1 5 + * The `!` separator divides sheet name from cell reference. 6 + * Quoted names (single quotes) allow spaces and special characters. 7 + */ 8 + 9 + /** 10 + * Parse a cross-sheet reference string. 11 + * @param {string} ref - e.g. "Sheet2!A1", "'My Sheet'!A1:B5" 12 + * @returns {{ sheetName: string, ref: string } | null} 13 + */ 14 + export function parseCrossSheetRef(ref) { 15 + // Quoted sheet name: 'Sheet Name'!A1 or 'Sheet Name'!A1:B5 16 + const quotedMatch = ref.match(/^'([^']+)'!(.+)$/); 17 + if (quotedMatch) { 18 + return { sheetName: quotedMatch[1], ref: quotedMatch[2] }; 19 + } 20 + 21 + // Unquoted sheet name: Sheet2!A1 or Sheet2!A1:B5 22 + // Sheet name is anything before `!` that isn't just a cell ref 23 + const unquotedMatch = ref.match(/^([A-Za-z_][A-Za-z0-9_]*)!(.+)$/); 24 + if (unquotedMatch) { 25 + return { sheetName: unquotedMatch[1], ref: unquotedMatch[2] }; 26 + } 27 + 28 + return null; 29 + } 30 + 31 + /** 32 + * Resolve a cross-sheet cell reference using a resolver. 33 + * @param {string} sheetName - Target sheet name 34 + * @param {string} cellRef - Cell reference (e.g. "A1") 35 + * @param {object} resolver - Has getSheetCellValue(sheetName, cellRef), sheetExists(name) 36 + * @returns {any} Cell value or '#REF!' if sheet doesn't exist 37 + */ 38 + export function resolveCrossSheetRef(sheetName, cellRef, resolver) { 39 + if (!resolver.sheetExists(sheetName)) { 40 + return '#REF!'; 41 + } 42 + return resolver.getSheetCellValue(sheetName, cellRef); 43 + }
+74
src/sheets/formula-tracer.js
··· 1 + /** 2 + * Formula tracer — visualize cell dependencies. 3 + * 4 + * Precedents: cells that a formula references (its inputs). 5 + * Dependents: cells that reference a given cell (its outputs). 6 + * 7 + * The tracer works on the formula text using extractRefs from formulas.js, 8 + * without evaluating formulas. 9 + */ 10 + 11 + import { extractRefs } from './formulas.js'; 12 + 13 + /** 14 + * Trace the precedents (inputs) of a cell. 15 + * Returns the set of cell IDs that the cell's formula directly references. 16 + * 17 + * @param {string} cellId - The cell to trace (e.g. "C1") 18 + * @param {object} cellData - Map of cellId -> { v, f } where f is formula string 19 + * @returns {Set<string>} Set of precedent cell IDs 20 + */ 21 + export function tracePrecedents(cellId, cellData) { 22 + const cell = cellData[cellId]; 23 + if (!cell || !cell.f) return new Set(); 24 + return extractRefs(cell.f); 25 + } 26 + 27 + /** 28 + * Trace the dependents (outputs) of a cell. 29 + * Returns the set of cell IDs whose formulas reference the given cell. 30 + * 31 + * @param {string} cellId - The cell to trace (e.g. "A1") 32 + * @param {object} cellData - Map of cellId -> { v, f } 33 + * @returns {Set<string>} Set of dependent cell IDs 34 + */ 35 + export function traceDependents(cellId, cellData) { 36 + const dependents = new Set(); 37 + for (const [id, cell] of Object.entries(cellData)) { 38 + if (!cell.f) continue; 39 + const refs = extractRefs(cell.f); 40 + if (refs.has(cellId)) { 41 + dependents.add(id); 42 + } 43 + } 44 + return dependents; 45 + } 46 + 47 + /** 48 + * Build a full dependency graph for all cells. 49 + * Returns both precedent and dependent maps. 50 + * 51 + * @param {object} cellData - Map of cellId -> { v, f } 52 + * @returns {{ precedents: Map<string, Set<string>>, dependents: Map<string, Set<string>> }} 53 + */ 54 + export function buildDependencyGraph(cellData) { 55 + const precedents = new Map(); // cellId -> Set of cells it depends on 56 + const dependents = new Map(); // cellId -> Set of cells that depend on it 57 + 58 + for (const [id, cell] of Object.entries(cellData)) { 59 + if (!cell.f) continue; 60 + const refs = extractRefs(cell.f); 61 + if (refs.size === 0) continue; 62 + 63 + precedents.set(id, refs); 64 + 65 + for (const ref of refs) { 66 + if (!dependents.has(ref)) { 67 + dependents.set(ref, new Set()); 68 + } 69 + dependents.get(ref).add(id); 70 + } 71 + } 72 + 73 + return { precedents, dependents }; 74 + }
+159 -11
src/sheets/formulas.js
··· 11 11 STRING: 'STRING', 12 12 BOOLEAN: 'BOOLEAN', 13 13 CELL_REF: 'CELL_REF', 14 + CROSS_SHEET_REF: 'CROSS_SHEET_REF', // e.g. Sheet2!A1 14 15 RANGE: 'RANGE', 15 16 FUNCTION: 'FUNCTION', 17 + IDENTIFIER: 'IDENTIFIER', // named range or unknown identifier 16 18 OPERATOR: 'OPERATOR', 17 19 LPAREN: 'LPAREN', 18 20 RPAREN: 'RPAREN', 19 21 COMMA: 'COMMA', 20 22 COLON: 'COLON', 23 + BANG: 'BANG', 21 24 EOF: 'EOF', 22 25 }; 23 26 ··· 30 33 // Whitespace 31 34 if (s[i] === ' ' || s[i] === '\t') { i++; continue; } 32 35 36 + // Quoted sheet name: 'Sheet Name'!A1 37 + if (s[i] === "'") { 38 + let sheetName = ''; 39 + i++; // skip opening quote 40 + while (i < s.length && s[i] !== "'") { 41 + sheetName += s[i++]; 42 + } 43 + i++; // skip closing quote 44 + // Expect ! after quoted sheet name 45 + if (i < s.length && s[i] === '!') { 46 + i++; // skip ! 47 + // Read the cell ref after ! 48 + let cellRef = ''; 49 + while (i < s.length && /[A-Za-z0-9$:]/.test(s[i])) { 50 + cellRef += s[i++]; 51 + } 52 + tokens.push({ type: TokenType.CROSS_SHEET_REF, value: { sheetName, ref: cellRef.toUpperCase().replace(/\$/g, '') } }); 53 + } 54 + continue; 55 + } 56 + 33 57 // String literal 34 58 if (s[i] === '"') { 35 59 let str = ''; ··· 56 80 continue; 57 81 } 58 82 59 - // Cell ref or function or boolean 83 + // Cell ref, function, boolean, identifier, or cross-sheet ref (Name!A1) 60 84 if (/[A-Za-z$_]/.test(s[i])) { 61 85 let word = ''; 62 - let start = i; 63 - // Allow $ for absolute refs 64 - while (i < s.length && /[A-Za-z0-9$_]/.test(s[i])) { 86 + // Allow $ for absolute refs, . for named range names like sales.q1 87 + while (i < s.length && /[A-Za-z0-9$_.]/.test(s[i])) { 65 88 word += s[i++]; 66 89 } 67 90 ··· 77 100 continue; 78 101 } 79 102 103 + // Check for cross-sheet ref: word!cellRef (e.g. Sheet2!A1) 104 + if (i < s.length && s[i] === '!') { 105 + i++; // skip ! 106 + let cellRef = ''; 107 + while (i < s.length && /[A-Za-z0-9$:]/.test(s[i])) { 108 + cellRef += s[i++]; 109 + } 110 + tokens.push({ type: TokenType.CROSS_SHEET_REF, value: { sheetName: word, ref: cellRef.toUpperCase().replace(/\$/g, '') } }); 111 + continue; 112 + } 113 + 80 114 // Check if next non-space char is '(' → function 81 115 let peek = i; 82 116 while (peek < s.length && s[peek] === ' ') peek++; ··· 92 126 continue; 93 127 } 94 128 95 - // Unknown identifier — treat as error 96 - tokens.push({ type: TokenType.STRING, value: word }); 129 + // Unknown identifier — could be a named range 130 + tokens.push({ type: TokenType.IDENTIFIER, value: word }); 97 131 continue; 98 132 } 99 133 ··· 125 159 if (s[i] === ')') { tokens.push({ type: TokenType.RPAREN }); i++; continue; } 126 160 if (s[i] === ',') { tokens.push({ type: TokenType.COMMA }); i++; continue; } 127 161 if (s[i] === ':') { tokens.push({ type: TokenType.COLON }); i++; continue; } 162 + if (s[i] === '!') { tokens.push({ type: TokenType.BANG }); i++; continue; } 128 163 129 164 // Unknown character 130 165 i++; ··· 136 171 137 172 // --- Parser (recursive descent) --- 138 173 class Parser { 139 - constructor(tokens, getCellValue) { 174 + constructor(tokens, getCellValue, crossSheetResolver, namedRanges) { 140 175 this.tokens = tokens; 141 176 this.pos = 0; 142 177 this.getCellValue = getCellValue; 178 + this.crossSheetResolver = crossSheetResolver; 179 + this.namedRanges = namedRanges || {}; 143 180 } 144 181 145 182 peek() { return this.tokens[this.pos]; } ··· 234 271 return this.primary(); 235 272 } 236 273 237 - // primary → NUMBER | STRING | BOOLEAN | CELL_REF (':' CELL_REF)? | FUNCTION '(' args ')' | '(' expression ')' 274 + // primary → NUMBER | STRING | BOOLEAN | CELL_REF (':' CELL_REF)? | CROSS_SHEET_REF | IDENTIFIER | FUNCTION '(' args ')' | '(' expression ')' 238 275 primary() { 239 276 const t = this.peek(); 240 277 ··· 251 288 if (t.type === TokenType.BOOLEAN) { 252 289 this.advance(); 253 290 return t.value; 291 + } 292 + 293 + // Cross-sheet reference: Sheet2!A1 or 'Sheet Name'!A1:B5 294 + if (t.type === TokenType.CROSS_SHEET_REF) { 295 + this.advance(); 296 + const { sheetName, ref } = t.value; 297 + // Check for range in the ref (e.g. A1:B5 was captured as one string) 298 + if (ref.includes(':')) { 299 + return this.resolveCrossSheetRange(sheetName, ref); 300 + } 301 + return this.resolveCrossSheetCell(sheetName, ref); 254 302 } 255 303 256 304 if (t.type === TokenType.CELL_REF) { ··· 264 312 return this.getCellValue(t.value); 265 313 } 266 314 315 + // Named range identifier (not a function, not a cell ref) 316 + if (t.type === TokenType.IDENTIFIER) { 317 + this.advance(); 318 + return this.resolveNamedRange(t.value); 319 + } 320 + 267 321 if (t.type === TokenType.FUNCTION) { 268 322 this.advance(); 269 323 this.expect(TokenType.LPAREN); ··· 289 343 throw new Error(`Unexpected token: ${t.type}`); 290 344 } 291 345 292 - // Function args can be ranges (CELL_REF:CELL_REF) or expressions 346 + // Function args can be ranges (CELL_REF:CELL_REF), cross-sheet ranges, named ranges, or expressions 293 347 parseFunctionArg() { 348 + // Cross-sheet ref in function arg (range already parsed in tokenizer) 349 + if (this.peek().type === TokenType.CROSS_SHEET_REF) { 350 + const saved = this.pos; 351 + const t = this.advance(); 352 + const { sheetName, ref } = t.value; 353 + if (ref.includes(':')) { 354 + return this.resolveCrossSheetRange(sheetName, ref); 355 + } 356 + // Single cross-sheet cell ref; could be part of an expression, backtrack 357 + this.pos = saved; 358 + return this.expression(); 359 + } 294 360 // Peek ahead for range pattern: CELL_REF COLON CELL_REF 295 361 if (this.peek().type === TokenType.CELL_REF) { 296 362 const saved = this.pos; ··· 305 371 // Not a range — backtrack and parse as expression 306 372 this.pos = saved; 307 373 } 374 + // Named range identifier in function arg 375 + if (this.peek().type === TokenType.IDENTIFIER) { 376 + const saved = this.pos; 377 + const t = this.advance(); 378 + const resolved = this.resolveNamedRange(t.value); 379 + if (Array.isArray(resolved)) { 380 + return resolved; 381 + } 382 + // Not a named range, backtrack 383 + this.pos = saved; 384 + } 308 385 return this.expression(); 309 386 } 310 387 ··· 326 403 values._rangeRows = rowMax - rowMin + 1; 327 404 values._rangeCols = colMax - colMin + 1; 328 405 return values; 406 + } 407 + 408 + // Resolve a cross-sheet single cell reference 409 + resolveCrossSheetCell(sheetName, cellRef) { 410 + if (!this.crossSheetResolver) return '#REF!'; 411 + if (!this.crossSheetResolver.sheetExists(sheetName)) return '#REF!'; 412 + return this.crossSheetResolver.getSheetCellValue(sheetName, cellRef); 413 + } 414 + 415 + // Resolve a cross-sheet range reference (e.g. Sheet2!A1:B5) 416 + resolveCrossSheetRange(sheetName, rangeStr) { 417 + if (!this.crossSheetResolver) return '#REF!'; 418 + if (!this.crossSheetResolver.sheetExists(sheetName)) return '#REF!'; 419 + const parts = rangeStr.split(':'); 420 + if (parts.length !== 2) return '#REF!'; 421 + const start = parseRef(parts[0]); 422 + const end = parseRef(parts[1]); 423 + if (!start || !end) return '#REF!'; 424 + const values = []; 425 + const rowMin = Math.min(start.row, end.row); 426 + const rowMax = Math.max(start.row, end.row); 427 + const colMin = Math.min(start.col, end.col); 428 + const colMax = Math.max(start.col, end.col); 429 + for (let r = rowMin; r <= rowMax; r++) { 430 + for (let c = colMin; c <= colMax; c++) { 431 + const ref = colToLetter(c) + r; 432 + values.push(this.crossSheetResolver.getSheetCellValue(sheetName, ref)); 433 + } 434 + } 435 + values._rangeRows = rowMax - rowMin + 1; 436 + values._rangeCols = colMax - colMin + 1; 437 + return values; 438 + } 439 + 440 + // Resolve a named range identifier to its values 441 + resolveNamedRange(name) { 442 + const key = name.toLowerCase(); 443 + const entry = this.namedRanges[key]; 444 + if (!entry) { 445 + return `#NAME? (${name})`; 446 + } 447 + const rangeStr = entry.range; 448 + // Determine the getCellValue to use — could be cross-sheet or same-sheet 449 + const parts = rangeStr.split(':'); 450 + if (parts.length === 2) { 451 + return this.resolveRange(parts[0], parts[1]); 452 + } 453 + // Single cell named range 454 + return this.getCellValue(rangeStr); 329 455 } 330 456 } 331 457 ··· 608 734 * Evaluate a formula string. 609 735 * @param {string} formula - The formula (without leading '=') 610 736 * @param {(ref: string) => any} getCellValue - Resolver for cell references 737 + * @param {object} [crossSheetResolver] - Optional resolver for cross-sheet refs 738 + * @param {object} [namedRanges] - Optional map of lowercase name -> { range, sheet } 611 739 * @returns {any} The computed value 612 740 */ 613 - export function evaluate(formula, getCellValue) { 741 + export function evaluate(formula, getCellValue, crossSheetResolver, namedRanges) { 614 742 try { 615 743 const tokens = tokenize(formula); 616 - const parser = new Parser(tokens, getCellValue); 744 + const parser = new Parser(tokens, getCellValue, crossSheetResolver, namedRanges); 617 745 return parser.parse(); 618 746 } catch (err) { 619 747 return '#ERROR!'; ··· 622 750 623 751 /** 624 752 * Extract cell references from a formula for dependency tracking. 753 + * Cross-sheet refs are returned as 'SheetName!CellId'. 625 754 */ 626 755 export function extractRefs(formula) { 627 756 const refs = new Set(); 628 757 const tokens = tokenize(formula); 629 758 for (let i = 0; i < tokens.length; i++) { 630 759 const t = tokens[i]; 760 + if (t.type === TokenType.CROSS_SHEET_REF) { 761 + const { sheetName, ref } = t.value; 762 + if (ref.includes(':')) { 763 + // Cross-sheet range 764 + const parts = ref.split(':'); 765 + const start = parseRef(parts[0]); 766 + const end = parseRef(parts[1]); 767 + if (start && end) { 768 + for (let r = Math.min(start.row, end.row); r <= Math.max(start.row, end.row); r++) { 769 + for (let c = Math.min(start.col, end.col); c <= Math.max(start.col, end.col); c++) { 770 + refs.add(sheetName + '!' + colToLetter(c) + r); 771 + } 772 + } 773 + } 774 + } else { 775 + refs.add(sheetName + '!' + ref); 776 + } 777 + continue; 778 + } 631 779 if (t.type === TokenType.CELL_REF) { 632 780 // Check for range 633 781 if (tokens[i + 1]?.type === TokenType.COLON && tokens[i + 2]?.type === TokenType.CELL_REF) {
+110
src/sheets/named-ranges.js
··· 1 + /** 2 + * Named ranges management. 3 + * 4 + * Named ranges map human-friendly names to cell ranges for cleaner formulas. 5 + * Example: =SUM(Sales) instead of =SUM(A2:A100) 6 + * 7 + * Rules: 8 + * - Names are case-insensitive (stored lowercase internally) 9 + * - Must start with a letter or underscore 10 + * - Can contain letters, digits, underscores, periods 11 + * - Must not collide with cell references (e.g. "A1") 12 + * - Must not be TRUE/FALSE 13 + */ 14 + 15 + const CELL_REF_PATTERN = /^[A-Z]{1,3}[0-9]+$/i; 16 + const VALID_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_.]*$/; 17 + const RESERVED_WORDS = ['TRUE', 'FALSE']; 18 + 19 + /** 20 + * Validate a named range name. 21 + * @param {string} name 22 + * @returns {{ valid: boolean, reason?: string }} 23 + */ 24 + export function validateRangeName(name) { 25 + if (!name || !name.trim()) { 26 + return { valid: false, reason: 'Name cannot be empty' }; 27 + } 28 + 29 + const trimmed = name.trim(); 30 + 31 + if (RESERVED_WORDS.includes(trimmed.toUpperCase())) { 32 + return { valid: false, reason: `"${trimmed}" is a reserved word` }; 33 + } 34 + 35 + if (!VALID_NAME_PATTERN.test(trimmed)) { 36 + return { valid: false, reason: 'Name must start with a letter or underscore and contain only letters, digits, underscores, and periods' }; 37 + } 38 + 39 + // Check it doesn't look like a cell ref (e.g. A1, AB123) 40 + if (CELL_REF_PATTERN.test(trimmed)) { 41 + return { valid: false, reason: `"${trimmed}" looks like a cell reference` }; 42 + } 43 + 44 + return { valid: true }; 45 + } 46 + 47 + /** 48 + * Create or update a named range. 49 + * @param {object} store - Map-like store (Yjs Map or mock) 50 + * @param {string} name - Range name 51 + * @param {string} range - Cell range string (e.g. "A1:A10") 52 + * @param {string} sheet - Sheet name 53 + * @throws {Error} if name is invalid 54 + */ 55 + export function createNamedRange(store, name, range, sheet) { 56 + const validation = validateRangeName(name); 57 + if (!validation.valid) { 58 + throw new Error(`Invalid range name "${name}": ${validation.reason}`); 59 + } 60 + 61 + const key = name.toLowerCase(); 62 + store.set(key, JSON.stringify({ name, range, sheet })); 63 + } 64 + 65 + /** 66 + * Get a named range by name (case-insensitive). 67 + * @param {object} store 68 + * @param {string} name 69 + * @returns {{ name: string, range: string, sheet: string } | null} 70 + */ 71 + export function getNamedRange(store, name) { 72 + const key = name.toLowerCase(); 73 + if (!store.has(key)) return null; 74 + return JSON.parse(store.get(key)); 75 + } 76 + 77 + /** 78 + * Delete a named range (case-insensitive). 79 + * @param {object} store 80 + * @param {string} name 81 + */ 82 + export function deleteNamedRange(store, name) { 83 + const key = name.toLowerCase(); 84 + store.delete(key); 85 + } 86 + 87 + /** 88 + * List all named ranges. 89 + * @param {object} store 90 + * @returns {Array<{ name: string, range: string, sheet: string }>} 91 + */ 92 + export function listNamedRanges(store) { 93 + const result = []; 94 + store.forEach((val) => { 95 + result.push(JSON.parse(val)); 96 + }); 97 + return result; 98 + } 99 + 100 + /** 101 + * Resolve a named range name to its range and sheet. 102 + * @param {object} store 103 + * @param {string} name 104 + * @returns {{ range: string, sheet: string } | null} 105 + */ 106 + export function resolveNamedRange(store, name) { 107 + const entry = getNamedRange(store, name); 108 + if (!entry) return null; 109 + return { range: entry.range, sheet: entry.sheet }; 110 + }
+194
tests/cross-sheet.test.js
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { evaluate, extractRefs, parseRef, colToLetter } from '../src/sheets/formulas.js'; 3 + import { resolveCrossSheetRef, parseCrossSheetRef } from '../src/sheets/cross-sheet.js'; 4 + 5 + /** 6 + * Cross-sheet reference tests. 7 + * 8 + * Syntax: =Sheet2!A1, =Sheet2!A1:B5, ='Sheet Name'!A1 9 + * The formula parser must handle the `!` separator. 10 + * Cross-sheet refs resolve by looking up the named sheet's cells. 11 + * Returns #REF! if the referenced sheet doesn't exist. 12 + */ 13 + 14 + // Helper: build a simple multi-sheet cell resolver 15 + // sheetsData is { sheetName: { cellId: value } } 16 + function makeResolver(sheetsData, currentSheet = 'Sheet 1') { 17 + return { 18 + getSheetCellValue(sheetName, cellRef) { 19 + const sheet = sheetsData[sheetName]; 20 + if (!sheet) return '#REF!'; 21 + return sheet[cellRef] ?? ''; 22 + }, 23 + getCurrentSheet() { 24 + return currentSheet; 25 + }, 26 + sheetExists(name) { 27 + return name in sheetsData; 28 + }, 29 + }; 30 + } 31 + 32 + // Evaluate with cross-sheet support 33 + function evalCrossSheet(formula, sheetsData = {}, currentSheet = 'Sheet 1') { 34 + const resolver = makeResolver(sheetsData, currentSheet); 35 + return evaluate(formula, (ref) => { 36 + // For same-sheet refs 37 + const sheet = sheetsData[currentSheet]; 38 + if (!sheet) return ''; 39 + return sheet[ref] ?? ''; 40 + }, resolver); 41 + } 42 + 43 + describe('parseCrossSheetRef', () => { 44 + it('parses simple sheet!ref syntax', () => { 45 + const result = parseCrossSheetRef('Sheet2!A1'); 46 + expect(result).toEqual({ sheetName: 'Sheet2', ref: 'A1' }); 47 + }); 48 + 49 + it('parses sheet name with numbers', () => { 50 + const result = parseCrossSheetRef('Sheet10!B5'); 51 + expect(result).toEqual({ sheetName: 'Sheet10', ref: 'B5' }); 52 + }); 53 + 54 + it('parses quoted sheet name with spaces', () => { 55 + const result = parseCrossSheetRef("'My Sheet'!A1"); 56 + expect(result).toEqual({ sheetName: 'My Sheet', ref: 'A1' }); 57 + }); 58 + 59 + it('parses quoted sheet name with special characters', () => { 60 + const result = parseCrossSheetRef("'Q1 2024 Data'!C3"); 61 + expect(result).toEqual({ sheetName: 'Q1 2024 Data', ref: 'C3' }); 62 + }); 63 + 64 + it('returns null for non-cross-sheet refs', () => { 65 + expect(parseCrossSheetRef('A1')).toBeNull(); 66 + expect(parseCrossSheetRef('SUM')).toBeNull(); 67 + expect(parseCrossSheetRef('Sheet2')).toBeNull(); 68 + }); 69 + 70 + it('parses range cross-sheet ref', () => { 71 + const result = parseCrossSheetRef('Sheet2!A1:B5'); 72 + expect(result).toEqual({ sheetName: 'Sheet2', ref: 'A1:B5' }); 73 + }); 74 + }); 75 + 76 + describe('resolveCrossSheetRef', () => { 77 + it('resolves a single cell from another sheet', () => { 78 + const sheetsData = { 79 + 'Sheet 1': { A1: 10 }, 80 + 'Sheet 2': { A1: 42 }, 81 + }; 82 + const resolver = makeResolver(sheetsData); 83 + expect(resolveCrossSheetRef('Sheet 2', 'A1', resolver)).toBe(42); 84 + }); 85 + 86 + it('returns #REF! for non-existent sheet', () => { 87 + const resolver = makeResolver({ 'Sheet 1': {} }); 88 + expect(resolveCrossSheetRef('NoSuchSheet', 'A1', resolver)).toBe('#REF!'); 89 + }); 90 + 91 + it('returns empty string for empty cell in existing sheet', () => { 92 + const resolver = makeResolver({ 'Sheet 2': {} }); 93 + expect(resolveCrossSheetRef('Sheet 2', 'Z99', resolver)).toBe(''); 94 + }); 95 + }); 96 + 97 + describe('cross-sheet formula evaluation', () => { 98 + it('evaluates =Sheet2!A1 referencing another sheet', () => { 99 + const sheetsData = { 100 + 'Sheet 1': { A1: 10 }, 101 + 'Sheet2': { A1: 42 }, 102 + }; 103 + const result = evalCrossSheet('Sheet2!A1', sheetsData, 'Sheet 1'); 104 + expect(result).toBe(42); 105 + }); 106 + 107 + it('evaluates cross-sheet ref with arithmetic', () => { 108 + const sheetsData = { 109 + 'Sheet 1': { A1: 10 }, 110 + 'Sheet2': { A1: 20 }, 111 + }; 112 + const result = evalCrossSheet('A1+Sheet2!A1', sheetsData, 'Sheet 1'); 113 + expect(result).toBe(30); 114 + }); 115 + 116 + it('evaluates cross-sheet range in SUM', () => { 117 + const sheetsData = { 118 + 'Sheet 1': {}, 119 + 'Sheet2': { A1: 10, A2: 20, A3: 30 }, 120 + }; 121 + const result = evalCrossSheet('SUM(Sheet2!A1:A3)', sheetsData, 'Sheet 1'); 122 + expect(result).toBe(60); 123 + }); 124 + 125 + it('returns #REF! for non-existent sheet', () => { 126 + const sheetsData = { 'Sheet 1': { A1: 10 } }; 127 + const result = evalCrossSheet('FakeSheet!A1', sheetsData, 'Sheet 1'); 128 + expect(result).toBe('#REF!'); 129 + }); 130 + 131 + it('evaluates quoted sheet name with spaces', () => { 132 + const sheetsData = { 133 + 'Sheet 1': {}, 134 + 'My Data': { B2: 99 }, 135 + }; 136 + const result = evalCrossSheet("'My Data'!B2", sheetsData, 'Sheet 1'); 137 + expect(result).toBe(99); 138 + }); 139 + 140 + it('returns #REF! for quoted sheet that does not exist', () => { 141 + const sheetsData = { 'Sheet 1': {} }; 142 + const result = evalCrossSheet("'No Such Sheet'!A1", sheetsData, 'Sheet 1'); 143 + expect(result).toBe('#REF!'); 144 + }); 145 + 146 + it('handles cross-sheet ref in IF function', () => { 147 + const sheetsData = { 148 + 'Sheet 1': { A1: 100 }, 149 + 'Sheet2': { A1: 50 }, 150 + }; 151 + const result = evalCrossSheet('IF(A1>Sheet2!A1,"bigger","smaller")', sheetsData, 'Sheet 1'); 152 + expect(result).toBe('bigger'); 153 + }); 154 + 155 + it('handles multiple cross-sheet refs in one formula', () => { 156 + const sheetsData = { 157 + 'Sheet 1': {}, 158 + 'Sheet2': { A1: 10 }, 159 + 'Sheet3': { A1: 20 }, 160 + }; 161 + const result = evalCrossSheet('Sheet2!A1+Sheet3!A1', sheetsData, 'Sheet 1'); 162 + expect(result).toBe(30); 163 + }); 164 + 165 + it('handles cross-sheet ref with VLOOKUP', () => { 166 + const sheetsData = { 167 + 'Sheet 1': {}, 168 + 'Data': { A1: 'apple', B1: 1, A2: 'banana', B2: 2, A3: 'cherry', B3: 3 }, 169 + }; 170 + const result = evalCrossSheet('VLOOKUP("banana",Data!A1:B3,2,FALSE)', sheetsData, 'Sheet 1'); 171 + expect(result).toBe(2); 172 + }); 173 + }); 174 + 175 + describe('extractRefs with cross-sheet references', () => { 176 + it('extracts cross-sheet refs with sheet prefix', () => { 177 + const refs = extractRefs('Sheet2!A1+B1'); 178 + // Should include the cross-sheet ref as 'Sheet2!A1' and same-sheet 'B1' 179 + expect(refs.has('Sheet2!A1')).toBe(true); 180 + expect(refs.has('B1')).toBe(true); 181 + }); 182 + 183 + it('extracts cross-sheet range refs', () => { 184 + const refs = extractRefs('SUM(Sheet2!A1:A3)'); 185 + expect(refs.has('Sheet2!A1')).toBe(true); 186 + expect(refs.has('Sheet2!A2')).toBe(true); 187 + expect(refs.has('Sheet2!A3')).toBe(true); 188 + }); 189 + 190 + it('extracts quoted sheet name refs', () => { 191 + const refs = extractRefs("'My Sheet'!A1"); 192 + expect(refs.has('My Sheet!A1')).toBe(true); 193 + }); 194 + });
+205
tests/formula-tracer.test.js
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + tracePrecedents, 4 + traceDependents, 5 + buildDependencyGraph, 6 + } from '../src/sheets/formula-tracer.js'; 7 + 8 + /** 9 + * Formula tracer tests. 10 + * 11 + * The tracer visualizes cell dependencies: 12 + * - Precedents: cells that a formula references (inputs) 13 + * - Dependents: cells that reference a given cell (outputs) 14 + * 15 + * The core logic builds a dependency graph from cell formulas, 16 + * then traverses it for precedents/dependents of a given cell. 17 + */ 18 + 19 + // Helper: build cell data map where each entry has { v, f } (value and formula) 20 + function makeCellData(entries) { 21 + // entries: { 'A1': { v: 10, f: '' }, 'B1': { v: 20, f: 'A1*2' } } 22 + return entries; 23 + } 24 + 25 + describe('tracePrecedents', () => { 26 + it('returns empty set for a cell with no formula', () => { 27 + const cellData = makeCellData({ 28 + A1: { v: 10, f: '' }, 29 + }); 30 + const result = tracePrecedents('A1', cellData); 31 + expect(result).toEqual(new Set()); 32 + }); 33 + 34 + it('returns direct precedents of a simple formula', () => { 35 + const cellData = makeCellData({ 36 + A1: { v: 10, f: '' }, 37 + B1: { v: 20, f: '' }, 38 + C1: { v: 30, f: 'A1+B1' }, 39 + }); 40 + const result = tracePrecedents('C1', cellData); 41 + expect(result).toEqual(new Set(['A1', 'B1'])); 42 + }); 43 + 44 + it('returns range precedents', () => { 45 + const cellData = makeCellData({ 46 + A1: { v: 10, f: '' }, 47 + A2: { v: 20, f: '' }, 48 + A3: { v: 30, f: '' }, 49 + B1: { v: 60, f: 'SUM(A1:A3)' }, 50 + }); 51 + const result = tracePrecedents('B1', cellData); 52 + expect(result).toEqual(new Set(['A1', 'A2', 'A3'])); 53 + }); 54 + 55 + it('returns precedents for nested functions', () => { 56 + const cellData = makeCellData({ 57 + A1: { v: 10, f: '' }, 58 + A2: { v: 20, f: '' }, 59 + B1: { v: 15, f: 'AVERAGE(A1,A2)' }, 60 + C1: { v: 30, f: 'B1*2' }, 61 + }); 62 + // Direct precedents of C1 should be B1 only (not transitive) 63 + const result = tracePrecedents('C1', cellData); 64 + expect(result).toEqual(new Set(['B1'])); 65 + }); 66 + 67 + it('returns empty set for a cell not in the data', () => { 68 + const cellData = makeCellData({}); 69 + const result = tracePrecedents('Z99', cellData); 70 + expect(result).toEqual(new Set()); 71 + }); 72 + }); 73 + 74 + describe('traceDependents', () => { 75 + it('returns empty set for a cell that nothing depends on', () => { 76 + const cellData = makeCellData({ 77 + A1: { v: 10, f: '' }, 78 + B1: { v: 20, f: '' }, 79 + }); 80 + const result = traceDependents('A1', cellData); 81 + expect(result).toEqual(new Set()); 82 + }); 83 + 84 + it('returns cells that reference the given cell', () => { 85 + const cellData = makeCellData({ 86 + A1: { v: 10, f: '' }, 87 + B1: { v: 20, f: 'A1*2' }, 88 + C1: { v: 30, f: 'A1+5' }, 89 + }); 90 + const result = traceDependents('A1', cellData); 91 + expect(result).toEqual(new Set(['B1', 'C1'])); 92 + }); 93 + 94 + it('returns cells that reference via range', () => { 95 + const cellData = makeCellData({ 96 + A1: { v: 10, f: '' }, 97 + A2: { v: 20, f: '' }, 98 + A3: { v: 30, f: '' }, 99 + B1: { v: 60, f: 'SUM(A1:A3)' }, 100 + }); 101 + // A2 is referenced via range in B1 102 + const result = traceDependents('A2', cellData); 103 + expect(result).toEqual(new Set(['B1'])); 104 + }); 105 + 106 + it('returns multiple dependents', () => { 107 + const cellData = makeCellData({ 108 + A1: { v: 5, f: '' }, 109 + B1: { v: 10, f: 'A1*2' }, 110 + C1: { v: 15, f: 'A1*3' }, 111 + D1: { v: 25, f: 'A1+B1+C1' }, 112 + }); 113 + const result = traceDependents('A1', cellData); 114 + // B1, C1, and D1 all reference A1 115 + expect(result).toEqual(new Set(['B1', 'C1', 'D1'])); 116 + }); 117 + 118 + it('returns empty set for a cell not referenced by any formula', () => { 119 + const cellData = makeCellData({ 120 + A1: { v: 10, f: '' }, 121 + B1: { v: 20, f: 'C1+1' }, 122 + C1: { v: 5, f: '' }, 123 + }); 124 + const result = traceDependents('A1', cellData); 125 + expect(result).toEqual(new Set()); 126 + }); 127 + }); 128 + 129 + describe('buildDependencyGraph', () => { 130 + it('builds a graph with precedent edges', () => { 131 + const cellData = makeCellData({ 132 + A1: { v: 10, f: '' }, 133 + B1: { v: 20, f: '' }, 134 + C1: { v: 30, f: 'A1+B1' }, 135 + D1: { v: 50, f: 'C1+B1' }, 136 + }); 137 + const graph = buildDependencyGraph(cellData); 138 + 139 + // C1 depends on A1 and B1 140 + expect(graph.precedents.get('C1')).toEqual(new Set(['A1', 'B1'])); 141 + // D1 depends on C1 and B1 142 + expect(graph.precedents.get('D1')).toEqual(new Set(['C1', 'B1'])); 143 + 144 + // A1 is depended on by C1 145 + expect(graph.dependents.get('A1')).toContain('C1'); 146 + // B1 is depended on by C1 and D1 147 + expect(graph.dependents.get('B1')).toEqual(expect.objectContaining(new Set(['C1', 'D1']))); 148 + // C1 is depended on by D1 149 + expect(graph.dependents.get('C1')).toContain('D1'); 150 + }); 151 + 152 + it('builds empty graph for no formulas', () => { 153 + const cellData = makeCellData({ 154 + A1: { v: 10, f: '' }, 155 + B1: { v: 20, f: '' }, 156 + }); 157 + const graph = buildDependencyGraph(cellData); 158 + expect(graph.precedents.size).toBe(0); 159 + expect(graph.dependents.size).toBe(0); 160 + }); 161 + 162 + it('handles range references in graph', () => { 163 + const cellData = makeCellData({ 164 + A1: { v: 1, f: '' }, 165 + A2: { v: 2, f: '' }, 166 + A3: { v: 3, f: '' }, 167 + B1: { v: 6, f: 'SUM(A1:A3)' }, 168 + }); 169 + const graph = buildDependencyGraph(cellData); 170 + expect(graph.precedents.get('B1')).toEqual(new Set(['A1', 'A2', 'A3'])); 171 + expect(graph.dependents.get('A1')).toContain('B1'); 172 + expect(graph.dependents.get('A2')).toContain('B1'); 173 + expect(graph.dependents.get('A3')).toContain('B1'); 174 + }); 175 + }); 176 + 177 + describe('edge cases', () => { 178 + it('self-referencing cell shows itself as precedent', () => { 179 + // A cell with formula referencing itself (this would be a circular ref at eval time, 180 + // but the tracer just reports raw refs) 181 + const cellData = makeCellData({ 182 + A1: { v: '#ERROR!', f: 'A1+1' }, 183 + }); 184 + const prec = tracePrecedents('A1', cellData); 185 + expect(prec).toEqual(new Set(['A1'])); 186 + const deps = traceDependents('A1', cellData); 187 + expect(deps).toEqual(new Set(['A1'])); 188 + }); 189 + 190 + it('handles formula with no cell refs (constant formula)', () => { 191 + const cellData = makeCellData({ 192 + A1: { v: 42, f: '40+2' }, 193 + }); 194 + const result = tracePrecedents('A1', cellData); 195 + expect(result).toEqual(new Set()); 196 + }); 197 + 198 + it('handles formula with function calls but no refs', () => { 199 + const cellData = makeCellData({ 200 + A1: { v: 3.14, f: 'PI()' }, 201 + }); 202 + const result = tracePrecedents('A1', cellData); 203 + expect(result).toEqual(new Set()); 204 + }); 205 + });
+222
tests/named-ranges.test.js
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { evaluate, extractRefs } from '../src/sheets/formulas.js'; 3 + import { 4 + createNamedRange, 5 + deleteNamedRange, 6 + getNamedRange, 7 + listNamedRanges, 8 + validateRangeName, 9 + resolveNamedRange, 10 + } from '../src/sheets/named-ranges.js'; 11 + 12 + /** 13 + * Named ranges tests. 14 + * 15 + * Named ranges let users assign names to cell ranges for cleaner formulas. 16 + * Example: =SUM(Sales) instead of =SUM(A2:A100) 17 + * 18 + * Rules: 19 + * - Names are case-insensitive 20 + * - Names must start with a letter or underscore 21 + * - Names must not collide with cell references (e.g. "A1" is invalid) 22 + * - Names can contain letters, digits, underscores, and periods 23 + */ 24 + 25 + // Mock Yjs-like store for named ranges 26 + function createMockStore() { 27 + const data = new Map(); 28 + return { 29 + get(key) { return data.get(key); }, 30 + set(key, val) { data.set(key, val); }, 31 + delete(key) { data.delete(key); }, 32 + has(key) { return data.has(key); }, 33 + forEach(fn) { data.forEach(fn); }, 34 + keys() { return [...data.keys()]; }, 35 + toJSON() { return Object.fromEntries(data); }, 36 + }; 37 + } 38 + 39 + describe('validateRangeName', () => { 40 + it('accepts valid names starting with a letter', () => { 41 + expect(validateRangeName('Sales')).toEqual({ valid: true }); 42 + expect(validateRangeName('total_revenue')).toEqual({ valid: true }); 43 + expect(validateRangeName('Q1Data')).toEqual({ valid: true }); 44 + }); 45 + 46 + it('accepts names starting with underscore', () => { 47 + expect(validateRangeName('_temp')).toEqual({ valid: true }); 48 + expect(validateRangeName('_123')).toEqual({ valid: true }); 49 + }); 50 + 51 + it('rejects names starting with a digit', () => { 52 + const result = validateRangeName('1stQuarter'); 53 + expect(result.valid).toBe(false); 54 + expect(result.reason).toBeDefined(); 55 + }); 56 + 57 + it('rejects names that look like cell references', () => { 58 + const result = validateRangeName('A1'); 59 + expect(result.valid).toBe(false); 60 + 61 + const result2 = validateRangeName('AB123'); 62 + expect(result2.valid).toBe(false); 63 + 64 + const result3 = validateRangeName('Z99'); 65 + expect(result3.valid).toBe(false); 66 + }); 67 + 68 + it('rejects empty or whitespace-only names', () => { 69 + expect(validateRangeName('')).toEqual({ valid: false, reason: expect.any(String) }); 70 + expect(validateRangeName(' ')).toEqual({ valid: false, reason: expect.any(String) }); 71 + }); 72 + 73 + it('rejects names with spaces', () => { 74 + const result = validateRangeName('my range'); 75 + expect(result.valid).toBe(false); 76 + }); 77 + 78 + it('rejects names with special characters', () => { 79 + expect(validateRangeName('foo@bar').valid).toBe(false); 80 + expect(validateRangeName('a+b').valid).toBe(false); 81 + expect(validateRangeName('x!y').valid).toBe(false); 82 + }); 83 + 84 + it('accepts names with periods', () => { 85 + expect(validateRangeName('sales.q1')).toEqual({ valid: true }); 86 + }); 87 + 88 + it('rejects reserved words TRUE/FALSE', () => { 89 + expect(validateRangeName('TRUE').valid).toBe(false); 90 + expect(validateRangeName('FALSE').valid).toBe(false); 91 + expect(validateRangeName('true').valid).toBe(false); 92 + expect(validateRangeName('false').valid).toBe(false); 93 + }); 94 + }); 95 + 96 + describe('createNamedRange / getNamedRange / deleteNamedRange', () => { 97 + it('creates and retrieves a named range', () => { 98 + const store = createMockStore(); 99 + createNamedRange(store, 'Sales', 'A2:A100', 'Sheet 1'); 100 + const range = getNamedRange(store, 'Sales'); 101 + expect(range).toEqual({ name: 'Sales', range: 'A2:A100', sheet: 'Sheet 1' }); 102 + }); 103 + 104 + it('is case-insensitive on retrieval', () => { 105 + const store = createMockStore(); 106 + createNamedRange(store, 'Sales', 'A2:A100', 'Sheet 1'); 107 + expect(getNamedRange(store, 'sales')).toBeTruthy(); 108 + expect(getNamedRange(store, 'SALES')).toBeTruthy(); 109 + expect(getNamedRange(store, 'sAlEs')).toBeTruthy(); 110 + }); 111 + 112 + it('overwrites existing range with same name', () => { 113 + const store = createMockStore(); 114 + createNamedRange(store, 'Sales', 'A2:A100', 'Sheet 1'); 115 + createNamedRange(store, 'Sales', 'B2:B50', 'Sheet 2'); 116 + const range = getNamedRange(store, 'Sales'); 117 + expect(range.range).toBe('B2:B50'); 118 + expect(range.sheet).toBe('Sheet 2'); 119 + }); 120 + 121 + it('deletes a named range', () => { 122 + const store = createMockStore(); 123 + createNamedRange(store, 'Sales', 'A2:A100', 'Sheet 1'); 124 + deleteNamedRange(store, 'Sales'); 125 + expect(getNamedRange(store, 'Sales')).toBeNull(); 126 + }); 127 + 128 + it('delete is case-insensitive', () => { 129 + const store = createMockStore(); 130 + createNamedRange(store, 'Revenue', 'C1:C50', 'Sheet 1'); 131 + deleteNamedRange(store, 'revenue'); 132 + expect(getNamedRange(store, 'Revenue')).toBeNull(); 133 + }); 134 + 135 + it('throws or returns error for invalid name on create', () => { 136 + const store = createMockStore(); 137 + expect(() => createNamedRange(store, 'A1', 'B2:B10', 'Sheet 1')).toThrow(); 138 + expect(() => createNamedRange(store, '1bad', 'B2:B10', 'Sheet 1')).toThrow(); 139 + expect(() => createNamedRange(store, '', 'B2:B10', 'Sheet 1')).toThrow(); 140 + }); 141 + }); 142 + 143 + describe('listNamedRanges', () => { 144 + it('returns all named ranges', () => { 145 + const store = createMockStore(); 146 + createNamedRange(store, 'Sales', 'A1:A10', 'Sheet 1'); 147 + createNamedRange(store, 'Costs', 'B1:B10', 'Sheet 1'); 148 + createNamedRange(store, 'Profit', 'C1:C10', 'Sheet 2'); 149 + const list = listNamedRanges(store); 150 + expect(list).toHaveLength(3); 151 + expect(list.map(r => r.name).sort()).toEqual(['Costs', 'Profit', 'Sales']); 152 + }); 153 + 154 + it('returns empty array when no ranges defined', () => { 155 + const store = createMockStore(); 156 + expect(listNamedRanges(store)).toEqual([]); 157 + }); 158 + }); 159 + 160 + describe('resolveNamedRange', () => { 161 + it('resolves a name to its range string', () => { 162 + const store = createMockStore(); 163 + createNamedRange(store, 'Sales', 'A1:A10', 'Sheet 1'); 164 + const resolved = resolveNamedRange(store, 'Sales'); 165 + expect(resolved).toEqual({ range: 'A1:A10', sheet: 'Sheet 1' }); 166 + }); 167 + 168 + it('is case-insensitive', () => { 169 + const store = createMockStore(); 170 + createNamedRange(store, 'Revenue', 'B1:B50', 'Sheet 1'); 171 + expect(resolveNamedRange(store, 'revenue')).toBeTruthy(); 172 + expect(resolveNamedRange(store, 'REVENUE')).toBeTruthy(); 173 + }); 174 + 175 + it('returns null for undefined name', () => { 176 + const store = createMockStore(); 177 + expect(resolveNamedRange(store, 'Nonexistent')).toBeNull(); 178 + }); 179 + }); 180 + 181 + describe('named ranges in formula evaluation', () => { 182 + it('resolves named range in SUM', () => { 183 + const cells = { A1: 10, A2: 20, A3: 30 }; 184 + const namedRanges = { sales: { range: 'A1:A3', sheet: 'Sheet 1' } }; 185 + const getCellValue = (ref) => cells[ref] ?? ''; 186 + const result = evaluate('SUM(Sales)', getCellValue, null, namedRanges); 187 + expect(result).toBe(60); 188 + }); 189 + 190 + it('resolves named range in AVERAGE', () => { 191 + const cells = { B1: 10, B2: 20, B3: 30 }; 192 + const namedRanges = { data: { range: 'B1:B3', sheet: 'Sheet 1' } }; 193 + const getCellValue = (ref) => cells[ref] ?? ''; 194 + const result = evaluate('AVERAGE(Data)', getCellValue, null, namedRanges); 195 + expect(result).toBe(20); 196 + }); 197 + 198 + it('resolves named range in arithmetic', () => { 199 + // A single-cell named range used in arithmetic 200 + const cells = { A1: 42 }; 201 + const namedRanges = { total: { range: 'A1:A1', sheet: 'Sheet 1' } }; 202 + const getCellValue = (ref) => cells[ref] ?? ''; 203 + const result = evaluate('SUM(Total)+10', getCellValue, null, namedRanges); 204 + expect(result).toBe(52); 205 + }); 206 + 207 + it('returns #NAME? for undefined named range', () => { 208 + const getCellValue = (ref) => ''; 209 + // Direct use of undefined name (not wrapped in SUM which would coerce to 0) 210 + const result = evaluate('Undefined', getCellValue, null, {}); 211 + expect(String(result)).toContain('#NAME?'); 212 + }); 213 + 214 + it('handles named range case insensitivity in formulas', () => { 215 + const cells = { A1: 5, A2: 10 }; 216 + const namedRanges = { myrange: { range: 'A1:A2', sheet: 'Sheet 1' } }; 217 + const getCellValue = (ref) => cells[ref] ?? ''; 218 + expect(evaluate('SUM(MYRANGE)', getCellValue, null, namedRanges)).toBe(15); 219 + expect(evaluate('SUM(myrange)', getCellValue, null, namedRanges)).toBe(15); 220 + expect(evaluate('SUM(MyRange)', getCellValue, null, namedRanges)).toBe(15); 221 + }); 222 + });