mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-27 12:16:54 -06:00
refactor(engine): move cmd/pivot tests to src/cmd/pivot/tests.rs
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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<u32> {
|
||||
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<Vec<Option<f64>>> {
|
||||
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::<f64>(), 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<Json> = 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<Json> = (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<Json> = 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<Json> = (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<Vec<String>> {
|
||||
let mut out = Vec::new();
|
||||
for r in 0..30u32 {
|
||||
let row: Vec<String> = (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"));
|
||||
}
|
||||
Reference in New Issue
Block a user