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