use text_document::{
BlockFormat, BlockSnapshot, DocumentFragment, FlowElement, FlowElementSnapshot, FlowSnapshot,
MoveMode, MoveOperation, SelectionKind, TextDocument,
};
fn heading_and_body_doc() -> TextDocument {
let doc = TextDocument::new();
doc.set_plain_text("Title\nBody").unwrap();
let cursor = doc.cursor_at(0);
cursor
.set_block_format(&BlockFormat {
heading_level: Some(1),
..Default::default()
})
.unwrap();
doc
}
fn find_table(doc: &TextDocument) -> Option<text_document::TextTable> {
doc.flow().into_iter().find_map(|e| match e {
FlowElement::Table(t) => Some(t),
_ => None,
})
}
#[test]
fn extract_partial_block_no_gap() {
let doc = heading_and_body_doc();
let cursor = doc.cursor_at(0);
cursor.set_position(5, MoveMode::KeepAnchor); let frag = cursor.selection();
let html = frag.to_html();
assert!(
!html.contains("<h1>"),
"partial block should not have heading: {}",
html
);
assert!(html.contains("Title"), "should contain the text: {}", html);
}
#[test]
fn extract_full_block_crosses_gap() {
let doc = heading_and_body_doc();
let cursor = doc.cursor_at(0);
cursor.set_position(6, MoveMode::KeepAnchor); let frag = cursor.selection();
let html = frag.to_html();
assert!(
html.contains("<h1>") || html.contains("<h2>") || html.contains("<h3>"),
"full block should have heading: {}",
html
);
}
#[test]
fn extract_middle_blocks_always_full() {
let doc = TextDocument::new();
doc.set_plain_text("First\nMiddle\nLast").unwrap();
let c1 = doc.cursor_at(0);
c1.set_block_format(&BlockFormat {
heading_level: Some(1),
..Default::default()
})
.unwrap();
let c3 = doc.cursor_at(13);
c3.set_block_format(&BlockFormat {
heading_level: Some(2),
..Default::default()
})
.unwrap();
let c4 = doc.cursor_at(2);
c4.set_position(15, MoveMode::KeepAnchor);
let frag = c4.selection();
let html = frag.to_html();
assert!(
html.contains("<p>") || html.contains("Middle"),
"middle block should be included: {}",
html
);
}
#[test]
fn extract_list_items_from_html() {
let doc = TextDocument::new();
doc.set_plain_text("Item one\nItem two").unwrap();
let plain = doc.to_plain_text().unwrap();
assert_eq!(plain, "Item one\nItem two");
let cursor = doc.cursor_at(0);
cursor.set_position(9, MoveMode::KeepAnchor);
let frag = cursor.selection();
let plain_frag = frag.to_plain_text();
assert!(
plain_frag.contains("Item one"),
"should contain first item: {}",
plain_frag
);
}
#[test]
fn extract_inline_formatting_on_partial() {
let doc = TextDocument::new();
let cursor = doc.cursor_at(0);
cursor
.insert_html("<p>Normal <b>bold text</b> normal</p>")
.unwrap();
let plain = doc.to_plain_text().unwrap();
let bold_start = plain.find("bold").expect("should contain 'bold'");
let c2 = doc.cursor_at(bold_start);
c2.set_position(bold_start + 4, MoveMode::KeepAnchor);
let frag = c2.selection();
let html = frag.to_html();
assert!(
html.contains("<b>") || html.contains("<strong>") || html.contains("font-weight"),
"partial selection should preserve inline bold: {}",
html
);
}
#[test]
fn insert_inline_preserves_target_format() {
let doc = heading_and_body_doc();
let cursor = doc.cursor_at(2);
let frag = DocumentFragment::from_plain_text("INSERTED");
cursor.insert_fragment(&frag).unwrap();
let c2 = doc.cursor_at(0);
let fmt = c2.block_format().unwrap();
assert!(
fmt.heading_level.is_some(),
"heading should be preserved after inline paste"
);
}
#[test]
fn insert_full_block_splits_and_inherits() {
let doc = TextDocument::new();
doc.set_plain_text("Some text here").unwrap();
let frag = DocumentFragment::from_html("<h1>Heading</h1>");
let cursor = doc.cursor_at(5);
cursor.insert_fragment(&frag).unwrap();
let plain = doc.to_plain_text().unwrap();
assert!(
plain.contains("Heading"),
"pasted heading text should appear: {}",
plain
);
}
#[test]
fn insert_with_selection_replaces_atomically() {
let doc = TextDocument::new();
doc.set_plain_text("Hello World").unwrap();
let cursor = doc.cursor_at(6);
cursor.set_position(11, MoveMode::KeepAnchor);
cursor.insert_text("Universe").unwrap();
let plain = doc.to_plain_text().unwrap();
assert_eq!(plain, "Hello Universe");
doc.undo().unwrap();
let plain2 = doc.to_plain_text().unwrap();
assert_eq!(plain2, "Hello World");
}
#[test]
fn insert_html_with_selection_replaces() {
let doc = TextDocument::new();
doc.set_plain_text("Hello World").unwrap();
let cursor = doc.cursor_at(6);
cursor.set_position(11, MoveMode::KeepAnchor);
cursor.insert_html("<b>Bold</b>").unwrap();
let plain = doc.to_plain_text().unwrap();
assert!(
plain.contains("Bold"),
"should contain pasted text: {}",
plain
);
assert!(
!plain.contains("World"),
"should not contain replaced text: {}",
plain
);
}
#[test]
fn insert_fragment_with_selection_replaces() {
let doc = TextDocument::new();
doc.set_plain_text("Hello World").unwrap();
let cursor = doc.cursor_at(6);
cursor.set_position(11, MoveMode::KeepAnchor);
let frag = DocumentFragment::from_plain_text("Rust");
cursor.insert_fragment(&frag).unwrap();
let plain = doc.to_plain_text().unwrap();
assert_eq!(plain, "Hello Rust");
}
#[test]
fn insert_table_outside_table_creates_new() {
let doc = TextDocument::new();
doc.set_plain_text("Before").unwrap();
let cursor = doc.cursor_at(6);
cursor.insert_table(2, 3).unwrap();
let table = find_table(&doc);
assert!(table.is_some(), "should create a table");
let t = table.unwrap();
assert_eq!(t.rows(), 2);
assert_eq!(t.columns(), 3);
}
#[test]
fn delete_cross_block_preserves_first_format() {
let doc = heading_and_body_doc();
let cursor = doc.cursor_at(3);
cursor.set_position(7, MoveMode::KeepAnchor);
cursor.remove_selected_text().unwrap();
let c2 = doc.cursor_at(0);
let fmt = c2.block_format().unwrap();
assert!(
fmt.heading_level.is_some(),
"merged block should keep first block's heading format"
);
}
#[test]
fn delete_cross_cell_clears_cells() {
let doc = TextDocument::new();
doc.set_plain_text("Before").unwrap();
let cursor = doc.cursor_at(6);
let table = cursor.insert_table(2, 2).unwrap();
let cell00 = table.cell(0, 0).unwrap();
let pos00 = cell00.blocks()[0].position();
let c1 = doc.cursor_at(pos00);
c1.insert_text("CellA").unwrap();
let table2 = find_table(&doc).unwrap();
let cell01 = table2.cell(0, 1).unwrap();
let pos01 = cell01.blocks()[0].position();
let c2 = doc.cursor_at(pos01);
c2.insert_text("CellB").unwrap();
let table3 = find_table(&doc).unwrap();
let new_pos00 = table3.cell(0, 0).unwrap().blocks()[0].position();
let new_pos01 = table3.cell(0, 1).unwrap().blocks()[0].position();
let c3 = doc.cursor_at(new_pos00 + 1);
c3.set_position(new_pos01 + 2, MoveMode::KeepAnchor);
c3.remove_selected_text().unwrap();
assert!(
find_table(&doc).is_some(),
"table should survive cross-cell delete"
);
}
#[test]
fn roundtrip_heading_paragraph() {
let doc = heading_and_body_doc();
let cursor = doc.cursor_at(0);
cursor.set_position(6, MoveMode::KeepAnchor);
let frag = cursor.selection();
let doc2 = TextDocument::new();
doc2.set_plain_text("Normal paragraph").unwrap();
let c2 = doc2.cursor_at(7);
c2.insert_fragment(&frag).unwrap();
let plain = doc2.to_plain_text().unwrap();
assert!(plain.contains("Title"), "pasted heading text: {}", plain);
}
#[test]
fn roundtrip_plain_text() {
let doc = TextDocument::new();
doc.set_plain_text("Hello World").unwrap();
let cursor = doc.cursor_at(0);
cursor.set_position(5, MoveMode::KeepAnchor);
let frag = cursor.selection();
let doc2 = TextDocument::new();
doc2.set_plain_text("Goodbye").unwrap();
let c2 = doc2.cursor_at(7);
c2.insert_fragment(&frag).unwrap();
let plain = doc2.to_plain_text().unwrap();
assert_eq!(plain, "GoodbyeHello");
}
#[test]
fn roundtrip_mixed_text_table() {
let doc = TextDocument::new();
doc.set_plain_text("Before").unwrap();
let cursor = doc.cursor_at(6);
cursor.insert_table(2, 2).unwrap();
let table = find_table(&doc).unwrap();
let cell = table.cell(0, 0).unwrap();
let cell_pos = cell.blocks()[0].position();
let c2 = doc.cursor_at(cell_pos);
c2.insert_text("Hello").unwrap();
let table2 = find_table(&doc).unwrap();
let new_cell_pos = table2.cell(0, 0).unwrap().blocks()[0].position();
let c3 = doc.cursor_at(0);
c3.set_position(new_cell_pos + 2, MoveMode::KeepAnchor);
let frag = c3.selection();
let html = frag.to_html();
assert!(
html.contains("Before"),
"should contain 'Before' text: {}",
html
);
assert!(html.contains("<table>"), "should contain table: {}", html);
}
#[test]
fn copy_paste_preserves_inline_bold() {
let doc = TextDocument::new();
let cursor = doc.cursor_at(0);
cursor.insert_html("<p><b>Bold Text</b></p>").unwrap();
let plain = doc.to_plain_text().unwrap();
let bold_start = plain.find("Bold Text").expect("should contain bold text");
let bold_end = bold_start + "Bold Text".len();
let c2 = doc.cursor_at(bold_start);
c2.set_position(bold_end, MoveMode::KeepAnchor);
let frag = c2.selection();
let doc2 = TextDocument::new();
let c3 = doc2.cursor_at(0);
c3.insert_fragment(&frag).unwrap();
let plain2 = doc2.to_plain_text().unwrap();
let bs = plain2.find("Bold").expect("should have bold text");
let c4 = doc2.cursor_at(bs);
c4.set_position(bs + 4, MoveMode::KeepAnchor);
let frag2 = c4.selection();
let html2 = frag2.to_html();
assert!(
html2.contains("<b>") || html2.contains("<strong>") || html2.contains("font-weight"),
"bold should survive roundtrip: {}",
html2
);
}
#[test]
fn html_table_roundtrip_preserves_structure() {
let html = "<table><tr><td>A</td><td>B</td></tr><tr><td>C</td><td>D</td></tr></table>";
let frag = DocumentFragment::from_html(html);
let out_html = frag.to_html();
assert!(
out_html.contains("<table>"),
"roundtrip should preserve <table>: {}",
out_html
);
assert!(
out_html.contains("<td>"),
"roundtrip should preserve <td>: {}",
out_html
);
assert!(
out_html.contains("A") && out_html.contains("D"),
"cell content preserved: {}",
out_html
);
}
#[test]
fn html_table_with_text_roundtrip() {
let html = "<p>Before</p><table><tr><td>X</td></tr></table><p>After</p>";
let frag = DocumentFragment::from_html(html);
let out_html = frag.to_html();
assert!(
out_html.contains("Before"),
"text before table: {}",
out_html
);
assert!(
out_html.contains("<table>"),
"table preserved: {}",
out_html
);
assert!(out_html.contains("After"), "text after table: {}", out_html);
}
#[test]
fn markdown_table_roundtrip() {
let md = "| A | B |\n| --- | --- |\n| C | D |";
let frag = DocumentFragment::from_markdown(md);
let out_md = frag.to_markdown();
assert!(
out_md.contains("|"),
"markdown table should survive roundtrip: {}",
out_md
);
assert!(
out_md.contains("A") && out_md.contains("D"),
"cell content preserved: {}",
out_md
);
}
#[test]
fn insert_html_table_creates_table_entity() {
let doc = TextDocument::new();
doc.set_plain_text("Text").unwrap();
let cursor = doc.cursor_at(4);
cursor
.insert_html("<table><tr><td>A</td><td>B</td></tr><tr><td>C</td><td>D</td></tr></table>")
.unwrap();
assert!(
find_table(&doc).is_some(),
"insert_html with <table> should create a table entity"
);
}
#[test]
fn cell_selection_extract_produces_table_fragment() {
let doc = TextDocument::new();
doc.set_plain_text("Text").unwrap();
let cursor = doc.cursor_at(4);
cursor.insert_table(2, 2).unwrap();
let snap = doc.snapshot_flow();
let cell_pos = snap
.elements
.iter()
.find_map(|e| {
if let text_document::FlowElementSnapshot::Table(ts) = e {
ts.cells
.iter()
.find(|c| c.row == 0 && c.column == 0)
.map(|c| c.blocks[0].position)
} else {
None
}
})
.expect("cell(0,0) block should exist");
let c1 = doc.cursor_at(cell_pos);
c1.insert_text("Hello").unwrap();
let table2 = find_table(&doc).unwrap();
let table_id = table2.id();
let c3 = doc.cursor_at(0);
c3.select_cell_range(table_id, 0, 0, 1, 1);
let kind = c3.selection_kind();
assert!(
matches!(kind, SelectionKind::Cells(_)),
"should be cell selection: {:?}",
kind
);
let frag = c3.selection();
assert!(
!frag.is_empty(),
"cell selection should produce non-empty fragment"
);
let html = frag.to_html();
assert!(
html.contains("<table>"),
"cell selection should produce table HTML: {}",
html
);
assert!(
html.contains("Hello"),
"should contain cell content: {}",
html
);
}
#[test]
fn paste_table_into_existing_table_replaces_cells() {
let doc = TextDocument::new();
doc.set_plain_text("Text").unwrap();
let cursor = doc.cursor_at(4);
cursor.insert_table(2, 2).unwrap();
let table = find_table(&doc).unwrap();
let pos00 = table.cell(0, 0).unwrap().blocks()[0].position();
let c1 = doc.cursor_at(pos00);
c1.insert_text("Original").unwrap();
let frag = DocumentFragment::from_html("<table><tr><td>Replaced</td></tr></table>");
let table2 = find_table(&doc).unwrap();
let new_pos00 = table2.cell(0, 0).unwrap().blocks()[0].position();
let c2 = doc.cursor_at(new_pos00);
c2.insert_fragment(&frag).unwrap();
let _tables: Vec<_> = doc
.flow()
.into_iter()
.filter_map(|e| match e {
FlowElement::Table(t) => Some(t),
_ => None,
})
.collect();
let plain = doc.to_plain_text().unwrap();
assert!(
plain.contains("Replaced"),
"cell content should be replaced: {}",
plain
);
}
#[test]
fn paste_list_continues_adjacent_list() {
let doc = TextDocument::new();
let cursor = doc.cursor_at(0);
cursor
.insert_html("<ul><li>Existing item</li></ul>")
.unwrap();
let frag = DocumentFragment::from_html("<ul><li>New item</li></ul>");
let plain = doc.to_plain_text().unwrap();
let c2 = doc.cursor_at(plain.len());
c2.insert_fragment(&frag).unwrap();
let plain2 = doc.to_plain_text().unwrap();
assert!(
plain2.contains("Existing item") && plain2.contains("New item"),
"both items should exist: {}",
plain2
);
}
#[test]
fn paste_heading_into_list_preserves_tail_list() {
let doc = TextDocument::new();
let cursor = doc.cursor_at(0);
cursor
.insert_html("<ul><li>List item text</li></ul>")
.unwrap();
let frag = DocumentFragment::from_html("<h1>Heading</h1>");
let plain = doc.to_plain_text().unwrap();
let mid = plain.find("item").unwrap_or(5);
let c2 = doc.cursor_at(mid);
c2.insert_fragment(&frag).unwrap();
let plain2 = doc.to_plain_text().unwrap();
assert!(
plain2.contains("Heading"),
"heading text should appear: {}",
plain2
);
}
#[test]
fn undo_paste_html_over_selection_is_atomic() {
let doc = TextDocument::new();
doc.set_plain_text("Hello World").unwrap();
let cursor = doc.cursor_at(6);
cursor.set_position(11, MoveMode::KeepAnchor);
cursor.insert_html("<b>Bold</b>").unwrap();
let after_paste = doc.to_plain_text().unwrap();
assert!(
after_paste.contains("Bold"),
"paste worked: {}",
after_paste
);
doc.undo().unwrap();
let after_undo = doc.to_plain_text().unwrap();
assert_eq!(after_undo, "Hello World", "undo should restore original");
}
#[test]
fn extract_inside_blockquote_includes_text() {
let doc = TextDocument::new();
let cursor = doc.cursor_at(0);
cursor
.insert_markdown("> Quoted text inside a frame")
.unwrap();
let plain = doc.to_plain_text().unwrap();
let start = plain.find("Quoted").unwrap_or(0);
let end = start + "Quoted".len();
let c2 = doc.cursor_at(start);
c2.set_position(end, MoveMode::KeepAnchor);
let frag = c2.selection();
let frag_text = frag.to_plain_text();
assert!(
frag_text.contains("Quoted"),
"should extract text from blockquote: plain='{}', frag='{}'",
plain,
frag_text
);
}
#[test]
fn insert_into_blockquote_stays_in_blockquote() {
let doc = TextDocument::new();
let cursor = doc.cursor_at(0);
cursor.insert_markdown("> Quoted text").unwrap();
let plain = doc.to_plain_text().unwrap();
let mid = plain.find("text").unwrap_or(plain.len().saturating_sub(2));
let c2 = doc.cursor_at(mid);
c2.insert_text("INSERTED ").unwrap();
let plain2 = doc.to_plain_text().unwrap();
assert!(
plain2.contains("INSERTED"),
"inserted text should appear: {}",
plain2
);
}
#[test]
fn copy_paste_inside_blockquote_roundtrip() {
let doc = TextDocument::new();
let cursor = doc.cursor_at(0);
cursor
.insert_markdown("> First line\n> Second line")
.unwrap();
let plain = doc.to_plain_text().unwrap();
let start = plain.find("First").unwrap_or(0);
let end = start + "First".len();
let c2 = doc.cursor_at(start);
c2.set_position(end, MoveMode::KeepAnchor);
let frag = c2.selection();
assert!(
!frag.is_empty(),
"should extract non-empty fragment from blockquote"
);
let c3 = doc.cursor_at(plain.len());
c3.insert_fragment(&frag).unwrap();
let plain2 = doc.to_plain_text().unwrap();
let count = plain2.matches("First").count();
assert!(
count >= 2,
"pasted text should appear twice, got {}: {}",
count,
plain2
);
}
#[test]
fn undo_paste_fragment_over_selection_is_atomic() {
let doc = TextDocument::new();
doc.set_plain_text("ABCDEF").unwrap();
let cursor = doc.cursor_at(2);
cursor.set_position(4, MoveMode::KeepAnchor); let frag = DocumentFragment::from_plain_text("XY");
cursor.insert_fragment(&frag).unwrap();
assert_eq!(doc.to_plain_text().unwrap(), "ABXYEF");
doc.undo().unwrap();
assert_eq!(doc.to_plain_text().unwrap(), "ABCDEF");
}
#[derive(Debug, Clone, PartialEq)]
enum ElementFingerprint {
Block {
text: String,
block_format: BlockFormat,
list_style: Option<text_document::ListStyle>,
list_indent: Option<u8>,
fragment_texts: Vec<String>,
fragment_bolds: Vec<Option<bool>>,
fragment_italics: Vec<Option<bool>>,
},
Table {
rows: usize,
columns: usize,
cells: Vec<CellFingerprint>,
},
Frame {
is_blockquote: Option<bool>,
elements: Vec<ElementFingerprint>,
},
}
#[derive(Debug, Clone, PartialEq)]
struct CellFingerprint {
row: usize,
column: usize,
row_span: usize,
column_span: usize,
blocks: Vec<ElementFingerprint>,
}
fn fingerprint_block(snap: &BlockSnapshot) -> ElementFingerprint {
let mut frag_texts = Vec::new();
let mut frag_bolds = Vec::new();
let mut frag_italics = Vec::new();
for f in &snap.fragments {
match f {
text_document::FragmentContent::Text { text, format, .. } => {
if text.is_empty() {
continue;
}
frag_texts.push(text.clone());
frag_bolds.push(format.font_bold);
frag_italics.push(format.font_italic);
}
text_document::FragmentContent::Image { name, .. } => {
frag_texts.push(format!("[img:{}]", name));
frag_bolds.push(None);
frag_italics.push(None);
}
}
}
ElementFingerprint::Block {
text: snap.text.clone(),
block_format: snap.block_format.clone(),
list_style: snap.list_info.as_ref().map(|l| l.style.clone()),
list_indent: snap.list_info.as_ref().map(|l| l.indent),
fragment_texts: frag_texts,
fragment_bolds: frag_bolds,
fragment_italics: frag_italics,
}
}
fn fingerprint_element(elem: &FlowElementSnapshot) -> ElementFingerprint {
match elem {
FlowElementSnapshot::Block(snap) => fingerprint_block(snap),
FlowElementSnapshot::Table(snap) => ElementFingerprint::Table {
rows: snap.rows,
columns: snap.columns,
cells: snap
.cells
.iter()
.map(|c| CellFingerprint {
row: c.row,
column: c.column,
row_span: c.row_span,
column_span: c.column_span,
blocks: c.blocks.iter().map(fingerprint_block).collect(),
})
.collect(),
},
FlowElementSnapshot::Frame(snap) => ElementFingerprint::Frame {
is_blockquote: snap.format.is_blockquote,
elements: snap.elements.iter().map(fingerprint_element).collect(),
},
}
}
fn fingerprint_flow(snap: &FlowSnapshot) -> Vec<ElementFingerprint> {
snap.elements.iter().map(fingerprint_element).collect()
}
fn elem_summary(e: &ElementFingerprint) -> String {
match e {
ElementFingerprint::Block {
text,
block_format,
list_style,
..
} => {
let kind = if let Some(level) = block_format.heading_level {
format!("H{}", level)
} else if let Some(style) = list_style {
format!("List({:?})", style)
} else {
"P".to_string()
};
format!(
"{}[{}]",
kind,
if text.len() > 30 { &text[..30] } else { text }
)
}
ElementFingerprint::Table { rows, columns, .. } => {
format!("Table({}x{})", rows, columns)
}
ElementFingerprint::Frame {
is_blockquote,
elements,
..
} => {
format!("Frame(bq={:?}, {} elems)", is_blockquote, elements.len())
}
}
}
#[test]
fn comprehensive_roundtrip_select_all_copy_paste() {
let doc = TextDocument::new();
doc.set_plain_text("x").unwrap();
let cursor = doc.cursor_at(0);
cursor.set_position(1, MoveMode::KeepAnchor);
cursor
.insert_html(concat!(
"<h1>Document Title</h1>",
"<p>Normal paragraph with <b>bold</b> and <i>italic</i> text.</p>",
"<h2>Section One</h2>",
"<ul>",
"<li>Bullet item one</li>",
"<li>Bullet item two</li>",
"</ul>",
"<ol>",
"<li>Numbered first</li>",
"<li>Numbered second</li>",
"</ol>",
"<p>Final paragraph.</p>",
))
.unwrap();
let snap_before = doc.snapshot_flow();
let fp_before = fingerprint_flow(&snap_before);
let plain_before = doc.to_plain_text().unwrap();
assert!(
plain_before.contains("Document Title"),
"should have title: {}",
plain_before
);
let block_count = fp_before
.iter()
.filter(|e| matches!(e, ElementFingerprint::Block { .. }))
.count();
assert!(
block_count >= 5,
"should have multiple blocks: {}",
block_count
);
let len = plain_before.len();
let c2 = doc.cursor_at(0);
c2.set_position(len, MoveMode::KeepAnchor);
let frag = c2.selection();
assert!(!frag.is_empty(), "selection should not be empty");
let c3 = doc.cursor_at(0);
let len2 = doc.to_plain_text().unwrap().len();
c3.set_position(len2, MoveMode::KeepAnchor);
c3.insert_fragment(&frag).unwrap();
let snap_after = doc.snapshot_flow();
let fp_after = fingerprint_flow(&snap_after);
eprintln!("BEFORE ({} elements):", fp_before.len());
for (i, e) in fp_before.iter().enumerate() {
eprintln!(" {}: {}", i, elem_summary(e));
}
eprintln!("AFTER ({} elements):", fp_after.len());
for (i, e) in fp_after.iter().enumerate() {
eprintln!(" {}: {}", i, elem_summary(e));
}
assert_eq!(
fp_before.len(),
fp_after.len(),
"element count should match: before={}, after={}",
fp_before.len(),
fp_after.len()
);
for (i, (before, after)) in fp_before.iter().zip(fp_after.iter()).enumerate() {
assert_eq!(
before, after,
"element {} differs:\n BEFORE: {:?}\n AFTER: {:?}",
i, before, after
);
}
}
#[test]
fn comprehensive_roundtrip_with_table() {
let doc = TextDocument::new();
doc.set_plain_text("x").unwrap();
let cursor = doc.cursor_at(0);
cursor.set_position(1, MoveMode::KeepAnchor);
cursor
.insert_html(concat!(
"<h1>Title</h1>",
"<p>Before table.</p>",
"<table>",
"<tr><td>A1</td><td>B1</td></tr>",
"<tr><td>A2</td><td>B2</td></tr>",
"</table>",
"<p>After table.</p>",
))
.unwrap();
let snap_before = doc.snapshot_flow();
let fp_before = fingerprint_flow(&snap_before);
eprintln!("BEFORE ({} elements):", fp_before.len());
for (i, e) in fp_before.iter().enumerate() {
eprintln!(" {}: {}", i, elem_summary(e));
}
#[allow(dead_code)]
fn max_pos_from_snapshot(snap: &FlowSnapshot) -> usize {
fn max_pos_elem(elem: &FlowElementSnapshot) -> usize {
match elem {
FlowElementSnapshot::Block(b) => b.position + b.length,
FlowElementSnapshot::Table(t) => t
.cells
.iter()
.flat_map(|c| &c.blocks)
.map(|b| b.position + b.length)
.max()
.unwrap_or(0),
FlowElementSnapshot::Frame(f) => {
f.elements.iter().map(max_pos_elem).max().unwrap_or(0)
}
}
}
snap.elements.iter().map(max_pos_elem).max().unwrap_or(0)
}
let c2 = doc.cursor_at(0);
c2.move_position(MoveOperation::End, MoveMode::KeepAnchor, 1);
let frag = c2.selection();
assert!(!frag.is_empty());
let c3 = doc.cursor_at(0);
c3.move_position(MoveOperation::End, MoveMode::KeepAnchor, 1);
c3.insert_fragment(&frag).unwrap();
let snap_after = doc.snapshot_flow();
let fp_after = fingerprint_flow(&snap_after);
eprintln!("AFTER ({} elements):", fp_after.len());
for (i, e) in fp_after.iter().enumerate() {
eprintln!(" {}: {}", i, elem_summary(e));
}
assert_eq!(
fp_before.len(),
fp_after.len(),
"element count should match: before={}, after={}",
fp_before.len(),
fp_after.len()
);
for (i, (before, after)) in fp_before.iter().zip(fp_after.iter()).enumerate() {
assert_eq!(
before, after,
"element {} differs:\n BEFORE: {:?}\n AFTER: {:?}",
i, before, after
);
}
}
#[test]
fn roundtrip_nested_list_three_levels() {
let doc = TextDocument::new();
doc.set_html(concat!(
"<ul>",
"<li>Level 1 first",
"<ul>",
"<li>Level 2 first",
"<ul>",
"<li>Level 3 first</li>",
"<li>Level 3 second</li>",
"</ul>",
"</li>",
"<li>Level 2 second</li>",
"</ul>",
"</li>",
"<li>Level 1 second</li>",
"<li>Level 1 third",
"<ul>",
"<li>Level 2 third</li>",
"</ul>",
"</li>",
"</ul>",
))
.unwrap()
.wait()
.unwrap();
let snap_before = doc.snapshot_flow();
let fp_before = fingerprint_flow(&snap_before);
eprintln!("BEFORE ({} elements):", fp_before.len());
for (i, e) in fp_before.iter().enumerate() {
eprintln!(" {}: {:?}", i, e);
}
let items: Vec<_> = fp_before
.iter()
.filter_map(|e| match e {
ElementFingerprint::Block {
text, list_indent, ..
} => Some((text.as_str(), *list_indent)),
_ => None,
})
.collect();
eprintln!("Items: {:?}", items);
let expected: Vec<(&str, Option<u8>)> = vec![
("Level 1 first", Some(0)),
("Level 2 first", Some(1)),
("Level 3 first", Some(2)),
("Level 3 second", Some(2)),
("Level 2 second", Some(1)),
("Level 1 second", Some(0)),
("Level 1 third", Some(0)),
("Level 2 third", Some(1)),
];
assert_eq!(
items, expected,
"HTML import should produce correct list order and indents"
);
let c2 = doc.cursor_at(0);
c2.move_position(MoveOperation::End, MoveMode::KeepAnchor, 1);
let frag = c2.selection();
assert!(!frag.is_empty(), "selection should not be empty");
let c3 = doc.cursor_at(0);
c3.move_position(MoveOperation::End, MoveMode::KeepAnchor, 1);
c3.insert_fragment(&frag).unwrap();
let snap_after = doc.snapshot_flow();
let fp_after = fingerprint_flow(&snap_after);
eprintln!("AFTER ({} elements):", fp_after.len());
for (i, e) in fp_after.iter().enumerate() {
eprintln!(" {}: {:?}", i, e);
}
let items_after: Vec<_> = fp_after
.iter()
.filter_map(|e| match e {
ElementFingerprint::Block {
text, list_indent, ..
} => Some((text.as_str(), *list_indent)),
_ => None,
})
.collect();
assert_eq!(
items_after, expected,
"copy/paste roundtrip should preserve correct list order and indents (HTML)"
);
}
#[test]
fn roundtrip_nested_list_three_levels_markdown() {
let doc = TextDocument::new();
doc.set_markdown(concat!(
"- Level 1 first\n",
" - Level 2 first\n",
" - Level 3 first\n",
" - Level 3 second\n",
" - Level 2 second\n",
"- Level 1 second\n",
"- Level 1 third\n",
" - Level 2 third\n",
))
.unwrap()
.wait()
.unwrap();
let snap_before = doc.snapshot_flow();
let fp_before = fingerprint_flow(&snap_before);
eprintln!("BEFORE ({} elements):", fp_before.len());
for (i, e) in fp_before.iter().enumerate() {
eprintln!(" {}: {:?}", i, e);
}
let items: Vec<_> = fp_before
.iter()
.filter_map(|e| match e {
ElementFingerprint::Block {
text, list_indent, ..
} => Some((text.as_str(), *list_indent)),
_ => None,
})
.collect();
eprintln!("Items: {:?}", items);
let expected: Vec<(&str, Option<u8>)> = vec![
("Level 1 first", Some(0)),
("Level 2 first", Some(1)),
("Level 3 first", Some(2)),
("Level 3 second", Some(2)),
("Level 2 second", Some(1)),
("Level 1 second", Some(0)),
("Level 1 third", Some(0)),
("Level 2 third", Some(1)),
];
assert_eq!(
items, expected,
"Markdown import should produce correct list order and indents"
);
let c2 = doc.cursor_at(0);
c2.move_position(MoveOperation::End, MoveMode::KeepAnchor, 1);
let frag = c2.selection();
assert!(!frag.is_empty(), "selection should not be empty");
let c3 = doc.cursor_at(0);
c3.move_position(MoveOperation::End, MoveMode::KeepAnchor, 1);
c3.insert_fragment(&frag).unwrap();
let snap_after = doc.snapshot_flow();
let fp_after = fingerprint_flow(&snap_after);
eprintln!("AFTER ({} elements):", fp_after.len());
for (i, e) in fp_after.iter().enumerate() {
eprintln!(" {}: {:?}", i, e);
}
let items_after: Vec<_> = fp_after
.iter()
.filter_map(|e| match e {
ElementFingerprint::Block {
text, list_indent, ..
} => Some((text.as_str(), *list_indent)),
_ => None,
})
.collect();
assert_eq!(
items_after, expected,
"copy/paste roundtrip should preserve correct list order and indents (Markdown)"
);
}