Skip to main content

sheetkit_core/
validation.rs

1//! Data validation builder and utilities.
2//!
3//! Provides a high-level API for adding, querying, and removing data validation
4//! rules on worksheet cells.
5
6use crate::error::{Error, Result};
7use sheetkit_xml::worksheet::{DataValidation, DataValidations, WorksheetXml};
8
9/// The type of data validation to apply.
10#[derive(Debug, Clone, PartialEq)]
11pub enum ValidationType {
12    /// No value restriction (prompt/message only).
13    None,
14    Whole,
15    Decimal,
16    List,
17    Date,
18    Time,
19    TextLength,
20    Custom,
21}
22
23impl ValidationType {
24    /// Convert to the XML attribute string.
25    pub fn as_str(&self) -> &str {
26        match self {
27            ValidationType::None => "none",
28            ValidationType::Whole => "whole",
29            ValidationType::Decimal => "decimal",
30            ValidationType::List => "list",
31            ValidationType::Date => "date",
32            ValidationType::Time => "time",
33            ValidationType::TextLength => "textLength",
34            ValidationType::Custom => "custom",
35        }
36    }
37
38    /// Parse from the XML attribute string.
39    pub fn parse(s: &str) -> Option<Self> {
40        match s {
41            "none" => Some(ValidationType::None),
42            "whole" => Some(ValidationType::Whole),
43            "decimal" => Some(ValidationType::Decimal),
44            "list" => Some(ValidationType::List),
45            "date" => Some(ValidationType::Date),
46            "time" => Some(ValidationType::Time),
47            "textLength" => Some(ValidationType::TextLength),
48            "custom" => Some(ValidationType::Custom),
49            _ => None,
50        }
51    }
52
53    /// Whether this type requires an operator.
54    pub fn uses_operator(&self) -> bool {
55        matches!(
56            self,
57            ValidationType::Whole
58                | ValidationType::Decimal
59                | ValidationType::Date
60                | ValidationType::Time
61                | ValidationType::TextLength
62        )
63    }
64}
65
66/// The comparison operator for data validation.
67#[derive(Debug, Clone, PartialEq)]
68pub enum ValidationOperator {
69    Between,
70    NotBetween,
71    Equal,
72    NotEqual,
73    LessThan,
74    LessThanOrEqual,
75    GreaterThan,
76    GreaterThanOrEqual,
77}
78
79impl ValidationOperator {
80    /// Convert to the XML attribute string.
81    pub fn as_str(&self) -> &str {
82        match self {
83            ValidationOperator::Between => "between",
84            ValidationOperator::NotBetween => "notBetween",
85            ValidationOperator::Equal => "equal",
86            ValidationOperator::NotEqual => "notEqual",
87            ValidationOperator::LessThan => "lessThan",
88            ValidationOperator::LessThanOrEqual => "lessThanOrEqual",
89            ValidationOperator::GreaterThan => "greaterThan",
90            ValidationOperator::GreaterThanOrEqual => "greaterThanOrEqual",
91        }
92    }
93
94    /// Parse from the XML attribute string.
95    pub fn parse(s: &str) -> Option<Self> {
96        match s {
97            "between" => Some(ValidationOperator::Between),
98            "notBetween" => Some(ValidationOperator::NotBetween),
99            "equal" => Some(ValidationOperator::Equal),
100            "notEqual" => Some(ValidationOperator::NotEqual),
101            "lessThan" => Some(ValidationOperator::LessThan),
102            "lessThanOrEqual" => Some(ValidationOperator::LessThanOrEqual),
103            "greaterThan" => Some(ValidationOperator::GreaterThan),
104            "greaterThanOrEqual" => Some(ValidationOperator::GreaterThanOrEqual),
105            _ => None,
106        }
107    }
108
109    /// Whether this operator requires two formulas.
110    pub fn needs_formula2(&self) -> bool {
111        matches!(
112            self,
113            ValidationOperator::Between | ValidationOperator::NotBetween
114        )
115    }
116}
117
118/// The error display style for validation failures.
119#[derive(Debug, Clone, PartialEq)]
120pub enum ErrorStyle {
121    Stop,
122    Warning,
123    Information,
124}
125
126impl ErrorStyle {
127    /// Convert to the XML attribute string.
128    pub fn as_str(&self) -> &str {
129        match self {
130            ErrorStyle::Stop => "stop",
131            ErrorStyle::Warning => "warning",
132            ErrorStyle::Information => "information",
133        }
134    }
135
136    /// Parse from the XML attribute string.
137    pub fn parse(s: &str) -> Option<Self> {
138        match s {
139            "stop" => Some(ErrorStyle::Stop),
140            "warning" => Some(ErrorStyle::Warning),
141            "information" => Some(ErrorStyle::Information),
142            _ => None,
143        }
144    }
145}
146
147/// Configuration for a data validation rule.
148#[derive(Debug, Clone)]
149pub struct DataValidationConfig {
150    /// The cell range to apply validation to (e.g. "A1:A100").
151    pub sqref: String,
152    /// The type of validation.
153    pub validation_type: ValidationType,
154    /// The comparison operator (not used for list validations).
155    pub operator: Option<ValidationOperator>,
156    /// The first formula/value for the validation constraint.
157    pub formula1: Option<String>,
158    /// The second formula/value (used with Between/NotBetween operators).
159    pub formula2: Option<String>,
160    /// Whether blank cells are allowed.
161    pub allow_blank: bool,
162    /// The error display style.
163    pub error_style: Option<ErrorStyle>,
164    /// The title for the error dialog.
165    pub error_title: Option<String>,
166    /// The message for the error dialog.
167    pub error_message: Option<String>,
168    /// The title for the input prompt.
169    pub prompt_title: Option<String>,
170    /// The message for the input prompt.
171    pub prompt_message: Option<String>,
172    /// Whether to show the input message when the cell is selected.
173    pub show_input_message: bool,
174    /// Whether to show the error message on invalid input.
175    pub show_error_message: bool,
176}
177
178impl DataValidationConfig {
179    /// Create a dropdown list validation.
180    ///
181    /// The items are joined with commas and quoted for the formula.
182    /// Individual items must not contain commas (Excel limitation).
183    pub fn dropdown(sqref: &str, items: &[&str]) -> Self {
184        let formula = format!("\"{}\"", items.join(","));
185        Self {
186            sqref: sqref.to_string(),
187            validation_type: ValidationType::List,
188            operator: None,
189            formula1: Some(formula),
190            formula2: None,
191            allow_blank: true,
192            error_style: Some(ErrorStyle::Stop),
193            error_title: None,
194            error_message: None,
195            prompt_title: None,
196            prompt_message: None,
197            show_input_message: true,
198            show_error_message: true,
199        }
200    }
201
202    /// Create a whole number range validation (between min and max).
203    pub fn whole_number(sqref: &str, min: i64, max: i64) -> Self {
204        Self {
205            sqref: sqref.to_string(),
206            validation_type: ValidationType::Whole,
207            operator: Some(ValidationOperator::Between),
208            formula1: Some(min.to_string()),
209            formula2: Some(max.to_string()),
210            allow_blank: true,
211            error_style: Some(ErrorStyle::Stop),
212            error_title: None,
213            error_message: None,
214            prompt_title: None,
215            prompt_message: None,
216            show_input_message: true,
217            show_error_message: true,
218        }
219    }
220
221    /// Create a decimal range validation (between min and max).
222    pub fn decimal(sqref: &str, min: f64, max: f64) -> Self {
223        Self {
224            sqref: sqref.to_string(),
225            validation_type: ValidationType::Decimal,
226            operator: Some(ValidationOperator::Between),
227            formula1: Some(min.to_string()),
228            formula2: Some(max.to_string()),
229            allow_blank: true,
230            error_style: Some(ErrorStyle::Stop),
231            error_title: None,
232            error_message: None,
233            prompt_title: None,
234            prompt_message: None,
235            show_input_message: true,
236            show_error_message: true,
237        }
238    }
239
240    /// Create a text length validation.
241    pub fn text_length(sqref: &str, operator: ValidationOperator, length: u32) -> Self {
242        Self {
243            sqref: sqref.to_string(),
244            validation_type: ValidationType::TextLength,
245            operator: Some(operator),
246            formula1: Some(length.to_string()),
247            formula2: None,
248            allow_blank: true,
249            error_style: Some(ErrorStyle::Stop),
250            error_title: None,
251            error_message: None,
252            prompt_title: None,
253            prompt_message: None,
254            show_input_message: true,
255            show_error_message: true,
256        }
257    }
258}
259
260/// Validate that `sqref` looks like a valid cell range reference.
261///
262/// Accepts single refs ("A1"), ranges ("A1:B10"), and space-separated
263/// multi-area refs ("A1:B10 D1:E10"). This is not exhaustive but catches
264/// obvious mistakes like empty strings.
265fn validate_sqref(sqref: &str) -> Result<()> {
266    if sqref.is_empty() {
267        return Err(Error::InvalidReference {
268            reference: sqref.to_string(),
269        });
270    }
271    // Each part (split by space) must match a cell or range pattern.
272    for part in sqref.split(' ') {
273        if part.is_empty() {
274            return Err(Error::InvalidReference {
275                reference: sqref.to_string(),
276            });
277        }
278        // Allow "A1" or "A1:B10" shapes.  Each side must start with a letter
279        // and contain at least one digit.
280        for side in part.split(':') {
281            let has_alpha = side.chars().any(|c| c.is_ascii_alphabetic());
282            let has_digit = side.chars().any(|c| c.is_ascii_digit());
283            if !has_alpha || !has_digit {
284                return Err(Error::InvalidReference {
285                    reference: sqref.to_string(),
286                });
287            }
288        }
289    }
290    Ok(())
291}
292
293/// Validate formula constraints for the given validation type and operator.
294fn validate_formulas(config: &DataValidationConfig) -> Result<()> {
295    match &config.validation_type {
296        ValidationType::None => {}
297        ValidationType::List | ValidationType::Custom => {
298            if config.formula1.as_ref().is_none_or(|f| f.is_empty()) {
299                return Err(Error::InvalidArgument(format!(
300                    "formula1 is required for {:?} validation",
301                    config.validation_type
302                )));
303            }
304        }
305        _ => {
306            // Types that use an operator need formula1 at minimum.
307            if config.formula1.as_ref().is_none_or(|f| f.is_empty()) {
308                return Err(Error::InvalidArgument(format!(
309                    "formula1 is required for {:?} validation",
310                    config.validation_type
311                )));
312            }
313            if let Some(op) = &config.operator {
314                if op.needs_formula2() && config.formula2.as_ref().is_none_or(|f| f.is_empty()) {
315                    return Err(Error::InvalidArgument(format!(
316                        "formula2 is required for {:?} operator",
317                        op
318                    )));
319                }
320            }
321        }
322    }
323    Ok(())
324}
325
326/// Convert a `DataValidationConfig` to the XML `DataValidation` struct.
327pub fn config_to_xml(config: &DataValidationConfig) -> DataValidation {
328    DataValidation {
329        validation_type: Some(config.validation_type.as_str().to_string()),
330        operator: config.operator.as_ref().map(|o| o.as_str().to_string()),
331        allow_blank: if config.allow_blank { Some(true) } else { None },
332        show_drop_down: None,
333        show_input_message: if config.show_input_message {
334            Some(true)
335        } else {
336            None
337        },
338        show_error_message: if config.show_error_message {
339            Some(true)
340        } else {
341            None
342        },
343        error_style: config.error_style.as_ref().map(|e| e.as_str().to_string()),
344        ime_mode: None,
345        error_title: config.error_title.clone(),
346        error: config.error_message.clone(),
347        prompt_title: config.prompt_title.clone(),
348        prompt: config.prompt_message.clone(),
349        sqref: config.sqref.clone(),
350        formula1: config.formula1.clone(),
351        formula2: config.formula2.clone(),
352    }
353}
354
355/// Convert an XML `DataValidation` to a `DataValidationConfig`.
356fn xml_to_config(dv: &DataValidation) -> DataValidationConfig {
357    DataValidationConfig {
358        sqref: dv.sqref.clone(),
359        validation_type: dv
360            .validation_type
361            .as_deref()
362            .and_then(ValidationType::parse)
363            .unwrap_or(ValidationType::None),
364        operator: dv.operator.as_deref().and_then(ValidationOperator::parse),
365        formula1: dv.formula1.clone(),
366        formula2: dv.formula2.clone(),
367        allow_blank: dv.allow_blank.unwrap_or(false),
368        error_style: dv.error_style.as_deref().and_then(ErrorStyle::parse),
369        error_title: dv.error_title.clone(),
370        error_message: dv.error.clone(),
371        prompt_title: dv.prompt_title.clone(),
372        prompt_message: dv.prompt.clone(),
373        show_input_message: dv.show_input_message.unwrap_or(false),
374        show_error_message: dv.show_error_message.unwrap_or(false),
375    }
376}
377
378/// Add a data validation to a worksheet.
379pub fn add_validation(ws: &mut WorksheetXml, config: &DataValidationConfig) -> Result<()> {
380    validate_sqref(&config.sqref)?;
381    validate_formulas(config)?;
382    let dv = config_to_xml(config);
383    let dvs = ws.data_validations.get_or_insert_with(|| DataValidations {
384        count: Some(0),
385        disable_prompts: None,
386        x_window: None,
387        y_window: None,
388        data_validations: Vec::new(),
389    });
390    dvs.data_validations.push(dv);
391    dvs.count = Some(dvs.data_validations.len() as u32);
392    Ok(())
393}
394
395/// Get all data validations from a worksheet.
396pub fn get_validations(ws: &WorksheetXml) -> Vec<DataValidationConfig> {
397    match &ws.data_validations {
398        Some(dvs) => dvs.data_validations.iter().map(xml_to_config).collect(),
399        None => Vec::new(),
400    }
401}
402
403/// Remove validations matching a specific cell range from a worksheet.
404///
405/// Returns `Ok(())` regardless of whether any validations were actually removed.
406pub fn remove_validation(ws: &mut WorksheetXml, sqref: &str) -> Result<()> {
407    if let Some(ref mut dvs) = ws.data_validations {
408        dvs.data_validations.retain(|dv| dv.sqref != sqref);
409        dvs.count = Some(dvs.data_validations.len() as u32);
410        if dvs.data_validations.is_empty() {
411            ws.data_validations = None;
412        }
413    }
414    Ok(())
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    #[test]
422    fn test_dropdown_validation() {
423        let config = DataValidationConfig::dropdown("A1:A100", &["Yes", "No", "Maybe"]);
424        assert_eq!(config.sqref, "A1:A100");
425        assert_eq!(config.validation_type, ValidationType::List);
426        assert_eq!(config.formula1, Some("\"Yes,No,Maybe\"".to_string()));
427        assert!(config.allow_blank);
428        assert!(config.show_input_message);
429        assert!(config.show_error_message);
430    }
431
432    #[test]
433    fn test_whole_number_validation() {
434        let config = DataValidationConfig::whole_number("B1:B50", 1, 100);
435        assert_eq!(config.sqref, "B1:B50");
436        assert_eq!(config.validation_type, ValidationType::Whole);
437        assert_eq!(config.operator, Some(ValidationOperator::Between));
438        assert_eq!(config.formula1, Some("1".to_string()));
439        assert_eq!(config.formula2, Some("100".to_string()));
440    }
441
442    #[test]
443    fn test_decimal_validation() {
444        let config = DataValidationConfig::decimal("C1:C10", 0.0, 99.99);
445        assert_eq!(config.sqref, "C1:C10");
446        assert_eq!(config.validation_type, ValidationType::Decimal);
447        assert_eq!(config.operator, Some(ValidationOperator::Between));
448        assert_eq!(config.formula1, Some("0".to_string()));
449        assert_eq!(config.formula2, Some("99.99".to_string()));
450    }
451
452    #[test]
453    fn test_text_length_validation() {
454        let config =
455            DataValidationConfig::text_length("D1:D10", ValidationOperator::LessThanOrEqual, 255);
456        assert_eq!(config.sqref, "D1:D10");
457        assert_eq!(config.validation_type, ValidationType::TextLength);
458        assert_eq!(config.operator, Some(ValidationOperator::LessThanOrEqual));
459        assert_eq!(config.formula1, Some("255".to_string()));
460    }
461
462    #[test]
463    fn test_config_to_xml_roundtrip() {
464        let config = DataValidationConfig::dropdown("A1:A10", &["Red", "Blue"]);
465        let xml = config_to_xml(&config);
466        assert_eq!(xml.validation_type, Some("list".to_string()));
467        assert_eq!(xml.sqref, "A1:A10");
468        assert_eq!(xml.formula1, Some("\"Red,Blue\"".to_string()));
469        assert_eq!(xml.allow_blank, Some(true));
470        assert_eq!(xml.show_input_message, Some(true));
471        assert_eq!(xml.show_error_message, Some(true));
472    }
473
474    #[test]
475    fn test_add_validation_to_worksheet() {
476        let mut ws = WorksheetXml::default();
477        let config = DataValidationConfig::dropdown("A1:A100", &["Yes", "No"]);
478        add_validation(&mut ws, &config).unwrap();
479
480        assert!(ws.data_validations.is_some());
481        let dvs = ws.data_validations.as_ref().unwrap();
482        assert_eq!(dvs.count, Some(1));
483        assert_eq!(dvs.data_validations.len(), 1);
484        assert_eq!(dvs.data_validations[0].sqref, "A1:A100");
485    }
486
487    #[test]
488    fn test_add_multiple_validations() {
489        let mut ws = WorksheetXml::default();
490        let config1 = DataValidationConfig::dropdown("A1:A100", &["Yes", "No"]);
491        let config2 = DataValidationConfig::whole_number("B1:B100", 1, 100);
492        add_validation(&mut ws, &config1).unwrap();
493        add_validation(&mut ws, &config2).unwrap();
494
495        let dvs = ws.data_validations.as_ref().unwrap();
496        assert_eq!(dvs.count, Some(2));
497        assert_eq!(dvs.data_validations.len(), 2);
498    }
499
500    #[test]
501    fn test_get_validations() {
502        let mut ws = WorksheetXml::default();
503        let config = DataValidationConfig::dropdown("A1:A100", &["Yes", "No"]);
504        add_validation(&mut ws, &config).unwrap();
505
506        let configs = get_validations(&ws);
507        assert_eq!(configs.len(), 1);
508        assert_eq!(configs[0].sqref, "A1:A100");
509        assert_eq!(configs[0].validation_type, ValidationType::List);
510    }
511
512    #[test]
513    fn test_get_validations_empty() {
514        let ws = WorksheetXml::default();
515        let configs = get_validations(&ws);
516        assert!(configs.is_empty());
517    }
518
519    #[test]
520    fn test_remove_validation() {
521        let mut ws = WorksheetXml::default();
522        let config1 = DataValidationConfig::dropdown("A1:A100", &["Yes", "No"]);
523        let config2 = DataValidationConfig::whole_number("B1:B100", 1, 100);
524        add_validation(&mut ws, &config1).unwrap();
525        add_validation(&mut ws, &config2).unwrap();
526
527        remove_validation(&mut ws, "A1:A100").unwrap();
528
529        let dvs = ws.data_validations.as_ref().unwrap();
530        assert_eq!(dvs.count, Some(1));
531        assert_eq!(dvs.data_validations.len(), 1);
532        assert_eq!(dvs.data_validations[0].sqref, "B1:B100");
533    }
534
535    #[test]
536    fn test_remove_last_validation_clears_container() {
537        let mut ws = WorksheetXml::default();
538        let config = DataValidationConfig::dropdown("A1:A100", &["Yes", "No"]);
539        add_validation(&mut ws, &config).unwrap();
540        remove_validation(&mut ws, "A1:A100").unwrap();
541
542        assert!(ws.data_validations.is_none());
543    }
544
545    #[test]
546    fn test_remove_nonexistent_validation() {
547        let mut ws = WorksheetXml::default();
548        // Should not error when removing from empty worksheet
549        remove_validation(&mut ws, "Z1:Z99").unwrap();
550        assert!(ws.data_validations.is_none());
551    }
552
553    #[test]
554    fn test_validation_xml_serialization_roundtrip() {
555        let mut ws = WorksheetXml::default();
556        let config = DataValidationConfig::dropdown("A1:A10", &["Apple", "Banana"]);
557        add_validation(&mut ws, &config).unwrap();
558
559        let xml = quick_xml::se::to_string(&ws).unwrap();
560        assert!(xml.contains("dataValidations"));
561        assert!(xml.contains("A1:A10"));
562
563        let parsed: WorksheetXml = quick_xml::de::from_str(&xml).unwrap();
564        assert!(parsed.data_validations.is_some());
565        let dvs = parsed.data_validations.as_ref().unwrap();
566        assert_eq!(dvs.data_validations.len(), 1);
567        assert_eq!(dvs.data_validations[0].sqref, "A1:A10");
568        assert_eq!(
569            dvs.data_validations[0].validation_type,
570            Some("list".to_string())
571        );
572    }
573
574    #[test]
575    fn test_whole_number_validation_xml_roundtrip() {
576        let mut ws = WorksheetXml::default();
577        let config = DataValidationConfig::whole_number("B1:B50", 10, 200);
578        add_validation(&mut ws, &config).unwrap();
579
580        let xml = quick_xml::se::to_string(&ws).unwrap();
581        let parsed: WorksheetXml = quick_xml::de::from_str(&xml).unwrap();
582
583        let configs = get_validations(&parsed);
584        assert_eq!(configs.len(), 1);
585        assert_eq!(configs[0].sqref, "B1:B50");
586        assert_eq!(configs[0].validation_type, ValidationType::Whole);
587        assert_eq!(configs[0].operator, Some(ValidationOperator::Between));
588        assert_eq!(configs[0].formula1, Some("10".to_string()));
589        assert_eq!(configs[0].formula2, Some("200".to_string()));
590    }
591
592    #[test]
593    fn test_decimal_validation_xml_roundtrip() {
594        let mut ws = WorksheetXml::default();
595        let config = DataValidationConfig::decimal("C1:C10", 1.5, 99.9);
596        add_validation(&mut ws, &config).unwrap();
597
598        let xml = quick_xml::se::to_string(&ws).unwrap();
599        let parsed: WorksheetXml = quick_xml::de::from_str(&xml).unwrap();
600
601        let configs = get_validations(&parsed);
602        assert_eq!(configs.len(), 1);
603        assert_eq!(configs[0].validation_type, ValidationType::Decimal);
604    }
605
606    #[test]
607    fn test_validation_type_as_str() {
608        assert_eq!(ValidationType::None.as_str(), "none");
609        assert_eq!(ValidationType::Whole.as_str(), "whole");
610        assert_eq!(ValidationType::Decimal.as_str(), "decimal");
611        assert_eq!(ValidationType::List.as_str(), "list");
612        assert_eq!(ValidationType::Date.as_str(), "date");
613        assert_eq!(ValidationType::Time.as_str(), "time");
614        assert_eq!(ValidationType::TextLength.as_str(), "textLength");
615        assert_eq!(ValidationType::Custom.as_str(), "custom");
616    }
617
618    #[test]
619    fn test_validation_operator_as_str() {
620        assert_eq!(ValidationOperator::Between.as_str(), "between");
621        assert_eq!(ValidationOperator::NotBetween.as_str(), "notBetween");
622        assert_eq!(ValidationOperator::Equal.as_str(), "equal");
623        assert_eq!(ValidationOperator::NotEqual.as_str(), "notEqual");
624        assert_eq!(ValidationOperator::LessThan.as_str(), "lessThan");
625        assert_eq!(
626            ValidationOperator::LessThanOrEqual.as_str(),
627            "lessThanOrEqual"
628        );
629        assert_eq!(ValidationOperator::GreaterThan.as_str(), "greaterThan");
630        assert_eq!(
631            ValidationOperator::GreaterThanOrEqual.as_str(),
632            "greaterThanOrEqual"
633        );
634    }
635
636    #[test]
637    fn test_error_style_as_str() {
638        assert_eq!(ErrorStyle::Stop.as_str(), "stop");
639        assert_eq!(ErrorStyle::Warning.as_str(), "warning");
640        assert_eq!(ErrorStyle::Information.as_str(), "information");
641    }
642
643    #[test]
644    fn test_none_type_roundtrip() {
645        assert_eq!(ValidationType::parse("none"), Some(ValidationType::None));
646        assert_eq!(ValidationType::None.as_str(), "none");
647    }
648
649    #[test]
650    fn test_unknown_type_defaults_to_none() {
651        let dv = DataValidation {
652            validation_type: Some("unknownFuture".to_string()),
653            operator: None,
654            allow_blank: None,
655            show_drop_down: None,
656            show_input_message: None,
657            show_error_message: None,
658            error_style: None,
659            ime_mode: None,
660            error_title: None,
661            error: None,
662            prompt_title: None,
663            prompt: None,
664            sqref: "A1".to_string(),
665            formula1: None,
666            formula2: None,
667        };
668        let config = xml_to_config(&dv);
669        assert_eq!(config.validation_type, ValidationType::None);
670    }
671
672    #[test]
673    fn test_validate_sqref_valid() {
674        assert!(validate_sqref("A1").is_ok());
675        assert!(validate_sqref("A1:B10").is_ok());
676        assert!(validate_sqref("A1:B10 D1:E10").is_ok());
677        assert!(validate_sqref("AA100:ZZ999").is_ok());
678    }
679
680    #[test]
681    fn test_validate_sqref_invalid() {
682        assert!(validate_sqref("").is_err());
683        assert!(validate_sqref("hello").is_err());
684        assert!(validate_sqref("123").is_err());
685        assert!(validate_sqref("A1: B10").is_err()); // space inside range
686    }
687
688    #[test]
689    fn test_add_validation_rejects_empty_sqref() {
690        let mut ws = WorksheetXml::default();
691        let config = DataValidationConfig {
692            sqref: "".to_string(),
693            validation_type: ValidationType::List,
694            operator: None,
695            formula1: Some("\"A,B\"".to_string()),
696            formula2: None,
697            allow_blank: false,
698            error_style: None,
699            error_title: None,
700            error_message: None,
701            prompt_title: None,
702            prompt_message: None,
703            show_input_message: false,
704            show_error_message: false,
705        };
706        assert!(add_validation(&mut ws, &config).is_err());
707    }
708
709    #[test]
710    fn test_add_validation_rejects_missing_formula1_for_list() {
711        let mut ws = WorksheetXml::default();
712        let config = DataValidationConfig {
713            sqref: "A1:A10".to_string(),
714            validation_type: ValidationType::List,
715            operator: None,
716            formula1: None,
717            formula2: None,
718            allow_blank: false,
719            error_style: None,
720            error_title: None,
721            error_message: None,
722            prompt_title: None,
723            prompt_message: None,
724            show_input_message: false,
725            show_error_message: false,
726        };
727        assert!(add_validation(&mut ws, &config).is_err());
728    }
729
730    #[test]
731    fn test_add_validation_rejects_missing_formula2_for_between() {
732        let mut ws = WorksheetXml::default();
733        let config = DataValidationConfig {
734            sqref: "A1:A10".to_string(),
735            validation_type: ValidationType::Whole,
736            operator: Some(ValidationOperator::Between),
737            formula1: Some("1".to_string()),
738            formula2: None,
739            allow_blank: false,
740            error_style: None,
741            error_title: None,
742            error_message: None,
743            prompt_title: None,
744            prompt_message: None,
745            show_input_message: false,
746            show_error_message: false,
747        };
748        assert!(add_validation(&mut ws, &config).is_err());
749    }
750
751    #[test]
752    fn test_none_type_no_formula_required() {
753        let mut ws = WorksheetXml::default();
754        let config = DataValidationConfig {
755            sqref: "A1:A10".to_string(),
756            validation_type: ValidationType::None,
757            operator: None,
758            formula1: None,
759            formula2: None,
760            allow_blank: false,
761            error_style: None,
762            error_title: None,
763            error_message: None,
764            prompt_title: Some("Hint".to_string()),
765            prompt_message: Some("Enter a value".to_string()),
766            show_input_message: true,
767            show_error_message: false,
768        };
769        assert!(add_validation(&mut ws, &config).is_ok());
770        let configs = get_validations(&ws);
771        assert_eq!(configs[0].validation_type, ValidationType::None);
772    }
773
774    #[test]
775    fn test_uses_operator() {
776        assert!(!ValidationType::None.uses_operator());
777        assert!(ValidationType::Whole.uses_operator());
778        assert!(ValidationType::Decimal.uses_operator());
779        assert!(!ValidationType::List.uses_operator());
780        assert!(ValidationType::Date.uses_operator());
781        assert!(ValidationType::Time.uses_operator());
782        assert!(ValidationType::TextLength.uses_operator());
783        assert!(!ValidationType::Custom.uses_operator());
784    }
785
786    #[test]
787    fn test_needs_formula2() {
788        assert!(ValidationOperator::Between.needs_formula2());
789        assert!(ValidationOperator::NotBetween.needs_formula2());
790        assert!(!ValidationOperator::Equal.needs_formula2());
791        assert!(!ValidationOperator::GreaterThan.needs_formula2());
792    }
793
794    #[test]
795    fn test_show_drop_down_preserved_in_xml() {
796        let dv = DataValidation {
797            validation_type: Some("list".to_string()),
798            operator: None,
799            allow_blank: None,
800            show_drop_down: Some(true),
801            show_input_message: None,
802            show_error_message: None,
803            error_style: None,
804            ime_mode: None,
805            error_title: None,
806            error: None,
807            prompt_title: None,
808            prompt: None,
809            sqref: "A1".to_string(),
810            formula1: Some("\"A,B\"".to_string()),
811            formula2: None,
812        };
813        let xml = quick_xml::se::to_string(&dv).unwrap();
814        assert!(xml.contains("showDropDown"));
815
816        let parsed: DataValidation = quick_xml::de::from_str(&xml).unwrap();
817        assert_eq!(parsed.show_drop_down, Some(true));
818    }
819
820    #[test]
821    fn test_ime_mode_preserved_in_xml() {
822        let dv = DataValidation {
823            validation_type: Some("whole".to_string()),
824            operator: None,
825            allow_blank: None,
826            show_drop_down: None,
827            show_input_message: None,
828            show_error_message: None,
829            error_style: None,
830            ime_mode: Some("hiragana".to_string()),
831            error_title: None,
832            error: None,
833            prompt_title: None,
834            prompt: None,
835            sqref: "A1".to_string(),
836            formula1: Some("1".to_string()),
837            formula2: None,
838        };
839        let xml = quick_xml::se::to_string(&dv).unwrap();
840        assert!(xml.contains("imeMode"));
841
842        let parsed: DataValidation = quick_xml::de::from_str(&xml).unwrap();
843        assert_eq!(parsed.ime_mode, Some("hiragana".to_string()));
844    }
845
846    #[test]
847    fn test_container_attrs_preserved_in_xml() {
848        let dvs = DataValidations {
849            count: Some(0),
850            disable_prompts: Some(true),
851            x_window: Some(100),
852            y_window: Some(200),
853            data_validations: Vec::new(),
854        };
855        let xml = quick_xml::se::to_string(&dvs).unwrap();
856        assert!(xml.contains("disablePrompts"));
857        assert!(xml.contains("xWindow"));
858        assert!(xml.contains("yWindow"));
859
860        let parsed: DataValidations = quick_xml::de::from_str(&xml).unwrap();
861        assert_eq!(parsed.disable_prompts, Some(true));
862        assert_eq!(parsed.x_window, Some(100));
863        assert_eq!(parsed.y_window, Some(200));
864    }
865}