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 {
337 if !pivot.row_fields.is_empty() {
338 return pivot.row_fields.len();
339 }
340 if pivot.value_fields.len() == 1 && !pivot.col_fields.is_empty() {
341 1
342 } else {
343 0
344 }
345}
346
347#[derive(Debug, Clone)]
356pub struct PivotGrid {
357 pub filter_rows: Vec<(String, String)>,
365 pub header_rows: Vec<Vec<String>>,
368 pub body_rows: Vec<PivotBodyRow>,
371 pub width: usize,
375 pub row_axis: Vec<PivotAxisItem>,
380 pub col_axis: Vec<PivotAxisItem>,
382}
383
384#[derive(Debug, Clone)]
387pub struct PivotBodyRow {
388 pub row_labels: Vec<String>,
391 pub is_grand_total: bool,
393 pub values: Vec<ResultData>,
395}
396
397#[derive(Debug, Clone)]
401pub struct PivotAxisItem {
402 pub labels: Vec<Option<String>>,
404 pub is_subtotal: bool,
406 pub is_grand_total: bool,
408}
409
410impl PivotGrid {
411 pub fn grid_row_offset(&self) -> usize {
415 if self.filter_rows.is_empty() {
416 0
417 } else {
418 self.filter_rows.len() + 1
419 }
420 }
421
422 pub fn height(&self) -> usize {
425 self.grid_row_offset() + self.header_rows.len() + self.body_rows.len()
426 }
427}
428
429struct FlatGroup {
435 labels: Vec<Option<String>>,
438 record_indices: Vec<usize>,
439 is_subtotal: bool,
440 is_grand_total: bool,
441}
442
443struct GroupNode {
444 label: String,
445 record_indices: Vec<usize>,
446 children: Vec<GroupNode>,
447}
448
449pub(crate) fn group_key(result: &ResultData) -> String {
450 match result {
451 ResultData::None => "(blank)".to_string(),
452 ResultData::String(s) if s.is_empty() => "(blank)".to_string(),
453 other => other.to_string(),
454 }
455}
456
457pub(crate) fn field_is_numeric(records: &[Vec<ResultData>], field_idx: usize) -> bool {
473 !records.is_empty()
474 && records.iter().all(|r| {
475 matches!(
476 r.get(field_idx),
477 Some(ResultData::Integer(_)) | Some(ResultData::Float(_)) | Some(ResultData::None)
478 )
479 })
480}
481
482fn text_sort_key(s: &str) -> String {
499 let trimmed = s.trim();
500 let key = match trimmed.strip_prefix('-') {
501 Some(rest) if rest.starts_with(|c: char| c.is_ascii_digit()) => rest,
502 _ => trimmed,
503 };
504 key.to_lowercase()
505}
506
507fn sort_group_entries(pairs: &mut [(String, Vec<usize>)], numeric: bool) {
508 pairs.sort_by(|a, b| match (a.0 == "(blank)", b.0 == "(blank)") {
509 (true, true) => std::cmp::Ordering::Equal,
510 (true, false) => std::cmp::Ordering::Greater,
511 (false, true) => std::cmp::Ordering::Less,
512 (false, false) if numeric => {
513 let fa: f64 = a.0.trim().parse().unwrap_or(0.0);
514 let fb: f64 = b.0.trim().parse().unwrap_or(0.0);
515 fa.partial_cmp(&fb).unwrap_or(std::cmp::Ordering::Equal)
516 }
517 (false, false) => text_sort_key(&a.0).cmp(&text_sort_key(&b.0)),
518 });
519}
520
521fn build_group_tree(
522 indices: &[usize],
523 keys: &[Vec<String>],
524 depth: usize,
525 num_fields: usize,
526 numeric_by_depth: &[bool],
527) -> Vec<GroupNode> {
528 let mut groups: Vec<(String, Vec<usize>)> = Vec::new();
529 for &idx in indices {
530 let key = &keys[idx][depth];
531 if let Some(entry) = groups.iter_mut().find(|(k, _)| k.eq_ignore_ascii_case(key)) {
532 entry.1.push(idx);
533 } else {
534 groups.push((key.clone(), vec![idx]));
535 }
536 }
537 sort_group_entries(&mut groups, numeric_by_depth[depth]);
538 groups
539 .into_iter()
540 .map(|(label, idxs)| {
541 let children = if depth + 1 < num_fields {
542 build_group_tree(&idxs, keys, depth + 1, num_fields, numeric_by_depth)
543 } else {
544 Vec::new()
545 };
546 GroupNode {
547 label,
548 record_indices: idxs,
549 children,
550 }
551 })
552 .collect()
553}
554
555fn flatten_groups(
559 nodes: &[GroupNode],
560 fields: &[PivotField],
561 depth: usize,
562 num_fields: usize,
563 prefix: &[Option<String>],
564 out: &mut Vec<FlatGroup>,
565) {
566 for node in nodes {
567 let mut labels = prefix.to_vec();
568 labels.push(Some(node.label.clone()));
569
570 if node.children.is_empty() {
571 let mut leaf_labels = labels.clone();
572 leaf_labels.resize(num_fields, None);
573 out.push(FlatGroup {
574 labels: leaf_labels,
575 record_indices: node.record_indices.clone(),
576 is_subtotal: false,
577 is_grand_total: false,
578 });
579 } else {
580 flatten_groups(&node.children, fields, depth + 1, num_fields, &labels, out);
581 let is_innermost = depth + 1 >= num_fields;
582 if fields[depth].subtotal && !is_innermost {
583 let mut subtotal_labels = labels.clone();
584 subtotal_labels.resize(num_fields, None);
585 out.push(FlatGroup {
586 labels: subtotal_labels,
587 record_indices: node.record_indices.clone(),
588 is_subtotal: true,
589 is_grand_total: false,
590 });
591 }
592 }
593 }
594}
595
596fn build_axis(
600 record_indices: &[usize],
601 keys: &[Vec<String>],
602 fields: &[PivotField],
603 grand_total: bool,
604 numeric_by_depth: &[bool],
605) -> Vec<FlatGroup> {
606 if fields.is_empty() {
607 return vec![FlatGroup {
608 labels: Vec::new(),
609 record_indices: record_indices.to_vec(),
610 is_subtotal: false,
611 is_grand_total: false,
612 }];
613 }
614 let tree = build_group_tree(record_indices, keys, 0, fields.len(), numeric_by_depth);
615 let mut flat = Vec::new();
616 flatten_groups(&tree, fields, 0, fields.len(), &[], &mut flat);
617 if grand_total && !flat.is_empty() {
618 flat.push(FlatGroup {
619 labels: vec![None; fields.len()],
620 record_indices: record_indices.to_vec(),
621 is_subtotal: false,
622 is_grand_total: true,
623 });
624 }
625 flat
626}
627
628fn aggregate(sheet: &Sheet, values: &[ResultData], agg: PivotAggregation) -> ResultData {
629 if values.is_empty() {
630 return ResultData::None;
631 }
632 match agg {
633 PivotAggregation::Count => ResultData::Integer(
634 values
635 .iter()
636 .filter(|v| !matches!(v, ResultData::None))
637 .count() as i64,
638 ),
639 PivotAggregation::CountNumbers => ResultData::Integer(
640 values
641 .iter()
642 .filter(|v| matches!(v, ResultData::Integer(_) | ResultData::Float(_)))
643 .count() as i64,
644 ),
645 _ => {
646 let nums: Vec<f64> = values
647 .iter()
648 .filter_map(|v| match v {
649 ResultData::Integer(_) | ResultData::Float(_) => sheet.to_f64(v),
650 _ => None,
651 })
652 .collect();
653 match agg {
654 PivotAggregation::Sum => {
655 if nums.is_empty() {
656 ResultData::Integer(0)
657 } else {
658 ResultData::Float(Sheet::clean_float(nums.iter().sum()))
659 }
660 }
661 PivotAggregation::Average => {
662 if nums.is_empty() {
663 ResultData::Error("#DIV/0!".to_string())
664 } else {
665 let avg = nums.iter().sum::<f64>() / nums.len() as f64;
666 ResultData::Float(Sheet::clean_float(avg))
667 }
668 }
669 PivotAggregation::Max => nums
670 .into_iter()
671 .fold(None, |acc: Option<f64>, x| {
672 Some(acc.map_or(x, |a| a.max(x)))
673 })
674 .map(ResultData::Float)
675 .unwrap_or(ResultData::None),
676 PivotAggregation::Min => nums
677 .into_iter()
678 .fold(None, |acc: Option<f64>, x| {
679 Some(acc.map_or(x, |a| a.min(x)))
680 })
681 .map(ResultData::Float)
682 .unwrap_or(ResultData::None),
683 PivotAggregation::Count | PivotAggregation::CountNumbers => unreachable!(),
684 }
685 }
686 }
687}
688
689pub(crate) type ResolvedSource<'a> = (&'a Sheet, Vec<String>, Vec<usize>, Vec<usize>);
695
696pub(crate) fn resolve_source<'a>(
697 sheets: &'a [&'a Sheet],
698 source: &PivotSource,
699) -> Result<ResolvedSource<'a>, String> {
700 match source {
701 PivotSource::Table { name } => {
702 let (sheet, table) = sheets
703 .iter()
704 .find_map(|s| s.find_table(name).map(|t| (*s, t)))
705 .ok_or_else(|| format!("Table '{}' not found", name))?;
706 let cols: Vec<usize> = (table.start_col..=table.end_col).collect();
707 let rows: Vec<usize> = (table.data_start_row()..=table.data_end_row()).collect();
708 Ok((sheet, table.columns.clone(), cols, rows))
709 }
710 PivotSource::Range {
711 sheet_id,
712 start_row,
713 start_col,
714 end_row,
715 end_col,
716 } => {
717 let sheet = *sheets
718 .iter()
719 .find(|s| s.id == *sheet_id)
720 .ok_or_else(|| "Pivot source sheet no longer exists".to_string())?;
721 if *end_row < *start_row || *end_col < *start_col {
722 return Err("Pivot source range end must not precede its start".to_string());
723 }
724 let cols: Vec<usize> = (*start_col..=*end_col).collect();
725 let names: Vec<String> = cols
726 .iter()
727 .map(|&c| {
728 let v = sheet.get_result_data(&CellRef::new(*start_row, c));
729 let s = v.to_string();
730 if s.is_empty() {
731 crate::core::parser::col_idx_to_letters(c)
732 } else {
733 s
734 }
735 })
736 .collect();
737 let rows: Vec<usize> = if *end_row > *start_row {
738 (*start_row + 1..=*end_row).collect()
739 } else {
740 Vec::new()
741 };
742 Ok((sheet, names, cols, rows))
743 }
744 }
745}
746
747pub(crate) fn column_index(names: &[String], target: &str) -> Result<usize, String> {
748 names
749 .iter()
750 .position(|c| c.eq_ignore_ascii_case(target))
751 .ok_or_else(|| {
752 format!(
753 "Source column '{}' not found (columns: {})",
754 target,
755 names.join(", ")
756 )
757 })
758}
759
760pub fn compute_pivot(sheets: &[&Sheet], pivot: &PivotTable) -> Result<PivotGrid, String> {
778 let (sheet, col_names, sheet_cols, data_rows) = resolve_source(sheets, &pivot.source)?;
779
780 for f in pivot.row_fields.iter().chain(pivot.col_fields.iter()) {
781 column_index(&col_names, &f.column)?;
782 }
783 for vf in &pivot.value_fields {
784 column_index(&col_names, &vf.column)?;
785 }
786 for ff in &pivot.filter_fields {
787 column_index(&col_names, &ff.column)?;
788 }
789 if pivot.value_fields.is_empty() {
790 return Err("Pivot table has no value fields".to_string());
791 }
792
793 let mut all_rows: Vec<Vec<ResultData>> = Vec::with_capacity(data_rows.len());
794 for &r in &data_rows {
795 let mut row_vals = Vec::with_capacity(sheet_cols.len());
796 for &c in &sheet_cols {
797 row_vals.push(sheet.get_result_data(&CellRef::new(r, c)));
798 }
799 all_rows.push(row_vals);
800 }
801
802 let mut filter_rows: Vec<(String, String)> = Vec::new();
803 for ff in &pivot.filter_fields {
804 let idx = column_index(&col_names, &ff.column)?;
805 let distinct: std::collections::HashSet<String> = all_rows
806 .iter()
807 .map(|row| group_key(&row[idx]).to_ascii_lowercase())
808 .collect();
809 let state = match &ff.selected_values {
810 None => "(All)".to_string(),
811 Some(selected) => {
812 let selected_set: std::collections::HashSet<String> =
813 selected.iter().map(|v| v.to_ascii_lowercase()).collect();
814 let is_all = selected_set.len() == distinct.len()
815 && distinct.iter().all(|v| selected_set.contains(v));
816 if is_all {
817 "(All)".to_string()
818 } else if !ff.multiple_selection && selected_set.len() == 1 {
819 let wanted = &selected_set;
820 all_rows
821 .iter()
822 .map(|row| group_key(&row[idx]))
823 .find(|v| wanted.contains(&v.to_ascii_lowercase()))
824 .unwrap_or_else(|| "(Multiple Items)".to_string())
825 } else {
826 "(Multiple Items)".to_string()
827 }
828 }
829 };
830 filter_rows.push((ff.column.clone(), state));
831 }
832
833 let mut records: Vec<Vec<ResultData>> = Vec::new();
834 'row: for row_vals in &all_rows {
835 for ff in &pivot.filter_fields {
836 if let Some(selected) = &ff.selected_values {
837 let idx = column_index(&col_names, &ff.column)?;
838 let key = group_key(&row_vals[idx]);
839 if !selected.iter().any(|v| v.eq_ignore_ascii_case(&key)) {
840 continue 'row;
841 }
842 }
843 }
844 records.push(row_vals.clone());
845 }
846
847 let record_indices: Vec<usize> = (0..records.len()).collect();
848
849 let row_field_idxs: Vec<usize> = pivot
850 .row_fields
851 .iter()
852 .map(|f| column_index(&col_names, &f.column))
853 .collect::<Result<_, _>>()?;
854 let col_field_idxs: Vec<usize> = pivot
855 .col_fields
856 .iter()
857 .map(|f| column_index(&col_names, &f.column))
858 .collect::<Result<_, _>>()?;
859 let mut case_canon: HashMap<usize, HashMap<String, String>> = HashMap::new();
860 let mut canonical_key = |field_idx: usize, raw: String| -> String {
861 let map = case_canon.entry(field_idx).or_default();
862 map.entry(raw.to_ascii_lowercase()).or_insert(raw).clone()
863 };
864 for row_vals in &all_rows {
865 for &i in row_field_idxs.iter().chain(col_field_idxs.iter()) {
866 canonical_key(i, group_key(&row_vals[i]));
867 }
868 }
869 let row_keys: Vec<Vec<String>> = if pivot.row_fields.is_empty() {
870 Vec::new()
871 } else {
872 records
873 .iter()
874 .map(|rec| {
875 row_field_idxs
876 .iter()
877 .map(|&i| canonical_key(i, group_key(&rec[i])))
878 .collect()
879 })
880 .collect()
881 };
882 let col_keys: Vec<Vec<String>> = if pivot.col_fields.is_empty() {
883 Vec::new()
884 } else {
885 records
886 .iter()
887 .map(|rec| {
888 col_field_idxs
889 .iter()
890 .map(|&i| canonical_key(i, group_key(&rec[i])))
891 .collect()
892 })
893 .collect()
894 };
895 let row_numeric: Vec<bool> = row_field_idxs
896 .iter()
897 .map(|&i| field_is_numeric(&records, i))
898 .collect();
899 let col_numeric: Vec<bool> = col_field_idxs
900 .iter()
901 .map(|&i| field_is_numeric(&records, i))
902 .collect();
903
904 let row_groups = build_axis(
905 &record_indices,
906 &row_keys,
907 &pivot.row_fields,
908 pivot.grand_totals_row,
909 &row_numeric,
910 );
911 let col_groups = build_axis(
912 &record_indices,
913 &col_keys,
914 &pivot.col_fields,
915 pivot.grand_totals_col,
916 &col_numeric,
917 );
918
919 let value_multiplier = if pivot.value_fields.len() > 1 {
920 pivot.value_fields.len()
921 } else {
922 1
923 };
924 let value_idxs: Vec<usize> = pivot
925 .value_fields
926 .iter()
927 .map(|vf| column_index(&col_names, &vf.column))
928 .collect::<Result<_, _>>()?;
929 let value_labels = value_field_labels(&pivot.value_fields);
930
931 let n_col_header_rows = pivot.col_fields.len().max(1);
932 let n_header_rows = if value_multiplier > 1 && !pivot.col_fields.is_empty() {
933 n_col_header_rows + 1
934 } else {
935 n_col_header_rows
936 };
937 let row_label_width = row_label_width(pivot);
938
939 let mut header_rows: Vec<Vec<String>> = Vec::new();
940 for r in 0..n_header_rows {
941 let mut row: Vec<String> = Vec::new();
942 for i in 0..row_label_width {
943 if r == n_header_rows - 1 {
944 row.push(if i == 0 && !pivot.row_fields.is_empty() {
945 "Row Labels".to_string()
946 } else {
947 pivot
948 .row_fields
949 .get(i)
950 .map(|f| f.column.clone())
951 .unwrap_or_default()
952 });
953 } else {
954 row.push(String::new());
955 }
956 }
957 let mut prev_group: Option<&FlatGroup> = None;
958 for group in &col_groups {
959 let subtotal_depth = group
960 .is_subtotal
961 .then(|| group.labels.iter().rposition(|l| l.is_some()))
962 .flatten();
963 for vf in 0..value_multiplier {
964 let label = if r < pivot.col_fields.len() {
965 if group.is_grand_total {
966 if r == 0 {
967 if value_multiplier > 1 {
968 format!("Total {}", value_labels[vf])
969 } else {
970 "Grand Total".to_string()
971 }
972 } else {
973 String::new()
974 }
975 } else if subtotal_depth == Some(r) {
976 let value = group.labels[r].clone().unwrap();
977 if value_multiplier > 1 {
978 format!("{} {}", value, value_labels[vf])
979 } else {
980 format!("{} Total", value)
981 }
982 } else {
983 let is_repeat = if vf > 0 {
984 true
985 } else {
986 prev_group.is_some_and(|pg| {
987 (0..=r).all(|d| pg.labels.get(d) == group.labels.get(d))
988 })
989 };
990 if is_repeat {
991 String::new()
992 } else {
993 group
994 .labels
995 .get(r)
996 .and_then(|l| l.clone())
997 .unwrap_or_default()
998 }
999 }
1000 } else if group.is_grand_total || group.is_subtotal {
1001 String::new()
1002 } else {
1003 value_labels.get(vf).cloned().unwrap_or_default()
1004 };
1005 row.push(label);
1006 }
1007 prev_group = Some(group);
1008 }
1009 header_rows.push(row);
1010 }
1011 if pivot.col_fields.is_empty()
1012 && value_multiplier == 1
1013 && let Some(last) = header_rows.last_mut()
1014 && let Some(cell) = last.last_mut()
1015 && let Some(label) = value_labels.first()
1016 {
1017 *cell = label.clone();
1018 }
1019 if !pivot.col_fields.is_empty() {
1020 let mut row = vec![String::new(); row_label_width];
1021 if value_multiplier == 1
1022 && !pivot.row_fields.is_empty()
1023 && let Some(label) = value_labels.first()
1024 {
1025 row[0] = label.clone();
1026 }
1027 row.push("Column Labels".to_string());
1028 row.resize(
1029 row_label_width + col_groups.len() * value_multiplier,
1030 String::new(),
1031 );
1032 header_rows.insert(0, row);
1033 }
1034
1035 let mut body_rows: Vec<PivotBodyRow> = Vec::new();
1036 let mut prev_labels: Vec<Option<String>> = vec![None; row_label_width];
1037 for rg in &row_groups {
1038 let mut display_labels = vec![String::new(); row_label_width];
1039 if rg.is_grand_total {
1040 display_labels[0] = "Grand Total".to_string();
1041 for l in prev_labels.iter_mut() {
1042 *l = None;
1043 }
1044 } else {
1045 let mut changed = false;
1046 for d in 0..row_label_width {
1047 let cur = if pivot.row_fields.is_empty() {
1048 None
1049 } else {
1050 rg.labels.get(d).cloned().flatten()
1051 };
1052 let is_subtotal_marker =
1053 rg.is_subtotal && rg.labels.get(d).map(|l| l.is_some()).unwrap_or(false);
1054 let show = changed || cur != prev_labels[d] || is_subtotal_marker;
1055 if show {
1056 if let Some(ref v) = cur {
1057 display_labels[d] = if is_subtotal_marker {
1058 format!("{} Total", v)
1059 } else {
1060 v.clone()
1061 };
1062 }
1063 changed = true;
1064 }
1065 prev_labels[d] = cur;
1066 }
1067 if pivot.row_fields.is_empty() && row_label_width > 0 {
1068 display_labels[0] = if !pivot.col_fields.is_empty() && value_multiplier == 1 {
1069 value_labels.first().cloned().unwrap_or_default()
1070 } else {
1071 "Total".to_string()
1072 };
1073 }
1074 }
1075
1076 let row_record_set: std::collections::HashSet<usize> =
1077 rg.record_indices.iter().copied().collect();
1078 let mut values: Vec<ResultData> = Vec::new();
1079 for cg in &col_groups {
1080 for (vf_pos, &vidx) in value_idxs.iter().enumerate() {
1081 if vf_pos > 0 && value_multiplier == 1 {
1082 break;
1083 }
1084 let col_vals: Vec<ResultData> = cg
1085 .record_indices
1086 .iter()
1087 .filter(|i| row_record_set.contains(i))
1088 .map(|&i| records[i][vidx].clone())
1089 .collect();
1090 values.push(aggregate(
1091 sheet,
1092 &col_vals,
1093 pivot.value_fields[vf_pos].aggregation,
1094 ));
1095 }
1096 }
1097
1098 body_rows.push(PivotBodyRow {
1099 row_labels: display_labels,
1100 is_grand_total: rg.is_grand_total,
1101 values,
1102 });
1103 }
1104
1105 let width = row_label_width + col_groups.len() * value_multiplier;
1106 let to_axis_items = |groups: &[FlatGroup]| -> Vec<PivotAxisItem> {
1107 groups
1108 .iter()
1109 .map(|g| PivotAxisItem {
1110 labels: g.labels.clone(),
1111 is_subtotal: g.is_subtotal,
1112 is_grand_total: g.is_grand_total,
1113 })
1114 .collect()
1115 };
1116 Ok(PivotGrid {
1117 filter_rows,
1118 header_rows,
1119 body_rows,
1120 width,
1121 row_axis: to_axis_items(&row_groups),
1122 col_axis: to_axis_items(&col_groups),
1123 })
1124}
1125
1126fn match_pivot_axis(
1136 axis: &[PivotAxisItem],
1137 criteria: &[(usize, &str)],
1138 field_count: usize,
1139) -> Result<usize, String> {
1140 if criteria.is_empty() {
1141 return axis
1142 .iter()
1143 .position(|g| g.is_grand_total)
1144 .or(if field_count == 0 && axis.len() == 1 {
1145 Some(0)
1146 } else {
1147 None
1148 })
1149 .ok_or_else(|| "#REF!".to_string());
1150 }
1151 let max_depth = criteria.iter().map(|(d, _)| *d).max().unwrap_or(0);
1152 let want_leaf = max_depth + 1 == field_count;
1153 let matches: Vec<usize> = axis
1154 .iter()
1155 .enumerate()
1156 .filter(|(_, group)| {
1157 if group.is_grand_total {
1158 return false;
1159 }
1160 if want_leaf {
1161 if group.is_subtotal {
1162 return false;
1163 }
1164 } else {
1165 let own_depth = group.labels.iter().rposition(|l| l.is_some());
1166 if !(group.is_subtotal && own_depth == Some(max_depth)) {
1167 return false;
1168 }
1169 }
1170 criteria.iter().all(|(depth, item)| {
1171 group
1172 .labels
1173 .get(*depth)
1174 .and_then(|l| l.as_deref())
1175 .map(|l| l.eq_ignore_ascii_case(item))
1176 .unwrap_or(false)
1177 })
1178 })
1179 .map(|(i, _)| i)
1180 .collect();
1181 match matches.len() {
1182 1 => Ok(matches[0]),
1183 _ => Err("#REF!".to_string()),
1184 }
1185}
1186
1187pub fn getpivotdata(
1194 sheets: &[&Sheet],
1195 pivot: &PivotTable,
1196 data_field: &str,
1197 criteria: &[(String, String)],
1198) -> Result<ResultData, String> {
1199 let grid = compute_pivot(sheets, pivot)?;
1200
1201 let value_labels = value_field_labels(&pivot.value_fields);
1202 let value_multiplier = if pivot.value_fields.len() > 1 {
1203 pivot.value_fields.len()
1204 } else {
1205 1
1206 };
1207 let value_field_idx = pivot
1208 .value_fields
1209 .iter()
1210 .position(|vf| vf.column.eq_ignore_ascii_case(data_field))
1211 .or_else(|| {
1212 value_labels
1213 .iter()
1214 .position(|l| l.eq_ignore_ascii_case(data_field))
1215 })
1216 .ok_or_else(|| "#VALUE!".to_string())?;
1217
1218 let mut row_criteria: Vec<(usize, &str)> = Vec::new();
1219 let mut col_criteria: Vec<(usize, &str)> = Vec::new();
1220 for (field, item) in criteria {
1221 if let Some(depth) = pivot
1222 .row_fields
1223 .iter()
1224 .position(|f| f.column.eq_ignore_ascii_case(field))
1225 {
1226 row_criteria.push((depth, item.as_str()));
1227 } else if let Some(depth) = pivot
1228 .col_fields
1229 .iter()
1230 .position(|f| f.column.eq_ignore_ascii_case(field))
1231 {
1232 col_criteria.push((depth, item.as_str()));
1233 } else {
1234 return Err("#REF!".to_string());
1235 }
1236 }
1237
1238 let row_idx = match_pivot_axis(&grid.row_axis, &row_criteria, pivot.row_fields.len())?;
1239 let col_idx = match_pivot_axis(&grid.col_axis, &col_criteria, pivot.col_fields.len())?;
1240
1241 let pos = col_idx * value_multiplier + value_field_idx;
1242 grid.body_rows
1243 .get(row_idx)
1244 .and_then(|r| r.values.get(pos))
1245 .cloned()
1246 .ok_or_else(|| "#REF!".to_string())
1247}
1248
1249pub(crate) fn sorted_distinct_strings(values: &[String], numeric: bool) -> Vec<String> {
1254 let mut pairs: Vec<(String, Vec<usize>)> = distinct_strings(values)
1255 .into_iter()
1256 .map(|s| (s, Vec::new()))
1257 .collect();
1258 sort_group_entries(&mut pairs, numeric);
1259 pairs.into_iter().map(|(s, _)| s).collect()
1260}
1261
1262pub(crate) fn distinct_strings(values: &[String]) -> Vec<String> {
1276 let mut seen: Vec<String> = Vec::new();
1277 for v in values {
1278 if !seen.iter().any(|s| s.eq_ignore_ascii_case(v)) {
1279 seen.push(v.clone());
1280 }
1281 }
1282 seen
1283}
1284
1285#[cfg(test)]
1286mod tests {
1287 use super::*;
1288 use crate::core::engine::SheetInit;
1289
1290 fn source_sheet() -> Sheet {
1291 let mut sheet = Sheet::new(SheetInit {
1292 name: Some("Data".to_string()),
1293 rows: 9,
1294 cols: 4,
1295 ..Default::default()
1296 });
1297 let header = ["Region", "Product", "Rep", "Amount"];
1298 for (c, h) in header.iter().enumerate() {
1299 sheet.set_cell_src(0, c, h.to_string());
1300 }
1301 let rows: [[&str; 4]; 8] = [
1302 ["East", "Widget", "Alice", "10"],
1303 ["East", "Widget", "Bob", "20"],
1304 ["East", "Gadget", "Alice", "5"],
1305 ["West", "Widget", "Carol", "30"],
1306 ["West", "Gadget", "Carol", "40"],
1307 ["West", "Gadget", "Dave", "50"],
1308 ["East", "Gadget", "Bob", "15"],
1309 ["West", "Widget", "Dave", "25"],
1310 ];
1311 for (r, row) in rows.iter().enumerate() {
1312 for (c, v) in row.iter().enumerate() {
1313 sheet.set_cell_src(r + 1, c, v.to_string());
1314 }
1315 }
1316 sheet.commit(None).unwrap();
1317 sheet
1318 .add_table("Sales".to_string(), 0, 0, 8, 3, true, false)
1319 .unwrap();
1320 sheet
1321 }
1322
1323 fn base_pivot() -> PivotTable {
1324 PivotTable {
1325 id: 1,
1326 name: "Pivot1".to_string(),
1327 source: PivotSource::Table {
1328 name: "Sales".to_string(),
1329 },
1330 dest_sheet_id: 0,
1331 dest_row: 0,
1332 dest_col: 0,
1333 row_fields: vec![PivotField::new("Region")],
1334 col_fields: vec![],
1335 value_fields: vec![PivotValueField::new("Amount", PivotAggregation::Sum)],
1336 filter_fields: vec![],
1337 grand_totals_row: true,
1338 grand_totals_col: true,
1339 last_output_end_row: None,
1340 last_output_end_col: None,
1341 }
1342 }
1343
1344 fn value_at(row: &PivotBodyRow, col: usize) -> f64 {
1345 match &row.values[col] {
1346 ResultData::Float(f) => *f,
1347 ResultData::Integer(i) => *i as f64,
1348 other => panic!("expected numeric, got {:?}", other),
1349 }
1350 }
1351
1352 #[test]
1353 fn test_single_row_field_sum_with_grand_total() {
1354 let sheet = source_sheet();
1355 let pivot = base_pivot();
1356 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1357
1358 assert_eq!(grid.body_rows.len(), 3);
1359 assert_eq!(grid.body_rows[0].row_labels[0], "East");
1360 assert_eq!(value_at(&grid.body_rows[0], 0), 50.0);
1361 assert_eq!(grid.body_rows[1].row_labels[0], "West");
1362 assert_eq!(value_at(&grid.body_rows[1], 0), 145.0);
1363 assert!(grid.body_rows[2].is_grand_total);
1364 assert_eq!(grid.body_rows[2].row_labels[0], "Grand Total");
1365 assert_eq!(value_at(&grid.body_rows[2], 0), 195.0);
1366 }
1367
1368 #[test]
1369 fn test_getpivotdata_matches_a_row_group() {
1370 let sheet = source_sheet();
1371 let pivot = base_pivot();
1372 let result = getpivotdata(
1373 &[&sheet],
1374 &pivot,
1375 "Amount",
1376 &[("Region".to_string(), "East".to_string())],
1377 )
1378 .unwrap();
1379 assert!(matches!(result, ResultData::Float(f) if f == 50.0));
1380 }
1381
1382 #[test]
1383 fn test_getpivotdata_empty_criteria_matches_grand_total() {
1384 let sheet = source_sheet();
1385 let pivot = base_pivot();
1386 let result = getpivotdata(&[&sheet], &pivot, "Amount", &[]).unwrap();
1387 assert!(matches!(result, ResultData::Float(f) if f == 195.0));
1388 }
1389
1390 #[test]
1391 fn test_getpivotdata_partial_criteria_matches_subtotal() {
1392 let sheet = source_sheet();
1393 let mut pivot = base_pivot();
1394 pivot.row_fields = vec![PivotField::new("Region"), PivotField::new("Product")];
1395 let result = getpivotdata(
1396 &[&sheet],
1397 &pivot,
1398 "Amount",
1399 &[("Region".to_string(), "East".to_string())],
1400 )
1401 .unwrap();
1402 assert!(matches!(result, ResultData::Float(f) if f == 50.0));
1403 }
1404
1405 #[test]
1406 fn test_getpivotdata_full_path_matches_leaf() {
1407 let sheet = source_sheet();
1408 let mut pivot = base_pivot();
1409 pivot.row_fields = vec![PivotField::new("Region"), PivotField::new("Product")];
1410 let result = getpivotdata(
1411 &[&sheet],
1412 &pivot,
1413 "Amount",
1414 &[
1415 ("Region".to_string(), "East".to_string()),
1416 ("Product".to_string(), "Widget".to_string()),
1417 ],
1418 )
1419 .unwrap();
1420 assert!(matches!(result, ResultData::Float(f) if f == 30.0));
1421 }
1422
1423 #[test]
1424 fn test_getpivotdata_unknown_field_is_ref_error() {
1425 let sheet = source_sheet();
1426 let pivot = base_pivot();
1427 let err = getpivotdata(
1428 &[&sheet],
1429 &pivot,
1430 "Amount",
1431 &[("NotAField".to_string(), "East".to_string())],
1432 )
1433 .unwrap_err();
1434 assert_eq!(err, "#REF!");
1435 }
1436
1437 #[test]
1438 fn test_getpivotdata_unknown_item_is_ref_error() {
1439 let sheet = source_sheet();
1440 let pivot = base_pivot();
1441 let err = getpivotdata(
1442 &[&sheet],
1443 &pivot,
1444 "Amount",
1445 &[("Region".to_string(), "North".to_string())],
1446 )
1447 .unwrap_err();
1448 assert_eq!(err, "#REF!");
1449 }
1450
1451 #[test]
1452 fn test_getpivotdata_unknown_data_field_is_value_error() {
1453 let sheet = source_sheet();
1454 let pivot = base_pivot();
1455 let err = getpivotdata(
1456 &[&sheet],
1457 &pivot,
1458 "NotAField",
1459 &[("Region".to_string(), "East".to_string())],
1460 )
1461 .unwrap_err();
1462 assert_eq!(err, "#VALUE!");
1463 }
1464
1465 #[test]
1466 fn test_row_and_col_fields_with_subtotals() {
1467 let sheet = source_sheet();
1468 let mut pivot = base_pivot();
1469 pivot.row_fields = vec![PivotField::new("Region"), PivotField::new("Product")];
1470 pivot.col_fields = vec![PivotField::new("Rep")];
1471 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1472
1473 let subtotal_rows: Vec<&PivotBodyRow> = grid
1474 .body_rows
1475 .iter()
1476 .filter(|r| r.row_labels[0].ends_with("Total") && !r.is_grand_total)
1477 .collect();
1478 assert_eq!(subtotal_rows.len(), 2);
1479 assert!(grid.body_rows.last().unwrap().is_grand_total);
1480 }
1481
1482 #[test]
1483 fn test_nested_row_field_second_level_labels_are_not_lost() {
1484 let sheet = source_sheet();
1485 let mut pivot = base_pivot();
1486 pivot.row_fields = vec![PivotField::new("Region"), PivotField::new("Product")];
1487 pivot.grand_totals_row = false;
1488 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1489
1490 let leaf_rows: Vec<&PivotBodyRow> = grid
1491 .body_rows
1492 .iter()
1493 .filter(|r| !r.row_labels[0].ends_with("Total") && !r.is_grand_total)
1494 .collect();
1495 assert_eq!(leaf_rows.len(), 4);
1496 for row in &leaf_rows {
1497 assert!(
1498 !row.row_labels[1].is_empty(),
1499 "expected a Product label on leaf row {:?}, got blank",
1500 row.row_labels
1501 );
1502 }
1503 let products: Vec<&str> = leaf_rows.iter().map(|r| r.row_labels[1].as_str()).collect();
1504 assert!(products.contains(&"Widget"));
1505 assert!(products.contains(&"Gadget"));
1506 }
1507
1508 #[test]
1509 fn test_count_aggregation() {
1510 let sheet = source_sheet();
1511 let mut pivot = base_pivot();
1512 pivot.value_fields = vec![PivotValueField::new("Rep", PivotAggregation::Count)];
1513 pivot.grand_totals_row = false;
1514 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1515 assert_eq!(grid.body_rows.len(), 2);
1516 for row in &grid.body_rows {
1517 assert_eq!(value_at(row, 0), 4.0);
1518 }
1519 }
1520
1521 #[test]
1522 fn test_filter_field_restricts_records() {
1523 let sheet = source_sheet();
1524 let mut pivot = base_pivot();
1525 pivot.filter_fields = vec![PivotFilterField {
1526 column: "Product".to_string(),
1527 selected_values: Some(vec!["Widget".to_string()]),
1528 multiple_selection: true,
1529 }];
1530 pivot.grand_totals_row = false;
1531 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1532 assert_eq!(grid.body_rows.len(), 2);
1533 assert_eq!(value_at(&grid.body_rows[0], 0), 30.0);
1534 assert_eq!(value_at(&grid.body_rows[1], 0), 55.0);
1535 }
1536
1537 #[test]
1538 fn test_filter_field_selection_matches_case_insensitively() {
1539 let mut sheet = Sheet::new(SheetInit {
1540 name: Some("Data".to_string()),
1541 rows: 4,
1542 cols: 2,
1543 ..Default::default()
1544 });
1545 for (c, h) in ["Mixed", "Amount"].iter().enumerate() {
1546 sheet.set_cell_src(0, c, h.to_string());
1547 }
1548 let rows: [[&str; 2]; 3] = [["East", "10"], ["east", "20"], ["West", "30"]];
1549 for (r, row) in rows.iter().enumerate() {
1550 for (c, v) in row.iter().enumerate() {
1551 sheet.set_cell_src(r + 1, c, v.to_string());
1552 }
1553 }
1554 sheet.commit(None).unwrap();
1555 sheet
1556 .add_table("Sales".to_string(), 0, 0, 3, 1, true, false)
1557 .unwrap();
1558
1559 let mut pivot = base_pivot();
1560 pivot.source = PivotSource::Table {
1561 name: "Sales".to_string(),
1562 };
1563 pivot.row_fields = vec![];
1564 pivot.value_fields = vec![PivotValueField::new("Amount", PivotAggregation::Sum)];
1565 pivot.filter_fields = vec![PivotFilterField {
1566 column: "Mixed".to_string(),
1567 selected_values: Some(vec!["east".to_string()]),
1568 multiple_selection: true,
1569 }];
1570 pivot.grand_totals_row = false;
1571 pivot.grand_totals_col = false;
1572 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1573 assert_eq!(value_at(&grid.body_rows[0], 0), 30.0);
1574 }
1575
1576 #[test]
1577 fn test_no_filter_fields_means_no_reserved_rows() {
1578 let sheet = source_sheet();
1579 let pivot = base_pivot();
1580 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1581 assert!(grid.filter_rows.is_empty());
1582 assert_eq!(grid.grid_row_offset(), 0);
1583 assert_eq!(grid.height(), grid.header_rows.len() + grid.body_rows.len());
1584 }
1585
1586 #[test]
1587 fn test_filter_field_state_label_all_vs_multiple_items() {
1588 let sheet = source_sheet();
1589 let mut pivot = base_pivot();
1590 pivot.filter_fields = vec![PivotFilterField {
1591 column: "Product".to_string(),
1592 selected_values: None,
1593 multiple_selection: true,
1594 }];
1595
1596 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1597 assert_eq!(
1598 grid.filter_rows,
1599 vec![("Product".to_string(), "(All)".to_string())]
1600 );
1601 assert_eq!(grid.grid_row_offset(), 2);
1602
1603 pivot.filter_fields[0].selected_values =
1604 Some(vec!["Widget".to_string(), "Gadget".to_string()]);
1605 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1606 assert_eq!(grid.filter_rows[0].1, "(All)");
1607
1608 pivot.filter_fields[0].selected_values = Some(vec!["Widget".to_string()]);
1609 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1610 assert_eq!(grid.filter_rows[0].1, "(Multiple Items)");
1611
1612 pivot.filter_fields[0].multiple_selection = false;
1613 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1614 assert_eq!(grid.filter_rows[0].1, "Widget");
1615 }
1616
1617 #[test]
1618 fn test_col_axis_subtotal_group_gets_total_caption_and_grand_total_stays_outermost() {
1619 let sheet = source_sheet();
1620 let mut pivot = base_pivot();
1621 pivot.row_fields = vec![PivotField::new("Rep")];
1622 pivot.col_fields = vec![PivotField::new("Region"), PivotField::new("Product")];
1623 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1624
1625 let region_row = &grid.header_rows[1];
1626 assert!(region_row.contains(&"East Total".to_string()));
1627 assert!(region_row.contains(&"West Total".to_string()));
1628 assert!(region_row.contains(&"Grand Total".to_string()));
1629 let product_row = &grid.header_rows[2];
1630 assert_eq!(product_row.last().unwrap(), "");
1631 }
1632
1633 #[test]
1634 fn test_col_axis_subtotal_caption_uses_value_field_label_with_multiple_value_fields() {
1635 let sheet = source_sheet();
1636 let mut pivot = base_pivot();
1637 pivot.row_fields = vec![];
1638 pivot.col_fields = vec![PivotField::new("Region"), PivotField::new("Product")];
1639 pivot.value_fields = vec![
1640 PivotValueField::new("Amount", PivotAggregation::Min),
1641 PivotValueField::new("Amount", PivotAggregation::Sum),
1642 ];
1643 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1644
1645 let region_row = &grid.header_rows[1];
1646 assert!(region_row.contains(&"East Min of Amount".to_string()));
1647 assert!(region_row.contains(&"East Sum of Amount".to_string()));
1648 assert!(region_row.contains(&"West Min of Amount".to_string()));
1649 assert!(region_row.contains(&"West Sum of Amount".to_string()));
1650 assert!(
1651 !region_row
1652 .iter()
1653 .any(|c| c == "East Total" || c == "West Total")
1654 );
1655
1656 let value_label_row = grid.header_rows.last().unwrap();
1657 assert!(value_label_row.contains(&"Min of Amount".to_string()));
1658 assert!(value_label_row.contains(&"Sum of Amount".to_string()));
1659 let east_subtotal_idx = region_row
1660 .iter()
1661 .position(|c| c == "East Min of Amount")
1662 .unwrap();
1663 assert_eq!(value_label_row[east_subtotal_idx], "");
1664 assert_eq!(value_label_row[east_subtotal_idx + 1], "");
1665 }
1666
1667 #[test]
1668 fn test_col_axis_repeated_leaf_value_under_different_parents_is_not_falsely_merged() {
1669 let mut sheet = Sheet::new(SheetInit {
1670 name: Some("Data".to_string()),
1671 rows: 3,
1672 cols: 3,
1673 ..Default::default()
1674 });
1675 for (c, h) in ["Group", "Sub", "Amount"].iter().enumerate() {
1676 sheet.set_cell_src(0, c, h.to_string());
1677 }
1678 let rows: [[&str; 3]; 2] = [["GroupA", "X", "1"], ["GroupB", "X", "2"]];
1679 for (r, row) in rows.iter().enumerate() {
1680 for (c, v) in row.iter().enumerate() {
1681 sheet.set_cell_src(r + 1, c, v.to_string());
1682 }
1683 }
1684 sheet.commit(None).unwrap();
1685 sheet
1686 .add_table("Sales".to_string(), 0, 0, 2, 2, true, false)
1687 .unwrap();
1688
1689 let mut pivot = base_pivot();
1690 pivot.row_fields = vec![];
1691 pivot.col_fields = vec![PivotField::new("Group"), PivotField::new("Sub")];
1692 pivot.grand_totals_col = false;
1693 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1694
1695 let sub_row = &grid.header_rows[2];
1696 let x_count = sub_row.iter().filter(|c| *c == "X").count();
1697 assert_eq!(
1698 x_count, 2,
1699 "expected \"X\" under both GroupA and GroupB, got {sub_row:?}"
1700 );
1701 }
1702
1703 #[test]
1704 fn test_multiple_value_fields_become_column_labels() {
1705 let sheet = source_sheet();
1706 let mut pivot = base_pivot();
1707 pivot.value_fields = vec![
1708 PivotValueField::new("Amount", PivotAggregation::Sum),
1709 PivotValueField::new("Amount", PivotAggregation::Count),
1710 ];
1711 pivot.grand_totals_row = false;
1712 pivot.grand_totals_col = false;
1713 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1714 assert_eq!(grid.header_rows.last().unwrap()[1], "Sum of Amount");
1715 assert_eq!(grid.header_rows.last().unwrap()[2], "Count of Amount2");
1716 assert_eq!(grid.body_rows[0].values.len(), 2);
1717 assert_eq!(value_at(&grid.body_rows[0], 0), 50.0);
1718 assert_eq!(value_at(&grid.body_rows[0], 1), 4.0);
1719 }
1720
1721 #[test]
1722 fn test_row_labels_caption_replaces_outermost_row_field_name() {
1723 let sheet = source_sheet();
1724 let pivot = base_pivot();
1725 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1726 assert_eq!(grid.header_rows.last().unwrap()[0], "Row Labels");
1727 }
1728
1729 #[test]
1730 fn test_column_labels_row_prepended_and_deeper_row_field_keeps_its_name() {
1731 let sheet = source_sheet();
1732 let mut pivot = base_pivot();
1733 pivot.row_fields = vec![PivotField::new("Region"), PivotField::new("Product")];
1734 pivot.col_fields = vec![PivotField::new("Rep")];
1735 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1736
1737 assert!(grid.header_rows[0].iter().any(|c| c == "Column Labels"));
1738 let last = grid.header_rows.last().unwrap();
1739 assert_eq!(last[0], "Row Labels");
1740 assert_eq!(last[1], "Product");
1741 }
1742
1743 #[test]
1744 fn test_grand_total_column_shows_total_prefixed_value_label_with_multiple_value_fields() {
1745 let sheet = source_sheet();
1746 let mut pivot = base_pivot();
1747 pivot.col_fields = vec![PivotField::new("Product")];
1748 pivot.value_fields = vec![
1749 PivotValueField::new("Amount", PivotAggregation::Sum),
1750 PivotValueField::new("Amount", PivotAggregation::Min),
1751 ];
1752 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1753
1754 let col_values_row = &grid.header_rows[1];
1755 assert!(col_values_row.contains(&"Total Sum of Amount".to_string()));
1756 assert!(col_values_row.contains(&"Total Min of Amount2".to_string()));
1757 assert_eq!(grid.header_rows.last().unwrap().last().unwrap(), "");
1758 }
1759
1760 #[test]
1761 fn test_grand_total_still_shows_with_only_one_leaf_group() {
1762 let sheet = source_sheet();
1763 let mut pivot = base_pivot();
1764 pivot.filter_fields = vec![PivotFilterField {
1765 column: "Region".to_string(),
1766 selected_values: Some(vec!["East".to_string()]),
1767 multiple_selection: true,
1768 }];
1769 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1770 assert!(grid.body_rows.iter().any(|r| r.is_grand_total));
1771 }
1772
1773 #[test]
1774 fn test_case_variant_values_merge_using_globally_first_seen_casing() {
1775 let mut sheet = Sheet::new(SheetInit {
1776 name: Some("Data".to_string()),
1777 rows: 5,
1778 cols: 3,
1779 ..Default::default()
1780 });
1781 for (c, h) in ["Group", "Mixed", "Amount"].iter().enumerate() {
1782 sheet.set_cell_src(0, c, h.to_string());
1783 }
1784 let rows: [[&str; 3]; 3] = [
1785 ["G1", "EAST", "10"],
1786 ["G1", "West", "20"],
1787 ["G2", "east", "30"],
1788 ];
1789 for (r, row) in rows.iter().enumerate() {
1790 for (c, v) in row.iter().enumerate() {
1791 sheet.set_cell_src(r + 1, c, v.to_string());
1792 }
1793 }
1794 sheet.commit(None).unwrap();
1795 sheet
1796 .add_table("Sales".to_string(), 0, 0, 3, 2, true, false)
1797 .unwrap();
1798
1799 let mut pivot = base_pivot();
1800 pivot.row_fields = vec![PivotField::new("Group"), PivotField::new("Mixed")];
1801 pivot.grand_totals_row = false;
1802 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1803
1804 let mixed_labels: Vec<&str> = grid
1805 .body_rows
1806 .iter()
1807 .map(|r| r.row_labels[1].as_str())
1808 .filter(|l| !l.is_empty())
1809 .collect();
1810 assert!(
1811 mixed_labels.contains(&"EAST") && !mixed_labels.contains(&"east"),
1812 "expected every occurrence to use the globally first-seen casing \"EAST\", got {mixed_labels:?}"
1813 );
1814 }
1815
1816 #[test]
1817 fn test_case_canonicalization_uses_first_seen_casing_from_unfiltered_source_not_just_surviving_rows()
1818 {
1819 let mut sheet = Sheet::new(SheetInit {
1820 name: Some("Data".to_string()),
1821 rows: 4,
1822 cols: 3,
1823 ..Default::default()
1824 });
1825 for (c, h) in ["Cat", "Mixed", "Amount"].iter().enumerate() {
1826 sheet.set_cell_src(0, c, h.to_string());
1827 }
1828 let rows: [[&str; 3]; 3] = [
1829 ["Alpha", "WEST", "10"],
1830 ["Beta", "East", "20"],
1831 ["Beta", "west", "30"],
1832 ];
1833 for (r, row) in rows.iter().enumerate() {
1834 for (c, v) in row.iter().enumerate() {
1835 sheet.set_cell_src(r + 1, c, v.to_string());
1836 }
1837 }
1838 sheet.commit(None).unwrap();
1839 sheet
1840 .add_table("Sales".to_string(), 0, 0, 3, 2, true, false)
1841 .unwrap();
1842
1843 let mut pivot = base_pivot();
1844 pivot.row_fields = vec![PivotField::new("Mixed")];
1845 pivot.filter_fields = vec![PivotFilterField {
1846 column: "Cat".to_string(),
1847 selected_values: Some(vec!["Beta".to_string()]),
1848 multiple_selection: true,
1849 }];
1850 pivot.grand_totals_row = false;
1851 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1852
1853 let labels: Vec<&str> = grid
1854 .body_rows
1855 .iter()
1856 .map(|r| r.row_labels[0].as_str())
1857 .collect();
1858 assert!(
1859 labels.contains(&"WEST") && !labels.contains(&"west"),
1860 "expected the filtered-out row's casing \"WEST\" to still win, got {labels:?}"
1861 );
1862 }
1863
1864 #[test]
1865 fn test_blank_group_sorts_last_even_among_numeric_siblings() {
1866 let mut sheet = Sheet::new(SheetInit {
1867 name: Some("Data".to_string()),
1868 rows: 4,
1869 cols: 2,
1870 ..Default::default()
1871 });
1872 for (c, h) in ["Code", "Amount"].iter().enumerate() {
1873 sheet.set_cell_src(0, c, h.to_string());
1874 }
1875 sheet.set_cell_src(1, 0, "30".to_string());
1876 sheet.set_cell_src(1, 1, "1".to_string());
1877 sheet.set_cell_src(3, 0, "10".to_string());
1878 sheet.set_cell_src(3, 1, "3".to_string());
1879 sheet.commit(None).unwrap();
1880 sheet
1881 .add_table("Sales".to_string(), 0, 0, 3, 1, true, false)
1882 .unwrap();
1883
1884 let mut pivot = base_pivot();
1885 pivot.row_fields = vec![PivotField::new("Code")];
1886 pivot.grand_totals_row = false;
1887 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1888
1889 let codes: Vec<&str> = grid
1890 .body_rows
1891 .iter()
1892 .map(|r| r.row_labels[0].as_str())
1893 .collect();
1894 assert_eq!(codes, vec!["10", "30", "(blank)"]);
1895 }
1896
1897 #[test]
1898 fn test_negative_looking_text_sorts_last_among_text_siblings() {
1899 let mut sheet = Sheet::new(SheetInit {
1900 name: Some("Data".to_string()),
1901 rows: 6,
1902 cols: 2,
1903 ..Default::default()
1904 });
1905 for (c, h) in ["Code", "Amount"].iter().enumerate() {
1906 sheet.set_cell_src(0, c, h.to_string());
1907 }
1908 let rows: [(&str, &str); 5] = [
1909 ("\"-7\"", "1"),
1910 ("\".0152\"", "2"),
1911 ("\"13\"", "3"),
1912 ("\"34\"", "4"),
1913 ("\"4\"", "5"),
1914 ];
1915 for (r, (code, amount)) in rows.iter().enumerate() {
1916 sheet.set_cell_src(r + 1, 0, code.to_string());
1917 sheet.set_cell_src(r + 1, 1, amount.to_string());
1918 }
1919 sheet.commit(None).unwrap();
1920 sheet
1921 .add_table("Sales".to_string(), 0, 0, 5, 1, true, false)
1922 .unwrap();
1923
1924 let mut pivot = base_pivot();
1925 pivot.row_fields = vec![PivotField::new("Code")];
1926 pivot.grand_totals_row = false;
1927 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1928
1929 let codes: Vec<&str> = grid
1930 .body_rows
1931 .iter()
1932 .map(|r| r.row_labels[0].as_str())
1933 .collect();
1934 assert_eq!(codes, vec![".0152", "13", "34", "4", "-7"]);
1935 }
1936
1937 #[test]
1938 fn test_negative_looking_text_sorts_by_stripped_digits_not_last() {
1939 let mut sheet = Sheet::new(SheetInit {
1940 name: Some("Data".to_string()),
1941 rows: 4,
1942 cols: 2,
1943 ..Default::default()
1944 });
1945 for (c, h) in ["Code", "Amount"].iter().enumerate() {
1946 sheet.set_cell_src(0, c, h.to_string());
1947 }
1948 let rows: [(&str, &str); 3] = [("\"12\"", "1"), ("\"37\"", "2"), ("\"-25\"", "3")];
1949 for (r, (code, amount)) in rows.iter().enumerate() {
1950 sheet.set_cell_src(r + 1, 0, code.to_string());
1951 sheet.set_cell_src(r + 1, 1, amount.to_string());
1952 }
1953 sheet.commit(None).unwrap();
1954 sheet
1955 .add_table("Sales".to_string(), 0, 0, 3, 1, true, false)
1956 .unwrap();
1957
1958 let mut pivot = base_pivot();
1959 pivot.row_fields = vec![PivotField::new("Code")];
1960 pivot.grand_totals_row = false;
1961 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1962
1963 let codes: Vec<&str> = grid
1964 .body_rows
1965 .iter()
1966 .map(|r| r.row_labels[0].as_str())
1967 .collect();
1968 assert_eq!(codes, vec!["12", "-25", "37"]);
1969 }
1970
1971 #[test]
1972 fn test_empty_row_col_intersection_renders_blank_not_zero_or_error() {
1973 let mut sheet = Sheet::new(SheetInit {
1974 name: Some("Data".to_string()),
1975 rows: 3,
1976 cols: 3,
1977 ..Default::default()
1978 });
1979 for (c, h) in ["Region", "Product", "Amount"].iter().enumerate() {
1980 sheet.set_cell_src(0, c, h.to_string());
1981 }
1982 let rows: [[&str; 3]; 2] = [["East", "Widget", "10"], ["West", "Gadget", "20"]];
1983 for (r, row) in rows.iter().enumerate() {
1984 for (c, v) in row.iter().enumerate() {
1985 sheet.set_cell_src(r + 1, c, v.to_string());
1986 }
1987 }
1988 sheet.commit(None).unwrap();
1989 sheet
1990 .add_table("Sales".to_string(), 0, 0, 2, 2, true, false)
1991 .unwrap();
1992
1993 let mut pivot = base_pivot();
1994 pivot.col_fields = vec![PivotField::new("Product")];
1995 pivot.value_fields = vec![
1996 PivotValueField::new("Amount", PivotAggregation::Sum),
1997 PivotValueField::new("Amount", PivotAggregation::Average),
1998 ];
1999 pivot.grand_totals_row = false;
2000 pivot.grand_totals_col = false;
2001 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
2002
2003 let east_row = grid
2004 .body_rows
2005 .iter()
2006 .find(|r| r.row_labels[0] == "East")
2007 .unwrap();
2008 for v in &east_row.values[..2] {
2009 assert!(
2010 matches!(v, ResultData::None),
2011 "expected blank for an empty intersection, got {v:?}"
2012 );
2013 }
2014 }
2015
2016 #[test]
2017 fn test_value_field_labels_distinct_aggregations_without_sum_stay_unsuffixed() {
2018 let fields = vec![
2019 PivotValueField::new("Amount", PivotAggregation::Count),
2020 PivotValueField::new("Amount", PivotAggregation::Max),
2021 ];
2022 assert_eq!(
2023 value_field_labels(&fields),
2024 vec!["Count of Amount".to_string(), "Max of Amount".to_string()]
2025 );
2026 }
2027
2028 #[test]
2029 fn test_value_field_labels_sum_clones_column_for_later_fields() {
2030 let fields = vec![
2031 PivotValueField::new("Amount", PivotAggregation::Sum),
2032 PivotValueField::new("Rate", PivotAggregation::Average),
2033 PivotValueField::new("Amount", PivotAggregation::Min),
2034 PivotValueField::new("Amount", PivotAggregation::Max),
2035 ];
2036 assert_eq!(
2037 value_field_labels(&fields),
2038 vec![
2039 "Sum of Amount".to_string(),
2040 "Average of Rate".to_string(),
2041 "Min of Amount2".to_string(),
2042 "Max of Amount2".to_string(),
2043 ]
2044 );
2045 }
2046
2047 #[test]
2048 fn test_value_field_labels_second_sum_clones_again() {
2049 let fields = vec![
2050 PivotValueField::new("Amount", PivotAggregation::Sum),
2051 PivotValueField::new("Amount", PivotAggregation::Sum),
2052 PivotValueField::new("Amount", PivotAggregation::Count),
2053 ];
2054 assert_eq!(
2055 value_field_labels(&fields),
2056 vec![
2057 "Sum of Amount".to_string(),
2058 "Sum of Amount2".to_string(),
2059 "Count of Amount3".to_string(),
2060 ]
2061 );
2062 }
2063
2064 #[test]
2065 fn test_value_field_labels_disambiguates_identical_aggregation_and_column() {
2066 let fields = vec![
2067 PivotValueField::new("Amount", PivotAggregation::Sum),
2068 PivotValueField::new("Amount", PivotAggregation::Sum),
2069 PivotValueField::new("Amount", PivotAggregation::Sum),
2070 ];
2071 assert_eq!(
2072 value_field_labels(&fields),
2073 vec![
2074 "Sum of Amount".to_string(),
2075 "Sum of Amount2".to_string(),
2076 "Sum of Amount3".to_string(),
2077 ]
2078 );
2079 }
2080
2081 #[test]
2082 fn test_value_field_labels_collision_within_sum_clone_uses_underscore_suffix() {
2083 let fields = vec![
2084 PivotValueField::new("Amount", PivotAggregation::Sum),
2085 PivotValueField::new("Amount", PivotAggregation::Max),
2086 PivotValueField::new("Amount", PivotAggregation::Max),
2087 ];
2088 assert_eq!(
2089 value_field_labels(&fields),
2090 vec![
2091 "Sum of Amount".to_string(),
2092 "Max of Amount2".to_string(),
2093 "Max of Amount2_2".to_string(),
2094 ]
2095 );
2096 }
2097
2098 #[test]
2099 fn test_value_field_labels_count_numbers_shares_plain_count_caption() {
2100 let fields = vec![
2101 PivotValueField::new("Rate", PivotAggregation::CountNumbers),
2102 PivotValueField::new("Rate", PivotAggregation::Count),
2103 ];
2104 assert_eq!(
2105 value_field_labels(&fields),
2106 vec!["Count of Rate".to_string(), "Count of Rate2".to_string()]
2107 );
2108 }
2109
2110 #[test]
2111 fn test_value_field_labels_leaves_custom_name_untouched() {
2112 let mut fields = vec![
2113 PivotValueField::new("Amount", PivotAggregation::Sum),
2114 PivotValueField::new("Amount", PivotAggregation::Min),
2115 ];
2116 fields[1].custom_name = Some("Lowest Amount".to_string());
2117 assert_eq!(
2118 value_field_labels(&fields),
2119 vec!["Sum of Amount".to_string(), "Lowest Amount".to_string()]
2120 );
2121 }
2122
2123 #[test]
2124 fn test_flat_pivot_with_no_row_or_col_fields_has_no_reserved_label_column() {
2125 let sheet = source_sheet();
2126 let mut pivot = base_pivot();
2127 pivot.row_fields = vec![];
2128 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
2129
2130 assert_eq!(grid.width, 1);
2131 assert_eq!(
2132 grid.header_rows.last().unwrap(),
2133 &vec!["Sum of Amount".to_string()]
2134 );
2135 assert_eq!(grid.body_rows.len(), 1);
2136 assert!(grid.body_rows[0].row_labels.is_empty());
2137 assert_eq!(value_at(&grid.body_rows[0], 0), 195.0);
2138 }
2139
2140 #[test]
2141 fn test_no_row_fields_with_multiple_value_fields_has_no_reserved_label_column_either() {
2142 let sheet = source_sheet();
2143 let mut pivot = base_pivot();
2144 pivot.row_fields = vec![];
2145 pivot.col_fields = vec![PivotField::new("Product")];
2146 pivot.value_fields = vec![
2147 PivotValueField::new("Amount", PivotAggregation::Sum),
2148 PivotValueField::new("Amount", PivotAggregation::Count),
2149 ];
2150 pivot.grand_totals_col = false;
2151 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
2152
2153 assert_eq!(grid.width, 4);
2154 assert_eq!(grid.body_rows.len(), 1);
2155 assert!(grid.body_rows[0].row_labels.is_empty());
2156 }
2157
2158 #[test]
2159 fn test_multiple_value_fields_with_no_column_fields_share_one_header_row() {
2160 let sheet = source_sheet();
2161 let mut pivot = base_pivot();
2162 pivot.value_fields = vec![
2163 PivotValueField::new("Amount", PivotAggregation::Sum),
2164 PivotValueField::new("Amount", PivotAggregation::Count),
2165 ];
2166 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
2167
2168 assert_eq!(grid.header_rows.len(), 1);
2169 assert_eq!(
2170 grid.header_rows[0],
2171 vec![
2172 "Row Labels".to_string(),
2173 "Sum of Amount".to_string(),
2174 "Count of Amount2".to_string(),
2175 ]
2176 );
2177 }
2178
2179 #[test]
2180 fn test_missing_column_errors() {
2181 let sheet = source_sheet();
2182 let mut pivot = base_pivot();
2183 pivot.row_fields = vec![PivotField::new("Nope")];
2184 let err = compute_pivot(&[&sheet], &pivot).unwrap_err();
2185 assert!(err.contains("not found"));
2186 }
2187
2188 #[test]
2189 fn test_range_source_matches_table_source() {
2190 let sheet = source_sheet();
2191 let mut pivot = base_pivot();
2192 pivot.source = PivotSource::Range {
2193 sheet_id: sheet.id,
2194 start_row: 0,
2195 start_col: 0,
2196 end_row: 8,
2197 end_col: 3,
2198 };
2199 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
2200 assert_eq!(grid.body_rows.len(), 3);
2201 assert_eq!(value_at(&grid.body_rows[0], 0), 50.0);
2202 assert_eq!(value_at(&grid.body_rows[1], 0), 145.0);
2203 assert_eq!(value_at(&grid.body_rows[2], 0), 195.0);
2204 }
2205
2206 #[test]
2207 fn test_zero_data_rows_produces_empty_grid_without_panicking() {
2208 let mut sheet = Sheet::new(SheetInit {
2209 name: Some("Empty".to_string()),
2210 rows: 1,
2211 cols: 2,
2212 ..Default::default()
2213 });
2214 sheet.set_cell_src(0, 0, "Region".to_string());
2215 sheet.set_cell_src(0, 1, "Amount".to_string());
2216 sheet.commit(None).unwrap();
2217 sheet
2218 .add_table("Empty".to_string(), 0, 0, 0, 1, true, false)
2219 .unwrap();
2220
2221 let pivot = PivotTable {
2222 id: 1,
2223 name: "EmptyPivot".to_string(),
2224 source: PivotSource::Table {
2225 name: "Empty".to_string(),
2226 },
2227 dest_sheet_id: sheet.id,
2228 dest_row: 0,
2229 dest_col: 0,
2230 row_fields: vec![PivotField::new("Region")],
2231 col_fields: vec![],
2232 value_fields: vec![PivotValueField::new("Amount", PivotAggregation::Sum)],
2233 filter_fields: vec![],
2234 grand_totals_row: true,
2235 grand_totals_col: true,
2236 last_output_end_row: None,
2237 last_output_end_col: None,
2238 };
2239 let grid = compute_pivot(&[&sheet], &pivot).unwrap();
2240 assert!(grid.body_rows.is_empty());
2241 assert!(grid.row_axis.is_empty());
2242 }
2243
2244 use rand::rngs::StdRng;
2245 use rand::{Rng, SeedableRng};
2246
2247 const FUZZ_COLS: [&str; 6] = ["Cat", "Mixed", "NumStr", "Amount", "Rate", "Flag"];
2248 const FUZZ_CATEGORIES: [&str; 5] = ["Alpha", "Beta", "Gamma", "Delta", "Epsilon"];
2249 const FUZZ_CASE_VARIANTS: [&str; 5] = ["East", "east", "WEST", "west", "North"];
2250
2251 fn fuzz_source_sheet(rng: &mut StdRng, num_rows: usize) -> (Sheet, Vec<String>) {
2259 let mut sheet = Sheet::new(SheetInit {
2260 name: Some("FuzzData".to_string()),
2261 rows: num_rows + 1,
2262 cols: FUZZ_COLS.len(),
2263 ..Default::default()
2264 });
2265 for (c, h) in FUZZ_COLS.iter().enumerate() {
2266 sheet.set_cell_src(0, c, h.to_string());
2267 }
2268 for r in 0..num_rows {
2269 let cat = if rng.gen_bool(0.1) {
2270 String::new()
2271 } else {
2272 FUZZ_CATEGORIES[rng.gen_range(0..FUZZ_CATEGORIES.len())].to_string()
2273 };
2274 sheet.set_cell_src(r + 1, 0, cat);
2275
2276 let mixed = FUZZ_CASE_VARIANTS[rng.gen_range(0..FUZZ_CASE_VARIANTS.len())].to_string();
2277 sheet.set_cell_src(r + 1, 1, mixed);
2278
2279 let numstr = match rng.gen_range(0u8..4u8) {
2280 0 => String::new(),
2281 1 => format!("\"0{}\"", rng.gen_range(0u32..10u32)),
2282 2 => format!("\".0{}\"", rng.gen_range(0u32..1000u32)),
2283 _ => format!("\"{}\"", rng.gen_range(-50i64..50i64)),
2284 };
2285 sheet.set_cell_src(r + 1, 2, numstr);
2286
2287 sheet.set_cell_src(r + 1, 3, rng.gen_range(-100i64..=100i64).to_string());
2288
2289 let rate =
2290 (rng.gen_range(-500i64..=500i64) as f64) / (rng.gen_range(1i64..=100i64) as f64);
2291 sheet.set_cell_src(r + 1, 4, format!("{:.4}", rate));
2292
2293 sheet.set_cell_src(r + 1, 5, rng.gen_bool(0.5).to_string());
2294 }
2295 sheet.commit(None).unwrap();
2296 (sheet, FUZZ_COLS.iter().map(|s| s.to_string()).collect())
2297 }
2298
2299 fn random_aggregation(rng: &mut StdRng) -> PivotAggregation {
2300 match rng.gen_range(0u8..6u8) {
2301 0 => PivotAggregation::Sum,
2302 1 => PivotAggregation::Count,
2303 2 => PivotAggregation::CountNumbers,
2304 3 => PivotAggregation::Average,
2305 4 => PivotAggregation::Max,
2306 _ => PivotAggregation::Min,
2307 }
2308 }
2309
2310 fn fuzz_pivot_config(
2317 rng: &mut StdRng,
2318 sheet: &Sheet,
2319 col_names: &[String],
2320 num_rows: usize,
2321 use_table: bool,
2322 ) -> PivotTable {
2323 let mut pool: Vec<usize> = vec![0, 1, 2];
2324 let numeric: [usize; 2] = [3, 4];
2325
2326 let n_row = rng.gen_range(0..=pool.len().min(2));
2327 let row_cols: Vec<usize> = (0..n_row)
2328 .map(|_| pool.remove(rng.gen_range(0..pool.len())))
2329 .collect();
2330 let n_col = rng.gen_range(0..=pool.len().min(2));
2331 let col_cols: Vec<usize> = (0..n_col)
2332 .map(|_| pool.remove(rng.gen_range(0..pool.len())))
2333 .collect();
2334
2335 let row_fields: Vec<PivotField> = row_cols
2336 .iter()
2337 .map(|&i| PivotField {
2338 column: col_names[i].clone(),
2339 subtotal: rng.gen_bool(0.7),
2340 })
2341 .collect();
2342 let col_fields: Vec<PivotField> = col_cols
2343 .iter()
2344 .map(|&i| PivotField {
2345 column: col_names[i].clone(),
2346 subtotal: rng.gen_bool(0.7),
2347 })
2348 .collect();
2349
2350 let n_value = rng.gen_range(1..=2);
2351 let value_fields: Vec<PivotValueField> = (0..n_value)
2352 .map(|_| {
2353 let col = numeric[rng.gen_range(0..numeric.len())];
2354 PivotValueField::new(col_names[col].clone(), random_aggregation(rng))
2355 })
2356 .collect();
2357
2358 let mut filter_fields = Vec::new();
2359 if rng.gen_bool(0.5) {
2360 let candidates = [0usize, 1, 2, 5];
2361 let fcol = candidates[rng.gen_range(0..candidates.len())];
2362 let mut distinct: Vec<String> = (1..=num_rows)
2363 .map(|r| group_key(&sheet.get_result_data(&CellRef::new(r, fcol))))
2364 .collect();
2365 distinct.sort();
2366 distinct.dedup();
2367 let selected = if distinct.is_empty() || rng.gen_bool(0.2) {
2368 None
2369 } else {
2370 Some(distinct.into_iter().filter(|_| rng.gen_bool(0.5)).collect())
2371 };
2372 filter_fields.push(PivotFilterField {
2373 column: col_names[fcol].clone(),
2374 selected_values: selected,
2375 multiple_selection: true,
2376 });
2377 }
2378
2379 let source = if use_table {
2380 PivotSource::Table {
2381 name: "FuzzTable".to_string(),
2382 }
2383 } else {
2384 PivotSource::Range {
2385 sheet_id: sheet.id,
2386 start_row: 0,
2387 start_col: 0,
2388 end_row: num_rows,
2389 end_col: col_names.len() - 1,
2390 }
2391 };
2392
2393 PivotTable {
2394 id: 1,
2395 name: "FuzzPivot".to_string(),
2396 source,
2397 dest_sheet_id: sheet.id,
2398 dest_row: num_rows + 20,
2399 dest_col: 0,
2400 row_fields,
2401 col_fields,
2402 value_fields,
2403 filter_fields,
2404 grand_totals_row: rng.gen_bool(0.7),
2405 grand_totals_col: rng.gen_bool(0.7),
2406 last_output_end_row: None,
2407 last_output_end_col: None,
2408 }
2409 }
2410
2411 fn results_close(a: &ResultData, b: &ResultData) -> bool {
2412 match (a, b) {
2413 (ResultData::Integer(x), ResultData::Integer(y)) => x == y,
2414 (ResultData::Float(x), ResultData::Float(y)) => (x - y).abs() < 1e-6,
2415 (ResultData::Integer(x), ResultData::Float(y))
2416 | (ResultData::Float(y), ResultData::Integer(x)) => (*x as f64 - y).abs() < 1e-6,
2417 (ResultData::None, ResultData::None) => true,
2418 (ResultData::Error(x), ResultData::Error(y)) => x == y,
2419 (ResultData::String(x), ResultData::String(y)) => x == y,
2420 (ResultData::Boolean(x), ResultData::Boolean(y)) => x == y,
2421 _ => false,
2422 }
2423 }
2424
2425 fn matches_partial(key: &[String], labels: &[Option<String>]) -> bool {
2431 key.iter()
2432 .zip(labels)
2433 .all(|(k, want)| want.as_ref().is_none_or(|w| w.eq_ignore_ascii_case(k)))
2434 }
2435
2436 fn verify_grid_matches_records(sheet: &Sheet, pivot: &PivotTable, grid: &PivotGrid) {
2444 let (_, col_names, sheet_cols, data_rows) =
2445 resolve_source(&[sheet], &pivot.source).unwrap();
2446 let row_idxs: Vec<usize> = pivot
2447 .row_fields
2448 .iter()
2449 .map(|f| column_index(&col_names, &f.column).unwrap())
2450 .collect();
2451 let col_idxs: Vec<usize> = pivot
2452 .col_fields
2453 .iter()
2454 .map(|f| column_index(&col_names, &f.column).unwrap())
2455 .collect();
2456
2457 let mut records: Vec<(Vec<String>, Vec<String>, Vec<ResultData>)> = Vec::new();
2458 'row: for &r in &data_rows {
2459 let row_vals: Vec<ResultData> = sheet_cols
2460 .iter()
2461 .map(|&c| sheet.get_result_data(&CellRef::new(r, c)))
2462 .collect();
2463 for ff in &pivot.filter_fields {
2464 if let Some(selected) = &ff.selected_values {
2465 let idx = column_index(&col_names, &ff.column).unwrap();
2466 let key = group_key(&row_vals[idx]);
2467 if !selected.iter().any(|v| v.eq_ignore_ascii_case(&key)) {
2468 continue 'row;
2469 }
2470 }
2471 }
2472 let row_key: Vec<String> = row_idxs.iter().map(|&i| group_key(&row_vals[i])).collect();
2473 let col_key: Vec<String> = col_idxs.iter().map(|&i| group_key(&row_vals[i])).collect();
2474 records.push((row_key, col_key, row_vals));
2475 }
2476
2477 let value_idxs: Vec<usize> = pivot
2478 .value_fields
2479 .iter()
2480 .map(|vf| column_index(&col_names, &vf.column).unwrap())
2481 .collect();
2482 let value_multiplier = if pivot.value_fields.len() > 1 {
2483 pivot.value_fields.len()
2484 } else {
2485 1
2486 };
2487 let width = row_label_width(pivot);
2488
2489 assert_eq!(grid.body_rows.len(), grid.row_axis.len());
2490 assert_eq!(grid.width, width + grid.col_axis.len() * value_multiplier);
2491 for hrow in &grid.header_rows {
2492 assert_eq!(hrow.len(), grid.width);
2493 }
2494
2495 for (i, (body_row, row_axis)) in grid.body_rows.iter().zip(grid.row_axis.iter()).enumerate()
2496 {
2497 assert_eq!(
2498 body_row.is_grand_total, row_axis.is_grand_total,
2499 "row {i} grand-total flag mismatch"
2500 );
2501 assert_eq!(body_row.row_labels.len(), width, "row {i} label width");
2502 assert_eq!(
2503 body_row.values.len(),
2504 grid.col_axis.len() * value_multiplier,
2505 "row {i} value count"
2506 );
2507
2508 for (j, col_axis) in grid.col_axis.iter().enumerate() {
2509 let matching: Vec<&Vec<ResultData>> = records
2510 .iter()
2511 .filter(|(rk, ck, _)| {
2512 matches_partial(rk, &row_axis.labels)
2513 && matches_partial(ck, &col_axis.labels)
2514 })
2515 .map(|(_, _, row)| row)
2516 .collect();
2517
2518 for (vf_pos, &vidx) in value_idxs.iter().enumerate() {
2519 if vf_pos > 0 && value_multiplier == 1 {
2520 break;
2521 }
2522 let col_vals: Vec<ResultData> =
2523 matching.iter().map(|row| row[vidx].clone()).collect();
2524 let expected =
2525 aggregate(sheet, &col_vals, pivot.value_fields[vf_pos].aggregation);
2526 let actual = &body_row.values[j * value_multiplier + vf_pos];
2527 assert!(
2528 results_close(&expected, actual),
2529 "row {i} col {j} value-field {vf_pos}: expected {expected:?}, got {actual:?} \
2530 (row_labels={:?}, col_labels={:?})",
2531 row_axis.labels,
2532 col_axis.labels,
2533 );
2534 }
2535 }
2536 }
2537 }
2538
2539 fn verify_grand_total_placement(
2546 axis: &[PivotAxisItem],
2547 grand_total_requested: bool,
2548 axis_has_fields: bool,
2549 label: &str,
2550 ) {
2551 let grand_count = axis.iter().filter(|a| a.is_grand_total).count();
2552 let has_any_real_group = axis.iter().any(|a| !a.is_grand_total);
2553 assert!(grand_count <= 1, "{label}: more than one grand-total group");
2554 if grand_total_requested && axis_has_fields && has_any_real_group {
2555 assert_eq!(
2556 grand_count, 1,
2557 "{label}: expected a grand total to be appended"
2558 );
2559 } else {
2560 assert_eq!(grand_count, 0, "{label}: did not expect a grand total");
2561 }
2562 }
2563
2564 #[test]
2565 fn test_fuzz_pivot_random_invariants() {
2566 for seed in 0u64..300 {
2567 let mut rng: StdRng = SeedableRng::seed_from_u64(seed);
2568 let use_table = seed % 2 == 0;
2569 let num_rows = rng.gen_range(0..=40usize);
2570 let (mut sheet, col_names) = fuzz_source_sheet(&mut rng, num_rows);
2571 if use_table {
2572 sheet
2573 .add_table(
2574 "FuzzTable".to_string(),
2575 0,
2576 0,
2577 num_rows,
2578 col_names.len() - 1,
2579 true,
2580 false,
2581 )
2582 .unwrap();
2583 }
2584 let pivot = fuzz_pivot_config(&mut rng, &sheet, &col_names, num_rows, use_table);
2585
2586 let grid = compute_pivot(&[&sheet], &pivot)
2587 .unwrap_or_else(|e| panic!("seed {seed}: compute_pivot failed: {e}"));
2588
2589 verify_grid_matches_records(&sheet, &pivot, &grid);
2590 verify_grand_total_placement(
2591 &grid.row_axis,
2592 pivot.grand_totals_row,
2593 !pivot.row_fields.is_empty(),
2594 "row axis",
2595 );
2596 verify_grand_total_placement(
2597 &grid.col_axis,
2598 pivot.grand_totals_col,
2599 !pivot.col_fields.is_empty(),
2600 "col axis",
2601 );
2602
2603 let xlsx = crate::core::xlsx::export_xlsx_data(
2604 std::slice::from_ref(&sheet),
2605 &[],
2606 std::slice::from_ref(&pivot),
2607 None,
2608 )
2609 .unwrap_or_else(|e| panic!("seed {seed}: export failed: {e}"));
2610 let (imported_sheets, _, imported_pivots, _) =
2611 crate::core::xlsx::import_xlsx_data(&xlsx, &[], |_, _, _| {})
2612 .unwrap_or_else(|e| panic!("seed {seed}: import failed: {e}"));
2613 assert_eq!(
2614 imported_pivots.len(),
2615 1,
2616 "seed {seed}: pivot lost on round-trip"
2617 );
2618 let reimported = &imported_pivots[0];
2619
2620 assert_eq!(
2621 reimported
2622 .row_fields
2623 .iter()
2624 .map(|f| &f.column)
2625 .collect::<Vec<_>>(),
2626 pivot
2627 .row_fields
2628 .iter()
2629 .map(|f| &f.column)
2630 .collect::<Vec<_>>(),
2631 "seed {seed}: row field columns changed on round-trip"
2632 );
2633 assert_eq!(
2634 reimported
2635 .col_fields
2636 .iter()
2637 .map(|f| &f.column)
2638 .collect::<Vec<_>>(),
2639 pivot
2640 .col_fields
2641 .iter()
2642 .map(|f| &f.column)
2643 .collect::<Vec<_>>(),
2644 "seed {seed}: col field columns changed on round-trip"
2645 );
2646 assert_eq!(
2647 reimported
2648 .value_fields
2649 .iter()
2650 .map(|f| (&f.column, f.aggregation))
2651 .collect::<Vec<_>>(),
2652 pivot
2653 .value_fields
2654 .iter()
2655 .map(|f| (&f.column, f.aggregation))
2656 .collect::<Vec<_>>(),
2657 "seed {seed}: value fields changed on round-trip"
2658 );
2659 assert_eq!(reimported.grand_totals_row, pivot.grand_totals_row);
2660 assert_eq!(reimported.grand_totals_col, pivot.grand_totals_col);
2661 assert_eq!(
2662 reimported
2663 .row_fields
2664 .iter()
2665 .map(|f| f.subtotal)
2666 .collect::<Vec<_>>(),
2667 pivot
2668 .row_fields
2669 .iter()
2670 .map(|f| f.subtotal)
2671 .collect::<Vec<_>>(),
2672 "seed {seed}: row field subtotal toggle should round-trip"
2673 );
2674 assert_eq!(
2675 reimported
2676 .col_fields
2677 .iter()
2678 .map(|f| f.subtotal)
2679 .collect::<Vec<_>>(),
2680 pivot
2681 .col_fields
2682 .iter()
2683 .map(|f| f.subtotal)
2684 .collect::<Vec<_>>(),
2685 "seed {seed}: col field subtotal toggle should round-trip"
2686 );
2687 let nothing_lossy = true;
2688 let any_filter_is_also_an_axis_field = pivot.filter_fields.iter().any(|ff| {
2689 pivot
2690 .row_fields
2691 .iter()
2692 .chain(pivot.col_fields.iter())
2693 .any(|f| f.column.eq_ignore_ascii_case(&ff.column))
2694 });
2695 let reimported_sheets: Vec<Sheet> =
2696 imported_sheets.into_iter().map(|s| s.sheet).collect();
2697 let reimported_sheet_refs: Vec<&Sheet> = reimported_sheets.iter().collect();
2698 let reimported_grid = compute_pivot(&reimported_sheet_refs, reimported)
2699 .unwrap_or_else(|e| panic!("seed {seed}: reimported compute_pivot failed: {e}"));
2700 if nothing_lossy && !any_filter_is_also_an_axis_field {
2701 assert_eq!(
2702 reimported_grid.body_rows.len(),
2703 grid.body_rows.len(),
2704 "seed {seed}: grid shape changed on lossless round-trip"
2705 );
2706 }
2707
2708 let sorted = |f: &PivotFilterField| {
2709 f.selected_values.as_ref().map(|v| {
2710 let mut v: Vec<String> = v.iter().map(|s| s.to_lowercase()).collect();
2711 v.sort();
2712 v.dedup();
2713 v
2714 })
2715 };
2716 let axis_bound = |column: &str| {
2717 pivot
2718 .row_fields
2719 .iter()
2720 .chain(pivot.col_fields.iter())
2721 .any(|f| f.column.eq_ignore_ascii_case(column))
2722 };
2723 for (before, after) in pivot
2724 .filter_fields
2725 .iter()
2726 .zip(reimported.filter_fields.iter())
2727 {
2728 if axis_bound(&before.column) {
2729 continue;
2730 }
2731 if before.selected_values.is_some() && after.selected_values.is_none() {
2732 assert_eq!(
2733 reimported_grid.body_rows.len(),
2734 grid.body_rows.len(),
2735 "seed {seed}: filter on '{}' was dropped and it mattered",
2736 before.column
2737 );
2738 } else {
2739 assert_eq!(
2740 sorted(before),
2741 sorted(after),
2742 "seed {seed}: filter selection should round-trip for '{}'",
2743 before.column
2744 );
2745 }
2746 }
2747 }
2748 }
2749}