mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-24 10:46:50 -06:00
Merge PR #2396
This commit is contained in:
@@ -148,6 +148,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 | 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) |
|
||||
| 2026-10-14 | Recalculation: a dynamic array's anchor given a new formula that reads its old spill area (`A1` was `=SEQUENCE(3)`, becomes `=SUM(A2:A3)+1` or `=SEQUENCE(1,A2+1)`) sees that area empty instead of the old values |
|
||||
@@ -232,6 +233,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: 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 |
|
||||
| 2026-10-14 | minor | Progress log: formulas reading an array that loses a spill conflict |
|
||||
|
||||
@@ -1588,6 +1588,91 @@ fn offset_extreme_arguments_return_ref() {
|
||||
}
|
||||
}
|
||||
|
||||
#[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.
|
||||
|
||||
@@ -30,6 +30,7 @@ macro_rules! f {
|
||||
category: $crate::Category::$cat,
|
||||
volatile: true,
|
||||
scalar_args: $scal,
|
||||
value_args: &[],
|
||||
signature: $sig,
|
||||
description: $desc,
|
||||
imp: {
|
||||
@@ -48,6 +49,7 @@ macro_rules! f {
|
||||
category: $crate::Category::$cat,
|
||||
volatile: false,
|
||||
scalar_args: $scal,
|
||||
value_args: &[],
|
||||
signature: $sig,
|
||||
description: $desc,
|
||||
imp: {
|
||||
@@ -156,6 +158,11 @@ pub struct FnSpec {
|
||||
/// (broadcast like Excel's dynamic arrays). The last entry repeats for variadic tails; an
|
||||
/// empty slice means no lifting.
|
||||
pub scalar_args: &'static [bool],
|
||||
/// Parameter positions that take a single value and reject a reference to more than one
|
||||
/// cell with `#VALUE!` (the Analysis ToolPak functions: EOMONTH, WORKDAY, COUPDAYS, BIN2DEC…),
|
||||
/// where other scalar parameters lift the range. Arrays that aren't references still lift.
|
||||
/// Same layout as `scalar_args`; filled at registration from [`lift::value_args`].
|
||||
pub value_args: &'static [bool],
|
||||
pub signature: &'static str,
|
||||
pub description: &'static str,
|
||||
pub imp: FnImpl,
|
||||
@@ -176,6 +183,14 @@ impl FnSpec {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether parameter `i` takes a single value, so a multi-cell reference there is `#VALUE!`.
|
||||
pub fn is_value_arg(&self, i: usize) -> bool {
|
||||
match self.value_args.get(i) {
|
||||
Some(b) => *b,
|
||||
None => self.value_args.last().copied().unwrap_or(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a call with `n` arguments is valid (Excel allows at most 255).
|
||||
pub fn accepts(&self, n: usize) -> bool {
|
||||
(self.min_args..=self.max_args).contains(&n) && n <= 255
|
||||
@@ -204,6 +219,9 @@ fn registry() -> &'static (Vec<FnSpec>, HashMap<&'static str, usize>) {
|
||||
] {
|
||||
v.extend(list);
|
||||
}
|
||||
for s in &mut v {
|
||||
s.value_args = lift::value_args(s.name);
|
||||
}
|
||||
let mut map = HashMap::with_capacity(v.len());
|
||||
for (i, s) in v.iter().enumerate() {
|
||||
map.entry(s.name).or_insert(i);
|
||||
|
||||
@@ -6,7 +6,61 @@ use gridcraft_core::{Array, CellError, Value};
|
||||
use crate::util::MAX_CELLS;
|
||||
use crate::{Arg, Ctx, FnSpec};
|
||||
|
||||
/// Value-parameter masks (see [`FnSpec::value_args`]), observed in Excel 365: a reference to more
|
||||
/// than one cell at these positions gives `#VALUE!` in dynamic-array and legacy formulas alike (no
|
||||
/// implicit intersection), while array constants and computed arrays lift. Range parameters
|
||||
/// (holidays, schedules, cash flows, coefficients) and the WORKDAY.INTL/NETWORKDAYS.INTL weekend
|
||||
/// (which lifts a range) are `false`.
|
||||
pub(crate) const VALUE_ARGS: &[(&str, &[bool])] = &[
|
||||
// Date and time.
|
||||
("EOMONTH EDATE YEARFRAC WEEKNUM", &[true]),
|
||||
("WORKDAY NETWORKDAYS", &[true, true, false]),
|
||||
("WORKDAY.INTL NETWORKDAYS.INTL", &[true, true, false, false]),
|
||||
// Financial.
|
||||
(
|
||||
"ACCRINT ACCRINTM AMORDEGRC AMORLINC COUPDAYBS COUPDAYS COUPDAYSNC COUPNCD COUPNUM COUPPCD CUMIPMT CUMPRINC DISC \
|
||||
DOLLARDE DOLLARFR DURATION EFFECT INTRATE MDURATION NOMINAL ODDFPRICE ODDFYIELD ODDLPRICE ODDLYIELD PRICE PRICEDISC \
|
||||
PRICEMAT RECEIVED TBILLEQ TBILLPRICE TBILLYIELD YIELD YIELDDISC YIELDMAT",
|
||||
&[true],
|
||||
),
|
||||
("FVSCHEDULE XNPV", &[true, false]),
|
||||
("XIRR", &[false, false, true]),
|
||||
// Math and information.
|
||||
("ISEVEN ISODD QUOTIENT MROUND FACTDOUBLE SQRTPI RANDBETWEEN", &[true]),
|
||||
("SERIESSUM", &[true, true, true, false]),
|
||||
// Engineering (IMSUM and IMPRODUCT aggregate, so they aren't here).
|
||||
(
|
||||
"BIN2DEC BIN2HEX BIN2OCT DEC2BIN DEC2HEX DEC2OCT HEX2BIN HEX2DEC HEX2OCT OCT2BIN OCT2DEC OCT2HEX CONVERT DELTA GESTEP \
|
||||
ERF ERF.PRECISE ERFC ERFC.PRECISE BESSELI BESSELJ BESSELK BESSELY COMPLEX IMABS IMAGINARY IMARGUMENT IMCONJUGATE \
|
||||
IMCOS IMCOSH IMCOT IMCSC IMCSCH IMDIV IMEXP IMLN IMLOG10 IMLOG2 IMPOWER IMREAL IMSEC IMSECH IMSIN IMSINH IMSQRT IMSUB \
|
||||
IMTAN",
|
||||
&[true],
|
||||
),
|
||||
];
|
||||
|
||||
/// The value-parameter mask of `name` (empty for functions without value parameters).
|
||||
pub(crate) fn value_args(name: &str) -> &'static [bool] {
|
||||
VALUE_ARGS.iter().find(|(names, _)| names.split_ascii_whitespace().any(|n| n == name)).map_or(&[], |(_, mask)| mask)
|
||||
}
|
||||
|
||||
/// A reference to more than one cell at a value parameter becomes a scalar `#VALUE!`, so it adds
|
||||
/// no shape: `EOMONTH(A1:A3, {1;2})` is a 2x1 array of `#VALUE!`, as in Excel.
|
||||
fn reject_multi_cell_refs(spec: &FnSpec, args: &[Arg]) -> Option<Vec<Arg>> {
|
||||
let multi = |i: usize, a: &Arg| a.from_ref && spec.is_value_arg(i) && matches!(&a.value, Value::Array(arr) if arr.data.len() > 1);
|
||||
if !args.iter().enumerate().any(|(i, a)| multi(i, a)) {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
args.iter()
|
||||
.enumerate()
|
||||
.map(|(i, a)| if multi(i, a) { Arg { value: Value::Error(CellError::Value), from_ref: false } } else { a.clone() })
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn call_lifted(spec: &FnSpec, args: &[Arg], ctx: &mut dyn Ctx) -> Value {
|
||||
let rejected = if spec.value_args.is_empty() { None } else { reject_multi_cell_refs(spec, args) };
|
||||
let args = rejected.as_deref().unwrap_or(args);
|
||||
let mut rows = 0usize;
|
||||
let mut cols = 0usize;
|
||||
let mut any = false;
|
||||
@@ -94,4 +148,37 @@ mod tests {
|
||||
);
|
||||
is_err(huge, CellError::Num);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_params_reject_multi_cell_references() {
|
||||
let s = FnSpec { value_args: &[true, false], ..spec() };
|
||||
let mut ctx = TestCtx::default();
|
||||
// A multi-cell reference is #VALUE!; a one-cell reference and a computed array are fine.
|
||||
is_err(crate::call(&s, &[rf(col(&[1.0, 2.0, 3.0])), n(1.0)], &mut ctx), CellError::Value);
|
||||
assert_eq!(crate::call(&s, &[rf(col(&[4.0])), n(1.0)], &mut ctx), nv(5.0));
|
||||
assert_eq!(rows_of(&crate::call(&s, &[av(col(&[1.0, 2.0])), n(1.0)], &mut ctx)), vec![vec![nv(2.0)], vec![nv(3.0)]]);
|
||||
// The rejected reference adds no shape: the other argument still lifts.
|
||||
let v = Value::Error(CellError::Value);
|
||||
assert_eq!(rows_of(&crate::call(&s, &[rf(col(&[1.0, 2.0, 3.0])), av(col(&[1.0, 2.0]))], &mut ctx)), vec![vec![v.clone()], vec![v]]);
|
||||
// A position that isn't a value parameter still lifts the reference.
|
||||
assert_eq!(rows_of(&crate::call(&s, &[n(1.0), rf(row(&[1.0, 2.0]))], &mut ctx)), vec![vec![nv(2.0), nv(3.0)]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_arg_table_names_registered_scalar_positions() {
|
||||
for (names, mask) in super::VALUE_ARGS {
|
||||
assert!(!mask.is_empty());
|
||||
for name in names.split_ascii_whitespace() {
|
||||
let Some(s) = crate::lookup(name) else { panic!("{name} isn't registered") };
|
||||
assert_eq!(s.value_args, *mask, "{name}");
|
||||
for i in 0..s.max_args.min(12) {
|
||||
assert!(!s.is_value_arg(i) || s.is_scalar_arg(i), "{name} argument {i} is a value parameter but doesn't lift");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(crate::lookup("WORKDAY").is_some_and(|s| s.is_value_arg(1) && !s.is_value_arg(2)));
|
||||
assert!(crate::lookup("WORKDAY.INTL").is_some_and(|s| !s.is_value_arg(2) && s.is_scalar_arg(2)));
|
||||
assert!(crate::lookup("SUM").is_some_and(|s| s.value_args.is_empty()));
|
||||
assert!(crate::lookup("ISOWEEKNUM").is_some_and(|s| s.value_args.is_empty()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ This improves series preparation; it does not change the model-fitting or season
|
||||
| SUBTOTAL / AGGREGATE input size | references trim to the used range; all areas, arrays and values of one call are charged against the 16-million-cell limit before any cell is read (skipped hidden, nested and error cells count), and `#NUM!` past it (#2175) |
|
||||
| Implicit intersection `@`, legacy CSE array formulas | done; legacy array range fill (PR #156) |
|
||||
| Implicit intersection `@`, legacy CSE array formulas | done, the entered range filled (PR #156) |
|
||||
| Array lifting: an array in a scalar argument evaluates the function per element | done. The Analysis ToolPak functions (EOMONTH, EDATE, WORKDAY(.INTL), NETWORKDAYS(.INTL), YEARFRAC, WEEKNUM, the COUP*, PRICE*, YIELD*, ODD*, TBILL*, ACCRINT*, AMOR*, DISC, DURATION, CUMIPMT/CUMPRINC, EFFECT, NOMINAL, DOLLARDE/FR, XNPV/XIRR rate and guess, FVSCHEDULE principal, ISEVEN/ISODD, QUOTIENT, MROUND, FACTDOUBLE, SQRTPI, RANDBETWEEN, SERIESSUM, the base conversions, CONVERT, DELTA, GESTEP, ERF/ERFC, BESSEL*, COMPLEX and IM* except IMSUM/IMPRODUCT) give `#VALUE!` for a reference to more than one cell in a value argument, as Excel 365 does in both dynamic-array and legacy formulas; array constants and computed arrays (`A1:A3+0`) still lift, and range arguments (holidays, schedule, cash flows) and the .INTL weekend accept ranges. Difference: BYROW/BYCOL pass their row or column to the LAMBDA as values, so `BYROW(A1:B3,LAMBDA(r,EOMONTH(r,0)))` lifts where Excel gives `#VALUE!` |
|
||||
| Trim references `A1:.E10` (trailing), `A1.:E10` (leading), `A1.:.E10` (both), also on whole rows/columns (`A:.A`) | done; evaluated as `TRIMRANGE(range, code, code)`, adjusted on insert/delete; files store `_xlfn.TRIMRANGE(range,code,code)` (Excel's own file spelling unconfirmed in public docs), `_xlfn._TRO_ALL/_TRO_LEADING/_TRO_TRAILING(...)` read too |
|
||||
| LET, LAMBDA, named LAMBDAs, ISOMITTED, MAP/REDUCE/SCAN/BYROW/BYCOL/MAKEARRAY | done |
|
||||
| Iterative calculation for circular references | done: with Enable Iterative Calculation on (`formulas.calculationOptions` `{iterative, maxIterations, maxChange}`, saved as `<calcPr iterate iterateCount iterateDelta>`), loops are evaluated again from their previous values (blank = 0) until no cell on a loop changes by Maximum Change or Maximum Iterations is reached; off, cycles give `#CIRC!`. Differences: no Options dialog yet (command only); GridCraft recalculates on open, so a loop that never settles (`=A1+1`) advances again each time the file is opened |
|
||||
@@ -130,6 +131,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 | 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) |
|
||||
| 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) |
|
||||
|
||||
Reference in New Issue
Block a user