visi_core/core/pivot.rs
1//! Pivot table definitions and the pure function that computes one.
2//!
3//! A [`PivotTable`] is a *definition*: where the records come from, which
4//! fields go in the row, column, value and filter areas, and where the result
5//! should land. [`compute_pivot`] turns that definition plus the workbook's
6//! sheets into a [`PivotGrid`], a display-ready set of header and body rows.
7//!
8//! Computing a grid never touches a sheet. Writing one into cells is
9//! `WorkbookManager::refresh_pivot_table`'s job, and -- as in Excel -- it only
10//! happens when something asks for it: **nothing recomputes a pivot table
11//! implicitly**, not `Sheet::commit` and not `WorkbookManager::evaluate`, so
12//! editing the source data leaves the rendered grid stale until a refresh.
13//! Every CRUD operation on a pivot definition refreshes explicitly afterward.
14//!
15//! Unlike an [`ExcelTable`](crate::core::table::ExcelTable), which is scoped
16//! to one sheet, a pivot table is workbook-level: its source and destination
17//! ranges may live on different sheets, so `WorkbookManager` owns the list.
18
19use serde::{Deserialize, Serialize};
20use std::collections::HashMap;
21
22use crate::core::engine::{CellRef, ResultData, Sheet};
23
24/// Where a `PivotTable` reads its source records from: either an existing
25/// `ExcelTable` (looked up by name at compute time, so renames/resizes of
26/// the table are picked up automatically on refresh) or a plain cell range
27/// whose first row is treated as column headers.
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
29pub enum PivotSource {
30 /// An `ExcelTable`, resolved by name on every refresh.
31 Table {
32 /// The table's name, matched case-insensitively workbook-wide.
33 name: String,
34 },
35 /// A raw rectangular range, whose first row supplies the field names.
36 Range {
37 /// Sheet the range lives on.
38 sheet_id: u64,
39 /// First row of the range, 0-based, and the header row.
40 start_row: usize,
41 /// First column of the range, 0-based.
42 start_col: usize,
43 /// Last row of the range, 0-based and inclusive.
44 end_row: usize,
45 /// Last column of the range, 0-based and inclusive.
46 end_col: usize,
47 },
48}
49
50/// Matches the "Summarize value field by" choices Excel exposes for a data
51/// field; the five most commonly used ones plus the numeric-only count.
52#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
53pub enum PivotAggregation {
54 /// Total of the numeric values.
55 Sum,
56 /// How many non-blank values there are, text included.
57 Count,
58 /// How many values are numbers.
59 CountNumbers,
60 /// Mean of the numeric values.
61 Average,
62 /// Largest numeric value.
63 Max,
64 /// Smallest numeric value.
65 Min,
66}
67
68impl PivotAggregation {
69 /// The caption Excel uses for this aggregation in a value field's default
70 /// label ("Sum of Amount").
71 ///
72 /// [`PivotAggregation::CountNumbers`] shares `Count`'s caption, which is
73 /// why two such fields on one column collide and get disambiguated by
74 /// [`value_field_labels`].
75 pub fn label(&self) -> &'static str {
76 match self {
77 PivotAggregation::Sum => "Sum",
78 // Excel's default value-field caption for "Count Numbers" is
79 // "Count of <field>" -- identical to plain "Count" -- not
80 // "Count Numbers of <field>"; there's no separate caption text
81 // for it in Excel's own UI (confirmed via fuzz/fuzz_pivot.py
82 // against real Excel).
83 PivotAggregation::Count | PivotAggregation::CountNumbers => "Count",
84 PivotAggregation::Average => "Average",
85 PivotAggregation::Max => "Max",
86 PivotAggregation::Min => "Min",
87 }
88 }
89
90 /// Parses a user-supplied aggregation name, ignoring case, spaces,
91 /// underscores and hyphens, and accepting the common short forms (`avg`,
92 /// `countnums`, `maximum`). `None` if it names nothing.
93 pub fn parse(s: &str) -> Option<Self> {
94 match s.to_ascii_lowercase().replace(['_', '-', ' '], "").as_str() {
95 "sum" => Some(Self::Sum),
96 "count" => Some(Self::Count),
97 "countnumbers" | "countnums" => Some(Self::CountNumbers),
98 "average" | "avg" => Some(Self::Average),
99 "max" | "maximum" => Some(Self::Max),
100 "min" | "minimum" => Some(Self::Min),
101 _ => None,
102 }
103 }
104}
105
106/// One field placed in the Row or Column area.
107#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
108pub struct PivotField {
109 /// Name of the source column to group by, matched against the header row.
110 pub column: String,
111 /// Whether a subtotal line is emitted for this field when it isn't the
112 /// innermost field in its area (Excel's per-field "Subtotals" toggle).
113 pub subtotal: bool,
114}
115
116impl PivotField {
117 /// A field on `column` with subtotals enabled, Excel's default.
118 pub fn new(column: impl Into<String>) -> Self {
119 Self {
120 column: column.into(),
121 subtotal: true,
122 }
123 }
124}
125
126/// One field placed in the Values area.
127#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
128pub struct PivotValueField {
129 /// Name of the source column to aggregate, matched against the header row.
130 pub column: String,
131 /// How the column's values are summarized.
132 pub aggregation: PivotAggregation,
133 /// Overrides the default "Sum of Amount" caption. A custom name is used
134 /// verbatim and takes no part in [`value_field_labels`]' disambiguation.
135 pub custom_name: Option<String>,
136}
137
138impl PivotValueField {
139 /// A value field on `column` with the default caption.
140 pub fn new(column: impl Into<String>, aggregation: PivotAggregation) -> Self {
141 Self {
142 column: column.into(),
143 aggregation,
144 custom_name: None,
145 }
146 }
147
148 /// This field's caption considered on its own, ignoring any collision
149 /// with the pivot's other value fields. Use [`value_field_labels`] to
150 /// caption a whole list the way Excel would.
151 pub fn label(&self) -> String {
152 self.custom_name
153 .clone()
154 .unwrap_or_else(|| format!("{} of {}", self.aggregation.label(), self.column))
155 }
156}
157
158/// Default display labels for a pivot's whole value-field list, matching
159/// Excel's own (surprisingly convoluted) disambiguation for repeated
160/// source columns -- derived empirically against real Excel via
161/// fuzz/fuzz_pivot.py plus direct probing (see the probe script referenced
162/// in the PR that added this comment), since none of it is documented.
163///
164/// Two independent mechanisms are in play, both scoped per source column:
165///
166/// 1. **The "Sum" clone.** The *first* value field for a column that uses
167/// the `Sum` aggregation causes Excel to silently clone that column
168/// into a new pseudo-field ("Amount" -> "Amount2") for every value
169/// field *after* it in the list (not before) -- regardless of their own
170/// aggregation. A *second* `Sum` on the same column clones again
171/// ("Amount2" -> "Amount3"), but non-`Sum` aggregations never trigger a
172/// further clone; they just ride whatever clone slot is already active.
173/// E.g. `[Sum, Max, Count]` on "Amount" -> `["Sum of Amount", "Max of
174/// Amount2", "Count of Amount2"]` (both non-Sum fields share slot 2);
175/// `[Sum, Sum, Count]` -> `["Sum of Amount", "Sum of Amount2", "Count
176/// of Amount3"]` (the second Sum clones again). A column with *no* Sum
177/// value field anywhere is never cloned at all.
178/// 2. **Literal caption collision.** Independent of the above, if two
179/// value fields end up wanting the exact same caption text, Excel still
180/// has to disambiguate. If neither is in a Sum-cloned slot, it appends
181/// a plain digit straight onto the column name (`"Count of Amount"`,
182/// `"Count of Amount2"`, `"Count of Amount3"`, ...) -- this is also how
183/// `CountNumbers` colliding with `Count` gets suffixed, since both
184/// share the caption label "Count" (see `PivotAggregation::label`). If
185/// the collision instead happens *inside* an already Sum-cloned slot
186/// (two non-Sum fields sharing one clone with the same aggregation),
187/// Excel instead appends an underscored counter to the *whole* already-
188/// suffixed caption (`"Max of Amount2"`, `"Max of Amount2_2"`) rather
189/// than incrementing the clone number again.
190///
191/// An explicit `custom_name` bypasses both mechanisms entirely -- it's
192/// used as-is and doesn't consume a collision slot or trigger a clone.
193pub fn value_field_labels(value_fields: &[PivotValueField]) -> Vec<String> {
194 let mut clone_suffix: HashMap<&str, usize> = HashMap::new();
195 let mut next_clone: HashMap<&str, usize> = HashMap::new();
196 let mut label_counts: HashMap<String, usize> = HashMap::new();
197
198 value_fields
199 .iter()
200 .map(|vf| {
201 if let Some(name) = &vf.custom_name {
202 return name.clone();
203 }
204 let agg_label = vf.aggregation.label();
205 let in_clone_slot = clone_suffix.contains_key(vf.column.as_str());
206 let base_column = match clone_suffix.get(vf.column.as_str()) {
207 Some(n) => format!("{}{}", vf.column, n),
208 None => vf.column.clone(),
209 };
210 let base_label = format!("{} of {}", agg_label, base_column);
211 let count = label_counts.entry(base_label.clone()).or_insert(0);
212 *count += 1;
213 let label = if *count == 1 {
214 base_label
215 } else if in_clone_slot {
216 format!("{}_{}", base_label, count)
217 } else {
218 format!("{} of {}{}", agg_label, vf.column, count)
219 };
220 if vf.aggregation == PivotAggregation::Sum {
221 let assigned = *next_clone.entry(vf.column.as_str()).or_insert(2);
222 next_clone.insert(vf.column.as_str(), assigned + 1);
223 clone_suffix.insert(vf.column.as_str(), assigned);
224 }
225 label
226 })
227 .collect()
228}
229
230/// One field placed in the Filter (Page) area.
231#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
232pub struct PivotFilterField {
233 /// Name of the source column to filter on, matched against the header row.
234 pub column: String,
235 /// `None` means every value is allowed (no filtering applied yet).
236 ///
237 /// **Reconstructed on xlsx import**, resolved through the cache's
238 /// `<sharedItems>` to plain value strings rather than kept as indices --
239 /// which is what makes it safe. The indices are trusted only against the
240 /// cache definition in the same file, which is self-consistent by
241 /// construction, and a value that no longer exists in changed source data
242 /// simply matches nothing.
243 ///
244 /// Two things do not survive, both because the format cannot hold them:
245 ///
246 /// - A selection covering *every* value marks nothing hidden, so it is
247 /// indistinguishable from no filter and reads back as `None`. The
248 /// grid is the same either way.
249 /// - A filter on a column that is *also* a row or column field is lost
250 /// entirely: a pivot field carries one `axis`, so there is nowhere to
251 /// record it. Excel cannot express that config at all -- a field has
252 /// exactly one orientation there.
253 ///
254 /// Matching is case-insensitive, because the items themselves are merged
255 /// that way; a selection naming `east` picks the merged `East` item.
256 pub selected_values: Option<Vec<String>>,
257 /// Whether the field is in Excel's *multi-select* page mode
258 /// (`multipleItemSelectionAllowed` in the file) rather than its classic
259 /// single-select one.
260 ///
261 /// The two differ in what the page-field cell says, which is observable:
262 /// with one item chosen, multi-select shows `(Multiple Items)` while
263 /// single-select shows the **item's own name**. Both measured -- the
264 /// first through `PivotItems(x).Visible = False`, the second through
265 /// `PivotField.CurrentPage = "Widget"`, which is what puts a field into
266 /// single-select mode in the first place.
267 ///
268 /// Defaults to `true`, matching `set_pivot_filter` and the CLI, which
269 /// select a set of values rather than one page.
270 #[serde(default = "default_true")]
271 pub multiple_selection: bool,
272}
273
274fn default_true() -> bool {
275 true
276}
277
278impl PivotFilterField {
279 /// A filter field on `column` with nothing filtered out yet.
280 pub fn new(column: impl Into<String>) -> Self {
281 Self {
282 column: column.into(),
283 selected_values: None,
284 multiple_selection: true,
285 }
286 }
287}
288
289/// The area of a pivot table a field can be assigned to, used by the
290/// add/remove-field CRUD operations.
291#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
292pub enum PivotArea {
293 /// Groups down the left edge; adds to `PivotTable::row_fields`.
294 Row,
295 /// Groups across the top; adds to `PivotTable::col_fields`.
296 Column,
297 /// Aggregated data; adds to `PivotTable::value_fields`.
298 Value,
299 /// Restricts which source records take part; adds to
300 /// `PivotTable::filter_fields`.
301 Filter,
302}
303
304/// A pivot table definition: a summary of `source`, grouped by `row_fields`
305/// nested within `col_fields`, restricted by `filter_fields`, and
306/// aggregated per `value_fields`. This is a workbook-level object (like
307/// `Chart`) rather than sheet-scoped like `ExcelTable`, since its source and
308/// destination ranges may live on different sheets.
309#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
310pub struct PivotTable {
311 /// Workbook-unique identifier, stable across renames.
312 pub id: u64,
313 /// Display name, unique workbook-wide.
314 pub name: String,
315 /// Where the records come from.
316 pub source: PivotSource,
317 /// Sheet the grid is written to, which need not be the source's sheet.
318 pub dest_sheet_id: u64,
319 /// Top-left row of the output grid, 0-based.
320 pub dest_row: usize,
321 /// Top-left column of the output grid, 0-based.
322 pub dest_col: usize,
323 /// Fields grouped down the left edge, outermost first.
324 pub row_fields: Vec<PivotField>,
325 /// Fields grouped across the top, outermost first.
326 pub col_fields: Vec<PivotField>,
327 /// Fields aggregated into the body. At least one is required for
328 /// [`compute_pivot`] to succeed.
329 pub value_fields: Vec<PivotValueField>,
330 /// Fields restricting which source records take part.
331 pub filter_fields: Vec<PivotFilterField>,
332 /// Whether a grand-total row is appended below the body.
333 pub grand_totals_row: bool,
334 /// Whether a grand-total column is appended to the right of the body.
335 pub grand_totals_col: bool,
336 /// Bottom-right corner of the last rendered output grid, so a refresh
337 /// that produces a smaller grid can clear the now-stale cells.
338 #[serde(default)]
339 pub last_output_end_row: Option<usize>,
340 /// Column half of that corner; see [`PivotTable::last_output_end_row`].
341 #[serde(default)]
342 pub last_output_end_col: Option<usize>,
343}
344
345/// Width, in columns, reserved for row-field labels: one column per row
346/// field when there are any. With no row fields at all, Excel only
347/// reserves a single placeholder column when there's *exactly one* value
348/// field *and* at least one column field for it to sit to the left of --
349/// that lone cell holds the value field's own label (e.g. "Max of
350/// Amount"), the same way the header's "Row Labels | Sum of X" corner
351/// would if there were row fields. With no column fields either (the fully
352/// "flat" single-aggregate pivot) or with more than one value field (whose
353/// labels already show up elsewhere in the header), there's nothing
354/// unambiguous to put in a corner, so Excel reserves no column there at
355/// all. All three shapes verified against real Excel via
356/// fuzz/fuzz_pivot.py. Shared between `compute_pivot` (which must actually
357/// size `PivotBodyRow::row_labels` this way) and `pivot_xlsx.rs` (which
358/// needs the same number for `firstDataCol`).
359pub(crate) fn row_label_width(pivot: &PivotTable) -> usize {
360 if !pivot.row_fields.is_empty() {
361 return pivot.row_fields.len();
362 }
363 if pivot.value_fields.len() == 1 && !pivot.col_fields.is_empty() {
364 1
365 } else {
366 0
367 }
368}
369
370/// A fully computed pivot result, ready to be materialized into a sheet:
371/// `filter_rows` (if any) come first, then a blank spacer row, then
372/// `header_rows`, then one entry of `body_rows` per output row -- mirroring
373/// Excel's own report-filter placement (verified against real Excel: it
374/// always reserves one row per filter field plus a blank spacer above the
375/// row/column header grid, and captions each with a "(All)"/"(Multiple
376/// Items)" state -- never a specific value's name, since that's specific to
377/// the classic single-select page-field mode Excel no longer defaults to).
378#[derive(Debug, Clone)]
379pub struct PivotGrid {
380 /// One `(field name, state)` pair per filter field, in the order they
381 /// were added.
382 ///
383 /// The state is `"(All)"` when every value is allowed, the **item's own
384 /// name** when exactly one is selected, and `"(Multiple Items)"`
385 /// otherwise -- which is what Excel puts in the page-field cell, and what
386 /// `PivotField.CurrentPage` reports alongside it.
387 pub filter_rows: Vec<(String, String)>,
388 /// The column-header block above the body: one row per column field,
389 /// plus a value-field row when there is more than one value field.
390 pub header_rows: Vec<Vec<String>>,
391 /// The body, one entry per output row, subtotal and grand-total rows
392 /// included.
393 pub body_rows: Vec<PivotBodyRow>,
394 /// Total width in columns (row-label columns + data columns), used by
395 /// the caller to know how large a range to clear/allocate. Always >= 2,
396 /// so `filter_rows`' two columns (name, state) always fit within it.
397 pub width: usize,
398 /// The flattened row/column axis groups underlying `body_rows`/the data
399 /// columns, exposed (independent of display formatting) so an xlsx
400 /// exporter can reconstruct a native `pivotTableDefinition`'s
401 /// `rowItems`/`colItems` without re-deriving the grouping itself.
402 pub row_axis: Vec<PivotAxisItem>,
403 /// Column half of that axis pair; see [`PivotGrid::row_axis`].
404 pub col_axis: Vec<PivotAxisItem>,
405}
406
407/// One row of a computed pivot's body: its row-field labels and its
408/// aggregated values.
409#[derive(Debug, Clone)]
410pub struct PivotBodyRow {
411 /// One entry per row field (or a single "Grand Total" entry when there
412 /// are no row fields); blank entries mean "same as the row above".
413 pub row_labels: Vec<String>,
414 /// Whether this row is the grand total rather than a data or subtotal row.
415 pub is_grand_total: bool,
416 /// One entry per data column, aligned with the last `header_rows` row.
417 pub values: Vec<ResultData>,
418}
419
420/// One flattened group along a row or column axis: a label per axis field
421/// (`None` past its own depth), plus whether it's a subtotal or grand-total
422/// pseudo-group rather than a real leaf group.
423#[derive(Debug, Clone)]
424pub struct PivotAxisItem {
425 /// One entry per field in this axis, `None` past this group's own depth.
426 pub labels: Vec<Option<String>>,
427 /// Whether this is a subtotal pseudo-group rather than a leaf group.
428 pub is_subtotal: bool,
429 /// Whether this is the axis's grand-total pseudo-group.
430 pub is_grand_total: bool,
431}
432
433impl PivotGrid {
434 /// Row offset from the pivot's `dest_row` anchor to where the row/col
435 /// header + data grid actually begins: 0 with no filter fields, else
436 /// one row per filter field plus a blank spacer row.
437 pub fn grid_row_offset(&self) -> usize {
438 if self.filter_rows.is_empty() {
439 0
440 } else {
441 self.filter_rows.len() + 1
442 }
443 }
444
445 /// Total height in rows, filter rows and spacer included -- what the
446 /// caller needs to allocate or clear at the pivot's `dest_row` anchor.
447 pub fn height(&self) -> usize {
448 self.grid_row_offset() + self.header_rows.len() + self.body_rows.len()
449 }
450}
451
452/// A flattened, labeled group of source records along one axis (row or
453/// column), produced by recursively grouping by each field in that axis in
454/// turn. `record_indices` is the union of every record folded into this
455/// group -- for a leaf group that's just its own bucket, for a subtotal or
456/// grand-total pseudo-group it's every record under it.
457struct FlatGroup {
458 /// One label per field in this axis; `None` past the group's own depth
459 /// (e.g. a subtotal group has no label for deeper fields).
460 labels: Vec<Option<String>>,
461 record_indices: Vec<usize>,
462 is_subtotal: bool,
463 is_grand_total: bool,
464}
465
466struct GroupNode {
467 label: String,
468 record_indices: Vec<usize>,
469 children: Vec<GroupNode>,
470}
471
472pub(crate) fn group_key(result: &ResultData) -> String {
473 match result {
474 ResultData::None => "(blank)".to_string(),
475 ResultData::String(s) if s.is_empty() => "(blank)".to_string(),
476 other => other.to_string(),
477 }
478}
479
480/// Whether every non-blank value of `records[..][field_idx]` is a genuine
481/// number (`Integer`/`Float`), as opposed to text that merely looks
482/// numeric (e.g. a zero-padded code like `"08"`, or digits kept as text on
483/// purpose). Determines sort order for that field's pivot groups --
484/// Excel sorts a real numeric field numerically but a text field
485/// alphabetically even when its values happen to look like numbers
486/// (verified against real Excel via fuzz/fuzz_pivot.py's `NumStr` column,
487/// whose whole purpose is generating quoted numeric-looking text to probe
488/// exactly this). Grouping already collapsed values to strings by this
489/// point (`group_key`), which can no longer tell a real `22` from a text
490/// `"22"` -- this has to be decided from the original `ResultData`s.
491pub(crate) fn field_is_numeric(records: &[Vec<ResultData>], field_idx: usize) -> bool {
492 !records.is_empty()
493 && records.iter().all(|r| {
494 matches!(
495 r.get(field_idx),
496 Some(ResultData::Integer(_)) | Some(ResultData::Float(_)) | Some(ResultData::None)
497 )
498 })
499}
500
501fn sort_group_entries(pairs: &mut [(String, Vec<usize>)], numeric: bool) {
502 // A blank/empty group always sorts last, regardless of the field's
503 // otherwise-numeric-or-text order (verified against real Excel via
504 // fuzz/fuzz_pivot.py).
505 pairs.sort_by(|a, b| match (a.0 == "(blank)", b.0 == "(blank)") {
506 (true, true) => std::cmp::Ordering::Equal,
507 (true, false) => std::cmp::Ordering::Greater,
508 (false, true) => std::cmp::Ordering::Less,
509 (false, false) if numeric => {
510 let fa: f64 = a.0.trim().parse().unwrap_or(0.0);
511 let fb: f64 = b.0.trim().parse().unwrap_or(0.0);
512 fa.partial_cmp(&fb).unwrap_or(std::cmp::Ordering::Equal)
513 }
514 (false, false) => a.0.to_lowercase().cmp(&b.0.to_lowercase()),
515 });
516}
517
518fn build_group_tree(
519 indices: &[usize],
520 keys: &[Vec<String>],
521 depth: usize,
522 num_fields: usize,
523 numeric_by_depth: &[bool],
524) -> Vec<GroupNode> {
525 // Case-insensitive merge (verified against real Excel via
526 // fuzz/fuzz_pivot.py, whose generator deliberately mixes casings like
527 // "East"/"east" to probe this): Excel's PivotTable field grouping
528 // treats text values that differ only in case as the same group,
529 // captioned with whichever casing appeared first in the source data --
530 // which fewer distinct `groups` entries than `keys` naturally
531 // preserves here, since only the first-seen spelling of a key ever
532 // becomes `entry.0`.
533 let mut groups: Vec<(String, Vec<usize>)> = Vec::new();
534 for &idx in indices {
535 let key = &keys[idx][depth];
536 if let Some(entry) = groups.iter_mut().find(|(k, _)| k.eq_ignore_ascii_case(key)) {
537 entry.1.push(idx);
538 } else {
539 groups.push((key.clone(), vec![idx]));
540 }
541 }
542 sort_group_entries(&mut groups, numeric_by_depth[depth]);
543 groups
544 .into_iter()
545 .map(|(label, idxs)| {
546 let children = if depth + 1 < num_fields {
547 build_group_tree(&idxs, keys, depth + 1, num_fields, numeric_by_depth)
548 } else {
549 Vec::new()
550 };
551 GroupNode {
552 label,
553 record_indices: idxs,
554 children,
555 }
556 })
557 .collect()
558}
559
560/// Recursively flattens a group tree into a list of `FlatGroup`s: every leaf
561/// group, plus (when enabled for that field) a subtotal pseudo-group after
562/// each non-innermost group's children.
563fn flatten_groups(
564 nodes: &[GroupNode],
565 fields: &[PivotField],
566 depth: usize,
567 num_fields: usize,
568 prefix: &[Option<String>],
569 out: &mut Vec<FlatGroup>,
570) {
571 for node in nodes {
572 // `labels` holds exactly this node's own depth (depth+1 entries) so
573 // that a child's `push` lands at the right position; it's only
574 // padded out to `num_fields` at the point a `FlatGroup` is actually
575 // emitted (leaf or subtotal), never before recursing further.
576 let mut labels = prefix.to_vec();
577 labels.push(Some(node.label.clone()));
578
579 if node.children.is_empty() {
580 let mut leaf_labels = labels.clone();
581 leaf_labels.resize(num_fields, None);
582 out.push(FlatGroup {
583 labels: leaf_labels,
584 record_indices: node.record_indices.clone(),
585 is_subtotal: false,
586 is_grand_total: false,
587 });
588 } else {
589 flatten_groups(&node.children, fields, depth + 1, num_fields, &labels, out);
590 let is_innermost = depth + 1 >= num_fields;
591 if fields[depth].subtotal && !is_innermost {
592 let mut subtotal_labels = labels.clone();
593 subtotal_labels.resize(num_fields, None);
594 out.push(FlatGroup {
595 labels: subtotal_labels,
596 record_indices: node.record_indices.clone(),
597 is_subtotal: true,
598 is_grand_total: false,
599 });
600 }
601 }
602 }
603}
604
605/// Builds the flattened axis groups for `fields` over `record_indices`,
606/// optionally appending a grand-total pseudo-group. Returns a single
607/// implicit "all records" group when `fields` is empty.
608fn build_axis(
609 record_indices: &[usize],
610 keys: &[Vec<String>],
611 fields: &[PivotField],
612 grand_total: bool,
613 numeric_by_depth: &[bool],
614) -> Vec<FlatGroup> {
615 if fields.is_empty() {
616 return vec![FlatGroup {
617 labels: Vec::new(),
618 record_indices: record_indices.to_vec(),
619 is_subtotal: false,
620 is_grand_total: false,
621 }];
622 }
623 let tree = build_group_tree(record_indices, keys, 0, fields.len(), numeric_by_depth);
624 let mut flat = Vec::new();
625 flatten_groups(&tree, fields, 0, fields.len(), &[], &mut flat);
626 // Excel shows the grand total whenever the toggle is on, even when
627 // there's only one real group and the grand total would be a literal
628 // duplicate of it -- confirmed against real Excel via
629 // fuzz/fuzz_pivot.py: a column axis with a single field, filtered down
630 // to exactly one distinct value (so there's no possible subtotal
631 // either), still got its own redundant "Grand Total" column. An
632 // earlier version of this guard suppressed the grand total whenever
633 // there was only one *leaf* group, on the assumption Excel considered
634 // it redundant -- that assumption doesn't hold; only skip it when
635 // there's no data to total at all.
636 if grand_total && !flat.is_empty() {
637 flat.push(FlatGroup {
638 labels: vec![None; fields.len()],
639 record_indices: record_indices.to_vec(),
640 is_subtotal: false,
641 is_grand_total: true,
642 });
643 }
644 flat
645}
646
647fn aggregate(sheet: &Sheet, values: &[ResultData], agg: PivotAggregation) -> ResultData {
648 // A row/column intersection with zero underlying records (a sparse
649 // cell in the cross-tab -- e.g. a row group and column group that
650 // simply never co-occur in the source data) renders as a genuinely
651 // blank cell in Excel, not a computed zero or #DIV/0! error, for every
652 // aggregation kind (verified against real Excel via fuzz/fuzz_pivot.py:
653 // even Count and Sum, which have an obvious "zero" answer, still show
654 // blank there). This is distinct from records existing but this
655 // column's values all being blank for them, which the per-aggregation
656 // branches below already handle on their own terms (e.g. Max/Min over
657 // an all-blank column already fall back to `ResultData::None`).
658 if values.is_empty() {
659 return ResultData::None;
660 }
661 match agg {
662 PivotAggregation::Count => ResultData::Integer(
663 values
664 .iter()
665 .filter(|v| !matches!(v, ResultData::None))
666 .count() as i64,
667 ),
668 PivotAggregation::CountNumbers => ResultData::Integer(
669 values
670 .iter()
671 .filter(|v| matches!(v, ResultData::Integer(_) | ResultData::Float(_)))
672 .count() as i64,
673 ),
674 _ => {
675 let nums: Vec<f64> = values
676 .iter()
677 .filter_map(|v| match v {
678 ResultData::Integer(_) | ResultData::Float(_) => sheet.to_f64(v),
679 _ => None,
680 })
681 .collect();
682 match agg {
683 PivotAggregation::Sum => {
684 if nums.is_empty() {
685 ResultData::Integer(0)
686 } else {
687 ResultData::Float(Sheet::clean_float(nums.iter().sum()))
688 }
689 }
690 PivotAggregation::Average => {
691 if nums.is_empty() {
692 ResultData::Error("#DIV/0!".to_string())
693 } else {
694 let avg = nums.iter().sum::<f64>() / nums.len() as f64;
695 ResultData::Float(Sheet::clean_float(avg))
696 }
697 }
698 PivotAggregation::Max => nums
699 .into_iter()
700 .fold(None, |acc: Option<f64>, x| {
701 Some(acc.map_or(x, |a| a.max(x)))
702 })
703 .map(ResultData::Float)
704 .unwrap_or(ResultData::None),
705 PivotAggregation::Min => nums
706 .into_iter()
707 .fold(None, |acc: Option<f64>, x| {
708 Some(acc.map_or(x, |a| a.min(x)))
709 })
710 .map(ResultData::Float)
711 .unwrap_or(ResultData::None),
712 PivotAggregation::Count | PivotAggregation::CountNumbers => unreachable!(),
713 }
714 }
715 }
716}
717
718/// Resolves a `PivotSource` against the workbook's sheets, returning the
719/// owning sheet, the source's column names (in source-column order), the
720/// matching absolute sheet-column indices, and the absolute sheet-row
721/// indices holding data (i.e. excluding any header/totals row).
722/// (owning sheet, source column names, absolute sheet-column indices, absolute data-row indices).
723pub(crate) type ResolvedSource<'a> = (&'a Sheet, Vec<String>, Vec<usize>, Vec<usize>);
724
725pub(crate) fn resolve_source<'a>(
726 sheets: &'a [&'a Sheet],
727 source: &PivotSource,
728) -> Result<ResolvedSource<'a>, String> {
729 match source {
730 PivotSource::Table { name } => {
731 let (sheet, table) = sheets
732 .iter()
733 .find_map(|s| s.find_table(name).map(|t| (*s, t)))
734 .ok_or_else(|| format!("Table '{}' not found", name))?;
735 let cols: Vec<usize> = (table.start_col..=table.end_col).collect();
736 let rows: Vec<usize> = (table.data_start_row()..=table.data_end_row()).collect();
737 Ok((sheet, table.columns.clone(), cols, rows))
738 }
739 PivotSource::Range {
740 sheet_id,
741 start_row,
742 start_col,
743 end_row,
744 end_col,
745 } => {
746 let sheet = *sheets
747 .iter()
748 .find(|s| s.id == *sheet_id)
749 .ok_or_else(|| "Pivot source sheet no longer exists".to_string())?;
750 if *end_row < *start_row || *end_col < *start_col {
751 return Err("Pivot source range end must not precede its start".to_string());
752 }
753 let cols: Vec<usize> = (*start_col..=*end_col).collect();
754 let names: Vec<String> = cols
755 .iter()
756 .map(|&c| {
757 let v = sheet.get_result_data(&CellRef::new(*start_row, c));
758 let s = v.to_string();
759 if s.is_empty() {
760 crate::core::parser::col_idx_to_letters(c)
761 } else {
762 s
763 }
764 })
765 .collect();
766 let rows: Vec<usize> = if *end_row > *start_row {
767 (*start_row + 1..=*end_row).collect()
768 } else {
769 Vec::new()
770 };
771 Ok((sheet, names, cols, rows))
772 }
773 }
774}
775
776pub(crate) fn column_index(names: &[String], target: &str) -> Result<usize, String> {
777 names
778 .iter()
779 .position(|c| c.eq_ignore_ascii_case(target))
780 .ok_or_else(|| {
781 format!(
782 "Source column '{}' not found (columns: {})",
783 target,
784 names.join(", ")
785 )
786 })
787}
788
789/// Computes a pivot table's result grid from the current state of `sheets`.
790/// Pure and read-only: callers materialize the returned `PivotGrid` into
791/// sheet cells themselves.
792/// Computes `pivot` against `sheets`, returning a display-ready grid.
793///
794/// Pure: it reads source records, applies the filter fields, groups by the
795/// row and column fields, aggregates the value fields, and returns the
796/// result. Nothing is written -- materializing the grid into cells is
797/// `WorkbookManager::refresh_pivot_table`'s job.
798///
799/// `sheets` must include both the source's sheet and, for a
800/// [`PivotSource::Table`] source, whichever sheet carries that table.
801///
802/// # Errors
803///
804/// Returns a message if the source cannot be resolved, if a named field is
805/// not among the source's columns, or if the pivot has no value fields.
806pub fn compute_pivot(sheets: &[&Sheet], pivot: &PivotTable) -> Result<PivotGrid, String> {
807 let (sheet, col_names, sheet_cols, data_rows) = resolve_source(sheets, &pivot.source)?;
808
809 for f in pivot.row_fields.iter().chain(pivot.col_fields.iter()) {
810 column_index(&col_names, &f.column)?;
811 }
812 for vf in &pivot.value_fields {
813 column_index(&col_names, &vf.column)?;
814 }
815 for ff in &pivot.filter_fields {
816 column_index(&col_names, &ff.column)?;
817 }
818 if pivot.value_fields.is_empty() {
819 return Err("Pivot table has no value fields".to_string());
820 }
821
822 // Read every source record unfiltered first -- the filter-row captions
823 // below need every distinct value that actually exists in the source,
824 // not just the ones that survive filtering, to tell "(All)" apart from
825 // "(Multiple Items)".
826 let mut all_rows: Vec<Vec<ResultData>> = Vec::with_capacity(data_rows.len());
827 for &r in &data_rows {
828 let mut row_vals = Vec::with_capacity(sheet_cols.len());
829 for &c in &sheet_cols {
830 row_vals.push(sheet.get_result_data(&CellRef::new(r, c)));
831 }
832 all_rows.push(row_vals);
833 }
834
835 // A filter field's selectable items are Excel pivot-cache items, which
836 // (like row/col group labels) merge case-different text into one item
837 // -- so both the "(All)"/"(Multiple Items)" state and the actual
838 // row-inclusion test below must compare case-insensitively, not by
839 // exact string equality. Verified against real Excel via
840 // fuzz/fuzz_pivot.py (iteration 8, seed 599783): a source column with
841 // both "East" and "east" rows, filtered to a selection containing
842 // "east", must include every row of either casing -- Excel's pivot
843 // cache only ever offers one merged "East"/"east" checkbox, not two.
844 let mut filter_rows: Vec<(String, String)> = Vec::new();
845 for ff in &pivot.filter_fields {
846 let idx = column_index(&col_names, &ff.column)?;
847 let distinct: std::collections::HashSet<String> = all_rows
848 .iter()
849 .map(|row| group_key(&row[idx]).to_ascii_lowercase())
850 .collect();
851 let state = match &ff.selected_values {
852 None => "(All)".to_string(),
853 Some(selected) => {
854 let selected_set: std::collections::HashSet<String> =
855 selected.iter().map(|v| v.to_ascii_lowercase()).collect();
856 let is_all = selected_set.len() == distinct.len()
857 && distinct.iter().all(|v| selected_set.contains(v));
858 if is_all {
859 "(All)".to_string()
860 } else if !ff.multiple_selection && selected_set.len() == 1 {
861 // Single-select mode names the item; multi-select says
862 // `(Multiple Items)` even for one. Both measured -- see
863 // `PivotFilterField::multiple_selection`. The item's own
864 // casing is used, since the cache merges case variants
865 // onto whichever it saw first.
866 let wanted = &selected_set;
867 all_rows
868 .iter()
869 .map(|row| group_key(&row[idx]))
870 .find(|v| wanted.contains(&v.to_ascii_lowercase()))
871 .unwrap_or_else(|| "(Multiple Items)".to_string())
872 } else {
873 "(Multiple Items)".to_string()
874 }
875 }
876 };
877 filter_rows.push((ff.column.clone(), state));
878 }
879
880 // Apply filter fields to build the working record set.
881 let mut records: Vec<Vec<ResultData>> = Vec::new();
882 'row: for row_vals in &all_rows {
883 for ff in &pivot.filter_fields {
884 if let Some(selected) = &ff.selected_values {
885 let idx = column_index(&col_names, &ff.column)?;
886 let key = group_key(&row_vals[idx]);
887 if !selected.iter().any(|v| v.eq_ignore_ascii_case(&key)) {
888 continue 'row;
889 }
890 }
891 }
892 records.push(row_vals.clone());
893 }
894
895 let record_indices: Vec<usize> = (0..records.len()).collect();
896
897 let row_field_idxs: Vec<usize> = pivot
898 .row_fields
899 .iter()
900 .map(|f| column_index(&col_names, &f.column))
901 .collect::<Result<_, _>>()?;
902 let col_field_idxs: Vec<usize> = pivot
903 .col_fields
904 .iter()
905 .map(|f| column_index(&col_names, &f.column))
906 .collect::<Result<_, _>>()?;
907 // The casing a case-insensitively-merged group displays under must be
908 // decided once per field, from that field's first occurrence anywhere
909 // in the source data -- not independently within whichever nested
910 // branch of the *other* axis it happens to first appear under.
911 // `build_group_tree`'s merge only sees one branch's records at a time,
912 // so canonicalizing case up front here (before grouping) is what makes
913 // every branch agree on the same casing for the same value (verified
914 // against real Excel via fuzz/fuzz_pivot.py: its pivot cache assigns
915 // one canonical spelling per distinct value field-wide).
916 let mut case_canon: HashMap<usize, HashMap<String, String>> = HashMap::new();
917 let mut canonical_key = |field_idx: usize, raw: String| -> String {
918 let map = case_canon.entry(field_idx).or_default();
919 map.entry(raw.to_ascii_lowercase()).or_insert(raw).clone()
920 };
921 // Seed the canonical casing from *every* source row, not just the ones
922 // that survive `pivot.filter_fields` -- Excel's pivot cache assigns a
923 // value's canonical casing once, field-wide, from the raw source data,
924 // and a filter only hides cached items afterward rather than rebuilding
925 // the cache from the filtered subset. Skipping this seeding step used
926 // to let a filter change which occurrence of a case-variant value
927 // counted as "first" (whichever one happened to survive the filter),
928 // even though Excel's own choice never depends on the filter at all.
929 for row_vals in &all_rows {
930 for &i in row_field_idxs.iter().chain(col_field_idxs.iter()) {
931 canonical_key(i, group_key(&row_vals[i]));
932 }
933 }
934 let row_keys: Vec<Vec<String>> = if pivot.row_fields.is_empty() {
935 Vec::new()
936 } else {
937 records
938 .iter()
939 .map(|rec| {
940 row_field_idxs
941 .iter()
942 .map(|&i| canonical_key(i, group_key(&rec[i])))
943 .collect()
944 })
945 .collect()
946 };
947 let col_keys: Vec<Vec<String>> = if pivot.col_fields.is_empty() {
948 Vec::new()
949 } else {
950 records
951 .iter()
952 .map(|rec| {
953 col_field_idxs
954 .iter()
955 .map(|&i| canonical_key(i, group_key(&rec[i])))
956 .collect()
957 })
958 .collect()
959 };
960 let row_numeric: Vec<bool> = row_field_idxs
961 .iter()
962 .map(|&i| field_is_numeric(&records, i))
963 .collect();
964 let col_numeric: Vec<bool> = col_field_idxs
965 .iter()
966 .map(|&i| field_is_numeric(&records, i))
967 .collect();
968
969 let row_groups = build_axis(
970 &record_indices,
971 &row_keys,
972 &pivot.row_fields,
973 pivot.grand_totals_row,
974 &row_numeric,
975 );
976 let col_groups = build_axis(
977 &record_indices,
978 &col_keys,
979 &pivot.col_fields,
980 pivot.grand_totals_col,
981 &col_numeric,
982 );
983
984 let value_multiplier = if pivot.value_fields.len() > 1 {
985 pivot.value_fields.len()
986 } else {
987 1
988 };
989 let value_idxs: Vec<usize> = pivot
990 .value_fields
991 .iter()
992 .map(|vf| column_index(&col_names, &vf.column))
993 .collect::<Result<_, _>>()?;
994 let value_labels = value_field_labels(&pivot.value_fields);
995
996 // --- Header rows ---
997 // Matches Excel's default "compact form" display, verified against real
998 // Excel via fuzz/fuzz_pivot.py (see fuzz/README.md's pivot section):
999 // the outermost row field's caption becomes the literal text "Row
1000 // Labels" (deeper row fields keep their real name), and -- whenever
1001 // there's at least one column field -- an extra header row captioned
1002 // "Column Labels" is inserted above the column-field-value rows. Excel
1003 // can't be made to use its alternate "tabular form" (the per-field
1004 // LayoutForm VBA property that would show real field names instead is
1005 // confirmed to have no effect on Mac Excel, and the table-wide
1006 // RowAxisLayout/ColumnAxisLayout methods that do work hang Mac Excel
1007 // outright when driven via VBA/AppleScript), so matching this on visi's
1008 // side is the only tractable way to reach parity.
1009 let n_col_header_rows = pivot.col_fields.len().max(1);
1010 // The extra value-label row (needed to tell a column group's own value
1011 // apart from which value field a sub-column holds) only makes sense
1012 // when there's a column-group-values row for it to sit below in the
1013 // first place. With no column fields at all, there's no such row --
1014 // Excel just lists every value field as a plain adjacent column in the
1015 // single header row instead, exactly like a flat table's header
1016 // (verified against real Excel via fuzz/fuzz_pivot.py: 2 value fields
1017 // with no column fields produced one header row with both labels side
1018 // by side, not two stacked rows).
1019 let n_header_rows = if value_multiplier > 1 && !pivot.col_fields.is_empty() {
1020 n_col_header_rows + 1
1021 } else {
1022 n_col_header_rows
1023 };
1024 let row_label_width = row_label_width(pivot);
1025
1026 let mut header_rows: Vec<Vec<String>> = Vec::new();
1027 for r in 0..n_header_rows {
1028 let mut row: Vec<String> = Vec::new();
1029 for i in 0..row_label_width {
1030 // Row-label captions ("Row Labels" plus any deeper row fields'
1031 // real names) sit on the *last* header row -- the one right
1032 // above the data -- not the first: with multiple value fields
1033 // that's the extra value-label row, not the column-field-value
1034 // row above it (confirmed against real Excel: with 2 value
1035 // fields, "Row Labels" lands on the value-label row while the
1036 // column-value row directly above it leaves that same spot
1037 // blank).
1038 if r == n_header_rows - 1 {
1039 row.push(if i == 0 && !pivot.row_fields.is_empty() {
1040 "Row Labels".to_string()
1041 } else {
1042 pivot
1043 .row_fields
1044 .get(i)
1045 .map(|f| f.column.clone())
1046 .unwrap_or_default()
1047 });
1048 } else {
1049 row.push(String::new());
1050 }
1051 }
1052 // Excel merges a repeated label across the columns it spans -- a
1053 // value field fanning a single column group out into several
1054 // adjacent sub-columns is one way that happens, a shallower column
1055 // field repeating over several deeper-field sub-columns under the
1056 // *same* ancestor chain is another -- showing the label once at the
1057 // leftmost column and blank for the rest. The two cases need
1058 // different adjacency tests: within one group, every `vf` beyond
1059 // the first is *always* a repeat (they all render that group's same
1060 // `labels[r]`, `vf` doesn't affect it). Across groups, `labels[r]`
1061 // matching alone isn't enough -- two unrelated groups can
1062 // coincidentally share a leaf value at depth `r` (e.g. two
1063 // different outer-field branches both happening to have a "west"
1064 // child) without being siblings under the same parent, so merging
1065 // them would silently drop one's real value. Only merge when every
1066 // depth from 0 up to and including `r` matches the immediately
1067 // preceding group, which is exactly the condition for them being
1068 // adjacent leaves of the same parent in `col_groups`'s tree order.
1069 let mut prev_group: Option<&FlatGroup> = None;
1070 for group in &col_groups {
1071 // A subtotal group's labels hold exactly one real value, at
1072 // whichever depth it was inserted -- e.g. `[Some("-3"), None]`
1073 // for an outer-field subtotal over a 2-level axis. That's the
1074 // one row its caption becomes "<value> Total" (or, with 2+
1075 // value fields, "<value> <value field label>" per sub-column,
1076 // mirroring the grand-total column's "Total <value label>"
1077 // treatment below -- confirmed against real Excel via
1078 // fuzz/fuzz_pivot.py: with 2 value fields it repeats the value
1079 // field's own name under a subtotal group instead of the
1080 // literal word "Total", and doesn't emit a separate
1081 // value-label row beneath it the way non-subtotal groups do);
1082 // every other column-field row either inherits an ancestor's
1083 // label (already handled below) or stays blank.
1084 let subtotal_depth = group
1085 .is_subtotal
1086 .then(|| group.labels.iter().rposition(|l| l.is_some()))
1087 .flatten();
1088 for vf in 0..value_multiplier {
1089 let label = if r < pivot.col_fields.len() {
1090 if group.is_grand_total {
1091 // The grand-total column's caption always lands on
1092 // the *outermost* column-field row (r == 0), not
1093 // the deepest one -- confirmed against real Excel
1094 // with a 2-level column axis, where "Grand Total"
1095 // showed up on the shallow row while the deep row
1096 // beneath it stayed blank (the two coincide, and so
1097 // looked identical, in every single-column-field
1098 // case tested before that).
1099 if r == 0 {
1100 if value_multiplier > 1 {
1101 format!("Total {}", value_labels[vf])
1102 } else {
1103 "Grand Total".to_string()
1104 }
1105 } else {
1106 String::new()
1107 }
1108 } else if subtotal_depth == Some(r) {
1109 let value = group.labels[r].clone().unwrap();
1110 if value_multiplier > 1 {
1111 format!("{} {}", value, value_labels[vf])
1112 } else {
1113 format!("{} Total", value)
1114 }
1115 } else {
1116 let is_repeat = if vf > 0 {
1117 true
1118 } else {
1119 prev_group.is_some_and(|pg| {
1120 (0..=r).all(|d| pg.labels.get(d) == group.labels.get(d))
1121 })
1122 };
1123 if is_repeat {
1124 String::new()
1125 } else {
1126 group
1127 .labels
1128 .get(r)
1129 .and_then(|l| l.clone())
1130 .unwrap_or_default()
1131 }
1132 }
1133 } else if group.is_grand_total || group.is_subtotal {
1134 // Already captioned "Total <value label>" (grand total)
1135 // or "<value> <value label>" (subtotal) on the
1136 // column-field row above -- no separate value-label row
1137 // for these groups.
1138 String::new()
1139 } else {
1140 value_labels.get(vf).cloned().unwrap_or_default()
1141 };
1142 row.push(label);
1143 }
1144 prev_group = Some(group);
1145 }
1146 header_rows.push(row);
1147 }
1148 // If there's exactly one column group with no column fields, put the
1149 // single value field's label directly in the header row (mirrors the
1150 // classic single-value-field pivot layout: "Row Labels | Sum of X").
1151 if pivot.col_fields.is_empty()
1152 && value_multiplier == 1
1153 && let Some(last) = header_rows.last_mut()
1154 && let Some(cell) = last.last_mut()
1155 && let Some(label) = value_labels.first()
1156 {
1157 *cell = label.clone();
1158 }
1159 // Whenever there's at least one column field, Excel prepends a header
1160 // row captioned "Column Labels" above the column-field-value rows.
1161 // Its row-label area is blank, except: when there's exactly one value
1162 // field *and* at least one row field, that field's label goes in the
1163 // very first cell (mirrors the single-value-field layout's "Row Labels
1164 // | Sum of X" convention, just one row up since the row-label area's
1165 // own first cell is taken by the "Row Labels" caption instead). With no
1166 // row fields, that label has nowhere to go here -- the row-label area
1167 // has no field caption to displace -- so it surfaces on the sole body
1168 // row's corner instead (see the "Total" fallback below).
1169 if !pivot.col_fields.is_empty() {
1170 let mut row = vec![String::new(); row_label_width];
1171 if value_multiplier == 1
1172 && !pivot.row_fields.is_empty()
1173 && let Some(label) = value_labels.first()
1174 {
1175 row[0] = label.clone();
1176 }
1177 row.push("Column Labels".to_string());
1178 row.resize(
1179 row_label_width + col_groups.len() * value_multiplier,
1180 String::new(),
1181 );
1182 header_rows.insert(0, row);
1183 }
1184
1185 // --- Body rows ---
1186 let mut body_rows: Vec<PivotBodyRow> = Vec::new();
1187 let mut prev_labels: Vec<Option<String>> = vec![None; row_label_width];
1188 for rg in &row_groups {
1189 let mut display_labels = vec![String::new(); row_label_width];
1190 if rg.is_grand_total {
1191 display_labels[0] = "Grand Total".to_string();
1192 for l in prev_labels.iter_mut() {
1193 *l = None;
1194 }
1195 } else {
1196 let mut changed = false;
1197 for d in 0..row_label_width {
1198 let cur = if pivot.row_fields.is_empty() {
1199 None
1200 } else {
1201 rg.labels.get(d).cloned().flatten()
1202 };
1203 let is_subtotal_marker =
1204 rg.is_subtotal && rg.labels.get(d).map(|l| l.is_some()).unwrap_or(false);
1205 let show = changed || cur != prev_labels[d] || is_subtotal_marker;
1206 if show {
1207 if let Some(ref v) = cur {
1208 display_labels[d] = if is_subtotal_marker {
1209 format!("{} Total", v)
1210 } else {
1211 v.clone()
1212 };
1213 }
1214 changed = true;
1215 }
1216 prev_labels[d] = cur;
1217 }
1218 // When there are no row fields *and* no column fields either,
1219 // `row_label_width` is 0 (see `row_label_width`'s doc comment)
1220 // -- there's no label cell here at all, just the value itself.
1221 if pivot.row_fields.is_empty() && row_label_width > 0 {
1222 // With no row fields there's exactly one body row (the
1223 // aggregate over everything), and no "Row Labels"-captioned
1224 // header row above it to hold a single value field's label
1225 // the way the col_fields-empty layout does in the header
1226 // (see the header construction above) -- so it surfaces
1227 // here instead, on the one row that exists. Falls back to
1228 // "Total" when there's more than one value field, same as
1229 // the header's equivalent case.
1230 display_labels[0] = if !pivot.col_fields.is_empty() && value_multiplier == 1 {
1231 value_labels.first().cloned().unwrap_or_default()
1232 } else {
1233 "Total".to_string()
1234 };
1235 }
1236 }
1237
1238 let row_record_set: std::collections::HashSet<usize> =
1239 rg.record_indices.iter().copied().collect();
1240 let mut values: Vec<ResultData> = Vec::new();
1241 for cg in &col_groups {
1242 for (vf_pos, &vidx) in value_idxs.iter().enumerate() {
1243 if vf_pos > 0 && value_multiplier == 1 {
1244 break;
1245 }
1246 let col_vals: Vec<ResultData> = cg
1247 .record_indices
1248 .iter()
1249 .filter(|i| row_record_set.contains(i))
1250 .map(|&i| records[i][vidx].clone())
1251 .collect();
1252 values.push(aggregate(
1253 sheet,
1254 &col_vals,
1255 pivot.value_fields[vf_pos].aggregation,
1256 ));
1257 }
1258 }
1259
1260 body_rows.push(PivotBodyRow {
1261 row_labels: display_labels,
1262 is_grand_total: rg.is_grand_total,
1263 values,
1264 });
1265 }
1266
1267 let width = row_label_width + col_groups.len() * value_multiplier;
1268 let to_axis_items = |groups: &[FlatGroup]| -> Vec<PivotAxisItem> {
1269 groups
1270 .iter()
1271 .map(|g| PivotAxisItem {
1272 labels: g.labels.clone(),
1273 is_subtotal: g.is_subtotal,
1274 is_grand_total: g.is_grand_total,
1275 })
1276 .collect()
1277 };
1278 Ok(PivotGrid {
1279 filter_rows,
1280 header_rows,
1281 body_rows,
1282 width,
1283 row_axis: to_axis_items(&row_groups),
1284 col_axis: to_axis_items(&col_groups),
1285 })
1286}
1287
1288/// Finds the unique row/col-axis group matching `criteria` -- `(field
1289/// depth, item text)` pairs restricted to one axis -- for `GETPIVOTDATA`.
1290/// Empty `criteria` means "the axis's grand total". A non-empty `criteria`
1291/// that doesn't specify every field on the axis matches the subtotal group
1292/// at that depth (mirrors Excel: naming only the outer field(s) of a nested
1293/// row/col axis returns that branch's subtotal, not an arbitrary leaf under
1294/// it); naming every field down to the innermost one matches the leaf.
1295/// Ambiguous or absent matches are both reported as `#REF!`, matching real
1296/// Excel's error for a `GETPIVOTDATA` criteria pair that doesn't resolve.
1297fn match_pivot_axis(
1298 axis: &[PivotAxisItem],
1299 criteria: &[(usize, &str)],
1300 field_count: usize,
1301) -> Result<usize, String> {
1302 if criteria.is_empty() {
1303 return axis
1304 .iter()
1305 .position(|g| g.is_grand_total)
1306 .or(if field_count == 0 && axis.len() == 1 {
1307 Some(0)
1308 } else {
1309 None
1310 })
1311 .ok_or_else(|| "#REF!".to_string());
1312 }
1313 let max_depth = criteria.iter().map(|(d, _)| *d).max().unwrap_or(0);
1314 let want_leaf = max_depth + 1 == field_count;
1315 let matches: Vec<usize> = axis
1316 .iter()
1317 .enumerate()
1318 .filter(|(_, group)| {
1319 if group.is_grand_total {
1320 return false;
1321 }
1322 if want_leaf {
1323 if group.is_subtotal {
1324 return false;
1325 }
1326 } else {
1327 let own_depth = group.labels.iter().rposition(|l| l.is_some());
1328 if !(group.is_subtotal && own_depth == Some(max_depth)) {
1329 return false;
1330 }
1331 }
1332 criteria.iter().all(|(depth, item)| {
1333 group
1334 .labels
1335 .get(*depth)
1336 .and_then(|l| l.as_deref())
1337 .map(|l| l.eq_ignore_ascii_case(item))
1338 .unwrap_or(false)
1339 })
1340 })
1341 .map(|(i, _)| i)
1342 .collect();
1343 match matches.len() {
1344 1 => Ok(matches[0]),
1345 _ => Err("#REF!".to_string()),
1346 }
1347}
1348
1349/// Implements `GETPIVOTDATA`: extracts a single summarized value out of a
1350/// pivot table's computed grid by data-field name plus `(row/col field,
1351/// item)` criteria pairs, the same way real Excel's formula does when
1352/// pointed at a rendered pivot. Recomputes the grid fresh from `sheets`
1353/// rather than caching it, consistent with formulas re-evaluating from
1354/// current sheet state on every recalculation pass.
1355pub fn getpivotdata(
1356 sheets: &[&Sheet],
1357 pivot: &PivotTable,
1358 data_field: &str,
1359 criteria: &[(String, String)],
1360) -> Result<ResultData, String> {
1361 let grid = compute_pivot(sheets, pivot)?;
1362
1363 let value_labels = value_field_labels(&pivot.value_fields);
1364 let value_multiplier = if pivot.value_fields.len() > 1 {
1365 pivot.value_fields.len()
1366 } else {
1367 1
1368 };
1369 let value_field_idx = pivot
1370 .value_fields
1371 .iter()
1372 .position(|vf| vf.column.eq_ignore_ascii_case(data_field))
1373 .or_else(|| {
1374 value_labels
1375 .iter()
1376 .position(|l| l.eq_ignore_ascii_case(data_field))
1377 })
1378 .ok_or_else(|| "#VALUE!".to_string())?;
1379
1380 let mut row_criteria: Vec<(usize, &str)> = Vec::new();
1381 let mut col_criteria: Vec<(usize, &str)> = Vec::new();
1382 for (field, item) in criteria {
1383 if let Some(depth) = pivot
1384 .row_fields
1385 .iter()
1386 .position(|f| f.column.eq_ignore_ascii_case(field))
1387 {
1388 row_criteria.push((depth, item.as_str()));
1389 } else if let Some(depth) = pivot
1390 .col_fields
1391 .iter()
1392 .position(|f| f.column.eq_ignore_ascii_case(field))
1393 {
1394 col_criteria.push((depth, item.as_str()));
1395 } else {
1396 return Err("#REF!".to_string());
1397 }
1398 }
1399
1400 let row_idx = match_pivot_axis(&grid.row_axis, &row_criteria, pivot.row_fields.len())?;
1401 let col_idx = match_pivot_axis(&grid.col_axis, &col_criteria, pivot.col_fields.len())?;
1402
1403 let pos = col_idx * value_multiplier + value_field_idx;
1404 grid.body_rows
1405 .get(row_idx)
1406 .and_then(|r| r.values.get(pos))
1407 .cloned()
1408 .ok_or_else(|| "#REF!".to_string())
1409}
1410
1411/// Returns the distinct values of `values`, sorted the same way pivot
1412/// groups are (ascending numeric if every value parses as a number,
1413/// otherwise case-insensitive ascending text) -- used by the xlsx exporter
1414/// to build a pivot field's flat `<items>` enumeration.
1415pub(crate) fn sorted_distinct_strings(values: &[String], numeric: bool) -> Vec<String> {
1416 let mut pairs: Vec<(String, Vec<usize>)> = distinct_strings(values)
1417 .into_iter()
1418 .map(|s| (s, Vec::new()))
1419 .collect();
1420 sort_group_entries(&mut pairs, numeric);
1421 pairs.into_iter().map(|(s, _)| s).collect()
1422}
1423
1424/// The distinct values in **first-seen** order, which is the order a pivot
1425/// cache stores them in.
1426///
1427/// Measured: Excel's `<sharedItems>` are in source order while a pivot
1428/// field's `<items>` are sorted for display and reference sharedItems by
1429/// index, so the two orders are both needed and are different. See
1430/// `fuzz/pivot_filter_probe.py`.
1431///
1432/// Case-insensitive dedup (first-seen casing kept), matching
1433/// `build_group_tree`'s merge -- this feeds the exported pivot cache, so it
1434/// must agree with how `compute_pivot` actually groups these same values or a
1435/// reimported or refreshed pivot's item list falls out of sync with its own
1436/// displayed grouping.
1437pub(crate) fn distinct_strings(values: &[String]) -> Vec<String> {
1438 let mut seen: Vec<String> = Vec::new();
1439 for v in values {
1440 if !seen.iter().any(|s| s.eq_ignore_ascii_case(v)) {
1441 seen.push(v.clone());
1442 }
1443 }
1444 seen
1445}
1446
1447#[cfg(test)]
1448mod tests {
1449 use super::*;
1450 use crate::core::engine::SheetInit;
1451
1452 fn source_sheet() -> Sheet {
1453 let mut sheet = Sheet::new(SheetInit {
1454 name: Some("Data".to_string()),
1455 rows: 9,
1456 cols: 4,
1457 ..Default::default()
1458 });
1459 let header = ["Region", "Product", "Rep", "Amount"];
1460 for (c, h) in header.iter().enumerate() {
1461 sheet.set_cell_src(0, c, h.to_string());
1462 }
1463 let rows: [[&str; 4]; 8] = [
1464 ["East", "Widget", "Alice", "10"],
1465 ["East", "Widget", "Bob", "20"],
1466 ["East", "Gadget", "Alice", "5"],
1467 ["West", "Widget", "Carol", "30"],
1468 ["West", "Gadget", "Carol", "40"],
1469 ["West", "Gadget", "Dave", "50"],
1470 ["East", "Gadget", "Bob", "15"],
1471 ["West", "Widget", "Dave", "25"],
1472 ];
1473 for (r, row) in rows.iter().enumerate() {
1474 for (c, v) in row.iter().enumerate() {
1475 sheet.set_cell_src(r + 1, c, v.to_string());
1476 }
1477 }
1478 sheet.commit(None).unwrap();
1479 sheet
1480 .add_table("Sales".to_string(), 0, 0, 8, 3, true, false)
1481 .unwrap();
1482 sheet
1483 }
1484
1485 fn base_pivot() -> PivotTable {
1486 PivotTable {
1487 id: 1,
1488 name: "Pivot1".to_string(),
1489 source: PivotSource::Table {
1490 name: "Sales".to_string(),
1491 },
1492 dest_sheet_id: 0,
1493 dest_row: 0,
1494 dest_col: 0,
1495 row_fields: vec![PivotField::new("Region")],
1496 col_fields: vec![],
1497 value_fields: vec![PivotValueField::new("Amount", PivotAggregation::Sum)],
1498 filter_fields: vec![],
1499 grand_totals_row: true,
1500 grand_totals_col: true,
1501 last_output_end_row: None,
1502 last_output_end_col: None,
1503 }
1504 }
1505
1506 fn value_at(row: &PivotBodyRow, col: usize) -> f64 {
1507 match &row.values[col] {
1508 ResultData::Float(f) => *f,
1509 ResultData::Integer(i) => *i as f64,
1510 other => panic!("expected numeric, got {:?}", other),
1511 }
1512 }
1513
1514 #[test]
1515 fn test_single_row_field_sum_with_grand_total() {
1516 let sheet = source_sheet();
1517 let pivot = base_pivot();
1518 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1519
1520 // East: 10+20+5+15=50, West: 30+40+50+25=145, Grand Total: 195
1521 assert_eq!(grid.body_rows.len(), 3);
1522 assert_eq!(grid.body_rows[0].row_labels[0], "East");
1523 assert_eq!(value_at(&grid.body_rows[0], 0), 50.0);
1524 assert_eq!(grid.body_rows[1].row_labels[0], "West");
1525 assert_eq!(value_at(&grid.body_rows[1], 0), 145.0);
1526 assert!(grid.body_rows[2].is_grand_total);
1527 assert_eq!(grid.body_rows[2].row_labels[0], "Grand Total");
1528 assert_eq!(value_at(&grid.body_rows[2], 0), 195.0);
1529 }
1530
1531 #[test]
1532 fn test_getpivotdata_matches_a_row_group() {
1533 let sheet = source_sheet();
1534 let pivot = base_pivot();
1535 let result = getpivotdata(
1536 &[&sheet],
1537 &pivot,
1538 "Amount",
1539 &[("Region".to_string(), "East".to_string())],
1540 )
1541 .unwrap();
1542 assert!(matches!(result, ResultData::Float(f) if f == 50.0));
1543 }
1544
1545 #[test]
1546 fn test_getpivotdata_empty_criteria_matches_grand_total() {
1547 let sheet = source_sheet();
1548 let pivot = base_pivot();
1549 let result = getpivotdata(&[&sheet], &pivot, "Amount", &[]).unwrap();
1550 assert!(matches!(result, ResultData::Float(f) if f == 195.0));
1551 }
1552
1553 #[test]
1554 fn test_getpivotdata_partial_criteria_matches_subtotal() {
1555 let sheet = source_sheet();
1556 let mut pivot = base_pivot();
1557 pivot.row_fields = vec![PivotField::new("Region"), PivotField::new("Product")];
1558 // East: Widget=10+20=30, Gadget=5+15=20 -> Region subtotal 50
1559 let result = getpivotdata(
1560 &[&sheet],
1561 &pivot,
1562 "Amount",
1563 &[("Region".to_string(), "East".to_string())],
1564 )
1565 .unwrap();
1566 assert!(matches!(result, ResultData::Float(f) if f == 50.0));
1567 }
1568
1569 #[test]
1570 fn test_getpivotdata_full_path_matches_leaf() {
1571 let sheet = source_sheet();
1572 let mut pivot = base_pivot();
1573 pivot.row_fields = vec![PivotField::new("Region"), PivotField::new("Product")];
1574 let result = getpivotdata(
1575 &[&sheet],
1576 &pivot,
1577 "Amount",
1578 &[
1579 ("Region".to_string(), "East".to_string()),
1580 ("Product".to_string(), "Widget".to_string()),
1581 ],
1582 )
1583 .unwrap();
1584 assert!(matches!(result, ResultData::Float(f) if f == 30.0));
1585 }
1586
1587 #[test]
1588 fn test_getpivotdata_unknown_field_is_ref_error() {
1589 let sheet = source_sheet();
1590 let pivot = base_pivot();
1591 let err = getpivotdata(
1592 &[&sheet],
1593 &pivot,
1594 "Amount",
1595 &[("NotAField".to_string(), "East".to_string())],
1596 )
1597 .unwrap_err();
1598 assert_eq!(err, "#REF!");
1599 }
1600
1601 #[test]
1602 fn test_getpivotdata_unknown_item_is_ref_error() {
1603 let sheet = source_sheet();
1604 let pivot = base_pivot();
1605 let err = getpivotdata(
1606 &[&sheet],
1607 &pivot,
1608 "Amount",
1609 &[("Region".to_string(), "North".to_string())],
1610 )
1611 .unwrap_err();
1612 assert_eq!(err, "#REF!");
1613 }
1614
1615 #[test]
1616 fn test_getpivotdata_unknown_data_field_is_value_error() {
1617 let sheet = source_sheet();
1618 let pivot = base_pivot();
1619 let err = getpivotdata(
1620 &[&sheet],
1621 &pivot,
1622 "NotAField",
1623 &[("Region".to_string(), "East".to_string())],
1624 )
1625 .unwrap_err();
1626 assert_eq!(err, "#VALUE!");
1627 }
1628
1629 #[test]
1630 fn test_row_and_col_fields_with_subtotals() {
1631 let sheet = source_sheet();
1632 let mut pivot = base_pivot();
1633 pivot.row_fields = vec![PivotField::new("Region"), PivotField::new("Product")];
1634 pivot.col_fields = vec![PivotField::new("Rep")];
1635 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1636
1637 // Region subtotal rows should appear (2 regions x (2 products + 1 subtotal)) + grand total
1638 let subtotal_rows: Vec<&PivotBodyRow> = grid
1639 .body_rows
1640 .iter()
1641 .filter(|r| r.row_labels[0].ends_with("Total") && !r.is_grand_total)
1642 .collect();
1643 assert_eq!(subtotal_rows.len(), 2); // one per region
1644 assert!(grid.body_rows.last().unwrap().is_grand_total);
1645 }
1646
1647 #[test]
1648 fn test_nested_row_field_second_level_labels_are_not_lost() {
1649 // Regression test: the second (innermost) row field's own labels
1650 // must survive being nested under the first field's groups, not be
1651 // truncated away when the group tree is flattened.
1652 let sheet = source_sheet();
1653 let mut pivot = base_pivot();
1654 pivot.row_fields = vec![PivotField::new("Region"), PivotField::new("Product")];
1655 pivot.grand_totals_row = false;
1656 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1657
1658 let leaf_rows: Vec<&PivotBodyRow> = grid
1659 .body_rows
1660 .iter()
1661 .filter(|r| !r.row_labels[0].ends_with("Total") && !r.is_grand_total)
1662 .collect();
1663 // East has Widget+Gadget, West has Widget+Gadget: 4 leaf rows.
1664 assert_eq!(leaf_rows.len(), 4);
1665 // Every leaf row must show a real (non-blank) Product label, not "".
1666 for row in &leaf_rows {
1667 assert!(
1668 !row.row_labels[1].is_empty(),
1669 "expected a Product label on leaf row {:?}, got blank",
1670 row.row_labels
1671 );
1672 }
1673 let products: Vec<&str> = leaf_rows.iter().map(|r| r.row_labels[1].as_str()).collect();
1674 assert!(products.contains(&"Widget"));
1675 assert!(products.contains(&"Gadget"));
1676 }
1677
1678 #[test]
1679 fn test_count_aggregation() {
1680 let sheet = source_sheet();
1681 let mut pivot = base_pivot();
1682 pivot.value_fields = vec![PivotValueField::new("Rep", PivotAggregation::Count)];
1683 pivot.grand_totals_row = false;
1684 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1685 assert_eq!(grid.body_rows.len(), 2);
1686 // East has 4 records, West has 4 records
1687 for row in &grid.body_rows {
1688 assert_eq!(value_at(row, 0), 4.0);
1689 }
1690 }
1691
1692 #[test]
1693 fn test_filter_field_restricts_records() {
1694 let sheet = source_sheet();
1695 let mut pivot = base_pivot();
1696 pivot.filter_fields = vec![PivotFilterField {
1697 column: "Product".to_string(),
1698 selected_values: Some(vec!["Widget".to_string()]),
1699 multiple_selection: true,
1700 }];
1701 pivot.grand_totals_row = false;
1702 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1703 // East widgets: 10+20=30, West widgets: 30+25=55
1704 assert_eq!(grid.body_rows.len(), 2);
1705 assert_eq!(value_at(&grid.body_rows[0], 0), 30.0);
1706 assert_eq!(value_at(&grid.body_rows[1], 0), 55.0);
1707 }
1708
1709 #[test]
1710 fn test_filter_field_selection_matches_case_insensitively() {
1711 // Regression test (fuzz/fuzz_pivot.py iteration 8, seed 599783): a
1712 // filter field's selectable items are Excel pivot-cache items,
1713 // which merge case-different text into a single item exactly like
1714 // row/col group labels do (see
1715 // test_case_variant_values_merge_using_globally_first_seen_casing)
1716 // -- so selecting "east" must match *every* row spelled "East" or
1717 // "east", not just rows with that exact casing. An earlier version
1718 // of `compute_pivot`'s filter step compared the raw row value
1719 // against `selected_values` with plain string equality, which
1720 // under-counted case variants; real Excel (driven via
1721 // fuzz_pivot.py's AppleScript/VBA macro path) matched them all.
1722 let mut sheet = Sheet::new(SheetInit {
1723 name: Some("Data".to_string()),
1724 rows: 4,
1725 cols: 2,
1726 ..Default::default()
1727 });
1728 for (c, h) in ["Mixed", "Amount"].iter().enumerate() {
1729 sheet.set_cell_src(0, c, h.to_string());
1730 }
1731 let rows: [[&str; 2]; 3] = [["East", "10"], ["east", "20"], ["West", "30"]];
1732 for (r, row) in rows.iter().enumerate() {
1733 for (c, v) in row.iter().enumerate() {
1734 sheet.set_cell_src(r + 1, c, v.to_string());
1735 }
1736 }
1737 sheet.commit(None).unwrap();
1738 sheet
1739 .add_table("Sales".to_string(), 0, 0, 3, 1, true, false)
1740 .unwrap();
1741
1742 let mut pivot = base_pivot();
1743 pivot.source = PivotSource::Table {
1744 name: "Sales".to_string(),
1745 };
1746 pivot.row_fields = vec![];
1747 pivot.value_fields = vec![PivotValueField::new("Amount", PivotAggregation::Sum)];
1748 pivot.filter_fields = vec![PivotFilterField {
1749 column: "Mixed".to_string(),
1750 selected_values: Some(vec!["east".to_string()]),
1751 multiple_selection: true,
1752 }];
1753 pivot.grand_totals_row = false;
1754 pivot.grand_totals_col = false;
1755 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1756 // Both "East" (10) and "east" (20) rows must be included: 30, not 20.
1757 assert_eq!(value_at(&grid.body_rows[0], 0), 30.0);
1758 }
1759
1760 #[test]
1761 fn test_no_filter_fields_means_no_reserved_rows() {
1762 let sheet = source_sheet();
1763 let pivot = base_pivot();
1764 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1765 assert!(grid.filter_rows.is_empty());
1766 assert_eq!(grid.grid_row_offset(), 0);
1767 assert_eq!(grid.height(), grid.header_rows.len() + grid.body_rows.len());
1768 }
1769
1770 #[test]
1771 fn test_filter_field_state_label_all_vs_multiple_items() {
1772 // Product has exactly two distinct values in `source_sheet`: Widget, Gadget.
1773 let sheet = source_sheet();
1774 let mut pivot = base_pivot();
1775 pivot.filter_fields = vec![PivotFilterField {
1776 column: "Product".to_string(),
1777 selected_values: None,
1778 multiple_selection: true,
1779 }];
1780
1781 // No selection at all -> "(All)".
1782 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1783 assert_eq!(
1784 grid.filter_rows,
1785 vec![("Product".to_string(), "(All)".to_string())]
1786 );
1787 assert_eq!(grid.grid_row_offset(), 2); // 1 filter row + 1 blank spacer
1788
1789 // Explicitly selecting every existing distinct value is equivalent to "(All)".
1790 pivot.filter_fields[0].selected_values =
1791 Some(vec!["Widget".to_string(), "Gadget".to_string()]);
1792 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1793 assert_eq!(grid.filter_rows[0].1, "(All)");
1794
1795 // A strict subset -> "(Multiple Items)". Verified against real
1796 // Excel: even a single selected value out of several shows this,
1797 // never the value's own name -- that's specific to the classic
1798 // single-select page-field mode Excel no longer defaults to.
1799 pivot.filter_fields[0].selected_values = Some(vec!["Widget".to_string()]);
1800 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1801 assert_eq!(grid.filter_rows[0].1, "(Multiple Items)");
1802
1803 // ...and that single-select mode is exactly where the item's own
1804 // name does show, which is what `PivotField.CurrentPage = "Widget"`
1805 // produces. Measured: the page-field cell reads `Widget`.
1806 pivot.filter_fields[0].multiple_selection = false;
1807 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1808 assert_eq!(grid.filter_rows[0].1, "Widget");
1809 }
1810
1811 #[test]
1812 fn test_col_axis_subtotal_group_gets_total_caption_and_grand_total_stays_outermost() {
1813 // Regression test: with a 2-level column axis (both fields'
1814 // subtotals enabled by default), the header logic never gave a
1815 // column-axis subtotal group its own "<value> Total" caption at
1816 // all -- it just repeated the parent group's plain label, which
1817 // the header's own "repeated label merges" dedup pass then blanked
1818 // out entirely since it looked identical to the leaf column next
1819 // to it. Separately, the grand-total column's caption was placed
1820 // on the *deepest* column-field row (indistinguishable from the
1821 // outermost row in every single-column-field case this was
1822 // originally verified against), but real Excel puts it on the
1823 // *outermost* row instead -- confirmed once a genuine 2-level
1824 // column axis was tested against real Excel via fuzz/fuzz_pivot.py.
1825 let sheet = source_sheet();
1826 let mut pivot = base_pivot();
1827 pivot.row_fields = vec![PivotField::new("Rep")];
1828 pivot.col_fields = vec![PivotField::new("Region"), PivotField::new("Product")];
1829 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1830
1831 // header_rows[0] is the prepended "Column Labels" row; [1] is the
1832 // outermost column field (Region), [2] is the deepest (Product).
1833 let region_row = &grid.header_rows[1];
1834 assert!(region_row.contains(&"East Total".to_string()));
1835 assert!(region_row.contains(&"West Total".to_string()));
1836 assert!(region_row.contains(&"Grand Total".to_string()));
1837 let product_row = &grid.header_rows[2];
1838 assert_eq!(product_row.last().unwrap(), "");
1839 }
1840
1841 #[test]
1842 fn test_col_axis_subtotal_caption_uses_value_field_label_with_multiple_value_fields() {
1843 // Regression test for issue #17: with 2+ value fields, a col-field
1844 // subtotal group used to repeat the literal text "<n> Total" under
1845 // every value-field sub-column, plus an extra value-field-label row
1846 // beneath it. Real Excel instead repeats the value field's own name
1847 // directly on the subtotal's caption row ("<n> Min of Amount",
1848 // "<n> Sum of Amount") and emits no separate label row underneath
1849 // for those sub-columns -- confirmed against real Excel via
1850 // fuzz/fuzz_pivot.py (--seed 100 --iterations 8, iteration 6/seed
1851 // 106).
1852 let sheet = source_sheet();
1853 let mut pivot = base_pivot();
1854 pivot.row_fields = vec![];
1855 pivot.col_fields = vec![PivotField::new("Region"), PivotField::new("Product")];
1856 pivot.value_fields = vec![
1857 PivotValueField::new("Amount", PivotAggregation::Min),
1858 PivotValueField::new("Amount", PivotAggregation::Sum),
1859 ];
1860 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1861
1862 // header_rows[0] is "Column Labels", [1] is Region (outer, with the
1863 // subtotal), [2] is Product (deepest), [3] is the value-label row.
1864 let region_row = &grid.header_rows[1];
1865 // Min and Sum are different aggregations, so their default
1866 // captions are distinct on their own and Excel leaves the reused
1867 // "Amount" source column unsuffixed (see
1868 // test_value_field_labels_leaves_distinct_aggregations_on_same_column_unsuffixed).
1869 assert!(region_row.contains(&"East Min of Amount".to_string()));
1870 assert!(region_row.contains(&"East Sum of Amount".to_string()));
1871 assert!(region_row.contains(&"West Min of Amount".to_string()));
1872 assert!(region_row.contains(&"West Sum of Amount".to_string()));
1873 assert!(
1874 !region_row
1875 .iter()
1876 .any(|c| c == "East Total" || c == "West Total")
1877 );
1878
1879 // The value-label row must stay blank under the subtotal's
1880 // sub-columns (no redundant second label row for them), while still
1881 // showing the value labels under the non-subtotal leaf columns.
1882 let value_label_row = grid.header_rows.last().unwrap();
1883 assert!(value_label_row.contains(&"Min of Amount".to_string()));
1884 assert!(value_label_row.contains(&"Sum of Amount".to_string()));
1885 let east_subtotal_idx = region_row
1886 .iter()
1887 .position(|c| c == "East Min of Amount")
1888 .unwrap();
1889 assert_eq!(value_label_row[east_subtotal_idx], "");
1890 assert_eq!(value_label_row[east_subtotal_idx + 1], "");
1891 }
1892
1893 #[test]
1894 fn test_col_axis_repeated_leaf_value_under_different_parents_is_not_falsely_merged() {
1895 // Regression test: the header's "merge a repeated label across the
1896 // columns it spans" dedup used to compare a cell's text against
1897 // the last *non-blank* value seen anywhere earlier in the row, with
1898 // no regard for which column group it actually came from. That's
1899 // correct for the case it was built for (a value field fanning one
1900 // group out into several adjacent sub-columns, or a shallower
1901 // field spanning several *of its own* deeper sub-columns), but it
1902 // also silently blanked a deeper field's leaf value whenever it
1903 // happened to equal the leaf value of the *previous, unrelated*
1904 // outer-field branch -- e.g. two different outer groups that each
1905 // have exactly one child, and both children happen to be named the
1906 // same. Discovered via fuzz/fuzz_pivot.py: a `Cat` branch with only
1907 // a "west" `Mixed` child, immediately followed by another `Cat`
1908 // branch whose only `Mixed` child was *also* "west", lost the
1909 // second one's column entirely.
1910 let mut sheet = Sheet::new(SheetInit {
1911 name: Some("Data".to_string()),
1912 rows: 3,
1913 cols: 3,
1914 ..Default::default()
1915 });
1916 for (c, h) in ["Group", "Sub", "Amount"].iter().enumerate() {
1917 sheet.set_cell_src(0, c, h.to_string());
1918 }
1919 // GroupA's only Sub child and GroupB's only Sub child are both "X",
1920 // with nothing else between them once flattened.
1921 let rows: [[&str; 3]; 2] = [["GroupA", "X", "1"], ["GroupB", "X", "2"]];
1922 for (r, row) in rows.iter().enumerate() {
1923 for (c, v) in row.iter().enumerate() {
1924 sheet.set_cell_src(r + 1, c, v.to_string());
1925 }
1926 }
1927 sheet.commit(None).unwrap();
1928 sheet
1929 .add_table("Sales".to_string(), 0, 0, 2, 2, true, false)
1930 .unwrap();
1931
1932 let mut pivot = base_pivot();
1933 pivot.row_fields = vec![];
1934 pivot.col_fields = vec![PivotField::new("Group"), PivotField::new("Sub")];
1935 pivot.grand_totals_col = false;
1936 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1937
1938 // Deepest (Sub) row: "X" must appear for *both* groups, not just
1939 // the first (with the second silently blanked as a false "repeat").
1940 let sub_row = &grid.header_rows[2];
1941 let x_count = sub_row.iter().filter(|c| *c == "X").count();
1942 assert_eq!(
1943 x_count, 2,
1944 "expected \"X\" under both GroupA and GroupB, got {sub_row:?}"
1945 );
1946 }
1947
1948 #[test]
1949 fn test_multiple_value_fields_become_column_labels() {
1950 let sheet = source_sheet();
1951 let mut pivot = base_pivot();
1952 pivot.value_fields = vec![
1953 PivotValueField::new("Amount", PivotAggregation::Sum),
1954 PivotValueField::new("Amount", PivotAggregation::Count),
1955 ];
1956 pivot.grand_totals_row = false;
1957 pivot.grand_totals_col = false;
1958 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1959 assert_eq!(grid.header_rows.last().unwrap()[1], "Sum of Amount");
1960 // The first value field on "Amount" uses Sum, which clones the
1961 // column for every value field after it (see `value_field_labels`'s
1962 // doc comment) -- so the second value field's default label
1963 // disambiguates as "Amount2", matching real Excel.
1964 assert_eq!(grid.header_rows.last().unwrap()[2], "Count of Amount2");
1965 assert_eq!(grid.body_rows[0].values.len(), 2);
1966 assert_eq!(value_at(&grid.body_rows[0], 0), 50.0); // Sum for East
1967 assert_eq!(value_at(&grid.body_rows[0], 1), 4.0); // Count for East
1968 }
1969
1970 #[test]
1971 fn test_row_labels_caption_replaces_outermost_row_field_name() {
1972 // Matches Excel's default "compact form" display (verified against
1973 // real Excel via fuzz/fuzz_pivot.py): the outermost row field's own
1974 // name never appears in the header at all -- it's always the
1975 // literal text "Row Labels".
1976 let sheet = source_sheet();
1977 let pivot = base_pivot(); // row_fields=[Region], col_fields=[]
1978 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1979 assert_eq!(grid.header_rows.last().unwrap()[0], "Row Labels");
1980 }
1981
1982 #[test]
1983 fn test_column_labels_row_prepended_and_deeper_row_field_keeps_its_name() {
1984 let sheet = source_sheet();
1985 let mut pivot = base_pivot();
1986 pivot.row_fields = vec![PivotField::new("Region"), PivotField::new("Product")];
1987 pivot.col_fields = vec![PivotField::new("Rep")];
1988 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1989
1990 // Whenever there's at least one column field, Excel inserts an
1991 // extra header row above the column-value rows, captioned
1992 // "Column Labels".
1993 assert!(grid.header_rows[0].iter().any(|c| c == "Column Labels"));
1994 // Row-label captions land on the last header row: the outermost
1995 // row field ("Region") becomes "Row Labels", but a *deeper* row
1996 // field ("Product") keeps its own real name.
1997 let last = grid.header_rows.last().unwrap();
1998 assert_eq!(last[0], "Row Labels");
1999 assert_eq!(last[1], "Product");
2000 }
2001
2002 #[test]
2003 fn test_grand_total_column_shows_total_prefixed_value_label_with_multiple_value_fields() {
2004 let sheet = source_sheet();
2005 let mut pivot = base_pivot();
2006 pivot.col_fields = vec![PivotField::new("Product")];
2007 pivot.value_fields = vec![
2008 PivotValueField::new("Amount", PivotAggregation::Sum),
2009 PivotValueField::new("Amount", PivotAggregation::Min),
2010 ];
2011 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
2012
2013 // The grand-total column's caption lands on the column-field row
2014 // (not repeated per value field as plain "Grand Total"), combining
2015 // "Total " with each value field's own label. Sum is first on
2016 // "Amount", so it clones the column for the following value field
2017 // (see `value_field_labels`'s doc comment), giving Min the
2018 // disambiguated "Amount2".
2019 let col_values_row = &grid.header_rows[1];
2020 assert!(col_values_row.contains(&"Total Sum of Amount".to_string()));
2021 assert!(col_values_row.contains(&"Total Min of Amount2".to_string()));
2022 // The value-label row directly below leaves the grand-total's
2023 // columns blank, since the caption already appeared above it.
2024 assert_eq!(grid.header_rows.last().unwrap().last().unwrap(), "");
2025 }
2026
2027 #[test]
2028 fn test_grand_total_still_shows_with_only_one_leaf_group() {
2029 // Regression test: an earlier version of this suppressed the grand
2030 // total whenever an axis had only one *leaf* group, on the theory
2031 // that a grand total identical to that lone group's own value would
2032 // be a redundant duplicate Excel wouldn't bother showing. That
2033 // theory turned out to be wrong -- verified against real Excel via
2034 // fuzz/fuzz_pivot.py: a column field filtered down to exactly one
2035 // distinct value still got its own "Grand Total" column, an exact
2036 // duplicate of the single real column right next to it. Excel
2037 // shows the grand total whenever the toggle is on, full stop,
2038 // regardless of how many groups it's summarizing.
2039 let sheet = source_sheet();
2040 let mut pivot = base_pivot();
2041 pivot.filter_fields = vec![PivotFilterField {
2042 column: "Region".to_string(),
2043 selected_values: Some(vec!["East".to_string()]),
2044 multiple_selection: true,
2045 }];
2046 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
2047 assert!(grid.body_rows.iter().any(|r| r.is_grand_total));
2048 }
2049
2050 #[test]
2051 fn test_case_variant_values_merge_using_globally_first_seen_casing() {
2052 // Regression test: case-insensitive grouping used to merge values
2053 // independently within each branch of the *other* axis, so which
2054 // casing "won" depended on which branch happened to be built
2055 // first -- a value could display as "EAST" under one Group and
2056 // "east" under another, when Excel shows one consistent spelling
2057 // (the field's first occurrence anywhere in the source data) no
2058 // matter which other-axis branch it's nested under. Discovered via
2059 // fuzz/fuzz_pivot.py, whose generator deliberately mixes casings.
2060 let mut sheet = Sheet::new(SheetInit {
2061 name: Some("Data".to_string()),
2062 rows: 5,
2063 cols: 3,
2064 ..Default::default()
2065 });
2066 for (c, h) in ["Group", "Mixed", "Amount"].iter().enumerate() {
2067 sheet.set_cell_src(0, c, h.to_string());
2068 }
2069 // "EAST" (uppercase) appears first in sheet order under Group=G1;
2070 // "east" (lowercase) appears later, nested under a *different*
2071 // Group=G2 branch.
2072 let rows: [[&str; 3]; 3] = [
2073 ["G1", "EAST", "10"],
2074 ["G1", "West", "20"],
2075 ["G2", "east", "30"],
2076 ];
2077 for (r, row) in rows.iter().enumerate() {
2078 for (c, v) in row.iter().enumerate() {
2079 sheet.set_cell_src(r + 1, c, v.to_string());
2080 }
2081 }
2082 sheet.commit(None).unwrap();
2083 sheet
2084 .add_table("Sales".to_string(), 0, 0, 3, 2, true, false)
2085 .unwrap();
2086
2087 let mut pivot = base_pivot();
2088 pivot.row_fields = vec![PivotField::new("Group"), PivotField::new("Mixed")];
2089 pivot.grand_totals_row = false;
2090 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
2091
2092 let mixed_labels: Vec<&str> = grid
2093 .body_rows
2094 .iter()
2095 .map(|r| r.row_labels[1].as_str())
2096 .filter(|l| !l.is_empty())
2097 .collect();
2098 assert!(
2099 mixed_labels.contains(&"EAST") && !mixed_labels.contains(&"east"),
2100 "expected every occurrence to use the globally first-seen casing \"EAST\", got {mixed_labels:?}"
2101 );
2102 }
2103
2104 #[test]
2105 fn test_case_canonicalization_uses_first_seen_casing_from_unfiltered_source_not_just_surviving_rows()
2106 {
2107 // Regression test: the canonical casing for a case-insensitively
2108 // merged group used to be decided by scanning only the *filtered*
2109 // record set, not the full source data -- so if a filter field
2110 // happened to exclude whichever row had the true first occurrence
2111 // of a value, a later-appearing (but filter-surviving) casing won
2112 // instead. Excel's pivot cache assigns canonical casing once from
2113 // the raw source data field-wide; a filter only hides cached items
2114 // afterward, it never changes which casing was "first". Discovered
2115 // via fuzz/fuzz_pivot.py with a filter field present alongside a
2116 // case-variant row field.
2117 let mut sheet = Sheet::new(SheetInit {
2118 name: Some("Data".to_string()),
2119 rows: 4,
2120 cols: 3,
2121 ..Default::default()
2122 });
2123 for (c, h) in ["Cat", "Mixed", "Amount"].iter().enumerate() {
2124 sheet.set_cell_src(0, c, h.to_string());
2125 }
2126 // The true first occurrence of the "west"/"WEST" value is "WEST"
2127 // (row 1), but it's filtered out below (Cat="Alpha" excluded);
2128 // "west" (row 3, Cat="Beta", which survives the filter) must still
2129 // canonicalize to "WEST", not to itself.
2130 let rows: [[&str; 3]; 3] = [
2131 ["Alpha", "WEST", "10"],
2132 ["Beta", "East", "20"],
2133 ["Beta", "west", "30"],
2134 ];
2135 for (r, row) in rows.iter().enumerate() {
2136 for (c, v) in row.iter().enumerate() {
2137 sheet.set_cell_src(r + 1, c, v.to_string());
2138 }
2139 }
2140 sheet.commit(None).unwrap();
2141 sheet
2142 .add_table("Sales".to_string(), 0, 0, 3, 2, true, false)
2143 .unwrap();
2144
2145 let mut pivot = base_pivot();
2146 pivot.row_fields = vec![PivotField::new("Mixed")];
2147 pivot.filter_fields = vec![PivotFilterField {
2148 column: "Cat".to_string(),
2149 selected_values: Some(vec!["Beta".to_string()]),
2150 multiple_selection: true,
2151 }];
2152 pivot.grand_totals_row = false;
2153 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
2154
2155 let labels: Vec<&str> = grid
2156 .body_rows
2157 .iter()
2158 .map(|r| r.row_labels[0].as_str())
2159 .collect();
2160 assert!(
2161 labels.contains(&"WEST") && !labels.contains(&"west"),
2162 "expected the filtered-out row's casing \"WEST\" to still win, got {labels:?}"
2163 );
2164 }
2165
2166 #[test]
2167 fn test_blank_group_sorts_last_even_among_numeric_siblings() {
2168 let mut sheet = Sheet::new(SheetInit {
2169 name: Some("Data".to_string()),
2170 rows: 4,
2171 cols: 2,
2172 ..Default::default()
2173 });
2174 for (c, h) in ["Code", "Amount"].iter().enumerate() {
2175 sheet.set_cell_src(0, c, h.to_string());
2176 }
2177 // 30 < ... numerically, but the blank row's Code cell is left
2178 // empty entirely -- deliberately out of numeric order so a sort
2179 // that just treated "(blank)" as any other value would put it
2180 // first (its group_key text "(blank)" sorts alphabetically before
2181 // digits) rather than last.
2182 sheet.set_cell_src(1, 0, "30".to_string());
2183 sheet.set_cell_src(1, 1, "1".to_string());
2184 sheet.set_cell_src(3, 0, "10".to_string());
2185 sheet.set_cell_src(3, 1, "3".to_string());
2186 sheet.commit(None).unwrap();
2187 sheet
2188 .add_table("Sales".to_string(), 0, 0, 3, 1, true, false)
2189 .unwrap();
2190
2191 let mut pivot = base_pivot();
2192 pivot.row_fields = vec![PivotField::new("Code")];
2193 pivot.grand_totals_row = false;
2194 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
2195
2196 let codes: Vec<&str> = grid
2197 .body_rows
2198 .iter()
2199 .map(|r| r.row_labels[0].as_str())
2200 .collect();
2201 assert_eq!(codes, vec!["10", "30", "(blank)"]);
2202 }
2203
2204 #[test]
2205 fn test_empty_row_col_intersection_renders_blank_not_zero_or_error() {
2206 // A row/column combination with zero underlying records (a sparse
2207 // cell in the cross-tab) renders as a genuinely blank cell in
2208 // Excel for every aggregation kind, not a computed zero or error
2209 // (verified against real Excel via fuzz/fuzz_pivot.py).
2210 let mut sheet = Sheet::new(SheetInit {
2211 name: Some("Data".to_string()),
2212 rows: 3,
2213 cols: 3,
2214 ..Default::default()
2215 });
2216 for (c, h) in ["Region", "Product", "Amount"].iter().enumerate() {
2217 sheet.set_cell_src(0, c, h.to_string());
2218 }
2219 // East only ever pairs with Widget; West only ever pairs with
2220 // Gadget -- so (East, Gadget) and (West, Widget) are both
2221 // genuinely empty intersections.
2222 let rows: [[&str; 3]; 2] = [["East", "Widget", "10"], ["West", "Gadget", "20"]];
2223 for (r, row) in rows.iter().enumerate() {
2224 for (c, v) in row.iter().enumerate() {
2225 sheet.set_cell_src(r + 1, c, v.to_string());
2226 }
2227 }
2228 sheet.commit(None).unwrap();
2229 sheet
2230 .add_table("Sales".to_string(), 0, 0, 2, 2, true, false)
2231 .unwrap();
2232
2233 let mut pivot = base_pivot();
2234 pivot.col_fields = vec![PivotField::new("Product")];
2235 pivot.value_fields = vec![
2236 PivotValueField::new("Amount", PivotAggregation::Sum),
2237 PivotValueField::new("Amount", PivotAggregation::Average),
2238 ];
2239 pivot.grand_totals_row = false;
2240 pivot.grand_totals_col = false;
2241 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
2242
2243 // Row "East" only has Widget data, so both of its Gadget-column
2244 // cells (Sum and Average) must be blank.
2245 let east_row = grid
2246 .body_rows
2247 .iter()
2248 .find(|r| r.row_labels[0] == "East")
2249 .unwrap();
2250 for v in &east_row.values[..2] {
2251 assert!(
2252 matches!(v, ResultData::None),
2253 "expected blank for an empty intersection, got {v:?}"
2254 );
2255 }
2256 }
2257
2258 #[test]
2259 fn test_value_field_labels_distinct_aggregations_without_sum_stay_unsuffixed() {
2260 // Regression test (fuzz/fuzz_pivot.py iteration 4, seed 883294):
2261 // reusing a source column across multiple value fields with
2262 // *different*, non-Sum aggregations produces distinct default
2263 // captions on its own ("Max of Amount", "Count of Amount"), so
2264 // real Excel leaves them alone -- no "Amount2" suffix. An earlier
2265 // version of `value_field_labels` suffixed on any repeated column
2266 // regardless of aggregation, which real Excel (driven via
2267 // fuzz_pivot.py's AppleScript/VBA macro path) did not do here: the
2268 // dataFields XML it wrote out named these plainly as "Count of
2269 // Amount" and "Max of Amount". (Reusing a column that *does* have
2270 // a Sum value field is a different story -- see
2271 // test_value_field_labels_sum_clones_column_for_later_fields.)
2272 let fields = vec![
2273 PivotValueField::new("Amount", PivotAggregation::Count),
2274 PivotValueField::new("Amount", PivotAggregation::Max),
2275 ];
2276 assert_eq!(
2277 value_field_labels(&fields),
2278 vec!["Count of Amount".to_string(), "Max of Amount".to_string()]
2279 );
2280 }
2281
2282 #[test]
2283 fn test_value_field_labels_sum_clones_column_for_later_fields() {
2284 // Regression test (fuzz/fuzz_pivot.py iteration 3, seed 406509,
2285 // found in a follow-up fuzz batch after the fix above): unlike
2286 // other aggregations, the *first* value field on a column that
2287 // uses `Sum` silently clones that column ("Amount" -> "Amount2")
2288 // for every value field *after* it in the list, regardless of
2289 // their own aggregation -- confirmed by direct probing against
2290 // real Excel (build a pivot with N value fields on one column via
2291 // the same VBA `AddDataField` macro fuzz_pivot.py uses, across
2292 // every ordering of {sum, count, average, max, min}). "Rate" here
2293 // has no Sum field at all, so it's unaffected and stays plain.
2294 let fields = vec![
2295 PivotValueField::new("Amount", PivotAggregation::Sum),
2296 PivotValueField::new("Rate", PivotAggregation::Average),
2297 PivotValueField::new("Amount", PivotAggregation::Min),
2298 PivotValueField::new("Amount", PivotAggregation::Max),
2299 ];
2300 assert_eq!(
2301 value_field_labels(&fields),
2302 vec![
2303 "Sum of Amount".to_string(),
2304 "Average of Rate".to_string(),
2305 "Min of Amount2".to_string(),
2306 "Max of Amount2".to_string(),
2307 ]
2308 );
2309 }
2310
2311 #[test]
2312 fn test_value_field_labels_second_sum_clones_again() {
2313 // A second `Sum` value field on the same column clones *again*
2314 // ("Amount2" -> "Amount3"), rather than reusing the first clone --
2315 // verified by direct real-Excel probing (see the test above).
2316 let fields = vec![
2317 PivotValueField::new("Amount", PivotAggregation::Sum),
2318 PivotValueField::new("Amount", PivotAggregation::Sum),
2319 PivotValueField::new("Amount", PivotAggregation::Count),
2320 ];
2321 assert_eq!(
2322 value_field_labels(&fields),
2323 vec![
2324 "Sum of Amount".to_string(),
2325 "Sum of Amount2".to_string(),
2326 "Count of Amount3".to_string(),
2327 ]
2328 );
2329 }
2330
2331 #[test]
2332 fn test_value_field_labels_disambiguates_identical_aggregation_and_column() {
2333 // Two value fields on the same column with the *same* aggregation
2334 // do produce an identical default caption ("Sum of Amount" twice),
2335 // so this is the one shape where real Excel's plain digit-suffix
2336 // disambiguation kicks in even without any preceding clone.
2337 let fields = vec![
2338 PivotValueField::new("Amount", PivotAggregation::Sum),
2339 PivotValueField::new("Amount", PivotAggregation::Sum),
2340 PivotValueField::new("Amount", PivotAggregation::Sum),
2341 ];
2342 assert_eq!(
2343 value_field_labels(&fields),
2344 vec![
2345 "Sum of Amount".to_string(),
2346 "Sum of Amount2".to_string(),
2347 "Sum of Amount3".to_string(),
2348 ]
2349 );
2350 }
2351
2352 #[test]
2353 fn test_value_field_labels_collision_within_sum_clone_uses_underscore_suffix() {
2354 // When a caption collision happens *inside* an already Sum-cloned
2355 // slot (two non-Sum fields on the same clone sharing an
2356 // aggregation), real Excel disambiguates by appending an
2357 // underscored counter to the whole already-suffixed caption
2358 // instead of incrementing the clone number again -- verified by
2359 // direct real-Excel probing.
2360 let fields = vec![
2361 PivotValueField::new("Amount", PivotAggregation::Sum),
2362 PivotValueField::new("Amount", PivotAggregation::Max),
2363 PivotValueField::new("Amount", PivotAggregation::Max),
2364 ];
2365 assert_eq!(
2366 value_field_labels(&fields),
2367 vec![
2368 "Sum of Amount".to_string(),
2369 "Max of Amount2".to_string(),
2370 "Max of Amount2_2".to_string(),
2371 ]
2372 );
2373 }
2374
2375 #[test]
2376 fn test_value_field_labels_count_numbers_shares_plain_count_caption() {
2377 // Regression test (fuzz/fuzz_pivot.py iteration 1, seed 837909):
2378 // Excel's default caption for the "Count Numbers" summary function
2379 // is "Count of <field>" -- identical to plain "Count" -- not
2380 // "Count Numbers of <field>". Since both aggregations now generate
2381 // the same caption text, using both on the same column is exactly
2382 // the collide-and-suffix case above.
2383 let fields = vec![
2384 PivotValueField::new("Rate", PivotAggregation::CountNumbers),
2385 PivotValueField::new("Rate", PivotAggregation::Count),
2386 ];
2387 assert_eq!(
2388 value_field_labels(&fields),
2389 vec!["Count of Rate".to_string(), "Count of Rate2".to_string()]
2390 );
2391 }
2392
2393 #[test]
2394 fn test_value_field_labels_leaves_custom_name_untouched() {
2395 let mut fields = vec![
2396 PivotValueField::new("Amount", PivotAggregation::Sum),
2397 PivotValueField::new("Amount", PivotAggregation::Min),
2398 ];
2399 fields[1].custom_name = Some("Lowest Amount".to_string());
2400 assert_eq!(
2401 value_field_labels(&fields),
2402 vec!["Sum of Amount".to_string(), "Lowest Amount".to_string()]
2403 );
2404 }
2405
2406 #[test]
2407 fn test_flat_pivot_with_no_row_or_col_fields_has_no_reserved_label_column() {
2408 // Regression test: with neither row nor column fields (a single
2409 // aggregate value, no grouping at all), Excel doesn't reserve a
2410 // separate row-label column the way it does whenever *either* axis
2411 // has fields -- the value field's own header sits directly above
2412 // the value, one column wide total (verified against real Excel
2413 // via fuzz/fuzz_pivot.py; previously visi always reserved a
2414 // placeholder label column here, one column too many, which put
2415 // the header/value one column to the right of where Excel puts
2416 // them and left a stray blank column in between).
2417 let sheet = source_sheet();
2418 let mut pivot = base_pivot();
2419 pivot.row_fields = vec![];
2420 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
2421
2422 assert_eq!(grid.width, 1);
2423 assert_eq!(
2424 grid.header_rows.last().unwrap(),
2425 &vec!["Sum of Amount".to_string()]
2426 );
2427 assert_eq!(grid.body_rows.len(), 1);
2428 assert!(grid.body_rows[0].row_labels.is_empty());
2429 assert_eq!(value_at(&grid.body_rows[0], 0), 195.0);
2430 }
2431
2432 #[test]
2433 fn test_no_row_fields_with_multiple_value_fields_has_no_reserved_label_column_either() {
2434 // Regression test: unlike the single-value-field case (which
2435 // reserves one corner column for that field's own label, e.g. "Max
2436 // of Amount"), with *multiple* value fields and no row fields
2437 // there's no single unambiguous label to put in a corner -- each
2438 // value field's label already shows up in its own column further
2439 // along the header -- so Excel reserves no column for it at all,
2440 // regardless of whether column fields are present (verified
2441 // against real Excel via fuzz/fuzz_pivot.py). Previously visi
2442 // always reserved one placeholder column whenever row fields were
2443 // empty, off by one column versus Excel's actual grid.
2444 let sheet = source_sheet();
2445 let mut pivot = base_pivot();
2446 pivot.row_fields = vec![];
2447 pivot.col_fields = vec![PivotField::new("Product")];
2448 pivot.value_fields = vec![
2449 PivotValueField::new("Amount", PivotAggregation::Sum),
2450 PivotValueField::new("Amount", PivotAggregation::Count),
2451 ];
2452 pivot.grand_totals_col = false;
2453 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
2454
2455 // width = 0 reserved + 2 column groups (Gadget, Widget) * 2 value
2456 // fields.
2457 assert_eq!(grid.width, 4);
2458 assert_eq!(grid.body_rows.len(), 1);
2459 assert!(grid.body_rows[0].row_labels.is_empty());
2460 }
2461
2462 #[test]
2463 fn test_multiple_value_fields_with_no_column_fields_share_one_header_row() {
2464 // Regression test: `compute_pivot` used to unconditionally add an
2465 // extra header row for the value-field labels whenever there was
2466 // more than one value field, regardless of whether there were any
2467 // column fields for that extra row to distinguish itself from --
2468 // with no column fields at all there's no column-group-values row
2469 // in the first place, so Excel just lists each value field as a
2470 // plain adjacent column in the single header row, like an ordinary
2471 // flat table (verified against real Excel via fuzz/fuzz_pivot.py:
2472 // this previously pushed every row's data down by one row versus
2473 // Excel's actual output whenever a pivot had 2+ value fields and no
2474 // column fields).
2475 let sheet = source_sheet();
2476 let mut pivot = base_pivot();
2477 pivot.value_fields = vec![
2478 PivotValueField::new("Amount", PivotAggregation::Sum),
2479 PivotValueField::new("Amount", PivotAggregation::Count),
2480 ];
2481 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
2482
2483 assert_eq!(grid.header_rows.len(), 1);
2484 // Sum is first on "Amount", so it clones the column for the
2485 // following value field (see `value_field_labels`'s doc comment).
2486 assert_eq!(
2487 grid.header_rows[0],
2488 vec![
2489 "Row Labels".to_string(),
2490 "Sum of Amount".to_string(),
2491 "Count of Amount2".to_string(),
2492 ]
2493 );
2494 }
2495
2496 #[test]
2497 fn test_missing_column_errors() {
2498 let sheet = source_sheet();
2499 let mut pivot = base_pivot();
2500 pivot.row_fields = vec![PivotField::new("Nope")];
2501 let err = compute_pivot(&[&sheet], &pivot).unwrap_err();
2502 assert!(err.contains("not found"));
2503 }
2504
2505 #[test]
2506 fn test_range_source_matches_table_source() {
2507 // A pivot sourced from a raw range covering exactly a table's
2508 // declared bounds must produce the same grid as one sourced from
2509 // the table itself.
2510 let sheet = source_sheet();
2511 let mut pivot = base_pivot();
2512 pivot.source = PivotSource::Range {
2513 sheet_id: sheet.id,
2514 start_row: 0,
2515 start_col: 0,
2516 end_row: 8,
2517 end_col: 3,
2518 };
2519 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
2520 assert_eq!(grid.body_rows.len(), 3);
2521 assert_eq!(value_at(&grid.body_rows[0], 0), 50.0);
2522 assert_eq!(value_at(&grid.body_rows[1], 0), 145.0);
2523 assert_eq!(value_at(&grid.body_rows[2], 0), 195.0);
2524 }
2525
2526 #[test]
2527 fn test_zero_data_rows_produces_empty_grid_without_panicking() {
2528 let mut sheet = Sheet::new(SheetInit {
2529 name: Some("Empty".to_string()),
2530 rows: 1,
2531 cols: 2,
2532 ..Default::default()
2533 });
2534 sheet.set_cell_src(0, 0, "Region".to_string());
2535 sheet.set_cell_src(0, 1, "Amount".to_string());
2536 sheet.commit(None).unwrap();
2537 sheet
2538 .add_table("Empty".to_string(), 0, 0, 0, 1, true, false)
2539 .unwrap();
2540
2541 let pivot = PivotTable {
2542 id: 1,
2543 name: "EmptyPivot".to_string(),
2544 source: PivotSource::Table {
2545 name: "Empty".to_string(),
2546 },
2547 dest_sheet_id: sheet.id,
2548 dest_row: 0,
2549 dest_col: 0,
2550 row_fields: vec![PivotField::new("Region")],
2551 col_fields: vec![],
2552 value_fields: vec![PivotValueField::new("Amount", PivotAggregation::Sum)],
2553 filter_fields: vec![],
2554 grand_totals_row: true,
2555 grand_totals_col: true,
2556 last_output_end_row: None,
2557 last_output_end_col: None,
2558 };
2559 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
2560 // No records at all -> no groups, and (per `build_axis`) a grand
2561 // total is only appended when there's more than one group, so none
2562 // is emitted here either.
2563 assert!(grid.body_rows.is_empty());
2564 assert!(grid.row_axis.is_empty());
2565 }
2566
2567 // ---- Randomized invariant fuzzing --------------------------------
2568 //
2569 // Builds many random source sheets + pivot configurations and checks
2570 // internal self-consistency (never panics; every output cell, whether
2571 // leaf/subtotal/grand-total, equals an independently-derived aggregate
2572 // over the same filtered records; xlsx export/import round-trips
2573 // field assignments faithfully). This is a self-consistency fuzzer,
2574 // not a check against real Excel -- that's `fuzz/fuzz_pivot.py`'s job
2575 // -- but it's cheap to run in `cargo test` and catches crashes/logic
2576 // regressions in the group-tree flattening/subtotal/grand-total code
2577 // (see `test_nested_row_field_second_level_labels_are_not_lost` for a
2578 // bug this style of check would have caught immediately).
2579 use rand::rngs::StdRng;
2580 use rand::{Rng, SeedableRng};
2581
2582 const FUZZ_COLS: [&str; 6] = ["Cat", "Mixed", "NumStr", "Amount", "Rate", "Flag"];
2583 const FUZZ_CATEGORIES: [&str; 5] = ["Alpha", "Beta", "Gamma", "Delta", "Epsilon"];
2584 const FUZZ_CASE_VARIANTS: [&str; 5] = ["East", "east", "WEST", "west", "North"];
2585
2586 /// Builds a random source sheet with columns chosen to exercise
2587 /// grouping edge cases: a low-cardinality category column with
2588 /// occasional blanks, a case-variant category column (case-insensitive
2589 /// grouping parity), a quoted numeric-looking-string column (the
2590 /// numeric-vs-text sort ambiguity `sort_group_entries` has to resolve),
2591 /// two numeric columns (ints and floats, including negative/zero), and
2592 /// a boolean column (ignored by Sum/Average/Max/Min).
2593 fn fuzz_source_sheet(rng: &mut StdRng, num_rows: usize) -> (Sheet, Vec<String>) {
2594 let mut sheet = Sheet::new(SheetInit {
2595 name: Some("FuzzData".to_string()),
2596 rows: num_rows + 1,
2597 cols: FUZZ_COLS.len(),
2598 ..Default::default()
2599 });
2600 for (c, h) in FUZZ_COLS.iter().enumerate() {
2601 sheet.set_cell_src(0, c, h.to_string());
2602 }
2603 for r in 0..num_rows {
2604 let cat = if rng.gen_bool(0.1) {
2605 String::new()
2606 } else {
2607 FUZZ_CATEGORIES[rng.gen_range(0..FUZZ_CATEGORIES.len())].to_string()
2608 };
2609 sheet.set_cell_src(r + 1, 0, cat);
2610
2611 let mixed = FUZZ_CASE_VARIANTS[rng.gen_range(0..FUZZ_CASE_VARIANTS.len())].to_string();
2612 sheet.set_cell_src(r + 1, 1, mixed);
2613
2614 let numstr = match rng.gen_range(0u8..4u8) {
2615 0 => String::new(),
2616 1 => format!("\"0{}\"", rng.gen_range(0u32..10u32)),
2617 2 => format!("\".0{}\"", rng.gen_range(0u32..1000u32)),
2618 _ => format!("\"{}\"", rng.gen_range(-50i64..50i64)),
2619 };
2620 sheet.set_cell_src(r + 1, 2, numstr);
2621
2622 sheet.set_cell_src(r + 1, 3, rng.gen_range(-100i64..=100i64).to_string());
2623
2624 let rate =
2625 (rng.gen_range(-500i64..=500i64) as f64) / (rng.gen_range(1i64..=100i64) as f64);
2626 sheet.set_cell_src(r + 1, 4, format!("{:.4}", rate));
2627
2628 sheet.set_cell_src(r + 1, 5, rng.gen_bool(0.5).to_string());
2629 }
2630 sheet.commit(None).unwrap();
2631 (sheet, FUZZ_COLS.iter().map(|s| s.to_string()).collect())
2632 }
2633
2634 fn random_aggregation(rng: &mut StdRng) -> PivotAggregation {
2635 match rng.gen_range(0u8..6u8) {
2636 0 => PivotAggregation::Sum,
2637 1 => PivotAggregation::Count,
2638 2 => PivotAggregation::CountNumbers,
2639 3 => PivotAggregation::Average,
2640 4 => PivotAggregation::Max,
2641 _ => PivotAggregation::Min,
2642 }
2643 }
2644
2645 /// Builds a random, always-valid `PivotTable` config over `sheet`:
2646 /// 0-2 row fields and 0-2 col fields (drawn without replacement from
2647 /// the categorical columns), 1-2 value fields (from the numeric
2648 /// columns), an optional filter field with a random subset of its
2649 /// actual distinct values selected (including the all-excluded case),
2650 /// and random per-field subtotal / grand-total toggles.
2651 fn fuzz_pivot_config(
2652 rng: &mut StdRng,
2653 sheet: &Sheet,
2654 col_names: &[String],
2655 num_rows: usize,
2656 use_table: bool,
2657 ) -> PivotTable {
2658 let mut pool: Vec<usize> = vec![0, 1, 2]; // Cat, Mixed, NumStr
2659 let numeric: [usize; 2] = [3, 4]; // Amount, Rate
2660
2661 let n_row = rng.gen_range(0..=pool.len().min(2));
2662 let row_cols: Vec<usize> = (0..n_row)
2663 .map(|_| pool.remove(rng.gen_range(0..pool.len())))
2664 .collect();
2665 let n_col = rng.gen_range(0..=pool.len().min(2));
2666 let col_cols: Vec<usize> = (0..n_col)
2667 .map(|_| pool.remove(rng.gen_range(0..pool.len())))
2668 .collect();
2669
2670 let row_fields: Vec<PivotField> = row_cols
2671 .iter()
2672 .map(|&i| PivotField {
2673 column: col_names[i].clone(),
2674 subtotal: rng.gen_bool(0.7),
2675 })
2676 .collect();
2677 let col_fields: Vec<PivotField> = col_cols
2678 .iter()
2679 .map(|&i| PivotField {
2680 column: col_names[i].clone(),
2681 subtotal: rng.gen_bool(0.7),
2682 })
2683 .collect();
2684
2685 let n_value = rng.gen_range(1..=2);
2686 let value_fields: Vec<PivotValueField> = (0..n_value)
2687 .map(|_| {
2688 let col = numeric[rng.gen_range(0..numeric.len())];
2689 PivotValueField::new(col_names[col].clone(), random_aggregation(rng))
2690 })
2691 .collect();
2692
2693 let mut filter_fields = Vec::new();
2694 if rng.gen_bool(0.5) {
2695 let candidates = [0usize, 1, 2, 5];
2696 let fcol = candidates[rng.gen_range(0..candidates.len())];
2697 let mut distinct: Vec<String> = (1..=num_rows)
2698 .map(|r| group_key(&sheet.get_result_data(&CellRef::new(r, fcol))))
2699 .collect();
2700 distinct.sort();
2701 distinct.dedup();
2702 let selected = if distinct.is_empty() || rng.gen_bool(0.2) {
2703 None
2704 } else {
2705 // May legitimately come out empty -> filters out every record.
2706 Some(distinct.into_iter().filter(|_| rng.gen_bool(0.5)).collect())
2707 };
2708 filter_fields.push(PivotFilterField {
2709 column: col_names[fcol].clone(),
2710 selected_values: selected,
2711 multiple_selection: true,
2712 });
2713 }
2714
2715 let source = if use_table {
2716 PivotSource::Table {
2717 name: "FuzzTable".to_string(),
2718 }
2719 } else {
2720 PivotSource::Range {
2721 sheet_id: sheet.id,
2722 start_row: 0,
2723 start_col: 0,
2724 end_row: num_rows,
2725 end_col: col_names.len() - 1,
2726 }
2727 };
2728
2729 PivotTable {
2730 id: 1,
2731 name: "FuzzPivot".to_string(),
2732 source,
2733 dest_sheet_id: sheet.id,
2734 dest_row: num_rows + 20,
2735 dest_col: 0,
2736 row_fields,
2737 col_fields,
2738 value_fields,
2739 filter_fields,
2740 grand_totals_row: rng.gen_bool(0.7),
2741 grand_totals_col: rng.gen_bool(0.7),
2742 last_output_end_row: None,
2743 last_output_end_col: None,
2744 }
2745 }
2746
2747 fn results_close(a: &ResultData, b: &ResultData) -> bool {
2748 match (a, b) {
2749 (ResultData::Integer(x), ResultData::Integer(y)) => x == y,
2750 (ResultData::Float(x), ResultData::Float(y)) => (x - y).abs() < 1e-6,
2751 (ResultData::Integer(x), ResultData::Float(y))
2752 | (ResultData::Float(y), ResultData::Integer(x)) => (*x as f64 - y).abs() < 1e-6,
2753 (ResultData::None, ResultData::None) => true,
2754 (ResultData::Error(x), ResultData::Error(y)) => x == y,
2755 (ResultData::String(x), ResultData::String(y)) => x == y,
2756 (ResultData::Boolean(x), ResultData::Boolean(y)) => x == y,
2757 _ => false,
2758 }
2759 }
2760
2761 /// A row/col axis label vector (`Some` per own depth, `None` past it --
2762 /// see `FlatGroup`) is a *partial key*: `None` positions are wildcards.
2763 /// This is exactly what a subtotal or grand-total group represents, so
2764 /// the same matcher works uniformly for leaf, subtotal, and grand-total
2765 /// groups.
2766 fn matches_partial(key: &[String], labels: &[Option<String>]) -> bool {
2767 // Case-insensitive, matching `build_group_tree`'s merge: an axis
2768 // label is whichever casing was first seen for that group, so a
2769 // record whose own key differs only in case must still match it.
2770 key.iter()
2771 .zip(labels)
2772 .all(|(k, want)| want.as_ref().is_none_or(|w| w.eq_ignore_ascii_case(k)))
2773 }
2774
2775 /// Cross-checks every cell of `grid` against an aggregate computed by a
2776 /// structurally independent path: instead of `compute_pivot`'s
2777 /// recursive group-tree + flatten, this filters the same record set by
2778 /// simple partial-key matching against each axis item's labels. Catches
2779 /// bugs in the tree-based grouping/flattening/subtotal-insertion logic
2780 /// specifically, since the aggregation math itself (`aggregate`) is
2781 /// shared and already covered by the fixed-data tests above.
2782 fn verify_grid_matches_records(sheet: &Sheet, pivot: &PivotTable, grid: &PivotGrid) {
2783 let (_, col_names, sheet_cols, data_rows) =
2784 resolve_source(&[sheet], &pivot.source).unwrap();
2785 let row_idxs: Vec<usize> = pivot
2786 .row_fields
2787 .iter()
2788 .map(|f| column_index(&col_names, &f.column).unwrap())
2789 .collect();
2790 let col_idxs: Vec<usize> = pivot
2791 .col_fields
2792 .iter()
2793 .map(|f| column_index(&col_names, &f.column).unwrap())
2794 .collect();
2795
2796 let mut records: Vec<(Vec<String>, Vec<String>, Vec<ResultData>)> = Vec::new();
2797 'row: for &r in &data_rows {
2798 let row_vals: Vec<ResultData> = sheet_cols
2799 .iter()
2800 .map(|&c| sheet.get_result_data(&CellRef::new(r, c)))
2801 .collect();
2802 for ff in &pivot.filter_fields {
2803 if let Some(selected) = &ff.selected_values {
2804 let idx = column_index(&col_names, &ff.column).unwrap();
2805 let key = group_key(&row_vals[idx]);
2806 // Case-insensitive, matching `compute_pivot`'s own filter
2807 // step (a filter field's items are merged case-different
2808 // text, same as row/col group labels).
2809 if !selected.iter().any(|v| v.eq_ignore_ascii_case(&key)) {
2810 continue 'row;
2811 }
2812 }
2813 }
2814 let row_key: Vec<String> = row_idxs.iter().map(|&i| group_key(&row_vals[i])).collect();
2815 let col_key: Vec<String> = col_idxs.iter().map(|&i| group_key(&row_vals[i])).collect();
2816 records.push((row_key, col_key, row_vals));
2817 }
2818
2819 let value_idxs: Vec<usize> = pivot
2820 .value_fields
2821 .iter()
2822 .map(|vf| column_index(&col_names, &vf.column).unwrap())
2823 .collect();
2824 let value_multiplier = if pivot.value_fields.len() > 1 {
2825 pivot.value_fields.len()
2826 } else {
2827 1
2828 };
2829 let width = row_label_width(pivot);
2830
2831 assert_eq!(grid.body_rows.len(), grid.row_axis.len());
2832 assert_eq!(grid.width, width + grid.col_axis.len() * value_multiplier);
2833 for hrow in &grid.header_rows {
2834 assert_eq!(hrow.len(), grid.width);
2835 }
2836
2837 for (i, (body_row, row_axis)) in grid.body_rows.iter().zip(grid.row_axis.iter()).enumerate()
2838 {
2839 assert_eq!(
2840 body_row.is_grand_total, row_axis.is_grand_total,
2841 "row {i} grand-total flag mismatch"
2842 );
2843 assert_eq!(body_row.row_labels.len(), width, "row {i} label width");
2844 assert_eq!(
2845 body_row.values.len(),
2846 grid.col_axis.len() * value_multiplier,
2847 "row {i} value count"
2848 );
2849
2850 for (j, col_axis) in grid.col_axis.iter().enumerate() {
2851 let matching: Vec<&Vec<ResultData>> = records
2852 .iter()
2853 .filter(|(rk, ck, _)| {
2854 matches_partial(rk, &row_axis.labels)
2855 && matches_partial(ck, &col_axis.labels)
2856 })
2857 .map(|(_, _, row)| row)
2858 .collect();
2859
2860 for (vf_pos, &vidx) in value_idxs.iter().enumerate() {
2861 if vf_pos > 0 && value_multiplier == 1 {
2862 break;
2863 }
2864 let col_vals: Vec<ResultData> =
2865 matching.iter().map(|row| row[vidx].clone()).collect();
2866 let expected =
2867 aggregate(sheet, &col_vals, pivot.value_fields[vf_pos].aggregation);
2868 let actual = &body_row.values[j * value_multiplier + vf_pos];
2869 assert!(
2870 results_close(&expected, actual),
2871 "row {i} col {j} value-field {vf_pos}: expected {expected:?}, got {actual:?} \
2872 (row_labels={:?}, col_labels={:?})",
2873 row_axis.labels,
2874 col_axis.labels,
2875 );
2876 }
2877 }
2878 }
2879 }
2880
2881 /// A grand-total pseudo-group is appended whenever the toggle is on,
2882 /// *except* when the axis has no fields at all (`build_axis`'s
2883 /// no-fields early return never adds one -- there's no separate
2884 /// grouping to total distinctly from the single implicit group).
2885 /// Otherwise Excel shows it regardless of how many real groups exist,
2886 /// even just one (confirmed against real Excel via fuzz/fuzz_pivot.py).
2887 fn verify_grand_total_placement(
2888 axis: &[PivotAxisItem],
2889 grand_total_requested: bool,
2890 axis_has_fields: bool,
2891 label: &str,
2892 ) {
2893 let grand_count = axis.iter().filter(|a| a.is_grand_total).count();
2894 let has_any_real_group = axis.iter().any(|a| !a.is_grand_total);
2895 assert!(grand_count <= 1, "{label}: more than one grand-total group");
2896 if grand_total_requested && axis_has_fields && has_any_real_group {
2897 assert_eq!(
2898 grand_count, 1,
2899 "{label}: expected a grand total to be appended"
2900 );
2901 } else {
2902 assert_eq!(grand_count, 0, "{label}: did not expect a grand total");
2903 }
2904 }
2905
2906 #[test]
2907 fn test_fuzz_pivot_random_invariants() {
2908 for seed in 0u64..300 {
2909 let mut rng: StdRng = SeedableRng::seed_from_u64(seed);
2910 let use_table = seed % 2 == 0;
2911 // A zero-data-row (header-only) Excel Table used to panic on
2912 // export+reimport ("invalid range bounds" inside calamine's
2913 // `Range::range`, reachable via `Xlsx::table_by_name`) --
2914 // this fuzz loop is what originally found that bug. The import
2915 // path no longer calls calamine's table API at all (see
2916 // `xlsx::import_tables_from_zip`), so the Table-sourced arm no
2917 // longer needs to avoid num_rows=0.
2918 let num_rows = rng.gen_range(0..=40usize);
2919 let (mut sheet, col_names) = fuzz_source_sheet(&mut rng, num_rows);
2920 if use_table {
2921 sheet
2922 .add_table(
2923 "FuzzTable".to_string(),
2924 0,
2925 0,
2926 num_rows,
2927 col_names.len() - 1,
2928 true,
2929 false,
2930 )
2931 .unwrap();
2932 }
2933 let pivot = fuzz_pivot_config(&mut rng, &sheet, &col_names, num_rows, use_table);
2934
2935 let grid = compute_pivot(&[&sheet], &pivot)
2936 .unwrap_or_else(|e| panic!("seed {seed}: compute_pivot failed: {e}"));
2937
2938 verify_grid_matches_records(&sheet, &pivot, &grid);
2939 verify_grand_total_placement(
2940 &grid.row_axis,
2941 pivot.grand_totals_row,
2942 !pivot.row_fields.is_empty(),
2943 "row axis",
2944 );
2945 verify_grand_total_placement(
2946 &grid.col_axis,
2947 pivot.grand_totals_col,
2948 !pivot.col_fields.is_empty(),
2949 "col axis",
2950 );
2951
2952 // Round-trip through xlsx export/import: field/aggregation
2953 // assignments, grand-total flags, and subtotal toggles must
2954 // survive; filter selections are documented (pivot_xlsx.rs) as
2955 // resetting to "all" rather than surviving.
2956 let xlsx = crate::core::xlsx::export_xlsx_data(
2957 std::slice::from_ref(&sheet),
2958 &[],
2959 std::slice::from_ref(&pivot),
2960 None,
2961 )
2962 .unwrap_or_else(|e| panic!("seed {seed}: export failed: {e}"));
2963 let (imported_sheets, _, imported_pivots, _) =
2964 crate::core::xlsx::import_xlsx_data(&xlsx, &[], |_, _, _| {})
2965 .unwrap_or_else(|e| panic!("seed {seed}: import failed: {e}"));
2966 assert_eq!(
2967 imported_pivots.len(),
2968 1,
2969 "seed {seed}: pivot lost on round-trip"
2970 );
2971 let reimported = &imported_pivots[0];
2972
2973 assert_eq!(
2974 reimported
2975 .row_fields
2976 .iter()
2977 .map(|f| &f.column)
2978 .collect::<Vec<_>>(),
2979 pivot
2980 .row_fields
2981 .iter()
2982 .map(|f| &f.column)
2983 .collect::<Vec<_>>(),
2984 "seed {seed}: row field columns changed on round-trip"
2985 );
2986 assert_eq!(
2987 reimported
2988 .col_fields
2989 .iter()
2990 .map(|f| &f.column)
2991 .collect::<Vec<_>>(),
2992 pivot
2993 .col_fields
2994 .iter()
2995 .map(|f| &f.column)
2996 .collect::<Vec<_>>(),
2997 "seed {seed}: col field columns changed on round-trip"
2998 );
2999 assert_eq!(
3000 reimported
3001 .value_fields
3002 .iter()
3003 .map(|f| (&f.column, f.aggregation))
3004 .collect::<Vec<_>>(),
3005 pivot
3006 .value_fields
3007 .iter()
3008 .map(|f| (&f.column, f.aggregation))
3009 .collect::<Vec<_>>(),
3010 "seed {seed}: value fields changed on round-trip"
3011 );
3012 assert_eq!(reimported.grand_totals_row, pivot.grand_totals_row);
3013 assert_eq!(reimported.grand_totals_col, pivot.grand_totals_col);
3014 assert_eq!(
3015 reimported
3016 .row_fields
3017 .iter()
3018 .map(|f| f.subtotal)
3019 .collect::<Vec<_>>(),
3020 pivot
3021 .row_fields
3022 .iter()
3023 .map(|f| f.subtotal)
3024 .collect::<Vec<_>>(),
3025 "seed {seed}: row field subtotal toggle should round-trip"
3026 );
3027 assert_eq!(
3028 reimported
3029 .col_fields
3030 .iter()
3031 .map(|f| f.subtotal)
3032 .collect::<Vec<_>>(),
3033 pivot
3034 .col_fields
3035 .iter()
3036 .map(|f| f.subtotal)
3037 .collect::<Vec<_>>(),
3038 "seed {seed}: col field subtotal toggle should round-trip"
3039 );
3040 // If nothing lossy was actually in play, the reimported grid
3041 // must be structurally identical -- this is where a genuine
3042 // round-trip bug (e.g. losing a value field's aggregation)
3043 // would show up as a shape mismatch rather than a field-list
3044 // diff the assertions above already caught. Subtotal toggles
3045 // now round-trip exactly, so only filter selections remain
3046 // lossy.
3047 // Filter selections round-trip now too, so a lossless round trip
3048 // is the ordinary case rather than the exception.
3049 let nothing_lossy = true;
3050 let any_filter_is_also_an_axis_field = pivot.filter_fields.iter().any(|ff| {
3051 pivot
3052 .row_fields
3053 .iter()
3054 .chain(pivot.col_fields.iter())
3055 .any(|f| f.column.eq_ignore_ascii_case(&ff.column))
3056 });
3057 let reimported_sheets: Vec<Sheet> =
3058 imported_sheets.into_iter().map(|s| s.sheet).collect();
3059 let reimported_sheet_refs: Vec<&Sheet> = reimported_sheets.iter().collect();
3060 let reimported_grid = compute_pivot(&reimported_sheet_refs, reimported)
3061 .unwrap_or_else(|e| panic!("seed {seed}: reimported compute_pivot failed: {e}"));
3062 // A field Excel could not represent at all -- see
3063 // `axis_bound` below -- is excluded from the shape check for the
3064 // same reason it is excluded from the selection check.
3065 if nothing_lossy && !any_filter_is_also_an_axis_field {
3066 assert_eq!(
3067 reimported_grid.body_rows.len(),
3068 grid.body_rows.len(),
3069 "seed {seed}: grid shape changed on lossless round-trip"
3070 );
3071 }
3072
3073 // Filter selections round-trip now: they are written as indices
3074 // into the cache's `<sharedItems>` and resolved back to plain
3075 // values on import. What must match is the *set* of selected
3076 // values, since the file stores them in the cache's first-seen
3077 // order rather than the caller's.
3078 //
3079 // The one legitimate difference: a selection covering every
3080 // value marks nothing hidden, so it is indistinguishable from no
3081 // filter once written and comes back as `None`. That is only
3082 // acceptable if it really was a no-op, which the grid proves.
3083 // Compared case-insensitively, because the engine merges
3084 // case-variant values into one item (keyed by the first casing
3085 // seen in the source). So a selection naming both `WEST` and
3086 // `west` picks a single item and legitimately reads back as
3087 // whichever casing the cache stored -- a canonicalization, not a
3088 // loss.
3089 let sorted = |f: &PivotFilterField| {
3090 f.selected_values.as_ref().map(|v| {
3091 let mut v: Vec<String> = v.iter().map(|s| s.to_lowercase()).collect();
3092 v.sort();
3093 v.dedup();
3094 v
3095 })
3096 };
3097 // A filter column that is *also* a row or column field has no
3098 // representation in the file: a pivot field carries one `axis`,
3099 // so the row/column orientation wins and there is nowhere left to
3100 // record the selection. Excel cannot express that config either
3101 // -- a field has exactly one orientation there -- so this is a
3102 // shape visi's model admits and the format does not, rather than
3103 // a round-trip bug.
3104 let axis_bound = |column: &str| {
3105 pivot
3106 .row_fields
3107 .iter()
3108 .chain(pivot.col_fields.iter())
3109 .any(|f| f.column.eq_ignore_ascii_case(column))
3110 };
3111 for (before, after) in pivot
3112 .filter_fields
3113 .iter()
3114 .zip(reimported.filter_fields.iter())
3115 {
3116 if axis_bound(&before.column) {
3117 continue;
3118 }
3119 if before.selected_values.is_some() && after.selected_values.is_none() {
3120 assert_eq!(
3121 reimported_grid.body_rows.len(),
3122 grid.body_rows.len(),
3123 "seed {seed}: filter on '{}' was dropped and it mattered",
3124 before.column
3125 );
3126 } else {
3127 assert_eq!(
3128 sorted(before),
3129 sorted(after),
3130 "seed {seed}: filter selection should round-trip for '{}'",
3131 before.column
3132 );
3133 }
3134 }
3135 }
3136 }
3137}