Files
gridcraft/crates/calc/src/tests.rs
T
Dylan M. TaylorandCodebuff f284f6a825 M2.9: budget lambda work and bound wildcard substring search (#2415)
Sweep findings from the ac8da7d4 security scan (storytold-gridcraft-agent-
findings.json), remaining on main at 72c1d57d:

- finding 14 (crates/calc/src/eval.rs:988): every lambda invocation now
  spends one charge against a per-cell work budget (16M — one per cell of
  the largest array a reference may expand to), so a hostile, deeply
  nested LAMBDA/LET formula stops at the bound a legitimately wide one
  could reach instead of growing exponentially with nesting. Exhaustion
  is #CALC!, like the existing depth cap. Metered at `call_lambda_values`
  so every path into a body is counted (direct application, MAP/REDUCE/
  SCAN/BYROW/BYCOL/MAKEARRAY, D-function criteria); the criteria
  sub-evaluator hands its spend back to the parent.
- finding 16 (crates/functions/src/criteria.rs:313): `match_from` takes a
  step budget (1<<26: an honest match against Excel's 32,767-char cell
  ceiling costs a few hundred thousand steps), and `wildcard_find` drops
  the leading star run (a no-op for a prefix match) so the first literal
  anchor skips straight to its occurrences; all suffix attempts share one
  budget. Star churn degrades to no-match instead of quadratic work.

Unit tests: mock-host exhaustion/under-budget for lambda applications
(eval::fuel_tests), nested REDUCE under budget in full recalc, wildcard
behaviour unchanged.

🤖 Generated with Codebuff

Co-authored-by: Codebuff <noreply@codebuff.com>
2026-10-24 05:07:08 -04:00

2157 lines
82 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use gridcraft_core::{CellError, CellRef, Value};
use gridcraft_model::{Cell, Formula, Workbook};
use crate::Calc;
fn c(s: &str) -> CellRef {
CellRef::parse(s).unwrap()
}
struct T {
wb: Workbook,
calc: Calc,
}
impl T {
fn new() -> T {
T { wb: Workbook::new(), calc: Calc::new() }
}
fn set(&mut self, at: &str, input: &str) {
let cell = if input.starts_with('=') {
Cell::formula(Formula::new(input))
} else {
Cell::value(gridcraft_core::parse::parse_input(input, self.wb.date_system).value)
};
self.wb.sheet_mut(0).unwrap().cells.set(c(at), cell);
self.calc.cells_changed(&mut self.wb, &[(0, c(at))]);
}
fn get(&self, at: &str) -> Value {
self.wb.sheet(0).unwrap().value(c(at))
}
fn num(&self, at: &str) -> f64 {
self.get(at).as_f64().unwrap_or_else(|| panic!("{at} = {:?}", self.get(at)))
}
}
#[test]
fn spill_range_operator() {
let mut t = T::new();
t.set("A1", "3");
t.set("B1", "=SEQUENCE(A1)");
t.set("C1", "=SUM(B1#)");
t.set("C2", "=ROWS(B1#)");
assert_eq!((t.num("C1"), t.num("C2")), (6.0, 3.0));
// Growing and shrinking the spill recalculates its users.
t.set("A1", "5");
assert_eq!((t.num("C1"), t.num("C2")), (15.0, 5.0));
t.set("A1", "2");
assert_eq!((t.num("C1"), t.num("C2")), (3.0, 2.0));
// A blocked spill, or a cell that doesn't spill, gives #REF!.
t.set("B2", "x");
assert_eq!(t.get("C1"), Value::Error(CellError::Ref));
t.set("B2", "");
assert_eq!(t.num("C1"), 3.0);
t.set("D1", "=SUM(A1#)");
assert_eq!(t.get("D1"), Value::Error(CellError::Ref));
// Through a defined name.
t.wb.names.push(gridcraft_model::DefinedName {
name: "Seq".into(),
scope: None,
formula: "Sheet1!$B$1#".into(),
comment: String::new(),
hidden: false,
});
t.set("E1", "=SUM(Seq)*10");
assert_eq!(t.num("E1"), 30.0);
t.set("A1", "4");
assert_eq!(t.num("E1"), 100.0);
}
#[test]
fn arrays_that_spill_as_before_do_not_requeue_their_dependents() {
// Two arrays that each read the other's spill area, and a formula reading both spills.
let mut t = T::new();
t.set("A1", "=SEQUENCE(2)+0*B2");
t.set("B1", "=SEQUENCE(2)+0*A2");
t.set("C1", "=SUM(A1#)+SUM(B1#)");
// An edit that spills the same values again: one round, not one per round up to the limit.
t.set("A1", "=SEQUENCE(2)+0*B2+0");
assert_eq!((t.num("A2"), t.num("B2"), t.num("C1")), (2.0, 2.0, 6.0));
assert!(t.calc.last_recalc_cells <= 3, "{} formulas evaluated for one edit", t.calc.last_recalc_cells);
// Only the anchor's value changes: formulas reading the whole spill still see it.
t.set("A1", "={7;2}+0*B2");
assert_eq!((t.num("A1"), t.num("C1")), (7.0, 12.0));
// A spill that does change still reaches its dependents.
t.set("A1", "=SEQUENCE(2)*10+0*B2");
assert_eq!((t.num("A2"), t.num("C1")), (20.0, 33.0));
}
#[test]
fn arithmetic_and_dependencies() {
let mut t = T::new();
t.set("A1", "2");
t.set("A2", "3");
t.set("A3", "=A1*A2+1");
assert_eq!(t.num("A3"), 7.0);
t.set("A1", "10");
assert_eq!(t.num("A3"), 31.0);
t.set("B1", "=SUM(A1:A3)");
assert_eq!(t.num("B1"), 44.0);
t.set("A2", "0");
assert_eq!(t.num("A3"), 1.0);
assert_eq!(t.num("B1"), 11.0);
}
#[test]
fn chains_out_of_order() {
let mut t = T::new();
t.set("A5", "=A4+1");
t.set("A4", "=A3+1");
t.set("A3", "=A2+1");
t.set("A2", "=A1+1");
t.set("A1", "1");
assert_eq!(t.num("A5"), 5.0);
}
#[test]
fn xlookup_returns_a_reference() {
let mut t = T::new();
for (i, (k, v)) in [("a", 1), ("b", 2), ("c", 3), ("d", 4)].iter().enumerate() {
t.set(&format!("A{}", i + 1), k);
t.set(&format!("B{}", i + 1), &v.to_string());
}
t.set("D1", "=SUM(XLOOKUP(\"b\",A1:A4,B1:B4):XLOOKUP(\"d\",A1:A4,B1:B4))");
assert_eq!(t.num("D1"), 9.0);
t.set("D2", "=SUM(B1:XLOOKUP(\"c\",A1:A4,B1:B4))");
assert_eq!(t.num("D2"), 6.0);
t.set("D3", "=CELL(\"address\",XLOOKUP(\"c\",A1:A4,B1:B4))");
assert_eq!(t.get("D3"), Value::from("$B$3"));
// Not found: if_not_found, else #N/A, as before.
t.set("D4", "=XLOOKUP(\"z\",A1:A4,B1:B4,\"none\")");
assert_eq!(t.get("D4"), Value::from("none"));
t.set("D5", "=XLOOKUP(\"z\",A1:A4,B1:B4)");
assert_eq!(t.get("D5"), Value::Error(CellError::NA));
// A match on an empty cell is a blank reference: ISBLANK is TRUE and & adds nothing (#374).
t.set("A5", "e");
t.set("D6", "=ISBLANK(XLOOKUP(\"e\",A1:A5,B1:B5))");
assert_eq!(t.get("D6"), Value::Bool(true));
t.set("D7", "=\"x\"&XLOOKUP(\"e\",A1:A5,B1:B5)&\"y\"");
assert_eq!(t.get("D7"), Value::from("xy"));
// An empty match_mode keeps the search_mode after it: search last to first.
t.set("A6", "b");
t.set("B6", "20");
t.set("D8", "=XLOOKUP(\"b\",A1:A6,B1:B6,,,-1)");
assert_eq!(t.num("D8"), 20.0);
t.set("D9", "=XLOOKUP(\"b\",A1:A6,B1:B6)");
assert_eq!(t.num("D9"), 2.0);
}
#[test]
fn long_chain() {
let mut t = T::new();
t.set("A1", "1");
for r in 2..=3000 {
t.set(&format!("A{r}"), &format!("=A{}+1", r - 1));
}
assert_eq!(t.num("A3000"), 3000.0);
t.set("A1", "10");
assert_eq!(t.num("A3000"), 3009.0);
}
#[test]
fn index_with_arrays_of_positions() {
let mut t = T::new();
for (i, row) in [[1, 2, 3], [4, 5, 6], [7, 8, 9]].iter().enumerate() {
for (j, v) in row.iter().enumerate() {
t.set(&format!("{}{}", ["A", "B", "C"][j], i + 1), &v.to_string());
}
}
t.set("E1", "=INDEX(A1:C3,{1;3},{1,3})");
assert_eq!([t.num("E1"), t.num("F1"), t.num("E2"), t.num("F2")], [1.0, 3.0, 7.0, 9.0]);
t.set("E4", "=SUM(INDEX(A1:C3,{1,2,3},2))");
assert_eq!(t.num("E4"), 15.0);
t.set("E5", "=INDEX({10,20,30},{3,1})");
assert_eq!([t.num("E5"), t.num("F5")], [30.0, 10.0]);
t.set("E7", "=INDEX(A1:C3,2,3)");
assert_eq!(t.num("E7"), 6.0);
}
#[test]
fn index_area_number_overflow() {
let mut t = T::new();
t.set("A1", "7");
t.set("D1", "9");
for (area, expected) in [("1", 7.0), ("2", 9.0)] {
t.set("G5", &format!("=INDEX((A1:B2,D1:E2),1,1,{area})"));
assert_eq!(t.num("G5"), expected, "area {area}");
}
for area in ["3", "1E300", "-1", "-1E300"] {
t.set("G5", &format!("=INDEX((A1:B2,D1:E2),1,1,{area})"));
assert_eq!(t.get("G5"), Value::Error(CellError::Ref), "area {area}");
}
}
/// INDEX with one index, as observed in Excel: a 2-D reference needs both indexes (#REF!), a
/// column array gives a 1×1 array (TYPE 64), a row array or 1-D reference gives one value.
#[test]
fn index_with_one_index() {
let mut t = T::new();
for (i, v) in [10, 20, 30].iter().enumerate() {
t.set(&format!("A{}", i + 1), &v.to_string());
}
let mut k = 1;
for r in 5..=7 {
for col in ["A", "B", "C"] {
t.set(&format!("{col}{r}"), &k.to_string());
k += 1;
}
}
for (row, (formula, expected)) in [
// Column arrays keep Excel's 1×1 array; row arrays and explicit columns give a value.
("=INDEX({10;20;30},2)", 20.0),
("=TYPE(INDEX({10;20;30},2))", 64.0),
("=TYPE(INDEX(SEQUENCE(3),2))", 64.0),
("=TYPE(INDEX({10;20;30},2,1))", 1.0),
("=TYPE(INDEX({10,20,30},2))", 1.0),
("=TYPE(INDEX(INDEX({10;20;30},2),1))", 1.0),
("=SUM(INDEX({10;20;30},2))", 20.0),
// 1-D references with one index are single-cell references.
("=TYPE(INDEX(A1:A3,2))", 1.0),
("=SUM(INDEX(A1:A3,1):INDEX(A1:A3,3))", 60.0),
("=SUM(INDEX((A1:A3,A5:C7),2))", 20.0),
// A 2-D array with one index is the row.
("=SUM(INDEX({1,2;3,4},2))", 7.0),
("=SUM(INDEX({1,2;3,4},0))", 10.0),
// A 2-D reference with an empty (not omitted) column argument is the row.
("=SUM(INDEX(A5:C7,2,))", 15.0),
("=SUM(INDEX(A5:C7,2,,1))", 15.0),
("=COLUMNS(INDEX((A1:A3,A5:C7),2,,2))", 3.0),
("=INDEX(A5:C7,2,2)", 5.0),
// A 2-D reference with one index is #REF!, and the error carries through.
("=TYPE(INDEX(A5:C7,2))", 16.0),
("=ISREF(INDEX(A5:C7,2))", 0.0),
]
.iter()
.enumerate()
{
let at = format!("J{}", row * 4 + 1);
t.set(&at, formula);
let got = match t.get(&at) {
Value::Bool(b) => f64::from(u8::from(b)),
v => v.as_f64().unwrap_or_else(|| panic!("{formula} = {v:?}")),
};
assert_eq!(got, *expected, "{formula}");
}
for formula in [
"=INDEX(A5:C7,2)",
"=INDEX(A5:C7,0)",
"=INDEX(A5:C7,1.9)",
"=INDEX(A5:C7,)",
"=INDEX(A5:B6,1)",
"=INDEX(A5:C7,2)+1",
"=SUM(INDEX(A5:C7,{1;2}))",
"=ROWS(INDEX(INDEX(A5:C7,2),3))",
"=SUM(A5:INDEX(A5:C7,2))",
"=SUM(INDEX(A5:C7,2):C7)",
] {
t.set("L1", formula);
assert_eq!(t.get("L1"), Value::Error(CellError::Ref), "{formula}");
}
t.set("L1", "=INDEX(A5:C7,{1;2})");
assert_eq!([t.get("L1"), t.get("L2")], [Value::Error(CellError::Ref), Value::Error(CellError::Ref)]);
t.set("L1", "=IFERROR(INDEX(A5:C7,2),\"e\")");
assert_eq!(t.get("L1"), Value::from("e"));
// A bad position is still #VALUE!.
t.set("L1", "=INDEX(A5:C7,\"x\")");
assert_eq!(t.get("L1"), Value::Error(CellError::Value));
}
#[test]
fn errors_and_cycles() {
let mut t = T::new();
t.set("A1", "=1/0");
assert_eq!(t.get("A1"), Value::Error(CellError::Div0));
t.set("B1", "=B2");
t.set("B2", "=B1");
assert!(t.get("B1").is_error());
t.set("C1", "=NOSUCHFN(1)");
assert_eq!(t.get("C1"), Value::Error(CellError::Name));
// `_xludf.` marks a user-defined function, never the built-in of the same name (#726).
for f in ["=_xludf.CONCAT(\"a\",\"b\")", "=_xludf.SUM(1,2)", "=_xludf.EOMONTH()"] {
t.set("C3", f);
assert_eq!(t.get("C3"), Value::Error(CellError::Name), "{f}");
}
t.set("C2", "=1+");
assert_eq!(t.get("C2"), Value::Error(CellError::Name));
}
impl T {
fn iterative(max_iterations: u32, max_change: f64) -> T {
let mut t = T::new();
t.wb.calc.iterative = true;
t.wb.calc.set_max_iterations(max_iterations);
t.wb.calc.set_max_change(max_change);
t
}
}
#[test]
fn iterative_calculation_stops_at_maximum_iterations() {
// A1 =A1+1 never settles: every recalculation adds Maximum Iterations.
let mut t = T::iterative(100, 0.001);
t.set("A1", "=A1+1");
assert_eq!(t.num("A1"), 100.0);
assert!(t.calc.circular.is_empty());
// An edit outside the loop doesn't recalculate it.
t.set("B1", "7");
assert_eq!(t.num("A1"), 100.0);
// Each recalculation starts from the value the cell shows.
t.calc.recalc_all(&mut t.wb);
assert_eq!(t.num("A1"), 200.0);
t.wb.calc.set_max_iterations(5);
t.calc.recalc_all(&mut t.wb);
assert_eq!(t.num("A1"), 205.0);
// Through a range that holds the formula itself.
t.set("C1", "1");
t.set("C2", "2");
t.set("C3", "=SUM(C1:C3)");
assert_eq!(t.num("C3"), 15.0);
// Excel's largest Maximum Iterations.
let mut t = T::iterative(32767, 0.001);
t.set("A1", "=A1+1");
assert_eq!(t.num("A1"), 32767.0);
}
#[test]
fn iterative_calculation_converges_within_maximum_change() {
let mut t = T::iterative(100, 0.001);
t.set("A1", "=(A2+10)/2");
t.set("A2", "=A1");
assert!((t.num("A1") - 10.0).abs() < 0.002, "{}", t.num("A1"));
assert!((t.num("A2") - 10.0).abs() < 0.002, "{}", t.num("A2"));
assert!(t.calc.circular.is_empty());
// A loop fed by a constant, and a formula that reads the loop, follow a change.
t.set("D1", "10");
t.set("A1", "=(A2+D1)/2");
t.set("E1", "=A1*2");
t.set("D1", "30");
assert!((t.num("A1") - 30.0).abs() < 0.002, "{}", t.num("A1"));
assert!((t.num("E1") - 60.0).abs() < 0.004, "{}", t.num("E1"));
// Already settled: recalculating changes it by less than Maximum Change.
let a1 = t.num("A1");
t.calc.recalc_all(&mut t.wb);
assert!((t.num("A1") - a1).abs() < 0.001);
}
#[test]
fn iterative_calculation_stops_once_changes_are_below_maximum_change() {
// A1 halves its distance to 10 each pass, from 5: 7.5, 8.75, 9.375, 9.6875, and A2 trails
// one pass behind. Maximum Change 1 stops once neither moved by 1 or more.
let mut t = T::iterative(100, 1.0);
t.set("A1", "=(A2+10)/2");
assert_eq!(t.num("A1"), 5.0);
t.set("A2", "=A1");
assert_eq!((t.num("A1"), t.num("A2")), (9.6875, 9.375));
// Maximum Iterations 2 stops first.
let mut t = T::iterative(2, 1.0);
t.set("A1", "=(A2+10)/2");
t.set("A2", "=A1");
assert_eq!((t.num("A1"), t.num("A2")), (8.75, 7.5));
}
#[test]
fn iterative_calculation_of_text_and_errors() {
let mut t = T::iterative(100, 0.001);
t.set("A1", "=IF(ISTEXT(A1),A1&\"\",\"go\")");
assert_eq!(t.get("A1"), Value::from("go"));
t.set("B1", "=B1+1/0");
assert_eq!(t.get("B1"), Value::Error(CellError::Div0));
}
#[test]
fn circular_references_without_iterative_calculation() {
let mut t = T::new();
t.set("A1", "=A1+1");
assert_eq!(t.get("A1"), Value::Error(CellError::Circ));
assert_eq!(t.calc.circular, vec![(0, c("A1"))]);
// Switched on, the loop is calculated from 0 rather than from the error.
t.wb.calc.iterative = true;
t.calc.recalc_all(&mut t.wb);
assert_eq!(t.num("A1"), 100.0);
assert!(t.calc.circular.is_empty());
t.wb.calc.iterative = false;
t.calc.recalc_all(&mut t.wb);
assert_eq!(t.get("A1"), Value::Error(CellError::Circ));
}
#[test]
fn date_text_coerces_in_the_workbook_date_system() {
for (sys, serial) in [(gridcraft_core::DateSystem::D1900, 1462.0), (gridcraft_core::DateSystem::D1904, 0.0)] {
let mut t = T::new();
t.wb.date_system = sys;
t.set("A1", "'1/1/1904");
t.set("B1", "=A1+0");
t.set("C1", "=VALUE(A1)");
t.set("D1", "=-A1");
t.set("E1", "=DATEVALUE(A1)");
t.set("F1", "=NUMBERVALUE(A1)");
assert_eq!(t.get("A1"), Value::from("1/1/1904"));
assert_eq!(t.num("B1"), serial, "{sys:?}");
assert_eq!(t.num("C1"), serial, "{sys:?}");
assert_eq!(t.num("D1"), -serial, "{sys:?}");
assert_eq!(t.num("E1"), serial, "{sys:?}");
assert_eq!(t.num("F1"), serial, "{sys:?}");
}
}
/// Lookups compare numbers exactly while `=` and the criteria functions compare them at 15
/// significant digits. Every expectation was observed in Excel 365 with these fixtures
/// (`=0.1+0.2` is 0.30000000000000004, `=100+1E-14` is 100.00000000000001).
#[test]
fn lookups_compare_numbers_exactly() {
let mut t = T::new();
for (at, input) in [
("A1", "=0.1+0.2"),
("A2", "0.3"),
("A3", "=100+1E-14"),
("A4", "100"),
("A5", "=1E15+0.5"),
("A6", "1E15"),
// Ascending 0.1, 0.3+e, 0.5 and the same descending.
("B1", "0.1"),
("B2", "=0.1+0.2"),
("B3", "0.5"),
("C1", "0.5"),
("C2", "=0.1+0.2"),
("C3", "0.1"),
// 0.3 next to 0.3+e, ascending and descending.
("D1", "0.3"),
("D2", "=0.1+0.2"),
("D3", "0.7"),
("E1", "0.7"),
("E2", "=0.1+0.2"),
("E3", "0.3"),
("G1", "=0.1+0.2"),
("H1", "x"),
("G2", "row2"),
// The bisection probes 0.3 first and must not run on into 0.3+e.
("J1", "0.1"),
("J2", "0.2"),
("J3", "0.3"),
("J4", "=0.1+0.2"),
("J5", "0.4"),
("K1", "a"),
("K2", "b"),
("K3", "c"),
("K4", "d"),
("K5", "e"),
] {
t.set(at, input);
}
let e = 0.1 + 0.2;
let n = Value::Number;
let s = |x: &str| Value::from(x);
let na = Value::Error(CellError::NA);
let cases: Vec<(&str, Value)> = vec![
// `=` and friends stay at 15 digits.
("=A1=A2", Value::Bool(true)),
("=A3=A4", Value::Bool(true)),
("=A5=A6", Value::Bool(true)),
("=SWITCH(A1,0.3,\"hit\",\"miss\")", s("hit")),
// Exact match.
("=MATCH(A1,A2:A2,0)", na.clone()),
("=MATCH(A2,A1:A1,0)", na.clone()),
("=VLOOKUP(A1,A2:A2,1,FALSE)", na.clone()),
("=HLOOKUP(A1,A2,1,FALSE)", na.clone()),
("=HLOOKUP(0.3,G1:H2,2,FALSE)", na.clone()),
("=XLOOKUP(A1,A2:A2,A2:A2,\"nf\")", s("nf")),
("=XMATCH(A1,A2:A2)", na.clone()),
("=MATCH(0.1+0.2,{0.3},0)", na.clone()),
("=MATCH(0.3,A1:A2,0)", n(2.0)),
("=MATCH(A1,A1:A2,0)", n(1.0)),
("=MATCH(0.3,INDEX(A1:A2,0),0)", n(2.0)),
("=MATCH(100,A3:A3,0)", na.clone()),
("=MATCH(1E15,A5:A5,0)", na.clone()),
("=XLOOKUP(0.3,A1:A2,{1;2},\"nf\",0,-1)", n(2.0)),
("=XMATCH(A1,A1:A2,0,-1)", n(1.0)),
// Approximate match against a single entry.
("=MATCH(0.3,A1:A1,1)", na.clone()),
("=MATCH(0.3,A1:A1,-1)", n(1.0)),
("=LOOKUP(0.3,A1:A1)", na.clone()),
("=LOOKUP(0.3,A1:A1,G2)", na.clone()),
("=VLOOKUP(0.3,A1:A1,1,TRUE)", na.clone()),
("=HLOOKUP(0.3,G1:H2,2,TRUE)", na.clone()),
("=XLOOKUP(0.3,A1:A1,A1:A1,\"nf\",-1)", s("nf")),
("=XLOOKUP(0.3,A1:A1,A1:A1,\"nf\",1)", n(e)),
("=XMATCH(0.3,A1:A1,-1)", na.clone()),
("=XMATCH(0.3,A1:A1,1)", n(1.0)),
("=MATCH(100,A3:A3,1)", na.clone()),
("=MATCH(A3,A4:A4,1)", n(1.0)),
// Approximate match in sorted lists.
("=MATCH(0.3,B1:B3,1)", n(1.0)),
("=MATCH(A1,B1:B3,1)", n(2.0)),
("=MATCH(0.3,C1:C3,-1)", n(2.0)),
("=MATCH(0.3,D1:D3,1)", n(1.0)),
("=MATCH(A1,D1:D3,1)", n(2.0)),
("=MATCH(0.3,E1:E3,-1)", n(3.0)),
("=MATCH(A1,E1:E3,-1)", n(2.0)),
("=MATCH(0.3,J1:J5,1)", n(3.0)),
("=MATCH(A1,J1:J5,1)", n(4.0)),
("=MATCH(0.3,J1:J5,0)", n(3.0)),
("=MATCH(A1,J1:J5,0)", n(4.0)),
("=VLOOKUP(0.3,J1:K5,2,TRUE)", s("c")),
("=VLOOKUP(A1,J1:K5,2,TRUE)", s("d")),
("=LOOKUP(0.3,J1:J5,K1:K5)", s("c")),
("=LOOKUP(A1,J1:J5,K1:K5)", s("d")),
// XLOOKUP / XMATCH match and search modes.
("=XLOOKUP(0.3,B1:B3,B1:B3,\"nf\",0,2)", s("nf")),
("=XLOOKUP(0.3,B1:B3,B1:B3,\"nf\",-1,2)", n(0.1)),
("=XLOOKUP(0.3,B1:B3,B1:B3,\"nf\",1,2)", n(e)),
("=XMATCH(0.3,B1:B3,-1,2)", n(1.0)),
("=XMATCH(0.3,B1:B3,1,2)", n(2.0)),
("=XMATCH(0.3,C1:C3,1,-2)", n(2.0)),
("=XMATCH(0.3,C1:C3,-1,-2)", n(3.0)),
("=XMATCH(0.3,C1:C3,0,-2)", na.clone()),
("=XMATCH(0.3,J1:J5,0,2)", n(3.0)),
("=XMATCH(A1,J1:J5,0,2)", n(4.0)),
("=XMATCH(0.3,J1:J5,1,2)", n(3.0)),
("=XMATCH(0.3,J1:J5,-1)", n(3.0)),
("=XMATCH(0.3,J1:J5,1)", n(3.0)),
("=XMATCH(A1,J1:J5,-1)", n(4.0)),
("=XMATCH(0.3,B1:B3,1)", n(2.0)),
("=XMATCH(0.3,B1:B3,-1)", n(1.0)),
("=XMATCH(0.3,B1:B3,0,-1)", na.clone()),
("=XMATCH(0.3,D1:D3,1,-1)", n(1.0)),
// The criteria functions keep 15 digits.
("=COUNTIF(A1:A1,0.3)", n(1.0)),
("=COUNTIF(A1:A1,\"<0.3\")", n(0.0)),
("=COUNTIF(A1:A1,\">0.3\")", n(0.0)),
("=COUNTIF(A2:A2,A1)", n(1.0)),
("=COUNTIFS(A1:A1,0.3)", n(1.0)),
("=SUMIF(A1:A1,0.3)", n(e)),
("=SUMIFS(A1:A1,A1:A1,0.3)", n(e)),
("=AVERAGEIF(A1:A1,0.3)", n(e)),
("=MAXIFS(A1:A1,A1:A1,0.3)", n(e)),
("=COUNTIF(A3:A3,\">100\")", n(0.0)),
("=COUNTIF(J1:J5,0.3)", n(2.0)),
("=ROWS(FILTER(A1:A2,A1:A2=0.3))", n(2.0)),
("=MATCH(TRUE,A1:A2=0.3,0)", n(1.0)),
];
let mut wrong = Vec::new();
for (formula, expected) in cases {
let got = crate::evaluate(&t.wb, 0, c("Z1"), formula);
if got != expected {
wrong.push(format!("{formula}: got {got:?}, Excel {expected:?}"));
}
}
assert!(wrong.is_empty(), "{}", wrong.join("\n"));
}
#[test]
fn lambda_helpers_check_shapes_and_parameters() {
let mut t = T::new();
let calc = Value::Error(CellError::Calc);
// A LAMBDA returning several values can't fill one cell: the whole result is #CALC!.
t.set("A1", "=MAP({1,2},LAMBDA(x,{1,2}))");
assert_eq!((t.get("A1"), t.get("B1")), (calc.clone(), Value::Empty));
t.set("A2", "=BYROW({1,2;3,4},LAMBDA(r,r))");
assert_eq!((t.get("A2"), t.get("A3")), (calc.clone(), Value::Empty));
t.set("A4", "=MAKEARRAY(1,2,LAMBDA(r,c,{1,2}))");
assert_eq!(t.get("A4"), calc.clone());
t.set("D1", "=SCAN(0,{1,2},LAMBDA(a,b,{1,2}))");
assert_eq!((t.get("D1"), t.get("E1")), (calc, Value::Empty));
// One parameter per array (MAP) or exactly one (BYROW/BYCOL).
t.set("A5", "=MAP({1,2},LAMBDA(x,y,x))");
assert_eq!(t.get("A5"), Value::Error(CellError::Value));
t.set("A6", "=BYCOL({1,2},LAMBDA(c,d,SUM(c)))");
assert_eq!(t.get("A6"), Value::Error(CellError::Value));
// A single value in an array is fine.
t.set("A7", "=MAP({1,2},LAMBDA(x,SEQUENCE(1)*x))");
assert_eq!(t.num("B7"), 2.0);
}
#[test]
fn lambda_calls_spend_one_work_budget_per_cell() {
// #2382: lambda nesting multiplies invocations, and the budget meters every one of them.
// Exhaustion itself is tested on a mock host in `eval::fuel_tests` (burning the real 16M
// budget in a full recalc needs tens of millions of calls, far too slow for a unit test);
// here the same nested shape runs inside it: 6 × 9 ≈ 59k scalar REDUCE invocations.
let mut t = T::new();
t.set(
"A1",
"=LET(s,SEQUENCE(9),REDUCE(0,s,LAMBDA(a,v,REDUCE(0,s,LAMBDA(a,v,REDUCE(0,s,LAMBDA(a,v,REDUCE(0,s,LAMBDA(a,v,REDUCE(0,s,LAMBDA(a,v,a+v)))))))))))",
);
// The bodies are the same inner reduce, so every level sums to 45 while the invocation
// count multiplies by 9 per level (9, 81, 729, 6561, 59049 calls).
assert_eq!(t.num("A1"), 45.0);
t.set("A2", "=REDUCE(0,SEQUENCE(4000),LAMBDA(a,v,a+v))");
assert_eq!(t.num("A2"), 4000.0 * 4001.0 / 2.0);
}
#[test]
fn accounting_negative_text_coerces() {
let mut t = T::new();
t.set("A1", "'$(1,234)");
t.set("B1", "=A1+0");
t.set("C1", "=VALUE(A1)");
t.set("D1", "=--\"$ (5)\"");
t.set("E1", "=\"(5)%\"*100");
t.set("F1", "=\"(-5)\"+0");
t.set("G1", "$(1,234)");
assert_eq!((t.num("B1"), t.num("C1"), t.num("D1"), t.num("E1")), (-1234.0, -1234.0, -5.0, -5.0));
assert_eq!(t.get("F1"), Value::Error(CellError::Value));
assert_eq!(t.num("G1"), -1234.0);
}
#[test]
fn operators() {
let mut t = T::new();
t.set("A1", "=\"a\"&1&TRUE");
assert_eq!(t.get("A1"), Value::from("a1TRUE"));
t.set("A2", "=-2^2");
assert_eq!(t.num("A2"), 4.0);
t.set("A3", "=50%");
assert_eq!(t.num("A3"), 0.5);
t.set("A4", "=\"abc\"=\"ABC\"");
assert_eq!(t.get("A4"), Value::Bool(true));
t.set("A5", "=\"5\"+1");
assert_eq!(t.num("A5"), 6.0);
t.set("A6", "=\"x\"+1");
assert_eq!(t.get("A6"), Value::Error(CellError::Value));
t.set("A7", "=B99");
assert_eq!(t.num("A7"), 0.0);
}
#[test]
fn blocked_spill_reads_as_spill_error() {
// Calculated in the same pass, a formula over a blocked spill read the array that couldn't
// spill.
let mut t = T::new();
let s = t.wb.sheet_mut(0).unwrap();
for a in ["A1", "B1", "C1", "A2", "C2", "A3", "B3", "C3"] {
s.cells.set(c(a), Cell::value(Value::Number(1.0)));
}
s.cells.set(c("B2"), Cell::formula(Formula::new("=SEQUENCE(2,2,9)")));
s.cells.set(c("E1"), Cell::formula(Formula::new("=SUM(A1:C3)")));
t.calc.recalc_all(&mut t.wb);
assert_eq!(t.get("B2"), Value::Error(CellError::Spill));
assert_eq!(t.get("E1"), Value::Error(CellError::Spill));
assert_eq!(t.num("C3"), 1.0);
}
#[test]
fn blocked_spill_read_through_offset() {
// The reader's reference is only known while evaluating, so it isn't in the dependency graph.
let mut t = T::new();
t.set("E4", "5");
t.set("E8", "=SUM(OFFSET(E2,0,0,2,2))");
t.set("E3", "=SEQUENCE(2,2,3)");
t.calc.recalc_all(&mut t.wb);
assert_eq!(t.get("E3"), Value::Error(CellError::Spill));
assert_eq!(t.get("E8"), Value::Error(CellError::Spill));
}
#[test]
fn spill_into_the_old_area_of_a_blocked_neighbour() {
// C7's array becomes blocked by C8, whose array then takes over C7's old area.
let mut t = T::new();
t.set("C7", "=SEQUENCE(2,2)");
t.set("C8", "=SEQUENCE(2,2)");
assert_eq!(t.get("C7"), Value::Error(CellError::Spill));
assert_eq!([t.num("C8"), t.num("D8"), t.num("C9"), t.num("D9")], [1.0, 2.0, 3.0, 4.0]);
}
#[test]
fn formulas_reading_an_array_that_loses_a_spill_conflict_read_spill_errors() {
// Two arrays of one recalculation want the same empty cell: the one laid out second is
// #SPILL!, and the formulas that read it in that pass (at its anchor, through `A2#`, or over
// the cells it would have spilled into) kept its values.
let mut t = T::new();
t.set("B1", "=SEQUENCE(3)");
t.set("A2", "=SEQUENCE(1,3)");
t.set("D2", "=A2*10");
t.set("E2", "=C2");
t.set("F2", "=SUM(A2#)");
t.set("G2", "=SUM(C2:C3)");
// Both arrays recalculated together: B1 is laid out first and takes B2.
t.calc.recalc_all(&mut t.wb);
let spill = Value::Error(CellError::Spill);
assert_eq!((t.num("B2"), t.get("A2"), t.get("D2")), (2.0, spill.clone(), spill));
assert_eq!((t.get("C2"), t.num("E2"), t.get("F2"), t.num("G2")), (Value::Empty, 0.0, Value::Error(CellError::Ref), 0.0));
}
#[test]
fn arrays_do_not_spill_in_tables() {
// Excel: spilled array formulas aren't supported in tables.
let mut t = T::new();
t.wb.sheet_mut(0).unwrap().tables.push(gridcraft_model::Table {
id: 1,
name: "Table1".into(),
range: gridcraft_core::RangeRef::parse("B3:C6").unwrap(),
header_row: true,
totals_row: false,
columns: vec![],
style: String::new(),
banded_rows: false,
banded_cols: false,
first_col: false,
last_col: false,
filter_button: false,
filter: vec![],
table_type: None,
connection_id: None,
});
t.set("C4", "=SEQUENCE(2)");
t.set("E1", "=SEQUENCE(4)");
t.set("H1", "=SUM(C4:C5)");
assert_eq!(t.get("C4"), Value::Error(CellError::Spill));
assert_eq!(t.get("C5"), Value::Empty);
assert_eq!(t.get("H1"), Value::Error(CellError::Spill));
assert_eq!(t.num("E4"), 4.0);
}
#[test]
fn ifs_and_lazy() {
let mut t = T::new();
t.set("A1", "5");
t.set("B1", "=IF(A1>3,\"big\",1/0)");
assert_eq!(t.get("B1"), Value::from("big"));
t.set("B2", "=IFERROR(1/0,\"oops\")");
assert_eq!(t.get("B2"), Value::from("oops"));
t.set("B3", "=CHOOSE(2,\"a\",\"b\",\"c\")");
assert_eq!(t.get("B3"), Value::from("b"));
t.set("B4", "=SWITCH(A1,1,\"one\",5,\"five\",\"other\")");
assert_eq!(t.get("B4"), Value::from("five"));
}
#[test]
fn choose_with_an_array_index() {
let mut t = T::new();
for (i, (k, v)) in [(1, 10), (2, 20), (3, 30)].iter().enumerate() {
t.set(&format!("A{}", i + 1), &k.to_string());
t.set(&format!("B{}", i + 1), &v.to_string());
}
t.set("D1", "=CHOOSE({1,2},\"a\",\"b\")");
assert_eq!(t.get("D1"), Value::from("a"));
assert_eq!(t.get("E1"), Value::from("b"));
// The usual "look left" idiom: a two-column array from two ranges.
t.set("D3", "=VLOOKUP(20,CHOOSE({1,2},B1:B3,A1:A3),2,FALSE)");
assert_eq!(t.num("D3"), 2.0);
t.set("D4", "=SUM(CHOOSE({1,2},A1:A3,B1:B3))");
assert_eq!(t.num("D4"), 66.0);
t.set("D5", "=CHOOSE(2,A1:A3,B1:B3)");
assert_eq!(t.num("D7"), 30.0);
}
#[test]
fn references_functions() {
let mut t = T::new();
for (i, v) in [10, 20, 30, 40].iter().enumerate() {
t.set(&format!("A{}", i + 1), &v.to_string());
}
t.set("B1", "=ROW(A3)");
assert_eq!(t.num("B1"), 3.0);
t.set("B2", "=ROWS(A1:A4)*COLUMNS(A1:C1)");
assert_eq!(t.num("B2"), 12.0);
t.set("B3", "=SUM(OFFSET(A1,1,0,2,1))");
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)");
assert_eq!(t.num("B6"), 100.0);
t.set("A2", "25");
assert_eq!(t.num("B3"), 55.0);
assert_eq!(t.num("B6"), 105.0);
}
#[test]
fn indirect_r1c1_out_of_bounds() {
for reference in ["R[9223372036854775807]C", "RC[9223372036854775807]", "R[4294967296]C", "R[4294967291]C", "R[2000000]C", "RC[20000]"] {
let mut t = T::new();
t.set("D5", &format!("=ROW(INDIRECT(\"{reference}\",FALSE))"));
assert_eq!(t.get("D5"), Value::Error(CellError::Ref), "{reference}");
}
}
#[test]
fn indirect_r1c1_past_the_sheet_does_not_fall_back_to_a1() {
let mut t = T::new();
t.set("RC16385", "78");
for reference in ["RC16385", "rc16385", "RC1:RC16385"] {
t.set("D5", &format!("=INDIRECT(\"{reference}\",FALSE)"));
assert_eq!(t.get("D5"), Value::Error(CellError::Ref), "{reference}");
}
}
#[test]
fn indirect_r1c1_valid_references() {
let mut t = T::new();
t.set("E6", "11");
t.set("A1", "22");
t.set("D4", "33");
for (reference, expected) in [("R[1]C[1]", 11.0), ("R1C1", 22.0), ("R[-1]C", 33.0)] {
t.set("D5", &format!("=INDIRECT(\"{reference}\",FALSE)"));
assert_eq!(t.num("D5"), expected, "{reference}");
}
}
#[test]
fn relative_names() {
let mut t = T::new();
// As files store them: relative to A1. `Left` is the cell to the left, `Above3` the three
// cells above, `Mixed` column A of the same row.
for (name, formula) in [("Left", "Sheet1!XFD1"), ("Above3", "Sheet1!A1048574:A1048576"), ("Mixed", "Sheet1!$A1")] {
t.wb.names.push(gridcraft_model::DefinedName {
name: name.into(),
scope: None,
formula: formula.into(),
comment: String::new(),
hidden: false,
});
}
t.calc.rebuild(&t.wb);
for r in 1..=5 {
t.set(&format!("A{r}"), &(r * 10).to_string());
}
t.set("B2", "=Left");
assert_eq!(t.num("B2"), 20.0);
t.set("B4", "=Left+Mixed");
assert_eq!(t.num("B4"), 80.0);
t.set("A6", "=SUM(Above3)");
assert_eq!(t.num("A6"), 120.0);
}
#[test]
fn spills() {
let mut t = T::new();
t.set("A1", "=SEQUENCE(3)");
assert_eq!(t.num("A1"), 1.0);
assert_eq!(t.num("A3"), 3.0);
t.set("B1", "=SUM(A1:A3)");
assert_eq!(t.num("B1"), 6.0);
t.set("A2", "x");
assert_eq!(t.get("A1"), Value::Error(CellError::Spill));
t.set("A2", "");
assert_eq!(t.num("A3"), 3.0);
t.set("C1", "={1,2;3,4}*10");
assert_eq!(t.num("D2"), 40.0);
}
#[test]
fn an_anchor_given_a_formula_that_reads_its_old_spill_area_sees_it_empty() {
// The new formula was evaluated while the old array was still laid out, and only the
// anchor's dependents were evaluated again once it was gone.
let mut t = T::new();
t.set("A1", "=SEQUENCE(3)");
assert_eq!(t.num("A3"), 3.0);
t.set("A1", "=SUM(A2:A3)+1");
assert_eq!((t.num("A1"), t.get("A3")), (1.0, Value::Empty));
// Still an array, now spilling elsewhere: it read A2 as 2 and spilled three columns.
t.set("A1", "=SEQUENCE(3)");
t.set("A1", "=SEQUENCE(1,A2+1)");
assert_eq!((t.num("A1"), t.get("B1"), t.get("A2")), (1.0, Value::Empty, Value::Empty));
// A smaller array inside the old area: it read A3 as 3 and spilled over A1:A2.
t.set("A1", "=SEQUENCE(3)");
t.set("A1", "=SEQUENCE(MAX(1,A3-1))");
assert_eq!((t.num("A1"), t.get("A2"), t.get("A3")), (1.0, Value::Empty, Value::Empty));
}
#[test]
fn references_keep_their_size_past_the_used_range() {
let mut t = T::new();
t.set("A1", "5");
t.set("A2", "6");
t.set("C1", "=ROWS(A1:A10*1)");
t.set("C2", "=SUMPRODUCT(--(A1:A10=\"\"))");
t.set("C3", "=COUNTBLANK(A1:A10)");
t.set("C4", "=COUNTIF(A1:A10,\"\")");
t.set("C5", "=COLUMNS(TRANSPOSE(A1:A10))");
for (at, want) in [("C1", 10.0), ("C2", 8.0), ("C3", 8.0), ("C4", 8.0), ("C5", 10.0)] {
assert_eq!(t.num(at), want, "{at}");
}
// Whole columns are still read up to the used range.
t.set("D1", "=ROWS(FILTER(A:A,A:A<>\"\"))");
assert_eq!(t.num("D1"), 2.0);
}
#[test]
fn spilled_blank_cells_show_zero() {
let mut t = T::new();
t.set("A1", "5");
t.set("A3", "7");
t.set("C1", "=A1:A4");
assert_eq!(t.get("C2"), Value::Number(0.0));
assert_eq!(t.get("C4"), Value::Number(0.0));
t.set("D1", "=COUNT(C1:C4)");
assert_eq!(t.num("D1"), 4.0);
t.set("E1", "=A2:A3");
assert_eq!(t.get("E1"), Value::Number(0.0));
t.set("F1", "=SORT(A1:A3)");
assert_eq!(t.get("F3"), Value::Number(0.0));
}
#[test]
fn let_lambda() {
let mut t = T::new();
t.set("A1", "=LET(x,2,y,3,x*y)");
assert_eq!(t.num("A1"), 6.0);
t.set("A2", "=LAMBDA(a,b,a+b)(2,3)");
assert_eq!(t.num("A2"), 5.0);
t.set("A3", "=REDUCE(0,{1,2,3},LAMBDA(acc,v,acc+v))");
assert_eq!(t.num("A3"), 6.0);
t.set("A4", "=SUM(MAP({1,2,3},LAMBDA(v,v*v)))");
assert_eq!(t.num("A4"), 14.0);
}
#[test]
fn let_lambda_names_ignore_case_in_any_script() {
let mut t = T::new();
t.set("A1", "=LET(dług,100,DŁUG*2)");
assert_eq!(t.num("A1"), 200.0);
t.set("A2", "=LAMBDA(λ,ISOMITTED(Λ))()");
assert_eq!(t.get("A2"), Value::Bool(true));
t.set("A3", "=LAMBDA(ćma,ĆMA+1)(1)");
assert_eq!(t.num("A3"), 2.0);
t.set("A4", "=LET(ŻAR,LAMBDA(x,x*3),żar(2))");
assert_eq!(t.num("A4"), 6.0);
t.set("A5", "=LET(λ,LAMBDA(x,x+1),SUM(MAP({1,2},Λ)))");
assert_eq!(t.num("A5"), 5.0);
// `ß` upper-cases to `SS`, yet `ẞ` is its capital.
t.set("A6", "=LET(ß,100,ẞ*2)");
assert_eq!(t.num("A6"), 200.0);
// Dotless ı is a different name from i.
t.set("A7", "=LET(i,1,ı,2,i)");
assert_eq!(t.num("A7"), 1.0);
}
#[test]
fn functions_given_by_name() {
let mut t = T::new();
for (i, (k, v)) in [("a", 1), ("b", -2), ("a", 3)].iter().enumerate() {
t.set(&format!("A{}", i + 1), k);
t.set(&format!("B{}", i + 1), &v.to_string());
}
t.set("D1", "=BYROW(B1:B3,SUM)");
assert_eq!(t.num("D2"), -2.0);
t.set("E1", "=MAP(B1:B3,ABS)");
assert_eq!(t.num("E2"), 2.0);
t.set("F1", "=REDUCE(0,B1:B3,SUM)");
assert_eq!(t.num("F1"), 2.0);
t.set("G1", "=GROUPBY(A1:A3,B1:B3,SUM,0,0)");
assert_eq!(t.get("G1"), Value::from("a"));
assert_eq!(t.num("H1"), 4.0);
// A LET variable of that name is still the variable.
t.set("J1", "=LET(ABS,LAMBDA(x,x*10),SUM(MAP(B1:B3,ABS)))");
assert_eq!(t.num("J1"), 20.0);
}
#[test]
fn groupby_and_pivotby_take_a_lambda() {
let mut t = T::new();
for (i, (k, c, v)) in [("a", "x", 1), ("b", "x", 2), ("a", "y", 3)].iter().enumerate() {
t.set(&format!("A{}", i + 1), k);
t.set(&format!("B{}", i + 1), c);
t.set(&format!("C{}", i + 1), &v.to_string());
}
t.set("E1", "=GROUPBY(A1:A3,C1:C3,LAMBDA(x,SUM(x)))");
assert_eq!(t.get("E1"), Value::from("a"));
assert_eq!(t.num("F1"), 4.0);
assert_eq!(t.get("E2"), Value::from("b"));
assert_eq!(t.num("F2"), 2.0);
assert_eq!(t.get("E3"), Value::from("Total"));
assert_eq!(t.num("F3"), 6.0);
// A LET-bound lambda, called once per group with that group's values.
t.set("H1", "=LET(f,LAMBDA(v,ROWS(v)*10),GROUPBY(A1:A3,C1:C3,f))");
assert_eq!(t.num("I1"), 20.0);
assert_eq!(t.num("I3"), 30.0);
// PIVOTBY: rows a, b, Total; columns x, y, Total.
t.set("K1", "=PIVOTBY(A1:A3,B1:B3,C1:C3,LAMBDA(x,SUM(x)))");
assert_eq!(t.get("L1"), Value::from("x"));
assert_eq!(t.num("L2"), 1.0);
assert_eq!(t.num("M2"), 3.0);
assert_eq!(t.num("N4"), 6.0);
// Neither a function nor a lambda.
t.set("P1", "=GROUPBY(A1:A3,C1:C3,TRUE)");
assert_eq!(t.get("P1"), Value::Error(CellError::Value));
}
#[test]
fn lambda_argument_count() {
let mut t = T::new();
t.wb.names.push(gridcraft_model::DefinedName {
name: "AddPair".into(),
scope: None,
formula: "LAMBDA(a,b,a+b)".into(),
comment: String::new(),
hidden: false,
});
t.calc.rebuild(&t.wb);
let omit = "LAMBDA(x,y,IF(ISOMITTED(y),\"Missing\",x+y))";
t.set("A1", "=LAMBDA(a,b,a+b)(2)");
t.set("A2", "=LAMBDA(a,b,a+b)(2,3,4)");
t.set("A3", &format!("={omit}(1,)"));
t.set("A4", &format!("={omit}(1)"));
t.set("A5", &format!("={omit}(1,2)"));
t.set("A6", "=LAMBDA(7)()");
t.set("A7", "=AddPair(2)");
t.set("A8", "=AddPair(2,3)");
assert_eq!(t.get("A1"), Value::Error(CellError::Value));
assert_eq!(t.get("A2"), Value::Error(CellError::Value));
assert_eq!(t.get("A3"), Value::Text("Missing".into()));
assert_eq!(t.get("A4"), Value::Error(CellError::Value));
assert_eq!(t.num("A5"), 3.0);
assert_eq!(t.num("A6"), 7.0);
assert_eq!(t.get("A7"), Value::Error(CellError::Value));
assert_eq!(t.num("A8"), 5.0);
t.set("A9", "=LAMBDA(x,ISOMITTED(x))()");
assert_eq!(t.get("A9"), Value::Bool(true));
}
#[test]
fn names_and_sheets() {
let mut t = T::new();
t.wb.names.push(gridcraft_model::DefinedName { name: "Rate".into(), scope: None, formula: "0.5".into(), comment: String::new(), hidden: false });
t.wb.sheets.push(std::sync::Arc::new(gridcraft_model::Sheet::new("Data")));
t.wb.sheet_mut(1).unwrap().set_value(c("A1"), Value::Number(8.0));
t.calc.rebuild(&t.wb);
t.set("A1", "=Rate*Data!A1");
assert_eq!(t.num("A1"), 4.0);
t.wb.sheet_mut(1).unwrap().set_value(c("A1"), Value::Number(10.0));
t.calc.cells_changed(&mut t.wb, &[(1, c("A1"))]);
assert_eq!(t.num("A1"), 5.0);
}
#[test]
fn name_referring_to_several_ranges() {
let mut t = T::new();
// As Excel writes it in `<definedName>`: a union with no parentheses around it.
let union = "Sheet1!$A$1:$A$2,Sheet1!$C$1:$C$2";
t.wb.names.push(gridcraft_model::DefinedName { name: "u".into(), scope: None, formula: union.into(), comment: String::new(), hidden: false });
for (at, v) in [("A1", "1"), ("A2", "2"), ("C1", "5"), ("C2", "7")] {
t.set(at, v);
}
t.set("E1", "=SUM(u)");
t.set("E2", "=AREAS(u)");
t.set("E3", "=MIN(u)");
t.set("E4", "=INDEX(u,2,1,2)");
assert_eq!((t.num("E1"), t.num("E2"), t.num("E3"), t.num("E4")), (15.0, 2.0, 1.0, 7.0));
t.set("C2", "70");
assert_eq!(t.num("E1"), 78.0);
}
#[test]
fn legacy_array_formulas_fill_their_range() {
let mut t = T::new();
t.set("A1", "1");
t.set("A2", "2");
let cse = |t: &mut T, at: &str, formula: &str, range: &str| {
let mut f = Formula::new(formula);
f.array = gridcraft_core::RangeRef::parse(range);
t.wb.sheet_mut(0).unwrap().cells.set(c(at), Cell::formula(f));
t.calc.cells_changed(&mut t.wb, &[(0, c(at))]);
};
// Past the result: #N/A. A single column repeats across the range.
cse(&mut t, "C1", "=A1:A2*2", "C1:D3");
cse(&mut t, "F1", "=A1:A2*2", "F1");
cse(&mut t, "G1", "=5", "G1:G2");
assert_eq!(t.num("C2"), 4.0);
assert_eq!(t.num("D2"), 4.0);
assert_eq!(t.get("C3"), Value::Error(CellError::NA));
// A smaller range shows only part of the result and never spills.
assert_eq!(t.num("F1"), 2.0);
assert_eq!(t.get("F2"), Value::Empty);
assert_eq!(t.num("G2"), 5.0);
// A range too large to fill is an error, not an allocation of every cell.
cse(&mut t, "H1", "=1", "H1:XFD1048576");
assert_eq!(t.get("H1"), Value::Error(CellError::Num));
}
#[test]
fn array_broadcast_result_is_capped() {
// A full column against a full row broadcasts to 1048576 x 16384 (17 billion cells, about
// 400 GB): each operand is within its own cap, so only the product check stops the abort.
let mut t = T::new();
let col = "SEQUENCE(1048576)";
let row = "SEQUENCE(1,16384)";
for (at, formula) in [
("A1", format!("=ROWS({col}*{row})")),
("A2", format!("=ROWS({col}&{row})")),
("A3", format!("=ROWS({col}>{row})")),
("A4", format!("=ROWS(IF({col},{row}))")),
("A5", format!("=ROWS(IF({col}>0,1,{row}))")),
("A6", format!("=ROWS(TEXT({col},{row}))")),
("A7", format!("=ROWS(MAP({col},{row},LAMBDA(a,b,a)))")),
] {
t.set(at, &formula);
assert_eq!(t.get(at), Value::Error(CellError::Num), "{formula}");
}
// Broadcasts within the cap are unchanged.
t.set("C1", "=SUM(SEQUENCE(3)*SEQUENCE(1,4))");
assert_eq!(t.num("C1"), 60.0);
t.set("C2", "=SUM(IF(SEQUENCE(2)>1,SEQUENCE(1,3),0))");
assert_eq!(t.num("C2"), 6.0);
t.set("C3", "=COLUMNS(TEXT(SEQUENCE(2),SEQUENCE(1,5)))");
assert_eq!(t.num("C3"), 5.0);
}
#[test]
fn text_function() {
let mut t = T::new();
t.set("A1", "=TEXT(1234.5,\"#,##0.00\")");
assert_eq!(t.get("A1"), Value::from("1,234.50"));
}
#[test]
fn long_operator_chains() {
let mut t = T::new();
t.set("A1", "1");
// 1,000 additions: a formula of 3,000 characters, well within Excel's 8,192.
t.set("B1", &format!("={}", vec!["A1"; 1000].join("+")));
assert_eq!(t.num("B1"), 1000.0);
t.set("B2", &format!("=5{}", "+1-1".repeat(600)));
assert_eq!(t.num("B2"), 5.0);
}
#[test]
fn flat_chains_as_long_as_excel_allows() {
// Values observed in Excel (#916): a flat chain has no operand limit below the 8,192-character
// formula limit, which `=1+1+…` with 4,096 operands meets exactly. On a 1 MiB stack, as on
// the Windows main thread.
std::thread::Builder::new()
.stack_size(1 << 20)
.spawn(|| {
let chain = |op: &str, item: &str, n: usize| format!("={}", vec![item; n].join(op));
let mut t = T::new();
for r in 1..=1500 {
t.set(&format!("A{r}"), "1");
}
t.set("B1", &chain("+", "1", 401));
t.set("B2", &chain("+", "1", 4096));
t.set("B3", &chain("-", "1", 2000));
t.set("B4", &chain("*", "1", 4096));
t.set("B5", &chain("/", "2", 4096));
t.set("B6", &chain("^", "1", 4096));
t.set("B7", &chain("+", "1%", 2000));
t.set("B8", "=1=1=1");
t.set("B9", &format!("={}", (1..=1500).map(|r| format!("A{r}")).collect::<Vec<_>>().join("+")));
t.set("B10", &chain("&", "1", 2000));
for (at, want) in [("B1", 401.0), ("B2", 4096.0), ("B3", -1998.0), ("B4", 1.0), ("B5", 0.0), ("B6", 1.0), ("B9", 1500.0)] {
assert_eq!(t.num(at), want, "{at}");
}
assert!((t.num("B7") - 20.0).abs() < 1e-9);
assert_eq!(t.get("B8"), Value::Bool(false));
assert_eq!(t.get("B10"), Value::text("1".repeat(2000)));
})
.expect("spawn")
.join()
.expect("long chains must not overflow the stack");
}
#[test]
fn subtotal_skips_hidden() {
let mut t = T::new();
t.set("A1", "1");
t.set("A2", "2");
t.set("A3", "3");
t.wb.sheet_mut(0).unwrap().rows.insert(1, gridcraft_model::LineInfo { hidden: true, ..Default::default() });
t.set("B1", "=SUBTOTAL(109,A1:A3)");
t.set("B2", "=SUBTOTAL(9,A1:A3)");
assert_eq!(t.num("B1"), 4.0);
assert_eq!(t.num("B2"), 6.0);
}
#[test]
fn subtotal_109_skips_nested_subtotal() {
let mut t = T::new();
t.set("A1", "2");
t.set("A2", "=SUBTOTAL(9,A1)");
t.set("C1", "=SUBTOTAL(109,A1:A2)");
t.set("C2", "=SUBTOTAL(9,A1:A2)");
t.set("C3", "=SUBTOTAL(109,A1)");
assert_eq!(t.num("C1"), 2.0);
assert_eq!(t.num("C2"), 2.0);
assert_eq!(t.num("C3"), 2.0);
// Manually hidden rows are still left out by 109 only.
let mut t = T::new();
t.set("A1", "1");
t.set("A2", "2");
t.set("A3", "=SUBTOTAL(9,A1:A2)");
t.wb.sheet_mut(0).unwrap().rows.insert(1, gridcraft_model::LineInfo { hidden: true, ..Default::default() });
t.set("B1", "=SUBTOTAL(109,A1:A3)");
t.set("B2", "=SUBTOTAL(9,A1:A3)");
assert_eq!(t.num("B1"), 1.0);
assert_eq!(t.num("B2"), 3.0);
}
/// One value stored in the sheet's last cell makes `A:XFD` trim to ~17 billion cells. SUBTOTAL
/// and AGGREGATE used to read every one of them into a vector (an abort on open, #2175); now
/// every argument is charged against `MAX_CELLS` before any cell is read, and `#NUM!` comes back.
#[test]
fn subtotal_and_aggregate_cap_cells_visited() {
let mut t = T::new();
t.set("XFD1048576", "1");
let formulas = [
"=SUBTOTAL(9,A:XFD)",
"=SUBTOTAL(109,1:1048576)",
"=AGGREGATE(9,6,A:XFD)",
"=AGGREGATE(14,7,A:XFD,1)",
// A:J and K:T are about 10.5 million cells each: under the limit alone, over it together,
// so the second area is refused before the first is read.
"=SUBTOTAL(9,A:J,K:T)",
"=AGGREGATE(9,4,(A:J,K:T))",
];
for (i, f) in formulas.iter().enumerate() {
let at = format!("B{}", i + 1);
t.set(&at, f);
assert_eq!(t.get(&at), Value::Error(CellError::Num), "{f}");
}
// Whole columns and rows still clip to the used range when it is small.
let mut t = T::new();
t.set("A1", "1");
t.set("B2", "2");
t.set("C2", "#N/A");
t.set("E5", "=SUBTOTAL(9,A:B)");
t.set("E6", "=AGGREGATE(9,6,1:2)");
t.set("E7", "=AGGREGATE(14,6,A:C,1)");
assert_eq!((t.num("E5"), t.num("E6"), t.num("E7")), (3.0, 3.0, 2.0));
}
/// SUBTOTAL leaves out rows a filter hides for every function number, while 1-11 keep rows
/// hidden by hand (as in ConditionalFormattingSamples.xlsx, where `SUBTOTAL(9,Table69[Sales])`
/// under a filtered table adds only the visible rows).
#[test]
fn subtotal_skips_filtered_rows() {
use gridcraft_model::{AutoFilter, FilterCriterion, LineInfo};
let mut t = T::new();
for (i, v) in [1, 2, 4, 8, 16, 32].iter().enumerate() {
t.set(&format!("A{}", i + 2), &v.to_string());
t.set(&format!("C{}", i + 2), &v.to_string());
}
let crit = || vec![(0, FilterCriterion::Custom { a: (">".into(), "1".into()), b: None, and: false })];
let sh = t.wb.sheet_mut(0).unwrap();
sh.tables.push(gridcraft_model::Table {
id: 1,
name: "Sales".into(),
range: gridcraft_core::RangeRef::parse("A1:A7").unwrap(),
header_row: true,
totals_row: false,
columns: vec![gridcraft_model::TableColumn {
name: "Amount".into(),
totals: Default::default(),
totals_label: None,
formula: None,
unique_name: None,
xml: None,
}],
style: String::new(),
banded_rows: false,
banded_cols: false,
first_col: false,
last_col: false,
filter_button: true,
filter: crit(),
table_type: None,
connection_id: None,
});
sh.autofilter = Some(AutoFilter { range: gridcraft_core::RangeRef::parse("C1:C7").unwrap(), criteria: crit() });
// Rows 3 and 5 are filtered out; row 9 (outside both filters) is hidden by hand.
for row in [2, 4, 8] {
sh.rows.insert(row, LineInfo { hidden: true, ..Default::default() });
}
t.set("A9", "64");
t.set("E1", "=SUBTOTAL(9,Sales[Amount])");
t.set("E2", "=SUBTOTAL(109,Sales[Amount])");
t.set("E3", "=SUBTOTAL(9,C2:C7)");
t.set("E4", "=SUBTOTAL(9,A2:A9)");
t.set("E5", "=SUBTOTAL(109,A2:A9)");
t.set("E6", "=SUBTOTAL(2,A2:A7)");
t.set("E7", "=AGGREGATE(9,4,A2:A7)");
for (at, want) in [("E1", 53.0), ("E2", 53.0), ("E3", 53.0), ("E4", 117.0), ("E5", 53.0), ("E6", 4.0), ("E7", 63.0)] {
assert_eq!(t.num(at), want, "{at}");
}
}
#[test]
fn aggregate_without_data_argument_is_an_error() {
let mut t = T::new();
t.set("A1", "1");
t.set("A2", "2");
t.set("D1", "=AGGREGATE(9,0)");
t.set("D2", "=AGGREGATE(4,0)");
t.set("D3", "=AGGREGATE(9,0,A1:A2)");
t.set("D4", "=AGGREGATE(9,0,A3:A4)");
t.set("D5", "=AGGREGATE(9,0,A1:A2,A1:A2)");
assert_eq!(t.get("D1"), Value::Error(CellError::Value));
assert_eq!(t.get("D2"), Value::Error(CellError::Value));
assert_eq!(t.num("D3"), 3.0);
assert_eq!(t.num("D4"), 0.0);
assert_eq!(t.num("D5"), 6.0);
}
#[test]
fn recalc_all_after_load() {
let mut t = T::new();
let s = t.wb.sheet_mut(0).unwrap();
s.cells.set(c("A1"), Cell::value(Value::Number(4.0)));
s.cells.set(c("A2"), Cell::formula(Formula::new("=A1*2")));
s.cells.set(c("A3"), Cell::formula(Formula::new("=A2+A1")));
t.calc.recalc_all(&mut t.wb);
assert_eq!(t.num("A3"), 12.0);
}
#[test]
fn sumif_sum_range_takes_the_criteria_size() {
// Excel adds up the range that starts at sum_range's top-left cell with the criteria range's
// height and width, whatever size sum_range has.
let mut t = T::new();
for (r, (k, v)) in [("a", 1), ("b", 2), ("a", 3)].iter().enumerate() {
t.set(&format!("A{}", r + 1), k);
t.set(&format!("B{}", r + 1), &v.to_string());
}
t.set("D1", "=SUMIF(A1:A3,\"a\",B1)");
assert_eq!(t.num("D1"), 4.0);
t.set("D2", "=AVERAGEIF(A1:A3,\"a\",B1)");
assert_eq!(t.num("D2"), 2.0);
t.set("D3", "=SUMIF(A1:A3,\"a\",B1:B2)");
assert_eq!(t.num("D3"), 4.0);
t.set("D4", "=SUMIF(A1:A3,\"a\",B1:F1)");
assert_eq!(t.num("D4"), 4.0);
t.set("D5", "=SUMIF(A1:A3,\"a\",B1:B99)");
assert_eq!(t.num("D5"), 4.0);
t.set("D6", "=SUMIF(A:A,\"a\",B1)");
assert_eq!(t.num("D6"), 4.0);
t.set("D7", "=SUMIF(A1:A3,\"a\",$B$2)");
assert_eq!(t.num("D7"), 2.0); // B2:B4: rows 1 and 3 match → B2 + B4 (empty)
t.set("D8", "=SUMIF(A1:A3,\"a\")");
assert_eq!(t.num("D8"), 0.0);
// A horizontal criteria range resizes across.
t.set("H1", "1");
t.set("I1", "0");
t.set("J1", "1");
t.set("H2", "10");
t.set("I2", "20");
t.set("J2", "30");
t.set("D9", "=SUMIF(H1:J1,1,H2)");
assert_eq!(t.num("D9"), 40.0);
// The resized cells are precedents: editing one recalculates.
t.set("B3", "5");
assert_eq!(t.num("D1"), 6.0);
assert_eq!(t.num("D2"), 3.0);
assert_eq!(t.num("D3"), 6.0);
assert_eq!(t.num("D4"), 6.0);
assert_eq!(t.num("D6"), 6.0);
// SUMIFS and AVERAGEIFS don't resize: different sizes are #VALUE!.
t.set("D11", "=SUMIFS(B1,A1:A3,\"a\")");
assert_eq!(t.get("D11"), Value::Error(CellError::Value));
t.set("D12", "=AVERAGEIFS(B1:B2,A1:A3,\"a\")");
assert_eq!(t.get("D12"), Value::Error(CellError::Value));
t.set("D13", "=SUMIFS(B1:B3,A1:A3,\"a\")");
assert_eq!(t.num("D13"), 6.0);
}
#[test]
fn sumif_resized_sum_range_stops_at_the_sheet_edge() {
let mut t = T::new();
t.set("A1", "a");
t.set("A2", "a");
t.set("B1048575", "5");
t.set("B1048576", "7");
t.set("C1", "=SUMIF(A1:A2,\"a\",B1048576)");
assert_eq!(t.num("C1"), 7.0);
t.set("C2", "=SUMIF(A1:A2,\"a\",B1048575)");
assert_eq!(t.num("C2"), 12.0);
t.set("C3", "=SUMIF(A1:B1,\"a\",XFD1)");
assert_eq!(t.num("C3"), 0.0);
}
#[test]
fn sumif_resized_sum_range_on_another_sheet_and_through_a_name() {
let mut t = T::new();
t.wb.sheets.push(std::sync::Arc::new(gridcraft_model::Sheet::new("Data")));
t.wb.names.push(gridcraft_model::DefinedName {
name: "Amounts".into(),
scope: None,
formula: "Data!$B$1".into(),
comment: String::new(),
hidden: false,
});
for (r, k) in ["a", "b", "a"].iter().enumerate() {
t.set(&format!("A{}", r + 1), k);
t.wb.sheet_mut(1).unwrap().set_value(CellRef::new(r as u32, 1), Value::Number((r + 1) as f64));
}
t.calc.rebuild(&t.wb);
t.set("C1", "=SUMIF(A1:A3,\"a\",Data!B1)");
assert_eq!(t.num("C1"), 4.0);
t.set("C2", "=AVERAGEIF(A1:A3,\"a\",Amounts)");
assert_eq!(t.num("C2"), 2.0);
t.wb.sheet_mut(1).unwrap().set_value(c("B3"), Value::Number(9.0));
t.calc.cells_changed(&mut t.wb, &[(1, c("B3"))]);
assert_eq!(t.num("C1"), 10.0);
assert_eq!(t.num("C2"), 5.0);
}
#[test]
fn trimrange() {
let mut t = T::new();
t.set("C3", "1");
t.set("D5", "2");
let cases = [
("J1", "=ROWS(TRIMRANGE(A1:F10))", 3.0),
("J2", "=COLUMNS(TRIMRANGE(A1:F10))", 2.0),
("J3", "=ROWS(TRIMRANGE(A1:F10,2))", 5.0),
("J4", "=ROWS(TRIMRANGE(A1:F10,1,0))", 8.0),
("J5", "=COLUMNS(TRIMRANGE(A1:F10,1,0))", 6.0),
("J6", "=ROWS(TRIMRANGE(A1:F10,0,0))", 10.0),
("J7", "=SUM(TRIMRANGE(C:C))", 1.0),
("J8", "=ROWS(TRIMRANGE(C:C))", 1.0),
("J9", "=ROWS(TRIMRANGE(C:C,2))", 3.0),
("J10", "=MIN(ROW(TRIMRANGE(A1:F10)))", 3.0),
("J11", "=COLUMNS(TRIMRANGE({1,2;3,4}))", 2.0),
];
for (at, f, _) in cases {
t.set(at, f);
}
for (at, f, want) in cases {
assert_eq!(t.num(at), want, "{f}");
}
// The result spills as the trimmed block and follows edits.
t.set("L1", "=TRIMRANGE(A1:F10)");
assert_eq!((t.num("L1"), t.num("M3")), (1.0, 2.0));
t.set("E6", "3");
assert_eq!((t.num("J1"), t.num("J2")), (4.0, 3.0));
// Errors: a bad trim code, nothing but blanks, several areas.
t.set("K1", "=TRIMRANGE(A1:F10,4)");
assert_eq!(t.get("K1"), Value::Error(CellError::Value));
t.set("K2", "=TRIMRANGE(Q1:Q9)");
assert_eq!(t.get("K2"), Value::Error(CellError::Calc));
t.set("K3", "=TRIMRANGE((A1:B2,C3:D4))");
assert_eq!(t.get("K3"), Value::Error(CellError::Value));
}
/// `formula` in AB1 over a small mixed sheet. The ranges in the cases below are cut from it:
///
/// ```text
/// A B C E
/// 1 1.5 10 =A1*4 (C1 is a formula cell inside the ranges)
/// 2 2 TRUE #DIV/0! (E2 only when `error` is set)
/// 3 text 3
/// 4 -0.25
/// ```
fn aggregate_of(error: bool, formula: &str) -> Value {
let mut t = T::new();
for (at, v) in [("A1", "1.5"), ("A2", "2"), ("A3", "skipped"), ("A4", "-0.25"), ("B1", "10"), ("B2", "TRUE"), ("B3", "3"), ("C1", "=A1*4")] {
t.set(at, v);
}
if error {
t.set("E2", "#DIV/0!");
}
// AB1 is outside every range used, so the formula can't read itself.
t.set("AB1", formula);
t.get("AB1")
}
#[test]
fn sum_count_average_over_ranges() {
// Text, booleans, blanks and a formula cell inside ranges; direct arguments; unions; whole
// columns; blocks holding an error or nothing numeric. The expected values are what the
// array path (the ranges copied into an array) gave before the ranges were folded in place,
// so any difference is a change of behavior.
let n = Value::Number;
let e = Value::Error;
// (formula written with SUM, error in E2?, [SUM, COUNT, AVERAGE] of that formula)
#[rustfmt::skip]
let cases: &[(&str, bool, [Value; 3])] = &[
("=SUM(A1:A1,B3)", false, [n(4.5), n(2.0), n(2.25)]),
("=SUM(A1:A1,B3)", true, [n(4.5), n(2.0), n(2.25)]),
("=SUM((A1:A2,B1:B3))", false, [n(16.5), n(4.0), n(4.125)]),
("=SUM((A1:A2,B1:B3))", true, [n(16.5), n(4.0), n(4.125)]),
("=SUM(A1:A4,B1:B3)", false, [n(16.25), n(5.0), n(3.25)]),
("=SUM(A1:A4,B1:B3)", true, [n(16.25), n(5.0), n(3.25)]),
("=SUM(A1:A4,\"x\")", false, [e(CellError::Value), n(3.0), e(CellError::Value)]),
("=SUM(A1:A4,\"x\")", true, [e(CellError::Value), n(3.0), e(CellError::Value)]),
("=SUM(A1:B4,5,\"7\",TRUE)", false, [n(29.25), n(8.0), n(3.65625)]),
("=SUM(A1:B4,5,\"7\",TRUE)", true, [n(29.25), n(8.0), n(3.65625)]),
("=SUM(A1:C4)+SUM(B1:B3)", false, [n(35.25), n(8.0), n(10.208333333333334)]),
("=SUM(A1:C4)+SUM(B1:B3)", true, [n(35.25), n(8.0), n(10.208333333333334)]),
("=SUM(A1:C4)", false, [n(22.25), n(6.0), n(3.7083333333333335)]),
("=SUM(A1:C4)", true, [n(22.25), n(6.0), n(3.7083333333333335)]),
("=SUM(A1:E4)", false, [n(22.25), n(6.0), n(3.7083333333333335)]),
("=SUM(A1:E4)", true, [e(CellError::Div0), n(6.0), e(CellError::Div0)]),
("=SUM(A1)", false, [n(1.5), n(1.0), n(1.5)]),
("=SUM(A1)", true, [n(1.5), n(1.0), n(1.5)]),
("=SUM(A3,B2)", false, [n(0.0), n(0.0), e(CellError::Div0)]),
("=SUM(A3,B2)", true, [n(0.0), n(0.0), e(CellError::Div0)]),
("=SUM(A:C)", false, [n(22.25), n(6.0), n(3.7083333333333335)]),
("=SUM(A:C)", true, [n(22.25), n(6.0), n(3.7083333333333335)]),
("=SUM(E1:E3)", false, [n(0.0), n(0.0), e(CellError::Div0)]),
("=SUM(E1:E3)", true, [e(CellError::Div0), n(0.0), e(CellError::Div0)]),
("=SUM(Z1:Z9)", false, [n(0.0), n(0.0), e(CellError::Div0)]),
("=SUM(Z1:Z9)", true, [n(0.0), n(0.0), e(CellError::Div0)]),
];
for (formula, error, want) in cases {
for (func, want) in ["SUM", "COUNT", "AVERAGE"].into_iter().zip(want) {
// The same formula for each function: `SUM(` becomes `COUNT(` or `AVERAGE(`.
let f = formula.replace("SUM", func);
assert_eq!(&aggregate_of(*error, &f), want, "{f} (error in E2: {error})");
}
}
}
#[test]
fn sum_count_average_have_no_size_limit() {
// One stored cell far down stretches the used range to 27 million cells (27 columns by a
// million rows), past the 16M-cell limit of ranges copied into an array. Only two cells are
// stored, so the sheet itself is tiny.
for (func, want) in [("SUM", Value::Number(2.5)), ("COUNT", Value::Number(2.0)), ("AVERAGE", Value::Number(1.25))] {
for range in ["A1:AA1000000", "A:AA"] {
let mut t = T::new();
t.set("A1", "1.5");
t.set("Z1000000", "1");
t.set("AB1", &format!("={func}({range})"));
assert_eq!(t.get("AB1"), want, "{func}({range})");
}
}
}
#[test]
fn sum_count_average_over_spilled_values() {
// A1 spills over A1:A3, so the block holds values that exist only as a spill (they have no
// stored cell of their own). The host must not fold such a block in place: it falls back
// to the array path, which sees the spill. Expected: 1 + 2 + 3 + 10.
let mut t = T::new();
t.set("A1", "=SEQUENCE(3)");
t.set("A4", "10");
for (f, want) in [("SUM", 16.0), ("COUNT", 4.0), ("AVERAGE", 4.0)] {
t.set("C1", &format!("={f}(A1:A5)"));
assert_eq!(t.num("C1"), want, "{f}");
}
}
#[test]
fn aggregate_arguments_are_read_in_order() {
// Volatile cells draw a random number each time they are calculated, so the order in which
// the arguments are read decides which draw each one gets. `B1:B2` comes first, so B2 must
// draw before the `RAND()` argument does. (Reading the plain `RAND()` first once gave
// 7.9043 here.) The expected values are what the array path gave; they depend on the random
// generator and its seed, and must be updated if either changes.
let mut t = T::new();
t.set("B1", "1");
t.set("B2", "=RAND()*10");
t.set("A1", "=SUM(B1:B2,RAND())");
assert_eq!((t.num("A1"), t.num("B2")), (4.968520165759784, 3.3112028100185356));
}
#[test]
fn sum_count_average_follow_edits_inside_the_range() {
// A range read in place must still count as a precedent of the formula: if it didn't, an
// edit inside it would leave the formula showing a stale value. Each step below changes one
// cell inside the range and checks all three functions; the last one changes a cell outside.
for range in ["A1:A5", "A:A"] {
let mut t = T::new();
t.set("A1", "1");
t.set("A3", "3");
t.set("C1", &format!("=SUM({range})"));
t.set("C2", &format!("=COUNT({range})"));
t.set("C3", &format!("=AVERAGE({range})"));
let all = |t: &T| (t.num("C1"), t.num("C2"), t.num("C3"));
assert_eq!(all(&t), (4.0, 2.0, 2.0), "{range}");
t.set("A1", "5");
assert_eq!(all(&t), (8.0, 2.0, 4.0), "{range}: edit");
t.set("A2", "10");
assert_eq!(all(&t), (18.0, 3.0, 6.0), "{range}: fill a blank");
t.set("A3", "text");
assert_eq!(all(&t), (15.0, 2.0, 7.5), "{range}: number to text");
t.set("A2", "");
assert_eq!(all(&t), (5.0, 1.0, 5.0), "{range}: clear");
t.set("A5", "=A1*2");
assert_eq!(all(&t), (15.0, 2.0, 7.5), "{range}: formula in the range");
t.set("A1", "1");
assert_eq!(all(&t), (3.0, 2.0, 1.5), "{range}: its precedent changes");
// A7 is inside the whole column A:A but outside A1:A5.
t.set("A7", "100");
let outside = if range == "A:A" { (103.0, 3.0, 103.0 / 3.0) } else { (3.0, 2.0, 1.5) };
assert_eq!(all(&t), outside, "{range}: a cell outside the range");
}
}
#[test]
fn visit_range_declines_when_a_formula_in_the_block_spills_into_it() {
// The sheet: D1 = SEQUENCE(3) and D4 = 10, and D1 has not been calculated yet when the
// block D1:D5 is read.
let mut wb = Workbook::new();
let sh = wb.sheet_mut(0).unwrap();
sh.cells.set(c("D1"), Cell::formula(Formula::new("=SEQUENCE(3)")));
sh.cells.set(c("D4"), Cell::value(Value::Number(10.0)));
let block = gridcraft_core::RangeRef::parse("D1:D5").unwrap();
// Reading the block calculates D1, which spills over D2:D3. A walk over the stored cells
// would never see 2 and 3, so the host must decline and leave the block to `range_values`.
// (`Calc` recalculates a pass that produced new spills, so final results are right either
// way; only this direct call shows the host's contract.)
let walked = crate::recalc::tests::probe_visit_range(&wb, &[("D1", "=SEQUENCE(3)")], block);
assert_eq!(walked, None, "walked a block holding a spill it can't see");
}
/// Trim references evaluate as `TRIMRANGE(range, code, code)`.
#[test]
fn trim_references() {
let mut t = T::new();
t.set("C3", "1");
t.set("D5", "2");
t.set("A20", "5");
t.set("A21", "6");
let cases = [
("J1", "=ROWS(A1.:.F10)", "=ROWS(TRIMRANGE(A1:F10,3,3))", 3.0),
("J2", "=COLUMNS(A1.:.F10)", "=COLUMNS(TRIMRANGE(A1:F10,3,3))", 2.0),
("J3", "=ROWS(A1:.F10)", "=ROWS(TRIMRANGE(A1:F10,2,2))", 5.0),
("J4", "=COLUMNS(A1:.F10)", "=COLUMNS(TRIMRANGE(A1:F10,2,2))", 4.0),
("J5", "=ROWS(A1.:F10)", "=ROWS(TRIMRANGE(A1:F10,1,1))", 8.0),
("J6", "=MIN(ROW(A1.:F10))", "=MIN(ROW(TRIMRANGE(A1:F10,1,1)))", 3.0),
("J7", "=SUM(A:.A)", "=SUM(TRIMRANGE(A:A,2,2))", 11.0),
("J8", "=ROWS(A:.A)", "=ROWS(TRIMRANGE(A:A,2,2))", 21.0),
("J9", "=ROWS(A.:.A)", "=ROWS(TRIMRANGE(A:A,3,3))", 2.0),
("J10", "=ROWS(20.:.30)", "=ROWS(TRIMRANGE(20:30,3,3))", 2.0),
];
for (at, f, _, _) in cases {
t.set(at, f);
}
for (i, (at, f, same, want)) in cases.iter().enumerate() {
assert_eq!(t.num(at), *want, "{f}");
let k = format!("K{}", i + 1);
t.set(&k, same);
assert_eq!(t.num(&k), *want, "{same}");
}
// The trimmed block spills and follows edits to the full range.
t.set("L1", "=C1.:.F10");
assert_eq!((t.num("L1"), t.num("M3")), (1.0, 2.0));
t.set("A22", "4");
assert_eq!((t.num("J7"), t.num("J8")), (15.0, 22.0));
// Nothing but blanks.
t.set("K20", "=SUM(Q1:.Q9)");
assert_eq!(t.get("K20"), Value::Error(CellError::Calc));
}
#[test]
fn cell_width_is_the_column_width_dialog_value_rounded() {
// #735: Excel 365 values. Widths in px as `home.columnWidth {"chars"}` sets them
// (chars * 7 + 5): 10.71 -> 80, 12 -> 89, 10.57 -> 79, 2.86 -> 25; B keeps the default 8.43 (64).
let mut t = T::new();
let sh = t.wb.sheet_mut(0).unwrap();
for (col, px) in [(2, 80.0), (3, 89.0), (4, 79.0), (5, 25.0)] {
sh.cols.insert(col, gridcraft_model::LineInfo { size: Some(px), ..Default::default() });
}
for (at, col) in [("H1", "B"), ("H2", "C"), ("H3", "D"), ("H4", "E"), ("H5", "F")] {
t.set(at, &format!("=CELL(\"width\",{col}1)"));
}
assert_eq!([t.num("H1"), t.num("H2"), t.num("H3"), t.num("H4"), t.num("H5")], [8.0, 11.0, 12.0, 11.0, 3.0]);
}
/// Expected results are Excel's, from Apache POI's `DGet.xls` (rows 46-49): a criteria cell holding
/// a formula under a label that names no field is evaluated for every record.
#[test]
fn database_functions_with_computed_criteria() {
let mut t = T::new();
let db = [
["name", "age", "size", "headgear", "glasses"],
["Huey", "6", "45", "cap~", "FALSE"],
["Dewey", "7", "fourty-six", "cap?", "FALSE"],
["Louie", "8", "47", "cap*", "FALSE"],
["Donald", "32", "70", "sailor's hat", "FALSE"],
["Scrooge", "62", "65", "top hat", "TRUE"],
];
for (r, row) in db.iter().enumerate() {
for (c, v) in row.iter().enumerate() {
t.set(&format!("{}{}", ["F", "G", "H", "I", "J"][c], r + 2), v);
}
}
t.set("G45", "7");
let cases = [
// Relative references are to the first record's row and move to each record's row;
// `$G$45` stays put.
("G", "=G2<$G$45", "Dewey"),
// Column labels stand for the record's fields.
("H", "=glasses=TRUE", "Scrooge"),
("I", "=age<7", "Huey"),
("J", "=size/7=10", "Donald"),
// An absolute reference to a cell that is not the first record's.
("K", "=H3=$L$1", "Donald"),
];
t.set("L1", "70");
for (i, (col, formula, _)) in cases.iter().enumerate() {
t.set(&format!("{col}46"), "Formula");
t.set(&format!("{col}47"), formula);
t.set(&format!("B{}", 46 + i), &format!("=DGET($F$2:$J$7,\"name\",{col}46:{col}47)"));
}
for (i, (_, formula, want)) in cases.iter().enumerate() {
assert_eq!(t.get(&format!("B{}", 46 + i)), Value::text(*want), "{formula}");
}
// Every database function takes them, ANDed with the other criteria of their row.
// Text sorts after numbers, so Dewey's "fourty-six" is > 40.
t.set("L47", "=size>40");
t.set("M46", "age");
t.set("M47", ">6");
t.set("B51", "=DCOUNT($F$2:$J$7,\"age\",I46:I47)");
t.set("B52", "=DSUM($F$2:$J$7,\"age\",G46:G47)");
t.set("B53", "=DCOUNTA($F$2:$J$7,\"name\",L46:M47)");
assert_eq!(t.num("B51"), 1.0);
assert_eq!(t.num("B52"), 7.0);
assert_eq!(t.num("B53"), 4.0);
}
#[test]
fn offset_extreme_arguments_return_ref() {
let wb = Workbook::new();
for formula in [
"=OFFSET(B2,1E300,0)",
"=OFFSET(B2,0,1E300)",
"=OFFSET(B2,0,0,1E300,1)",
"=OFFSET(B2,0,0,1,1E300)",
"=OFFSET(B2,-1E300,0)",
"=OFFSET(B2,0,-1E300)",
"=OFFSET(B2,0,0,-1E300,1)",
"=OFFSET(B2,0,0,1,-1E300)",
"=OFFSET(B2,1E300,0,-1E300,1)",
"=OFFSET(B2,0,1E300,1,-1E300)",
"=OFFSET(B2,-1E300,0,-1E300,1)",
] {
assert_eq!(crate::evaluate(&wb, 0, c("D1"), formula), Value::Error(CellError::Ref), "{formula}");
}
for (formula, expected) in [
("=ROW(OFFSET(B2,1.9,0))", 3.0),
("=COLUMN(OFFSET(B2,0,-1.9))", 1.0),
("=ROWS(OFFSET(B2,0,0,-2,1))", 2.0),
("=COLUMNS(OFFSET(B2,0,0,1,-2))", 2.0),
("=ROWS(OFFSET(A1,0,0,1048576,1))", 1048576.0),
] {
assert_eq!(crate::evaluate(&wb, 0, c("D1"), formula), Value::Number(expected), "{formula}");
}
}
#[test]
fn analysis_toolpak_value_params_reject_multi_cell_references() {
// Excel 365 (observed): EOMONTH, WORKDAY, COUPDAYS, BIN2DEC... give #VALUE! for a reference to more
// than one cell, however the reference is formed, and lift array constants and computed arrays.
let mut t = T::new();
for (a, v) in [
("A1", "45000"),
("A2", "45031"),
("A3", "45061"),
("B1", "1"),
("B2", "2"),
("B3", "3"),
("C2", "7"),
("F1", "1"),
("F2", "2"),
("F3", "11"),
] {
t.set(a, v);
}
t.wb.names.push(gridcraft_model::DefinedName {
name: "Dates".into(),
scope: None,
formula: "Sheet1!$A$1:$A$3".into(),
comment: String::new(),
hidden: false,
});
let value = Value::Error(CellError::Value);
for f in [
"=EOMONTH(A1:A3,1)",
"=EOMONTH(A1,B1:B3)",
"=EDATE(A1:A3,1)",
"=SUMPRODUCT(EOMONTH(A1:A3,0))",
"=EOMONTH(INDEX(A1:A3,0,1),0)",
"=EOMONTH(OFFSET(A1,0,0,3,1),0)",
"=EOMONTH(IF(TRUE,A1:A3),0)",
"=EOMONTH(CHOOSE(1,A1:A3),0)",
"=LET(x,A1:A3,EOMONTH(x,0))",
"=EOMONTH(Dates,0)",
"=EOMONTH((A1,A2),0)",
"=EOMONTH(Z:Z,0)",
"=EOMONTH(A1:INDEX(A1:A3,3),0)",
"=WORKDAY(A1:A3,1)",
"=NETWORKDAYS(A1,A3:A4)",
"=YEARFRAC(A1,A3,B1:B2)",
"=WEEKNUM(A1,B1:B3)",
"=COUPDAYS(A1:A3,46000,2,0)",
"=DISC(A1,46000,98,100,B1:B3)",
"=BIN2DEC(B1:B3)",
"=ISEVEN(B1:B3)",
] {
t.set("H5", f);
assert_eq!((t.get("H5"), t.get("H6")), (value.clone(), Value::Empty), "{f}");
t.set("H5", "");
}
// One-cell references, intersections, @ and the range parameters are fine.
for (f, want) in [
("=EOMONTH(A1:A1,0)", 45016.0),
("=EOMONTH(INDEX(A1:A3,2),0)", 45046.0),
("=EOMONTH(OFFSET(A1,0,0,1,1),0)", 45016.0),
("=EOMONTH(A1:A3 A2:C2,0)", 45046.0),
("=EOMONTH(@A1:A3,0)", 45046.0),
("=WORKDAY(A1,1,A1:A3)", 45001.0),
("=XNPV(0,B1:B3,A1:A3)", 6.0),
] {
t.set("H2", f);
assert_eq!(t.num("H2"), want, "{f}");
t.set("H2", "");
}
// Arrays that aren't references lift; so does the WORKDAY.INTL/NETWORKDAYS.INTL weekend.
for (f, below, right) in [
("=EOMONTH(A1:A3+0,0)", Value::Number(45046.0), Value::Empty),
("=EOMONTH(A1,{1,2})", Value::Empty, Value::Number(45077.0)),
("=EOMONTH(TRANSPOSE(A1:A3),0)", Value::Empty, Value::Number(45046.0)),
("=MAP(A1:A3,LAMBDA(x,EOMONTH(x,0)))", Value::Number(45046.0), Value::Empty),
("=NETWORKDAYS.INTL(A1,A3+30,F1:F3)", Value::Number(66.0), Value::Empty),
("=ISOWEEKNUM(A1:A3)", Value::Number(15.0), Value::Empty),
// The rejected reference adds no shape: a 2x1 array of #VALUE!.
("=EOMONTH(A1:A3,{1;2})", value.clone(), Value::Empty),
] {
t.set("H5", f);
assert_eq!((t.get("H6"), t.get("I5")), (below, right), "{f}");
t.set("H5", "");
}
}
/// A formula reading one further down (or on a later sheet) is calculated on demand, inside the
/// formula that reads it. Chains of thousands of such formulas, common in financial models, used
/// to overflow the stack (or give `#CIRC!` past 2,000 levels); they now calculate in full.
#[test]
fn long_dependency_chains_against_row_order_calculate() {
const N: u32 = 20_000;
let mut t = T::new();
{
let sh = t.wb.sheet_mut(0).unwrap();
for r in 1..N {
sh.cells.set(c(&format!("A{r}")), Cell::formula(Formula::new(&format!("=A{}+1", r + 1))));
}
sh.cells.set(c(&format!("A{N}")), Cell::value(Value::Number(1.0)));
}
t.calc.recalc_all(&mut t.wb);
assert_eq!(t.num("A1"), N as f64);
assert_eq!(t.num("A2"), (N - 1) as f64);
assert!(t.calc.circular.is_empty());
// An edit at the far end recalculates the whole chain.
t.set(&format!("A{N}"), "10");
assert_eq!(t.num("A1"), (N + 9) as f64);
assert!(t.calc.circular.is_empty());
// Hopping between two sheets at every step, through a range read (SUM) as well.
let mut t = T::new();
t.wb.sheets.push(std::sync::Arc::new(gridcraft_model::Sheet::new("Data")));
for r in 1..N {
t.wb.sheet_mut(0).unwrap().cells.set(c(&format!("A{r}")), Cell::formula(Formula::new(&format!("=Data!A{r}+1"))));
let next = format!("=SUM(Sheet1!A{}:A{})", r + 1, r + 1);
t.wb.sheet_mut(1).unwrap().cells.set(c(&format!("A{r}")), Cell::formula(Formula::new(&next)));
}
t.wb.sheet_mut(0).unwrap().cells.set(c(&format!("A{N}")), Cell::value(Value::Number(0.0)));
t.calc.recalc_all(&mut t.wb);
assert_eq!(t.num("A1"), (N - 1) as f64);
assert_eq!(t.wb.sheet(1).unwrap().value(c("A1")), Value::Number((N - 2) as f64));
assert!(t.calc.circular.is_empty());
}
/// A loop longer than the on-demand nesting limit is still a circular reference (and still ends).
#[test]
fn long_circular_chain_is_circular() {
const N: u32 = 5_000;
let mut t = T::new();
{
let sh = t.wb.sheet_mut(0).unwrap();
for r in 1..N {
sh.cells.set(c(&format!("A{r}")), Cell::formula(Formula::new(&format!("=A{}+1", r + 1))));
}
sh.cells.set(c(&format!("A{N}")), Cell::formula(Formula::new("=A1+1")));
sh.cells.set(c("B1"), Cell::formula(Formula::new("=1+2")));
}
t.calc.recalc_all(&mut t.wb);
assert_eq!(t.get("A1"), Value::Error(CellError::Circ));
assert_eq!(t.get(&format!("A{N}")), Value::Error(CellError::Circ));
assert_eq!(t.num("B1"), 3.0);
assert!(!t.calc.circular.is_empty());
// Each formula is listed once at most.
let mut seen = t.calc.circular.clone();
seen.sort_by_key(|(s, c)| (*s, c.row, c.col));
seen.dedup();
assert_eq!(seen.len(), t.calc.circular.len());
// With iterative calculation the loop settles to numbers instead (no hang, no crash).
t.wb.calc.iterative = true;
t.wb.calc.max_iterations = 3;
t.calc.recalc_all(&mut t.wb);
assert!(t.get("A1").as_f64().is_some());
}
/// A loop closing back into the middle of a chain deeper than the on-demand nesting limit is found
/// where calculating it in one go finds it: at the formula read while it was being calculated.
#[test]
fn deep_loop_is_found_where_it_closes() {
let mut t = T::new();
{
let sh = t.wb.sheet_mut(0).unwrap();
for r in 1..300 {
sh.cells.set(c(&format!("A{r}")), Cell::formula(Formula::new(&format!("=A{}+1", r + 1))));
}
sh.cells.set(c("A300"), Cell::formula(Formula::new("=A30+1")));
// Reads the loop through IFERROR, so its value shows where the loop was cut.
sh.cells.set(c("B1"), Cell::formula(Formula::new("=IFERROR(A1,-1)")));
}
t.calc.recalc_all(&mut t.wb);
assert_eq!(t.calc.circular, vec![(0, c("A30"))]);
assert_eq!(t.get("A1"), Value::Error(CellError::Circ));
assert_eq!(t.num("B1"), -1.0);
}
#[test]
fn large_bounded_references_keep_their_shape() {
// D3:AZ400 is 19,502 cells, past the size where a block used to be cut to the used range
// (which ends at column F here). Sliced that way it no longer lined up with the full-size
// operands next to it. Expected values observed in Excel 365 on the same layout.
let mut t = T::new();
for (at, v) in [
("D1", "1"),
("E1", "2"),
("F1", "3"),
("A3", "1"),
("A4", "2"),
("A5", "1"),
("B3", "2"),
("B4", "3"),
("B5", "4"),
("D3", "10"),
("E3", "20"),
("F3", "30"),
("D4", "40"),
("E4", "50"),
("F4", "60"),
("D5", "70"),
("E5", "80"),
("F5", "90"),
] {
t.set(at, v);
}
let cases = [
("=SUMPRODUCT(D3:AZ400*B3:B400,(A3:A400=1)*(D1:AZ1=2))", Value::Number(360.0)),
("=ROWS(D3:AZ400*1)", Value::Number(398.0)),
("=COLUMNS(D3:AZ400*1)", Value::Number(49.0)),
("=SUMPRODUCT(D3:AZ400,(A3:A400=1)*(D1:AZ1=2))", Value::Number(100.0)),
("=SUMPRODUCT(D3:AZ400+0,D3:AZ401)", Value::Error(CellError::Value)),
("=COUNTBLANK(D3:AZ400)", Value::Number(19493.0)),
("=SUMPRODUCT(--(D3:AZ400=\"\"))", Value::Number(19493.0)),
("=SUM(D3:AZ400*B3:B400)", Value::Number(1530.0)),
("=COLUMNS(TRANSPOSE(D3:AZ400))", Value::Number(398.0)),
// Whole columns are still read up to the used range.
("=SUMPRODUCT((A:A=1)*B:B)", Value::Number(6.0)),
];
for (i, (formula, want)) in cases.iter().enumerate() {
let at = format!("C{}", 1001 + i);
t.set(&at, formula);
assert_eq!(&t.get(&at), want, "{formula}");
}
}
#[test]
fn median_large_values_recalculate_dependents() {
let mut t = T::new();
t.set("A1", "1e308");
t.set("A2", "1.6e308");
t.set("B1", "=MEDIAN(A1:A2)");
t.set("B2", "=AGGREGATE(12,0,A1:A2)");
t.set("C1", "=B1/1e308");
t.set("C2", "=B2/1e308");
for cell in ["B1", "B2"] {
assert!((t.num(cell) / 1e308 - 1.3).abs() < 1e-14);
}
for cell in ["C1", "C2"] {
assert!((t.num(cell) - 1.3).abs() < 1e-14);
}
t.set("A2", "-1e308");
for cell in ["B1", "B2", "C1", "C2"] {
assert_eq!(t.num(cell), 0.0);
}
t.set("A1", "-1.6e308");
for cell in ["B1", "B2"] {
assert!((t.num(cell) / 1e308 + 1.3).abs() < 1e-14);
}
for cell in ["C1", "C2"] {
assert!((t.num(cell) + 1.3).abs() < 1e-14);
}
}
#[test]
fn cell_filename_names_the_workbook_file() {
// Excel returns `<folder>/[<file>]<sheet>`, and the classic sheet-name formula splits it on
// the `]`; an empty string broke that formula in every German workbook that used it (#1489).
let dir = std::path::Path::new("books");
let mut t = T::new();
t.wb.sheet_mut(0).unwrap().name = "Info".into();
// No file yet: empty, as in a workbook Excel has never saved.
t.set("A1", "1");
t.set("B1", "=CELL(\"filename\",A1)");
assert_eq!(t.get("B1"), Value::from(""));
t.wb.source_path = Some(dir.join("gc_cell_info_de.xlsx"));
t.calc = Calc::new();
let expect = dir.join("[gc_cell_info_de.xlsx]Info").to_string_lossy().into_owned();
t.set("B2", "=CELL(\"filename\",A1)");
assert_eq!(t.get("B2"), Value::from(expect.as_str()));
// The two formulas from the report: the sheet name, and the test that the `]` is there.
t.set("B3", "=MID(CELL(\"filename\",A1),FIND(\"]\",CELL(\"filename\",A1))+1,31)");
assert_eq!(t.get("B3"), Value::from("Info"), "the sheet-name formula");
t.set("B4", "=ISNUMBER(FIND(\"]\",CELL(\"filename\",A1)))");
assert_eq!(t.get("B4"), Value::Bool(true));
// The sheet is the one the reference is on, not the active one.
t.wb.sheets.push(std::sync::Arc::new(gridcraft_model::Sheet::new("Second")));
t.set("B6", "=CELL(\"filename\",Second!A1)");
assert_eq!(t.get("B6"), Value::from(dir.join("[gc_cell_info_de.xlsx]Second").to_string_lossy().into_owned().as_str()));
// A workbook that arrived as bytes knows its name but no folder: still splittable.
t.wb.source_path = Some(std::path::PathBuf::from("Book.xlsx"));
t.calc = Calc::new();
t.set("B7", "=CELL(\"filename\",A1)");
assert_eq!(t.get("B7"), Value::from("[Book.xlsx]Info"));
}
#[test]
fn n_lifts_over_a_computed_array_but_reads_the_first_cell_of_a_range() {
let mut t = T::new();
t.set("B1", "0");
t.set("B2", "23850");
t.set("D2", "10%");
t.set("D3", "12%");
t.set("F1", "50000");
t.set("H1", "=SUMPRODUCT(D2:D3-D1:D2,F1-B1:B2,N(F1>B1:B2))");
assert!((t.num("H1") - 5523.0).abs() < 1e-9, "{:?}", t.get("H1"));
t.set("H2", "=SUMPRODUCT(N(B1:B2>0))");
assert_eq!(t.num("H2"), 1.0);
// A spill of N(D2:D3) would also show 10% in H3; summing it tells the two apart.
t.set("H3", "=SUM(N(D2:D3))");
assert!((t.num("H3") - 0.1).abs() < 1e-12, "{:?}", t.get("H3"));
}
#[test]
fn a_dynamic_array_of_one_value_still_anchors_a_spill() {
// In Excel a dynamic-array formula anchors a spill range even while its result is a single
// value, so `C1#` is the 1 x 1 range and ROWS/SUM/COUNT of it work. GridCraft gave #REF!
// (and COUNT 0), so every formula counting a filtered list broke the moment the list had
// exactly one entry (#1491). Values and cells follow the reported fixture.
let mut t = T::new();
for i in 1..=5 {
t.set(&format!("A{i}"), &i.to_string());
t.set(&format!("B{i}"), "7");
}
t.set("C1", "=FILTER(A1:A5,A1:A5>4)");
t.set("C3", "=SORT(FILTER(A1:A5,A1:A5=3))");
t.set("C5", "=UNIQUE(B1:B5)");
// Control: the same FILTER matching two rows, which worked before.
t.set("C9", "=FILTER(A1:A5,A1:A5>3)");
t.set("E1", "=ROWS(C1#)");
t.set("E2", "=SUM(C1#)");
t.set("E3", "=ROWS(C3#)");
t.set("E5", "=COUNT(C5#)");
t.set("E9", "=ROWS(C9#)");
assert_eq!(t.get("E1"), Value::from(1.0));
assert_eq!(t.get("E2"), Value::from(5.0));
assert_eq!(t.get("E3"), Value::from(1.0));
assert_eq!(t.get("E5"), Value::from(1.0));
assert_eq!(t.get("E9"), Value::from(2.0), "a spilling array is unchanged");
// The anchors still show their first value, as they did.
assert_eq!(t.get("C1"), Value::from(5.0));
assert_eq!(t.get("C5"), Value::from(7.0));
// A formula whose result is a scalar never becomes an anchor: `#` on it is still #REF!.
t.set("D1", "=1+1");
t.set("E11", "=ROWS(D1#)");
assert_eq!(t.get("E11"), Value::Error(CellError::Ref));
}
#[test]
fn text_before_after_empty_instance_defaults_to_one() {
let mut t = T::new();
t.set("A1", "=TEXTBEFORE(\"Little Red Riding Hood\",\"red\",,1)");
assert_eq!(t.get("A1"), Value::Text("Little ".into()));
t.set("A2", "=TEXTAFTER(\"a-b\",\"-\",,,,\"nf\")");
assert_eq!(t.get("A2"), Value::Text("b".into()));
t.set("A3", "=TEXTAFTER(\"a-b\",\"x\",,,,\"nf\")");
assert_eq!(t.get("A3"), Value::Text("nf".into()));
t.set("A4", "=TEXTBEFORE(\"a-b\",\"-\",0)");
assert_eq!(t.get("A4"), Value::Error(CellError::Value));
}
#[test]
fn loan_schedule_residue_keeps_excels_sign() {
// A synthetic monthly loan schedule: interest on the opening balance, principal = PMT - interest,
// each later period guarded by IF(p - SUM(principal so far) > 0, ...). After the 12th payment
// Excel is left with 2^-30 > 0, so it takes a 13th, tiny-interest period. That last bit comes
// from PMT; the old (1 + rate)^nper form left -2.8e-8 and returned "" instead.
let mut t = T::new();
t.set("B1", "4653300");
t.set("B2", "1");
t.set("B3", "0.141");
let col = |i: u8| char::from(b'A' + i).to_string();
t.set("C8", "=$B$1*($B$3/12)");
t.set("C7", "=PMT($B$3/12,$B$2*12,-$B$1)-C8");
t.set("D8", "=($B1-C7)*($B$3/12)");
t.set("D7", "=PMT($B$3/12,$B$2*12,-$B$1)-D8");
for i in 4..16 {
let (c, p) = (col(i), col(i - 1));
t.set(&format!("{c}8"), &format!("=IF($B1-SUM($C7:{p}7)>0,($B1-SUM($C7:{p}7))*($B$3/12),\"\")"));
t.set(&format!("{c}7"), &format!("=IF($B1-SUM($C7:{p}7)>0,PMT($B$3/12,$B$2*12,-$B$1)-{c}8,\"\")"));
}
t.set("N4", "=IF($B$1-SUM($C7:N7)>0,$B$1-SUM($C7:N7),\"\")");
// Excel's values (Value2) for the same synthetic inputs.
// The leftover (2^-30 in Excel) is 12 payments' worth of rounding, so a one-ULP libm difference
// moves it by as much as its own size: only its sign is checked, and the 13th period must exist.
let positive = |cell: &str| match t.get(cell) {
Value::Number(x) => assert!(x > 0.0 && x < 1e-6, "{cell}: {x}"),
other => panic!("{cell}: expected a small positive number, got {other:?}"),
};
positive("N4");
positive("O8");
match t.get("O7") {
Value::Number(x) => assert!((x - 418025.37230211904).abs() < 1e-3, "O7: {x}"),
other => panic!("O7: expected a number, got {other:?}"),
}
assert_eq!(t.get("P7"), Value::Text("".into()));
}