oxedyne/fe2o3/fe2o3_file/tests/office.rs
17.9 KiB, 30 runs
created by r1870400018:22591, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | //! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\ |
| 2 | //! Anthropic Claude |
| 3 | |
| 4 | use oxedyne_fe2o3_file::office::docx; |
| 5 | use oxedyne_fe2o3_file::office::docx::read::Undrawable; |
| 6 | use oxedyne_fe2o3_file::office::opc::{ |
| 7 | CT_DOCUMENT, |
| 8 | REL_DOC, |
| 9 | REL_HYPERLINK, |
| 10 | }; |
| 11 | use oxedyne_fe2o3_file::zip::Zip; |
| 12 | |
| 13 | use oxedyne_fe2o3_core::{ |
| 14 | prelude::*, |
| 15 | test::test_it, |
| 16 | }; |
| 17 | use oxedyne_fe2o3_text::doc::{ |
| 18 | Block, |
| 19 | markdown, |
| 20 | }; |
| 21 | use oxedyne_fe2o3_text::xml::Xml; |
| 22 | |
| 23 | // Prose exercising every block the tree has, so a created document is checked against something with |
| 24 | // more in it than paragraphs. |
| 25 | const SOURCE: &str = "\ |
| 26 | # A Report On Something |
| 27 | |
| 28 | An opening paragraph with **bold words**, some *emphasis*, and `inline code`. |
| 29 | |
| 30 | ## The second heading |
| 31 | |
| 32 | - First bullet |
| 33 | - Second with a [link](https://example.com/page) |
| 34 | - A nested bullet |
| 35 | |
| 36 | 1. Step one |
| 37 | 2. Step two |
| 38 | |
| 39 | > A quotation. |
| 40 | |
| 41 | | Name | Quantity | Price | |
| 42 | | :--- | ---: | :---: | |
| 43 | | Widget | 12 | 3.40 | |
| 44 | |
| 45 | ```rust |
| 46 | fn main() {} |
| 47 | ``` |
| 48 | |
| 49 | --- |
| 50 | |
| 51 | A closing paragraph. |
| 52 | "; |
| 53 | |
| 54 | // A `.docx` LibreOffice wrote from an HTML document with known content: two heading levels, a |
| 55 | // bulleted list, a numbered list, a quotation, a table with a bold first row, a link, and bold and |
| 56 | // italic runs. |
| 57 | // |
| 58 | // The intent is ours and the bytes are somebody else's, which is the only useful shape for a reader |
| 59 | // test. It has already earned its place: LibreOffice gives its heading 1 style NO outline level and |
| 60 | // calls its quotation style `BlockQuotation`, and a reader written against Word's spellings alone |
| 61 | // gets both wrong. |
| 62 | const RICH: &[u8] = include_bytes!("data/rich.docx"); |
| 63 | |
| 64 | // A `.docx` LibreOffice wrote holding a picture, which is the one thing in this fixture set that a |
| 65 | // reading view genuinely cannot draw. The band that says so is the whole point of it. |
| 66 | const WITHPIC: &[u8] = include_bytes!("data/withpic.docx"); |
| 67 | |
| 68 | // The parts a `.docx` cannot open without. |
| 69 | const REQUIRED: [&str; 4] = [ |
| 70 | "[Content_Types].xml", |
| 71 | "_rels/.rels", |
| 72 | "word/document.xml", |
| 73 | "word/_rels/document.xml.rels", |
| 74 | ]; |
| 75 | |
| 76 | /// The document under test, and its archive. |
| 77 | fn made() -> Outcome<(Vec<u8>, Zip)> { |
| 78 | let doc = res!(markdown::parse(SOURCE)); |
| 79 | let (bytes, left) = res!(docx::write(&doc)); |
| 80 | assert!(left.is_empty(), "this source holds no image, so nothing was left out"); |
| 81 | let zip = res!(Zip::read(bytes.clone())); |
| 82 | Ok((bytes, zip)) |
| 83 | } |
| 84 | |
| 85 | pub fn test_office(filter: &'static str) -> Outcome<()> { |
| 86 | |
| 87 | res!(test_it(filter, &["A created .docx is a package, not a heap of XML 000", "all", "office"], || { |
| 88 | // The four parts and the two declarations. A document missing any of them is one Word declines |
| 89 | // to open, with a message naming neither the part nor the reason. |
| 90 | let (_, zip) = res!(made()); |
| 91 | for part in REQUIRED { |
| 92 | assert!(zip.has(part), "the package is missing {}", part); |
| 93 | } |
| 94 | let types = res!(zip.text("[Content_Types].xml")); |
| 95 | assert!(types.contains(CT_DOCUMENT), "the main part's content type is declared"); |
| 96 | assert!(types.contains("Extension=\"rels\""), "the relationship parts are declared"); |
| 97 | // The package's own rels must point at the document part, which is how a reader finds it. |
| 98 | let root = res!(Xml::parse(&res!(zip.text("_rels/.rels")))); |
| 99 | let rels = res!(root.root()).children("Relationship"); |
| 100 | assert_eq!(rels.len(), 1); |
| 101 | assert_eq!(rels[0].attr("Type"), Some(REL_DOC)); |
| 102 | assert_eq!(rels[0].attr("Target"), Some("word/document.xml")); |
| 103 | // And everything the package declares an override for must actually be in the archive: a |
| 104 | // content type for a part that is not there is how a package passes a check on its own |
| 105 | // manifest and fails in Word. |
| 106 | let types = res!(Xml::parse(&types)); |
| 107 | for over in res!(types.root()).children("Override") { |
| 108 | let part = res!(over.attr("PartName").ok_or_else(|| err!( |
| 109 | "an override with no PartName"; Invalid, Input, Missing))); |
| 110 | let name = part.trim_start_matches('/'); |
| 111 | assert!(zip.has(name), "'{}' is declared and is not in the archive", name); |
| 112 | } |
| 113 | Ok(()) |
| 114 | })); |
| 115 | |
| 116 | res!(test_it(filter, &["The document says what the tree said 001", "all", "office"], || { |
| 117 | let (_, zip) = res!(made()); |
| 118 | let xml = res!(Xml::parse(&res!(zip.text("word/document.xml")))); |
| 119 | let root = res!(xml.root()); |
| 120 | assert_eq!(root.name.qname, "w:document"); |
| 121 | let body = res!(root.child("w:body").ok_or_else(|| err!("no w:body"; Missing))); |
| 122 | |
| 123 | // A heading is Word's OWN heading, by style, so its navigation pane and a generated contents |
| 124 | // page both find it. A bold run at 20 point would look the same and be neither. |
| 125 | let styles: Vec<String> = xml.all("w:pStyle").iter() |
| 126 | .filter_map(|e| e.attr("w:val").map(|s| s.to_string())) |
| 127 | .collect(); |
| 128 | assert!(styles.contains(&"Heading1".to_string()), "got {:?}", styles); |
| 129 | assert!(styles.contains(&"Heading2".to_string())); |
| 130 | assert!(styles.contains(&"Quote".to_string()), "a quotation is a Quote"); |
| 131 | assert!(styles.contains(&"SourceCode".to_string()), "a listing is monospaced by style"); |
| 132 | |
| 133 | // The section properties are last in the body, as the schema requires. |
| 134 | let last = res!(body.elems().last().ok_or_else(|| err!("an empty body"; Missing))); |
| 135 | assert_eq!(last.name.qname, "w:sectPr"); |
| 136 | |
| 137 | // Lists are Word's lists: a numbering reference, not a bullet character typed into the text. |
| 138 | let nums: Vec<(&str, &str)> = xml.all("w:numPr").iter() |
| 139 | .filter_map(|e| { |
| 140 | let lvl = e.child("w:ilvl")?.attr("w:val")?; |
| 141 | let id = e.child("w:numId")?.attr("w:val")?; |
| 142 | Some((id, lvl)) |
| 143 | }) |
| 144 | .collect(); |
| 145 | assert!(nums.contains(&("1", "0")), "a bullet at the top level: {:?}", nums); |
| 146 | assert!(nums.contains(&("1", "1")), "a nested bullet is one level deeper: {:?}", nums); |
| 147 | assert!(nums.contains(&("2", "0")), "a numbered list is the other definition: {:?}", nums); |
| 148 | |
| 149 | // And the prose is all there, in order. |
| 150 | let text: String = xml.all("w:t").iter().map(|e| xml.text_of(e)).collect(); |
| 151 | for phrase in [ |
| 152 | "A Report On Something", |
| 153 | "An opening paragraph with ", |
| 154 | "bold words", |
| 155 | "inline code", |
| 156 | "First bullet", |
| 157 | "A quotation.", |
| 158 | "fn main() {}", |
| 159 | "A closing paragraph.", |
| 160 | ] { |
| 161 | assert!(text.contains(phrase), "the document lost {:?}", phrase); |
| 162 | } |
| 163 | // The spaces between runs survive, which is what `xml:space="preserve"` is for: a sentence |
| 164 | // built from three runs otherwise arrives with the words jammed together. |
| 165 | assert!(text.contains("with bold words, some emphasis"), "got {:?}", text); |
| 166 | Ok(()) |
| 167 | })); |
| 168 | |
| 169 | res!(test_it(filter, &["A link's target lives in the rels and resolves 002", "all", "office"], || { |
| 170 | // The one place a created document refers across parts, and so the one place it can be |
| 171 | // internally inconsistent while every part is well formed on its own. |
| 172 | let (_, zip) = res!(made()); |
| 173 | let xml = res!(Xml::parse(&res!(zip.text("word/document.xml")))); |
| 174 | let rels = res!(Xml::parse(&res!(zip.text("word/_rels/document.xml.rels")))); |
| 175 | let links = xml.all("w:hyperlink"); |
| 176 | assert_eq!(links.len(), 1); |
| 177 | let id = res!(links[0].attr("r:id").ok_or_else(|| err!( |
| 178 | "the hyperlink names no relationship"; Invalid, Missing))); |
| 179 | let rel = res!(res!(rels.root()).children("Relationship").into_iter() |
| 180 | .find(|r| r.attr("Id") == Some(id)) |
| 181 | .ok_or_else(|| err!("'{}' is named and not declared", id; Invalid, Missing))); |
| 182 | assert_eq!(rel.attr("Type"), Some(REL_HYPERLINK)); |
| 183 | assert_eq!(rel.attr("Target"), Some("https://example.com/page")); |
| 184 | assert_eq!(rel.attr("TargetMode"), Some("External"), |
| 185 | "a URL outside the package must say so, or Word reads it as a part name"); |
| 186 | // And every id the body names is declared: an r:id with nothing behind it is a document Word |
| 187 | // refuses to open. |
| 188 | let declared: Vec<&str> = res!(rels.root()).children("Relationship").iter() |
| 189 | .filter_map(|r| r.attr("Id")) |
| 190 | .collect(); |
| 191 | for link in &links { |
| 192 | let id = res!(link.attr("r:id").ok_or_else(|| err!("a link with no id"; Missing))); |
| 193 | assert!(declared.contains(&id), "'{}' is named and not declared", id); |
| 194 | } |
| 195 | Ok(()) |
| 196 | })); |
| 197 | |
| 198 | res!(test_it(filter, &["A table becomes a table 003", "all", "office"], || { |
| 199 | let (_, zip) = res!(made()); |
| 200 | let xml = res!(Xml::parse(&res!(zip.text("word/document.xml")))); |
| 201 | let tbls = xml.all("w:tbl"); |
| 202 | assert_eq!(tbls.len(), 1); |
| 203 | let tbl = tbls[0]; |
| 204 | assert_eq!(tbl.all("w:gridCol").len(), 3, "three columns are declared in the grid"); |
| 205 | let rows = tbl.children("w:tr"); |
| 206 | assert_eq!(rows.len(), 2, "a header row and one body row"); |
| 207 | assert_eq!(rows[0].children("w:tc").len(), 3); |
| 208 | assert!(rows[0].find(&["w:trPr", "w:tblHeader"]).is_some(), |
| 209 | "the header row repeats onto each page it runs on"); |
| 210 | // The tree names the sides Start and End because it does not know which way its text runs, and |
| 211 | // OOXML has the same two words for the same reason, so nothing here decides what "left" means. |
| 212 | let jc: Vec<&str> = tbl.all("w:jc").iter().filter_map(|e| e.attr("w:val")).collect(); |
| 213 | assert!(jc.contains(&"start"), "got {:?}", jc); |
| 214 | assert!(jc.contains(&"end"), "got {:?}", jc); |
| 215 | assert!(jc.contains(&"center"), "got {:?}", jc); |
| 216 | Ok(()) |
| 217 | })); |
| 218 | |
| 219 | res!(test_it(filter, &["What could not be carried is said 004", "all", "office"], || { |
| 220 | // An image's bytes are somewhere this crate cannot reach: the tree holds where an image IS, and |
| 221 | // there is no filesystem here and no network. The alt text stands in its place and the omission |
| 222 | // is COUNTED, so a caller can tell the reader rather than the reader having to notice. |
| 223 | let doc = res!(markdown::parse("Before  after.\n")); |
| 224 | let (bytes, left) = res!(docx::write(&doc)); |
| 225 | assert_eq!(left.images, vec!["pics/one.png".to_string()]); |
| 226 | assert!(!left.is_empty()); |
| 227 | let zip = res!(Zip::read(bytes)); |
| 228 | let xml = res!(Xml::parse(&res!(zip.text("word/document.xml")))); |
| 229 | let text: String = xml.all("w:t").iter().map(|e| xml.text_of(e)).collect(); |
| 230 | assert_eq!(text, "Before a diagram after.", "the alt text stands in for the image"); |
| 231 | Ok(()) |
| 232 | })); |
| 233 | |
| 234 | res!(test_it(filter, &["The same tree gives the same bytes 005", "all", "office"], || { |
| 235 | // Nothing in a created document comes from the clock or from a counter that survives a call, so |
| 236 | // a build that has to be reproducible stays reproducible. |
| 237 | let doc = res!(markdown::parse(SOURCE)); |
| 238 | let (a, _) = res!(docx::write(&doc)); |
| 239 | let (b, _) = res!(docx::write(&doc)); |
| 240 | assert_eq!(a, b); |
| 241 | // And it survives the archive's own round trip, which is the property an edit will rest on. |
| 242 | let zip = res!(Zip::read(a.clone())); |
| 243 | assert_eq!(res!(zip.write()), a); |
| 244 | Ok(()) |
| 245 | })); |
| 246 | |
| 247 | |
| 248 | res!(test_it(filter, &["A foreign document reads back as what it says 006", "all", "office"], || { |
| 249 | // Read against a document this crate did not write. Reading back our own output would prove |
| 250 | // that the writer and the reader share their assumptions, which is exactly the thing worth |
| 251 | // doubting. |
| 252 | let r = res!(docx::read(RICH)); |
| 253 | let blocks = &r.doc.blocks; |
| 254 | |
| 255 | // Headings, by what the style RESOLVES to. LibreOffice gives `Heading1` no outline level at |
| 256 | // all, so a reader that asked for one would find no title in this document. |
| 257 | let heads: Vec<(u8, String)> = blocks.iter().filter_map(|b| match b { |
| 258 | Block::Heading { level, content } => { |
| 259 | Some((*level, oxedyne_fe2o3_text::doc::text_of(content))) |
| 260 | } |
| 261 | _ => None, |
| 262 | }).collect(); |
| 263 | assert_eq!(heads, vec![ |
| 264 | (1, "Quarterly Review".to_string()), |
| 265 | (2, "Findings".to_string()), |
| 266 | ], "got {:?}", heads); |
| 267 | |
| 268 | // Both lists, and which is which. That answer comes from word/numbering.xml through two hops: |
| 269 | // a paragraph names a w:num, which names an abstract definition, which holds the format. |
| 270 | let lists: Vec<(bool, usize)> = blocks.iter().filter_map(|b| match b { |
| 271 | Block::List { ordered, items } => Some((*ordered, items.len())), |
| 272 | _ => None, |
| 273 | }).collect(); |
| 274 | assert_eq!(lists, vec![(false, 2), (true, 2)], "got {:?}", lists); |
| 275 | |
| 276 | // A quotation, which LibreOffice calls `BlockQuotation` and Word calls `Quote`. |
| 277 | assert!(blocks.iter().any(|b| matches!(b, Block::Quote(_))), "the quotation is a quotation"); |
| 278 | |
| 279 | // The table, with its bold first row read as the header. |
| 280 | let table = res!(blocks.iter().find_map(|b| match b { |
| 281 | Block::Table { head, rows, .. } => Some((head, rows)), |
| 282 | _ => None, |
| 283 | }).ok_or_else(|| err!("no table"; Missing))); |
| 284 | let head = res!(table.0.as_ref().ok_or_else(|| err!("the table lost its header"; Missing))); |
| 285 | assert_eq!(head.text_of(), "Region Units"); |
| 286 | assert_eq!(table.1.len(), 2); |
| 287 | assert_eq!(table.1[0].text_of(), "North 120"); |
| 288 | |
| 289 | // The link, and its target out of the relationships part. |
| 290 | let text = markdown::write::render(&r.doc); |
| 291 | assert!(text.contains("[a link](https://example.org/detail)"), "got {}", text); |
| 292 | // Bold and italic survive as emphasis rather than as font sizes. |
| 293 | assert!(text.contains("**bold words**"), "got {}", text); |
| 294 | assert!(text.contains("*italic ones*"), "got {}", text); |
| 295 | |
| 296 | // Nothing in this document is undrawable, and it carries no macros. |
| 297 | assert!(r.undrawn.is_empty(), "got {:?}", r.undrawn); |
| 298 | assert!(r.say_undrawn().is_none()); |
| 299 | assert!(!r.macros); |
| 300 | Ok(()) |
| 301 | })); |
| 302 | |
| 303 | res!(test_it(filter, &["A picture in a foreign document is counted 012", "all", "office"], || { |
| 304 | // The counting, on a real document rather than on a hand-made `Reading`. LibreOffice writes a |
| 305 | // picture as `w:drawing`, and the prose around it has to survive it: a reader that stopped at |
| 306 | // the drawing would lose the rest of the document and report one image. |
| 307 | let r = res!(docx::read(WITHPIC)); |
| 308 | assert_eq!(r.undrawn, vec![(Undrawable::Image, 1)], "got {:?}", r.undrawn); |
| 309 | assert_eq!(res!(r.say_undrawn().ok_or_else(|| err!("no phrase"; Missing))), |
| 310 | "1 thing is not drawn: 1 image"); |
| 311 | let md = markdown::write::render(&r.doc); |
| 312 | assert!(md.contains("# Report With A Picture"), "got {}", md); |
| 313 | assert!(md.contains("Text before the picture."), "got {}", md); |
| 314 | assert!(md.contains("Text after the picture."), "the prose after the drawing survived: {}", md); |
| 315 | Ok(()) |
| 316 | })); |
| 317 | |
| 318 | res!(test_it(filter, &["What cannot be drawn is counted by kind 007", "all", "office"], || { |
| 319 | // The band in the panel header says "4 things are not drawn: 3 text boxes, 1 chart". The number |
| 320 | // and the kind are both the information: a reader told only that something is missing has been |
| 321 | // told that the reader cannot be trusted, and nothing else. |
| 322 | assert_eq!(Undrawable::TextBox.say(1), "1 text box"); |
| 323 | assert_eq!(Undrawable::TextBox.say(3), "3 text boxes"); |
| 324 | assert_eq!(Undrawable::Chart.say(1), "1 chart"); |
| 325 | let mut r = docx::read::Reading::default(); |
| 326 | assert!(r.say_undrawn().is_none(), "a document with everything drawn says nothing"); |
| 327 | r.undrawn = vec![(Undrawable::Image, 2), (Undrawable::Chart, 1)]; |
| 328 | assert_eq!(res!(r.say_undrawn().ok_or_else(|| err!("no phrase"; Missing))), |
| 329 | "3 things are not drawn: 2 images, 1 chart"); |
| 330 | r.undrawn = vec![(Undrawable::Chart, 1)]; |
| 331 | assert_eq!(res!(r.say_undrawn().ok_or_else(|| err!("no phrase"; Missing))), |
| 332 | "1 thing is not drawn: 1 chart"); |
| 333 | Ok(()) |
| 334 | })); |
| 335 | |
| 336 | res!(test_it(filter, &["A document we wrote reads back as what we put in 008", "all", "office"], || { |
| 337 | // Weaker evidence than 006 and worth having for a different reason: it is the ROUND TRIP, and |
| 338 | // it covers the constructs the foreign fixture has none of -- a listing, a thematic break, a |
| 339 | // code span. It proves the writer and reader agree, and nothing about either being right. |
| 340 | let doc = res!(markdown::parse(SOURCE)); |
| 341 | let (bytes, _) = res!(docx::write(&doc)); |
| 342 | let back = res!(docx::read(&bytes)); |
| 343 | let md = markdown::write::render(&back.doc); |
| 344 | for phrase in [ |
| 345 | "# A Report On Something", |
| 346 | "## The second heading", |
| 347 | "**bold words**", |
| 348 | "- First bullet", |
| 349 | "1. Step one", |
| 350 | "> A quotation.", |
| 351 | "[link](https://example.com/page)", |
| 352 | "| Widget | 12 | 3.40 |", |
| 353 | "fn main() {}", |
| 354 | ] { |
| 355 | assert!(md.contains(phrase), "the round trip lost {:?}\n---\n{}", phrase, md); |
| 356 | } |
| 357 | Ok(()) |
| 358 | })); |
| 359 | |
| 360 | res!(test_it(filter, &["An encrypted document is refused by name 009", "all", "office"], || { |
| 361 | // An encrypted Office file is an OLE compound file with the real document inside it. There is |
| 362 | // no password here, so it is refused rather than shown as the binary rubble it decodes to. |
| 363 | let ole = [ |
| 364 | 0xD0u8, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1, |
| 365 | 0, 0, 0, 0, 0, 0, 0, 0, |
| 366 | ]; |
| 367 | let e = docx::read(&ole); |
| 368 | assert!(e.is_err()); |
| 369 | // And something that is not a document at all is refused too, rather than read as empty. |
| 370 | assert!(docx::read(b"not a document").is_err()); |
| 371 | Ok(()) |
| 372 | })); |
| 373 | |
| 374 | res!(test_it(filter, &["A macro project is said rather than run 010", "all", "office"], || { |
| 375 | // Never executed, never stripped: the binary travels with the file and the reader is TOLD. |
| 376 | // A reader who is not told does not know what they have been sent. |
| 377 | let doc = res!(markdown::parse("A document.\n")); |
| 378 | let (bytes, _) = res!(docx::write(&doc)); |
| 379 | let mut zip = res!(Zip::read(bytes)); |
| 380 | assert!(!res!(docx::read(&res!(zip.write()))).macros); |
| 381 | zip.set("word/vbaProject.bin", vec![0xCA, 0xFE], oxedyne_fe2o3_file::zip::Method::Store); |
| 382 | let with = res!(zip.write()); |
| 383 | assert!(res!(docx::read(&with)).macros, "the macro project is seen"); |
| 384 | // And it is still there afterwards, byte for byte. |
| 385 | let back = res!(Zip::read(with)); |
| 386 | assert_eq!(res!(back.content("word/vbaProject.bin")), vec![0xCA, 0xFE]); |
| 387 | Ok(()) |
| 388 | })); |
| 389 | |
| 390 | res!(test_it(filter, &["Tracked changes are displayed as the document stands 011", "all", "office"], || { |
| 391 | // An insertion's text IS in the document, so it is read. A deletion's is not, so it is not. |
| 392 | // Display only: nothing here authors w:ins or w:del. |
| 393 | let doc = res!(markdown::parse("Placeholder.\n")); |
| 394 | let (bytes, _) = res!(docx::write(&doc)); |
| 395 | let mut zip = res!(Zip::read(bytes)); |
| 396 | let body = "<w:p><w:ins><w:r><w:t>kept </w:t></w:r></w:ins>\ |
| 397 | <w:del><w:r><w:delText>gone </w:delText></w:r></w:del>\ |
| 398 | <w:r><w:t>plain</w:t></w:r></w:p>"; |
| 399 | let part = fmt!( |
| 400 | "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\ |
| 401 | <w:document xmlns:w=\"{}\"><w:body>{}</w:body></w:document>", |
| 402 | docx::NS_W, body); |
| 403 | zip.set("word/document.xml", part.into_bytes(), oxedyne_fe2o3_file::zip::Method::Deflate); |
| 404 | let r = res!(docx::read(&res!(zip.write()))); |
| 405 | let text = markdown::write::render(&r.doc); |
| 406 | assert!(text.contains("kept plain"), "got {:?}", text); |
| 407 | assert!(!text.contains("gone"), "removed text is not what the document says: {:?}", text); |
| 408 | assert_eq!(r.tracked, 1, "and the insertion is counted, so a reader can be told"); |
| 409 | Ok(()) |
| 410 | })); |
| 411 | |
| 412 | Ok(()) |
| 413 | } |