oxedyne/fe2o3/fe2o3_file/tests/edit.rs
20.3 KiB, 22 runs
created by r1870400018:22955, 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 | //! The third verb: changing a document somebody else wrote. |
| 2 | //! |
| 3 | //! Every fixture here is FOREIGN -- LibreOffice wrote it -- because the property being tested is that |
| 4 | //! an edit leaves a stranger's file intact, and a file this crate wrote has none of the constructs |
| 5 | //! that would be lost. The archive check is the load-bearing one: it compares the COMPRESSED BYTES of |
| 6 | //! every member nobody touched, not their content, because two members can hold the same content and |
| 7 | //! different bytes and it is the bytes a colleague's reader parses. |
| 8 | //! |
| 9 | //! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\ |
| 10 | //! Anthropic Claude |
| 11 | |
| 12 | use oxedyne_fe2o3_file::office::docx; |
| 13 | use oxedyne_fe2o3_file::office::edit::Find; |
| 14 | use oxedyne_fe2o3_file::office::odf; |
| 15 | use oxedyne_fe2o3_file::office::sheet::{ |
| 16 | Ref, |
| 17 | Value, |
| 18 | typed, |
| 19 | }; |
| 20 | use oxedyne_fe2o3_file::office::xlsx; |
| 21 | use oxedyne_fe2o3_file::zip::{ |
| 22 | Method, |
| 23 | Zip, |
| 24 | }; |
| 25 | |
| 26 | use oxedyne_fe2o3_core::{ |
| 27 | prelude::*, |
| 28 | test::test_it, |
| 29 | }; |
| 30 | |
| 31 | // A `.docx` LibreOffice wrote: three paragraphs, one of which splits a sentence across four runs. |
| 32 | const DOCX: &[u8] = include_bytes!("data/rich.docx"); |
| 33 | // An `.odt` LibreOffice wrote, holding one of everything the tree can carry. |
| 34 | const ODT: &[u8] = include_bytes!("data/foreign.odt"); |
| 35 | // A `.xlsx` LibreOffice wrote: shared strings, formulas with cached values, a styled date, a boolean, |
| 36 | // an absent row and a row with a gap in it. |
| 37 | const XLSX: &[u8] = include_bytes!("data/foreign.xlsx"); |
| 38 | // The same spreadsheet as an `.ods`, where the empty row is one repeated cell. |
| 39 | const ODS: &[u8] = include_bytes!("data/foreign.ods"); |
| 40 | |
| 41 | // The chain a real Excel would have written for sheet 1 of `foreign.xlsx`: the five cells that hold |
| 42 | // an `<f>`, the two products first, the boolean pair next, and the SUM that depends on the products |
| 43 | // last. |
| 44 | const CHAIN: &str = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\r\n\ |
| 45 | <calcChain xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\">\ |
| 46 | <c r=\"D2\" i=\"1\"/><c r=\"D3\"/><c r=\"F2\"/><c r=\"F3\"/><c r=\"D5\" l=\"1\"/></calcChain>"; |
| 47 | |
| 48 | const CT_CHAIN: &str = |
| 49 | "application/vnd.openxmlformats-officedocument.spreadsheetml.calcChain+xml"; |
| 50 | const REL_CHAIN: &str = |
| 51 | "http://schemas.openxmlformats.org/officeDocument/2006/relationships/calcChain"; |
| 52 | |
| 53 | /// `foreign.xlsx` with a calculation chain grafted on, which is the ONLY way this code path gets |
| 54 | /// exercised at all. |
| 55 | /// |
| 56 | /// **LibreOffice does not write `xl/calcChain.xml`**, so every `.xlsx` fixture on the machine lacked |
| 57 | /// one and `drop_calc_chain` had never once had a chain to drop -- a removal proved by never being |
| 58 | /// invoked. The chain is grafted rather than invented so the workbook around it stays a real reader's |
| 59 | /// output and the only novel bytes are the part under test. All THREE declarations go in; a chain |
| 60 | /// declared in two places out of three would be a broken package testing the wrong thing. |
| 61 | fn with_chain() -> Outcome<Vec<u8>> { |
| 62 | let mut zip = res!(Zip::read(XLSX.to_vec())); |
| 63 | zip.set("xl/calcChain.xml", CHAIN.as_bytes().to_vec(), Method::Deflate); |
| 64 | let types = res!(zip.text("[Content_Types].xml")); |
| 65 | let over = fmt!("<Override PartName=\"/xl/calcChain.xml\" ContentType=\"{}\"/></Types>", CT_CHAIN); |
| 66 | let types = res!(types.rsplit_once("</Types>").ok_or_else(|| err!( |
| 67 | "The fixture's [Content_Types].xml has no </Types>."; Test, Invalid))).0.to_string() + &over; |
| 68 | zip.set("[Content_Types].xml", types.into_bytes(), Method::Deflate); |
| 69 | let rels = res!(zip.text("xl/_rels/workbook.xml.rels")); |
| 70 | let one = fmt!( |
| 71 | "<Relationship Id=\"rId9\" Type=\"{}\" Target=\"calcChain.xml\"/></Relationships>", REL_CHAIN); |
| 72 | let rels = res!(rels.rsplit_once("</Relationships>").ok_or_else(|| err!( |
| 73 | "The fixture's workbook rels has no </Relationships>."; Test, Invalid))).0.to_string() + &one; |
| 74 | zip.set("xl/_rels/workbook.xml.rels", rels.into_bytes(), Method::Deflate); |
| 75 | zip.write() |
| 76 | } |
| 77 | |
| 78 | /// The three places a calculation chain is declared, as this package has them. |
| 79 | fn chain_state(bytes: &[u8]) -> Outcome<(bool, bool, bool)> { |
| 80 | let zip = res!(Zip::read(bytes.to_vec())); |
| 81 | let part = zip.has("xl/calcChain.xml"); |
| 82 | let over = res!(zip.text("[Content_Types].xml")).contains("calcChain.xml"); |
| 83 | let rel = res!(zip.text("xl/_rels/workbook.xml.rels")).contains("calcChain.xml"); |
| 84 | Ok((part, over, rel)) |
| 85 | } |
| 86 | |
| 87 | /// Which members differ between two packages, and which are missing from the second. |
| 88 | /// |
| 89 | /// The comparison is of the members' own bytes rather than of their content: a member rebuilt with the |
| 90 | /// same content and a different compression level is a member that was not copied, and copying is the |
| 91 | /// whole claim. |
| 92 | fn differs(before: &[u8], after: &[u8]) -> Outcome<Vec<String>> { |
| 93 | let a = res!(Zip::read(before.to_vec())); |
| 94 | let b = res!(Zip::read(after.to_vec())); |
| 95 | let mut out = Vec::new(); |
| 96 | for m in a.members() { |
| 97 | match b.member(&m.name) { |
| 98 | None => out.push(fmt!("{} is gone", m.name)), |
| 99 | Some(n) => { |
| 100 | if res!(m.raw(&a)) != res!(n.raw(&b)) { |
| 101 | out.push(m.name.clone()); |
| 102 | } |
| 103 | } |
| 104 | } |
| 105 | } |
| 106 | Ok(out) |
| 107 | } |
| 108 | |
| 109 | /// The cells of the first sheet of either spreadsheet format, as the text a person would see. |
| 110 | fn grid(bytes: &[u8], ods: bool) -> Outcome<Vec<Vec<String>>> { |
| 111 | let book = match ods { |
| 112 | true => res!(odf::sheet::read(bytes)).book, |
| 113 | false => res!(xlsx::read(bytes)).book, |
| 114 | }; |
| 115 | let sheet = res!(book.sheets.first().ok_or_else(|| err!("no sheets"; Missing))); |
| 116 | Ok(sheet.rows.iter() |
| 117 | .map(|r| r.iter().map(|c| c.value.show()).collect()) |
| 118 | .collect()) |
| 119 | } |
| 120 | |
| 121 | // The two spreadsheet formats, so a test asserts the same property of both in one place. One test |
| 122 | // over both rather than two tests: the answer has to be the SAME answer, and two tests would let the |
| 123 | // formats drift while each still passed. |
| 124 | const FORMATS: [(bool, &str, &[u8]); 2] = [(false, "xlsx", XLSX), (true, "ods", ODS)]; |
| 125 | |
| 126 | /// Writes cells into whichever spreadsheet format, from `(ref, value, formula)` triples. |
| 127 | fn write_cells(ods: bool, src: &[u8], cells: &[(&str, Option<&str>, Option<&str>)]) |
| 128 | -> Outcome<Vec<u8>> |
| 129 | { |
| 130 | match ods { |
| 131 | true => { |
| 132 | let mut sets = Vec::new(); |
| 133 | for (at, value, formula) in cells { |
| 134 | sets.push(odf::sheet::Set { |
| 135 | sheet: None, |
| 136 | at: res!(Ref::parse(at)), |
| 137 | value: value.map(|v| v.to_string()), |
| 138 | formula: formula.map(|f| f.to_string()), |
| 139 | }); |
| 140 | } |
| 141 | Ok(res!(odf::sheet::edit(src, &sets)).bytes) |
| 142 | } |
| 143 | false => { |
| 144 | let mut sets = Vec::new(); |
| 145 | for (at, value, formula) in cells { |
| 146 | sets.push(xlsx::edit::Set { |
| 147 | sheet: None, |
| 148 | at: res!(Ref::parse(at)), |
| 149 | value: value.map(|v| v.to_string()), |
| 150 | formula: formula.map(|f| f.to_string()), |
| 151 | }); |
| 152 | } |
| 153 | Ok(res!(xlsx::edit::edit(src, &sets)).bytes) |
| 154 | } |
| 155 | } |
| 156 | } |
| 157 | |
| 158 | pub fn test_edit(filter: &'static str) -> Outcome<()> { |
| 159 | |
| 160 | res!(test_it(filter, &["A replacement spread over four runs lands as one 000", "all", "edit"], || { |
| 161 | // The sentence is `An opening `, `paragraph with bold words`, ` and `, `italic`, ` ones.` in |
| 162 | // the file -- LibreOffice split it where the formatting changes. A find that matched inside one |
| 163 | // run at a time would find none of this, which is the whole reason the pieces are joined. |
| 164 | let before = res!(docx::edit::body_text(DOCX)); |
| 165 | let whole = "paragraph with bold words and italic"; |
| 166 | assert!(before.iter().any(|p| p.contains(whole)), |
| 167 | "the fixture no longer holds the phrase this tests: {:?}", before); |
| 168 | // And it is NOT one contiguous run: the part's own XML has markup through the middle of the |
| 169 | // phrase, so a find that looked inside one element at a time would report it absent. Without |
| 170 | // this the test above would pass on a fixture that made the join unnecessary. |
| 171 | let zip = res!(Zip::read(DOCX.to_vec())); |
| 172 | let part = res!(zip.text("word/document.xml")); |
| 173 | assert!(!part.contains(whole), |
| 174 | "the phrase stands in the XML as one string, so this fixture cannot prove the join works"); |
| 175 | |
| 176 | let out = res!(docx::edit::edit(DOCX, &[Find::every(whole, "SENTENCE")])); |
| 177 | assert!(out.runs >= 2, "a phrase spread over runs was rewritten in {} of them", out.runs); |
| 178 | let after = res!(docx::edit::body_text(&out.bytes)); |
| 179 | assert!(after.iter().any(|p| p.contains("An opening SENTENCE ones.")), |
| 180 | "the replacement did not land as one string: {:?}", after); |
| 181 | // And the rest of the document says exactly what it said. |
| 182 | assert_eq!(before.len(), after.len(), "an edit changed how many paragraphs there are"); |
| 183 | Ok(()) |
| 184 | })); |
| 185 | |
| 186 | res!(test_it(filter, &["Every part but the one edited is copied 010", "all", "edit"], || { |
| 187 | let out = res!(docx::edit::edit(DOCX, &[Find::every("Findings", "Results")])); |
| 188 | let moved = res!(differs(DOCX, &out.bytes)); |
| 189 | assert_eq!(moved, vec!["word/document.xml".to_string()], |
| 190 | "an edit rewrote parts it was not asked to touch"); |
| 191 | Ok(()) |
| 192 | })); |
| 193 | |
| 194 | res!(test_it(filter, &["An unmatched find is an error naming it 020", "all", "edit"], || { |
| 195 | // The failure this is written against is the SILENT one: a caller told the document was edited |
| 196 | // has no way to find out that one of its four replacements did nothing. |
| 197 | let e = docx::edit::edit(DOCX, &[Find::every("a phrase nobody wrote", "x")]); |
| 198 | let msg = match e { |
| 199 | Ok(_) => return Err(err!("an unmatched find was accepted"; Test)), |
| 200 | Err(e) => fmt!("{}", e), |
| 201 | }; |
| 202 | assert!(msg.contains("a phrase nobody wrote"), "the refusal does not name the string: {}", msg); |
| 203 | // And nothing was written: the call answers with an error rather than with bytes. |
| 204 | let e = odf::text::edit(ODT, &[ |
| 205 | Find::every("Widget", "Sprocket"), |
| 206 | Find::every("a phrase nobody wrote", "x"), |
| 207 | ]); |
| 208 | assert!(e.is_err(), "a list whose second edit matched nothing was accepted whole"); |
| 209 | Ok(()) |
| 210 | })); |
| 211 | |
| 212 | res!(test_it(filter, &["An occurrence is counted through the document 030", "all", "edit"], || { |
| 213 | let out = res!(odf::text::edit(ODT, &[Find::at("bullet", "BULLET", 2)])); |
| 214 | let after = res!(odf::text::body_text(&out.bytes)); |
| 215 | let hits: Vec<&String> = after.iter().filter(|p| p.contains("BULLET")).collect(); |
| 216 | assert_eq!(hits.len(), 1, "one occurrence was asked for and {} changed", hits.len()); |
| 217 | assert!(hits[0].contains("Second"), "the wrong occurrence changed: {}", hits[0]); |
| 218 | // Past the end is refused, and the refusal says how many there are. |
| 219 | let e = odf::text::edit(ODT, &[Find::at("bullet", "x", 99)]); |
| 220 | let msg = match e { |
| 221 | Ok(_) => return Err(err!("occurrence 99 of a phrase with four was accepted"; Test)), |
| 222 | Err(e) => fmt!("{}", e), |
| 223 | }; |
| 224 | assert!(msg.contains("99"), "the refusal does not name the occurrence asked for: {}", msg); |
| 225 | Ok(()) |
| 226 | })); |
| 227 | |
| 228 | res!(test_it(filter, &["An .odt keeps its spaces when text is replaced 040", "all", "edit"], || { |
| 229 | // OpenDocument collapses a literal run of spaces, so a replacement carrying one has to be |
| 230 | // written as `<text:s>`. Left literal, the file opens and says something else. |
| 231 | let out = res!(odf::text::edit(ODT, &[Find::every("Widget", "A B C")])); |
| 232 | let after = res!(odf::text::body_text(&out.bytes)); |
| 233 | assert!(after.iter().any(|p| p.contains("A B C")), |
| 234 | "the spaces did not survive the write: {:?}", |
| 235 | after.iter().filter(|p| p.contains('A')).collect::<Vec<_>>()); |
| 236 | assert_eq!(res!(differs(ODT, &out.bytes)), vec!["content.xml".to_string()]); |
| 237 | Ok(()) |
| 238 | })); |
| 239 | |
| 240 | res!(test_it(filter, &["A typed string becomes what a person meant 050", "all", "edit"], || { |
| 241 | assert_eq!(typed("3.5"), Value::Number(3.5)); |
| 242 | assert_eq!(typed("12"), Value::Number(12.0)); |
| 243 | assert_eq!(typed("-0.25"), Value::Number(-0.25)); |
| 244 | assert_eq!(typed("true"), Value::Bool(true)); |
| 245 | assert_eq!(typed(""), Value::Empty); |
| 246 | // The ones that must NOT become numbers. A part number, an account code and a padded figure |
| 247 | // are all text, and a rule that parsed anything parseable would silently renumber them. |
| 248 | assert_eq!(typed("007"), Value::Text("007".to_string())); |
| 249 | assert_eq!(typed("+3"), Value::Text("+3".to_string())); |
| 250 | assert_eq!(typed("1,000"), Value::Text("1,000".to_string())); |
| 251 | assert_eq!(typed(" 4 "), Value::Text(" 4 ".to_string())); |
| 252 | assert_eq!(typed("1e3"), Value::Text("1e3".to_string())); |
| 253 | Ok(()) |
| 254 | })); |
| 255 | |
| 256 | res!(test_it(filter, &["A cell written into a gap lands in its own column 060", "all", "edit"], || { |
| 257 | // Row 4 of both fixtures is empty -- in the `.ods` it is ONE cell with a repeat of six, which |
| 258 | // is the case that goes silently wrong: a writer that ignored the repeat would move every |
| 259 | // value after it. The neighbours either side are what prove it did not. |
| 260 | // |
| 261 | // Both formats in one test, because the answer has to be the SAME answer. Two tests would let |
| 262 | // them drift and each still pass. |
| 263 | for (ods, name, src) in FORMATS { |
| 264 | let before = res!(grid(src, ods)); |
| 265 | let out = res!(write_cells(ods, src, &[("C4", Some("mid"), None), ("E4", Some("7.5"), None)])); |
| 266 | let after = res!(grid(&out, ods)); |
| 267 | assert_eq!(after[3].get(2).map(|s| s.as_str()), Some("mid"), |
| 268 | "{}: C4 did not land in column C: {:?}", name, after[3]); |
| 269 | assert_eq!(after[3].get(4).map(|s| s.as_str()), Some("7.5"), |
| 270 | "{}: E4 did not land in column E: {:?}", name, after[3]); |
| 271 | assert_eq!(after[3].get(1).map(|s| s.as_str()).unwrap_or(""), "", |
| 272 | "{}: a cell nobody wrote to gained a value: {:?}", name, after[3]); |
| 273 | // And every OTHER row says what it said, which is what a moved run would break. |
| 274 | for (i, row) in before.iter().enumerate() { |
| 275 | if i == 3 { |
| 276 | continue; |
| 277 | } |
| 278 | assert_eq!(after.get(i), Some(row), |
| 279 | "{}: row {} changed and nobody asked it to", name, i + 1); |
| 280 | } |
| 281 | } |
| 282 | Ok(()) |
| 283 | })); |
| 284 | |
| 285 | res!(test_it(filter, &["A written formula is left for the reader to work out 070", "all", "edit"], || { |
| 286 | // Nothing here calculates, so a formula goes in without a cached value. What is checked is |
| 287 | // that the EXPRESSION arrived intact -- and for the `.ods`, that its references came out |
| 288 | // bracketed, because `of:=B2*C2` makes LibreOffice write `Err:510` over the value beside it. |
| 289 | for (ods, name, src) in FORMATS { |
| 290 | let out = res!(write_cells(ods, src, &[("D6", None, Some("=B2*C2"))])); |
| 291 | let book = match ods { |
| 292 | true => res!(odf::sheet::read(&out)).book, |
| 293 | false => res!(xlsx::read(&out)).book, |
| 294 | }; |
| 295 | let cell = res!(book.sheets.first().ok_or_else(|| err!("no sheets"; Missing))) |
| 296 | .at(&res!(Ref::parse("D6"))); |
| 297 | assert_eq!(cell.formula.as_deref(), Some("B2*C2"), |
| 298 | "{}: the formula did not arrive as written: {:?}", name, cell); |
| 299 | assert!(cell.value.is_empty(), |
| 300 | "{}: a value was invented for a formula nothing calculated: {:?}", name, cell); |
| 301 | if ods { |
| 302 | let zip = res!(Zip::read(out.clone())); |
| 303 | let text = res!(zip.text("content.xml")); |
| 304 | assert!(text.contains("of:=[.B2]*[.C2]"), |
| 305 | "an OpenDocument formula was written without its brackets, which destroys the \ |
| 306 | value beside it: {}", text.len()); |
| 307 | } |
| 308 | } |
| 309 | Ok(()) |
| 310 | })); |
| 311 | |
| 312 | res!(test_it(filter, &["A sheet that is not there is named, not guessed at 080", "all", "edit"], || { |
| 313 | for (ods, name, src) in FORMATS { |
| 314 | let at = res!(Ref::parse("A1")); |
| 315 | let e = match ods { |
| 316 | true => odf::sheet::edit(src, &[odf::sheet::Set { |
| 317 | sheet: Some("Nowhere".to_string()), |
| 318 | at, |
| 319 | value: Some("x".to_string()), |
| 320 | formula: None, |
| 321 | }]).map(|e| e.bytes), |
| 322 | false => xlsx::edit::edit(src, &[xlsx::edit::Set { |
| 323 | sheet: Some("Nowhere".to_string()), |
| 324 | at, |
| 325 | value: Some("x".to_string()), |
| 326 | formula: None, |
| 327 | }]).map(|e| e.bytes), |
| 328 | }; |
| 329 | let msg = match e { |
| 330 | Ok(_) => return Err(err!( |
| 331 | "{}: a write to a sheet that does not exist was accepted", name; Test)), |
| 332 | Err(e) => fmt!("{}", e), |
| 333 | }; |
| 334 | assert!(msg.contains("Nowhere"), "{}: the refusal does not name the sheet: {}", name, msg); |
| 335 | assert!(msg.contains("Sales"), |
| 336 | "{}: the refusal does not say what sheets there are: {}", name, msg); |
| 337 | } |
| 338 | Ok(()) |
| 339 | })); |
| 340 | |
| 341 | res!(test_it(filter, &["One cell written twice is refused, in both formats 085", "all", "edit"], || { |
| 342 | // There is no order for two writes to one cell to be applied in, so picking one would be a |
| 343 | // rule about how a caller happened to build its list. The two formats have to answer the same |
| 344 | // way -- and before this check they did not: the `.xlsx` path failed on an overlapping splice |
| 345 | // and the `.ods` path silently took the first and dropped the second. |
| 346 | for (ods, name, src) in FORMATS { |
| 347 | let twice = [("B2", Some("first"), None), ("B2", Some("second"), None)]; |
| 348 | let msg = match write_cells(ods, src, &twice) { |
| 349 | Ok(_) => return Err(err!("{}: one cell written twice was accepted", name; Test)), |
| 350 | Err(e) => fmt!("{}", e), |
| 351 | }; |
| 352 | assert!(msg.contains("B2"), "{}: the refusal does not name the cell: {}", name, msg); |
| 353 | assert!(msg.contains("twice"), "{}: the refusal does not say what went wrong: {}", name, msg); |
| 354 | } |
| 355 | Ok(()) |
| 356 | })); |
| 357 | |
| 358 | res!(test_it(filter, &["A spreadsheet keeps the parts nobody wrote to 090", "all", "edit"], || { |
| 359 | let out = res!(odf::sheet::edit(ODS, &[odf::sheet::Set { |
| 360 | sheet: None, |
| 361 | at: res!(Ref::parse("B2")), |
| 362 | value: Some("999".to_string()), |
| 363 | formula: None, |
| 364 | }])); |
| 365 | assert_eq!(res!(differs(ODS, &out.bytes)), vec!["content.xml".to_string()], |
| 366 | "a cell edit rewrote parts of the package it had no business in"); |
| 367 | let out = res!(xlsx::edit::edit(XLSX, &[xlsx::edit::Set { |
| 368 | sheet: None, |
| 369 | at: res!(Ref::parse("B2")), |
| 370 | value: Some("999".to_string()), |
| 371 | formula: None, |
| 372 | }])); |
| 373 | // No formula was written, so the calculation chain is left where it is. |
| 374 | let moved = res!(differs(XLSX, &out.bytes)); |
| 375 | assert!(moved.iter().all(|n| n.contains("sheet")), |
| 376 | "a cell edit rewrote parts of the package it had no business in: {:?}", moved); |
| 377 | Ok(()) |
| 378 | })); |
| 379 | |
| 380 | res!(test_it(filter, &["A calculation chain goes when a formula is DESTROYED 095", "all", "edit"], || { |
| 381 | // FIRST that the fixture has a chain at all, because every assertion below is about a part |
| 382 | // being removed and a fixture without one would pass all of them by having nothing to remove. |
| 383 | let src = res!(with_chain()); |
| 384 | assert_eq!(res!(chain_state(&src)), (true, true, true), |
| 385 | "the grafted fixture has no chain, so nothing below is testing anything"); |
| 386 | |
| 387 | // A formula WRITTEN, which is the case the module always covered. |
| 388 | let out = res!(xlsx::edit::edit(&src, &[xlsx::edit::Set { |
| 389 | sheet: None, |
| 390 | at: res!(Ref::parse("D6")), |
| 391 | value: None, |
| 392 | formula: Some("B2+B3".to_string()), |
| 393 | }])); |
| 394 | assert_eq!(res!(chain_state(&out.bytes)), (false, false, false), |
| 395 | "a written formula left the chain, or one of its two declarations, behind"); |
| 396 | |
| 397 | // A plain value over a cell that HELD a formula, which is the case it did not. D2 holds |
| 398 | // `<f>B2*C2</f>` and the chain names it; ECMA-376 §18.6.1 says a `c` in the chain "shall |
| 399 | // contain a formula", so a chain that survives this edit is a SPEC VIOLATION and not a stale |
| 400 | // hint. There is no formula anywhere in the call. |
| 401 | let out = res!(xlsx::edit::edit(&src, &[xlsx::edit::Set { |
| 402 | sheet: None, |
| 403 | at: res!(Ref::parse("D2")), |
| 404 | value: Some("999".to_string()), |
| 405 | formula: None, |
| 406 | }])); |
| 407 | assert_eq!(res!(chain_state(&out.bytes)), (false, false, false), |
| 408 | "a plain value written over a formula cell left the chain naming a cell with no <f>"); |
| 409 | let book = res!(xlsx::read(&out.bytes)).book; |
| 410 | let cell = res!(book.sheets.first().ok_or_else(|| err!("no sheets"; Missing))) |
| 411 | .at(&res!(Ref::parse("D2"))); |
| 412 | assert!(cell.formula.is_none(), |
| 413 | "D2 kept its formula, so the premise of this test is wrong: {:?}", cell); |
| 414 | |
| 415 | // EMPTYING a formula cell destroys the formula just as thoroughly, and by a different route |
| 416 | // through `cell_markup` -- the element comes out `<c r="D2"/>` with no body at all. |
| 417 | let out = res!(xlsx::edit::edit(&src, &[xlsx::edit::Set { |
| 418 | sheet: None, |
| 419 | at: res!(Ref::parse("D3")), |
| 420 | value: Some(String::new()), |
| 421 | formula: None, |
| 422 | }])); |
| 423 | assert_eq!(res!(chain_state(&out.bytes)), (false, false, false), |
| 424 | "emptying a formula cell left the chain naming it"); |
| 425 | |
| 426 | // AND IT REDDENS ONLY WHAT IT SHOULD. B2 holds a plain 120 and no `<f>`, so the chain is still |
| 427 | // true after writing over it and dropping it would be a needless rewrite of somebody else's |
| 428 | // bytes. A flag that fired here would be "drop it always" wearing a narrower name. |
| 429 | let out = res!(xlsx::edit::edit(&src, &[xlsx::edit::Set { |
| 430 | sheet: None, |
| 431 | at: res!(Ref::parse("B2")), |
| 432 | value: Some("999".to_string()), |
| 433 | formula: None, |
| 434 | }])); |
| 435 | assert_eq!(res!(chain_state(&out.bytes)), (true, true, true), |
| 436 | "the chain was dropped for a plain write over a plain cell, which breaks nothing"); |
| 437 | |
| 438 | // A formula written OVER a formula: destroyed and replaced, so the chain still goes -- but for |
| 439 | // the writing and not the razing, and the cell still has an `<f>` afterwards. |
| 440 | let out = res!(xlsx::edit::edit(&src, &[xlsx::edit::Set { |
| 441 | sheet: None, |
| 442 | at: res!(Ref::parse("D5")), |
| 443 | value: None, |
| 444 | formula: Some("SUM(D2:D4)".to_string()), |
| 445 | }])); |
| 446 | assert_eq!(res!(chain_state(&out.bytes)), (false, false, false), |
| 447 | "a formula written over a formula left the chain behind"); |
| 448 | Ok(()) |
| 449 | })); |
| 450 | |
| 451 | Ok(()) |
| 452 | } |