Merge main into #871

# Conflicts:
#	docs/cli.md
This commit is contained in:
Brandon Thomas
2026-10-13 00:09:07 -04:00
158 changed files with 10995 additions and 966 deletions
+100 -5
View File
@@ -101,6 +101,9 @@ pub struct CalcReport {
/// aren't results, so every formula counts as no oracle.
pub stale_cache: bool,
pub volatile: usize,
/// Matched cells whose producer saved GridCraft's TRUE or FALSE as the number 1 or 0 (no
/// `t="b"`, as Business Central's Excel exports do); counted in `matched` too.
pub bool_as_number: usize,
/// The first `max_report` mismatches, primary ones first.
pub mismatches: Vec<Mismatch>,
pub functions: BTreeMap<String, FnStat>,
@@ -351,6 +354,13 @@ fn cell_name(wb: &Workbook, (sheet, c): Key) -> String {
format!("{}!{}", quote_sheet(name), c.a1())
}
/// Whether the producer cached GridCraft's logical result as the number 1 or 0. Excel types a
/// logical result `t="b"`; a writer that leaves the type out (`<c><f>TRUE()</f><v>1</v></c>`, as
/// Business Central's "Open in Excel" exports do) stores the same value, read back as a number.
pub fn bool_cached_as_number(expected: &Value, actual: &Value) -> bool {
matches!((expected, actual), (Value::Number(n), Value::Bool(b)) if *n == if *b { 1.0 } else { 0.0 })
}
/// Whether GridCraft's `actual` agrees with the producer's `expected`.
pub fn values_match(expected: &Value, actual: &Value, tolerance: f64) -> bool {
match (expected, actual) {
@@ -462,7 +472,11 @@ pub fn oracle(cached: &Workbook, fresh: &Workbook, calc: &Calc, trust_cache: boo
}
None => vec!["(unparsed)".to_string()],
};
let ok = values_match(&cell.value, &actual, opts.tolerance);
let bool_as_number = bool_cached_as_number(&cell.value, &actual);
let ok = bool_as_number || values_match(&cell.value, &actual, opts.tolerance);
if bool_as_number {
r.bool_as_number += 1;
}
for n in &funcs {
let st = r.functions.entry(n.clone()).or_default();
st.checked += 1;
@@ -571,6 +585,14 @@ fn diff_sets(d: &mut Diffs, sheet: &str, a: &BTreeSet<String>, b: &BTreeSet<Stri
}
}
/// A sheet's conditional-format rules in precedence order, as comparable text. The priority number
/// itself is left out, so renumbering without changing the order isn't a difference.
fn cond_format_list(s: &Sheet) -> Vec<String> {
let mut cfs: Vec<_> = s.cond_formats.iter().collect();
cfs.sort_by_key(|c| c.priority);
cfs.iter().map(|c| format!("{:?} {:?} stopIfTrue={}", c.ranges, c.rule, c.stop_if_true)).collect()
}
fn diff_count(d: &mut Diffs, sheet: &str, a: usize, b: usize) {
if a != b {
d.push(|| format!("{sheet}: {a} → {b}"));
@@ -646,7 +668,12 @@ pub fn diff_workbooks(a: &Workbook, b: &Workbook, opts: &Options) -> Vec<Categor
.collect()
};
diff_sets(&mut tables, &name, &set(&table_list, sa), &set(&table_list, sb));
diff_count(&mut cond_formats, &name, sa.cond_formats.len(), sb.cond_formats.len());
let (cfa, cfb) = (cond_format_list(sa), cond_format_list(sb));
if cfa.len() != cfb.len() {
cond_formats.push(|| format!("{name}: {} → {}", cfa.len(), cfb.len()));
} else if let Some((x, y)) = cfa.iter().zip(&cfb).find(|(x, y)| x != y) {
cond_formats.push(|| format!("{name}: {x} → {y}"));
}
diff_count(&mut validations, &name, sa.validations.len(), sb.validations.len());
let chart_list = |s: &Sheet| s.charts.iter().map(|c| format!("{:?} ({} series)", c.kind, c.series.len())).collect::<Vec<_>>();
let (ca, cb) = (chart_list(sa), chart_list(sb));
@@ -984,6 +1011,7 @@ struct Totals {
mismatched: usize,
no_oracle: usize,
volatile: usize,
bool_as_number: usize,
round_trip_failed: usize,
round_trip_differing: usize,
categories: BTreeMap<&'static str, usize>,
@@ -1005,6 +1033,7 @@ fn totals(reports: &[FileReport]) -> Totals {
t.mismatched += r.calc.mismatched;
t.no_oracle += r.calc.no_oracle;
t.volatile += r.calc.volatile;
t.bool_as_number += r.calc.bool_as_number;
if r.round_trip.error.is_some() {
t.round_trip_failed += 1;
}
@@ -1065,7 +1094,8 @@ pub fn report_json(reports: &[FileReport], opts: &Options) -> Json {
"warnings": r.warnings,
"calc": {
"formulaCells": c.formula_cells, "checked": c.checked, "matched": c.matched, "mismatched": c.mismatched,
"noOracle": c.no_oracle, "staleCache": c.stale_cache, "volatile": c.volatile, "mismatches": mismatches, "functions": functions_json(&c.functions),
"noOracle": c.no_oracle, "staleCache": c.stale_cache, "volatile": c.volatile, "boolAsNumber": c.bool_as_number,
"mismatches": mismatches, "functions": functions_json(&c.functions),
"signatures": c.signatures,
},
"roundTrip": {"error": r.round_trip.error, "differing": r.round_trip.differing(), "categories": categories},
@@ -1079,7 +1109,7 @@ pub fn report_json(reports: &[FileReport], opts: &Options) -> Json {
"files": files,
"totals": {
"files": t.files, "opened": t.opened, "failedToOpen": t.failed, "formulaCells": t.formula_cells, "checked": t.checked,
"matched": t.matched, "mismatched": t.mismatched, "noOracle": t.no_oracle, "volatile": t.volatile,
"matched": t.matched, "mismatched": t.mismatched, "noOracle": t.no_oracle, "volatile": t.volatile, "boolAsNumber": t.bool_as_number,
"roundTripFailed": t.round_trip_failed, "roundTripDiffering": t.round_trip_differing, "categoriesDiffering": t.categories,
},
"functions": functions_json(&t.functions),
@@ -1118,6 +1148,9 @@ fn print_human(reports: &[FileReport], opts: &Options) {
if c.stale_cache {
outln!(" (the producer asked for a full recalculation on load: its cached values aren't results)");
}
if c.bool_as_number > 0 {
outln!(" ({} matched TRUE/FALSE results were saved by the producer as 1/0, without t=\"b\")", c.bool_as_number);
}
for m in &c.mismatches {
let tag = if m.primary { "mismatch" } else { " (downstream)" };
outln!(
@@ -1214,6 +1247,9 @@ fn print_human(reports: &[FileReport], opts: &Options) {
t.no_oracle,
t.volatile
);
if t.bool_as_number > 0 {
outln!("booleans saved as 1/0 by the producer: {} (counted as matched)", t.bool_as_number);
}
let cats: Vec<String> = t.categories.iter().map(|(c, n)| format!("{c} {n}")).collect();
outln!(
"round trip: {} of {} files differ{}{}",
@@ -1318,6 +1354,31 @@ mod tests {
assert_eq!(check(&wb, &Options { tolerance: 1e-12, ..Default::default() }).calc.mismatched, 1);
}
/// Business Central writes TRUE and FALSE as `<c><f>TRUE()</f><v>1</v></c>`, 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 =
@@ -1387,6 +1448,27 @@ mod tests {
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<u8> {
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
for (name, body) in parts {
@@ -1538,7 +1620,19 @@ mod tests {
assert_eq!(keys(f), set(&["path", "opened", "error", "warnings", "calc", "roundTrip"]));
assert_eq!(
keys(&f["calc"]),
set(&["formulaCells", "checked", "matched", "mismatched", "noOracle", "staleCache", "volatile", "mismatches", "functions", "signatures"])
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"]));
@@ -1577,6 +1671,7 @@ mod tests {
"mismatched",
"noOracle",
"volatile",
"boolAsNumber",
"roundTripFailed",
"roundTripDiffering",
"categoriesDiffering"