#![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"])); }