Merge pull request #752 from marcoarnulfo:fix/function-excel-diffs-738

This commit is contained in:
Brandon Thomas 2026-10-13 01:39:44 -04:00
commit 80d282e7a5
6 changed files with 39 additions and 8 deletions

View File

@ -1167,16 +1167,18 @@ impl<'h> Evaluator<'h> {
v.scalar().to_bool().unwrap_or(true)
}
};
let text = if a1 {
text
// With a1 = FALSE, text that isn't R1C1 can still be a name, but never an A1 reference.
let (text, a1_ok) = if a1 {
(text, true)
} else {
match r1c1_to_a1(&text, self.at) {
Ok(Some(a1_text)) => a1_text,
Ok(None) => text,
Ok(Some(a1_text)) => (a1_text, true),
Ok(None) => (text, false),
Err(e) => return err(e),
}
};
match gridcraft_formula::parse(&text) {
Ok(Expr::Ref(_)) if !a1_ok => err(CellError::Ref),
Ok(e @ (Expr::Ref(_) | Expr::Name(_) | Expr::Struct(_))) => match self.eval(&e) {
r @ Ev::R(_) => r,
_ => err(CellError::Ref),

View File

@ -443,6 +443,22 @@ fn references_functions() {
assert_eq!(t.num("B3"), 50.0);
t.set("B4", "=INDIRECT(\"A\"&4)");
assert_eq!(t.num("B4"), 40.0);
// With a1 = FALSE the text must be R1C1: A1-style text is #REF!, not read as A1.
t.set("C1", "=INDIRECT(\"R3C1\",FALSE)");
assert_eq!(t.num("C1"), 30.0);
t.set("C2", "=INDIRECT(\"A3\",FALSE)");
assert_eq!(t.get("C2"), Value::Error(CellError::Ref));
// Defined names don't depend on the reference style.
t.wb.names.push(gridcraft_model::DefinedName {
name: "Third".into(),
scope: None,
formula: "Sheet1!$A$3".into(),
comment: String::new(),
hidden: false,
});
t.calc.rebuild(&t.wb);
t.set("C3", "=INDIRECT(\"Third\",FALSE)");
assert_eq!(t.num("C3"), 30.0);
t.set("B5", "=SUM(A1:INDEX(A1:A4,3))");
assert_eq!(t.num("B5"), 60.0);
t.set("B6", "=SUM(A:A)");

View File

@ -138,9 +138,10 @@ impl Criterion {
match self.op {
Op::Eq => blank,
Op::Ne => !blank,
// "<" / ">" with nothing: Excel compares against "" for text cells.
// "<" / ">" with nothing: Excel compares against "" for text cells, but an
// empty string never matches ("<=" and ">=" count it in neither).
_ => match &v {
Value::Text(t) => cmp_op(self.op, compare_text(t, "")),
Value::Text(t) if !t.is_empty() => cmp_op(self.op, compare_text(t, "")),
_ => false,
},
}
@ -361,6 +362,9 @@ mod tests {
assert!(m("<>", Value::from("a")));
assert!(!m("<>", Value::Empty));
assert!(m("TRUE", Value::Bool(true)));
// A bare "<=" or ">=" never matches an empty string (Excel 365: COUNTIF(="", "<=") is 0).
assert!(!m("<=", Value::from("")));
assert!(!m(">=", Value::from("")));
assert!(m("<b", Value::from("apple")));
assert!(!m("<b", Value::Number(1.0)));
assert!(m("#N/A", Value::Error(CellError::NA)));

View File

@ -321,7 +321,8 @@ fn mirr(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
neg += x / (1.0 + fr).powi(i as i32);
}
}
if pos == 0.0 || neg == 0.0 {
// No positive flow is not an error in Excel: the formula below gives -1.
if neg == 0.0 {
return Err(CellError::Div0);
}
let r = (-pos * (1.0 + rr).powf(n - 1.0) / neg).powf(1.0 / (n - 1.0)) - 1.0;
@ -1813,6 +1814,8 @@ mod tests {
close_tol(ev("MIRR", vec![rf(m), n(0.1), n(0.12)]), 0.1260943, 1e-6);
close_tol(ev("MIRR", vec![rf(col(&[-120000.0, 39000.0, 30000.0, 21000.0])), n(0.1), n(0.12)]), -0.048044655, 1e-7);
is_err(ev("MIRR", vec![rf(col(&[1.0, 2.0])), n(0.1), n(0.1)]), CellError::Div0);
// Excel 365 gives -1 when every flow is negative: the positive flows are worth nothing.
close_tol(ev("MIRR", vec![rf(col(&[-10000.0, -3400.0, -6500.0, -1000.0])), n(0.07), n(0.01)]), -1.0, 1e-12);
close(ev("FVSCHEDULE", vec![n(1.0), av(row(&[0.09, 0.11, 0.1]))]), 1.33089);
close(ev("EFFECT", vec![n(0.0525), n(4.0)]), 0.0535426673707584);
is_err(ev("EFFECT", vec![n(-0.1), n(4.0)]), CellError::Num);

View File

@ -845,6 +845,7 @@ pub(crate) fn specs() -> Vec<FnSpec> {
fixed
),
f!("DOLLAR", 1, 2, Text, S, "DOLLAR(number, [decimals])", "Formats a number as currency text.", dollar),
f!("USDOLLAR", 1, 2, Compatibility, S, "USDOLLAR(number, [decimals])", "Formats a number as currency text, like DOLLAR.", dollar),
f!("BAHTTEXT", 1, 1, Text, S, "BAHTTEXT(number)", "Spells out a number in Thai words with Baht.", bahttext),
f!("T", 1, 1, Text, S, "T(value)", "Returns the value if it is text, otherwise empty text.", t_fn),
f!(
@ -1104,6 +1105,8 @@ mod tests {
is_text(ev("DOLLAR", vec![n(1234.567)]), "$1,234.57");
is_text(ev("DOLLAR", vec![n(-1234.567), n(1.0)]), "($1,234.6)");
is_text(ev("DOLLAR", vec![n(0.5), n(0.0)]), "$1");
is_text(ev("USDOLLAR", vec![n(1.0)]), "$1.00");
is_text(ev("USDOLLAR", vec![n(-1234.567), n(1.0)]), "($1,234.6)");
is_text(ev("T", vec![t("x")]), "x");
is_text(ev("T", vec![n(1.0)]), "");
}

View File

@ -1,6 +1,6 @@
# Function parity: worksheet functions and formulas
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (TEXT and number formats: fraction literals, `?` thousands padding, booleans through the text section) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (USDOLLAR added as a compatibility function; MIRR, COUNTIF and INDIRECT edge cases match Excel 365) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Excel's worksheet functions, category by category, against GridCraft's function library. Part of
[target-app-parity.md](target-app-parity.md); gaps feed [gaps.md](gaps.md).
@ -20,6 +20,8 @@ Excel's worksheet functions, category by category, against GridCraft's function
evaluator's special forms in `crates/calc/src/eval.rs` (`SPECIAL_FUNCTIONS`: IF, IFS, CHOOSE,
OFFSET, INDIRECT, INDEX, SUBTOTAL, AGGREGATE, TEXT, LET, LAMBDA, MAP, REDUCE, SCAN, BYROW, BYCOL,
MAKEARRAY…), excluding the test-only `ADD2`. Every one is an Excel name.
- **Not on Microsoft's list:** USDOLLAR, a legacy function Excel still evaluates (like DOLLAR),
is in our library as a Compatibility function for files that use it. It is not counted above.
- **Working** excludes stubs that return an error by design: the 7 CUBE functions (`#N/A`, no
cube connections) and WEBSERVICE and FILTERXML (`#VALUE!`). Excel for Mac also lacks
WEBSERVICE and FILTERXML; Excel for Windows has them.
@ -106,6 +108,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | USDOLLAR added as a Compatibility function (not on Microsoft's list, so the counts don't change); MIRR with no positive flow is -1, COUNTIF's bare `"<="` / `">="` skip empty strings, INDIRECT with `a1` = FALSE gives `#REF!` for A1-style text (#738) |
| 2026-10-11 | minor | TEXT / number formats fixed against Excel-cached results: fraction literals and zero fractions, `0` denominators zero padded, `?` pads the thousands separator, booleans use the text section (226 mismatches → 0 in 352 TEXT cells) |
| 2026-10-11 | minor | Iterative calculation: circular references iterate up to Maximum Iterations / Maximum Change when enabled |
| 2026-10-11 | minor | Trim-reference operators (`:.`, `.:`, `.:.`) and unbracketed `[[#This Row],Col]` done; dropped the stale "Scope of what's missing" rows for TRIMRANGE, PERCENTOF, BAHTTEXT and the trim references |