From 2396bb4f64d64f257dbac5c1f95f9530cae90313 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:41 +0000 Subject: [PATCH] refactor(engine): move cmd/pivot tests to src/cmd/pivot/tests.rs --- crates/engine/src/cmd/pivot.rs | 997 +-------------------------- crates/engine/src/cmd/pivot/tests.rs | 994 ++++++++++++++++++++++++++ 2 files changed, 995 insertions(+), 996 deletions(-) create mode 100644 crates/engine/src/cmd/pivot/tests.rs diff --git a/crates/engine/src/cmd/pivot.rs b/crates/engine/src/cmd/pivot.rs index 164ae4449..feb548702 100644 --- a/crates/engine/src/cmd/pivot.rs +++ b/crates/engine/src/cmd/pivot.rs @@ -1695,999 +1695,4 @@ fn recommended(s: &mut Session, p: &Json) -> Result { } #[cfg(test)] -mod tests { - use gridcraft_core::{CellError, CellRef, Value}; - use serde_json::{Value as Json, json}; - - use crate::Session; - - /// Region, Product, Date, Units, Sales — 12 records over 2026. - fn sales() -> Session { - let mut s = Session::new(); - s.new_workbook(); - let rows = json!([ - ["Region", "Product", "Date", "Units", "Sales"], - ["East", "Apples", "1/15/2026", 10, 100], - ["West", "Apples", "2/10/2026", 5, 50], - ["East", "Pears", "3/3/2026", 8, 120], - ["North", "Pears", "4/20/2026", 2, 30], - ["West", "Plums", "5/5/2026", 7, 70], - ["East", "Plums", "6/30/2026", 4, 60], - ["North", "Apples", "7/4/2026", 9, 90], - ["West", "Pears", "8/8/2026", 3, 45], - ["East", "Apples", "9/9/2026", 6, 60], - ["North", "Plums", "10/10/2026", 1, 15], - ["West", "Apples", "11/11/2026", 12, 120], - ["East", "Pears", "12/12/2026", 5, 75] - ]); - s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap(); - s.execute("selection.set", json!({"range": "A1"})).unwrap(); - s - } - const TOTAL: f64 = 835.0; - - fn val(s: &Session, a: &str) -> Value { - s.doc().unwrap().wb.active().unwrap().value(CellRef::parse(a).unwrap()) - } - fn text(s: &Session, a: &str) -> String { - val(s, a).display() - } - fn pt(s: &Session) -> gridcraft_model::PivotTable { - s.doc().unwrap().wb.active().unwrap().pivots.last().unwrap().clone() - } - /// Finds the row (0-based) whose first report column holds `label`. - fn find_row(s: &Session, label: &str) -> Option { - let p = pt(s); - let r = p.last_range.unwrap(); - (r.start.row..=r.end.row).find(|row| val(s, &CellRef::new(*row, r.start.col).a1()).display() == label) - } - - #[test] - fn insert_creates_placeholder_on_new_sheet() { - let mut s = sales(); - let r = s.execute("insert.pivotTable", json!({})).unwrap(); - assert_eq!(r["name"], "PivotTable1"); - assert_eq!(r["anchor"], "A3"); - assert_eq!(r["fields"], json!(["Region", "Product", "Date", "Units", "Sales"])); - let d = s.doc().unwrap(); - assert_eq!(d.wb.sheets.len(), 2); - assert_eq!(d.wb.active_sheet(), 0, "new sheet goes before the current one"); - assert_eq!(d.wb.active().unwrap().name, "Sheet2"); - assert!(text(&s, "A3").starts_with("PivotTable1")); - assert_eq!(pt(&s).source, "Sheet1!$A$1:$E$13"); - // One undo step removes it. - s.execute("edit.undo", json!({})).unwrap(); - assert_eq!(s.doc().unwrap().wb.sheets.len(), 1); - } - - #[test] - fn region_by_product_sum_of_sales() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({})).unwrap(); - s.execute("pivot.addField", json!({"field": "region", "area": "rows"})).unwrap(); - s.execute("pivot.addField", json!({"field": "Product", "area": "columns"})).unwrap(); - let r = s.execute("pivot.addField", json!({"field": "Sales", "area": "values"})).unwrap(); - assert_eq!(r["range"], "A3:E8"); - assert_eq!(text(&s, "A3"), "Sum of Sales"); - assert_eq!(text(&s, "B3"), "Column Labels"); - assert_eq!(text(&s, "A4"), "Row Labels"); - assert_eq!(text(&s, "B4"), "Apples"); - assert_eq!(text(&s, "C4"), "Pears"); - assert_eq!(text(&s, "D4"), "Plums"); - assert_eq!(text(&s, "E4"), "Grand Total"); - assert_eq!(text(&s, "A5"), "East"); - assert_eq!(text(&s, "A6"), "North"); - assert_eq!(text(&s, "A7"), "West"); - assert_eq!(text(&s, "A8"), "Grand Total"); - assert_eq!(val(&s, "B5"), Value::Number(160.0)); - assert_eq!(val(&s, "C5"), Value::Number(195.0)); - assert_eq!(val(&s, "E5"), Value::Number(415.0)); - assert_eq!(val(&s, "E8"), Value::Number(TOTAL)); - // Column grand totals add up too. - let sum: f64 = ["B8", "C8", "D8"].iter().map(|a| val(&s, a).as_f64().unwrap()).sum(); - assert_eq!(sum, TOTAL); - let rows: f64 = ["E5", "E6", "E7"].iter().map(|a| val(&s, a).as_f64().unwrap()).sum(); - assert_eq!(rows, TOTAL); - // Styles: header bold, grand total bold with a top border. - let d = s.doc().unwrap(); - let sh = d.wb.active().unwrap(); - assert!(d.wb.styles.get(sh.style_id(CellRef::parse("A4").unwrap())).font.bold); - let g = d.wb.styles.get(sh.style_id(CellRef::parse("B8").unwrap())); - assert!(g.font.bold && !g.borders.top.is_none()); - // Listing. - let l = s.execute("pivot.list", json!({})).unwrap(); - assert_eq!(l[0]["values"][0]["name"], "Sum of Sales"); - } - - #[test] - fn multiple_value_fields_make_a_values_dimension() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales", {"field": "Units", "func": "average"}]})).unwrap(); - assert_eq!(text(&s, "A3"), "Row Labels"); - assert_eq!(text(&s, "B3"), "Sum of Sales"); - assert_eq!(text(&s, "C3"), "Average of Units"); - assert_eq!(text(&s, "A7"), "Grand Total"); - assert_eq!(val(&s, "B7"), Value::Number(TOTAL)); - assert_eq!(val(&s, "C7"), Value::Number(72.0 / 12.0)); - // With a column field the values become the innermost column level. - s.execute("pivot.addField", json!({"field": "Product", "area": "columns"})).unwrap(); - assert_eq!(text(&s, "B4"), "Apples"); - assert_eq!(text(&s, "B5"), "Sum of Sales"); - assert_eq!(text(&s, "C5"), "Average of Units"); - assert_eq!(text(&s, "D4"), "Pears"); - assert_eq!(text(&s, "H4"), "Total Sum of Sales"); - assert_eq!(text(&s, "I4"), "Total Average of Units"); - let last = pt(&s).last_range.unwrap(); - assert_eq!(val(&s, &CellRef::new(last.end.row, 7).a1()), Value::Number(TOTAL)); - } - - #[test] - fn date_grouping_by_quarter_and_month() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({"rows": ["Date"], "values": ["Sales"]})).unwrap(); - // Ungrouped: one row per date, shown with the source date format. - assert_eq!(pt(&s).last_range.unwrap().height(), 14); - s.execute("pivot.group", json!({"field": "Date", "by": "quarters"})).unwrap(); - assert_eq!(text(&s, "A4"), "Qtr1"); - assert_eq!(text(&s, "A7"), "Qtr4"); - assert_eq!(val(&s, "B4"), Value::Number(270.0)); - assert_eq!(val(&s, "B5"), Value::Number(160.0)); - assert_eq!(val(&s, "B8"), Value::Number(TOTAL)); - s.execute("pivot.group", json!({"field": "Date", "by": "months"})).unwrap(); - assert_eq!(text(&s, "A4"), "Jan"); - assert_eq!(text(&s, "A15"), "Dec"); - s.execute("pivot.group", json!({"field": "Date", "by": "years"})).unwrap(); - assert_eq!(text(&s, "A4"), "2026"); - assert!(s.execute("pivot.group", json!({"field": "Region", "by": "years"})).is_err()); - } - - #[test] - fn filters_and_report_filter_rows() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({"rows": ["Product"], "values": ["Sales"], "filters": ["Region"]})).unwrap(); - // Report filter above the body; the body stays at A3. - assert_eq!(text(&s, "A1"), "Region"); - assert_eq!(text(&s, "B1"), "(All)"); - assert_eq!(text(&s, "A3"), "Row Labels"); - s.execute("pivot.filter", json!({"field": "Region", "selected": ["East"]})).unwrap(); - assert_eq!(text(&s, "B1"), "East"); - let gt = find_row(&s, "Grand Total").unwrap(); - assert_eq!(val(&s, &CellRef::new(gt, 1).a1()), Value::Number(415.0)); - s.execute("pivot.filter", json!({"field": "Region", "selected": ["East", "west"]})).unwrap(); - assert_eq!(text(&s, "B1"), "(Multiple Items)"); - s.execute("pivot.filter", json!({"field": "Region", "selected": null})).unwrap(); - let gt = find_row(&s, "Grand Total").unwrap(); - assert_eq!(val(&s, &CellRef::new(gt, 1).a1()), Value::Number(TOTAL)); - // Hidden items on a row field. - s.execute("pivot.filter", json!({"field": "Product", "selected": ["Apples"]})).unwrap(); - assert_eq!(text(&s, "A4"), "Apples"); - assert_eq!(text(&s, "A5"), "Grand Total"); - assert_eq!(val(&s, "B5"), Value::Number(420.0)); - assert!(s.execute("pivot.filter", json!({"field": "Product", "selected": []})).is_err()); - } - - #[test] - fn show_values_as() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]})).unwrap(); - s.execute("pivot.valueSettings", json!({"field": "Sum of Sales", "showAs": "percentOfGrandTotal"})).unwrap(); - let east = val(&s, "B4").as_f64().unwrap(); - assert!((east - 415.0 / TOTAL).abs() < 1e-12); - assert_eq!(val(&s, "B7"), Value::Number(1.0)); - assert_eq!(text(&s, "B7"), "1"); - let d = s.doc().unwrap(); - let sh = d.wb.active().unwrap(); - assert_eq!(d.wb.styles.get(sh.style_id(CellRef::parse("B4").unwrap())).num_fmt.as_str(), "0.00%"); - s.execute("pivot.valueSettings", json!({"index": 0, "showAs": "runningTotal"})).unwrap(); - assert_eq!(val(&s, "B4"), Value::Number(415.0)); - assert_eq!(val(&s, "B5"), Value::Number(415.0 + 135.0)); - assert_eq!(val(&s, "B6"), Value::Number(TOTAL)); - s.execute("pivot.valueSettings", json!({"index": 0, "showAs": "rank"})).unwrap(); - assert_eq!(val(&s, "B4"), Value::Number(1.0)); - assert_eq!(val(&s, "B5"), Value::Number(3.0)); - assert_eq!(val(&s, "B6"), Value::Number(2.0)); - s.execute("pivot.valueSettings", json!({"index": 0, "showAs": "normal", "func": "max", "numberFormat": "#,##0"})).unwrap(); - assert_eq!(text(&s, "B3"), "Max of Sales"); - assert_eq!(val(&s, "B4"), Value::Number(120.0)); - assert!(s.execute("pivot.valueSettings", json!({"index": 0, "name": "Sales"})).is_err()); - s.execute("pivot.valueSettings", json!({"index": 0, "name": "Biggest sale"})).unwrap(); - assert_eq!(text(&s, "B3"), "Biggest sale"); - } - - #[test] - fn compact_vs_tabular_shapes() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({"rows": ["Region", "Product"], "values": ["Sales"]})).unwrap(); - // Compact: one label column; parents carry subtotals at the top; children indented. - let r = pt(&s).last_range.unwrap(); - assert_eq!(r.width(), 2); - // header + 3 regions + 9 (region, product) pairs present + grand total - assert_eq!(r.height(), 1 + 3 + 9 + 1); - assert_eq!(text(&s, "A4"), "East"); - assert_eq!(val(&s, "B4"), Value::Number(415.0)); - assert_eq!(text(&s, "A5"), "Apples"); - let d = s.doc().unwrap(); - let sh = d.wb.active().unwrap(); - assert_eq!(d.wb.styles.get(sh.style_id(CellRef::parse("A5").unwrap())).align.indent, 1); - assert!(d.wb.styles.get(sh.style_id(CellRef::parse("A4").unwrap())).font.bold); - // Outline: one column per field. - s.execute("pivot.layout", json!({"layout": "outline"})).unwrap(); - let r = pt(&s).last_range.unwrap(); - assert_eq!(r.width(), 3); - assert_eq!(text(&s, "A3"), "Region"); - assert_eq!(text(&s, "B3"), "Product"); - assert_eq!(text(&s, "A4"), "East"); - assert_eq!(text(&s, "B5"), "Apples"); - // Tabular: first child on its parent's row; "East Total" rows below each group. - s.execute("pivot.layout", json!({"layout": "tabular"})).unwrap(); - let r = pt(&s).last_range.unwrap(); - assert_eq!(r.width(), 3); - assert_eq!(r.height(), 1 + 9 + 3 + 1); - assert_eq!(text(&s, "A4"), "East"); - assert_eq!(text(&s, "B4"), "Apples"); - assert_eq!(text(&s, "A5"), ""); - assert_eq!(text(&s, "B5"), "Pears"); - assert_eq!(text(&s, "A7"), "East Total"); - assert_eq!(val(&s, "C7"), Value::Number(415.0)); - // No subtotals, no grand totals. - s.execute("pivot.layout", json!({"subtotals": false, "grandTotalsCols": false})).unwrap(); - assert_eq!(pt(&s).last_range.unwrap().height(), 1 + 9); - // Collapse East in compact layout: its children disappear. - s.execute("pivot.layout", json!({"layout": "compact", "subtotals": "top"})).unwrap(); - s.execute("pivot.collapse", json!({"field": "Region", "items": ["East"]})).unwrap(); - assert_eq!(text(&s, "A4"), "East"); - assert_eq!(text(&s, "A5"), "North"); - s.execute("pivot.collapse", json!({"field": "Region", "collapse": false})).unwrap(); - assert_eq!(text(&s, "A5"), "Apples"); - } - - #[test] - fn values_on_rows_layouts() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales", "Units"]})).unwrap(); - assert_eq!(text(&s, "C3"), "Sum of Units"); - // Σ Values after Region: each region holds one line per value field. - s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows"})).unwrap(); - let p = pt(&s); - assert!(p.values_on_rows); - assert_eq!(p.values_position, None); - let r = p.last_range.unwrap(); - assert_eq!(r.width(), 2); - // header + 3 × (region + 2 values) + 2 grand totals - assert_eq!(r.height(), 1 + 9 + 2); - assert_eq!(text(&s, "A4"), "East"); - assert_eq!(val(&s, "B4"), Value::Empty); - assert_eq!(text(&s, "A5"), "Sum of Sales"); - assert_eq!(val(&s, "B5"), Value::Number(415.0)); - assert_eq!(text(&s, "A6"), "Sum of Units"); - assert_eq!(val(&s, "B6"), Value::Number(33.0)); - assert_eq!(text(&s, "A13"), "Total Sum of Sales"); - assert_eq!(val(&s, "B13"), Value::Number(TOTAL)); - assert_eq!(text(&s, "A14"), "Total Sum of Units"); - assert_eq!(val(&s, "B14"), Value::Number(72.0)); - // Tabular, no column field: "Total" heads the data column, as Excel saves it. - s.execute("pivot.layout", json!({"layout": "tabular"})).unwrap(); - assert_eq!(text(&s, "A3"), "Region"); - assert_eq!(text(&s, "B3"), "Values"); - assert_eq!(text(&s, "C3"), "Total"); - s.execute("pivot.layout", json!({"layout": "compact"})).unwrap(); - // Σ Values before Region: the value fields are the outer items. - s.execute("pivot.moveField", json!({"field": "Values", "from": "rows", "to": "rows", "position": 0})).unwrap(); - assert_eq!(pt(&s).values_position, Some(0)); - assert_eq!(text(&s, "A4"), "Sum of Sales"); - assert_eq!(val(&s, "B4"), Value::Empty); - assert_eq!(text(&s, "A5"), "East"); - assert_eq!(val(&s, "B5"), Value::Number(415.0)); - assert_eq!(text(&s, "A8"), "Sum of Units"); - assert_eq!(text(&s, "A9"), "East"); - assert_eq!(val(&s, "B9"), Value::Number(33.0)); - assert_eq!(text(&s, "A12"), "Total Sum of Sales"); - assert_eq!(val(&s, "B13"), Value::Number(72.0)); - // Adding a row field in front keeps Σ Values ahead of Region. - s.execute("pivot.addField", json!({"field": "Product", "area": "rows", "position": 0})).unwrap(); - assert_eq!(pt(&s).values_position, Some(1)); - s.execute("pivot.removeField", json!({"field": "Product", "area": "rows"})).unwrap(); - assert_eq!(pt(&s).values_position, Some(0)); - // No row field: one line per value field and no grand total. - s.execute("pivot.removeField", json!({"field": "Region", "area": "rows"})).unwrap(); - let r = pt(&s).last_range.unwrap(); - assert_eq!(r.height(), 1 + 2); - assert_eq!(text(&s, "A4"), "Sum of Sales"); - assert_eq!(val(&s, "B4"), Value::Number(TOTAL)); - assert_eq!(text(&s, "A5"), "Sum of Units"); - assert_eq!(val(&s, "B5"), Value::Number(72.0)); - // Back to Columns. - s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns"})).unwrap(); - assert!(!pt(&s).values_on_rows); - assert_eq!(text(&s, "A3"), "Sum of Sales"); - assert_eq!(text(&s, "B3"), "Sum of Units"); - assert!(s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "filters"})).is_err()); - assert!(s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns"})).is_err()); - } - - #[test] - fn collapsed_item_puts_values_in_the_values_column() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({"rows": ["Region", "Product"], "values": ["Sales", "Units"]})).unwrap(); - s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows"})).unwrap(); - s.execute("pivot.collapse", json!({"field": "Region", "items": ["East"]})).unwrap(); - for layout in ["tabular", "outline"] { - s.execute("pivot.layout", json!({"layout": layout})).unwrap(); - assert_eq!(text(&s, "C3"), "Values", "{layout}"); - let east = (4..20).find(|r| text(&s, &format!("A{r}")) == "East").unwrap(); - let first = if layout == "tabular" { east } else { east + 1 }; - assert_eq!(text(&s, &format!("B{first}")), "", "{layout}"); - assert_eq!(text(&s, &format!("C{first}")), "Sum of Sales", "{layout}"); - assert_eq!(val(&s, &format!("D{first}")), Value::Number(415.0), "{layout}"); - assert_eq!(text(&s, &format!("C{}", first + 1)), "Sum of Units", "{layout}"); - } - } - - #[test] - fn values_stay_last_in_columns() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({"rows": ["Product"], "columns": ["Region"], "values": ["Sales", "Units"]})).unwrap(); - s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "columns", "position": 0})).unwrap(); - let p = pt(&s); - assert!(!p.values_on_rows); - assert_eq!(p.values_position, None); - // Σ Values to Rows and back with a position: still last in Columns. - s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows"})).unwrap(); - s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns", "position": 0})).unwrap(); - assert_eq!(pt(&s).values_position, None); - assert_eq!(text(&s, "B5"), "Sum of Sales"); - assert_eq!(text(&s, "C5"), "Sum of Units"); - } - - #[test] - fn change_source_keeps_values_ahead_of_its_field() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({"rows": ["Region", "Product"], "values": ["Sales", "Units"]})).unwrap(); - s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows", "position": 1})).unwrap(); - // The new source has no Region: Σ Values stays ahead of Product. - s.execute("pivot.changeSource", json!({"source": "Sheet1!B1:E13"})).unwrap(); - let p = pt(&s); - assert_eq!(p.rows.len(), 1); - assert_eq!(p.values_position, Some(0)); - assert_eq!(text(&s, "A4"), "Sum of Sales"); - assert_eq!(text(&s, "A5"), "Apples"); - } - - #[test] - fn values_on_rows_between_row_fields() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({"rows": ["Region", "Product"], "values": ["Sales", "Units"]})).unwrap(); - s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows", "position": 1})).unwrap(); - // Compact: East, then per value field its products, then per-value subtotals of East. - assert_eq!(text(&s, "A4"), "East"); - assert_eq!(text(&s, "A5"), "Sum of Sales"); - assert_eq!(text(&s, "A6"), "Apples"); - assert_eq!(val(&s, "B6"), Value::Number(160.0)); - assert_eq!(text(&s, "A9"), "Sum of Units"); - assert_eq!(text(&s, "A10"), "Apples"); - assert_eq!(val(&s, "B10"), Value::Number(16.0)); - assert_eq!(text(&s, "A13"), "East Sum of Sales"); - assert_eq!(val(&s, "B13"), Value::Number(415.0)); - assert_eq!(text(&s, "A14"), "East Sum of Units"); - assert_eq!(val(&s, "B14"), Value::Number(33.0)); - let d = s.doc().unwrap(); - let sh = d.wb.active().unwrap(); - assert_eq!(d.wb.styles.get(sh.style_id(CellRef::parse("A5").unwrap())).align.indent, 1); - assert_eq!(d.wb.styles.get(sh.style_id(CellRef::parse("A6").unwrap())).align.indent, 2); - // Outline: one column per level, "Values" heads the Σ Values column. - s.execute("pivot.layout", json!({"layout": "outline"})).unwrap(); - assert_eq!(pt(&s).last_range.unwrap().width(), 4); - assert_eq!(text(&s, "A3"), "Region"); - assert_eq!(text(&s, "B3"), "Values"); - assert_eq!(text(&s, "C3"), "Product"); - assert_eq!(text(&s, "B5"), "Sum of Sales"); - assert_eq!(text(&s, "C6"), "Apples"); - assert_eq!(val(&s, "D6"), Value::Number(160.0)); - // Tabular: labels start on their parent's row; totals per value field below each group. - s.execute("pivot.layout", json!({"layout": "tabular"})).unwrap(); - assert_eq!(text(&s, "A4"), "East"); - assert_eq!(text(&s, "B4"), "Sum of Sales"); - assert_eq!(text(&s, "C4"), "Apples"); - assert_eq!(val(&s, "D4"), Value::Number(160.0)); - assert_eq!(text(&s, "B7"), "Sum of Units"); - assert_eq!(text(&s, "C7"), "Apples"); - assert_eq!(val(&s, "D7"), Value::Number(16.0)); - assert_eq!(text(&s, "A10"), "East Sum of Sales"); - assert_eq!(val(&s, "D10"), Value::Number(415.0)); - assert_eq!(text(&s, "A11"), "East Sum of Units"); - let r = pt(&s).last_range.unwrap(); - let last = r.end.row + 1; - assert_eq!(text(&s, &format!("A{}", last - 1)), "Total Sum of Sales"); - assert_eq!(text(&s, &format!("A{last}")), "Total Sum of Units"); - assert_eq!(val(&s, &format!("D{last}")), Value::Number(72.0)); - } - - #[test] - fn refresh_after_source_edits() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]})).unwrap(); - s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); - s.execute("cell.set", json!({"cell": "E2", "input": "1100"})).unwrap(); - // Refresh All picks the change up. - let r = s.execute("data.refreshAll", json!({})).unwrap(); - assert_eq!(r["refreshed"], 1); - let d = s.doc().unwrap(); - let si = d.wb.sheet_index("Sheet2").unwrap(); - let sh = d.wb.sheet(si).unwrap(); - assert_eq!(sh.value(CellRef::parse("B4").unwrap()), Value::Number(1415.0)); - assert_eq!(sh.value(CellRef::parse("B7").unwrap()), Value::Number(TOTAL + 1000.0)); - // pivot.refresh by name; change source to fewer rows. - s.execute("pivot.changeSource", json!({"pivot": "PivotTable1", "source": "Sheet1!A1:E3"})).unwrap(); - let d = s.doc().unwrap(); - let sh = d.wb.sheet(si).unwrap(); - assert_eq!(sh.value(CellRef::parse("A6").unwrap()).display(), "Grand Total"); - assert_eq!(sh.value(CellRef::parse("B6").unwrap()), Value::Number(1150.0)); - // Old rows were cleared. - assert_eq!(sh.value(CellRef::parse("A7").unwrap()), Value::Empty); - s.execute("pivot.refresh", json!({"pivot": "PivotTable1"})).unwrap(); - } - - fn pivot_chart(s: &Session) -> gridcraft_model::Chart { - s.doc().unwrap().wb.active().unwrap().charts.last().unwrap().clone() - } - fn plotted(s: &Session) -> Vec>> { - let d = s.doc().unwrap(); - let data = gridcraft_chart::resolve(&d.wb, d.wb.active_sheet(), &pivot_chart(s)); - data.series.into_iter().map(|x| x.values).collect() - } - - #[test] - fn pivot_chart_follows_the_report() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]})).unwrap(); - // Any cell of the report picks the PivotTable. - let r = s.execute("insert.pivotChart", json!({"pivot": "B5"})).unwrap(); - assert_eq!(r["series"], 1); - let ch = pivot_chart(&s); - assert_eq!(ch.pivot.as_deref(), Some("Sheet2!PivotTable1")); - assert_eq!(ch.title.as_deref(), Some("Total")); - assert_eq!(ch.series[0].values, "Sheet2!$B$4:$B$6"); - assert_eq!(ch.series[0].categories.as_deref(), Some("Sheet2!$A$4:$A$6")); - assert_eq!(plotted(&s), vec![vec![Some(415.0), Some(135.0), Some(285.0)]]); - // A column field: one series per item, rebound by the layout change. - s.execute("pivot.addField", json!({"field": "Product", "area": "columns"})).unwrap(); - let ch = pivot_chart(&s); - let names: Vec<_> = ch.series.iter().map(|x| x.name.clone().unwrap_or_default()).collect(); - assert_eq!(names, vec!["\"Apples\"", "\"Pears\"", "\"Plums\""]); - assert_eq!(plotted(&s)[0], vec![Some(160.0), Some(90.0), Some(170.0)]); - // Nested row fields: the innermost items only, as a union around the parent rows. - s.execute("pivot.moveField", json!({"field": "Product", "from": "columns", "to": "rows"})).unwrap(); - let ch = pivot_chart(&s); - assert!(ch.series[0].values.starts_with('('), "{}", ch.series[0].values); - let pts = plotted(&s); - assert_eq!(pts[0].len(), 9); - assert_eq!(pts[0].iter().flatten().sum::(), TOTAL); - // Survives an XLSX round trip as a PivotChart. - let wb = (*s.doc().unwrap().wb).clone(); - let (back, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(&wb).unwrap()).unwrap(); - let si = back.sheet_index("Sheet2").unwrap(); - assert_eq!(back.sheet(si).unwrap().charts.first().and_then(|c| c.pivot.clone()).as_deref(), Some("Sheet2!PivotTable1")); - // Renaming the sheet keeps the link; a refresh still rebinds. - s.execute("sheet.rename", json!({"name": "Report"})).unwrap(); - assert_eq!(pivot_chart(&s).pivot.as_deref(), Some("Report!PivotTable1")); - s.execute("pivot.moveField", json!({"field": "Region", "from": "rows", "to": "columns"})).unwrap(); - assert_eq!(pivot_chart(&s).series.len(), 3); - // Deleting the PivotTable leaves an ordinary chart. - s.execute("pivot.delete", json!({})).unwrap(); - assert_eq!(pivot_chart(&s).pivot, None); - } - - #[test] - fn pivot_chart_errors() { - let mut s = sales(); - // No PivotTable yet. - assert!(s.execute("insert.pivotChart", json!({})).is_err()); - s.execute("insert.pivotTable", json!({"rows": ["Region"]})).unwrap(); - // Not in the report; no values. - assert!(s.execute("insert.pivotChart", json!({"pivot": "Z99"})).is_err()); - assert!(s.execute("insert.pivotChart", json!({})).unwrap_err().to_string().contains("Values")); - s.execute("pivot.addField", json!({"field": "Units", "area": "values"})).unwrap(); - let r = s.execute("insert.pivotChart", json!({"pivot": "PivotTable1", "type": "line", "title": null})).unwrap(); - assert_eq!(r["series"], 1); - let ch = pivot_chart(&s); - assert_eq!(ch.kind, gridcraft_model::ChartKind::Line); - assert_eq!(ch.title, None); - // One undo step. - s.execute("edit.undo", json!({})).unwrap(); - assert!(s.doc().unwrap().wb.active().unwrap().charts.is_empty()); - } - - #[test] - fn calculated_field_on_sums() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]})).unwrap(); - let r = s.execute("pivot.calculatedField", json!({"name": "Price", "formula": "Sales / Units"})).unwrap(); - assert_eq!(r["formula"], "=Sales / Units"); - assert_eq!(text(&s, "C3"), "Sum of Price"); - assert_eq!(text(&s, "A4"), "East"); - // East: 415 sales over 33 units, from the sums. - let east = val(&s, "C4").as_f64().unwrap(); - assert!((east - 415.0 / 33.0).abs() < 1e-12, "{east}"); - // The grand total applies the formula to the totals, not the sum of the rows. - assert_eq!(text(&s, "A7"), "Grand Total"); - let total = val(&s, "C7").as_f64().unwrap(); - assert!((total - TOTAL / 72.0).abs() < 1e-12, "{total}"); - // Quoted names, functions, and another calculated field. - s.execute("pivot.calculatedField", json!({"name": "Rounded", "formula": "=ROUND('Price'*2,1)"})).unwrap(); - assert_eq!(val(&s, "D4"), Value::Number(25.2)); - // Division by zero shows #DIV/0! cells. - s.execute("pivot.calculatedField", json!({"name": "Zero", "formula": "=Sales/(Units-Units)"})).unwrap(); - assert_eq!(val(&s, "E4"), Value::Error(CellError::Div0)); - // Change and delete. - s.execute("pivot.calculatedField", json!({"name": "zero", "formula": "=Units*2"})).unwrap(); - assert_eq!(val(&s, "E4"), Value::Number(66.0)); - s.execute("pivot.calculatedField", json!({"name": "Zero", "delete": true})).unwrap(); - assert_eq!(pt(&s).values.len(), 3); - assert_eq!(pt(&s).calculated_fields.len(), 2); - // The field list shows calculated fields. - let f = s.execute("pivot.fields", json!({})).unwrap(); - assert!(f["fields"].as_array().unwrap().iter().any(|x| x["name"] == "Price" && x["calculated"] == true)); - } - - #[test] - fn calculated_field_errors() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]})).unwrap(); - let mut e = |p: Json| s.execute("pivot.calculatedField", p).unwrap_err().to_string(); - assert!(e(json!({"name": "Bad", "formula": "=Sales*Bogus"})).contains("Bogus")); - assert!(e(json!({"name": "Bad", "formula": "=A1*2"})).contains("cells")); - assert!(e(json!({"name": "Bad", "formula": "=Sales*("})).contains("error")); - assert!(e(json!({"name": "Sales", "formula": "=1"})).contains("already exists")); - assert!(e(json!({"name": "Bad"})).contains("formula")); - assert!(e(json!({"name": "Gone", "delete": true})).contains("Gone")); - // A cycle is refused. - s.execute("pivot.calculatedField", json!({"name": "X", "formula": "=Sales"})).unwrap(); - s.execute("pivot.calculatedField", json!({"name": "Y", "formula": "=X*2"})).unwrap(); - let err = s.execute("pivot.calculatedField", json!({"name": "X", "formula": "=Y"})).unwrap_err().to_string(); - assert!(err.contains("itself"), "{err}"); - // Calculated fields only go to Values; nothing changed after the failures. - assert!(s.execute("pivot.addField", json!({"field": "Y", "area": "rows"})).is_err()); - assert_eq!(pt(&s).calculated_fields.len(), 2); - // Without a name the dialog opens (menu-style call). - s.ui_requests.clear(); - s.execute("pivot.calculatedField", json!({})).unwrap(); - assert!(matches!(s.ui_requests.last(), Some(crate::UiRequest::Dialog(n, _)) if n == "pivotCalculatedField")); - } - - #[test] - fn replace_protection() { - let mut s = sales(); - s.execute("cell.set", json!({"cell": "H5", "input": "keep me"})).unwrap(); - let e = s.execute("insert.pivotTable", json!({"destination": "Sheet1!H3", "rows": ["Region"], "values": ["Sales"]})).unwrap_err().to_string(); - assert!(e.contains("There's already data in Sheet1!H5"), "{e}"); - assert_eq!(s.doc().unwrap().wb.active().unwrap().pivots.len(), 0); - // Growing into data is refused as well. - s.execute("insert.pivotTable", json!({"destination": "Sheet1!H3"})).unwrap(); - assert!(s.execute("pivot.addField", json!({"field": "Region", "area": "rows"})).is_err()); - s.execute("pivot.addField", json!({"field": "Region", "area": "rows", "replace": true})).unwrap(); - assert_eq!(text(&s, "H5"), "North"); - // A second pivot can't overlap the first. - let e = - s.execute("insert.pivotTable", json!({"source": "Sheet1!A1:E13", "destination": "Sheet1!H6", "replace": true})).unwrap_err().to_string(); - assert!(e.contains("overlap"), "{e}"); - // Nor its own source data. - let e = - s.execute("insert.pivotTable", json!({"source": "Sheet1!A1:E13", "destination": "Sheet1!B2", "replace": true})).unwrap_err().to_string(); - assert!(e.contains("source data"), "{e}"); - // Delete clears the report. - s.execute("pivot.delete", json!({})).unwrap(); - assert_eq!(val(&s, "H5"), Value::Empty); - assert!(s.doc().unwrap().wb.active().unwrap().pivots.is_empty()); - } - - #[test] - fn fields_move_remove_sort() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]})).unwrap(); - let f = s.execute("pivot.fields", json!({})).unwrap(); - assert_eq!(f["fields"][0]["name"], "Region"); - assert_eq!(f["fields"][0]["samples"], json!(["East", "North", "West"])); - assert_eq!(f["fields"][0]["areas"], json!(["rows"])); - assert_eq!(f["fields"][4]["numeric"], true); - assert_eq!(f["fields"][2]["date"], true); - s.execute("pivot.sort", json!({"field": "Region", "order": "desc"})).unwrap(); - assert_eq!(text(&s, "A4"), "West"); - s.execute("pivot.moveField", json!({"field": "Region", "from": "rows", "to": "columns"})).unwrap(); - assert_eq!(text(&s, "B4"), "West"); - s.execute("pivot.removeField", json!({"field": "Sum of Sales", "area": "values"})).unwrap(); - s.execute("pivot.addField", json!({"field": "Region", "area": "values"})).unwrap(); - assert_eq!(pt(&s).values[0].name, "Count of Region"); - s.execute("pivot.removeField", json!({"field": "Region"})).unwrap(); - assert!(pt(&s).columns.is_empty() && pt(&s).values.is_empty()); - assert!(text(&s, "A3").contains("choose fields")); - assert!(s.execute("pivot.addField", json!({"field": "Nope", "area": "rows"})).is_err()); - assert!(s.execute("pivot.addField", json!({"field": "Region", "area": "sideways"})).is_err()); - } - - #[test] - fn recommended_and_summarize() { - let mut s = sales(); - let r = s.execute("insert.recommendedPivotTables", json!({})).unwrap(); - let list = r["suggestions"].as_array().unwrap(); - assert!((3..=5).contains(&list.len()), "{list:?}"); - assert_eq!(list[0]["title"], "Sum of Units by Region"); - let r = s.execute("insert.recommendedPivotTables", json!({"apply": 2})).unwrap(); - assert!(r["suggestion"]["title"].as_str().unwrap().contains("quarters")); - assert_eq!(text(&s, "A4"), "Qtr1"); - // From a table. - s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); - s.execute("insert.table", json!({"range": "A1:E13"})).unwrap(); - s.execute("selection.set", json!({"range": "B2"})).unwrap(); - let r = s.execute("table.summarizePivot", json!({"rows": ["Product"], "values": ["Units"]})).unwrap(); - assert_eq!(pt(&s).source, "Table1"); - assert_eq!(r["name"], "PivotTable2"); - let gt = find_row(&s, "Grand Total").unwrap(); - assert_eq!(val(&s, &CellRef::new(gt, 1).a1()), Value::Number(72.0)); - } - - #[test] - fn repeated_headers_get_numbered_names() { - let mut s = Session::new(); - s.new_workbook(); - let mut header = vec![json!("a"), json!("A"), json!("a"), json!("a2")]; - // Thousands of equal headers (a row of check boxes, say) used to take cubic time; this would - // hang rather than fail a wall-clock check, which is flaky on a loaded machine. - header.extend((0..3000).map(|_| json!("x"))); - let data: Vec<_> = (0..header.len()).map(|i| json!(i)).collect(); - let end = CellRef::new(1, header.len() as u32 - 1).a1(); - s.execute("range.setValues", json!({"range": "A1", "values": [header, data]})).unwrap(); - let src = crate::pivot::read_source(&s.doc().unwrap().wb, &format!("Sheet1!A1:{end}"), 0).unwrap(); - assert_eq!(&src.headers[..4], ["a", "A2", "a3", "a22"]); - assert_eq!(src.headers[4], "x"); - assert_eq!(src.headers.last().map(String::as_str), Some("x3000")); - // Table column names follow the same rule. - s.execute("insert.table", json!({"range": format!("A1:{end}"), "header": true})).unwrap(); - let text = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["text"].clone(); - assert_eq!(text(&mut s, "B1"), "A2"); - let last = CellRef::new(0, 3003).a1(); - assert_eq!(text(&mut s, &last), "x3000"); - } - - #[test] - fn hostile_params_never_panic() { - let mut s = sales(); - let cmds = [ - "insert.pivotTable", - "insert.recommendedPivotTables", - "table.summarizePivot", - "pivot.fields", - "pivot.fieldList", - "pivot.list", - "pivot.addField", - "pivot.removeField", - "pivot.moveField", - "pivot.valueSettings", - "pivot.filter", - "pivot.sort", - "pivot.group", - "pivot.collapse", - "pivot.layout", - "pivot.grandTotals", - "pivot.subtotals", - "pivot.reportLayout", - "pivot.style", - "pivot.refresh", - "data.refreshAll", - "pivot.changeSource", - "pivot.delete", - "pivot.showDetails", - "pivot.options", - ]; - let hostile = [ - json!({}), - json!([1, 2]), - json!({"pivot": 99999, "field": 5, "area": null}), - json!({"source": "ZZZ999999999", "destination": "Nope!A1"}), - json!({"source": "A:XFD", "destination": "Sheet1!XFD1048576"}), - json!({"source": "''!A1", "destination": "!"}), - json!({"field": "Sales", "area": "values", "func": "bogus", "position": 4294967295u64}), - json!({"field": "Region", "selected": [null, 1, {"x": 1}], "index": 18446744073709551615u64, "showAs": "rank"}), - json!({"apply": 99, "layout": "weird", "subtotals": 7, "style": "x".repeat(500)}), - json!({"field": "Date", "by": "decades", "items": "nope", "order": "sideways"}), - ]; - for c in cmds { - for h in &hostile { - let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - let spec = crate::find_command(c).unwrap(); - let _ = (spec.run)(&mut s, h); - })); - assert!(r.is_ok(), "{c} panicked on {h}"); - } - } - // And on a real pivot. - s.execute("insert.pivotTable", json!({"source": "Sheet1!A1:E13", "rows": ["Region"], "values": ["Sales"]})).unwrap(); - for c in cmds { - for h in &hostile { - let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - let spec = crate::find_command(c).unwrap(); - let _ = (spec.run)(&mut s, h); - })); - assert!(r.is_ok(), "{c} panicked on {h}"); - } - } - } - - #[test] - fn empty_header_is_refused() { - let mut s = Session::new(); - s.new_workbook(); - s.execute("range.setValues", json!({"range": "A1", "values": [["Region", ""], ["East", 1]]})).unwrap(); - let e = s.execute("insert.pivotTable", json!({"source": "A1:B2"})).unwrap_err().to_string(); - assert!(e.contains("field name is not valid"), "{e}"); - assert!(s.execute("insert.pivotTable", json!({"source": "NoSuchTable"})).is_err()); - } - - #[test] - fn too_many_value_columns_are_refused() { - let mut s = Session::new(); - s.new_workbook(); - let mut records: Vec = vec![json!(["Cat", "Sub", "V"])]; - for r in 0..64 { - for c in 0..64 { - records.push(json!([format!("c{r}"), format!("s{c}"), 1.0])); - } - } - s.execute("range.setValues", json!({"range": "A1", "values": records})).unwrap(); - let req = |n: usize| { - let values: Vec = (0..n).map(|_| json!({"field": "V"})).collect(); - json!({"source": "A1:C4097", "rows": ["Cat"], "columns": ["Sub"], "values": values}) - }; - let e = s.execute("insert.pivotTable", req(256)).unwrap_err().to_string(); - assert!(e.contains("will not fit"), "{e}"); - assert!(s.execute("insert.pivotTable", req(128)).is_ok()); - } - - #[test] - fn collapsed_column_items_count_as_one_column_group() { - let mut s = Session::new(); - s.new_workbook(); - let mut records: Vec = vec![json!(["Grp", "Sub", "V"])]; - records.extend((0..64).map(|c| json!(["g", format!("s{c}"), 1.0]))); - s.execute("range.setValues", json!({"range": "A1", "values": records})).unwrap(); - let values: Vec = (0..256).map(|_| json!({"field": "V"})).collect(); - s.execute("insert.pivotTable", json!({"source": "A1:C65", "columns": ["Grp"], "values": values})).unwrap(); - s.execute("pivot.collapse", json!({"field": "Grp"})).unwrap(); - // 64 Sub items under the collapsed "g" show as one column group: (1 + grand total) x 256 columns fit. - s.execute("pivot.addField", json!({"field": "Sub", "area": "columns"})).unwrap(); - } - - #[test] - fn inserting_or_deleting_lines_moves_the_pivot_with_its_cells() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({"source": "Sheet1!A1:E13", "destination": "Sheet1!H10", "rows": ["Region"], "values": ["Sales"]})) - .unwrap(); - assert_eq!(pt(&s).last_range.unwrap().a1(), "H10:I14"); - // Rows above the report (and below the source) move the report only. - s.execute("home.insertRows", json!({"rows": "16:17"})).unwrap(); - s.execute("home.insertRows", json!({"rows": "6:7"})).unwrap(); - assert_eq!(text(&s, "H12"), "Row Labels"); - let l = s.execute("pivot.list", json!({})).unwrap(); - assert_eq!(l[0]["anchor"], "H12"); - assert_eq!(l[0]["range"], "H12:I16"); - assert_eq!(l[0]["source"], "Sheet1!A1:E15"); - // A row above the source moves both. - s.execute("home.insertRows", json!({"rows": "1:1"})).unwrap(); - let p = pt(&s); - assert_eq!(p.source, "Sheet1!A2:E16"); - assert_eq!(p.anchor.a1(), "H13"); - s.execute("pivot.refresh", json!({})).unwrap(); - assert_eq!(pt(&s).last_range.unwrap().a1(), "H13:I17"); - assert_eq!(text(&s, "H13"), "Row Labels"); - assert_eq!(val(&s, "I17"), Value::Number(TOTAL)); - // Deleting a column left of the report moves it left. - s.execute("home.deleteColumns", json!({"cols": "F:F"})).unwrap(); - let p = pt(&s); - assert_eq!(p.anchor.a1(), "G13"); - assert_eq!(p.last_range.unwrap().a1(), "G13:H17"); - // Deleting rows above moves it up. - s.execute("home.deleteRows", json!({"rows": "1:1"})).unwrap(); - assert_eq!(pt(&s).source, "Sheet1!A1:E15"); - assert_eq!(pt(&s).anchor.a1(), "G12"); - s.execute("pivot.refresh", json!({})).unwrap(); - assert_eq!(text(&s, "G12"), "Row Labels"); - assert_eq!(val(&s, "H16"), Value::Number(TOTAL)); - } - - #[test] - fn inserting_rows_on_the_source_sheet_moves_another_sheets_pivot_source() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({"source": "Sheet1!A1:E13", "rows": ["Region"], "values": ["Sales"]})).unwrap(); - s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); - s.execute("home.insertRows", json!({"rows": "1:2"})).unwrap(); - let l = s.execute("pivot.list", json!({})).unwrap(); - assert_eq!(l[0]["source"], "Sheet1!A3:E15"); - assert_eq!(l[0]["anchor"], "A3"); - s.execute("pivot.refresh", json!({"pivot": "PivotTable1"})).unwrap(); - } - - /// The active sheet's first five columns as shown, down to the first blank row. - fn sheet_rows(s: &mut Session) -> Vec> { - let mut out = Vec::new(); - for r in 0..30u32 { - let row: Vec = (0..5) - .map(|c| s.execute("cell.get", json!({"cell": CellRef::new(r, c).a1()})).unwrap()["text"].as_str().unwrap_or("").to_string()) - .collect(); - if row.iter().all(String::is_empty) { - break; - } - out.push(row); - } - out - } - - #[test] - fn show_details_lists_a_value_cells_records_on_a_new_sheet() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); - // B5 is East × Apples (100 + 60); the active cell is the default. - s.execute("selection.set", json!({"range": "B5"})).unwrap(); - let r = s.execute("pivot.showDetails", json!({})).unwrap(); - assert_eq!(r["records"], 2); - let d = s.doc().unwrap(); - assert_eq!(d.wb.sheets.len(), 3); - assert_eq!(d.wb.active_sheet(), 0, "the new sheet goes before the PivotTable's"); - assert_eq!(d.wb.active().unwrap().name, "Sheet3"); - assert_eq!( - sheet_rows(&mut s), - vec![ - vec!["Region", "Product", "Date", "Units", "Sales"], - vec!["East", "Apples", "1/15/2026", "10", "100"], - vec!["East", "Apples", "9/9/2026", "6", "60"], - ] - ); - // The dates fit their column instead of showing ####. - let sh = s.doc().unwrap().wb.active().unwrap().clone(); - assert!(sh.col_width(2) > sh.col_width(5), "{} vs {}", sh.col_width(2), sh.col_width(5)); - // One undo step takes the sheet away. - s.execute("edit.undo", json!({})).unwrap(); - assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); - assert_eq!(val(&s, "B5"), Value::Number(160.0)); - } - - #[test] - fn show_details_on_a_total_takes_every_record_it_adds_up() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); - let records = |s: &mut Session, cell: &str| { - let n = s.execute("pivot.showDetails", json!({"cell": cell})).unwrap()["records"].as_u64().unwrap(); - s.execute("edit.undo", json!({})).unwrap(); - n - }; - assert_eq!(records(&mut s, "E5"), 5, "East's row total"); - assert_eq!(records(&mut s, "C8"), 4, "Pears' column total"); - assert_eq!(records(&mut s, "E8"), 12, "the grand total"); - // A subtotal at the top of its group, in a two-level row axis. - s.execute("pivot.moveField", json!({"field": "Product", "from": "columns", "to": "rows"})).unwrap(); - assert_eq!(text(&s, "A4"), "East"); - assert_eq!(records(&mut s, "B4"), 5); - assert_eq!(text(&s, "A6"), "Pears"); - assert_eq!(records(&mut s, "B6"), 2); - } - - #[test] - fn show_details_leaves_out_the_records_the_report_filters_out() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({"rows": ["Product"], "values": ["Sales"], "filters": ["Region"]})).unwrap(); - s.execute("pivot.filter", json!({"field": "Region", "selected": ["East"]})).unwrap(); - s.execute("pivot.filter", json!({"field": "Product", "selected": ["Apples"]})).unwrap(); - let gt = find_row(&s, "Grand Total").unwrap(); - let r = s.execute("pivot.showDetails", json!({"cell": CellRef::new(gt, 1).a1()})).unwrap(); - assert_eq!(r["records"], 2); - let rows = sheet_rows(&mut s); - assert_eq!(rows.len(), 3); - assert!(rows.iter().skip(1).all(|row| row[0] == "East" && row[1] == "Apples"), "{rows:?}"); - } - - #[test] - fn show_details_refuses_a_report_its_source_has_moved_on_from() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); - // B5 still shows East × Apples (160), but the first East record is now Central's. - s.execute("range.setValues", json!({"range": "Sheet1!A2", "values": [["Central"]]})).unwrap(); - assert_eq!(val(&s, "B5"), Value::Number(160.0)); - let e = s.execute("pivot.showDetails", json!({"cell": "B5"})).unwrap_err().to_string(); - assert!(e.contains("out of date"), "{e}"); - assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); - // After a refresh East × Apples (now B6) has the one record left. - s.execute("pivot.refresh", json!({})).unwrap(); - assert_eq!(text(&s, "A6"), "East"); - assert_eq!(s.execute("pivot.showDetails", json!({"cell": "B6"})).unwrap()["records"], 1); - } - - #[test] - fn show_details_refuses_a_report_that_would_now_be_shorter() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); - // North goes away: the source now gives one row fewer, so row 8 is no longer the Grand Total. - s.execute("range.setValues", json!({"range": "Sheet1!A5", "values": [["East"]]})).unwrap(); - s.execute("range.setValues", json!({"range": "Sheet1!A8", "values": [["East"]]})).unwrap(); - s.execute("range.setValues", json!({"range": "Sheet1!A11", "values": [["East"]]})).unwrap(); - assert_eq!(text(&s, "A8"), "Grand Total"); - let e = s.execute("pivot.showDetails", json!({"cell": "E8"})).unwrap_err().to_string(); - assert!(e.contains("out of date"), "{e}"); - assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); - } - - #[test] - fn show_details_needs_a_value_cell() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); - for cell in ["A3", "B3", "A4", "B4", "A5", "A8", "H20"] { - assert!(s.execute("pivot.showDetails", json!({"cell": cell})).is_err(), "{cell}"); - } - assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); - } - - #[test] - fn enable_show_details_off_stops_show_details() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); - assert_eq!(s.execute("pivot.options", json!({})).unwrap()["enableShowDetails"], true); - s.execute("pivot.options", json!({"enableShowDetails": false})).unwrap(); - assert!(!pt(&s).enable_show_details); - assert_eq!(s.execute("pivot.fields", json!({})).unwrap()["enableShowDetails"], false); - let e = s.execute("pivot.showDetails", json!({"cell": "B5"})).unwrap_err().to_string(); - assert_eq!(e, "Show Details is turned off for this PivotTable."); - assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); - // A label still reports that it is not a value cell. - let e = s.execute("pivot.showDetails", json!({"cell": "A5"})).unwrap_err().to_string(); - assert!(!e.contains("turned off"), "{e}"); - // Setting the same value again adds no undo step. - let after = s.doc().unwrap().revision; - s.execute("pivot.options", json!({"enableShowDetails": false})).unwrap(); - assert_eq!(s.doc().unwrap().revision, after); - assert!(s.execute("pivot.options", json!({"enableShowDetails": "no"})).is_err()); - // One undo turns the option back on. - s.execute("edit.undo", json!({})).unwrap(); - assert!(pt(&s).enable_show_details); - assert_eq!(s.execute("pivot.showDetails", json!({"cell": "B5"})).unwrap()["records"], 2); - } - - #[test] - fn enable_show_details_off_is_reported_before_an_out_of_date_report() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); - s.execute("pivot.options", json!({"enableShowDetails": false})).unwrap(); - // The source changes after the report was written, so the report is out of date. - s.execute("range.setValues", json!({"range": "Sheet1!A2", "values": [["Central"]]})).unwrap(); - let e = s.execute("pivot.showDetails", json!({"cell": "B5"})).unwrap_err().to_string(); - assert!(e.contains("turned off") && !e.contains("out of date"), "{e}"); - assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); - } - - #[test] - fn detail_cells_are_the_cells_show_details_takes() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); - for layout in 0..2 { - if layout == 1 { - // A two-level row axis, with subtotals at the top of each group. - s.execute("pivot.moveField", json!({"field": "Product", "from": "columns", "to": "rows"})).unwrap(); - } - let d = s.doc().unwrap(); - let si = d.wb.active_sheet(); - let pt = &d.wb.active().unwrap().pivots[0]; - let r = pt.last_range.unwrap(); - let cells = crate::pivot::detail_cells(&d.wb, pt, si).unwrap(); - let mut values = 0; - for row in 0..r.height() { - for col in 0..r.width() { - let one = crate::pivot::detail_records(&d.wb, pt, si, row, col).unwrap(); - assert_eq!(cells.contains(&(row, col)), one.is_some(), "layout {layout}, cell ({row}, {col})"); - values += usize::from(one.is_some()); - } - } - assert!(values > 4, "layout {layout} has value cells"); - } - // An out-of-date report answers as Show Details does. - s.execute("range.setValues", json!({"range": "Sheet1!A2", "values": [["Central"]]})).unwrap(); - let d = s.doc().unwrap(); - let pt = &d.wb.active().unwrap().pivots[0]; - assert!(crate::pivot::detail_cells(&d.wb, pt, d.wb.active_sheet()).unwrap_err().contains("out of date")); - } -} +mod tests; diff --git a/crates/engine/src/cmd/pivot/tests.rs b/crates/engine/src/cmd/pivot/tests.rs new file mode 100644 index 000000000..49aca34d8 --- /dev/null +++ b/crates/engine/src/cmd/pivot/tests.rs @@ -0,0 +1,994 @@ +use gridcraft_core::{CellError, CellRef, Value}; +use serde_json::{Value as Json, json}; + +use crate::Session; + +/// Region, Product, Date, Units, Sales — 12 records over 2026. +fn sales() -> Session { + let mut s = Session::new(); + s.new_workbook(); + let rows = json!([ + ["Region", "Product", "Date", "Units", "Sales"], + ["East", "Apples", "1/15/2026", 10, 100], + ["West", "Apples", "2/10/2026", 5, 50], + ["East", "Pears", "3/3/2026", 8, 120], + ["North", "Pears", "4/20/2026", 2, 30], + ["West", "Plums", "5/5/2026", 7, 70], + ["East", "Plums", "6/30/2026", 4, 60], + ["North", "Apples", "7/4/2026", 9, 90], + ["West", "Pears", "8/8/2026", 3, 45], + ["East", "Apples", "9/9/2026", 6, 60], + ["North", "Plums", "10/10/2026", 1, 15], + ["West", "Apples", "11/11/2026", 12, 120], + ["East", "Pears", "12/12/2026", 5, 75] + ]); + s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap(); + s.execute("selection.set", json!({"range": "A1"})).unwrap(); + s +} +const TOTAL: f64 = 835.0; + +fn val(s: &Session, a: &str) -> Value { + s.doc().unwrap().wb.active().unwrap().value(CellRef::parse(a).unwrap()) +} +fn text(s: &Session, a: &str) -> String { + val(s, a).display() +} +fn pt(s: &Session) -> gridcraft_model::PivotTable { + s.doc().unwrap().wb.active().unwrap().pivots.last().unwrap().clone() +} +/// Finds the row (0-based) whose first report column holds `label`. +fn find_row(s: &Session, label: &str) -> Option { + let p = pt(s); + let r = p.last_range.unwrap(); + (r.start.row..=r.end.row).find(|row| val(s, &CellRef::new(*row, r.start.col).a1()).display() == label) +} + +#[test] +fn insert_creates_placeholder_on_new_sheet() { + let mut s = sales(); + let r = s.execute("insert.pivotTable", json!({})).unwrap(); + assert_eq!(r["name"], "PivotTable1"); + assert_eq!(r["anchor"], "A3"); + assert_eq!(r["fields"], json!(["Region", "Product", "Date", "Units", "Sales"])); + let d = s.doc().unwrap(); + assert_eq!(d.wb.sheets.len(), 2); + assert_eq!(d.wb.active_sheet(), 0, "new sheet goes before the current one"); + assert_eq!(d.wb.active().unwrap().name, "Sheet2"); + assert!(text(&s, "A3").starts_with("PivotTable1")); + assert_eq!(pt(&s).source, "Sheet1!$A$1:$E$13"); + // One undo step removes it. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.doc().unwrap().wb.sheets.len(), 1); +} + +#[test] +fn region_by_product_sum_of_sales() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({})).unwrap(); + s.execute("pivot.addField", json!({"field": "region", "area": "rows"})).unwrap(); + s.execute("pivot.addField", json!({"field": "Product", "area": "columns"})).unwrap(); + let r = s.execute("pivot.addField", json!({"field": "Sales", "area": "values"})).unwrap(); + assert_eq!(r["range"], "A3:E8"); + assert_eq!(text(&s, "A3"), "Sum of Sales"); + assert_eq!(text(&s, "B3"), "Column Labels"); + assert_eq!(text(&s, "A4"), "Row Labels"); + assert_eq!(text(&s, "B4"), "Apples"); + assert_eq!(text(&s, "C4"), "Pears"); + assert_eq!(text(&s, "D4"), "Plums"); + assert_eq!(text(&s, "E4"), "Grand Total"); + assert_eq!(text(&s, "A5"), "East"); + assert_eq!(text(&s, "A6"), "North"); + assert_eq!(text(&s, "A7"), "West"); + assert_eq!(text(&s, "A8"), "Grand Total"); + assert_eq!(val(&s, "B5"), Value::Number(160.0)); + assert_eq!(val(&s, "C5"), Value::Number(195.0)); + assert_eq!(val(&s, "E5"), Value::Number(415.0)); + assert_eq!(val(&s, "E8"), Value::Number(TOTAL)); + // Column grand totals add up too. + let sum: f64 = ["B8", "C8", "D8"].iter().map(|a| val(&s, a).as_f64().unwrap()).sum(); + assert_eq!(sum, TOTAL); + let rows: f64 = ["E5", "E6", "E7"].iter().map(|a| val(&s, a).as_f64().unwrap()).sum(); + assert_eq!(rows, TOTAL); + // Styles: header bold, grand total bold with a top border. + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + assert!(d.wb.styles.get(sh.style_id(CellRef::parse("A4").unwrap())).font.bold); + let g = d.wb.styles.get(sh.style_id(CellRef::parse("B8").unwrap())); + assert!(g.font.bold && !g.borders.top.is_none()); + // Listing. + let l = s.execute("pivot.list", json!({})).unwrap(); + assert_eq!(l[0]["values"][0]["name"], "Sum of Sales"); +} + +#[test] +fn multiple_value_fields_make_a_values_dimension() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales", {"field": "Units", "func": "average"}]})).unwrap(); + assert_eq!(text(&s, "A3"), "Row Labels"); + assert_eq!(text(&s, "B3"), "Sum of Sales"); + assert_eq!(text(&s, "C3"), "Average of Units"); + assert_eq!(text(&s, "A7"), "Grand Total"); + assert_eq!(val(&s, "B7"), Value::Number(TOTAL)); + assert_eq!(val(&s, "C7"), Value::Number(72.0 / 12.0)); + // With a column field the values become the innermost column level. + s.execute("pivot.addField", json!({"field": "Product", "area": "columns"})).unwrap(); + assert_eq!(text(&s, "B4"), "Apples"); + assert_eq!(text(&s, "B5"), "Sum of Sales"); + assert_eq!(text(&s, "C5"), "Average of Units"); + assert_eq!(text(&s, "D4"), "Pears"); + assert_eq!(text(&s, "H4"), "Total Sum of Sales"); + assert_eq!(text(&s, "I4"), "Total Average of Units"); + let last = pt(&s).last_range.unwrap(); + assert_eq!(val(&s, &CellRef::new(last.end.row, 7).a1()), Value::Number(TOTAL)); +} + +#[test] +fn date_grouping_by_quarter_and_month() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Date"], "values": ["Sales"]})).unwrap(); + // Ungrouped: one row per date, shown with the source date format. + assert_eq!(pt(&s).last_range.unwrap().height(), 14); + s.execute("pivot.group", json!({"field": "Date", "by": "quarters"})).unwrap(); + assert_eq!(text(&s, "A4"), "Qtr1"); + assert_eq!(text(&s, "A7"), "Qtr4"); + assert_eq!(val(&s, "B4"), Value::Number(270.0)); + assert_eq!(val(&s, "B5"), Value::Number(160.0)); + assert_eq!(val(&s, "B8"), Value::Number(TOTAL)); + s.execute("pivot.group", json!({"field": "Date", "by": "months"})).unwrap(); + assert_eq!(text(&s, "A4"), "Jan"); + assert_eq!(text(&s, "A15"), "Dec"); + s.execute("pivot.group", json!({"field": "Date", "by": "years"})).unwrap(); + assert_eq!(text(&s, "A4"), "2026"); + assert!(s.execute("pivot.group", json!({"field": "Region", "by": "years"})).is_err()); +} + +#[test] +fn filters_and_report_filter_rows() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Product"], "values": ["Sales"], "filters": ["Region"]})).unwrap(); + // Report filter above the body; the body stays at A3. + assert_eq!(text(&s, "A1"), "Region"); + assert_eq!(text(&s, "B1"), "(All)"); + assert_eq!(text(&s, "A3"), "Row Labels"); + s.execute("pivot.filter", json!({"field": "Region", "selected": ["East"]})).unwrap(); + assert_eq!(text(&s, "B1"), "East"); + let gt = find_row(&s, "Grand Total").unwrap(); + assert_eq!(val(&s, &CellRef::new(gt, 1).a1()), Value::Number(415.0)); + s.execute("pivot.filter", json!({"field": "Region", "selected": ["East", "west"]})).unwrap(); + assert_eq!(text(&s, "B1"), "(Multiple Items)"); + s.execute("pivot.filter", json!({"field": "Region", "selected": null})).unwrap(); + let gt = find_row(&s, "Grand Total").unwrap(); + assert_eq!(val(&s, &CellRef::new(gt, 1).a1()), Value::Number(TOTAL)); + // Hidden items on a row field. + s.execute("pivot.filter", json!({"field": "Product", "selected": ["Apples"]})).unwrap(); + assert_eq!(text(&s, "A4"), "Apples"); + assert_eq!(text(&s, "A5"), "Grand Total"); + assert_eq!(val(&s, "B5"), Value::Number(420.0)); + assert!(s.execute("pivot.filter", json!({"field": "Product", "selected": []})).is_err()); +} + +#[test] +fn show_values_as() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]})).unwrap(); + s.execute("pivot.valueSettings", json!({"field": "Sum of Sales", "showAs": "percentOfGrandTotal"})).unwrap(); + let east = val(&s, "B4").as_f64().unwrap(); + assert!((east - 415.0 / TOTAL).abs() < 1e-12); + assert_eq!(val(&s, "B7"), Value::Number(1.0)); + assert_eq!(text(&s, "B7"), "1"); + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + assert_eq!(d.wb.styles.get(sh.style_id(CellRef::parse("B4").unwrap())).num_fmt.as_str(), "0.00%"); + s.execute("pivot.valueSettings", json!({"index": 0, "showAs": "runningTotal"})).unwrap(); + assert_eq!(val(&s, "B4"), Value::Number(415.0)); + assert_eq!(val(&s, "B5"), Value::Number(415.0 + 135.0)); + assert_eq!(val(&s, "B6"), Value::Number(TOTAL)); + s.execute("pivot.valueSettings", json!({"index": 0, "showAs": "rank"})).unwrap(); + assert_eq!(val(&s, "B4"), Value::Number(1.0)); + assert_eq!(val(&s, "B5"), Value::Number(3.0)); + assert_eq!(val(&s, "B6"), Value::Number(2.0)); + s.execute("pivot.valueSettings", json!({"index": 0, "showAs": "normal", "func": "max", "numberFormat": "#,##0"})).unwrap(); + assert_eq!(text(&s, "B3"), "Max of Sales"); + assert_eq!(val(&s, "B4"), Value::Number(120.0)); + assert!(s.execute("pivot.valueSettings", json!({"index": 0, "name": "Sales"})).is_err()); + s.execute("pivot.valueSettings", json!({"index": 0, "name": "Biggest sale"})).unwrap(); + assert_eq!(text(&s, "B3"), "Biggest sale"); +} + +#[test] +fn compact_vs_tabular_shapes() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region", "Product"], "values": ["Sales"]})).unwrap(); + // Compact: one label column; parents carry subtotals at the top; children indented. + let r = pt(&s).last_range.unwrap(); + assert_eq!(r.width(), 2); + // header + 3 regions + 9 (region, product) pairs present + grand total + assert_eq!(r.height(), 1 + 3 + 9 + 1); + assert_eq!(text(&s, "A4"), "East"); + assert_eq!(val(&s, "B4"), Value::Number(415.0)); + assert_eq!(text(&s, "A5"), "Apples"); + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + assert_eq!(d.wb.styles.get(sh.style_id(CellRef::parse("A5").unwrap())).align.indent, 1); + assert!(d.wb.styles.get(sh.style_id(CellRef::parse("A4").unwrap())).font.bold); + // Outline: one column per field. + s.execute("pivot.layout", json!({"layout": "outline"})).unwrap(); + let r = pt(&s).last_range.unwrap(); + assert_eq!(r.width(), 3); + assert_eq!(text(&s, "A3"), "Region"); + assert_eq!(text(&s, "B3"), "Product"); + assert_eq!(text(&s, "A4"), "East"); + assert_eq!(text(&s, "B5"), "Apples"); + // Tabular: first child on its parent's row; "East Total" rows below each group. + s.execute("pivot.layout", json!({"layout": "tabular"})).unwrap(); + let r = pt(&s).last_range.unwrap(); + assert_eq!(r.width(), 3); + assert_eq!(r.height(), 1 + 9 + 3 + 1); + assert_eq!(text(&s, "A4"), "East"); + assert_eq!(text(&s, "B4"), "Apples"); + assert_eq!(text(&s, "A5"), ""); + assert_eq!(text(&s, "B5"), "Pears"); + assert_eq!(text(&s, "A7"), "East Total"); + assert_eq!(val(&s, "C7"), Value::Number(415.0)); + // No subtotals, no grand totals. + s.execute("pivot.layout", json!({"subtotals": false, "grandTotalsCols": false})).unwrap(); + assert_eq!(pt(&s).last_range.unwrap().height(), 1 + 9); + // Collapse East in compact layout: its children disappear. + s.execute("pivot.layout", json!({"layout": "compact", "subtotals": "top"})).unwrap(); + s.execute("pivot.collapse", json!({"field": "Region", "items": ["East"]})).unwrap(); + assert_eq!(text(&s, "A4"), "East"); + assert_eq!(text(&s, "A5"), "North"); + s.execute("pivot.collapse", json!({"field": "Region", "collapse": false})).unwrap(); + assert_eq!(text(&s, "A5"), "Apples"); +} + +#[test] +fn values_on_rows_layouts() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales", "Units"]})).unwrap(); + assert_eq!(text(&s, "C3"), "Sum of Units"); + // Σ Values after Region: each region holds one line per value field. + s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows"})).unwrap(); + let p = pt(&s); + assert!(p.values_on_rows); + assert_eq!(p.values_position, None); + let r = p.last_range.unwrap(); + assert_eq!(r.width(), 2); + // header + 3 × (region + 2 values) + 2 grand totals + assert_eq!(r.height(), 1 + 9 + 2); + assert_eq!(text(&s, "A4"), "East"); + assert_eq!(val(&s, "B4"), Value::Empty); + assert_eq!(text(&s, "A5"), "Sum of Sales"); + assert_eq!(val(&s, "B5"), Value::Number(415.0)); + assert_eq!(text(&s, "A6"), "Sum of Units"); + assert_eq!(val(&s, "B6"), Value::Number(33.0)); + assert_eq!(text(&s, "A13"), "Total Sum of Sales"); + assert_eq!(val(&s, "B13"), Value::Number(TOTAL)); + assert_eq!(text(&s, "A14"), "Total Sum of Units"); + assert_eq!(val(&s, "B14"), Value::Number(72.0)); + // Tabular, no column field: "Total" heads the data column, as Excel saves it. + s.execute("pivot.layout", json!({"layout": "tabular"})).unwrap(); + assert_eq!(text(&s, "A3"), "Region"); + assert_eq!(text(&s, "B3"), "Values"); + assert_eq!(text(&s, "C3"), "Total"); + s.execute("pivot.layout", json!({"layout": "compact"})).unwrap(); + // Σ Values before Region: the value fields are the outer items. + s.execute("pivot.moveField", json!({"field": "Values", "from": "rows", "to": "rows", "position": 0})).unwrap(); + assert_eq!(pt(&s).values_position, Some(0)); + assert_eq!(text(&s, "A4"), "Sum of Sales"); + assert_eq!(val(&s, "B4"), Value::Empty); + assert_eq!(text(&s, "A5"), "East"); + assert_eq!(val(&s, "B5"), Value::Number(415.0)); + assert_eq!(text(&s, "A8"), "Sum of Units"); + assert_eq!(text(&s, "A9"), "East"); + assert_eq!(val(&s, "B9"), Value::Number(33.0)); + assert_eq!(text(&s, "A12"), "Total Sum of Sales"); + assert_eq!(val(&s, "B13"), Value::Number(72.0)); + // Adding a row field in front keeps Σ Values ahead of Region. + s.execute("pivot.addField", json!({"field": "Product", "area": "rows", "position": 0})).unwrap(); + assert_eq!(pt(&s).values_position, Some(1)); + s.execute("pivot.removeField", json!({"field": "Product", "area": "rows"})).unwrap(); + assert_eq!(pt(&s).values_position, Some(0)); + // No row field: one line per value field and no grand total. + s.execute("pivot.removeField", json!({"field": "Region", "area": "rows"})).unwrap(); + let r = pt(&s).last_range.unwrap(); + assert_eq!(r.height(), 1 + 2); + assert_eq!(text(&s, "A4"), "Sum of Sales"); + assert_eq!(val(&s, "B4"), Value::Number(TOTAL)); + assert_eq!(text(&s, "A5"), "Sum of Units"); + assert_eq!(val(&s, "B5"), Value::Number(72.0)); + // Back to Columns. + s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns"})).unwrap(); + assert!(!pt(&s).values_on_rows); + assert_eq!(text(&s, "A3"), "Sum of Sales"); + assert_eq!(text(&s, "B3"), "Sum of Units"); + assert!(s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "filters"})).is_err()); + assert!(s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns"})).is_err()); +} + +#[test] +fn collapsed_item_puts_values_in_the_values_column() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region", "Product"], "values": ["Sales", "Units"]})).unwrap(); + s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows"})).unwrap(); + s.execute("pivot.collapse", json!({"field": "Region", "items": ["East"]})).unwrap(); + for layout in ["tabular", "outline"] { + s.execute("pivot.layout", json!({"layout": layout})).unwrap(); + assert_eq!(text(&s, "C3"), "Values", "{layout}"); + let east = (4..20).find(|r| text(&s, &format!("A{r}")) == "East").unwrap(); + let first = if layout == "tabular" { east } else { east + 1 }; + assert_eq!(text(&s, &format!("B{first}")), "", "{layout}"); + assert_eq!(text(&s, &format!("C{first}")), "Sum of Sales", "{layout}"); + assert_eq!(val(&s, &format!("D{first}")), Value::Number(415.0), "{layout}"); + assert_eq!(text(&s, &format!("C{}", first + 1)), "Sum of Units", "{layout}"); + } +} + +#[test] +fn values_stay_last_in_columns() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Product"], "columns": ["Region"], "values": ["Sales", "Units"]})).unwrap(); + s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "columns", "position": 0})).unwrap(); + let p = pt(&s); + assert!(!p.values_on_rows); + assert_eq!(p.values_position, None); + // Σ Values to Rows and back with a position: still last in Columns. + s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows"})).unwrap(); + s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns", "position": 0})).unwrap(); + assert_eq!(pt(&s).values_position, None); + assert_eq!(text(&s, "B5"), "Sum of Sales"); + assert_eq!(text(&s, "C5"), "Sum of Units"); +} + +#[test] +fn change_source_keeps_values_ahead_of_its_field() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region", "Product"], "values": ["Sales", "Units"]})).unwrap(); + s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows", "position": 1})).unwrap(); + // The new source has no Region: Σ Values stays ahead of Product. + s.execute("pivot.changeSource", json!({"source": "Sheet1!B1:E13"})).unwrap(); + let p = pt(&s); + assert_eq!(p.rows.len(), 1); + assert_eq!(p.values_position, Some(0)); + assert_eq!(text(&s, "A4"), "Sum of Sales"); + assert_eq!(text(&s, "A5"), "Apples"); +} + +#[test] +fn values_on_rows_between_row_fields() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region", "Product"], "values": ["Sales", "Units"]})).unwrap(); + s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows", "position": 1})).unwrap(); + // Compact: East, then per value field its products, then per-value subtotals of East. + assert_eq!(text(&s, "A4"), "East"); + assert_eq!(text(&s, "A5"), "Sum of Sales"); + assert_eq!(text(&s, "A6"), "Apples"); + assert_eq!(val(&s, "B6"), Value::Number(160.0)); + assert_eq!(text(&s, "A9"), "Sum of Units"); + assert_eq!(text(&s, "A10"), "Apples"); + assert_eq!(val(&s, "B10"), Value::Number(16.0)); + assert_eq!(text(&s, "A13"), "East Sum of Sales"); + assert_eq!(val(&s, "B13"), Value::Number(415.0)); + assert_eq!(text(&s, "A14"), "East Sum of Units"); + assert_eq!(val(&s, "B14"), Value::Number(33.0)); + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + assert_eq!(d.wb.styles.get(sh.style_id(CellRef::parse("A5").unwrap())).align.indent, 1); + assert_eq!(d.wb.styles.get(sh.style_id(CellRef::parse("A6").unwrap())).align.indent, 2); + // Outline: one column per level, "Values" heads the Σ Values column. + s.execute("pivot.layout", json!({"layout": "outline"})).unwrap(); + assert_eq!(pt(&s).last_range.unwrap().width(), 4); + assert_eq!(text(&s, "A3"), "Region"); + assert_eq!(text(&s, "B3"), "Values"); + assert_eq!(text(&s, "C3"), "Product"); + assert_eq!(text(&s, "B5"), "Sum of Sales"); + assert_eq!(text(&s, "C6"), "Apples"); + assert_eq!(val(&s, "D6"), Value::Number(160.0)); + // Tabular: labels start on their parent's row; totals per value field below each group. + s.execute("pivot.layout", json!({"layout": "tabular"})).unwrap(); + assert_eq!(text(&s, "A4"), "East"); + assert_eq!(text(&s, "B4"), "Sum of Sales"); + assert_eq!(text(&s, "C4"), "Apples"); + assert_eq!(val(&s, "D4"), Value::Number(160.0)); + assert_eq!(text(&s, "B7"), "Sum of Units"); + assert_eq!(text(&s, "C7"), "Apples"); + assert_eq!(val(&s, "D7"), Value::Number(16.0)); + assert_eq!(text(&s, "A10"), "East Sum of Sales"); + assert_eq!(val(&s, "D10"), Value::Number(415.0)); + assert_eq!(text(&s, "A11"), "East Sum of Units"); + let r = pt(&s).last_range.unwrap(); + let last = r.end.row + 1; + assert_eq!(text(&s, &format!("A{}", last - 1)), "Total Sum of Sales"); + assert_eq!(text(&s, &format!("A{last}")), "Total Sum of Units"); + assert_eq!(val(&s, &format!("D{last}")), Value::Number(72.0)); +} + +#[test] +fn refresh_after_source_edits() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]})).unwrap(); + s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + s.execute("cell.set", json!({"cell": "E2", "input": "1100"})).unwrap(); + // Refresh All picks the change up. + let r = s.execute("data.refreshAll", json!({})).unwrap(); + assert_eq!(r["refreshed"], 1); + let d = s.doc().unwrap(); + let si = d.wb.sheet_index("Sheet2").unwrap(); + let sh = d.wb.sheet(si).unwrap(); + assert_eq!(sh.value(CellRef::parse("B4").unwrap()), Value::Number(1415.0)); + assert_eq!(sh.value(CellRef::parse("B7").unwrap()), Value::Number(TOTAL + 1000.0)); + // pivot.refresh by name; change source to fewer rows. + s.execute("pivot.changeSource", json!({"pivot": "PivotTable1", "source": "Sheet1!A1:E3"})).unwrap(); + let d = s.doc().unwrap(); + let sh = d.wb.sheet(si).unwrap(); + assert_eq!(sh.value(CellRef::parse("A6").unwrap()).display(), "Grand Total"); + assert_eq!(sh.value(CellRef::parse("B6").unwrap()), Value::Number(1150.0)); + // Old rows were cleared. + assert_eq!(sh.value(CellRef::parse("A7").unwrap()), Value::Empty); + s.execute("pivot.refresh", json!({"pivot": "PivotTable1"})).unwrap(); +} + +fn pivot_chart(s: &Session) -> gridcraft_model::Chart { + s.doc().unwrap().wb.active().unwrap().charts.last().unwrap().clone() +} +fn plotted(s: &Session) -> Vec>> { + let d = s.doc().unwrap(); + let data = gridcraft_chart::resolve(&d.wb, d.wb.active_sheet(), &pivot_chart(s)); + data.series.into_iter().map(|x| x.values).collect() +} + +#[test] +fn pivot_chart_follows_the_report() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]})).unwrap(); + // Any cell of the report picks the PivotTable. + let r = s.execute("insert.pivotChart", json!({"pivot": "B5"})).unwrap(); + assert_eq!(r["series"], 1); + let ch = pivot_chart(&s); + assert_eq!(ch.pivot.as_deref(), Some("Sheet2!PivotTable1")); + assert_eq!(ch.title.as_deref(), Some("Total")); + assert_eq!(ch.series[0].values, "Sheet2!$B$4:$B$6"); + assert_eq!(ch.series[0].categories.as_deref(), Some("Sheet2!$A$4:$A$6")); + assert_eq!(plotted(&s), vec![vec![Some(415.0), Some(135.0), Some(285.0)]]); + // A column field: one series per item, rebound by the layout change. + s.execute("pivot.addField", json!({"field": "Product", "area": "columns"})).unwrap(); + let ch = pivot_chart(&s); + let names: Vec<_> = ch.series.iter().map(|x| x.name.clone().unwrap_or_default()).collect(); + assert_eq!(names, vec!["\"Apples\"", "\"Pears\"", "\"Plums\""]); + assert_eq!(plotted(&s)[0], vec![Some(160.0), Some(90.0), Some(170.0)]); + // Nested row fields: the innermost items only, as a union around the parent rows. + s.execute("pivot.moveField", json!({"field": "Product", "from": "columns", "to": "rows"})).unwrap(); + let ch = pivot_chart(&s); + assert!(ch.series[0].values.starts_with('('), "{}", ch.series[0].values); + let pts = plotted(&s); + assert_eq!(pts[0].len(), 9); + assert_eq!(pts[0].iter().flatten().sum::(), TOTAL); + // Survives an XLSX round trip as a PivotChart. + let wb = (*s.doc().unwrap().wb).clone(); + let (back, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(&wb).unwrap()).unwrap(); + let si = back.sheet_index("Sheet2").unwrap(); + assert_eq!(back.sheet(si).unwrap().charts.first().and_then(|c| c.pivot.clone()).as_deref(), Some("Sheet2!PivotTable1")); + // Renaming the sheet keeps the link; a refresh still rebinds. + s.execute("sheet.rename", json!({"name": "Report"})).unwrap(); + assert_eq!(pivot_chart(&s).pivot.as_deref(), Some("Report!PivotTable1")); + s.execute("pivot.moveField", json!({"field": "Region", "from": "rows", "to": "columns"})).unwrap(); + assert_eq!(pivot_chart(&s).series.len(), 3); + // Deleting the PivotTable leaves an ordinary chart. + s.execute("pivot.delete", json!({})).unwrap(); + assert_eq!(pivot_chart(&s).pivot, None); +} + +#[test] +fn pivot_chart_errors() { + let mut s = sales(); + // No PivotTable yet. + assert!(s.execute("insert.pivotChart", json!({})).is_err()); + s.execute("insert.pivotTable", json!({"rows": ["Region"]})).unwrap(); + // Not in the report; no values. + assert!(s.execute("insert.pivotChart", json!({"pivot": "Z99"})).is_err()); + assert!(s.execute("insert.pivotChart", json!({})).unwrap_err().to_string().contains("Values")); + s.execute("pivot.addField", json!({"field": "Units", "area": "values"})).unwrap(); + let r = s.execute("insert.pivotChart", json!({"pivot": "PivotTable1", "type": "line", "title": null})).unwrap(); + assert_eq!(r["series"], 1); + let ch = pivot_chart(&s); + assert_eq!(ch.kind, gridcraft_model::ChartKind::Line); + assert_eq!(ch.title, None); + // One undo step. + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.doc().unwrap().wb.active().unwrap().charts.is_empty()); +} + +#[test] +fn calculated_field_on_sums() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]})).unwrap(); + let r = s.execute("pivot.calculatedField", json!({"name": "Price", "formula": "Sales / Units"})).unwrap(); + assert_eq!(r["formula"], "=Sales / Units"); + assert_eq!(text(&s, "C3"), "Sum of Price"); + assert_eq!(text(&s, "A4"), "East"); + // East: 415 sales over 33 units, from the sums. + let east = val(&s, "C4").as_f64().unwrap(); + assert!((east - 415.0 / 33.0).abs() < 1e-12, "{east}"); + // The grand total applies the formula to the totals, not the sum of the rows. + assert_eq!(text(&s, "A7"), "Grand Total"); + let total = val(&s, "C7").as_f64().unwrap(); + assert!((total - TOTAL / 72.0).abs() < 1e-12, "{total}"); + // Quoted names, functions, and another calculated field. + s.execute("pivot.calculatedField", json!({"name": "Rounded", "formula": "=ROUND('Price'*2,1)"})).unwrap(); + assert_eq!(val(&s, "D4"), Value::Number(25.2)); + // Division by zero shows #DIV/0! cells. + s.execute("pivot.calculatedField", json!({"name": "Zero", "formula": "=Sales/(Units-Units)"})).unwrap(); + assert_eq!(val(&s, "E4"), Value::Error(CellError::Div0)); + // Change and delete. + s.execute("pivot.calculatedField", json!({"name": "zero", "formula": "=Units*2"})).unwrap(); + assert_eq!(val(&s, "E4"), Value::Number(66.0)); + s.execute("pivot.calculatedField", json!({"name": "Zero", "delete": true})).unwrap(); + assert_eq!(pt(&s).values.len(), 3); + assert_eq!(pt(&s).calculated_fields.len(), 2); + // The field list shows calculated fields. + let f = s.execute("pivot.fields", json!({})).unwrap(); + assert!(f["fields"].as_array().unwrap().iter().any(|x| x["name"] == "Price" && x["calculated"] == true)); +} + +#[test] +fn calculated_field_errors() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]})).unwrap(); + let mut e = |p: Json| s.execute("pivot.calculatedField", p).unwrap_err().to_string(); + assert!(e(json!({"name": "Bad", "formula": "=Sales*Bogus"})).contains("Bogus")); + assert!(e(json!({"name": "Bad", "formula": "=A1*2"})).contains("cells")); + assert!(e(json!({"name": "Bad", "formula": "=Sales*("})).contains("error")); + assert!(e(json!({"name": "Sales", "formula": "=1"})).contains("already exists")); + assert!(e(json!({"name": "Bad"})).contains("formula")); + assert!(e(json!({"name": "Gone", "delete": true})).contains("Gone")); + // A cycle is refused. + s.execute("pivot.calculatedField", json!({"name": "X", "formula": "=Sales"})).unwrap(); + s.execute("pivot.calculatedField", json!({"name": "Y", "formula": "=X*2"})).unwrap(); + let err = s.execute("pivot.calculatedField", json!({"name": "X", "formula": "=Y"})).unwrap_err().to_string(); + assert!(err.contains("itself"), "{err}"); + // Calculated fields only go to Values; nothing changed after the failures. + assert!(s.execute("pivot.addField", json!({"field": "Y", "area": "rows"})).is_err()); + assert_eq!(pt(&s).calculated_fields.len(), 2); + // Without a name the dialog opens (menu-style call). + s.ui_requests.clear(); + s.execute("pivot.calculatedField", json!({})).unwrap(); + assert!(matches!(s.ui_requests.last(), Some(crate::UiRequest::Dialog(n, _)) if n == "pivotCalculatedField")); +} + +#[test] +fn replace_protection() { + let mut s = sales(); + s.execute("cell.set", json!({"cell": "H5", "input": "keep me"})).unwrap(); + let e = s.execute("insert.pivotTable", json!({"destination": "Sheet1!H3", "rows": ["Region"], "values": ["Sales"]})).unwrap_err().to_string(); + assert!(e.contains("There's already data in Sheet1!H5"), "{e}"); + assert_eq!(s.doc().unwrap().wb.active().unwrap().pivots.len(), 0); + // Growing into data is refused as well. + s.execute("insert.pivotTable", json!({"destination": "Sheet1!H3"})).unwrap(); + assert!(s.execute("pivot.addField", json!({"field": "Region", "area": "rows"})).is_err()); + s.execute("pivot.addField", json!({"field": "Region", "area": "rows", "replace": true})).unwrap(); + assert_eq!(text(&s, "H5"), "North"); + // A second pivot can't overlap the first. + let e = + s.execute("insert.pivotTable", json!({"source": "Sheet1!A1:E13", "destination": "Sheet1!H6", "replace": true})).unwrap_err().to_string(); + assert!(e.contains("overlap"), "{e}"); + // Nor its own source data. + let e = + s.execute("insert.pivotTable", json!({"source": "Sheet1!A1:E13", "destination": "Sheet1!B2", "replace": true})).unwrap_err().to_string(); + assert!(e.contains("source data"), "{e}"); + // Delete clears the report. + s.execute("pivot.delete", json!({})).unwrap(); + assert_eq!(val(&s, "H5"), Value::Empty); + assert!(s.doc().unwrap().wb.active().unwrap().pivots.is_empty()); +} + +#[test] +fn fields_move_remove_sort() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]})).unwrap(); + let f = s.execute("pivot.fields", json!({})).unwrap(); + assert_eq!(f["fields"][0]["name"], "Region"); + assert_eq!(f["fields"][0]["samples"], json!(["East", "North", "West"])); + assert_eq!(f["fields"][0]["areas"], json!(["rows"])); + assert_eq!(f["fields"][4]["numeric"], true); + assert_eq!(f["fields"][2]["date"], true); + s.execute("pivot.sort", json!({"field": "Region", "order": "desc"})).unwrap(); + assert_eq!(text(&s, "A4"), "West"); + s.execute("pivot.moveField", json!({"field": "Region", "from": "rows", "to": "columns"})).unwrap(); + assert_eq!(text(&s, "B4"), "West"); + s.execute("pivot.removeField", json!({"field": "Sum of Sales", "area": "values"})).unwrap(); + s.execute("pivot.addField", json!({"field": "Region", "area": "values"})).unwrap(); + assert_eq!(pt(&s).values[0].name, "Count of Region"); + s.execute("pivot.removeField", json!({"field": "Region"})).unwrap(); + assert!(pt(&s).columns.is_empty() && pt(&s).values.is_empty()); + assert!(text(&s, "A3").contains("choose fields")); + assert!(s.execute("pivot.addField", json!({"field": "Nope", "area": "rows"})).is_err()); + assert!(s.execute("pivot.addField", json!({"field": "Region", "area": "sideways"})).is_err()); +} + +#[test] +fn recommended_and_summarize() { + let mut s = sales(); + let r = s.execute("insert.recommendedPivotTables", json!({})).unwrap(); + let list = r["suggestions"].as_array().unwrap(); + assert!((3..=5).contains(&list.len()), "{list:?}"); + assert_eq!(list[0]["title"], "Sum of Units by Region"); + let r = s.execute("insert.recommendedPivotTables", json!({"apply": 2})).unwrap(); + assert!(r["suggestion"]["title"].as_str().unwrap().contains("quarters")); + assert_eq!(text(&s, "A4"), "Qtr1"); + // From a table. + s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + s.execute("insert.table", json!({"range": "A1:E13"})).unwrap(); + s.execute("selection.set", json!({"range": "B2"})).unwrap(); + let r = s.execute("table.summarizePivot", json!({"rows": ["Product"], "values": ["Units"]})).unwrap(); + assert_eq!(pt(&s).source, "Table1"); + assert_eq!(r["name"], "PivotTable2"); + let gt = find_row(&s, "Grand Total").unwrap(); + assert_eq!(val(&s, &CellRef::new(gt, 1).a1()), Value::Number(72.0)); +} + +#[test] +fn repeated_headers_get_numbered_names() { + let mut s = Session::new(); + s.new_workbook(); + let mut header = vec![json!("a"), json!("A"), json!("a"), json!("a2")]; + // Thousands of equal headers (a row of check boxes, say) used to take cubic time; this would + // hang rather than fail a wall-clock check, which is flaky on a loaded machine. + header.extend((0..3000).map(|_| json!("x"))); + let data: Vec<_> = (0..header.len()).map(|i| json!(i)).collect(); + let end = CellRef::new(1, header.len() as u32 - 1).a1(); + s.execute("range.setValues", json!({"range": "A1", "values": [header, data]})).unwrap(); + let src = crate::pivot::read_source(&s.doc().unwrap().wb, &format!("Sheet1!A1:{end}"), 0).unwrap(); + assert_eq!(&src.headers[..4], ["a", "A2", "a3", "a22"]); + assert_eq!(src.headers[4], "x"); + assert_eq!(src.headers.last().map(String::as_str), Some("x3000")); + // Table column names follow the same rule. + s.execute("insert.table", json!({"range": format!("A1:{end}"), "header": true})).unwrap(); + let text = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["text"].clone(); + assert_eq!(text(&mut s, "B1"), "A2"); + let last = CellRef::new(0, 3003).a1(); + assert_eq!(text(&mut s, &last), "x3000"); +} + +#[test] +fn hostile_params_never_panic() { + let mut s = sales(); + let cmds = [ + "insert.pivotTable", + "insert.recommendedPivotTables", + "table.summarizePivot", + "pivot.fields", + "pivot.fieldList", + "pivot.list", + "pivot.addField", + "pivot.removeField", + "pivot.moveField", + "pivot.valueSettings", + "pivot.filter", + "pivot.sort", + "pivot.group", + "pivot.collapse", + "pivot.layout", + "pivot.grandTotals", + "pivot.subtotals", + "pivot.reportLayout", + "pivot.style", + "pivot.refresh", + "data.refreshAll", + "pivot.changeSource", + "pivot.delete", + "pivot.showDetails", + "pivot.options", + ]; + let hostile = [ + json!({}), + json!([1, 2]), + json!({"pivot": 99999, "field": 5, "area": null}), + json!({"source": "ZZZ999999999", "destination": "Nope!A1"}), + json!({"source": "A:XFD", "destination": "Sheet1!XFD1048576"}), + json!({"source": "''!A1", "destination": "!"}), + json!({"field": "Sales", "area": "values", "func": "bogus", "position": 4294967295u64}), + json!({"field": "Region", "selected": [null, 1, {"x": 1}], "index": 18446744073709551615u64, "showAs": "rank"}), + json!({"apply": 99, "layout": "weird", "subtotals": 7, "style": "x".repeat(500)}), + json!({"field": "Date", "by": "decades", "items": "nope", "order": "sideways"}), + ]; + for c in cmds { + for h in &hostile { + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let spec = crate::find_command(c).unwrap(); + let _ = (spec.run)(&mut s, h); + })); + assert!(r.is_ok(), "{c} panicked on {h}"); + } + } + // And on a real pivot. + s.execute("insert.pivotTable", json!({"source": "Sheet1!A1:E13", "rows": ["Region"], "values": ["Sales"]})).unwrap(); + for c in cmds { + for h in &hostile { + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let spec = crate::find_command(c).unwrap(); + let _ = (spec.run)(&mut s, h); + })); + assert!(r.is_ok(), "{c} panicked on {h}"); + } + } +} + +#[test] +fn empty_header_is_refused() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Region", ""], ["East", 1]]})).unwrap(); + let e = s.execute("insert.pivotTable", json!({"source": "A1:B2"})).unwrap_err().to_string(); + assert!(e.contains("field name is not valid"), "{e}"); + assert!(s.execute("insert.pivotTable", json!({"source": "NoSuchTable"})).is_err()); +} + +#[test] +fn too_many_value_columns_are_refused() { + let mut s = Session::new(); + s.new_workbook(); + let mut records: Vec = vec![json!(["Cat", "Sub", "V"])]; + for r in 0..64 { + for c in 0..64 { + records.push(json!([format!("c{r}"), format!("s{c}"), 1.0])); + } + } + s.execute("range.setValues", json!({"range": "A1", "values": records})).unwrap(); + let req = |n: usize| { + let values: Vec = (0..n).map(|_| json!({"field": "V"})).collect(); + json!({"source": "A1:C4097", "rows": ["Cat"], "columns": ["Sub"], "values": values}) + }; + let e = s.execute("insert.pivotTable", req(256)).unwrap_err().to_string(); + assert!(e.contains("will not fit"), "{e}"); + assert!(s.execute("insert.pivotTable", req(128)).is_ok()); +} + +#[test] +fn collapsed_column_items_count_as_one_column_group() { + let mut s = Session::new(); + s.new_workbook(); + let mut records: Vec = vec![json!(["Grp", "Sub", "V"])]; + records.extend((0..64).map(|c| json!(["g", format!("s{c}"), 1.0]))); + s.execute("range.setValues", json!({"range": "A1", "values": records})).unwrap(); + let values: Vec = (0..256).map(|_| json!({"field": "V"})).collect(); + s.execute("insert.pivotTable", json!({"source": "A1:C65", "columns": ["Grp"], "values": values})).unwrap(); + s.execute("pivot.collapse", json!({"field": "Grp"})).unwrap(); + // 64 Sub items under the collapsed "g" show as one column group: (1 + grand total) x 256 columns fit. + s.execute("pivot.addField", json!({"field": "Sub", "area": "columns"})).unwrap(); +} + +#[test] +fn inserting_or_deleting_lines_moves_the_pivot_with_its_cells() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"source": "Sheet1!A1:E13", "destination": "Sheet1!H10", "rows": ["Region"], "values": ["Sales"]})) + .unwrap(); + assert_eq!(pt(&s).last_range.unwrap().a1(), "H10:I14"); + // Rows above the report (and below the source) move the report only. + s.execute("home.insertRows", json!({"rows": "16:17"})).unwrap(); + s.execute("home.insertRows", json!({"rows": "6:7"})).unwrap(); + assert_eq!(text(&s, "H12"), "Row Labels"); + let l = s.execute("pivot.list", json!({})).unwrap(); + assert_eq!(l[0]["anchor"], "H12"); + assert_eq!(l[0]["range"], "H12:I16"); + assert_eq!(l[0]["source"], "Sheet1!A1:E15"); + // A row above the source moves both. + s.execute("home.insertRows", json!({"rows": "1:1"})).unwrap(); + let p = pt(&s); + assert_eq!(p.source, "Sheet1!A2:E16"); + assert_eq!(p.anchor.a1(), "H13"); + s.execute("pivot.refresh", json!({})).unwrap(); + assert_eq!(pt(&s).last_range.unwrap().a1(), "H13:I17"); + assert_eq!(text(&s, "H13"), "Row Labels"); + assert_eq!(val(&s, "I17"), Value::Number(TOTAL)); + // Deleting a column left of the report moves it left. + s.execute("home.deleteColumns", json!({"cols": "F:F"})).unwrap(); + let p = pt(&s); + assert_eq!(p.anchor.a1(), "G13"); + assert_eq!(p.last_range.unwrap().a1(), "G13:H17"); + // Deleting rows above moves it up. + s.execute("home.deleteRows", json!({"rows": "1:1"})).unwrap(); + assert_eq!(pt(&s).source, "Sheet1!A1:E15"); + assert_eq!(pt(&s).anchor.a1(), "G12"); + s.execute("pivot.refresh", json!({})).unwrap(); + assert_eq!(text(&s, "G12"), "Row Labels"); + assert_eq!(val(&s, "H16"), Value::Number(TOTAL)); +} + +#[test] +fn inserting_rows_on_the_source_sheet_moves_another_sheets_pivot_source() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"source": "Sheet1!A1:E13", "rows": ["Region"], "values": ["Sales"]})).unwrap(); + s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + s.execute("home.insertRows", json!({"rows": "1:2"})).unwrap(); + let l = s.execute("pivot.list", json!({})).unwrap(); + assert_eq!(l[0]["source"], "Sheet1!A3:E15"); + assert_eq!(l[0]["anchor"], "A3"); + s.execute("pivot.refresh", json!({"pivot": "PivotTable1"})).unwrap(); +} + +/// The active sheet's first five columns as shown, down to the first blank row. +fn sheet_rows(s: &mut Session) -> Vec> { + let mut out = Vec::new(); + for r in 0..30u32 { + let row: Vec = (0..5) + .map(|c| s.execute("cell.get", json!({"cell": CellRef::new(r, c).a1()})).unwrap()["text"].as_str().unwrap_or("").to_string()) + .collect(); + if row.iter().all(String::is_empty) { + break; + } + out.push(row); + } + out +} + +#[test] +fn show_details_lists_a_value_cells_records_on_a_new_sheet() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); + // B5 is East × Apples (100 + 60); the active cell is the default. + s.execute("selection.set", json!({"range": "B5"})).unwrap(); + let r = s.execute("pivot.showDetails", json!({})).unwrap(); + assert_eq!(r["records"], 2); + let d = s.doc().unwrap(); + assert_eq!(d.wb.sheets.len(), 3); + assert_eq!(d.wb.active_sheet(), 0, "the new sheet goes before the PivotTable's"); + assert_eq!(d.wb.active().unwrap().name, "Sheet3"); + assert_eq!( + sheet_rows(&mut s), + vec![ + vec!["Region", "Product", "Date", "Units", "Sales"], + vec!["East", "Apples", "1/15/2026", "10", "100"], + vec!["East", "Apples", "9/9/2026", "6", "60"], + ] + ); + // The dates fit their column instead of showing ####. + let sh = s.doc().unwrap().wb.active().unwrap().clone(); + assert!(sh.col_width(2) > sh.col_width(5), "{} vs {}", sh.col_width(2), sh.col_width(5)); + // One undo step takes the sheet away. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); + assert_eq!(val(&s, "B5"), Value::Number(160.0)); +} + +#[test] +fn show_details_on_a_total_takes_every_record_it_adds_up() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); + let records = |s: &mut Session, cell: &str| { + let n = s.execute("pivot.showDetails", json!({"cell": cell})).unwrap()["records"].as_u64().unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + n + }; + assert_eq!(records(&mut s, "E5"), 5, "East's row total"); + assert_eq!(records(&mut s, "C8"), 4, "Pears' column total"); + assert_eq!(records(&mut s, "E8"), 12, "the grand total"); + // A subtotal at the top of its group, in a two-level row axis. + s.execute("pivot.moveField", json!({"field": "Product", "from": "columns", "to": "rows"})).unwrap(); + assert_eq!(text(&s, "A4"), "East"); + assert_eq!(records(&mut s, "B4"), 5); + assert_eq!(text(&s, "A6"), "Pears"); + assert_eq!(records(&mut s, "B6"), 2); +} + +#[test] +fn show_details_leaves_out_the_records_the_report_filters_out() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Product"], "values": ["Sales"], "filters": ["Region"]})).unwrap(); + s.execute("pivot.filter", json!({"field": "Region", "selected": ["East"]})).unwrap(); + s.execute("pivot.filter", json!({"field": "Product", "selected": ["Apples"]})).unwrap(); + let gt = find_row(&s, "Grand Total").unwrap(); + let r = s.execute("pivot.showDetails", json!({"cell": CellRef::new(gt, 1).a1()})).unwrap(); + assert_eq!(r["records"], 2); + let rows = sheet_rows(&mut s); + assert_eq!(rows.len(), 3); + assert!(rows.iter().skip(1).all(|row| row[0] == "East" && row[1] == "Apples"), "{rows:?}"); +} + +#[test] +fn show_details_refuses_a_report_its_source_has_moved_on_from() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); + // B5 still shows East × Apples (160), but the first East record is now Central's. + s.execute("range.setValues", json!({"range": "Sheet1!A2", "values": [["Central"]]})).unwrap(); + assert_eq!(val(&s, "B5"), Value::Number(160.0)); + let e = s.execute("pivot.showDetails", json!({"cell": "B5"})).unwrap_err().to_string(); + assert!(e.contains("out of date"), "{e}"); + assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); + // After a refresh East × Apples (now B6) has the one record left. + s.execute("pivot.refresh", json!({})).unwrap(); + assert_eq!(text(&s, "A6"), "East"); + assert_eq!(s.execute("pivot.showDetails", json!({"cell": "B6"})).unwrap()["records"], 1); +} + +#[test] +fn show_details_refuses_a_report_that_would_now_be_shorter() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); + // North goes away: the source now gives one row fewer, so row 8 is no longer the Grand Total. + s.execute("range.setValues", json!({"range": "Sheet1!A5", "values": [["East"]]})).unwrap(); + s.execute("range.setValues", json!({"range": "Sheet1!A8", "values": [["East"]]})).unwrap(); + s.execute("range.setValues", json!({"range": "Sheet1!A11", "values": [["East"]]})).unwrap(); + assert_eq!(text(&s, "A8"), "Grand Total"); + let e = s.execute("pivot.showDetails", json!({"cell": "E8"})).unwrap_err().to_string(); + assert!(e.contains("out of date"), "{e}"); + assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); +} + +#[test] +fn show_details_needs_a_value_cell() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); + for cell in ["A3", "B3", "A4", "B4", "A5", "A8", "H20"] { + assert!(s.execute("pivot.showDetails", json!({"cell": cell})).is_err(), "{cell}"); + } + assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); +} + +#[test] +fn enable_show_details_off_stops_show_details() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); + assert_eq!(s.execute("pivot.options", json!({})).unwrap()["enableShowDetails"], true); + s.execute("pivot.options", json!({"enableShowDetails": false})).unwrap(); + assert!(!pt(&s).enable_show_details); + assert_eq!(s.execute("pivot.fields", json!({})).unwrap()["enableShowDetails"], false); + let e = s.execute("pivot.showDetails", json!({"cell": "B5"})).unwrap_err().to_string(); + assert_eq!(e, "Show Details is turned off for this PivotTable."); + assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); + // A label still reports that it is not a value cell. + let e = s.execute("pivot.showDetails", json!({"cell": "A5"})).unwrap_err().to_string(); + assert!(!e.contains("turned off"), "{e}"); + // Setting the same value again adds no undo step. + let after = s.doc().unwrap().revision; + s.execute("pivot.options", json!({"enableShowDetails": false})).unwrap(); + assert_eq!(s.doc().unwrap().revision, after); + assert!(s.execute("pivot.options", json!({"enableShowDetails": "no"})).is_err()); + // One undo turns the option back on. + s.execute("edit.undo", json!({})).unwrap(); + assert!(pt(&s).enable_show_details); + assert_eq!(s.execute("pivot.showDetails", json!({"cell": "B5"})).unwrap()["records"], 2); +} + +#[test] +fn enable_show_details_off_is_reported_before_an_out_of_date_report() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); + s.execute("pivot.options", json!({"enableShowDetails": false})).unwrap(); + // The source changes after the report was written, so the report is out of date. + s.execute("range.setValues", json!({"range": "Sheet1!A2", "values": [["Central"]]})).unwrap(); + let e = s.execute("pivot.showDetails", json!({"cell": "B5"})).unwrap_err().to_string(); + assert!(e.contains("turned off") && !e.contains("out of date"), "{e}"); + assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); +} + +#[test] +fn detail_cells_are_the_cells_show_details_takes() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); + for layout in 0..2 { + if layout == 1 { + // A two-level row axis, with subtotals at the top of each group. + s.execute("pivot.moveField", json!({"field": "Product", "from": "columns", "to": "rows"})).unwrap(); + } + let d = s.doc().unwrap(); + let si = d.wb.active_sheet(); + let pt = &d.wb.active().unwrap().pivots[0]; + let r = pt.last_range.unwrap(); + let cells = crate::pivot::detail_cells(&d.wb, pt, si).unwrap(); + let mut values = 0; + for row in 0..r.height() { + for col in 0..r.width() { + let one = crate::pivot::detail_records(&d.wb, pt, si, row, col).unwrap(); + assert_eq!(cells.contains(&(row, col)), one.is_some(), "layout {layout}, cell ({row}, {col})"); + values += usize::from(one.is_some()); + } + } + assert!(values > 4, "layout {layout} has value cells"); + } + // An out-of-date report answers as Show Details does. + s.execute("range.setValues", json!({"range": "Sheet1!A2", "values": [["Central"]]})).unwrap(); + let d = s.doc().unwrap(); + let pt = &d.wb.active().unwrap().pivots[0]; + assert!(crate::pivot::detail_cells(&d.wb, pt, d.wb.active_sheet()).unwrap_err().contains("out of date")); +}