use proptest::prelude::*;
use text_document::{MoveMode, MoveOperation, TextDocument};
fn new_doc(plain: &str) -> TextDocument {
let doc = TextDocument::new();
doc.set_plain_text(plain).unwrap();
doc
}
proptest! {
#[test]
fn cursor_position_space_is_consistent(text in "[a-zA-Z0-9 \n]{0,100}") {
let doc = new_doc(&text);
let plain = doc.to_plain_text().unwrap();
prop_assert_eq!(
doc.character_count() + doc.block_count() - 1,
plain.chars().count()
);
}
}
proptest! {
#[test]
fn insert_after_delete_restores_text(
text in "[a-zA-Z0-9 \n]{1,60}",
pos_frac in 0.0f64..1.0,
) {
let doc = new_doc(&text);
let before = doc.to_plain_text().unwrap();
let max_pos = doc.character_count() + doc.block_count() - 1;
if max_pos == 0 { return Ok(()); }
let pos = ((pos_frac * max_pos as f64).floor() as usize).min(max_pos.saturating_sub(1));
let c = doc.cursor_at(pos);
let c_probe = doc.cursor_at(pos);
c_probe.move_position(MoveOperation::NextCharacter, MoveMode::KeepAnchor, 1);
let cluster = c_probe.selected_text().unwrap_or_default();
if cluster.is_empty() { return Ok(()); }
if c.delete_char().is_err() { return Ok(()); }
let c2 = doc.cursor_at(pos);
c2.insert_text(&cluster).unwrap();
let after = doc.to_plain_text().unwrap();
prop_assert_eq!(
before, after,
"delete + insert of the same cluster must round-trip"
);
}
}
proptest! {
#[test]
fn undo_single_edit_restores_text(
seed in "[a-zA-Z ]{0,40}",
insert in "[a-z]{0,10}",
pos_frac in 0.0f64..=1.0,
) {
let doc = new_doc(&seed);
let before = doc.to_plain_text().unwrap();
let max_pos = doc.character_count() + doc.block_count().saturating_sub(1);
let pos = ((pos_frac * max_pos as f64).floor() as usize).min(max_pos);
let c = doc.cursor_at(pos);
if insert.is_empty() { return Ok(()); }
c.insert_text(&insert).unwrap();
prop_assume!(doc.to_plain_text().unwrap() != before);
doc.undo().unwrap();
let after_undo = doc.to_plain_text().unwrap();
prop_assert_eq!(before, after_undo);
}
}
proptest! {
#[test]
fn undo_then_redo_is_identity(
seed in "[a-zA-Z ]{0,40}",
insert in "[a-z]{1,10}",
pos_frac in 0.0f64..=1.0,
) {
let doc = new_doc(&seed);
let max_pos = doc.character_count() + doc.block_count().saturating_sub(1);
let pos = ((pos_frac * max_pos as f64).floor() as usize).min(max_pos);
let c = doc.cursor_at(pos);
c.insert_text(&insert).unwrap();
let after_edit = doc.to_plain_text().unwrap();
prop_assume!(doc.can_undo());
doc.undo().unwrap();
if !doc.can_redo() { return Ok(()); }
doc.redo().unwrap();
prop_assert_eq!(after_edit, doc.to_plain_text().unwrap());
}
}
proptest! {
#[test]
fn next_then_prev_character_is_identity(
text in r"[a-zA-Z0-9 \u{0301}\u{1F44B}\u{1F3FB}]{1,40}",
pos_frac in 0.0f64..=1.0,
) {
let doc = new_doc(&text);
let max_pos = doc.character_count() + doc.block_count().saturating_sub(1);
let requested = ((pos_frac * max_pos as f64).floor() as usize).min(max_pos);
let c = doc.cursor_at(requested);
let start = c.position();
let moved = c.move_position(MoveOperation::NextCharacter, MoveMode::MoveAnchor, 1);
if !moved { return Ok(()); } c.move_position(MoveOperation::PreviousCharacter, MoveMode::MoveAnchor, 1);
prop_assert_eq!(
c.position(),
start,
"NextCharacter then PreviousCharacter must return to start"
);
}
}
proptest! {
#[test]
fn insert_shifts_downstream_cursors_by_length(
seed in "[a-zA-Z ]{5,40}",
insert in "[a-z]{1,10}",
p_frac in 0.0f64..1.0,
q_frac in 0.0f64..1.0,
) {
let doc = new_doc(&seed);
let max = doc.character_count();
let p = ((p_frac * max as f64).floor() as usize).min(max);
let q = ((q_frac * max as f64).floor() as usize).min(max);
let c1 = doc.cursor_at(p);
let c2 = doc.cursor_at(q);
let n = insert.chars().count();
c1.insert_text(&insert).unwrap();
let q_prime = c2.position();
if q < p {
prop_assert_eq!(q_prime, q, "cursor strictly before insert must not move");
} else if q > p {
prop_assert_eq!(
q_prime, q + n,
"cursor strictly after insert must shift by n chars"
);
} else {
prop_assert!(
q_prime == q || q_prime == q + n,
"collocated cursor must resolve to either q or q+n, got {}",
q_prime
);
}
}
}
proptest! {
#[test]
fn fragment_reinsert_is_identity(
seed in "[a-zA-Z ]{5,40}",
start_frac in 0.0f64..1.0,
end_frac in 0.0f64..=1.0,
) {
let doc = new_doc(&seed);
let max = doc.character_count();
if max == 0 { return Ok(()); }
let mut start = ((start_frac * max as f64).floor() as usize).min(max);
let mut end = ((end_frac * max as f64).floor() as usize).min(max);
if start > end { std::mem::swap(&mut start, &mut end); }
if start == end { return Ok(()); }
let before = doc.to_plain_text().unwrap();
let c = doc.cursor_at(start);
c.set_position(end, MoveMode::KeepAnchor);
let frag = c.selection();
if frag.is_empty() { return Ok(()); }
c.remove_selected_text().unwrap();
let c2 = doc.cursor_at(start);
c2.insert_fragment(&frag).unwrap();
prop_assert_eq!(before, doc.to_plain_text().unwrap());
}
}
proptest! {
#[test]
fn insert_text_is_monotone_and_additive(
seed in "[a-zA-Z ]{0,30}",
insert in "[a-z0-9 ]{0,20}",
pos_frac in 0.0f64..=1.0,
) {
let doc = new_doc(&seed);
let cc_before = doc.character_count();
let bc_before = doc.block_count();
let max_pos = cc_before + bc_before.saturating_sub(1);
let pos = ((pos_frac * max_pos as f64).floor() as usize).min(max_pos);
let c = doc.cursor_at(pos);
c.insert_text(&insert).unwrap();
prop_assert_eq!(
doc.character_count(),
cc_before + insert.chars().count(),
"character_count must grow by exactly insert.chars().count()"
);
prop_assert_eq!(
doc.block_count(), bc_before,
"insert_text must not split blocks"
);
}
}
proptest! {
#[test]
fn backspace_at_block_start_merges_or_noops(
a in "[a-z]{1,10}",
b in "[a-z]{1,10}",
) {
let doc = TextDocument::new();
doc.set_plain_text(&a).unwrap();
let c = doc.cursor_at(a.chars().count());
c.insert_block().unwrap();
c.insert_text(&b).unwrap();
let bc_before = doc.block_count();
prop_assert_eq!(bc_before, 2);
let start_of_b = a.chars().count() + 1;
let c2 = doc.cursor_at(start_of_b);
c2.delete_previous_char().unwrap();
prop_assert_eq!(doc.block_count(), 1, "blocks must merge");
prop_assert_eq!(
doc.to_plain_text().unwrap(),
format!("{}{}", a, b),
"merged text must equal concatenation"
);
}
}
proptest! {
#[test]
fn cursor_at_out_of_range_is_safe(
seed in "[a-zA-Z ]{0,30}",
requested in 0usize..10_000,
) {
let doc = new_doc(&seed);
let max = doc.character_count() + doc.block_count().saturating_sub(1);
let c = doc.cursor_at(requested);
let before = doc.to_plain_text().unwrap();
let _ = c.delete_char();
let _ = c.delete_previous_char();
let after = doc.to_plain_text().unwrap();
if requested > max {
prop_assert_eq!(
before, after,
"out-of-range cursor edit ops must not mutate"
);
}
}
}
proptest! {
#[test]
fn undo_does_not_reset_id_counter(seed in "[a-zA-Z ]{1,40}") {
let doc = new_doc(&seed);
let pos1 = doc.character_count() + doc.block_count().saturating_sub(1);
let c1 = doc.cursor_at(pos1);
c1.insert_block().unwrap();
let max_first = doc
.blocks()
.iter()
.map(|b| b.id())
.max()
.unwrap_or(0);
doc.undo().unwrap();
let pos2 = doc.character_count() + doc.block_count().saturating_sub(1);
let c2 = doc.cursor_at(pos2);
c2.insert_block().unwrap();
let max_second = doc
.blocks()
.iter()
.map(|b| b.id())
.max()
.unwrap_or(0);
prop_assert!(
max_second > max_first,
"id counter must not rewind on undo: max_first={}, max_second={}",
max_first,
max_second
);
}
}
proptest! {
#[test]
fn composite_undoes_as_one_unit(
seed in "[a-zA-Z ]{1,30}",
edits in proptest::collection::vec("[a-z]{1,3}", 2..6),
) {
let doc = new_doc(&seed);
let before = doc.to_plain_text().unwrap();
let pos = doc.character_count() + doc.block_count().saturating_sub(1);
let c = doc.cursor_at(pos);
c.begin_edit_block();
for chunk in &edits {
c.insert_text(chunk).unwrap();
}
c.end_edit_block();
let after = doc.to_plain_text().unwrap();
prop_assume!(before != after);
doc.undo().unwrap();
let after_undo = doc.to_plain_text().unwrap();
prop_assert_eq!(
&before,
&after_undo,
"one undo of a composite must revert ALL inner edits"
);
doc.redo().unwrap();
let after_redo = doc.to_plain_text().unwrap();
prop_assert_eq!(
&after,
&after_redo,
"one redo of a composite must restore the post-composite state"
);
doc.undo().unwrap();
let after_undo2 = doc.to_plain_text().unwrap();
prop_assert_eq!(
before,
after_undo2,
"second undo of a composite must also revert ALL inner edits"
);
}
}