use serde_json::json;
use tiptap_rusty_parser::{apply, Change, Mark, Node};
fn roundtrip(a: &Node, b: &Node) -> Vec<Change> {
let changes = a.diff(b);
let mut got = a.clone();
apply(&mut got, &changes).unwrap();
assert_eq!(&got, b, "diff+apply did not reproduce target");
let undo = a.invert(&changes).unwrap();
let mut back = b.clone();
apply(&mut back, &undo).unwrap();
assert_eq!(&back, a, "invert+apply did not restore source");
changes
}
fn p(text: &str) -> Node {
Node::element("paragraph").with_child(Node::text(text))
}
#[test]
fn identical_is_empty() {
let a = Node::element("doc").with_children([p("one"), p("two")]);
let changes = roundtrip(&a, &a.clone());
assert!(changes.is_empty());
}
#[test]
fn attr_add_change_remove() {
let base = Node::element("heading");
let with = base.clone().with_attr("level", 1);
let changed = base.clone().with_attr("level", 2);
let c = roundtrip(&base, &with);
assert_eq!(
c,
vec![Change::SetAttr {
path: vec![],
key: "level".into(),
value: json!(1)
}]
);
roundtrip(&with, &changed); let c = roundtrip(&with, &base); assert_eq!(
c,
vec![Change::RemoveAttr {
path: vec![],
key: "level".into()
}]
);
}
#[test]
fn text_some_none_transitions() {
let mut a = Node::text("hello");
let mut b = Node::text("world");
roundtrip(&a, &b);
b.text = None;
let c = roundtrip(&a, &b); assert_eq!(
c,
vec![Change::SetText {
path: vec![],
text: None
}]
);
a.text = None;
let b2 = Node::text("added");
roundtrip(&a, &b2); }
#[test]
fn marks_via_setmarks() {
let plain = Node::text("x");
let bold = Node::text("x").with_mark(Mark::new("bold"));
let link_a = Node::text("x").with_mark(Mark::new("link").attr("href", "a"));
let link_b = Node::text("x").with_mark(Mark::new("link").attr("href", "b"));
let c = roundtrip(&plain, &bold);
assert!(matches!(c.as_slice(), [Change::SetMarks { .. }]));
let c = roundtrip(&bold, &plain);
assert_eq!(
c,
vec![Change::SetMarks {
path: vec![],
marks: None
}]
);
roundtrip(&link_a, &link_b);
let ab = Node::text("x")
.with_mark(Mark::new("bold"))
.with_mark(Mark::new("italic"));
let ba = Node::text("x")
.with_mark(Mark::new("italic"))
.with_mark(Mark::new("bold"));
roundtrip(&ab, &ba);
}
#[test]
fn child_insert_positions() {
let two = Node::element("doc").with_children([p("a"), p("b")]);
roundtrip(
&two,
&Node::element("doc").with_children([p("x"), p("a"), p("b")]),
);
roundtrip(
&two,
&Node::element("doc").with_children([p("a"), p("x"), p("b")]),
);
roundtrip(
&two,
&Node::element("doc").with_children([p("a"), p("b"), p("x")]),
);
roundtrip(
&Node::element("doc"),
&Node::element("doc").with_child(p("a")),
);
}
#[test]
fn child_remove_positions() {
let three = Node::element("doc").with_children([p("a"), p("b"), p("c")]);
roundtrip(
&three,
&Node::element("doc").with_children([p("b"), p("c")]),
); roundtrip(
&three,
&Node::element("doc").with_children([p("a"), p("c")]),
); roundtrip(
&three,
&Node::element("doc").with_children([p("a"), p("b")]),
); let one = Node::element("doc").with_child(p("a"));
roundtrip(&one, &Node::element("doc"));
}
#[test]
fn interleaved_inserts_and_removes() {
let a = Node::element("doc").with_children([p("a"), p("b"), p("c"), p("d")]);
let b = Node::element("doc").with_children([p("x"), p("b"), p("d"), p("e")]);
roundtrip(&a, &b);
}
#[test]
fn nested_change_leaves_siblings_untouched() {
let a = Node::element("doc").with_children([p("keep"), p("old"), p("keep2")]);
let b = Node::element("doc").with_children([p("keep"), p("new"), p("keep2")]);
let changes = roundtrip(&a, &b);
assert_eq!(
changes,
vec![Change::SetText {
path: vec![1, 0],
text: Some("new".into())
}]
);
}
#[test]
fn aligned_modify_vs_type_replace() {
let a = Node::element("doc").with_children([p("a"), p("b")]);
let b = Node::element("doc").with_children([p("a"), p("B")]);
let c = roundtrip(&a, &b);
assert!(matches!(c.as_slice(), [Change::SetText { .. }]));
let a2 =
Node::element("doc").with_child(Node::element("paragraph").with_child(Node::text("t")));
let b2 = Node::element("doc").with_child(Node::element("heading").with_child(Node::text("t")));
let c2 = roundtrip(&a2, &b2);
assert!(matches!(c2.as_slice(), [Change::Replace { path, .. }] if path == &vec![0]));
}
#[test]
fn node_type_replace_at_root_and_child() {
let a = Node::element("doc").with_child(p("x"));
let b = Node::element("section").with_child(p("x"));
let c = roundtrip(&a, &b);
assert_eq!(
c,
vec![Change::Replace {
path: vec![],
node: b.clone()
}]
);
}
#[test]
fn long_list_single_middle_insert() {
let kids_a: Vec<Node> = (0..200).map(|i| p(&format!("n{i}"))).collect();
let mut kids_b = kids_a.clone();
kids_b.insert(100, p("inserted"));
let a = Node::element("doc").with_children(kids_a);
let b = Node::element("doc").with_children(kids_b);
let c = roundtrip(&a, &b);
assert_eq!(
c,
vec![Change::Insert {
path: vec![],
index: 100,
node: p("inserted")
}]
);
}
fn count<F: Fn(&Change) -> bool>(c: &[Change], f: F) -> usize {
c.iter().filter(|x| f(x)).count()
}
#[test]
fn move_single_child_rotation() {
let a = Node::element("doc").with_children([p("a"), p("b"), p("c")]);
let b = Node::element("doc").with_children([p("b"), p("c"), p("a")]);
let c = roundtrip(&a, &b);
assert_eq!(count(&c, |x| matches!(x, Change::Move { .. })), 1, "{c:?}");
assert_eq!(count(&c, |x| matches!(x, Change::Insert { .. })), 0);
assert_eq!(count(&c, |x| matches!(x, Change::Replace { .. })), 0);
assert_eq!(count(&c, |x| matches!(x, Change::Remove { .. })), 0);
}
#[test]
fn move_drag_past_anchors_is_one_move() {
let a = Node::element("doc").with_children([p("A"), p("B"), p("C"), p("D"), p("E")]);
let b = Node::element("doc").with_children([p("A"), p("C"), p("D"), p("B"), p("E")]);
let c = roundtrip(&a, &b);
assert_eq!(count(&c, |x| matches!(x, Change::Move { .. })), 1, "{c:?}");
assert_eq!(count(&c, |x| matches!(x, Change::Insert { .. })), 0);
}
#[test]
fn move_reversal_no_clones() {
let a = Node::element("doc").with_children([p("A"), p("B"), p("C"), p("D")]);
let b = Node::element("doc").with_children([p("D"), p("C"), p("B"), p("A")]);
let c = roundtrip(&a, &b);
assert!(
count(&c, |x| matches!(x, Change::Move { .. })) >= 1,
"{c:?}"
);
assert_eq!(count(&c, |x| matches!(x, Change::Insert { .. })), 0);
assert_eq!(count(&c, |x| matches!(x, Change::Replace { .. })), 0);
}
#[test]
fn move_interleaved_with_insert_and_remove() {
let a = Node::element("doc").with_children([p("a"), p("b"), p("c"), p("d")]);
let b = Node::element("doc").with_children([p("c"), p("a"), p("x"), p("y")]);
let c = roundtrip(&a, &b);
assert!(
count(&c, |x| matches!(x, Change::Move { .. })) >= 1,
"{c:?}"
);
}
#[test]
fn move_duplicate_equal_children() {
let a = Node::element("doc").with_children([p("x"), p("x"), p("y")]);
let b = Node::element("doc").with_children([p("y"), p("x"), p("x")]);
roundtrip(&a, &b);
}
#[test]
fn move_with_nested_modify() {
let a = Node::element("doc").with_children([p("one"), p("two"), p("three")]);
let b = Node::element("doc").with_children([p("three"), p("one"), p("TWO")]);
roundtrip(&a, &b);
}
#[test]
fn move_invert_restores() {
let a = Node::element("doc").with_children([p("a"), p("b"), p("c")]);
let b = Node::element("doc").with_children([p("c"), p("a"), p("b")]);
let fwd = a.diff(&b);
let inv = a.invert(&fwd).unwrap();
let mut doc = a.clone();
apply(&mut doc, &fwd).unwrap();
assert_eq!(doc, b);
apply(&mut doc, &inv).unwrap();
assert_eq!(doc, a);
}
#[test]
fn fuzz_shuffle_is_pure_moves() {
let mut rng = Rng(0xD1B54A32D192ED03);
for _ in 0..1000 {
let n = 1 + rng.below(8);
let kids: Vec<Node> = (0..n).map(|i| p(&format!("n{i}"))).collect();
let mut shuffled = kids.clone();
for i in (1..shuffled.len()).rev() {
shuffled.swap(i, rng.below(i + 1));
}
let a = Node::element("doc").with_children(kids);
let b = Node::element("doc").with_children(shuffled);
let c = roundtrip(&a, &b);
assert_eq!(
count(&c, |x| matches!(x, Change::Insert { .. })),
0,
"{c:?}"
);
assert_eq!(
count(&c, |x| matches!(x, Change::Remove { .. })),
0,
"{c:?}"
);
assert_eq!(
count(&c, |x| matches!(x, Change::Replace { .. })),
0,
"{c:?}"
);
}
}
#[test]
fn extra_fields_roundtrip() {
let mut a = Node::element("doc");
a.extra.insert("docId".into(), json!("abc"));
let mut b = Node::element("doc");
b.extra.insert("docId".into(), json!("xyz")); b.extra.insert("version".into(), json!(3)); let c = roundtrip(&a, &b);
assert!(c
.iter()
.any(|ch| matches!(ch, Change::SetExtra { key, .. } if key == "version")));
roundtrip(&b, &a);
}
#[test]
fn extra_from_parsed_json() {
use tiptap_rusty_parser::Document;
let a = Document::from_json_str(
r#"{"type":"doc","customField":1,"content":[{"type":"paragraph","textAlign":"left"}]}"#,
)
.unwrap();
let b = Document::from_json_str(
r#"{"type":"doc","customField":2,"content":[{"type":"paragraph","textAlign":"center"}]}"#,
)
.unwrap();
let changes = a.diff(&b);
let mut got = a.clone();
got.apply(&changes).unwrap();
assert_eq!(got, b);
}
#[test]
fn empty_container_shapes_roundtrip() {
use tiptap_rusty_parser::Document;
let empty = Document::from_json_str(r#"{"type":"doc","content":[]}"#).unwrap();
let absent = Document::from_json_str(r#"{"type":"doc"}"#).unwrap();
assert_ne!(empty, absent);
roundtrip(&empty, &absent); roundtrip(&absent, &empty);
let attrs_empty = Document::from_json_str(r#"{"type":"paragraph","attrs":{}}"#).unwrap();
let attrs_absent = Document::from_json_str(r#"{"type":"paragraph"}"#).unwrap();
assert_ne!(attrs_empty, attrs_absent);
roundtrip(&attrs_empty, &attrs_absent);
roundtrip(&attrs_absent, &attrs_empty);
let with_kids =
Document::from_json_str(r#"{"type":"doc","content":[{"type":"paragraph"}]}"#).unwrap();
roundtrip(&empty, &with_kids);
}
#[test]
fn apply_rejects_out_of_range_insert() {
let doc = Node::element("doc").with_child(p("a")); let bad = vec![Change::Insert {
path: vec![],
index: 5, node: p("x"),
}];
let mut got = doc.clone();
assert!(apply(&mut got, &bad).is_err());
assert_eq!(got, doc); let ok = vec![Change::Insert {
path: vec![],
index: 1,
node: p("x"),
}];
let mut got2 = doc.clone();
apply(&mut got2, &ok).unwrap();
assert_eq!(got2.child_count(), 2);
}
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
self.0
}
fn below(&mut self, n: usize) -> usize {
(self.next() % n as u64) as usize
}
}
fn random_tree(rng: &mut Rng, depth: usize) -> Node {
let types = ["doc", "paragraph", "heading", "list", "item"];
let mut n = Node::element(types[rng.below(types.len())]);
if rng.below(2) == 0 {
n = n.with_attr("k", rng.below(3) as i64);
}
if depth == 0 || rng.below(3) == 0 {
if rng.below(2) == 0 {
let words = ["a", "bb", "ccc", "dd"];
n = n.with_child(Node::text(words[rng.below(words.len())]));
}
return n;
}
let count = rng.below(4);
for _ in 0..count {
n = n.with_child(random_tree(rng, depth - 1));
}
if rng.below(6) == 0 {
n.content = Some(Vec::new());
}
if rng.below(6) == 0 {
n.attrs = Some(serde_json::Map::new());
}
n
}
#[test]
fn fuzz_roundtrip_and_undo() {
let mut rng = Rng(0x9E3779B97F4A7C15);
for _ in 0..2000 {
let a = random_tree(&mut rng, 4);
let b = random_tree(&mut rng, 4);
let changes = a.diff(&b);
let mut got = a.clone();
apply(&mut got, &changes).unwrap();
assert_eq!(got, b, "fuzz round-trip failed\n a={a:?}\n b={b:?}");
let undo = a.invert(&changes).unwrap();
let mut back = b.clone();
apply(&mut back, &undo).unwrap();
assert_eq!(back, a, "fuzz undo failed\n a={a:?}\n b={b:?}");
}
}
#[test]
fn invert_apply_forward_then_undo() {
let a = Node::element("doc").with_children([p("a"), p("b")]);
let b = Node::element("doc").with_children([p("a"), p("B"), p("c")]);
let fwd = a.diff(&b);
let inv = a.invert(&fwd).unwrap();
let mut doc = a.clone();
apply(&mut doc, &fwd).unwrap();
assert_eq!(doc, b);
apply(&mut doc, &inv).unwrap();
assert_eq!(doc, a);
}
#[test]
fn invert_empty_is_empty() {
let a = Node::element("doc").with_child(p("x"));
assert!(a.invert(&[]).unwrap().is_empty());
}