Skip to main content

visi_core/core/
pivot.rs

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