1use super::*;
2
3impl Workbook {
4 pub fn add_pivot_table(&mut self, config: &PivotTableConfig) -> Result<()> {
10 self.hydrate_pivot_tables();
11 let _src_idx = self.sheet_index(&config.source_sheet)?;
13
14 let target_idx = self.sheet_index(&config.target_sheet)?;
16
17 if self
19 .pivot_tables
20 .iter()
21 .any(|(_, pt)| pt.name == config.name)
22 {
23 return Err(Error::PivotTableAlreadyExists {
24 name: config.name.clone(),
25 });
26 }
27
28 let field_names = self.read_header_row(&config.source_sheet, &config.source_range)?;
30 if field_names.is_empty() {
31 return Err(Error::InvalidSourceRange(
32 "source range header row is empty".to_string(),
33 ));
34 }
35
36 let cache_id = self
38 .pivot_tables
39 .iter()
40 .map(|(_, pt)| pt.cache_id)
41 .max()
42 .map(|m| m + 1)
43 .unwrap_or(0);
44
45 let pt_def = crate::pivot::build_pivot_table_xml(config, cache_id, &field_names)?;
47 let pcd = crate::pivot::build_pivot_cache_definition(
48 &config.source_sheet,
49 &config.source_range,
50 &field_names,
51 );
52 let pcr = sheetkit_xml::pivot_cache::PivotCacheRecords {
53 xmlns: sheetkit_xml::namespaces::SPREADSHEET_ML.to_string(),
54 xmlns_r: sheetkit_xml::namespaces::RELATIONSHIPS.to_string(),
55 count: Some(0),
56 records: vec![],
57 };
58
59 let pt_num = self.pivot_tables.len() + 1;
61 let cache_num = self.pivot_cache_defs.len() + 1;
62
63 let pt_path = format!("xl/pivotTables/pivotTable{}.xml", pt_num);
64 let pcd_path = format!("xl/pivotCache/pivotCacheDefinition{}.xml", cache_num);
65 let pcr_path = format!("xl/pivotCache/pivotCacheRecords{}.xml", cache_num);
66
67 self.pivot_tables.push((pt_path.clone(), pt_def));
69 self.pivot_cache_defs.push((pcd_path.clone(), pcd));
70 self.pivot_cache_records.push((pcr_path.clone(), pcr));
71
72 self.content_types.overrides.push(ContentTypeOverride {
74 part_name: format!("/{}", pt_path),
75 content_type: mime_types::PIVOT_TABLE.to_string(),
76 });
77 self.content_types.overrides.push(ContentTypeOverride {
78 part_name: format!("/{}", pcd_path),
79 content_type: mime_types::PIVOT_CACHE_DEFINITION.to_string(),
80 });
81 self.content_types.overrides.push(ContentTypeOverride {
82 part_name: format!("/{}", pcr_path),
83 content_type: mime_types::PIVOT_CACHE_RECORDS.to_string(),
84 });
85
86 let wb_rid = crate::sheet::next_rid(&self.workbook_rels.relationships);
88 self.workbook_rels.relationships.push(Relationship {
89 id: wb_rid.clone(),
90 rel_type: rel_types::PIVOT_CACHE_DEF.to_string(),
91 target: format!("pivotCache/pivotCacheDefinition{}.xml", cache_num),
92 target_mode: None,
93 });
94
95 let pivot_caches = self
97 .workbook_xml
98 .pivot_caches
99 .get_or_insert_with(|| sheetkit_xml::workbook::PivotCaches { caches: vec![] });
100 pivot_caches
101 .caches
102 .push(sheetkit_xml::workbook::PivotCacheEntry {
103 cache_id,
104 r_id: wb_rid,
105 });
106
107 let ws_rid = self.next_worksheet_rid(target_idx);
109 let ws_rels = self
110 .worksheet_rels
111 .entry(target_idx)
112 .or_insert_with(|| Relationships {
113 xmlns: sheetkit_xml::namespaces::PACKAGE_RELATIONSHIPS.to_string(),
114 relationships: vec![],
115 });
116 ws_rels.relationships.push(Relationship {
117 id: ws_rid,
118 rel_type: rel_types::PIVOT_TABLE.to_string(),
119 target: format!("../pivotTables/pivotTable{}.xml", pt_num),
120 target_mode: None,
121 });
122
123 Ok(())
124 }
125
126 pub fn get_pivot_tables(&self) -> Vec<PivotTableInfo> {
128 use crate::workbook::aux::AuxCategory;
129
130 let mut pivot_tables = self.pivot_tables.clone();
131 if let Some(entries) = self.deferred_parts.entries(AuxCategory::PivotTables) {
132 for (path, bytes) in entries {
133 if pivot_tables.iter().any(|(existing, _)| existing == path) {
134 continue;
135 }
136 let xml_str = String::from_utf8_lossy(bytes);
137 if let Ok(pt) = quick_xml::de::from_str::<
138 sheetkit_xml::pivot_table::PivotTableDefinition,
139 >(&xml_str)
140 {
141 pivot_tables.push((path.clone(), pt));
142 }
143 }
144 }
145
146 let mut pivot_cache_defs = self.pivot_cache_defs.clone();
147 if let Some(entries) = self.deferred_parts.entries(AuxCategory::PivotCaches) {
148 for (path, bytes) in entries {
149 if !path.contains("pivotCacheDefinition")
150 || pivot_cache_defs
151 .iter()
152 .any(|(existing, _)| existing == path)
153 {
154 continue;
155 }
156 let xml_str = String::from_utf8_lossy(bytes);
157 if let Ok(pcd) = quick_xml::de::from_str::<
158 sheetkit_xml::pivot_cache::PivotCacheDefinition,
159 >(&xml_str)
160 {
161 pivot_cache_defs.push((path.clone(), pcd));
162 }
163 }
164 }
165
166 pivot_tables
167 .iter()
168 .map(|(path, pt)| {
169 let (source_sheet, source_range) = pivot_cache_defs
171 .iter()
172 .enumerate()
173 .find(|(i, _)| {
174 self.workbook_xml
175 .pivot_caches
176 .as_ref()
177 .and_then(|pc| pc.caches.iter().find(|e| e.cache_id == pt.cache_id))
178 .is_some()
179 || *i == pt.cache_id as usize
180 })
181 .and_then(|(_, (_, pcd))| {
182 pcd.cache_source
183 .worksheet_source
184 .as_ref()
185 .map(|ws| (ws.sheet.clone(), ws.reference.clone()))
186 })
187 .unwrap_or_default();
188
189 let target_sheet = self
191 .find_pivot_table_target_sheet_by_path(path)
192 .unwrap_or_default();
193
194 PivotTableInfo {
195 name: pt.name.clone(),
196 source_sheet,
197 source_range,
198 target_sheet,
199 location: pt.location.reference.clone(),
200 }
201 })
202 .collect()
203 }
204
205 pub fn delete_pivot_table(&mut self, name: &str) -> Result<()> {
207 self.hydrate_pivot_tables();
208 let pt_idx = self
210 .pivot_tables
211 .iter()
212 .position(|(_, pt)| pt.name == name)
213 .ok_or_else(|| Error::PivotTableNotFound {
214 name: name.to_string(),
215 })?;
216
217 let (pt_path, pt_def) = self.pivot_tables.remove(pt_idx);
218 let cache_id = pt_def.cache_id;
219
220 let mut wb_cache_rid = None;
223 if let Some(ref mut pivot_caches) = self.workbook_xml.pivot_caches {
224 if let Some(pos) = pivot_caches
225 .caches
226 .iter()
227 .position(|e| e.cache_id == cache_id)
228 {
229 wb_cache_rid = Some(pivot_caches.caches[pos].r_id.clone());
230 pivot_caches.caches.remove(pos);
231 }
232 if pivot_caches.caches.is_empty() {
233 self.workbook_xml.pivot_caches = None;
234 }
235 }
236
237 if let Some(ref rid) = wb_cache_rid {
239 if let Some(rel) = self
241 .workbook_rels
242 .relationships
243 .iter()
244 .find(|r| r.id == *rid)
245 {
246 let target_path = format!("xl/{}", rel.target);
247 self.pivot_cache_defs.retain(|(p, _)| *p != target_path);
248
249 let records_path = target_path.replace("pivotCacheDefinition", "pivotCacheRecords");
251 self.pivot_cache_records.retain(|(p, _)| *p != records_path);
252 }
253 self.workbook_rels.relationships.retain(|r| r.id != *rid);
254 }
255
256 let pt_part = format!("/{}", pt_path);
258 self.content_types
259 .overrides
260 .retain(|o| o.part_name != pt_part);
261
262 self.content_types.overrides.retain(|o| {
264 let p = o.part_name.trim_start_matches('/');
265 if o.content_type == mime_types::PIVOT_CACHE_DEFINITION {
267 return self.pivot_cache_defs.iter().any(|(path, _)| path == p);
268 }
269 if o.content_type == mime_types::PIVOT_CACHE_RECORDS {
270 return self.pivot_cache_records.iter().any(|(path, _)| path == p);
271 }
272 if o.content_type == mime_types::PIVOT_TABLE {
273 return self.pivot_tables.iter().any(|(path, _)| path == p);
274 }
275 true
276 });
277
278 for (_idx, rels) in self.worksheet_rels.iter_mut() {
280 rels.relationships.retain(|r| {
281 if r.rel_type != rel_types::PIVOT_TABLE {
282 return true;
283 }
284 let full_target = format!(
286 "xl/pivotTables/{}",
287 r.target.trim_start_matches("../pivotTables/")
288 );
289 full_target != pt_path
290 });
291 }
292
293 Ok(())
294 }
295
296 pub fn add_sparkline(
298 &mut self,
299 sheet: &str,
300 config: &crate::sparkline::SparklineConfig,
301 ) -> Result<()> {
302 let idx = self.sheet_index(sheet)?;
303 crate::sparkline::validate_sparkline_config(config)?;
304 while self.sheet_sparklines.len() <= idx {
305 self.sheet_sparklines.push(vec![]);
306 }
307 self.sheet_sparklines[idx].push(config.clone());
308 self.mark_sheet_dirty(idx);
309 Ok(())
310 }
311
312 pub fn get_sparklines(&self, sheet: &str) -> Result<Vec<crate::sparkline::SparklineConfig>> {
314 let idx = self.sheet_index(sheet)?;
315 Ok(self.sheet_sparklines.get(idx).cloned().unwrap_or_default())
316 }
317
318 pub fn remove_sparkline(&mut self, sheet: &str, location: &str) -> Result<()> {
320 let idx = self.sheet_index(sheet)?;
321 let sparklines = self
322 .sheet_sparklines
323 .get_mut(idx)
324 .ok_or_else(|| Error::Internal(format!("no sparkline data for sheet '{sheet}'")))?;
325 let pos = sparklines
326 .iter()
327 .position(|s| s.location == location)
328 .ok_or_else(|| {
329 Error::Internal(format!(
330 "sparkline at location '{location}' not found on sheet '{sheet}'"
331 ))
332 })?;
333 sparklines.remove(pos);
334 Ok(())
335 }
336
337 pub fn evaluate_formula(&self, sheet: &str, formula: &str) -> Result<CellValue> {
342 let _ = self.sheet_index(sheet)?;
344 let parsed = crate::formula::parser::parse_formula(formula)?;
345 let snapshot = self.build_cell_snapshot(sheet)?;
346 crate::formula::eval::evaluate(&parsed, &snapshot)
347 }
348
349 pub fn calculate_all(&mut self) -> Result<()> {
353 use crate::formula::eval::{build_dependency_graph, topological_sort, CellCoord};
354
355 let sheet_names: Vec<String> = self.sheet_names().iter().map(|s| s.to_string()).collect();
356
357 let mut formula_cells: Vec<(CellCoord, String)> = Vec::new();
359 for (idx, sn) in sheet_names.iter().enumerate() {
360 self.ensure_hydrated(idx)?;
361 let ws = self.worksheets[idx].1.get().unwrap();
362 for row in &ws.sheet_data.rows {
363 for cell in &row.cells {
364 if let Some(ref f) = cell.f {
365 let formula_str = f.value.clone().unwrap_or_default();
366 if !formula_str.is_empty() {
367 if let Ok((c, r)) = cell_name_to_coordinates(cell.r.as_str()) {
368 formula_cells.push((
369 CellCoord {
370 sheet: sn.clone(),
371 col: c,
372 row: r,
373 },
374 formula_str,
375 ));
376 }
377 }
378 }
379 }
380 }
381 }
382
383 if formula_cells.is_empty() {
384 return Ok(());
385 }
386
387 let deps = build_dependency_graph(&formula_cells)?;
389 let coords: Vec<CellCoord> = formula_cells.iter().map(|(c, _)| c.clone()).collect();
390 let eval_order = topological_sort(&coords, &deps)?;
391
392 let formula_map: HashMap<CellCoord, String> = formula_cells.into_iter().collect();
394
395 let first_sheet = sheet_names.first().cloned().unwrap_or_default();
397 let mut snapshot = self.build_cell_snapshot(&first_sheet)?;
398
399 let mut results: Vec<(CellCoord, String, CellValue)> = Vec::new();
402 for coord in &eval_order {
403 if let Some(formula_str) = formula_map.get(coord) {
404 snapshot.set_current_sheet(&coord.sheet);
405 let parsed = crate::formula::parser::parse_formula(formula_str)?;
406 let mut evaluator = crate::formula::eval::Evaluator::new(&snapshot);
407 let result = evaluator.eval_expr(&parsed)?;
408 snapshot.set_cell(&coord.sheet, coord.col, coord.row, result.clone());
409 results.push((coord.clone(), formula_str.clone(), result));
410 }
411 }
412
413 let sheet_name_to_idx: HashMap<String, usize> = sheet_names
416 .iter()
417 .enumerate()
418 .map(|(idx, name)| (name.clone(), idx))
419 .collect();
420
421 for (coord, _formula_str, result) in results {
422 let cell_ref = crate::utils::cell_ref::coordinates_to_cell_name(coord.col, coord.row)?;
423 let Some(&sheet_idx) = sheet_name_to_idx.get(&coord.sheet) else {
424 continue;
425 };
426 let Some(ws) = self.worksheets[sheet_idx].1.get_mut() else {
427 continue;
428 };
429 if let Some(row) = ws.sheet_data.rows.iter_mut().find(|r| r.r == coord.row) {
430 if let Some(cell) = row.cells.iter_mut().find(|c| c.r == *cell_ref) {
431 let (new_v, new_t) = match &result {
432 CellValue::Number(n) => (Some(n.to_string()), CellTypeTag::None),
433 CellValue::String(s) => (Some(s.clone()), CellTypeTag::FormulaString),
434 CellValue::Bool(b) => (
435 Some(if *b { "1".to_string() } else { "0".to_string() }),
436 CellTypeTag::Boolean,
437 ),
438 CellValue::Error(e) => (Some(e.clone()), CellTypeTag::Error),
439 CellValue::Date(n) => (Some(n.to_string()), CellTypeTag::None),
440 _ => continue,
441 };
442
443 let changed = cell.v != new_v || cell.t != new_t;
444 if changed {
445 cell.v = new_v;
446 cell.t = new_t;
447 self.mark_sheet_dirty(sheet_idx);
448 }
449 }
450 }
451 }
452
453 Ok(())
454 }
455
456 fn build_cell_snapshot(
459 &self,
460 current_sheet: &str,
461 ) -> Result<crate::formula::eval::CellSnapshot> {
462 let mut snapshot = crate::formula::eval::CellSnapshot::new(current_sheet.to_string());
463 for (idx, (sn, _)) in self.worksheets.iter().enumerate() {
464 let ws = self.worksheet_ref_by_index(idx)?;
465 for row in &ws.sheet_data.rows {
466 for cell in &row.cells {
467 if let Ok((c, r)) = cell_name_to_coordinates(cell.r.as_str()) {
468 let cv = self.xml_cell_to_value(cell)?;
469 snapshot.set_cell(sn, c, r, cv);
470 }
471 }
472 }
473 }
474 Ok(snapshot)
475 }
476
477 pub fn get_occupied_cells(&self, sheet: &str) -> Result<Vec<(u32, u32)>> {
479 let ws = self.worksheet_ref(sheet)?;
480 let mut cells = Vec::new();
481 for row in &ws.sheet_data.rows {
482 for cell in &row.cells {
483 if let Ok((c, r)) = cell_name_to_coordinates(cell.r.as_str()) {
484 cells.push((c, r));
485 }
486 }
487 }
488 Ok(cells)
489 }
490
491 fn read_header_row(&self, sheet: &str, range: &str) -> Result<Vec<String>> {
494 let parts: Vec<&str> = range.split(':').collect();
495 if parts.len() != 2 {
496 return Err(Error::InvalidSourceRange(range.to_string()));
497 }
498 let (start_col, start_row) = cell_name_to_coordinates(parts[0])
499 .map_err(|_| Error::InvalidSourceRange(range.to_string()))?;
500 let (end_col, _end_row) = cell_name_to_coordinates(parts[1])
501 .map_err(|_| Error::InvalidSourceRange(range.to_string()))?;
502
503 let mut headers = Vec::new();
504 for col in start_col..=end_col {
505 let cell_name = crate::utils::cell_ref::coordinates_to_cell_name(col, start_row)?;
506 let val = self.get_cell_value(sheet, &cell_name)?;
507 let s = match val {
508 CellValue::String(s) => s,
509 CellValue::Number(n) => n.to_string(),
510 CellValue::Bool(b) => b.to_string(),
511 CellValue::RichString(runs) => crate::rich_text::rich_text_to_plain(&runs),
512 _ => String::new(),
513 };
514 headers.push(s);
515 }
516 Ok(headers)
517 }
518
519 fn find_pivot_table_target_sheet_by_path(&self, pt_path: &str) -> Option<String> {
521 for (sheet_idx, rels) in &self.worksheet_rels {
523 for r in &rels.relationships {
524 if r.rel_type == rel_types::PIVOT_TABLE {
525 let full_target = format!(
526 "xl/pivotTables/{}",
527 r.target.trim_start_matches("../pivotTables/")
528 );
529 if full_target == pt_path {
530 return self
531 .worksheets
532 .get(*sheet_idx)
533 .map(|(name, _)| name.clone());
534 }
535 }
536 }
537 }
538 None
539 }
540
541 pub fn set_doc_props(&mut self, props: crate::doc_props::DocProperties) {
543 self.hydrate_doc_props();
544 self.core_properties = Some(props.to_core_properties());
545 self.ensure_doc_props_content_types();
546 }
547
548 pub fn get_doc_props(&self) -> crate::doc_props::DocProperties {
550 use crate::workbook::aux::AuxCategory;
551
552 if let Some(props) = self.core_properties.as_ref() {
553 return crate::doc_props::DocProperties::from(props);
554 }
555
556 self.deferred_parts
557 .get_path(AuxCategory::DocProperties, "docProps/core.xml")
558 .and_then(|bytes| {
559 let xml_str = String::from_utf8_lossy(bytes);
560 sheetkit_xml::doc_props::deserialize_core_properties(&xml_str).ok()
561 })
562 .as_ref()
563 .map(crate::doc_props::DocProperties::from)
564 .unwrap_or_default()
565 }
566
567 pub fn set_app_props(&mut self, props: crate::doc_props::AppProperties) {
569 self.hydrate_doc_props();
570 self.app_properties = Some(props.to_extended_properties());
571 self.ensure_doc_props_content_types();
572 }
573
574 pub fn get_app_props(&self) -> crate::doc_props::AppProperties {
576 use crate::workbook::aux::AuxCategory;
577
578 if let Some(props) = self.app_properties.as_ref() {
579 return crate::doc_props::AppProperties::from(props);
580 }
581
582 self.deferred_parts
583 .get_path(AuxCategory::DocProperties, "docProps/app.xml")
584 .and_then(|bytes| {
585 let xml_str = String::from_utf8_lossy(bytes);
586 quick_xml::de::from_str::<sheetkit_xml::doc_props::ExtendedProperties>(&xml_str)
587 .ok()
588 })
589 .as_ref()
590 .map(crate::doc_props::AppProperties::from)
591 .unwrap_or_default()
592 }
593
594 pub fn set_custom_property(
597 &mut self,
598 name: &str,
599 value: crate::doc_props::CustomPropertyValue,
600 ) {
601 self.hydrate_doc_props();
602 let props = self
603 .custom_properties
604 .get_or_insert_with(sheetkit_xml::doc_props::CustomProperties::default);
605 crate::doc_props::set_custom_property(props, name, value);
606 self.ensure_custom_props_content_types();
607 }
608
609 pub fn get_custom_property(&self, name: &str) -> Option<crate::doc_props::CustomPropertyValue> {
611 use crate::workbook::aux::AuxCategory;
612
613 if let Some(props) = self.custom_properties.as_ref() {
614 return crate::doc_props::find_custom_property(props, name);
615 }
616
617 self.deferred_parts
618 .get_path(AuxCategory::DocProperties, "docProps/custom.xml")
619 .and_then(|bytes| {
620 let xml_str = String::from_utf8_lossy(bytes);
621 sheetkit_xml::doc_props::deserialize_custom_properties(&xml_str).ok()
622 })
623 .as_ref()
624 .and_then(|props| crate::doc_props::find_custom_property(props, name))
625 }
626
627 pub fn delete_custom_property(&mut self, name: &str) -> bool {
630 self.hydrate_doc_props();
631 if let Some(ref mut props) = self.custom_properties {
632 crate::doc_props::delete_custom_property(props, name)
633 } else {
634 false
635 }
636 }
637
638 fn ensure_doc_props_content_types(&mut self) {
640 let core_part = "/docProps/core.xml";
641 let app_part = "/docProps/app.xml";
642
643 let has_core = self
644 .content_types
645 .overrides
646 .iter()
647 .any(|o| o.part_name == core_part);
648 if !has_core {
649 self.content_types.overrides.push(ContentTypeOverride {
650 part_name: core_part.to_string(),
651 content_type: mime_types::CORE_PROPERTIES.to_string(),
652 });
653 }
654
655 let has_app = self
656 .content_types
657 .overrides
658 .iter()
659 .any(|o| o.part_name == app_part);
660 if !has_app {
661 self.content_types.overrides.push(ContentTypeOverride {
662 part_name: app_part.to_string(),
663 content_type: mime_types::EXTENDED_PROPERTIES.to_string(),
664 });
665 }
666 }
667
668 fn ensure_custom_props_content_types(&mut self) {
670 self.ensure_doc_props_content_types();
671
672 let custom_part = "/docProps/custom.xml";
673 let has_custom = self
674 .content_types
675 .overrides
676 .iter()
677 .any(|o| o.part_name == custom_part);
678 if !has_custom {
679 self.content_types.overrides.push(ContentTypeOverride {
680 part_name: custom_part.to_string(),
681 content_type: mime_types::CUSTOM_PROPERTIES.to_string(),
682 });
683 }
684
685 let has_custom_rel = self
686 .package_rels
687 .relationships
688 .iter()
689 .any(|r| r.rel_type == rel_types::CUSTOM_PROPERTIES);
690 if !has_custom_rel {
691 let next_id = self.package_rels.relationships.len() + 1;
692 self.package_rels.relationships.push(Relationship {
693 id: format!("rId{next_id}"),
694 rel_type: rel_types::CUSTOM_PROPERTIES.to_string(),
695 target: "docProps/custom.xml".to_string(),
696 target_mode: None,
697 });
698 }
699 }
700
701 fn find_table_by_name(
704 &self,
705 name: &str,
706 ) -> Option<(&String, &sheetkit_xml::table::TableXml, usize)> {
707 self.tables
708 .iter()
709 .find(|(_, t, _)| t.name == name)
710 .map(|(path, t, idx)| (path, t, *idx))
711 }
712
713 pub fn add_slicer(&mut self, sheet: &str, config: &crate::slicer::SlicerConfig) -> Result<()> {
718 use sheetkit_xml::content_types::ContentTypeOverride;
719 use sheetkit_xml::slicer::{
720 SlicerCacheDefinition, SlicerDefinition, SlicerDefinitions, TableSlicerCache,
721 };
722
723 self.hydrate_slicers();
724 self.hydrate_tables();
725 crate::slicer::validate_slicer_config(config)?;
726
727 let sheet_idx = self.sheet_index(sheet)?;
728
729 for (_, sd) in &self.slicer_defs {
731 for s in &sd.slicers {
732 if s.name == config.name {
733 return Err(Error::SlicerAlreadyExists {
734 name: config.name.clone(),
735 });
736 }
737 }
738 }
739
740 let cache_name = crate::slicer::slicer_cache_name(&config.name);
741 let caption = config
742 .caption
743 .clone()
744 .unwrap_or_else(|| config.column_name.clone());
745
746 let slicer_num = self.slicer_defs.len() + 1;
748 let cache_num = self.slicer_caches.len() + 1;
749
750 let slicer_path = format!("xl/slicers/slicer{}.xml", slicer_num);
751 let cache_path = format!("xl/slicerCaches/slicerCache{}.xml", cache_num);
752
753 let slicer_def = SlicerDefinition {
755 name: config.name.clone(),
756 cache: cache_name.clone(),
757 caption: Some(caption),
758 start_item: None,
759 column_count: config.column_count,
760 show_caption: config.show_caption,
761 style: config.style.clone(),
762 locked_position: None,
763 row_height: crate::slicer::DEFAULT_ROW_HEIGHT_EMU,
764 };
765
766 let slicer_defs = SlicerDefinitions {
767 xmlns: sheetkit_xml::namespaces::SLICER_2009.to_string(),
768 xmlns_mc: Some(sheetkit_xml::namespaces::MC.to_string()),
769 slicers: vec![slicer_def],
770 };
771
772 let (_path, table_xml, table_sheet_idx) = self
774 .find_table_by_name(&config.table_name)
775 .ok_or_else(|| Error::TableNotFound {
776 name: config.table_name.clone(),
777 })?;
778
779 if table_sheet_idx != sheet_idx {
780 return Err(Error::TableNotFound {
781 name: config.table_name.clone(),
782 });
783 }
784
785 let column_index = table_xml
787 .table_columns
788 .columns
789 .iter()
790 .position(|c| c.name == config.column_name)
791 .ok_or_else(|| Error::TableColumnNotFound {
792 table: config.table_name.clone(),
793 column: config.column_name.clone(),
794 })?;
795
796 let real_table_id = table_xml.id;
797 let real_column = (column_index + 1) as u32;
798
799 let slicer_cache = SlicerCacheDefinition {
801 name: cache_name.clone(),
802 source_name: config.column_name.clone(),
803 table_slicer_cache: Some(TableSlicerCache {
804 table_id: real_table_id,
805 column: real_column,
806 }),
807 };
808
809 self.slicer_defs.push((slicer_path.clone(), slicer_defs));
811 self.slicer_caches.push((cache_path.clone(), slicer_cache));
812
813 self.content_types.overrides.push(ContentTypeOverride {
815 part_name: format!("/{}", slicer_path),
816 content_type: mime_types::SLICER.to_string(),
817 });
818 self.content_types.overrides.push(ContentTypeOverride {
819 part_name: format!("/{}", cache_path),
820 content_type: mime_types::SLICER_CACHE.to_string(),
821 });
822
823 let wb_rid = crate::sheet::next_rid(&self.workbook_rels.relationships);
825 self.workbook_rels.relationships.push(Relationship {
826 id: wb_rid,
827 rel_type: rel_types::SLICER_CACHE.to_string(),
828 target: format!("slicerCaches/slicerCache{}.xml", cache_num),
829 target_mode: None,
830 });
831
832 let ws_rid = self.next_worksheet_rid(sheet_idx);
834 let ws_rels = self
835 .worksheet_rels
836 .entry(sheet_idx)
837 .or_insert_with(|| Relationships {
838 xmlns: sheetkit_xml::namespaces::PACKAGE_RELATIONSHIPS.to_string(),
839 relationships: vec![],
840 });
841 ws_rels.relationships.push(Relationship {
842 id: ws_rid,
843 rel_type: rel_types::SLICER.to_string(),
844 target: format!("../slicers/slicer{}.xml", slicer_num),
845 target_mode: None,
846 });
847
848 Ok(())
849 }
850
851 pub fn get_slicers(&self, sheet: &str) -> Result<Vec<crate::slicer::SlicerInfo>> {
853 use crate::workbook::aux::AuxCategory;
854
855 let sheet_idx = self.sheet_index(sheet)?;
856 let mut result = Vec::new();
857 let mut slicer_defs = self.slicer_defs.clone();
858 let mut slicer_caches = self.slicer_caches.clone();
859 let mut tables = self.tables.clone();
860
861 if let Some(entries) = self.deferred_parts.entries(AuxCategory::Slicers) {
862 for (path, bytes) in entries {
863 if slicer_defs.iter().any(|(existing, _)| existing == path) {
864 continue;
865 }
866 let xml_str = String::from_utf8_lossy(bytes);
867 if let Ok(sd) =
868 quick_xml::de::from_str::<sheetkit_xml::slicer::SlicerDefinitions>(&xml_str)
869 {
870 slicer_defs.push((path.clone(), sd));
871 }
872 }
873 }
874
875 if let Some(entries) = self.deferred_parts.entries(AuxCategory::SlicerCaches) {
876 for (path, bytes) in entries {
877 if slicer_caches.iter().any(|(existing, _)| existing == path) {
878 continue;
879 }
880 let xml_str = String::from_utf8_lossy(bytes);
881 if let Some(cache) = sheetkit_xml::slicer::parse_slicer_cache(&xml_str) {
882 slicer_caches.push((path.clone(), cache));
883 }
884 }
885 }
886
887 let resolve_table_sheet_idx = |table_path: &str| -> usize {
888 for (idx, rels) in &self.worksheet_rels {
889 for rel in &rels.relationships {
890 if rel.rel_type != rel_types::TABLE {
891 continue;
892 }
893 let resolved = crate::workbook_paths::resolve_relationship_target(
894 &self.sheet_part_path(*idx),
895 &rel.target,
896 );
897 if resolved == table_path {
898 return *idx;
899 }
900 }
901 }
902 0
903 };
904
905 if let Some(entries) = self.deferred_parts.entries(AuxCategory::Tables) {
906 for (path, bytes) in entries {
907 if tables.iter().any(|(existing, _, _)| existing == path) {
908 continue;
909 }
910 let xml_str = String::from_utf8_lossy(bytes);
911 if let Ok(table_xml) =
912 quick_xml::de::from_str::<sheetkit_xml::table::TableXml>(&xml_str)
913 {
914 let table_sheet_idx = resolve_table_sheet_idx(path);
915 tables.push((path.clone(), table_xml, table_sheet_idx));
916 }
917 }
918 }
919
920 let empty_rels = Relationships {
922 xmlns: sheetkit_xml::namespaces::PACKAGE_RELATIONSHIPS.to_string(),
923 relationships: vec![],
924 };
925 let rels = self.worksheet_rels.get(&sheet_idx).unwrap_or(&empty_rels);
926
927 let slicer_targets: Vec<String> = rels
928 .relationships
929 .iter()
930 .filter(|r| r.rel_type == rel_types::SLICER)
931 .map(|r| {
932 let sheet_path = self.sheet_part_path(sheet_idx);
933 crate::workbook_paths::resolve_relationship_target(&sheet_path, &r.target)
934 })
935 .collect();
936
937 for (path, sd) in &slicer_defs {
938 if !slicer_targets.contains(path) {
939 continue;
940 }
941 for slicer in &sd.slicers {
942 let cache = slicer_caches.iter().find(|(_, sc)| sc.name == slicer.cache);
944
945 let (table_name, column_name) = if let Some((_, sc)) = cache {
946 let tname = sc
947 .table_slicer_cache
948 .as_ref()
949 .and_then(|tsc| {
950 tables
951 .iter()
952 .find(|(_, t, _)| t.id == tsc.table_id)
953 .map(|(_, t, _)| t.name.clone())
954 })
955 .unwrap_or_default();
956 let cname = sc
957 .table_slicer_cache
958 .as_ref()
959 .and_then(|tsc| {
960 tables
961 .iter()
962 .find(|(_, t, _)| t.id == tsc.table_id)
963 .and_then(|(_, t, _)| {
964 t.table_columns
965 .columns
966 .get(tsc.column.saturating_sub(1) as usize)
967 })
968 .map(|c| c.name.clone())
969 })
970 .unwrap_or_else(|| sc.source_name.clone());
971 (tname, cname)
972 } else {
973 (String::new(), String::new())
974 };
975
976 result.push(crate::slicer::SlicerInfo {
977 name: slicer.name.clone(),
978 caption: slicer
979 .caption
980 .clone()
981 .unwrap_or_else(|| slicer.name.clone()),
982 table_name,
983 column_name,
984 style: slicer.style.clone(),
985 });
986 }
987 }
988
989 Ok(result)
990 }
991
992 pub fn delete_slicer(&mut self, sheet: &str, name: &str) -> Result<()> {
996 self.hydrate_slicers();
997 let sheet_idx = self.sheet_index(sheet)?;
998
999 let sd_idx = self
1001 .slicer_defs
1002 .iter()
1003 .position(|(_, sd)| sd.slicers.iter().any(|s| s.name == name))
1004 .ok_or_else(|| Error::SlicerNotFound {
1005 name: name.to_string(),
1006 })?;
1007
1008 let (sd_path, sd) = &self.slicer_defs[sd_idx];
1009
1010 let cache_name = sd
1012 .slicers
1013 .iter()
1014 .find(|s| s.name == name)
1015 .map(|s| s.cache.clone())
1016 .unwrap_or_default();
1017
1018 let remove_whole_part = sd.slicers.len() == 1;
1020
1021 if remove_whole_part {
1022 let sd_path_clone = sd_path.clone();
1023 self.slicer_defs.remove(sd_idx);
1024
1025 let sd_part = format!("/{}", sd_path_clone);
1027 self.content_types
1028 .overrides
1029 .retain(|o| o.part_name != sd_part);
1030
1031 let ws_path = self.sheet_part_path(sheet_idx);
1033 if let Some(rels) = self.worksheet_rels.get_mut(&sheet_idx) {
1034 rels.relationships.retain(|r| {
1035 if r.rel_type != rel_types::SLICER {
1036 return true;
1037 }
1038 let target =
1039 crate::workbook_paths::resolve_relationship_target(&ws_path, &r.target);
1040 target != sd_path_clone
1041 });
1042 }
1043 } else {
1044 self.slicer_defs[sd_idx]
1046 .1
1047 .slicers
1048 .retain(|s| s.name != name);
1049 }
1050
1051 if !cache_name.is_empty() {
1053 if let Some(sc_idx) = self
1054 .slicer_caches
1055 .iter()
1056 .position(|(_, sc)| sc.name == cache_name)
1057 {
1058 let (sc_path, _) = self.slicer_caches.remove(sc_idx);
1059 let sc_part = format!("/{}", sc_path);
1060 self.content_types
1061 .overrides
1062 .retain(|o| o.part_name != sc_part);
1063
1064 self.workbook_rels.relationships.retain(|r| {
1066 if r.rel_type != rel_types::SLICER_CACHE {
1067 return true;
1068 }
1069 let full_target = format!("xl/{}", r.target);
1070 full_target != sc_path
1071 });
1072 }
1073 }
1074
1075 Ok(())
1076 }
1077}
1078
1079#[cfg(test)]
1080mod tests {
1081 use super::*;
1082 use crate::workbook::open_options::{AuxParts, OpenOptions, ReadMode};
1083 use tempfile::TempDir;
1084
1085 fn make_pivot_workbook() -> Workbook {
1086 let mut wb = Workbook::new();
1087 wb.set_cell_value("Sheet1", "A1", "Name").unwrap();
1088 wb.set_cell_value("Sheet1", "B1", "Region").unwrap();
1089 wb.set_cell_value("Sheet1", "C1", "Sales").unwrap();
1090 wb.set_cell_value("Sheet1", "A2", "Alice").unwrap();
1091 wb.set_cell_value("Sheet1", "B2", "North").unwrap();
1092 wb.set_cell_value("Sheet1", "C2", 100.0).unwrap();
1093 wb.set_cell_value("Sheet1", "A3", "Bob").unwrap();
1094 wb.set_cell_value("Sheet1", "B3", "South").unwrap();
1095 wb.set_cell_value("Sheet1", "C3", 200.0).unwrap();
1096 wb.set_cell_value("Sheet1", "A4", "Carol").unwrap();
1097 wb.set_cell_value("Sheet1", "B4", "North").unwrap();
1098 wb.set_cell_value("Sheet1", "C4", 150.0).unwrap();
1099 wb
1100 }
1101
1102 fn basic_pivot_config() -> PivotTableConfig {
1103 use crate::pivot::{AggregateFunction, PivotDataField, PivotField};
1104 PivotTableConfig {
1105 name: "PivotTable1".to_string(),
1106 source_sheet: "Sheet1".to_string(),
1107 source_range: "A1:C4".to_string(),
1108 target_sheet: "Sheet1".to_string(),
1109 target_cell: "E1".to_string(),
1110 rows: vec![PivotField {
1111 name: "Name".to_string(),
1112 }],
1113 columns: vec![],
1114 data: vec![PivotDataField {
1115 name: "Sales".to_string(),
1116 function: AggregateFunction::Sum,
1117 display_name: None,
1118 }],
1119 }
1120 }
1121
1122 #[test]
1123 fn test_add_pivot_table_basic() {
1124 let mut wb = make_pivot_workbook();
1125 let config = basic_pivot_config();
1126 wb.add_pivot_table(&config).unwrap();
1127
1128 assert_eq!(wb.pivot_tables.len(), 1);
1129 assert_eq!(wb.pivot_cache_defs.len(), 1);
1130 assert_eq!(wb.pivot_cache_records.len(), 1);
1131 assert_eq!(wb.pivot_tables[0].1.name, "PivotTable1");
1132 assert_eq!(wb.pivot_tables[0].1.cache_id, 0);
1133 }
1134
1135 #[test]
1136 fn test_add_pivot_table_with_columns() {
1137 use crate::pivot::{AggregateFunction, PivotDataField, PivotField};
1138 let mut wb = make_pivot_workbook();
1139 let config = PivotTableConfig {
1140 name: "PT2".to_string(),
1141 source_sheet: "Sheet1".to_string(),
1142 source_range: "A1:C4".to_string(),
1143 target_sheet: "Sheet1".to_string(),
1144 target_cell: "E1".to_string(),
1145 rows: vec![PivotField {
1146 name: "Name".to_string(),
1147 }],
1148 columns: vec![PivotField {
1149 name: "Region".to_string(),
1150 }],
1151 data: vec![PivotDataField {
1152 name: "Sales".to_string(),
1153 function: AggregateFunction::Average,
1154 display_name: Some("Avg Sales".to_string()),
1155 }],
1156 };
1157 wb.add_pivot_table(&config).unwrap();
1158
1159 let pt = &wb.pivot_tables[0].1;
1160 assert!(pt.row_fields.is_some());
1161 assert!(pt.col_fields.is_some());
1162 assert!(pt.data_fields.is_some());
1163 }
1164
1165 #[test]
1166 fn test_add_pivot_table_source_sheet_not_found() {
1167 use crate::pivot::{AggregateFunction, PivotDataField, PivotField};
1168 let mut wb = Workbook::new();
1169 let config = PivotTableConfig {
1170 name: "PT".to_string(),
1171 source_sheet: "NonExistent".to_string(),
1172 source_range: "A1:B2".to_string(),
1173 target_sheet: "Sheet1".to_string(),
1174 target_cell: "A1".to_string(),
1175 rows: vec![PivotField {
1176 name: "Col1".to_string(),
1177 }],
1178 columns: vec![],
1179 data: vec![PivotDataField {
1180 name: "Col2".to_string(),
1181 function: AggregateFunction::Sum,
1182 display_name: None,
1183 }],
1184 };
1185 let result = wb.add_pivot_table(&config);
1186 assert!(result.is_err());
1187 assert!(matches!(result.unwrap_err(), Error::SheetNotFound { .. }));
1188 }
1189
1190 #[test]
1191 fn test_add_pivot_table_target_sheet_not_found() {
1192 use crate::pivot::{AggregateFunction, PivotDataField, PivotField};
1193 let mut wb = make_pivot_workbook();
1194 let config = PivotTableConfig {
1195 name: "PT".to_string(),
1196 source_sheet: "Sheet1".to_string(),
1197 source_range: "A1:C4".to_string(),
1198 target_sheet: "Report".to_string(),
1199 target_cell: "A1".to_string(),
1200 rows: vec![PivotField {
1201 name: "Name".to_string(),
1202 }],
1203 columns: vec![],
1204 data: vec![PivotDataField {
1205 name: "Sales".to_string(),
1206 function: AggregateFunction::Sum,
1207 display_name: None,
1208 }],
1209 };
1210 let result = wb.add_pivot_table(&config);
1211 assert!(result.is_err());
1212 assert!(matches!(result.unwrap_err(), Error::SheetNotFound { .. }));
1213 }
1214
1215 #[test]
1216 fn test_add_pivot_table_duplicate_name() {
1217 let mut wb = make_pivot_workbook();
1218 let config = basic_pivot_config();
1219 wb.add_pivot_table(&config).unwrap();
1220
1221 let result = wb.add_pivot_table(&config);
1222 assert!(result.is_err());
1223 assert!(matches!(
1224 result.unwrap_err(),
1225 Error::PivotTableAlreadyExists { .. }
1226 ));
1227 }
1228
1229 #[test]
1230 fn test_get_pivot_tables_empty() {
1231 let wb = Workbook::new();
1232 let pts = wb.get_pivot_tables();
1233 assert!(pts.is_empty());
1234 }
1235
1236 #[test]
1237 fn test_get_pivot_tables_after_add() {
1238 let mut wb = make_pivot_workbook();
1239 let config = basic_pivot_config();
1240 wb.add_pivot_table(&config).unwrap();
1241
1242 let pts = wb.get_pivot_tables();
1243 assert_eq!(pts.len(), 1);
1244 assert_eq!(pts[0].name, "PivotTable1");
1245 assert_eq!(pts[0].source_sheet, "Sheet1");
1246 assert_eq!(pts[0].source_range, "A1:C4");
1247 assert_eq!(pts[0].target_sheet, "Sheet1");
1248 assert_eq!(pts[0].location, "E1");
1249 }
1250
1251 #[test]
1252 fn test_lazy_get_pivot_tables_without_mutation() {
1253 let mut wb = make_pivot_workbook();
1254 wb.add_pivot_table(&basic_pivot_config()).unwrap();
1255 let buf = wb.save_to_buffer().unwrap();
1256
1257 let opts = OpenOptions::new().read_mode(ReadMode::Lazy);
1258 let wb2 = Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
1259 let pts = wb2.get_pivot_tables();
1260 assert_eq!(pts.len(), 1);
1261 assert_eq!(pts[0].name, "PivotTable1");
1262 assert_eq!(pts[0].source_sheet, "Sheet1");
1263 assert_eq!(pts[0].source_range, "A1:C4");
1264 assert_eq!(pts[0].target_sheet, "Sheet1");
1265 }
1266
1267 #[test]
1268 fn test_delete_pivot_table() {
1269 let mut wb = make_pivot_workbook();
1270 let config = basic_pivot_config();
1271 wb.add_pivot_table(&config).unwrap();
1272 assert_eq!(wb.pivot_tables.len(), 1);
1273
1274 wb.delete_pivot_table("PivotTable1").unwrap();
1275 assert!(wb.pivot_tables.is_empty());
1276 assert!(wb.pivot_cache_defs.is_empty());
1277 assert!(wb.pivot_cache_records.is_empty());
1278 assert!(wb.workbook_xml.pivot_caches.is_none());
1279
1280 let pivot_overrides: Vec<_> = wb
1282 .content_types
1283 .overrides
1284 .iter()
1285 .filter(|o| {
1286 o.content_type == mime_types::PIVOT_TABLE
1287 || o.content_type == mime_types::PIVOT_CACHE_DEFINITION
1288 || o.content_type == mime_types::PIVOT_CACHE_RECORDS
1289 })
1290 .collect();
1291 assert!(pivot_overrides.is_empty());
1292 }
1293
1294 #[test]
1295 fn test_delete_pivot_table_not_found() {
1296 let wb_result = Workbook::new().delete_pivot_table("NonExistent");
1297 assert!(wb_result.is_err());
1298 assert!(matches!(
1299 wb_result.unwrap_err(),
1300 Error::PivotTableNotFound { .. }
1301 ));
1302 }
1303
1304 #[test]
1305 fn test_pivot_table_save_open_roundtrip() {
1306 let dir = TempDir::new().unwrap();
1307 let path = dir.path().join("pivot_roundtrip.xlsx");
1308
1309 let mut wb = make_pivot_workbook();
1310 let config = basic_pivot_config();
1311 wb.add_pivot_table(&config).unwrap();
1312
1313 wb.save(&path).unwrap();
1314
1315 let file = std::fs::File::open(&path).unwrap();
1317 let mut archive = zip::ZipArchive::new(file).unwrap();
1318 assert!(archive.by_name("xl/pivotTables/pivotTable1.xml").is_ok());
1319 assert!(archive
1320 .by_name("xl/pivotCache/pivotCacheDefinition1.xml")
1321 .is_ok());
1322 assert!(archive
1323 .by_name("xl/pivotCache/pivotCacheRecords1.xml")
1324 .is_ok());
1325
1326 let opts = OpenOptions::new()
1328 .read_mode(ReadMode::Eager)
1329 .aux_parts(AuxParts::EagerLoad);
1330 let wb2 = Workbook::open_with_options(&path, &opts).unwrap();
1331 assert_eq!(wb2.pivot_tables.len(), 1);
1332 assert_eq!(wb2.pivot_tables[0].1.name, "PivotTable1");
1333 assert_eq!(wb2.pivot_cache_defs.len(), 1);
1334 assert_eq!(wb2.pivot_cache_records.len(), 1);
1335 }
1336
1337 #[test]
1338 fn test_add_multiple_pivot_tables() {
1339 use crate::pivot::{AggregateFunction, PivotDataField, PivotField};
1340 let mut wb = make_pivot_workbook();
1341
1342 let config1 = basic_pivot_config();
1343 wb.add_pivot_table(&config1).unwrap();
1344
1345 let config2 = PivotTableConfig {
1346 name: "PivotTable2".to_string(),
1347 source_sheet: "Sheet1".to_string(),
1348 source_range: "A1:C4".to_string(),
1349 target_sheet: "Sheet1".to_string(),
1350 target_cell: "H1".to_string(),
1351 rows: vec![PivotField {
1352 name: "Region".to_string(),
1353 }],
1354 columns: vec![],
1355 data: vec![PivotDataField {
1356 name: "Sales".to_string(),
1357 function: AggregateFunction::Count,
1358 display_name: None,
1359 }],
1360 };
1361 wb.add_pivot_table(&config2).unwrap();
1362
1363 assert_eq!(wb.pivot_tables.len(), 2);
1364 assert_eq!(wb.pivot_cache_defs.len(), 2);
1365 assert_eq!(wb.pivot_tables[0].1.cache_id, 0);
1366 assert_eq!(wb.pivot_tables[1].1.cache_id, 1);
1367
1368 let pts = wb.get_pivot_tables();
1369 assert_eq!(pts.len(), 2);
1370 assert_eq!(pts[0].name, "PivotTable1");
1371 assert_eq!(pts[1].name, "PivotTable2");
1372 }
1373
1374 #[test]
1375 fn test_add_pivot_table_content_types_added() {
1376 let mut wb = make_pivot_workbook();
1377 let config = basic_pivot_config();
1378 wb.add_pivot_table(&config).unwrap();
1379
1380 let has_pt_ct = wb.content_types.overrides.iter().any(|o| {
1381 o.content_type == mime_types::PIVOT_TABLE
1382 && o.part_name == "/xl/pivotTables/pivotTable1.xml"
1383 });
1384 assert!(has_pt_ct);
1385
1386 let has_pcd_ct = wb.content_types.overrides.iter().any(|o| {
1387 o.content_type == mime_types::PIVOT_CACHE_DEFINITION
1388 && o.part_name == "/xl/pivotCache/pivotCacheDefinition1.xml"
1389 });
1390 assert!(has_pcd_ct);
1391
1392 let has_pcr_ct = wb.content_types.overrides.iter().any(|o| {
1393 o.content_type == mime_types::PIVOT_CACHE_RECORDS
1394 && o.part_name == "/xl/pivotCache/pivotCacheRecords1.xml"
1395 });
1396 assert!(has_pcr_ct);
1397 }
1398
1399 #[test]
1400 fn test_add_pivot_table_workbook_rels_and_pivot_caches() {
1401 let mut wb = make_pivot_workbook();
1402 let config = basic_pivot_config();
1403 wb.add_pivot_table(&config).unwrap();
1404
1405 let cache_rel = wb
1407 .workbook_rels
1408 .relationships
1409 .iter()
1410 .find(|r| r.rel_type == rel_types::PIVOT_CACHE_DEF);
1411 assert!(cache_rel.is_some());
1412 let cache_rel = cache_rel.unwrap();
1413 assert_eq!(cache_rel.target, "pivotCache/pivotCacheDefinition1.xml");
1414
1415 let pivot_caches = wb.workbook_xml.pivot_caches.as_ref().unwrap();
1417 assert_eq!(pivot_caches.caches.len(), 1);
1418 assert_eq!(pivot_caches.caches[0].cache_id, 0);
1419 assert_eq!(pivot_caches.caches[0].r_id, cache_rel.id);
1420 }
1421
1422 #[test]
1423 fn test_add_pivot_table_worksheet_rels_added() {
1424 let mut wb = make_pivot_workbook();
1425 let config = basic_pivot_config();
1426 wb.add_pivot_table(&config).unwrap();
1427
1428 let ws_rels = wb.worksheet_rels.get(&0).unwrap();
1430 let pt_rel = ws_rels
1431 .relationships
1432 .iter()
1433 .find(|r| r.rel_type == rel_types::PIVOT_TABLE);
1434 assert!(pt_rel.is_some());
1435 assert_eq!(pt_rel.unwrap().target, "../pivotTables/pivotTable1.xml");
1436 }
1437
1438 #[test]
1439 fn test_add_pivot_table_on_separate_target_sheet() {
1440 use crate::pivot::{AggregateFunction, PivotDataField, PivotField};
1441 let mut wb = make_pivot_workbook();
1442 wb.new_sheet("Report").unwrap();
1443
1444 let config = PivotTableConfig {
1445 name: "CrossSheet".to_string(),
1446 source_sheet: "Sheet1".to_string(),
1447 source_range: "A1:C4".to_string(),
1448 target_sheet: "Report".to_string(),
1449 target_cell: "A1".to_string(),
1450 rows: vec![PivotField {
1451 name: "Name".to_string(),
1452 }],
1453 columns: vec![],
1454 data: vec![PivotDataField {
1455 name: "Sales".to_string(),
1456 function: AggregateFunction::Sum,
1457 display_name: None,
1458 }],
1459 };
1460 wb.add_pivot_table(&config).unwrap();
1461
1462 let pts = wb.get_pivot_tables();
1463 assert_eq!(pts.len(), 1);
1464 assert_eq!(pts[0].target_sheet, "Report");
1465 assert_eq!(pts[0].source_sheet, "Sheet1");
1466
1467 let ws_rels = wb.worksheet_rels.get(&1).unwrap();
1469 let pt_rel = ws_rels
1470 .relationships
1471 .iter()
1472 .find(|r| r.rel_type == rel_types::PIVOT_TABLE);
1473 assert!(pt_rel.is_some());
1474 }
1475
1476 #[test]
1477 fn test_pivot_table_invalid_source_range() {
1478 use crate::pivot::{AggregateFunction, PivotDataField, PivotField};
1479 let mut wb = make_pivot_workbook();
1480 let config = PivotTableConfig {
1481 name: "BadRange".to_string(),
1482 source_sheet: "Sheet1".to_string(),
1483 source_range: "INVALID".to_string(),
1484 target_sheet: "Sheet1".to_string(),
1485 target_cell: "E1".to_string(),
1486 rows: vec![PivotField {
1487 name: "Name".to_string(),
1488 }],
1489 columns: vec![],
1490 data: vec![PivotDataField {
1491 name: "Sales".to_string(),
1492 function: AggregateFunction::Sum,
1493 display_name: None,
1494 }],
1495 };
1496 let result = wb.add_pivot_table(&config);
1497 assert!(result.is_err());
1498 }
1499
1500 #[test]
1501 fn test_delete_pivot_table_then_add_another() {
1502 use crate::pivot::{AggregateFunction, PivotDataField, PivotField};
1503 let mut wb = make_pivot_workbook();
1504 let config1 = basic_pivot_config();
1505 wb.add_pivot_table(&config1).unwrap();
1506 wb.delete_pivot_table("PivotTable1").unwrap();
1507
1508 let config2 = PivotTableConfig {
1509 name: "PivotTable2".to_string(),
1510 source_sheet: "Sheet1".to_string(),
1511 source_range: "A1:C4".to_string(),
1512 target_sheet: "Sheet1".to_string(),
1513 target_cell: "E1".to_string(),
1514 rows: vec![PivotField {
1515 name: "Region".to_string(),
1516 }],
1517 columns: vec![],
1518 data: vec![PivotDataField {
1519 name: "Sales".to_string(),
1520 function: AggregateFunction::Max,
1521 display_name: None,
1522 }],
1523 };
1524 wb.add_pivot_table(&config2).unwrap();
1525
1526 assert_eq!(wb.pivot_tables.len(), 1);
1527 assert_eq!(wb.pivot_tables[0].1.name, "PivotTable2");
1528 }
1529
1530 #[test]
1531 fn test_pivot_table_cache_definition_stores_source_info() {
1532 let mut wb = make_pivot_workbook();
1533 let config = basic_pivot_config();
1534 wb.add_pivot_table(&config).unwrap();
1535
1536 let pcd = &wb.pivot_cache_defs[0].1;
1537 let ws_source = pcd.cache_source.worksheet_source.as_ref().unwrap();
1538 assert_eq!(ws_source.sheet, "Sheet1");
1539 assert_eq!(ws_source.reference, "A1:C4");
1540 assert_eq!(pcd.cache_fields.fields.len(), 3);
1541 assert_eq!(pcd.cache_fields.fields[0].name, "Name");
1542 assert_eq!(pcd.cache_fields.fields[1].name, "Region");
1543 assert_eq!(pcd.cache_fields.fields[2].name, "Sales");
1544 }
1545
1546 #[test]
1547 fn test_pivot_table_field_names_from_data() {
1548 let mut wb = make_pivot_workbook();
1549 let config = basic_pivot_config();
1550 wb.add_pivot_table(&config).unwrap();
1551
1552 let pt = &wb.pivot_tables[0].1;
1553 assert_eq!(pt.pivot_fields.fields.len(), 3);
1554 assert_eq!(pt.pivot_fields.fields[0].axis, Some("axisRow".to_string()));
1556 assert_eq!(pt.pivot_fields.fields[1].axis, None);
1558 assert_eq!(pt.pivot_fields.fields[2].data_field, Some(true));
1560 }
1561
1562 #[test]
1563 fn test_pivot_table_empty_header_row_error() {
1564 use crate::pivot::{AggregateFunction, PivotDataField, PivotField};
1565 let mut wb = Workbook::new();
1566 let config = PivotTableConfig {
1568 name: "Empty".to_string(),
1569 source_sheet: "Sheet1".to_string(),
1570 source_range: "A1:B1".to_string(),
1571 target_sheet: "Sheet1".to_string(),
1572 target_cell: "D1".to_string(),
1573 rows: vec![PivotField {
1574 name: "X".to_string(),
1575 }],
1576 columns: vec![],
1577 data: vec![PivotDataField {
1578 name: "Y".to_string(),
1579 function: AggregateFunction::Sum,
1580 display_name: None,
1581 }],
1582 };
1583 let result = wb.add_pivot_table(&config);
1584 assert!(result.is_err());
1585 }
1586
1587 #[test]
1588 fn test_pivot_table_multiple_save_roundtrip() {
1589 use crate::pivot::{AggregateFunction, PivotDataField, PivotField};
1590 let dir = TempDir::new().unwrap();
1591 let path = dir.path().join("multi_pivot.xlsx");
1592
1593 let mut wb = make_pivot_workbook();
1594 let config1 = basic_pivot_config();
1595 wb.add_pivot_table(&config1).unwrap();
1596
1597 let config2 = PivotTableConfig {
1598 name: "PT2".to_string(),
1599 source_sheet: "Sheet1".to_string(),
1600 source_range: "A1:C4".to_string(),
1601 target_sheet: "Sheet1".to_string(),
1602 target_cell: "H1".to_string(),
1603 rows: vec![PivotField {
1604 name: "Region".to_string(),
1605 }],
1606 columns: vec![],
1607 data: vec![PivotDataField {
1608 name: "Sales".to_string(),
1609 function: AggregateFunction::Min,
1610 display_name: None,
1611 }],
1612 };
1613 wb.add_pivot_table(&config2).unwrap();
1614 wb.save(&path).unwrap();
1615
1616 let opts = OpenOptions::new()
1617 .read_mode(ReadMode::Eager)
1618 .aux_parts(AuxParts::EagerLoad);
1619 let wb2 = Workbook::open_with_options(&path, &opts).unwrap();
1620 assert_eq!(wb2.pivot_tables.len(), 2);
1621 let names: Vec<&str> = wb2
1622 .pivot_tables
1623 .iter()
1624 .map(|(_, pt)| pt.name.as_str())
1625 .collect();
1626 assert!(names.contains(&"PivotTable1"));
1627 assert!(names.contains(&"PT2"));
1628 }
1629
1630 #[test]
1631 fn test_calculate_all_with_dependency_order() {
1632 let mut wb = Workbook::new();
1633 wb.set_cell_value("Sheet1", "A1", 10.0).unwrap();
1635 wb.set_cell_value(
1637 "Sheet1",
1638 "A2",
1639 CellValue::Formula {
1640 expr: "A1*2".to_string(),
1641 result: None,
1642 },
1643 )
1644 .unwrap();
1645 wb.set_cell_value(
1647 "Sheet1",
1648 "A3",
1649 CellValue::Formula {
1650 expr: "A2+A1".to_string(),
1651 result: None,
1652 },
1653 )
1654 .unwrap();
1655
1656 wb.calculate_all().unwrap();
1657
1658 let a2 = wb.get_cell_value("Sheet1", "A2").unwrap();
1660 match a2 {
1661 CellValue::Formula { result, .. } => {
1662 assert_eq!(*result.unwrap(), CellValue::Number(20.0));
1663 }
1664 _ => panic!("A2 should be a formula cell"),
1665 }
1666
1667 let a3 = wb.get_cell_value("Sheet1", "A3").unwrap();
1669 match a3 {
1670 CellValue::Formula { result, .. } => {
1671 assert_eq!(*result.unwrap(), CellValue::Number(30.0));
1672 }
1673 _ => panic!("A3 should be a formula cell"),
1674 }
1675 }
1676
1677 #[test]
1678 fn test_calculate_all_no_formulas() {
1679 let mut wb = Workbook::new();
1680 wb.set_cell_value("Sheet1", "A1", 10.0).unwrap();
1681 wb.set_cell_value("Sheet1", "B1", 20.0).unwrap();
1682 wb.calculate_all().unwrap();
1684 }
1685
1686 #[test]
1687 fn test_calculate_all_no_formulas_keeps_lazy_sheet_clean() {
1688 let mut wb = Workbook::new();
1689 wb.set_cell_value("Sheet1", "A1", 10.0).unwrap();
1690 let buf = wb.save_to_buffer().unwrap();
1691
1692 let opts = OpenOptions::new().read_mode(ReadMode::Lazy);
1693 let mut wb2 = Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
1694 assert!(!wb2.is_sheet_dirty(0));
1695
1696 wb2.calculate_all().unwrap();
1697 assert!(
1698 !wb2.is_sheet_dirty(0),
1699 "calculate_all without formulas must not dirty the sheet"
1700 );
1701 }
1702
1703 #[test]
1704 fn test_calculate_all_cycle_detection() {
1705 let mut wb = Workbook::new();
1706 wb.set_cell_value(
1708 "Sheet1",
1709 "A1",
1710 CellValue::Formula {
1711 expr: "B1".to_string(),
1712 result: None,
1713 },
1714 )
1715 .unwrap();
1716 wb.set_cell_value(
1717 "Sheet1",
1718 "B1",
1719 CellValue::Formula {
1720 expr: "A1".to_string(),
1721 result: None,
1722 },
1723 )
1724 .unwrap();
1725
1726 let result = wb.calculate_all();
1727 assert!(result.is_err());
1728 let err_str = result.unwrap_err().to_string();
1729 assert!(
1730 err_str.contains("circular reference"),
1731 "expected circular reference error, got: {err_str}"
1732 );
1733 }
1734
1735 #[test]
1736 fn test_set_get_doc_props() {
1737 let mut wb = Workbook::new();
1738 let props = crate::doc_props::DocProperties {
1739 title: Some("My Title".to_string()),
1740 subject: Some("My Subject".to_string()),
1741 creator: Some("Author".to_string()),
1742 keywords: Some("rust, excel".to_string()),
1743 description: Some("A test workbook".to_string()),
1744 last_modified_by: Some("Editor".to_string()),
1745 revision: Some("2".to_string()),
1746 created: Some("2024-01-01T00:00:00Z".to_string()),
1747 modified: Some("2024-06-01T12:00:00Z".to_string()),
1748 category: Some("Testing".to_string()),
1749 content_status: Some("Draft".to_string()),
1750 };
1751 wb.set_doc_props(props);
1752
1753 let got = wb.get_doc_props();
1754 assert_eq!(got.title.as_deref(), Some("My Title"));
1755 assert_eq!(got.subject.as_deref(), Some("My Subject"));
1756 assert_eq!(got.creator.as_deref(), Some("Author"));
1757 assert_eq!(got.keywords.as_deref(), Some("rust, excel"));
1758 assert_eq!(got.description.as_deref(), Some("A test workbook"));
1759 assert_eq!(got.last_modified_by.as_deref(), Some("Editor"));
1760 assert_eq!(got.revision.as_deref(), Some("2"));
1761 assert_eq!(got.created.as_deref(), Some("2024-01-01T00:00:00Z"));
1762 assert_eq!(got.modified.as_deref(), Some("2024-06-01T12:00:00Z"));
1763 assert_eq!(got.category.as_deref(), Some("Testing"));
1764 assert_eq!(got.content_status.as_deref(), Some("Draft"));
1765 }
1766
1767 #[test]
1768 fn test_set_get_app_props() {
1769 let mut wb = Workbook::new();
1770 let props = crate::doc_props::AppProperties {
1771 application: Some("SheetKit".to_string()),
1772 doc_security: Some(0),
1773 company: Some("Acme Corp".to_string()),
1774 app_version: Some("1.0.0".to_string()),
1775 manager: Some("Boss".to_string()),
1776 template: Some("default.xltx".to_string()),
1777 };
1778 wb.set_app_props(props);
1779
1780 let got = wb.get_app_props();
1781 assert_eq!(got.application.as_deref(), Some("SheetKit"));
1782 assert_eq!(got.doc_security, Some(0));
1783 assert_eq!(got.company.as_deref(), Some("Acme Corp"));
1784 assert_eq!(got.app_version.as_deref(), Some("1.0.0"));
1785 assert_eq!(got.manager.as_deref(), Some("Boss"));
1786 assert_eq!(got.template.as_deref(), Some("default.xltx"));
1787 }
1788
1789 #[test]
1790 fn test_custom_property_crud() {
1791 let mut wb = Workbook::new();
1792
1793 wb.set_custom_property(
1795 "Project",
1796 crate::doc_props::CustomPropertyValue::String("SheetKit".to_string()),
1797 );
1798
1799 let val = wb.get_custom_property("Project");
1801 assert_eq!(
1802 val,
1803 Some(crate::doc_props::CustomPropertyValue::String(
1804 "SheetKit".to_string()
1805 ))
1806 );
1807
1808 wb.set_custom_property(
1810 "Project",
1811 crate::doc_props::CustomPropertyValue::String("Updated".to_string()),
1812 );
1813 let val = wb.get_custom_property("Project");
1814 assert_eq!(
1815 val,
1816 Some(crate::doc_props::CustomPropertyValue::String(
1817 "Updated".to_string()
1818 ))
1819 );
1820
1821 assert!(wb.delete_custom_property("Project"));
1823 assert!(wb.get_custom_property("Project").is_none());
1824 assert!(!wb.delete_custom_property("Project")); }
1826
1827 #[test]
1828 fn test_doc_props_save_open_roundtrip() {
1829 let dir = TempDir::new().unwrap();
1830 let path = dir.path().join("doc_props.xlsx");
1831
1832 let mut wb = Workbook::new();
1833 wb.set_doc_props(crate::doc_props::DocProperties {
1834 title: Some("Test Title".to_string()),
1835 creator: Some("Test Author".to_string()),
1836 created: Some("2024-01-01T00:00:00Z".to_string()),
1837 ..Default::default()
1838 });
1839 wb.set_app_props(crate::doc_props::AppProperties {
1840 application: Some("SheetKit".to_string()),
1841 company: Some("TestCorp".to_string()),
1842 ..Default::default()
1843 });
1844 wb.set_custom_property("Version", crate::doc_props::CustomPropertyValue::Int(42));
1845 wb.save(&path).unwrap();
1846
1847 let wb2 = Workbook::open(&path).unwrap();
1848 let doc = wb2.get_doc_props();
1849 assert_eq!(doc.title.as_deref(), Some("Test Title"));
1850 assert_eq!(doc.creator.as_deref(), Some("Test Author"));
1851 assert_eq!(doc.created.as_deref(), Some("2024-01-01T00:00:00Z"));
1852
1853 let app = wb2.get_app_props();
1854 assert_eq!(app.application.as_deref(), Some("SheetKit"));
1855 assert_eq!(app.company.as_deref(), Some("TestCorp"));
1856
1857 let custom = wb2.get_custom_property("Version");
1858 assert_eq!(custom, Some(crate::doc_props::CustomPropertyValue::Int(42)));
1859 }
1860
1861 #[test]
1862 fn test_open_without_doc_props() {
1863 let dir = TempDir::new().unwrap();
1866 let path = dir.path().join("no_props.xlsx");
1867
1868 let wb = Workbook::new();
1869 wb.save(&path).unwrap();
1870
1871 let wb2 = Workbook::open(&path).unwrap();
1872 let doc = wb2.get_doc_props();
1873 assert!(doc.title.is_none());
1874 assert!(doc.creator.is_none());
1875
1876 let app = wb2.get_app_props();
1877 assert!(app.application.is_none());
1878
1879 assert!(wb2.get_custom_property("anything").is_none());
1880 }
1881
1882 #[test]
1883 fn test_custom_property_multiple_types() {
1884 let mut wb = Workbook::new();
1885
1886 wb.set_custom_property(
1887 "StringProp",
1888 crate::doc_props::CustomPropertyValue::String("hello".to_string()),
1889 );
1890 wb.set_custom_property("IntProp", crate::doc_props::CustomPropertyValue::Int(-7));
1891 wb.set_custom_property(
1892 "FloatProp",
1893 crate::doc_props::CustomPropertyValue::Float(3.15),
1894 );
1895 wb.set_custom_property(
1896 "BoolProp",
1897 crate::doc_props::CustomPropertyValue::Bool(true),
1898 );
1899 wb.set_custom_property(
1900 "DateProp",
1901 crate::doc_props::CustomPropertyValue::DateTime("2024-01-01T00:00:00Z".to_string()),
1902 );
1903
1904 assert_eq!(
1905 wb.get_custom_property("StringProp"),
1906 Some(crate::doc_props::CustomPropertyValue::String(
1907 "hello".to_string()
1908 ))
1909 );
1910 assert_eq!(
1911 wb.get_custom_property("IntProp"),
1912 Some(crate::doc_props::CustomPropertyValue::Int(-7))
1913 );
1914 assert_eq!(
1915 wb.get_custom_property("FloatProp"),
1916 Some(crate::doc_props::CustomPropertyValue::Float(3.15))
1917 );
1918 assert_eq!(
1919 wb.get_custom_property("BoolProp"),
1920 Some(crate::doc_props::CustomPropertyValue::Bool(true))
1921 );
1922 assert_eq!(
1923 wb.get_custom_property("DateProp"),
1924 Some(crate::doc_props::CustomPropertyValue::DateTime(
1925 "2024-01-01T00:00:00Z".to_string()
1926 ))
1927 );
1928 }
1929
1930 #[test]
1931 fn test_doc_props_default_values() {
1932 let wb = Workbook::new();
1933 let doc = wb.get_doc_props();
1934 assert!(doc.title.is_none());
1935 assert!(doc.subject.is_none());
1936 assert!(doc.creator.is_none());
1937 assert!(doc.keywords.is_none());
1938 assert!(doc.description.is_none());
1939 assert!(doc.last_modified_by.is_none());
1940 assert!(doc.revision.is_none());
1941 assert!(doc.created.is_none());
1942 assert!(doc.modified.is_none());
1943 assert!(doc.category.is_none());
1944 assert!(doc.content_status.is_none());
1945
1946 let app = wb.get_app_props();
1947 assert!(app.application.is_none());
1948 assert!(app.doc_security.is_none());
1949 assert!(app.company.is_none());
1950 assert!(app.app_version.is_none());
1951 assert!(app.manager.is_none());
1952 assert!(app.template.is_none());
1953 }
1954
1955 #[test]
1956 fn test_lazy_get_doc_properties_without_mutation() {
1957 let mut wb = Workbook::new();
1958 wb.set_doc_props(crate::doc_props::DocProperties {
1959 title: Some("Deferred Title".to_string()),
1960 creator: Some("Deferred Author".to_string()),
1961 ..Default::default()
1962 });
1963 wb.set_app_props(crate::doc_props::AppProperties {
1964 company: Some("Deferred Corp".to_string()),
1965 application: Some("SheetKit".to_string()),
1966 ..Default::default()
1967 });
1968 wb.set_custom_property(
1969 "DeferredVersion",
1970 crate::doc_props::CustomPropertyValue::Int(7),
1971 );
1972 let buf = wb.save_to_buffer().unwrap();
1973
1974 let opts = OpenOptions::new().read_mode(ReadMode::Lazy);
1975 let wb2 = Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
1976
1977 let doc = wb2.get_doc_props();
1978 assert_eq!(doc.title.as_deref(), Some("Deferred Title"));
1979 assert_eq!(doc.creator.as_deref(), Some("Deferred Author"));
1980
1981 let app = wb2.get_app_props();
1982 assert_eq!(app.company.as_deref(), Some("Deferred Corp"));
1983 assert_eq!(app.application.as_deref(), Some("SheetKit"));
1984
1985 assert_eq!(
1986 wb2.get_custom_property("DeferredVersion"),
1987 Some(crate::doc_props::CustomPropertyValue::Int(7))
1988 );
1989 }
1990
1991 #[test]
1992 fn test_add_sparkline_and_get_sparklines() {
1993 let mut wb = Workbook::new();
1994 let config = crate::sparkline::SparklineConfig::new("Sheet1!A1:A10", "B1");
1995 wb.add_sparkline("Sheet1", &config).unwrap();
1996
1997 let sparklines = wb.get_sparklines("Sheet1").unwrap();
1998 assert_eq!(sparklines.len(), 1);
1999 assert_eq!(sparklines[0].data_range, "Sheet1!A1:A10");
2000 assert_eq!(sparklines[0].location, "B1");
2001 }
2002
2003 #[test]
2004 fn test_add_multiple_sparklines_to_same_sheet() {
2005 let mut wb = Workbook::new();
2006 let config1 = crate::sparkline::SparklineConfig::new("Sheet1!A1:A10", "B1");
2007 let config2 = crate::sparkline::SparklineConfig::new("Sheet1!A1:A10", "B2");
2008 let mut config3 = crate::sparkline::SparklineConfig::new("Sheet1!C1:C10", "D1");
2009 config3.sparkline_type = crate::sparkline::SparklineType::Column;
2010
2011 wb.add_sparkline("Sheet1", &config1).unwrap();
2012 wb.add_sparkline("Sheet1", &config2).unwrap();
2013 wb.add_sparkline("Sheet1", &config3).unwrap();
2014
2015 let sparklines = wb.get_sparklines("Sheet1").unwrap();
2016 assert_eq!(sparklines.len(), 3);
2017 assert_eq!(
2018 sparklines[2].sparkline_type,
2019 crate::sparkline::SparklineType::Column
2020 );
2021 }
2022
2023 #[test]
2024 fn test_remove_sparkline_by_location() {
2025 let mut wb = Workbook::new();
2026 let config1 = crate::sparkline::SparklineConfig::new("Sheet1!A1:A10", "B1");
2027 let config2 = crate::sparkline::SparklineConfig::new("Sheet1!A1:A10", "B2");
2028 wb.add_sparkline("Sheet1", &config1).unwrap();
2029 wb.add_sparkline("Sheet1", &config2).unwrap();
2030
2031 wb.remove_sparkline("Sheet1", "B1").unwrap();
2032
2033 let sparklines = wb.get_sparklines("Sheet1").unwrap();
2034 assert_eq!(sparklines.len(), 1);
2035 assert_eq!(sparklines[0].location, "B2");
2036 }
2037
2038 #[test]
2039 fn test_remove_nonexistent_sparkline_returns_error() {
2040 let mut wb = Workbook::new();
2041 let result = wb.remove_sparkline("Sheet1", "Z99");
2042 assert!(result.is_err());
2043 }
2044
2045 #[test]
2046 fn test_sparkline_on_nonexistent_sheet_returns_error() {
2047 let mut wb = Workbook::new();
2048 let config = crate::sparkline::SparklineConfig::new("Sheet1!A1:A10", "B1");
2049 let result = wb.add_sparkline("NoSuchSheet", &config);
2050 assert!(result.is_err());
2051 assert!(matches!(result.unwrap_err(), Error::SheetNotFound { .. }));
2052
2053 let result = wb.get_sparklines("NoSuchSheet");
2054 assert!(result.is_err());
2055 }
2056
2057 #[test]
2058 fn test_sparkline_save_open_roundtrip() {
2059 let dir = TempDir::new().unwrap();
2060 let path = dir.path().join("sparkline_roundtrip.xlsx");
2061
2062 let mut wb = Workbook::new();
2063 for i in 1..=10 {
2064 wb.set_cell_value(
2065 "Sheet1",
2066 &format!("A{i}"),
2067 CellValue::Number(i as f64 * 10.0),
2068 )
2069 .unwrap();
2070 }
2071
2072 let mut config = crate::sparkline::SparklineConfig::new("Sheet1!A1:A10", "B1");
2073 config.sparkline_type = crate::sparkline::SparklineType::Column;
2074 config.markers = true;
2075 config.high_point = true;
2076 config.line_weight = Some(1.5);
2077
2078 wb.add_sparkline("Sheet1", &config).unwrap();
2079
2080 let config2 = crate::sparkline::SparklineConfig::new("Sheet1!A1:A5", "C1");
2081 wb.add_sparkline("Sheet1", &config2).unwrap();
2082
2083 wb.save(&path).unwrap();
2084
2085 let opts = OpenOptions::new()
2086 .read_mode(ReadMode::Eager)
2087 .aux_parts(AuxParts::EagerLoad);
2088 let wb2 = Workbook::open_with_options(&path, &opts).unwrap();
2089 let sparklines = wb2.get_sparklines("Sheet1").unwrap();
2090 assert_eq!(sparklines.len(), 2);
2091 assert_eq!(sparklines[0].data_range, "Sheet1!A1:A10");
2092 assert_eq!(sparklines[0].location, "B1");
2093 assert_eq!(
2094 sparklines[0].sparkline_type,
2095 crate::sparkline::SparklineType::Column
2096 );
2097 assert!(sparklines[0].markers);
2098 assert!(sparklines[0].high_point);
2099 assert_eq!(sparklines[0].line_weight, Some(1.5));
2100 assert_eq!(sparklines[1].data_range, "Sheet1!A1:A5");
2101 assert_eq!(sparklines[1].location, "C1");
2102 }
2103
2104 #[test]
2105 fn test_sparkline_empty_sheet_returns_empty_vec() {
2106 let wb = Workbook::new();
2107 let sparklines = wb.get_sparklines("Sheet1").unwrap();
2108 assert!(sparklines.is_empty());
2109 }
2110
2111 fn make_table_config(cols: &[&str]) -> crate::table::TableConfig {
2112 crate::table::TableConfig {
2113 name: "Table1".to_string(),
2114 display_name: "Table1".to_string(),
2115 range: "A1:D10".to_string(),
2116 columns: cols
2117 .iter()
2118 .map(|c| crate::table::TableColumn {
2119 name: c.to_string(),
2120 totals_row_function: None,
2121 totals_row_label: None,
2122 })
2123 .collect(),
2124 ..crate::table::TableConfig::default()
2125 }
2126 }
2127
2128 fn make_slicer_workbook() -> Workbook {
2129 let mut wb = Workbook::new();
2130 let table = make_table_config(&["Status", "Region", "Category", "Col1", "Col2"]);
2131 wb.add_table("Sheet1", &table).unwrap();
2132 wb
2133 }
2134
2135 fn make_slicer_config(name: &str, col: &str) -> crate::slicer::SlicerConfig {
2136 crate::slicer::SlicerConfig {
2137 name: name.to_string(),
2138 cell: "F1".to_string(),
2139 table_name: "Table1".to_string(),
2140 column_name: col.to_string(),
2141 caption: None,
2142 style: None,
2143 width: None,
2144 height: None,
2145 show_caption: None,
2146 column_count: None,
2147 }
2148 }
2149
2150 #[test]
2151 fn test_add_slicer_basic() {
2152 let mut wb = make_slicer_workbook();
2153 let config = make_slicer_config("StatusFilter", "Status");
2154 wb.add_slicer("Sheet1", &config).unwrap();
2155
2156 let slicers = wb.get_slicers("Sheet1").unwrap();
2157 assert_eq!(slicers.len(), 1);
2158 assert_eq!(slicers[0].name, "StatusFilter");
2159 assert_eq!(slicers[0].column_name, "Status");
2160 assert_eq!(slicers[0].table_name, "Table1");
2161 }
2162
2163 #[test]
2164 fn test_add_slicer_with_options() {
2165 let mut wb = make_slicer_workbook();
2166 let config = crate::slicer::SlicerConfig {
2167 name: "RegionSlicer".to_string(),
2168 cell: "G2".to_string(),
2169 table_name: "Table1".to_string(),
2170 column_name: "Region".to_string(),
2171 caption: Some("Filter by Region".to_string()),
2172 style: Some("SlicerStyleLight1".to_string()),
2173 width: Some(300),
2174 height: Some(250),
2175 show_caption: Some(true),
2176 column_count: Some(2),
2177 };
2178 wb.add_slicer("Sheet1", &config).unwrap();
2179
2180 let slicers = wb.get_slicers("Sheet1").unwrap();
2181 assert_eq!(slicers.len(), 1);
2182 assert_eq!(slicers[0].caption, "Filter by Region");
2183 assert_eq!(slicers[0].style, Some("SlicerStyleLight1".to_string()));
2184 }
2185
2186 #[test]
2187 fn test_add_slicer_duplicate_name() {
2188 let mut wb = make_slicer_workbook();
2189 let config = make_slicer_config("MySlicer", "Status");
2190 wb.add_slicer("Sheet1", &config).unwrap();
2191
2192 let result = wb.add_slicer("Sheet1", &config);
2193 assert!(result.is_err());
2194 assert!(result.unwrap_err().to_string().contains("already exists"));
2195 }
2196
2197 #[test]
2198 fn test_add_slicer_invalid_sheet() {
2199 let mut wb = make_slicer_workbook();
2200 let config = make_slicer_config("S1", "Status");
2201 let result = wb.add_slicer("NoSuchSheet", &config);
2202 assert!(result.is_err());
2203 }
2204
2205 #[test]
2206 fn test_add_slicer_table_not_found() {
2207 let mut wb = Workbook::new();
2208 let config = crate::slicer::SlicerConfig {
2209 name: "S1".to_string(),
2210 cell: "F1".to_string(),
2211 table_name: "NonExistent".to_string(),
2212 column_name: "Col".to_string(),
2213 caption: None,
2214 style: None,
2215 width: None,
2216 height: None,
2217 show_caption: None,
2218 column_count: None,
2219 };
2220 let result = wb.add_slicer("Sheet1", &config);
2221 assert!(result.is_err());
2222 assert!(matches!(result.unwrap_err(), Error::TableNotFound { .. }));
2223 }
2224
2225 #[test]
2226 fn test_add_slicer_column_not_found() {
2227 let mut wb = make_slicer_workbook();
2228 let config = crate::slicer::SlicerConfig {
2229 name: "S1".to_string(),
2230 cell: "F1".to_string(),
2231 table_name: "Table1".to_string(),
2232 column_name: "NonExistentColumn".to_string(),
2233 caption: None,
2234 style: None,
2235 width: None,
2236 height: None,
2237 show_caption: None,
2238 column_count: None,
2239 };
2240 let result = wb.add_slicer("Sheet1", &config);
2241 assert!(result.is_err());
2242 assert!(matches!(
2243 result.unwrap_err(),
2244 Error::TableColumnNotFound { .. }
2245 ));
2246 }
2247
2248 #[test]
2249 fn test_add_slicer_correct_table_id_and_column() {
2250 let mut wb = make_slicer_workbook();
2251 let config = make_slicer_config("RegFilter", "Region");
2252 wb.add_slicer("Sheet1", &config).unwrap();
2253
2254 let cache = &wb.slicer_caches[0].1;
2256 let tsc = cache.table_slicer_cache.as_ref().unwrap();
2257 assert_eq!(tsc.table_id, 1);
2258 assert_eq!(tsc.column, 2);
2259 }
2260
2261 #[test]
2262 fn test_get_slicers_resolves_table_name() {
2263 let mut wb = make_slicer_workbook();
2264 wb.add_slicer("Sheet1", &make_slicer_config("S1", "Category"))
2265 .unwrap();
2266
2267 let slicers = wb.get_slicers("Sheet1").unwrap();
2268 assert_eq!(slicers.len(), 1);
2269 assert_eq!(slicers[0].table_name, "Table1");
2270 assert_eq!(slicers[0].column_name, "Category");
2271 }
2272
2273 #[test]
2274 fn test_get_slicers_empty() {
2275 let wb = Workbook::new();
2276 let slicers = wb.get_slicers("Sheet1").unwrap();
2277 assert!(slicers.is_empty());
2278 }
2279
2280 #[test]
2281 fn test_delete_slicer() {
2282 let mut wb = make_slicer_workbook();
2283 let config = make_slicer_config("S1", "Status");
2284 wb.add_slicer("Sheet1", &config).unwrap();
2285
2286 assert_eq!(wb.get_slicers("Sheet1").unwrap().len(), 1);
2287
2288 wb.delete_slicer("Sheet1", "S1").unwrap();
2289 assert_eq!(wb.get_slicers("Sheet1").unwrap().len(), 0);
2290 }
2291
2292 #[test]
2293 fn test_delete_slicer_not_found() {
2294 let mut wb = Workbook::new();
2295 let result = wb.delete_slicer("Sheet1", "NonExistent");
2296 assert!(result.is_err());
2297 assert!(result.unwrap_err().to_string().contains("not found"));
2298 }
2299
2300 #[test]
2301 fn test_delete_slicer_cleans_content_types() {
2302 let mut wb = make_slicer_workbook();
2303 let config = make_slicer_config("S1", "Status");
2304 wb.add_slicer("Sheet1", &config).unwrap();
2305
2306 let ct_before = wb.content_types.overrides.len();
2307 wb.delete_slicer("Sheet1", "S1").unwrap();
2308 let ct_after = wb.content_types.overrides.len();
2309
2310 assert_eq!(ct_before - ct_after, 2);
2312 }
2313
2314 #[test]
2315 fn test_delete_slicer_cleans_workbook_rels() {
2316 let mut wb = make_slicer_workbook();
2317 let config = make_slicer_config("S1", "Status");
2318 wb.add_slicer("Sheet1", &config).unwrap();
2319
2320 let has_cache_rel = wb
2321 .workbook_rels
2322 .relationships
2323 .iter()
2324 .any(|r| r.rel_type == rel_types::SLICER_CACHE);
2325 assert!(has_cache_rel);
2326
2327 wb.delete_slicer("Sheet1", "S1").unwrap();
2328
2329 let has_cache_rel = wb
2330 .workbook_rels
2331 .relationships
2332 .iter()
2333 .any(|r| r.rel_type == rel_types::SLICER_CACHE);
2334 assert!(!has_cache_rel);
2335 }
2336
2337 #[test]
2338 fn test_multiple_slicers_on_same_sheet() {
2339 let mut wb = make_slicer_workbook();
2340 wb.add_slicer("Sheet1", &make_slicer_config("S1", "Col1"))
2341 .unwrap();
2342 wb.add_slicer("Sheet1", &make_slicer_config("S2", "Col2"))
2343 .unwrap();
2344
2345 let slicers = wb.get_slicers("Sheet1").unwrap();
2346 assert_eq!(slicers.len(), 2);
2347 }
2348
2349 #[test]
2350 fn test_slicer_roundtrip() {
2351 let tmp = TempDir::new().unwrap();
2352 let path = tmp.path().join("slicer_rt.xlsx");
2353
2354 let mut wb = make_slicer_workbook();
2355 wb.add_slicer("Sheet1", &make_slicer_config("MySlicer", "Category"))
2356 .unwrap();
2357 wb.save(&path).unwrap();
2358
2359 let wb2 = Workbook::open(&path).unwrap();
2360 let slicers = wb2.get_slicers("Sheet1").unwrap();
2361 assert_eq!(slicers.len(), 1);
2362 assert_eq!(slicers[0].name, "MySlicer");
2363 assert_eq!(slicers[0].column_name, "Category");
2364 assert_eq!(slicers[0].table_name, "Table1");
2365 }
2366
2367 #[test]
2368 fn test_lazy_get_slicers_without_mutation() {
2369 let mut wb = make_slicer_workbook();
2370 wb.add_slicer("Sheet1", &make_slicer_config("LazySlicer", "Status"))
2371 .unwrap();
2372 let buf = wb.save_to_buffer().unwrap();
2373
2374 let opts = OpenOptions::new().read_mode(ReadMode::Lazy);
2375 let wb2 = Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
2376 let slicers = wb2.get_slicers("Sheet1").unwrap();
2377 assert_eq!(slicers.len(), 1);
2378 assert_eq!(slicers[0].name, "LazySlicer");
2379 assert_eq!(slicers[0].column_name, "Status");
2380 assert_eq!(slicers[0].table_name, "Table1");
2381 }
2382
2383 #[test]
2384 fn test_slicer_content_types_added() {
2385 let mut wb = make_slicer_workbook();
2386 wb.add_slicer("Sheet1", &make_slicer_config("S1", "Status"))
2387 .unwrap();
2388
2389 let has_slicer_ct = wb
2390 .content_types
2391 .overrides
2392 .iter()
2393 .any(|o| o.content_type == mime_types::SLICER);
2394 let has_cache_ct = wb
2395 .content_types
2396 .overrides
2397 .iter()
2398 .any(|o| o.content_type == mime_types::SLICER_CACHE);
2399
2400 assert!(has_slicer_ct);
2401 assert!(has_cache_ct);
2402 }
2403
2404 #[test]
2405 fn test_slicer_worksheet_rels_added() {
2406 let mut wb = make_slicer_workbook();
2407 wb.add_slicer("Sheet1", &make_slicer_config("S1", "Status"))
2408 .unwrap();
2409
2410 let rels = wb.worksheet_rels.get(&0).unwrap();
2411 let has_slicer_rel = rels
2412 .relationships
2413 .iter()
2414 .any(|r| r.rel_type == rel_types::SLICER);
2415 assert!(has_slicer_rel);
2416 }
2417
2418 #[test]
2419 fn test_slicer_error_display() {
2420 let err = Error::SlicerNotFound {
2421 name: "Missing".to_string(),
2422 };
2423 assert_eq!(err.to_string(), "slicer 'Missing' not found");
2424
2425 let err = Error::SlicerAlreadyExists {
2426 name: "Dup".to_string(),
2427 };
2428 assert_eq!(err.to_string(), "slicer 'Dup' already exists");
2429 }
2430
2431 #[test]
2432 fn test_add_table_and_get_tables() {
2433 let mut wb = Workbook::new();
2434 let table = make_table_config(&["Name", "Age", "City"]);
2435 wb.add_table("Sheet1", &table).unwrap();
2436
2437 let tables = wb.get_tables("Sheet1").unwrap();
2438 assert_eq!(tables.len(), 1);
2439 assert_eq!(tables[0].name, "Table1");
2440 assert_eq!(tables[0].columns, vec!["Name", "Age", "City"]);
2441 }
2442
2443 #[test]
2444 fn test_add_table_duplicate_name() {
2445 let mut wb = Workbook::new();
2446 let table = make_table_config(&["Col"]);
2447 wb.add_table("Sheet1", &table).unwrap();
2448
2449 let result = wb.add_table("Sheet1", &table);
2450 assert!(result.is_err());
2451 assert!(result.unwrap_err().to_string().contains("already exists"));
2452 }
2453
2454 #[test]
2455 fn test_slicer_table_on_wrong_sheet() {
2456 let mut wb = Workbook::new();
2457 wb.new_sheet("Sheet2").unwrap();
2458 let table = make_table_config(&["Status"]);
2459 wb.add_table("Sheet2", &table).unwrap();
2460
2461 let config = make_slicer_config("S1", "Status");
2462 let result = wb.add_slicer("Sheet1", &config);
2463 assert!(result.is_err());
2464 assert!(matches!(result.unwrap_err(), Error::TableNotFound { .. }));
2465 }
2466}