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

Configure Feed

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

test: edge case coverage for custom-format, outline, xlsx-import (#433)

Add 52 edge case tests across three modules:
- custom-format: date format disambiguation, multi-section, fraction edge cases
- outline: skipped heading levels, orphan headings, ID generation edge cases
- xlsx-import: format priority, signature validation, non-ZIP detection

+358
+143
tests/custom-format.test.ts
··· 270 270 }); 271 271 }); 272 272 273 + // ===================================================================== 274 + // applyDateFormat — edge cases (minute vs month, serial numbers, NaN) 275 + // ===================================================================== 276 + 277 + describe('applyDateFormat — edge cases', () => { 278 + it('uses minutes (not months) for mm after hh in time format', () => { 279 + const date = new Date(2026, 2, 19, 14, 5, 30); // 14:05:30 280 + const result = applyDateFormat(date, 'hh:mm:ss'); 281 + expect(result).toBe('14:05:30'); 282 + }); 283 + 284 + it('uses months for standalone mm (no hh in format)', () => { 285 + const date = new Date(2026, 2, 19); 286 + expect(applyDateFormat(date, 'mm/yyyy')).toBe('03/2026'); 287 + }); 288 + 289 + it('converts Excel serial number (<=100000) to date', () => { 290 + // 44927 = 2023-01-01 in Excel serial dates 291 + const result = applyDateFormat(44927, 'yyyy-mm-dd'); 292 + expect(result).toBe('2023-01-01'); 293 + }); 294 + 295 + it('treats large number (>100000) as JS timestamp in milliseconds', () => { 296 + const ts = new Date(2026, 0, 15).getTime(); // Jan 15 2026 297 + const result = applyDateFormat(ts, 'yyyy-mm-dd'); 298 + expect(result).toBe('2026-01-15'); 299 + }); 300 + 301 + it('returns String(value) for NaN input', () => { 302 + expect(applyDateFormat('not-a-date', 'yyyy-mm-dd')).toBe('not-a-date'); 303 + }); 304 + 305 + it('returns String(value) for invalid Date result', () => { 306 + expect(applyDateFormat(NaN, 'yyyy-mm-dd')).toBe('NaN'); 307 + }); 308 + 309 + it('handles Date object with hh:mm:ss time format', () => { 310 + const date = new Date(2026, 5, 10, 9, 45, 12); 311 + expect(applyDateFormat(date, 'hh:mm:ss')).toBe('09:45:12'); 312 + }); 313 + 314 + it('handles combined date-time format with minutes after hh', () => { 315 + const date = new Date(2026, 2, 19, 14, 30, 0); 316 + const result = applyDateFormat(date, 'yyyy-mm-dd hh:mm'); 317 + // First mm (before dd) = month (03), second mm (after hh:) = minutes (30) 318 + expect(result).toBe('2026-03-19 14:30'); 319 + }); 320 + }); 321 + 322 + // ===================================================================== 323 + // applyCustomFormat — edge cases (multi-section, color+percent, literal) 324 + // ===================================================================== 325 + 326 + describe('applyCustomFormat — edge cases', () => { 327 + it('zero falls through to first section when only 2 sections', () => { 328 + // 2-section format: positive;negative — zero uses positive section 329 + const result = applyCustomFormat(0, '#,##0;(#,##0)'); 330 + expect(result).toBe('0'); 331 + }); 332 + 333 + it('color override with percent format combined', () => { 334 + const result = applyCustomFormat(0.5, '[Red]0.0%'); 335 + expect(result).toBe('50.0%'); 336 + }); 337 + 338 + it('pure literal format returns literal text', () => { 339 + expect(applyCustomFormat(42, '"N/A"')).toBe('N/A'); 340 + expect(applyCustomFormat(0, '"---"')).toBe('---'); 341 + }); 342 + 343 + it('negative number uses absolute value in second section', () => { 344 + expect(applyCustomFormat(-42.5, '0.0;0.0')).toBe('42.5'); 345 + }); 346 + 347 + it('3-section format with zero section as literal', () => { 348 + expect(applyCustomFormat(0, '#,##0;(#,##0);"zero"')).toBe('zero'); 349 + }); 350 + 351 + it('non-numeric string with numeric format returns string as-is', () => { 352 + expect(applyCustomFormat('abc', '0.00')).toBe('abc'); 353 + expect(applyCustomFormat('N/A', '#,##0')).toBe('N/A'); 354 + }); 355 + }); 356 + 357 + // ===================================================================== 358 + // formatFraction — edge cases 359 + // ===================================================================== 360 + 361 + describe('formatFraction — edge cases', () => { 362 + it('very small fractional part below threshold returns whole', () => { 363 + expect(formatFraction(5.00001, 1)).toBe('5'); 364 + }); 365 + 366 + it('2-digit denominator gives better precision', () => { 367 + // 0.375 = 3/8 with 2-digit denominator 368 + const result = formatFraction(0.375, 2); 369 + expect(result).toBe('3/8'); 370 + }); 371 + 372 + it('formats zero correctly', () => { 373 + expect(formatFraction(0, 1)).toBe('0'); 374 + }); 375 + 376 + it('formats negative mixed number', () => { 377 + expect(formatFraction(-2.5, 1)).toBe('-2 1/2'); 378 + }); 379 + 380 + it('1/4 with single digit denominator', () => { 381 + expect(formatFraction(0.25, 1)).toBe('1/4'); 382 + }); 383 + }); 384 + 385 + // ===================================================================== 386 + // parseFormatString — edge cases 387 + // ===================================================================== 388 + 389 + describe('parseFormatString — edge cases', () => { 390 + it('extracts suffix after last digit placeholder', () => { 391 + const result = parseFormatString('0.00 "units"'); 392 + expect(result.suffix).toContain('units'); 393 + }); 394 + 395 + it('no color override when format lacks brackets', () => { 396 + const result = parseFormatString('0.00'); 397 + expect(result.colorOverride).toBeUndefined(); 398 + }); 399 + 400 + it('handles fraction format with ? placeholders', () => { 401 + const result = parseFormatString('# ?/?'); 402 + expect(result.decimalPlaces).toBe(0); // ? is not a decimal 403 + }); 404 + 405 + it('detects lowercase scientific notation', () => { 406 + const result = parseFormatString('0.00e+0'); 407 + expect(result.isScientific).toBe(true); 408 + }); 409 + 410 + it('no prefix when format starts with digit placeholder', () => { 411 + const result = parseFormatString('#,##0'); 412 + expect(result.prefix).toBe(''); 413 + }); 414 + }); 415 + 273 416 describe('FORMAT_PRESETS', () => { 274 417 it('has at least 5 presets', () => { 275 418 expect(FORMAT_PRESETS.length).toBeGreaterThanOrEqual(5);
+138
tests/outline.test.ts
··· 219 219 }); 220 220 }); 221 221 222 + // ===================================================================== 223 + // buildOutlineTree — edge cases (skipped levels, deep nesting) 224 + // ===================================================================== 225 + 226 + describe('buildOutlineTree — edge cases', () => { 227 + it('H3 before any H1 or H2 — appears at root level', () => { 228 + const headings = [ 229 + { level: 3, text: 'Deep Start', id: 'deep-start' }, 230 + { level: 1, text: 'Later H1', id: 'later-h1' }, 231 + ]; 232 + const tree = buildOutlineTree(headings); 233 + expect(tree.length).toBe(2); 234 + expect(tree[0].text).toBe('Deep Start'); 235 + expect(tree[0].children).toEqual([]); 236 + }); 237 + 238 + it('H1 then H3 (skipping H2) — H3 nests under H1', () => { 239 + const headings = [ 240 + { level: 1, text: 'Top', id: 'top' }, 241 + { level: 3, text: 'Skipped H2', id: 'skipped' }, 242 + ]; 243 + const tree = buildOutlineTree(headings); 244 + expect(tree.length).toBe(1); 245 + expect(tree[0].children.length).toBe(1); 246 + expect(tree[0].children[0].text).toBe('Skipped H2'); 247 + }); 248 + 249 + it('alternating H1, H3, H1, H3 — each H3 nests under its H1', () => { 250 + const headings = [ 251 + { level: 1, text: 'A', id: 'a' }, 252 + { level: 3, text: 'A-sub', id: 'a-sub' }, 253 + { level: 1, text: 'B', id: 'b' }, 254 + { level: 3, text: 'B-sub', id: 'b-sub' }, 255 + ]; 256 + const tree = buildOutlineTree(headings); 257 + expect(tree.length).toBe(2); 258 + expect(tree[0].children[0].text).toBe('A-sub'); 259 + expect(tree[1].children[0].text).toBe('B-sub'); 260 + }); 261 + 262 + it('returns null/undefined input as empty array', () => { 263 + expect(buildOutlineTree(null as any)).toEqual([]); 264 + expect(buildOutlineTree(undefined as any)).toEqual([]); 265 + }); 266 + 267 + it('deep descent H1→H2→H3 then back to H1', () => { 268 + const headings = [ 269 + { level: 1, text: 'A', id: 'a' }, 270 + { level: 2, text: 'B', id: 'b' }, 271 + { level: 3, text: 'C', id: 'c' }, 272 + { level: 1, text: 'D', id: 'd' }, 273 + ]; 274 + const tree = buildOutlineTree(headings); 275 + expect(tree.length).toBe(2); 276 + expect(tree[0].children[0].children[0].text).toBe('C'); 277 + expect(tree[1].text).toBe('D'); 278 + expect(tree[1].children).toEqual([]); 279 + }); 280 + 281 + it('all H3 headings — all at root level', () => { 282 + const headings = [ 283 + { level: 3, text: 'A', id: 'a' }, 284 + { level: 3, text: 'B', id: 'b' }, 285 + { level: 3, text: 'C', id: 'c' }, 286 + ]; 287 + const tree = buildOutlineTree(headings); 288 + expect(tree.length).toBe(3); 289 + }); 290 + }); 291 + 292 + // ===================================================================== 293 + // generateHeadingId — edge cases 294 + // ===================================================================== 295 + 296 + describe('generateHeadingId — edge cases', () => { 297 + it('all-special-characters input falls back to "heading"', () => { 298 + expect(generateHeadingId('!@#$%^&*()')).toBe('heading'); 299 + }); 300 + 301 + it('unicode characters are stripped', () => { 302 + expect(generateHeadingId('Héllo Wörld')).toBe('hllo-wrld'); 303 + }); 304 + 305 + it('numbers in heading are preserved', () => { 306 + expect(generateHeadingId('Step 1: Setup')).toBe('step-1-setup'); 307 + }); 308 + 309 + it('leading/trailing spaces produce clean id', () => { 310 + expect(generateHeadingId(' spaced ')).toBe('spaced'); 311 + }); 312 + 313 + it('hyphens in original text are preserved', () => { 314 + expect(generateHeadingId('well-known-fact')).toBe('well-known-fact'); 315 + }); 316 + }); 317 + 318 + // ===================================================================== 319 + // extractHeadings — edge cases 320 + // ===================================================================== 321 + 322 + describe('extractHeadings — edge cases', () => { 323 + it('duplicate heading text generates unique IDs with suffix', () => { 324 + const json = { 325 + content: [ 326 + { type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Overview' }] }, 327 + { type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Overview' }] }, 328 + { type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Overview' }] }, 329 + ], 330 + }; 331 + const headings = extractHeadings(json); 332 + expect(headings[0].id).toBe('overview'); 333 + expect(headings[1].id).toBe('overview-1'); 334 + expect(headings[2].id).toBe('overview-2'); 335 + }); 336 + 337 + it('heading with level 0 is ignored', () => { 338 + const json = { 339 + content: [ 340 + { type: 'heading', attrs: { level: 0 }, content: [{ type: 'text', text: 'Bad' }] }, 341 + { type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Good' }] }, 342 + ], 343 + }; 344 + const headings = extractHeadings(json); 345 + expect(headings.length).toBe(1); 346 + expect(headings[0].text).toBe('Good'); 347 + }); 348 + 349 + it('heading with missing attrs level is ignored', () => { 350 + const json = { 351 + content: [ 352 + { type: 'heading', attrs: {}, content: [{ type: 'text', text: 'No Level' }] }, 353 + ], 354 + }; 355 + const headings = extractHeadings(json); 356 + expect(headings.length).toBe(0); 357 + }); 358 + }); 359 + 222 360 describe('OutlineState — toggle and heading management', () => { 223 361 it('starts closed', () => { 224 362 const state = new OutlineState();
+77
tests/xlsx-import.test.ts
··· 272 272 }); 273 273 }); 274 274 275 + // ===================================================================== 276 + // mapExcelFormat — edge cases 277 + // ===================================================================== 278 + 279 + describe('mapExcelFormat — edge cases', () => { 280 + it('currency takes priority over percent when both present', () => { 281 + // $ is checked before %, so $0.00% → currency 282 + expect(mapExcelFormat('$0.00%')).toBe('currency'); 283 + }); 284 + 285 + it('currency takes priority over number when both present', () => { 286 + expect(mapExcelFormat('$#,##0.00')).toBe('currency'); 287 + }); 288 + 289 + it('detects date with mixed-case tokens (YYYY-MM-DD)', () => { 290 + expect(mapExcelFormat('YYYY-MM-DD')).toBe('date'); 291 + }); 292 + 293 + it('returns undefined for text format (@)', () => { 294 + expect(mapExcelFormat('@')).toBeUndefined(); 295 + }); 296 + 297 + it('returns undefined for single-character format', () => { 298 + expect(mapExcelFormat('0')).toBeUndefined(); 299 + }); 300 + 301 + it('returns undefined for whitespace-only string', () => { 302 + expect(mapExcelFormat(' ')).toBeUndefined(); 303 + }); 304 + 305 + it('detects number format with European decimal comma', () => { 306 + expect(mapExcelFormat('#.##0,00')).toBe('number'); 307 + }); 308 + 309 + it('detects date with slashes (dd/MMM/yyyy pattern)', () => { 310 + expect(mapExcelFormat('dd/MM/yyyy')).toBe('date'); 311 + }); 312 + }); 313 + 314 + // ===================================================================== 315 + // isValidXlsx — edge cases 316 + // ===================================================================== 317 + 318 + describe('isValidXlsx — edge cases', () => { 319 + it('returns false for PDF signature (%PDF)', () => { 320 + const buf = new Uint8Array([0x25, 0x50, 0x44, 0x46]).buffer; 321 + expect(isValidXlsx(buf)).toBe(false); 322 + }); 323 + 324 + it('returns true with exactly 4 bytes of PK header', () => { 325 + const buf = new Uint8Array([0x50, 0x4B, 0x03, 0x04]).buffer; 326 + expect(isValidXlsx(buf)).toBe(true); 327 + }); 328 + 329 + it('returns false for partial PK signature (PK only, no 0304)', () => { 330 + const buf = new Uint8Array([0x50, 0x4B, 0x00, 0x00]).buffer; 331 + expect(isValidXlsx(buf)).toBe(false); 332 + }); 333 + 334 + it('returns false for PK signature at wrong offset', () => { 335 + const buf = new Uint8Array([0x00, 0x50, 0x4B, 0x03, 0x04]).buffer; 336 + expect(isValidXlsx(buf)).toBe(false); 337 + }); 338 + 339 + it('returns true for large buffer with valid header', () => { 340 + const arr = new Uint8Array(10000); 341 + arr[0] = 0x50; arr[1] = 0x4B; arr[2] = 0x03; arr[3] = 0x04; 342 + expect(isValidXlsx(arr.buffer)).toBe(true); 343 + }); 344 + 345 + it('returns false for XLS (old format) OLE signature', () => { 346 + // .xls files start with 0xD0CF11E0 347 + const buf = new Uint8Array([0xD0, 0xCF, 0x11, 0xE0]).buffer; 348 + expect(isValidXlsx(buf)).toBe(false); 349 + }); 350 + }); 351 + 275 352 describe('.xlsx Import — rich text cells', () => { 276 353 async function createXlsxWithRichText() { 277 354 const workbook = new ExcelJS.Workbook();