use text_document::{DocumentEvent, TextDocument};
fn lines(doc: &TextDocument) -> Vec<String> {
doc.blocks().iter().map(|b| b.text()).collect()
}
fn end_of(doc: &TextDocument) -> usize {
let last = doc.block_by_number(doc.block_count() - 1).unwrap();
last.position() + last.length()
}
fn content_chars(doc: &TextDocument) -> usize {
doc.blocks().iter().map(|b| b.text().chars().count()).sum()
}
#[test]
fn append_line_adds_lines_in_order() {
let doc = TextDocument::new();
doc.set_plain_text("first").unwrap();
doc.append_line("second").unwrap();
doc.append_line("third").unwrap();
assert_eq!(lines(&doc), vec!["first", "second", "third"]);
}
#[test]
fn append_line_returns_the_new_block_count() {
let doc = TextDocument::new();
doc.set_plain_text("first").unwrap();
assert_eq!(doc.append_line("second").unwrap(), 2);
assert_eq!(doc.append_line("third").unwrap(), 3);
}
#[test]
fn append_line_count_agrees_with_block_count() {
let doc = TextDocument::new();
doc.set_plain_text("first").unwrap();
let returned = doc.append_line("second").unwrap();
assert_eq!(returned, doc.block_count());
assert_eq!(returned, doc.blocks().len());
}
#[test]
fn appending_matches_a_document_built_by_editing() {
let streamed = TextDocument::new();
streamed.set_plain_text("alpha").unwrap();
streamed.append_line("beta").unwrap();
streamed.append_line("gamma").unwrap();
let edited = TextDocument::new();
edited.set_plain_text("alpha\nbeta\ngamma").unwrap();
assert_eq!(
streamed.to_plain_text().unwrap(),
edited.to_plain_text().unwrap()
);
assert_eq!(lines(&streamed), lines(&edited));
assert_eq!(streamed.block_count(), edited.block_count());
assert_eq!(streamed.character_count(), edited.character_count());
}
#[test]
fn appended_lines_are_addressable_by_cursor() {
let doc = TextDocument::new();
doc.set_plain_text("first").unwrap();
doc.append_line("second").unwrap();
doc.append_line("third").unwrap();
let last = doc.block_by_number(2).unwrap();
assert_eq!(last.text(), "third");
let cursor = doc.cursor_at(last.position());
assert_eq!(cursor.block_number(), 2);
assert_eq!(cursor.position_in_block(), 0);
}
#[test]
fn editing_still_works_after_appending() {
let doc = TextDocument::new();
doc.set_plain_text("first").unwrap();
doc.append_line("second").unwrap();
let cursor = doc.cursor_at(end_of(&doc));
cursor.insert_text(" more").unwrap();
assert_eq!(lines(&doc), vec!["first", "second more"]);
}
#[test]
fn append_to_a_fresh_document_keeps_the_rope_and_blocks_in_step() {
let doc = TextDocument::new();
doc.append_line("alpha").unwrap();
assert_eq!(
lines(&doc),
vec!["", "alpha"],
"a fresh document is one empty line; appending adds a second"
);
assert_eq!(
doc.to_plain_text().unwrap(),
"\nalpha",
"the block separator must be in the rope — without it the two blocks \
share a byte offset"
);
assert_eq!(
doc.blocks().len(),
2,
"blocks() and the rope must describe the same document"
);
}
#[test]
fn append_lines_to_a_fresh_document_keeps_the_rope_and_blocks_in_step() {
let doc = TextDocument::new();
doc.append_lines(["alpha", "beta"]).unwrap();
assert_eq!(lines(&doc), vec!["", "alpha", "beta"]);
assert_eq!(doc.to_plain_text().unwrap(), "\nalpha\nbeta");
}
#[test]
fn appended_lines_are_addressable_on_a_fresh_document() {
let doc = TextDocument::new();
doc.append_line("alpha").unwrap();
doc.append_line("beta").unwrap();
let last = doc.block_by_number(2).unwrap();
assert_eq!(last.text(), "beta");
let cursor = doc.cursor_at(last.position());
assert_eq!(cursor.block_number(), 2);
assert_eq!(cursor.position_in_block(), 0);
}
#[test]
fn append_lines_batches() {
let doc = TextDocument::new();
doc.set_plain_text("first").unwrap();
let count = doc.append_lines(["second", "third", "fourth"]).unwrap();
assert_eq!(count, 4);
assert_eq!(lines(&doc), vec!["first", "second", "third", "fourth"]);
}
#[test]
fn append_lines_of_nothing_is_a_no_op() {
let doc = TextDocument::new();
doc.set_plain_text("first").unwrap();
let count = doc.append_lines(Vec::<String>::new()).unwrap();
assert_eq!(count, 1);
assert_eq!(lines(&doc), vec!["first"]);
}
#[test]
fn append_line_rejects_embedded_newlines() {
let doc = TextDocument::new();
doc.set_plain_text("first").unwrap();
assert!(doc.append_line("two\nlines").is_err());
assert!(doc.append_lines(["ok", "two\nlines"]).is_err());
assert_eq!(
lines(&doc),
vec!["first"],
"a rejected append must change nothing"
);
}
#[test]
fn a_rejected_append_changes_nothing() {
let doc = TextDocument::new();
doc.set_plain_text("a\nb").unwrap();
let before = doc.to_plain_text().unwrap();
assert!(doc.append_line("bad\nline").is_err());
assert!(doc.append_lines(["ok", "bad\nline"]).is_err());
assert_eq!(doc.to_plain_text().unwrap(), before);
assert_eq!(doc.block_count(), 2);
assert_eq!(doc.character_count(), content_chars(&doc));
}
#[test]
fn truncation_is_all_or_nothing() {
let doc = TextDocument::new();
doc.set_plain_text("a\nb\nc\nd").unwrap();
let before_text = doc.to_plain_text().unwrap();
let before_blocks = lines(&doc);
let before_chars = doc.character_count();
doc.truncate_front(99).unwrap();
assert_eq!(lines(&doc), vec!["d"]);
assert_eq!(doc.to_plain_text().unwrap(), "d");
assert_eq!(doc.character_count(), content_chars(&doc));
assert_eq!(before_text, "a\nb\nc\nd");
assert_eq!(before_blocks.len(), 4);
assert_eq!(before_chars, 4);
}
#[test]
fn the_document_always_agrees_with_itself() {
let doc = TextDocument::new();
doc.set_plain_text("seed").unwrap();
let check = |doc: &TextDocument, label: &str| {
assert_eq!(
doc.to_plain_text().unwrap(),
lines(doc).join("\n"),
"{label}: to_plain_text() disagrees with blocks()"
);
assert_eq!(
doc.block_count(),
doc.blocks().len(),
"{label}: cached block_count disagrees with blocks()"
);
assert_eq!(
doc.character_count(),
content_chars(doc),
"{label}: cached character_count disagrees with the blocks' text"
);
};
check(&doc, "seeded");
doc.append_line("one").unwrap();
check(&doc, "after append_line");
doc.append_lines(["two", "three"]).unwrap();
check(&doc, "after append_lines");
doc.truncate_front(2).unwrap();
check(&doc, "after truncate_front");
let _ = doc.append_line("bad\nline");
check(&doc, "after a rejected append");
}
#[test]
fn appending_leaves_the_undo_stack_alone() {
let doc = TextDocument::new();
doc.set_plain_text("first").unwrap();
let cursor = doc.cursor_at(end_of(&doc));
cursor.insert_text(" edited").unwrap();
assert!(doc.can_undo(), "precondition: the user's edit is undoable");
doc.append_line("streamed").unwrap();
doc.append_line("more").unwrap();
assert!(
doc.can_undo(),
"appending must not clear the user's history"
);
doc.undo().unwrap();
assert_eq!(
doc.block_by_number(0).unwrap().text(),
"first",
"undo must reach the user's edit, not an appended line"
);
}
#[test]
fn appending_alone_leaves_nothing_to_undo() {
let doc = TextDocument::new();
doc.set_plain_text("first").unwrap();
assert!(
!doc.can_undo(),
"precondition: a fresh document has no history"
);
doc.append_line("streamed").unwrap();
doc.append_lines(["more", "and more"]).unwrap();
doc.truncate_front(1).unwrap();
assert!(
!doc.can_undo(),
"streaming must not put anything on the undo stack"
);
}
#[test]
fn truncating_leaves_the_undo_stack_alone() {
let doc = TextDocument::new();
doc.set_plain_text("a\nb\nc\nd").unwrap();
let cursor = doc.cursor_at(0);
cursor.insert_text("X").unwrap();
assert!(doc.can_undo());
doc.truncate_front(2).unwrap();
assert!(doc.can_undo(), "eviction must not clear the user's history");
}
#[test]
fn truncate_front_drops_the_oldest_lines() {
let doc = TextDocument::new();
doc.set_plain_text("a\nb\nc\nd\ne").unwrap();
let removed = doc.truncate_front(2).unwrap();
assert_eq!(removed, 2);
assert_eq!(lines(&doc), vec!["c", "d", "e"]);
assert_eq!(doc.block_count(), 3);
}
#[test]
fn truncate_front_of_nothing_is_a_no_op() {
let doc = TextDocument::new();
doc.set_plain_text("a\nb").unwrap();
assert_eq!(doc.truncate_front(0).unwrap(), 0);
assert_eq!(lines(&doc), vec!["a", "b"]);
}
#[test]
fn truncate_front_never_empties_the_document() {
let doc = TextDocument::new();
doc.set_plain_text("a\nb\nc").unwrap();
let removed = doc.truncate_front(99).unwrap();
assert_eq!(removed, 2, "must keep one block");
assert_eq!(lines(&doc), vec!["c"]);
assert_eq!(doc.block_count(), 1);
}
#[test]
fn survivors_are_addressable_after_truncation() {
let doc = TextDocument::new();
doc.set_plain_text("a\nb\nc\nd").unwrap();
doc.truncate_front(2).unwrap();
assert_eq!(doc.to_plain_text().unwrap(), "c\nd");
let first = doc.block_by_number(0).unwrap();
assert_eq!(first.text(), "c");
let cursor = doc.cursor_at(first.position());
assert_eq!(cursor.block_number(), 0);
assert_eq!(doc.character_count(), content_chars(&doc));
}
#[test]
fn append_and_evict_cycle_stays_coherent() {
const CAP: usize = 5;
let doc = TextDocument::new();
doc.set_plain_text("line 0").unwrap();
for i in 1..50 {
let count = doc.append_line(&format!("line {i}")).unwrap();
if count > CAP {
doc.truncate_front(count - CAP).unwrap();
}
}
let text = lines(&doc);
assert_eq!(text.len(), CAP, "the cap must hold");
assert_eq!(
text,
vec!["line 45", "line 46", "line 47", "line 48", "line 49"]
);
assert_eq!(doc.block_count(), CAP);
assert_eq!(
doc.character_count(),
content_chars(&doc),
"cached character_count must still match the real text"
);
}
#[test]
fn append_reports_a_tail_insertion() {
let doc = TextDocument::new();
doc.set_plain_text("first").unwrap();
let _ = doc.poll_events();
doc.append_line("second").unwrap();
let seen = doc.poll_events();
assert!(
seen.iter().any(|e| matches!(
e,
DocumentEvent::FlowElementsInserted {
flow_index: 1,
count: 1
}
)),
"expected a tail insertion at index 1, got {seen:?}"
);
assert!(
seen.iter()
.any(|e| matches!(e, DocumentEvent::BlockCountChanged(2))),
"expected BlockCountChanged(2), got {seen:?}"
);
}
#[test]
fn reading_the_text_before_streaming_does_not_freeze_it() {
let doc = TextDocument::new();
doc.set_plain_text("first").unwrap();
assert_eq!(doc.to_plain_text().unwrap(), "first");
doc.append_line("second").unwrap();
assert_eq!(
doc.to_plain_text().unwrap(),
"first\nsecond",
"appended text must be visible to a caller that had already read the \
document once"
);
doc.append_lines(["third", "fourth"]).unwrap();
assert_eq!(doc.to_plain_text().unwrap(), "first\nsecond\nthird\nfourth");
doc.truncate_front(2).unwrap();
assert_eq!(
doc.to_plain_text().unwrap(),
"third\nfourth",
"eviction must be visible too"
);
}
#[test]
fn plain_text_and_blocks_agree_after_streaming() {
let doc = TextDocument::new();
doc.set_plain_text("first").unwrap();
let _ = doc.to_plain_text().unwrap();
doc.append_line("second").unwrap();
assert_eq!(
doc.to_plain_text().unwrap(),
lines(&doc).join("\n"),
"to_plain_text() and blocks() must describe the same document"
);
}
#[test]
fn a_batch_reports_every_block_it_added() {
let doc = TextDocument::new();
doc.set_plain_text("first").unwrap();
let _ = doc.poll_events();
doc.append_lines(["a", "b", "c"]).unwrap();
let changed = doc
.poll_events()
.into_iter()
.find_map(|e| match e {
DocumentEvent::ContentsChanged {
blocks_affected, ..
} => Some(blocks_affected),
_ => None,
})
.expect("a batch must report a content change");
assert_eq!(changed, 3, "three lines added means three blocks affected");
}
#[test]
fn chars_added_counts_the_separator_only_when_one_was_written() {
let fresh = TextDocument::new();
let _ = fresh.poll_events();
fresh.append_line("alpha").unwrap();
let with_sep = first_chars_added(&fresh);
assert_eq!(
with_sep, 6,
"\"alpha\" plus the separator that joins it to the empty first block"
);
assert_eq!(
fresh.to_plain_text().unwrap().chars().count(),
with_sep,
"chars_added must equal what the document actually gained"
);
}
fn first_chars_added(doc: &TextDocument) -> usize {
doc.poll_events()
.into_iter()
.find_map(|e| match e {
DocumentEvent::ContentsChanged { chars_added, .. } => Some(chars_added),
_ => None,
})
.expect("expected a content change")
}
#[test]
fn the_reported_edit_position_covers_the_separator() {
let doc = TextDocument::new();
doc.set_plain_text("first").unwrap();
let before = doc.to_plain_text().unwrap().chars().count();
let _ = doc.poll_events();
doc.append_line("second").unwrap();
let (pos, added) = doc
.poll_events()
.into_iter()
.find_map(|e| match e {
DocumentEvent::ContentsChanged {
position,
chars_added,
..
} => Some((position, chars_added)),
_ => None,
})
.expect("expected a content change");
assert_eq!(
pos, before,
"the insert begins at the old end of the document"
);
assert_eq!(
pos + added,
doc.to_plain_text().unwrap().chars().count(),
"position + chars_added must land exactly at the new end"
);
}
#[test]
fn truncation_reports_a_front_removal() {
let doc = TextDocument::new();
doc.set_plain_text("a\nb\nc").unwrap();
let _ = doc.poll_events();
doc.truncate_front(2).unwrap();
let seen = doc.poll_events();
assert!(
seen.iter().any(|e| matches!(
e,
DocumentEvent::FlowElementsRemoved {
flow_index: 0,
count: 2
}
)),
"expected a front removal of 2, got {seen:?}"
);
}
#[test]
fn appends_notify_on_change_subscribers() {
use std::sync::{Arc, Mutex};
let doc = TextDocument::new();
let seen: Arc<Mutex<Vec<DocumentEvent>>> = Arc::new(Mutex::new(Vec::new()));
let sink = seen.clone();
let _sub = doc.on_change(move |e| sink.lock().unwrap().push(e));
doc.append_line("one").unwrap();
doc.append_lines(["two", "three"]).unwrap();
let got = seen.lock().unwrap();
assert!(
got.iter()
.any(|e| matches!(e, DocumentEvent::BlockCountChanged(_))),
"append must fire a BlockCountChanged to on_change, got {got:?}"
);
assert!(
got.iter()
.any(|e| matches!(e, DocumentEvent::FlowElementsInserted { .. })),
"append must fire a FlowElementsInserted to on_change, got {got:?}"
);
}
#[test]
fn truncation_notifies_on_change_subscribers() {
use std::sync::{Arc, Mutex};
let doc = TextDocument::new();
doc.set_plain_text("a\nb\nc\nd").unwrap();
let seen: Arc<Mutex<Vec<DocumentEvent>>> = Arc::new(Mutex::new(Vec::new()));
let sink = seen.clone();
let _sub = doc.on_change(move |e| sink.lock().unwrap().push(e));
doc.truncate_front(2).unwrap();
let got = seen.lock().unwrap();
assert!(
got.iter().any(|e| matches!(
e,
DocumentEvent::FlowElementsRemoved {
flow_index: 0,
count: 2
}
)),
"truncate_front must fire a FlowElementsRemoved to on_change, got {got:?}"
);
}