1use super::*;
2
3impl Workbook {
4 pub fn add_pivot_table(&mut self, config: &PivotTableConfig) -> Result<()> {
10 let _src_idx = self.sheet_index(&config.source_sheet)?;
12
13 let target_idx = self.sheet_index(&config.target_sheet)?;
15
16 if self
18 .pivot_tables
19 .iter()
20 .any(|(_, pt)| pt.name == config.name)
21 {
22 return Err(Error::PivotTableAlreadyExists {
23 name: config.name.clone(),
24 });
25 }
26
27 let field_names = self.read_header_row(&config.source_sheet, &config.source_range)?;
29 if field_names.is_empty() {
30 return Err(Error::InvalidSourceRange(
31 "source range header row is empty".to_string(),
32 ));
33 }
34
35 let cache_id = self
37 .pivot_tables
38 .iter()
39 .map(|(_, pt)| pt.cache_id)
40 .max()
41 .map(|m| m + 1)
42 .unwrap_or(0);
43
44 let pt_def = crate::pivot::build_pivot_table_xml(config, cache_id, &field_names)?;
46 let pcd = crate::pivot::build_pivot_cache_definition(
47 &config.source_sheet,
48 &config.source_range,
49 &field_names,
50 );
51 let pcr = sheetkit_xml::pivot_cache::PivotCacheRecords {
52 xmlns: sheetkit_xml::namespaces::SPREADSHEET_ML.to_string(),
53 xmlns_r: sheetkit_xml::namespaces::RELATIONSHIPS.to_string(),
54 count: Some(0),
55 records: vec![],
56 };
57
58 let pt_num = self.pivot_tables.len() + 1;
60 let cache_num = self.pivot_cache_defs.len() + 1;
61
62 let pt_path = format!("xl/pivotTables/pivotTable{}.xml", pt_num);
63 let pcd_path = format!("xl/pivotCache/pivotCacheDefinition{}.xml", cache_num);
64 let pcr_path = format!("xl/pivotCache/pivotCacheRecords{}.xml", cache_num);
65
66 self.pivot_tables.push((pt_path.clone(), pt_def));
68 self.pivot_cache_defs.push((pcd_path.clone(), pcd));
69 self.pivot_cache_records.push((pcr_path.clone(), pcr));
70
71 self.content_types.overrides.push(ContentTypeOverride {
73 part_name: format!("/{}", pt_path),
74 content_type: mime_types::PIVOT_TABLE.to_string(),
75 });
76 self.content_types.overrides.push(ContentTypeOverride {
77 part_name: format!("/{}", pcd_path),
78 content_type: mime_types::PIVOT_CACHE_DEFINITION.to_string(),
79 });
80 self.content_types.overrides.push(ContentTypeOverride {
81 part_name: format!("/{}", pcr_path),
82 content_type: mime_types::PIVOT_CACHE_RECORDS.to_string(),
83 });
84
85 let wb_rid = crate::sheet::next_rid(&self.workbook_rels.relationships);
87 self.workbook_rels.relationships.push(Relationship {
88 id: wb_rid.clone(),
89 rel_type: rel_types::PIVOT_CACHE_DEF.to_string(),
90 target: format!("pivotCache/pivotCacheDefinition{}.xml", cache_num),
91 target_mode: None,
92 });
93
94 let pivot_caches = self
96 .workbook_xml
97 .pivot_caches
98 .get_or_insert_with(|| sheetkit_xml::workbook::PivotCaches { caches: vec![] });
99 pivot_caches
100 .caches
101 .push(sheetkit_xml::workbook::PivotCacheEntry {
102 cache_id,
103 r_id: wb_rid,
104 });
105
106 let ws_rid = self.next_worksheet_rid(target_idx);
108 let ws_rels = self
109 .worksheet_rels
110 .entry(target_idx)
111 .or_insert_with(|| Relationships {
112 xmlns: sheetkit_xml::namespaces::PACKAGE_RELATIONSHIPS.to_string(),
113 relationships: vec![],
114 });
115 ws_rels.relationships.push(Relationship {
116 id: ws_rid,
117 rel_type: rel_types::PIVOT_TABLE.to_string(),
118 target: format!("../pivotTables/pivotTable{}.xml", pt_num),
119 target_mode: None,
120 });
121
122 Ok(())
123 }
124
125 pub fn get_pivot_tables(&self) -> Vec<PivotTableInfo> {
127 self.pivot_tables
128 .iter()
129 .map(|(_path, pt)| {
130 let (source_sheet, source_range) = self
132 .pivot_cache_defs
133 .iter()
134 .enumerate()
135 .find(|(i, _)| {
136 self.workbook_xml
137 .pivot_caches
138 .as_ref()
139 .and_then(|pc| pc.caches.iter().find(|e| e.cache_id == pt.cache_id))
140 .is_some()
141 || *i == pt.cache_id as usize
142 })
143 .and_then(|(_, (_, pcd))| {
144 pcd.cache_source
145 .worksheet_source
146 .as_ref()
147 .map(|ws| (ws.sheet.clone(), ws.reference.clone()))
148 })
149 .unwrap_or_default();
150
151 let target_sheet = self.find_pivot_table_target_sheet(pt).unwrap_or_default();
153
154 PivotTableInfo {
155 name: pt.name.clone(),
156 source_sheet,
157 source_range,
158 target_sheet,
159 location: pt.location.reference.clone(),
160 }
161 })
162 .collect()
163 }
164
165 pub fn delete_pivot_table(&mut self, name: &str) -> Result<()> {
167 let pt_idx = self
169 .pivot_tables
170 .iter()
171 .position(|(_, pt)| pt.name == name)
172 .ok_or_else(|| Error::PivotTableNotFound {
173 name: name.to_string(),
174 })?;
175
176 let (pt_path, pt_def) = self.pivot_tables.remove(pt_idx);
177 let cache_id = pt_def.cache_id;
178
179 let mut wb_cache_rid = None;
182 if let Some(ref mut pivot_caches) = self.workbook_xml.pivot_caches {
183 if let Some(pos) = pivot_caches
184 .caches
185 .iter()
186 .position(|e| e.cache_id == cache_id)
187 {
188 wb_cache_rid = Some(pivot_caches.caches[pos].r_id.clone());
189 pivot_caches.caches.remove(pos);
190 }
191 if pivot_caches.caches.is_empty() {
192 self.workbook_xml.pivot_caches = None;
193 }
194 }
195
196 if let Some(ref rid) = wb_cache_rid {
198 if let Some(rel) = self
200 .workbook_rels
201 .relationships
202 .iter()
203 .find(|r| r.id == *rid)
204 {
205 let target_path = format!("xl/{}", rel.target);
206 self.pivot_cache_defs.retain(|(p, _)| *p != target_path);
207
208 let records_path = target_path.replace("pivotCacheDefinition", "pivotCacheRecords");
210 self.pivot_cache_records.retain(|(p, _)| *p != records_path);
211 }
212 self.workbook_rels.relationships.retain(|r| r.id != *rid);
213 }
214
215 let pt_part = format!("/{}", pt_path);
217 self.content_types
218 .overrides
219 .retain(|o| o.part_name != pt_part);
220
221 self.content_types.overrides.retain(|o| {
223 let p = o.part_name.trim_start_matches('/');
224 if o.content_type == mime_types::PIVOT_CACHE_DEFINITION {
226 return self.pivot_cache_defs.iter().any(|(path, _)| path == p);
227 }
228 if o.content_type == mime_types::PIVOT_CACHE_RECORDS {
229 return self.pivot_cache_records.iter().any(|(path, _)| path == p);
230 }
231 if o.content_type == mime_types::PIVOT_TABLE {
232 return self.pivot_tables.iter().any(|(path, _)| path == p);
233 }
234 true
235 });
236
237 for (_idx, rels) in self.worksheet_rels.iter_mut() {
239 rels.relationships.retain(|r| {
240 if r.rel_type != rel_types::PIVOT_TABLE {
241 return true;
242 }
243 let full_target = format!(
245 "xl/pivotTables/{}",
246 r.target.trim_start_matches("../pivotTables/")
247 );
248 full_target != pt_path
249 });
250 }
251
252 Ok(())
253 }
254
255 pub fn add_sparkline(
257 &mut self,
258 sheet: &str,
259 config: &crate::sparkline::SparklineConfig,
260 ) -> Result<()> {
261 let idx = self.sheet_index(sheet)?;
262 crate::sparkline::validate_sparkline_config(config)?;
263 while self.sheet_sparklines.len() <= idx {
264 self.sheet_sparklines.push(vec![]);
265 }
266 self.sheet_sparklines[idx].push(config.clone());
267 Ok(())
268 }
269
270 pub fn get_sparklines(&self, sheet: &str) -> Result<Vec<crate::sparkline::SparklineConfig>> {
272 let idx = self.sheet_index(sheet)?;
273 Ok(self.sheet_sparklines.get(idx).cloned().unwrap_or_default())
274 }
275
276 pub fn remove_sparkline(&mut self, sheet: &str, location: &str) -> Result<()> {
278 let idx = self.sheet_index(sheet)?;
279 let sparklines = self
280 .sheet_sparklines
281 .get_mut(idx)
282 .ok_or_else(|| Error::Internal(format!("no sparkline data for sheet '{sheet}'")))?;
283 let pos = sparklines
284 .iter()
285 .position(|s| s.location == location)
286 .ok_or_else(|| {
287 Error::Internal(format!(
288 "sparkline at location '{location}' not found on sheet '{sheet}'"
289 ))
290 })?;
291 sparklines.remove(pos);
292 Ok(())
293 }
294
295 pub fn evaluate_formula(&self, sheet: &str, formula: &str) -> Result<CellValue> {
300 let _ = self.sheet_index(sheet)?;
302 let parsed = crate::formula::parser::parse_formula(formula)?;
303 let snapshot = self.build_cell_snapshot(sheet)?;
304 crate::formula::eval::evaluate(&parsed, &snapshot)
305 }
306
307 pub fn calculate_all(&mut self) -> Result<()> {
311 use crate::formula::eval::{build_dependency_graph, topological_sort, CellCoord};
312
313 let sheet_names: Vec<String> = self.sheet_names().iter().map(|s| s.to_string()).collect();
314
315 let mut formula_cells: Vec<(CellCoord, String)> = Vec::new();
317 for sn in &sheet_names {
318 let ws = self
319 .worksheets
320 .iter()
321 .find(|(name, _)| name == sn)
322 .map(|(_, ws)| ws)
323 .ok_or_else(|| Error::SheetNotFound {
324 name: sn.to_string(),
325 })?;
326 for row in &ws.sheet_data.rows {
327 for cell in &row.cells {
328 if let Some(ref f) = cell.f {
329 let formula_str = f.value.clone().unwrap_or_default();
330 if !formula_str.is_empty() {
331 if let Ok((c, r)) = cell_name_to_coordinates(&cell.r) {
332 formula_cells.push((
333 CellCoord {
334 sheet: sn.clone(),
335 col: c,
336 row: r,
337 },
338 formula_str,
339 ));
340 }
341 }
342 }
343 }
344 }
345 }
346
347 if formula_cells.is_empty() {
348 return Ok(());
349 }
350
351 let deps = build_dependency_graph(&formula_cells)?;
353 let coords: Vec<CellCoord> = formula_cells.iter().map(|(c, _)| c.clone()).collect();
354 let eval_order = topological_sort(&coords, &deps)?;
355
356 let formula_map: HashMap<CellCoord, String> = formula_cells.into_iter().collect();
358
359 let first_sheet = sheet_names.first().cloned().unwrap_or_default();
361 let mut snapshot = self.build_cell_snapshot(&first_sheet)?;
362
363 let mut results: Vec<(CellCoord, String, CellValue)> = Vec::new();
366 for coord in &eval_order {
367 if let Some(formula_str) = formula_map.get(coord) {
368 snapshot.set_current_sheet(&coord.sheet);
369 let parsed = crate::formula::parser::parse_formula(formula_str)?;
370 let mut evaluator = crate::formula::eval::Evaluator::new(&snapshot);
371 let result = evaluator.eval_expr(&parsed)?;
372 snapshot.set_cell(&coord.sheet, coord.col, coord.row, result.clone());
373 results.push((coord.clone(), formula_str.clone(), result));
374 }
375 }
376
377 for (coord, _formula_str, result) in results {
380 let cell_ref = crate::utils::cell_ref::coordinates_to_cell_name(coord.col, coord.row)?;
381 if let Some((_, ws)) = self.worksheets.iter_mut().find(|(n, _)| *n == coord.sheet) {
382 if let Some(row) = ws.sheet_data.rows.iter_mut().find(|r| r.r == coord.row) {
383 if let Some(cell) = row.cells.iter_mut().find(|c| c.r == cell_ref) {
384 match &result {
385 CellValue::Number(n) => {
386 cell.v = Some(n.to_string());
387 cell.t = None;
388 }
389 CellValue::String(s) => {
390 cell.v = Some(s.clone());
391 cell.t = Some("str".to_string());
392 }
393 CellValue::Bool(b) => {
394 cell.v = Some(if *b { "1".to_string() } else { "0".to_string() });
395 cell.t = Some("b".to_string());
396 }
397 CellValue::Error(e) => {
398 cell.v = Some(e.clone());
399 cell.t = Some("e".to_string());
400 }
401 CellValue::Date(n) => {
402 cell.v = Some(n.to_string());
403 cell.t = None;
404 }
405 _ => {}
406 }
407 }
408 }
409 }
410 }
411
412 Ok(())
413 }
414
415 fn build_cell_snapshot(
418 &self,
419 current_sheet: &str,
420 ) -> Result<crate::formula::eval::CellSnapshot> {
421 let mut snapshot = crate::formula::eval::CellSnapshot::new(current_sheet.to_string());
422 for (sn, ws) in &self.worksheets {
423 for row in &ws.sheet_data.rows {
424 for cell in &row.cells {
425 if let Ok((c, r)) = cell_name_to_coordinates(&cell.r) {
426 let cv = self.xml_cell_to_value(cell)?;
427 snapshot.set_cell(sn, c, r, cv);
428 }
429 }
430 }
431 }
432 Ok(snapshot)
433 }
434
435 pub fn get_occupied_cells(&self, sheet: &str) -> Result<Vec<(u32, u32)>> {
437 let ws = self
438 .worksheets
439 .iter()
440 .find(|(name, _)| name == sheet)
441 .map(|(_, ws)| ws)
442 .ok_or_else(|| Error::SheetNotFound {
443 name: sheet.to_string(),
444 })?;
445 let mut cells = Vec::new();
446 for row in &ws.sheet_data.rows {
447 for cell in &row.cells {
448 if let Ok((c, r)) = cell_name_to_coordinates(&cell.r) {
449 cells.push((c, r));
450 }
451 }
452 }
453 Ok(cells)
454 }
455
456 fn read_header_row(&self, sheet: &str, range: &str) -> Result<Vec<String>> {
459 let parts: Vec<&str> = range.split(':').collect();
460 if parts.len() != 2 {
461 return Err(Error::InvalidSourceRange(range.to_string()));
462 }
463 let (start_col, start_row) = cell_name_to_coordinates(parts[0])
464 .map_err(|_| Error::InvalidSourceRange(range.to_string()))?;
465 let (end_col, _end_row) = cell_name_to_coordinates(parts[1])
466 .map_err(|_| Error::InvalidSourceRange(range.to_string()))?;
467
468 let mut headers = Vec::new();
469 for col in start_col..=end_col {
470 let cell_name = crate::utils::cell_ref::coordinates_to_cell_name(col, start_row)?;
471 let val = self.get_cell_value(sheet, &cell_name)?;
472 let s = match val {
473 CellValue::String(s) => s,
474 CellValue::Number(n) => n.to_string(),
475 CellValue::Bool(b) => b.to_string(),
476 CellValue::RichString(runs) => crate::rich_text::rich_text_to_plain(&runs),
477 _ => String::new(),
478 };
479 headers.push(s);
480 }
481 Ok(headers)
482 }
483
484 fn find_pivot_table_target_sheet(
487 &self,
488 pt: &sheetkit_xml::pivot_table::PivotTableDefinition,
489 ) -> Option<String> {
490 let pt_path = self
492 .pivot_tables
493 .iter()
494 .find(|(_, p)| p.name == pt.name)
495 .map(|(path, _)| path.as_str())?;
496
497 for (sheet_idx, rels) in &self.worksheet_rels {
499 for r in &rels.relationships {
500 if r.rel_type == rel_types::PIVOT_TABLE {
501 let full_target = format!(
502 "xl/pivotTables/{}",
503 r.target.trim_start_matches("../pivotTables/")
504 );
505 if full_target == pt_path {
506 return self
507 .worksheets
508 .get(*sheet_idx)
509 .map(|(name, _)| name.clone());
510 }
511 }
512 }
513 }
514 None
515 }
516
517 pub fn set_doc_props(&mut self, props: crate::doc_props::DocProperties) {
519 self.core_properties = Some(props.to_core_properties());
520 self.ensure_doc_props_content_types();
521 }
522
523 pub fn get_doc_props(&self) -> crate::doc_props::DocProperties {
525 self.core_properties
526 .as_ref()
527 .map(crate::doc_props::DocProperties::from)
528 .unwrap_or_default()
529 }
530
531 pub fn set_app_props(&mut self, props: crate::doc_props::AppProperties) {
533 self.app_properties = Some(props.to_extended_properties());
534 self.ensure_doc_props_content_types();
535 }
536
537 pub fn get_app_props(&self) -> crate::doc_props::AppProperties {
539 self.app_properties
540 .as_ref()
541 .map(crate::doc_props::AppProperties::from)
542 .unwrap_or_default()
543 }
544
545 pub fn set_custom_property(
548 &mut self,
549 name: &str,
550 value: crate::doc_props::CustomPropertyValue,
551 ) {
552 let props = self
553 .custom_properties
554 .get_or_insert_with(sheetkit_xml::doc_props::CustomProperties::default);
555 crate::doc_props::set_custom_property(props, name, value);
556 self.ensure_custom_props_content_types();
557 }
558
559 pub fn get_custom_property(&self, name: &str) -> Option<crate::doc_props::CustomPropertyValue> {
561 self.custom_properties
562 .as_ref()
563 .and_then(|p| crate::doc_props::find_custom_property(p, name))
564 }
565
566 pub fn delete_custom_property(&mut self, name: &str) -> bool {
569 if let Some(ref mut props) = self.custom_properties {
570 crate::doc_props::delete_custom_property(props, name)
571 } else {
572 false
573 }
574 }
575
576 fn ensure_doc_props_content_types(&mut self) {
578 let core_part = "/docProps/core.xml";
579 let app_part = "/docProps/app.xml";
580
581 let has_core = self
582 .content_types
583 .overrides
584 .iter()
585 .any(|o| o.part_name == core_part);
586 if !has_core {
587 self.content_types.overrides.push(ContentTypeOverride {
588 part_name: core_part.to_string(),
589 content_type: mime_types::CORE_PROPERTIES.to_string(),
590 });
591 }
592
593 let has_app = self
594 .content_types
595 .overrides
596 .iter()
597 .any(|o| o.part_name == app_part);
598 if !has_app {
599 self.content_types.overrides.push(ContentTypeOverride {
600 part_name: app_part.to_string(),
601 content_type: mime_types::EXTENDED_PROPERTIES.to_string(),
602 });
603 }
604 }
605
606 fn ensure_custom_props_content_types(&mut self) {
608 self.ensure_doc_props_content_types();
609
610 let custom_part = "/docProps/custom.xml";
611 let has_custom = self
612 .content_types
613 .overrides
614 .iter()
615 .any(|o| o.part_name == custom_part);
616 if !has_custom {
617 self.content_types.overrides.push(ContentTypeOverride {
618 part_name: custom_part.to_string(),
619 content_type: mime_types::CUSTOM_PROPERTIES.to_string(),
620 });
621 }
622
623 let has_custom_rel = self
624 .package_rels
625 .relationships
626 .iter()
627 .any(|r| r.rel_type == rel_types::CUSTOM_PROPERTIES);
628 if !has_custom_rel {
629 let next_id = self.package_rels.relationships.len() + 1;
630 self.package_rels.relationships.push(Relationship {
631 id: format!("rId{next_id}"),
632 rel_type: rel_types::CUSTOM_PROPERTIES.to_string(),
633 target: "docProps/custom.xml".to_string(),
634 target_mode: None,
635 });
636 }
637 }
638}
639
640#[cfg(test)]
641mod tests {
642 use super::*;
643 use tempfile::TempDir;
644
645 fn make_pivot_workbook() -> Workbook {
646 let mut wb = Workbook::new();
647 wb.set_cell_value("Sheet1", "A1", "Name").unwrap();
648 wb.set_cell_value("Sheet1", "B1", "Region").unwrap();
649 wb.set_cell_value("Sheet1", "C1", "Sales").unwrap();
650 wb.set_cell_value("Sheet1", "A2", "Alice").unwrap();
651 wb.set_cell_value("Sheet1", "B2", "North").unwrap();
652 wb.set_cell_value("Sheet1", "C2", 100.0).unwrap();
653 wb.set_cell_value("Sheet1", "A3", "Bob").unwrap();
654 wb.set_cell_value("Sheet1", "B3", "South").unwrap();
655 wb.set_cell_value("Sheet1", "C3", 200.0).unwrap();
656 wb.set_cell_value("Sheet1", "A4", "Carol").unwrap();
657 wb.set_cell_value("Sheet1", "B4", "North").unwrap();
658 wb.set_cell_value("Sheet1", "C4", 150.0).unwrap();
659 wb
660 }
661
662 fn basic_pivot_config() -> PivotTableConfig {
663 use crate::pivot::{AggregateFunction, PivotDataField, PivotField};
664 PivotTableConfig {
665 name: "PivotTable1".to_string(),
666 source_sheet: "Sheet1".to_string(),
667 source_range: "A1:C4".to_string(),
668 target_sheet: "Sheet1".to_string(),
669 target_cell: "E1".to_string(),
670 rows: vec![PivotField {
671 name: "Name".to_string(),
672 }],
673 columns: vec![],
674 data: vec![PivotDataField {
675 name: "Sales".to_string(),
676 function: AggregateFunction::Sum,
677 display_name: None,
678 }],
679 }
680 }
681
682 #[test]
683 fn test_add_pivot_table_basic() {
684 let mut wb = make_pivot_workbook();
685 let config = basic_pivot_config();
686 wb.add_pivot_table(&config).unwrap();
687
688 assert_eq!(wb.pivot_tables.len(), 1);
689 assert_eq!(wb.pivot_cache_defs.len(), 1);
690 assert_eq!(wb.pivot_cache_records.len(), 1);
691 assert_eq!(wb.pivot_tables[0].1.name, "PivotTable1");
692 assert_eq!(wb.pivot_tables[0].1.cache_id, 0);
693 }
694
695 #[test]
696 fn test_add_pivot_table_with_columns() {
697 use crate::pivot::{AggregateFunction, PivotDataField, PivotField};
698 let mut wb = make_pivot_workbook();
699 let config = PivotTableConfig {
700 name: "PT2".to_string(),
701 source_sheet: "Sheet1".to_string(),
702 source_range: "A1:C4".to_string(),
703 target_sheet: "Sheet1".to_string(),
704 target_cell: "E1".to_string(),
705 rows: vec![PivotField {
706 name: "Name".to_string(),
707 }],
708 columns: vec![PivotField {
709 name: "Region".to_string(),
710 }],
711 data: vec![PivotDataField {
712 name: "Sales".to_string(),
713 function: AggregateFunction::Average,
714 display_name: Some("Avg Sales".to_string()),
715 }],
716 };
717 wb.add_pivot_table(&config).unwrap();
718
719 let pt = &wb.pivot_tables[0].1;
720 assert!(pt.row_fields.is_some());
721 assert!(pt.col_fields.is_some());
722 assert!(pt.data_fields.is_some());
723 }
724
725 #[test]
726 fn test_add_pivot_table_source_sheet_not_found() {
727 use crate::pivot::{AggregateFunction, PivotDataField, PivotField};
728 let mut wb = Workbook::new();
729 let config = PivotTableConfig {
730 name: "PT".to_string(),
731 source_sheet: "NonExistent".to_string(),
732 source_range: "A1:B2".to_string(),
733 target_sheet: "Sheet1".to_string(),
734 target_cell: "A1".to_string(),
735 rows: vec![PivotField {
736 name: "Col1".to_string(),
737 }],
738 columns: vec![],
739 data: vec![PivotDataField {
740 name: "Col2".to_string(),
741 function: AggregateFunction::Sum,
742 display_name: None,
743 }],
744 };
745 let result = wb.add_pivot_table(&config);
746 assert!(result.is_err());
747 assert!(matches!(result.unwrap_err(), Error::SheetNotFound { .. }));
748 }
749
750 #[test]
751 fn test_add_pivot_table_target_sheet_not_found() {
752 use crate::pivot::{AggregateFunction, PivotDataField, PivotField};
753 let mut wb = make_pivot_workbook();
754 let config = PivotTableConfig {
755 name: "PT".to_string(),
756 source_sheet: "Sheet1".to_string(),
757 source_range: "A1:C4".to_string(),
758 target_sheet: "Report".to_string(),
759 target_cell: "A1".to_string(),
760 rows: vec![PivotField {
761 name: "Name".to_string(),
762 }],
763 columns: vec![],
764 data: vec![PivotDataField {
765 name: "Sales".to_string(),
766 function: AggregateFunction::Sum,
767 display_name: None,
768 }],
769 };
770 let result = wb.add_pivot_table(&config);
771 assert!(result.is_err());
772 assert!(matches!(result.unwrap_err(), Error::SheetNotFound { .. }));
773 }
774
775 #[test]
776 fn test_add_pivot_table_duplicate_name() {
777 let mut wb = make_pivot_workbook();
778 let config = basic_pivot_config();
779 wb.add_pivot_table(&config).unwrap();
780
781 let result = wb.add_pivot_table(&config);
782 assert!(result.is_err());
783 assert!(matches!(
784 result.unwrap_err(),
785 Error::PivotTableAlreadyExists { .. }
786 ));
787 }
788
789 #[test]
790 fn test_get_pivot_tables_empty() {
791 let wb = Workbook::new();
792 let pts = wb.get_pivot_tables();
793 assert!(pts.is_empty());
794 }
795
796 #[test]
797 fn test_get_pivot_tables_after_add() {
798 let mut wb = make_pivot_workbook();
799 let config = basic_pivot_config();
800 wb.add_pivot_table(&config).unwrap();
801
802 let pts = wb.get_pivot_tables();
803 assert_eq!(pts.len(), 1);
804 assert_eq!(pts[0].name, "PivotTable1");
805 assert_eq!(pts[0].source_sheet, "Sheet1");
806 assert_eq!(pts[0].source_range, "A1:C4");
807 assert_eq!(pts[0].target_sheet, "Sheet1");
808 assert_eq!(pts[0].location, "E1");
809 }
810
811 #[test]
812 fn test_delete_pivot_table() {
813 let mut wb = make_pivot_workbook();
814 let config = basic_pivot_config();
815 wb.add_pivot_table(&config).unwrap();
816 assert_eq!(wb.pivot_tables.len(), 1);
817
818 wb.delete_pivot_table("PivotTable1").unwrap();
819 assert!(wb.pivot_tables.is_empty());
820 assert!(wb.pivot_cache_defs.is_empty());
821 assert!(wb.pivot_cache_records.is_empty());
822 assert!(wb.workbook_xml.pivot_caches.is_none());
823
824 let pivot_overrides: Vec<_> = wb
826 .content_types
827 .overrides
828 .iter()
829 .filter(|o| {
830 o.content_type == mime_types::PIVOT_TABLE
831 || o.content_type == mime_types::PIVOT_CACHE_DEFINITION
832 || o.content_type == mime_types::PIVOT_CACHE_RECORDS
833 })
834 .collect();
835 assert!(pivot_overrides.is_empty());
836 }
837
838 #[test]
839 fn test_delete_pivot_table_not_found() {
840 let wb_result = Workbook::new().delete_pivot_table("NonExistent");
841 assert!(wb_result.is_err());
842 assert!(matches!(
843 wb_result.unwrap_err(),
844 Error::PivotTableNotFound { .. }
845 ));
846 }
847
848 #[test]
849 fn test_pivot_table_save_open_roundtrip() {
850 let dir = TempDir::new().unwrap();
851 let path = dir.path().join("pivot_roundtrip.xlsx");
852
853 let mut wb = make_pivot_workbook();
854 let config = basic_pivot_config();
855 wb.add_pivot_table(&config).unwrap();
856
857 wb.save(&path).unwrap();
858
859 let file = std::fs::File::open(&path).unwrap();
861 let mut archive = zip::ZipArchive::new(file).unwrap();
862 assert!(archive.by_name("xl/pivotTables/pivotTable1.xml").is_ok());
863 assert!(archive
864 .by_name("xl/pivotCache/pivotCacheDefinition1.xml")
865 .is_ok());
866 assert!(archive
867 .by_name("xl/pivotCache/pivotCacheRecords1.xml")
868 .is_ok());
869
870 let wb2 = Workbook::open(&path).unwrap();
872 assert_eq!(wb2.pivot_tables.len(), 1);
873 assert_eq!(wb2.pivot_tables[0].1.name, "PivotTable1");
874 assert_eq!(wb2.pivot_cache_defs.len(), 1);
875 assert_eq!(wb2.pivot_cache_records.len(), 1);
876 }
877
878 #[test]
879 fn test_add_multiple_pivot_tables() {
880 use crate::pivot::{AggregateFunction, PivotDataField, PivotField};
881 let mut wb = make_pivot_workbook();
882
883 let config1 = basic_pivot_config();
884 wb.add_pivot_table(&config1).unwrap();
885
886 let config2 = PivotTableConfig {
887 name: "PivotTable2".to_string(),
888 source_sheet: "Sheet1".to_string(),
889 source_range: "A1:C4".to_string(),
890 target_sheet: "Sheet1".to_string(),
891 target_cell: "H1".to_string(),
892 rows: vec![PivotField {
893 name: "Region".to_string(),
894 }],
895 columns: vec![],
896 data: vec![PivotDataField {
897 name: "Sales".to_string(),
898 function: AggregateFunction::Count,
899 display_name: None,
900 }],
901 };
902 wb.add_pivot_table(&config2).unwrap();
903
904 assert_eq!(wb.pivot_tables.len(), 2);
905 assert_eq!(wb.pivot_cache_defs.len(), 2);
906 assert_eq!(wb.pivot_tables[0].1.cache_id, 0);
907 assert_eq!(wb.pivot_tables[1].1.cache_id, 1);
908
909 let pts = wb.get_pivot_tables();
910 assert_eq!(pts.len(), 2);
911 assert_eq!(pts[0].name, "PivotTable1");
912 assert_eq!(pts[1].name, "PivotTable2");
913 }
914
915 #[test]
916 fn test_add_pivot_table_content_types_added() {
917 let mut wb = make_pivot_workbook();
918 let config = basic_pivot_config();
919 wb.add_pivot_table(&config).unwrap();
920
921 let has_pt_ct = wb.content_types.overrides.iter().any(|o| {
922 o.content_type == mime_types::PIVOT_TABLE
923 && o.part_name == "/xl/pivotTables/pivotTable1.xml"
924 });
925 assert!(has_pt_ct);
926
927 let has_pcd_ct = wb.content_types.overrides.iter().any(|o| {
928 o.content_type == mime_types::PIVOT_CACHE_DEFINITION
929 && o.part_name == "/xl/pivotCache/pivotCacheDefinition1.xml"
930 });
931 assert!(has_pcd_ct);
932
933 let has_pcr_ct = wb.content_types.overrides.iter().any(|o| {
934 o.content_type == mime_types::PIVOT_CACHE_RECORDS
935 && o.part_name == "/xl/pivotCache/pivotCacheRecords1.xml"
936 });
937 assert!(has_pcr_ct);
938 }
939
940 #[test]
941 fn test_add_pivot_table_workbook_rels_and_pivot_caches() {
942 let mut wb = make_pivot_workbook();
943 let config = basic_pivot_config();
944 wb.add_pivot_table(&config).unwrap();
945
946 let cache_rel = wb
948 .workbook_rels
949 .relationships
950 .iter()
951 .find(|r| r.rel_type == rel_types::PIVOT_CACHE_DEF);
952 assert!(cache_rel.is_some());
953 let cache_rel = cache_rel.unwrap();
954 assert_eq!(cache_rel.target, "pivotCache/pivotCacheDefinition1.xml");
955
956 let pivot_caches = wb.workbook_xml.pivot_caches.as_ref().unwrap();
958 assert_eq!(pivot_caches.caches.len(), 1);
959 assert_eq!(pivot_caches.caches[0].cache_id, 0);
960 assert_eq!(pivot_caches.caches[0].r_id, cache_rel.id);
961 }
962
963 #[test]
964 fn test_add_pivot_table_worksheet_rels_added() {
965 let mut wb = make_pivot_workbook();
966 let config = basic_pivot_config();
967 wb.add_pivot_table(&config).unwrap();
968
969 let ws_rels = wb.worksheet_rels.get(&0).unwrap();
971 let pt_rel = ws_rels
972 .relationships
973 .iter()
974 .find(|r| r.rel_type == rel_types::PIVOT_TABLE);
975 assert!(pt_rel.is_some());
976 assert_eq!(pt_rel.unwrap().target, "../pivotTables/pivotTable1.xml");
977 }
978
979 #[test]
980 fn test_add_pivot_table_on_separate_target_sheet() {
981 use crate::pivot::{AggregateFunction, PivotDataField, PivotField};
982 let mut wb = make_pivot_workbook();
983 wb.new_sheet("Report").unwrap();
984
985 let config = PivotTableConfig {
986 name: "CrossSheet".to_string(),
987 source_sheet: "Sheet1".to_string(),
988 source_range: "A1:C4".to_string(),
989 target_sheet: "Report".to_string(),
990 target_cell: "A1".to_string(),
991 rows: vec![PivotField {
992 name: "Name".to_string(),
993 }],
994 columns: vec![],
995 data: vec![PivotDataField {
996 name: "Sales".to_string(),
997 function: AggregateFunction::Sum,
998 display_name: None,
999 }],
1000 };
1001 wb.add_pivot_table(&config).unwrap();
1002
1003 let pts = wb.get_pivot_tables();
1004 assert_eq!(pts.len(), 1);
1005 assert_eq!(pts[0].target_sheet, "Report");
1006 assert_eq!(pts[0].source_sheet, "Sheet1");
1007
1008 let ws_rels = wb.worksheet_rels.get(&1).unwrap();
1010 let pt_rel = ws_rels
1011 .relationships
1012 .iter()
1013 .find(|r| r.rel_type == rel_types::PIVOT_TABLE);
1014 assert!(pt_rel.is_some());
1015 }
1016
1017 #[test]
1018 fn test_pivot_table_invalid_source_range() {
1019 use crate::pivot::{AggregateFunction, PivotDataField, PivotField};
1020 let mut wb = make_pivot_workbook();
1021 let config = PivotTableConfig {
1022 name: "BadRange".to_string(),
1023 source_sheet: "Sheet1".to_string(),
1024 source_range: "INVALID".to_string(),
1025 target_sheet: "Sheet1".to_string(),
1026 target_cell: "E1".to_string(),
1027 rows: vec![PivotField {
1028 name: "Name".to_string(),
1029 }],
1030 columns: vec![],
1031 data: vec![PivotDataField {
1032 name: "Sales".to_string(),
1033 function: AggregateFunction::Sum,
1034 display_name: None,
1035 }],
1036 };
1037 let result = wb.add_pivot_table(&config);
1038 assert!(result.is_err());
1039 }
1040
1041 #[test]
1042 fn test_delete_pivot_table_then_add_another() {
1043 use crate::pivot::{AggregateFunction, PivotDataField, PivotField};
1044 let mut wb = make_pivot_workbook();
1045 let config1 = basic_pivot_config();
1046 wb.add_pivot_table(&config1).unwrap();
1047 wb.delete_pivot_table("PivotTable1").unwrap();
1048
1049 let config2 = PivotTableConfig {
1050 name: "PivotTable2".to_string(),
1051 source_sheet: "Sheet1".to_string(),
1052 source_range: "A1:C4".to_string(),
1053 target_sheet: "Sheet1".to_string(),
1054 target_cell: "E1".to_string(),
1055 rows: vec![PivotField {
1056 name: "Region".to_string(),
1057 }],
1058 columns: vec![],
1059 data: vec![PivotDataField {
1060 name: "Sales".to_string(),
1061 function: AggregateFunction::Max,
1062 display_name: None,
1063 }],
1064 };
1065 wb.add_pivot_table(&config2).unwrap();
1066
1067 assert_eq!(wb.pivot_tables.len(), 1);
1068 assert_eq!(wb.pivot_tables[0].1.name, "PivotTable2");
1069 }
1070
1071 #[test]
1072 fn test_pivot_table_cache_definition_stores_source_info() {
1073 let mut wb = make_pivot_workbook();
1074 let config = basic_pivot_config();
1075 wb.add_pivot_table(&config).unwrap();
1076
1077 let pcd = &wb.pivot_cache_defs[0].1;
1078 let ws_source = pcd.cache_source.worksheet_source.as_ref().unwrap();
1079 assert_eq!(ws_source.sheet, "Sheet1");
1080 assert_eq!(ws_source.reference, "A1:C4");
1081 assert_eq!(pcd.cache_fields.fields.len(), 3);
1082 assert_eq!(pcd.cache_fields.fields[0].name, "Name");
1083 assert_eq!(pcd.cache_fields.fields[1].name, "Region");
1084 assert_eq!(pcd.cache_fields.fields[2].name, "Sales");
1085 }
1086
1087 #[test]
1088 fn test_pivot_table_field_names_from_data() {
1089 let mut wb = make_pivot_workbook();
1090 let config = basic_pivot_config();
1091 wb.add_pivot_table(&config).unwrap();
1092
1093 let pt = &wb.pivot_tables[0].1;
1094 assert_eq!(pt.pivot_fields.fields.len(), 3);
1095 assert_eq!(pt.pivot_fields.fields[0].axis, Some("axisRow".to_string()));
1097 assert_eq!(pt.pivot_fields.fields[1].axis, None);
1099 assert_eq!(pt.pivot_fields.fields[2].data_field, Some(true));
1101 }
1102
1103 #[test]
1104 fn test_pivot_table_empty_header_row_error() {
1105 use crate::pivot::{AggregateFunction, PivotDataField, PivotField};
1106 let mut wb = Workbook::new();
1107 let config = PivotTableConfig {
1109 name: "Empty".to_string(),
1110 source_sheet: "Sheet1".to_string(),
1111 source_range: "A1:B1".to_string(),
1112 target_sheet: "Sheet1".to_string(),
1113 target_cell: "D1".to_string(),
1114 rows: vec![PivotField {
1115 name: "X".to_string(),
1116 }],
1117 columns: vec![],
1118 data: vec![PivotDataField {
1119 name: "Y".to_string(),
1120 function: AggregateFunction::Sum,
1121 display_name: None,
1122 }],
1123 };
1124 let result = wb.add_pivot_table(&config);
1125 assert!(result.is_err());
1126 }
1127
1128 #[test]
1129 fn test_pivot_table_multiple_save_roundtrip() {
1130 use crate::pivot::{AggregateFunction, PivotDataField, PivotField};
1131 let dir = TempDir::new().unwrap();
1132 let path = dir.path().join("multi_pivot.xlsx");
1133
1134 let mut wb = make_pivot_workbook();
1135 let config1 = basic_pivot_config();
1136 wb.add_pivot_table(&config1).unwrap();
1137
1138 let config2 = PivotTableConfig {
1139 name: "PT2".to_string(),
1140 source_sheet: "Sheet1".to_string(),
1141 source_range: "A1:C4".to_string(),
1142 target_sheet: "Sheet1".to_string(),
1143 target_cell: "H1".to_string(),
1144 rows: vec![PivotField {
1145 name: "Region".to_string(),
1146 }],
1147 columns: vec![],
1148 data: vec![PivotDataField {
1149 name: "Sales".to_string(),
1150 function: AggregateFunction::Min,
1151 display_name: None,
1152 }],
1153 };
1154 wb.add_pivot_table(&config2).unwrap();
1155 wb.save(&path).unwrap();
1156
1157 let wb2 = Workbook::open(&path).unwrap();
1158 assert_eq!(wb2.pivot_tables.len(), 2);
1159 let names: Vec<&str> = wb2
1160 .pivot_tables
1161 .iter()
1162 .map(|(_, pt)| pt.name.as_str())
1163 .collect();
1164 assert!(names.contains(&"PivotTable1"));
1165 assert!(names.contains(&"PT2"));
1166 }
1167
1168 #[test]
1169 fn test_calculate_all_with_dependency_order() {
1170 let mut wb = Workbook::new();
1171 wb.set_cell_value("Sheet1", "A1", 10.0).unwrap();
1173 wb.set_cell_value(
1175 "Sheet1",
1176 "A2",
1177 CellValue::Formula {
1178 expr: "A1*2".to_string(),
1179 result: None,
1180 },
1181 )
1182 .unwrap();
1183 wb.set_cell_value(
1185 "Sheet1",
1186 "A3",
1187 CellValue::Formula {
1188 expr: "A2+A1".to_string(),
1189 result: None,
1190 },
1191 )
1192 .unwrap();
1193
1194 wb.calculate_all().unwrap();
1195
1196 let a2 = wb.get_cell_value("Sheet1", "A2").unwrap();
1198 match a2 {
1199 CellValue::Formula { result, .. } => {
1200 assert_eq!(*result.unwrap(), CellValue::Number(20.0));
1201 }
1202 _ => panic!("A2 should be a formula cell"),
1203 }
1204
1205 let a3 = wb.get_cell_value("Sheet1", "A3").unwrap();
1207 match a3 {
1208 CellValue::Formula { result, .. } => {
1209 assert_eq!(*result.unwrap(), CellValue::Number(30.0));
1210 }
1211 _ => panic!("A3 should be a formula cell"),
1212 }
1213 }
1214
1215 #[test]
1216 fn test_calculate_all_no_formulas() {
1217 let mut wb = Workbook::new();
1218 wb.set_cell_value("Sheet1", "A1", 10.0).unwrap();
1219 wb.set_cell_value("Sheet1", "B1", 20.0).unwrap();
1220 wb.calculate_all().unwrap();
1222 }
1223
1224 #[test]
1225 fn test_calculate_all_cycle_detection() {
1226 let mut wb = Workbook::new();
1227 wb.set_cell_value(
1229 "Sheet1",
1230 "A1",
1231 CellValue::Formula {
1232 expr: "B1".to_string(),
1233 result: None,
1234 },
1235 )
1236 .unwrap();
1237 wb.set_cell_value(
1238 "Sheet1",
1239 "B1",
1240 CellValue::Formula {
1241 expr: "A1".to_string(),
1242 result: None,
1243 },
1244 )
1245 .unwrap();
1246
1247 let result = wb.calculate_all();
1248 assert!(result.is_err());
1249 let err_str = result.unwrap_err().to_string();
1250 assert!(
1251 err_str.contains("circular reference"),
1252 "expected circular reference error, got: {err_str}"
1253 );
1254 }
1255
1256 #[test]
1257 fn test_set_get_doc_props() {
1258 let mut wb = Workbook::new();
1259 let props = crate::doc_props::DocProperties {
1260 title: Some("My Title".to_string()),
1261 subject: Some("My Subject".to_string()),
1262 creator: Some("Author".to_string()),
1263 keywords: Some("rust, excel".to_string()),
1264 description: Some("A test workbook".to_string()),
1265 last_modified_by: Some("Editor".to_string()),
1266 revision: Some("2".to_string()),
1267 created: Some("2024-01-01T00:00:00Z".to_string()),
1268 modified: Some("2024-06-01T12:00:00Z".to_string()),
1269 category: Some("Testing".to_string()),
1270 content_status: Some("Draft".to_string()),
1271 };
1272 wb.set_doc_props(props);
1273
1274 let got = wb.get_doc_props();
1275 assert_eq!(got.title.as_deref(), Some("My Title"));
1276 assert_eq!(got.subject.as_deref(), Some("My Subject"));
1277 assert_eq!(got.creator.as_deref(), Some("Author"));
1278 assert_eq!(got.keywords.as_deref(), Some("rust, excel"));
1279 assert_eq!(got.description.as_deref(), Some("A test workbook"));
1280 assert_eq!(got.last_modified_by.as_deref(), Some("Editor"));
1281 assert_eq!(got.revision.as_deref(), Some("2"));
1282 assert_eq!(got.created.as_deref(), Some("2024-01-01T00:00:00Z"));
1283 assert_eq!(got.modified.as_deref(), Some("2024-06-01T12:00:00Z"));
1284 assert_eq!(got.category.as_deref(), Some("Testing"));
1285 assert_eq!(got.content_status.as_deref(), Some("Draft"));
1286 }
1287
1288 #[test]
1289 fn test_set_get_app_props() {
1290 let mut wb = Workbook::new();
1291 let props = crate::doc_props::AppProperties {
1292 application: Some("SheetKit".to_string()),
1293 doc_security: Some(0),
1294 company: Some("Acme Corp".to_string()),
1295 app_version: Some("1.0.0".to_string()),
1296 manager: Some("Boss".to_string()),
1297 template: Some("default.xltx".to_string()),
1298 };
1299 wb.set_app_props(props);
1300
1301 let got = wb.get_app_props();
1302 assert_eq!(got.application.as_deref(), Some("SheetKit"));
1303 assert_eq!(got.doc_security, Some(0));
1304 assert_eq!(got.company.as_deref(), Some("Acme Corp"));
1305 assert_eq!(got.app_version.as_deref(), Some("1.0.0"));
1306 assert_eq!(got.manager.as_deref(), Some("Boss"));
1307 assert_eq!(got.template.as_deref(), Some("default.xltx"));
1308 }
1309
1310 #[test]
1311 fn test_custom_property_crud() {
1312 let mut wb = Workbook::new();
1313
1314 wb.set_custom_property(
1316 "Project",
1317 crate::doc_props::CustomPropertyValue::String("SheetKit".to_string()),
1318 );
1319
1320 let val = wb.get_custom_property("Project");
1322 assert_eq!(
1323 val,
1324 Some(crate::doc_props::CustomPropertyValue::String(
1325 "SheetKit".to_string()
1326 ))
1327 );
1328
1329 wb.set_custom_property(
1331 "Project",
1332 crate::doc_props::CustomPropertyValue::String("Updated".to_string()),
1333 );
1334 let val = wb.get_custom_property("Project");
1335 assert_eq!(
1336 val,
1337 Some(crate::doc_props::CustomPropertyValue::String(
1338 "Updated".to_string()
1339 ))
1340 );
1341
1342 assert!(wb.delete_custom_property("Project"));
1344 assert!(wb.get_custom_property("Project").is_none());
1345 assert!(!wb.delete_custom_property("Project")); }
1347
1348 #[test]
1349 fn test_doc_props_save_open_roundtrip() {
1350 let dir = TempDir::new().unwrap();
1351 let path = dir.path().join("doc_props.xlsx");
1352
1353 let mut wb = Workbook::new();
1354 wb.set_doc_props(crate::doc_props::DocProperties {
1355 title: Some("Test Title".to_string()),
1356 creator: Some("Test Author".to_string()),
1357 created: Some("2024-01-01T00:00:00Z".to_string()),
1358 ..Default::default()
1359 });
1360 wb.set_app_props(crate::doc_props::AppProperties {
1361 application: Some("SheetKit".to_string()),
1362 company: Some("TestCorp".to_string()),
1363 ..Default::default()
1364 });
1365 wb.set_custom_property("Version", crate::doc_props::CustomPropertyValue::Int(42));
1366 wb.save(&path).unwrap();
1367
1368 let wb2 = Workbook::open(&path).unwrap();
1369 let doc = wb2.get_doc_props();
1370 assert_eq!(doc.title.as_deref(), Some("Test Title"));
1371 assert_eq!(doc.creator.as_deref(), Some("Test Author"));
1372 assert_eq!(doc.created.as_deref(), Some("2024-01-01T00:00:00Z"));
1373
1374 let app = wb2.get_app_props();
1375 assert_eq!(app.application.as_deref(), Some("SheetKit"));
1376 assert_eq!(app.company.as_deref(), Some("TestCorp"));
1377
1378 let custom = wb2.get_custom_property("Version");
1379 assert_eq!(custom, Some(crate::doc_props::CustomPropertyValue::Int(42)));
1380 }
1381
1382 #[test]
1383 fn test_open_without_doc_props() {
1384 let dir = TempDir::new().unwrap();
1387 let path = dir.path().join("no_props.xlsx");
1388
1389 let wb = Workbook::new();
1390 wb.save(&path).unwrap();
1391
1392 let wb2 = Workbook::open(&path).unwrap();
1393 let doc = wb2.get_doc_props();
1394 assert!(doc.title.is_none());
1395 assert!(doc.creator.is_none());
1396
1397 let app = wb2.get_app_props();
1398 assert!(app.application.is_none());
1399
1400 assert!(wb2.get_custom_property("anything").is_none());
1401 }
1402
1403 #[test]
1404 fn test_custom_property_multiple_types() {
1405 let mut wb = Workbook::new();
1406
1407 wb.set_custom_property(
1408 "StringProp",
1409 crate::doc_props::CustomPropertyValue::String("hello".to_string()),
1410 );
1411 wb.set_custom_property("IntProp", crate::doc_props::CustomPropertyValue::Int(-7));
1412 wb.set_custom_property(
1413 "FloatProp",
1414 crate::doc_props::CustomPropertyValue::Float(3.14),
1415 );
1416 wb.set_custom_property(
1417 "BoolProp",
1418 crate::doc_props::CustomPropertyValue::Bool(true),
1419 );
1420 wb.set_custom_property(
1421 "DateProp",
1422 crate::doc_props::CustomPropertyValue::DateTime("2024-01-01T00:00:00Z".to_string()),
1423 );
1424
1425 assert_eq!(
1426 wb.get_custom_property("StringProp"),
1427 Some(crate::doc_props::CustomPropertyValue::String(
1428 "hello".to_string()
1429 ))
1430 );
1431 assert_eq!(
1432 wb.get_custom_property("IntProp"),
1433 Some(crate::doc_props::CustomPropertyValue::Int(-7))
1434 );
1435 assert_eq!(
1436 wb.get_custom_property("FloatProp"),
1437 Some(crate::doc_props::CustomPropertyValue::Float(3.14))
1438 );
1439 assert_eq!(
1440 wb.get_custom_property("BoolProp"),
1441 Some(crate::doc_props::CustomPropertyValue::Bool(true))
1442 );
1443 assert_eq!(
1444 wb.get_custom_property("DateProp"),
1445 Some(crate::doc_props::CustomPropertyValue::DateTime(
1446 "2024-01-01T00:00:00Z".to_string()
1447 ))
1448 );
1449 }
1450
1451 #[test]
1452 fn test_doc_props_default_values() {
1453 let wb = Workbook::new();
1454 let doc = wb.get_doc_props();
1455 assert!(doc.title.is_none());
1456 assert!(doc.subject.is_none());
1457 assert!(doc.creator.is_none());
1458 assert!(doc.keywords.is_none());
1459 assert!(doc.description.is_none());
1460 assert!(doc.last_modified_by.is_none());
1461 assert!(doc.revision.is_none());
1462 assert!(doc.created.is_none());
1463 assert!(doc.modified.is_none());
1464 assert!(doc.category.is_none());
1465 assert!(doc.content_status.is_none());
1466
1467 let app = wb.get_app_props();
1468 assert!(app.application.is_none());
1469 assert!(app.doc_security.is_none());
1470 assert!(app.company.is_none());
1471 assert!(app.app_version.is_none());
1472 assert!(app.manager.is_none());
1473 assert!(app.template.is_none());
1474 }
1475
1476 #[test]
1477 fn test_add_sparkline_and_get_sparklines() {
1478 let mut wb = Workbook::new();
1479 let config = crate::sparkline::SparklineConfig::new("Sheet1!A1:A10", "B1");
1480 wb.add_sparkline("Sheet1", &config).unwrap();
1481
1482 let sparklines = wb.get_sparklines("Sheet1").unwrap();
1483 assert_eq!(sparklines.len(), 1);
1484 assert_eq!(sparklines[0].data_range, "Sheet1!A1:A10");
1485 assert_eq!(sparklines[0].location, "B1");
1486 }
1487
1488 #[test]
1489 fn test_add_multiple_sparklines_to_same_sheet() {
1490 let mut wb = Workbook::new();
1491 let config1 = crate::sparkline::SparklineConfig::new("Sheet1!A1:A10", "B1");
1492 let config2 = crate::sparkline::SparklineConfig::new("Sheet1!A1:A10", "B2");
1493 let mut config3 = crate::sparkline::SparklineConfig::new("Sheet1!C1:C10", "D1");
1494 config3.sparkline_type = crate::sparkline::SparklineType::Column;
1495
1496 wb.add_sparkline("Sheet1", &config1).unwrap();
1497 wb.add_sparkline("Sheet1", &config2).unwrap();
1498 wb.add_sparkline("Sheet1", &config3).unwrap();
1499
1500 let sparklines = wb.get_sparklines("Sheet1").unwrap();
1501 assert_eq!(sparklines.len(), 3);
1502 assert_eq!(
1503 sparklines[2].sparkline_type,
1504 crate::sparkline::SparklineType::Column
1505 );
1506 }
1507
1508 #[test]
1509 fn test_remove_sparkline_by_location() {
1510 let mut wb = Workbook::new();
1511 let config1 = crate::sparkline::SparklineConfig::new("Sheet1!A1:A10", "B1");
1512 let config2 = crate::sparkline::SparklineConfig::new("Sheet1!A1:A10", "B2");
1513 wb.add_sparkline("Sheet1", &config1).unwrap();
1514 wb.add_sparkline("Sheet1", &config2).unwrap();
1515
1516 wb.remove_sparkline("Sheet1", "B1").unwrap();
1517
1518 let sparklines = wb.get_sparklines("Sheet1").unwrap();
1519 assert_eq!(sparklines.len(), 1);
1520 assert_eq!(sparklines[0].location, "B2");
1521 }
1522
1523 #[test]
1524 fn test_remove_nonexistent_sparkline_returns_error() {
1525 let mut wb = Workbook::new();
1526 let result = wb.remove_sparkline("Sheet1", "Z99");
1527 assert!(result.is_err());
1528 }
1529
1530 #[test]
1531 fn test_sparkline_on_nonexistent_sheet_returns_error() {
1532 let mut wb = Workbook::new();
1533 let config = crate::sparkline::SparklineConfig::new("Sheet1!A1:A10", "B1");
1534 let result = wb.add_sparkline("NoSuchSheet", &config);
1535 assert!(result.is_err());
1536 assert!(matches!(result.unwrap_err(), Error::SheetNotFound { .. }));
1537
1538 let result = wb.get_sparklines("NoSuchSheet");
1539 assert!(result.is_err());
1540 }
1541
1542 #[test]
1543 fn test_sparkline_save_open_roundtrip() {
1544 let dir = TempDir::new().unwrap();
1545 let path = dir.path().join("sparkline_roundtrip.xlsx");
1546
1547 let mut wb = Workbook::new();
1548 for i in 1..=10 {
1549 wb.set_cell_value(
1550 "Sheet1",
1551 &format!("A{i}"),
1552 CellValue::Number(i as f64 * 10.0),
1553 )
1554 .unwrap();
1555 }
1556
1557 let mut config = crate::sparkline::SparklineConfig::new("Sheet1!A1:A10", "B1");
1558 config.sparkline_type = crate::sparkline::SparklineType::Column;
1559 config.markers = true;
1560 config.high_point = true;
1561 config.line_weight = Some(1.5);
1562
1563 wb.add_sparkline("Sheet1", &config).unwrap();
1564
1565 let config2 = crate::sparkline::SparklineConfig::new("Sheet1!A1:A5", "C1");
1566 wb.add_sparkline("Sheet1", &config2).unwrap();
1567
1568 wb.save(&path).unwrap();
1569
1570 let wb2 = Workbook::open(&path).unwrap();
1571 let sparklines = wb2.get_sparklines("Sheet1").unwrap();
1572 assert_eq!(sparklines.len(), 2);
1573 assert_eq!(sparklines[0].data_range, "Sheet1!A1:A10");
1574 assert_eq!(sparklines[0].location, "B1");
1575 assert_eq!(
1576 sparklines[0].sparkline_type,
1577 crate::sparkline::SparklineType::Column
1578 );
1579 assert!(sparklines[0].markers);
1580 assert!(sparklines[0].high_point);
1581 assert_eq!(sparklines[0].line_weight, Some(1.5));
1582 assert_eq!(sparklines[1].data_range, "Sheet1!A1:A5");
1583 assert_eq!(sparklines[1].location, "C1");
1584 }
1585
1586 #[test]
1587 fn test_sparkline_empty_sheet_returns_empty_vec() {
1588 let wb = Workbook::new();
1589 let sparklines = wb.get_sparklines("Sheet1").unwrap();
1590 assert!(sparklines.is_empty());
1591 }
1592}