338 lines
14 KiB
Rust
338 lines
14 KiB
Rust
//! Array evaluation must survive export even when a formula returns one value.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use gridcraft_core::{CellRef, RangeRef, Value};
|
|
use gridcraft_model::{Cell, DefinedName, Formula, Sheet, Table, TableColumn, TotalsFn, Workbook};
|
|
|
|
use crate::package::Package;
|
|
use crate::xml::El;
|
|
use crate::{read_xlsx, write_xlsx};
|
|
|
|
fn at(s: &str) -> CellRef {
|
|
CellRef::parse(s).unwrap()
|
|
}
|
|
|
|
fn range(s: &str) -> RangeRef {
|
|
RangeRef::parse(s).unwrap()
|
|
}
|
|
|
|
fn formula(text: &str, value: f64) -> Cell {
|
|
let mut cell = Cell::formula(Formula::new(text));
|
|
cell.value = Value::Number(value);
|
|
cell
|
|
}
|
|
|
|
fn cell<'a>(sheet: &'a El, address: &str) -> &'a El {
|
|
sheet.child("sheetData").unwrap().kids("row").flat_map(|r| r.kids("c")).find(|c| c.attr("r") == Some(address)).unwrap()
|
|
}
|
|
|
|
fn assert_scalar(sheet: &El, address: &str) {
|
|
let c = cell(sheet, address);
|
|
assert_eq!(c.attr("cm"), None, "scalar formula must not have dynamic metadata at {address}");
|
|
let f = c.child("f").unwrap();
|
|
assert_eq!(f.attr("t"), None);
|
|
assert_eq!(f.attr("ref"), None);
|
|
}
|
|
|
|
fn assert_dynamic(sheet: &El, address: &str, reference: &str) {
|
|
let c = cell(sheet, address);
|
|
assert_eq!(c.attr("cm"), Some("1"), "array evaluation metadata at {address}");
|
|
let f = c.child("f").unwrap();
|
|
assert_eq!(f.attr("t"), Some("array"));
|
|
assert_eq!(f.attr("ref"), Some(reference));
|
|
}
|
|
|
|
#[test]
|
|
fn nonspilling_formulas_preserve_array_evaluation_and_metadata() {
|
|
let mut wb = Workbook::new();
|
|
let data = wb.sheet_mut(0).unwrap();
|
|
data.name = "Data".into();
|
|
for (address, value) in [("A1", 1.0), ("A2", 4.0), ("A3", 9.0), ("B1", 2.0), ("B2", 3.0), ("B3", 4.0)] {
|
|
data.cells.set(at(address), Cell::value(Value::Number(value)));
|
|
}
|
|
let cases = [("B107", "SUM(Data!A1:A3*Data!B1:B3)", 50.0), ("B108", "SUM(SQRT(Data!A1:A3))", 6.0), ("B109", "SUM(ABS(Data!A1:A3))", 14.0)];
|
|
let mut results = Sheet::new("Results");
|
|
for (address, text, value) in cases {
|
|
results.cells.set(at(address), formula(text, value));
|
|
}
|
|
results.cells.set(at("C107"), Cell::value(Value::text("neighbor")));
|
|
assert!(results.spill_ranges.is_empty());
|
|
wb.sheets.push(Arc::new(results));
|
|
|
|
let bytes = write_xlsx(&wb).unwrap();
|
|
let mut pkg = Package::open(&bytes).unwrap();
|
|
let sheet = pkg.read_xml("xl/worksheets/sheet2.xml").unwrap().unwrap();
|
|
for (address, text, _) in cases {
|
|
assert_dynamic(&sheet, address, address);
|
|
assert_eq!(cell(&sheet, address).child("f").unwrap().text, text);
|
|
}
|
|
assert_eq!(cell(&sheet, "C107").attr("cm"), None);
|
|
|
|
let metadata = pkg.read_xml("xl/metadata.xml").unwrap().unwrap();
|
|
let kind = metadata.path(&["metadataTypes", "metadataType"]).unwrap();
|
|
assert_eq!(kind.attr("name"), Some("XLDAPR"));
|
|
assert_eq!(kind.attr("cellMeta"), Some("1"));
|
|
let dynamic = metadata.path(&["futureMetadata", "bk", "extLst", "ext", "dynamicArrayProperties"]).unwrap();
|
|
assert_eq!(dynamic.attr("fDynamic"), Some("1"));
|
|
assert_eq!(dynamic.attr("fCollapsed"), Some("0"));
|
|
let record = metadata.path(&["cellMetadata", "bk", "rc"]).unwrap();
|
|
assert_eq!((record.attr("t"), record.attr("v")), (Some("1"), Some("0")));
|
|
let types = pkg.read_xml("[Content_Types].xml").unwrap().unwrap();
|
|
assert!(types.kids("Override").any(|e| e.attr("PartName") == Some("/xl/metadata.xml")
|
|
&& e.attr("ContentType") == Some("application/vnd.openxmlformats-officedocument.spreadsheetml.sheetMetadata+xml")));
|
|
assert!(pkg.rels("xl/workbook.xml").unwrap().iter().any(|r| r.kind == "sheetMetadata" && r.target == "xl/metadata.xml"));
|
|
|
|
let (back, report) = read_xlsx(&bytes).unwrap();
|
|
assert!(report.warnings.is_empty(), "{:?}", report.warnings);
|
|
let results = back.sheet(1).unwrap();
|
|
for (address, text, value) in cases {
|
|
let c = results.cell(at(address)).unwrap();
|
|
let f = c.formula.as_ref().unwrap();
|
|
assert_eq!(f.text, text);
|
|
assert_eq!(f.array, None, "dynamic formula must not become legacy CSE");
|
|
assert_eq!(c.value, Value::Number(value));
|
|
}
|
|
assert_eq!(results.value(at("C107")), Value::text("neighbor"));
|
|
}
|
|
|
|
#[test]
|
|
fn dynamic_spills_and_legacy_cse_keep_distinct_ranges() {
|
|
let mut wb = Workbook::new();
|
|
let sh = wb.sheet_mut(0).unwrap();
|
|
let mut legacy = Formula::new("ROW(A1:A2)");
|
|
legacy.array = Some(range("A1:A2"));
|
|
sh.cells.set(at("A1"), Cell::formula(legacy));
|
|
sh.cells.set(at("A2"), Cell::value(Value::Number(2.0)));
|
|
sh.cells.set(at("B1"), formula("SEQUENCE(3)", 1.0));
|
|
sh.spill_ranges.insert(at("B1"), range("B1:B3"));
|
|
sh.spill.insert(at("B2"), Value::Number(2.0));
|
|
sh.spill.insert(at("B3"), Value::Number(3.0));
|
|
sh.cells.set(at("C1"), formula("SUM(B1:B3)", 6.0));
|
|
|
|
let bytes = write_xlsx(&wb).unwrap();
|
|
let mut pkg = Package::open(&bytes).unwrap();
|
|
let sheet = pkg.read_xml("xl/worksheets/sheet1.xml").unwrap().unwrap();
|
|
assert_eq!(cell(&sheet, "A1").attr("cm"), None);
|
|
let legacy = cell(&sheet, "A1").child("f").unwrap();
|
|
assert_eq!((legacy.attr("t"), legacy.attr("ref")), (Some("array"), Some("A1:A2")));
|
|
assert_dynamic(&sheet, "B1", "B1:B3");
|
|
assert_scalar(&sheet, "C1");
|
|
assert_eq!(cell(&sheet, "B2").child("v").unwrap().text, "2");
|
|
assert_eq!(cell(&sheet, "B2").attr("cm"), None);
|
|
|
|
let (back, _) = read_xlsx(&bytes).unwrap();
|
|
let sh = back.sheet(0).unwrap();
|
|
assert_eq!(sh.cell(at("A1")).unwrap().formula.as_ref().unwrap().array, Some(range("A1:A2")));
|
|
// A legacy array's cached values are dropped on read: the formula fills its range on recalc (#156).
|
|
assert!(sh.cell(at("A2")).is_none());
|
|
assert_eq!(sh.cell(at("B1")).unwrap().formula.as_ref().unwrap().array, None);
|
|
assert!(sh.cell(at("B2")).is_none());
|
|
assert!(sh.cell(at("B3")).is_none());
|
|
assert_eq!(sh.value(at("C1")), Value::Number(6.0));
|
|
}
|
|
|
|
#[test]
|
|
fn scalar_formulas_constants_and_legacy_cse_do_not_require_dynamic_metadata() {
|
|
let mut wb = Workbook::new();
|
|
let sh = wb.sheet_mut(0).unwrap();
|
|
sh.name = "Data".into();
|
|
sh.cells.set(at("A1"), Cell::value(Value::Number(3.0)));
|
|
for (address, text, value) in [("C1", "1+2", 3.0), ("C2", "Data!A1", 3.0), ("C3", "SUM(A1:A3)", 3.0), ("C4", "A1*2", 6.0)] {
|
|
sh.cells.set(at(address), formula(text, value));
|
|
}
|
|
for legacy in [false, true] {
|
|
if legacy {
|
|
let mut f = Formula::new("SUM(A1:A2)");
|
|
f.array = Some(range("B1"));
|
|
wb.sheet_mut(0).unwrap().cells.set(at("B1"), Cell::formula(f));
|
|
}
|
|
let bytes = write_xlsx(&wb).unwrap();
|
|
let mut pkg = Package::open(&bytes).unwrap();
|
|
assert!(!pkg.has("xl/metadata.xml"));
|
|
assert!(!pkg.rels("xl/workbook.xml").unwrap().iter().any(|r| r.kind == "sheetMetadata"));
|
|
let types = pkg.read_xml("[Content_Types].xml").unwrap().unwrap();
|
|
assert!(!types.kids("Override").any(|e| e.attr("PartName") == Some("/xl/metadata.xml")));
|
|
let sheet = pkg.read_xml("xl/worksheets/sheet1.xml").unwrap().unwrap();
|
|
assert_eq!(cell(&sheet, "A1").attr("cm"), None);
|
|
for address in ["C1", "C2", "C3", "C4"] {
|
|
assert_scalar(&sheet, address);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn calculated_table_column_keeps_formula_and_cell_value() {
|
|
let mut wb = Workbook::new();
|
|
let sh = wb.sheet_mut(0).unwrap();
|
|
sh.cells.set(at("A2"), Cell::value(Value::Number(3.0)));
|
|
sh.cells.set(at("B2"), formula("A2*2", 6.0));
|
|
sh.tables.push(Table {
|
|
id: 1,
|
|
name: "Data".into(),
|
|
range: range("A1:B2"),
|
|
header_row: true,
|
|
totals_row: false,
|
|
columns: vec![
|
|
TableColumn { name: "Input".into(), totals: TotalsFn::None, totals_label: None, formula: None },
|
|
TableColumn { name: "Output".into(), totals: TotalsFn::None, totals_label: None, formula: Some("A2*2".into()) },
|
|
],
|
|
style: "TableStyleLight1".into(),
|
|
banded_rows: true,
|
|
banded_cols: false,
|
|
first_col: false,
|
|
last_col: false,
|
|
filter_button: true,
|
|
});
|
|
let bytes = write_xlsx(&wb).unwrap();
|
|
let mut pkg = Package::open(&bytes).unwrap();
|
|
let sheet = pkg.read_xml("xl/worksheets/sheet1.xml").unwrap().unwrap();
|
|
assert_scalar(&sheet, "B2");
|
|
assert_eq!(cell(&sheet, "B1").attr("cm"), None, "table headers are text");
|
|
let table = pkg.read_xml("xl/tables/table1.xml").unwrap().unwrap();
|
|
let column = table.child("tableColumns").unwrap().kids("tableColumn").nth(1).unwrap();
|
|
assert_eq!(column.child("calculatedColumnFormula").unwrap().text, "A2*2");
|
|
let (back, _) = read_xlsx(&bytes).unwrap();
|
|
let sh = back.sheet(0).unwrap();
|
|
assert_eq!(sh.tables[0].columns[1].formula.as_deref(), Some("A2*2"));
|
|
assert_eq!(sh.cell(at("B2")).unwrap().formula.as_ref().unwrap().array, None);
|
|
assert_eq!(sh.value(at("B2")), Value::Number(6.0));
|
|
}
|
|
|
|
/// Compare both cell and formula metadata, collecting all boundaries in the group on failure.
|
|
fn check_formula_metadata(mut wb: Workbook, sheet_index: usize, cases: &[(&str, bool)]) {
|
|
for (i, (text, _)) in cases.iter().enumerate() {
|
|
let f = Formula::new(text);
|
|
assert!(f.expr().is_some(), "test formula must parse: {text}");
|
|
wb.sheet_mut(sheet_index).unwrap().cells.set(CellRef::new(i as u32 + 100, 5), Cell::formula(f));
|
|
}
|
|
let bytes = write_xlsx(&wb).unwrap();
|
|
let mut pkg = Package::open(&bytes).unwrap();
|
|
let sheet = pkg.read_xml(&format!("xl/worksheets/sheet{}.xml", sheet_index + 1)).unwrap().unwrap();
|
|
let mut mismatches = vec![];
|
|
for (i, (text, dynamic)) in cases.iter().enumerate() {
|
|
let address = CellRef::new(i as u32 + 100, 5).to_string();
|
|
let c = cell(&sheet, &address);
|
|
let f = c.child("f").unwrap();
|
|
let expected = if *dynamic { (Some("1"), Some("array"), Some(address.as_str())) } else { (None, None, None) };
|
|
let actual = (c.attr("cm"), f.attr("t"), f.attr("ref"));
|
|
if actual != expected {
|
|
mismatches.push(format!("{text}: expected {expected:?}, got {actual:?}"));
|
|
}
|
|
}
|
|
assert!(mismatches.is_empty(), "{}", mismatches.join("\n"));
|
|
}
|
|
|
|
#[test]
|
|
fn implicit_intersection_metadata_respects_function_argument_context() {
|
|
check_formula_metadata(
|
|
Workbook::new(),
|
|
0,
|
|
&[
|
|
("SQRT(A1:A3)", true),
|
|
("ABS(A1:A3)", true),
|
|
("SUM(A1:A3*B1:B3)", true),
|
|
("SUM(SQRT(A1:A3))", true),
|
|
("SUM(A1:A3)", false),
|
|
("SUMPRODUCT(A1:A3*B1:B3)", false),
|
|
("SUMPRODUCT(SQRT(A1:A3))", false),
|
|
("SQRT(@A1:A3)", false),
|
|
("@A1:A3*2", false),
|
|
("@(A1:A3*2)", true),
|
|
("SQRT(INDIRECT(\"A1\"))", false),
|
|
("SUM(INDIRECT(\"A1:A3\"))", false),
|
|
("SQRT(INDIRECT(\"A1:A3\"))", true),
|
|
("SUM(INDIRECT(A1))", false),
|
|
("SQRT(INDIRECT(A1))", true),
|
|
("INDEX(A1:B3,1,1)", false),
|
|
("INDEX(A1:A3,1)", false),
|
|
("SUM(INDEX(A1:B3,0,1))", false),
|
|
("SQRT(INDEX(A1:B3,0,1))", true),
|
|
("SUM(ROW(A1:A3))", true),
|
|
("SUMPRODUCT(ROW(A1:A3))", false),
|
|
("OFFSET(A1,0,0,1,1)*2", false),
|
|
("SUM(OFFSET(A1,0,0,3,1)*2)", true),
|
|
("SUM(OFFSET(A1,0,0,3,1))", false),
|
|
("LET(x,A1:A3,SUM(x))", false),
|
|
("LET(x,A1:A3,SUM(x*2))", true),
|
|
("XLOOKUP(1,A1:A3,B1:B3)", false),
|
|
("XLOOKUP(1,A1:A3,B1:C3)", true),
|
|
],
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn implicit_intersection_metadata_resolves_names_in_formula_sheet_scope() {
|
|
let mut wb = Workbook::new();
|
|
wb.sheets.push(Arc::new(Sheet::new("Results")));
|
|
for (name, scope, text) in [
|
|
("Rate", None, "2"),
|
|
("Inputs", None, "Sheet1!$A$1:$A$3"),
|
|
("Single", None, "Sheet1!$A$1"),
|
|
("Alias", None, "Inputs"),
|
|
("Scoped", None, "Sheet1!$A$1:$A$3"),
|
|
("Scoped", Some(0), "2"),
|
|
("LocalInputs", Some(1), "Results!$A$1:$A$3"),
|
|
] {
|
|
wb.names.push(DefinedName { name: name.into(), scope, formula: text.into(), comment: String::new(), hidden: false });
|
|
}
|
|
check_formula_metadata(
|
|
wb.clone(),
|
|
0,
|
|
&[
|
|
("Rate", false),
|
|
("SQRT(Rate)", false),
|
|
("Single*2", false),
|
|
("Inputs", true),
|
|
("SUM(Inputs)", false),
|
|
("SUM(Inputs*2)", true),
|
|
("SUM(SQRT(Alias))", true),
|
|
("SQRT(Scoped)", false),
|
|
],
|
|
);
|
|
check_formula_metadata(wb, 1, &[("SQRT(Scoped)", true), ("SUM(LocalInputs)", false), ("LocalInputs*2", true)]);
|
|
}
|
|
|
|
#[test]
|
|
fn cyclic_names_preserve_array_evaluation_without_unbounded_export_recursion() {
|
|
let mut wb = Workbook::new();
|
|
for (name, formula) in [("First", "Second"), ("Second", "First")] {
|
|
wb.names.push(DefinedName { name: name.into(), scope: None, formula: formula.into(), comment: String::new(), hidden: false });
|
|
}
|
|
check_formula_metadata(wb, 0, &[("First", true), ("SUM(First)", true)]);
|
|
}
|
|
|
|
#[test]
|
|
fn implicit_intersection_metadata_distinguishes_table_rows_and_columns() {
|
|
let mut wb = Workbook::new();
|
|
let sh = wb.sheet_mut(0).unwrap();
|
|
sh.tables.push(Table {
|
|
id: 1,
|
|
name: "Data".into(),
|
|
range: range("A1:B3"),
|
|
header_row: true,
|
|
totals_row: false,
|
|
columns: vec![
|
|
TableColumn { name: "Input".into(), totals: TotalsFn::None, totals_label: None, formula: None },
|
|
TableColumn { name: "Output".into(), totals: TotalsFn::None, totals_label: None, formula: Some("[@Input]*2".into()) },
|
|
],
|
|
style: "TableStyleLight1".into(),
|
|
banded_rows: true,
|
|
banded_cols: false,
|
|
first_col: false,
|
|
last_col: false,
|
|
filter_button: true,
|
|
});
|
|
sh.cells.set(at("A2"), Cell::value(Value::Number(3.0)));
|
|
sh.cells.set(at("B2"), formula("[@Input]*2", 6.0));
|
|
let bytes = write_xlsx(&wb).unwrap();
|
|
let mut pkg = Package::open(&bytes).unwrap();
|
|
let sheet = pkg.read_xml("xl/worksheets/sheet1.xml").unwrap().unwrap();
|
|
assert_scalar(&sheet, "B2");
|
|
check_formula_metadata(
|
|
wb,
|
|
0,
|
|
&[("Data[@Input]*2", false), ("SUM(Data[Input])", false), ("SUM(Data[Input]*2)", true), ("SQRT(Data[Input])", true)],
|
|
);
|
|
}
|