diff --git a/apps/gridcraft-cli/src/check.rs b/apps/gridcraft-cli/src/check.rs
index bbd3103d8..318b43aad 100644
--- a/apps/gridcraft-cli/src/check.rs
+++ b/apps/gridcraft-cli/src/check.rs
@@ -1353,456 +1353,4 @@ fn print_human(reports: &[FileReport], opts: &Options) {
}
#[cfg(test)]
-mod tests {
- #![allow(clippy::unwrap_used)]
- use std::io::Write as _;
- use std::sync::Arc;
-
- use gridcraft_engine::model::Formula;
-
- use super::*;
-
- #[test]
- fn discovery_reports_a_directory_read_error() {
- let path = std::env::temp_dir().join(format!("gridcraft-check-not-a-directory-{}", std::process::id()));
- std::fs::write(&path, "not a directory").unwrap();
- let mut discovery = Discovery::default();
- let mut seen = std::collections::HashSet::new();
- assert!(walk(&path, 0, &mut discovery, &mut seen));
- assert!(discovery.files.is_empty());
- assert_eq!(discovery.failures.len(), 1);
- assert_eq!(discovery.failures[0].path, path.to_string_lossy());
- assert!(discovery.failures[0].error.as_ref().unwrap().contains("cannot read directory"));
- let _ = std::fs::remove_file(path);
- }
-
- #[test]
- fn discovery_reports_omissions_only_after_the_file_limit() {
- let mut discovery = Discovery { files: vec![PathBuf::from("book.xlsx"); MAX_FILES - 1], ..Default::default() };
- assert!(discovery.add_file(PathBuf::from("last.xlsx")));
- assert!(discovery.failures.is_empty());
- assert!(!discovery.add_file(PathBuf::from("omitted.xlsx")));
- assert_eq!(discovery.files.len(), MAX_FILES);
- assert_eq!(discovery.failures[0].path, "omitted.xlsx");
- assert!(discovery.failures[0].error.as_ref().unwrap().contains("file limit"));
- }
-
- fn at(a1: &str) -> CellRef {
- CellRef::parse(a1).unwrap()
- }
-
- /// A workbook whose cells are `(A1, input, cached value)`: an input starting with `=` is a
- /// formula with that cached value, anything else a constant.
- fn book(cells: &[(&str, &str, Value)]) -> Workbook {
- let mut wb = Workbook::new();
- let sh = wb.sheet_mut(0).unwrap();
- for (a1, input, cached) in cells {
- let cell = if input.starts_with('=') {
- Cell { value: cached.clone(), formula: Some(Arc::new(Formula::new(input))), ..Default::default() }
- } else {
- Cell::value(cached.clone())
- };
- sh.set_cell(at(a1), cell);
- }
- wb
- }
-
- fn check(wb: &Workbook, opts: &Options) -> FileReport {
- let bytes = gridcraft_engine::io::save_bytes(wb, "t.xlsx").unwrap();
- check_bytes("t.xlsx", &bytes, opts)
- }
-
- fn n(x: f64) -> Value {
- Value::number(x)
- }
-
- #[test]
- fn matching_cached_values_pass() {
- let wb = book(&[("A1", "2", n(2.0)), ("B1", "=A1*3", n(6.0)), ("C1", "=A1&\"x\"", Value::text("2x")), ("D1", "=A1>1", Value::Bool(true))]);
- let r = check(&wb, &Options::default());
- assert_eq!(r.error, None);
- assert_eq!((r.calc.formula_cells, r.calc.checked, r.calc.matched, r.calc.mismatched), (3, 3, 3, 0));
- assert!(r.is_clean(), "{r:?}");
- }
-
- #[test]
- fn wrong_cached_value_is_a_mismatch_and_ranks_its_function() {
- // Excel "said" SUM gives 7; GridCraft computes 6. C1 reads B1 and is consistent with
- // Excel's 7, so it mismatches too, but only as a downstream effect.
- let wb = book(&[("A1", "2", n(2.0)), ("A2", "4", n(4.0)), ("B1", "=SUM(A1:A2)", n(7.0)), ("C1", "=B1+1", n(8.0))]);
- let r = check(&wb, &Options::default());
- assert_eq!((r.calc.checked, r.calc.mismatched), (2, 2));
- let first = &r.calc.mismatches[0];
- assert_eq!(first.cell, "Sheet1!B1");
- assert_eq!(first.formula, "=SUM(A1:A2)");
- assert_eq!((first.expected.clone(), first.actual.clone(), first.kind, first.primary), (n(7.0), n(6.0), "numeric", true));
- assert!(!r.calc.mismatches[1].primary);
- assert_eq!(r.calc.functions["SUM"], FnStat { checked: 1, mismatched: 1, primary: 1 });
- assert_eq!(r.calc.functions["(no function)"], FnStat { checked: 1, mismatched: 1, primary: 0 });
- assert!(!r.is_clean());
- }
-
- #[test]
- fn errors_compare_by_kind() {
- let wb = book(&[("A1", "=1/0", Value::Error(CellError::Div0)), ("A2", "=1/0", Value::Error(CellError::NA)), ("A3", "=1/0", n(0.0))]);
- let r = check(&wb, &Options::default());
- assert_eq!((r.calc.matched, r.calc.mismatched), (1, 2));
- assert!(r.calc.mismatches.iter().all(|m| m.kind == "error"));
- assert_eq!(r.calc.signatures.get("#N/A → #DIV/0!"), Some(&1));
- assert_eq!(r.calc.signatures.get("number → #DIV/0!"), Some(&1));
- assert!(values_match(&Value::Error(CellError::NA), &Value::Error(CellError::NA), 0.0));
- assert!(!values_match(&Value::Error(CellError::NA), &Value::Error(CellError::Value), 0.0));
- }
-
- #[test]
- fn numbers_compare_within_tolerance() {
- assert!(values_match(&n(3.0000000001), &n(3.0), 1e-9));
- assert!(!values_match(&n(3.0000000001), &n(3.0), 1e-12));
- // Absolute near zero.
- assert!(values_match(&n(1e-12), &n(0.0), 1e-9));
- assert!(values_match(&n(1e12 + 1.0), &n(1e12), 1e-9));
- assert!(!values_match(&n(0.0), &Value::Empty, 1.0));
- assert!(!values_match(&Value::text("1"), &n(1.0), 1.0));
- assert!(values_match(&Value::text("a"), &Value::text("a"), 0.0));
- assert!(!values_match(&Value::text("a"), &Value::text("A"), 0.0));
- assert!(!values_match(&Value::Bool(true), &n(1.0), 1.0));
- let wb = book(&[("A1", "=1+2", n(3.0000000001))]);
- assert_eq!(check(&wb, &Options::default()).calc.matched, 1);
- assert_eq!(check(&wb, &Options { tolerance: 1e-12, ..Default::default() }).calc.mismatched, 1);
- }
-
- /// Business Central writes TRUE and FALSE as `TRUE()1`, without `t="b"`:
- /// the cache holds the right value under a number type, so it matches and is counted apart.
- #[test]
- fn booleans_cached_as_numbers_match_and_are_counted_apart() {
- let wb = book(&[
- ("A1", "=TRUE()", n(1.0)),
- ("B1", "=FALSE()", n(0.0)),
- ("C1", "=TRUE()", Value::Bool(true)),
- ("D1", "=1=1", n(0.0)),
- ("E1", "=TRUE()+0", n(1.0)),
- ]);
- let r = check(&wb, &Options::default());
- assert_eq!((r.calc.checked, r.calc.matched, r.calc.mismatched), (5, 4, 1));
- // A cached 0 for TRUE is still a mismatch, and a number result is compared as a number.
- assert_eq!(r.calc.mismatches[0].cell, "Sheet1!D1");
- assert_eq!(r.calc.signatures.get("number → bool"), Some(&1));
- assert_eq!(r.calc.bool_as_number, 2);
- let v = report_json(std::slice::from_ref(&r), &Options::default());
- assert_eq!((v["files"][0]["calc"]["boolAsNumber"].clone(), v["totals"]["boolAsNumber"].clone()), (json!(2), json!(2)));
- assert!(bool_cached_as_number(&n(1.0), &Value::Bool(true)));
- assert!(!bool_cached_as_number(&n(1.0), &Value::Bool(false)));
- assert!(!bool_cached_as_number(&n(2.0), &Value::Bool(true)));
- assert!(!bool_cached_as_number(&Value::Bool(true), &n(1.0)));
- }
-
- #[test]
- fn missing_cached_values_and_volatile_formulas_are_skipped() {
- let wb =
- book(&[("A1", "=1+1", Value::Empty), ("B1", "=NOW()", n(1.0)), ("B2", "=B1+1", n(2.0)), ("B3", "=CELL(\"filename\")", Value::text("x"))]);
- let r = check(&wb, &Options::default());
- assert_eq!((r.calc.formula_cells, r.calc.checked, r.calc.no_oracle, r.calc.volatile), (4, 0, 1, 3));
- }
-
- #[test]
- fn full_calc_on_load_means_no_oracle() {
- // A formula without a cached value makes GridCraft's writer flag the file
- // `fullCalcOnLoad`, like libraries that store 0 for every formula: B1's wrong "cached"
- // value must not count as a mismatch then.
- let wb = book(&[("A1", "=1+1", Value::Empty), ("B1", "=2+2", n(0.0))]);
- let bytes = gridcraft_engine::io::save_bytes(&wb, "t.xlsx").unwrap();
- assert!(full_calc_on_load(&bytes));
- let r = check_bytes("t.xlsx", &bytes, &Options::default());
- assert!(r.calc.stale_cache);
- assert_eq!((r.calc.checked, r.calc.no_oracle, r.calc.mismatched), (0, 2, 0));
- let fresh = gridcraft_engine::io::save_bytes(&book(&[("B1", "=2+2", n(0.0))]), "t.xlsx").unwrap();
- assert!(!full_calc_on_load(&fresh));
- assert!(!full_calc_on_load(b"not a zip"));
- }
-
- #[test]
- fn volatile_names_propagate() {
- let mut wb = book(&[("A1", "=Stamp+1", n(1.0))]);
- wb.names.push(gridcraft_engine::model::DefinedName {
- name: "Stamp".into(),
- scope: None,
- formula: "TODAY()".into(),
- comment: String::new(),
- hidden: false,
- });
- assert!(volatile_names(&wb).contains("STAMP"));
- assert_eq!(check(&wb, &Options::default()).calc.volatile, 1);
- }
-
- #[test]
- fn round_trip_of_a_simple_book_is_clean() {
- let mut wb = book(&[("A1", "2", n(2.0)), ("B1", "=A1*3", n(6.0)), ("A2", "x", Value::text("x"))]);
- wb.sheet_mut(0).unwrap().merges.push(gridcraft_engine::core::RangeRef::parse("C1:D2").unwrap());
- let source = gridcraft_engine::io::save_bytes(&wb, "t.xlsx").unwrap();
- let rt = round_trip(&wb, &source, &Options::default());
- assert_eq!(rt.error, None);
- assert!(rt.is_clean(), "{rt:?}");
- assert!(rt.categories.iter().any(|c| c.name == "package"));
- }
-
- #[test]
- fn diff_detects_lost_features() {
- let mut a = book(&[("A1", "2", n(2.0)), ("B1", "=A1*3", n(6.0))]);
- a.sheet_mut(0).unwrap().merges.push(gridcraft_engine::core::RangeRef::parse("C1:D2").unwrap());
- let mut b = a.clone();
- let sh = b.sheet_mut(0).unwrap();
- sh.merges.clear();
- sh.set_cell(at("A1"), Cell::value(n(3.0)));
- sh.set_cell(at("B1"), Cell::value(n(6.0)));
- b.sheets.push(Arc::new(Sheet::new("Extra")));
- let cats = diff_workbooks(&a, &b, &Options { max_report: 1, ..Default::default() });
- let get = |name: &str| cats.iter().find(|c| c.name == name).unwrap();
- assert_eq!(get("values").diffs, 1);
- assert_eq!(get("values").examples, ["Sheet1!A1: 2 → 3"]);
- assert_eq!(get("formulas").examples, ["Sheet1!B1: =A1*3 → "]);
- assert_eq!(get("merges").examples, ["Sheet1: lost C1:D2"]);
- assert_eq!(get("sheets").diffs, 1);
- assert_eq!(get("tables").diffs, 0);
- }
-
- #[test]
- fn diff_detects_changed_cond_format_rule() {
- use gridcraft_engine::model::{CfRule, CfValueKind, Color, CondFormat};
- let bar = |gradient: bool, priority: u32| CondFormat {
- ranges: vec![gridcraft_engine::core::RangeRef::parse("A1").unwrap()],
- rule: CfRule::DataBar { min: CfValueKind::Min, max: CfValueKind::Max, color: Color::Rgb(0x638EC6), gradient, show_value: true },
- priority,
- stop_if_true: false,
- };
- let with = |cf: CondFormat| {
- let mut wb = book(&[("A1", "2", n(2.0))]);
- wb.sheet_mut(0).unwrap().cond_formats.push(cf);
- wb
- };
- let diffs =
- |a: &Workbook, b: &Workbook| diff_workbooks(a, b, &Options::default()).into_iter().find(|c| c.name == "condFormats").unwrap().diffs;
- assert_eq!(diffs(&with(bar(false, 1)), &with(bar(true, 1))), 1);
- assert_eq!(diffs(&with(bar(true, 1)), &with(bar(true, 1))), 0);
- assert_eq!(diffs(&with(bar(true, 1)), &with(bar(true, 5))), 0);
- }
-
- fn zip_of(parts: &[(&str, &str)]) -> Vec {
- let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
- for (name, body) in parts {
- z.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
- z.write_all(body.as_bytes()).unwrap();
- }
- z.finish().unwrap().into_inner()
- }
-
- #[test]
- fn package_validation_finds_what_excel_would_repair() {
- let bytes = zip_of(&[
- (
- "[Content_Types].xml",
- r#""#,
- ),
- ("_rels/.rels", r#""#),
- ("xl/workbook.xml", ""),
- ("xl/styles.xml", ""),
- (
- "xl/_rels/workbook.xml.rels",
- r#""#,
- ),
- ]);
- let issues = validate_package(&bytes).unwrap();
- assert_eq!(
- issues,
- [
- "part xl/styles.xml has no content type",
- "content type override for missing part /xl/gone.xml",
- "xl/_rels/workbook.xml.rels: rId2 (worksheet) targets missing part xl/worksheets/sheet1.xml",
- "xl/_rels/workbook.xml.rels: duplicate relationship id rId2",
- ]
- );
- assert!(validate_package(b"not a zip").is_err());
- let own = gridcraft_engine::io::save_bytes(&book(&[("A1", "=1", n(1.0))]), "t.xlsx").unwrap();
- assert_eq!(validate_package(&own).unwrap(), Vec::::new());
- }
-
- const WB_RELS: &str = "xl/_rels/workbook.xml.rels";
- const SHEET_RELS: &str = "xl/worksheets/_rels/sheet1.xml.rels";
- const MADE_UP: &str = r#""#;
- /// Not empty: an empty part isn't a loss.
- const MADE_UP_PART: (&str, &str) = ("xl/madeUp.xml", " ");
-
- /// `bytes` with relationships added to rels parts (created when missing) and `parts` added.
- fn with_rels(bytes: &[u8], rels: &[(&str, &str)], parts: &[(&str, &str)]) -> Vec {
- let mut src = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
- let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
- let mut pending: Vec<(&str, &str)> = rels.to_vec();
- for i in 0..src.len() {
- let mut f = src.by_index(i).unwrap();
- let name = f.name().to_string();
- let mut body = String::new();
- f.read_to_string(&mut body).unwrap();
- if let Some(at) = pending.iter().position(|(r, _)| *r == name) {
- body = body.replace("", &format!("{}", pending.remove(at).1));
- }
- out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
- out.write_all(body.as_bytes()).unwrap();
- }
- let created = pending.iter().map(|(r, extra)| {
- (*r, format!(r#"{extra}"#))
- });
- for (name, body) in created.collect::>().iter().map(|(n, b)| (*n, b.as_str())).chain(parts.iter().copied()) {
- out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
- out.write_all(body.as_bytes()).unwrap();
- }
- out.finish().unwrap().into_inner()
- }
-
- /// The `package` examples of checking `source`.
- fn package_of(source: &[u8]) -> Vec {
- let r = check_bytes("t.xlsx", source, &Options::default());
- r.round_trip.categories.iter().find(|c| c.name == "package").unwrap().examples.clone()
- }
-
- fn plain() -> Vec {
- gridcraft_engine::io::save_bytes(&book(&[("A1", "1", n(1.0))]), "t.xlsx").unwrap()
- }
-
- #[test]
- fn package_reports_relationship_kinds_the_output_lost() {
- // A kind GridCraft doesn't know is dropped on save; a calculation chain is never written
- // (Excel rebuilds it), so its absence isn't a loss.
- let calc_chain = r#""#;
- let source = with_rels(&plain(), &[(WB_RELS, &format!("{MADE_UP}{calc_chain}"))], &[MADE_UP_PART, ("xl/calcChain.xml", "")]);
- assert_eq!(package_of(&source), ["lost relationship madeUp"]);
- assert!(check_bytes("t.xlsx", &plain(), &Options::default()).round_trip.is_clean());
- }
-
- // In the three tests below the made-up kind stays lost, so the comparison is known to run.
-
- #[test]
- fn an_internal_link_written_as_a_relationship_is_not_a_loss() {
- // openpyxl writes `#Sheet1!B2` as an external relationship; GridCraft writes `location`.
- let link = r##""##;
- let source = with_rels(&plain(), &[(WB_RELS, MADE_UP), (SHEET_RELS, link)], &[MADE_UP_PART]);
- assert_eq!(package_of(&source), ["lost relationship madeUp"]);
- }
-
- #[test]
- fn comments_without_a_comment_are_not_a_loss() {
- let rels = r#""#;
- let comments = r#"A"#;
- let vml = r#""#;
- let source = with_rels(
- &plain(),
- &[(WB_RELS, MADE_UP), (SHEET_RELS, rels)],
- &[MADE_UP_PART, ("xl/comments1.xml", comments), ("xl/drawings/vmlDrawing1.vml", vml)],
- );
- assert_eq!(package_of(&source), ["lost relationship madeUp"]);
- }
-
- #[test]
- fn empty_drawings_and_person_lists_are_not_a_loss() {
- let drawing = r#""#;
- let person =
- r#""#;
- let source = with_rels(
- &plain(),
- &[(WB_RELS, &format!("{MADE_UP}{person}")), (SHEET_RELS, drawing)],
- &[
- MADE_UP_PART,
- ("xl/drawings/drawing1.xml", r#""#),
- ("xl/persons/person.xml", r#""#),
- ],
- );
- assert_eq!(package_of(&source), ["lost relationship madeUp"]);
- }
-
- #[test]
- fn json_lists_warning_messages() {
- let with = FileReport { path: "a.ods".into(), warnings: vec!["ODS data-only import".into()], ..Default::default() };
- let without = FileReport { path: "b.xlsx".into(), ..Default::default() };
- let v = report_json(&[with, without], &Options::default());
- assert_eq!(v["files"][0]["warnings"], json!(["ODS data-only import"]));
- assert_eq!(v["files"][1]["warnings"], json!([]));
- }
-
- #[test]
- fn unopenable_files_are_reported() {
- let r = check_bytes("broken.xlsx", b"PK\x03\x04 not really", &Options::default());
- assert!(r.error.is_some());
- assert!(!r.is_clean());
- }
-
- #[test]
- fn json_schema_keys() {
- let wb = book(&[("A1", "=1+1", n(3.0))]);
- let reports = vec![check(&wb, &Options::default()), check_bytes("bad.xlsx", b"", &Options::default())];
- let v = report_json(&reports, &Options::default());
- let keys = |v: &Json| v.as_object().unwrap().keys().cloned().collect::>();
- let set = |k: &[&str]| k.iter().map(|s| s.to_string()).collect::>();
- assert_eq!(keys(&v), set(&["schema", "tolerance", "files", "totals", "functions", "signatures"]));
- assert_eq!(v["schema"], SCHEMA);
- let f = &v["files"][0];
- assert_eq!(keys(f), set(&["path", "opened", "error", "warnings", "calc", "roundTrip"]));
- assert_eq!(
- keys(&f["calc"]),
- set(&[
- "formulaCells",
- "checked",
- "matched",
- "mismatched",
- "noOracle",
- "staleCache",
- "volatile",
- "boolAsNumber",
- "mismatches",
- "functions",
- "signatures"
- ])
- );
- assert_eq!(f["calc"]["signatures"], json!({"number → number": 1}));
- assert_eq!(keys(&f["calc"]["mismatches"][0]), set(&["cell", "formula", "expected", "actual", "kind", "functions", "primary"]));
- assert_eq!(f["calc"]["mismatches"][0]["expected"], json!(3.0));
- assert_eq!(keys(&f["roundTrip"]), set(&["error", "differing", "categories"]));
- assert_eq!(
- keys(&f["roundTrip"]["categories"]),
- set(&[
- "sheets",
- "values",
- "formulas",
- "numFmts",
- "merges",
- "names",
- "tables",
- "condFormats",
- "validations",
- "charts",
- "comments",
- "hyperlinks",
- "pivots",
- "package"
- ])
- );
- assert_eq!(keys(&f["roundTrip"]["categories"]["values"]), set(&["equal", "diffs", "examples"]));
- assert_eq!(v["files"][1]["opened"], json!(false));
- assert_eq!(
- keys(&v["totals"]),
- set(&[
- "files",
- "opened",
- "failedToOpen",
- "formulaCells",
- "checked",
- "matched",
- "mismatched",
- "noOracle",
- "volatile",
- "boolAsNumber",
- "roundTripFailed",
- "roundTripDiffering",
- "categoriesDiffering"
- ])
- );
- assert_eq!(keys(&v["functions"][0]), set(&["name", "checked", "mismatched", "primary"]));
- }
-}
+mod tests;
diff --git a/apps/gridcraft-cli/src/check/tests.rs b/apps/gridcraft-cli/src/check/tests.rs
new file mode 100644
index 000000000..f1df04670
--- /dev/null
+++ b/apps/gridcraft-cli/src/check/tests.rs
@@ -0,0 +1,451 @@
+#![allow(clippy::unwrap_used)]
+use std::io::Write as _;
+use std::sync::Arc;
+
+use gridcraft_engine::model::Formula;
+
+use super::*;
+
+#[test]
+fn discovery_reports_a_directory_read_error() {
+ let path = std::env::temp_dir().join(format!("gridcraft-check-not-a-directory-{}", std::process::id()));
+ std::fs::write(&path, "not a directory").unwrap();
+ let mut discovery = Discovery::default();
+ let mut seen = std::collections::HashSet::new();
+ assert!(walk(&path, 0, &mut discovery, &mut seen));
+ assert!(discovery.files.is_empty());
+ assert_eq!(discovery.failures.len(), 1);
+ assert_eq!(discovery.failures[0].path, path.to_string_lossy());
+ assert!(discovery.failures[0].error.as_ref().unwrap().contains("cannot read directory"));
+ let _ = std::fs::remove_file(path);
+}
+
+#[test]
+fn discovery_reports_omissions_only_after_the_file_limit() {
+ let mut discovery = Discovery { files: vec![PathBuf::from("book.xlsx"); MAX_FILES - 1], ..Default::default() };
+ assert!(discovery.add_file(PathBuf::from("last.xlsx")));
+ assert!(discovery.failures.is_empty());
+ assert!(!discovery.add_file(PathBuf::from("omitted.xlsx")));
+ assert_eq!(discovery.files.len(), MAX_FILES);
+ assert_eq!(discovery.failures[0].path, "omitted.xlsx");
+ assert!(discovery.failures[0].error.as_ref().unwrap().contains("file limit"));
+}
+
+fn at(a1: &str) -> CellRef {
+ CellRef::parse(a1).unwrap()
+}
+
+/// A workbook whose cells are `(A1, input, cached value)`: an input starting with `=` is a
+/// formula with that cached value, anything else a constant.
+fn book(cells: &[(&str, &str, Value)]) -> Workbook {
+ let mut wb = Workbook::new();
+ let sh = wb.sheet_mut(0).unwrap();
+ for (a1, input, cached) in cells {
+ let cell = if input.starts_with('=') {
+ Cell { value: cached.clone(), formula: Some(Arc::new(Formula::new(input))), ..Default::default() }
+ } else {
+ Cell::value(cached.clone())
+ };
+ sh.set_cell(at(a1), cell);
+ }
+ wb
+}
+
+fn check(wb: &Workbook, opts: &Options) -> FileReport {
+ let bytes = gridcraft_engine::io::save_bytes(wb, "t.xlsx").unwrap();
+ check_bytes("t.xlsx", &bytes, opts)
+}
+
+fn n(x: f64) -> Value {
+ Value::number(x)
+}
+
+#[test]
+fn matching_cached_values_pass() {
+ let wb = book(&[("A1", "2", n(2.0)), ("B1", "=A1*3", n(6.0)), ("C1", "=A1&\"x\"", Value::text("2x")), ("D1", "=A1>1", Value::Bool(true))]);
+ let r = check(&wb, &Options::default());
+ assert_eq!(r.error, None);
+ assert_eq!((r.calc.formula_cells, r.calc.checked, r.calc.matched, r.calc.mismatched), (3, 3, 3, 0));
+ assert!(r.is_clean(), "{r:?}");
+}
+
+#[test]
+fn wrong_cached_value_is_a_mismatch_and_ranks_its_function() {
+ // Excel "said" SUM gives 7; GridCraft computes 6. C1 reads B1 and is consistent with
+ // Excel's 7, so it mismatches too, but only as a downstream effect.
+ let wb = book(&[("A1", "2", n(2.0)), ("A2", "4", n(4.0)), ("B1", "=SUM(A1:A2)", n(7.0)), ("C1", "=B1+1", n(8.0))]);
+ let r = check(&wb, &Options::default());
+ assert_eq!((r.calc.checked, r.calc.mismatched), (2, 2));
+ let first = &r.calc.mismatches[0];
+ assert_eq!(first.cell, "Sheet1!B1");
+ assert_eq!(first.formula, "=SUM(A1:A2)");
+ assert_eq!((first.expected.clone(), first.actual.clone(), first.kind, first.primary), (n(7.0), n(6.0), "numeric", true));
+ assert!(!r.calc.mismatches[1].primary);
+ assert_eq!(r.calc.functions["SUM"], FnStat { checked: 1, mismatched: 1, primary: 1 });
+ assert_eq!(r.calc.functions["(no function)"], FnStat { checked: 1, mismatched: 1, primary: 0 });
+ assert!(!r.is_clean());
+}
+
+#[test]
+fn errors_compare_by_kind() {
+ let wb = book(&[("A1", "=1/0", Value::Error(CellError::Div0)), ("A2", "=1/0", Value::Error(CellError::NA)), ("A3", "=1/0", n(0.0))]);
+ let r = check(&wb, &Options::default());
+ assert_eq!((r.calc.matched, r.calc.mismatched), (1, 2));
+ assert!(r.calc.mismatches.iter().all(|m| m.kind == "error"));
+ assert_eq!(r.calc.signatures.get("#N/A → #DIV/0!"), Some(&1));
+ assert_eq!(r.calc.signatures.get("number → #DIV/0!"), Some(&1));
+ assert!(values_match(&Value::Error(CellError::NA), &Value::Error(CellError::NA), 0.0));
+ assert!(!values_match(&Value::Error(CellError::NA), &Value::Error(CellError::Value), 0.0));
+}
+
+#[test]
+fn numbers_compare_within_tolerance() {
+ assert!(values_match(&n(3.0000000001), &n(3.0), 1e-9));
+ assert!(!values_match(&n(3.0000000001), &n(3.0), 1e-12));
+ // Absolute near zero.
+ assert!(values_match(&n(1e-12), &n(0.0), 1e-9));
+ assert!(values_match(&n(1e12 + 1.0), &n(1e12), 1e-9));
+ assert!(!values_match(&n(0.0), &Value::Empty, 1.0));
+ assert!(!values_match(&Value::text("1"), &n(1.0), 1.0));
+ assert!(values_match(&Value::text("a"), &Value::text("a"), 0.0));
+ assert!(!values_match(&Value::text("a"), &Value::text("A"), 0.0));
+ assert!(!values_match(&Value::Bool(true), &n(1.0), 1.0));
+ let wb = book(&[("A1", "=1+2", n(3.0000000001))]);
+ assert_eq!(check(&wb, &Options::default()).calc.matched, 1);
+ assert_eq!(check(&wb, &Options { tolerance: 1e-12, ..Default::default() }).calc.mismatched, 1);
+}
+
+/// Business Central writes TRUE and FALSE as `TRUE()1`, without `t="b"`:
+/// the cache holds the right value under a number type, so it matches and is counted apart.
+#[test]
+fn booleans_cached_as_numbers_match_and_are_counted_apart() {
+ let wb = book(&[
+ ("A1", "=TRUE()", n(1.0)),
+ ("B1", "=FALSE()", n(0.0)),
+ ("C1", "=TRUE()", Value::Bool(true)),
+ ("D1", "=1=1", n(0.0)),
+ ("E1", "=TRUE()+0", n(1.0)),
+ ]);
+ let r = check(&wb, &Options::default());
+ assert_eq!((r.calc.checked, r.calc.matched, r.calc.mismatched), (5, 4, 1));
+ // A cached 0 for TRUE is still a mismatch, and a number result is compared as a number.
+ assert_eq!(r.calc.mismatches[0].cell, "Sheet1!D1");
+ assert_eq!(r.calc.signatures.get("number → bool"), Some(&1));
+ assert_eq!(r.calc.bool_as_number, 2);
+ let v = report_json(std::slice::from_ref(&r), &Options::default());
+ assert_eq!((v["files"][0]["calc"]["boolAsNumber"].clone(), v["totals"]["boolAsNumber"].clone()), (json!(2), json!(2)));
+ assert!(bool_cached_as_number(&n(1.0), &Value::Bool(true)));
+ assert!(!bool_cached_as_number(&n(1.0), &Value::Bool(false)));
+ assert!(!bool_cached_as_number(&n(2.0), &Value::Bool(true)));
+ assert!(!bool_cached_as_number(&Value::Bool(true), &n(1.0)));
+}
+
+#[test]
+fn missing_cached_values_and_volatile_formulas_are_skipped() {
+ let wb =
+ book(&[("A1", "=1+1", Value::Empty), ("B1", "=NOW()", n(1.0)), ("B2", "=B1+1", n(2.0)), ("B3", "=CELL(\"filename\")", Value::text("x"))]);
+ let r = check(&wb, &Options::default());
+ assert_eq!((r.calc.formula_cells, r.calc.checked, r.calc.no_oracle, r.calc.volatile), (4, 0, 1, 3));
+}
+
+#[test]
+fn full_calc_on_load_means_no_oracle() {
+ // A formula without a cached value makes GridCraft's writer flag the file
+ // `fullCalcOnLoad`, like libraries that store 0 for every formula: B1's wrong "cached"
+ // value must not count as a mismatch then.
+ let wb = book(&[("A1", "=1+1", Value::Empty), ("B1", "=2+2", n(0.0))]);
+ let bytes = gridcraft_engine::io::save_bytes(&wb, "t.xlsx").unwrap();
+ assert!(full_calc_on_load(&bytes));
+ let r = check_bytes("t.xlsx", &bytes, &Options::default());
+ assert!(r.calc.stale_cache);
+ assert_eq!((r.calc.checked, r.calc.no_oracle, r.calc.mismatched), (0, 2, 0));
+ let fresh = gridcraft_engine::io::save_bytes(&book(&[("B1", "=2+2", n(0.0))]), "t.xlsx").unwrap();
+ assert!(!full_calc_on_load(&fresh));
+ assert!(!full_calc_on_load(b"not a zip"));
+}
+
+#[test]
+fn volatile_names_propagate() {
+ let mut wb = book(&[("A1", "=Stamp+1", n(1.0))]);
+ wb.names.push(gridcraft_engine::model::DefinedName {
+ name: "Stamp".into(),
+ scope: None,
+ formula: "TODAY()".into(),
+ comment: String::new(),
+ hidden: false,
+ });
+ assert!(volatile_names(&wb).contains("STAMP"));
+ assert_eq!(check(&wb, &Options::default()).calc.volatile, 1);
+}
+
+#[test]
+fn round_trip_of_a_simple_book_is_clean() {
+ let mut wb = book(&[("A1", "2", n(2.0)), ("B1", "=A1*3", n(6.0)), ("A2", "x", Value::text("x"))]);
+ wb.sheet_mut(0).unwrap().merges.push(gridcraft_engine::core::RangeRef::parse("C1:D2").unwrap());
+ let source = gridcraft_engine::io::save_bytes(&wb, "t.xlsx").unwrap();
+ let rt = round_trip(&wb, &source, &Options::default());
+ assert_eq!(rt.error, None);
+ assert!(rt.is_clean(), "{rt:?}");
+ assert!(rt.categories.iter().any(|c| c.name == "package"));
+}
+
+#[test]
+fn diff_detects_lost_features() {
+ let mut a = book(&[("A1", "2", n(2.0)), ("B1", "=A1*3", n(6.0))]);
+ a.sheet_mut(0).unwrap().merges.push(gridcraft_engine::core::RangeRef::parse("C1:D2").unwrap());
+ let mut b = a.clone();
+ let sh = b.sheet_mut(0).unwrap();
+ sh.merges.clear();
+ sh.set_cell(at("A1"), Cell::value(n(3.0)));
+ sh.set_cell(at("B1"), Cell::value(n(6.0)));
+ b.sheets.push(Arc::new(Sheet::new("Extra")));
+ let cats = diff_workbooks(&a, &b, &Options { max_report: 1, ..Default::default() });
+ let get = |name: &str| cats.iter().find(|c| c.name == name).unwrap();
+ assert_eq!(get("values").diffs, 1);
+ assert_eq!(get("values").examples, ["Sheet1!A1: 2 → 3"]);
+ assert_eq!(get("formulas").examples, ["Sheet1!B1: =A1*3 → "]);
+ assert_eq!(get("merges").examples, ["Sheet1: lost C1:D2"]);
+ assert_eq!(get("sheets").diffs, 1);
+ assert_eq!(get("tables").diffs, 0);
+}
+
+#[test]
+fn diff_detects_changed_cond_format_rule() {
+ use gridcraft_engine::model::{CfRule, CfValueKind, Color, CondFormat};
+ let bar = |gradient: bool, priority: u32| CondFormat {
+ ranges: vec![gridcraft_engine::core::RangeRef::parse("A1").unwrap()],
+ rule: CfRule::DataBar { min: CfValueKind::Min, max: CfValueKind::Max, color: Color::Rgb(0x638EC6), gradient, show_value: true },
+ priority,
+ stop_if_true: false,
+ };
+ let with = |cf: CondFormat| {
+ let mut wb = book(&[("A1", "2", n(2.0))]);
+ wb.sheet_mut(0).unwrap().cond_formats.push(cf);
+ wb
+ };
+ let diffs =
+ |a: &Workbook, b: &Workbook| diff_workbooks(a, b, &Options::default()).into_iter().find(|c| c.name == "condFormats").unwrap().diffs;
+ assert_eq!(diffs(&with(bar(false, 1)), &with(bar(true, 1))), 1);
+ assert_eq!(diffs(&with(bar(true, 1)), &with(bar(true, 1))), 0);
+ assert_eq!(diffs(&with(bar(true, 1)), &with(bar(true, 5))), 0);
+}
+
+fn zip_of(parts: &[(&str, &str)]) -> Vec {
+ let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
+ for (name, body) in parts {
+ z.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
+ z.write_all(body.as_bytes()).unwrap();
+ }
+ z.finish().unwrap().into_inner()
+}
+
+#[test]
+fn package_validation_finds_what_excel_would_repair() {
+ let bytes = zip_of(&[
+ (
+ "[Content_Types].xml",
+ r#""#,
+ ),
+ ("_rels/.rels", r#""#),
+ ("xl/workbook.xml", ""),
+ ("xl/styles.xml", ""),
+ (
+ "xl/_rels/workbook.xml.rels",
+ r#""#,
+ ),
+ ]);
+ let issues = validate_package(&bytes).unwrap();
+ assert_eq!(
+ issues,
+ [
+ "part xl/styles.xml has no content type",
+ "content type override for missing part /xl/gone.xml",
+ "xl/_rels/workbook.xml.rels: rId2 (worksheet) targets missing part xl/worksheets/sheet1.xml",
+ "xl/_rels/workbook.xml.rels: duplicate relationship id rId2",
+ ]
+ );
+ assert!(validate_package(b"not a zip").is_err());
+ let own = gridcraft_engine::io::save_bytes(&book(&[("A1", "=1", n(1.0))]), "t.xlsx").unwrap();
+ assert_eq!(validate_package(&own).unwrap(), Vec::::new());
+}
+
+const WB_RELS: &str = "xl/_rels/workbook.xml.rels";
+const SHEET_RELS: &str = "xl/worksheets/_rels/sheet1.xml.rels";
+const MADE_UP: &str = r#""#;
+/// Not empty: an empty part isn't a loss.
+const MADE_UP_PART: (&str, &str) = ("xl/madeUp.xml", " ");
+
+/// `bytes` with relationships added to rels parts (created when missing) and `parts` added.
+fn with_rels(bytes: &[u8], rels: &[(&str, &str)], parts: &[(&str, &str)]) -> Vec {
+ let mut src = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
+ let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
+ let mut pending: Vec<(&str, &str)> = rels.to_vec();
+ for i in 0..src.len() {
+ let mut f = src.by_index(i).unwrap();
+ let name = f.name().to_string();
+ let mut body = String::new();
+ f.read_to_string(&mut body).unwrap();
+ if let Some(at) = pending.iter().position(|(r, _)| *r == name) {
+ body = body.replace("", &format!("{}", pending.remove(at).1));
+ }
+ out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
+ out.write_all(body.as_bytes()).unwrap();
+ }
+ let created = pending.iter().map(|(r, extra)| {
+ (*r, format!(r#"{extra}"#))
+ });
+ for (name, body) in created.collect::>().iter().map(|(n, b)| (*n, b.as_str())).chain(parts.iter().copied()) {
+ out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
+ out.write_all(body.as_bytes()).unwrap();
+ }
+ out.finish().unwrap().into_inner()
+}
+
+/// The `package` examples of checking `source`.
+fn package_of(source: &[u8]) -> Vec {
+ let r = check_bytes("t.xlsx", source, &Options::default());
+ r.round_trip.categories.iter().find(|c| c.name == "package").unwrap().examples.clone()
+}
+
+fn plain() -> Vec {
+ gridcraft_engine::io::save_bytes(&book(&[("A1", "1", n(1.0))]), "t.xlsx").unwrap()
+}
+
+#[test]
+fn package_reports_relationship_kinds_the_output_lost() {
+ // A kind GridCraft doesn't know is dropped on save; a calculation chain is never written
+ // (Excel rebuilds it), so its absence isn't a loss.
+ let calc_chain = r#""#;
+ let source = with_rels(&plain(), &[(WB_RELS, &format!("{MADE_UP}{calc_chain}"))], &[MADE_UP_PART, ("xl/calcChain.xml", "")]);
+ assert_eq!(package_of(&source), ["lost relationship madeUp"]);
+ assert!(check_bytes("t.xlsx", &plain(), &Options::default()).round_trip.is_clean());
+}
+
+// In the three tests below the made-up kind stays lost, so the comparison is known to run.
+
+#[test]
+fn an_internal_link_written_as_a_relationship_is_not_a_loss() {
+ // openpyxl writes `#Sheet1!B2` as an external relationship; GridCraft writes `location`.
+ let link = r##""##;
+ let source = with_rels(&plain(), &[(WB_RELS, MADE_UP), (SHEET_RELS, link)], &[MADE_UP_PART]);
+ assert_eq!(package_of(&source), ["lost relationship madeUp"]);
+}
+
+#[test]
+fn comments_without_a_comment_are_not_a_loss() {
+ let rels = r#""#;
+ let comments = r#"A"#;
+ let vml = r#""#;
+ let source = with_rels(
+ &plain(),
+ &[(WB_RELS, MADE_UP), (SHEET_RELS, rels)],
+ &[MADE_UP_PART, ("xl/comments1.xml", comments), ("xl/drawings/vmlDrawing1.vml", vml)],
+ );
+ assert_eq!(package_of(&source), ["lost relationship madeUp"]);
+}
+
+#[test]
+fn empty_drawings_and_person_lists_are_not_a_loss() {
+ let drawing = r#""#;
+ let person =
+ r#""#;
+ let source = with_rels(
+ &plain(),
+ &[(WB_RELS, &format!("{MADE_UP}{person}")), (SHEET_RELS, drawing)],
+ &[
+ MADE_UP_PART,
+ ("xl/drawings/drawing1.xml", r#""#),
+ ("xl/persons/person.xml", r#""#),
+ ],
+ );
+ assert_eq!(package_of(&source), ["lost relationship madeUp"]);
+}
+
+#[test]
+fn json_lists_warning_messages() {
+ let with = FileReport { path: "a.ods".into(), warnings: vec!["ODS data-only import".into()], ..Default::default() };
+ let without = FileReport { path: "b.xlsx".into(), ..Default::default() };
+ let v = report_json(&[with, without], &Options::default());
+ assert_eq!(v["files"][0]["warnings"], json!(["ODS data-only import"]));
+ assert_eq!(v["files"][1]["warnings"], json!([]));
+}
+
+#[test]
+fn unopenable_files_are_reported() {
+ let r = check_bytes("broken.xlsx", b"PK\x03\x04 not really", &Options::default());
+ assert!(r.error.is_some());
+ assert!(!r.is_clean());
+}
+
+#[test]
+fn json_schema_keys() {
+ let wb = book(&[("A1", "=1+1", n(3.0))]);
+ let reports = vec![check(&wb, &Options::default()), check_bytes("bad.xlsx", b"", &Options::default())];
+ let v = report_json(&reports, &Options::default());
+ let keys = |v: &Json| v.as_object().unwrap().keys().cloned().collect::>();
+ let set = |k: &[&str]| k.iter().map(|s| s.to_string()).collect::>();
+ assert_eq!(keys(&v), set(&["schema", "tolerance", "files", "totals", "functions", "signatures"]));
+ assert_eq!(v["schema"], SCHEMA);
+ let f = &v["files"][0];
+ assert_eq!(keys(f), set(&["path", "opened", "error", "warnings", "calc", "roundTrip"]));
+ assert_eq!(
+ keys(&f["calc"]),
+ set(&[
+ "formulaCells",
+ "checked",
+ "matched",
+ "mismatched",
+ "noOracle",
+ "staleCache",
+ "volatile",
+ "boolAsNumber",
+ "mismatches",
+ "functions",
+ "signatures"
+ ])
+ );
+ assert_eq!(f["calc"]["signatures"], json!({"number → number": 1}));
+ assert_eq!(keys(&f["calc"]["mismatches"][0]), set(&["cell", "formula", "expected", "actual", "kind", "functions", "primary"]));
+ assert_eq!(f["calc"]["mismatches"][0]["expected"], json!(3.0));
+ assert_eq!(keys(&f["roundTrip"]), set(&["error", "differing", "categories"]));
+ assert_eq!(
+ keys(&f["roundTrip"]["categories"]),
+ set(&[
+ "sheets",
+ "values",
+ "formulas",
+ "numFmts",
+ "merges",
+ "names",
+ "tables",
+ "condFormats",
+ "validations",
+ "charts",
+ "comments",
+ "hyperlinks",
+ "pivots",
+ "package"
+ ])
+ );
+ assert_eq!(keys(&f["roundTrip"]["categories"]["values"]), set(&["equal", "diffs", "examples"]));
+ assert_eq!(v["files"][1]["opened"], json!(false));
+ assert_eq!(
+ keys(&v["totals"]),
+ set(&[
+ "files",
+ "opened",
+ "failedToOpen",
+ "formulaCells",
+ "checked",
+ "matched",
+ "mismatched",
+ "noOracle",
+ "volatile",
+ "boolAsNumber",
+ "roundTripFailed",
+ "roundTripDiffering",
+ "categoriesDiffering"
+ ])
+ );
+ assert_eq!(keys(&v["functions"][0]), set(&["name", "checked", "mismatched", "primary"]));
+}