diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 982c139..24e6268 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -460,6 +460,7 @@ fn delete_sheet(s: &mut Session, p: &Json) -> Result { return Err(EngineError::Other("A workbook must contain at least one visible worksheet.".into())); } let name = d.wb.sheet(i).map(|s| s.name.clone()).unwrap_or_default(); + let order: Vec = d.wb.sheets.iter().map(|s| s.name.clone()).collect(); let d = s.doc_mut()?; edit_doc(d, |cx| { cx.wb.sheets.remove(i); @@ -469,7 +470,7 @@ fn delete_sheet(s: &mut Session, p: &Json) -> Result { let Some(sh) = cx.wb.sheet_mut(si) else { continue }; for (c, f) in keys { if let Some(e) = f.expr() { - let ne = gridcraft_formula::adjust::delete_sheet(e, &name); + let ne = gridcraft_formula::adjust::delete_sheet(e, &name, &order); if let Some(cell) = sh.cells.get_mut(c) { cell.formula = Some(Arc::new(gridcraft_model::Formula::from_expr(ne))); } @@ -485,7 +486,7 @@ fn delete_sheet(s: &mut Session, p: &Json) -> Result { } // Names referring to the sheet become #REF!. if let Ok(e) = gridcraft_formula::parse(&n.formula) { - let ne = gridcraft_formula::adjust::delete_sheet(e.clone(), &name); + let ne = gridcraft_formula::adjust::delete_sheet(e.clone(), &name, &order); if ne != e { n.formula = gridcraft_formula::print(&ne); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f2f3e1a..1e17874 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -764,6 +764,32 @@ fn sheet_operations_keep_names_on_their_sheets() { assert_eq!(names(&s).len(), 1); } +#[test] +fn deleting_a_3d_span_end_sheet_keeps_the_rest() { + let book = || { + let mut s = s(); + for name in ["Sheet2", "Sheet3", "Results"] { + s.execute("home.insertSheet", json!({"name": name})).unwrap(); + } + for (sheet, n) in [("Sheet1", 3), ("Sheet2", 5), ("Sheet3", 7)] { + s.execute("sheet.activate", json!({"sheet": sheet})).unwrap(); + s.execute("cell.set", json!({"cell": "A1", "input": n.to_string()})).unwrap(); + } + s.execute("sheet.activate", json!({"sheet": "Results"})).unwrap(); + s.execute("cell.set", json!({"cell": "A1", "input": "=SUM(Sheet1:Sheet3!A1)"})).unwrap(); + s.execute("cell.set", json!({"cell": "B1", "input": "=Sheet1!A1"})).unwrap(); + s + }; + let gone = Value::Error(gridcraft_core::CellError::Ref); + for (deleted, total, direct) in [("Sheet1", 12.0, gone), ("Sheet3", 8.0, Value::Number(3.0)), ("Sheet2", 10.0, Value::Number(3.0))] { + let mut s = book(); + s.execute("home.deleteSheet", json!({"sheet": deleted})).unwrap(); + s.execute("sheet.activate", json!({"sheet": "Results"})).unwrap(); + assert_eq!(v(&s, "A1"), Value::Number(total), "deleting {deleted}"); + assert_eq!(v(&s, "B1"), direct, "deleting {deleted}"); + } +} + #[test] fn xlsx_roundtrip_through_engine() { let mut s = s(); diff --git a/crates/formula/src/adjust.rs b/crates/formula/src/adjust.rs index 7feb904..17dd0c1 100644 --- a/crates/formula/src/adjust.rs +++ b/crates/formula/src/adjust.rs @@ -229,10 +229,28 @@ pub fn rename_sheet(e: Expr, old: &str, new: &str) -> Expr { }) } -/// References to a deleted sheet become `#REF!`. -pub fn delete_sheet(e: Expr, name: &str) -> Expr { +/// References to a deleted sheet become `#REF!`. `order` lists the sheet names (before the deletion) in tab order: +/// a 3-D span that loses an end sheet shrinks to the neighbouring surviving sheet, as in Excel. +pub fn delete_sheet(e: Expr, name: &str, order: &[String]) -> Expr { + let pos = |n: &str| order.iter().position(|o| o.eq_ignore_ascii_case(n)); e.map(&mut |x| match x { Expr::Ref(r) if r.sheet_name().is_some_and(|n| n.eq_ignore_ascii_case(name)) => Expr::Error(CellError::Ref), + Expr::Ref(mut r) => { + if let SheetSel::Span(a, b) = &r.sheet + && (a.eq_ignore_ascii_case(name) || b.eq_ignore_ascii_case(name)) + { + let (Some(ia), Some(ib)) = (pos(a), pos(b)) else { return Expr::Ref(r) }; + if ia == ib { + return Expr::Error(CellError::Ref); + } + // The deleted end moves one sheet towards the other end; the span keeps its direction. + let inward = |from: usize, to: usize| order.get(if from < to { from + 1 } else { from.saturating_sub(1) }); + let (na, nb) = if a.eq_ignore_ascii_case(name) { (inward(ia, ib), order.get(ib)) } else { (order.get(ia), inward(ib, ia)) }; + let (Some(na), Some(nb)) = (na, nb) else { return Expr::Error(CellError::Ref) }; + r.sheet = if na.eq_ignore_ascii_case(nb) { SheetSel::Named(na.clone()) } else { SheetSel::Span(na.clone(), nb.clone()) }; + } + Expr::Ref(r) + } other => other, }) } diff --git a/crates/formula/src/tests.rs b/crates/formula/src/tests.rs index 3aca4a5..b6e5cfa 100644 --- a/crates/formula/src/tests.rs +++ b/crates/formula/src/tests.rs @@ -65,7 +65,19 @@ fn spill_operator() { assert!(parse("A1:B2#").is_err()); assert!(parse("Name#").is_err()); // Deleting the anchor's sheet leaves #REF!. - assert_eq!(print(&crate::adjust::delete_sheet(parse("SUM(Data!A1#)").unwrap(), "Data")), "SUM(#REF!)"); + assert_eq!(print(&crate::adjust::delete_sheet(parse("SUM(Data!A1#)").unwrap(), "Data", &[])), "SUM(#REF!)"); +} + +#[test] +fn delete_sheet_shrinks_3d_span() { + let order: Vec = ["Sheet1", "Sheet2", "Sheet3", "Results"].iter().map(|s| s.to_string()).collect(); + let del = |f: &str, n: &str| print(&crate::adjust::delete_sheet(parse(f).unwrap(), n, &order)); + assert_eq!(del("SUM(Sheet1:Sheet3!A1)", "Sheet1"), "SUM(Sheet2:Sheet3!A1)"); + assert_eq!(del("SUM(Sheet1:Sheet3!A1)", "Sheet3"), "SUM(Sheet1:Sheet2!A1)"); + assert_eq!(del("SUM(Sheet3:Sheet1!A1)", "Sheet1"), "SUM(Sheet3:Sheet2!A1)"); + assert_eq!(del("SUM(Sheet1:Sheet3!A1)", "Sheet2"), "SUM(Sheet1:Sheet3!A1)"); + assert_eq!(del("SUM(Sheet1:Sheet2!A1)", "Sheet1"), "SUM(Sheet2!A1)"); + assert_eq!(del("Sheet1!A1+Sheet3!A1", "Sheet1"), "#REF!+Sheet3!A1"); } #[test]