use tiptap_rusty_parser::{apply, BlockError, BlockRange, Node, Position};
fn para(text: &str) -> Node {
Node::element("paragraph").with_child(Node::text(text))
}
fn doc(children: impl IntoIterator<Item = Node>) -> Node {
Node::element("doc").with_children(children)
}
fn check<F, G>(before: &Node, direct: F, recorded: G)
where
F: FnOnce(&mut Node) -> Result<(), BlockError>,
G: FnOnce(&mut tiptap_rusty_parser::Transform) -> Result<(), BlockError>,
{
let mut a = before.clone();
direct(&mut a).unwrap();
let mut b = before.clone();
let changes = {
let mut tx = b.transform();
recorded(&mut tx).unwrap();
tx.finish()
};
assert_eq!(a, b, "direct op and transform-recorded op diverged");
let mut replay = before.clone();
apply(&mut replay, &changes).unwrap();
assert_eq!(replay, b, "recorded patch did not reproduce the result");
let undo = before.invert(&changes).unwrap();
let mut back = b.clone();
apply(&mut back, &undo).unwrap();
assert_eq!(back, *before, "invert did not restore the original");
}
#[test]
fn set_block_type_keeps_content() {
let before = doc([para("hi")]);
check(
&before,
|n| n.set_block_type(&[0], "heading", None),
|tx| tx.set_block_type(vec![0], "heading", None).map(|_| ()),
);
let mut n = before.clone();
n.set_block_type(&[0], "heading", None).unwrap();
assert_eq!(n.child(0).unwrap().node_type.as_deref(), Some("heading"));
assert_eq!(n.child(0).unwrap().text_content(), "hi"); }
#[test]
fn set_block_type_bad_path() {
let mut n = doc([para("a")]);
assert_eq!(
n.set_block_type(&[5], "heading", None),
Err(BlockError::PathNotFound { path: vec![5] })
);
}
#[test]
fn split_block_at_boundary() {
let before =
doc([Node::element("paragraph")
.with_children([Node::text("foo"), Node::element("hardBreak")])]);
check(
&before,
|n| n.split_block(&[0], 1, 0),
|tx| tx.split_block(vec![0], 1, 0).map(|_| ()),
);
let mut n = before.clone();
n.split_block(&[0], 1, 0).unwrap();
assert_eq!(n.child_count(), 2);
assert_eq!(n.child(0).unwrap().text_content(), "foo");
assert_eq!(
n.child(1).unwrap().child(0).unwrap().node_type.as_deref(),
Some("hardBreak")
);
}
#[test]
fn split_block_mid_text() {
let before = doc([para("abcd")]);
let mut n = before.clone();
n.split_block_at(&[0], Position::new(0, 2), 0).unwrap(); assert_eq!(n.child_count(), 2);
assert_eq!(n.child(0).unwrap().text_content(), "ab");
assert_eq!(n.child(1).unwrap().text_content(), "cd");
check(
&before,
|x| x.split_block_at(&[0], Position::new(0, 2), 0),
|tx| {
tx.split_block_at(vec![0], Position::new(0, 2), 0)
.map(|_| ())
},
);
}
#[test]
fn split_block_depth_one_splits_parent() {
let before =
doc([Node::element("bulletList")
.with_child(Node::element("listItem").with_child(para("abcd")))]);
let mut n = before.clone();
n.split_block_at(&[0, 0, 0], Position::new(0, 2), 1)
.unwrap();
let list = n.child(0).unwrap();
assert_eq!(list.child_count(), 2, "listItem should have split");
assert_eq!(
list.child(0).unwrap().node_type.as_deref(),
Some("listItem")
);
assert_eq!(list.child(0).unwrap().text_content(), "ab");
assert_eq!(list.child(1).unwrap().text_content(), "cd");
}
#[test]
fn split_block_edges() {
let before = doc([para("ab")]);
let mut at0 = before.clone();
at0.split_block(&[0], 0, 0).unwrap(); assert_eq!(at0.child_count(), 2);
assert_eq!(at0.child(0).unwrap().child_count(), 0);
assert_eq!(at0.child(1).unwrap().text_content(), "ab");
let mut at_len = before.clone();
at_len.split_block(&[0], 1, 0).unwrap(); assert_eq!(at_len.child(1).unwrap().child_count(), 0);
}
#[test]
fn split_block_root_errors() {
let mut n = doc([para("a")]);
assert_eq!(n.split_block(&[], 0, 0), Err(BlockError::NoParent));
}
#[test]
fn split_block_at_root_errors_without_partial_mutation() {
let mut n = doc([para("abcd")]);
let before = n.clone();
assert_eq!(
n.split_block_at(&[], Position::new(0, 2), 0),
Err(BlockError::NoParent)
);
assert_eq!(n, before, "tree must be unchanged on error");
}
#[test]
fn join_merges_into_previous() {
let before = doc([para("Hello "), para("world")]);
check(
&before,
|n| n.join_blocks(&[], 1),
|tx| tx.join_blocks(vec![], 1).map(|_| ()),
);
let mut n = before.clone();
n.join_blocks(&[], 1).unwrap();
assert_eq!(n.child_count(), 1);
assert_eq!(n.child(0).unwrap().child_count(), 1);
assert_eq!(n.child(0).unwrap().text_content(), "Hello world");
}
#[test]
fn join_keeps_left_type_on_mismatch() {
let before = doc([
Node::element("heading").with_child(Node::text("H")),
para("p"),
]);
let mut n = before.clone();
n.join_blocks(&[], 1).unwrap();
assert_eq!(n.child(0).unwrap().node_type.as_deref(), Some("heading"));
assert_eq!(n.child(0).unwrap().text_content(), "Hp");
}
#[test]
fn join_index_zero_errors() {
let mut n = doc([para("a")]);
assert_eq!(
n.join_blocks(&[], 0),
Err(BlockError::NoPreviousSibling { path: vec![0] })
);
}
#[test]
fn wrap_single_block() {
let before = doc([para("a")]);
check(
&before,
|n| n.wrap(&[0], "blockquote", None),
|tx| tx.wrap(vec![0], "blockquote", None).map(|_| ()),
);
let mut n = before.clone();
n.wrap(&[0], "blockquote", None).unwrap();
assert_eq!(n.child(0).unwrap().node_type.as_deref(), Some("blockquote"));
assert_eq!(n.child(0).unwrap().child(0).unwrap().text_content(), "a");
}
#[test]
fn wrap_range_preserves_surrounding_siblings() {
let before = doc([para("a"), para("b"), para("c"), para("d")]);
let range = BlockRange::new(vec![], 1, 3); check(
&before,
|n| n.wrap_range(&range, "blockquote", None),
|tx| {
tx.wrap_range(BlockRange::new(vec![], 1, 3), "blockquote", None)
.map(|_| ())
},
);
let mut n = before.clone();
n.wrap_range(&range, "blockquote", None).unwrap();
assert_eq!(n.child_count(), 3); assert_eq!(n.child(0).unwrap().text_content(), "a");
assert_eq!(n.child(1).unwrap().node_type.as_deref(), Some("blockquote"));
assert_eq!(n.child(1).unwrap().child_count(), 2);
assert_eq!(n.child(2).unwrap().text_content(), "d");
}
#[test]
fn wrap_range_invalid() {
let mut n = doc([para("a")]);
assert_eq!(
n.wrap_range(&BlockRange::new(vec![], 0, 5), "blockquote", None),
Err(BlockError::InvalidRange {
parent: vec![],
start: 0,
end: 5
})
);
}
#[test]
fn lift_sole_child_collapses_parent() {
let before = doc([Node::element("blockquote").with_child(para("a"))]);
check(
&before,
|n| n.lift(&[0, 0]),
|tx| tx.lift(vec![0, 0]).map(|_| ()),
);
let mut n = before.clone();
n.lift(&[0, 0]).unwrap();
assert_eq!(n.child_count(), 1);
assert_eq!(n.child(0).unwrap().node_type.as_deref(), Some("paragraph"));
assert_eq!(n.child(0).unwrap().text_content(), "a");
}
#[test]
fn lift_middle_child_splits_parent() {
let before =
doc([Node::element("blockquote").with_children([para("a"), para("b"), para("c")])]);
check(
&before,
|n| n.lift(&[0, 1]),
|tx| tx.lift(vec![0, 1]).map(|_| ()),
);
let mut n = before.clone();
n.lift(&[0, 1]).unwrap();
assert_eq!(n.child_count(), 3);
assert_eq!(n.child(0).unwrap().node_type.as_deref(), Some("blockquote"));
assert_eq!(n.child(0).unwrap().text_content(), "a");
assert_eq!(n.child(1).unwrap().node_type.as_deref(), Some("paragraph"));
assert_eq!(n.child(1).unwrap().text_content(), "b");
assert_eq!(n.child(2).unwrap().node_type.as_deref(), Some("blockquote"));
assert_eq!(n.child(2).unwrap().text_content(), "c");
}
#[test]
fn lift_too_shallow_errors() {
let mut n = doc([para("a")]);
assert_eq!(n.lift(&[0]), Err(BlockError::NoParent)); }
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
}
}
#[test]
fn fuzz_block_ops_roundtrip() {
let mut rng = Rng(0x1234_5678_9abc_def1);
for _ in 0..500 {
let n = 2 + rng.below(4);
let before = doc((0..n).map(|i| {
if rng.below(2) == 0 {
Node::element("blockquote").with_child(para(&format!("b{i}")))
} else {
para(&format!("p{i}"))
}
}));
let mut tree = before.clone();
let changes = {
let mut tx = tree.transform();
let kids = before.child_count();
let _ = match rng.below(5) {
0 => tx.set_block_type(vec![rng.below(kids)], "heading", None),
1 if kids >= 2 => tx.join_blocks(vec![], 1 + rng.below(kids - 1)),
2 => tx.wrap(vec![rng.below(kids)], "section", None),
3 => tx.split_block(vec![rng.below(kids)], 0, 0),
_ => tx.wrap_range(BlockRange::new(vec![], 0, kids), "section", None),
};
tx.finish()
};
let mut replay = before.clone();
apply(&mut replay, &changes).unwrap();
assert_eq!(replay, tree, "patch replay mismatch");
let undo = before.invert(&changes).unwrap();
let mut back = tree.clone();
apply(&mut back, &undo).unwrap();
assert_eq!(back, before, "undo mismatch");
}
}