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: batch 15 — cell-styles, autoformat-rules, sort (74 tests)' (#257) from test/batch15-cell-styles-autoformat-sort into main

scott bd080685 21f06da4

+551
+219
tests/autoformat-rules.test.ts
··· 1 + /** 2 + * Tests for autoformat rule definitions and matching (src/docs/autoformat-rules.ts). 3 + * Covers AUTOFORMAT_RULES data, resolveAutoformat matching, parseLinkMatch, and linkInputRegex. 4 + */ 5 + import { describe, it, expect } from 'vitest'; 6 + import { 7 + AUTOFORMAT_RULES, 8 + linkInputRegex, 9 + resolveAutoformat, 10 + parseLinkMatch, 11 + } from '../src/docs/autoformat-rules.js'; 12 + 13 + // ===================================================================== 14 + // AUTOFORMAT_RULES — data integrity 15 + // ===================================================================== 16 + 17 + describe('AUTOFORMAT_RULES', () => { 18 + it('has at least 10 rules', () => { 19 + expect(AUTOFORMAT_RULES.length).toBeGreaterThanOrEqual(10); 20 + }); 21 + 22 + it('each rule has required fields', () => { 23 + for (const rule of AUTOFORMAT_RULES) { 24 + expect(rule.id).toBeTruthy(); 25 + expect(rule.description).toBeTruthy(); 26 + expect(rule.trigger).toBeTruthy(); 27 + expect(rule.regex).toBeInstanceOf(RegExp); 28 + expect(rule.source).toBeTruthy(); 29 + expect(typeof rule.custom).toBe('boolean'); 30 + } 31 + }); 32 + 33 + it('has unique IDs', () => { 34 + const ids = AUTOFORMAT_RULES.map(r => r.id); 35 + expect(new Set(ids).size).toBe(ids.length); 36 + }); 37 + 38 + it('has exactly one custom rule (link)', () => { 39 + const custom = AUTOFORMAT_RULES.filter(r => r.custom); 40 + expect(custom.length).toBe(1); 41 + expect(custom[0].id).toBe('link'); 42 + }); 43 + 44 + it('contains expected rule IDs', () => { 45 + const ids = AUTOFORMAT_RULES.map(r => r.id); 46 + expect(ids).toContain('heading1'); 47 + expect(ids).toContain('heading2'); 48 + expect(ids).toContain('heading3'); 49 + expect(ids).toContain('bulletList'); 50 + expect(ids).toContain('orderedList'); 51 + expect(ids).toContain('blockquote'); 52 + expect(ids).toContain('codeBlock'); 53 + expect(ids).toContain('bold'); 54 + expect(ids).toContain('italic'); 55 + expect(ids).toContain('strikethrough'); 56 + expect(ids).toContain('link'); 57 + }); 58 + }); 59 + 60 + // ===================================================================== 61 + // linkInputRegex 62 + // ===================================================================== 63 + 64 + describe('linkInputRegex', () => { 65 + it('matches [text](url) at end of string', () => { 66 + const match = '[Example](https://example.com)'.match(linkInputRegex); 67 + expect(match).not.toBeNull(); 68 + expect(match![1]).toBe('Example'); 69 + expect(match![2]).toBe('https://example.com'); 70 + }); 71 + 72 + it('matches with leading space', () => { 73 + const match = 'some text [link](http://a.com)'.match(linkInputRegex); 74 + expect(match).not.toBeNull(); 75 + expect(match![1]).toBe('link'); 76 + }); 77 + 78 + it('does not match without closing paren', () => { 79 + expect('[text](url'.match(linkInputRegex)).toBeNull(); 80 + }); 81 + 82 + it('does not match without brackets', () => { 83 + expect('text(url)'.match(linkInputRegex)).toBeNull(); 84 + }); 85 + 86 + it('does not match empty text', () => { 87 + expect('[](url)'.match(linkInputRegex)).toBeNull(); 88 + }); 89 + }); 90 + 91 + // ===================================================================== 92 + // resolveAutoformat 93 + // ===================================================================== 94 + 95 + describe('resolveAutoformat', () => { 96 + describe('block-level rules', () => { 97 + it('matches # + space as heading1', () => { 98 + const result = resolveAutoformat('# '); 99 + expect(result).not.toBeNull(); 100 + expect(result!.id).toBe('heading1'); 101 + }); 102 + 103 + it('matches ## + space as heading2', () => { 104 + const result = resolveAutoformat('## '); 105 + expect(result).not.toBeNull(); 106 + expect(result!.id).toBe('heading2'); 107 + }); 108 + 109 + it('matches ### + space as heading3', () => { 110 + const result = resolveAutoformat('### '); 111 + expect(result).not.toBeNull(); 112 + expect(result!.id).toBe('heading3'); 113 + }); 114 + 115 + it('matches > + space as blockquote', () => { 116 + const result = resolveAutoformat('> '); 117 + expect(result).not.toBeNull(); 118 + expect(result!.id).toBe('blockquote'); 119 + }); 120 + 121 + it('matches - + space as bulletList', () => { 122 + const result = resolveAutoformat('- '); 123 + expect(result).not.toBeNull(); 124 + expect(result!.id).toBe('bulletList'); 125 + }); 126 + 127 + it('matches * + space as bulletList', () => { 128 + const result = resolveAutoformat('* '); 129 + expect(result).not.toBeNull(); 130 + expect(result!.id).toBe('bulletList'); 131 + }); 132 + 133 + it('matches 1. + space as orderedList', () => { 134 + const result = resolveAutoformat('1. '); 135 + expect(result).not.toBeNull(); 136 + expect(result!.id).toBe('orderedList'); 137 + }); 138 + 139 + it('matches ``` + space as codeBlock', () => { 140 + const result = resolveAutoformat('``` '); 141 + expect(result).not.toBeNull(); 142 + expect(result!.id).toBe('codeBlock'); 143 + }); 144 + 145 + it('matches ```js + newline as codeBlock with language', () => { 146 + const result = resolveAutoformat('```js\n'); 147 + expect(result).not.toBeNull(); 148 + expect(result!.id).toBe('codeBlock'); 149 + }); 150 + }); 151 + 152 + describe('inline mark rules', () => { 153 + it('matches **text** as bold', () => { 154 + const result = resolveAutoformat(' **hello**'); 155 + expect(result).not.toBeNull(); 156 + expect(result!.id).toBe('bold'); 157 + }); 158 + 159 + it('matches *text* as italic', () => { 160 + const result = resolveAutoformat(' *hello*'); 161 + expect(result).not.toBeNull(); 162 + expect(result!.id).toBe('italic'); 163 + }); 164 + 165 + it('matches ~~text~~ as strikethrough', () => { 166 + const result = resolveAutoformat(' ~~hello~~'); 167 + expect(result).not.toBeNull(); 168 + expect(result!.id).toBe('strikethrough'); 169 + }); 170 + 171 + it('matches `code` as inlineCode', () => { 172 + const result = resolveAutoformat('`code`'); 173 + expect(result).not.toBeNull(); 174 + expect(result!.id).toBe('inlineCode'); 175 + }); 176 + }); 177 + 178 + describe('custom rules', () => { 179 + it('matches [text](url) as link', () => { 180 + const result = resolveAutoformat(' [Example](https://example.com)'); 181 + expect(result).not.toBeNull(); 182 + expect(result!.id).toBe('link'); 183 + }); 184 + }); 185 + 186 + describe('non-matches', () => { 187 + it('returns null for plain text', () => { 188 + expect(resolveAutoformat('hello world')).toBeNull(); 189 + }); 190 + 191 + it('returns null for empty string', () => { 192 + expect(resolveAutoformat('')).toBeNull(); 193 + }); 194 + 195 + it('returns null for partial markdown', () => { 196 + expect(resolveAutoformat('#no-space')).toBeNull(); 197 + }); 198 + }); 199 + }); 200 + 201 + // ===================================================================== 202 + // parseLinkMatch 203 + // ===================================================================== 204 + 205 + describe('parseLinkMatch', () => { 206 + it('extracts text and href from regex match', () => { 207 + const match = ' [My Link](https://example.com)'.match(linkInputRegex)!; 208 + const parsed = parseLinkMatch(match); 209 + expect(parsed.text).toBe('My Link'); 210 + expect(parsed.href).toBe('https://example.com'); 211 + }); 212 + 213 + it('handles URL with path and query', () => { 214 + const match = ' [Docs](https://example.com/docs?v=1)'.match(linkInputRegex)!; 215 + const parsed = parseLinkMatch(match); 216 + expect(parsed.text).toBe('Docs'); 217 + expect(parsed.href).toBe('https://example.com/docs?v=1'); 218 + }); 219 + });
+173
tests/cell-styles.test.ts
··· 1 + /** 2 + * Tests for cell style utility functions (src/sheets/cell-styles.ts). 3 + * Covers border building, presets, wrap styles, and striped rows. 4 + */ 5 + import { describe, it, expect } from 'vitest'; 6 + import { 7 + buildBorderStyle, 8 + applyBorderPreset, 9 + getWrapStyle, 10 + getStripedRowClass, 11 + } from '../src/sheets/cell-styles.js'; 12 + 13 + // ===================================================================== 14 + // buildBorderStyle 15 + // ===================================================================== 16 + 17 + describe('buildBorderStyle', () => { 18 + it('returns empty string for null', () => { 19 + expect(buildBorderStyle(null)).toBe(''); 20 + }); 21 + 22 + it('returns empty string for undefined', () => { 23 + expect(buildBorderStyle(undefined)).toBe(''); 24 + }); 25 + 26 + it('returns empty string for empty borders object', () => { 27 + expect(buildBorderStyle({})).toBe(''); 28 + }); 29 + 30 + it('builds top border', () => { 31 + expect(buildBorderStyle({ top: '1px solid black' })).toBe('border-top:1px solid black;'); 32 + }); 33 + 34 + it('builds bottom border', () => { 35 + expect(buildBorderStyle({ bottom: '2px dashed red' })).toBe('border-bottom:2px dashed red;'); 36 + }); 37 + 38 + it('builds left border', () => { 39 + expect(buildBorderStyle({ left: '1px solid blue' })).toBe('border-left:1px solid blue;'); 40 + }); 41 + 42 + it('builds right border', () => { 43 + expect(buildBorderStyle({ right: '1px solid green' })).toBe('border-right:1px solid green;'); 44 + }); 45 + 46 + it('builds all four borders', () => { 47 + const style = buildBorderStyle({ 48 + top: '1px solid black', 49 + bottom: '1px solid black', 50 + left: '1px solid black', 51 + right: '1px solid black', 52 + }); 53 + expect(style).toContain('border-top:'); 54 + expect(style).toContain('border-bottom:'); 55 + expect(style).toContain('border-left:'); 56 + expect(style).toContain('border-right:'); 57 + }); 58 + 59 + it('builds partial borders (top + bottom only)', () => { 60 + const style = buildBorderStyle({ top: '1px solid', bottom: '1px solid' }); 61 + expect(style).toContain('border-top:'); 62 + expect(style).toContain('border-bottom:'); 63 + expect(style).not.toContain('border-left:'); 64 + expect(style).not.toContain('border-right:'); 65 + }); 66 + }); 67 + 68 + // ===================================================================== 69 + // applyBorderPreset 70 + // ===================================================================== 71 + 72 + describe('applyBorderPreset', () => { 73 + const borderVal = '1px solid #000'; 74 + 75 + it('returns empty for null preset', () => { 76 + expect(applyBorderPreset(null, borderVal)).toEqual({}); 77 + }); 78 + 79 + it('applies "all" preset — all four sides', () => { 80 + const result = applyBorderPreset('all', borderVal); 81 + expect(result.top).toBe(borderVal); 82 + expect(result.bottom).toBe(borderVal); 83 + expect(result.left).toBe(borderVal); 84 + expect(result.right).toBe(borderVal); 85 + }); 86 + 87 + it('applies "outline" preset — same as all', () => { 88 + const result = applyBorderPreset('outline', borderVal); 89 + expect(result).toEqual(applyBorderPreset('all', borderVal)); 90 + }); 91 + 92 + it('applies "none" preset — empty object', () => { 93 + expect(applyBorderPreset('none', borderVal)).toEqual({}); 94 + }); 95 + 96 + it('applies "top" preset', () => { 97 + const result = applyBorderPreset('top', borderVal); 98 + expect(result).toEqual({ top: borderVal }); 99 + }); 100 + 101 + it('applies "bottom" preset', () => { 102 + expect(applyBorderPreset('bottom', borderVal)).toEqual({ bottom: borderVal }); 103 + }); 104 + 105 + it('applies "left" preset', () => { 106 + expect(applyBorderPreset('left', borderVal)).toEqual({ left: borderVal }); 107 + }); 108 + 109 + it('applies "right" preset', () => { 110 + expect(applyBorderPreset('right', borderVal)).toEqual({ right: borderVal }); 111 + }); 112 + 113 + it('returns empty for unknown preset', () => { 114 + expect(applyBorderPreset('diagonal', borderVal)).toEqual({}); 115 + }); 116 + }); 117 + 118 + // ===================================================================== 119 + // getWrapStyle 120 + // ===================================================================== 121 + 122 + describe('getWrapStyle', () => { 123 + it('returns wrap style when true', () => { 124 + const style = getWrapStyle(true); 125 + expect(style).toContain('white-space:normal'); 126 + expect(style).toContain('word-wrap:break-word'); 127 + }); 128 + 129 + it('returns nowrap style when false', () => { 130 + const style = getWrapStyle(false); 131 + expect(style).toContain('white-space:nowrap'); 132 + expect(style).toContain('overflow:hidden'); 133 + expect(style).toContain('text-overflow:ellipsis'); 134 + }); 135 + 136 + it('returns nowrap style for null', () => { 137 + expect(getWrapStyle(null)).toContain('white-space:nowrap'); 138 + }); 139 + 140 + it('returns nowrap style for undefined', () => { 141 + expect(getWrapStyle(undefined)).toContain('white-space:nowrap'); 142 + }); 143 + }); 144 + 145 + // ===================================================================== 146 + // getStripedRowClass 147 + // ===================================================================== 148 + 149 + describe('getStripedRowClass', () => { 150 + it('returns class for even row when striped', () => { 151 + expect(getStripedRowClass(0, true)).toBe('striped-row'); 152 + expect(getStripedRowClass(2, true)).toBe('striped-row'); 153 + expect(getStripedRowClass(4, true)).toBe('striped-row'); 154 + }); 155 + 156 + it('returns empty string for odd row when striped', () => { 157 + expect(getStripedRowClass(1, true)).toBe(''); 158 + expect(getStripedRowClass(3, true)).toBe(''); 159 + }); 160 + 161 + it('returns empty string when not striped', () => { 162 + expect(getStripedRowClass(0, false)).toBe(''); 163 + expect(getStripedRowClass(1, false)).toBe(''); 164 + }); 165 + 166 + it('returns empty string for null', () => { 167 + expect(getStripedRowClass(0, null)).toBe(''); 168 + }); 169 + 170 + it('returns empty string for undefined', () => { 171 + expect(getStripedRowClass(0, undefined)).toBe(''); 172 + }); 173 + });
+159
tests/sort.test.ts
··· 1 + /** 2 + * Tests for multi-column sort (src/sheets/sort.ts). 3 + * Covers multiColumnSort with numeric, string, empty, and mixed-type data. 4 + */ 5 + import { describe, it, expect } from 'vitest'; 6 + import { multiColumnSort } from '../src/sheets/sort.js'; 7 + 8 + // ===================================================================== 9 + // multiColumnSort — basic behavior 10 + // ===================================================================== 11 + 12 + describe('multiColumnSort', () => { 13 + it('sorts by a single numeric column ascending', () => { 14 + const rows = [{ A: 3 }, { A: 1 }, { A: 2 }]; 15 + const result = multiColumnSort(rows, [{ col: 'A', order: 'asc' }]); 16 + expect(result.map(r => r.A)).toEqual([1, 2, 3]); 17 + }); 18 + 19 + it('sorts by a single numeric column descending', () => { 20 + const rows = [{ A: 1 }, { A: 3 }, { A: 2 }]; 21 + const result = multiColumnSort(rows, [{ col: 'A', order: 'desc' }]); 22 + expect(result.map(r => r.A)).toEqual([3, 2, 1]); 23 + }); 24 + 25 + it('sorts by a single string column ascending', () => { 26 + const rows = [{ A: 'Banana' }, { A: 'Apple' }, { A: 'Cherry' }]; 27 + const result = multiColumnSort(rows, [{ col: 'A', order: 'asc' }]); 28 + expect(result.map(r => r.A)).toEqual(['Apple', 'Banana', 'Cherry']); 29 + }); 30 + 31 + it('sorts by a single string column descending', () => { 32 + const rows = [{ A: 'Apple' }, { A: 'Cherry' }, { A: 'Banana' }]; 33 + const result = multiColumnSort(rows, [{ col: 'A', order: 'desc' }]); 34 + expect(result.map(r => r.A)).toEqual(['Cherry', 'Banana', 'Apple']); 35 + }); 36 + 37 + it('does not mutate the original array', () => { 38 + const rows = [{ A: 3 }, { A: 1 }, { A: 2 }]; 39 + const original = [...rows]; 40 + multiColumnSort(rows, [{ col: 'A', order: 'asc' }]); 41 + expect(rows).toEqual(original); 42 + }); 43 + 44 + it('returns empty array for empty input', () => { 45 + expect(multiColumnSort([], [{ col: 'A', order: 'asc' }])).toEqual([]); 46 + }); 47 + 48 + it('returns empty array for null input', () => { 49 + expect(multiColumnSort(null as any, [{ col: 'A', order: 'asc' }])).toEqual([]); 50 + }); 51 + 52 + it('returns copy of array when no sort keys', () => { 53 + const rows = [{ A: 3 }, { A: 1 }]; 54 + const result = multiColumnSort(rows, []); 55 + expect(result).toEqual(rows); 56 + expect(result).not.toBe(rows); 57 + }); 58 + 59 + it('returns copy for null sort keys', () => { 60 + const rows = [{ A: 1 }]; 61 + const result = multiColumnSort(rows, null as any); 62 + expect(result).toEqual(rows); 63 + }); 64 + }); 65 + 66 + // ===================================================================== 67 + // multiColumnSort — multi-key sorting 68 + // ===================================================================== 69 + 70 + describe('multiColumnSort — multi-key', () => { 71 + it('sorts by primary then secondary key', () => { 72 + const rows = [ 73 + { dept: 'Sales', name: 'Charlie' }, 74 + { dept: 'Engineering', name: 'Bob' }, 75 + { dept: 'Sales', name: 'Alice' }, 76 + { dept: 'Engineering', name: 'Dave' }, 77 + ]; 78 + const result = multiColumnSort(rows, [ 79 + { col: 'dept', order: 'asc' }, 80 + { col: 'name', order: 'asc' }, 81 + ]); 82 + expect(result.map(r => r.name)).toEqual(['Bob', 'Dave', 'Alice', 'Charlie']); 83 + }); 84 + 85 + it('secondary key only breaks ties in primary', () => { 86 + const rows = [ 87 + { A: 1, B: 'z' }, 88 + { A: 2, B: 'a' }, 89 + { A: 1, B: 'a' }, 90 + ]; 91 + const result = multiColumnSort(rows, [ 92 + { col: 'A', order: 'asc' }, 93 + { col: 'B', order: 'asc' }, 94 + ]); 95 + expect(result.map(r => `${r.A}${r.B}`)).toEqual(['1a', '1z', '2a']); 96 + }); 97 + 98 + it('supports mixed asc/desc across keys', () => { 99 + const rows = [ 100 + { A: 1, B: 3 }, 101 + { A: 1, B: 1 }, 102 + { A: 2, B: 2 }, 103 + ]; 104 + const result = multiColumnSort(rows, [ 105 + { col: 'A', order: 'asc' }, 106 + { col: 'B', order: 'desc' }, 107 + ]); 108 + expect(result.map(r => r.B)).toEqual([3, 1, 2]); 109 + }); 110 + }); 111 + 112 + // ===================================================================== 113 + // multiColumnSort — type coercion and edge cases 114 + // ===================================================================== 115 + 116 + describe('multiColumnSort — type coercion', () => { 117 + it('sorts numeric strings as numbers', () => { 118 + const rows = [{ A: '10' }, { A: '2' }, { A: '1' }]; 119 + const result = multiColumnSort(rows, [{ col: 'A', order: 'asc' }]); 120 + expect(result.map(r => r.A)).toEqual(['1', '2', '10']); 121 + }); 122 + 123 + it('numbers sort before strings', () => { 124 + const rows = [{ A: 'hello' }, { A: 5 }, { A: 'abc' }, { A: 1 }]; 125 + const result = multiColumnSort(rows, [{ col: 'A', order: 'asc' }]); 126 + expect(result.map(r => r.A)).toEqual([1, 5, 'abc', 'hello']); 127 + }); 128 + 129 + it('empty values sort before everything', () => { 130 + const rows = [{ A: 'b' }, { A: '' }, { A: 'a' }, { A: null }, { A: undefined }]; 131 + const result = multiColumnSort(rows, [{ col: 'A', order: 'asc' }]); 132 + // Empty/null/undefined first, then strings 133 + expect(result[0].A === '' || result[0].A === null || result[0].A === undefined).toBe(true); 134 + expect(result[1].A === '' || result[1].A === null || result[1].A === undefined).toBe(true); 135 + expect(result[2].A === '' || result[2].A === null || result[2].A === undefined).toBe(true); 136 + }); 137 + 138 + it('handles mixed numbers and strings in same column', () => { 139 + const rows = [{ A: 'hello' }, { A: 42 }, { A: '100' }]; 140 + const result = multiColumnSort(rows, [{ col: 'A', order: 'asc' }]); 141 + // 42 and '100' are numeric → sort numerically first, then 'hello' 142 + expect(result[0].A).toBe(42); 143 + expect(result[1].A).toBe('100'); 144 + expect(result[2].A).toBe('hello'); 145 + }); 146 + 147 + it('handles single-element array', () => { 148 + const rows = [{ A: 1 }]; 149 + const result = multiColumnSort(rows, [{ col: 'A', order: 'asc' }]); 150 + expect(result).toEqual([{ A: 1 }]); 151 + }); 152 + 153 + it('handles all-equal values', () => { 154 + const rows = [{ A: 5 }, { A: 5 }, { A: 5 }]; 155 + const result = multiColumnSort(rows, [{ col: 'A', order: 'asc' }]); 156 + expect(result.length).toBe(3); 157 + expect(result.every(r => r.A === 5)).toBe(true); 158 + }); 159 + });