diff --git a/ROADMAP.md b/ROADMAP.md index 44bb3c76b..9ba8709d1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -149,6 +149,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-16 | Memory: the interface and script fallback fonts are read once per run and shared with egui instead of copied into it: an empty workbook settles at 158 MB instead of 258 MB on macOS | | 2026-10-15 | Function help follows the interface language: argument hints, autocomplete, Insert Function (search and readable categories) and the Formulas-tab menus read per-language tables of descriptions and argument names; Spanish keeps its 10 functions in the new format | | 2026-10-15 | Row auto-fit counts a cell's line breaks only when the cell wraps, as Excel does: a file whose unwrapped cells hold multi-line text no longer opens with tall rows (typing a break still turns wrap on and grows the row) | +| 2026-10-14 | Lookups (MATCH, XMATCH, VLOOKUP, HLOOKUP, XLOOKUP, LOOKUP) compare numbers exactly like Excel, so a computed key such as `=0.1+0.2` no longer finds 0.3; `=` and COUNTIF/SUMIF keep their 15-digit comparison | | 2026-10-14 | Functions: EOMONTH, EDATE, WORKDAY, NETWORKDAYS, YEARFRAC, WEEKNUM, the bond and coupon functions, DISC, BIN2DEC, IM* and the other Analysis ToolPak functions give `#VALUE!` for a reference to more than one cell in a value argument, as Excel 365 does, instead of spilling; array constants and computed arrays still lift | | 2026-10-14 | Protect Sheet on a protected sheet: Home › Format, its keytips (HOP, RPS) and the tab menu now read Unprotect Sheet… and unprotect (straight away without a password); a protected sheet that forbids selecting locked (or unlocked) cells is enforced for clicks, arrows and Go To, and the Protect Sheet dialog has the select options (#1346) | | 2026-10-14 | XLSX: formulas opened with dynamic-array metadata (`cm`) keep it on save, with `xl/metadata.xml`. Before, a formula returning one value lost it (11 of 40 corpus files that have the part lost it entirely), and Excel recalculated some of them with implicit intersection (`SUMPRODUCT(IFERROR(...))` changed value) | @@ -235,6 +236,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-16 | minor | Progress log: floating pictures' textures bounded and kept per workbook | | 2026-10-16 | trivial | Progress log: web preferences kept between visits (#1562) | | 2026-10-16 | minor | Progress log: interface fonts held once instead of twice | +| 2026-10-14 | minor | Progress log: lookups compare numbers exactly | | 2026-10-14 | minor | Progress log: Analysis ToolPak value arguments reject multi-cell references | | 2026-10-14 | minor | XLSX dynamic-array `cm` metadata kept on save; progress log row | | 2026-10-14 | minor | Progress log: spill anchor reading its old spill area | diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 54cd65d2f..8255184e8 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -404,6 +404,149 @@ fn date_text_coerces_in_the_workbook_date_system() { } } +/// 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(); diff --git a/crates/functions/src/criteria.rs b/crates/functions/src/criteria.rs index e326267ce..58f0e6594 100644 --- a/crates/functions/src/criteria.rs +++ b/crates/functions/src/criteria.rs @@ -314,7 +314,9 @@ pub(crate) fn wildcard_find(pattern: &str, text: &str, start: usize) -> Option bool { match (needle, hay) { (Value::Text(p), Value::Text(t)) => { @@ -324,7 +326,7 @@ pub(crate) fn lookup_equal(needle: &Value, hay: &Value, wild: bool) -> bool { compare_text(p, t) == Ordering::Equal } } - (Value::Number(a), Value::Number(b)) => compare_numbers(*a, *b) == Ordering::Equal, + (Value::Number(a), Value::Number(b)) => a == b, (Value::Bool(a), Value::Bool(b)) => a == b, (Value::Error(a), Value::Error(b)) => a == b, (Value::Empty, Value::Empty) => true, @@ -370,7 +372,8 @@ mod tests { assert!(m("#N/A", Value::Error(CellError::NA))); assert!(Criterion::parse(&Value::Number(3.0)).matches(&Value::Number(3.0))); assert!(Criterion::parse(&Value::Number(0.3)).matches(&Value::Number(0.1 + 0.2))); - assert!(lookup_equal(&Value::Number(0.3), &Value::Number(0.1 + 0.2), false)); + assert!(!lookup_equal(&Value::Number(0.3), &Value::Number(0.1 + 0.2), false)); + assert!(lookup_equal(&Value::Number(0.0), &Value::Number(-0.0), false)); assert!(m("<>a*", Value::from("bcd"))); assert!(m(">1/1/2020", Value::Number(44000.0))); // Text patterns never match blank cells, but an empty string is text. diff --git a/crates/functions/src/lookup.rs b/crates/functions/src/lookup.rs index acb23f2ea..b89200199 100644 --- a/crates/functions/src/lookup.rs +++ b/crates/functions/src/lookup.rs @@ -23,6 +23,19 @@ fn needle(args: &[Arg], i: usize) -> R { } } +/// Order of two values for the approximate and binary searches: [`compare`], except that numbers +/// compare exactly. Excel's `=` treats numbers equal to 15 significant digits as equal, but its +/// lookups don't. Observed in Excel 365 with A1 holding +/// `=0.1+0.2` (0.30000000000000004): `A1=0.3` is TRUE, yet `MATCH(0.3,A1,1)` is #N/A because +/// A1 is larger, and in {0.1;0.2;0.3;A1;0.4} `MATCH(0.3,…,1)` is 3 and `MATCH(A1,…,1)` is 4. +/// The same holds for `=100+1E-14` against 100, and in every XLOOKUP / XMATCH mode. +fn lookup_order(a: &Value, b: &Value) -> Ordering { + match (a, b) { + (Value::Number(x), Value::Number(y)) => x.partial_cmp(y).unwrap_or(Ordering::Equal), + _ => compare(a, b), + } +} + fn same_type(a: &Value, b: &Value) -> bool { matches!((a, b), (Value::Number(_), Value::Number(_)) | (Value::Text(_), Value::Text(_)) | (Value::Bool(_), Value::Bool(_))) } @@ -59,10 +72,10 @@ fn bsearch_last(x: &Value, hay: &[Value], desc: bool) -> Option { continue; }; let v = hay.get(j)?; - let o = compare(v, x); + let o = lookup_order(v, x); if o == Ordering::Equal { let mut k = j; - while hay.get(k + 1).is_some_and(|w| same_type(x, w) && compare(w, x) == Ordering::Equal) { + while hay.get(k + 1).is_some_and(|w| same_type(x, w) && lookup_order(w, x) == Ordering::Equal) { k += 1; } return Some(k); @@ -88,8 +101,8 @@ fn bsearch_last(x: &Value, hay: &[Value], desc: bool) -> Option { fn binary_order(v: &Value, x: &Value, desc: bool) -> Ordering { match v { Value::Empty => Ordering::Greater, - _ if desc => compare(v, x).reverse(), - _ => compare(v, x), + _ if desc => lookup_order(v, x).reverse(), + _ => lookup_order(v, x), } } @@ -262,7 +275,7 @@ fn xsearch(x: &Value, hay: &[Value], match_mode: i64, search_mode: i64) -> R R true, Some(bv) => { - let c = compare(v, bv); + let c = lookup_order(v, bv); if match_mode == -1 { c == Ordering::Greater } else { c == Ordering::Less } } }; @@ -748,6 +761,44 @@ mod tests { assert_eq!(rows_of(&r), vec![vec![nv(3.0), nv(1.0)]]); } + /// Lookups compare numbers exactly, unlike `=` (15 significant digits). Observed in Excel 365. + #[test] + fn lookups_compare_numbers_exactly() { + let e = 0.1 + 0.2; + let one = || rf(col(&[e])); + // Exact modes miss a key one bit away. + is_err(ev("MATCH", vec![n(0.3), one(), n(0.0)]), CellError::NA); + is_err(ev("VLOOKUP", vec![n(0.3), one(), n(1.0), b(false)]), CellError::NA); + is_err(ev("XMATCH", vec![n(0.3), one()]), CellError::NA); + close(ev("MATCH", vec![n(0.3), rf(col(&[e, 0.3])), n(0.0)]), 2.0); + close(ev("MATCH", vec![n(e), rf(col(&[e, 0.3])), n(0.0)]), 1.0); + is_err(ev("MATCH", vec![n(100.0), rf(col(&[100.0 + 1e-14])), n(0.0)]), CellError::NA); + // 0 and -0 are still the same key. + close(ev("MATCH", vec![n(-0.0), rf(col(&[0.0])), n(0.0)]), 1.0); + // Approximate modes: 0.3 sorts below 0.30000000000000004. + is_err(ev("MATCH", vec![n(0.3), one(), n(1.0)]), CellError::NA); + close(ev("MATCH", vec![n(0.3), one(), n(-1.0)]), 1.0); + is_err(ev("LOOKUP", vec![n(0.3), one()]), CellError::NA); + is_err(ev("XMATCH", vec![n(0.3), one(), n(-1.0)]), CellError::NA); + close(ev("XMATCH", vec![n(0.3), one(), n(1.0)]), 1.0); + // The bisection stops on 0.3 and doesn't run on into the next entry. + let list = || rf(col(&[0.1, 0.2, 0.3, e, 0.4])); + close(ev("MATCH", vec![n(0.3), list(), n(1.0)]), 3.0); + close(ev("MATCH", vec![n(e), list(), n(1.0)]), 4.0); + close(ev("XMATCH", vec![n(0.3), list(), n(0.0), n(2.0)]), 3.0); + close(ev("XMATCH", vec![n(e), list(), n(0.0), n(2.0)]), 4.0); + let asc = || rf(col(&[0.1, e, 0.5])); + let desc = || rf(col(&[0.5, e, 0.1])); + close(ev("MATCH", vec![n(0.3), asc(), n(1.0)]), 1.0); + close(ev("MATCH", vec![n(0.3), desc(), n(-1.0)]), 2.0); + is_err(ev("XMATCH", vec![n(0.3), asc(), n(0.0), n(2.0)]), CellError::NA); + close(ev("XMATCH", vec![n(0.3), asc(), n(-1.0), n(2.0)]), 1.0); + close(ev("XMATCH", vec![n(0.3), asc(), n(1.0), n(2.0)]), 2.0); + close(ev("XMATCH", vec![n(0.3), desc(), n(-1.0), n(-2.0)]), 3.0); + close(ev("XMATCH", vec![n(0.3), asc(), n(-1.0)]), 1.0); + close(ev("XMATCH", vec![n(0.3), asc(), n(1.0)]), 2.0); + } + #[test] fn index() { let t3 = table_rows3(); diff --git a/docs/function-parity.md b/docs/function-parity.md index d61d02149..a8dd1a4e2 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -132,6 +132,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | 2026-10-16 | minor | FORECAST.ETS gap interpolation uses a forward-only known-point scan instead of rescanning each gap for every missing value; numerical behavior and missing-point limits are preserved (#1042) | | 2026-10-15 | minor | MEDIAN and AGGREGATE median use an overflow-safe midpoint (#1579); regressions cover finite extremes, tiny values and dependent recalculation | | 2026-10-15 | minor | ADDRESS quotes a sheet name the way Excel does: names that read as an A1 cell (`A1`) or an R1C1 reference (`R1C1`, `RC`) and names starting with `.` get quotes; `TRUE` / `FALSE` stay bare (#1458) | +| 2026-10-14 | minor | MATCH, XMATCH, VLOOKUP, HLOOKUP, XLOOKUP and LOOKUP compare numbers exactly in every match and search mode, as Excel 365 does: `MATCH(0.1+0.2,{0.3},0)` is `#N/A` and 0.3 sorts below 0.30000000000000004 in approximate and binary search. `=`, SWITCH and the criteria functions (COUNTIF(S), SUMIF(S), AVERAGEIF(S), MAXIFS…) keep comparing at 15 significant digits, also as observed | | 2026-10-14 | minor | Analysis ToolPak value arguments (EOMONTH, WORKDAY, YEARFRAC, COUPDAYS, DISC, BIN2DEC, IM* and the rest, observed in Excel 365) give `#VALUE!` for a multi-cell reference instead of lifting it | | 2026-10-13 | minor | Clear reports changed stored cells sparsely, including those beyond the first million coordinates; dependent formulas and removed spills update, while clearing empty merges still unblocks spills (#1032) | | 2026-10-14 | minor | Bounded references up to 1,048,576 cells (`D3:AZ400`) keep their full shape instead of stopping at the used range, so `SUMPRODUCT(D3:AZ400*B3:B400,(A3:A400=x)*(D1:AZ1=y))` broadcasts like Excel and `ROWS`, `COUNTBLANK` and `--(r="")` count the blanks; references that run to the edge of the sheet (`A:A`) still stop at the used range (#1010) |