1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4use crate::core::engine::{CellRef, ResultData, Sheet};
5
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
11pub enum PivotSource {
12 Table {
14 name: String,
16 },
17 Range {
19 sheet_id: u64,
21 start_row: usize,
23 start_col: usize,
25 end_row: usize,
27 end_col: usize,
29 },
30}
31
32#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
35pub enum PivotAggregation {
36 Sum,
38 Count,
40 CountNumbers,
42 Average,
44 Max,
46 Min,
48}
49
50impl PivotAggregation {
51 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 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
85pub struct PivotField {
86 pub column: String,
88 pub subtotal: bool,
91}
92
93impl PivotField {
94 pub fn new(column: impl Into<String>) -> Self {
96 Self {
97 column: column.into(),
98 subtotal: true,
99 }
100 }
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
105pub struct PivotValueField {
106 pub column: String,
108 pub aggregation: PivotAggregation,
110 pub custom_name: Option<String>,
113}
114
115impl PivotValueField {
116 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 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
135pub 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
209pub struct PivotFilterField {
210 pub column: String,
212 pub selected_values: Option<Vec<String>>,
234 #[serde(default = "default_true")]
248 pub multiple_selection: bool,
249}
250
251fn default_true() -> bool {
252 true
253}
254
255impl PivotFilterField {
256 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#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
269pub enum PivotArea {
270 Row,
272 Column,
274 Value,
276 Filter,
279}
280
281#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
287pub struct PivotTable {
288 pub id: u64,
290 pub name: String,
292 pub source: PivotSource,
294 pub dest_sheet_id: u64,
296 pub dest_row: usize,
298 pub dest_col: usize,
300 pub row_fields: Vec<PivotField>,
302 pub col_fields: Vec<PivotField>,
304 pub value_fields: Vec<PivotValueField>,
307 pub filter_fields: Vec<PivotFilterField>,
309 pub grand_totals_row: bool,
311 pub grand_totals_col: bool,
313 #[serde(default)]
316 pub last_output_end_row: Option<usize>,
317 #[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#[derive(Debug, Clone)]
342pub struct PivotGrid {
343 pub filter_rows: Vec<(String, String)>,
351 pub header_rows: Vec<Vec<String>>,
354 pub body_rows: Vec<PivotBodyRow>,
357 pub width: usize,
361 pub row_axis: Vec<PivotAxisItem>,
366 pub col_axis: Vec<PivotAxisItem>,
368}
369
370#[derive(Debug, Clone)]
373pub struct PivotBodyRow {
374 pub row_labels: Vec<String>,
377 pub is_grand_total: bool,
379 pub values: Vec<ResultData>,
381}
382
383#[derive(Debug, Clone)]
387pub struct PivotAxisItem {
388 pub labels: Vec<Option<String>>,
390 pub is_subtotal: bool,
392 pub is_grand_total: bool,
394}
395
396impl PivotGrid {
397 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 pub fn height(&self) -> usize {
411 self.grid_row_offset() + self.header_rows.len() + self.body_rows.len()
412 }
413}
414
415struct FlatGroup {
416 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
699pub 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
1117pub 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}