Engine: command-sequence fuzz harness (undo/redo, XLSX round-trip, full-recalc invariants) and the bugs it found
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
13afa8c773
commit
828efab385
|
|
@ -26,7 +26,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
|
||||||
- **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): `get()` instead of indexing, checked/saturating arithmetic, cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references).
|
- **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): `get()` instead of indexing, checked/saturating arithmetic, cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references).
|
||||||
- **Bound recursion** (formula nesting, dependency chains, names referring to names).
|
- **Bound recursion** (formula nesting, dependency chains, names referring to names).
|
||||||
- **Last-resort guard:** `Session::execute` catches escaped panics and restores the workbook. It's a safety net, not a licence.
|
- **Last-resort guard:** `Session::execute` catches escaped panics and restores the workbook. It's a safety net, not a licence.
|
||||||
- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command).
|
- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`).
|
||||||
- Crates carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
|
- Crates carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
|
||||||
|
|
||||||
## Non-negotiables
|
## Non-negotiables
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
|
||||||
- **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): `get()` instead of indexing, checked/saturating arithmetic, cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references).
|
- **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): `get()` instead of indexing, checked/saturating arithmetic, cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references).
|
||||||
- **Bound recursion** (formula nesting, dependency chains, names referring to names).
|
- **Bound recursion** (formula nesting, dependency chains, names referring to names).
|
||||||
- **Last-resort guard:** `Session::execute` catches escaped panics and restores the workbook. It's a safety net, not a licence.
|
- **Last-resort guard:** `Session::execute` catches escaped panics and restores the workbook. It's a safety net, not a licence.
|
||||||
- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command).
|
- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`).
|
||||||
- Crates carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
|
- Crates carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
|
||||||
|
|
||||||
## Non-negotiables
|
## Non-negotiables
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||||
|
|
||||||
| Date | What landed |
|
| Date | What landed |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found |
|
||||||
| 2026-10-11 | Text boxes keep their formatting: run size, bold, italic, typeface, text colour and outline, alignment, vertical anchor and "no outline" (#377) |
|
| 2026-10-11 | Text boxes keep their formatting: run size, bold, italic, typeface, text colour and outline, alignment, vertical anchor and "no outline" (#377) |
|
||||||
| 2026-10-11 | Desktop printing opens the print PDF in the system viewer; Save As offers CSV, web page and PDF; macOS File › Properties opens File › Info (#370, #343) |
|
| 2026-10-11 | Desktop printing opens the print PDF in the system viewer; Save As offers CSV, web page and PDF; macOS File › Properties opens File › Info (#370, #343) |
|
||||||
| 2026-10-11 | Windows: DirectX 12 shaders compile with FXC, so a missing or stray `dxcompiler.dll` no longer stops the app at start (#339, #354) |
|
| 2026-10-11 | Windows: DirectX 12 shaders compile with FXC, so a missing or stray `dxcompiler.dll` no longer stops the app at start (#339, #354) |
|
||||||
|
|
|
||||||
|
|
@ -210,10 +210,17 @@ fn cell_set(s: &mut Session, p: &Json) -> Result<Json> {
|
||||||
{
|
{
|
||||||
return Err(EngineError::Other(m));
|
return Err(EngineError::Other(m));
|
||||||
}
|
}
|
||||||
|
let active_sheet = s.doc()?.wb.active_sheet;
|
||||||
edit(s, |cx| {
|
edit(s, |cx| {
|
||||||
if array {
|
if array {
|
||||||
// Legacy Ctrl+Shift+Enter array formula over the selection.
|
// Legacy Ctrl+Shift+Enter array formula over the selection, when the selection holds
|
||||||
let range = cx.sel.current();
|
// the cell; a cell elsewhere (or on another sheet) gets a one-cell array.
|
||||||
|
let selected = cx.sel.current();
|
||||||
|
let range = if sheet == active_sheet && selected.contains(at) { selected } else { RangeRef::cell(at) };
|
||||||
|
// A table header can't hold an array's value, and a table resizes under it.
|
||||||
|
if !range.is_single() && cx.wb.sheet(sheet).is_some_and(|sh| sh.tables.iter().any(|t| t.range.intersects(&range))) {
|
||||||
|
return Err(EngineError::Other("Multi-cell array formulas aren't allowed in tables.".into()));
|
||||||
|
}
|
||||||
let mut cell = input_to_cell(&input, sheet, range.start, &mut cx.wb)?.unwrap_or_default();
|
let mut cell = input_to_cell(&input, sheet, range.start, &mut cx.wb)?.unwrap_or_default();
|
||||||
if let Some(f) = cell.formula.as_mut() {
|
if let Some(f) = cell.formula.as_mut() {
|
||||||
std::sync::Arc::make_mut(f).array = Some(range);
|
std::sync::Arc::make_mut(f).array = Some(range);
|
||||||
|
|
@ -578,6 +585,7 @@ fn undo(s: &mut Session, p: &Json) -> Result<Json> {
|
||||||
let steps = u32_param(p, "steps").unwrap_or(1).max(1);
|
let steps = u32_param(p, "steps").unwrap_or(1).max(1);
|
||||||
let d = s.doc_mut()?;
|
let d = s.doc_mut()?;
|
||||||
let mut label = String::new();
|
let mut label = String::new();
|
||||||
|
let now = d.wb.clone();
|
||||||
for _ in 0..steps {
|
for _ in 0..steps {
|
||||||
let Some(e) = d.undo.pop() else { break };
|
let Some(e) = d.undo.pop() else { break };
|
||||||
let cur = crate::HistoryEntry { label: e.label.clone(), wb: d.wb.clone(), selection: d.selection.clone() };
|
let cur = crate::HistoryEntry { label: e.label.clone(), wb: d.wb.clone(), selection: d.selection.clone() };
|
||||||
|
|
@ -586,6 +594,7 @@ fn undo(s: &mut Session, p: &Json) -> Result<Json> {
|
||||||
d.wb = e.wb;
|
d.wb = e.wb;
|
||||||
d.selection = e.selection;
|
d.selection = e.selection;
|
||||||
}
|
}
|
||||||
|
super::extra::keep_watch_list(&now, &mut d.wb);
|
||||||
d.calc.rebuild(&d.wb);
|
d.calc.rebuild(&d.wb);
|
||||||
d.revision += 1;
|
d.revision += 1;
|
||||||
Ok(json!({"undone": label}))
|
Ok(json!({"undone": label}))
|
||||||
|
|
@ -595,6 +604,7 @@ fn redo(s: &mut Session, p: &Json) -> Result<Json> {
|
||||||
let steps = u32_param(p, "steps").unwrap_or(1).max(1);
|
let steps = u32_param(p, "steps").unwrap_or(1).max(1);
|
||||||
let d = s.doc_mut()?;
|
let d = s.doc_mut()?;
|
||||||
let mut label = String::new();
|
let mut label = String::new();
|
||||||
|
let now = d.wb.clone();
|
||||||
for _ in 0..steps {
|
for _ in 0..steps {
|
||||||
let Some(e) = d.redo.pop() else { break };
|
let Some(e) = d.redo.pop() else { break };
|
||||||
let cur = crate::HistoryEntry { label: e.label.clone(), wb: d.wb.clone(), selection: d.selection.clone() };
|
let cur = crate::HistoryEntry { label: e.label.clone(), wb: d.wb.clone(), selection: d.selection.clone() };
|
||||||
|
|
@ -603,6 +613,7 @@ fn redo(s: &mut Session, p: &Json) -> Result<Json> {
|
||||||
d.wb = e.wb;
|
d.wb = e.wb;
|
||||||
d.selection = e.selection;
|
d.selection = e.selection;
|
||||||
}
|
}
|
||||||
|
super::extra::keep_watch_list(&now, &mut d.wb);
|
||||||
d.calc.rebuild(&d.wb);
|
d.calc.rebuild(&d.wb);
|
||||||
d.revision += 1;
|
d.revision += 1;
|
||||||
Ok(json!({"redone": label}))
|
Ok(json!({"redone": label}))
|
||||||
|
|
@ -972,7 +983,9 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||||
e
|
e
|
||||||
};
|
};
|
||||||
let mut moved = Formula::from_expr(e);
|
let mut moved = Formula::from_expr(e);
|
||||||
moved.array = f.array;
|
// The array range moves with its formula.
|
||||||
|
let (dr, dc) = (dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64);
|
||||||
|
moved.array = f.array.and_then(|a| Some(RangeRef::new(a.start.offset(dr, dc)?, a.end.offset(dr, dc)?)));
|
||||||
return Some(std::sync::Arc::new(moved));
|
return Some(std::sync::Arc::new(moved));
|
||||||
}
|
}
|
||||||
let moved =
|
let moved =
|
||||||
|
|
@ -1139,6 +1152,10 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||||
|
|
||||||
/// Applies a reference adjustment to every cell formula and defined name in the workbook.
|
/// Applies a reference adjustment to every cell formula and defined name in the workbook.
|
||||||
pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &str, e: &gridcraft_formula::adjust::Edit) {
|
pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &str, e: &gridcraft_formula::adjust::Edit) {
|
||||||
|
use gridcraft_formula::adjust::Edit;
|
||||||
|
// Rows and columns inserted or deleted on the target sheet move its legacy array ranges too
|
||||||
|
// (a cut moves its arrays itself).
|
||||||
|
let moves_arrays = matches!(e, Edit::Insert { .. } | Edit::Delete { .. } | Edit::InsertCells { .. } | Edit::DeleteCells { .. });
|
||||||
for si in 0..wb.sheets.len() {
|
for si in 0..wb.sheets.len() {
|
||||||
let host = wb.sheets.get(si).map(|s| s.name.clone()).unwrap_or_default();
|
let host = wb.sheets.get(si).map(|s| s.name.clone()).unwrap_or_default();
|
||||||
let keys: Vec<(CellRef, std::sync::Arc<Formula>)> =
|
let keys: Vec<(CellRef, std::sync::Arc<Formula>)> =
|
||||||
|
|
@ -1146,16 +1163,21 @@ pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &
|
||||||
if keys.is_empty() {
|
if keys.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
let on_target = moves_arrays && host.eq_ignore_ascii_case(target);
|
||||||
let Some(sheet) = wb.sheet_mut(si) else { continue };
|
let Some(sheet) = wb.sheet_mut(si) else { continue };
|
||||||
for (c, f) in keys {
|
for (c, f) in keys {
|
||||||
let Some(expr) = f.expr() else { continue };
|
let Some(expr) = f.expr() else { continue };
|
||||||
let new = gridcraft_formula::adjust::adjust(expr, &host, target, e);
|
let new = gridcraft_formula::adjust::adjust(expr, &host, target, e);
|
||||||
let text = gridcraft_formula::print(&new);
|
let text = gridcraft_formula::print(&new);
|
||||||
if text != f.text
|
let array = match f.array {
|
||||||
|
Some(a) if on_target => super::sheet::map_range(a, e),
|
||||||
|
a => a,
|
||||||
|
};
|
||||||
|
if (text != f.text || array != f.array)
|
||||||
&& let Some(cell) = sheet.cells.get_mut(c)
|
&& let Some(cell) = sheet.cells.get_mut(c)
|
||||||
{
|
{
|
||||||
let mut nf = Formula::from_expr(new);
|
let mut nf = Formula::from_expr(new);
|
||||||
nf.array = f.array;
|
nf.array = array;
|
||||||
cell.formula = Some(std::sync::Arc::new(nf));
|
cell.formula = Some(std::sync::Arc::new(nf));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1554,6 +1576,22 @@ mod tests {
|
||||||
assert_eq!(bar.as_deref(), Some("{=B2:B3*2}"));
|
assert_eq!(bar.as_deref(), Some("{=B2:B3*2}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_array_entered_outside_the_selection_covers_only_its_cell() {
|
||||||
|
// Found by the command-sequence fuzzer: the array took the selection's range even with
|
||||||
|
// `cell` elsewhere (or on another sheet), so its formula sat outside its own range and the
|
||||||
|
// selected cells were wiped.
|
||||||
|
let mut s = Session::new();
|
||||||
|
s.new_workbook();
|
||||||
|
s.execute("cell.set", json!({"cell": "K20", "input": "keep"})).unwrap();
|
||||||
|
s.execute("selection.set", json!({"range": "K20:K22"})).unwrap();
|
||||||
|
s.execute("cell.set", json!({"cell": "H5", "input": "=1+1", "array": true})).unwrap();
|
||||||
|
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||||
|
let f = sh.cell(CellRef::parse("H5").unwrap()).and_then(|c| c.formula.clone()).unwrap();
|
||||||
|
assert_eq!(f.array, Some(RangeRef::parse("H5").unwrap()));
|
||||||
|
assert_eq!(sh.value(CellRef::parse("K20").unwrap()), Value::text("keep"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn wildcards() {
|
fn wildcards() {
|
||||||
assert!(wildcard("hello", "h*o"));
|
assert!(wildcard("hello", "h*o"));
|
||||||
|
|
|
||||||
|
|
@ -651,6 +651,19 @@ fn stash_set(wb: &mut Workbook, key: &str, v: &Json) {
|
||||||
wb.names.push(DefinedName { name: key.into(), scope: None, formula, comment: String::new(), hidden: true });
|
wb.names.push(DefinedName { name: key.into(), scope: None, formula, comment: String::new(), hidden: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The Watch Window list isn't an edit (Excel doesn't undo it): undo and redo keep the current
|
||||||
|
/// list when they swap in another snapshot of the workbook.
|
||||||
|
pub(crate) fn keep_watch_list(from: &Workbook, to: &mut std::sync::Arc<Workbook>) {
|
||||||
|
let find = |wb: &Workbook| wb.names.iter().find(|n| n.name == WATCH_NAME).cloned();
|
||||||
|
let keep = find(from);
|
||||||
|
if keep == find(to) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let wb = std::sync::Arc::make_mut(to);
|
||||||
|
wb.names.retain(|n| n.name != WATCH_NAME);
|
||||||
|
wb.names.extend(keep);
|
||||||
|
}
|
||||||
|
|
||||||
fn custom_views(s: &mut Session, p: &Json) -> Result<Json> {
|
fn custom_views(s: &mut Session, p: &Json) -> Result<Json> {
|
||||||
let d = s.doc()?;
|
let d = s.doc()?;
|
||||||
let mut views = stash_get(&d.wb, VIEWS_NAME);
|
let mut views = stash_get(&d.wb, VIEWS_NAME);
|
||||||
|
|
|
||||||
|
|
@ -561,8 +561,11 @@ fn merge(s: &mut Session, p: &Json, how: &str) -> Result<Json> {
|
||||||
if how == "center" && s.doc()?.wb.sheet(sheet).is_some_and(|sh| ranges.iter().all(|r| sh.merges.contains(r))) { "unmerge" } else { how };
|
if how == "center" && s.doc()?.wb.sheet(sheet).is_some_and(|sh| ranges.iter().all(|r| sh.merges.contains(r))) { "unmerge" } else { how };
|
||||||
edit(s, |cx| {
|
edit(s, |cx| {
|
||||||
let mut touched = Vec::new();
|
let mut touched = Vec::new();
|
||||||
|
// Areas whose merging changes: a merged cell blocks a dynamic array's spill.
|
||||||
|
let mut areas: Vec<RangeRef> = ranges.clone();
|
||||||
for r in &ranges {
|
for r in &ranges {
|
||||||
let sh = cx.sheet_mut(sheet)?;
|
let sh = cx.sheet_mut(sheet)?;
|
||||||
|
areas.extend(sh.merges.iter().filter(|m| m.intersects(r)));
|
||||||
sh.merges.retain(|m| !m.intersects(r));
|
sh.merges.retain(|m| !m.intersects(r));
|
||||||
match how {
|
match how {
|
||||||
"unmerge" => {}
|
"unmerge" => {}
|
||||||
|
|
@ -593,6 +596,17 @@ fn merge(s: &mut Session, p: &Json, how: &str) -> Result<Json> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Spills over the areas, and blocked ones that could reach them, are evaluated again.
|
||||||
|
let sh = cx.sheet_mut(sheet)?;
|
||||||
|
for a in &areas {
|
||||||
|
touched.extend(sh.spill_ranges.iter().filter(|(_, r)| r.intersects(a)).map(|(anchor, _)| (sheet, *anchor)));
|
||||||
|
let reach = RangeRef::new(CellRef::new(0, 0), a.end);
|
||||||
|
let blocked = sh
|
||||||
|
.cells
|
||||||
|
.iter_range(reach)
|
||||||
|
.filter(|(_, x)| x.formula.is_some() && x.value == gridcraft_core::Value::Error(gridcraft_core::CellError::Spill));
|
||||||
|
touched.extend(blocked.map(|(c, _)| (sheet, c)));
|
||||||
|
}
|
||||||
cx.changed.extend(touched);
|
cx.changed.extend(touched);
|
||||||
Ok(())
|
Ok(())
|
||||||
})?;
|
})?;
|
||||||
|
|
|
||||||
|
|
@ -78,18 +78,28 @@ fn auto_sum(s: &mut Session, p: &Json) -> Result<Json> {
|
||||||
};
|
};
|
||||||
if !sel.is_single() {
|
if !sel.is_single() {
|
||||||
// A block: totals below each column (and to the right when a blank column is included).
|
// A block: totals below each column (and to the right when a blank column is included).
|
||||||
let num_end = sel.end;
|
// Columns past the sheet's data get no total (a whole-row selection doesn't write 16,384
|
||||||
let below = CellRef::new(num_end.row + 1, 0);
|
// formulas), and all totals are one edit (one undo step).
|
||||||
let mut writes = Vec::new();
|
let Some(last_col) = sh.cells.used_range().map(|u| u.end.col.min(sel.end.col)) else { return ok() };
|
||||||
let last_row_blank = (sel.start.col..=sel.end.col).all(|c| sh.value(CellRef::new(sel.end.row, c)).is_empty());
|
let last_row_blank = (sel.start.col..=last_col).all(|c| sh.value(CellRef::new(sel.end.row, c)).is_empty());
|
||||||
let (data_end_row, out_row) = if last_row_blank { (sel.end.row.saturating_sub(1), sel.end.row) } else { (sel.end.row, below.row) };
|
let (data_end_row, out_row) = if last_row_blank { (sel.end.row.saturating_sub(1), sel.end.row) } else { (sel.end.row, sel.end.row + 1) };
|
||||||
for c in sel.start.col..=sel.end.col {
|
if out_row >= gridcraft_core::MAX_ROWS {
|
||||||
let r = RangeRef::new(CellRef::new(sel.start.row, c), CellRef::new(data_end_row, c));
|
return Err(EngineError::Other("There's no room below the selection for the totals.".into()));
|
||||||
writes.push((CellRef::new(out_row, c), format!("={func}({})", r.a1())));
|
|
||||||
}
|
|
||||||
for (c, f) in writes {
|
|
||||||
s.execute("cell.set", json!({"cell": c.a1(), "input": f}))?;
|
|
||||||
}
|
}
|
||||||
|
let writes: Vec<(CellRef, String)> = (sel.start.col..=last_col)
|
||||||
|
.map(|c| {
|
||||||
|
(CellRef::new(out_row, c), format!("={func}({})", RangeRef::new(CellRef::new(sel.start.row, c), CellRef::new(data_end_row, c)).a1()))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let sheet = d.wb.active_sheet;
|
||||||
|
edit(s, |cx| {
|
||||||
|
for (c, f) in &writes {
|
||||||
|
let cell = super::edit::input_to_cell(f, sheet, *c, &mut cx.wb)?.unwrap_or_default();
|
||||||
|
cx.sheet_mut(sheet)?.set_cell(*c, cell);
|
||||||
|
cx.touch(sheet, *c);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
return ok();
|
return ok();
|
||||||
}
|
}
|
||||||
// Single cell: guess the range above, else to the left.
|
// Single cell: guess the range above, else to the left.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
//! Insert tab: tables, charts, sparklines, pictures, shapes, hyperlinks, comments and notes,
|
//! Insert tab: tables, charts, sparklines, pictures, shapes, hyperlinks, comments and notes,
|
||||||
//! conditional formatting (Home › Styles) and symbols.
|
//! conditional formatting (Home › Styles) and symbols.
|
||||||
|
|
||||||
use gridcraft_core::{CellRef, RangeRef};
|
use gridcraft_core::{CellRef, MAX_ROWS, RangeRef};
|
||||||
use gridcraft_model::*;
|
use gridcraft_model::*;
|
||||||
use serde_json::{Value as Json, json};
|
use serde_json::{Value as Json, json};
|
||||||
|
|
||||||
|
|
@ -224,6 +224,12 @@ fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
|
||||||
if d.wb.sheet(sheet).is_some_and(|sh| sh.tables.iter().any(|t| t.range.intersects(&r))) {
|
if d.wb.sheet(sheet).is_some_and(|sh| sh.tables.iter().any(|t| t.range.intersects(&r))) {
|
||||||
return Err(EngineError::Other("A table can't overlap another table.".into()));
|
return Err(EngineError::Other("A table can't overlap another table.".into()));
|
||||||
}
|
}
|
||||||
|
let arrays = |sh: &gridcraft_model::Sheet| {
|
||||||
|
sh.cells.iter().any(|(_, x)| x.formula.as_ref().and_then(|f| f.array).is_some_and(|a| !a.is_single() && a.intersects(&r)))
|
||||||
|
};
|
||||||
|
if d.wb.sheet(sheet).is_some_and(arrays) {
|
||||||
|
return Err(EngineError::Other("Multi-cell array formulas aren't allowed in tables.".into()));
|
||||||
|
}
|
||||||
if r.count() > 50_000_000 {
|
if r.count() > 50_000_000 {
|
||||||
return Err(bad("insert.table", "range too large"));
|
return Err(bad("insert.table", "range too large"));
|
||||||
}
|
}
|
||||||
|
|
@ -234,9 +240,19 @@ fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
|
||||||
let name = str_param(p, "name")
|
let name = str_param(p, "name")
|
||||||
.map(str::to_string)
|
.map(str::to_string)
|
||||||
.unwrap_or_else(|| (1..).map(|n| format!("Table{n}")).find(|n| d.wb.table(n).is_none()).unwrap_or_else(|| "Table".into()));
|
.unwrap_or_else(|| (1..).map(|n| format!("Table{n}")).find(|n| d.wb.table(n).is_none()).unwrap_or_else(|| "Table".into()));
|
||||||
|
if name.is_empty() || name.contains(' ') || name.chars().next().is_some_and(|c| c.is_ascii_digit()) || CellRef::parse(&name).is_some() {
|
||||||
|
return Err(EngineError::Other("The name that you entered is not valid.".into()));
|
||||||
|
}
|
||||||
if d.wb.table(&name).is_some() || d.wb.names.iter().any(|n| n.name.eq_ignore_ascii_case(&name)) {
|
if d.wb.table(&name).is_some() || d.wb.names.iter().any(|n| n.name.eq_ignore_ascii_case(&name)) {
|
||||||
return Err(EngineError::Other("The name entered already exists. Enter a unique name.".into()));
|
return Err(EngineError::Other("The name entered already exists. Enter a unique name.".into()));
|
||||||
}
|
}
|
||||||
|
// Without a header row, the data moves down a row to make room for one: never a table with it.
|
||||||
|
let moves_a_table = |sh: &gridcraft_model::Sheet| {
|
||||||
|
sh.tables.iter().any(|t| t.range.end.row >= r.start.row && t.range.start.col <= r.end.col && t.range.end.col >= r.start.col)
|
||||||
|
};
|
||||||
|
if !header && d.wb.sheet(sheet).is_some_and(moves_a_table) {
|
||||||
|
return Err(EngineError::Other("This operation isn't allowed: it would shift cells in a table on your worksheet.".into()));
|
||||||
|
}
|
||||||
let style = str_param(p, "style").unwrap_or("TableStyleMedium2").to_string();
|
let style = str_param(p, "style").unwrap_or("TableStyleMedium2").to_string();
|
||||||
let id = d.wb.next_object_id();
|
let id = d.wb.next_object_id();
|
||||||
edit(s, |cx| {
|
edit(s, |cx| {
|
||||||
|
|
@ -245,6 +261,11 @@ fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
|
||||||
r
|
r
|
||||||
} else {
|
} else {
|
||||||
let sh = cx.sheet_mut(sheet)?;
|
let sh = cx.sheet_mut(sheet)?;
|
||||||
|
// The header row pushes the data down one row: never off the end of the sheet.
|
||||||
|
let last = RangeRef::new(CellRef::new(MAX_ROWS - 1, r.start.col), CellRef::new(MAX_ROWS - 1, r.end.col));
|
||||||
|
if sh.cells.iter_range(last).any(|(_, x)| x.formula.is_some() || !x.value.is_empty()) {
|
||||||
|
return Err(EngineError::Other("To prevent possible loss of data, cells can't be pushed off the end of the worksheet.".into()));
|
||||||
|
}
|
||||||
sh.cells.shift_rows_in_cols(r.start.col, r.end.col, r.start.row, 1);
|
sh.cells.shift_rows_in_cols(r.start.col, r.end.col, r.start.row, 1);
|
||||||
sh.cell_pictures = std::mem::take(&mut sh.cell_pictures)
|
sh.cell_pictures = std::mem::take(&mut sh.cell_pictures)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|
@ -257,7 +278,7 @@ fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
|
||||||
sh.set_value(CellRef::new(r.start.row, c), gridcraft_core::Value::text(format!("Column{}", i + 1)));
|
sh.set_value(CellRef::new(r.start.row, c), gridcraft_core::Value::text(format!("Column{}", i + 1)));
|
||||||
}
|
}
|
||||||
cx.structural = true;
|
cx.structural = true;
|
||||||
RangeRef::new(r.start, CellRef::new(r.end.row + 1, r.end.col))
|
RangeRef::new(r.start, CellRef::new((r.end.row + 1).min(MAX_ROWS - 1), r.end.col))
|
||||||
};
|
};
|
||||||
let sh = cx.sheet_mut(sheet)?;
|
let sh = cx.sheet_mut(sheet)?;
|
||||||
let mut names: Vec<String> = Vec::new();
|
let mut names: Vec<String> = Vec::new();
|
||||||
|
|
|
||||||
|
|
@ -302,6 +302,9 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
|
||||||
if !protection_checked {
|
if !protection_checked {
|
||||||
check_protection(&d.wb, &wb, &d.unlocked_ranges)?;
|
check_protection(&d.wb, &wb, &d.unlocked_ranges)?;
|
||||||
}
|
}
|
||||||
|
if !structural {
|
||||||
|
check_arrays(&d.wb, &wb)?;
|
||||||
|
}
|
||||||
if changed.len() <= 200_000 {
|
if changed.len() <= 200_000 {
|
||||||
fit_rows.extend(changed.iter().map(|(s, c)| (*s, c.row)));
|
fit_rows.extend(changed.iter().map(|(s, c)| (*s, c.row)));
|
||||||
}
|
}
|
||||||
|
|
@ -372,6 +375,36 @@ pub(crate) fn locked_cell_error(sh: &gridcraft_model::Sheet, c: CellRef, unlocke
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Refuses an edit that put something into a cell of a multi-cell legacy array other than its
|
||||||
|
/// formula cell, while the array stays (Excel: "You can't change part of an array."). The array
|
||||||
|
/// would show its value there while the file kept the new content. Cells shifted by row and
|
||||||
|
/// column edits aren't edits of their content, so structural commits aren't checked.
|
||||||
|
fn check_arrays(old: &Workbook, new: &Workbook) -> Result<()> {
|
||||||
|
for sh in &old.sheets {
|
||||||
|
if sh.spill.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(after) = new.sheet_index(&sh.name).and_then(|i| new.sheets.get(i)) else { continue };
|
||||||
|
if std::sync::Arc::ptr_eq(sh, after) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for c in sh.cells.diff(&after.cells) {
|
||||||
|
let filled = after.cell(c).is_some_and(|x| x.formula.is_some() || !x.value.is_empty());
|
||||||
|
if !filled || !sh.spill.contains_key(&c) || same_content(sh.cell(c), after.cell(c)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let in_array = sh.spill_ranges.keys().any(|&anchor| {
|
||||||
|
anchor != c
|
||||||
|
&& after.cell(anchor).and_then(|x| x.formula.as_ref()).and_then(|f| f.array).is_some_and(|a| a.start == anchor && a.contains(c))
|
||||||
|
});
|
||||||
|
if in_array {
|
||||||
|
return Err(EngineError::Other("You can't change part of an array.".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Two cells hold the same value or formula (formats aside).
|
/// Two cells hold the same value or formula (formats aside).
|
||||||
fn same_content(a: Option<&gridcraft_model::Cell>, b: Option<&gridcraft_model::Cell>) -> bool {
|
fn same_content(a: Option<&gridcraft_model::Cell>, b: Option<&gridcraft_model::Cell>) -> bool {
|
||||||
let empty = |c: Option<&gridcraft_model::Cell>| c.is_none_or(|c| c.formula.is_none() && c.value.is_empty());
|
let empty = |c: Option<&gridcraft_model::Cell>| c.is_none_or(|c| c.formula.is_none() && c.value.is_empty());
|
||||||
|
|
|
||||||
|
|
@ -167,27 +167,8 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert:
|
||||||
sh.validations.retain(|dv| !dv.ranges.is_empty());
|
sh.validations.retain(|dv| !dv.ranges.is_empty());
|
||||||
for t in sh.tables.iter_mut() {
|
for t in sh.tables.iter_mut() {
|
||||||
if let Some(r) = map_range(t.range) {
|
if let Some(r) = map_range(t.range) {
|
||||||
// Columns inserted inside a table become new table columns.
|
if axis == Axis::Cols {
|
||||||
if axis == Axis::Cols && insert && at > t.range.start.col && at <= t.range.end.col {
|
shift_table_columns(t, at, count, insert);
|
||||||
let idx = (at - t.range.start.col) as usize;
|
|
||||||
for k in 0..count {
|
|
||||||
let name = format!("Column{}", t.columns.len() + 1);
|
|
||||||
t.columns.insert(
|
|
||||||
(idx + k as usize).min(t.columns.len()),
|
|
||||||
gridcraft_model::TableColumn { name, totals: Default::default(), totals_label: None, formula: None },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if axis == Axis::Cols && !insert {
|
|
||||||
let lo = at.max(t.range.start.col);
|
|
||||||
let hi = (at + count).min(t.range.end.col + 1);
|
|
||||||
if hi > lo {
|
|
||||||
let a = (lo - t.range.start.col) as usize;
|
|
||||||
let b = (hi - t.range.start.col) as usize;
|
|
||||||
if b <= t.columns.len() {
|
|
||||||
t.columns.drain(a..b);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
t.range = r;
|
t.range = r;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -444,6 +425,9 @@ fn insert_cells(s: &mut Session, p: &Json) -> Result<Json> {
|
||||||
}
|
}
|
||||||
let e = Edit::InsertCells { axis: if shift == "right" { Axis::Cols } else { Axis::Rows }, range: r };
|
let e = Edit::InsertCells { axis: if shift == "right" { Axis::Cols } else { Axis::Rows }, range: r };
|
||||||
let sheet = target_sheet(s, p)?;
|
let sheet = target_sheet(s, p)?;
|
||||||
|
if let Some(sh) = s.doc()?.wb.sheet(sheet) {
|
||||||
|
check_table_shift(sh, &e)?;
|
||||||
|
}
|
||||||
let right = shift == "right";
|
let right = shift == "right";
|
||||||
let out = edit(s, |cx| {
|
let out = edit(s, |cx| {
|
||||||
let sh = cx.sheet_mut(sheet)?;
|
let sh = cx.sheet_mut(sheet)?;
|
||||||
|
|
@ -472,6 +456,9 @@ fn delete_cells(s: &mut Session, p: &Json) -> Result<Json> {
|
||||||
}
|
}
|
||||||
let e = Edit::DeleteCells { axis: if shift == "left" { Axis::Cols } else { Axis::Rows }, range: r };
|
let e = Edit::DeleteCells { axis: if shift == "left" { Axis::Cols } else { Axis::Rows }, range: r };
|
||||||
let sheet = target_sheet(s, p)?;
|
let sheet = target_sheet(s, p)?;
|
||||||
|
if let Some(sh) = s.doc()?.wb.sheet(sheet) {
|
||||||
|
check_table_shift(sh, &e)?;
|
||||||
|
}
|
||||||
let left = shift == "left";
|
let left = shift == "left";
|
||||||
let out = edit(s, |cx| {
|
let out = edit(s, |cx| {
|
||||||
let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default();
|
let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default();
|
||||||
|
|
@ -798,8 +785,16 @@ fn move_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||||
if copy {
|
if copy {
|
||||||
let mut sh = (*src).clone();
|
let mut sh = (*src).clone();
|
||||||
let base = sh.name.clone();
|
let base = sh.name.clone();
|
||||||
let name = (2..).map(|k| format!("{base} ({k})")).find(|nm| cx.wb.sheet_index(nm).is_none()).unwrap_or(base.clone());
|
// `Name (2)`: the name is shortened so the suffix fits in Excel's 31 characters.
|
||||||
sh.name = name.chars().take(31).collect();
|
let name = (2..)
|
||||||
|
.map(|k| {
|
||||||
|
let suffix = format!(" ({k})");
|
||||||
|
let keep = 31usize.saturating_sub(suffix.chars().count());
|
||||||
|
format!("{}{suffix}", base.chars().take(keep).collect::<String>().trim_end())
|
||||||
|
})
|
||||||
|
.find(|nm| cx.wb.sheet_index(nm).is_none())
|
||||||
|
.unwrap_or(base.clone());
|
||||||
|
sh.name = name;
|
||||||
sh.tables.clear(); // table names must be unique
|
sh.tables.clear(); // table names must be unique
|
||||||
sh.preserved = None; // kept file content (form controls, OLE objects…) stays with the original
|
sh.preserved = None; // kept file content (form controls, OLE objects…) stays with the original
|
||||||
let at = to.min(n);
|
let at = to.min(n);
|
||||||
|
|
@ -910,7 +905,7 @@ fn step_sheet(s: &mut Session, dir: i64) -> Result<Json> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where a range of the edited sheet goes after a structural edit (`None` = deleted).
|
/// Where a range of the edited sheet goes after a structural edit (`None` = deleted).
|
||||||
fn map_range(r: RangeRef, e: &Edit) -> Option<RangeRef> {
|
pub(crate) fn map_range(r: RangeRef, e: &Edit) -> Option<RangeRef> {
|
||||||
let expr = gridcraft_formula::Expr::Ref(gridcraft_formula::Reference {
|
let expr = gridcraft_formula::Expr::Ref(gridcraft_formula::Reference {
|
||||||
sheet: gridcraft_formula::SheetSel::Current,
|
sheet: gridcraft_formula::SheetSel::Current,
|
||||||
kind: gridcraft_formula::RefKind::Range(
|
kind: gridcraft_formula::RefKind::Range(
|
||||||
|
|
@ -942,9 +937,16 @@ fn shift_cell_features(sh: &mut Sheet, e: &Edit) {
|
||||||
}
|
}
|
||||||
sh.validations.retain(|dv| !dv.ranges.is_empty());
|
sh.validations.retain(|dv| !dv.ranges.is_empty());
|
||||||
for t in sh.tables.iter_mut() {
|
for t in sh.tables.iter_mut() {
|
||||||
|
// Cells inserted or deleted across a table's full height add or remove table columns.
|
||||||
|
if let Edit::InsertCells { axis: Axis::Cols, range } | Edit::DeleteCells { axis: Axis::Cols, range } = *e
|
||||||
|
&& range.start.row <= t.range.start.row
|
||||||
|
&& range.end.row >= t.range.end.row
|
||||||
|
{
|
||||||
|
shift_table_columns(t, range.start.col, range.width(), matches!(e, Edit::InsertCells { .. }));
|
||||||
|
}
|
||||||
t.range = map_range(t.range, e).unwrap_or_default();
|
t.range = map_range(t.range, e).unwrap_or_default();
|
||||||
}
|
}
|
||||||
sh.tables.retain(|t| t.range != RangeRef::default());
|
sh.tables.retain(|t| t.range != RangeRef::default() && !t.columns.is_empty());
|
||||||
if let Some(af) = sh.autofilter.as_mut() {
|
if let Some(af) = sh.autofilter.as_mut() {
|
||||||
match map_range(af.range, e) {
|
match map_range(af.range, e) {
|
||||||
Some(r) => af.range = r,
|
Some(r) => af.range = r,
|
||||||
|
|
@ -964,3 +966,64 @@ fn shift_cell_features(sh: &mut Sheet, e: &Edit) {
|
||||||
sh.print.print_area = map_range(pa, e);
|
sh.print.print_area = map_range(pa, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Columns inserted inside a table become new table columns (with names no other column has);
|
||||||
|
/// deleted ones leave it.
|
||||||
|
fn shift_table_columns(t: &mut gridcraft_model::Table, at: u32, count: u32, insert: bool) {
|
||||||
|
if insert {
|
||||||
|
if at > t.range.start.col && at <= t.range.end.col {
|
||||||
|
let idx = (at - t.range.start.col) as usize;
|
||||||
|
let mut n = t.columns.len();
|
||||||
|
for k in 0..count as usize {
|
||||||
|
let name = loop {
|
||||||
|
n += 1;
|
||||||
|
let name = format!("Column{n}");
|
||||||
|
if !t.columns.iter().any(|c| c.name.eq_ignore_ascii_case(&name)) {
|
||||||
|
break name;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let col = gridcraft_model::TableColumn { name, totals: Default::default(), totals_label: None, formula: None };
|
||||||
|
t.columns.insert((idx + k).min(t.columns.len()), col);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let lo = at.max(t.range.start.col);
|
||||||
|
let hi = at.saturating_add(count).min(t.range.end.col + 1);
|
||||||
|
if hi > lo {
|
||||||
|
let a = (lo - t.range.start.col) as usize;
|
||||||
|
let b = (hi - t.range.start.col) as usize;
|
||||||
|
if b <= t.columns.len() {
|
||||||
|
t.columns.drain(a..b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like Excel, Insert/Delete Cells never shifts part of a table: cells shifted up or down must
|
||||||
|
/// hold whole table columns, cells shifted left or right whole table rows, and deleting cells
|
||||||
|
/// never takes a table's header row without the whole table.
|
||||||
|
pub(crate) fn check_table_shift(sh: &Sheet, e: &Edit) -> Result<()> {
|
||||||
|
let (axis, r, delete) = match *e {
|
||||||
|
Edit::InsertCells { axis, range } => (axis, range, false),
|
||||||
|
Edit::DeleteCells { axis, range } => (axis, range, true),
|
||||||
|
_ => return Ok(()),
|
||||||
|
};
|
||||||
|
for t in &sh.tables {
|
||||||
|
let tr = t.range;
|
||||||
|
let ok = match axis {
|
||||||
|
Axis::Rows => {
|
||||||
|
let moves = tr.end.row >= r.start.row && tr.start.col <= r.end.col && tr.end.col >= r.start.col;
|
||||||
|
let header_alone = delete && t.header_row && r.start.row <= tr.start.row && r.end.row >= tr.start.row && r.end.row < tr.end.row;
|
||||||
|
!moves || (r.start.col <= tr.start.col && r.end.col >= tr.end.col && !header_alone)
|
||||||
|
}
|
||||||
|
Axis::Cols => {
|
||||||
|
let moves = tr.end.col >= r.start.col && tr.start.row <= r.end.row && tr.end.row >= r.start.row;
|
||||||
|
!moves || (r.start.row <= tr.start.row && r.end.row >= tr.end.row)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !ok {
|
||||||
|
return Err(EngineError::Other("This operation isn't allowed: it would shift cells in a table on your worksheet.".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -176,8 +176,15 @@ pub(crate) fn sync_headers(cx: &mut crate::cmd::Ctx) {
|
||||||
let Some(t) = sh.tables.get(ti) else { continue };
|
let Some(t) = sh.tables.get(ti) else { continue };
|
||||||
let col = (c.col - t.range.start.col) as usize;
|
let col = (c.col - t.range.start.col) as usize;
|
||||||
let Some(old) = t.columns.get(col).map(|x| x.name.clone()) else { continue };
|
let Some(old) = t.columns.get(col).map(|x| x.name.clone()) else { continue };
|
||||||
let text = crate::display::cell_text(&cx.wb, sh, c);
|
// A text header names its column as it is: a number format only changes how it looks (an
|
||||||
let is_text = sh.value(c).is_text();
|
// accounting format pads text with spaces), and the file writes the name into the cell.
|
||||||
|
let value = sh.value(c);
|
||||||
|
let text = match value.as_text() {
|
||||||
|
Some(t) => t.to_string(),
|
||||||
|
None => crate::display::cell_text(&cx.wb, sh, c),
|
||||||
|
};
|
||||||
|
// Headers hold constant text: a formula (or a spilled value) there becomes its text.
|
||||||
|
let is_text = value.is_text() && sh.cell(c).is_some_and(|x| x.formula.is_none());
|
||||||
if text == old && is_text {
|
if text == old && is_text {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1032,6 +1032,202 @@ fn slicer_filters_table() {
|
||||||
assert!(s.execute("slicer.toggle", json!({"value": "Nowhere"})).is_err());
|
assert!(s.execute("slicer.toggle", json!({"value": "Nowhere"})).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn table_header_formatted_as_accounting_keeps_its_text_as_the_column_name() {
|
||||||
|
// Found by the command-sequence fuzzer: the accounting format pads text (` 172.4 `), and that
|
||||||
|
// padded text became the column name, which the XLSX writer then put in the header cell.
|
||||||
|
let mut s = s();
|
||||||
|
s.execute("range.setValues", json!({"range": "A1", "values": [[172.4, "Qty"], [1, 2]]})).unwrap();
|
||||||
|
s.execute("home.accounting", json!({"range": "A1:B2"})).unwrap();
|
||||||
|
s.execute("insert.table", json!({"range": "A1:B2", "header": true})).unwrap();
|
||||||
|
let d = s.doc().unwrap();
|
||||||
|
let t = &d.wb.sheet(0).unwrap().tables[0];
|
||||||
|
let names: Vec<_> = t.columns.iter().map(|c| c.name.as_str()).collect();
|
||||||
|
assert_eq!(names, ["172.4", "Qty"]);
|
||||||
|
assert_eq!(v(&s, "A1"), Value::text("172.4"));
|
||||||
|
reopen(&mut s);
|
||||||
|
assert_eq!(v(&s, "A1"), Value::text("172.4"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_formula_moved_into_a_table_header_becomes_its_text() {
|
||||||
|
// Found by the command-sequence fuzzer: deleting a header row moved a formula into the
|
||||||
|
// header, which kept it while the XLSX writer wrote the column name there instead.
|
||||||
|
let mut s = s();
|
||||||
|
s.execute("range.setValues", json!({"range": "A1", "values": [["Item", "Qty"], ["x", "=1+1"], ["y", 5]]})).unwrap();
|
||||||
|
s.execute("insert.table", json!({"range": "A1:B3", "header": true})).unwrap();
|
||||||
|
s.execute("home.deleteRows", json!({"rows": "1:1"})).unwrap();
|
||||||
|
let d = s.doc().unwrap();
|
||||||
|
let sh = d.wb.sheet(0).unwrap();
|
||||||
|
assert_eq!(sh.cell(CellRef::new(0, 1)).and_then(|x| x.formula.clone()), None);
|
||||||
|
assert_eq!(v(&s, "B1"), Value::text("2"));
|
||||||
|
let names: Vec<_> = sh.tables[0].columns.iter().map(|c| c.name.as_str()).collect();
|
||||||
|
assert_eq!(names, ["x", "2"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_whole_column_table_without_headers_stays_on_the_sheet() {
|
||||||
|
// Found by the command-sequence fuzzer: the inserted header row pushed the table's end past
|
||||||
|
// the last row, so the table had an invalid range and was dropped from the XLSX file.
|
||||||
|
let mut s = s();
|
||||||
|
s.execute("insert.table", json!({"range": "A:C", "header": false})).unwrap();
|
||||||
|
let d = s.doc().unwrap();
|
||||||
|
assert_eq!(d.wb.sheet(0).unwrap().tables[0].range.a1(), "A:C");
|
||||||
|
s.execute("cell.set", json!({"cell": "E1", "input": "=ROWS(Table1)"})).unwrap();
|
||||||
|
assert_eq!(v(&s, "E1"), Value::Number(1_048_575.0));
|
||||||
|
reopen(&mut s);
|
||||||
|
assert_eq!(v(&s, "E1"), Value::Number(1_048_575.0));
|
||||||
|
s.execute("cell.set", json!({"cell": "F1048576", "input": "x"})).unwrap();
|
||||||
|
let e = s.execute("insert.table", json!({"range": "F:F", "header": false})).unwrap_err();
|
||||||
|
assert!(e.to_string().contains("pushed off the end"), "{e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn autosum_over_whole_rows_is_one_undo_step_within_the_data() {
|
||||||
|
// Found by the command-sequence fuzzer: AutoSum over rows `3:5` ran one `cell.set` per column
|
||||||
|
// (16,384 undo steps, so undo never got back to the start).
|
||||||
|
let mut s = s();
|
||||||
|
s.execute("range.setValues", json!({"range": "A3", "values": [[1, 2], [3, 4], [5, 6]]})).unwrap();
|
||||||
|
s.execute("formulas.autoSum", json!({"range": "3:5"})).unwrap();
|
||||||
|
assert_eq!(v(&s, "A6"), Value::Number(9.0));
|
||||||
|
assert_eq!(v(&s, "B6"), Value::Number(12.0));
|
||||||
|
let d = s.doc().unwrap();
|
||||||
|
assert_eq!(d.undo.len(), 2);
|
||||||
|
assert_eq!(d.wb.active().unwrap().cells.used_range().unwrap().a1(), "A3:B6");
|
||||||
|
s.execute("edit.undo", json!({})).unwrap();
|
||||||
|
assert_eq!(v(&s, "A6"), Value::Empty);
|
||||||
|
assert_eq!(v(&s, "B6"), Value::Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn insert_and_delete_cells_never_shift_part_of_a_table() {
|
||||||
|
// Found by the command-sequence fuzzer: Delete Cells moved part of a table's cells (so two
|
||||||
|
// tables overlapped) or narrowed a table without dropping its columns, and a table without
|
||||||
|
// headers pushed the cells of another table down from under it.
|
||||||
|
let mut s = s();
|
||||||
|
s.execute("range.setValues", json!({"range": "D8", "values": [["a", "b", "c"], [1, 2, 3], [4, 5, 6]]})).unwrap();
|
||||||
|
s.execute("insert.table", json!({"range": "D8:F10", "header": true})).unwrap();
|
||||||
|
for (cmd, range, shift) in [
|
||||||
|
("home.deleteCells", "E9:G9", "left"),
|
||||||
|
("home.deleteCells", "E9", "up"),
|
||||||
|
("home.insertCells", "C9:D9", "down"),
|
||||||
|
("home.deleteCells", "D8:F8", "up"),
|
||||||
|
("home.insertCells", "A9:B9", "right"),
|
||||||
|
] {
|
||||||
|
let e = s.execute(cmd, json!({"range": range, "shift": shift})).unwrap_err();
|
||||||
|
assert!(e.to_string().contains("shift cells in a table"), "{cmd} {range} {shift}: {e}");
|
||||||
|
}
|
||||||
|
// Whole table columns: the table loses (or gains) columns with them.
|
||||||
|
s.execute("home.deleteCells", json!({"range": "E7:E12", "shift": "left"})).unwrap();
|
||||||
|
let names = |s: &Session| -> Vec<String> { s.doc().unwrap().wb.sheet(0).unwrap().tables[0].columns.iter().map(|c| c.name.clone()).collect() };
|
||||||
|
assert_eq!(names(&s), ["a", "c"]);
|
||||||
|
assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().tables[0].range.a1(), "D8:E10");
|
||||||
|
s.execute("home.insertCells", json!({"range": "E8:E10", "shift": "right"})).unwrap();
|
||||||
|
assert_eq!(names(&s), ["a", "Column2", "c"]);
|
||||||
|
assert_eq!(v(&s, "E8"), Value::text("Column2"));
|
||||||
|
let e = s.execute("insert.table", json!({"range": "H1:H2", "name": "T1"})).unwrap_err();
|
||||||
|
assert!(e.to_string().contains("not valid"), "a cell reference isn't a table name: {e}");
|
||||||
|
let e = s.execute("insert.table", json!({"range": "D5:F6", "header": false})).unwrap_err();
|
||||||
|
assert!(e.to_string().contains("shift cells in a table"), "{e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn undo_and_redo_keep_the_watch_window_list() {
|
||||||
|
// Found by the command-sequence fuzzer: the list lives in the workbook but isn't an edit, so
|
||||||
|
// undoing an earlier edit swapped in a snapshot without it.
|
||||||
|
let mut s = s();
|
||||||
|
s.execute("cell.set", json!({"cell": "A1", "input": "1"})).unwrap();
|
||||||
|
s.execute("formulas.watchWindow", json!({"add": "Sheet1!A1"})).unwrap();
|
||||||
|
let watched = |s: &mut Session| -> Vec<String> {
|
||||||
|
let rows = s.execute("formulas.watchWindow", json!({})).unwrap();
|
||||||
|
rows.as_array().unwrap().iter().map(|r| r["cell"].as_str().unwrap().to_string()).collect()
|
||||||
|
};
|
||||||
|
s.execute("edit.undo", json!({})).unwrap();
|
||||||
|
assert_eq!(watched(&mut s), ["Sheet1!A1"], "kept after undo");
|
||||||
|
s.execute("edit.redo", json!({})).unwrap();
|
||||||
|
assert_eq!(watched(&mut s), ["Sheet1!A1"], "kept after redo");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merging_over_a_spill_blocks_it_and_unmerging_unblocks_it() {
|
||||||
|
// Found by the command-sequence fuzzer: merges changed no cell the spill depended on, so the
|
||||||
|
// spill went on as if the merged cells were free (a full recalc gave #SPILL!).
|
||||||
|
let mut s = s();
|
||||||
|
s.execute("cell.set", json!({"cell": "A1", "input": "=SEQUENCE(3)"})).unwrap();
|
||||||
|
s.execute("home.mergeCenter", json!({"range": "A2:B3"})).unwrap();
|
||||||
|
assert_eq!(v(&s, "A1"), Value::Error(gridcraft_core::CellError::Spill));
|
||||||
|
s.execute("home.mergeCenter", json!({"range": "A2:B3"})).unwrap();
|
||||||
|
assert_eq!(v(&s, "A1"), Value::Number(1.0));
|
||||||
|
assert_eq!(v(&s, "A3"), Value::Number(3.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multi_cell_array_formulas_and_tables_dont_overlap() {
|
||||||
|
// Found by the command-sequence fuzzer: an array over a table header lost its value to the
|
||||||
|
// header name written as text, then showed #SPILL! (and reloaded from XLSX differently).
|
||||||
|
let mut s = s();
|
||||||
|
s.execute("range.setValues", json!({"range": "B2", "values": [["Item", "Qty"], ["x", 2]]})).unwrap();
|
||||||
|
s.execute("insert.table", json!({"range": "B2:C3", "header": true})).unwrap();
|
||||||
|
s.execute("selection.set", json!({"range": "B1:B2"})).unwrap();
|
||||||
|
let e = s.execute("cell.set", json!({"input": "=1", "array": true})).unwrap_err();
|
||||||
|
assert!(e.to_string().contains("aren't allowed in tables"), "{e}");
|
||||||
|
assert_eq!(v(&s, "B2"), Value::text("Item"));
|
||||||
|
s.execute("selection.set", json!({"range": "E1:E2"})).unwrap();
|
||||||
|
s.execute("cell.set", json!({"input": "=1", "array": true})).unwrap();
|
||||||
|
let e = s.execute("insert.table", json!({"range": "E2:F3"})).unwrap_err();
|
||||||
|
assert!(e.to_string().contains("aren't allowed in tables"), "{e}");
|
||||||
|
// One-cell arrays are fine.
|
||||||
|
s.execute("selection.set", json!({"range": "C3"})).unwrap();
|
||||||
|
s.execute("cell.set", json!({"input": "=5", "array": true})).unwrap();
|
||||||
|
assert_eq!(v(&s, "C3"), Value::Number(5.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn copies_of_a_long_named_sheet_get_unique_names() {
|
||||||
|
// Found by the command-sequence fuzzer: `name (2)` was cut to 31 characters, so the second
|
||||||
|
// copy of a 30-character name took the first copy's name.
|
||||||
|
let mut s = s();
|
||||||
|
s.execute("sheet.rename", json!({"sheet": 0, "name": "Long name of a sheet 31 chars!"})).unwrap();
|
||||||
|
s.execute("sheet.move", json!({"sheet": 0, "to": 1, "copy": true})).unwrap();
|
||||||
|
s.execute("sheet.move", json!({"sheet": 0, "to": 2, "copy": true})).unwrap();
|
||||||
|
let d = s.doc().unwrap();
|
||||||
|
let names: Vec<&str> = d.wb.sheets.iter().map(|x| x.name.as_str()).collect();
|
||||||
|
assert_eq!(names, ["Long name of a sheet 31 chars!", "Long name of a sheet 31 cha (2)", "Long name of a sheet 31 cha (3)"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn row_and_column_inserts_move_legacy_array_ranges() {
|
||||||
|
// Found by the command-sequence fuzzer: the formula cell moved but its array range stayed,
|
||||||
|
// so the array covered the wrong cells (and read back from XLSX as #SPILL!).
|
||||||
|
let mut s = s();
|
||||||
|
s.execute("cell.set", json!({"cell": "A1", "input": "7"})).unwrap();
|
||||||
|
s.execute("selection.set", json!({"range": "C3:D3"})).unwrap();
|
||||||
|
s.execute("cell.set", json!({"input": "=$A$1*2", "array": true})).unwrap();
|
||||||
|
s.execute("home.insertColumns", json!({"cols": "B:B"})).unwrap();
|
||||||
|
s.execute("home.insertRows", json!({"rows": "1:1"})).unwrap();
|
||||||
|
let d = s.doc().unwrap();
|
||||||
|
let f = d.wb.sheet(0).unwrap().cell(CellRef::new(3, 3)).and_then(|x| x.formula.clone()).unwrap();
|
||||||
|
assert_eq!(f.array, Some(gridcraft_core::RangeRef::parse("D4:E4").unwrap()));
|
||||||
|
assert_eq!(v(&s, "E4"), Value::Number(14.0));
|
||||||
|
reopen(&mut s);
|
||||||
|
assert_eq!(v(&s, "E4"), Value::Number(14.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn part_of_a_legacy_array_cant_be_changed() {
|
||||||
|
// Found by the command-sequence fuzzer: a value typed into an array cell showed in the
|
||||||
|
// workbook, but the file kept the array there (Excel refuses the edit).
|
||||||
|
let mut s = s();
|
||||||
|
s.execute("selection.set", json!({"range": "A1:A3"})).unwrap();
|
||||||
|
s.execute("cell.set", json!({"input": "=5", "array": true})).unwrap();
|
||||||
|
let e = s.execute("range.setValues", json!({"range": "A2", "values": [[13]]})).unwrap_err();
|
||||||
|
assert!(e.to_string().contains("part of an array"), "{e}");
|
||||||
|
assert_eq!(v(&s, "A2"), Value::Number(5.0));
|
||||||
|
// Replacing the whole array is fine.
|
||||||
|
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3]]})).unwrap();
|
||||||
|
assert_eq!(v(&s, "A2"), Value::Number(2.0));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn create_names_from_selection() {
|
fn create_names_from_selection() {
|
||||||
let names = |s: &Session| -> Vec<(String, String)> {
|
let names = |s: &Session| -> Vec<(String, String)> {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -76,7 +76,9 @@ fn expr_to_file(e: Expr) -> String {
|
||||||
Some(n) => Expr::Call(n, args),
|
Some(n) => Expr::Call(n, args),
|
||||||
None => Expr::Call(name, args),
|
None => Expr::Call(name, args),
|
||||||
},
|
},
|
||||||
// Files have no `#` operator: Excel writes `A1#` as `_xlfn.ANCHORARRAY(A1)`.
|
// Files have no `#` operator: Excel writes `A1#` as `_xlfn.ANCHORARRAY(A1)`, and a spill
|
||||||
|
// reference that became `#REF!` (its cell was deleted) as plain `#REF!`.
|
||||||
|
Expr::Unary(gridcraft_formula::UnOp::Spill, r) if matches!(*r, Expr::Error(_)) => *r,
|
||||||
Expr::Unary(gridcraft_formula::UnOp::Spill, r) => Expr::Call("_xlfn.ANCHORARRAY".into(), vec![*r]),
|
Expr::Unary(gridcraft_formula::UnOp::Spill, r) => Expr::Call("_xlfn.ANCHORARRAY".into(), vec![*r]),
|
||||||
// Nor an `@` operator: Excel writes `@x` as `_xlfn.SINGLE(x)`.
|
// Nor an `@` operator: Excel writes `@x` as `_xlfn.SINGLE(x)`.
|
||||||
Expr::Unary(gridcraft_formula::UnOp::At, x) => Expr::Call("_xlfn.SINGLE".into(), vec![*x]),
|
Expr::Unary(gridcraft_formula::UnOp::At, x) => Expr::Call("_xlfn.SINGLE".into(), vec![*x]),
|
||||||
|
|
@ -115,6 +117,11 @@ mod tests {
|
||||||
assert_eq!(to_file(&Formula::new("SUM(Sheet1!$D$1#)+B2#")), "SUM(_xlfn.ANCHORARRAY(Sheet1!$D$1))+_xlfn.ANCHORARRAY(B2)");
|
assert_eq!(to_file(&Formula::new("SUM(Sheet1!$D$1#)+B2#")), "SUM(_xlfn.ANCHORARRAY(Sheet1!$D$1))+_xlfn.ANCHORARRAY(B2)");
|
||||||
assert_eq!(text_to_file("=Sheet1!$D$1#"), "_xlfn.ANCHORARRAY(Sheet1!$D$1)");
|
assert_eq!(text_to_file("=Sheet1!$D$1#"), "_xlfn.ANCHORARRAY(Sheet1!$D$1)");
|
||||||
assert_eq!(from_file("SUM(_xlfn.ANCHORARRAY(Sheet1!$D$1))+_xlfn.ANCHORARRAY(B2)"), "SUM(Sheet1!$D$1#)+B2#");
|
assert_eq!(from_file("SUM(_xlfn.ANCHORARRAY(Sheet1!$D$1))+_xlfn.ANCHORARRAY(B2)"), "SUM(Sheet1!$D$1#)+B2#");
|
||||||
|
// Found by the command-sequence fuzzer: deleting the anchor of `H5#` left `=#REF!` in
|
||||||
|
// the workbook but `ANCHORARRAY(#REF!)` in the file, which read back differently.
|
||||||
|
let deleted = Formula::from_expr(Expr::Unary(gridcraft_formula::UnOp::Spill, Box::new(Expr::Error(gridcraft_core::CellError::Ref))));
|
||||||
|
assert_eq!(deleted.text, "#REF!");
|
||||||
|
assert_eq!(to_file(&deleted), "#REF!");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Target-app parity: GridCraft vs Microsoft Excel
|
# Target-app parity: GridCraft vs Microsoft Excel
|
||||||
|
|
||||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (community merges: worksheet functions 513 / 523, catalog 274 / 290) · **Target:** Microsoft Excel (Microsoft 365)
|
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (stability: command-sequence fuzzing against undo/redo, XLSX round-trip and full-recalc invariants) · **Target:** Microsoft Excel (Microsoft 365)
|
||||||
|
|
||||||
This is the authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it,
|
This is the authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it,
|
||||||
[`gaps.md`](gaps.md) lists every shortfall, and the area checklists hold the detail:
|
[`gaps.md`](gaps.md) lists every shortfall, and the area checklists hold the detail:
|
||||||
|
|
@ -102,7 +102,7 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2
|
||||||
| File formats | 15 | ~50% | 80–120 | XLSX read/write is broad but unproven on a real corpus and in Excel; XLSB and ODS are values-only import; no XLS, XML Spreadsheet 2003, SYLK, DIF, PRN; VBA dropped; 100 MB XLSX fails (#175). [file-format-parity.md](file-format-parity.md) |
|
| File formats | 15 | ~50% | 80–120 | XLSX read/write is broad but unproven on a real corpus and in Excel; XLSB and ODS are values-only import; no XLS, XML Spreadsheet 2003, SYLK, DIF, PRN; VBA dropped; 100 MB XLSX fails (#175). [file-format-parity.md](file-format-parity.md) |
|
||||||
| UI/UX fidelity | 12 | ~55% | 50–80 | Excel-style ribbon, galleries, dialogs, formula bar, status bar; 59 commands with shortcuts vs Excel's 200+; key tips on part of the Home tab only; short context menu; single window. [ui-parity.md](ui-parity.md) |
|
| UI/UX fidelity | 12 | ~55% | 50–80 | Excel-style ribbon, galleries, dialogs, formula bar, status bar; 59 commands with shortcuts vs Excel's 200+; key tips on part of the Home tab only; short context menu; single window. [ui-parity.md](ui-parity.md) |
|
||||||
| Performance | 8 | ~35% | 35–55 | [Performance](#performance) |
|
| Performance | 8 | ~35% | 35–55 | [Performance](#performance) |
|
||||||
| Stability | 8 | ~60% | 15–25 | Never-crash rules enforced by lint, every command fuzzed with hostile params, last-resort guard in `Session::execute`. Field reports: Windows Intel GPU crash (#120), error on close (#117), freeze opening from Finder (#166), screen flashing (#76) |
|
| Stability | 8 | ~60% | 15–25 | Never-crash rules enforced by lint, every command fuzzed with hostile params, random command sequences fuzzed against undo/redo, XLSX round-trip and full-recalc invariants, last-resort guard in `Session::execute`. Field reports: Windows Intel GPU crash (#120), error on close (#117), freeze opening from Finder (#166), screen flashing (#76) |
|
||||||
| Localization | 4 | ~10% | 100–165 | Ribbon-only catalog in five languages (ja, zh, ko, pt-BR, ru; ~12% of strings each), CJK/Thai/Hebrew font fallback, no shaping or RTL. [localization-parity.md](localization-parity.md) |
|
| Localization | 4 | ~10% | 100–165 | Ribbon-only catalog in five languages (ja, zh, ko, pt-BR, ru; ~12% of strings each), CJK/Thai/Hebrew font fallback, no shaping or RTL. [localization-parity.md](localization-parity.md) |
|
||||||
| Hardware | 2 | ~40% | 25–40 | GPU UI via wgpu; single-threaded calc; no pen pressure; one window. [hardware-parity.md](hardware-parity.md) |
|
| Hardware | 2 | ~40% | 25–40 | GPU UI via wgpu; single-threaded calc; no pen pressure; one window. [hardware-parity.md](hardware-parity.md) |
|
||||||
| Platforms | 2 | ~80% | 30–60 | [Platforms](#platforms) |
|
| Platforms | 2 | ~80% | 30–60 | [Platforms](#platforms) |
|
||||||
|
|
@ -332,6 +332,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land
|
||||||
|
|
||||||
| Date | Change | Summary |
|
| Date | Change | Summary |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| 2026-10-11 | minor | Stability: command-sequence fuzz harness (`crates/engine/tests/command_sequence_fuzz.rs`) checks random command sequences against undo/redo, XLSX round-trip, full-recalc and structure invariants; the sequence bugs it found are fixed (no score change) |
|
||||||
| 2026-10-11 | minor | Worksheet functions 505 / 523 (96.6%; 496 working): ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD |
|
| 2026-10-11 | minor | Worksheet functions 505 / 523 (96.6%; 496 working): ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD |
|
||||||
| 2026-10-11 | minor | Worksheet functions 503 / 523 (96.2%; 494 working): PERCENTOF, EUROCONVERT |
|
| 2026-10-11 | minor | Worksheet functions 503 / 523 (96.2%; 494 working): PERCENTOF, EUROCONVERT |
|
||||||
| 2026-10-11 | minor | Worksheet functions 502 / 523 (96.0%; 493 working): BAHTTEXT |
|
| 2026-10-11 | minor | Worksheet functions 502 / 523 (96.0%; 493 working): BAHTTEXT |
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue