at main 1188 lines 35 kB view raw
1//! Snapshot tests for the markdown editor rendering pipeline. 2 3use serde::Serialize; 4use weaver_common::ResolvedContent; 5use weaver_editor_core::ParagraphRender; 6use weaver_editor_core::{ 7 EditorImageResolver, OffsetMapping, TextBuffer, find_mapping_for_char, 8 render_paragraphs_incremental, 9}; 10use weaver_editor_crdt::LoroTextBuffer; 11 12/// Serializable version of ParagraphRender for snapshot testing. 13#[derive(Debug, Serialize)] 14struct TestParagraph { 15 byte_range: (usize, usize), 16 char_range: (usize, usize), 17 html: String, 18 offset_map: Vec<TestOffsetMapping>, 19 source_hash: u64, 20} 21 22impl From<&ParagraphRender> for TestParagraph { 23 fn from(p: &ParagraphRender) -> Self { 24 TestParagraph { 25 byte_range: (p.byte_range.start, p.byte_range.end), 26 char_range: (p.char_range.start, p.char_range.end), 27 html: p.html.clone(), 28 offset_map: p.offset_map.iter().map(TestOffsetMapping::from).collect(), 29 source_hash: p.source_hash, 30 } 31 } 32} 33 34/// Serializable version of OffsetMapping for snapshot testing. 35#[derive(Debug, Serialize)] 36struct TestOffsetMapping { 37 byte_range: (usize, usize), 38 char_range: (usize, usize), 39 node_id: String, 40 char_offset_in_node: usize, 41 child_index: Option<usize>, 42 utf16_len: usize, 43} 44 45impl From<&OffsetMapping> for TestOffsetMapping { 46 fn from(m: &OffsetMapping) -> Self { 47 TestOffsetMapping { 48 byte_range: (m.byte_range.start, m.byte_range.end), 49 char_range: (m.char_range.start, m.char_range.end), 50 node_id: m.node_id.to_string(), 51 char_offset_in_node: m.char_offset_in_node, 52 child_index: m.child_index, 53 utf16_len: m.utf16_len, 54 } 55 } 56} 57 58/// Helper: render markdown and convert to serializable test output. 59fn render_test(input: &str) -> Vec<TestParagraph> { 60 let mut buffer = LoroTextBuffer::new(); 61 buffer.insert(0, input); 62 let result = render_paragraphs_incremental( 63 &buffer, 64 None, 65 0, 66 None, 67 None::<&EditorImageResolver>, 68 None, 69 &ResolvedContent::default(), 70 ); 71 result.paragraphs.iter().map(TestParagraph::from).collect() 72} 73 74// ============================================================================= 75// Basic Paragraph Tests 76// ============================================================================= 77 78#[test] 79fn test_single_paragraph() { 80 let result = render_test("Hello world"); 81 insta::assert_yaml_snapshot!(result); 82} 83 84#[test] 85fn test_two_paragraphs() { 86 let result = render_test("First paragraph.\n\nSecond paragraph."); 87 insta::assert_yaml_snapshot!(result); 88} 89 90#[test] 91fn test_three_paragraphs() { 92 let result = render_test("One.\n\nTwo.\n\nThree."); 93 insta::assert_yaml_snapshot!(result); 94} 95 96// ============================================================================= 97// Block Element Tests 98// ============================================================================= 99 100#[test] 101fn test_heading_h1() { 102 let result = render_test("# Heading 1"); 103 insta::assert_yaml_snapshot!(result); 104} 105 106#[test] 107fn test_heading_levels() { 108 let result = render_test("# H1\n\n## H2\n\n### H3\n\n#### H4"); 109 insta::assert_yaml_snapshot!(result); 110} 111 112#[test] 113fn test_code_block_fenced() { 114 let result = render_test("```rust\nfn main() {}\n```"); 115 insta::assert_yaml_snapshot!(result); 116} 117 118#[test] 119fn test_unordered_list() { 120 let result = render_test("- Item 1\n- Item 2\n- Item 3"); 121 insta::assert_yaml_snapshot!(result); 122} 123 124#[test] 125fn test_ordered_list() { 126 let result = render_test("1. First\n2. Second\n3. Third"); 127 insta::assert_yaml_snapshot!(result); 128} 129 130#[test] 131fn test_nested_list() { 132 let result = render_test("- Parent\n - Child 1\n - Child 2\n- Another parent"); 133 insta::assert_yaml_snapshot!(result); 134} 135 136#[test] 137fn test_blockquote() { 138 let result = render_test("> This is a quote\n>\n> With multiple lines"); 139 insta::assert_yaml_snapshot!(result); 140} 141 142// ============================================================================= 143// Inline Formatting Tests 144// ============================================================================= 145 146#[test] 147fn test_bold() { 148 let result = render_test("Some **bold** text"); 149 insta::assert_yaml_snapshot!(result); 150} 151 152#[test] 153fn test_italic() { 154 let result = render_test("Some *italic* text"); 155 insta::assert_yaml_snapshot!(result); 156} 157 158#[test] 159fn test_inline_code() { 160 let result = render_test("Some `code` here"); 161 insta::assert_yaml_snapshot!(result); 162} 163 164#[test] 165fn test_bold_italic() { 166 let result = render_test("Some ***bold italic*** text"); 167 insta::assert_yaml_snapshot!(result); 168} 169 170#[test] 171fn test_multiple_inline_formats() { 172 let result = render_test("**Bold** and *italic* and `code`"); 173 insta::assert_yaml_snapshot!(result); 174} 175 176// ============================================================================= 177// Gap Paragraph Tests 178// ============================================================================= 179 180#[test] 181fn test_gap_between_blocks() { 182 // Verify gap paragraphs are inserted for whitespace between blocks 183 let result = render_test("# Heading\n\nParagraph below"); 184 // Should have: heading, gap for \n\n, paragraph 185 insta::assert_yaml_snapshot!(result); 186} 187 188#[test] 189fn test_multiple_blank_lines() { 190 let result = render_test("First\n\n\n\nSecond"); 191 // Extra blank lines should be captured in gap paragraphs 192 insta::assert_yaml_snapshot!(result); 193} 194 195// ============================================================================= 196// Edge Case Tests 197// ============================================================================= 198 199#[test] 200fn test_empty_document() { 201 let result = render_test(""); 202 insta::assert_yaml_snapshot!(result); 203} 204 205#[test] 206fn test_only_newlines() { 207 let result = render_test("\n\n\n"); 208 insta::assert_yaml_snapshot!(result); 209} 210 211#[test] 212fn test_trailing_single_newline() { 213 let result = render_test("Hello\n"); 214 insta::assert_yaml_snapshot!(result); 215} 216 217#[test] 218fn test_trailing_double_newline() { 219 let result = render_test("Hello\n\n"); 220 insta::assert_yaml_snapshot!(result); 221} 222 223#[test] 224fn test_hard_break() { 225 // Two trailing spaces + newline = hard break 226 let result = render_test("Line one \nLine two"); 227 insta::assert_yaml_snapshot!(result); 228} 229 230#[test] 231fn test_unicode_emoji() { 232 let result = render_test("Hello 🎉 world"); 233 insta::assert_yaml_snapshot!(result); 234} 235 236#[test] 237fn test_unicode_cjk() { 238 let result = render_test("你好世界"); 239 insta::assert_yaml_snapshot!(result); 240} 241 242#[test] 243fn test_mixed_unicode_ascii() { 244 let result = render_test("Hello 你好 world 🎉"); 245 insta::assert_yaml_snapshot!(result); 246} 247 248// ============================================================================= 249// Offset Map Lookup Tests 250// ============================================================================= 251 252#[test] 253fn test_find_mapping_exact_start() { 254 let mappings = vec![OffsetMapping { 255 byte_range: 0..5, 256 char_range: 0..5, 257 node_id: "n0".into(), 258 char_offset_in_node: 0, 259 child_index: None, 260 utf16_len: 5, 261 }]; 262 263 let result = find_mapping_for_char(&mappings, 0); 264 assert!(result.is_some()); 265 let (mapping, _) = result.unwrap(); 266 assert_eq!(mapping.char_range, 0..5); 267} 268 269#[test] 270fn test_find_mapping_exact_end_inclusive() { 271 // Bug #1 regression: cursor at end of range should match 272 let mappings = vec![OffsetMapping { 273 byte_range: 0..5, 274 char_range: 0..5, 275 node_id: "n0".into(), 276 char_offset_in_node: 0, 277 child_index: None, 278 utf16_len: 5, 279 }]; 280 281 // Position 5 should match the range 0..5 (end-inclusive for cursor) 282 let result = find_mapping_for_char(&mappings, 5); 283 assert!(result.is_some(), "cursor at end of range should match"); 284} 285 286#[test] 287fn test_find_mapping_middle() { 288 let mappings = vec![OffsetMapping { 289 byte_range: 0..10, 290 char_range: 0..10, 291 node_id: "n0".into(), 292 char_offset_in_node: 0, 293 child_index: None, 294 utf16_len: 10, 295 }]; 296 297 let result = find_mapping_for_char(&mappings, 5); 298 assert!(result.is_some()); 299} 300 301#[test] 302fn test_find_mapping_before_first() { 303 let mappings = vec![OffsetMapping { 304 byte_range: 5..10, 305 char_range: 5..10, 306 node_id: "n0".into(), 307 char_offset_in_node: 0, 308 child_index: None, 309 utf16_len: 5, 310 }]; 311 312 // Position 2 is before the first mapping 313 let result = find_mapping_for_char(&mappings, 2); 314 assert!(result.is_none()); 315} 316 317#[test] 318fn test_find_mapping_after_last() { 319 let mappings = vec![OffsetMapping { 320 byte_range: 0..5, 321 char_range: 0..5, 322 node_id: "n0".into(), 323 char_offset_in_node: 0, 324 child_index: None, 325 utf16_len: 5, 326 }]; 327 328 // Position 10 is after the last mapping 329 let result = find_mapping_for_char(&mappings, 10); 330 assert!(result.is_none()); 331} 332 333#[test] 334fn test_find_mapping_empty() { 335 let mappings: Vec<OffsetMapping> = vec![]; 336 let result = find_mapping_for_char(&mappings, 0); 337 assert!(result.is_none()); 338} 339 340#[test] 341fn test_find_mapping_invisible_snaps() { 342 // Invisible content should flag should_snap=true 343 let mappings = vec![OffsetMapping { 344 byte_range: 0..2, 345 char_range: 0..2, 346 node_id: "n0".into(), 347 char_offset_in_node: 0, 348 child_index: None, 349 utf16_len: 0, // invisible 350 }]; 351 352 let result = find_mapping_for_char(&mappings, 1); 353 assert!(result.is_some()); 354 let (_, should_snap) = result.unwrap(); 355 assert!(should_snap, "invisible content should trigger snap"); 356} 357 358// ============================================================================= 359// Regression Tests (from status doc bugs) 360// ============================================================================= 361 362#[test] 363fn regression_bug6_heading_as_paragraph_boundary() { 364 // Bug #6: Headings should be tracked as paragraph boundaries 365 let result = render_test("# Heading\n\nParagraph"); 366 367 // Should have at least 2 content paragraphs (heading + paragraph) 368 // Plus potential gap paragraphs 369 assert!( 370 result.len() >= 2, 371 "heading should create separate paragraph" 372 ); 373 374 // First paragraph should contain heading 375 assert!( 376 result[0].html.contains("<h1>") || result[0].html.contains("Heading"), 377 "first paragraph should be heading" 378 ); 379} 380 381#[test] 382fn regression_bug8_inline_formatting_no_double_syntax() { 383 // Bug #8: Inline formatting should not produce double ** 384 let result = render_test("some **bold** text"); 385 386 // Count occurrences of ** in HTML 387 let html = &result[0].html; 388 let double_star_count = html.matches("**").count(); 389 390 // Should have exactly 2 occurrences (opening and closing, wrapped in spans) 391 // The bug was producing 4 (doubled emission) 392 assert!( 393 double_star_count <= 2, 394 "should not have double ** syntax: found {} in {}", 395 double_star_count, 396 html 397 ); 398} 399 400#[test] 401fn regression_bug9_lists_as_paragraph_boundary() { 402 // Bug #9: Lists should be tracked as paragraph boundaries 403 let result = render_test("Before\n\n- Item 1\n- Item 2\n\nAfter"); 404 405 // Should have paragraphs for: Before, list, After (plus gaps) 406 let has_list = result 407 .iter() 408 .any(|p| p.html.contains("<li>") || p.html.contains("<ul>")); 409 assert!(has_list, "list should be present in rendered output"); 410} 411 412#[test] 413fn regression_bug9_code_blocks_as_paragraph_boundary() { 414 // Bug #9: Code blocks should be tracked as paragraph boundaries 415 let result = render_test("Before\n\n```\ncode\n```\n\nAfter"); 416 417 let has_code = result 418 .iter() 419 .any(|p| p.html.contains("<pre>") || p.html.contains("<code>")); 420 assert!(has_code, "code block should be present in rendered output"); 421} 422 423// ignored bc changing paragraph spacing 424// #[test] 425// fn regression_bug11_gap_paragraphs_for_whitespace() { 426// // Bug #11: Gap paragraphs should be created for EXTRA inter-block whitespace 427// // Note: Headings consume trailing newline, so need 4 newlines total for gap > MIN_PARAGRAPH_BREAK 428 429// // Test with extra whitespace (4 newlines = heading eats 1, leaves 3, gap = 3 > 2) 430// let result = render_test("# Title\n\n\n\nContent"); // 4 newlines 431// assert_eq!(result.len(), 3, "Expected 3 elements with extra whitespace"); 432// assert!( 433// result[1].html.contains("gap-"), 434// "Middle element should be a gap" 435// ); 436 437// // Test standard break (3 newlines = heading eats 1, leaves 2, gap = 2 = MIN, no gap element) 438// let result2 = render_test("# Title\n\n\nContent"); // 3 newlines 439// assert_eq!( 440// result2.len(), 441// 2, 442// "Expected 2 elements with standard break equivalent" 443// ); 444// } 445 446// ============================================================================= 447// Syntax Span Edge Case Tests 448// ============================================================================= 449 450#[test] 451fn test_invalid_heading_no_space() { 452 // "#text" without space is NOT a valid heading - should be plain text 453 // The '#' should NOT be wrapped in a syntax span 454 let result = render_test("#text"); 455 456 // Should be a single paragraph with plain text 457 assert_eq!(result.len(), 1, "Should have 1 paragraph"); 458 459 // HTML should NOT contain md-syntax-block for the # 460 assert!( 461 !result[0].html.contains("md-syntax-block"), 462 "Invalid heading '#text' should NOT have block syntax span. HTML: {}", 463 result[0].html 464 ); 465 466 // The # should be visible as regular text content 467 assert!( 468 result[0].html.contains("#text") || result[0].html.contains("&num;text"), 469 "The '#text' should appear as regular text. HTML: {}", 470 result[0].html 471 ); 472} 473 474#[test] 475fn test_valid_heading_with_space() { 476 // "# text" WITH space IS a valid heading 477 let result = render_test("# Heading"); 478 479 // Should have heading syntax span 480 assert!( 481 result[0].html.contains("md-syntax-block"), 482 "Valid heading should have block syntax span. HTML: {}", 483 result[0].html 484 ); 485 486 // Should have <h1> tag 487 assert!( 488 result[0].html.contains("<h1"), 489 "Valid heading should render as h1. HTML: {}", 490 result[0].html 491 ); 492} 493 494#[test] 495fn test_hash_in_middle_of_text() { 496 // "#" in middle of text should not be treated as heading syntax 497 let result = render_test("Some #hashtag here"); 498 499 assert!( 500 !result[0].html.contains("md-syntax-block"), 501 "# in middle of text should NOT be block syntax. HTML: {}", 502 result[0].html 503 ); 504} 505 506#[test] 507fn test_unclosed_bold() { 508 // "**text" without closing ** should be plain text, not bold 509 let result = render_test("**unclosed bold"); 510 511 // Should NOT have <strong> tag 512 assert!( 513 !result[0].html.contains("<strong>"), 514 "Unclosed ** should NOT render as bold. HTML: {}", 515 result[0].html 516 ); 517} 518 519#[test] 520fn test_unclosed_italic() { 521 // "*text" without closing * should be plain text, not italic 522 let result = render_test("*unclosed italic"); 523 524 // Should NOT have <em> tag 525 assert!( 526 !result[0].html.contains("<em>"), 527 "Unclosed * should NOT render as italic. HTML: {}", 528 result[0].html 529 ); 530} 531 532#[test] 533fn test_asterisk_not_emphasis() { 534 // Single * surrounded by spaces is not emphasis 535 let result = render_test("5 * 3 = 15"); 536 537 // Should NOT have <em> tag 538 assert!( 539 !result[0].html.contains("<em>"), 540 "Math expression with * should NOT be italic. HTML: {}", 541 result[0].html 542 ); 543} 544 545#[test] 546fn test_list_marker_needs_space() { 547 // "-text" without space is NOT a list item 548 let result = render_test("-not-a-list"); 549 550 // Should NOT have <li> or <ul> tags 551 assert!( 552 !result[0].html.contains("<li>") && !result[0].html.contains("<ul>"), 553 "'-text' without space should NOT be a list. HTML: {}", 554 result[0].html 555 ); 556} 557 558#[test] 559fn test_valid_list_with_space() { 560 // "- text" WITH space IS a valid list item 561 let result = render_test("- List item"); 562 563 // Should have list markup 564 assert!( 565 result[0].html.contains("<li>") || result[0].html.contains("<ul>"), 566 "Valid list should have list markup. HTML: {}", 567 result[0].html 568 ); 569 570 // Should have block syntax span for the marker 571 assert!( 572 result[0].html.contains("md-syntax-block"), 573 "List marker should have block syntax span. HTML: {}", 574 result[0].html 575 ); 576} 577 578#[test] 579fn test_number_dot_needs_space() { 580 // "1.text" without space is NOT an ordered list 581 let result = render_test("1.not-a-list"); 582 583 // Should NOT have <ol> tag 584 assert!( 585 !result[0].html.contains("<ol>"), 586 "'1.text' without space should NOT be ordered list. HTML: {}", 587 result[0].html 588 ); 589} 590 591#[test] 592fn test_hash_with_zero_width_char() { 593 // "#\u{200B}text" - zero-width space after # should NOT make it a valid heading 594 let result = render_test("#\u{200B}text"); 595 596 // Debug: print what we got 597 eprintln!("HTML for '#\\u{{200B}}text': {}", result[0].html); 598 599 // Should NOT be a heading - zero-width space is not a real space 600 assert!( 601 !result[0].html.contains("<h1"), 602 "# followed by zero-width space should NOT be h1. HTML: {}", 603 result[0].html 604 ); 605} 606 607#[test] 608fn test_hash_with_zwj() { 609 // Test with zero-width joiner 610 let result = render_test("#\u{200C}text"); 611 612 eprintln!("HTML for '#\\u{{200C}}text': {}", result[0].html); 613 614 assert!( 615 !result[0].html.contains("<h1"), 616 "# followed by ZWNJ should NOT be h1. HTML: {}", 617 result[0].html 618 ); 619} 620 621#[test] 622fn test_hash_space_then_zero_width() { 623 // "# \u{200B}" - valid heading marker, but content is just zero-width 624 let result = render_test("# \u{200B}"); 625 626 eprintln!("HTML for '# \\u{{200B}}': {}", result[0].html); 627 eprintln!("Syntax spans: {:?}", result[0].offset_map); 628 629 // This IS a valid heading (has space after #), even if content is "invisible" 630 // The question is: should we hide the # syntax in this case? 631} 632 633#[test] 634fn test_hash_alone() { 635 // Just "#" at EOL IS a valid empty heading (standard CommonMark behavior) 636 let result = render_test("#"); 637 eprintln!("HTML for '#': {}", result[0].html); 638 639 // This IS a heading - empty headings are valid 640 assert!( 641 result[0].html.contains("<h1"), 642 "'#' alone IS a valid empty h1. HTML: {}", 643 result[0].html 644 ); 645} 646 647#[test] 648fn test_heading_to_non_heading_transition() { 649 // Simulates typing: start with "#" (heading), then add "t" to make "#t" (not heading) 650 // This tests that the syntax spans are correctly updated on content change. 651 use weaver_editor_core::render_paragraphs_incremental; 652 653 let mut buffer = LoroTextBuffer::new(); 654 655 // Initial state: "#" is a valid empty heading 656 buffer.insert(0, "#"); 657 let result1 = render_paragraphs_incremental( 658 &buffer, 659 None, 660 0, 661 None, 662 None::<&EditorImageResolver>, 663 None, 664 &ResolvedContent::default(), 665 ); 666 let paras1 = result1.paragraphs; 667 let cache1 = result1.cache; 668 669 eprintln!("State 1 ('#'): {}", paras1[0].html); 670 assert!(paras1[0].html.contains("<h1"), "# alone should be heading"); 671 assert!( 672 paras1[0].html.contains("md-syntax-block"), 673 "# should have syntax span" 674 ); 675 676 // Transition: add "t" to make "#t" - no longer a heading 677 buffer.insert(1, "t"); 678 let result2 = render_paragraphs_incremental( 679 &buffer, 680 Some(&cache1), 681 0, 682 None, 683 None::<&EditorImageResolver>, 684 None, 685 &ResolvedContent::default(), 686 ); 687 let paras2 = result2.paragraphs; 688 689 eprintln!("State 2 ('#t'): {}", paras2[0].html); 690 assert!( 691 !paras2[0].html.contains("<h1"), 692 "#t should NOT be heading. HTML: {}", 693 paras2[0].html 694 ); 695 assert!( 696 !paras2[0].html.contains("md-syntax-block"), 697 "#t should NOT have block syntax span. HTML: {}", 698 paras2[0].html 699 ); 700} 701 702#[test] 703fn test_hash_space_alone() { 704 // "# " (hash + space, no content) - IS this a heading? 705 let result = render_test("# "); 706 eprintln!("HTML for '# ': {}", result[0].html); 707 708 // Document actual behavior - this determines if empty headings are valid 709} 710 711#[test] 712fn test_empty_blockquote() { 713 // Just ">" alone - empty blockquote 714 // BUG: Currently produces 0 paragraphs, making the > invisible! 715 let result = render_test(">"); 716 eprintln!("Paragraphs for '>': {:?}", result.len()); 717 for (i, p) in result.iter().enumerate() { 718 eprintln!( 719 " Para {}: html={}, char_range={:?}", 720 i, p.html, p.char_range 721 ); 722 } 723 724 // Empty blockquote should still produce at least one paragraph 725 // containing the > syntax so it can be rendered and edited 726 assert!( 727 !result.is_empty(), 728 "Empty blockquote should produce at least one paragraph, got 0" 729 ); 730} 731 732#[test] 733fn test_blockquote_needs_space_or_newline() { 734 // ">text" directly attached might not be a blockquote depending on parser 735 // This test documents expected behavior 736 let result = render_test(">quote"); 737 738 // Whether this is a blockquote depends on the parser - document actual behavior 739 insta::assert_yaml_snapshot!(result, @r#" 740 - byte_range: 741 - 6 742 - 6 743 char_range: 744 - 0 745 - 6 746 html: "<blockquote><p id=\"p-0-n0\"><span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"1\">&gt;</span>quote</p>" 747 offset_map: 748 - byte_range: 749 - 1 750 - 1 751 char_range: 752 - 0 753 - 0 754 node_id: p-0-n0 755 char_offset_in_node: 0 756 child_index: 0 757 utf16_len: 0 758 - byte_range: 759 - 0 760 - 1 761 char_range: 762 - 0 763 - 1 764 node_id: p-0-n0 765 char_offset_in_node: 0 766 child_index: ~ 767 utf16_len: 1 768 - byte_range: 769 - 1 770 - 6 771 char_range: 772 - 1 773 - 6 774 node_id: p-0-n0 775 char_offset_in_node: 1 776 child_index: ~ 777 utf16_len: 5 778 source_hash: 6279293067953035109 779 "#); 780} 781 782// ============================================================================= 783// Char Range Coverage Tests 784// ============================================================================= 785 786#[test] 787fn test_char_range_coverage_allows_paragraph_breaks() { 788 // Verify char ranges cover document content, allowing standard \n\n breaks 789 // The MIN_PARAGRAPH_BREAK zone (2 chars) is intentionally not covered - 790 // cursor snaps to adjacent paragraphs for standard breaks. 791 // Only EXTRA whitespace beyond \n\n gets gap elements. 792 let input = "Hello\n\nWorld"; 793 let mut buffer = LoroTextBuffer::new(); 794 buffer.insert(0, input); 795 let result = render_paragraphs_incremental( 796 &buffer, 797 None, 798 0, 799 None, 800 None::<&EditorImageResolver>, 801 None, 802 &ResolvedContent::default(), 803 ); 804 let paragraphs = result.paragraphs; 805 806 // With standard \n\n break, we expect 2 paragraphs (no gap element) 807 // Paragraph ranges include some trailing whitespace from markdown parsing 808 assert_eq!( 809 paragraphs.len(), 810 2, 811 "Expected 2 paragraphs for standard break" 812 ); 813 814 // First paragraph ends before second starts, with gap for \n\n 815 let gap_start = paragraphs[0].char_range.end; 816 let gap_end = paragraphs[1].char_range.start; 817 let gap_size = gap_end - gap_start; 818 assert!( 819 gap_size <= 2, 820 "Gap should be at most MIN_PARAGRAPH_BREAK (2), got {}", 821 gap_size 822 ); 823} 824 825// old behaviour, need to re-check 826// #[test] 827// fn test_char_range_coverage_with_extra_whitespace() { 828// // Extra whitespace beyond MIN_PARAGRAPH_BREAK (2) gets gap elements 829// // Plain paragraphs don't consume trailing newlines like headings do 830// let input = "Hello\n\n\n\nWorld"; // 4 newlines = gap of 4 > 2 831// let mut buffer = LoroTextBuffer::new(); 832// buffer.insert(0, input); 833// let (paragraphs, _cache, _refs) = render_paragraphs_incremental( 834// &buffer, 835// None, 836// 0, 837// None, 838// None, 839// None, 840// &ResolvedContent::default(), 841// ); 842 843// // With extra newlines, we expect 3 elements: para, gap, para 844// assert_eq!( 845// paragraphs.len(), 846// 3, 847// "Expected 3 elements with extra whitespace" 848// ); 849 850// // Gap element should exist and cover whitespace zone 851// let gap = &paragraphs[1]; 852// assert!(gap.html.contains("gap-"), "Second element should be a gap"); 853 854// // Gap should cover ALL whitespace (not just extra) 855// assert_eq!( 856// gap.char_range.start, paragraphs[0].char_range.end, 857// "Gap should start where first paragraph ends" 858// ); 859// assert_eq!( 860// gap.char_range.end, paragraphs[2].char_range.start, 861// "Gap should end where second paragraph starts" 862// ); 863// } 864 865#[test] 866fn test_node_ids_unique_across_paragraphs() { 867 // Verify HTML id attributes are unique across paragraphs 868 let result = render_test("# Heading\n\nParagraph with **bold**\n\n- List item"); 869 870 // Print rendered output for debugging failures 871 for (i, para) in result.iter().enumerate() { 872 eprintln!("--- Paragraph {} ---", i); 873 eprintln!("char_range: {:?}", para.char_range); 874 eprintln!("html: {}", para.html); 875 eprintln!( 876 "offset_map node_ids: {:?}", 877 para.offset_map 878 .iter() 879 .map(|m| &m.node_id) 880 .collect::<Vec<_>>() 881 ); 882 } 883 884 // Extract all id and data-node-id attributes from HTML 885 let id_regex = regex::Regex::new(r#"(?:id|data-node-id)="([^"]+)""#).unwrap(); 886 887 let mut all_html_ids = std::collections::HashSet::new(); 888 for (para_idx, para) in result.iter().enumerate() { 889 for cap in id_regex.captures_iter(&para.html) { 890 let id = cap.get(1).unwrap().as_str(); 891 assert!( 892 all_html_ids.insert(id.to_string()), 893 "Duplicate HTML id '{}' in paragraph {}", 894 id, 895 para_idx 896 ); 897 } 898 } 899} 900 901#[test] 902fn test_offset_mappings_reference_own_paragraph() { 903 // Verify offset mappings only reference node IDs that exist in their paragraph's HTML 904 let result = render_test("# Heading\n\nParagraph with **bold**\n\n- List item"); 905 906 let id_regex = regex::Regex::new(r#"(?:id|data-node-id)="([^"]+)""#).unwrap(); 907 908 for (para_idx, para) in result.iter().enumerate() { 909 // Collect all node IDs in this paragraph's HTML 910 let html_ids: std::collections::HashSet<_> = id_regex 911 .captures_iter(&para.html) 912 .map(|cap| cap.get(1).unwrap().as_str().to_string()) 913 .collect(); 914 915 // Verify each offset mapping references a node in this paragraph 916 for mapping in &para.offset_map { 917 assert!( 918 html_ids.contains(&mapping.node_id), 919 "Paragraph {} has offset mapping referencing '{}' but HTML only has {:?}\nHTML: {}", 920 para_idx, 921 mapping.node_id, 922 html_ids, 923 para.html 924 ); 925 } 926 } 927} 928 929// ============================================================================= 930// Incremental Rendering Tests 931// ============================================================================= 932 933#[test] 934fn test_incremental_cache_reuse() { 935 // Verify cache is populated and can be reused 936 let input = "First para\n\nSecond para"; 937 let mut buffer = LoroTextBuffer::new(); 938 buffer.insert(0, input); 939 940 let result1 = render_paragraphs_incremental( 941 &buffer, 942 None, 943 0, 944 None, 945 None::<&EditorImageResolver>, 946 None, 947 &ResolvedContent::default(), 948 ); 949 let paras1 = result1.paragraphs; 950 let cache1 = result1.cache; 951 assert!(!cache1.paragraphs.is_empty(), "Cache should be populated"); 952 953 // Second render with same content should reuse cache 954 let result2 = render_paragraphs_incremental( 955 &buffer, 956 Some(&cache1), 957 0, 958 None, 959 None::<&EditorImageResolver>, 960 None, 961 &ResolvedContent::default(), 962 ); 963 let paras2 = result2.paragraphs; 964 965 // Should produce identical output 966 assert_eq!(paras1.len(), paras2.len()); 967 for (p1, p2) in paras1.iter().zip(paras2.iter()) { 968 assert_eq!(p1.html, p2.html); 969 } 970} 971 972// ============================================================================= 973// Loro CRDT API Spike Tests 974// ============================================================================= 975 976#[test] 977fn test_loro_basic_text_operations() { 978 use loro::LoroDoc; 979 980 let doc = LoroDoc::new(); 981 let text = doc.get_text("content"); 982 983 // Insert 984 text.insert(0, "Hello").unwrap(); 985 assert_eq!(text.to_string(), "Hello"); 986 assert_eq!(text.len_unicode(), 5); 987 988 // Insert at position 989 text.insert(5, " world").unwrap(); 990 assert_eq!(text.to_string(), "Hello world"); 991 assert_eq!(text.len_unicode(), 11); 992 993 // Delete 994 text.delete(5, 6).unwrap(); // delete " world" 995 assert_eq!(text.to_string(), "Hello"); 996 assert_eq!(text.len_unicode(), 5); 997} 998 999#[test] 1000fn test_loro_unicode_handling() { 1001 use loro::LoroDoc; 1002 1003 let doc = LoroDoc::new(); 1004 let text = doc.get_text("content"); 1005 1006 // Insert unicode 1007 text.insert(0, "Hello 🎉 世界").unwrap(); 1008 1009 // Check lengths 1010 let content = text.to_string(); 1011 assert_eq!(content, "Hello 🎉 世界"); 1012 1013 // Unicode length (chars) 1014 assert_eq!(text.len_unicode(), 10); // H e l l o 🎉 世 界 1015 1016 // UTF-16 length (for DOM) 1017 // 🎉 is a surrogate pair (2 UTF-16 units), rest are 1 each 1018 assert_eq!(text.len_utf16(), 11); // 6 + 2 + 1 + 2 = 11 1019 1020 // UTF-8 length (bytes) 1021 assert_eq!(text.len_utf8(), content.len()); 1022} 1023 1024#[test] 1025fn test_loro_undo_redo() { 1026 use loro::{LoroDoc, UndoManager}; 1027 1028 let doc = LoroDoc::new(); 1029 let text = doc.get_text("content"); 1030 let mut undo_mgr = UndoManager::new(&doc); 1031 1032 // Type some text 1033 text.insert(0, "Hello").unwrap(); 1034 doc.commit(); 1035 1036 text.insert(5, " world").unwrap(); 1037 doc.commit(); 1038 1039 assert_eq!(text.to_string(), "Hello world"); 1040 1041 // Undo last change 1042 assert!(undo_mgr.can_undo()); 1043 undo_mgr.undo().unwrap(); 1044 assert_eq!(text.to_string(), "Hello"); 1045 1046 // Undo first change 1047 undo_mgr.undo().unwrap(); 1048 assert_eq!(text.to_string(), ""); 1049 1050 // Redo 1051 assert!(undo_mgr.can_redo()); 1052 undo_mgr.redo().unwrap(); 1053 assert_eq!(text.to_string(), "Hello"); 1054 1055 undo_mgr.redo().unwrap(); 1056 assert_eq!(text.to_string(), "Hello world"); 1057} 1058 1059#[test] 1060fn test_loro_char_to_utf16_conversion() { 1061 use loro::LoroDoc; 1062 1063 let doc = LoroDoc::new(); 1064 let text = doc.get_text("content"); 1065 1066 text.insert(0, "Hello 🎉 世界").unwrap(); 1067 1068 // Simulate char→UTF16 conversion for cursor positioning 1069 // Given a char offset, compute UTF-16 offset 1070 fn char_to_utf16(text: &loro::LoroText, char_pos: usize) -> usize { 1071 if char_pos == 0 { 1072 return 0; 1073 } 1074 // Fast path: if all ASCII, char == UTF-16 1075 if text.len_unicode() == text.len_utf16() { 1076 return char_pos; 1077 } 1078 // Slow path: get slice and count UTF-16 units 1079 match text.slice(0, char_pos) { 1080 Ok(slice) => slice.encode_utf16().count(), 1081 Err(_) => 0, 1082 } 1083 } 1084 1085 // "Hello 🎉 世界" 1086 // Positions: H(0) e(1) l(2) l(3) o(4) ' '(5) 🎉(6) ' '(7) 世(8) 界(9) 1087 // UTF-16: 0 1 2 3 4 5 6,7 8 9 10 1088 1089 assert_eq!(char_to_utf16(&text, 0), 0); 1090 assert_eq!(char_to_utf16(&text, 6), 6); // before emoji 1091 assert_eq!(char_to_utf16(&text, 7), 8); // after emoji (emoji is 2 UTF-16 units) 1092 assert_eq!(char_to_utf16(&text, 10), 11); // end 1093} 1094 1095#[test] 1096fn test_loro_ascii_fast_path() { 1097 use loro::LoroDoc; 1098 1099 let doc = LoroDoc::new(); 1100 let text = doc.get_text("content"); 1101 1102 // Pure ASCII content 1103 text.insert(0, "Hello world, this is a test!").unwrap(); 1104 1105 // Verify fast path condition: all lengths equal for ASCII 1106 assert_eq!(text.len_unicode(), text.len_utf8()); 1107 assert_eq!(text.len_unicode(), text.len_utf16()); 1108 1109 // Fast path should just return char_pos directly 1110 fn char_to_utf16(text: &loro::LoroText, char_pos: usize) -> usize { 1111 if char_pos == 0 { 1112 return 0; 1113 } 1114 if text.len_unicode() == text.len_utf16() { 1115 return char_pos; // fast path 1116 } 1117 text.slice(0, char_pos) 1118 .map(|s| s.encode_utf16().count()) 1119 .unwrap_or(0) 1120 } 1121 1122 // All positions should be identity for ASCII 1123 for i in 0..=text.len_unicode() { 1124 assert_eq!( 1125 char_to_utf16(&text, i), 1126 i, 1127 "ASCII fast path failed at pos {}", 1128 i 1129 ); 1130 } 1131} 1132 1133// ============================================================================= 1134// Text Direction Tests 1135// ============================================================================= 1136 1137#[test] 1138fn test_paragraph_dir_ltr() { 1139 let result = render_test("Hello world"); 1140 // Verify HTML contains dir="ltr" 1141 assert!(result[0].html.contains("dir=\"ltr\"")); 1142} 1143 1144#[test] 1145fn test_paragraph_dir_rtl_hebrew() { 1146 let result = render_test("שלום עולם"); 1147 // Verify HTML contains dir="rtl" 1148 assert!(result[0].html.contains("dir=\"rtl\"")); 1149} 1150 1151#[test] 1152fn test_paragraph_dir_rtl_arabic() { 1153 let result = render_test("مرحبا بالعالم"); 1154 // Verify HTML contains dir="rtl" 1155 assert!(result[0].html.contains("dir=\"rtl\"")); 1156} 1157 1158#[test] 1159fn test_paragraph_dir_mixed_leading_neutrals() { 1160 // Leading numbers and punctuation should be skipped, Hebrew should be detected 1161 let result = render_test("123... שלום"); 1162 assert!(result[0].html.contains("dir=\"rtl\"")); 1163} 1164 1165#[test] 1166fn test_heading_dir_rtl() { 1167 let result = render_test("# שלום"); 1168 // Verify heading has dir="rtl" 1169 assert!(result[0].html.contains("dir=\"rtl\"")); 1170} 1171 1172#[test] 1173fn test_heading_dir_ltr() { 1174 let result = render_test("# Hello"); 1175 // Verify heading has dir="ltr" 1176 assert!(result[0].html.contains("dir=\"ltr\"")); 1177} 1178 1179#[test] 1180fn test_multiple_paragraphs_different_directions() { 1181 let result = render_test("Hello world\n\nשלום עולם\n\nBack to English"); 1182 // First paragraph should be LTR 1183 assert!(result[0].html.contains("dir=\"ltr\"")); 1184 // Second paragraph should be RTL 1185 assert!(result[1].html.contains("dir=\"rtl\"")); 1186 // Third paragraph should be LTR 1187 assert!(result[2].html.contains("dir=\"ltr\"")); 1188}