Files
wordcraft/crates/doc/src/tests.rs
T
Martin BallandClaude Opus 5.5 def2b9bd31 #3692: review — doc timing tests get a margin a busy CI machine can't break
Wall-clock bounds of 2 s failed on loaded runners. Each bound is now 10 s, at least 10x the
fixed code's time and well under the old code's on the same input (measured by running the
pre-fix code under the same load):

- many distinct media: 0.5 s now; the old scan of every image took 131 s.
- object walks: under 0.3 s now; the old walks took 360 s (#3614).
- delete across lone control markers: 3,000 markers (was 1,500); 1 ms now, the old delete
  took 18 s at 1,500 and 114 s at 3,000.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-25 21:03:06 -04:00

654 lines
29 KiB
Rust

use super::*;
#[test]
fn new_document_is_valid() {
let d = Document::new();
assert_eq!(d.body.len(), 1);
assert_eq!(d.para_paths(StoryRef::Body).len(), 1);
assert_eq!(d.plain_text(StoryRef::Body), "");
assert_eq!(d.sections().len(), 1);
}
#[test]
fn word_count() {
let d = Document::from_text("Hello, world!\nThis is — a test 42.\n\n");
assert_eq!(d.word_count(), 7);
assert_eq!(count_words(" -- "), 0);
}
#[test]
fn word_count_includes_numbered_list_labels_but_not_bullets() {
let mut d = Document::new();
let num_id = d.numbering.add_list(ListKind::Numbered);
let bul_id = d.numbering.add_list(ListKind::Bullet);
let mut p1 = Paragraph::with_text("Apple", CharProps::default());
p1.props.numbering = Some(props::NumRef { num: num_id, level: 0 });
let mut p2 = Paragraph::with_text("Banana", CharProps::default());
p2.props.numbering = Some(props::NumRef { num: num_id, level: 0 });
let mut p3 = Paragraph::with_text("Cherry", CharProps::default());
p3.props.numbering = Some(props::NumRef { num: bul_id, level: 0 });
let mut p4 = Paragraph::with_text("Date", CharProps::default());
p4.props.numbering = Some(props::NumRef { num: bul_id, level: 0 });
d.body = vec![Arc::new(Block::Para(p1)), Arc::new(Block::Para(p2)), Arc::new(Block::Para(p3)), Arc::new(Block::Para(p4))];
// Numbered items contribute 2 words each (label + text) = 4 words.
// Bullet items contribute 1 word each (bullet marker is not a word + text) = 2 words.
assert_eq!(d.word_count(), 6);
let plain = d.plain_text(StoryRef::Body);
assert_eq!(plain, "Apple\nBanana\nCherry\nDate");
}
#[test]
fn word_count_includes_numbered_labels_with_nothing_suffix() {
let mut d = Document::new();
let num_id = d.numbering.add_list(ListKind::Numbered);
let aid = d.numbering.num(num_id).expect("list num exists").abstract_id;
let a = d.numbering.abstracts.iter_mut().find(|a| a.id == aid).expect("abstract num exists");
a.levels[0].suffix = numbering::LevelSuffix::Nothing;
let mut p1 = Paragraph::with_text("Apple", CharProps::default());
p1.props.numbering = Some(props::NumRef { num: num_id, level: 0 });
let mut p2 = Paragraph::with_text("Banana Cherry", CharProps::default());
p2.props.numbering = Some(props::NumRef { num: num_id, level: 0 });
d.body = vec![Arc::new(Block::Para(p1)), Arc::new(Block::Para(p2))];
// Label "1." and text "Apple" must each be counted as a separate word (2 words),
// and label "2." and text "Banana Cherry" as 3 words (total: 5 words),
// even though LevelSuffix::Nothing produces no visible separator between label and text.
assert_eq!(d.word_count(), 5);
}
#[test]
fn sections_and_section_mut() {
let mut d = Document::from_text("a\nb\nc");
if let Some(Block::Para(p)) = d.body.get_mut(0).map(Arc::make_mut) {
p.section = Some(Box::new(SectionProps { landscape: true, ..Default::default() }));
}
let s = d.sections();
assert_eq!(s.len(), 2);
assert_eq!(s[0].0, 0);
assert_eq!(d.section_index_of(0), 0);
assert_eq!(d.section_index_of(2), 1);
d.section_mut(0).margin_left = 10.0;
d.section_mut(2).margin_left = 20.0;
assert_eq!(d.sections()[0].1.margin_left, 10.0);
assert_eq!(d.last_section.margin_left, 20.0);
}
#[test]
fn parts_and_media() {
let mut d = Document::new();
let id = d.add_part(PartKind::Header, Vec::new());
assert_eq!(d.story(StoryRef::Part(id)).map(|b| b.len()), Some(1));
let k1 = d.add_media(vec![1, 2, 3], "png");
let k2 = d.add_media(vec![1, 2, 3], "png");
let k3 = d.add_media(vec![4], "png");
assert_eq!(k1, k2);
assert_ne!(k1, k3);
}
#[test]
fn many_distinct_media_add_in_linear_time() {
// An import with tens of thousands of small distinct images (same length, differing only at
// the end) used to compare each against every earlier one.
let mut d = Document::new();
let t = std::time::Instant::now();
let n = 40_000u32;
for i in 0..n {
let mut bytes = vec![7u8; 256];
bytes.extend(i.to_le_bytes());
d.add_media(bytes, "png");
}
assert_eq!(d.media.len(), n as usize);
// Duplicates still find their first copy, including media inserted directly.
let mut first = vec![7u8; 256];
first.extend(0u32.to_le_bytes());
assert_eq!(d.add_media(first, "png"), "image1.png");
d.media.insert("direct.png".into(), Arc::new(vec![9, 9]));
assert_eq!(d.add_media(vec![9, 9], "png"), "direct.png");
// About half a second on a loaded machine; the old scan took over two minutes.
assert!(t.elapsed().as_secs_f32() < 10.0, "{:?}", t.elapsed());
// The lookup cache isn't part of the document.
let mut e = d.clone();
e.media_index = MediaIndex::default();
assert_eq!(d, e);
}
#[test]
fn media_keys_skip_taken_names_once() {
let mut d = Document::new();
// A file whose media already use the names add_media would pick next.
for i in 2..2002u32 {
d.media.insert(format!("image{i}.png"), Arc::new(i.to_le_bytes().to_vec()));
}
assert_eq!(d.add_media(vec![0], "png"), "image2002.png");
assert_eq!(d.add_media(vec![1], "png"), "image2003.png");
assert_eq!(d.add_media(vec![2], "png"), "image2004.png");
}
#[test]
fn free_ids_skip_taken_ones_instead_of_overflowing() {
assert_eq!(free_id(None, std::iter::empty(), 1), Some(1));
assert_eq!(free_id(Some(7), [3, 7].into_iter(), 1), Some(8));
assert_eq!(free_id(Some(0), [0].into_iter(), 1), Some(1));
// A file used the largest id: the lowest free one, not a wrapped 0 or a taken id.
assert_eq!(free_id(Some(u32::MAX), [1, 2, 4, u32::MAX].into_iter(), 1), Some(3));
assert_eq!(free_id(Some(u32::MAX), [u32::MAX, 1, 0, 1].into_iter(), 0), Some(2));
// Every id from `min` up taken: none, rather than a duplicate.
assert_eq!(free_id(Some(u32::MAX), [u32::MAX, u32::MAX - 1, u32::MAX - 2].into_iter(), u32::MAX - 2), None);
}
#[test]
fn parts_and_lists_after_the_largest_ids_take_free_ones() {
// A file can carry any ids, 0 and u32::MAX included; new ones must not overflow or replace them.
let mut d = Document::new();
for id in [0, 1, u32::MAX] {
let blocks = vec![para_block(Paragraph::with_text(&id.to_string(), CharProps::default()))];
d.parts.insert(id, Part { kind: PartKind::Header, blocks, ..Default::default() });
}
let parts = d.parts.clone();
assert_eq!(d.try_add_part(PartKind::Comment, Vec::new()), Ok(2));
assert_eq!(d.add_part(PartKind::Footer, Vec::new()), 3);
assert!(parts.iter().all(|(k, p)| d.parts.get(k) == Some(p)));
let n = &mut d.numbering;
n.add_list(ListKind::Bullet);
n.add_list(ListKind::Bullet);
n.abstracts[1].id = u32::MAX;
n.nums[1].id = u32::MAX;
n.nums[1].abstract_id = u32::MAX;
let lists = n.clone();
let num = n.try_add_list(ListKind::Numbered).unwrap();
assert_eq!((num, n.num(num).map(|x| x.abstract_id)), (2, Some(1)));
assert_eq!(n.abstract_of(num).map(|a| &a.levels), Some(&numbering::levels_for(ListKind::Numbered)));
assert_eq!(n.restart(num), Some(3));
assert_eq!(n.num(3).map(|x| x.abstract_id), Some(1));
assert_eq!((&n.abstracts[..2], &n.nums[..2]), (&lists.abstracts[..], &lists.nums[..]));
}
#[test]
fn clone_is_shallow() {
let d = Document::from_text("a\nb");
let mut e = d.clone();
assert!(Arc::ptr_eq(&d.body[0], &e.body[0]));
e.insert_text(&Pos::body(1, 0), "x", &CharProps::default()).unwrap();
assert!(Arc::ptr_eq(&d.body[0], &e.body[0]));
assert!(!Arc::ptr_eq(&d.body[1], &e.body[1]));
assert_eq!(d.plain_text(StoryRef::Body), "a\nb");
}
/// [`Document::same_content`] is what tells the engine that a command changed nothing: it
/// must see every change, and it must not call two different documents the same.
#[test]
fn same_content_sees_every_change() {
let d = Document::from_text("Hello\nWorld");
assert!(d.same_content(&d.clone()), "a copy is the same document");
// The text changed.
let mut e = d.clone();
e.insert_text(&Pos::body(0, 0), "x", &CharProps::default()).unwrap();
assert!(!d.same_content(&e), "the text changed");
// The formatting changed.
let mut e = d.clone();
e.format_range(&Pos::body(0, 0), &Pos::body(0, 5), &|c| c.bold = Some(true)).unwrap();
assert!(!d.same_content(&e), "the formatting changed");
// The styles changed.
let mut e = d.clone();
e.styles.get_mut("Normal").unwrap().chr.bold = Some(true);
assert!(!d.same_content(&e), "a style changed");
// The lists changed.
let mut e = d.clone();
e.numbering.add_list(ListKind::Numbered);
assert!(!d.same_content(&e), "a list was added");
// The settings changed.
let mut e = d.clone();
e.settings.track_changes = true;
assert!(!d.same_content(&e), "a setting changed");
// The metadata changed.
let mut e = d.clone();
e.core.title = "Draft".into();
assert!(!d.same_content(&e), "the title changed");
// A part changed.
let mut e = d.clone();
e.add_part(PartKind::Header, vec![para_block(Paragraph::new())]);
assert!(!d.same_content(&e), "a part was added");
// A picture changed.
let mut e = d.clone();
e.media.insert("i.png".into(), Arc::new(vec![1, 2, 3]));
assert!(!d.same_content(&e), "a picture was added");
// A revision was recorded.
let mut e = d.clone();
e.revisions.push(Revision::new(RevisionKind::Insert, "Mira", "2026-10-23T00:00:00Z"));
assert!(!d.same_content(&e), "a revision was recorded");
// A comment was added.
let mut e = d.clone();
e.comments.insert(1, Comment { author: "Mira".into(), ..Default::default() });
assert!(!d.same_content(&e), "a comment was added");
// A paragraph that was marked as changed but holds the same content is the same.
let mut e = d.clone();
e.para_mut(StoryRef::Body, &Path::top(0)).unwrap().touch();
assert!(d.same_content(&e), "the paragraph only changed its counter");
// A table copied for an edit: the same until a cell's content changes.
let mut d = Document::from_text("Hello");
d.body.insert(0, Arc::new(Block::Table(Table::new(2, 2, 200.0))));
let mut e = d.clone();
Arc::make_mut(&mut e.body[0]);
assert!(!Arc::ptr_eq(&d.body[0], &e.body[0]));
assert!(d.same_content(&e), "the table was only copied");
let Block::Table(t) = Arc::make_mut(&mut e.body[0]) else { panic!("a table") };
t.rows[1].cells[1].blocks[0] = para_block(Paragraph::with_text("x", CharProps::default()));
assert!(!d.same_content(&e), "a cell's text changed");
// A building block was added.
let mut e = d.clone();
e.glossary.push(BuildingBlock { name: "Sign-off".into(), ..Default::default() });
assert!(!d.same_content(&e), "a building block was added");
}
#[test]
fn json_round_trip() {
let mut d = Document::from_text("Hello\nWorld");
d.format_range(&Pos::body(0, 0), &Pos::body(0, 5), &|c| c.bold = Some(true)).unwrap();
let j = serde_json::to_string(&d).unwrap();
let back: Document = serde_json::from_str(&j).unwrap();
assert_eq!(back.plain_text(StoryRef::Body), "Hello\nWorld");
assert_eq!(back.para(StoryRef::Body, &Path::top(0)).unwrap().runs[0].props.bold, Some(true));
}
#[test]
fn ensure_nonempty_repairs() {
let mut d = Document::new();
d.body.clear();
d.body.push(Arc::new(Block::Table(Table::new(1, 1, 100.0))));
d.ensure_nonempty();
assert!(matches!(d.body.last().map(|b| &**b), Some(Block::Para(_))));
}
#[test]
fn text_box_anchor_finds_the_owning_shape() {
let mut d = Document::from_text("Before after");
let id = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text("Inside", CharProps::default()))]);
let shape = |story| InlineObject::Shape {
kind: para::ShapeKind::TextBox,
w: 144.0,
h: 72.0,
fill: None,
stroke: None,
stroke_width: 0.75,
float: Default::default(),
story,
freeform: None,
effects: Default::default(),
extra: Default::default(),
};
// A plain shape before it doesn't count.
d.insert_object(&Pos::body(0, 0), shape(None), &CharProps::default()).unwrap();
let end = d.insert_object(&Pos::body(0, 10), shape(Some(id)), &CharProps::default()).unwrap();
assert_eq!(d.text_box_anchor(id), Some(end));
assert_eq!(d.text_box_anchor(id + 1), None);
}
#[test]
fn word_count_including_notes_counts_used_boxes_and_notes_only() {
let mut d = Document::from_text("one two");
let shape = |story| InlineObject::Shape {
kind: para::ShapeKind::TextBox,
w: 144.0,
h: 72.0,
fill: None,
stroke: None,
stroke_width: 0.75,
float: Default::default(),
story: Some(story),
freeform: None,
effects: Default::default(),
extra: Default::default(),
};
let para_of = |t: &str| vec![para_block(Paragraph::with_text(t, CharProps::default()))];
let boxed = d.add_part(PartKind::TextBox, para_of("three four"));
let nested = d.add_part(PartKind::TextBox, para_of("five"));
let note = d.add_part(PartKind::Footnote, para_of("six seven"));
// Not counted: an orphaned box (deleted or cut), a header, a comment.
d.add_part(PartKind::TextBox, para_of("orphan words here"));
d.add_part(PartKind::Header, para_of("header words"));
d.add_part(PartKind::Comment, para_of("comment words"));
d.insert_object(&Pos::body(0, 3), shape(boxed), &CharProps::default()).unwrap();
d.insert_object(&Pos { story: StoryRef::Part(boxed), path: Path::top(0), off: 0 }, shape(nested), &CharProps::default()).unwrap();
d.insert_object(
&Pos::body(0, 0),
InlineObject::NoteRef { kind: para::NoteKind::Footnote, id: note, custom: String::new() },
&CharProps::default(),
)
.unwrap();
// A box pointing at a header doesn't make the header count.
let hdr = d.parts.iter().find(|(_, p)| p.kind == PartKind::Header).map(|(k, _)| *k).unwrap();
d.insert_object(&Pos::body(0, 0), shape(hdr), &CharProps::default()).unwrap();
assert_eq!(d.word_count(), 2);
assert_eq!(d.word_count_including_notes(), 7);
assert_eq!(d.counted_stories(), vec![StoryRef::Body, StoryRef::Part(note), StoryRef::Part(boxed), StoryRef::Part(nested)]);
}
#[test]
fn counted_stories_survive_self_nested_boxes() {
let mut d = Document::from_text("a");
let id = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text("b", CharProps::default()))]);
let shape = InlineObject::Shape {
kind: para::ShapeKind::TextBox,
w: 10.0,
h: 10.0,
fill: None,
stroke: None,
stroke_width: 0.0,
float: Default::default(),
story: Some(id),
freeform: None,
effects: Default::default(),
extra: Default::default(),
};
d.insert_object(&Pos::body(0, 0), shape.clone(), &CharProps::default()).unwrap();
d.insert_object(&Pos { story: StoryRef::Part(id), path: Path::top(0), off: 0 }, shape, &CharProps::default()).unwrap();
assert_eq!(d.word_count_including_notes(), 2);
}
#[test]
fn prune_text_boxes_drops_only_unshown_ones() {
let shape = |story| InlineObject::Shape {
kind: para::ShapeKind::TextBox,
w: 50.0,
h: 20.0,
fill: None,
stroke: None,
stroke_width: 0.0,
float: Default::default(),
story: Some(story),
freeform: None,
effects: Default::default(),
extra: Default::default(),
};
let para_of = |t: &str| vec![para_block(Paragraph::with_text(t, CharProps::default()))];
let mut d = Document::from_text("body");
assert_eq!(d.prune_text_boxes(), 0, "nothing to do without text boxes");
let shown = d.add_part(PartKind::TextBox, para_of("shown"));
let nested = d.add_part(PartKind::TextBox, para_of("inside shown"));
let orphan = d.add_part(PartKind::TextBox, para_of("deleted box"));
let orphan_child = d.add_part(PartKind::TextBox, para_of("inside the deleted box"));
let hdr = d.add_part(PartKind::Header, para_of("header"));
let in_header = d.add_part(PartKind::TextBox, para_of("box in the header"));
d.insert_object(&Pos::body(0, 0), shape(shown), &CharProps::default()).unwrap();
let at = |id| Pos { story: StoryRef::Part(id), path: Path::top(0), off: 0 };
d.insert_object(&at(shown), shape(nested), &CharProps::default()).unwrap();
d.insert_object(&at(orphan), shape(orphan_child), &CharProps::default()).unwrap();
d.insert_object(&at(hdr), shape(in_header), &CharProps::default()).unwrap();
assert_eq!(d.prune_text_boxes(), 2);
let kept: Vec<u32> = d.parts.keys().copied().collect();
assert_eq!(kept, vec![shown, nested, hdr, in_header]);
assert_eq!(d.prune_text_boxes(), 0);
}
/// `blocks` in the only cell of `levels` tables, each nested in the next.
fn nested_tables(levels: usize, blocks: Blocks) -> Blocks {
(0..levels).fold(blocks, |inner, _| {
let mut t = Table::new(1, 1, 100.0);
t.rows[0].cells[0].blocks = inner;
vec![Arc::new(Block::Table(t))]
})
}
/// A paragraph showing text box story `story` (drawing id `id`).
fn box_para(story: u32, id: u32) -> Blocks {
let shape = InlineObject::Shape {
kind: para::ShapeKind::TextBox,
w: 50.0,
h: 20.0,
fill: None,
stroke: None,
stroke_width: 0.0,
float: Default::default(),
story: Some(story),
freeform: None,
effects: Default::default(),
extra: connector::ShapeExtra { id, ..Default::default() },
};
let mut p = Paragraph::with_text("cell", CharProps::default());
p.insert_object(0, shape, &CharProps::default()).unwrap();
vec![para_block(p)]
}
#[test]
fn text_boxes_shown_from_deeply_nested_tables_are_kept() {
// #1339: the paragraph walk stopped 16 tables down, so a box shown only from deeper was taken
// for unshown and the next edit pruned its story.
let para_of = |t: &str| vec![para_block(Paragraph::with_text(t, CharProps::default()))];
let mut d = Document::from_text("body");
let at_17 = d.add_part(PartKind::TextBox, para_of("17 tables down"));
let at_40 = d.add_part(PartKind::TextBox, para_of("40 tables down"));
let inner = d.add_part(PartKind::TextBox, para_of("deep in a box"));
let outer = d.add_part(PartKind::TextBox, nested_tables(18, box_para(inner, 0)));
let in_header = d.add_part(PartKind::TextBox, para_of("deep in a header"));
let hdr = d.add_part(PartKind::Header, nested_tables(20, box_para(in_header, 0)));
let unshown = d.add_part(PartKind::TextBox, para_of("shown nowhere"));
d.body.extend(nested_tables(17, box_para(at_17, 0)));
d.body.extend(nested_tables(40, box_para(at_40, 7)));
d.body.extend(box_para(outer, 0));
assert_eq!(d.prune_text_boxes(), 1);
assert!(!d.parts.contains_key(&unshown));
let kept: Vec<u32> = d.parts.keys().copied().collect();
assert_eq!(kept, vec![at_17, at_40, inner, outer, in_header, hdr]);
assert_eq!(d.prune_text_boxes(), 0);
// Counted in reading order, the nested box after its own; new drawing ids above the deep one.
let part = StoryRef::Part;
assert_eq!(d.counted_stories(), vec![StoryRef::Body, part(at_17), part(at_40), part(outer), part(inner)]);
assert_eq!(d.next_shape_id(), 8);
}
#[test]
fn a_deeply_nested_table_is_copied_with_its_text_boxes() {
// A copy takes the box 17 tables down along, so pasting it into another document shows its
// text, not the other document's box that happens to have the same id.
let para_of = |t: &str| vec![para_block(Paragraph::with_text(t, CharProps::default()))];
let mut d = Document::from_text("body");
let deep = d.add_part(PartKind::TextBox, para_of("deep box"));
d.body.extend(nested_tables(17, box_para(deep, 0)));
let frag = d.body_fragment();
assert_eq!(frag.parts.keys().copied().collect::<Vec<_>>(), vec![deep]);
let mut e = Document::new();
let own = e.add_part(PartKind::TextBox, para_of("its own box"));
assert_eq!(own, deep, "the same id in both documents");
e.body = box_para(own, 0);
let end = e.end_of(StoryRef::Body);
e.insert_fragment(&end, &frag).unwrap();
let mut shown = Vec::new();
e.objects_in_reading_order(&mut |o| shown.extend(o.text_box()));
let texts: Vec<String> = shown.iter().map(|id| e.plain_text(StoryRef::Part(*id))).collect();
assert_eq!(texts, vec!["its own box", "deep box"]);
}
#[test]
fn notes_in_text_box_follow_nested_boxes_once() {
let shape = |story| InlineObject::Shape {
kind: para::ShapeKind::TextBox,
w: 50.0,
h: 20.0,
fill: None,
stroke: None,
stroke_width: 0.0,
float: Default::default(),
story: Some(story),
freeform: None,
effects: Default::default(),
extra: Default::default(),
};
let note = |id| InlineObject::NoteRef { kind: para::NoteKind::Footnote, id, custom: String::new() };
let mut d = Document::new();
let outer = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::new())]);
let inner = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::new())]);
let (n1, n2) = (d.add_part(PartKind::Footnote, Vec::new()), d.add_part(PartKind::Footnote, Vec::new()));
let at = |id| Pos { story: StoryRef::Part(id), path: Path::top(0), off: 0 };
// outer: [note n1][box inner][box outer (itself)]; inner: [note n2]
d.insert_object(&at(outer), shape(outer), &CharProps::default()).unwrap();
d.insert_object(&at(outer), shape(inner), &CharProps::default()).unwrap();
d.insert_object(&at(outer), note(n1), &CharProps::default()).unwrap();
d.insert_object(&at(inner), note(n2), &CharProps::default()).unwrap();
assert_eq!(d.notes_in_text_box(outer), vec![n1, n2]);
assert_eq!(d.notes_in_text_box(inner), vec![n2]);
assert!(d.notes_in_text_box(n1).is_empty(), "not a text box");
}
fn fs(instr: &str) -> InlineObject {
InlineObject::FieldStart { instr: instr.into(), locked: false, dirty: false }
}
#[test]
fn field_ranges_pair_across_paragraphs_and_nest() {
let mut d = Document::from_text("See .\nBib one\nBib two");
let c = CharProps::default();
// Citation in paragraph 0: "See ␣[cite](Smith 2020)␣." with a nested field inside.
let p0 = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
p0.insert_object(4, fs("ADDIN ZOTERO_ITEM CSL_CITATION {}"), &c).unwrap();
let n = p0.insert_text(7, "(Smith 2020)", &c).unwrap();
p0.insert_object(7 + n, InlineObject::FieldEnd, &c).unwrap();
p0.insert_object(7, fs("ADDIN inner"), &c).unwrap();
p0.insert_object(10, InlineObject::FieldEnd, &c).unwrap();
// Bibliography from paragraph 1 to the end of paragraph 2.
d.para_mut(StoryRef::Body, &Path::top(1)).unwrap().insert_object(0, fs("ADDIN ZOTERO_BIBL {} CSL_BIBLIOGRAPHY"), &c).unwrap();
let p2 = d.para_mut(StoryRef::Body, &Path::top(2)).unwrap();
let end = p2.len();
p2.insert_object(end, InlineObject::FieldEnd, &c).unwrap();
let r = d.field_ranges(StoryRef::Body);
assert_eq!(r.len(), 3);
assert!(r[0].instr.starts_with("ADDIN ZOTERO_ITEM"));
assert_eq!((r[0].start.off, r[0].depth), (4, 0));
assert_eq!((r[1].instr.as_str(), r[1].depth), ("ADDIN inner", 1));
assert_eq!(r[2].start.path, Path::top(1));
assert_eq!(r[2].end.path, Path::top(2));
assert!(!d.has_unbalanced_field_ranges());
assert_eq!(d.balance_field_ranges(), 0);
// Markers contribute no text.
assert_eq!(d.plain_text(StoryRef::Body), "See (Smith 2020).\nBib one\nBib two");
}
#[test]
fn orphan_field_markers_are_removed() {
let mut d = Document::from_text("ab\ncd");
let c = CharProps::default();
let p0 = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
p0.insert_object(0, InlineObject::FieldEnd, &c).unwrap();
// OBJ is three bytes: "␣a[x]b]" → start after "␣a", end after "b".
p0.insert_object(4, fs("ADDIN x"), &c).unwrap();
p0.insert_object(8, InlineObject::FieldEnd, &c).unwrap();
d.para_mut(StoryRef::Body, &Path::top(1)).unwrap().insert_object(1, fs("ADDIN open"), &c).unwrap();
assert!(d.has_unbalanced_field_ranges());
assert_eq!(d.balance_field_ranges(), 2);
assert!(!d.has_unbalanced_field_ranges());
let r = d.field_ranges(StoryRef::Body);
assert_eq!(r.len(), 1);
assert_eq!(r[0].instr, "ADDIN x");
assert_eq!(d.plain_text(StoryRef::Body), "ab\ncd");
}
#[test]
fn custom_props_set_get_remove() {
let mut d = Document::new();
assert_eq!(d.custom_prop("ZOTERO_PREF_1"), None);
d.set_custom_prop("ZOTERO_PREF_1", "<data/>");
d.set_custom_prop("Other", "x");
d.set_custom_prop("zotero_pref_1", "<data2/>");
assert_eq!(d.custom_props.len(), 2);
assert_eq!(d.custom_props[0].name, "ZOTERO_PREF_1");
assert_eq!(d.custom_prop("Zotero_Pref_1"), Some("<data2/>"));
assert!(d.remove_custom_prop("other"));
assert!(!d.remove_custom_prop("other"));
assert_eq!(d.custom_props.len(), 1);
}
/// A paragraph that is nothing but `objs` (built directly: inserting one at a time is slow too).
fn para_of_objects(objs: Vec<InlineObject>) -> Paragraph {
let mut p = Paragraph::new();
p.text = para::OBJ.to_string().repeat(objs.len());
p.runs = vec![Run { len: p.text.len(), props: CharProps::default() }];
p.objects = objs;
p
}
#[test]
fn object_walks_are_linear_in_the_paragraph() {
// #3614: loops of object_offsets + object_at recounted the objects before each one, so a
// paragraph of 40,000 markers took seconds per walk.
let n = 40_000u32;
let w = para::OBJ.len_utf8();
let mut objs: Vec<InlineObject> = Vec::new();
for i in 0..n {
objs.push(InlineObject::BookmarkStart { name: format!("b{i}") });
objs.push(fs("ADDIN x"));
objs.push(InlineObject::PermStart { id: i, group: "everyone".into(), editor: String::new() });
}
// Ranges close in the order they opened (the worst case for a backwards search).
for i in 0..n {
objs.push(InlineObject::FieldEnd);
objs.push(InlineObject::PermEnd { id: i });
}
let mut d = Document::new();
d.body = vec![Arc::new(Block::Para(para_of_objects(objs)))];
let t = std::time::Instant::now();
assert_eq!(d.bookmarks().len(), n as usize);
assert_eq!(d.field_ranges(StoryRef::Body).len(), n as usize);
let perms = d.perm_ranges();
assert_eq!(perms.len(), n as usize);
assert!(perms.iter().all(|r| r.end.off > r.start.off), "every range closed by its own end");
assert_eq!(d.text_box_anchor(7), None);
// A caret before a long row of field ends moves past all of them.
let first_end = 3 * n as usize * w;
let p = d.past_field_ends(&Pos::body(0, first_end));
assert_eq!(p.off, first_end + w);
// Under 0.3 s on a loaded machine; the old walks took minutes (360 s for the first test).
assert!(t.elapsed().as_secs_f32() < 10.0, "{:?}", t.elapsed());
let ends: Vec<InlineObject> = (0..n).map(|_| InlineObject::FieldEnd).collect();
d.body = vec![Arc::new(Block::Para(para_of_objects(ends)))];
let t = std::time::Instant::now();
assert_eq!(d.past_field_ends(&Pos::body(0, 0)).off, n as usize * w);
// Typing at the end of a row of block-level control starts.
let block = ContentControl { block: true, ..Default::default() };
let starts: Vec<InlineObject> = (0..n).map(|_| InlineObject::ControlStart { control: Box::new(block.clone()) }).collect();
d.body = vec![Arc::new(Block::Para(para_of_objects(starts)))];
let end = Pos::body(0, n as usize * w);
assert_eq!(d.typing_pos(&end), end);
assert!(t.elapsed().as_secs_f32() < 10.0, "{:?}", t.elapsed());
}
#[test]
fn deleting_across_many_lone_control_markers_is_not_quadratic() {
// A millisecond now; the old delete took 18 s at 1,500 markers and 114 s at 3,000.
let n = 3_000;
let starts: Vec<InlineObject> = (0..n).map(|_| InlineObject::ControlStart { control: Box::default() }).collect();
let mut d = Document::from_text("before\n\nafter");
d.body[1] = Arc::new(Block::Para(para_of_objects(starts)));
let t = std::time::Instant::now();
let at = d.delete_range(&Pos::body(0, 3), &Pos::body(2, 2)).unwrap();
assert_eq!(at, Pos::body(0, 3));
assert_eq!(d.plain_text(StoryRef::Body), "befter");
assert!(t.elapsed().as_secs_f32() < 10.0, "{:?}", t.elapsed());
}
#[test]
fn control_markers_kept_are_sorted() {
// `delete_range` groups the kept markers by paragraph and binary-searches each group, so
// `kept_control_markers` must hand them over sorted and without repeats (review of #3874).
let start = || InlineObject::ControlStart { control: Box::default() };
let mut d = Document::from_text("before");
// Controls open and close across paragraphs: 1 closes the one 0 opens, 3 the one 2 opens…
for i in 0..12 {
let objs = if i % 2 == 0 { vec![start(), start()] } else { vec![InlineObject::ControlEnd, InlineObject::ControlEnd] };
d.body.push(Arc::new(Block::Para(para_of_objects(objs))));
}
d.body.push(Arc::new(Block::Para(Paragraph::with_text("after", CharProps::default()))));
let w = para::OBJ.len_utf8();
for (a, b) in [(Pos::body(2, w), Pos::body(11, w)), (Pos::body(1, w), Pos::body(12, w)), (Pos::body(3, w), Pos::body(8, 0))] {
let kept = d.kept_control_markers(&a, &b);
assert!(!kept.is_empty(), "{a:?}..{b:?}");
assert!(kept.windows(2).all(|p| p[0] < p[1]), "sorted, no repeats: {kept:?}");
}
}