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"])); +}