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

Configure Feed

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

at main 395 lines 13 kB view raw
1import { describe, it, expect } from 'vitest'; 2import { tokenize } from '../src/sheets/formula-tokenizer.js'; 3import { Parser } from '../src/sheets/formula-parser.js'; 4 5/** Helper: parse a formula with optional cell values and return the result. */ 6function parse( 7 formula: string, 8 cells: Record<string, unknown> = {}, 9 opts: { crossSheet?: Record<string, Record<string, unknown>>; namedRanges?: Record<string, { range: string }> } = {}, 10): unknown { 11 const tokens = tokenize(formula); 12 const getCellValue = (ref: string) => (cells[ref] ?? '') as any; 13 const crossSheetResolver = opts.crossSheet 14 ? { 15 sheetExists: (name: string) => name in opts.crossSheet!, 16 getSheetCellValue: (name: string, ref: string) => opts.crossSheet![name]?.[ref] ?? '', 17 } 18 : null; 19 const callFunction = (name: string, _args: unknown[]) => `#NAME? (${name})`; 20 const parser = new Parser(tokens, getCellValue, crossSheetResolver, opts.namedRanges ?? null, callFunction); 21 return parser.parse(); 22} 23 24// ============================================================ 25// Arithmetic 26// ============================================================ 27 28describe('Parser — arithmetic', () => { 29 it('evaluates addition', () => { 30 expect(parse('1+2')).toBe(3); 31 }); 32 33 it('evaluates subtraction', () => { 34 expect(parse('10-3')).toBe(7); 35 }); 36 37 it('evaluates multiplication', () => { 38 expect(parse('4*5')).toBe(20); 39 }); 40 41 it('evaluates division', () => { 42 expect(parse('10/4')).toBe(2.5); 43 }); 44 45 it('returns #DIV/0! for division by zero', () => { 46 expect(parse('1/0')).toBe('#DIV/0!'); 47 }); 48 49 it('evaluates exponentiation', () => { 50 expect(parse('2^10')).toBe(1024); 51 }); 52 53 it('respects operator precedence', () => { 54 expect(parse('2+3*4')).toBe(14); 55 expect(parse('2*3+4')).toBe(10); 56 }); 57 58 it('respects parentheses', () => { 59 expect(parse('(2+3)*4')).toBe(20); 60 }); 61 62 it('handles unary minus', () => { 63 expect(parse('-5')).toBe(-5); 64 expect(parse('-2+3')).toBe(1); 65 }); 66 67 it('handles unary plus', () => { 68 expect(parse('+5')).toBe(5); 69 }); 70 71 it('right-associates exponentiation', () => { 72 // 2^3^2 should be 2^(3^2) = 2^9 = 512 73 expect(parse('2^3^2')).toBe(512); 74 }); 75}); 76 77// ============================================================ 78// String concatenation 79// ============================================================ 80 81describe('Parser — string concatenation', () => { 82 it('concatenates with &', () => { 83 expect(parse('"hello"&" "&"world"')).toBe('hello world'); 84 }); 85 86 it('coerces numbers to strings in concatenation', () => { 87 expect(parse('"value: "&42')).toBe('value: 42'); 88 }); 89}); 90 91// ============================================================ 92// Comparisons 93// ============================================================ 94 95describe('Parser — comparisons', () => { 96 it('evaluates = (equal)', () => { 97 expect(parse('1=1')).toBe(true); 98 expect(parse('1=2')).toBe(false); 99 }); 100 101 it('evaluates <> (not equal)', () => { 102 expect(parse('1<>2')).toBe(true); 103 expect(parse('1<>1')).toBe(false); 104 }); 105 106 it('evaluates < and >', () => { 107 expect(parse('1<2')).toBe(true); 108 expect(parse('2>1')).toBe(true); 109 expect(parse('2<1')).toBe(false); 110 }); 111 112 it('evaluates <= and >=', () => { 113 expect(parse('1<=1')).toBe(true); 114 expect(parse('1>=1')).toBe(true); 115 expect(parse('2<=1')).toBe(false); 116 }); 117 118 it('compares strings case-insensitively', () => { 119 expect(parse('"abc"="ABC"')).toBe(true); 120 }); 121}); 122 123// ============================================================ 124// Literal types 125// ============================================================ 126 127describe('Parser — literals', () => { 128 it('returns numbers', () => { 129 expect(parse('42')).toBe(42); 130 expect(parse('3.14')).toBe(3.14); 131 }); 132 133 it('returns strings', () => { 134 expect(parse('"hello"')).toBe('hello'); 135 }); 136 137 it('returns booleans', () => { 138 expect(parse('TRUE')).toBe(true); 139 expect(parse('FALSE')).toBe(false); 140 }); 141}); 142 143// ============================================================ 144// Cell references 145// ============================================================ 146 147describe('Parser — cell references', () => { 148 it('resolves a cell value', () => { 149 expect(parse('A1', { A1: 42 })).toBe(42); 150 }); 151 152 it('returns empty string for missing cell', () => { 153 expect(parse('Z99')).toBe(''); 154 }); 155 156 it('resolves cell in arithmetic', () => { 157 expect(parse('A1+B1', { A1: 10, B1: 20 })).toBe(30); 158 }); 159}); 160 161// ============================================================ 162// Ranges 163// ============================================================ 164 165describe('Parser — ranges', () => { 166 it('resolves a range A1:A3', () => { 167 const result = parse('A1:A3', { A1: 1, A2: 2, A3: 3 }); 168 expect(Array.isArray(result)).toBe(true); 169 expect(result).toEqual(expect.arrayContaining([1, 2, 3])); 170 }); 171 172 it('sets _rangeRows and _rangeCols on range result', () => { 173 const result = parse('A1:B2', { A1: 1, B1: 2, A2: 3, B2: 4 }) as any; 174 expect(result._rangeRows).toBe(2); 175 expect(result._rangeCols).toBe(2); 176 }); 177 178 it('returns #REF! for invalid range refs', () => { 179 // Force an invalid ref by using tokens directly 180 const tokens = tokenize('A1:A3'); 181 const getCellValue = () => '' as any; 182 // Sabotage: override parseRef behavior is hard, but we can test the error path 183 // by checking that a valid range works (covered above) 184 expect(Array.isArray(parse('A1:A3', { A1: 1 }))).toBe(true); 185 }); 186 187 it('rejects ranges exceeding 10000 cells', () => { 188 // A1:CV100 = 100 cols * 100 rows = 10000, should be ok 189 // A1:CW101 would exceed — but we can test a simpler large range 190 const result = parse('A1:CX101', {}); 191 // 102 cols * 101 rows = 10302 > 10000 192 expect(result).toEqual(expect.arrayContaining(['#VALUE!'])); 193 }); 194}); 195 196// ============================================================ 197// Cross-sheet references 198// ============================================================ 199 200describe('Parser — cross-sheet references', () => { 201 const crossSheet = { 202 Sheet2: { A1: 100, B1: 200 }, 203 }; 204 205 it('resolves cross-sheet cell', () => { 206 expect(parse("Sheet2!A1", {}, { crossSheet })).toBe(100); 207 }); 208 209 it('resolves quoted cross-sheet cell', () => { 210 const cs = { 'My Sheet': { C3: 42 } }; 211 expect(parse("'My Sheet'!C3", {}, { crossSheet: cs })).toBe(42); 212 }); 213 214 it('returns #REF! for nonexistent sheet', () => { 215 expect(parse("NoSheet!A1", {}, { crossSheet })).toBe('#REF!'); 216 }); 217 218 it('returns #REF! when no cross-sheet resolver', () => { 219 expect(parse("Sheet2!A1")).toBe('#REF!'); 220 }); 221 222 it('resolves cross-sheet range', () => { 223 const result = parse("Sheet2!A1:B1", {}, { crossSheet }); 224 expect(Array.isArray(result)).toBe(true); 225 expect(result).toEqual(expect.arrayContaining([100, 200])); 226 }); 227}); 228 229// ============================================================ 230// Named ranges 231// ============================================================ 232 233describe('Parser — named ranges', () => { 234 it('resolves a named range to a single cell', () => { 235 const namedRanges = { myval: { range: 'A1' } }; 236 expect(parse('myval', { A1: 99 }, { namedRanges })).toBe(99); 237 }); 238 239 it('resolves a named range to a cell range', () => { 240 const namedRanges = { data: { range: 'A1:A3' } }; 241 const result = parse('data', { A1: 1, A2: 2, A3: 3 }, { namedRanges }) as any[]; 242 expect(Array.isArray(result)).toBe(true); 243 expect(result).toEqual(expect.arrayContaining([1, 2, 3])); 244 }); 245 246 it('returns #NAME? for unknown identifier', () => { 247 const result = parse('UnknownName') as string; 248 expect(result).toContain('#NAME?'); 249 }); 250}); 251 252// ============================================================ 253// Function calls (via injected callFunction) 254// ============================================================ 255 256describe('Parser — function dispatch', () => { 257 it('calls the injected callFunction', () => { 258 const tokens = tokenize('FOO(1,2)'); 259 const calls: Array<{ name: string; args: unknown[] }> = []; 260 const callFunction = (name: string, args: unknown[]) => { 261 calls.push({ name, args }); 262 return 'result'; 263 }; 264 const parser = new Parser(tokens, () => '' as any, null, null, callFunction); 265 const result = parser.parse(); 266 expect(result).toBe('result'); 267 expect(calls).toHaveLength(1); 268 expect(calls[0].name).toBe('FOO'); 269 expect(calls[0].args).toEqual([1, 2]); 270 }); 271 272 it('handles empty argument list', () => { 273 const tokens = tokenize('NOW()'); 274 const calls: Array<{ name: string; args: unknown[] }> = []; 275 const callFunction = (name: string, args: unknown[]) => { 276 calls.push({ name, args }); 277 return 0; 278 }; 279 const parser = new Parser(tokens, () => '' as any, null, null, callFunction); 280 parser.parse(); 281 expect(calls[0].args).toEqual([]); 282 }); 283 284 it('handles skipped arguments (commas)', () => { 285 const tokens = tokenize('FN(1,,3)'); 286 const calls: Array<{ name: string; args: unknown[] }> = []; 287 const callFunction = (name: string, args: unknown[]) => { 288 calls.push({ name, args }); 289 return 0; 290 }; 291 const parser = new Parser(tokens, () => '' as any, null, null, callFunction); 292 parser.parse(); 293 expect(calls[0].args).toEqual([1, undefined, 3]); 294 }); 295}); 296 297// ============================================================ 298// INDIRECT 299// ============================================================ 300 301describe('Parser — INDIRECT', () => { 302 it('resolves INDIRECT to a cell value', () => { 303 expect(parse('INDIRECT("A1")', { A1: 42 })).toBe(42); 304 }); 305 306 it('handles absolute refs in INDIRECT', () => { 307 expect(parse('INDIRECT("$B$3")', { B3: 7 })).toBe(7); 308 }); 309 310 it('returns #REF! for empty INDIRECT', () => { 311 expect(parse('INDIRECT("")')).toBe('#REF!'); 312 }); 313 314 it('resolves cross-sheet INDIRECT', () => { 315 const crossSheet = { Sales: { D1: 500 } }; 316 expect(parse('INDIRECT("Sales!D1")', {}, { crossSheet })).toBe(500); 317 }); 318 319 it('resolves quoted cross-sheet INDIRECT', () => { 320 const crossSheet = { 'Q1 Data': { A1: 99 } }; 321 expect(parse("INDIRECT(\"'Q1 Data'!A1\")", {}, { crossSheet })).toBe(99); 322 }); 323}); 324 325// ============================================================ 326// ROW / COLUMN 327// ============================================================ 328 329describe('Parser — ROW and COLUMN', () => { 330 it('returns row number of a cell ref', () => { 331 expect(parse('ROW(B5)')).toBe(5); 332 }); 333 334 it('returns column number of a cell ref', () => { 335 expect(parse('COLUMN(C1)')).toBe(3); 336 }); 337 338 it('returns #REF! for invalid ref', () => { 339 expect(parse('ROW(INDIRECT("???"))')).toBe('#REF!'); 340 }); 341}); 342 343// ============================================================ 344// LET 345// ============================================================ 346 347describe('Parser — LET', () => { 348 it('binds a variable and uses it in expression', () => { 349 expect(parse('LET(x, 10, x+5)')).toBe(15); 350 }); 351 352 it('binds multiple variables', () => { 353 expect(parse('LET(x, 3, y, 4, x*y)')).toBe(12); 354 }); 355 356 it('allows variable names that look like cell refs in declaration but resolves body refs as cells', () => { 357 // LET accepts cell-ref tokens as variable names, but in the body 358 // A1 is tokenized as CELL_REF and resolved via getCellValue, not the LET scope. 359 // So LET(A1, 99, A1+1) → getCellValue("A1") + 1 = 0 + 1 = 1 360 expect(parse('LET(A1, 99, A1+1)')).toBe(1); 361 }); 362}); 363 364// ============================================================ 365// LAMBDA 366// ============================================================ 367 368describe('Parser — LAMBDA', () => { 369 it('creates and immediately invokes a lambda', () => { 370 expect(parse('LAMBDA(x, x*2)(5)')).toBe(10); 371 }); 372 373 it('handles multi-param lambda', () => { 374 expect(parse('LAMBDA(a, b, a+b)(3, 7)')).toBe(10); 375 }); 376 377 it('returns #VALUE! for un-invoked lambda', () => { 378 // LAMBDA without trailing () returns #VALUE! 379 // Actually: the parser checks if next token is LPAREN 380 // If not, returns #VALUE! 381 expect(parse('LAMBDA(x, x)')).toBe('#VALUE!'); 382 }); 383}); 384 385// ============================================================ 386// Error handling 387// ============================================================ 388 389describe('Parser — error handling', () => { 390 it('throws on unexpected token', () => { 391 const tokens = tokenize(','); 392 const parser = new Parser(tokens, () => '' as any, null, null, () => 0); 393 expect(() => parser.parse()).toThrow(); 394 }); 395});