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 'test: recalc spill semantics + isVolatile edge cases' (#251) from test/batch10-recalc-spill-volatile into main

scott 39275bea ecd4f117

+341
+341
tests/recalc-spill.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { RecalcEngine, isVolatile } from '../src/sheets/recalc.js'; 3 + import type { CellValue } from '../src/sheets/types.js'; 4 + 5 + /** 6 + * Recalc engine tests — spill semantics, isVolatile edge cases. 7 + * 8 + * The spill system handles array formula results by spreading values 9 + * into adjacent cells below/right of the source formula cell. 10 + * Uses SEQUENCE() to produce real RangeArrays from the formula engine. 11 + */ 12 + 13 + // --- Helpers --- 14 + 15 + function makeCellStore(data: Record<string, { v: CellValue | ''; f: string }>) { 16 + const store = new Map<string, { v: CellValue | ''; f: string }>(); 17 + for (const [id, cell] of Object.entries(data)) { 18 + store.set(id, { ...cell }); 19 + } 20 + return { 21 + get(id: string) { return store.get(id) || null; }, 22 + set(id: string, cell: { v: CellValue | ''; f: string }) { store.set(id, { ...cell }); }, 23 + has(id: string) { return store.has(id); }, 24 + entries() { return store.entries(); }, 25 + getAllFormulaCells() { 26 + const result: [string, { v: CellValue | ''; f: string }][] = []; 27 + for (const [id, cell] of store.entries()) { 28 + if (cell.f) result.push([id, cell]); 29 + } 30 + return result; 31 + }, 32 + }; 33 + } 34 + 35 + // ===================================================================== 36 + // SPILL SEMANTICS (using SEQUENCE() for real array results) 37 + // ===================================================================== 38 + 39 + describe('RecalcEngine — spill semantics', () => { 40 + it('spills a vertical array into cells below', () => { 41 + // SEQUENCE(3) returns [1, 2, 3] as a 3-row, 1-col array 42 + const store = makeCellStore({ 43 + A1: { v: '', f: 'SEQUENCE(3)' }, 44 + }); 45 + 46 + const engine = new RecalcEngine(store); 47 + engine.buildFullGraph(); 48 + const changed = engine.recalculate('A1'); 49 + 50 + expect(store.get('A1')?.v).toBe(1); 51 + expect(store.get('A2')?.v).toBe(2); 52 + expect(store.get('A3')?.v).toBe(3); 53 + expect(changed.has('A1')).toBe(true); 54 + expect(changed.has('A2')).toBe(true); 55 + expect(changed.has('A3')).toBe(true); 56 + }); 57 + 58 + it('spills a horizontal array into cells to the right', () => { 59 + // SEQUENCE(1,3) returns [1, 2, 3] as a 1-row, 3-col array 60 + const store = makeCellStore({ 61 + A1: { v: '', f: 'SEQUENCE(1,3)' }, 62 + }); 63 + 64 + const engine = new RecalcEngine(store); 65 + engine.buildFullGraph(); 66 + engine.recalculate('A1'); 67 + 68 + expect(store.get('A1')?.v).toBe(1); 69 + expect(store.get('B1')?.v).toBe(2); 70 + expect(store.get('C1')?.v).toBe(3); 71 + }); 72 + 73 + it('spills a 2D array (2 rows x 3 cols)', () => { 74 + // SEQUENCE(2,3) returns [1,2,3,4,5,6] as 2-row, 3-col 75 + // B2=1 C2=2 D2=3 76 + // B3=4 C3=5 D3=6 77 + const store = makeCellStore({ 78 + B2: { v: '', f: 'SEQUENCE(2,3)' }, 79 + }); 80 + 81 + const engine = new RecalcEngine(store); 82 + engine.buildFullGraph(); 83 + engine.recalculate('B2'); 84 + 85 + expect(store.get('B2')?.v).toBe(1); 86 + expect(store.get('C2')?.v).toBe(2); 87 + expect(store.get('D2')?.v).toBe(3); 88 + expect(store.get('B3')?.v).toBe(4); 89 + expect(store.get('C3')?.v).toBe(5); 90 + expect(store.get('D3')?.v).toBe(6); 91 + }); 92 + 93 + it('spills with custom start and step', () => { 94 + // SEQUENCE(3,1,10,10) returns [10, 20, 30] 95 + const store = makeCellStore({ 96 + A1: { v: '', f: 'SEQUENCE(3,1,10,10)' }, 97 + }); 98 + 99 + const engine = new RecalcEngine(store); 100 + engine.buildFullGraph(); 101 + engine.recalculate('A1'); 102 + 103 + expect(store.get('A1')?.v).toBe(10); 104 + expect(store.get('A2')?.v).toBe(20); 105 + expect(store.get('A3')?.v).toBe(30); 106 + }); 107 + 108 + it('getSpillSource returns source cell for spill targets', () => { 109 + const store = makeCellStore({ 110 + A1: { v: '', f: 'SEQUENCE(3)' }, 111 + }); 112 + 113 + const engine = new RecalcEngine(store); 114 + engine.buildFullGraph(); 115 + engine.recalculate('A1'); 116 + 117 + expect(engine.getSpillSource('A2')).toBe('A1'); 118 + expect(engine.getSpillSource('A3')).toBe('A1'); 119 + }); 120 + 121 + it('getSpillSource returns null for non-spill cells', () => { 122 + const store = makeCellStore({ 123 + A1: { v: 10, f: '' }, 124 + B1: { v: '', f: 'A1*2' }, 125 + }); 126 + 127 + const engine = new RecalcEngine(store); 128 + engine.buildFullGraph(); 129 + engine.recalculate('A1'); 130 + 131 + expect(engine.getSpillSource('A1')).toBeNull(); 132 + expect(engine.getSpillSource('B1')).toBeNull(); 133 + expect(engine.getSpillSource('Z99')).toBeNull(); 134 + }); 135 + 136 + it('getSpillRange returns target cells for source cell', () => { 137 + const store = makeCellStore({ 138 + A1: { v: '', f: 'SEQUENCE(3)' }, 139 + }); 140 + 141 + const engine = new RecalcEngine(store); 142 + engine.buildFullGraph(); 143 + engine.recalculate('A1'); 144 + 145 + const targets = engine.getSpillRange('A1'); 146 + expect(targets).toContain('A2'); 147 + expect(targets).toContain('A3'); 148 + expect(targets).toHaveLength(2); // excludes source cell itself 149 + }); 150 + 151 + it('getSpillRange returns empty array for non-spill cells', () => { 152 + const store = makeCellStore({ 153 + A1: { v: 10, f: '' }, 154 + }); 155 + 156 + const engine = new RecalcEngine(store); 157 + engine.buildFullGraph(); 158 + 159 + expect(engine.getSpillRange('A1')).toEqual([]); 160 + expect(engine.getSpillRange('Z99')).toEqual([]); 161 + }); 162 + 163 + it('sets #SPILL! when target cell is occupied by a value', () => { 164 + const store = makeCellStore({ 165 + A1: { v: '', f: 'SEQUENCE(3)' }, 166 + A2: { v: 'occupied', f: '' }, // blocks spill 167 + }); 168 + 169 + const engine = new RecalcEngine(store); 170 + engine.buildFullGraph(); 171 + engine.recalculate('A1'); 172 + 173 + expect(store.get('A1')?.v).toBe('#SPILL!'); 174 + expect(store.get('A2')?.v).toBe('occupied'); 175 + }); 176 + 177 + it('sets #SPILL! when target cell has a formula', () => { 178 + const store = makeCellStore({ 179 + A1: { v: '', f: 'SEQUENCE(3)' }, 180 + A2: { v: '', f: 'B1+1' }, // formula blocks spill 181 + B1: { v: 5, f: '' }, 182 + }); 183 + 184 + const engine = new RecalcEngine(store); 185 + engine.buildFullGraph(); 186 + engine.recalculate('A1'); 187 + 188 + expect(store.get('A1')?.v).toBe('#SPILL!'); 189 + }); 190 + 191 + it('clears spill range when formula changes to scalar', () => { 192 + const store = makeCellStore({ 193 + A1: { v: '', f: 'SEQUENCE(3)' }, 194 + }); 195 + 196 + const engine = new RecalcEngine(store); 197 + engine.buildFullGraph(); 198 + engine.recalculate('A1'); 199 + 200 + // Verify spill happened 201 + expect(store.get('A2')?.v).toBe(2); 202 + expect(store.get('A3')?.v).toBe(3); 203 + expect(engine.getSpillRange('A1')).toHaveLength(2); 204 + 205 + // Change formula to scalar 206 + store.set('A1', { v: '', f: '42' }); 207 + engine.updateCell('A1'); 208 + engine.recalculate('A1'); 209 + 210 + expect(store.get('A1')?.v).toBe(42); 211 + expect(store.get('A2')?.v).toBe(''); 212 + expect(store.get('A3')?.v).toBe(''); 213 + expect(engine.getSpillRange('A1')).toEqual([]); 214 + expect(engine.getSpillSource('A2')).toBeNull(); 215 + }); 216 + 217 + it('updates spill range when array shrinks', () => { 218 + const store = makeCellStore({ 219 + A1: { v: '', f: 'SEQUENCE(4)' }, 220 + }); 221 + 222 + const engine = new RecalcEngine(store); 223 + engine.buildFullGraph(); 224 + engine.recalculate('A1'); 225 + 226 + expect(store.get('A4')?.v).toBe(4); 227 + expect(engine.getSpillRange('A1')).toHaveLength(3); // A2, A3, A4 228 + 229 + // Shrink to 2 rows 230 + store.set('A1', { v: '', f: 'SEQUENCE(2)' }); 231 + engine.updateCell('A1'); 232 + engine.recalculate('A1'); 233 + 234 + expect(store.get('A1')?.v).toBe(1); 235 + expect(store.get('A2')?.v).toBe(2); 236 + // Old spill targets should be cleared 237 + expect(store.get('A3')?.v).toBe(''); 238 + expect(store.get('A4')?.v).toBe(''); 239 + expect(engine.getSpillRange('A1')).toHaveLength(1); 240 + }); 241 + 242 + it('handles single-element array (no spill)', () => { 243 + const store = makeCellStore({ 244 + A1: { v: '', f: 'SEQUENCE(1,1)' }, 245 + }); 246 + 247 + const engine = new RecalcEngine(store); 248 + engine.buildFullGraph(); 249 + engine.recalculate('A1'); 250 + 251 + expect(store.get('A1')?.v).toBe(1); 252 + expect(engine.getSpillRange('A1')).toEqual([]); 253 + }); 254 + 255 + it('SORT produces a spilling array', () => { 256 + const store = makeCellStore({ 257 + A1: { v: 30, f: '' }, 258 + A2: { v: 10, f: '' }, 259 + A3: { v: 20, f: '' }, 260 + C1: { v: '', f: 'SORT(A1:A3)' }, 261 + }); 262 + 263 + const engine = new RecalcEngine(store); 264 + engine.buildFullGraph(); 265 + engine.recalculate('C1'); 266 + 267 + expect(store.get('C1')?.v).toBe(10); 268 + expect(store.get('C2')?.v).toBe(20); 269 + expect(store.get('C3')?.v).toBe(30); 270 + }); 271 + 272 + it('UNIQUE produces a spilling array', () => { 273 + const store = makeCellStore({ 274 + A1: { v: 'apple', f: '' }, 275 + A2: { v: 'banana', f: '' }, 276 + A3: { v: 'apple', f: '' }, 277 + A4: { v: 'cherry', f: '' }, 278 + C1: { v: '', f: 'UNIQUE(A1:A4)' }, 279 + }); 280 + 281 + const engine = new RecalcEngine(store); 282 + engine.buildFullGraph(); 283 + engine.recalculate('C1'); 284 + 285 + expect(store.get('C1')?.v).toBe('apple'); 286 + expect(store.get('C2')?.v).toBe('banana'); 287 + expect(store.get('C3')?.v).toBe('cherry'); 288 + expect(engine.getSpillRange('C1')).toHaveLength(2); 289 + }); 290 + }); 291 + 292 + // ===================================================================== 293 + // isVolatile EDGE CASES 294 + // ===================================================================== 295 + 296 + describe('isVolatile — edge cases', () => { 297 + it('detects all volatile functions', () => { 298 + expect(isVolatile('NOW()')).toBe(true); 299 + expect(isVolatile('TODAY()')).toBe(true); 300 + expect(isVolatile('RAND()')).toBe(true); 301 + expect(isVolatile('RANDBETWEEN(1,10)')).toBe(true); 302 + }); 303 + 304 + it('is case-insensitive', () => { 305 + expect(isVolatile('now()')).toBe(true); 306 + expect(isVolatile('Today()')).toBe(true); 307 + expect(isVolatile('rand()')).toBe(true); 308 + }); 309 + 310 + it('detects volatile function with space before paren', () => { 311 + expect(isVolatile('NOW ()')).toBe(true); 312 + }); 313 + 314 + it('returns false for non-volatile functions', () => { 315 + expect(isVolatile('SUM(A1:A10)')).toBe(false); 316 + expect(isVolatile('IF(A1>0,1,0)')).toBe(false); 317 + expect(isVolatile('VLOOKUP("x",A1:B5,2,FALSE)')).toBe(false); 318 + }); 319 + 320 + it('returns false for empty formula', () => { 321 + expect(isVolatile('')).toBe(false); 322 + }); 323 + 324 + it('detects volatile in complex formula', () => { 325 + expect(isVolatile('A1+NOW()+B1')).toBe(true); 326 + expect(isVolatile('IF(RAND()>0.5,"yes","no")')).toBe(true); 327 + }); 328 + 329 + it('substring false positive: RENOW contains NOW(', () => { 330 + // Known behavior: substring matching causes false positives on 331 + // non-existent function names. Acceptable trade-off vs. full parsing. 332 + expect(isVolatile('RENOW()')).toBe(true); 333 + }); 334 + 335 + it('does not trigger on partial matches without paren', () => { 336 + expect(isVolatile('KNOWN')).toBe(false); 337 + expect(isVolatile('RANDOM_VALUE')).toBe(false); 338 + expect(isVolatile('NOWADAYS')).toBe(false); 339 + expect(isVolatile('SNOWFALL()')).toBe(false); // NOWF, not NOW( 340 + }); 341 + });