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