Skip to main content

visi_core/core/
pivot.rs

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