use serde_json::json;
use tiptap_rusty_parser::{Mark, Node, PosContent, PosEdit, PosEditError};
fn p(text: &str) -> Node {
Node::element("paragraph").with_child(Node::text(text))
}
fn two_paras() -> Node {
Node::element("doc").with_children([p("hello world"), p("second line")])
}
fn text(s: &str) -> PosContent {
PosContent::Text {
text: s.into(),
marks: None,
}
}
fn assert_invertible(before: &Node, after: &Node, patch: &[tiptap_rusty_parser::Change]) {
let mut replay = before.clone();
replay.apply(patch).unwrap();
assert_eq!(&replay, after, "patch must reproduce the mutated tree");
let undo = before.invert(patch).unwrap();
let mut back = after.clone();
back.apply(&undo).unwrap();
assert_eq!(&back, before, "invert must restore the original");
}
#[test]
fn same_block_replace_text() {
let mut d = two_paras();
let before = d.clone();
let patch = d
.apply_pos_edits(&[PosEdit::Replace {
from: 7,
to: 12,
content: text("there"),
}])
.unwrap();
assert_eq!(d.child(0).unwrap().text_content(), "hello there");
assert_invertible(&before, &d, &patch);
}
#[test]
fn same_block_delete() {
let mut d = two_paras();
let before = d.clone();
let patch = d
.apply_pos_edits(&[PosEdit::Delete { from: 6, to: 12 }])
.unwrap();
assert_eq!(d.child(0).unwrap().text_content(), "hello");
assert_invertible(&before, &d, &patch);
}
#[test]
fn insert_text_mid_block() {
let mut d = two_paras();
let before = d.clone();
let patch = d
.apply_pos_edits(&[PosEdit::Insert {
pos: 7,
content: text("big "),
}])
.unwrap();
assert_eq!(d.child(0).unwrap().text_content(), "hello big world");
assert_invertible(&before, &d, &patch);
}
#[test]
fn add_and_remove_mark_same_block() {
let mut d = two_paras();
let before = d.clone();
let patch = d
.apply_pos_edits(&[PosEdit::AddMark {
from: 7,
to: 12,
mark: Mark::new("bold"),
}])
.unwrap();
let p0 = d.child(0).unwrap();
assert!(p0.children().iter().any(|c| c.has_mark("bold")));
assert_invertible(&before, &d, &patch);
let mut d2 = d.clone();
let before2 = d2.clone();
let patch2 = d2
.apply_pos_edits(&[PosEdit::RemoveMark {
from: 7,
to: 12,
mark_type: "bold".into(),
}])
.unwrap();
assert!(!d2
.child(0)
.unwrap()
.children()
.iter()
.any(|c| c.has_mark("bold")));
assert_invertible(&before2, &d2, &patch2);
}
#[test]
fn cross_block_delete_joins() {
let mut d = two_paras();
let before = d.clone();
let patch = d
.apply_pos_edits(&[PosEdit::Delete { from: 7, to: 21 }])
.unwrap();
assert_eq!(d.child_count(), 1);
assert_eq!(d.child(0).unwrap().text_content(), "hello line");
assert_invertible(&before, &d, &patch);
}
#[test]
fn cross_block_replace() {
let mut d = two_paras();
let before = d.clone();
let patch = d
.apply_pos_edits(&[PosEdit::Replace {
from: 7,
to: 21,
content: text("X "),
}])
.unwrap();
assert_eq!(d.child_count(), 1);
assert_eq!(d.child(0).unwrap().text_content(), "hello X line");
assert_invertible(&before, &d, &patch);
}
#[test]
fn cross_block_delete_with_middle_block() {
let mut d = Node::element("doc").with_children([p("aa"), p("MID"), p("bb")]);
let before = d.clone();
let patch = d
.apply_pos_edits(&[PosEdit::Delete { from: 2, to: 11 }])
.unwrap();
assert_eq!(d.child_count(), 1);
assert_eq!(d.child(0).unwrap().text_content(), "ab");
assert_invertible(&before, &d, &patch);
}
#[test]
fn cross_block_add_mark_fans_out() {
let mut d = Node::element("doc").with_children([p("aa"), p("MID"), p("bb")]);
let before = d.clone();
let patch = d
.apply_pos_edits(&[PosEdit::AddMark {
from: 2,
to: 11,
mark: Mark::new("em"),
}])
.unwrap();
for i in 0..d.child_count() {
assert!(
d.child(i)
.unwrap()
.children()
.iter()
.any(|c| c.has_mark("em")),
"block {i} should carry the em mark"
);
}
assert_invertible(&before, &d, &patch);
}
#[test]
fn set_block_attrs_replaces_map() {
let mut d = two_paras();
let before = d.clone();
let mut attrs = serde_json::Map::new();
attrs.insert("textAlign".into(), json!("center"));
let patch = d
.apply_pos_edits(&[PosEdit::SetBlockAttrs { pos: 0, attrs }])
.unwrap();
assert_eq!(
d.child(0).unwrap().attrs.as_ref().unwrap().get("textAlign"),
Some(&json!("center"))
);
assert_invertible(&before, &d, &patch);
}
#[test]
fn set_block_attrs_from_inline_position_targets_block() {
let mut d = two_paras();
let before = d.clone();
let inside = d.pos_before(&[0]).unwrap() + 1; let mut attrs = serde_json::Map::new();
attrs.insert("textAlign".into(), json!("right"));
let patch = d
.apply_pos_edits(&[PosEdit::SetBlockAttrs { pos: inside, attrs }])
.unwrap();
assert_eq!(
d.child(0).unwrap().attrs.as_ref().unwrap().get("textAlign"),
Some(&json!("right"))
);
assert!(d.child(0).unwrap().child(0).unwrap().attrs.is_none());
assert_invertible(&before, &d, &patch);
}
#[test]
fn inverted_span_errors_as_inverted_range() {
use tiptap_rusty_parser::RangeError;
let mut d = two_paras();
let err = d
.apply_pos_edits(&[PosEdit::Delete { from: 6, to: 2 }])
.unwrap_err();
assert_eq!(err, PosEditError::Range(RangeError::InvertedRange));
assert_eq!(d, two_paras()); }
#[test]
fn insert_block_nodes_at_boundary() {
let mut d = two_paras();
let before = d.clone();
let patch = d
.apply_pos_edits(&[PosEdit::Insert {
pos: 13,
content: PosContent::Nodes {
nodes: vec![p("inserted")],
},
}])
.unwrap();
assert_eq!(d.child_count(), 3);
assert_eq!(d.child(1).unwrap().text_content(), "inserted");
assert_invertible(&before, &d, &patch);
}
#[test]
fn disjoint_batch_applies_descending() {
let mut d = two_paras();
let before = d.clone();
let patch = d
.apply_pos_edits(&[
PosEdit::Replace {
from: 1,
to: 6,
content: text("HI"),
}, PosEdit::Replace {
from: 21,
to: 25,
content: text("LINE"),
}, ])
.unwrap();
assert_eq!(d.child(0).unwrap().text_content(), "HI world");
assert_eq!(d.child(1).unwrap().text_content(), "second LINE");
assert_invertible(&before, &d, &patch);
}
#[test]
fn overlapping_edits_error() {
let mut d = two_paras();
let err = d
.apply_pos_edits(&[
PosEdit::Delete { from: 1, to: 6 },
PosEdit::Delete { from: 4, to: 9 },
])
.unwrap_err();
assert!(matches!(err, PosEditError::OverlappingEdits { .. }));
assert_eq!(d, two_paras());
}
#[test]
fn cross_depth_span_unsupported() {
let mut d = Node::element("doc")
.with_children([p("aa"), Node::element("blockquote").with_child(p("bb"))]);
let err = d
.apply_pos_edits(&[PosEdit::Delete { from: 2, to: 7 }])
.unwrap_err();
assert!(matches!(err, PosEditError::UnsupportedSpan { .. }));
}
#[test]
fn pos_edits_serde_roundtrip() {
let edits = vec![
PosEdit::Insert {
pos: 3,
content: PosContent::Text {
text: "x".into(),
marks: Some(vec![Mark::new("bold")]),
},
},
PosEdit::SetBlockAttrs {
pos: 0,
attrs: serde_json::Map::new(),
},
];
let s = serde_json::to_string(&edits).unwrap();
assert!(s.contains("\"type\":\"insert\""));
assert!(s.contains("\"setBlockAttrs\""));
let back: Vec<PosEdit> = serde_json::from_str(&s).unwrap();
assert_eq!(back, edits);
}