Oregami
Repositories/oxedyne/fe2o3

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
12use oxedyne_fe2o3_file::office::docx;
13use oxedyne_fe2o3_file::office::edit::Find;
14use oxedyne_fe2o3_file::office::odf;
15use oxedyne_fe2o3_file::office::sheet::{
16 Ref,
17 Value,
18 typed,
19};
20use oxedyne_fe2o3_file::office::xlsx;
21use oxedyne_fe2o3_file::zip::{
22 Method,
23 Zip,
24};
25
26use 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.
32const DOCX: &[u8] = include_bytes!("data/rich.docx");
33// An `.odt` LibreOffice wrote, holding one of everything the tree can carry.
34const 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.
37const XLSX: &[u8] = include_bytes!("data/foreign.xlsx");
38// The same spreadsheet as an `.ods`, where the empty row is one repeated cell.
39const 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.
44const 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
48const CT_CHAIN: &str =
49 "application/vnd.openxmlformats-officedocument.spreadsheetml.calcChain+xml";
50const 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.
61fn 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.
79fn 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.
92fn 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.
110fn 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.
124const FORMATS: [(bool, &str, &[u8]); 2] = [(false, "xlsx", XLSX), (true, "ods", ODS)];
125
126/// Writes cells into whichever spreadsheet format, from `(ref, value, formula)` triples.
127fn 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
158pub 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}