Skip to main content

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