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",
66 PivotAggregation::Average => "Average",
67 PivotAggregation::Max => "Max",
68 PivotAggregation::Min => "Min",
69 }
70 }
71
72 pub fn parse(s: &str) -> Option<Self> {
76 match s.to_ascii_lowercase().replace(['_', '-', ' '], "").as_str() {
77 "sum" => Some(Self::Sum),
78 "count" => Some(Self::Count),
79 "countnumbers" | "countnums" => Some(Self::CountNumbers),
80 "average" | "avg" => Some(Self::Average),
81 "max" | "maximum" => Some(Self::Max),
82 "min" | "minimum" => Some(Self::Min),
83 _ => None,
84 }
85 }
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
90pub struct PivotField {
91 pub column: String,
93 pub subtotal: bool,
96}
97
98impl PivotField {
99 pub fn new(column: impl Into<String>) -> Self {
101 Self {
102 column: column.into(),
103 subtotal: true,
104 }
105 }
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
110pub struct PivotValueField {
111 pub column: String,
113 pub aggregation: PivotAggregation,
115 pub custom_name: Option<String>,
118}
119
120impl PivotValueField {
121 pub fn new(column: impl Into<String>, aggregation: PivotAggregation) -> Self {
123 Self {
124 column: column.into(),
125 aggregation,
126 custom_name: None,
127 }
128 }
129
130 pub fn label(&self) -> String {
134 self.custom_name
135 .clone()
136 .unwrap_or_else(|| format!("{} of {}", self.aggregation.label(), self.column))
137 }
138}
139
140pub fn value_field_labels(value_fields: &[PivotValueField]) -> Vec<String> {
176 let mut clone_suffix: HashMap<&str, usize> = HashMap::new();
177 let mut next_clone: HashMap<&str, usize> = HashMap::new();
178 let mut label_counts: HashMap<String, usize> = HashMap::new();
179
180 value_fields
181 .iter()
182 .map(|vf| {
183 if let Some(name) = &vf.custom_name {
184 return name.clone();
185 }
186 let agg_label = vf.aggregation.label();
187 let in_clone_slot = clone_suffix.contains_key(vf.column.as_str());
188 let base_column = match clone_suffix.get(vf.column.as_str()) {
189 Some(n) => format!("{}{}", vf.column, n),
190 None => vf.column.clone(),
191 };
192 let base_label = format!("{} of {}", agg_label, base_column);
193 let count = label_counts.entry(base_label.clone()).or_insert(0);
194 *count += 1;
195 let label = if *count == 1 {
196 base_label
197 } else if in_clone_slot {
198 format!("{}_{}", base_label, count)
199 } else {
200 format!("{} of {}{}", agg_label, vf.column, count)
201 };
202 if vf.aggregation == PivotAggregation::Sum {
203 let assigned = *next_clone.entry(vf.column.as_str()).or_insert(2);
204 next_clone.insert(vf.column.as_str(), assigned + 1);
205 clone_suffix.insert(vf.column.as_str(), assigned);
206 }
207 label
208 })
209 .collect()
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
214pub struct PivotFilterField {
215 pub column: String,
217 pub selected_values: Option<Vec<String>>,
239 #[serde(default = "default_true")]
253 pub multiple_selection: bool,
254}
255
256fn default_true() -> bool {
257 true
258}
259
260impl PivotFilterField {
261 pub fn new(column: impl Into<String>) -> Self {
263 Self {
264 column: column.into(),
265 selected_values: None,
266 multiple_selection: true,
267 }
268 }
269}
270
271#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
274pub enum PivotArea {
275 Row,
277 Column,
279 Value,
281 Filter,
284}
285
286#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
292pub struct PivotTable {
293 pub id: u64,
295 pub name: String,
297 pub source: PivotSource,
299 pub dest_sheet_id: u64,
301 pub dest_row: usize,
303 pub dest_col: usize,
305 pub row_fields: Vec<PivotField>,
307 pub col_fields: Vec<PivotField>,
309 pub value_fields: Vec<PivotValueField>,
312 pub filter_fields: Vec<PivotFilterField>,
314 pub grand_totals_row: bool,
316 pub grand_totals_col: bool,
318 #[serde(default)]
321 pub last_output_end_row: Option<usize>,
322 #[serde(default)]
324 pub last_output_end_col: Option<usize>,
325}
326
327pub(crate) fn row_label_width(pivot: &PivotTable) -> usize {
342 if !pivot.row_fields.is_empty() {
343 return pivot.row_fields.len();
344 }
345 if pivot.value_fields.len() == 1 && !pivot.col_fields.is_empty() {
346 1
347 } else {
348 0
349 }
350}
351
352#[derive(Debug, Clone)]
361pub struct PivotGrid {
362 pub filter_rows: Vec<(String, String)>,
370 pub header_rows: Vec<Vec<String>>,
373 pub body_rows: Vec<PivotBodyRow>,
376 pub width: usize,
380 pub row_axis: Vec<PivotAxisItem>,
385 pub col_axis: Vec<PivotAxisItem>,
387}
388
389#[derive(Debug, Clone)]
392pub struct PivotBodyRow {
393 pub row_labels: Vec<String>,
396 pub is_grand_total: bool,
398 pub values: Vec<ResultData>,
400}
401
402#[derive(Debug, Clone)]
406pub struct PivotAxisItem {
407 pub labels: Vec<Option<String>>,
409 pub is_subtotal: bool,
411 pub is_grand_total: bool,
413}
414
415impl PivotGrid {
416 pub fn grid_row_offset(&self) -> usize {
420 if self.filter_rows.is_empty() {
421 0
422 } else {
423 self.filter_rows.len() + 1
424 }
425 }
426
427 pub fn height(&self) -> usize {
430 self.grid_row_offset() + self.header_rows.len() + self.body_rows.len()
431 }
432}
433
434struct FlatGroup {
440 labels: Vec<Option<String>>,
443 record_indices: Vec<usize>,
444 is_subtotal: bool,
445 is_grand_total: bool,
446}
447
448struct GroupNode {
449 label: String,
450 record_indices: Vec<usize>,
451 children: Vec<GroupNode>,
452}
453
454pub(crate) fn group_key(result: &ResultData) -> String {
455 match result {
456 ResultData::None => "(blank)".to_string(),
457 ResultData::String(s) if s.is_empty() => "(blank)".to_string(),
458 other => other.to_string(),
459 }
460}
461
462pub(crate) fn field_is_numeric(records: &[Vec<ResultData>], field_idx: usize) -> bool {
478 !records.is_empty()
479 && records.iter().all(|r| {
480 matches!(
481 r.get(field_idx),
482 Some(ResultData::Integer(_)) | Some(ResultData::Float(_)) | Some(ResultData::None)
483 )
484 })
485}
486
487fn text_sort_key(s: &str) -> String {
504 let trimmed = s.trim();
505 let key = match trimmed.strip_prefix('-') {
506 Some(rest) if rest.starts_with(|c: char| c.is_ascii_digit()) => rest,
507 _ => trimmed,
508 };
509 key.to_lowercase()
510}
511
512fn sort_group_entries(pairs: &mut [(String, Vec<usize>)], numeric: bool) {
513 pairs.sort_by(|a, b| match (a.0 == "(blank)", b.0 == "(blank)") {
517 (true, true) => std::cmp::Ordering::Equal,
518 (true, false) => std::cmp::Ordering::Greater,
519 (false, true) => std::cmp::Ordering::Less,
520 (false, false) if numeric => {
521 let fa: f64 = a.0.trim().parse().unwrap_or(0.0);
522 let fb: f64 = b.0.trim().parse().unwrap_or(0.0);
523 fa.partial_cmp(&fb).unwrap_or(std::cmp::Ordering::Equal)
524 }
525 (false, false) => text_sort_key(&a.0).cmp(&text_sort_key(&b.0)),
526 });
527}
528
529fn build_group_tree(
530 indices: &[usize],
531 keys: &[Vec<String>],
532 depth: usize,
533 num_fields: usize,
534 numeric_by_depth: &[bool],
535) -> Vec<GroupNode> {
536 let mut groups: Vec<(String, Vec<usize>)> = Vec::new();
545 for &idx in indices {
546 let key = &keys[idx][depth];
547 if let Some(entry) = groups.iter_mut().find(|(k, _)| k.eq_ignore_ascii_case(key)) {
548 entry.1.push(idx);
549 } else {
550 groups.push((key.clone(), vec![idx]));
551 }
552 }
553 sort_group_entries(&mut groups, numeric_by_depth[depth]);
554 groups
555 .into_iter()
556 .map(|(label, idxs)| {
557 let children = if depth + 1 < num_fields {
558 build_group_tree(&idxs, keys, depth + 1, num_fields, numeric_by_depth)
559 } else {
560 Vec::new()
561 };
562 GroupNode {
563 label,
564 record_indices: idxs,
565 children,
566 }
567 })
568 .collect()
569}
570
571fn flatten_groups(
575 nodes: &[GroupNode],
576 fields: &[PivotField],
577 depth: usize,
578 num_fields: usize,
579 prefix: &[Option<String>],
580 out: &mut Vec<FlatGroup>,
581) {
582 for node in nodes {
583 let mut labels = prefix.to_vec();
588 labels.push(Some(node.label.clone()));
589
590 if node.children.is_empty() {
591 let mut leaf_labels = labels.clone();
592 leaf_labels.resize(num_fields, None);
593 out.push(FlatGroup {
594 labels: leaf_labels,
595 record_indices: node.record_indices.clone(),
596 is_subtotal: false,
597 is_grand_total: false,
598 });
599 } else {
600 flatten_groups(&node.children, fields, depth + 1, num_fields, &labels, out);
601 let is_innermost = depth + 1 >= num_fields;
602 if fields[depth].subtotal && !is_innermost {
603 let mut subtotal_labels = labels.clone();
604 subtotal_labels.resize(num_fields, None);
605 out.push(FlatGroup {
606 labels: subtotal_labels,
607 record_indices: node.record_indices.clone(),
608 is_subtotal: true,
609 is_grand_total: false,
610 });
611 }
612 }
613 }
614}
615
616fn build_axis(
620 record_indices: &[usize],
621 keys: &[Vec<String>],
622 fields: &[PivotField],
623 grand_total: bool,
624 numeric_by_depth: &[bool],
625) -> Vec<FlatGroup> {
626 if fields.is_empty() {
627 return vec![FlatGroup {
628 labels: Vec::new(),
629 record_indices: record_indices.to_vec(),
630 is_subtotal: false,
631 is_grand_total: false,
632 }];
633 }
634 let tree = build_group_tree(record_indices, keys, 0, fields.len(), numeric_by_depth);
635 let mut flat = Vec::new();
636 flatten_groups(&tree, fields, 0, fields.len(), &[], &mut flat);
637 if grand_total && !flat.is_empty() {
645 flat.push(FlatGroup {
646 labels: vec![None; fields.len()],
647 record_indices: record_indices.to_vec(),
648 is_subtotal: false,
649 is_grand_total: true,
650 });
651 }
652 flat
653}
654
655fn aggregate(sheet: &Sheet, values: &[ResultData], agg: PivotAggregation) -> ResultData {
656 if values.is_empty() {
667 return ResultData::None;
668 }
669 match agg {
670 PivotAggregation::Count => ResultData::Integer(
671 values
672 .iter()
673 .filter(|v| !matches!(v, ResultData::None))
674 .count() as i64,
675 ),
676 PivotAggregation::CountNumbers => ResultData::Integer(
677 values
678 .iter()
679 .filter(|v| matches!(v, ResultData::Integer(_) | ResultData::Float(_)))
680 .count() as i64,
681 ),
682 _ => {
683 let nums: Vec<f64> = values
684 .iter()
685 .filter_map(|v| match v {
686 ResultData::Integer(_) | ResultData::Float(_) => sheet.to_f64(v),
687 _ => None,
688 })
689 .collect();
690 match agg {
691 PivotAggregation::Sum => {
692 if nums.is_empty() {
693 ResultData::Integer(0)
694 } else {
695 ResultData::Float(Sheet::clean_float(nums.iter().sum()))
696 }
697 }
698 PivotAggregation::Average => {
699 if nums.is_empty() {
700 ResultData::Error("#DIV/0!".to_string())
701 } else {
702 let avg = nums.iter().sum::<f64>() / nums.len() as f64;
703 ResultData::Float(Sheet::clean_float(avg))
704 }
705 }
706 PivotAggregation::Max => nums
707 .into_iter()
708 .fold(None, |acc: Option<f64>, x| {
709 Some(acc.map_or(x, |a| a.max(x)))
710 })
711 .map(ResultData::Float)
712 .unwrap_or(ResultData::None),
713 PivotAggregation::Min => nums
714 .into_iter()
715 .fold(None, |acc: Option<f64>, x| {
716 Some(acc.map_or(x, |a| a.min(x)))
717 })
718 .map(ResultData::Float)
719 .unwrap_or(ResultData::None),
720 PivotAggregation::Count | PivotAggregation::CountNumbers => unreachable!(),
721 }
722 }
723 }
724}
725
726pub(crate) type ResolvedSource<'a> = (&'a Sheet, Vec<String>, Vec<usize>, Vec<usize>);
732
733pub(crate) fn resolve_source<'a>(
734 sheets: &'a [&'a Sheet],
735 source: &PivotSource,
736) -> Result<ResolvedSource<'a>, String> {
737 match source {
738 PivotSource::Table { name } => {
739 let (sheet, table) = sheets
740 .iter()
741 .find_map(|s| s.find_table(name).map(|t| (*s, t)))
742 .ok_or_else(|| format!("Table '{}' not found", name))?;
743 let cols: Vec<usize> = (table.start_col..=table.end_col).collect();
744 let rows: Vec<usize> = (table.data_start_row()..=table.data_end_row()).collect();
745 Ok((sheet, table.columns.clone(), cols, rows))
746 }
747 PivotSource::Range {
748 sheet_id,
749 start_row,
750 start_col,
751 end_row,
752 end_col,
753 } => {
754 let sheet = *sheets
755 .iter()
756 .find(|s| s.id == *sheet_id)
757 .ok_or_else(|| "Pivot source sheet no longer exists".to_string())?;
758 if *end_row < *start_row || *end_col < *start_col {
759 return Err("Pivot source range end must not precede its start".to_string());
760 }
761 let cols: Vec<usize> = (*start_col..=*end_col).collect();
762 let names: Vec<String> = cols
763 .iter()
764 .map(|&c| {
765 let v = sheet.get_result_data(&CellRef::new(*start_row, c));
766 let s = v.to_string();
767 if s.is_empty() {
768 crate::core::parser::col_idx_to_letters(c)
769 } else {
770 s
771 }
772 })
773 .collect();
774 let rows: Vec<usize> = if *end_row > *start_row {
775 (*start_row + 1..=*end_row).collect()
776 } else {
777 Vec::new()
778 };
779 Ok((sheet, names, cols, rows))
780 }
781 }
782}
783
784pub(crate) fn column_index(names: &[String], target: &str) -> Result<usize, String> {
785 names
786 .iter()
787 .position(|c| c.eq_ignore_ascii_case(target))
788 .ok_or_else(|| {
789 format!(
790 "Source column '{}' not found (columns: {})",
791 target,
792 names.join(", ")
793 )
794 })
795}
796
797pub fn compute_pivot(sheets: &[&Sheet], pivot: &PivotTable) -> Result<PivotGrid, String> {
815 let (sheet, col_names, sheet_cols, data_rows) = resolve_source(sheets, &pivot.source)?;
816
817 for f in pivot.row_fields.iter().chain(pivot.col_fields.iter()) {
818 column_index(&col_names, &f.column)?;
819 }
820 for vf in &pivot.value_fields {
821 column_index(&col_names, &vf.column)?;
822 }
823 for ff in &pivot.filter_fields {
824 column_index(&col_names, &ff.column)?;
825 }
826 if pivot.value_fields.is_empty() {
827 return Err("Pivot table has no value fields".to_string());
828 }
829
830 let mut all_rows: Vec<Vec<ResultData>> = Vec::with_capacity(data_rows.len());
835 for &r in &data_rows {
836 let mut row_vals = Vec::with_capacity(sheet_cols.len());
837 for &c in &sheet_cols {
838 row_vals.push(sheet.get_result_data(&CellRef::new(r, c)));
839 }
840 all_rows.push(row_vals);
841 }
842
843 let mut filter_rows: Vec<(String, String)> = Vec::new();
853 for ff in &pivot.filter_fields {
854 let idx = column_index(&col_names, &ff.column)?;
855 let distinct: std::collections::HashSet<String> = all_rows
856 .iter()
857 .map(|row| group_key(&row[idx]).to_ascii_lowercase())
858 .collect();
859 let state = match &ff.selected_values {
860 None => "(All)".to_string(),
861 Some(selected) => {
862 let selected_set: std::collections::HashSet<String> =
863 selected.iter().map(|v| v.to_ascii_lowercase()).collect();
864 let is_all = selected_set.len() == distinct.len()
865 && distinct.iter().all(|v| selected_set.contains(v));
866 if is_all {
867 "(All)".to_string()
868 } else if !ff.multiple_selection && selected_set.len() == 1 {
869 let wanted = &selected_set;
875 all_rows
876 .iter()
877 .map(|row| group_key(&row[idx]))
878 .find(|v| wanted.contains(&v.to_ascii_lowercase()))
879 .unwrap_or_else(|| "(Multiple Items)".to_string())
880 } else {
881 "(Multiple Items)".to_string()
882 }
883 }
884 };
885 filter_rows.push((ff.column.clone(), state));
886 }
887
888 let mut records: Vec<Vec<ResultData>> = Vec::new();
889 'row: for row_vals in &all_rows {
890 for ff in &pivot.filter_fields {
891 if let Some(selected) = &ff.selected_values {
892 let idx = column_index(&col_names, &ff.column)?;
893 let key = group_key(&row_vals[idx]);
894 if !selected.iter().any(|v| v.eq_ignore_ascii_case(&key)) {
895 continue 'row;
896 }
897 }
898 }
899 records.push(row_vals.clone());
900 }
901
902 let record_indices: Vec<usize> = (0..records.len()).collect();
903
904 let row_field_idxs: Vec<usize> = pivot
905 .row_fields
906 .iter()
907 .map(|f| column_index(&col_names, &f.column))
908 .collect::<Result<_, _>>()?;
909 let col_field_idxs: Vec<usize> = pivot
910 .col_fields
911 .iter()
912 .map(|f| column_index(&col_names, &f.column))
913 .collect::<Result<_, _>>()?;
914 let mut case_canon: HashMap<usize, HashMap<String, String>> = HashMap::new();
924 let mut canonical_key = |field_idx: usize, raw: String| -> String {
925 let map = case_canon.entry(field_idx).or_default();
926 map.entry(raw.to_ascii_lowercase()).or_insert(raw).clone()
927 };
928 for row_vals in &all_rows {
937 for &i in row_field_idxs.iter().chain(col_field_idxs.iter()) {
938 canonical_key(i, group_key(&row_vals[i]));
939 }
940 }
941 let row_keys: Vec<Vec<String>> = if pivot.row_fields.is_empty() {
942 Vec::new()
943 } else {
944 records
945 .iter()
946 .map(|rec| {
947 row_field_idxs
948 .iter()
949 .map(|&i| canonical_key(i, group_key(&rec[i])))
950 .collect()
951 })
952 .collect()
953 };
954 let col_keys: Vec<Vec<String>> = if pivot.col_fields.is_empty() {
955 Vec::new()
956 } else {
957 records
958 .iter()
959 .map(|rec| {
960 col_field_idxs
961 .iter()
962 .map(|&i| canonical_key(i, group_key(&rec[i])))
963 .collect()
964 })
965 .collect()
966 };
967 let row_numeric: Vec<bool> = row_field_idxs
968 .iter()
969 .map(|&i| field_is_numeric(&records, i))
970 .collect();
971 let col_numeric: Vec<bool> = col_field_idxs
972 .iter()
973 .map(|&i| field_is_numeric(&records, i))
974 .collect();
975
976 let row_groups = build_axis(
977 &record_indices,
978 &row_keys,
979 &pivot.row_fields,
980 pivot.grand_totals_row,
981 &row_numeric,
982 );
983 let col_groups = build_axis(
984 &record_indices,
985 &col_keys,
986 &pivot.col_fields,
987 pivot.grand_totals_col,
988 &col_numeric,
989 );
990
991 let value_multiplier = if pivot.value_fields.len() > 1 {
992 pivot.value_fields.len()
993 } else {
994 1
995 };
996 let value_idxs: Vec<usize> = pivot
997 .value_fields
998 .iter()
999 .map(|vf| column_index(&col_names, &vf.column))
1000 .collect::<Result<_, _>>()?;
1001 let value_labels = value_field_labels(&pivot.value_fields);
1002
1003 let n_col_header_rows = pivot.col_fields.len().max(1);
1017 let n_header_rows = if value_multiplier > 1 && !pivot.col_fields.is_empty() {
1027 n_col_header_rows + 1
1028 } else {
1029 n_col_header_rows
1030 };
1031 let row_label_width = row_label_width(pivot);
1032
1033 let mut header_rows: Vec<Vec<String>> = Vec::new();
1034 for r in 0..n_header_rows {
1035 let mut row: Vec<String> = Vec::new();
1036 for i in 0..row_label_width {
1037 if r == n_header_rows - 1 {
1046 row.push(if i == 0 && !pivot.row_fields.is_empty() {
1047 "Row Labels".to_string()
1048 } else {
1049 pivot
1050 .row_fields
1051 .get(i)
1052 .map(|f| f.column.clone())
1053 .unwrap_or_default()
1054 });
1055 } else {
1056 row.push(String::new());
1057 }
1058 }
1059 let mut prev_group: Option<&FlatGroup> = None;
1077 for group in &col_groups {
1078 let subtotal_depth = group
1092 .is_subtotal
1093 .then(|| group.labels.iter().rposition(|l| l.is_some()))
1094 .flatten();
1095 for vf in 0..value_multiplier {
1096 let label = if r < pivot.col_fields.len() {
1097 if group.is_grand_total {
1098 if r == 0 {
1107 if value_multiplier > 1 {
1108 format!("Total {}", value_labels[vf])
1109 } else {
1110 "Grand Total".to_string()
1111 }
1112 } else {
1113 String::new()
1114 }
1115 } else if subtotal_depth == Some(r) {
1116 let value = group.labels[r].clone().unwrap();
1117 if value_multiplier > 1 {
1118 format!("{} {}", value, value_labels[vf])
1119 } else {
1120 format!("{} Total", value)
1121 }
1122 } else {
1123 let is_repeat = if vf > 0 {
1124 true
1125 } else {
1126 prev_group.is_some_and(|pg| {
1127 (0..=r).all(|d| pg.labels.get(d) == group.labels.get(d))
1128 })
1129 };
1130 if is_repeat {
1131 String::new()
1132 } else {
1133 group
1134 .labels
1135 .get(r)
1136 .and_then(|l| l.clone())
1137 .unwrap_or_default()
1138 }
1139 }
1140 } else if group.is_grand_total || group.is_subtotal {
1141 String::new()
1146 } else {
1147 value_labels.get(vf).cloned().unwrap_or_default()
1148 };
1149 row.push(label);
1150 }
1151 prev_group = Some(group);
1152 }
1153 header_rows.push(row);
1154 }
1155 if pivot.col_fields.is_empty()
1159 && value_multiplier == 1
1160 && let Some(last) = header_rows.last_mut()
1161 && let Some(cell) = last.last_mut()
1162 && let Some(label) = value_labels.first()
1163 {
1164 *cell = label.clone();
1165 }
1166 if !pivot.col_fields.is_empty() {
1177 let mut row = vec![String::new(); row_label_width];
1178 if value_multiplier == 1
1179 && !pivot.row_fields.is_empty()
1180 && let Some(label) = value_labels.first()
1181 {
1182 row[0] = label.clone();
1183 }
1184 row.push("Column Labels".to_string());
1185 row.resize(
1186 row_label_width + col_groups.len() * value_multiplier,
1187 String::new(),
1188 );
1189 header_rows.insert(0, row);
1190 }
1191
1192 let mut body_rows: Vec<PivotBodyRow> = Vec::new();
1194 let mut prev_labels: Vec<Option<String>> = vec![None; row_label_width];
1195 for rg in &row_groups {
1196 let mut display_labels = vec![String::new(); row_label_width];
1197 if rg.is_grand_total {
1198 display_labels[0] = "Grand Total".to_string();
1199 for l in prev_labels.iter_mut() {
1200 *l = None;
1201 }
1202 } else {
1203 let mut changed = false;
1204 for d in 0..row_label_width {
1205 let cur = if pivot.row_fields.is_empty() {
1206 None
1207 } else {
1208 rg.labels.get(d).cloned().flatten()
1209 };
1210 let is_subtotal_marker =
1211 rg.is_subtotal && rg.labels.get(d).map(|l| l.is_some()).unwrap_or(false);
1212 let show = changed || cur != prev_labels[d] || is_subtotal_marker;
1213 if show {
1214 if let Some(ref v) = cur {
1215 display_labels[d] = if is_subtotal_marker {
1216 format!("{} Total", v)
1217 } else {
1218 v.clone()
1219 };
1220 }
1221 changed = true;
1222 }
1223 prev_labels[d] = cur;
1224 }
1225 if pivot.row_fields.is_empty() && row_label_width > 0 {
1229 display_labels[0] = if !pivot.col_fields.is_empty() && value_multiplier == 1 {
1238 value_labels.first().cloned().unwrap_or_default()
1239 } else {
1240 "Total".to_string()
1241 };
1242 }
1243 }
1244
1245 let row_record_set: std::collections::HashSet<usize> =
1246 rg.record_indices.iter().copied().collect();
1247 let mut values: Vec<ResultData> = Vec::new();
1248 for cg in &col_groups {
1249 for (vf_pos, &vidx) in value_idxs.iter().enumerate() {
1250 if vf_pos > 0 && value_multiplier == 1 {
1251 break;
1252 }
1253 let col_vals: Vec<ResultData> = cg
1254 .record_indices
1255 .iter()
1256 .filter(|i| row_record_set.contains(i))
1257 .map(|&i| records[i][vidx].clone())
1258 .collect();
1259 values.push(aggregate(
1260 sheet,
1261 &col_vals,
1262 pivot.value_fields[vf_pos].aggregation,
1263 ));
1264 }
1265 }
1266
1267 body_rows.push(PivotBodyRow {
1268 row_labels: display_labels,
1269 is_grand_total: rg.is_grand_total,
1270 values,
1271 });
1272 }
1273
1274 let width = row_label_width + col_groups.len() * value_multiplier;
1275 let to_axis_items = |groups: &[FlatGroup]| -> Vec<PivotAxisItem> {
1276 groups
1277 .iter()
1278 .map(|g| PivotAxisItem {
1279 labels: g.labels.clone(),
1280 is_subtotal: g.is_subtotal,
1281 is_grand_total: g.is_grand_total,
1282 })
1283 .collect()
1284 };
1285 Ok(PivotGrid {
1286 filter_rows,
1287 header_rows,
1288 body_rows,
1289 width,
1290 row_axis: to_axis_items(&row_groups),
1291 col_axis: to_axis_items(&col_groups),
1292 })
1293}
1294
1295fn match_pivot_axis(
1305 axis: &[PivotAxisItem],
1306 criteria: &[(usize, &str)],
1307 field_count: usize,
1308) -> Result<usize, String> {
1309 if criteria.is_empty() {
1310 return axis
1311 .iter()
1312 .position(|g| g.is_grand_total)
1313 .or(if field_count == 0 && axis.len() == 1 {
1314 Some(0)
1315 } else {
1316 None
1317 })
1318 .ok_or_else(|| "#REF!".to_string());
1319 }
1320 let max_depth = criteria.iter().map(|(d, _)| *d).max().unwrap_or(0);
1321 let want_leaf = max_depth + 1 == field_count;
1322 let matches: Vec<usize> = axis
1323 .iter()
1324 .enumerate()
1325 .filter(|(_, group)| {
1326 if group.is_grand_total {
1327 return false;
1328 }
1329 if want_leaf {
1330 if group.is_subtotal {
1331 return false;
1332 }
1333 } else {
1334 let own_depth = group.labels.iter().rposition(|l| l.is_some());
1335 if !(group.is_subtotal && own_depth == Some(max_depth)) {
1336 return false;
1337 }
1338 }
1339 criteria.iter().all(|(depth, item)| {
1340 group
1341 .labels
1342 .get(*depth)
1343 .and_then(|l| l.as_deref())
1344 .map(|l| l.eq_ignore_ascii_case(item))
1345 .unwrap_or(false)
1346 })
1347 })
1348 .map(|(i, _)| i)
1349 .collect();
1350 match matches.len() {
1351 1 => Ok(matches[0]),
1352 _ => Err("#REF!".to_string()),
1353 }
1354}
1355
1356pub fn getpivotdata(
1363 sheets: &[&Sheet],
1364 pivot: &PivotTable,
1365 data_field: &str,
1366 criteria: &[(String, String)],
1367) -> Result<ResultData, String> {
1368 let grid = compute_pivot(sheets, pivot)?;
1369
1370 let value_labels = value_field_labels(&pivot.value_fields);
1371 let value_multiplier = if pivot.value_fields.len() > 1 {
1372 pivot.value_fields.len()
1373 } else {
1374 1
1375 };
1376 let value_field_idx = pivot
1377 .value_fields
1378 .iter()
1379 .position(|vf| vf.column.eq_ignore_ascii_case(data_field))
1380 .or_else(|| {
1381 value_labels
1382 .iter()
1383 .position(|l| l.eq_ignore_ascii_case(data_field))
1384 })
1385 .ok_or_else(|| "#VALUE!".to_string())?;
1386
1387 let mut row_criteria: Vec<(usize, &str)> = Vec::new();
1388 let mut col_criteria: Vec<(usize, &str)> = Vec::new();
1389 for (field, item) in criteria {
1390 if let Some(depth) = pivot
1391 .row_fields
1392 .iter()
1393 .position(|f| f.column.eq_ignore_ascii_case(field))
1394 {
1395 row_criteria.push((depth, item.as_str()));
1396 } else if let Some(depth) = pivot
1397 .col_fields
1398 .iter()
1399 .position(|f| f.column.eq_ignore_ascii_case(field))
1400 {
1401 col_criteria.push((depth, item.as_str()));
1402 } else {
1403 return Err("#REF!".to_string());
1404 }
1405 }
1406
1407 let row_idx = match_pivot_axis(&grid.row_axis, &row_criteria, pivot.row_fields.len())?;
1408 let col_idx = match_pivot_axis(&grid.col_axis, &col_criteria, pivot.col_fields.len())?;
1409
1410 let pos = col_idx * value_multiplier + value_field_idx;
1411 grid.body_rows
1412 .get(row_idx)
1413 .and_then(|r| r.values.get(pos))
1414 .cloned()
1415 .ok_or_else(|| "#REF!".to_string())
1416}
1417
1418pub(crate) fn sorted_distinct_strings(values: &[String], numeric: bool) -> Vec<String> {
1423 let mut pairs: Vec<(String, Vec<usize>)> = distinct_strings(values)
1424 .into_iter()
1425 .map(|s| (s, Vec::new()))
1426 .collect();
1427 sort_group_entries(&mut pairs, numeric);
1428 pairs.into_iter().map(|(s, _)| s).collect()
1429}
1430
1431pub(crate) fn distinct_strings(values: &[String]) -> Vec<String> {
1445 let mut seen: Vec<String> = Vec::new();
1446 for v in values {
1447 if !seen.iter().any(|s| s.eq_ignore_ascii_case(v)) {
1448 seen.push(v.clone());
1449 }
1450 }
1451 seen
1452}
1453
1454#[cfg(test)]
1455mod tests {
1456 use super::*;
1457 use crate::core::engine::SheetInit;
1458
1459 fn source_sheet() -> Sheet {
1460 let mut sheet = Sheet::new(SheetInit {
1461 name: Some("Data".to_string()),
1462 rows: 9,
1463 cols: 4,
1464 ..Default::default()
1465 });
1466 let header = ["Region", "Product", "Rep", "Amount"];
1467 for (c, h) in header.iter().enumerate() {
1468 sheet.set_cell_src(0, c, h.to_string());
1469 }
1470 let rows: [[&str; 4]; 8] = [
1471 ["East", "Widget", "Alice", "10"],
1472 ["East", "Widget", "Bob", "20"],
1473 ["East", "Gadget", "Alice", "5"],
1474 ["West", "Widget", "Carol", "30"],
1475 ["West", "Gadget", "Carol", "40"],
1476 ["West", "Gadget", "Dave", "50"],
1477 ["East", "Gadget", "Bob", "15"],
1478 ["West", "Widget", "Dave", "25"],
1479 ];
1480 for (r, row) in rows.iter().enumerate() {
1481 for (c, v) in row.iter().enumerate() {
1482 sheet.set_cell_src(r + 1, c, v.to_string());
1483 }
1484 }
1485 sheet.commit(None).unwrap();
1486 sheet
1487 .add_table("Sales".to_string(), 0, 0, 8, 3, true, false)
1488 .unwrap();
1489 sheet
1490 }
1491
1492 fn base_pivot() -> PivotTable {
1493 PivotTable {
1494 id: 1,
1495 name: "Pivot1".to_string(),
1496 source: PivotSource::Table {
1497 name: "Sales".to_string(),
1498 },
1499 dest_sheet_id: 0,
1500 dest_row: 0,
1501 dest_col: 0,
1502 row_fields: vec![PivotField::new("Region")],
1503 col_fields: vec![],
1504 value_fields: vec![PivotValueField::new("Amount", PivotAggregation::Sum)],
1505 filter_fields: vec![],
1506 grand_totals_row: true,
1507 grand_totals_col: true,
1508 last_output_end_row: None,
1509 last_output_end_col: None,
1510 }
1511 }
1512
1513 fn value_at(row: &PivotBodyRow, col: usize) -> f64 {
1514 match &row.values[col] {
1515 ResultData::Float(f) => *f,
1516 ResultData::Integer(i) => *i as f64,
1517 other => panic!("expected numeric, got {:?}", other),
1518 }
1519 }
1520
1521 #[test]
1522 fn test_single_row_field_sum_with_grand_total() {
1523 let sheet = source_sheet();
1524 let pivot = base_pivot();
1525 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1526
1527 assert_eq!(grid.body_rows.len(), 3);
1529 assert_eq!(grid.body_rows[0].row_labels[0], "East");
1530 assert_eq!(value_at(&grid.body_rows[0], 0), 50.0);
1531 assert_eq!(grid.body_rows[1].row_labels[0], "West");
1532 assert_eq!(value_at(&grid.body_rows[1], 0), 145.0);
1533 assert!(grid.body_rows[2].is_grand_total);
1534 assert_eq!(grid.body_rows[2].row_labels[0], "Grand Total");
1535 assert_eq!(value_at(&grid.body_rows[2], 0), 195.0);
1536 }
1537
1538 #[test]
1539 fn test_getpivotdata_matches_a_row_group() {
1540 let sheet = source_sheet();
1541 let pivot = base_pivot();
1542 let result = getpivotdata(
1543 &[&sheet],
1544 &pivot,
1545 "Amount",
1546 &[("Region".to_string(), "East".to_string())],
1547 )
1548 .unwrap();
1549 assert!(matches!(result, ResultData::Float(f) if f == 50.0));
1550 }
1551
1552 #[test]
1553 fn test_getpivotdata_empty_criteria_matches_grand_total() {
1554 let sheet = source_sheet();
1555 let pivot = base_pivot();
1556 let result = getpivotdata(&[&sheet], &pivot, "Amount", &[]).unwrap();
1557 assert!(matches!(result, ResultData::Float(f) if f == 195.0));
1558 }
1559
1560 #[test]
1561 fn test_getpivotdata_partial_criteria_matches_subtotal() {
1562 let sheet = source_sheet();
1563 let mut pivot = base_pivot();
1564 pivot.row_fields = vec![PivotField::new("Region"), PivotField::new("Product")];
1565 let result = getpivotdata(
1567 &[&sheet],
1568 &pivot,
1569 "Amount",
1570 &[("Region".to_string(), "East".to_string())],
1571 )
1572 .unwrap();
1573 assert!(matches!(result, ResultData::Float(f) if f == 50.0));
1574 }
1575
1576 #[test]
1577 fn test_getpivotdata_full_path_matches_leaf() {
1578 let sheet = source_sheet();
1579 let mut pivot = base_pivot();
1580 pivot.row_fields = vec![PivotField::new("Region"), PivotField::new("Product")];
1581 let result = getpivotdata(
1582 &[&sheet],
1583 &pivot,
1584 "Amount",
1585 &[
1586 ("Region".to_string(), "East".to_string()),
1587 ("Product".to_string(), "Widget".to_string()),
1588 ],
1589 )
1590 .unwrap();
1591 assert!(matches!(result, ResultData::Float(f) if f == 30.0));
1592 }
1593
1594 #[test]
1595 fn test_getpivotdata_unknown_field_is_ref_error() {
1596 let sheet = source_sheet();
1597 let pivot = base_pivot();
1598 let err = getpivotdata(
1599 &[&sheet],
1600 &pivot,
1601 "Amount",
1602 &[("NotAField".to_string(), "East".to_string())],
1603 )
1604 .unwrap_err();
1605 assert_eq!(err, "#REF!");
1606 }
1607
1608 #[test]
1609 fn test_getpivotdata_unknown_item_is_ref_error() {
1610 let sheet = source_sheet();
1611 let pivot = base_pivot();
1612 let err = getpivotdata(
1613 &[&sheet],
1614 &pivot,
1615 "Amount",
1616 &[("Region".to_string(), "North".to_string())],
1617 )
1618 .unwrap_err();
1619 assert_eq!(err, "#REF!");
1620 }
1621
1622 #[test]
1623 fn test_getpivotdata_unknown_data_field_is_value_error() {
1624 let sheet = source_sheet();
1625 let pivot = base_pivot();
1626 let err = getpivotdata(
1627 &[&sheet],
1628 &pivot,
1629 "NotAField",
1630 &[("Region".to_string(), "East".to_string())],
1631 )
1632 .unwrap_err();
1633 assert_eq!(err, "#VALUE!");
1634 }
1635
1636 #[test]
1637 fn test_row_and_col_fields_with_subtotals() {
1638 let sheet = source_sheet();
1639 let mut pivot = base_pivot();
1640 pivot.row_fields = vec![PivotField::new("Region"), PivotField::new("Product")];
1641 pivot.col_fields = vec![PivotField::new("Rep")];
1642 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1643
1644 let subtotal_rows: Vec<&PivotBodyRow> = grid
1646 .body_rows
1647 .iter()
1648 .filter(|r| r.row_labels[0].ends_with("Total") && !r.is_grand_total)
1649 .collect();
1650 assert_eq!(subtotal_rows.len(), 2); assert!(grid.body_rows.last().unwrap().is_grand_total);
1652 }
1653
1654 #[test]
1655 fn test_nested_row_field_second_level_labels_are_not_lost() {
1656 let sheet = source_sheet();
1660 let mut pivot = base_pivot();
1661 pivot.row_fields = vec![PivotField::new("Region"), PivotField::new("Product")];
1662 pivot.grand_totals_row = false;
1663 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1664
1665 let leaf_rows: Vec<&PivotBodyRow> = grid
1666 .body_rows
1667 .iter()
1668 .filter(|r| !r.row_labels[0].ends_with("Total") && !r.is_grand_total)
1669 .collect();
1670 assert_eq!(leaf_rows.len(), 4);
1672 for row in &leaf_rows {
1674 assert!(
1675 !row.row_labels[1].is_empty(),
1676 "expected a Product label on leaf row {:?}, got blank",
1677 row.row_labels
1678 );
1679 }
1680 let products: Vec<&str> = leaf_rows.iter().map(|r| r.row_labels[1].as_str()).collect();
1681 assert!(products.contains(&"Widget"));
1682 assert!(products.contains(&"Gadget"));
1683 }
1684
1685 #[test]
1686 fn test_count_aggregation() {
1687 let sheet = source_sheet();
1688 let mut pivot = base_pivot();
1689 pivot.value_fields = vec![PivotValueField::new("Rep", PivotAggregation::Count)];
1690 pivot.grand_totals_row = false;
1691 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1692 assert_eq!(grid.body_rows.len(), 2);
1693 for row in &grid.body_rows {
1695 assert_eq!(value_at(row, 0), 4.0);
1696 }
1697 }
1698
1699 #[test]
1700 fn test_filter_field_restricts_records() {
1701 let sheet = source_sheet();
1702 let mut pivot = base_pivot();
1703 pivot.filter_fields = vec![PivotFilterField {
1704 column: "Product".to_string(),
1705 selected_values: Some(vec!["Widget".to_string()]),
1706 multiple_selection: true,
1707 }];
1708 pivot.grand_totals_row = false;
1709 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1710 assert_eq!(grid.body_rows.len(), 2);
1712 assert_eq!(value_at(&grid.body_rows[0], 0), 30.0);
1713 assert_eq!(value_at(&grid.body_rows[1], 0), 55.0);
1714 }
1715
1716 #[test]
1717 fn test_filter_field_selection_matches_case_insensitively() {
1718 let mut sheet = Sheet::new(SheetInit {
1725 name: Some("Data".to_string()),
1726 rows: 4,
1727 cols: 2,
1728 ..Default::default()
1729 });
1730 for (c, h) in ["Mixed", "Amount"].iter().enumerate() {
1731 sheet.set_cell_src(0, c, h.to_string());
1732 }
1733 let rows: [[&str; 2]; 3] = [["East", "10"], ["east", "20"], ["West", "30"]];
1734 for (r, row) in rows.iter().enumerate() {
1735 for (c, v) in row.iter().enumerate() {
1736 sheet.set_cell_src(r + 1, c, v.to_string());
1737 }
1738 }
1739 sheet.commit(None).unwrap();
1740 sheet
1741 .add_table("Sales".to_string(), 0, 0, 3, 1, true, false)
1742 .unwrap();
1743
1744 let mut pivot = base_pivot();
1745 pivot.source = PivotSource::Table {
1746 name: "Sales".to_string(),
1747 };
1748 pivot.row_fields = vec![];
1749 pivot.value_fields = vec![PivotValueField::new("Amount", PivotAggregation::Sum)];
1750 pivot.filter_fields = vec![PivotFilterField {
1751 column: "Mixed".to_string(),
1752 selected_values: Some(vec!["east".to_string()]),
1753 multiple_selection: true,
1754 }];
1755 pivot.grand_totals_row = false;
1756 pivot.grand_totals_col = false;
1757 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1758 assert_eq!(value_at(&grid.body_rows[0], 0), 30.0);
1760 }
1761
1762 #[test]
1763 fn test_no_filter_fields_means_no_reserved_rows() {
1764 let sheet = source_sheet();
1765 let pivot = base_pivot();
1766 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1767 assert!(grid.filter_rows.is_empty());
1768 assert_eq!(grid.grid_row_offset(), 0);
1769 assert_eq!(grid.height(), grid.header_rows.len() + grid.body_rows.len());
1770 }
1771
1772 #[test]
1773 fn test_filter_field_state_label_all_vs_multiple_items() {
1774 let sheet = source_sheet();
1776 let mut pivot = base_pivot();
1777 pivot.filter_fields = vec![PivotFilterField {
1778 column: "Product".to_string(),
1779 selected_values: None,
1780 multiple_selection: true,
1781 }];
1782
1783 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1785 assert_eq!(
1786 grid.filter_rows,
1787 vec![("Product".to_string(), "(All)".to_string())]
1788 );
1789 assert_eq!(grid.grid_row_offset(), 2); pivot.filter_fields[0].selected_values =
1793 Some(vec!["Widget".to_string(), "Gadget".to_string()]);
1794 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1795 assert_eq!(grid.filter_rows[0].1, "(All)");
1796
1797 pivot.filter_fields[0].selected_values = Some(vec!["Widget".to_string()]);
1802 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1803 assert_eq!(grid.filter_rows[0].1, "(Multiple Items)");
1804
1805 pivot.filter_fields[0].multiple_selection = false;
1809 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1810 assert_eq!(grid.filter_rows[0].1, "Widget");
1811 }
1812
1813 #[test]
1814 fn test_col_axis_subtotal_group_gets_total_caption_and_grand_total_stays_outermost() {
1815 let sheet = source_sheet();
1820 let mut pivot = base_pivot();
1821 pivot.row_fields = vec![PivotField::new("Rep")];
1822 pivot.col_fields = vec![PivotField::new("Region"), PivotField::new("Product")];
1823 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1824
1825 let region_row = &grid.header_rows[1];
1828 assert!(region_row.contains(&"East Total".to_string()));
1829 assert!(region_row.contains(&"West Total".to_string()));
1830 assert!(region_row.contains(&"Grand Total".to_string()));
1831 let product_row = &grid.header_rows[2];
1832 assert_eq!(product_row.last().unwrap(), "");
1833 }
1834
1835 #[test]
1836 fn test_col_axis_subtotal_caption_uses_value_field_label_with_multiple_value_fields() {
1837 let sheet = source_sheet();
1842 let mut pivot = base_pivot();
1843 pivot.row_fields = vec![];
1844 pivot.col_fields = vec![PivotField::new("Region"), PivotField::new("Product")];
1845 pivot.value_fields = vec![
1846 PivotValueField::new("Amount", PivotAggregation::Min),
1847 PivotValueField::new("Amount", PivotAggregation::Sum),
1848 ];
1849 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1850
1851 let region_row = &grid.header_rows[1];
1854 assert!(region_row.contains(&"East Min of Amount".to_string()));
1859 assert!(region_row.contains(&"East Sum of Amount".to_string()));
1860 assert!(region_row.contains(&"West Min of Amount".to_string()));
1861 assert!(region_row.contains(&"West Sum of Amount".to_string()));
1862 assert!(
1863 !region_row
1864 .iter()
1865 .any(|c| c == "East Total" || c == "West Total")
1866 );
1867
1868 let value_label_row = grid.header_rows.last().unwrap();
1872 assert!(value_label_row.contains(&"Min of Amount".to_string()));
1873 assert!(value_label_row.contains(&"Sum of Amount".to_string()));
1874 let east_subtotal_idx = region_row
1875 .iter()
1876 .position(|c| c == "East Min of Amount")
1877 .unwrap();
1878 assert_eq!(value_label_row[east_subtotal_idx], "");
1879 assert_eq!(value_label_row[east_subtotal_idx + 1], "");
1880 }
1881
1882 #[test]
1883 fn test_col_axis_repeated_leaf_value_under_different_parents_is_not_falsely_merged() {
1884 let mut sheet = Sheet::new(SheetInit {
1887 name: Some("Data".to_string()),
1888 rows: 3,
1889 cols: 3,
1890 ..Default::default()
1891 });
1892 for (c, h) in ["Group", "Sub", "Amount"].iter().enumerate() {
1893 sheet.set_cell_src(0, c, h.to_string());
1894 }
1895 let rows: [[&str; 3]; 2] = [["GroupA", "X", "1"], ["GroupB", "X", "2"]];
1898 for (r, row) in rows.iter().enumerate() {
1899 for (c, v) in row.iter().enumerate() {
1900 sheet.set_cell_src(r + 1, c, v.to_string());
1901 }
1902 }
1903 sheet.commit(None).unwrap();
1904 sheet
1905 .add_table("Sales".to_string(), 0, 0, 2, 2, true, false)
1906 .unwrap();
1907
1908 let mut pivot = base_pivot();
1909 pivot.row_fields = vec![];
1910 pivot.col_fields = vec![PivotField::new("Group"), PivotField::new("Sub")];
1911 pivot.grand_totals_col = false;
1912 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1913
1914 let sub_row = &grid.header_rows[2];
1917 let x_count = sub_row.iter().filter(|c| *c == "X").count();
1918 assert_eq!(
1919 x_count, 2,
1920 "expected \"X\" under both GroupA and GroupB, got {sub_row:?}"
1921 );
1922 }
1923
1924 #[test]
1925 fn test_multiple_value_fields_become_column_labels() {
1926 let sheet = source_sheet();
1927 let mut pivot = base_pivot();
1928 pivot.value_fields = vec![
1929 PivotValueField::new("Amount", PivotAggregation::Sum),
1930 PivotValueField::new("Amount", PivotAggregation::Count),
1931 ];
1932 pivot.grand_totals_row = false;
1933 pivot.grand_totals_col = false;
1934 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1935 assert_eq!(grid.header_rows.last().unwrap()[1], "Sum of Amount");
1936 assert_eq!(grid.header_rows.last().unwrap()[2], "Count of Amount2");
1941 assert_eq!(grid.body_rows[0].values.len(), 2);
1942 assert_eq!(value_at(&grid.body_rows[0], 0), 50.0); assert_eq!(value_at(&grid.body_rows[0], 1), 4.0); }
1945
1946 #[test]
1947 fn test_row_labels_caption_replaces_outermost_row_field_name() {
1948 let sheet = source_sheet();
1953 let pivot = base_pivot(); let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1955 assert_eq!(grid.header_rows.last().unwrap()[0], "Row Labels");
1956 }
1957
1958 #[test]
1959 fn test_column_labels_row_prepended_and_deeper_row_field_keeps_its_name() {
1960 let sheet = source_sheet();
1961 let mut pivot = base_pivot();
1962 pivot.row_fields = vec![PivotField::new("Region"), PivotField::new("Product")];
1963 pivot.col_fields = vec![PivotField::new("Rep")];
1964 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1965
1966 assert!(grid.header_rows[0].iter().any(|c| c == "Column Labels"));
1970 let last = grid.header_rows.last().unwrap();
1974 assert_eq!(last[0], "Row Labels");
1975 assert_eq!(last[1], "Product");
1976 }
1977
1978 #[test]
1979 fn test_grand_total_column_shows_total_prefixed_value_label_with_multiple_value_fields() {
1980 let sheet = source_sheet();
1981 let mut pivot = base_pivot();
1982 pivot.col_fields = vec![PivotField::new("Product")];
1983 pivot.value_fields = vec![
1984 PivotValueField::new("Amount", PivotAggregation::Sum),
1985 PivotValueField::new("Amount", PivotAggregation::Min),
1986 ];
1987 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1988
1989 let col_values_row = &grid.header_rows[1];
1996 assert!(col_values_row.contains(&"Total Sum of Amount".to_string()));
1997 assert!(col_values_row.contains(&"Total Min of Amount2".to_string()));
1998 assert_eq!(grid.header_rows.last().unwrap().last().unwrap(), "");
2001 }
2002
2003 #[test]
2004 fn test_grand_total_still_shows_with_only_one_leaf_group() {
2005 let sheet = source_sheet();
2008 let mut pivot = base_pivot();
2009 pivot.filter_fields = vec![PivotFilterField {
2010 column: "Region".to_string(),
2011 selected_values: Some(vec!["East".to_string()]),
2012 multiple_selection: true,
2013 }];
2014 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
2015 assert!(grid.body_rows.iter().any(|r| r.is_grand_total));
2016 }
2017
2018 #[test]
2019 fn test_case_variant_values_merge_using_globally_first_seen_casing() {
2020 let mut sheet = Sheet::new(SheetInit {
2023 name: Some("Data".to_string()),
2024 rows: 5,
2025 cols: 3,
2026 ..Default::default()
2027 });
2028 for (c, h) in ["Group", "Mixed", "Amount"].iter().enumerate() {
2029 sheet.set_cell_src(0, c, h.to_string());
2030 }
2031 let rows: [[&str; 3]; 3] = [
2035 ["G1", "EAST", "10"],
2036 ["G1", "West", "20"],
2037 ["G2", "east", "30"],
2038 ];
2039 for (r, row) in rows.iter().enumerate() {
2040 for (c, v) in row.iter().enumerate() {
2041 sheet.set_cell_src(r + 1, c, v.to_string());
2042 }
2043 }
2044 sheet.commit(None).unwrap();
2045 sheet
2046 .add_table("Sales".to_string(), 0, 0, 3, 2, true, false)
2047 .unwrap();
2048
2049 let mut pivot = base_pivot();
2050 pivot.row_fields = vec![PivotField::new("Group"), PivotField::new("Mixed")];
2051 pivot.grand_totals_row = false;
2052 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
2053
2054 let mixed_labels: Vec<&str> = grid
2055 .body_rows
2056 .iter()
2057 .map(|r| r.row_labels[1].as_str())
2058 .filter(|l| !l.is_empty())
2059 .collect();
2060 assert!(
2061 mixed_labels.contains(&"EAST") && !mixed_labels.contains(&"east"),
2062 "expected every occurrence to use the globally first-seen casing \"EAST\", got {mixed_labels:?}"
2063 );
2064 }
2065
2066 #[test]
2067 fn test_case_canonicalization_uses_first_seen_casing_from_unfiltered_source_not_just_surviving_rows()
2068 {
2069 let mut sheet = Sheet::new(SheetInit {
2072 name: Some("Data".to_string()),
2073 rows: 4,
2074 cols: 3,
2075 ..Default::default()
2076 });
2077 for (c, h) in ["Cat", "Mixed", "Amount"].iter().enumerate() {
2078 sheet.set_cell_src(0, c, h.to_string());
2079 }
2080 let rows: [[&str; 3]; 3] = [
2085 ["Alpha", "WEST", "10"],
2086 ["Beta", "East", "20"],
2087 ["Beta", "west", "30"],
2088 ];
2089 for (r, row) in rows.iter().enumerate() {
2090 for (c, v) in row.iter().enumerate() {
2091 sheet.set_cell_src(r + 1, c, v.to_string());
2092 }
2093 }
2094 sheet.commit(None).unwrap();
2095 sheet
2096 .add_table("Sales".to_string(), 0, 0, 3, 2, true, false)
2097 .unwrap();
2098
2099 let mut pivot = base_pivot();
2100 pivot.row_fields = vec![PivotField::new("Mixed")];
2101 pivot.filter_fields = vec![PivotFilterField {
2102 column: "Cat".to_string(),
2103 selected_values: Some(vec!["Beta".to_string()]),
2104 multiple_selection: true,
2105 }];
2106 pivot.grand_totals_row = false;
2107 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
2108
2109 let labels: Vec<&str> = grid
2110 .body_rows
2111 .iter()
2112 .map(|r| r.row_labels[0].as_str())
2113 .collect();
2114 assert!(
2115 labels.contains(&"WEST") && !labels.contains(&"west"),
2116 "expected the filtered-out row's casing \"WEST\" to still win, got {labels:?}"
2117 );
2118 }
2119
2120 #[test]
2121 fn test_blank_group_sorts_last_even_among_numeric_siblings() {
2122 let mut sheet = Sheet::new(SheetInit {
2123 name: Some("Data".to_string()),
2124 rows: 4,
2125 cols: 2,
2126 ..Default::default()
2127 });
2128 for (c, h) in ["Code", "Amount"].iter().enumerate() {
2129 sheet.set_cell_src(0, c, h.to_string());
2130 }
2131 sheet.set_cell_src(1, 0, "30".to_string());
2137 sheet.set_cell_src(1, 1, "1".to_string());
2138 sheet.set_cell_src(3, 0, "10".to_string());
2139 sheet.set_cell_src(3, 1, "3".to_string());
2140 sheet.commit(None).unwrap();
2141 sheet
2142 .add_table("Sales".to_string(), 0, 0, 3, 1, true, false)
2143 .unwrap();
2144
2145 let mut pivot = base_pivot();
2146 pivot.row_fields = vec![PivotField::new("Code")];
2147 pivot.grand_totals_row = false;
2148 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
2149
2150 let codes: Vec<&str> = grid
2151 .body_rows
2152 .iter()
2153 .map(|r| r.row_labels[0].as_str())
2154 .collect();
2155 assert_eq!(codes, vec!["10", "30", "(blank)"]);
2156 }
2157
2158 #[test]
2159 fn test_negative_looking_text_sorts_last_among_text_siblings() {
2160 let mut sheet = Sheet::new(SheetInit {
2166 name: Some("Data".to_string()),
2167 rows: 6,
2168 cols: 2,
2169 ..Default::default()
2170 });
2171 for (c, h) in ["Code", "Amount"].iter().enumerate() {
2172 sheet.set_cell_src(0, c, h.to_string());
2173 }
2174 let rows: [(&str, &str); 5] = [
2175 ("\"-7\"", "1"),
2176 ("\".0152\"", "2"),
2177 ("\"13\"", "3"),
2178 ("\"34\"", "4"),
2179 ("\"4\"", "5"),
2180 ];
2181 for (r, (code, amount)) in rows.iter().enumerate() {
2182 sheet.set_cell_src(r + 1, 0, code.to_string());
2183 sheet.set_cell_src(r + 1, 1, amount.to_string());
2184 }
2185 sheet.commit(None).unwrap();
2186 sheet
2187 .add_table("Sales".to_string(), 0, 0, 5, 1, true, false)
2188 .unwrap();
2189
2190 let mut pivot = base_pivot();
2191 pivot.row_fields = vec![PivotField::new("Code")];
2192 pivot.grand_totals_row = false;
2193 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
2194
2195 let codes: Vec<&str> = grid
2196 .body_rows
2197 .iter()
2198 .map(|r| r.row_labels[0].as_str())
2199 .collect();
2200 assert_eq!(codes, vec![".0152", "13", "34", "4", "-7"]);
2201 }
2202
2203 #[test]
2204 fn test_negative_looking_text_sorts_by_stripped_digits_not_last() {
2205 let mut sheet = Sheet::new(SheetInit {
2213 name: Some("Data".to_string()),
2214 rows: 4,
2215 cols: 2,
2216 ..Default::default()
2217 });
2218 for (c, h) in ["Code", "Amount"].iter().enumerate() {
2219 sheet.set_cell_src(0, c, h.to_string());
2220 }
2221 let rows: [(&str, &str); 3] = [("\"12\"", "1"), ("\"37\"", "2"), ("\"-25\"", "3")];
2222 for (r, (code, amount)) in rows.iter().enumerate() {
2223 sheet.set_cell_src(r + 1, 0, code.to_string());
2224 sheet.set_cell_src(r + 1, 1, amount.to_string());
2225 }
2226 sheet.commit(None).unwrap();
2227 sheet
2228 .add_table("Sales".to_string(), 0, 0, 3, 1, true, false)
2229 .unwrap();
2230
2231 let mut pivot = base_pivot();
2232 pivot.row_fields = vec![PivotField::new("Code")];
2233 pivot.grand_totals_row = false;
2234 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
2235
2236 let codes: Vec<&str> = grid
2237 .body_rows
2238 .iter()
2239 .map(|r| r.row_labels[0].as_str())
2240 .collect();
2241 assert_eq!(codes, vec!["12", "-25", "37"]);
2242 }
2243
2244 #[test]
2245 fn test_empty_row_col_intersection_renders_blank_not_zero_or_error() {
2246 let mut sheet = Sheet::new(SheetInit {
2251 name: Some("Data".to_string()),
2252 rows: 3,
2253 cols: 3,
2254 ..Default::default()
2255 });
2256 for (c, h) in ["Region", "Product", "Amount"].iter().enumerate() {
2257 sheet.set_cell_src(0, c, h.to_string());
2258 }
2259 let rows: [[&str; 3]; 2] = [["East", "Widget", "10"], ["West", "Gadget", "20"]];
2263 for (r, row) in rows.iter().enumerate() {
2264 for (c, v) in row.iter().enumerate() {
2265 sheet.set_cell_src(r + 1, c, v.to_string());
2266 }
2267 }
2268 sheet.commit(None).unwrap();
2269 sheet
2270 .add_table("Sales".to_string(), 0, 0, 2, 2, true, false)
2271 .unwrap();
2272
2273 let mut pivot = base_pivot();
2274 pivot.col_fields = vec![PivotField::new("Product")];
2275 pivot.value_fields = vec![
2276 PivotValueField::new("Amount", PivotAggregation::Sum),
2277 PivotValueField::new("Amount", PivotAggregation::Average),
2278 ];
2279 pivot.grand_totals_row = false;
2280 pivot.grand_totals_col = false;
2281 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
2282
2283 let east_row = grid
2286 .body_rows
2287 .iter()
2288 .find(|r| r.row_labels[0] == "East")
2289 .unwrap();
2290 for v in &east_row.values[..2] {
2291 assert!(
2292 matches!(v, ResultData::None),
2293 "expected blank for an empty intersection, got {v:?}"
2294 );
2295 }
2296 }
2297
2298 #[test]
2299 fn test_value_field_labels_distinct_aggregations_without_sum_stay_unsuffixed() {
2300 let fields = vec![
2305 PivotValueField::new("Amount", PivotAggregation::Count),
2306 PivotValueField::new("Amount", PivotAggregation::Max),
2307 ];
2308 assert_eq!(
2309 value_field_labels(&fields),
2310 vec!["Count of Amount".to_string(), "Max of Amount".to_string()]
2311 );
2312 }
2313
2314 #[test]
2315 fn test_value_field_labels_sum_clones_column_for_later_fields() {
2316 let fields = vec![
2321 PivotValueField::new("Amount", PivotAggregation::Sum),
2322 PivotValueField::new("Rate", PivotAggregation::Average),
2323 PivotValueField::new("Amount", PivotAggregation::Min),
2324 PivotValueField::new("Amount", PivotAggregation::Max),
2325 ];
2326 assert_eq!(
2327 value_field_labels(&fields),
2328 vec![
2329 "Sum of Amount".to_string(),
2330 "Average of Rate".to_string(),
2331 "Min of Amount2".to_string(),
2332 "Max of Amount2".to_string(),
2333 ]
2334 );
2335 }
2336
2337 #[test]
2338 fn test_value_field_labels_second_sum_clones_again() {
2339 let fields = vec![
2343 PivotValueField::new("Amount", PivotAggregation::Sum),
2344 PivotValueField::new("Amount", PivotAggregation::Sum),
2345 PivotValueField::new("Amount", PivotAggregation::Count),
2346 ];
2347 assert_eq!(
2348 value_field_labels(&fields),
2349 vec![
2350 "Sum of Amount".to_string(),
2351 "Sum of Amount2".to_string(),
2352 "Count of Amount3".to_string(),
2353 ]
2354 );
2355 }
2356
2357 #[test]
2358 fn test_value_field_labels_disambiguates_identical_aggregation_and_column() {
2359 let fields = vec![
2364 PivotValueField::new("Amount", PivotAggregation::Sum),
2365 PivotValueField::new("Amount", PivotAggregation::Sum),
2366 PivotValueField::new("Amount", PivotAggregation::Sum),
2367 ];
2368 assert_eq!(
2369 value_field_labels(&fields),
2370 vec![
2371 "Sum of Amount".to_string(),
2372 "Sum of Amount2".to_string(),
2373 "Sum of Amount3".to_string(),
2374 ]
2375 );
2376 }
2377
2378 #[test]
2379 fn test_value_field_labels_collision_within_sum_clone_uses_underscore_suffix() {
2380 let fields = vec![
2387 PivotValueField::new("Amount", PivotAggregation::Sum),
2388 PivotValueField::new("Amount", PivotAggregation::Max),
2389 PivotValueField::new("Amount", PivotAggregation::Max),
2390 ];
2391 assert_eq!(
2392 value_field_labels(&fields),
2393 vec![
2394 "Sum of Amount".to_string(),
2395 "Max of Amount2".to_string(),
2396 "Max of Amount2_2".to_string(),
2397 ]
2398 );
2399 }
2400
2401 #[test]
2402 fn test_value_field_labels_count_numbers_shares_plain_count_caption() {
2403 let fields = vec![
2409 PivotValueField::new("Rate", PivotAggregation::CountNumbers),
2410 PivotValueField::new("Rate", PivotAggregation::Count),
2411 ];
2412 assert_eq!(
2413 value_field_labels(&fields),
2414 vec!["Count of Rate".to_string(), "Count of Rate2".to_string()]
2415 );
2416 }
2417
2418 #[test]
2419 fn test_value_field_labels_leaves_custom_name_untouched() {
2420 let mut fields = vec![
2421 PivotValueField::new("Amount", PivotAggregation::Sum),
2422 PivotValueField::new("Amount", PivotAggregation::Min),
2423 ];
2424 fields[1].custom_name = Some("Lowest Amount".to_string());
2425 assert_eq!(
2426 value_field_labels(&fields),
2427 vec!["Sum of Amount".to_string(), "Lowest Amount".to_string()]
2428 );
2429 }
2430
2431 #[test]
2432 fn test_flat_pivot_with_no_row_or_col_fields_has_no_reserved_label_column() {
2433 let sheet = source_sheet();
2439 let mut pivot = base_pivot();
2440 pivot.row_fields = vec![];
2441 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
2442
2443 assert_eq!(grid.width, 1);
2444 assert_eq!(
2445 grid.header_rows.last().unwrap(),
2446 &vec!["Sum of Amount".to_string()]
2447 );
2448 assert_eq!(grid.body_rows.len(), 1);
2449 assert!(grid.body_rows[0].row_labels.is_empty());
2450 assert_eq!(value_at(&grid.body_rows[0], 0), 195.0);
2451 }
2452
2453 #[test]
2454 fn test_no_row_fields_with_multiple_value_fields_has_no_reserved_label_column_either() {
2455 let sheet = source_sheet();
2463 let mut pivot = base_pivot();
2464 pivot.row_fields = vec![];
2465 pivot.col_fields = vec![PivotField::new("Product")];
2466 pivot.value_fields = vec![
2467 PivotValueField::new("Amount", PivotAggregation::Sum),
2468 PivotValueField::new("Amount", PivotAggregation::Count),
2469 ];
2470 pivot.grand_totals_col = false;
2471 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
2472
2473 assert_eq!(grid.width, 4);
2476 assert_eq!(grid.body_rows.len(), 1);
2477 assert!(grid.body_rows[0].row_labels.is_empty());
2478 }
2479
2480 #[test]
2481 fn test_multiple_value_fields_with_no_column_fields_share_one_header_row() {
2482 let sheet = source_sheet();
2487 let mut pivot = base_pivot();
2488 pivot.value_fields = vec![
2489 PivotValueField::new("Amount", PivotAggregation::Sum),
2490 PivotValueField::new("Amount", PivotAggregation::Count),
2491 ];
2492 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
2493
2494 assert_eq!(grid.header_rows.len(), 1);
2495 assert_eq!(
2498 grid.header_rows[0],
2499 vec![
2500 "Row Labels".to_string(),
2501 "Sum of Amount".to_string(),
2502 "Count of Amount2".to_string(),
2503 ]
2504 );
2505 }
2506
2507 #[test]
2508 fn test_missing_column_errors() {
2509 let sheet = source_sheet();
2510 let mut pivot = base_pivot();
2511 pivot.row_fields = vec![PivotField::new("Nope")];
2512 let err = compute_pivot(&[&sheet], &pivot).unwrap_err();
2513 assert!(err.contains("not found"));
2514 }
2515
2516 #[test]
2517 fn test_range_source_matches_table_source() {
2518 let sheet = source_sheet();
2522 let mut pivot = base_pivot();
2523 pivot.source = PivotSource::Range {
2524 sheet_id: sheet.id,
2525 start_row: 0,
2526 start_col: 0,
2527 end_row: 8,
2528 end_col: 3,
2529 };
2530 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
2531 assert_eq!(grid.body_rows.len(), 3);
2532 assert_eq!(value_at(&grid.body_rows[0], 0), 50.0);
2533 assert_eq!(value_at(&grid.body_rows[1], 0), 145.0);
2534 assert_eq!(value_at(&grid.body_rows[2], 0), 195.0);
2535 }
2536
2537 #[test]
2538 fn test_zero_data_rows_produces_empty_grid_without_panicking() {
2539 let mut sheet = Sheet::new(SheetInit {
2540 name: Some("Empty".to_string()),
2541 rows: 1,
2542 cols: 2,
2543 ..Default::default()
2544 });
2545 sheet.set_cell_src(0, 0, "Region".to_string());
2546 sheet.set_cell_src(0, 1, "Amount".to_string());
2547 sheet.commit(None).unwrap();
2548 sheet
2549 .add_table("Empty".to_string(), 0, 0, 0, 1, true, false)
2550 .unwrap();
2551
2552 let pivot = PivotTable {
2553 id: 1,
2554 name: "EmptyPivot".to_string(),
2555 source: PivotSource::Table {
2556 name: "Empty".to_string(),
2557 },
2558 dest_sheet_id: sheet.id,
2559 dest_row: 0,
2560 dest_col: 0,
2561 row_fields: vec![PivotField::new("Region")],
2562 col_fields: vec![],
2563 value_fields: vec![PivotValueField::new("Amount", PivotAggregation::Sum)],
2564 filter_fields: vec![],
2565 grand_totals_row: true,
2566 grand_totals_col: true,
2567 last_output_end_row: None,
2568 last_output_end_col: None,
2569 };
2570 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
2571 assert!(grid.body_rows.is_empty());
2575 assert!(grid.row_axis.is_empty());
2576 }
2577
2578 use rand::rngs::StdRng;
2589 use rand::{Rng, SeedableRng};
2590
2591 const FUZZ_COLS: [&str; 6] = ["Cat", "Mixed", "NumStr", "Amount", "Rate", "Flag"];
2592 const FUZZ_CATEGORIES: [&str; 5] = ["Alpha", "Beta", "Gamma", "Delta", "Epsilon"];
2593 const FUZZ_CASE_VARIANTS: [&str; 5] = ["East", "east", "WEST", "west", "North"];
2594
2595 fn fuzz_source_sheet(rng: &mut StdRng, num_rows: usize) -> (Sheet, Vec<String>) {
2603 let mut sheet = Sheet::new(SheetInit {
2604 name: Some("FuzzData".to_string()),
2605 rows: num_rows + 1,
2606 cols: FUZZ_COLS.len(),
2607 ..Default::default()
2608 });
2609 for (c, h) in FUZZ_COLS.iter().enumerate() {
2610 sheet.set_cell_src(0, c, h.to_string());
2611 }
2612 for r in 0..num_rows {
2613 let cat = if rng.gen_bool(0.1) {
2614 String::new()
2615 } else {
2616 FUZZ_CATEGORIES[rng.gen_range(0..FUZZ_CATEGORIES.len())].to_string()
2617 };
2618 sheet.set_cell_src(r + 1, 0, cat);
2619
2620 let mixed = FUZZ_CASE_VARIANTS[rng.gen_range(0..FUZZ_CASE_VARIANTS.len())].to_string();
2621 sheet.set_cell_src(r + 1, 1, mixed);
2622
2623 let numstr = match rng.gen_range(0u8..4u8) {
2624 0 => String::new(),
2625 1 => format!("\"0{}\"", rng.gen_range(0u32..10u32)),
2626 2 => format!("\".0{}\"", rng.gen_range(0u32..1000u32)),
2627 _ => format!("\"{}\"", rng.gen_range(-50i64..50i64)),
2628 };
2629 sheet.set_cell_src(r + 1, 2, numstr);
2630
2631 sheet.set_cell_src(r + 1, 3, rng.gen_range(-100i64..=100i64).to_string());
2632
2633 let rate =
2634 (rng.gen_range(-500i64..=500i64) as f64) / (rng.gen_range(1i64..=100i64) as f64);
2635 sheet.set_cell_src(r + 1, 4, format!("{:.4}", rate));
2636
2637 sheet.set_cell_src(r + 1, 5, rng.gen_bool(0.5).to_string());
2638 }
2639 sheet.commit(None).unwrap();
2640 (sheet, FUZZ_COLS.iter().map(|s| s.to_string()).collect())
2641 }
2642
2643 fn random_aggregation(rng: &mut StdRng) -> PivotAggregation {
2644 match rng.gen_range(0u8..6u8) {
2645 0 => PivotAggregation::Sum,
2646 1 => PivotAggregation::Count,
2647 2 => PivotAggregation::CountNumbers,
2648 3 => PivotAggregation::Average,
2649 4 => PivotAggregation::Max,
2650 _ => PivotAggregation::Min,
2651 }
2652 }
2653
2654 fn fuzz_pivot_config(
2661 rng: &mut StdRng,
2662 sheet: &Sheet,
2663 col_names: &[String],
2664 num_rows: usize,
2665 use_table: bool,
2666 ) -> PivotTable {
2667 let mut pool: Vec<usize> = vec![0, 1, 2]; let numeric: [usize; 2] = [3, 4]; let n_row = rng.gen_range(0..=pool.len().min(2));
2671 let row_cols: Vec<usize> = (0..n_row)
2672 .map(|_| pool.remove(rng.gen_range(0..pool.len())))
2673 .collect();
2674 let n_col = rng.gen_range(0..=pool.len().min(2));
2675 let col_cols: Vec<usize> = (0..n_col)
2676 .map(|_| pool.remove(rng.gen_range(0..pool.len())))
2677 .collect();
2678
2679 let row_fields: Vec<PivotField> = row_cols
2680 .iter()
2681 .map(|&i| PivotField {
2682 column: col_names[i].clone(),
2683 subtotal: rng.gen_bool(0.7),
2684 })
2685 .collect();
2686 let col_fields: Vec<PivotField> = col_cols
2687 .iter()
2688 .map(|&i| PivotField {
2689 column: col_names[i].clone(),
2690 subtotal: rng.gen_bool(0.7),
2691 })
2692 .collect();
2693
2694 let n_value = rng.gen_range(1..=2);
2695 let value_fields: Vec<PivotValueField> = (0..n_value)
2696 .map(|_| {
2697 let col = numeric[rng.gen_range(0..numeric.len())];
2698 PivotValueField::new(col_names[col].clone(), random_aggregation(rng))
2699 })
2700 .collect();
2701
2702 let mut filter_fields = Vec::new();
2703 if rng.gen_bool(0.5) {
2704 let candidates = [0usize, 1, 2, 5];
2705 let fcol = candidates[rng.gen_range(0..candidates.len())];
2706 let mut distinct: Vec<String> = (1..=num_rows)
2707 .map(|r| group_key(&sheet.get_result_data(&CellRef::new(r, fcol))))
2708 .collect();
2709 distinct.sort();
2710 distinct.dedup();
2711 let selected = if distinct.is_empty() || rng.gen_bool(0.2) {
2712 None
2713 } else {
2714 Some(distinct.into_iter().filter(|_| rng.gen_bool(0.5)).collect())
2716 };
2717 filter_fields.push(PivotFilterField {
2718 column: col_names[fcol].clone(),
2719 selected_values: selected,
2720 multiple_selection: true,
2721 });
2722 }
2723
2724 let source = if use_table {
2725 PivotSource::Table {
2726 name: "FuzzTable".to_string(),
2727 }
2728 } else {
2729 PivotSource::Range {
2730 sheet_id: sheet.id,
2731 start_row: 0,
2732 start_col: 0,
2733 end_row: num_rows,
2734 end_col: col_names.len() - 1,
2735 }
2736 };
2737
2738 PivotTable {
2739 id: 1,
2740 name: "FuzzPivot".to_string(),
2741 source,
2742 dest_sheet_id: sheet.id,
2743 dest_row: num_rows + 20,
2744 dest_col: 0,
2745 row_fields,
2746 col_fields,
2747 value_fields,
2748 filter_fields,
2749 grand_totals_row: rng.gen_bool(0.7),
2750 grand_totals_col: rng.gen_bool(0.7),
2751 last_output_end_row: None,
2752 last_output_end_col: None,
2753 }
2754 }
2755
2756 fn results_close(a: &ResultData, b: &ResultData) -> bool {
2757 match (a, b) {
2758 (ResultData::Integer(x), ResultData::Integer(y)) => x == y,
2759 (ResultData::Float(x), ResultData::Float(y)) => (x - y).abs() < 1e-6,
2760 (ResultData::Integer(x), ResultData::Float(y))
2761 | (ResultData::Float(y), ResultData::Integer(x)) => (*x as f64 - y).abs() < 1e-6,
2762 (ResultData::None, ResultData::None) => true,
2763 (ResultData::Error(x), ResultData::Error(y)) => x == y,
2764 (ResultData::String(x), ResultData::String(y)) => x == y,
2765 (ResultData::Boolean(x), ResultData::Boolean(y)) => x == y,
2766 _ => false,
2767 }
2768 }
2769
2770 fn matches_partial(key: &[String], labels: &[Option<String>]) -> bool {
2776 key.iter()
2780 .zip(labels)
2781 .all(|(k, want)| want.as_ref().is_none_or(|w| w.eq_ignore_ascii_case(k)))
2782 }
2783
2784 fn verify_grid_matches_records(sheet: &Sheet, pivot: &PivotTable, grid: &PivotGrid) {
2792 let (_, col_names, sheet_cols, data_rows) =
2793 resolve_source(&[sheet], &pivot.source).unwrap();
2794 let row_idxs: Vec<usize> = pivot
2795 .row_fields
2796 .iter()
2797 .map(|f| column_index(&col_names, &f.column).unwrap())
2798 .collect();
2799 let col_idxs: Vec<usize> = pivot
2800 .col_fields
2801 .iter()
2802 .map(|f| column_index(&col_names, &f.column).unwrap())
2803 .collect();
2804
2805 let mut records: Vec<(Vec<String>, Vec<String>, Vec<ResultData>)> = Vec::new();
2806 'row: for &r in &data_rows {
2807 let row_vals: Vec<ResultData> = sheet_cols
2808 .iter()
2809 .map(|&c| sheet.get_result_data(&CellRef::new(r, c)))
2810 .collect();
2811 for ff in &pivot.filter_fields {
2812 if let Some(selected) = &ff.selected_values {
2813 let idx = column_index(&col_names, &ff.column).unwrap();
2814 let key = group_key(&row_vals[idx]);
2815 if !selected.iter().any(|v| v.eq_ignore_ascii_case(&key)) {
2819 continue 'row;
2820 }
2821 }
2822 }
2823 let row_key: Vec<String> = row_idxs.iter().map(|&i| group_key(&row_vals[i])).collect();
2824 let col_key: Vec<String> = col_idxs.iter().map(|&i| group_key(&row_vals[i])).collect();
2825 records.push((row_key, col_key, row_vals));
2826 }
2827
2828 let value_idxs: Vec<usize> = pivot
2829 .value_fields
2830 .iter()
2831 .map(|vf| column_index(&col_names, &vf.column).unwrap())
2832 .collect();
2833 let value_multiplier = if pivot.value_fields.len() > 1 {
2834 pivot.value_fields.len()
2835 } else {
2836 1
2837 };
2838 let width = row_label_width(pivot);
2839
2840 assert_eq!(grid.body_rows.len(), grid.row_axis.len());
2841 assert_eq!(grid.width, width + grid.col_axis.len() * value_multiplier);
2842 for hrow in &grid.header_rows {
2843 assert_eq!(hrow.len(), grid.width);
2844 }
2845
2846 for (i, (body_row, row_axis)) in grid.body_rows.iter().zip(grid.row_axis.iter()).enumerate()
2847 {
2848 assert_eq!(
2849 body_row.is_grand_total, row_axis.is_grand_total,
2850 "row {i} grand-total flag mismatch"
2851 );
2852 assert_eq!(body_row.row_labels.len(), width, "row {i} label width");
2853 assert_eq!(
2854 body_row.values.len(),
2855 grid.col_axis.len() * value_multiplier,
2856 "row {i} value count"
2857 );
2858
2859 for (j, col_axis) in grid.col_axis.iter().enumerate() {
2860 let matching: Vec<&Vec<ResultData>> = records
2861 .iter()
2862 .filter(|(rk, ck, _)| {
2863 matches_partial(rk, &row_axis.labels)
2864 && matches_partial(ck, &col_axis.labels)
2865 })
2866 .map(|(_, _, row)| row)
2867 .collect();
2868
2869 for (vf_pos, &vidx) in value_idxs.iter().enumerate() {
2870 if vf_pos > 0 && value_multiplier == 1 {
2871 break;
2872 }
2873 let col_vals: Vec<ResultData> =
2874 matching.iter().map(|row| row[vidx].clone()).collect();
2875 let expected =
2876 aggregate(sheet, &col_vals, pivot.value_fields[vf_pos].aggregation);
2877 let actual = &body_row.values[j * value_multiplier + vf_pos];
2878 assert!(
2879 results_close(&expected, actual),
2880 "row {i} col {j} value-field {vf_pos}: expected {expected:?}, got {actual:?} \
2881 (row_labels={:?}, col_labels={:?})",
2882 row_axis.labels,
2883 col_axis.labels,
2884 );
2885 }
2886 }
2887 }
2888 }
2889
2890 fn verify_grand_total_placement(
2897 axis: &[PivotAxisItem],
2898 grand_total_requested: bool,
2899 axis_has_fields: bool,
2900 label: &str,
2901 ) {
2902 let grand_count = axis.iter().filter(|a| a.is_grand_total).count();
2903 let has_any_real_group = axis.iter().any(|a| !a.is_grand_total);
2904 assert!(grand_count <= 1, "{label}: more than one grand-total group");
2905 if grand_total_requested && axis_has_fields && has_any_real_group {
2906 assert_eq!(
2907 grand_count, 1,
2908 "{label}: expected a grand total to be appended"
2909 );
2910 } else {
2911 assert_eq!(grand_count, 0, "{label}: did not expect a grand total");
2912 }
2913 }
2914
2915 #[test]
2916 fn test_fuzz_pivot_random_invariants() {
2917 for seed in 0u64..300 {
2918 let mut rng: StdRng = SeedableRng::seed_from_u64(seed);
2919 let use_table = seed % 2 == 0;
2920 let num_rows = rng.gen_range(0..=40usize);
2922 let (mut sheet, col_names) = fuzz_source_sheet(&mut rng, num_rows);
2923 if use_table {
2924 sheet
2925 .add_table(
2926 "FuzzTable".to_string(),
2927 0,
2928 0,
2929 num_rows,
2930 col_names.len() - 1,
2931 true,
2932 false,
2933 )
2934 .unwrap();
2935 }
2936 let pivot = fuzz_pivot_config(&mut rng, &sheet, &col_names, num_rows, use_table);
2937
2938 let grid = compute_pivot(&[&sheet], &pivot)
2939 .unwrap_or_else(|e| panic!("seed {seed}: compute_pivot failed: {e}"));
2940
2941 verify_grid_matches_records(&sheet, &pivot, &grid);
2942 verify_grand_total_placement(
2943 &grid.row_axis,
2944 pivot.grand_totals_row,
2945 !pivot.row_fields.is_empty(),
2946 "row axis",
2947 );
2948 verify_grand_total_placement(
2949 &grid.col_axis,
2950 pivot.grand_totals_col,
2951 !pivot.col_fields.is_empty(),
2952 "col axis",
2953 );
2954
2955 let xlsx = crate::core::xlsx::export_xlsx_data(
2960 std::slice::from_ref(&sheet),
2961 &[],
2962 std::slice::from_ref(&pivot),
2963 None,
2964 )
2965 .unwrap_or_else(|e| panic!("seed {seed}: export failed: {e}"));
2966 let (imported_sheets, _, imported_pivots, _) =
2967 crate::core::xlsx::import_xlsx_data(&xlsx, &[], |_, _, _| {})
2968 .unwrap_or_else(|e| panic!("seed {seed}: import failed: {e}"));
2969 assert_eq!(
2970 imported_pivots.len(),
2971 1,
2972 "seed {seed}: pivot lost on round-trip"
2973 );
2974 let reimported = &imported_pivots[0];
2975
2976 assert_eq!(
2977 reimported
2978 .row_fields
2979 .iter()
2980 .map(|f| &f.column)
2981 .collect::<Vec<_>>(),
2982 pivot
2983 .row_fields
2984 .iter()
2985 .map(|f| &f.column)
2986 .collect::<Vec<_>>(),
2987 "seed {seed}: row field columns changed on round-trip"
2988 );
2989 assert_eq!(
2990 reimported
2991 .col_fields
2992 .iter()
2993 .map(|f| &f.column)
2994 .collect::<Vec<_>>(),
2995 pivot
2996 .col_fields
2997 .iter()
2998 .map(|f| &f.column)
2999 .collect::<Vec<_>>(),
3000 "seed {seed}: col field columns changed on round-trip"
3001 );
3002 assert_eq!(
3003 reimported
3004 .value_fields
3005 .iter()
3006 .map(|f| (&f.column, f.aggregation))
3007 .collect::<Vec<_>>(),
3008 pivot
3009 .value_fields
3010 .iter()
3011 .map(|f| (&f.column, f.aggregation))
3012 .collect::<Vec<_>>(),
3013 "seed {seed}: value fields changed on round-trip"
3014 );
3015 assert_eq!(reimported.grand_totals_row, pivot.grand_totals_row);
3016 assert_eq!(reimported.grand_totals_col, pivot.grand_totals_col);
3017 assert_eq!(
3018 reimported
3019 .row_fields
3020 .iter()
3021 .map(|f| f.subtotal)
3022 .collect::<Vec<_>>(),
3023 pivot
3024 .row_fields
3025 .iter()
3026 .map(|f| f.subtotal)
3027 .collect::<Vec<_>>(),
3028 "seed {seed}: row field subtotal toggle should round-trip"
3029 );
3030 assert_eq!(
3031 reimported
3032 .col_fields
3033 .iter()
3034 .map(|f| f.subtotal)
3035 .collect::<Vec<_>>(),
3036 pivot
3037 .col_fields
3038 .iter()
3039 .map(|f| f.subtotal)
3040 .collect::<Vec<_>>(),
3041 "seed {seed}: col field subtotal toggle should round-trip"
3042 );
3043 let nothing_lossy = true;
3053 let any_filter_is_also_an_axis_field = pivot.filter_fields.iter().any(|ff| {
3054 pivot
3055 .row_fields
3056 .iter()
3057 .chain(pivot.col_fields.iter())
3058 .any(|f| f.column.eq_ignore_ascii_case(&ff.column))
3059 });
3060 let reimported_sheets: Vec<Sheet> =
3061 imported_sheets.into_iter().map(|s| s.sheet).collect();
3062 let reimported_sheet_refs: Vec<&Sheet> = reimported_sheets.iter().collect();
3063 let reimported_grid = compute_pivot(&reimported_sheet_refs, reimported)
3064 .unwrap_or_else(|e| panic!("seed {seed}: reimported compute_pivot failed: {e}"));
3065 if nothing_lossy && !any_filter_is_also_an_axis_field {
3069 assert_eq!(
3070 reimported_grid.body_rows.len(),
3071 grid.body_rows.len(),
3072 "seed {seed}: grid shape changed on lossless round-trip"
3073 );
3074 }
3075
3076 let sorted = |f: &PivotFilterField| {
3093 f.selected_values.as_ref().map(|v| {
3094 let mut v: Vec<String> = v.iter().map(|s| s.to_lowercase()).collect();
3095 v.sort();
3096 v.dedup();
3097 v
3098 })
3099 };
3100 let axis_bound = |column: &str| {
3108 pivot
3109 .row_fields
3110 .iter()
3111 .chain(pivot.col_fields.iter())
3112 .any(|f| f.column.eq_ignore_ascii_case(column))
3113 };
3114 for (before, after) in pivot
3115 .filter_fields
3116 .iter()
3117 .zip(reimported.filter_fields.iter())
3118 {
3119 if axis_bound(&before.column) {
3120 continue;
3121 }
3122 if before.selected_values.is_some() && after.selected_values.is_none() {
3123 assert_eq!(
3124 reimported_grid.body_rows.len(),
3125 grid.body_rows.len(),
3126 "seed {seed}: filter on '{}' was dropped and it mattered",
3127 before.column
3128 );
3129 } else {
3130 assert_eq!(
3131 sorted(before),
3132 sorted(after),
3133 "seed {seed}: filter selection should round-trip for '{}'",
3134 before.column
3135 );
3136 }
3137 }
3138 }
3139 }
3140}