Skip to main content

visi_core/core/
pivot.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4use crate::core::engine::{CellRef, ResultData, Sheet};
5
6/// Where a `PivotTable` reads its source records from: either an existing
7/// `ExcelTable` (looked up by name at compute time, so renames/resizes of
8/// the table are picked up automatically on refresh) or a plain cell range
9/// whose first row is treated as column headers.
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
11pub enum PivotSource {
12    /// An `ExcelTable`, resolved by name on every refresh.
13    Table {
14        /// The table's name, matched case-insensitively workbook-wide.
15        name: String,
16    },
17    /// A raw rectangular range, whose first row supplies the field names.
18    Range {
19        /// Sheet the range lives on.
20        sheet_id: u64,
21        /// First row of the range, 0-based, and the header row.
22        start_row: usize,
23        /// First column of the range, 0-based.
24        start_col: usize,
25        /// Last row of the range, 0-based and inclusive.
26        end_row: usize,
27        /// Last column of the range, 0-based and inclusive.
28        end_col: usize,
29    },
30}
31
32/// Matches the "Summarize value field by" choices Excel exposes for a data
33/// field; the five most commonly used ones plus the numeric-only count.
34#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
35pub enum PivotAggregation {
36    /// Total of the numeric values.
37    Sum,
38    /// How many non-blank values there are, text included.
39    Count,
40    /// How many values are numbers.
41    CountNumbers,
42    /// Mean of the numeric values.
43    Average,
44    /// Largest numeric value.
45    Max,
46    /// Smallest numeric value.
47    Min,
48}
49
50impl PivotAggregation {
51    /// The caption Excel uses for this aggregation in a value field's default
52    /// label ("Sum of Amount").
53    ///
54    /// [`PivotAggregation::CountNumbers`] shares `Count`'s caption, which is
55    /// why two such fields on one column collide and get disambiguated by
56    /// [`value_field_labels`].
57    pub fn label(&self) -> &'static str {
58        match self {
59            PivotAggregation::Sum => "Sum",
60            PivotAggregation::Count | PivotAggregation::CountNumbers => "Count",
61            PivotAggregation::Average => "Average",
62            PivotAggregation::Max => "Max",
63            PivotAggregation::Min => "Min",
64        }
65    }
66
67    /// Parses a user-supplied aggregation name, ignoring case, spaces,
68    /// underscores and hyphens, and accepting the common short forms (`avg`,
69    /// `countnums`, `maximum`). `None` if it names nothing.
70    pub fn parse(s: &str) -> Option<Self> {
71        match s.to_ascii_lowercase().replace(['_', '-', ' '], "").as_str() {
72            "sum" => Some(Self::Sum),
73            "count" => Some(Self::Count),
74            "countnumbers" | "countnums" => Some(Self::CountNumbers),
75            "average" | "avg" => Some(Self::Average),
76            "max" | "maximum" => Some(Self::Max),
77            "min" | "minimum" => Some(Self::Min),
78            _ => None,
79        }
80    }
81}
82
83/// One field placed in the Row or Column area.
84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
85pub struct PivotField {
86    /// Name of the source column to group by, matched against the header row.
87    pub column: String,
88    /// Whether a subtotal line is emitted for this field when it isn't the
89    /// innermost field in its area (Excel's per-field "Subtotals" toggle).
90    pub subtotal: bool,
91}
92
93impl PivotField {
94    /// A field on `column` with subtotals enabled, Excel's default.
95    pub fn new(column: impl Into<String>) -> Self {
96        Self {
97            column: column.into(),
98            subtotal: true,
99        }
100    }
101}
102
103/// One field placed in the Values area.
104#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
105pub struct PivotValueField {
106    /// Name of the source column to aggregate, matched against the header row.
107    pub column: String,
108    /// How the column's values are summarized.
109    pub aggregation: PivotAggregation,
110    /// Overrides the default "Sum of Amount" caption. A custom name is used
111    /// verbatim and takes no part in [`value_field_labels`]' disambiguation.
112    pub custom_name: Option<String>,
113}
114
115impl PivotValueField {
116    /// A value field on `column` with the default caption.
117    pub fn new(column: impl Into<String>, aggregation: PivotAggregation) -> Self {
118        Self {
119            column: column.into(),
120            aggregation,
121            custom_name: None,
122        }
123    }
124
125    /// This field's caption considered on its own, ignoring any collision
126    /// with the pivot's other value fields. Use [`value_field_labels`] to
127    /// caption a whole list the way Excel would.
128    pub fn label(&self) -> String {
129        self.custom_name
130            .clone()
131            .unwrap_or_else(|| format!("{} of {}", self.aggregation.label(), self.column))
132    }
133}
134
135/// Default display labels for a pivot's whole value-field list, matching
136/// Excel's own (surprisingly convoluted) disambiguation for repeated
137/// source columns -- derived empirically against real Excel via
138/// fuzz/fuzz_pivot.py plus direct probing (see the probe script referenced
139/// in the PR that added this comment), since none of it is documented.
140///
141/// Two independent mechanisms are in play, both scoped per source column:
142///
143/// 1. **The "Sum" clone.** The *first* value field for a column that uses
144///    the `Sum` aggregation causes Excel to silently clone that column
145///    into a new pseudo-field ("Amount" -> "Amount2") for every value
146///    field *after* it in the list (not before) -- regardless of their own
147///    aggregation. A *second* `Sum` on the same column clones again
148///    ("Amount2" -> "Amount3"), but non-`Sum` aggregations never trigger a
149///    further clone; they just ride whatever clone slot is already active.
150///    E.g. `[Sum, Max, Count]` on "Amount" -> `["Sum of Amount", "Max of
151///    Amount2", "Count of Amount2"]` (both non-Sum fields share slot 2);
152///    `[Sum, Sum, Count]` -> `["Sum of Amount", "Sum of Amount2", "Count
153///    of Amount3"]` (the second Sum clones again). A column with *no* Sum
154///    value field anywhere is never cloned at all.
155/// 2. **Literal caption collision.** Independent of the above, if two
156///    value fields end up wanting the exact same caption text, Excel still
157///    has to disambiguate. If neither is in a Sum-cloned slot, it appends
158///    a plain digit straight onto the column name (`"Count of Amount"`,
159///    `"Count of Amount2"`, `"Count of Amount3"`, ...) -- this is also how
160///    `CountNumbers` colliding with `Count` gets suffixed, since both
161///    share the caption label "Count" (see `PivotAggregation::label`). If
162///    the collision instead happens *inside* an already Sum-cloned slot
163///    (two non-Sum fields sharing one clone with the same aggregation),
164///    Excel instead appends an underscored counter to the *whole* already-
165///    suffixed caption (`"Max of Amount2"`, `"Max of Amount2_2"`) rather
166///    than incrementing the clone number again.
167///
168/// An explicit `custom_name` bypasses both mechanisms entirely -- it's
169/// used as-is and doesn't consume a collision slot or trigger a clone.
170pub fn value_field_labels(value_fields: &[PivotValueField]) -> Vec<String> {
171    let mut clone_suffix: HashMap<&str, usize> = HashMap::new();
172    let mut next_clone: HashMap<&str, usize> = HashMap::new();
173    let mut label_counts: HashMap<String, usize> = HashMap::new();
174
175    value_fields
176        .iter()
177        .map(|vf| {
178            if let Some(name) = &vf.custom_name {
179                return name.clone();
180            }
181            let agg_label = vf.aggregation.label();
182            let in_clone_slot = clone_suffix.contains_key(vf.column.as_str());
183            let base_column = match clone_suffix.get(vf.column.as_str()) {
184                Some(n) => format!("{}{}", vf.column, n),
185                None => vf.column.clone(),
186            };
187            let base_label = format!("{} of {}", agg_label, base_column);
188            let count = label_counts.entry(base_label.clone()).or_insert(0);
189            *count += 1;
190            let label = if *count == 1 {
191                base_label
192            } else if in_clone_slot {
193                format!("{}_{}", base_label, count)
194            } else {
195                format!("{} of {}{}", agg_label, vf.column, count)
196            };
197            if vf.aggregation == PivotAggregation::Sum {
198                let assigned = *next_clone.entry(vf.column.as_str()).or_insert(2);
199                next_clone.insert(vf.column.as_str(), assigned + 1);
200                clone_suffix.insert(vf.column.as_str(), assigned);
201            }
202            label
203        })
204        .collect()
205}
206
207/// One field placed in the Filter (Page) area.
208#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
209pub struct PivotFilterField {
210    /// Name of the source column to filter on, matched against the header row.
211    pub column: String,
212    /// `None` means every value is allowed (no filtering applied yet).
213    ///
214    /// **Reconstructed on xlsx import**, resolved through the cache's
215    /// `<sharedItems>` to plain value strings rather than kept as indices --
216    /// which is what makes it safe. The indices are trusted only against the
217    /// cache definition in the same file, which is self-consistent by
218    /// construction, and a value that no longer exists in changed source data
219    /// simply matches nothing.
220    ///
221    /// Two things do not survive, both because the format cannot hold them:
222    ///
223    /// - A selection covering *every* value marks nothing hidden, so it is
224    ///   indistinguishable from no filter and reads back as `None`. The
225    ///   grid is the same either way.
226    /// - A filter on a column that is *also* a row or column field is lost
227    ///   entirely: a pivot field carries one `axis`, so there is nowhere to
228    ///   record it. Excel cannot express that config at all -- a field has
229    ///   exactly one orientation there.
230    ///
231    /// Matching is case-insensitive, because the items themselves are merged
232    /// that way; a selection naming `east` picks the merged `East` item.
233    pub selected_values: Option<Vec<String>>,
234    /// Whether the field is in Excel's *multi-select* page mode
235    /// (`multipleItemSelectionAllowed` in the file) rather than its classic
236    /// single-select one.
237    ///
238    /// The two differ in what the page-field cell says, which is observable:
239    /// with one item chosen, multi-select shows `(Multiple Items)` while
240    /// single-select shows the **item's own name**. Both measured -- the
241    /// first through `PivotItems(x).Visible = False`, the second through
242    /// `PivotField.CurrentPage = "Widget"`, which is what puts a field into
243    /// single-select mode in the first place.
244    ///
245    /// Defaults to `true`, matching `set_pivot_filter` and the CLI, which
246    /// select a set of values rather than one page.
247    #[serde(default = "default_true")]
248    pub multiple_selection: bool,
249}
250
251fn default_true() -> bool {
252    true
253}
254
255impl PivotFilterField {
256    /// A filter field on `column` with nothing filtered out yet.
257    pub fn new(column: impl Into<String>) -> Self {
258        Self {
259            column: column.into(),
260            selected_values: None,
261            multiple_selection: true,
262        }
263    }
264}
265
266/// The area of a pivot table a field can be assigned to, used by the
267/// add/remove-field CRUD operations.
268#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
269pub enum PivotArea {
270    /// Groups down the left edge; adds to `PivotTable::row_fields`.
271    Row,
272    /// Groups across the top; adds to `PivotTable::col_fields`.
273    Column,
274    /// Aggregated data; adds to `PivotTable::value_fields`.
275    Value,
276    /// Restricts which source records take part; adds to
277    /// `PivotTable::filter_fields`.
278    Filter,
279}
280
281/// A pivot table definition: a summary of `source`, grouped by `row_fields`
282/// nested within `col_fields`, restricted by `filter_fields`, and
283/// aggregated per `value_fields`. This is a workbook-level object (like
284/// `Chart`) rather than sheet-scoped like `ExcelTable`, since its source and
285/// destination ranges may live on different sheets.
286#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
287pub struct PivotTable {
288    /// Workbook-unique identifier, stable across renames.
289    pub id: u64,
290    /// Display name, unique workbook-wide.
291    pub name: String,
292    /// Where the records come from.
293    pub source: PivotSource,
294    /// Sheet the grid is written to, which need not be the source's sheet.
295    pub dest_sheet_id: u64,
296    /// Top-left row of the output grid, 0-based.
297    pub dest_row: usize,
298    /// Top-left column of the output grid, 0-based.
299    pub dest_col: usize,
300    /// Fields grouped down the left edge, outermost first.
301    pub row_fields: Vec<PivotField>,
302    /// Fields grouped across the top, outermost first.
303    pub col_fields: Vec<PivotField>,
304    /// Fields aggregated into the body. At least one is required for
305    /// [`compute_pivot`] to succeed.
306    pub value_fields: Vec<PivotValueField>,
307    /// Fields restricting which source records take part.
308    pub filter_fields: Vec<PivotFilterField>,
309    /// Whether a grand-total row is appended below the body.
310    pub grand_totals_row: bool,
311    /// Whether a grand-total column is appended to the right of the body.
312    pub grand_totals_col: bool,
313    /// Bottom-right corner of the last rendered output grid, so a refresh
314    /// that produces a smaller grid can clear the now-stale cells.
315    #[serde(default)]
316    pub last_output_end_row: Option<usize>,
317    /// Column half of that corner; see [`PivotTable::last_output_end_row`].
318    #[serde(default)]
319    pub last_output_end_col: Option<usize>,
320}
321
322pub(crate) fn row_label_width(pivot: &PivotTable) -> usize {
323    if !pivot.row_fields.is_empty() {
324        return pivot.row_fields.len();
325    }
326    if pivot.value_fields.len() == 1 && !pivot.col_fields.is_empty() {
327        1
328    } else {
329        0
330    }
331}
332
333/// A fully computed pivot result, ready to be materialized into a sheet:
334/// `filter_rows` (if any) come first, then a blank spacer row, then
335/// `header_rows`, then one entry of `body_rows` per output row -- mirroring
336/// Excel's own report-filter placement (verified against real Excel: it
337/// always reserves one row per filter field plus a blank spacer above the
338/// row/column header grid, and captions each with a "(All)"/"(Multiple
339/// Items)" state -- never a specific value's name, since that's specific to
340/// the classic single-select page-field mode Excel no longer defaults to).
341#[derive(Debug, Clone)]
342pub struct PivotGrid {
343    /// One `(field name, state)` pair per filter field, in the order they
344    /// were added.
345    ///
346    /// The state is `"(All)"` when every value is allowed, the **item's own
347    /// name** when exactly one is selected, and `"(Multiple Items)"`
348    /// otherwise -- which is what Excel puts in the page-field cell, and what
349    /// `PivotField.CurrentPage` reports alongside it.
350    pub filter_rows: Vec<(String, String)>,
351    /// The column-header block above the body: one row per column field,
352    /// plus a value-field row when there is more than one value field.
353    pub header_rows: Vec<Vec<String>>,
354    /// The body, one entry per output row, subtotal and grand-total rows
355    /// included.
356    pub body_rows: Vec<PivotBodyRow>,
357    /// Total width in columns (row-label columns + data columns), used by
358    /// the caller to know how large a range to clear/allocate. Always >= 2,
359    /// so `filter_rows`' two columns (name, state) always fit within it.
360    pub width: usize,
361    /// The flattened row/column axis groups underlying `body_rows`/the data
362    /// columns, exposed (independent of display formatting) so an xlsx
363    /// exporter can reconstruct a native `pivotTableDefinition`'s
364    /// `rowItems`/`colItems` without re-deriving the grouping itself.
365    pub row_axis: Vec<PivotAxisItem>,
366    /// Column half of that axis pair; see [`PivotGrid::row_axis`].
367    pub col_axis: Vec<PivotAxisItem>,
368}
369
370/// One row of a computed pivot's body: its row-field labels and its
371/// aggregated values.
372#[derive(Debug, Clone)]
373pub struct PivotBodyRow {
374    /// One entry per row field (or a single "Grand Total" entry when there
375    /// are no row fields); blank entries mean "same as the row above".
376    pub row_labels: Vec<String>,
377    /// Whether this row is the grand total rather than a data or subtotal row.
378    pub is_grand_total: bool,
379    /// One entry per data column, aligned with the last `header_rows` row.
380    pub values: Vec<ResultData>,
381}
382
383/// One flattened group along a row or column axis: a label per axis field
384/// (`None` past its own depth), plus whether it's a subtotal or grand-total
385/// pseudo-group rather than a real leaf group.
386#[derive(Debug, Clone)]
387pub struct PivotAxisItem {
388    /// One entry per field in this axis, `None` past this group's own depth.
389    pub labels: Vec<Option<String>>,
390    /// Whether this is a subtotal pseudo-group rather than a leaf group.
391    pub is_subtotal: bool,
392    /// Whether this is the axis's grand-total pseudo-group.
393    pub is_grand_total: bool,
394}
395
396impl PivotGrid {
397    /// Row offset from the pivot's `dest_row` anchor to where the row/col
398    /// header + data grid actually begins: 0 with no filter fields, else
399    /// one row per filter field plus a blank spacer row.
400    pub fn grid_row_offset(&self) -> usize {
401        if self.filter_rows.is_empty() {
402            0
403        } else {
404            self.filter_rows.len() + 1
405        }
406    }
407
408    /// Total height in rows, filter rows and spacer included -- what the
409    /// caller needs to allocate or clear at the pivot's `dest_row` anchor.
410    pub fn height(&self) -> usize {
411        self.grid_row_offset() + self.header_rows.len() + self.body_rows.len()
412    }
413}
414
415struct FlatGroup {
416    /// One label per field in this axis; `None` past the group's own depth
417    /// (e.g. a subtotal group has no label for deeper fields).
418    labels: Vec<Option<String>>,
419    record_indices: Vec<usize>,
420    is_subtotal: bool,
421    is_grand_total: bool,
422}
423
424struct GroupNode {
425    label: String,
426    record_indices: Vec<usize>,
427    children: Vec<GroupNode>,
428}
429
430pub(crate) fn group_key(result: &ResultData) -> String {
431    match result {
432        ResultData::None => "(blank)".to_string(),
433        ResultData::String(s) if s.is_empty() => "(blank)".to_string(),
434        other => other.to_string(),
435    }
436}
437
438pub(crate) fn field_is_numeric(records: &[Vec<ResultData>], field_idx: usize) -> bool {
439    !records.is_empty()
440        && records.iter().all(|r| {
441            matches!(
442                r.get(field_idx),
443                Some(ResultData::Integer(_)) | Some(ResultData::Float(_)) | Some(ResultData::None)
444            )
445        })
446}
447
448fn text_sort_key(s: &str) -> String {
449    let trimmed = s.trim();
450    let key = match trimmed.strip_prefix('-') {
451        Some(rest) if rest.starts_with(|c: char| c.is_ascii_digit()) => rest,
452        _ => trimmed,
453    };
454    key.to_lowercase()
455}
456
457fn sort_group_entries(pairs: &mut [(String, Vec<usize>)], numeric: bool) {
458    pairs.sort_by(|a, b| match (a.0 == "(blank)", b.0 == "(blank)") {
459        (true, true) => std::cmp::Ordering::Equal,
460        (true, false) => std::cmp::Ordering::Greater,
461        (false, true) => std::cmp::Ordering::Less,
462        (false, false) if numeric => {
463            let fa: f64 = a.0.trim().parse().unwrap_or(0.0);
464            let fb: f64 = b.0.trim().parse().unwrap_or(0.0);
465            fa.partial_cmp(&fb).unwrap_or(std::cmp::Ordering::Equal)
466        }
467        (false, false) => text_sort_key(&a.0).cmp(&text_sort_key(&b.0)),
468    });
469}
470
471fn build_group_tree(
472    indices: &[usize],
473    keys: &[Vec<String>],
474    depth: usize,
475    num_fields: usize,
476    numeric_by_depth: &[bool],
477) -> Vec<GroupNode> {
478    let mut groups: Vec<(String, Vec<usize>)> = Vec::new();
479    for &idx in indices {
480        let key = &keys[idx][depth];
481        if let Some(entry) = groups.iter_mut().find(|(k, _)| k.eq_ignore_ascii_case(key)) {
482            entry.1.push(idx);
483        } else {
484            groups.push((key.clone(), vec![idx]));
485        }
486    }
487    sort_group_entries(&mut groups, numeric_by_depth[depth]);
488    groups
489        .into_iter()
490        .map(|(label, idxs)| {
491            let children = if depth + 1 < num_fields {
492                build_group_tree(&idxs, keys, depth + 1, num_fields, numeric_by_depth)
493            } else {
494                Vec::new()
495            };
496            GroupNode {
497                label,
498                record_indices: idxs,
499                children,
500            }
501        })
502        .collect()
503}
504
505fn flatten_groups(
506    nodes: &[GroupNode],
507    fields: &[PivotField],
508    depth: usize,
509    num_fields: usize,
510    prefix: &[Option<String>],
511    out: &mut Vec<FlatGroup>,
512) {
513    for node in nodes {
514        let mut labels = prefix.to_vec();
515        labels.push(Some(node.label.clone()));
516
517        if node.children.is_empty() {
518            let mut leaf_labels = labels.clone();
519            leaf_labels.resize(num_fields, None);
520            out.push(FlatGroup {
521                labels: leaf_labels,
522                record_indices: node.record_indices.clone(),
523                is_subtotal: false,
524                is_grand_total: false,
525            });
526        } else {
527            flatten_groups(&node.children, fields, depth + 1, num_fields, &labels, out);
528            let is_innermost = depth + 1 >= num_fields;
529            if fields[depth].subtotal && !is_innermost {
530                let mut subtotal_labels = labels.clone();
531                subtotal_labels.resize(num_fields, None);
532                out.push(FlatGroup {
533                    labels: subtotal_labels,
534                    record_indices: node.record_indices.clone(),
535                    is_subtotal: true,
536                    is_grand_total: false,
537                });
538            }
539        }
540    }
541}
542
543fn build_axis(
544    record_indices: &[usize],
545    keys: &[Vec<String>],
546    fields: &[PivotField],
547    grand_total: bool,
548    numeric_by_depth: &[bool],
549) -> Vec<FlatGroup> {
550    if fields.is_empty() {
551        return vec![FlatGroup {
552            labels: Vec::new(),
553            record_indices: record_indices.to_vec(),
554            is_subtotal: false,
555            is_grand_total: false,
556        }];
557    }
558    let tree = build_group_tree(record_indices, keys, 0, fields.len(), numeric_by_depth);
559    let mut flat = Vec::new();
560    flatten_groups(&tree, fields, 0, fields.len(), &[], &mut flat);
561    if grand_total && !flat.is_empty() {
562        flat.push(FlatGroup {
563            labels: vec![None; fields.len()],
564            record_indices: record_indices.to_vec(),
565            is_subtotal: false,
566            is_grand_total: true,
567        });
568    }
569    flat
570}
571
572fn aggregate(sheet: &Sheet, values: &[ResultData], agg: PivotAggregation) -> ResultData {
573    if values.is_empty() {
574        return ResultData::None;
575    }
576    match agg {
577        PivotAggregation::Count => ResultData::Integer(
578            values
579                .iter()
580                .filter(|v| !matches!(v, ResultData::None))
581                .count() as i64,
582        ),
583        PivotAggregation::CountNumbers => ResultData::Integer(
584            values
585                .iter()
586                .filter(|v| matches!(v, ResultData::Integer(_) | ResultData::Float(_)))
587                .count() as i64,
588        ),
589        _ => {
590            let nums: Vec<f64> = values
591                .iter()
592                .filter_map(|v| match v {
593                    ResultData::Integer(_) | ResultData::Float(_) => sheet.to_f64(v),
594                    _ => None,
595                })
596                .collect();
597            match agg {
598                PivotAggregation::Sum => {
599                    if nums.is_empty() {
600                        ResultData::Integer(0)
601                    } else {
602                        ResultData::Float(Sheet::clean_float(nums.iter().sum()))
603                    }
604                }
605                PivotAggregation::Average => {
606                    if nums.is_empty() {
607                        ResultData::Error("#DIV/0!".to_string())
608                    } else {
609                        let avg = nums.iter().sum::<f64>() / nums.len() as f64;
610                        ResultData::Float(Sheet::clean_float(avg))
611                    }
612                }
613                PivotAggregation::Max => nums
614                    .into_iter()
615                    .fold(None, |acc: Option<f64>, x| {
616                        Some(acc.map_or(x, |a| a.max(x)))
617                    })
618                    .map(ResultData::Float)
619                    .unwrap_or(ResultData::None),
620                PivotAggregation::Min => nums
621                    .into_iter()
622                    .fold(None, |acc: Option<f64>, x| {
623                        Some(acc.map_or(x, |a| a.min(x)))
624                    })
625                    .map(ResultData::Float)
626                    .unwrap_or(ResultData::None),
627                PivotAggregation::Count | PivotAggregation::CountNumbers => unreachable!(),
628            }
629        }
630    }
631}
632
633pub(crate) type ResolvedSource<'a> = (&'a Sheet, Vec<String>, Vec<usize>, Vec<usize>);
634
635pub(crate) fn resolve_source<'a>(
636    sheets: &'a [&'a Sheet],
637    source: &PivotSource,
638) -> Result<ResolvedSource<'a>, String> {
639    match source {
640        PivotSource::Table { name } => {
641            let (sheet, table) = sheets
642                .iter()
643                .find_map(|s| s.find_table(name).map(|t| (*s, t)))
644                .ok_or_else(|| format!("Table '{}' not found", name))?;
645            let cols: Vec<usize> = (table.start_col..=table.end_col).collect();
646            let rows: Vec<usize> = (table.data_start_row()..=table.data_end_row()).collect();
647            Ok((sheet, table.columns.clone(), cols, rows))
648        }
649        PivotSource::Range {
650            sheet_id,
651            start_row,
652            start_col,
653            end_row,
654            end_col,
655        } => {
656            let sheet = *sheets
657                .iter()
658                .find(|s| s.id == *sheet_id)
659                .ok_or_else(|| "Pivot source sheet no longer exists".to_string())?;
660            if *end_row < *start_row || *end_col < *start_col {
661                return Err("Pivot source range end must not precede its start".to_string());
662            }
663            let cols: Vec<usize> = (*start_col..=*end_col).collect();
664            let names: Vec<String> = cols
665                .iter()
666                .map(|&c| {
667                    let v = sheet.get_result_data(&CellRef::new(*start_row, c));
668                    let s = v.to_string();
669                    if s.is_empty() {
670                        crate::core::parser::col_idx_to_letters(c)
671                    } else {
672                        s
673                    }
674                })
675                .collect();
676            let rows: Vec<usize> = if *end_row > *start_row {
677                (*start_row + 1..=*end_row).collect()
678            } else {
679                Vec::new()
680            };
681            Ok((sheet, names, cols, rows))
682        }
683    }
684}
685
686pub(crate) fn column_index(names: &[String], target: &str) -> Result<usize, String> {
687    names
688        .iter()
689        .position(|c| c.eq_ignore_ascii_case(target))
690        .ok_or_else(|| {
691            format!(
692                "Source column '{}' not found (columns: {})",
693                target,
694                names.join(", ")
695            )
696        })
697}
698
699/// Computes a pivot table's result grid from the current state of `sheets`.
700/// Pure and read-only: callers materialize the returned `PivotGrid` into
701/// sheet cells themselves.
702/// Computes `pivot` against `sheets`, returning a display-ready grid.
703///
704/// Pure: it reads source records, applies the filter fields, groups by the
705/// row and column fields, aggregates the value fields, and returns the
706/// result. Nothing is written -- materializing the grid into cells is
707/// `WorkbookManager::refresh_pivot_table`'s job.
708///
709/// `sheets` must include both the source's sheet and, for a
710/// [`PivotSource::Table`] source, whichever sheet carries that table.
711///
712/// # Errors
713///
714/// Returns a message if the source cannot be resolved, if a named field is
715/// not among the source's columns, or if the pivot has no value fields.
716pub fn compute_pivot(sheets: &[&Sheet], pivot: &PivotTable) -> Result<PivotGrid, String> {
717    let (sheet, col_names, sheet_cols, data_rows) = resolve_source(sheets, &pivot.source)?;
718
719    for f in pivot.row_fields.iter().chain(pivot.col_fields.iter()) {
720        column_index(&col_names, &f.column)?;
721    }
722    for vf in &pivot.value_fields {
723        column_index(&col_names, &vf.column)?;
724    }
725    for ff in &pivot.filter_fields {
726        column_index(&col_names, &ff.column)?;
727    }
728    if pivot.value_fields.is_empty() {
729        return Err("Pivot table has no value fields".to_string());
730    }
731
732    let mut all_rows: Vec<Vec<ResultData>> = Vec::with_capacity(data_rows.len());
733    for &r in &data_rows {
734        let mut row_vals = Vec::with_capacity(sheet_cols.len());
735        for &c in &sheet_cols {
736            row_vals.push(sheet.get_result_data(&CellRef::new(r, c)));
737        }
738        all_rows.push(row_vals);
739    }
740
741    let mut filter_rows: Vec<(String, String)> = Vec::new();
742    for ff in &pivot.filter_fields {
743        let idx = column_index(&col_names, &ff.column)?;
744        let distinct: std::collections::HashSet<String> = all_rows
745            .iter()
746            .map(|row| group_key(&row[idx]).to_ascii_lowercase())
747            .collect();
748        let state = match &ff.selected_values {
749            None => "(All)".to_string(),
750            Some(selected) => {
751                let selected_set: std::collections::HashSet<String> =
752                    selected.iter().map(|v| v.to_ascii_lowercase()).collect();
753                let is_all = selected_set.len() == distinct.len()
754                    && distinct.iter().all(|v| selected_set.contains(v));
755                if is_all {
756                    "(All)".to_string()
757                } else if !ff.multiple_selection && selected_set.len() == 1 {
758                    let wanted = &selected_set;
759                    all_rows
760                        .iter()
761                        .map(|row| group_key(&row[idx]))
762                        .find(|v| wanted.contains(&v.to_ascii_lowercase()))
763                        .unwrap_or_else(|| "(Multiple Items)".to_string())
764                } else {
765                    "(Multiple Items)".to_string()
766                }
767            }
768        };
769        filter_rows.push((ff.column.clone(), state));
770    }
771
772    let mut records: Vec<Vec<ResultData>> = Vec::new();
773    'row: for row_vals in &all_rows {
774        for ff in &pivot.filter_fields {
775            if let Some(selected) = &ff.selected_values {
776                let idx = column_index(&col_names, &ff.column)?;
777                let key = group_key(&row_vals[idx]);
778                if !selected.iter().any(|v| v.eq_ignore_ascii_case(&key)) {
779                    continue 'row;
780                }
781            }
782        }
783        records.push(row_vals.clone());
784    }
785
786    let record_indices: Vec<usize> = (0..records.len()).collect();
787
788    let row_field_idxs: Vec<usize> = pivot
789        .row_fields
790        .iter()
791        .map(|f| column_index(&col_names, &f.column))
792        .collect::<Result<_, _>>()?;
793    let col_field_idxs: Vec<usize> = pivot
794        .col_fields
795        .iter()
796        .map(|f| column_index(&col_names, &f.column))
797        .collect::<Result<_, _>>()?;
798    let mut case_canon: HashMap<usize, HashMap<String, String>> = HashMap::new();
799    let mut canonical_key = |field_idx: usize, raw: String| -> String {
800        let map = case_canon.entry(field_idx).or_default();
801        map.entry(raw.to_ascii_lowercase()).or_insert(raw).clone()
802    };
803    for row_vals in &all_rows {
804        for &i in row_field_idxs.iter().chain(col_field_idxs.iter()) {
805            canonical_key(i, group_key(&row_vals[i]));
806        }
807    }
808    let row_keys: Vec<Vec<String>> = if pivot.row_fields.is_empty() {
809        Vec::new()
810    } else {
811        records
812            .iter()
813            .map(|rec| {
814                row_field_idxs
815                    .iter()
816                    .map(|&i| canonical_key(i, group_key(&rec[i])))
817                    .collect()
818            })
819            .collect()
820    };
821    let col_keys: Vec<Vec<String>> = if pivot.col_fields.is_empty() {
822        Vec::new()
823    } else {
824        records
825            .iter()
826            .map(|rec| {
827                col_field_idxs
828                    .iter()
829                    .map(|&i| canonical_key(i, group_key(&rec[i])))
830                    .collect()
831            })
832            .collect()
833    };
834    let row_numeric: Vec<bool> = row_field_idxs
835        .iter()
836        .map(|&i| field_is_numeric(&records, i))
837        .collect();
838    let col_numeric: Vec<bool> = col_field_idxs
839        .iter()
840        .map(|&i| field_is_numeric(&records, i))
841        .collect();
842
843    let row_groups = build_axis(
844        &record_indices,
845        &row_keys,
846        &pivot.row_fields,
847        pivot.grand_totals_row,
848        &row_numeric,
849    );
850    let col_groups = build_axis(
851        &record_indices,
852        &col_keys,
853        &pivot.col_fields,
854        pivot.grand_totals_col,
855        &col_numeric,
856    );
857
858    let value_multiplier = if pivot.value_fields.len() > 1 {
859        pivot.value_fields.len()
860    } else {
861        1
862    };
863    let value_idxs: Vec<usize> = pivot
864        .value_fields
865        .iter()
866        .map(|vf| column_index(&col_names, &vf.column))
867        .collect::<Result<_, _>>()?;
868    let value_labels = value_field_labels(&pivot.value_fields);
869
870    let n_col_header_rows = pivot.col_fields.len().max(1);
871    let n_header_rows = if value_multiplier > 1 && !pivot.col_fields.is_empty() {
872        n_col_header_rows + 1
873    } else {
874        n_col_header_rows
875    };
876    let row_label_width = row_label_width(pivot);
877
878    let mut header_rows: Vec<Vec<String>> = Vec::new();
879    for r in 0..n_header_rows {
880        let mut row: Vec<String> = Vec::new();
881        for i in 0..row_label_width {
882            if r == n_header_rows - 1 {
883                row.push(if i == 0 && !pivot.row_fields.is_empty() {
884                    "Row Labels".to_string()
885                } else {
886                    pivot
887                        .row_fields
888                        .get(i)
889                        .map(|f| f.column.clone())
890                        .unwrap_or_default()
891                });
892            } else {
893                row.push(String::new());
894            }
895        }
896        let mut prev_group: Option<&FlatGroup> = None;
897        for group in &col_groups {
898            let subtotal_depth = group
899                .is_subtotal
900                .then(|| group.labels.iter().rposition(|l| l.is_some()))
901                .flatten();
902            for vf in 0..value_multiplier {
903                let label = if r < pivot.col_fields.len() {
904                    if group.is_grand_total {
905                        if r == 0 {
906                            if value_multiplier > 1 {
907                                format!("Total {}", value_labels[vf])
908                            } else {
909                                "Grand Total".to_string()
910                            }
911                        } else {
912                            String::new()
913                        }
914                    } else if subtotal_depth == Some(r) {
915                        let value = group.labels[r].clone().unwrap();
916                        if value_multiplier > 1 {
917                            format!("{} {}", value, value_labels[vf])
918                        } else {
919                            format!("{} Total", value)
920                        }
921                    } else {
922                        let is_repeat = if vf > 0 {
923                            true
924                        } else {
925                            prev_group.is_some_and(|pg| {
926                                (0..=r).all(|d| pg.labels.get(d) == group.labels.get(d))
927                            })
928                        };
929                        if is_repeat {
930                            String::new()
931                        } else {
932                            group
933                                .labels
934                                .get(r)
935                                .and_then(|l| l.clone())
936                                .unwrap_or_default()
937                        }
938                    }
939                } else if group.is_grand_total || group.is_subtotal {
940                    String::new()
941                } else {
942                    value_labels.get(vf).cloned().unwrap_or_default()
943                };
944                row.push(label);
945            }
946            prev_group = Some(group);
947        }
948        header_rows.push(row);
949    }
950    if pivot.col_fields.is_empty()
951        && value_multiplier == 1
952        && let Some(last) = header_rows.last_mut()
953        && let Some(cell) = last.last_mut()
954        && let Some(label) = value_labels.first()
955    {
956        *cell = label.clone();
957    }
958    if !pivot.col_fields.is_empty() {
959        let mut row = vec![String::new(); row_label_width];
960        if value_multiplier == 1
961            && !pivot.row_fields.is_empty()
962            && let Some(label) = value_labels.first()
963        {
964            row[0] = label.clone();
965        }
966        row.push("Column Labels".to_string());
967        row.resize(
968            row_label_width + col_groups.len() * value_multiplier,
969            String::new(),
970        );
971        header_rows.insert(0, row);
972    }
973
974    let mut body_rows: Vec<PivotBodyRow> = Vec::new();
975    let mut prev_labels: Vec<Option<String>> = vec![None; row_label_width];
976    for rg in &row_groups {
977        let mut display_labels = vec![String::new(); row_label_width];
978        if rg.is_grand_total {
979            display_labels[0] = "Grand Total".to_string();
980            for l in prev_labels.iter_mut() {
981                *l = None;
982            }
983        } else {
984            let mut changed = false;
985            for d in 0..row_label_width {
986                let cur = if pivot.row_fields.is_empty() {
987                    None
988                } else {
989                    rg.labels.get(d).cloned().flatten()
990                };
991                let is_subtotal_marker =
992                    rg.is_subtotal && rg.labels.get(d).map(|l| l.is_some()).unwrap_or(false);
993                let show = changed || cur != prev_labels[d] || is_subtotal_marker;
994                if show {
995                    if let Some(ref v) = cur {
996                        display_labels[d] = if is_subtotal_marker {
997                            format!("{} Total", v)
998                        } else {
999                            v.clone()
1000                        };
1001                    }
1002                    changed = true;
1003                }
1004                prev_labels[d] = cur;
1005            }
1006            if pivot.row_fields.is_empty() && row_label_width > 0 {
1007                display_labels[0] = if !pivot.col_fields.is_empty() && value_multiplier == 1 {
1008                    value_labels.first().cloned().unwrap_or_default()
1009                } else {
1010                    "Total".to_string()
1011                };
1012            }
1013        }
1014
1015        let row_record_set: std::collections::HashSet<usize> =
1016            rg.record_indices.iter().copied().collect();
1017        let mut values: Vec<ResultData> = Vec::new();
1018        for cg in &col_groups {
1019            for (vf_pos, &vidx) in value_idxs.iter().enumerate() {
1020                if vf_pos > 0 && value_multiplier == 1 {
1021                    break;
1022                }
1023                let col_vals: Vec<ResultData> = cg
1024                    .record_indices
1025                    .iter()
1026                    .filter(|i| row_record_set.contains(i))
1027                    .map(|&i| records[i][vidx].clone())
1028                    .collect();
1029                values.push(aggregate(
1030                    sheet,
1031                    &col_vals,
1032                    pivot.value_fields[vf_pos].aggregation,
1033                ));
1034            }
1035        }
1036
1037        body_rows.push(PivotBodyRow {
1038            row_labels: display_labels,
1039            is_grand_total: rg.is_grand_total,
1040            values,
1041        });
1042    }
1043
1044    let width = row_label_width + col_groups.len() * value_multiplier;
1045    let to_axis_items = |groups: &[FlatGroup]| -> Vec<PivotAxisItem> {
1046        groups
1047            .iter()
1048            .map(|g| PivotAxisItem {
1049                labels: g.labels.clone(),
1050                is_subtotal: g.is_subtotal,
1051                is_grand_total: g.is_grand_total,
1052            })
1053            .collect()
1054    };
1055    Ok(PivotGrid {
1056        filter_rows,
1057        header_rows,
1058        body_rows,
1059        width,
1060        row_axis: to_axis_items(&row_groups),
1061        col_axis: to_axis_items(&col_groups),
1062    })
1063}
1064
1065fn match_pivot_axis(
1066    axis: &[PivotAxisItem],
1067    criteria: &[(usize, &str)],
1068    field_count: usize,
1069) -> Result<usize, String> {
1070    if criteria.is_empty() {
1071        return axis
1072            .iter()
1073            .position(|g| g.is_grand_total)
1074            .or(if field_count == 0 && axis.len() == 1 {
1075                Some(0)
1076            } else {
1077                None
1078            })
1079            .ok_or_else(|| "#REF!".to_string());
1080    }
1081    let max_depth = criteria.iter().map(|(d, _)| *d).max().unwrap_or(0);
1082    let want_leaf = max_depth + 1 == field_count;
1083    let matches: Vec<usize> = axis
1084        .iter()
1085        .enumerate()
1086        .filter(|(_, group)| {
1087            if group.is_grand_total {
1088                return false;
1089            }
1090            if want_leaf {
1091                if group.is_subtotal {
1092                    return false;
1093                }
1094            } else {
1095                let own_depth = group.labels.iter().rposition(|l| l.is_some());
1096                if !(group.is_subtotal && own_depth == Some(max_depth)) {
1097                    return false;
1098                }
1099            }
1100            criteria.iter().all(|(depth, item)| {
1101                group
1102                    .labels
1103                    .get(*depth)
1104                    .and_then(|l| l.as_deref())
1105                    .map(|l| l.eq_ignore_ascii_case(item))
1106                    .unwrap_or(false)
1107            })
1108        })
1109        .map(|(i, _)| i)
1110        .collect();
1111    match matches.len() {
1112        1 => Ok(matches[0]),
1113        _ => Err("#REF!".to_string()),
1114    }
1115}
1116
1117/// Implements `GETPIVOTDATA`: extracts a single summarized value out of a
1118/// pivot table's computed grid by data-field name plus `(row/col field,
1119/// item)` criteria pairs, the same way real Excel's formula does when
1120/// pointed at a rendered pivot. Recomputes the grid fresh from `sheets`
1121/// rather than caching it, consistent with formulas re-evaluating from
1122/// current sheet state on every recalculation pass.
1123pub fn getpivotdata(
1124    sheets: &[&Sheet],
1125    pivot: &PivotTable,
1126    data_field: &str,
1127    criteria: &[(String, String)],
1128) -> Result<ResultData, String> {
1129    let grid = compute_pivot(sheets, pivot)?;
1130
1131    let value_labels = value_field_labels(&pivot.value_fields);
1132    let value_multiplier = if pivot.value_fields.len() > 1 {
1133        pivot.value_fields.len()
1134    } else {
1135        1
1136    };
1137    let value_field_idx = pivot
1138        .value_fields
1139        .iter()
1140        .position(|vf| vf.column.eq_ignore_ascii_case(data_field))
1141        .or_else(|| {
1142            value_labels
1143                .iter()
1144                .position(|l| l.eq_ignore_ascii_case(data_field))
1145        })
1146        .ok_or_else(|| "#VALUE!".to_string())?;
1147
1148    let mut row_criteria: Vec<(usize, &str)> = Vec::new();
1149    let mut col_criteria: Vec<(usize, &str)> = Vec::new();
1150    for (field, item) in criteria {
1151        if let Some(depth) = pivot
1152            .row_fields
1153            .iter()
1154            .position(|f| f.column.eq_ignore_ascii_case(field))
1155        {
1156            row_criteria.push((depth, item.as_str()));
1157        } else if let Some(depth) = pivot
1158            .col_fields
1159            .iter()
1160            .position(|f| f.column.eq_ignore_ascii_case(field))
1161        {
1162            col_criteria.push((depth, item.as_str()));
1163        } else {
1164            return Err("#REF!".to_string());
1165        }
1166    }
1167
1168    let row_idx = match_pivot_axis(&grid.row_axis, &row_criteria, pivot.row_fields.len())?;
1169    let col_idx = match_pivot_axis(&grid.col_axis, &col_criteria, pivot.col_fields.len())?;
1170
1171    let pos = col_idx * value_multiplier + value_field_idx;
1172    grid.body_rows
1173        .get(row_idx)
1174        .and_then(|r| r.values.get(pos))
1175        .cloned()
1176        .ok_or_else(|| "#REF!".to_string())
1177}
1178
1179pub(crate) fn sorted_distinct_strings(values: &[String], numeric: bool) -> Vec<String> {
1180    let mut pairs: Vec<(String, Vec<usize>)> = distinct_strings(values)
1181        .into_iter()
1182        .map(|s| (s, Vec::new()))
1183        .collect();
1184    sort_group_entries(&mut pairs, numeric);
1185    pairs.into_iter().map(|(s, _)| s).collect()
1186}
1187
1188pub(crate) fn distinct_strings(values: &[String]) -> Vec<String> {
1189    let mut seen: Vec<String> = Vec::new();
1190    for v in values {
1191        if !seen.iter().any(|s| s.eq_ignore_ascii_case(v)) {
1192            seen.push(v.clone());
1193        }
1194    }
1195    seen
1196}
1197
1198#[cfg(test)]
1199mod tests {
1200    use super::*;
1201    use crate::core::engine::SheetInit;
1202
1203    fn source_sheet() -> Sheet {
1204        let mut sheet = Sheet::new(SheetInit {
1205            name: Some("Data".to_string()),
1206            rows: 9,
1207            cols: 4,
1208            ..Default::default()
1209        });
1210        let header = ["Region", "Product", "Rep", "Amount"];
1211        for (c, h) in header.iter().enumerate() {
1212            sheet.set_cell_src(0, c, h.to_string());
1213        }
1214        let rows: [[&str; 4]; 8] = [
1215            ["East", "Widget", "Alice", "10"],
1216            ["East", "Widget", "Bob", "20"],
1217            ["East", "Gadget", "Alice", "5"],
1218            ["West", "Widget", "Carol", "30"],
1219            ["West", "Gadget", "Carol", "40"],
1220            ["West", "Gadget", "Dave", "50"],
1221            ["East", "Gadget", "Bob", "15"],
1222            ["West", "Widget", "Dave", "25"],
1223        ];
1224        for (r, row) in rows.iter().enumerate() {
1225            for (c, v) in row.iter().enumerate() {
1226                sheet.set_cell_src(r + 1, c, v.to_string());
1227            }
1228        }
1229        sheet.commit(None).unwrap();
1230        sheet
1231            .add_table("Sales".to_string(), 0, 0, 8, 3, true, false)
1232            .unwrap();
1233        sheet
1234    }
1235
1236    fn base_pivot() -> PivotTable {
1237        PivotTable {
1238            id: 1,
1239            name: "Pivot1".to_string(),
1240            source: PivotSource::Table {
1241                name: "Sales".to_string(),
1242            },
1243            dest_sheet_id: 0,
1244            dest_row: 0,
1245            dest_col: 0,
1246            row_fields: vec![PivotField::new("Region")],
1247            col_fields: vec![],
1248            value_fields: vec![PivotValueField::new("Amount", PivotAggregation::Sum)],
1249            filter_fields: vec![],
1250            grand_totals_row: true,
1251            grand_totals_col: true,
1252            last_output_end_row: None,
1253            last_output_end_col: None,
1254        }
1255    }
1256
1257    fn value_at(row: &PivotBodyRow, col: usize) -> f64 {
1258        match &row.values[col] {
1259            ResultData::Float(f) => *f,
1260            ResultData::Integer(i) => *i as f64,
1261            other => panic!("expected numeric, got {:?}", other),
1262        }
1263    }
1264
1265    #[test]
1266    fn test_single_row_field_sum_with_grand_total() {
1267        let sheet = source_sheet();
1268        let pivot = base_pivot();
1269        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1270
1271        assert_eq!(grid.body_rows.len(), 3);
1272        assert_eq!(grid.body_rows[0].row_labels[0], "East");
1273        assert_eq!(value_at(&grid.body_rows[0], 0), 50.0);
1274        assert_eq!(grid.body_rows[1].row_labels[0], "West");
1275        assert_eq!(value_at(&grid.body_rows[1], 0), 145.0);
1276        assert!(grid.body_rows[2].is_grand_total);
1277        assert_eq!(grid.body_rows[2].row_labels[0], "Grand Total");
1278        assert_eq!(value_at(&grid.body_rows[2], 0), 195.0);
1279    }
1280
1281    #[test]
1282    fn test_getpivotdata_matches_a_row_group() {
1283        let sheet = source_sheet();
1284        let pivot = base_pivot();
1285        let result = getpivotdata(
1286            &[&sheet],
1287            &pivot,
1288            "Amount",
1289            &[("Region".to_string(), "East".to_string())],
1290        )
1291        .unwrap();
1292        assert!(matches!(result, ResultData::Float(f) if f == 50.0));
1293    }
1294
1295    #[test]
1296    fn test_getpivotdata_empty_criteria_matches_grand_total() {
1297        let sheet = source_sheet();
1298        let pivot = base_pivot();
1299        let result = getpivotdata(&[&sheet], &pivot, "Amount", &[]).unwrap();
1300        assert!(matches!(result, ResultData::Float(f) if f == 195.0));
1301    }
1302
1303    #[test]
1304    fn test_getpivotdata_partial_criteria_matches_subtotal() {
1305        let sheet = source_sheet();
1306        let mut pivot = base_pivot();
1307        pivot.row_fields = vec![PivotField::new("Region"), PivotField::new("Product")];
1308        let result = getpivotdata(
1309            &[&sheet],
1310            &pivot,
1311            "Amount",
1312            &[("Region".to_string(), "East".to_string())],
1313        )
1314        .unwrap();
1315        assert!(matches!(result, ResultData::Float(f) if f == 50.0));
1316    }
1317
1318    #[test]
1319    fn test_getpivotdata_full_path_matches_leaf() {
1320        let sheet = source_sheet();
1321        let mut pivot = base_pivot();
1322        pivot.row_fields = vec![PivotField::new("Region"), PivotField::new("Product")];
1323        let result = getpivotdata(
1324            &[&sheet],
1325            &pivot,
1326            "Amount",
1327            &[
1328                ("Region".to_string(), "East".to_string()),
1329                ("Product".to_string(), "Widget".to_string()),
1330            ],
1331        )
1332        .unwrap();
1333        assert!(matches!(result, ResultData::Float(f) if f == 30.0));
1334    }
1335
1336    #[test]
1337    fn test_getpivotdata_unknown_field_is_ref_error() {
1338        let sheet = source_sheet();
1339        let pivot = base_pivot();
1340        let err = getpivotdata(
1341            &[&sheet],
1342            &pivot,
1343            "Amount",
1344            &[("NotAField".to_string(), "East".to_string())],
1345        )
1346        .unwrap_err();
1347        assert_eq!(err, "#REF!");
1348    }
1349
1350    #[test]
1351    fn test_getpivotdata_unknown_item_is_ref_error() {
1352        let sheet = source_sheet();
1353        let pivot = base_pivot();
1354        let err = getpivotdata(
1355            &[&sheet],
1356            &pivot,
1357            "Amount",
1358            &[("Region".to_string(), "North".to_string())],
1359        )
1360        .unwrap_err();
1361        assert_eq!(err, "#REF!");
1362    }
1363
1364    #[test]
1365    fn test_getpivotdata_unknown_data_field_is_value_error() {
1366        let sheet = source_sheet();
1367        let pivot = base_pivot();
1368        let err = getpivotdata(
1369            &[&sheet],
1370            &pivot,
1371            "NotAField",
1372            &[("Region".to_string(), "East".to_string())],
1373        )
1374        .unwrap_err();
1375        assert_eq!(err, "#VALUE!");
1376    }
1377
1378    #[test]
1379    fn test_row_and_col_fields_with_subtotals() {
1380        let sheet = source_sheet();
1381        let mut pivot = base_pivot();
1382        pivot.row_fields = vec![PivotField::new("Region"), PivotField::new("Product")];
1383        pivot.col_fields = vec![PivotField::new("Rep")];
1384        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1385
1386        let subtotal_rows: Vec<&PivotBodyRow> = grid
1387            .body_rows
1388            .iter()
1389            .filter(|r| r.row_labels[0].ends_with("Total") && !r.is_grand_total)
1390            .collect();
1391        assert_eq!(subtotal_rows.len(), 2);
1392        assert!(grid.body_rows.last().unwrap().is_grand_total);
1393    }
1394
1395    #[test]
1396    fn test_nested_row_field_second_level_labels_are_not_lost() {
1397        let sheet = source_sheet();
1398        let mut pivot = base_pivot();
1399        pivot.row_fields = vec![PivotField::new("Region"), PivotField::new("Product")];
1400        pivot.grand_totals_row = false;
1401        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1402
1403        let leaf_rows: Vec<&PivotBodyRow> = grid
1404            .body_rows
1405            .iter()
1406            .filter(|r| !r.row_labels[0].ends_with("Total") && !r.is_grand_total)
1407            .collect();
1408        assert_eq!(leaf_rows.len(), 4);
1409        for row in &leaf_rows {
1410            assert!(
1411                !row.row_labels[1].is_empty(),
1412                "expected a Product label on leaf row {:?}, got blank",
1413                row.row_labels
1414            );
1415        }
1416        let products: Vec<&str> = leaf_rows.iter().map(|r| r.row_labels[1].as_str()).collect();
1417        assert!(products.contains(&"Widget"));
1418        assert!(products.contains(&"Gadget"));
1419    }
1420
1421    #[test]
1422    fn test_count_aggregation() {
1423        let sheet = source_sheet();
1424        let mut pivot = base_pivot();
1425        pivot.value_fields = vec![PivotValueField::new("Rep", PivotAggregation::Count)];
1426        pivot.grand_totals_row = false;
1427        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1428        assert_eq!(grid.body_rows.len(), 2);
1429        for row in &grid.body_rows {
1430            assert_eq!(value_at(row, 0), 4.0);
1431        }
1432    }
1433
1434    #[test]
1435    fn test_filter_field_restricts_records() {
1436        let sheet = source_sheet();
1437        let mut pivot = base_pivot();
1438        pivot.filter_fields = vec![PivotFilterField {
1439            column: "Product".to_string(),
1440            selected_values: Some(vec!["Widget".to_string()]),
1441            multiple_selection: true,
1442        }];
1443        pivot.grand_totals_row = false;
1444        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1445        assert_eq!(grid.body_rows.len(), 2);
1446        assert_eq!(value_at(&grid.body_rows[0], 0), 30.0);
1447        assert_eq!(value_at(&grid.body_rows[1], 0), 55.0);
1448    }
1449
1450    #[test]
1451    fn test_filter_field_selection_matches_case_insensitively() {
1452        let mut sheet = Sheet::new(SheetInit {
1453            name: Some("Data".to_string()),
1454            rows: 4,
1455            cols: 2,
1456            ..Default::default()
1457        });
1458        for (c, h) in ["Mixed", "Amount"].iter().enumerate() {
1459            sheet.set_cell_src(0, c, h.to_string());
1460        }
1461        let rows: [[&str; 2]; 3] = [["East", "10"], ["east", "20"], ["West", "30"]];
1462        for (r, row) in rows.iter().enumerate() {
1463            for (c, v) in row.iter().enumerate() {
1464                sheet.set_cell_src(r + 1, c, v.to_string());
1465            }
1466        }
1467        sheet.commit(None).unwrap();
1468        sheet
1469            .add_table("Sales".to_string(), 0, 0, 3, 1, true, false)
1470            .unwrap();
1471
1472        let mut pivot = base_pivot();
1473        pivot.source = PivotSource::Table {
1474            name: "Sales".to_string(),
1475        };
1476        pivot.row_fields = vec![];
1477        pivot.value_fields = vec![PivotValueField::new("Amount", PivotAggregation::Sum)];
1478        pivot.filter_fields = vec![PivotFilterField {
1479            column: "Mixed".to_string(),
1480            selected_values: Some(vec!["east".to_string()]),
1481            multiple_selection: true,
1482        }];
1483        pivot.grand_totals_row = false;
1484        pivot.grand_totals_col = false;
1485        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1486        assert_eq!(value_at(&grid.body_rows[0], 0), 30.0);
1487    }
1488
1489    #[test]
1490    fn test_no_filter_fields_means_no_reserved_rows() {
1491        let sheet = source_sheet();
1492        let pivot = base_pivot();
1493        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1494        assert!(grid.filter_rows.is_empty());
1495        assert_eq!(grid.grid_row_offset(), 0);
1496        assert_eq!(grid.height(), grid.header_rows.len() + grid.body_rows.len());
1497    }
1498
1499    #[test]
1500    fn test_filter_field_state_label_all_vs_multiple_items() {
1501        let sheet = source_sheet();
1502        let mut pivot = base_pivot();
1503        pivot.filter_fields = vec![PivotFilterField {
1504            column: "Product".to_string(),
1505            selected_values: None,
1506            multiple_selection: true,
1507        }];
1508
1509        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1510        assert_eq!(
1511            grid.filter_rows,
1512            vec![("Product".to_string(), "(All)".to_string())]
1513        );
1514        assert_eq!(grid.grid_row_offset(), 2);
1515
1516        pivot.filter_fields[0].selected_values =
1517            Some(vec!["Widget".to_string(), "Gadget".to_string()]);
1518        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1519        assert_eq!(grid.filter_rows[0].1, "(All)");
1520
1521        pivot.filter_fields[0].selected_values = Some(vec!["Widget".to_string()]);
1522        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1523        assert_eq!(grid.filter_rows[0].1, "(Multiple Items)");
1524
1525        pivot.filter_fields[0].multiple_selection = false;
1526        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1527        assert_eq!(grid.filter_rows[0].1, "Widget");
1528    }
1529
1530    #[test]
1531    fn test_col_axis_subtotal_group_gets_total_caption_and_grand_total_stays_outermost() {
1532        let sheet = source_sheet();
1533        let mut pivot = base_pivot();
1534        pivot.row_fields = vec![PivotField::new("Rep")];
1535        pivot.col_fields = vec![PivotField::new("Region"), PivotField::new("Product")];
1536        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1537
1538        let region_row = &grid.header_rows[1];
1539        assert!(region_row.contains(&"East Total".to_string()));
1540        assert!(region_row.contains(&"West Total".to_string()));
1541        assert!(region_row.contains(&"Grand Total".to_string()));
1542        let product_row = &grid.header_rows[2];
1543        assert_eq!(product_row.last().unwrap(), "");
1544    }
1545
1546    #[test]
1547    fn test_col_axis_subtotal_caption_uses_value_field_label_with_multiple_value_fields() {
1548        let sheet = source_sheet();
1549        let mut pivot = base_pivot();
1550        pivot.row_fields = vec![];
1551        pivot.col_fields = vec![PivotField::new("Region"), PivotField::new("Product")];
1552        pivot.value_fields = vec![
1553            PivotValueField::new("Amount", PivotAggregation::Min),
1554            PivotValueField::new("Amount", PivotAggregation::Sum),
1555        ];
1556        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1557
1558        let region_row = &grid.header_rows[1];
1559        assert!(region_row.contains(&"East Min of Amount".to_string()));
1560        assert!(region_row.contains(&"East Sum of Amount".to_string()));
1561        assert!(region_row.contains(&"West Min of Amount".to_string()));
1562        assert!(region_row.contains(&"West Sum of Amount".to_string()));
1563        assert!(
1564            !region_row
1565                .iter()
1566                .any(|c| c == "East Total" || c == "West Total")
1567        );
1568
1569        let value_label_row = grid.header_rows.last().unwrap();
1570        assert!(value_label_row.contains(&"Min of Amount".to_string()));
1571        assert!(value_label_row.contains(&"Sum of Amount".to_string()));
1572        let east_subtotal_idx = region_row
1573            .iter()
1574            .position(|c| c == "East Min of Amount")
1575            .unwrap();
1576        assert_eq!(value_label_row[east_subtotal_idx], "");
1577        assert_eq!(value_label_row[east_subtotal_idx + 1], "");
1578    }
1579
1580    #[test]
1581    fn test_col_axis_repeated_leaf_value_under_different_parents_is_not_falsely_merged() {
1582        let mut sheet = Sheet::new(SheetInit {
1583            name: Some("Data".to_string()),
1584            rows: 3,
1585            cols: 3,
1586            ..Default::default()
1587        });
1588        for (c, h) in ["Group", "Sub", "Amount"].iter().enumerate() {
1589            sheet.set_cell_src(0, c, h.to_string());
1590        }
1591        let rows: [[&str; 3]; 2] = [["GroupA", "X", "1"], ["GroupB", "X", "2"]];
1592        for (r, row) in rows.iter().enumerate() {
1593            for (c, v) in row.iter().enumerate() {
1594                sheet.set_cell_src(r + 1, c, v.to_string());
1595            }
1596        }
1597        sheet.commit(None).unwrap();
1598        sheet
1599            .add_table("Sales".to_string(), 0, 0, 2, 2, true, false)
1600            .unwrap();
1601
1602        let mut pivot = base_pivot();
1603        pivot.row_fields = vec![];
1604        pivot.col_fields = vec![PivotField::new("Group"), PivotField::new("Sub")];
1605        pivot.grand_totals_col = false;
1606        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1607
1608        let sub_row = &grid.header_rows[2];
1609        let x_count = sub_row.iter().filter(|c| *c == "X").count();
1610        assert_eq!(
1611            x_count, 2,
1612            "expected \"X\" under both GroupA and GroupB, got {sub_row:?}"
1613        );
1614    }
1615
1616    #[test]
1617    fn test_multiple_value_fields_become_column_labels() {
1618        let sheet = source_sheet();
1619        let mut pivot = base_pivot();
1620        pivot.value_fields = vec![
1621            PivotValueField::new("Amount", PivotAggregation::Sum),
1622            PivotValueField::new("Amount", PivotAggregation::Count),
1623        ];
1624        pivot.grand_totals_row = false;
1625        pivot.grand_totals_col = false;
1626        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1627        assert_eq!(grid.header_rows.last().unwrap()[1], "Sum of Amount");
1628        assert_eq!(grid.header_rows.last().unwrap()[2], "Count of Amount2");
1629        assert_eq!(grid.body_rows[0].values.len(), 2);
1630        assert_eq!(value_at(&grid.body_rows[0], 0), 50.0);
1631        assert_eq!(value_at(&grid.body_rows[0], 1), 4.0);
1632    }
1633
1634    #[test]
1635    fn test_row_labels_caption_replaces_outermost_row_field_name() {
1636        let sheet = source_sheet();
1637        let pivot = base_pivot();
1638        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1639        assert_eq!(grid.header_rows.last().unwrap()[0], "Row Labels");
1640    }
1641
1642    #[test]
1643    fn test_column_labels_row_prepended_and_deeper_row_field_keeps_its_name() {
1644        let sheet = source_sheet();
1645        let mut pivot = base_pivot();
1646        pivot.row_fields = vec![PivotField::new("Region"), PivotField::new("Product")];
1647        pivot.col_fields = vec![PivotField::new("Rep")];
1648        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1649
1650        assert!(grid.header_rows[0].iter().any(|c| c == "Column Labels"));
1651        let last = grid.header_rows.last().unwrap();
1652        assert_eq!(last[0], "Row Labels");
1653        assert_eq!(last[1], "Product");
1654    }
1655
1656    #[test]
1657    fn test_grand_total_column_shows_total_prefixed_value_label_with_multiple_value_fields() {
1658        let sheet = source_sheet();
1659        let mut pivot = base_pivot();
1660        pivot.col_fields = vec![PivotField::new("Product")];
1661        pivot.value_fields = vec![
1662            PivotValueField::new("Amount", PivotAggregation::Sum),
1663            PivotValueField::new("Amount", PivotAggregation::Min),
1664        ];
1665        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1666
1667        let col_values_row = &grid.header_rows[1];
1668        assert!(col_values_row.contains(&"Total Sum of Amount".to_string()));
1669        assert!(col_values_row.contains(&"Total Min of Amount2".to_string()));
1670        assert_eq!(grid.header_rows.last().unwrap().last().unwrap(), "");
1671    }
1672
1673    #[test]
1674    fn test_grand_total_still_shows_with_only_one_leaf_group() {
1675        let sheet = source_sheet();
1676        let mut pivot = base_pivot();
1677        pivot.filter_fields = vec![PivotFilterField {
1678            column: "Region".to_string(),
1679            selected_values: Some(vec!["East".to_string()]),
1680            multiple_selection: true,
1681        }];
1682        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1683        assert!(grid.body_rows.iter().any(|r| r.is_grand_total));
1684    }
1685
1686    #[test]
1687    fn test_case_variant_values_merge_using_globally_first_seen_casing() {
1688        let mut sheet = Sheet::new(SheetInit {
1689            name: Some("Data".to_string()),
1690            rows: 5,
1691            cols: 3,
1692            ..Default::default()
1693        });
1694        for (c, h) in ["Group", "Mixed", "Amount"].iter().enumerate() {
1695            sheet.set_cell_src(0, c, h.to_string());
1696        }
1697        let rows: [[&str; 3]; 3] = [
1698            ["G1", "EAST", "10"],
1699            ["G1", "West", "20"],
1700            ["G2", "east", "30"],
1701        ];
1702        for (r, row) in rows.iter().enumerate() {
1703            for (c, v) in row.iter().enumerate() {
1704                sheet.set_cell_src(r + 1, c, v.to_string());
1705            }
1706        }
1707        sheet.commit(None).unwrap();
1708        sheet
1709            .add_table("Sales".to_string(), 0, 0, 3, 2, true, false)
1710            .unwrap();
1711
1712        let mut pivot = base_pivot();
1713        pivot.row_fields = vec![PivotField::new("Group"), PivotField::new("Mixed")];
1714        pivot.grand_totals_row = false;
1715        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1716
1717        let mixed_labels: Vec<&str> = grid
1718            .body_rows
1719            .iter()
1720            .map(|r| r.row_labels[1].as_str())
1721            .filter(|l| !l.is_empty())
1722            .collect();
1723        assert!(
1724            mixed_labels.contains(&"EAST") && !mixed_labels.contains(&"east"),
1725            "expected every occurrence to use the globally first-seen casing \"EAST\", got {mixed_labels:?}"
1726        );
1727    }
1728
1729    #[test]
1730    fn test_case_canonicalization_uses_first_seen_casing_from_unfiltered_source_not_just_surviving_rows()
1731     {
1732        let mut sheet = Sheet::new(SheetInit {
1733            name: Some("Data".to_string()),
1734            rows: 4,
1735            cols: 3,
1736            ..Default::default()
1737        });
1738        for (c, h) in ["Cat", "Mixed", "Amount"].iter().enumerate() {
1739            sheet.set_cell_src(0, c, h.to_string());
1740        }
1741        let rows: [[&str; 3]; 3] = [
1742            ["Alpha", "WEST", "10"],
1743            ["Beta", "East", "20"],
1744            ["Beta", "west", "30"],
1745        ];
1746        for (r, row) in rows.iter().enumerate() {
1747            for (c, v) in row.iter().enumerate() {
1748                sheet.set_cell_src(r + 1, c, v.to_string());
1749            }
1750        }
1751        sheet.commit(None).unwrap();
1752        sheet
1753            .add_table("Sales".to_string(), 0, 0, 3, 2, true, false)
1754            .unwrap();
1755
1756        let mut pivot = base_pivot();
1757        pivot.row_fields = vec![PivotField::new("Mixed")];
1758        pivot.filter_fields = vec![PivotFilterField {
1759            column: "Cat".to_string(),
1760            selected_values: Some(vec!["Beta".to_string()]),
1761            multiple_selection: true,
1762        }];
1763        pivot.grand_totals_row = false;
1764        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1765
1766        let labels: Vec<&str> = grid
1767            .body_rows
1768            .iter()
1769            .map(|r| r.row_labels[0].as_str())
1770            .collect();
1771        assert!(
1772            labels.contains(&"WEST") && !labels.contains(&"west"),
1773            "expected the filtered-out row's casing \"WEST\" to still win, got {labels:?}"
1774        );
1775    }
1776
1777    #[test]
1778    fn test_blank_group_sorts_last_even_among_numeric_siblings() {
1779        let mut sheet = Sheet::new(SheetInit {
1780            name: Some("Data".to_string()),
1781            rows: 4,
1782            cols: 2,
1783            ..Default::default()
1784        });
1785        for (c, h) in ["Code", "Amount"].iter().enumerate() {
1786            sheet.set_cell_src(0, c, h.to_string());
1787        }
1788        sheet.set_cell_src(1, 0, "30".to_string());
1789        sheet.set_cell_src(1, 1, "1".to_string());
1790        sheet.set_cell_src(3, 0, "10".to_string());
1791        sheet.set_cell_src(3, 1, "3".to_string());
1792        sheet.commit(None).unwrap();
1793        sheet
1794            .add_table("Sales".to_string(), 0, 0, 3, 1, true, false)
1795            .unwrap();
1796
1797        let mut pivot = base_pivot();
1798        pivot.row_fields = vec![PivotField::new("Code")];
1799        pivot.grand_totals_row = false;
1800        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1801
1802        let codes: Vec<&str> = grid
1803            .body_rows
1804            .iter()
1805            .map(|r| r.row_labels[0].as_str())
1806            .collect();
1807        assert_eq!(codes, vec!["10", "30", "(blank)"]);
1808    }
1809
1810    #[test]
1811    fn test_negative_looking_text_sorts_last_among_text_siblings() {
1812        let mut sheet = Sheet::new(SheetInit {
1813            name: Some("Data".to_string()),
1814            rows: 6,
1815            cols: 2,
1816            ..Default::default()
1817        });
1818        for (c, h) in ["Code", "Amount"].iter().enumerate() {
1819            sheet.set_cell_src(0, c, h.to_string());
1820        }
1821        let rows: [(&str, &str); 5] = [
1822            ("\"-7\"", "1"),
1823            ("\".0152\"", "2"),
1824            ("\"13\"", "3"),
1825            ("\"34\"", "4"),
1826            ("\"4\"", "5"),
1827        ];
1828        for (r, (code, amount)) in rows.iter().enumerate() {
1829            sheet.set_cell_src(r + 1, 0, code.to_string());
1830            sheet.set_cell_src(r + 1, 1, amount.to_string());
1831        }
1832        sheet.commit(None).unwrap();
1833        sheet
1834            .add_table("Sales".to_string(), 0, 0, 5, 1, true, false)
1835            .unwrap();
1836
1837        let mut pivot = base_pivot();
1838        pivot.row_fields = vec![PivotField::new("Code")];
1839        pivot.grand_totals_row = false;
1840        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1841
1842        let codes: Vec<&str> = grid
1843            .body_rows
1844            .iter()
1845            .map(|r| r.row_labels[0].as_str())
1846            .collect();
1847        assert_eq!(codes, vec![".0152", "13", "34", "4", "-7"]);
1848    }
1849
1850    #[test]
1851    fn test_negative_looking_text_sorts_by_stripped_digits_not_last() {
1852        let mut sheet = Sheet::new(SheetInit {
1853            name: Some("Data".to_string()),
1854            rows: 4,
1855            cols: 2,
1856            ..Default::default()
1857        });
1858        for (c, h) in ["Code", "Amount"].iter().enumerate() {
1859            sheet.set_cell_src(0, c, h.to_string());
1860        }
1861        let rows: [(&str, &str); 3] = [("\"12\"", "1"), ("\"37\"", "2"), ("\"-25\"", "3")];
1862        for (r, (code, amount)) in rows.iter().enumerate() {
1863            sheet.set_cell_src(r + 1, 0, code.to_string());
1864            sheet.set_cell_src(r + 1, 1, amount.to_string());
1865        }
1866        sheet.commit(None).unwrap();
1867        sheet
1868            .add_table("Sales".to_string(), 0, 0, 3, 1, true, false)
1869            .unwrap();
1870
1871        let mut pivot = base_pivot();
1872        pivot.row_fields = vec![PivotField::new("Code")];
1873        pivot.grand_totals_row = false;
1874        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1875
1876        let codes: Vec<&str> = grid
1877            .body_rows
1878            .iter()
1879            .map(|r| r.row_labels[0].as_str())
1880            .collect();
1881        assert_eq!(codes, vec!["12", "-25", "37"]);
1882    }
1883
1884    #[test]
1885    fn test_empty_row_col_intersection_renders_blank_not_zero_or_error() {
1886        let mut sheet = Sheet::new(SheetInit {
1887            name: Some("Data".to_string()),
1888            rows: 3,
1889            cols: 3,
1890            ..Default::default()
1891        });
1892        for (c, h) in ["Region", "Product", "Amount"].iter().enumerate() {
1893            sheet.set_cell_src(0, c, h.to_string());
1894        }
1895        let rows: [[&str; 3]; 2] = [["East", "Widget", "10"], ["West", "Gadget", "20"]];
1896        for (r, row) in rows.iter().enumerate() {
1897            for (c, v) in row.iter().enumerate() {
1898                sheet.set_cell_src(r + 1, c, v.to_string());
1899            }
1900        }
1901        sheet.commit(None).unwrap();
1902        sheet
1903            .add_table("Sales".to_string(), 0, 0, 2, 2, true, false)
1904            .unwrap();
1905
1906        let mut pivot = base_pivot();
1907        pivot.col_fields = vec![PivotField::new("Product")];
1908        pivot.value_fields = vec![
1909            PivotValueField::new("Amount", PivotAggregation::Sum),
1910            PivotValueField::new("Amount", PivotAggregation::Average),
1911        ];
1912        pivot.grand_totals_row = false;
1913        pivot.grand_totals_col = false;
1914        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1915
1916        let east_row = grid
1917            .body_rows
1918            .iter()
1919            .find(|r| r.row_labels[0] == "East")
1920            .unwrap();
1921        for v in &east_row.values[..2] {
1922            assert!(
1923                matches!(v, ResultData::None),
1924                "expected blank for an empty intersection, got {v:?}"
1925            );
1926        }
1927    }
1928
1929    #[test]
1930    fn test_value_field_labels_distinct_aggregations_without_sum_stay_unsuffixed() {
1931        let fields = vec![
1932            PivotValueField::new("Amount", PivotAggregation::Count),
1933            PivotValueField::new("Amount", PivotAggregation::Max),
1934        ];
1935        assert_eq!(
1936            value_field_labels(&fields),
1937            vec!["Count of Amount".to_string(), "Max of Amount".to_string()]
1938        );
1939    }
1940
1941    #[test]
1942    fn test_value_field_labels_sum_clones_column_for_later_fields() {
1943        let fields = vec![
1944            PivotValueField::new("Amount", PivotAggregation::Sum),
1945            PivotValueField::new("Rate", PivotAggregation::Average),
1946            PivotValueField::new("Amount", PivotAggregation::Min),
1947            PivotValueField::new("Amount", PivotAggregation::Max),
1948        ];
1949        assert_eq!(
1950            value_field_labels(&fields),
1951            vec![
1952                "Sum of Amount".to_string(),
1953                "Average of Rate".to_string(),
1954                "Min of Amount2".to_string(),
1955                "Max of Amount2".to_string(),
1956            ]
1957        );
1958    }
1959
1960    #[test]
1961    fn test_value_field_labels_second_sum_clones_again() {
1962        let fields = vec![
1963            PivotValueField::new("Amount", PivotAggregation::Sum),
1964            PivotValueField::new("Amount", PivotAggregation::Sum),
1965            PivotValueField::new("Amount", PivotAggregation::Count),
1966        ];
1967        assert_eq!(
1968            value_field_labels(&fields),
1969            vec![
1970                "Sum of Amount".to_string(),
1971                "Sum of Amount2".to_string(),
1972                "Count of Amount3".to_string(),
1973            ]
1974        );
1975    }
1976
1977    #[test]
1978    fn test_value_field_labels_disambiguates_identical_aggregation_and_column() {
1979        let fields = vec![
1980            PivotValueField::new("Amount", PivotAggregation::Sum),
1981            PivotValueField::new("Amount", PivotAggregation::Sum),
1982            PivotValueField::new("Amount", PivotAggregation::Sum),
1983        ];
1984        assert_eq!(
1985            value_field_labels(&fields),
1986            vec![
1987                "Sum of Amount".to_string(),
1988                "Sum of Amount2".to_string(),
1989                "Sum of Amount3".to_string(),
1990            ]
1991        );
1992    }
1993
1994    #[test]
1995    fn test_value_field_labels_collision_within_sum_clone_uses_underscore_suffix() {
1996        let fields = vec![
1997            PivotValueField::new("Amount", PivotAggregation::Sum),
1998            PivotValueField::new("Amount", PivotAggregation::Max),
1999            PivotValueField::new("Amount", PivotAggregation::Max),
2000        ];
2001        assert_eq!(
2002            value_field_labels(&fields),
2003            vec![
2004                "Sum of Amount".to_string(),
2005                "Max of Amount2".to_string(),
2006                "Max of Amount2_2".to_string(),
2007            ]
2008        );
2009    }
2010
2011    #[test]
2012    fn test_value_field_labels_count_numbers_shares_plain_count_caption() {
2013        let fields = vec![
2014            PivotValueField::new("Rate", PivotAggregation::CountNumbers),
2015            PivotValueField::new("Rate", PivotAggregation::Count),
2016        ];
2017        assert_eq!(
2018            value_field_labels(&fields),
2019            vec!["Count of Rate".to_string(), "Count of Rate2".to_string()]
2020        );
2021    }
2022
2023    #[test]
2024    fn test_value_field_labels_leaves_custom_name_untouched() {
2025        let mut fields = vec![
2026            PivotValueField::new("Amount", PivotAggregation::Sum),
2027            PivotValueField::new("Amount", PivotAggregation::Min),
2028        ];
2029        fields[1].custom_name = Some("Lowest Amount".to_string());
2030        assert_eq!(
2031            value_field_labels(&fields),
2032            vec!["Sum of Amount".to_string(), "Lowest Amount".to_string()]
2033        );
2034    }
2035
2036    #[test]
2037    fn test_flat_pivot_with_no_row_or_col_fields_has_no_reserved_label_column() {
2038        let sheet = source_sheet();
2039        let mut pivot = base_pivot();
2040        pivot.row_fields = vec![];
2041        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
2042
2043        assert_eq!(grid.width, 1);
2044        assert_eq!(
2045            grid.header_rows.last().unwrap(),
2046            &vec!["Sum of Amount".to_string()]
2047        );
2048        assert_eq!(grid.body_rows.len(), 1);
2049        assert!(grid.body_rows[0].row_labels.is_empty());
2050        assert_eq!(value_at(&grid.body_rows[0], 0), 195.0);
2051    }
2052
2053    #[test]
2054    fn test_no_row_fields_with_multiple_value_fields_has_no_reserved_label_column_either() {
2055        let sheet = source_sheet();
2056        let mut pivot = base_pivot();
2057        pivot.row_fields = vec![];
2058        pivot.col_fields = vec![PivotField::new("Product")];
2059        pivot.value_fields = vec![
2060            PivotValueField::new("Amount", PivotAggregation::Sum),
2061            PivotValueField::new("Amount", PivotAggregation::Count),
2062        ];
2063        pivot.grand_totals_col = false;
2064        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
2065
2066        assert_eq!(grid.width, 4);
2067        assert_eq!(grid.body_rows.len(), 1);
2068        assert!(grid.body_rows[0].row_labels.is_empty());
2069    }
2070
2071    #[test]
2072    fn test_multiple_value_fields_with_no_column_fields_share_one_header_row() {
2073        let sheet = source_sheet();
2074        let mut pivot = base_pivot();
2075        pivot.value_fields = vec![
2076            PivotValueField::new("Amount", PivotAggregation::Sum),
2077            PivotValueField::new("Amount", PivotAggregation::Count),
2078        ];
2079        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
2080
2081        assert_eq!(grid.header_rows.len(), 1);
2082        assert_eq!(
2083            grid.header_rows[0],
2084            vec![
2085                "Row Labels".to_string(),
2086                "Sum of Amount".to_string(),
2087                "Count of Amount2".to_string(),
2088            ]
2089        );
2090    }
2091
2092    #[test]
2093    fn test_missing_column_errors() {
2094        let sheet = source_sheet();
2095        let mut pivot = base_pivot();
2096        pivot.row_fields = vec![PivotField::new("Nope")];
2097        let err = compute_pivot(&[&sheet], &pivot).unwrap_err();
2098        assert!(err.contains("not found"));
2099    }
2100
2101    #[test]
2102    fn test_range_source_matches_table_source() {
2103        let sheet = source_sheet();
2104        let mut pivot = base_pivot();
2105        pivot.source = PivotSource::Range {
2106            sheet_id: sheet.id,
2107            start_row: 0,
2108            start_col: 0,
2109            end_row: 8,
2110            end_col: 3,
2111        };
2112        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
2113        assert_eq!(grid.body_rows.len(), 3);
2114        assert_eq!(value_at(&grid.body_rows[0], 0), 50.0);
2115        assert_eq!(value_at(&grid.body_rows[1], 0), 145.0);
2116        assert_eq!(value_at(&grid.body_rows[2], 0), 195.0);
2117    }
2118
2119    #[test]
2120    fn test_zero_data_rows_produces_empty_grid_without_panicking() {
2121        let mut sheet = Sheet::new(SheetInit {
2122            name: Some("Empty".to_string()),
2123            rows: 1,
2124            cols: 2,
2125            ..Default::default()
2126        });
2127        sheet.set_cell_src(0, 0, "Region".to_string());
2128        sheet.set_cell_src(0, 1, "Amount".to_string());
2129        sheet.commit(None).unwrap();
2130        sheet
2131            .add_table("Empty".to_string(), 0, 0, 0, 1, true, false)
2132            .unwrap();
2133
2134        let pivot = PivotTable {
2135            id: 1,
2136            name: "EmptyPivot".to_string(),
2137            source: PivotSource::Table {
2138                name: "Empty".to_string(),
2139            },
2140            dest_sheet_id: sheet.id,
2141            dest_row: 0,
2142            dest_col: 0,
2143            row_fields: vec![PivotField::new("Region")],
2144            col_fields: vec![],
2145            value_fields: vec![PivotValueField::new("Amount", PivotAggregation::Sum)],
2146            filter_fields: vec![],
2147            grand_totals_row: true,
2148            grand_totals_col: true,
2149            last_output_end_row: None,
2150            last_output_end_col: None,
2151        };
2152        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
2153        assert!(grid.body_rows.is_empty());
2154        assert!(grid.row_axis.is_empty());
2155    }
2156
2157    use rand::rngs::StdRng;
2158    use rand::{Rng, SeedableRng};
2159
2160    const FUZZ_COLS: [&str; 6] = ["Cat", "Mixed", "NumStr", "Amount", "Rate", "Flag"];
2161    const FUZZ_CATEGORIES: [&str; 5] = ["Alpha", "Beta", "Gamma", "Delta", "Epsilon"];
2162    const FUZZ_CASE_VARIANTS: [&str; 5] = ["East", "east", "WEST", "west", "North"];
2163
2164    fn fuzz_source_sheet(rng: &mut StdRng, num_rows: usize) -> (Sheet, Vec<String>) {
2165        let mut sheet = Sheet::new(SheetInit {
2166            name: Some("FuzzData".to_string()),
2167            rows: num_rows + 1,
2168            cols: FUZZ_COLS.len(),
2169            ..Default::default()
2170        });
2171        for (c, h) in FUZZ_COLS.iter().enumerate() {
2172            sheet.set_cell_src(0, c, h.to_string());
2173        }
2174        for r in 0..num_rows {
2175            let cat = if rng.gen_bool(0.1) {
2176                String::new()
2177            } else {
2178                FUZZ_CATEGORIES[rng.gen_range(0..FUZZ_CATEGORIES.len())].to_string()
2179            };
2180            sheet.set_cell_src(r + 1, 0, cat);
2181
2182            let mixed = FUZZ_CASE_VARIANTS[rng.gen_range(0..FUZZ_CASE_VARIANTS.len())].to_string();
2183            sheet.set_cell_src(r + 1, 1, mixed);
2184
2185            let numstr = match rng.gen_range(0u8..4u8) {
2186                0 => String::new(),
2187                1 => format!("\"0{}\"", rng.gen_range(0u32..10u32)),
2188                2 => format!("\".0{}\"", rng.gen_range(0u32..1000u32)),
2189                _ => format!("\"{}\"", rng.gen_range(-50i64..50i64)),
2190            };
2191            sheet.set_cell_src(r + 1, 2, numstr);
2192
2193            sheet.set_cell_src(r + 1, 3, rng.gen_range(-100i64..=100i64).to_string());
2194
2195            let rate =
2196                (rng.gen_range(-500i64..=500i64) as f64) / (rng.gen_range(1i64..=100i64) as f64);
2197            sheet.set_cell_src(r + 1, 4, format!("{:.4}", rate));
2198
2199            sheet.set_cell_src(r + 1, 5, rng.gen_bool(0.5).to_string());
2200        }
2201        sheet.commit(None).unwrap();
2202        (sheet, FUZZ_COLS.iter().map(|s| s.to_string()).collect())
2203    }
2204
2205    fn random_aggregation(rng: &mut StdRng) -> PivotAggregation {
2206        match rng.gen_range(0u8..6u8) {
2207            0 => PivotAggregation::Sum,
2208            1 => PivotAggregation::Count,
2209            2 => PivotAggregation::CountNumbers,
2210            3 => PivotAggregation::Average,
2211            4 => PivotAggregation::Max,
2212            _ => PivotAggregation::Min,
2213        }
2214    }
2215
2216    fn fuzz_pivot_config(
2217        rng: &mut StdRng,
2218        sheet: &Sheet,
2219        col_names: &[String],
2220        num_rows: usize,
2221        use_table: bool,
2222    ) -> PivotTable {
2223        let mut pool: Vec<usize> = vec![0, 1, 2];
2224        let numeric: [usize; 2] = [3, 4];
2225
2226        let n_row = rng.gen_range(0..=pool.len().min(2));
2227        let row_cols: Vec<usize> = (0..n_row)
2228            .map(|_| pool.remove(rng.gen_range(0..pool.len())))
2229            .collect();
2230        let n_col = rng.gen_range(0..=pool.len().min(2));
2231        let col_cols: Vec<usize> = (0..n_col)
2232            .map(|_| pool.remove(rng.gen_range(0..pool.len())))
2233            .collect();
2234
2235        let row_fields: Vec<PivotField> = row_cols
2236            .iter()
2237            .map(|&i| PivotField {
2238                column: col_names[i].clone(),
2239                subtotal: rng.gen_bool(0.7),
2240            })
2241            .collect();
2242        let col_fields: Vec<PivotField> = col_cols
2243            .iter()
2244            .map(|&i| PivotField {
2245                column: col_names[i].clone(),
2246                subtotal: rng.gen_bool(0.7),
2247            })
2248            .collect();
2249
2250        let n_value = rng.gen_range(1..=2);
2251        let value_fields: Vec<PivotValueField> = (0..n_value)
2252            .map(|_| {
2253                let col = numeric[rng.gen_range(0..numeric.len())];
2254                PivotValueField::new(col_names[col].clone(), random_aggregation(rng))
2255            })
2256            .collect();
2257
2258        let mut filter_fields = Vec::new();
2259        if rng.gen_bool(0.5) {
2260            let candidates = [0usize, 1, 2, 5];
2261            let fcol = candidates[rng.gen_range(0..candidates.len())];
2262            let mut distinct: Vec<String> = (1..=num_rows)
2263                .map(|r| group_key(&sheet.get_result_data(&CellRef::new(r, fcol))))
2264                .collect();
2265            distinct.sort();
2266            distinct.dedup();
2267            let selected = if distinct.is_empty() || rng.gen_bool(0.2) {
2268                None
2269            } else {
2270                Some(distinct.into_iter().filter(|_| rng.gen_bool(0.5)).collect())
2271            };
2272            filter_fields.push(PivotFilterField {
2273                column: col_names[fcol].clone(),
2274                selected_values: selected,
2275                multiple_selection: true,
2276            });
2277        }
2278
2279        let source = if use_table {
2280            PivotSource::Table {
2281                name: "FuzzTable".to_string(),
2282            }
2283        } else {
2284            PivotSource::Range {
2285                sheet_id: sheet.id,
2286                start_row: 0,
2287                start_col: 0,
2288                end_row: num_rows,
2289                end_col: col_names.len() - 1,
2290            }
2291        };
2292
2293        PivotTable {
2294            id: 1,
2295            name: "FuzzPivot".to_string(),
2296            source,
2297            dest_sheet_id: sheet.id,
2298            dest_row: num_rows + 20,
2299            dest_col: 0,
2300            row_fields,
2301            col_fields,
2302            value_fields,
2303            filter_fields,
2304            grand_totals_row: rng.gen_bool(0.7),
2305            grand_totals_col: rng.gen_bool(0.7),
2306            last_output_end_row: None,
2307            last_output_end_col: None,
2308        }
2309    }
2310
2311    fn results_close(a: &ResultData, b: &ResultData) -> bool {
2312        match (a, b) {
2313            (ResultData::Integer(x), ResultData::Integer(y)) => x == y,
2314            (ResultData::Float(x), ResultData::Float(y)) => (x - y).abs() < 1e-6,
2315            (ResultData::Integer(x), ResultData::Float(y))
2316            | (ResultData::Float(y), ResultData::Integer(x)) => (*x as f64 - y).abs() < 1e-6,
2317            (ResultData::None, ResultData::None) => true,
2318            (ResultData::Error(x), ResultData::Error(y)) => x == y,
2319            (ResultData::String(x), ResultData::String(y)) => x == y,
2320            (ResultData::Boolean(x), ResultData::Boolean(y)) => x == y,
2321            _ => false,
2322        }
2323    }
2324
2325    fn matches_partial(key: &[String], labels: &[Option<String>]) -> bool {
2326        key.iter()
2327            .zip(labels)
2328            .all(|(k, want)| want.as_ref().is_none_or(|w| w.eq_ignore_ascii_case(k)))
2329    }
2330
2331    fn verify_grid_matches_records(sheet: &Sheet, pivot: &PivotTable, grid: &PivotGrid) {
2332        let (_, col_names, sheet_cols, data_rows) =
2333            resolve_source(&[sheet], &pivot.source).unwrap();
2334        let row_idxs: Vec<usize> = pivot
2335            .row_fields
2336            .iter()
2337            .map(|f| column_index(&col_names, &f.column).unwrap())
2338            .collect();
2339        let col_idxs: Vec<usize> = pivot
2340            .col_fields
2341            .iter()
2342            .map(|f| column_index(&col_names, &f.column).unwrap())
2343            .collect();
2344
2345        let mut records: Vec<(Vec<String>, Vec<String>, Vec<ResultData>)> = Vec::new();
2346        'row: for &r in &data_rows {
2347            let row_vals: Vec<ResultData> = sheet_cols
2348                .iter()
2349                .map(|&c| sheet.get_result_data(&CellRef::new(r, c)))
2350                .collect();
2351            for ff in &pivot.filter_fields {
2352                if let Some(selected) = &ff.selected_values {
2353                    let idx = column_index(&col_names, &ff.column).unwrap();
2354                    let key = group_key(&row_vals[idx]);
2355                    if !selected.iter().any(|v| v.eq_ignore_ascii_case(&key)) {
2356                        continue 'row;
2357                    }
2358                }
2359            }
2360            let row_key: Vec<String> = row_idxs.iter().map(|&i| group_key(&row_vals[i])).collect();
2361            let col_key: Vec<String> = col_idxs.iter().map(|&i| group_key(&row_vals[i])).collect();
2362            records.push((row_key, col_key, row_vals));
2363        }
2364
2365        let value_idxs: Vec<usize> = pivot
2366            .value_fields
2367            .iter()
2368            .map(|vf| column_index(&col_names, &vf.column).unwrap())
2369            .collect();
2370        let value_multiplier = if pivot.value_fields.len() > 1 {
2371            pivot.value_fields.len()
2372        } else {
2373            1
2374        };
2375        let width = row_label_width(pivot);
2376
2377        assert_eq!(grid.body_rows.len(), grid.row_axis.len());
2378        assert_eq!(grid.width, width + grid.col_axis.len() * value_multiplier);
2379        for hrow in &grid.header_rows {
2380            assert_eq!(hrow.len(), grid.width);
2381        }
2382
2383        for (i, (body_row, row_axis)) in grid.body_rows.iter().zip(grid.row_axis.iter()).enumerate()
2384        {
2385            assert_eq!(
2386                body_row.is_grand_total, row_axis.is_grand_total,
2387                "row {i} grand-total flag mismatch"
2388            );
2389            assert_eq!(body_row.row_labels.len(), width, "row {i} label width");
2390            assert_eq!(
2391                body_row.values.len(),
2392                grid.col_axis.len() * value_multiplier,
2393                "row {i} value count"
2394            );
2395
2396            for (j, col_axis) in grid.col_axis.iter().enumerate() {
2397                let matching: Vec<&Vec<ResultData>> = records
2398                    .iter()
2399                    .filter(|(rk, ck, _)| {
2400                        matches_partial(rk, &row_axis.labels)
2401                            && matches_partial(ck, &col_axis.labels)
2402                    })
2403                    .map(|(_, _, row)| row)
2404                    .collect();
2405
2406                for (vf_pos, &vidx) in value_idxs.iter().enumerate() {
2407                    if vf_pos > 0 && value_multiplier == 1 {
2408                        break;
2409                    }
2410                    let col_vals: Vec<ResultData> =
2411                        matching.iter().map(|row| row[vidx].clone()).collect();
2412                    let expected =
2413                        aggregate(sheet, &col_vals, pivot.value_fields[vf_pos].aggregation);
2414                    let actual = &body_row.values[j * value_multiplier + vf_pos];
2415                    assert!(
2416                        results_close(&expected, actual),
2417                        "row {i} col {j} value-field {vf_pos}: expected {expected:?}, got {actual:?} \
2418                         (row_labels={:?}, col_labels={:?})",
2419                        row_axis.labels,
2420                        col_axis.labels,
2421                    );
2422                }
2423            }
2424        }
2425    }
2426
2427    fn verify_grand_total_placement(
2428        axis: &[PivotAxisItem],
2429        grand_total_requested: bool,
2430        axis_has_fields: bool,
2431        label: &str,
2432    ) {
2433        let grand_count = axis.iter().filter(|a| a.is_grand_total).count();
2434        let has_any_real_group = axis.iter().any(|a| !a.is_grand_total);
2435        assert!(grand_count <= 1, "{label}: more than one grand-total group");
2436        if grand_total_requested && axis_has_fields && has_any_real_group {
2437            assert_eq!(
2438                grand_count, 1,
2439                "{label}: expected a grand total to be appended"
2440            );
2441        } else {
2442            assert_eq!(grand_count, 0, "{label}: did not expect a grand total");
2443        }
2444    }
2445
2446    #[test]
2447    fn test_fuzz_pivot_random_invariants() {
2448        for seed in 0u64..300 {
2449            let mut rng: StdRng = SeedableRng::seed_from_u64(seed);
2450            let use_table = seed % 2 == 0;
2451            let num_rows = rng.gen_range(0..=40usize);
2452            let (mut sheet, col_names) = fuzz_source_sheet(&mut rng, num_rows);
2453            if use_table {
2454                sheet
2455                    .add_table(
2456                        "FuzzTable".to_string(),
2457                        0,
2458                        0,
2459                        num_rows,
2460                        col_names.len() - 1,
2461                        true,
2462                        false,
2463                    )
2464                    .unwrap();
2465            }
2466            let pivot = fuzz_pivot_config(&mut rng, &sheet, &col_names, num_rows, use_table);
2467
2468            let grid = compute_pivot(&[&sheet], &pivot)
2469                .unwrap_or_else(|e| panic!("seed {seed}: compute_pivot failed: {e}"));
2470
2471            verify_grid_matches_records(&sheet, &pivot, &grid);
2472            verify_grand_total_placement(
2473                &grid.row_axis,
2474                pivot.grand_totals_row,
2475                !pivot.row_fields.is_empty(),
2476                "row axis",
2477            );
2478            verify_grand_total_placement(
2479                &grid.col_axis,
2480                pivot.grand_totals_col,
2481                !pivot.col_fields.is_empty(),
2482                "col axis",
2483            );
2484
2485            let xlsx = crate::core::xlsx::export_xlsx_data(
2486                std::slice::from_ref(&sheet),
2487                &[],
2488                std::slice::from_ref(&pivot),
2489                None,
2490            )
2491            .unwrap_or_else(|e| panic!("seed {seed}: export failed: {e}"));
2492            let (imported_sheets, _, imported_pivots, _) =
2493                crate::core::xlsx::import_xlsx_data(&xlsx, &[], |_, _, _| {})
2494                    .unwrap_or_else(|e| panic!("seed {seed}: import failed: {e}"));
2495            assert_eq!(
2496                imported_pivots.len(),
2497                1,
2498                "seed {seed}: pivot lost on round-trip"
2499            );
2500            let reimported = &imported_pivots[0];
2501
2502            assert_eq!(
2503                reimported
2504                    .row_fields
2505                    .iter()
2506                    .map(|f| &f.column)
2507                    .collect::<Vec<_>>(),
2508                pivot
2509                    .row_fields
2510                    .iter()
2511                    .map(|f| &f.column)
2512                    .collect::<Vec<_>>(),
2513                "seed {seed}: row field columns changed on round-trip"
2514            );
2515            assert_eq!(
2516                reimported
2517                    .col_fields
2518                    .iter()
2519                    .map(|f| &f.column)
2520                    .collect::<Vec<_>>(),
2521                pivot
2522                    .col_fields
2523                    .iter()
2524                    .map(|f| &f.column)
2525                    .collect::<Vec<_>>(),
2526                "seed {seed}: col field columns changed on round-trip"
2527            );
2528            assert_eq!(
2529                reimported
2530                    .value_fields
2531                    .iter()
2532                    .map(|f| (&f.column, f.aggregation))
2533                    .collect::<Vec<_>>(),
2534                pivot
2535                    .value_fields
2536                    .iter()
2537                    .map(|f| (&f.column, f.aggregation))
2538                    .collect::<Vec<_>>(),
2539                "seed {seed}: value fields changed on round-trip"
2540            );
2541            assert_eq!(reimported.grand_totals_row, pivot.grand_totals_row);
2542            assert_eq!(reimported.grand_totals_col, pivot.grand_totals_col);
2543            assert_eq!(
2544                reimported
2545                    .row_fields
2546                    .iter()
2547                    .map(|f| f.subtotal)
2548                    .collect::<Vec<_>>(),
2549                pivot
2550                    .row_fields
2551                    .iter()
2552                    .map(|f| f.subtotal)
2553                    .collect::<Vec<_>>(),
2554                "seed {seed}: row field subtotal toggle should round-trip"
2555            );
2556            assert_eq!(
2557                reimported
2558                    .col_fields
2559                    .iter()
2560                    .map(|f| f.subtotal)
2561                    .collect::<Vec<_>>(),
2562                pivot
2563                    .col_fields
2564                    .iter()
2565                    .map(|f| f.subtotal)
2566                    .collect::<Vec<_>>(),
2567                "seed {seed}: col field subtotal toggle should round-trip"
2568            );
2569            let nothing_lossy = true;
2570            let any_filter_is_also_an_axis_field = pivot.filter_fields.iter().any(|ff| {
2571                pivot
2572                    .row_fields
2573                    .iter()
2574                    .chain(pivot.col_fields.iter())
2575                    .any(|f| f.column.eq_ignore_ascii_case(&ff.column))
2576            });
2577            let reimported_sheets: Vec<Sheet> =
2578                imported_sheets.into_iter().map(|s| s.sheet).collect();
2579            let reimported_sheet_refs: Vec<&Sheet> = reimported_sheets.iter().collect();
2580            let reimported_grid = compute_pivot(&reimported_sheet_refs, reimported)
2581                .unwrap_or_else(|e| panic!("seed {seed}: reimported compute_pivot failed: {e}"));
2582            if nothing_lossy && !any_filter_is_also_an_axis_field {
2583                assert_eq!(
2584                    reimported_grid.body_rows.len(),
2585                    grid.body_rows.len(),
2586                    "seed {seed}: grid shape changed on lossless round-trip"
2587                );
2588            }
2589
2590            let sorted = |f: &PivotFilterField| {
2591                f.selected_values.as_ref().map(|v| {
2592                    let mut v: Vec<String> = v.iter().map(|s| s.to_lowercase()).collect();
2593                    v.sort();
2594                    v.dedup();
2595                    v
2596                })
2597            };
2598            let axis_bound = |column: &str| {
2599                pivot
2600                    .row_fields
2601                    .iter()
2602                    .chain(pivot.col_fields.iter())
2603                    .any(|f| f.column.eq_ignore_ascii_case(column))
2604            };
2605            for (before, after) in pivot
2606                .filter_fields
2607                .iter()
2608                .zip(reimported.filter_fields.iter())
2609            {
2610                if axis_bound(&before.column) {
2611                    continue;
2612                }
2613                if before.selected_values.is_some() && after.selected_values.is_none() {
2614                    assert_eq!(
2615                        reimported_grid.body_rows.len(),
2616                        grid.body_rows.len(),
2617                        "seed {seed}: filter on '{}' was dropped and it mattered",
2618                        before.column
2619                    );
2620                } else {
2621                    assert_eq!(
2622                        sorted(before),
2623                        sorted(after),
2624                        "seed {seed}: filter selection should round-trip for '{}'",
2625                        before.column
2626                    );
2627                }
2628            }
2629        }
2630    }
2631}