Files
gridcraft/crates/engine/tests/it/long_operator_chains.rs
T
aiongg a7608ada6d CI: one test binary per crate, nextest, line tables only (#2372)
* CI: dev builds keep line tables only

* CI: one integration-test binary per crate

* CI: xtask runs tests with nextest, doctests apart, --keep-going on clippy and wasm

cargo xtask test runs the workspace tests (--lib --bins --tests) with cargo-nextest when it is installed, else cargo test, then the doctests. cargo xtask ci uses the same two steps.

* CI: docs name cargo xtask test

* CI: agent guides name the moved fuzz test

* CI: tests added on main join their crate's test binary
2026-10-23 12:59:46 -04:00

88 lines
4.9 KiB
Rust

//! Flat operator chains as long as Excel allows (#916). `=1+1+…` with 4,096 operands is
//! exactly 8,192 characters, Excel's formula limit, and builds a tree 4,095 levels deep on its
//! left. Entering, editing, saving and reopening such a formula must neither overflow the stack
//! nor fall back to an error: Excel computes it.
use gridcraft_core::{CellRef, Value};
use gridcraft_engine::{DocState, Session, io};
use serde_json::json;
/// Excel's longest flat chain of literals: 4,096 operands, 8,192 characters with the `=`.
fn literal_chain(op: &str) -> String {
format!("={}", vec!["1"; 4096].join(op))
}
/// `=A1+A2+…+A1500`: 7,893 characters, inside Excel's limit.
fn reference_chain() -> String {
format!("={}", (1..=1500).map(|r| format!("A{r}")).collect::<Vec<_>>().join("+"))
}
fn value(session: &Session, cell: &str) -> Value {
let at = CellRef::parse(cell).expect("cell");
session.doc().expect("doc").wb.sheet(0).expect("sheet").value(at)
}
/// Runs `f` on a 1 MiB stack, the size of the Windows main thread the desktop app runs on.
fn on_small_stack(f: impl FnOnce() + Send + 'static) {
std::thread::Builder::new().stack_size(1 << 20).spawn(f).expect("spawn").join().expect("long chains must not overflow the stack");
}
#[test]
fn longest_flat_chains_survive_editing_saving_and_reopening() {
on_small_stack(|| {
let mut session = Session::new();
session.new_workbook();
session.execute("range.setValues", json!({"range": "A1:A1500", "values": vec![[1]; 1500]})).expect("values");
session.execute("cell.set", json!({"cell": "C1", "input": literal_chain("+")})).expect("plus chain");
session.execute("cell.set", json!({"cell": "C2", "input": literal_chain("-")})).expect("minus chain");
session.execute("cell.set", json!({"cell": "C3", "input": literal_chain("&")})).expect("concat chain");
session.execute("cell.set", json!({"cell": "C4", "input": reference_chain()})).expect("reference chain");
assert_eq!(value(&session, "C1"), Value::Number(4096.0));
assert_eq!(value(&session, "C2"), Value::Number(-4094.0));
assert_eq!(value(&session, "C3"), Value::text("1".repeat(4096)));
assert_eq!(value(&session, "C4"), Value::Number(1500.0));
// Precedents of a long reference chain still drive recalculation.
session.execute("cell.set", json!({"cell": "A750", "input": "101"})).expect("edit precedent");
assert_eq!(value(&session, "C4"), Value::Number(1600.0));
// Evaluate Formula walks every operator of the chain.
let steps = session.execute("formulas.evaluateFormula", json!({"cell": "C4"})).expect("evaluate formula");
assert_eq!(steps["steps"].as_array().map(Vec::len), Some(200));
assert_eq!(steps["result"], json!(1600.0));
assert_eq!(session.execute("formulas.evaluate", json!({"formula": literal_chain("+")})).expect("evaluate"), json!(4096.0));
// Copy and paste move every reference (Excel: the pasted C4 sums A2:A1501).
session.execute("edit.copy", json!({"range": "C4"})).expect("copy");
session.execute("edit.paste", json!({"at": "C5"})).expect("paste");
assert_eq!(value(&session, "C5"), Value::Number(1599.0));
session.execute("edit.undo", json!({})).expect("undo paste");
// Fill and structural edits rewrite every reference in the chain (Excel: an inserted
// row above shifts A1:A1500 to A2:A1501; the sum is unchanged).
session.execute("edit.fillRight", json!({"range": "C1:D4"})).expect("fill right");
assert_eq!(value(&session, "D1"), Value::Number(4096.0));
assert_eq!(value(&session, "D4"), Value::Number(0.0));
session.execute("home.insertRows", json!({"rows": "1:1"})).expect("insert row");
assert_eq!(value(&session, "C5"), Value::Number(1600.0));
session.execute("home.insertColumns", json!({"cols": "A:A"})).expect("insert column");
assert_eq!(value(&session, "D5"), Value::Number(1600.0));
session.execute("edit.undo", json!({})).expect("undo");
session.execute("edit.undo", json!({})).expect("undo");
assert_eq!(value(&session, "C4"), Value::Number(1600.0));
session.execute("edit.redo", json!({})).expect("redo");
assert_eq!(value(&session, "C5"), Value::Number(1600.0));
// XLSX round trip keeps the formulas and their values.
let wb = &session.doc().expect("doc").wb;
let bytes = io::save_bytes(wb, "chains.xlsx").expect("save");
let (reopened, _) = io::open_bytes("chains.xlsx", &bytes).expect("open");
let mut session = Session::new();
session.add_document(DocState::new(reopened, None, "chains".into()));
session.execute("formulas.calculateNow", json!({})).expect("recalc");
assert_eq!(value(&session, "C2"), Value::Number(4096.0));
assert_eq!(value(&session, "C3"), Value::Number(-4094.0));
assert_eq!(value(&session, "C5"), Value::Number(1600.0));
});
}