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

Configure Feed

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

at main 417 lines 14 kB view raw
1// @vitest-environment jsdom 2import { describe, it, expect } from 'vitest'; 3import { 4 parseClipboardHtml, 5 parseClipboardTsv, 6 parseInlineStyles, 7} from '../src/sheets/clipboard-paste.js'; 8 9// ============================================================ 10// parseInlineStyles — extract CSS into CellStyle 11// ============================================================ 12 13describe('parseInlineStyles', () => { 14 it('returns empty object for empty string', () => { 15 expect(parseInlineStyles('')).toEqual({}); 16 }); 17 18 it('returns empty object for null/undefined', () => { 19 expect(parseInlineStyles(null)).toEqual({}); 20 expect(parseInlineStyles(undefined)).toEqual({}); 21 }); 22 23 it('parses background-color hex', () => { 24 const style = parseInlineStyles('background-color: #ff0000'); 25 expect(style.bg).toBe('#ff0000'); 26 }); 27 28 it('parses background shorthand', () => { 29 const style = parseInlineStyles('background: #00ff00'); 30 expect(style.bg).toBe('#00ff00'); 31 }); 32 33 it('parses background-color rgb', () => { 34 const style = parseInlineStyles('background-color: rgb(255, 0, 128)'); 35 expect(style.bg).toBe('#ff0080'); 36 }); 37 38 it('parses text color', () => { 39 const style = parseInlineStyles('color: #336699'); 40 expect(style.color).toBe('#336699'); 41 }); 42 43 it('parses color as rgb()', () => { 44 const style = parseInlineStyles('color: rgb(0, 0, 0)'); 45 expect(style.color).toBe('#000000'); 46 }); 47 48 it('parses font-weight:bold', () => { 49 const style = parseInlineStyles('font-weight: bold'); 50 expect(style.bold).toBe(true); 51 }); 52 53 it('parses font-weight:700', () => { 54 const style = parseInlineStyles('font-weight: 700'); 55 expect(style.bold).toBe(true); 56 }); 57 58 it('does not set bold for normal weight', () => { 59 const style = parseInlineStyles('font-weight: normal'); 60 expect(style.bold).toBeUndefined(); 61 }); 62 63 it('parses font-style:italic', () => { 64 const style = parseInlineStyles('font-style: italic'); 65 expect(style.italic).toBe(true); 66 }); 67 68 it('does not set italic for normal', () => { 69 const style = parseInlineStyles('font-style: normal'); 70 expect(style.italic).toBeUndefined(); 71 }); 72 73 it('parses text-decoration:underline', () => { 74 const style = parseInlineStyles('text-decoration: underline'); 75 expect(style.underline).toBe(true); 76 }); 77 78 it('parses text-decoration:line-through', () => { 79 const style = parseInlineStyles('text-decoration: line-through'); 80 expect(style.strikethrough).toBe(true); 81 }); 82 83 it('parses combined text-decoration (underline + line-through)', () => { 84 const style = parseInlineStyles('text-decoration: underline line-through'); 85 expect(style.underline).toBe(true); 86 expect(style.strikethrough).toBe(true); 87 }); 88 89 it('parses font-size in pt', () => { 90 const style = parseInlineStyles('font-size: 12pt'); 91 expect(style.fontSize).toBe(12); 92 }); 93 94 it('parses font-size with decimal pt', () => { 95 const style = parseInlineStyles('font-size: 10.5pt'); 96 expect(style.fontSize).toBe(10.5); 97 }); 98 99 it('ignores font-size in px (not pt)', () => { 100 const style = parseInlineStyles('font-size: 16px'); 101 expect(style.fontSize).toBeUndefined(); 102 }); 103 104 it('parses text-align', () => { 105 expect(parseInlineStyles('text-align: center').align).toBe('center'); 106 expect(parseInlineStyles('text-align: left').align).toBe('left'); 107 expect(parseInlineStyles('text-align: right').align).toBe('right'); 108 }); 109 110 it('ignores unsupported text-align values', () => { 111 const style = parseInlineStyles('text-align: justify'); 112 expect(style.align).toBeUndefined(); 113 }); 114 115 it('parses multiple properties', () => { 116 const style = parseInlineStyles( 117 'background-color: #eee; color: #333; font-weight: bold; font-style: italic; font-size: 14pt; text-align: right' 118 ); 119 expect(style.bg).toBe('#eeeeee'); 120 expect(style.color).toBe('#333333'); 121 expect(style.bold).toBe(true); 122 expect(style.italic).toBe(true); 123 expect(style.fontSize).toBe(14); 124 expect(style.align).toBe('right'); 125 }); 126 127 it('normalizes 3-char hex to 6-char', () => { 128 const style = parseInlineStyles('color: #abc'); 129 expect(style.color).toBe('#aabbcc'); 130 }); 131 132 it('strips alpha from 8-char hex', () => { 133 const style = parseInlineStyles('background-color: #ff000080'); 134 expect(style.bg).toBe('#ff0000'); 135 }); 136}); 137 138// ============================================================ 139// parseClipboardHtml — parse HTML tables 140// ============================================================ 141 142describe('parseClipboardHtml', () => { 143 it('returns null for empty/null input', () => { 144 expect(parseClipboardHtml(null)).toBeNull(); 145 expect(parseClipboardHtml('')).toBeNull(); 146 expect(parseClipboardHtml(undefined)).toBeNull(); 147 }); 148 149 it('returns null for HTML without a table', () => { 150 expect(parseClipboardHtml('<p>just text</p>')).toBeNull(); 151 }); 152 153 it('parses a simple 2x2 table', () => { 154 const html = '<table><tr><td>A</td><td>B</td></tr><tr><td>C</td><td>D</td></tr></table>'; 155 const result = parseClipboardHtml(html); 156 expect(result).not.toBeNull(); 157 expect(result.rows.length).toBe(2); 158 expect(result.rows[0].length).toBe(2); 159 expect(result.rows[0][0].value).toBe('A'); 160 expect(result.rows[0][1].value).toBe('B'); 161 expect(result.rows[1][0].value).toBe('C'); 162 expect(result.rows[1][1].value).toBe('D'); 163 }); 164 165 it('parses numeric values as numbers', () => { 166 const html = '<table><tr><td>42</td><td>3.14</td></tr></table>'; 167 const result = parseClipboardHtml(html); 168 expect(result.rows[0][0].value).toBe(42); 169 expect(result.rows[0][1].value).toBe(3.14); 170 }); 171 172 it('keeps non-numeric strings as strings', () => { 173 const html = '<table><tr><td>hello</td><td>world</td></tr></table>'; 174 const result = parseClipboardHtml(html); 175 expect(result.rows[0][0].value).toBe('hello'); 176 expect(result.rows[0][1].value).toBe('world'); 177 }); 178 179 it('extracts inline styles from cells', () => { 180 const html = '<table><tr><td style="background-color:#ff0;font-weight:bold;color:#000">A</td></tr></table>'; 181 const result = parseClipboardHtml(html); 182 expect(result.rows[0][0].style.bg).toBe('#ffff00'); 183 expect(result.rows[0][0].style.bold).toBe(true); 184 expect(result.rows[0][0].style.color).toBe('#000000'); 185 }); 186 187 it('detects Google Sheets as source', () => { 188 const html = '<meta name="google-sheets-html-origin"><table><tr><td>1</td></tr></table>'; 189 const result = parseClipboardHtml(html); 190 expect(result.sourceApp).toBe('google-sheets'); 191 }); 192 193 it('detects Excel as source', () => { 194 const html = '<html xmlns:x="urn:schemas-microsoft-com:office:excel"><table><tr><td>1</td></tr></table></html>'; 195 const result = parseClipboardHtml(html); 196 expect(result.sourceApp).toBe('excel'); 197 }); 198 199 it('returns null sourceApp for unknown source', () => { 200 const html = '<table><tr><td>1</td></tr></table>'; 201 const result = parseClipboardHtml(html); 202 expect(result.sourceApp).toBeNull(); 203 }); 204 205 it('handles single cell table', () => { 206 const html = '<table><tr><td>only</td></tr></table>'; 207 const result = parseClipboardHtml(html); 208 expect(result.rows.length).toBe(1); 209 expect(result.rows[0].length).toBe(1); 210 expect(result.rows[0][0].value).toBe('only'); 211 }); 212 213 it('handles empty cells', () => { 214 const html = '<table><tr><td></td><td>B</td></tr></table>'; 215 const result = parseClipboardHtml(html); 216 expect(result.rows[0][0].value).toBe(''); 217 expect(result.rows[0][1].value).toBe('B'); 218 }); 219 220 it('handles colspan by filling extra cells', () => { 221 const html = '<table><tr><td colspan="3">merged</td></tr></table>'; 222 const result = parseClipboardHtml(html); 223 expect(result.rows[0].length).toBe(3); 224 expect(result.rows[0][0].value).toBe('merged'); 225 expect(result.rows[0][1].value).toBe(''); 226 expect(result.rows[0][2].value).toBe(''); 227 }); 228 229 it('handles th elements the same as td', () => { 230 const html = '<table><tr><th>Header</th><td>Data</td></tr></table>'; 231 const result = parseClipboardHtml(html); 232 expect(result.rows[0][0].value).toBe('Header'); 233 expect(result.rows[0][1].value).toBe('Data'); 234 }); 235 236 it('detects formula from data-formula attribute', () => { 237 const html = '<table><tr><td data-formula="=A1+B1">3</td></tr></table>'; 238 const result = parseClipboardHtml(html); 239 expect(result.rows[0][0].formula).toBe('A1+B1'); 240 expect(result.rows[0][0].value).toBe('3'); 241 }); 242 243 it('detects formula from value starting with =', () => { 244 const html = '<table><tr><td>=SUM(A1:A5)</td></tr></table>'; 245 const result = parseClipboardHtml(html); 246 expect(result.rows[0][0].formula).toBe('SUM(A1:A5)'); 247 }); 248 249 it('parses Google Sheets HTML with rich formatting', () => { 250 const html = ` 251 <meta name="google-sheets-html-origin"> 252 <table> 253 <tr> 254 <td style="background-color:#4a86e8;color:#ffffff;font-weight:bold;font-size:12pt;text-align:center">Revenue</td> 255 <td style="font-style:italic;text-decoration:underline">Notes</td> 256 </tr> 257 <tr> 258 <td>1000</td> 259 <td style="text-decoration:line-through">old data</td> 260 </tr> 261 </table> 262 `; 263 const result = parseClipboardHtml(html); 264 expect(result.sourceApp).toBe('google-sheets'); 265 expect(result.rows.length).toBe(2); 266 267 const header = result.rows[0][0]; 268 expect(header.value).toBe('Revenue'); 269 expect(header.style.bg).toBe('#4a86e8'); 270 expect(header.style.color).toBe('#ffffff'); 271 expect(header.style.bold).toBe(true); 272 expect(header.style.fontSize).toBe(12); 273 expect(header.style.align).toBe('center'); 274 275 const notes = result.rows[0][1]; 276 expect(notes.style.italic).toBe(true); 277 expect(notes.style.underline).toBe(true); 278 279 const oldData = result.rows[1][1]; 280 expect(oldData.style.strikethrough).toBe(true); 281 282 expect(result.rows[1][0].value).toBe(1000); 283 }); 284 285 it('parses Excel HTML with rich formatting', () => { 286 const html = ` 287 <html xmlns:x="urn:schemas-microsoft-com:office:excel"> 288 <table> 289 <tr> 290 <td style="background-color:rgb(255,255,0);font-weight:700;text-align:right">Total</td> 291 <td style="color:rgb(255,0,0)">-50</td> 292 </tr> 293 </table> 294 </html> 295 `; 296 const result = parseClipboardHtml(html); 297 expect(result.sourceApp).toBe('excel'); 298 299 const total = result.rows[0][0]; 300 expect(total.value).toBe('Total'); 301 expect(total.style.bg).toBe('#ffff00'); 302 expect(total.style.bold).toBe(true); 303 expect(total.style.align).toBe('right'); 304 305 const neg = result.rows[0][1]; 306 expect(neg.value).toBe(-50); 307 expect(neg.style.color).toBe('#ff0000'); 308 }); 309 310 it('returns null for table with no rows', () => { 311 const html = '<table></table>'; 312 const result = parseClipboardHtml(html); 313 expect(result).toBeNull(); 314 }); 315 316 it('handles data-formula without leading =', () => { 317 const html = '<table><tr><td data-formula="SUM(A1:A5)">15</td></tr></table>'; 318 const result = parseClipboardHtml(html); 319 expect(result.rows[0][0].formula).toBe('SUM(A1:A5)'); 320 }); 321}); 322 323// ============================================================ 324// parseClipboardTsv — parse tab-separated values 325// ============================================================ 326 327describe('parseClipboardTsv', () => { 328 it('returns null for empty/null input', () => { 329 expect(parseClipboardTsv(null)).toBeNull(); 330 expect(parseClipboardTsv('')).toBeNull(); 331 expect(parseClipboardTsv(undefined)).toBeNull(); 332 }); 333 334 it('parses a single value', () => { 335 const result = parseClipboardTsv('hello'); 336 expect(result.rows.length).toBe(1); 337 expect(result.rows[0].length).toBe(1); 338 expect(result.rows[0][0].value).toBe('hello'); 339 }); 340 341 it('parses tab-separated columns', () => { 342 const result = parseClipboardTsv('A\tB\tC'); 343 expect(result.rows[0].length).toBe(3); 344 expect(result.rows[0][0].value).toBe('A'); 345 expect(result.rows[0][1].value).toBe('B'); 346 expect(result.rows[0][2].value).toBe('C'); 347 }); 348 349 it('parses multiple rows', () => { 350 const result = parseClipboardTsv('1\t2\n3\t4'); 351 expect(result.rows.length).toBe(2); 352 expect(result.rows[0][0].value).toBe(1); 353 expect(result.rows[0][1].value).toBe(2); 354 expect(result.rows[1][0].value).toBe(3); 355 expect(result.rows[1][1].value).toBe(4); 356 }); 357 358 it('parses numbers', () => { 359 const result = parseClipboardTsv('42\t3.14\t-7'); 360 expect(result.rows[0][0].value).toBe(42); 361 expect(result.rows[0][1].value).toBe(3.14); 362 expect(result.rows[0][2].value).toBe(-7); 363 }); 364 365 it('keeps non-numeric strings', () => { 366 const result = parseClipboardTsv('abc\t123abc'); 367 expect(result.rows[0][0].value).toBe('abc'); 368 expect(result.rows[0][1].value).toBe('123abc'); 369 }); 370 371 it('handles empty cells', () => { 372 const result = parseClipboardTsv('\tB\t\n\t\t'); 373 expect(result.rows[0][0].value).toBe(''); 374 expect(result.rows[0][1].value).toBe('B'); 375 expect(result.rows[0][2].value).toBe(''); 376 expect(result.rows[1][0].value).toBe(''); 377 }); 378 379 it('detects formulas starting with =', () => { 380 const result = parseClipboardTsv('=SUM(A1:A5)\t=B1+B2'); 381 expect(result.rows[0][0].formula).toBe('SUM(A1:A5)'); 382 expect(result.rows[0][1].formula).toBe('B1+B2'); 383 }); 384 385 it('handles Windows-style line endings (\\r\\n)', () => { 386 const result = parseClipboardTsv('A\tB\r\nC\tD'); 387 expect(result.rows.length).toBe(2); 388 expect(result.rows[0][0].value).toBe('A'); 389 expect(result.rows[1][0].value).toBe('C'); 390 }); 391 392 it('strips trailing empty line', () => { 393 const result = parseClipboardTsv('A\tB\n'); 394 expect(result.rows.length).toBe(1); 395 expect(result.rows[0][0].value).toBe('A'); 396 expect(result.rows[0][1].value).toBe('B'); 397 }); 398 399 it('returns null sourceApp', () => { 400 const result = parseClipboardTsv('test'); 401 expect(result.sourceApp).toBeNull(); 402 }); 403 404 it('styles are always empty objects', () => { 405 const result = parseClipboardTsv('A\tB'); 406 expect(result.rows[0][0].style).toEqual({}); 407 expect(result.rows[0][1].style).toEqual({}); 408 }); 409 410 it('treats single newline as one row with one empty cell', () => { 411 const result = parseClipboardTsv('\n'); 412 // Trailing newline is stripped, leaving one line with empty value 413 expect(result).not.toBeNull(); 414 expect(result.rows.length).toBe(1); 415 expect(result.rows[0][0].value).toBe(''); 416 }); 417});