👁️
5
fork

Configure Feed

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

tests!

+465 -10
+465 -10
src/lib/__tests__/richtext-convert.test.ts
··· 2 2 import { describe, expect, it } from "vitest"; 3 3 import { schema } from "@/components/richtext/schema"; 4 4 import type { 5 + BulletListBlock, 5 6 HeadingBlock, 7 + OrderedListBlock, 6 8 ParagraphBlock, 7 9 } from "@/lib/lexicons/types/com/deckbelcher/richtext"; 8 10 import { ··· 103 105 text: undefined, 104 106 facets: undefined, 105 107 }); 108 + }); 109 + }); 110 + 111 + describe("code blocks", () => { 112 + it("converts code block with text", () => { 113 + const doc = schema.node("doc", null, [ 114 + schema.node("code_block", { params: "" }, [ 115 + schema.text("const x = 1;"), 116 + ]), 117 + ]); 118 + const result = treeToLexicon(doc); 119 + 120 + expect(result.content[0]).toEqual({ 121 + $type: "com.deckbelcher.richtext#codeBlock", 122 + text: "const x = 1;", 123 + language: undefined, 124 + }); 125 + }); 126 + 127 + it("converts code block with language", () => { 128 + const doc = schema.node("doc", null, [ 129 + schema.node("code_block", { params: "typescript" }, [ 130 + schema.text("const x: number = 1;"), 131 + ]), 132 + ]); 133 + const result = treeToLexicon(doc); 134 + 135 + expect(result.content[0]).toEqual({ 136 + $type: "com.deckbelcher.richtext#codeBlock", 137 + text: "const x: number = 1;", 138 + language: "typescript", 139 + }); 140 + }); 141 + 142 + it("converts empty code block", () => { 143 + const doc = schema.node("doc", null, [ 144 + schema.node("code_block", { params: "" }), 145 + ]); 146 + const result = treeToLexicon(doc); 147 + 148 + expect(result.content[0]).toEqual({ 149 + $type: "com.deckbelcher.richtext#codeBlock", 150 + text: "", 151 + language: undefined, 152 + }); 153 + }); 154 + 155 + it("converts multiline code block", () => { 156 + const doc = schema.node("doc", null, [ 157 + schema.node("code_block", { params: "js" }, [ 158 + schema.text("function foo() {\n return 42;\n}"), 159 + ]), 160 + ]); 161 + const result = treeToLexicon(doc); 162 + 163 + expect(result.content[0]).toEqual({ 164 + $type: "com.deckbelcher.richtext#codeBlock", 165 + text: "function foo() {\n return 42;\n}", 166 + language: "js", 167 + }); 168 + }); 169 + }); 170 + 171 + describe("bullet lists", () => { 172 + it("converts single item bullet list", () => { 173 + const doc = schema.node("doc", null, [ 174 + schema.node("bullet_list", null, [ 175 + schema.node("list_item", null, [ 176 + schema.node("paragraph", null, [schema.text("Item one")]), 177 + ]), 178 + ]), 179 + ]); 180 + const result = treeToLexicon(doc); 181 + 182 + expect(result.content[0]).toEqual({ 183 + $type: "com.deckbelcher.richtext#bulletListBlock", 184 + items: [ 185 + { 186 + $type: "com.deckbelcher.richtext#listItem", 187 + text: "Item one", 188 + facets: undefined, 189 + }, 190 + ], 191 + }); 192 + }); 193 + 194 + it("converts multi-item bullet list", () => { 195 + const doc = schema.node("doc", null, [ 196 + schema.node("bullet_list", null, [ 197 + schema.node("list_item", null, [ 198 + schema.node("paragraph", null, [schema.text("First")]), 199 + ]), 200 + schema.node("list_item", null, [ 201 + schema.node("paragraph", null, [schema.text("Second")]), 202 + ]), 203 + schema.node("list_item", null, [ 204 + schema.node("paragraph", null, [schema.text("Third")]), 205 + ]), 206 + ]), 207 + ]); 208 + const result = treeToLexicon(doc); 209 + const block = result.content[0] as BulletListBlock; 210 + 211 + expect(block.$type).toBe("com.deckbelcher.richtext#bulletListBlock"); 212 + expect(block.items).toHaveLength(3); 213 + expect(block.items[0].text).toBe("First"); 214 + expect(block.items[1].text).toBe("Second"); 215 + expect(block.items[2].text).toBe("Third"); 216 + }); 217 + 218 + it("converts bullet list with formatted text", () => { 219 + const doc = schema.node("doc", null, [ 220 + schema.node("bullet_list", null, [ 221 + schema.node("list_item", null, [ 222 + schema.node("paragraph", null, [ 223 + schema.text("bold", [schema.marks.strong.create()]), 224 + schema.text(" item"), 225 + ]), 226 + ]), 227 + ]), 228 + ]); 229 + const result = treeToLexicon(doc); 230 + const block = result.content[0] as BulletListBlock; 231 + 232 + expect(block.items[0].text).toBe("bold item"); 233 + expect(block.items[0].facets).toHaveLength(1); 234 + expect(block.items[0].facets?.[0]).toMatchObject({ 235 + index: { byteStart: 0, byteEnd: 4 }, 236 + features: [{ $type: "com.deckbelcher.richtext.facet#bold" }], 237 + }); 238 + }); 239 + 240 + it("converts empty bullet list item", () => { 241 + const doc = schema.node("doc", null, [ 242 + schema.node("bullet_list", null, [ 243 + schema.node("list_item", null, [schema.node("paragraph")]), 244 + ]), 245 + ]); 246 + const result = treeToLexicon(doc); 247 + const block = result.content[0] as BulletListBlock; 248 + 249 + expect(block.items[0]).toEqual({ 250 + $type: "com.deckbelcher.richtext#listItem", 251 + text: undefined, 252 + facets: undefined, 253 + }); 254 + }); 255 + }); 256 + 257 + describe("ordered lists", () => { 258 + it("converts single item ordered list", () => { 259 + const doc = schema.node("doc", null, [ 260 + schema.node("ordered_list", { order: 1 }, [ 261 + schema.node("list_item", null, [ 262 + schema.node("paragraph", null, [schema.text("Step one")]), 263 + ]), 264 + ]), 265 + ]); 266 + const result = treeToLexicon(doc); 267 + 268 + expect(result.content[0]).toEqual({ 269 + $type: "com.deckbelcher.richtext#orderedListBlock", 270 + items: [ 271 + { 272 + $type: "com.deckbelcher.richtext#listItem", 273 + text: "Step one", 274 + facets: undefined, 275 + }, 276 + ], 277 + start: undefined, 278 + }); 279 + }); 280 + 281 + it("converts ordered list with custom start", () => { 282 + const doc = schema.node("doc", null, [ 283 + schema.node("ordered_list", { order: 5 }, [ 284 + schema.node("list_item", null, [ 285 + schema.node("paragraph", null, [schema.text("Item five")]), 286 + ]), 287 + schema.node("list_item", null, [ 288 + schema.node("paragraph", null, [schema.text("Item six")]), 289 + ]), 290 + ]), 291 + ]); 292 + const result = treeToLexicon(doc); 293 + const block = result.content[0] as OrderedListBlock; 294 + 295 + expect(block.$type).toBe("com.deckbelcher.richtext#orderedListBlock"); 296 + expect(block.start).toBe(5); 297 + expect(block.items).toHaveLength(2); 298 + }); 299 + 300 + it("converts ordered list with formatted text", () => { 301 + const doc = schema.node("doc", null, [ 302 + schema.node("ordered_list", { order: 1 }, [ 303 + schema.node("list_item", null, [ 304 + schema.node("paragraph", null, [ 305 + schema.text("Click "), 306 + schema.text("here", [ 307 + schema.marks.link.create({ href: "https://example.com" }), 308 + ]), 309 + ]), 310 + ]), 311 + ]), 312 + ]); 313 + const result = treeToLexicon(doc); 314 + const block = result.content[0] as OrderedListBlock; 315 + 316 + expect(block.items[0].text).toBe("Click here"); 317 + expect(block.items[0].facets?.[0]).toMatchObject({ 318 + index: { byteStart: 6, byteEnd: 10 }, 319 + features: [ 320 + { 321 + $type: "com.deckbelcher.richtext.facet#link", 322 + uri: "https://example.com", 323 + }, 324 + ], 325 + }); 326 + }); 327 + }); 328 + 329 + describe("horizontal rules", () => { 330 + it("converts horizontal rule", () => { 331 + const doc = schema.node("doc", null, [schema.node("horizontal_rule")]); 332 + const result = treeToLexicon(doc); 333 + 334 + expect(result.content[0]).toEqual({ 335 + $type: "com.deckbelcher.richtext#horizontalRuleBlock", 336 + }); 337 + }); 338 + 339 + it("converts horizontal rule between paragraphs", () => { 340 + const doc = schema.node("doc", null, [ 341 + schema.node("paragraph", null, [schema.text("Before")]), 342 + schema.node("horizontal_rule"), 343 + schema.node("paragraph", null, [schema.text("After")]), 344 + ]); 345 + const result = treeToLexicon(doc); 346 + 347 + expect(result.content).toHaveLength(3); 348 + expect(result.content[0]).toMatchObject({ 349 + $type: "com.deckbelcher.richtext#paragraphBlock", 350 + text: "Before", 351 + }); 352 + expect(result.content[1]).toEqual({ 353 + $type: "com.deckbelcher.richtext#horizontalRuleBlock", 354 + }); 355 + expect(result.content[2]).toMatchObject({ 356 + $type: "com.deckbelcher.richtext#paragraphBlock", 357 + text: "After", 358 + }); 359 + }); 360 + }); 361 + 362 + describe("mixed block types", () => { 363 + it("converts document with all block types", () => { 364 + const doc = schema.node("doc", null, [ 365 + schema.node("heading", { level: 1 }, [schema.text("Title")]), 366 + schema.node("paragraph", null, [schema.text("Intro text")]), 367 + schema.node("code_block", { params: "js" }, [schema.text("code()")]), 368 + schema.node("bullet_list", null, [ 369 + schema.node("list_item", null, [ 370 + schema.node("paragraph", null, [schema.text("Bullet")]), 371 + ]), 372 + ]), 373 + schema.node("ordered_list", { order: 1 }, [ 374 + schema.node("list_item", null, [ 375 + schema.node("paragraph", null, [schema.text("Numbered")]), 376 + ]), 377 + ]), 378 + schema.node("horizontal_rule"), 379 + schema.node("paragraph", null, [schema.text("End")]), 380 + ]); 381 + const result = treeToLexicon(doc); 382 + 383 + expect(result.content).toHaveLength(7); 384 + expect(result.content[0].$type).toBe( 385 + "com.deckbelcher.richtext#headingBlock", 386 + ); 387 + expect(result.content[1].$type).toBe( 388 + "com.deckbelcher.richtext#paragraphBlock", 389 + ); 390 + expect(result.content[2].$type).toBe( 391 + "com.deckbelcher.richtext#codeBlock", 392 + ); 393 + expect(result.content[3].$type).toBe( 394 + "com.deckbelcher.richtext#bulletListBlock", 395 + ); 396 + expect(result.content[4].$type).toBe( 397 + "com.deckbelcher.richtext#orderedListBlock", 398 + ); 399 + expect(result.content[5].$type).toBe( 400 + "com.deckbelcher.richtext#horizontalRuleBlock", 401 + ); 402 + expect(result.content[6].$type).toBe( 403 + "com.deckbelcher.richtext#paragraphBlock", 404 + ); 106 405 }); 107 406 }); 108 407 ··· 1174 1473 .tuple(arbHeadingLevel, arbParagraphContent) 1175 1474 .map(([level, content]) => schema.node("heading", { level }, content)); 1176 1475 1177 - // Arbitrary for a block (paragraph or heading) 1178 - const arbBlock = fc.oneof(arbParagraph, arbHeading); 1476 + // Arbitrary for code block text (can include newlines, no marks) 1477 + const arbCodeText = fc.oneof( 1478 + fc.string({ minLength: 0, maxLength: 100 }), 1479 + fc.constant("function foo() {\n return 42;\n}"), 1480 + fc.constant("const x = 1;"), 1481 + fc.constant(""), 1482 + ); 1483 + 1484 + // Arbitrary for language hint 1485 + const arbLanguage = fc.oneof( 1486 + fc.constant(""), 1487 + fc.constant("js"), 1488 + fc.constant("typescript"), 1489 + fc.constant("python"), 1490 + fc.constant("rust"), 1491 + ); 1492 + 1493 + // Arbitrary for code block 1494 + const arbCodeBlock = fc 1495 + .tuple(arbCodeText, arbLanguage) 1496 + .map(([text, lang]) => 1497 + schema.node( 1498 + "code_block", 1499 + { params: lang }, 1500 + text ? [schema.text(text)] : undefined, 1501 + ), 1502 + ); 1503 + 1504 + // Arbitrary for a list item 1505 + const arbListItem = arbParagraphContent.map((content) => 1506 + schema.node("list_item", null, [schema.node("paragraph", null, content)]), 1507 + ); 1508 + 1509 + // Arbitrary for bullet list (1-4 items) 1510 + const arbBulletList = fc 1511 + .array(arbListItem, { minLength: 1, maxLength: 4 }) 1512 + .map((items) => schema.node("bullet_list", null, items)); 1513 + 1514 + // Arbitrary for ordered list with optional start number 1515 + const arbOrderedList = fc 1516 + .tuple( 1517 + fc.array(arbListItem, { minLength: 1, maxLength: 4 }), 1518 + fc.integer({ min: 1, max: 10 }), 1519 + ) 1520 + .map(([items, start]) => 1521 + schema.node("ordered_list", { order: start }, items), 1522 + ); 1523 + 1524 + // Arbitrary for horizontal rule 1525 + const arbHorizontalRule = fc.constant(schema.node("horizontal_rule")); 1526 + 1527 + // Arbitrary for a block (all types) 1528 + const arbBlock = fc.oneof( 1529 + { weight: 3, arbitrary: arbParagraph }, 1530 + { weight: 2, arbitrary: arbHeading }, 1531 + { weight: 1, arbitrary: arbCodeBlock }, 1532 + { weight: 1, arbitrary: arbBulletList }, 1533 + { weight: 1, arbitrary: arbOrderedList }, 1534 + { weight: 1, arbitrary: arbHorizontalRule }, 1535 + ); 1179 1536 1180 1537 // Arbitrary for a document 1181 1538 const arbDocument = fc 1182 1539 .array(arbBlock, { minLength: 1, maxLength: 5 }) 1183 1540 .map((blocks) => schema.node("doc", null, blocks)); 1184 1541 1185 - it("roundtrip preserves document equality", () => { 1542 + // Arbitrary for documents with only text blocks (for text-specific property tests) 1543 + const arbTextBlock = fc.oneof(arbParagraph, arbHeading); 1544 + const arbTextOnlyDocument = fc 1545 + .array(arbTextBlock, { minLength: 1, maxLength: 5 }) 1546 + .map((blocks) => schema.node("doc", null, blocks)); 1547 + 1548 + it("roundtrip preserves document equality for all block types", () => { 1186 1549 fc.assert( 1187 1550 fc.property(arbDocument, (doc) => { 1188 1551 const lexicon = treeToLexicon(doc); 1189 1552 const result = lexiconToTree(lexicon); 1190 1553 return result.eq(doc); 1191 1554 }), 1192 - { numRuns: 1000 }, 1555 + { numRuns: 10_000 }, 1193 1556 ); 1194 1557 }); 1195 1558 1196 - it("lexicon text matches tree textContent per block", () => { 1559 + it("roundtrip preserves text-only documents", () => { 1197 1560 fc.assert( 1198 - fc.property(arbDocument, (doc) => { 1561 + fc.property(arbTextOnlyDocument, (doc) => { 1562 + const lexicon = treeToLexicon(doc); 1563 + const result = lexiconToTree(lexicon); 1564 + return result.eq(doc); 1565 + }), 1566 + { numRuns: 500 }, 1567 + ); 1568 + }); 1569 + 1570 + it("lexicon text matches tree textContent for text blocks", () => { 1571 + fc.assert( 1572 + fc.property(arbTextOnlyDocument, (doc) => { 1199 1573 const lexicon = treeToLexicon(doc); 1200 1574 1201 1575 for (let i = 0; i < doc.childCount; i++) { ··· 1213 1587 ); 1214 1588 }); 1215 1589 1216 - it("feature count matches or exceeds mark types", () => { 1590 + it("feature count matches or exceeds mark types for text blocks", () => { 1217 1591 fc.assert( 1218 - fc.property(arbDocument, (doc) => { 1592 + fc.property(arbTextOnlyDocument, (doc) => { 1219 1593 const lexicon = treeToLexicon(doc); 1220 1594 1221 1595 for (let i = 0; i < doc.childCount; i++) { ··· 1249 1623 ); 1250 1624 }); 1251 1625 1252 - it("byte offsets are valid UTF-8 positions", () => { 1626 + it("byte offsets are valid UTF-8 positions for text blocks", () => { 1253 1627 fc.assert( 1254 - fc.property(arbDocument, (doc) => { 1628 + fc.property(arbTextOnlyDocument, (doc) => { 1255 1629 const lexicon = treeToLexicon(doc); 1256 1630 1257 1631 for (const lexiconBlock of lexicon.content) { ··· 1279 1653 } catch { 1280 1654 return false; 1281 1655 } 1656 + } 1657 + } 1658 + return true; 1659 + }), 1660 + { numRuns: 1000 }, 1661 + ); 1662 + }); 1663 + 1664 + it("code blocks preserve text content exactly", () => { 1665 + fc.assert( 1666 + fc.property(arbCodeBlock, (codeBlock) => { 1667 + const doc = schema.node("doc", null, [codeBlock]); 1668 + const lexicon = treeToLexicon(doc); 1669 + const result = lexiconToTree(lexicon); 1670 + return result.eq(doc); 1671 + }), 1672 + { numRuns: 500 }, 1673 + ); 1674 + }); 1675 + 1676 + it("bullet lists preserve all item text and marks", () => { 1677 + fc.assert( 1678 + fc.property(arbBulletList, (list) => { 1679 + const doc = schema.node("doc", null, [list]); 1680 + const lexicon = treeToLexicon(doc); 1681 + const result = lexiconToTree(lexicon); 1682 + return result.eq(doc); 1683 + }), 1684 + { numRuns: 500 }, 1685 + ); 1686 + }); 1687 + 1688 + it("ordered lists preserve start number", () => { 1689 + fc.assert( 1690 + fc.property(arbOrderedList, (list) => { 1691 + const doc = schema.node("doc", null, [list]); 1692 + const lexicon = treeToLexicon(doc); 1693 + const block = lexicon.content[0] as OrderedListBlock; 1694 + const originalStart = list.attrs.order as number; 1695 + const lexiconStart = block.start ?? 1; 1696 + return ( 1697 + originalStart === lexiconStart || 1698 + (originalStart === 1 && block.start === undefined) 1699 + ); 1700 + }), 1701 + { numRuns: 500 }, 1702 + ); 1703 + }); 1704 + 1705 + it("horizontal rules roundtrip", () => { 1706 + fc.assert( 1707 + fc.property(arbHorizontalRule, (hr) => { 1708 + const doc = schema.node("doc", null, [hr]); 1709 + const lexicon = treeToLexicon(doc); 1710 + const result = lexiconToTree(lexicon); 1711 + return result.eq(doc); 1712 + }), 1713 + { numRuns: 100 }, 1714 + ); 1715 + }); 1716 + 1717 + it("block count is preserved through roundtrip", () => { 1718 + fc.assert( 1719 + fc.property(arbDocument, (doc) => { 1720 + const lexicon = treeToLexicon(doc); 1721 + const result = lexiconToTree(lexicon); 1722 + return doc.childCount === result.childCount; 1723 + }), 1724 + { numRuns: 1000 }, 1725 + ); 1726 + }); 1727 + 1728 + it("block types are preserved through roundtrip", () => { 1729 + fc.assert( 1730 + fc.property(arbDocument, (doc) => { 1731 + const lexicon = treeToLexicon(doc); 1732 + const result = lexiconToTree(lexicon); 1733 + 1734 + for (let i = 0; i < doc.childCount; i++) { 1735 + if (doc.child(i).type.name !== result.child(i).type.name) { 1736 + return false; 1282 1737 } 1283 1738 } 1284 1739 return true;