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

Configure Feed

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

test: add coverage for markdown, CSV, filters, calendar, drag-fill, recalc, pivots

+2123
+348
tests/calendar-edges.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + eventsOnDate, 4 + type CalendarEvent, 5 + } from '../src/calendar/helpers.js'; 6 + import { 7 + parseIcsFile, 8 + parseDuration, 9 + unfoldLines, 10 + } from '../src/calendar/ics-parser.js'; 11 + 12 + /** Helper: create a CalendarEvent with sensible defaults. */ 13 + function makeEvent(overrides: Partial<CalendarEvent> = {}): CalendarEvent { 14 + return { 15 + id: crypto.randomUUID(), 16 + title: 'Test Event', 17 + date: '2026-04-08', 18 + startTime: '09:00', 19 + endTime: '10:00', 20 + allDay: false, 21 + color: '#3a8a7a', 22 + description: '', 23 + createdAt: Date.now(), 24 + updatedAt: Date.now(), 25 + ...overrides, 26 + }; 27 + } 28 + 29 + // ========================================================================= 30 + // eventsOnDate: single-day filtering 31 + // ========================================================================= 32 + 33 + describe('eventsOnDate: basic filtering', () => { 34 + it('returns events matching the date', () => { 35 + const events = [ 36 + makeEvent({ date: '2026-04-08', title: 'A' }), 37 + makeEvent({ date: '2026-04-09', title: 'B' }), 38 + makeEvent({ date: '2026-04-08', title: 'C' }), 39 + ]; 40 + const result = eventsOnDate(events, '2026-04-08'); 41 + expect(result).toHaveLength(2); 42 + }); 43 + 44 + it('returns empty when no events match', () => { 45 + const events = [makeEvent({ date: '2026-04-08' })]; 46 + expect(eventsOnDate(events, '2026-04-09')).toHaveLength(0); 47 + }); 48 + 49 + it('returns empty for empty events array', () => { 50 + expect(eventsOnDate([], '2026-04-08')).toHaveLength(0); 51 + }); 52 + }); 53 + 54 + // ========================================================================= 55 + // eventsOnDate: all-day events 56 + // ========================================================================= 57 + 58 + describe('eventsOnDate: all-day events', () => { 59 + it('all-day event only matches its exact date', () => { 60 + const evt = makeEvent({ date: '2026-04-09', allDay: true, startTime: '', endTime: '' }); 61 + expect(eventsOnDate([evt], '2026-04-09')).toHaveLength(1); 62 + expect(eventsOnDate([evt], '2026-04-10')).toHaveLength(0); 63 + }); 64 + 65 + it('all-day events sort before timed events', () => { 66 + const allDay = makeEvent({ date: '2026-04-08', allDay: true, startTime: '', title: 'AllDay' }); 67 + const timed = makeEvent({ date: '2026-04-08', allDay: false, startTime: '09:00', title: 'Timed' }); 68 + const result = eventsOnDate([timed, allDay], '2026-04-08'); 69 + expect(result[0].title).toBe('AllDay'); 70 + expect(result[1].title).toBe('Timed'); 71 + }); 72 + 73 + it('multiple all-day events preserve insertion order', () => { 74 + const events = [ 75 + makeEvent({ date: '2026-04-08', allDay: true, startTime: '', title: 'C' }), 76 + makeEvent({ date: '2026-04-08', allDay: true, startTime: '', title: 'A' }), 77 + makeEvent({ date: '2026-04-08', allDay: true, startTime: '', title: 'B' }), 78 + ]; 79 + const result = eventsOnDate(events, '2026-04-08'); 80 + expect(result.map(e => e.title)).toEqual(['C', 'A', 'B']); 81 + }); 82 + }); 83 + 84 + // ========================================================================= 85 + // eventsOnDate: timed events sorting 86 + // ========================================================================= 87 + 88 + describe('eventsOnDate: timed event sorting', () => { 89 + it('sorts timed events by start time ascending', () => { 90 + const events = [ 91 + makeEvent({ date: '2026-04-08', startTime: '14:00', title: 'Afternoon' }), 92 + makeEvent({ date: '2026-04-08', startTime: '08:00', title: 'Morning' }), 93 + makeEvent({ date: '2026-04-08', startTime: '12:00', title: 'Noon' }), 94 + ]; 95 + const result = eventsOnDate(events, '2026-04-08'); 96 + expect(result.map(e => e.title)).toEqual(['Morning', 'Noon', 'Afternoon']); 97 + }); 98 + 99 + it('sorts mixed all-day and timed: all-day first, then by start time', () => { 100 + const events = [ 101 + makeEvent({ date: '2026-04-08', startTime: '17:00', title: 'Late' }), 102 + makeEvent({ date: '2026-04-08', allDay: true, startTime: '', title: 'AllDay1' }), 103 + makeEvent({ date: '2026-04-08', startTime: '08:00', title: 'Early' }), 104 + makeEvent({ date: '2026-04-08', allDay: true, startTime: '', title: 'AllDay2' }), 105 + ]; 106 + const result = eventsOnDate(events, '2026-04-08'); 107 + expect(result).toHaveLength(4); 108 + expect(result[0].title).toBe('AllDay1'); 109 + expect(result[1].title).toBe('AllDay2'); 110 + expect(result[2].title).toBe('Early'); 111 + expect(result[3].title).toBe('Late'); 112 + }); 113 + 114 + it('events with same start time maintain relative order', () => { 115 + const events = [ 116 + makeEvent({ date: '2026-04-08', startTime: '10:00', title: 'First' }), 117 + makeEvent({ date: '2026-04-08', startTime: '10:00', title: 'Second' }), 118 + ]; 119 + const result = eventsOnDate(events, '2026-04-08'); 120 + expect(result).toHaveLength(2); 121 + // Same startTime => stable sort preserves order 122 + expect(result[0].title).toBe('First'); 123 + expect(result[1].title).toBe('Second'); 124 + }); 125 + }); 126 + 127 + // ========================================================================= 128 + // ICS parser: duration parsing 129 + // ========================================================================= 130 + 131 + describe('ICS parser: parseDuration', () => { 132 + it('parses weeks', () => { 133 + expect(parseDuration('P2W')).toBe(2 * 7 * 24 * 60); 134 + }); 135 + 136 + it('parses days', () => { 137 + expect(parseDuration('P3D')).toBe(3 * 24 * 60); 138 + }); 139 + 140 + it('parses hours', () => { 141 + expect(parseDuration('PT2H')).toBe(120); 142 + }); 143 + 144 + it('parses minutes', () => { 145 + expect(parseDuration('PT30M')).toBe(30); 146 + }); 147 + 148 + it('parses combined days and time', () => { 149 + expect(parseDuration('P1DT2H30M')).toBe(24 * 60 + 2 * 60 + 30); 150 + }); 151 + 152 + it('parses time-only duration', () => { 153 + expect(parseDuration('PT1H30M')).toBe(90); 154 + }); 155 + }); 156 + 157 + // ========================================================================= 158 + // ICS parser: line unfolding 159 + // ========================================================================= 160 + 161 + describe('ICS parser: unfoldLines', () => { 162 + it('unfolds lines continued with CRLF + space', () => { 163 + const raw = 'SUMMARY:This is a long\r\n description'; 164 + const lines = unfoldLines(raw); 165 + expect(lines).toContain('SUMMARY:This is a long description'); 166 + }); 167 + 168 + it('unfolds lines continued with CRLF + tab', () => { 169 + const raw = 'SUMMARY:Hello\r\n\tWorld'; 170 + const lines = unfoldLines(raw); 171 + expect(lines).toContain('SUMMARY:HelloWorld'); 172 + }); 173 + 174 + it('handles non-folded content', () => { 175 + const raw = 'SUMMARY:Simple\r\nDESCRIPTION:Test'; 176 + const lines = unfoldLines(raw); 177 + expect(lines).toContain('SUMMARY:Simple'); 178 + expect(lines).toContain('DESCRIPTION:Test'); 179 + }); 180 + }); 181 + 182 + // ========================================================================= 183 + // ICS parser: full event parsing 184 + // ========================================================================= 185 + 186 + describe('ICS parser: parseIcsFile', () => { 187 + it('parses a simple all-day event', () => { 188 + const ics = [ 189 + 'BEGIN:VCALENDAR', 190 + 'BEGIN:VEVENT', 191 + 'DTSTART;VALUE=DATE:20260409', 192 + 'SUMMARY:All Day Event', 193 + 'END:VEVENT', 194 + 'END:VCALENDAR', 195 + ].join('\r\n'); 196 + 197 + const result = parseIcsFile(ics); 198 + expect(result.errors).toHaveLength(0); 199 + expect(result.sourceEventCount).toBe(1); 200 + expect(result.events).toHaveLength(1); 201 + expect(result.events[0].allDay).toBe(true); 202 + expect(result.events[0].date).toBe('2026-04-09'); 203 + expect(result.events[0].title).toBe('All Day Event'); 204 + }); 205 + 206 + it('parses a timed event with DTSTART and DTEND', () => { 207 + const ics = [ 208 + 'BEGIN:VCALENDAR', 209 + 'BEGIN:VEVENT', 210 + 'DTSTART:20260409T090000', 211 + 'DTEND:20260409T100000', 212 + 'SUMMARY:Morning Meeting', 213 + 'END:VEVENT', 214 + 'END:VCALENDAR', 215 + ].join('\r\n'); 216 + 217 + const result = parseIcsFile(ics); 218 + expect(result.errors).toHaveLength(0); 219 + expect(result.events).toHaveLength(1); 220 + expect(result.events[0].allDay).toBe(false); 221 + expect(result.events[0].startTime).toBe('09:00'); 222 + expect(result.events[0].endTime).toBe('10:00'); 223 + }); 224 + 225 + it('parses event with DURATION instead of DTEND', () => { 226 + const ics = [ 227 + 'BEGIN:VCALENDAR', 228 + 'BEGIN:VEVENT', 229 + 'DTSTART:20260409T140000', 230 + 'DURATION:PT1H30M', 231 + 'SUMMARY:With Duration', 232 + 'END:VEVENT', 233 + 'END:VCALENDAR', 234 + ].join('\r\n'); 235 + 236 + const result = parseIcsFile(ics); 237 + expect(result.errors).toHaveLength(0); 238 + expect(result.events[0].startTime).toBe('14:00'); 239 + expect(result.events[0].endTime).toBe('15:30'); 240 + }); 241 + 242 + it('parses multiple events from one file', () => { 243 + const ics = [ 244 + 'BEGIN:VCALENDAR', 245 + 'BEGIN:VEVENT', 246 + 'DTSTART;VALUE=DATE:20260409', 247 + 'SUMMARY:Event 1', 248 + 'END:VEVENT', 249 + 'BEGIN:VEVENT', 250 + 'DTSTART;VALUE=DATE:20260410', 251 + 'SUMMARY:Event 2', 252 + 'END:VEVENT', 253 + 'END:VCALENDAR', 254 + ].join('\r\n'); 255 + 256 + const result = parseIcsFile(ics); 257 + expect(result.sourceEventCount).toBe(2); 258 + expect(result.events).toHaveLength(2); 259 + }); 260 + 261 + it('returns error for file without VCALENDAR', () => { 262 + const result = parseIcsFile('random text content'); 263 + expect(result.errors.length).toBeGreaterThan(0); 264 + expect(result.events).toHaveLength(0); 265 + }); 266 + 267 + it('returns error for VCALENDAR with no VEVENT', () => { 268 + const ics = 'BEGIN:VCALENDAR\r\nEND:VCALENDAR'; 269 + const result = parseIcsFile(ics); 270 + expect(result.errors.length).toBeGreaterThan(0); 271 + expect(result.events).toHaveLength(0); 272 + }); 273 + 274 + it('unescapes iCal text (newlines, commas, semicolons, backslashes)', () => { 275 + const ics = [ 276 + 'BEGIN:VCALENDAR', 277 + 'BEGIN:VEVENT', 278 + 'DTSTART;VALUE=DATE:20260409', 279 + 'SUMMARY:Test\\, with\\; special\\n chars\\\\end', 280 + 'END:VEVENT', 281 + 'END:VCALENDAR', 282 + ].join('\r\n'); 283 + 284 + const result = parseIcsFile(ics); 285 + expect(result.events[0].title).toBe('Test, with; special\n chars\\end'); 286 + }); 287 + 288 + it('parses UTC datetime (Z suffix)', () => { 289 + const ics = [ 290 + 'BEGIN:VCALENDAR', 291 + 'BEGIN:VEVENT', 292 + 'DTSTART:20260409T160000Z', 293 + 'DTEND:20260409T170000Z', 294 + 'SUMMARY:UTC Event', 295 + 'END:VEVENT', 296 + 'END:VCALENDAR', 297 + ].join('\r\n'); 298 + 299 + const result = parseIcsFile(ics); 300 + expect(result.errors).toHaveLength(0); 301 + expect(result.events).toHaveLength(1); 302 + expect(result.events[0].title).toBe('UTC Event'); 303 + }); 304 + }); 305 + 306 + // ========================================================================= 307 + // ICS parser: recurring events 308 + // ========================================================================= 309 + 310 + describe('ICS parser: recurring events', () => { 311 + it('expands a daily recurring event with COUNT', () => { 312 + const ics = [ 313 + 'BEGIN:VCALENDAR', 314 + 'BEGIN:VEVENT', 315 + 'DTSTART;VALUE=DATE:20260409', 316 + 'SUMMARY:Daily', 317 + 'RRULE:FREQ=DAILY;COUNT=3', 318 + 'END:VEVENT', 319 + 'END:VCALENDAR', 320 + ].join('\r\n'); 321 + 322 + const result = parseIcsFile(ics); 323 + expect(result.errors).toHaveLength(0); 324 + // Original + 2 recurrences = 3 total 325 + expect(result.events).toHaveLength(3); 326 + expect(result.events[0].date).toBe('2026-04-09'); 327 + expect(result.events[1].date).toBe('2026-04-10'); 328 + expect(result.events[2].date).toBe('2026-04-11'); 329 + }); 330 + 331 + it('expands a weekly recurring event', () => { 332 + const ics = [ 333 + 'BEGIN:VCALENDAR', 334 + 'BEGIN:VEVENT', 335 + 'DTSTART;VALUE=DATE:20260409', 336 + 'SUMMARY:Weekly', 337 + 'RRULE:FREQ=WEEKLY;COUNT=3', 338 + 'END:VEVENT', 339 + 'END:VCALENDAR', 340 + ].join('\r\n'); 341 + 342 + const result = parseIcsFile(ics); 343 + expect(result.events.length).toBeGreaterThanOrEqual(2); 344 + const dates = result.events.map(e => e.date); 345 + expect(dates).toContain('2026-04-09'); 346 + expect(dates).toContain('2026-04-16'); 347 + }); 348 + });
+150
tests/csv-export-edges.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { exportToCsv, escapeField } from '../src/sheets/csv-export.js'; 3 + 4 + const BOM = '\uFEFF'; 5 + 6 + function gridFromArray(data: string[][]) { 7 + return (row: number, col: number): string => { 8 + const r = row - 1; 9 + const c = col - 1; 10 + if (r < 0 || r >= data.length) return ''; 11 + if (c < 0 || c >= data[r].length) return ''; 12 + return String(data[r][c] ?? ''); 13 + }; 14 + } 15 + 16 + describe('CSV export edge cases: cells with commas', () => { 17 + it('quotes a cell containing a comma in a multi-cell row', () => { 18 + const data = [['hello, world', 'plain']]; 19 + const result = exportToCsv(gridFromArray(data), 1, 2); 20 + expect(result).toBe(BOM + '"hello, world",plain'); 21 + }); 22 + 23 + it('quotes multiple cells that all contain commas', () => { 24 + const data = [['a,b', 'c,d']]; 25 + const result = exportToCsv(gridFromArray(data), 1, 2); 26 + expect(result).toBe(BOM + '"a,b","c,d"'); 27 + }); 28 + }); 29 + 30 + describe('CSV export edge cases: double quotes', () => { 31 + it('escapes a single double-quote inside a field', () => { 32 + const data = [['He said "hi"']]; 33 + const result = exportToCsv(gridFromArray(data), 1, 1); 34 + expect(result).toBe(BOM + '"He said ""hi"""'); 35 + }); 36 + 37 + it('escapes a field that is only a double-quote character', () => { 38 + const data = [['"']]; 39 + const result = exportToCsv(gridFromArray(data), 1, 1); 40 + expect(result).toBe(BOM + '""""'); 41 + }); 42 + 43 + it('escapes consecutive double-quotes', () => { 44 + const data = [['""']]; 45 + const result = exportToCsv(gridFromArray(data), 1, 1); 46 + expect(result).toBe(BOM + '""""""'); 47 + }); 48 + }); 49 + 50 + describe('CSV export edge cases: newlines in cells', () => { 51 + it('quotes a cell containing LF', () => { 52 + const data = [['line1\nline2']]; 53 + const result = exportToCsv(gridFromArray(data), 1, 1); 54 + expect(result).toBe(BOM + '"line1\nline2"'); 55 + }); 56 + 57 + it('quotes a cell containing CR', () => { 58 + const data = [['line1\rline2']]; 59 + const result = exportToCsv(gridFromArray(data), 1, 1); 60 + expect(result).toBe(BOM + '"line1\rline2"'); 61 + }); 62 + 63 + it('quotes a cell containing CRLF', () => { 64 + const data = [['line1\r\nline2']]; 65 + const result = exportToCsv(gridFromArray(data), 1, 1); 66 + expect(result).toBe(BOM + '"line1\r\nline2"'); 67 + }); 68 + }); 69 + 70 + describe('CSV export edge cases: empty cells', () => { 71 + it('exports a row of all empty cells', () => { 72 + const data = [['', '', '']]; 73 + const result = exportToCsv(gridFromArray(data), 1, 3); 74 + expect(result).toBe(BOM + ',,'); 75 + }); 76 + 77 + it('exports multiple rows with mixed empty and non-empty cells', () => { 78 + const data = [['', 'B1'], ['A2', '']]; 79 + const result = exportToCsv(gridFromArray(data), 2, 2); 80 + expect(result).toBe(BOM + ',B1\r\nA2,'); 81 + }); 82 + }); 83 + 84 + describe('CSV export edge cases: unicode', () => { 85 + it('preserves emoji characters', () => { 86 + const data = [['Hello \u{1F600}']]; 87 + const result = exportToCsv(gridFromArray(data), 1, 1); 88 + expect(result).toBe(BOM + 'Hello \u{1F600}'); 89 + }); 90 + 91 + it('preserves CJK characters', () => { 92 + const data = [['\u4F60\u597D']]; 93 + const result = exportToCsv(gridFromArray(data), 1, 1); 94 + expect(result).toBe(BOM + '\u4F60\u597D'); 95 + }); 96 + 97 + it('preserves accented characters', () => { 98 + const data = [['\u00E9\u00E8\u00EA\u00EB']]; 99 + const result = exportToCsv(gridFromArray(data), 1, 1); 100 + expect(result).toBe(BOM + '\u00E9\u00E8\u00EA\u00EB'); 101 + }); 102 + 103 + it('preserves RTL characters (Arabic)', () => { 104 + const data = [['\u0645\u0631\u062D\u0628\u0627']]; 105 + const result = exportToCsv(gridFromArray(data), 1, 1); 106 + expect(result).toBe(BOM + '\u0645\u0631\u062D\u0628\u0627'); 107 + }); 108 + }); 109 + 110 + describe('CSV export edge cases: booleans as strings', () => { 111 + it('exports "true" string as-is', () => { 112 + const data = [['true']]; 113 + const result = exportToCsv(gridFromArray(data), 1, 1); 114 + expect(result).toBe(BOM + 'true'); 115 + }); 116 + 117 + it('exports "false" string as-is', () => { 118 + const data = [['false']]; 119 + const result = exportToCsv(gridFromArray(data), 1, 1); 120 + expect(result).toBe(BOM + 'false'); 121 + }); 122 + 123 + it('exports "TRUE" (uppercase) string as-is', () => { 124 + const data = [['TRUE']]; 125 + const result = exportToCsv(gridFromArray(data), 1, 1); 126 + expect(result).toBe(BOM + 'TRUE'); 127 + }); 128 + }); 129 + 130 + describe('escapeField edge cases', () => { 131 + it('handles a field with only whitespace', () => { 132 + expect(escapeField(' ', ',')).toBe(' '); 133 + }); 134 + 135 + it('handles a field with only newlines', () => { 136 + expect(escapeField('\n\n', ',')).toBe('"\n\n"'); 137 + }); 138 + 139 + it('handles a field with comma at the very start', () => { 140 + expect(escapeField(',start', ',')).toBe('",start"'); 141 + }); 142 + 143 + it('handles a field with comma at the very end', () => { 144 + expect(escapeField('end,', ',')).toBe('"end,"'); 145 + }); 146 + 147 + it('handles a field with both quotes and newlines and commas', () => { 148 + expect(escapeField('a,"b"\nc', ',')).toBe('"a,""b""\nc"'); 149 + }); 150 + });
+317
tests/drag-fill-edges.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + detectPattern, 4 + generateFillValues, 5 + adjustFormulaRef, 6 + PATTERN_TYPES, 7 + } from '../src/sheets/drag-fill.js'; 8 + 9 + /** 10 + * Additional drag-fill edge case tests beyond the base drag-fill.test.ts. 11 + * 12 + * Covers: text+number patterns (as repeat since no auto-increment for mixed), 13 + * single cell repeat, descending sequences, floating point sequences, 14 + * date step variations, negative count, formula adjustment edge cases. 15 + */ 16 + 17 + // ============================================================ 18 + // Single cell repeat 19 + // ============================================================ 20 + 21 + describe('drag-fill: single cell repeat', () => { 22 + it('repeats a single text cell', () => { 23 + const values = ['hello']; 24 + const pattern = detectPattern(values); 25 + expect(pattern.type).toBe(PATTERN_TYPES.TEXT_REPEAT); 26 + const filled = generateFillValues(values, pattern, 4, 'forward'); 27 + expect(filled).toEqual(['hello', 'hello', 'hello', 'hello']); 28 + }); 29 + 30 + it('repeats a single number with step 1 (not constant repeat)', () => { 31 + const values = [10]; 32 + const pattern = detectPattern(values); 33 + expect(pattern.type).toBe(PATTERN_TYPES.NUMBER_INCREMENT); 34 + expect(pattern.step).toBe(1); 35 + const filled = generateFillValues(values, pattern, 3, 'forward'); 36 + expect(filled).toEqual([11, 12, 13]); 37 + }); 38 + 39 + it('repeats a single date with step 1 day', () => { 40 + const values = ['2026-04-09']; 41 + const pattern = detectPattern(values); 42 + expect(pattern.type).toBe(PATTERN_TYPES.DATE_INCREMENT); 43 + expect(pattern.stepMs).toBe(86400000); 44 + const filled = generateFillValues(values, pattern, 3, 'forward'); 45 + expect(filled).toEqual(['2026-04-10', '2026-04-11', '2026-04-12']); 46 + }); 47 + }); 48 + 49 + // ============================================================ 50 + // Text+number pattern (Item1, Item2 -> treated as text repeat) 51 + // ============================================================ 52 + 53 + describe('drag-fill: text+number strings', () => { 54 + it('treats text+number strings as text repeat (no auto-increment)', () => { 55 + // The drag-fill module does not parse text+number patterns like "Item1" 56 + // into incrementing sequences -- it treats them as text repeat. 57 + const values = ['Item1', 'Item2']; 58 + const pattern = detectPattern(values); 59 + expect(pattern.type).toBe(PATTERN_TYPES.TEXT_REPEAT); 60 + const filled = generateFillValues(values, pattern, 4, 'forward'); 61 + expect(filled).toEqual(['Item1', 'Item2', 'Item1', 'Item2']); 62 + }); 63 + 64 + it('repeats single text+number value', () => { 65 + const values = ['Row1']; 66 + const pattern = detectPattern(values); 67 + expect(pattern.type).toBe(PATTERN_TYPES.TEXT_REPEAT); 68 + const filled = generateFillValues(values, pattern, 3, 'forward'); 69 + expect(filled).toEqual(['Row1', 'Row1', 'Row1']); 70 + }); 71 + }); 72 + 73 + // ============================================================ 74 + // Descending number sequences 75 + // ============================================================ 76 + 77 + describe('drag-fill: descending sequences', () => { 78 + it('detects descending sequence with step -1', () => { 79 + const values = [5, 4, 3]; 80 + const pattern = detectPattern(values); 81 + expect(pattern.type).toBe(PATTERN_TYPES.NUMBER_INCREMENT); 82 + expect(pattern.step).toBe(-1); 83 + }); 84 + 85 + it('fills descending sequence forward (continues decreasing)', () => { 86 + const values = [5, 4, 3]; 87 + const pattern = detectPattern(values); 88 + const filled = generateFillValues(values, pattern, 3, 'forward'); 89 + expect(filled).toEqual([2, 1, 0]); 90 + }); 91 + 92 + it('fills descending sequence into negative numbers', () => { 93 + const values = [2, 1, 0]; 94 + const pattern = detectPattern(values); 95 + const filled = generateFillValues(values, pattern, 3, 'forward'); 96 + expect(filled).toEqual([-1, -2, -3]); 97 + }); 98 + 99 + it('detects descending sequence with step -10', () => { 100 + const values = [100, 90, 80]; 101 + const pattern = detectPattern(values); 102 + expect(pattern.type).toBe(PATTERN_TYPES.NUMBER_INCREMENT); 103 + expect(pattern.step).toBe(-10); 104 + const filled = generateFillValues(values, pattern, 2, 'forward'); 105 + expect(filled).toEqual([70, 60]); 106 + }); 107 + 108 + it('fills descending sequence backward (numbers increase, reversed)', () => { 109 + const values = [5, 4, 3]; 110 + const pattern = detectPattern(values); 111 + const filled = generateFillValues(values, pattern, 3, 'backward'); 112 + // Backward from first value (5): 5 - (-1)*1=6, 5-(-1)*2=7, 5-(-1)*3=8 113 + // result.reverse() produces [8, 7, 6] (furthest-first order) 114 + expect(filled).toEqual([8, 7, 6]); 115 + }); 116 + }); 117 + 118 + // ============================================================ 119 + // Floating point sequences 120 + // ============================================================ 121 + 122 + describe('drag-fill: floating point sequences', () => { 123 + it('detects step of 0.5', () => { 124 + const values = [1.0, 1.5, 2.0]; 125 + const pattern = detectPattern(values); 126 + expect(pattern.type).toBe(PATTERN_TYPES.NUMBER_INCREMENT); 127 + expect(pattern.step).toBeCloseTo(0.5); 128 + }); 129 + 130 + it('fills with step 0.5', () => { 131 + const values = [1.0, 1.5, 2.0]; 132 + const pattern = detectPattern(values); 133 + const filled = generateFillValues(values, pattern, 3, 'forward'); 134 + expect(filled[0]).toBeCloseTo(2.5); 135 + expect(filled[1]).toBeCloseTo(3.0); 136 + expect(filled[2]).toBeCloseTo(3.5); 137 + }); 138 + 139 + it('handles step of 0.1 without floating point drift breaking detection', () => { 140 + const values = [0.1, 0.2, 0.3]; 141 + const pattern = detectPattern(values); 142 + // The code uses 1e-10 tolerance, so 0.1+0.1=0.2 should be detected 143 + expect(pattern.type).toBe(PATTERN_TYPES.NUMBER_INCREMENT); 144 + expect(pattern.step).toBeCloseTo(0.1); 145 + }); 146 + }); 147 + 148 + // ============================================================ 149 + // Date step variations 150 + // ============================================================ 151 + 152 + describe('drag-fill: date step variations', () => { 153 + it('detects weekly date step', () => { 154 + const values = ['2026-04-01', '2026-04-08', '2026-04-15']; 155 + const pattern = detectPattern(values); 156 + expect(pattern.type).toBe(PATTERN_TYPES.DATE_INCREMENT); 157 + expect(pattern.stepMs).toBe(7 * 86400000); 158 + }); 159 + 160 + it('fills weekly dates forward', () => { 161 + const values = ['2026-04-01', '2026-04-08', '2026-04-15']; 162 + const pattern = detectPattern(values); 163 + const filled = generateFillValues(values, pattern, 2, 'forward'); 164 + expect(filled[0]).toBe('2026-04-22'); 165 + expect(filled[1]).toBe('2026-04-29'); 166 + }); 167 + 168 + it('fills daily dates backward', () => { 169 + const values = ['2026-04-05', '2026-04-06', '2026-04-07']; 170 + const pattern = detectPattern(values); 171 + const filled = generateFillValues(values, pattern, 3, 'backward'); 172 + expect(filled[0]).toBe('2026-04-02'); 173 + expect(filled[1]).toBe('2026-04-03'); 174 + expect(filled[2]).toBe('2026-04-04'); 175 + }); 176 + 177 + it('detects two-day step', () => { 178 + const values = ['2026-04-01', '2026-04-03']; 179 + const pattern = detectPattern(values); 180 + expect(pattern.type).toBe(PATTERN_TYPES.DATE_INCREMENT); 181 + expect(pattern.stepMs).toBe(2 * 86400000); 182 + const filled = generateFillValues(values, pattern, 2, 'forward'); 183 + expect(filled[0]).toBe('2026-04-05'); 184 + expect(filled[1]).toBe('2026-04-07'); 185 + }); 186 + }); 187 + 188 + // ============================================================ 189 + // Zero and negative count 190 + // ============================================================ 191 + 192 + describe('drag-fill: zero and negative fill count', () => { 193 + it('returns empty array for count 0', () => { 194 + const values = [1, 2, 3]; 195 + const pattern = detectPattern(values); 196 + expect(generateFillValues(values, pattern, 0, 'forward')).toEqual([]); 197 + }); 198 + 199 + it('returns empty array for negative count', () => { 200 + const values = [1, 2, 3]; 201 + const pattern = detectPattern(values); 202 + expect(generateFillValues(values, pattern, -1, 'forward')).toEqual([]); 203 + }); 204 + }); 205 + 206 + // ============================================================ 207 + // Formula reference adjustment edge cases 208 + // ============================================================ 209 + 210 + describe('drag-fill: formula adjustment edge cases', () => { 211 + it('adjusts multiple references in a complex formula', () => { 212 + const result = adjustFormulaRef('IF(A1>B1, A1*2, B1/2)', 0, 1); 213 + expect(result).toBe('IF(A2>B2, A2*2, B2/2)'); 214 + }); 215 + 216 + it('preserves dollar signs on fully absolute references', () => { 217 + const result = adjustFormulaRef('$A$1+$B$2', 5, 5); 218 + expect(result).toBe('$A$1+$B$2'); 219 + }); 220 + 221 + it('adjusts only the relative part of mixed references', () => { 222 + // $A1 means column A is fixed, row is relative 223 + const result = adjustFormulaRef('$A1', 3, 2); 224 + expect(result).toBe('$A3'); 225 + }); 226 + 227 + it('adjusts only the relative part of row-fixed references', () => { 228 + // A$1 means column A is relative, row 1 is fixed 229 + const result = adjustFormulaRef('A$1', 3, 2); 230 + expect(result).toBe('D$1'); 231 + }); 232 + 233 + it('handles formula with SUM across sheets-like syntax', () => { 234 + // adjustFormulaRef works on cell references, not sheet names 235 + const result = adjustFormulaRef('A1+B1', 1, 1); 236 + expect(result).toBe('B2+C2'); 237 + }); 238 + 239 + it('handles formula with no cell references at all', () => { 240 + expect(adjustFormulaRef('42+58', 1, 1)).toBe('42+58'); 241 + expect(adjustFormulaRef('"hello"', 0, 1)).toBe('"hello"'); 242 + }); 243 + 244 + it('clamps column to A (minimum) when adjusting left past A', () => { 245 + const result = adjustFormulaRef('A1', -5, 0); 246 + expect(result).toBe('A1'); 247 + }); 248 + 249 + it('clamps row to 1 (minimum) when adjusting up past row 1', () => { 250 + const result = adjustFormulaRef('A1', 0, -5); 251 + expect(result).toBe('A1'); 252 + }); 253 + }); 254 + 255 + // ============================================================ 256 + // Pattern detection: null and undefined values 257 + // ============================================================ 258 + 259 + describe('drag-fill: null and undefined in source values', () => { 260 + it('handles null values as text repeat', () => { 261 + const result = detectPattern([null, null]); 262 + expect(result.type).toBe(PATTERN_TYPES.TEXT_REPEAT); 263 + }); 264 + 265 + it('handles undefined values as text repeat', () => { 266 + const result = detectPattern([undefined]); 267 + expect(result.type).toBe(PATTERN_TYPES.TEXT_REPEAT); 268 + }); 269 + 270 + it('handles mixed null and numbers as text repeat', () => { 271 + const result = detectPattern([1, null, 3]); 272 + expect(result.type).toBe(PATTERN_TYPES.TEXT_REPEAT); 273 + }); 274 + }); 275 + 276 + // ============================================================ 277 + // Formula pattern detection 278 + // ============================================================ 279 + 280 + describe('drag-fill: formula pattern detection', () => { 281 + it('detects formula objects regardless of other values', () => { 282 + const values = [{ f: 'A1+1', v: 2 }, { f: 'A2+1', v: 3 }]; 283 + const pattern = detectPattern(values); 284 + expect(pattern.type).toBe(PATTERN_TYPES.FORMULA_ADJUST); 285 + }); 286 + 287 + it('formula pattern generates cyclic placeholders', () => { 288 + const values = [{ f: 'A1', v: '' }, { f: 'A2', v: '' }]; 289 + const pattern = detectPattern(values); 290 + const filled = generateFillValues(values, pattern, 3, 'forward'); 291 + expect(filled).toHaveLength(3); 292 + // Cyclic repetition of source values 293 + expect(filled[0]).toEqual(values[0]); 294 + expect(filled[1]).toEqual(values[1]); 295 + expect(filled[2]).toEqual(values[0]); 296 + }); 297 + }); 298 + 299 + // ============================================================ 300 + // Constant (step=0) number sequence 301 + // ============================================================ 302 + 303 + describe('drag-fill: constant number sequence (step=0)', () => { 304 + it('detects step 0 for identical numbers', () => { 305 + const values = [5, 5, 5]; 306 + const pattern = detectPattern(values); 307 + expect(pattern.type).toBe(PATTERN_TYPES.NUMBER_INCREMENT); 308 + expect(pattern.step).toBe(0); 309 + }); 310 + 311 + it('fills constant numbers (all same value)', () => { 312 + const values = [5, 5, 5]; 313 + const pattern = detectPattern(values); 314 + const filled = generateFillValues(values, pattern, 3, 'forward'); 315 + expect(filled).toEqual([5, 5, 5]); 316 + }); 317 + });
+315
tests/filter-special-types.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + getUniqueColumnValues, 4 + applyFilters, 5 + buildFilterState, 6 + clearColumnFilter, 7 + clearAllFilters, 8 + } from '../src/sheets/filter.js'; 9 + 10 + /** 11 + * Filter tests for special types: blanks, errors, booleans, mixed types, 12 + * numeric strings vs numbers, multiple filters, and clear filter. 13 + */ 14 + 15 + function makeGrid(data: unknown[][]) { 16 + return data.map((row, idx) => { 17 + const obj: Record<string, unknown> = { _row: idx + 1 }; 18 + row.forEach((val, col) => { 19 + obj[col + 1] = val; 20 + }); 21 + return obj; 22 + }); 23 + } 24 + 25 + describe('Filter with blank cells', () => { 26 + it('treats null values as empty string in unique values', () => { 27 + const rows = makeGrid([ 28 + ['Alice', null], 29 + ['Bob', 'Engineering'], 30 + ]); 31 + const values = getUniqueColumnValues(rows, 2); 32 + expect(values).toContain(''); 33 + expect(values).toContain('Engineering'); 34 + }); 35 + 36 + it('treats undefined values as empty string in unique values', () => { 37 + const rows = makeGrid([ 38 + ['Alice'], 39 + ['Bob', 'Sales'], 40 + ]); 41 + // Row 0 has no col 2, so it is undefined 42 + const values = getUniqueColumnValues(rows, 2); 43 + expect(values).toContain(''); 44 + expect(values).toContain('Sales'); 45 + }); 46 + 47 + it('filters can include or exclude blank cells', () => { 48 + const rows = makeGrid([ 49 + ['Alice', null], 50 + ['Bob', 'Engineering'], 51 + ['Carol', ''], 52 + ]); 53 + // Only show blanks 54 + const filters = { 55 + 2: { '': true, 'Engineering': false }, 56 + }; 57 + const visible = applyFilters(rows, filters); 58 + expect(visible).toHaveLength(2); 59 + expect(visible.every(r => r[2] === null || r[2] === '' || r[2] === undefined)).toBe(true); 60 + }); 61 + 62 + it('filters can exclude blank cells', () => { 63 + const rows = makeGrid([ 64 + ['Alice', null], 65 + ['Bob', 'Engineering'], 66 + ['Carol', ''], 67 + ]); 68 + const filters = { 69 + 2: { '': false, 'Engineering': true }, 70 + }; 71 + const visible = applyFilters(rows, filters); 72 + expect(visible).toHaveLength(1); 73 + expect(visible[0][1]).toBe('Bob'); 74 + }); 75 + }); 76 + 77 + describe('Filter with error values', () => { 78 + it('treats error strings as unique filter values', () => { 79 + const rows = makeGrid([ 80 + [10, '#DIV/0!'], 81 + [20, 'valid'], 82 + [30, '#REF!'], 83 + ]); 84 + const values = getUniqueColumnValues(rows, 2); 85 + expect(values).toContain('#DIV/0!'); 86 + expect(values).toContain('#REF!'); 87 + expect(values).toContain('valid'); 88 + }); 89 + 90 + it('can filter to show only error values', () => { 91 + const rows = makeGrid([ 92 + [10, '#DIV/0!'], 93 + [20, 'valid'], 94 + [30, '#REF!'], 95 + ]); 96 + const filters = { 97 + 2: { '#DIV/0!': true, 'valid': false, '#REF!': true }, 98 + }; 99 + const visible = applyFilters(rows, filters); 100 + expect(visible).toHaveLength(2); 101 + expect(visible.every(r => String(r[2]).startsWith('#'))).toBe(true); 102 + }); 103 + }); 104 + 105 + describe('Filter with booleans', () => { 106 + it('stringifies boolean values for filter comparison', () => { 107 + const rows = makeGrid([ 108 + ['A', true], 109 + ['B', false], 110 + ['C', true], 111 + ]); 112 + const values = getUniqueColumnValues(rows, 2); 113 + expect(values).toContain('true'); 114 + expect(values).toContain('false'); 115 + }); 116 + 117 + it('builds filter state with boolean values', () => { 118 + const rows = makeGrid([ 119 + ['A', true], 120 + ['B', false], 121 + ]); 122 + const state = buildFilterState(rows, 2); 123 + expect(state['true']).toBe(true); 124 + expect(state['false']).toBe(true); 125 + }); 126 + 127 + it('can filter to show only true values', () => { 128 + const rows = makeGrid([ 129 + ['A', true], 130 + ['B', false], 131 + ['C', true], 132 + ]); 133 + const filters = { 134 + 2: { 'true': true, 'false': false }, 135 + }; 136 + const visible = applyFilters(rows, filters); 137 + expect(visible).toHaveLength(2); 138 + expect(visible.every(r => r[2] === true)).toBe(true); 139 + }); 140 + }); 141 + 142 + describe('Filter with mixed types', () => { 143 + it('handles column with numbers, strings, booleans, and nulls', () => { 144 + const rows = makeGrid([ 145 + ['A', 42], 146 + ['B', 'text'], 147 + ['C', true], 148 + ['D', null], 149 + ['E', 0], 150 + ]); 151 + const values = getUniqueColumnValues(rows, 2); 152 + expect(values).toContain('42'); 153 + expect(values).toContain('text'); 154 + expect(values).toContain('true'); 155 + expect(values).toContain(''); 156 + expect(values).toContain('0'); 157 + }); 158 + 159 + it('filters mixed types correctly when some types are excluded', () => { 160 + const rows = makeGrid([ 161 + ['A', 42], 162 + ['B', 'text'], 163 + ['C', true], 164 + ['D', null], 165 + ]); 166 + const filters = { 167 + 2: { '42': true, 'text': false, 'true': false, '': false }, 168 + }; 169 + const visible = applyFilters(rows, filters); 170 + expect(visible).toHaveLength(1); 171 + expect(visible[0][2]).toBe(42); 172 + }); 173 + }); 174 + 175 + describe('Filter: numeric strings vs numbers', () => { 176 + it('treats number 42 and string "42" as the same filter value', () => { 177 + const rows = makeGrid([ 178 + ['A', 42], 179 + ['B', '42'], 180 + ]); 181 + const values = getUniqueColumnValues(rows, 2); 182 + // Both stringify to "42", so there should be exactly one unique value 183 + expect(values).toEqual(['42']); 184 + }); 185 + 186 + it('filtering "42" includes both number and string variants', () => { 187 + const rows = makeGrid([ 188 + ['A', 42], 189 + ['B', '42'], 190 + ['C', 99], 191 + ]); 192 + const filters = { 193 + 2: { '42': true, '99': false }, 194 + }; 195 + const visible = applyFilters(rows, filters); 196 + expect(visible).toHaveLength(2); 197 + }); 198 + }); 199 + 200 + describe('Filter: multiple simultaneous filters', () => { 201 + it('applies intersection of two column filters', () => { 202 + const rows = makeGrid([ 203 + ['Alice', 'HR', 'Senior'], 204 + ['Bob', 'Eng', 'Junior'], 205 + ['Carol', 'HR', 'Junior'], 206 + ['Dave', 'Eng', 'Senior'], 207 + ]); 208 + const filters = { 209 + 2: { 'HR': true, 'Eng': false }, 210 + 3: { 'Senior': true, 'Junior': false }, 211 + }; 212 + const visible = applyFilters(rows, filters); 213 + expect(visible).toHaveLength(1); 214 + expect(visible[0][1]).toBe('Alice'); 215 + }); 216 + 217 + it('applies intersection of three column filters', () => { 218 + const rows = makeGrid([ 219 + ['Alice', 'HR', 'Senior', 'NYC'], 220 + ['Bob', 'HR', 'Senior', 'LA'], 221 + ['Carol', 'HR', 'Junior', 'NYC'], 222 + ['Dave', 'Eng', 'Senior', 'NYC'], 223 + ]); 224 + const filters = { 225 + 2: { 'HR': true, 'Eng': false }, 226 + 3: { 'Senior': true, 'Junior': false }, 227 + 4: { 'NYC': true, 'LA': false }, 228 + }; 229 + const visible = applyFilters(rows, filters); 230 + expect(visible).toHaveLength(1); 231 + expect(visible[0][1]).toBe('Alice'); 232 + }); 233 + 234 + it('returns empty when filters are contradictory', () => { 235 + const rows = makeGrid([ 236 + ['Alice', 'HR'], 237 + ['Bob', 'Eng'], 238 + ]); 239 + const filters = { 240 + 2: { 'HR': false, 'Eng': false }, 241 + }; 242 + const visible = applyFilters(rows, filters); 243 + expect(visible).toHaveLength(0); 244 + }); 245 + }); 246 + 247 + describe('Filter: clear filter', () => { 248 + it('clearColumnFilter restores visibility for that column', () => { 249 + const rows = makeGrid([ 250 + ['Alice', 'HR'], 251 + ['Bob', 'Eng'], 252 + ]); 253 + let filters = { 254 + 2: { 'HR': true, 'Eng': false }, 255 + }; 256 + expect(applyFilters(rows, filters)).toHaveLength(1); 257 + 258 + filters = clearColumnFilter(filters, 2); 259 + expect(applyFilters(rows, filters)).toHaveLength(2); 260 + }); 261 + 262 + it('clearAllFilters restores all rows', () => { 263 + const rows = makeGrid([ 264 + ['Alice', 'HR', 'Senior'], 265 + ['Bob', 'Eng', 'Junior'], 266 + ]); 267 + let filters = { 268 + 2: { 'HR': true, 'Eng': false }, 269 + 3: { 'Senior': false, 'Junior': true }, 270 + }; 271 + expect(applyFilters(rows, filters)).toHaveLength(0); 272 + 273 + filters = clearAllFilters(); 274 + expect(applyFilters(rows, filters)).toHaveLength(2); 275 + }); 276 + 277 + it('clearColumnFilter does not affect other column filters', () => { 278 + const rows = makeGrid([ 279 + ['Alice', 'HR', 'Senior'], 280 + ['Bob', 'Eng', 'Junior'], 281 + ['Carol', 'HR', 'Junior'], 282 + ]); 283 + let filters = { 284 + 2: { 'HR': true, 'Eng': false }, 285 + 3: { 'Senior': true, 'Junior': false }, 286 + }; 287 + // Before: only Alice matches (HR + Senior) 288 + expect(applyFilters(rows, filters)).toHaveLength(1); 289 + 290 + // Clear column 2 filter, column 3 filter still active 291 + filters = clearColumnFilter(filters, 2); 292 + // Now anyone Senior: Alice and Dave... but only Alice is Senior here 293 + const visible = applyFilters(rows, filters); 294 + expect(visible).toHaveLength(1); 295 + expect(visible[0][1]).toBe('Alice'); 296 + }); 297 + }); 298 + 299 + describe('Filter: new data values not in filter state', () => { 300 + it('shows rows with values not present in the filter (default visible)', () => { 301 + const rows = makeGrid([ 302 + ['Alice', 'HR'], 303 + ['Bob', 'Eng'], 304 + ['Carol', 'Marketing'], // not in filter state 305 + ]); 306 + // Filter only knows about HR and Eng 307 + const filters = { 308 + 2: { 'HR': true, 'Eng': false }, 309 + }; 310 + const visible = applyFilters(rows, filters); 311 + // Carol's "Marketing" is not in the filter => shown by default 312 + expect(visible).toHaveLength(2); 313 + expect(visible.some(r => r[1] === 'Carol')).toBe(true); 314 + }); 315 + });
+279
tests/markdown-roundtrip.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { htmlToMarkdown } from '../src/docs/markdown-export.js'; 3 + import { markdownToHtml } from '../src/docs/markdown-parser.js'; 4 + 5 + /** 6 + * Markdown roundtrip tests: markdown -> HTML -> markdown. 7 + * 8 + * Verifies that content survives an import/export cycle by checking that the 9 + * re-exported markdown preserves the semantic content of the original. 10 + * 11 + * Note: exact whitespace/formatting may differ between cycles, so we test 12 + * for semantic equivalence (key content preserved) rather than byte-identical output. 13 + */ 14 + 15 + /** Helper: roundtrip a markdown string through import then export. */ 16 + function roundtrip(md: string): string { 17 + const html = markdownToHtml(md); 18 + return htmlToMarkdown(html); 19 + } 20 + 21 + describe('Markdown roundtrip: paragraphs', () => { 22 + it('preserves a single paragraph', () => { 23 + const result = roundtrip('Hello world'); 24 + expect(result).toContain('Hello world'); 25 + }); 26 + 27 + it('preserves multiple paragraphs separated by blank lines', () => { 28 + const result = roundtrip('Paragraph one.\n\nParagraph two.'); 29 + expect(result).toContain('Paragraph one.'); 30 + expect(result).toContain('Paragraph two.'); 31 + }); 32 + 33 + it('preserves paragraph with inline formatting', () => { 34 + const result = roundtrip('Text with **bold** and *italic* words.'); 35 + expect(result).toContain('**bold**'); 36 + expect(result).toContain('*italic*'); 37 + }); 38 + }); 39 + 40 + describe('Markdown roundtrip: headings H1-H6', () => { 41 + it('preserves H1', () => { 42 + const result = roundtrip('# Heading One'); 43 + expect(result).toContain('# Heading One'); 44 + }); 45 + 46 + it('preserves H2', () => { 47 + const result = roundtrip('## Heading Two'); 48 + expect(result).toContain('## Heading Two'); 49 + }); 50 + 51 + it('preserves H3', () => { 52 + const result = roundtrip('### Heading Three'); 53 + expect(result).toContain('### Heading Three'); 54 + }); 55 + 56 + it('preserves H4', () => { 57 + const result = roundtrip('#### Heading Four'); 58 + expect(result).toContain('#### Heading Four'); 59 + }); 60 + 61 + it('preserves H5', () => { 62 + const result = roundtrip('##### Heading Five'); 63 + expect(result).toContain('##### Heading Five'); 64 + }); 65 + 66 + it('preserves H6', () => { 67 + const result = roundtrip('###### Heading Six'); 68 + expect(result).toContain('###### Heading Six'); 69 + }); 70 + }); 71 + 72 + describe('Markdown roundtrip: bold, italic, code', () => { 73 + it('preserves bold text', () => { 74 + const result = roundtrip('This is **bold** text.'); 75 + expect(result).toContain('**bold**'); 76 + }); 77 + 78 + it('preserves italic text', () => { 79 + const result = roundtrip('This is *italic* text.'); 80 + expect(result).toContain('*italic*'); 81 + }); 82 + 83 + it('preserves inline code', () => { 84 + const result = roundtrip('Use `console.log()` for debugging.'); 85 + expect(result).toContain('`console.log()`'); 86 + }); 87 + 88 + it('preserves combined bold and italic', () => { 89 + const result = roundtrip('This is ***bold italic*** text.'); 90 + // Should contain both bold and italic markers around the content 91 + expect(result).toContain('bold italic'); 92 + // The content should have emphasis markers 93 + expect(result).toMatch(/\*{1,3}bold italic\*{1,3}/); 94 + }); 95 + }); 96 + 97 + describe('Markdown roundtrip: code blocks with language', () => { 98 + it('preserves fenced code block without language', () => { 99 + const result = roundtrip('```\nconst x = 1;\n```'); 100 + expect(result).toContain('```'); 101 + expect(result).toContain('const x = 1;'); 102 + }); 103 + 104 + it('preserves fenced code block with language specifier', () => { 105 + const result = roundtrip('```javascript\nconst x = 1;\n```'); 106 + expect(result).toContain('```javascript'); 107 + expect(result).toContain('const x = 1;'); 108 + }); 109 + 110 + it('preserves multi-line code block', () => { 111 + const code = '```python\ndef hello():\n print("hi")\n```'; 112 + const result = roundtrip(code); 113 + expect(result).toContain('```python'); 114 + expect(result).toContain('def hello():'); 115 + expect(result).toContain('print("hi")'); 116 + }); 117 + }); 118 + 119 + describe('Markdown roundtrip: lists', () => { 120 + it('preserves unordered list', () => { 121 + const result = roundtrip('- Alpha\n- Beta\n- Gamma'); 122 + expect(result).toContain('Alpha'); 123 + expect(result).toContain('Beta'); 124 + expect(result).toContain('Gamma'); 125 + // Should have list markers 126 + expect(result).toMatch(/[-*]\s+Alpha/); 127 + }); 128 + 129 + it('preserves ordered list', () => { 130 + const result = roundtrip('1. First\n2. Second\n3. Third'); 131 + expect(result).toContain('First'); 132 + expect(result).toContain('Second'); 133 + expect(result).toContain('Third'); 134 + expect(result).toContain('1.'); 135 + }); 136 + 137 + it('preserves nested unordered list', () => { 138 + const md = '- Parent\n - Child\n - Child 2\n- Sibling'; 139 + const result = roundtrip(md); 140 + expect(result).toContain('Parent'); 141 + expect(result).toContain('Child'); 142 + expect(result).toContain('Sibling'); 143 + }); 144 + 145 + it('preserves nested ordered inside unordered', () => { 146 + const md = '- Item\n 1. Sub one\n 2. Sub two'; 147 + const result = roundtrip(md); 148 + expect(result).toContain('Item'); 149 + expect(result).toContain('Sub one'); 150 + expect(result).toContain('Sub two'); 151 + }); 152 + }); 153 + 154 + describe('Markdown roundtrip: tables', () => { 155 + it('preserves simple table structure', () => { 156 + const md = '| Name | Age |\n| --- | --- |\n| Alice | 30 |'; 157 + const result = roundtrip(md); 158 + expect(result).toContain('Name'); 159 + expect(result).toContain('Age'); 160 + expect(result).toContain('Alice'); 161 + expect(result).toContain('30'); 162 + expect(result).toContain('|'); 163 + }); 164 + 165 + it('preserves multi-row table', () => { 166 + const md = '| A | B |\n| --- | --- |\n| 1 | 2 |\n| 3 | 4 |'; 167 + const result = roundtrip(md); 168 + expect(result).toContain('1'); 169 + expect(result).toContain('2'); 170 + expect(result).toContain('3'); 171 + expect(result).toContain('4'); 172 + }); 173 + }); 174 + 175 + describe('Markdown roundtrip: links', () => { 176 + it('preserves inline link', () => { 177 + const result = roundtrip('[Example](https://example.com)'); 178 + expect(result).toContain('[Example](https://example.com)'); 179 + }); 180 + 181 + it('preserves link with surrounding text', () => { 182 + const result = roundtrip('Visit [the site](https://example.com) now.'); 183 + expect(result).toContain('[the site](https://example.com)'); 184 + }); 185 + }); 186 + 187 + describe('Markdown roundtrip: images', () => { 188 + it('preserves image with alt text', () => { 189 + const result = roundtrip('![A photo](photo.jpg)'); 190 + expect(result).toContain('![A photo](photo.jpg)'); 191 + }); 192 + 193 + it('preserves image with empty alt', () => { 194 + const result = roundtrip('![](photo.jpg)'); 195 + expect(result).toContain('![](photo.jpg)'); 196 + }); 197 + }); 198 + 199 + describe('Markdown roundtrip: blockquotes', () => { 200 + it('preserves single-line blockquote', () => { 201 + const result = roundtrip('> This is a quote'); 202 + expect(result).toContain('> This is a quote'); 203 + }); 204 + 205 + it('preserves multi-line blockquote', () => { 206 + const result = roundtrip('> Line one\n>\n> Line two'); 207 + expect(result).toContain('> Line one'); 208 + expect(result).toContain('> Line two'); 209 + }); 210 + }); 211 + 212 + describe('Markdown roundtrip: horizontal rules', () => { 213 + it('preserves horizontal rule', () => { 214 + const result = roundtrip('Above\n\n---\n\nBelow'); 215 + expect(result).toContain('---'); 216 + expect(result).toContain('Above'); 217 + expect(result).toContain('Below'); 218 + }); 219 + }); 220 + 221 + describe('Markdown roundtrip: strikethrough', () => { 222 + it('preserves strikethrough text', () => { 223 + const result = roundtrip('This is ~~deleted~~ text.'); 224 + expect(result).toContain('~~deleted~~'); 225 + }); 226 + }); 227 + 228 + describe('Markdown roundtrip: task lists', () => { 229 + it('preserves unchecked task item content', () => { 230 + const result = roundtrip('- [ ] Todo item'); 231 + expect(result).toContain('Todo item'); 232 + expect(result).toContain('[ ]'); 233 + }); 234 + 235 + it('preserves checked task item content', () => { 236 + const result = roundtrip('- [x] Done item'); 237 + expect(result).toContain('Done item'); 238 + expect(result).toContain('[x]'); 239 + }); 240 + }); 241 + 242 + describe('Markdown roundtrip: complex documents', () => { 243 + it('preserves a document with mixed content types', () => { 244 + const md = [ 245 + '# Document Title', 246 + '', 247 + 'A paragraph with **bold** and *italic*.', 248 + '', 249 + '## Section', 250 + '', 251 + '- Item 1', 252 + '- Item 2', 253 + '', 254 + '> A blockquote', 255 + '', 256 + '```js', 257 + 'const x = 1;', 258 + '```', 259 + '', 260 + '---', 261 + '', 262 + '| Col A | Col B |', 263 + '| --- | --- |', 264 + '| 1 | 2 |', 265 + ].join('\n'); 266 + 267 + const result = roundtrip(md); 268 + expect(result).toContain('# Document Title'); 269 + expect(result).toContain('**bold**'); 270 + expect(result).toContain('*italic*'); 271 + expect(result).toContain('## Section'); 272 + expect(result).toContain('Item 1'); 273 + expect(result).toContain('> A blockquote'); 274 + expect(result).toContain('```'); 275 + expect(result).toContain('const x = 1;'); 276 + expect(result).toContain('---'); 277 + expect(result).toContain('Col A'); 278 + }); 279 + });
+448
tests/pivot-edges.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + computePivot, 4 + aggregate, 5 + extractKey, 6 + keyToString, 7 + formatAggregateValue, 8 + flatPivotValues, 9 + type PivotConfig, 10 + type PivotResult, 11 + } from '../src/sheets/pivot-table.js'; 12 + 13 + /** 14 + * Pivot table edge case tests: empty data, null values, mixed types, 15 + * single row, aggregate functions, key utilities, formatting. 16 + */ 17 + 18 + /** Helper: convert col number to letter (1=A, 2=B, ...) */ 19 + function colToLetter(col: number): string { 20 + let s = ''; 21 + let n = col; 22 + while (n > 0) { 23 + n--; 24 + s = String.fromCharCode(65 + (n % 26)) + s; 25 + n = Math.floor(n / 26); 26 + } 27 + return s; 28 + } 29 + 30 + /** Helper: build row data from a 2D array (1-indexed rows starting at startRow). */ 31 + function buildRows(data: unknown[][], startRow = 1): Map<string, unknown>[] { 32 + return data.map((rowData, i) => { 33 + const row = new Map<string, unknown>(); 34 + rowData.forEach((val, colIdx) => { 35 + const cellId = `${colToLetter(colIdx + 1)}${startRow + i}`; 36 + row.set(cellId, val); 37 + }); 38 + return row; 39 + }); 40 + } 41 + 42 + // ============================================================ 43 + // Empty data 44 + // ============================================================ 45 + 46 + describe('Pivot table: empty data', () => { 47 + it('returns empty result for zero rows', () => { 48 + const config: PivotConfig = { 49 + rowFields: [1], 50 + colFields: [], 51 + valueField: 2, 52 + aggregation: 'sum', 53 + }; 54 + const result = computePivot([], config, colToLetter); 55 + expect(result.rowKeys).toHaveLength(0); 56 + expect(result.colKeys).toHaveLength(0); 57 + expect(result.cells).toHaveLength(0); 58 + expect(result.grandTotal.value).toBe(0); 59 + expect(result.grandTotal.count).toBe(0); 60 + }); 61 + }); 62 + 63 + // ============================================================ 64 + // Null / undefined values 65 + // ============================================================ 66 + 67 + describe('Pivot table: null and undefined values', () => { 68 + it('treats null values as 0 for numeric aggregation', () => { 69 + const rows = buildRows([ 70 + ['A', null], 71 + ['A', 10], 72 + ]); 73 + const config: PivotConfig = { 74 + rowFields: [1], 75 + colFields: [], 76 + valueField: 2, 77 + aggregation: 'sum', 78 + }; 79 + const result = computePivot(rows, config, colToLetter); 80 + expect(result.rowKeys).toHaveLength(1); 81 + // null coerces to 0, so sum = 0 + 10 = 10 82 + expect(result.grandTotal.value).toBe(10); 83 + }); 84 + 85 + it('treats undefined values as 0 for numeric aggregation', () => { 86 + const rows = buildRows([ 87 + ['A', undefined], 88 + ['A', 5], 89 + ]); 90 + const config: PivotConfig = { 91 + rowFields: [1], 92 + colFields: [], 93 + valueField: 2, 94 + aggregation: 'sum', 95 + }; 96 + const result = computePivot(rows, config, colToLetter); 97 + expect(result.grandTotal.value).toBe(5); 98 + }); 99 + 100 + it('counts null values in count aggregation', () => { 101 + const rows = buildRows([ 102 + ['A', null], 103 + ['A', 10], 104 + ['A', null], 105 + ]); 106 + const config: PivotConfig = { 107 + rowFields: [1], 108 + colFields: [], 109 + valueField: 2, 110 + aggregation: 'count', 111 + }; 112 + const result = computePivot(rows, config, colToLetter); 113 + expect(result.grandTotal.value).toBe(3); 114 + }); 115 + }); 116 + 117 + // ============================================================ 118 + // Mixed types in value field 119 + // ============================================================ 120 + 121 + describe('Pivot table: mixed types in value field', () => { 122 + it('coerces string numbers to Number', () => { 123 + const rows = buildRows([ 124 + ['A', '10'], 125 + ['A', '20'], 126 + ]); 127 + const config: PivotConfig = { 128 + rowFields: [1], 129 + colFields: [], 130 + valueField: 2, 131 + aggregation: 'sum', 132 + }; 133 + const result = computePivot(rows, config, colToLetter); 134 + expect(result.grandTotal.value).toBe(30); 135 + }); 136 + 137 + it('treats non-numeric strings as 0', () => { 138 + const rows = buildRows([ 139 + ['A', 'hello'], 140 + ['A', 10], 141 + ]); 142 + const config: PivotConfig = { 143 + rowFields: [1], 144 + colFields: [], 145 + valueField: 2, 146 + aggregation: 'sum', 147 + }; 148 + const result = computePivot(rows, config, colToLetter); 149 + // 'hello' -> NaN -> 0, 10 -> 10, sum = 10 150 + expect(result.grandTotal.value).toBe(10); 151 + }); 152 + 153 + it('treats booleans as 0 (NaN fallback)', () => { 154 + const rows = buildRows([ 155 + ['A', true], 156 + ['A', 5], 157 + ]); 158 + const config: PivotConfig = { 159 + rowFields: [1], 160 + colFields: [], 161 + valueField: 2, 162 + aggregation: 'sum', 163 + }; 164 + const result = computePivot(rows, config, colToLetter); 165 + // true -> Number(true) = 1, so sum = 1 + 5 = 6 166 + expect(result.grandTotal.value).toBe(6); 167 + }); 168 + }); 169 + 170 + // ============================================================ 171 + // Single row 172 + // ============================================================ 173 + 174 + describe('Pivot table: single row', () => { 175 + it('produces a 1x1 pivot from a single data row', () => { 176 + const rows = buildRows([['East', 100]]); 177 + const config: PivotConfig = { 178 + rowFields: [1], 179 + colFields: [], 180 + valueField: 2, 181 + aggregation: 'sum', 182 + }; 183 + const result = computePivot(rows, config, colToLetter); 184 + expect(result.rowKeys).toHaveLength(1); 185 + expect(result.rowKeys[0]).toEqual(['East']); 186 + expect(result.grandTotal.value).toBe(100); 187 + expect(result.grandTotal.count).toBe(1); 188 + }); 189 + 190 + it('row totals match the single cell value', () => { 191 + const rows = buildRows([['East', 42]]); 192 + const config: PivotConfig = { 193 + rowFields: [1], 194 + colFields: [], 195 + valueField: 2, 196 + aggregation: 'sum', 197 + }; 198 + const result = computePivot(rows, config, colToLetter); 199 + expect(result.rowTotals).toHaveLength(1); 200 + expect(result.rowTotals[0].value).toBe(42); 201 + }); 202 + }); 203 + 204 + // ============================================================ 205 + // Multiple row groups with column groups 206 + // ============================================================ 207 + 208 + describe('Pivot table: row and column grouping', () => { 209 + it('groups by both row and column fields', () => { 210 + const rows = buildRows([ 211 + ['East', 'Q1', 10], 212 + ['East', 'Q2', 20], 213 + ['West', 'Q1', 30], 214 + ['West', 'Q2', 40], 215 + ]); 216 + const config: PivotConfig = { 217 + rowFields: [1], 218 + colFields: [2], 219 + valueField: 3, 220 + aggregation: 'sum', 221 + }; 222 + const result = computePivot(rows, config, colToLetter); 223 + expect(result.rowKeys).toHaveLength(2); 224 + expect(result.colKeys).toHaveLength(2); 225 + expect(result.grandTotal.value).toBe(100); 226 + }); 227 + }); 228 + 229 + // ============================================================ 230 + // Aggregate function coverage 231 + // ============================================================ 232 + 233 + describe('aggregate function', () => { 234 + it('sum returns sum of values', () => { 235 + expect(aggregate([1, 2, 3], 'sum')).toBe(6); 236 + }); 237 + 238 + it('count returns number of values', () => { 239 + expect(aggregate([1, 2, 3], 'count')).toBe(3); 240 + }); 241 + 242 + it('avg returns average', () => { 243 + expect(aggregate([2, 4, 6], 'avg')).toBe(4); 244 + }); 245 + 246 + it('min returns minimum', () => { 247 + expect(aggregate([5, 3, 8, 1], 'min')).toBe(1); 248 + }); 249 + 250 + it('max returns maximum', () => { 251 + expect(aggregate([5, 3, 8, 1], 'max')).toBe(8); 252 + }); 253 + 254 + it('countDistinct returns count of unique values', () => { 255 + expect(aggregate([1, 2, 2, 3, 3, 3], 'countDistinct')).toBe(3); 256 + }); 257 + 258 + it('all functions return 0 for empty array', () => { 259 + expect(aggregate([], 'sum')).toBe(0); 260 + expect(aggregate([], 'count')).toBe(0); 261 + expect(aggregate([], 'avg')).toBe(0); 262 + expect(aggregate([], 'min')).toBe(0); 263 + expect(aggregate([], 'max')).toBe(0); 264 + expect(aggregate([], 'countDistinct')).toBe(0); 265 + }); 266 + 267 + it('sum handles negative numbers', () => { 268 + expect(aggregate([-1, -2, 3], 'sum')).toBe(0); 269 + }); 270 + 271 + it('avg handles single value', () => { 272 + expect(aggregate([42], 'avg')).toBe(42); 273 + }); 274 + 275 + it('countDistinct with all same values returns 1', () => { 276 + expect(aggregate([7, 7, 7], 'countDistinct')).toBe(1); 277 + }); 278 + }); 279 + 280 + // ============================================================ 281 + // Key utilities 282 + // ============================================================ 283 + 284 + describe('extractKey', () => { 285 + it('extracts key from row data for given fields', () => { 286 + const row = new Map<string, unknown>(); 287 + row.set('A1', 'East'); 288 + row.set('B1', 'Q1'); 289 + const key = extractKey(row, [1, 2], colToLetter, 1); 290 + expect(key).toEqual(['East', 'Q1']); 291 + }); 292 + 293 + it('returns empty strings for missing values', () => { 294 + const row = new Map<string, unknown>(); 295 + const key = extractKey(row, [1, 2], colToLetter, 1); 296 + expect(key).toEqual(['', '']); 297 + }); 298 + }); 299 + 300 + describe('keyToString', () => { 301 + it('joins key parts with null character', () => { 302 + expect(keyToString(['A', 'B'])).toBe('A\0B'); 303 + }); 304 + 305 + it('handles single element', () => { 306 + expect(keyToString(['X'])).toBe('X'); 307 + }); 308 + 309 + it('handles empty array', () => { 310 + expect(keyToString([])).toBe(''); 311 + }); 312 + }); 313 + 314 + // ============================================================ 315 + // Format aggregate value 316 + // ============================================================ 317 + 318 + describe('formatAggregateValue', () => { 319 + it('formats count as integer', () => { 320 + expect(formatAggregateValue(5, 'count')).toBe('5'); 321 + expect(formatAggregateValue(5.7, 'count')).toBe('6'); 322 + }); 323 + 324 + it('formats countDistinct as integer', () => { 325 + expect(formatAggregateValue(3, 'countDistinct')).toBe('3'); 326 + }); 327 + 328 + it('formats avg with 2 decimal places', () => { 329 + expect(formatAggregateValue(3.14159, 'avg')).toBe('3.14'); 330 + }); 331 + 332 + it('formats sum as integer when whole number', () => { 333 + expect(formatAggregateValue(100, 'sum')).toBe('100'); 334 + }); 335 + 336 + it('formats sum with 2 decimals when fractional', () => { 337 + expect(formatAggregateValue(100.5, 'sum')).toBe('100.50'); 338 + }); 339 + 340 + it('formats min/max as integer when whole', () => { 341 + expect(formatAggregateValue(42, 'min')).toBe('42'); 342 + expect(formatAggregateValue(99, 'max')).toBe('99'); 343 + }); 344 + 345 + it('formats min/max with 2 decimals when fractional', () => { 346 + expect(formatAggregateValue(3.7, 'min')).toBe('3.70'); 347 + }); 348 + }); 349 + 350 + // ============================================================ 351 + // flatPivotValues 352 + // ============================================================ 353 + 354 + describe('flatPivotValues', () => { 355 + it('extracts non-null cell values from pivot result', () => { 356 + const result: PivotResult = { 357 + rowKeys: [['A'], ['B']], 358 + colKeys: [['X']], 359 + cells: [ 360 + [{ value: 10, count: 1 }], 361 + [null], 362 + ], 363 + rowTotals: [{ value: 10, count: 1 }, { value: 0, count: 0 }], 364 + colTotals: [{ value: 10, count: 1 }], 365 + grandTotal: { value: 10, count: 1 }, 366 + }; 367 + const values = flatPivotValues(result); 368 + expect(values).toEqual([10]); 369 + }); 370 + 371 + it('returns empty array when all cells are null', () => { 372 + const result: PivotResult = { 373 + rowKeys: [['A']], 374 + colKeys: [['X']], 375 + cells: [[null]], 376 + rowTotals: [{ value: 0, count: 0 }], 377 + colTotals: [{ value: 0, count: 0 }], 378 + grandTotal: { value: 0, count: 0 }, 379 + }; 380 + expect(flatPivotValues(result)).toEqual([]); 381 + }); 382 + 383 + it('returns empty array for empty cells grid', () => { 384 + const result: PivotResult = { 385 + rowKeys: [], 386 + colKeys: [], 387 + cells: [], 388 + rowTotals: [], 389 + colTotals: [], 390 + grandTotal: { value: 0, count: 0 }, 391 + }; 392 + expect(flatPivotValues(result)).toEqual([]); 393 + }); 394 + }); 395 + 396 + // ============================================================ 397 + // Pivot with startRow offset 398 + // ============================================================ 399 + 400 + describe('Pivot table: custom startRow', () => { 401 + it('uses correct cell IDs when startRow is 2 (skipping header row)', () => { 402 + // Simulates data starting at row 2 (row 1 is headers) 403 + const rows = buildRows([ 404 + ['East', 100], 405 + ['West', 200], 406 + ], 2); 407 + const config: PivotConfig = { 408 + rowFields: [1], 409 + colFields: [], 410 + valueField: 2, 411 + aggregation: 'sum', 412 + }; 413 + const result = computePivot(rows, config, colToLetter, 2); 414 + expect(result.rowKeys).toHaveLength(2); 415 + expect(result.grandTotal.value).toBe(300); 416 + }); 417 + }); 418 + 419 + // ============================================================ 420 + // Pivot: duplicate row keys aggregate correctly 421 + // ============================================================ 422 + 423 + describe('Pivot table: duplicate row keys', () => { 424 + it('aggregates values with the same row key', () => { 425 + const rows = buildRows([ 426 + ['East', 10], 427 + ['East', 20], 428 + ['East', 30], 429 + ['West', 40], 430 + ]); 431 + const config: PivotConfig = { 432 + rowFields: [1], 433 + colFields: [], 434 + valueField: 2, 435 + aggregation: 'sum', 436 + }; 437 + const result = computePivot(rows, config, colToLetter); 438 + expect(result.rowKeys).toHaveLength(2); 439 + // East sum = 60 440 + const eastIdx = result.rowKeys.findIndex(k => k[0] === 'East'); 441 + expect(result.rowTotals[eastIdx].value).toBe(60); 442 + // West sum = 40 443 + const westIdx = result.rowKeys.findIndex(k => k[0] === 'West'); 444 + expect(result.rowTotals[westIdx].value).toBe(40); 445 + // Grand total = 100 446 + expect(result.grandTotal.value).toBe(100); 447 + }); 448 + });
+266
tests/recalc-undo.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { RecalcEngine } from '../src/sheets/recalc.js'; 3 + 4 + /** 5 + * Recalculation after undo tests. 6 + * 7 + * Simulates the undo pattern: set values, recalc, change values, recalc, 8 + * then "undo" by restoring original values and recalculating again. 9 + * Verifies the dependency chain propagates correctly through all phases. 10 + */ 11 + 12 + function makeCellStore(data: Record<string, { v: unknown; f: string }>) { 13 + const store = new Map<string, { v: unknown; f: string }>(); 14 + for (const [id, cell] of Object.entries(data)) { 15 + store.set(id, { ...cell }); 16 + } 17 + return { 18 + get(id: string) { return store.get(id) || null; }, 19 + set(id: string, cell: { v: unknown; f: string }) { store.set(id, { ...cell }); }, 20 + has(id: string) { return store.has(id); }, 21 + entries() { return store.entries(); }, 22 + getAllFormulaCells() { 23 + const result: [string, { v: unknown; f: string }][] = []; 24 + for (const [id, cell] of store.entries()) { 25 + if (cell.f) result.push([id, cell]); 26 + } 27 + return result; 28 + }, 29 + }; 30 + } 31 + 32 + describe('Recalc after undo: A1=1, B1=A1+1', () => { 33 + it('initial state: B1=2', () => { 34 + const store = makeCellStore({ 35 + A1: { v: 1, f: '' }, 36 + B1: { v: '', f: 'A1+1' }, 37 + }); 38 + const engine = new RecalcEngine(store); 39 + engine.buildFullGraph(); 40 + engine.recalculate('A1'); 41 + expect(store.get('B1')!.v).toBe(2); 42 + }); 43 + 44 + it('after edit A1=5: B1=6', () => { 45 + const store = makeCellStore({ 46 + A1: { v: 1, f: '' }, 47 + B1: { v: '', f: 'A1+1' }, 48 + }); 49 + const engine = new RecalcEngine(store); 50 + engine.buildFullGraph(); 51 + engine.recalculate('A1'); 52 + expect(store.get('B1')!.v).toBe(2); 53 + 54 + // Edit A1 to 5 55 + store.set('A1', { v: 5, f: '' }); 56 + engine.recalculate('A1'); 57 + expect(store.get('B1')!.v).toBe(6); 58 + }); 59 + 60 + it('after undo (A1 back to 1): B1=2 again', () => { 61 + const store = makeCellStore({ 62 + A1: { v: 1, f: '' }, 63 + B1: { v: '', f: 'A1+1' }, 64 + }); 65 + const engine = new RecalcEngine(store); 66 + engine.buildFullGraph(); 67 + 68 + // Initial 69 + engine.recalculate('A1'); 70 + expect(store.get('B1')!.v).toBe(2); 71 + 72 + // Edit A1=5 73 + store.set('A1', { v: 5, f: '' }); 74 + engine.recalculate('A1'); 75 + expect(store.get('B1')!.v).toBe(6); 76 + 77 + // Undo: restore A1=1 78 + store.set('A1', { v: 1, f: '' }); 79 + const changed = engine.recalculate('A1'); 80 + expect(store.get('B1')!.v).toBe(2); 81 + expect(changed.has('B1')).toBe(true); 82 + }); 83 + }); 84 + 85 + describe('Recalc after undo: multi-cell chain A1 -> B1 -> C1', () => { 86 + it('propagates through a 3-cell chain after undo', () => { 87 + const store = makeCellStore({ 88 + A1: { v: 1, f: '' }, 89 + B1: { v: '', f: 'A1+1' }, 90 + C1: { v: '', f: 'B1*2' }, 91 + }); 92 + const engine = new RecalcEngine(store); 93 + engine.buildFullGraph(); 94 + 95 + // Initial: A1=1, B1=2, C1=4 96 + engine.recalculate('A1'); 97 + expect(store.get('B1')!.v).toBe(2); 98 + expect(store.get('C1')!.v).toBe(4); 99 + 100 + // Edit A1=10: B1=11, C1=22 101 + store.set('A1', { v: 10, f: '' }); 102 + engine.recalculate('A1'); 103 + expect(store.get('B1')!.v).toBe(11); 104 + expect(store.get('C1')!.v).toBe(22); 105 + 106 + // Undo: A1=1 again: B1=2, C1=4 107 + store.set('A1', { v: 1, f: '' }); 108 + engine.recalculate('A1'); 109 + expect(store.get('B1')!.v).toBe(2); 110 + expect(store.get('C1')!.v).toBe(4); 111 + }); 112 + }); 113 + 114 + describe('Recalc after undo: diamond dependency', () => { 115 + it('handles undo in diamond pattern: A1 -> B1, A1 -> C1, both -> D1', () => { 116 + const store = makeCellStore({ 117 + A1: { v: 10, f: '' }, 118 + B1: { v: '', f: 'A1+1' }, 119 + C1: { v: '', f: 'A1+2' }, 120 + D1: { v: '', f: 'B1+C1' }, 121 + }); 122 + const engine = new RecalcEngine(store); 123 + engine.buildFullGraph(); 124 + 125 + // Initial: A1=10, B1=11, C1=12, D1=23 126 + engine.recalculate('A1'); 127 + expect(store.get('D1')!.v).toBe(23); 128 + 129 + // Edit A1=20: B1=21, C1=22, D1=43 130 + store.set('A1', { v: 20, f: '' }); 131 + engine.recalculate('A1'); 132 + expect(store.get('D1')!.v).toBe(43); 133 + 134 + // Undo: A1=10: D1 should be 23 again 135 + store.set('A1', { v: 10, f: '' }); 136 + engine.recalculate('A1'); 137 + expect(store.get('D1')!.v).toBe(23); 138 + }); 139 + }); 140 + 141 + describe('Recalc after undo: formula cell edit and restore', () => { 142 + it('handles undo of a formula change', () => { 143 + const store = makeCellStore({ 144 + A1: { v: 5, f: '' }, 145 + B1: { v: 10, f: '' }, 146 + C1: { v: '', f: 'A1+1' }, 147 + }); 148 + const engine = new RecalcEngine(store); 149 + engine.buildFullGraph(); 150 + engine.recalculate('A1'); 151 + expect(store.get('C1')!.v).toBe(6); 152 + 153 + // Change C1 formula to reference B1 154 + store.set('C1', { v: '', f: 'B1+1' }); 155 + engine.updateCell('C1'); 156 + engine.recalculate('C1'); 157 + expect(store.get('C1')!.v).toBe(11); 158 + 159 + // Undo: restore C1 formula to A1+1 160 + store.set('C1', { v: '', f: 'A1+1' }); 161 + engine.updateCell('C1'); 162 + engine.recalculate('C1'); 163 + expect(store.get('C1')!.v).toBe(6); 164 + }); 165 + }); 166 + 167 + describe('Recalc after undo: multiple edits then undo all', () => { 168 + it('correctly recalculates after undoing two edits sequentially', () => { 169 + const store = makeCellStore({ 170 + A1: { v: 1, f: '' }, 171 + B1: { v: 2, f: '' }, 172 + C1: { v: '', f: 'A1+B1' }, 173 + }); 174 + const engine = new RecalcEngine(store); 175 + engine.buildFullGraph(); 176 + 177 + // Initial: C1 = 1+2 = 3 178 + engine.recalculate('A1'); 179 + engine.recalculate('B1'); 180 + expect(store.get('C1')!.v).toBe(3); 181 + 182 + // Edit A1=10: C1 = 10+2 = 12 183 + store.set('A1', { v: 10, f: '' }); 184 + engine.recalculate('A1'); 185 + expect(store.get('C1')!.v).toBe(12); 186 + 187 + // Edit B1=20: C1 = 10+20 = 30 188 + store.set('B1', { v: 20, f: '' }); 189 + engine.recalculate('B1'); 190 + expect(store.get('C1')!.v).toBe(30); 191 + 192 + // Undo B1 back to 2: C1 = 10+2 = 12 193 + store.set('B1', { v: 2, f: '' }); 194 + engine.recalculate('B1'); 195 + expect(store.get('C1')!.v).toBe(12); 196 + 197 + // Undo A1 back to 1: C1 = 1+2 = 3 198 + store.set('A1', { v: 1, f: '' }); 199 + engine.recalculate('A1'); 200 + expect(store.get('C1')!.v).toBe(3); 201 + }); 202 + }); 203 + 204 + describe('Recalc after undo: deep chain (5 cells)', () => { 205 + it('propagates undo through a 5-cell chain', () => { 206 + const store = makeCellStore({ 207 + A1: { v: 1, f: '' }, 208 + B1: { v: '', f: 'A1+1' }, // 2 209 + C1: { v: '', f: 'B1+1' }, // 3 210 + D1: { v: '', f: 'C1+1' }, // 4 211 + E1: { v: '', f: 'D1+1' }, // 5 212 + }); 213 + const engine = new RecalcEngine(store); 214 + engine.buildFullGraph(); 215 + 216 + engine.recalculate('A1'); 217 + expect(store.get('E1')!.v).toBe(5); 218 + 219 + // Edit A1=100 220 + store.set('A1', { v: 100, f: '' }); 221 + engine.recalculate('A1'); 222 + expect(store.get('E1')!.v).toBe(104); 223 + 224 + // Undo 225 + store.set('A1', { v: 1, f: '' }); 226 + engine.recalculate('A1'); 227 + expect(store.get('B1')!.v).toBe(2); 228 + expect(store.get('C1')!.v).toBe(3); 229 + expect(store.get('D1')!.v).toBe(4); 230 + expect(store.get('E1')!.v).toBe(5); 231 + }); 232 + }); 233 + 234 + describe('Recalc after undo: unrelated cells are not affected', () => { 235 + it('does not recompute unrelated formulas during undo', () => { 236 + const evalOrder: string[] = []; 237 + const store = makeCellStore({ 238 + A1: { v: 1, f: '' }, 239 + B1: { v: '', f: 'A1+1' }, 240 + X1: { v: 99, f: '' }, 241 + Y1: { v: '', f: 'X1*2' }, 242 + }); 243 + const engine = new RecalcEngine(store, { 244 + onEvaluate(cellId: string) { evalOrder.push(cellId); }, 245 + }); 246 + engine.buildFullGraph(); 247 + 248 + // Initial 249 + engine.recalculate('A1'); 250 + engine.recalculate('X1'); 251 + evalOrder.length = 0; 252 + 253 + // Edit A1=5 254 + store.set('A1', { v: 5, f: '' }); 255 + engine.recalculate('A1'); 256 + expect(evalOrder).toContain('B1'); 257 + expect(evalOrder).not.toContain('Y1'); 258 + evalOrder.length = 0; 259 + 260 + // Undo A1=1 261 + store.set('A1', { v: 1, f: '' }); 262 + engine.recalculate('A1'); 263 + expect(evalOrder).toContain('B1'); 264 + expect(evalOrder).not.toContain('Y1'); 265 + }); 266 + });