boxmux_lib/
validation.rs

1use crate::model::common::Config;
2use crate::{App, Layout, MuxBox};
3use jsonschema::JSONSchema;
4use serde_json::{Map, Value};
5use std::collections::HashSet;
6use std::fs;
7
8/// Configuration schema validation errors
9#[derive(Debug, Clone)]
10pub enum ValidationError {
11    InvalidFieldType {
12        field: String,
13        expected: String,
14        actual: String,
15    },
16    MissingRequiredField {
17        field: String,
18    },
19    InvalidFieldValue {
20        field: String,
21        value: String,
22        constraint: String,
23    },
24    DuplicateId {
25        id: String,
26        location: String,
27    },
28    InvalidReference {
29        field: String,
30        reference: String,
31        target_type: String,
32    },
33    SchemaStructure {
34        message: String,
35    },
36    JsonSchemaValidation {
37        field: String,
38        message: String,
39    },
40}
41
42impl std::fmt::Display for ValidationError {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        match self {
45            ValidationError::InvalidFieldType {
46                field,
47                expected,
48                actual,
49            } => {
50                write!(
51                    f,
52                    "Field '{}' expected type '{}' but got '{}'",
53                    field, expected, actual
54                )
55            }
56            ValidationError::MissingRequiredField { field } => {
57                write!(f, "Required field '{}' is missing", field)
58            }
59            ValidationError::InvalidFieldValue {
60                field,
61                value,
62                constraint,
63            } => {
64                write!(
65                    f,
66                    "Field '{}' has invalid value '{}' (constraint: {})",
67                    field, value, constraint
68                )
69            }
70            ValidationError::DuplicateId { id, location } => {
71                write!(f, "Duplicate ID '{}' found in {}", id, location)
72            }
73            ValidationError::InvalidReference {
74                field,
75                reference,
76                target_type,
77            } => {
78                write!(
79                    f,
80                    "Field '{}' references unknown {} '{}'",
81                    field, target_type, reference
82                )
83            }
84            ValidationError::SchemaStructure { message } => {
85                write!(f, "Schema structure error: {}", message)
86            }
87            ValidationError::JsonSchemaValidation { field, message } => {
88                write!(
89                    f,
90                    "JSON Schema validation error in '{}': {}",
91                    field, message
92                )
93            }
94        }
95    }
96}
97
98impl std::error::Error for ValidationError {}
99
100/// Schema validation result
101pub type ValidationResult = Result<(), Vec<ValidationError>>;
102
103/// Central schema validator for BoxMux configurations
104pub struct SchemaValidator {
105    errors: Vec<ValidationError>,
106    muxbox_ids: HashSet<String>,
107    layout_ids: HashSet<String>,
108}
109
110impl SchemaValidator {
111    pub fn new() -> Self {
112        Self {
113            errors: Vec::new(),
114            muxbox_ids: HashSet::new(),
115            layout_ids: HashSet::new(),
116        }
117    }
118
119    /// Validate a complete BoxMux application configuration
120    pub fn validate_app(&mut self, app: &App) -> ValidationResult {
121        self.clear();
122
123        // Collect all IDs first for reference validation
124        self.collect_ids(app);
125
126        // Validate application structure
127        if app.layouts.is_empty() {
128            self.add_error(ValidationError::MissingRequiredField {
129                field: "layouts".to_string(),
130            });
131        }
132
133        // Validate each layout
134        for (idx, layout) in app.layouts.iter().enumerate() {
135            let _ = self.validate_layout(layout, &format!("layouts[{}]", idx));
136        }
137
138        // Validate root layout constraints
139        self.validate_root_layout_constraints(app);
140
141        if self.errors.is_empty() {
142            Ok(())
143        } else {
144            Err(self.errors.clone())
145        }
146    }
147
148    /// Validate a single layout
149    pub fn validate_layout(&mut self, layout: &Layout, path: &str) -> ValidationResult {
150        // Validate required fields
151        if layout.id.is_empty() {
152            self.add_error(ValidationError::MissingRequiredField {
153                field: format!("{}.id", path),
154            });
155        }
156
157        // Validate muxboxes if present
158        if let Some(muxboxes) = &layout.children {
159            for (idx, muxbox) in muxboxes.iter().enumerate() {
160                let _ = self.validate_muxbox(muxbox, &format!("{}.children[{}]", path, idx));
161            }
162        }
163
164        if self.errors.is_empty() {
165            Ok(())
166        } else {
167            Err(self.errors.clone())
168        }
169    }
170
171    /// Validate a single muxbox
172    pub fn validate_muxbox(&mut self, muxbox: &MuxBox, path: &str) -> ValidationResult {
173        // Validate required fields
174        if muxbox.id.is_empty() {
175            self.add_error(ValidationError::MissingRequiredField {
176                field: format!("{}.id", path),
177            });
178        }
179
180        // Validate position bounds
181        self.validate_input_bounds_schema(&muxbox.position, &format!("{}.position", path));
182
183        // Validate child muxboxes recursively
184        if let Some(children) = &muxbox.children {
185            for (idx, child) in children.iter().enumerate() {
186                let _ = self.validate_muxbox(child, &format!("{}.children[{}]", path, idx));
187            }
188        }
189
190        // Validate script commands if present
191        if let Some(scripts) = &muxbox.script {
192            for (idx, script) in scripts.iter().enumerate() {
193                if script.trim().is_empty() {
194                    self.add_error(ValidationError::InvalidFieldValue {
195                        field: format!("{}.script[{}]", path, idx),
196                        value: "empty".to_string(),
197                        constraint: "script commands cannot be empty".to_string(),
198                    });
199                }
200            }
201        }
202
203        if self.errors.is_empty() {
204            Ok(())
205        } else {
206            Err(self.errors.clone())
207        }
208    }
209
210    /// Validate configuration schema
211    pub fn validate_config(&mut self, config: &Config) -> ValidationResult {
212        self.clear();
213
214        if config.frame_delay == 0 {
215            self.add_error(ValidationError::InvalidFieldValue {
216                field: "frame_delay".to_string(),
217                value: "0".to_string(),
218                constraint: "frame_delay must be greater than 0".to_string(),
219            });
220        }
221
222        if config.frame_delay > 1000 {
223            self.add_error(ValidationError::InvalidFieldValue {
224                field: "frame_delay".to_string(),
225                value: config.frame_delay.to_string(),
226                constraint: "frame_delay should not exceed 1000ms for usability".to_string(),
227            });
228        }
229
230        if self.errors.is_empty() {
231            Ok(())
232        } else {
233            Err(self.errors.clone())
234        }
235    }
236
237    /// Validate YAML content against JSON schema
238    pub fn validate_with_json_schema(
239        &mut self,
240        yaml_content: &str,
241        schema_dir: &str,
242    ) -> ValidationResult {
243        self.clear();
244
245        // Parse YAML content to JSON
246        let yaml_value: Value = match serde_yaml::from_str(yaml_content) {
247            Ok(value) => value,
248            Err(e) => {
249                self.add_error(ValidationError::SchemaStructure {
250                    message: format!("Invalid YAML syntax: {}", e),
251                });
252                return Err(self.errors.clone());
253            }
254        };
255
256        // Load and validate against app schema
257        let app_schema_path = format!("{}/app_schema.json", schema_dir);
258        if let Err(e) = self.validate_against_schema_file(&yaml_value, &app_schema_path, "app") {
259            return Err(e);
260        }
261
262        if self.errors.is_empty() {
263            Ok(())
264        } else {
265            Err(self.errors.clone())
266        }
267    }
268
269    /// Validate a JSON value against a specific schema file
270    fn validate_against_schema_file(
271        &mut self,
272        value: &Value,
273        schema_path: &str,
274        field_name: &str,
275    ) -> ValidationResult {
276        // Load schema file
277        let schema_content = match fs::read_to_string(schema_path) {
278            Ok(content) => content,
279            Err(e) => {
280                self.add_error(ValidationError::SchemaStructure {
281                    message: format!("Failed to load schema file '{}': {}", schema_path, e),
282                });
283                return Err(self.errors.clone());
284            }
285        };
286
287        // Parse schema
288        let schema_json: Value = match serde_json::from_str(&schema_content) {
289            Ok(schema) => schema,
290            Err(e) => {
291                self.add_error(ValidationError::SchemaStructure {
292                    message: format!("Invalid JSON schema in '{}': {}", schema_path, e),
293                });
294                return Err(self.errors.clone());
295            }
296        };
297
298        // Compile and validate schema
299        let compiled_schema = match JSONSchema::compile(&schema_json) {
300            Ok(schema) => schema,
301            Err(e) => {
302                self.add_error(ValidationError::SchemaStructure {
303                    message: format!("Failed to compile schema '{}': {}", schema_path, e),
304                });
305                return Err(self.errors.clone());
306            }
307        };
308
309        // Validate the value against the schema
310        if let Err(errors) = compiled_schema.validate(value) {
311            for error in errors {
312                let error_path = if error.instance_path.to_string().is_empty() {
313                    field_name.to_string()
314                } else {
315                    format!("{}.{}", field_name, error.instance_path)
316                };
317
318                self.add_error(ValidationError::JsonSchemaValidation {
319                    field: error_path,
320                    message: error.to_string(),
321                });
322            }
323            return Err(self.errors.clone());
324        }
325
326        Ok(())
327    }
328
329    /// Validate JSON configuration against schema
330    pub fn validate_json_config(&mut self, config: &Value) -> ValidationResult {
331        self.clear();
332
333        if !config.is_object() {
334            self.add_error(ValidationError::SchemaStructure {
335                message: "Configuration must be a JSON object".to_string(),
336            });
337            return Err(self.errors.clone());
338        }
339
340        let obj = config.as_object().unwrap();
341
342        // Validate required top-level fields
343        if !obj.contains_key("layouts") {
344            self.add_error(ValidationError::MissingRequiredField {
345                field: "layouts".to_string(),
346            });
347        } else if !obj["layouts"].is_array() {
348            self.add_error(ValidationError::InvalidFieldType {
349                field: "layouts".to_string(),
350                expected: "array".to_string(),
351                actual: self.get_json_type(&obj["layouts"]),
352            });
353        }
354
355        // Validate optional config section
356        if let Some(config_section) = obj.get("config") {
357            if !config_section.is_object() {
358                self.add_error(ValidationError::InvalidFieldType {
359                    field: "config".to_string(),
360                    expected: "object".to_string(),
361                    actual: self.get_json_type(config_section),
362                });
363            } else {
364                self.validate_json_config_section(config_section.as_object().unwrap());
365            }
366        }
367
368        if self.errors.is_empty() {
369            Ok(())
370        } else {
371            Err(self.errors.clone())
372        }
373    }
374
375    /// Helper methods
376    fn clear(&mut self) {
377        self.errors.clear();
378        self.muxbox_ids.clear();
379        self.layout_ids.clear();
380    }
381
382    fn add_error(&mut self, error: ValidationError) {
383        self.errors.push(error);
384    }
385
386    fn collect_ids(&mut self, app: &App) {
387        for layout in &app.layouts {
388            if !self.layout_ids.insert(layout.id.clone()) {
389                self.add_error(ValidationError::DuplicateId {
390                    id: layout.id.clone(),
391                    location: "layouts".to_string(),
392                });
393            }
394            self.collect_muxbox_ids_recursive(&layout.children, "muxboxes");
395        }
396    }
397
398    fn collect_muxbox_ids_recursive(&mut self, muxboxes: &Option<Vec<MuxBox>>, location: &str) {
399        if let Some(muxbox_list) = muxboxes {
400            for muxbox in muxbox_list {
401                if !self.muxbox_ids.insert(muxbox.id.clone()) {
402                    self.add_error(ValidationError::DuplicateId {
403                        id: muxbox.id.clone(),
404                        location: location.to_string(),
405                    });
406                }
407                self.collect_muxbox_ids_recursive(&muxbox.children, location);
408
409                // Check choice IDs if they exist
410                if let Some(choices) = &muxbox.choices {
411                    for choice in choices {
412                        if !self.muxbox_ids.insert(choice.id.clone()) {
413                            self.add_error(ValidationError::DuplicateId {
414                                id: choice.id.clone(),
415                                location: "choices".to_string(),
416                            });
417                        }
418                    }
419                }
420            }
421        }
422    }
423
424    fn validate_root_layout_constraints(&mut self, app: &App) {
425        let mut root_count = 0;
426        for layout in &app.layouts {
427            if layout.root == Some(true) {
428                root_count += 1;
429            }
430        }
431
432        if root_count > 1 {
433            self.add_error(ValidationError::SchemaStructure {
434                message:
435                    "Multiple root layouts detected. Only one layout can be marked as 'root: true'."
436                        .to_string(),
437            });
438        }
439    }
440
441    fn validate_input_bounds_schema(
442        &mut self,
443        bounds: &crate::model::common::InputBounds,
444        path: &str,
445    ) {
446        // Validate that bounds strings are not empty
447        if bounds.x1.trim().is_empty() {
448            self.add_error(ValidationError::InvalidFieldValue {
449                field: format!("{}.x1", path),
450                value: "empty".to_string(),
451                constraint: "bounds coordinates cannot be empty".to_string(),
452            });
453        }
454
455        if bounds.y1.trim().is_empty() {
456            self.add_error(ValidationError::InvalidFieldValue {
457                field: format!("{}.y1", path),
458                value: "empty".to_string(),
459                constraint: "bounds coordinates cannot be empty".to_string(),
460            });
461        }
462
463        if bounds.x2.trim().is_empty() {
464            self.add_error(ValidationError::InvalidFieldValue {
465                field: format!("{}.x2", path),
466                value: "empty".to_string(),
467                constraint: "bounds coordinates cannot be empty".to_string(),
468            });
469        }
470
471        if bounds.y2.trim().is_empty() {
472            self.add_error(ValidationError::InvalidFieldValue {
473                field: format!("{}.y2", path),
474                value: "empty".to_string(),
475                constraint: "bounds coordinates cannot be empty".to_string(),
476            });
477        }
478    }
479
480    fn validate_json_config_section(&mut self, config: &Map<String, Value>) {
481        if let Some(frame_delay) = config.get("frame_delay") {
482            if let Some(delay) = frame_delay.as_u64() {
483                if delay == 0 {
484                    self.add_error(ValidationError::InvalidFieldValue {
485                        field: "config.frame_delay".to_string(),
486                        value: "0".to_string(),
487                        constraint: "frame_delay must be greater than 0".to_string(),
488                    });
489                }
490            } else {
491                self.add_error(ValidationError::InvalidFieldType {
492                    field: "config.frame_delay".to_string(),
493                    expected: "number".to_string(),
494                    actual: self.get_json_type(frame_delay),
495                });
496            }
497        }
498    }
499
500    fn get_json_type(&self, value: &Value) -> String {
501        match value {
502            Value::Null => "null".to_string(),
503            Value::Bool(_) => "boolean".to_string(),
504            Value::Number(_) => "number".to_string(),
505            Value::String(_) => "string".to_string(),
506            Value::Array(_) => "array".to_string(),
507            Value::Object(_) => "object".to_string(),
508        }
509    }
510}
511
512impl Default for SchemaValidator {
513    fn default() -> Self {
514        Self::new()
515    }
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521    use crate::model::common::{Config, InputBounds};
522    use crate::{App, Layout, MuxBox};
523
524    fn create_test_muxbox(id: &str) -> MuxBox {
525        MuxBox {
526            id: id.to_string(),
527            position: InputBounds {
528                x1: "10".to_string(),
529                y1: "20".to_string(),
530                x2: "90".to_string(),
531                y2: "80".to_string(),
532            },
533            ..Default::default()
534        }
535    }
536
537    fn create_test_layout(id: &str) -> Layout {
538        Layout {
539            id: id.to_string(),
540            children: Some(vec![create_test_muxbox("muxbox1")]),
541            ..Default::default()
542        }
543    }
544
545    fn create_test_app() -> App {
546        let mut app = App::new();
547        app.layouts = vec![create_test_layout("layout1")];
548        app
549    }
550
551    #[test]
552    fn test_validate_app_success() {
553        let mut validator = SchemaValidator::new();
554        let app = create_test_app();
555
556        let result = validator.validate_app(&app);
557        assert!(result.is_ok());
558    }
559
560    #[test]
561    fn test_validate_app_no_layouts() {
562        let mut validator = SchemaValidator::new();
563        let app = App::new();
564
565        let result = validator.validate_app(&app);
566        assert!(result.is_err());
567
568        let errors = result.unwrap_err();
569        assert_eq!(errors.len(), 1);
570        assert!(matches!(
571            errors[0],
572            ValidationError::MissingRequiredField { .. }
573        ));
574    }
575
576    #[test]
577    fn test_validate_config_success() {
578        let mut validator = SchemaValidator::new();
579        let config = Config::new(60);
580
581        let result = validator.validate_config(&config);
582        assert!(result.is_ok());
583    }
584
585    #[test]
586    fn test_validate_config_zero_frame_delay() {
587        let mut validator = SchemaValidator::new();
588        let config = Config {
589            frame_delay: 0,
590            locked: false,
591        };
592
593        let result = validator.validate_config(&config);
594        assert!(result.is_err());
595
596        let errors = result.unwrap_err();
597        assert_eq!(errors.len(), 1);
598        assert!(matches!(
599            errors[0],
600            ValidationError::InvalidFieldValue { .. }
601        ));
602    }
603
604    #[test]
605    fn test_validate_config_excessive_frame_delay() {
606        let mut validator = SchemaValidator::new();
607        let config = Config {
608            frame_delay: 2000,
609            locked: false,
610        };
611
612        let result = validator.validate_config(&config);
613        assert!(result.is_err());
614
615        let errors = result.unwrap_err();
616        assert_eq!(errors.len(), 1);
617        assert!(matches!(
618            errors[0],
619            ValidationError::InvalidFieldValue { .. }
620        ));
621    }
622
623    #[test]
624    fn test_validate_json_config_success() {
625        let mut validator = SchemaValidator::new();
626        let config = serde_json::json!({
627            "layouts": [
628                {
629                    "id": "layout1",
630                    "children": [
631                        {
632                            "id": "muxbox1",
633                            "bounds": {
634                                "x1": "10",
635                                "y1": "20",
636                                "x2": "90",
637                                "y2": "80"
638                            }
639                        }
640                    ]
641                }
642            ],
643            "config": {
644                "frame_delay": 60
645            }
646        });
647
648        let result = validator.validate_json_config(&config);
649        assert!(result.is_ok());
650    }
651
652    #[test]
653    fn test_validate_json_config_missing_layouts() {
654        let mut validator = SchemaValidator::new();
655        let config = serde_json::json!({
656            "config": {
657                "frame_delay": 60
658            }
659        });
660
661        let result = validator.validate_json_config(&config);
662        assert!(result.is_err());
663
664        let errors = result.unwrap_err();
665        assert_eq!(errors.len(), 1);
666        assert!(matches!(
667            errors[0],
668            ValidationError::MissingRequiredField { .. }
669        ));
670    }
671
672    #[test]
673    fn test_validate_empty_bounds() {
674        let mut validator = SchemaValidator::new();
675        let muxbox = MuxBox {
676            id: "test_muxbox".to_string(),
677            position: InputBounds {
678                x1: "".to_string(), // Empty bound
679                y1: "20".to_string(),
680                x2: "90".to_string(),
681                y2: "80".to_string(),
682            },
683            ..Default::default()
684        };
685
686        let result = validator.validate_muxbox(&muxbox, "test_muxbox");
687        assert!(result.is_err());
688
689        let errors = result.unwrap_err();
690        assert!(errors
691            .iter()
692            .any(|e| matches!(e, ValidationError::InvalidFieldValue { .. })));
693    }
694
695    #[test]
696    fn test_validation_error_formatting() {
697        // Test that ValidationError instances format correctly for user display
698        let duplicate_error = ValidationError::DuplicateId {
699            id: "muxbox1".to_string(),
700            location: "muxboxes".to_string(),
701        };
702        assert_eq!(
703            duplicate_error.to_string(),
704            "Duplicate ID 'muxbox1' found in muxboxes"
705        );
706
707        let missing_field_error = ValidationError::MissingRequiredField {
708            field: "layouts".to_string(),
709        };
710        assert_eq!(
711            missing_field_error.to_string(),
712            "Required field 'layouts' is missing"
713        );
714
715        let schema_error = ValidationError::SchemaStructure {
716            message:
717                "Multiple root layouts detected. Only one layout can be marked as 'root: true'."
718                    .to_string(),
719        };
720        assert_eq!(schema_error.to_string(), "Schema structure error: Multiple root layouts detected. Only one layout can be marked as 'root: true'.");
721    }
722
723    #[test]
724    fn test_json_schema_validation_success() {
725        let mut validator = SchemaValidator::new();
726        let yaml_content = r#"
727app:
728  layouts:
729    - id: 'test_layout'
730      title: 'Test Layout'
731      children:
732        - id: 'muxbox1'
733          position:
734            x1: "0%"
735            y1: "0%"
736            x2: "100%"
737            y2: "100%"
738          content: 'Test content'
739          tab_order: 1
740"#;
741
742        let result = validator.validate_with_json_schema(yaml_content, "schemas");
743
744        // This should pass if schema files exist and are valid
745        match result {
746            Ok(_) => {
747                // Schema validation passed
748                assert!(true);
749            }
750            Err(errors) => {
751                // If schemas don't exist, that's expected - just verify the error is about missing schema files
752                let error_messages: Vec<String> = errors.iter().map(|e| e.to_string()).collect();
753                let combined = error_messages.join("; ");
754                assert!(
755                    combined.contains("Failed to load schema file")
756                        || combined.contains("No such file")
757                );
758            }
759        }
760    }
761
762    #[test]
763    fn test_json_schema_validation_invalid_yaml() {
764        let mut validator = SchemaValidator::new();
765        let invalid_yaml = r#"
766app:
767  layouts:
768    - id: 'test'
769      children:
770        - id: 'muxbox1'
771          position:
772            x1: "0%"
773            y1: "0%"
774            x2: "100%"
775            # Missing y2 - should cause validation error
776          border_color: 'invalid_color'  # Invalid color
777"#;
778
779        let result = validator.validate_with_json_schema(invalid_yaml, "schemas");
780
781        match result {
782            Ok(_) => {
783                // If schemas don't exist, the validation will skip JSON schema validation
784                // and this is expected behavior
785                assert!(true);
786            }
787            Err(errors) => {
788                // Should contain validation errors or schema loading errors
789                assert!(!errors.is_empty());
790                let error_messages: Vec<String> = errors.iter().map(|e| e.to_string()).collect();
791                let combined = error_messages.join("; ");
792                // Either schema validation errors or schema file missing
793                assert!(
794                    combined.contains("JSON Schema validation error")
795                        || combined.contains("Failed to load schema file")
796                        || combined.contains("No such file")
797                );
798            }
799        }
800    }
801
802    #[test]
803    fn test_json_schema_validation_malformed_yaml() {
804        let mut validator = SchemaValidator::new();
805        let malformed_yaml = r#"
806app:
807  layouts:
808    - id: 'test'
809      children:
810        - id: 'muxbox1'
811          position:
812            x1: "0%"
813            y1: "0%"
814            x2: "100%"
815            y2: "100%"
816          invalid_field_that_should_not_exist: 'invalid'
817          border_color: 123  # Wrong type - should be string
818"#;
819
820        let result = validator.validate_with_json_schema(malformed_yaml, "schemas");
821
822        match result {
823            Ok(_) => {
824                // If schemas don't exist, validation is skipped
825                assert!(true);
826            }
827            Err(errors) => {
828                assert!(!errors.is_empty());
829                // Verify we get meaningful error reporting
830                for error in &errors {
831                    match error {
832                        ValidationError::JsonSchemaValidation { field, message } => {
833                            assert!(!field.is_empty());
834                            assert!(!message.is_empty());
835                        }
836                        ValidationError::SchemaStructure { message } => {
837                            assert!(!message.is_empty());
838                        }
839                        _ => {} // Other error types are acceptable
840                    }
841                }
842            }
843        }
844    }
845
846    #[test]
847    fn test_json_schema_validation_error_formatting() {
848        let json_schema_error = ValidationError::JsonSchemaValidation {
849            field: "app.layouts[0].children[0].border_color".to_string(),
850            message: "invalid_color is not one of the allowed values".to_string(),
851        };
852
853        let formatted = json_schema_error.to_string();
854        assert!(formatted.contains("JSON Schema validation error"));
855        assert!(formatted.contains("app.layouts[0].children[0].border_color"));
856        assert!(formatted.contains("invalid_color is not one of the allowed values"));
857    }
858
859    #[test]
860    fn test_validate_against_schema_file_missing_file() {
861        let mut validator = SchemaValidator::new();
862        let test_value = serde_json::json!({
863            "test": "value"
864        });
865
866        let result =
867            validator.validate_against_schema_file(&test_value, "nonexistent/schema.json", "test");
868
869        assert!(result.is_err());
870        let errors = result.unwrap_err();
871        assert_eq!(errors.len(), 1);
872        assert!(matches!(errors[0], ValidationError::SchemaStructure { .. }));
873        assert!(errors[0].to_string().contains("Failed to load schema file"));
874    }
875
876    #[test]
877    fn test_comprehensive_validation_with_multiple_errors() {
878        let mut validator = SchemaValidator::new();
879
880        // Create an app with multiple validation errors
881        let mut app = App::new();
882
883        // Error 1: Multiple root layouts
884        let mut layout1 = create_test_layout("layout1");
885        layout1.root = Some(true);
886        let mut layout2 = create_test_layout("layout2");
887        layout2.root = Some(true); // Second root - should cause error
888
889        // Error 2: Duplicate muxbox IDs
890        let muxbox1 = create_test_muxbox("muxbox1");
891        let muxbox1_dup = create_test_muxbox("muxbox1"); // Duplicate ID
892        layout1.children = Some(vec![muxbox1]);
893        layout2.children = Some(vec![muxbox1_dup]);
894
895        app.layouts = vec![layout1, layout2];
896
897        let result = validator.validate_app(&app);
898        assert!(result.is_err());
899
900        let errors = result.unwrap_err();
901        assert!(
902            errors.len() >= 2,
903            "Should have at least 2 validation errors"
904        );
905
906        // Check for multiple root layouts error
907        assert!(errors
908            .iter()
909            .any(|e| matches!(e, ValidationError::SchemaStructure { .. })));
910
911        // Check for duplicate ID error
912        assert!(errors
913            .iter()
914            .any(|e| matches!(e, ValidationError::DuplicateId { .. })));
915    }
916}