use mtplatx_doc_core::{
Block, CodeBlock, Document, Heading, Inline, List, ListItem, Metadata, Paragraph, Table,
TableCell,
};
use mtplatx_doc_diff::{
apply, diff, inlines_equal_ignoring_ws, DiffError, DiffStats, Operation, Patch,
};
fn p(text: &str) -> Block {
Block::Paragraph(Paragraph {
content: vec![Inline::from(text)],
})
}
fn h(level: u8, text: &str) -> Block {
Block::Heading(Heading {
level,
content: vec![Inline::from(text)],
})
}
#[test]
fn test_diff_identical_documents_is_empty() {
let mut doc = Document::new();
doc.push(p("Hello"));
doc.push(p("World"));
let patch = diff(&doc, &doc);
assert!(patch.is_empty());
assert_eq!(patch.len(), 0);
assert_eq!(patch.edit_distance(), 0);
}
#[test]
fn test_diff_insert_beginning() {
let mut old_doc = Document::new();
old_doc.push(p("Second"));
let mut new_doc = Document::new();
new_doc.push(p("First"));
new_doc.push(p("Second"));
let patch = diff(&old_doc, &new_doc);
assert_eq!(patch.len(), 1);
assert!(matches!(&patch.operations[0], Operation::Insert { index: 0, block } if block == &p("First")));
}
#[test]
fn test_diff_insert_middle() {
let mut old_doc = Document::new();
old_doc.push(p("First"));
old_doc.push(p("Third"));
let mut new_doc = Document::new();
new_doc.push(p("First"));
new_doc.push(p("Second"));
new_doc.push(p("Third"));
let patch = diff(&old_doc, &new_doc);
assert_eq!(patch.len(), 1);
assert!(matches!(&patch.operations[0], Operation::Insert { index: 1, block } if block == &p("Second")));
}
#[test]
fn test_diff_insert_end() {
let mut old_doc = Document::new();
old_doc.push(p("First"));
let mut new_doc = Document::new();
new_doc.push(p("First"));
new_doc.push(p("Second"));
let patch = diff(&old_doc, &new_doc);
assert_eq!(patch.len(), 1);
assert!(matches!(&patch.operations[0], Operation::Insert { index: 1, block } if block == &p("Second")));
}
#[test]
fn test_diff_multiple_inserts() {
let old_doc = Document::new();
let mut new_doc = Document::new();
new_doc.push(p("A"));
new_doc.push(p("B"));
new_doc.push(p("C"));
let patch = diff(&old_doc, &new_doc);
let stats = DiffStats::from(&patch);
assert_eq!(stats.inserts, 3);
}
#[test]
fn test_diff_delete_first() {
let mut old_doc = Document::new();
old_doc.push(p("A"));
old_doc.push(p("B"));
let mut new_doc = Document::new();
new_doc.push(p("B"));
let patch = diff(&old_doc, &new_doc);
let stats = DiffStats::from(&patch);
assert_eq!(stats.deletes, 1);
}
#[test]
fn test_diff_delete_middle() {
let mut old_doc = Document::new();
old_doc.push(p("A"));
old_doc.push(p("B"));
old_doc.push(p("C"));
let mut new_doc = Document::new();
new_doc.push(p("A"));
new_doc.push(p("C"));
let patch = diff(&old_doc, &new_doc);
let stats = DiffStats::from(&patch);
assert_eq!(stats.deletes, 1);
assert!(matches!(&patch.operations[0], Operation::Delete { index: 1 }));
}
#[test]
fn test_diff_delete_end() {
let mut old_doc = Document::new();
old_doc.push(p("A"));
old_doc.push(p("B"));
let mut new_doc = Document::new();
new_doc.push(p("A"));
let patch = diff(&old_doc, &new_doc);
let stats = DiffStats::from(&patch);
assert_eq!(stats.deletes, 1);
}
#[test]
fn test_diff_delete_all() {
let mut old_doc = Document::new();
old_doc.push(p("A"));
old_doc.push(p("B"));
let new_doc = Document::new();
let patch = diff(&old_doc, &new_doc);
let stats = DiffStats::from(&patch);
assert_eq!(stats.deletes, 2);
}
#[test]
fn test_diff_replace_single_coalescing() {
let mut old_doc = Document::new();
old_doc.push(p("Old text"));
let mut new_doc = Document::new();
new_doc.push(p("New text"));
let patch = diff(&old_doc, &new_doc);
assert_eq!(patch.len(), 1);
assert!(matches!(&patch.operations[0], Operation::Replace { index: 0, block } if block == &p("New text")));
}
#[test]
fn test_diff_replace_multiple() {
let mut old_doc = Document::new();
old_doc.push(p("A1"));
let mut new_doc = Document::new();
new_doc.push(p("A2"));
let patch = diff(&old_doc, &new_doc);
let stats = DiffStats::from(&patch);
assert_eq!(stats.replaces, 1);
}
#[test]
fn test_diff_update_metadata_title() {
let mut old_doc = Document::new();
old_doc.metadata.title = Some("Doc V1".into());
let mut new_doc = Document::new();
new_doc.metadata.title = Some("Doc V2".into());
let patch = diff(&old_doc, &new_doc);
let stats = DiffStats::from(&patch);
assert_eq!(stats.metadata, 1);
assert!(matches!(&patch.operations[0], Operation::UpdateMetadata { .. }));
}
#[test]
fn test_diff_update_metadata_authors_and_tags() {
let old_doc = Document::new().with_metadata(Metadata::default().author("Alice").tag("v1"));
let new_doc = Document::new().with_metadata(
Metadata::default()
.author("Alice")
.author("Bob")
.tag("v2"),
);
let patch = diff(&old_doc, &new_doc);
let stats = DiffStats::from(&patch);
assert_eq!(stats.metadata, 1);
}
#[test]
fn test_diff_combined_metadata_and_blocks() {
let old_doc = Document::new()
.with_metadata(Metadata::default().title("Old Title"))
.with_blocks(vec![p("Block 1")]);
let new_doc = Document::new()
.with_metadata(Metadata::default().title("New Title"))
.with_blocks(vec![p("Block 1"), p("Block 2")]);
let patch = diff(&old_doc, &new_doc);
let stats = DiffStats::from(&patch);
assert_eq!(stats.metadata, 1);
assert_eq!(stats.inserts, 1);
}
#[test]
fn test_apply_insert_op() {
let doc = Document::new().with_blocks(vec![p("A")]);
let mut patch = Patch::new();
patch.push(Operation::Insert {
index: 1,
block: p("B"),
});
let result = apply(doc, &patch).unwrap();
assert_eq!(result.blocks.len(), 2);
assert_eq!(result.blocks[1], p("B"));
}
#[test]
fn test_apply_delete_op() {
let doc = Document::new().with_blocks(vec![p("A"), p("B")]);
let mut patch = Patch::new();
patch.push(Operation::Delete { index: 0 });
let result = apply(doc, &patch).unwrap();
assert_eq!(result.blocks.len(), 1);
assert_eq!(result.blocks[0], p("B"));
}
#[test]
fn test_apply_replace_op() {
let doc = Document::new().with_blocks(vec![p("Old")]);
let mut patch = Patch::new();
patch.push(Operation::Replace {
index: 0,
block: p("New"),
});
let result = apply(doc, &patch).unwrap();
assert_eq!(result.blocks[0], p("New"));
}
#[test]
fn test_apply_move_op() {
let doc = Document::new().with_blocks(vec![p("A"), p("B"), p("C")]);
let mut patch = Patch::new();
patch.push(Operation::Move { from: 0, to: 2 });
let result = apply(doc, &patch).unwrap();
assert_eq!(result.blocks, vec![p("B"), p("C"), p("A")]);
}
#[test]
fn test_apply_update_metadata_op() {
let doc = Document::new();
let mut patch = Patch::new();
let meta_val = serde_json::to_value(Metadata::default().title("Updated Title")).unwrap();
patch.push(Operation::UpdateMetadata {
from: serde_json::Value::Null,
to: meta_val,
});
let result = apply(doc, &patch).unwrap();
assert_eq!(result.metadata.title.as_deref(), Some("Updated Title"));
}
#[test]
fn test_apply_roundtrip_complex_doc() {
let mut old_doc = Document::new().with_metadata(Metadata::default().title("Old"));
old_doc.push(h(1, "Heading"));
old_doc.push(p("Paragraph 1"));
old_doc.push(p("Paragraph 2"));
let mut new_doc = Document::new().with_metadata(Metadata::default().title("New"));
new_doc.push(h(1, "Heading"));
new_doc.push(p("Paragraph 1"));
new_doc.push(p("Paragraph 2"));
new_doc.push(p("Paragraph 3"));
let patch = diff(&old_doc, &new_doc);
let applied = apply(old_doc, &patch).unwrap();
assert_eq!(applied.metadata, new_doc.metadata);
assert_eq!(applied.blocks, new_doc.blocks);
}
#[test]
fn test_apply_error_delete_out_of_bounds() {
let doc = Document::new();
let mut patch = Patch::new();
patch.push(Operation::Delete { index: 10 });
let err = apply(doc, &patch).unwrap_err();
assert!(matches!(err, DiffError::IndexOutOfRange { index: 10 }));
}
#[test]
fn test_apply_error_replace_out_of_bounds() {
let doc = Document::new();
let mut patch = Patch::new();
patch.push(Operation::Replace {
index: 5,
block: p("X"),
});
let err = apply(doc, &patch).unwrap_err();
assert!(matches!(err, DiffError::IndexOutOfRange { index: 5 }));
}
#[test]
fn test_apply_error_move_out_of_bounds() {
let doc = Document::new();
let mut patch = Patch::new();
patch.push(Operation::Move { from: 10, to: 0 });
let err = apply(doc, &patch).unwrap_err();
assert!(matches!(err, DiffError::IndexOutOfRange { index: 10 }));
}
#[test]
fn test_apply_error_invalid_metadata() {
let doc = Document::new();
let mut patch = Patch::new();
patch.push(Operation::UpdateMetadata {
from: serde_json::Value::Null,
to: serde_json::json!("invalid string for metadata struct"),
});
let err = apply(doc, &patch).unwrap_err();
assert!(matches!(err, DiffError::InvalidMetadata(_)));
}
#[test]
fn test_diff_stats_empty() {
let patch = Patch::new();
let stats = DiffStats::from(&patch);
assert_eq!(stats.inserts, 0);
assert_eq!(stats.deletes, 0);
assert_eq!(stats.replaces, 0);
assert_eq!(stats.moves, 0);
assert_eq!(stats.metadata, 0);
}
#[test]
fn test_diff_stats_mixed_counts() {
let mut patch = Patch::new();
patch.push(Operation::Insert { index: 0, block: p("A") });
patch.push(Operation::Delete { index: 1 });
patch.push(Operation::Replace { index: 2, block: p("B") });
patch.push(Operation::Move { from: 3, to: 4 });
patch.push(Operation::UpdateMetadata {
from: serde_json::Value::Null,
to: serde_json::Value::Null,
});
let stats = DiffStats::from(&patch);
assert_eq!(stats.inserts, 1);
assert_eq!(stats.deletes, 1);
assert_eq!(stats.replaces, 1);
assert_eq!(stats.moves, 1);
assert_eq!(stats.metadata, 1);
}
#[test]
fn test_patch_edit_distance_calculation() {
let mut patch = Patch::new();
patch.push(Operation::Insert { index: 0, block: p("A") });
patch.push(Operation::Delete { index: 1 });
patch.push(Operation::Replace { index: 2, block: p("B") });
patch.push(Operation::Move { from: 3, to: 4 }); patch.push(Operation::UpdateMetadata { from: serde_json::Value::Null,
to: serde_json::Value::Null,
});
assert_eq!(patch.edit_distance(), 3);
}
#[test]
fn test_patch_methods() {
let mut patch = Patch::default();
assert!(patch.is_empty());
assert_eq!(patch.len(), 0);
patch.push(Operation::Delete { index: 0 });
assert!(!patch.is_empty());
assert_eq!(patch.len(), 1);
}
#[test]
fn test_inlines_equal_ignoring_ws_identical() {
let a = vec![Inline::from("Hello world")];
let b = vec![Inline::from("Hello world")];
assert!(inlines_equal_ignoring_ws(&a, &b));
}
#[test]
fn test_inlines_equal_ignoring_ws_trimming() {
let a = vec![Inline::from(" Hello world ")];
let b = vec![Inline::from("Hello world")];
assert!(inlines_equal_ignoring_ws(&a, &b));
}
#[test]
fn test_inlines_equal_ignoring_ws_formatted_styles() {
let a = vec![Inline::Bold(vec![Inline::from("Bold text")])];
let b = vec![Inline::from("Bold text")];
assert!(inlines_equal_ignoring_ws(&a, &b));
}
#[test]
fn test_inlines_equal_ignoring_ws_different_text() {
let a = vec![Inline::from("Alpha")];
let b = vec![Inline::from("Beta")];
assert!(!inlines_equal_ignoring_ws(&a, &b));
}
#[test]
fn test_inlines_equal_ignoring_ws_empty() {
let a: Vec<Inline> = vec![];
let b: Vec<Inline> = vec![];
assert!(inlines_equal_ignoring_ws(&a, &b));
}
#[test]
fn test_operation_serde_json_roundtrip() {
let ops = vec![
Operation::Insert { index: 0, block: p("Insert") },
Operation::Delete { index: 1 },
Operation::Replace { index: 2, block: p("Replace") },
Operation::Move { from: 3, to: 4 },
Operation::UpdateMetadata {
from: serde_json::json!({"title": "v1"}),
to: serde_json::json!({"title": "v2"}),
},
];
for op in ops {
let json = serde_json::to_string(&op).unwrap();
let back: Operation = serde_json::from_str(&json).unwrap();
assert_eq!(op, back);
}
}
#[test]
fn test_patch_serde_json_roundtrip() {
let mut patch = Patch::new();
patch.push(Operation::Insert { index: 0, block: p("Test") });
patch.push(Operation::Delete { index: 1 });
let json = serde_json::to_string(&patch).unwrap();
let back: Patch = serde_json::from_str(&json).unwrap();
assert_eq!(patch, back);
}
#[test]
fn test_diff_error_display_formatting() {
let err1 = DiffError::IndexOutOfRange { index: 42 };
assert_eq!(err1.to_string(), "index 42 out of range");
let err2 = DiffError::InvalidMetadata("bad field".into());
assert_eq!(err2.to_string(), "invalid metadata in patch: bad field");
}
#[test]
fn test_diff_nested_list_changes() {
let old_doc = Document::new().with_blocks(vec![Block::List(List {
ordered: false,
items: vec![
ListItem { content: vec![p("Item 1")] },
ListItem { content: vec![p("Item 2")] },
],
})]);
let new_doc = Document::new().with_blocks(vec![Block::List(List {
ordered: false,
items: vec![
ListItem { content: vec![p("Item 1")] },
ListItem { content: vec![p("Item 2 Modified")] },
],
})]);
let patch = diff(&old_doc, &new_doc);
assert_eq!(patch.len(), 1);
assert!(matches!(&patch.operations[0], Operation::Replace { index: 0, .. }));
let applied = apply(old_doc, &patch).unwrap();
assert_eq!(applied.blocks, new_doc.blocks);
}
#[test]
fn test_diff_table_changes() {
let cell_a = TableCell { content: vec![p("A")] };
let cell_b = TableCell { content: vec![p("B")] };
let cell_c = TableCell { content: vec![p("C")] };
let old_doc = Document::new().with_blocks(vec![Block::Table(Table {
headers: vec![cell_a.clone(), cell_b.clone()],
rows: vec![vec![cell_a.clone(), cell_b.clone()]],
alignments: vec![],
})]);
let new_doc = Document::new().with_blocks(vec![Block::Table(Table {
headers: vec![cell_a.clone(), cell_b.clone()],
rows: vec![vec![cell_a.clone(), cell_c.clone()]],
alignments: vec![],
})]);
let patch = diff(&old_doc, &new_doc);
let applied = apply(old_doc, &patch).unwrap();
assert_eq!(applied.blocks, new_doc.blocks);
}
#[test]
fn test_diff_code_block_source_change() {
let old_doc = Document::new().with_blocks(vec![Block::Code(CodeBlock {
language: Some("rust".into()),
source: "fn main() {}".into(),
})]);
let new_doc = Document::new().with_blocks(vec![Block::Code(CodeBlock {
language: Some("rust".into()),
source: "fn main() { println!(\"hi\"); }".into(),
})]);
let patch = diff(&old_doc, &new_doc);
assert_eq!(patch.len(), 1);
assert!(matches!(&patch.operations[0], Operation::Replace { .. }));
}