Skip to main content

mig_bo4e/
pid_validation.rs

1//! PID validation errors — typed, LLM-consumable error reports.
2
3use std::fmt;
4
5use serde_json::Value;
6
7use crate::pid_requirements::{CodeValue, EntityRequirement, FieldRequirement, PidRequirements};
8
9/// Severity of a validation error.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum Severity {
12    /// Field is unconditionally required (Muss/X) or condition evaluated to True.
13    Error,
14    /// Condition evaluated to Unknown (depends on external context).
15    Warning,
16}
17
18/// A single PID validation error.
19#[derive(Debug, Clone)]
20pub enum PidValidationError {
21    /// An entire entity is missing from the interchange.
22    MissingEntity {
23        entity: String,
24        ahb_status: String,
25        severity: Severity,
26    },
27    /// A required field is None/missing.
28    MissingField {
29        entity: String,
30        field: String,
31        ahb_status: String,
32        rust_type: Option<String>,
33        valid_values: Vec<(String, String)>,
34        severity: Severity,
35    },
36    /// A code field has a value not in the allowed set.
37    InvalidCode {
38        entity: String,
39        field: String,
40        value: String,
41        valid_values: Vec<(String, String)>,
42    },
43}
44
45impl PidValidationError {
46    pub fn severity(&self) -> &Severity {
47        match self {
48            Self::MissingEntity { severity, .. } => severity,
49            Self::MissingField { severity, .. } => severity,
50            Self::InvalidCode { .. } => &Severity::Error,
51        }
52    }
53
54    pub fn is_error(&self) -> bool {
55        matches!(self.severity(), Severity::Error)
56    }
57}
58
59impl fmt::Display for PidValidationError {
60    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61        match self {
62            PidValidationError::MissingEntity {
63                entity,
64                ahb_status,
65                severity,
66            } => {
67                let label = severity_label(severity);
68                write!(
69                    f,
70                    "{label}: missing entity '{entity}' (required: {ahb_status})"
71                )
72            }
73            PidValidationError::MissingField {
74                entity,
75                field,
76                ahb_status,
77                rust_type,
78                valid_values,
79                severity,
80            } => {
81                let label = severity_label(severity);
82                write!(
83                    f,
84                    "{label}: missing {entity}.{field} (required: {ahb_status})"
85                )?;
86                if let Some(rt) = rust_type {
87                    write!(f, "\n  → type: {rt}")?;
88                }
89                if !valid_values.is_empty() {
90                    let codes: Vec<String> = valid_values
91                        .iter()
92                        .map(|(code, meaning)| {
93                            if meaning.is_empty() {
94                                code.clone()
95                            } else {
96                                format!("{code} ({meaning})")
97                            }
98                        })
99                        .collect();
100                    write!(f, "\n  → valid: {}", codes.join(", "))?;
101                }
102                Ok(())
103            }
104            PidValidationError::InvalidCode {
105                entity,
106                field,
107                value,
108                valid_values,
109            } => {
110                write!(f, "INVALID: {entity}.{field} = \"{value}\"")?;
111                if !valid_values.is_empty() {
112                    let codes: Vec<String> = valid_values.iter().map(|(c, _)| c.clone()).collect();
113                    write!(f, "\n  → valid: {}", codes.join(", "))?;
114                }
115                Ok(())
116            }
117        }
118    }
119}
120
121fn severity_label(severity: &Severity) -> &'static str {
122    match severity {
123        Severity::Error => "ERROR",
124        Severity::Warning => "WARNING",
125    }
126}
127
128/// A collection of validation errors for a PID.
129pub struct ValidationReport(pub Vec<PidValidationError>);
130
131impl ValidationReport {
132    /// Returns true if the report contains any errors (not just warnings).
133    pub fn has_errors(&self) -> bool {
134        self.0.iter().any(|e| e.is_error())
135    }
136
137    /// Returns only the errors (not warnings).
138    pub fn errors(&self) -> Vec<&PidValidationError> {
139        self.0.iter().filter(|e| e.is_error()).collect()
140    }
141
142    /// Returns true if the report is empty (no errors or warnings).
143    pub fn is_empty(&self) -> bool {
144        self.0.is_empty()
145    }
146
147    /// Returns the number of validation errors.
148    pub fn len(&self) -> usize {
149        self.0.len()
150    }
151}
152
153impl fmt::Display for ValidationReport {
154    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155        for (i, err) in self.0.iter().enumerate() {
156            if i > 0 {
157                writeln!(f)?;
158            }
159            write!(f, "{err}")?;
160        }
161        Ok(())
162    }
163}
164
165// ── Validation Logic ──────────────────────────────────────────────────────
166
167/// Validate a BO4E JSON value against PID requirements.
168///
169/// Walks the requirements and checks:
170/// 1. Required entities are present in the JSON
171/// 2. Required fields are present within each entity
172/// 3. Code fields have values in the allowed set
173pub fn validate_pid_json(json: &Value, requirements: &PidRequirements) -> Vec<PidValidationError> {
174    let mut errors = Vec::new();
175
176    for entity_req in &requirements.entities {
177        let key = to_camel_case(&entity_req.entity);
178
179        match json.get(&key) {
180            None => {
181                if is_unconditionally_required(&entity_req.ahb_status) {
182                    errors.push(PidValidationError::MissingEntity {
183                        entity: entity_req.entity.clone(),
184                        ahb_status: entity_req.ahb_status.clone(),
185                        severity: Severity::Error,
186                    });
187                }
188            }
189            Some(val) => {
190                if entity_req.is_array {
191                    if let Some(arr) = val.as_array() {
192                        for element in arr {
193                            validate_entity_fields(element, entity_req, &mut errors);
194                        }
195                    }
196                } else {
197                    validate_entity_fields(val, entity_req, &mut errors);
198                }
199            }
200        }
201    }
202
203    errors
204}
205
206/// Validate fields within a single entity JSON object.
207fn validate_entity_fields(
208    entity_json: &Value,
209    entity_req: &EntityRequirement,
210    errors: &mut Vec<PidValidationError>,
211) {
212    for field_req in &entity_req.fields {
213        // Look up the field by its stored name first; if not found and the name is
214        // snake_case, also try the camelCase variant.  This handles typed structs that
215        // use `#[serde(rename_all = "camelCase")]`, where e.g. `code_codepflege` is
216        // serialised as `codeCodepflege`.
217        let val = entity_json.get(&field_req.bo4e_name).or_else(|| {
218            if field_req.bo4e_name.contains('_') {
219                entity_json.get(&snake_to_camel_case(&field_req.bo4e_name))
220            } else {
221                None
222            }
223        });
224
225        match val {
226            None => {
227                if is_unconditionally_required(&field_req.ahb_status) {
228                    errors.push(PidValidationError::MissingField {
229                        entity: entity_req.entity.clone(),
230                        field: field_req.bo4e_name.clone(),
231                        ahb_status: field_req.ahb_status.clone(),
232                        rust_type: field_req.enum_name.clone(),
233                        valid_values: code_values_to_tuples(&field_req.valid_codes),
234                        severity: Severity::Error,
235                    });
236                }
237            }
238            Some(val) => {
239                if !field_req.valid_codes.is_empty() {
240                    validate_code_value(val, entity_req, field_req, errors);
241                }
242            }
243        }
244    }
245}
246
247/// Validate that a code field's value is in the allowed set.
248fn validate_code_value(
249    val: &Value,
250    entity_req: &EntityRequirement,
251    field_req: &FieldRequirement,
252    errors: &mut Vec<PidValidationError>,
253) {
254    let value_str = match val.as_str() {
255        Some(s) => s,
256        None => return, // Non-string values skip code validation
257    };
258
259    let is_valid = field_req.valid_codes.iter().any(|cv| cv.code == value_str);
260    if !is_valid {
261        errors.push(PidValidationError::InvalidCode {
262            entity: entity_req.entity.clone(),
263            field: field_req.bo4e_name.clone(),
264            value: value_str.to_string(),
265            valid_values: code_values_to_tuples(&field_req.valid_codes),
266        });
267    }
268}
269
270/// Convert CodeValue vec to (code, meaning) tuples.
271fn code_values_to_tuples(codes: &[CodeValue]) -> Vec<(String, String)> {
272    codes
273        .iter()
274        .map(|cv| (cv.code.clone(), cv.meaning.clone()))
275        .collect()
276}
277
278/// Convert PascalCase entity name to camelCase JSON key.
279///
280/// "Prozessdaten" → "prozessdaten"
281/// "RuhendeMarktlokation" → "ruhendeMarktlokation"
282/// "Marktlokation" → "marktlokation"
283fn to_camel_case(s: &str) -> String {
284    if s.is_empty() {
285        return String::new();
286    }
287    let mut chars = s.chars();
288    let first = chars.next().unwrap();
289    let mut result = first.to_lowercase().to_string();
290    result.extend(chars);
291    result
292}
293
294/// Convert a snake_case field name to camelCase.
295///
296/// This mirrors what `#[serde(rename_all = "camelCase")]` does at runtime, allowing
297/// the validator to find fields in JSON that was produced by typed structs even when
298/// the requirement stores the field name as snake_case (as it comes from TOML).
299///
300/// Examples:
301/// - `"code_codepflege"` → `"codeCodepflege"`
302/// - `"vorgang_id"` → `"vorgangId"`
303/// - `"marktlokation"` → `"marktlokation"` (unchanged — no underscores)
304fn snake_to_camel_case(s: &str) -> String {
305    let mut result = String::with_capacity(s.len());
306    let mut capitalize_next = false;
307    for ch in s.chars() {
308        if ch == '_' {
309            capitalize_next = true;
310        } else if capitalize_next {
311            result.extend(ch.to_uppercase());
312            capitalize_next = false;
313        } else {
314            result.push(ch);
315        }
316    }
317    result
318}
319
320/// Returns true if the AHB status indicates an unconditionally required field.
321fn is_unconditionally_required(ahb_status: &str) -> bool {
322    matches!(ahb_status, "X" | "Muss" | "Soll")
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328    use crate::pid_requirements::{
329        CodeValue, EntityRequirement, FieldRequirement, PidRequirements,
330    };
331    use serde_json::json;
332
333    fn sample_requirements() -> PidRequirements {
334        PidRequirements {
335            pid: "55001".to_string(),
336            beschreibung: "Anmeldung verb. MaLo".to_string(),
337            entities: vec![
338                EntityRequirement {
339                    entity: "Prozessdaten".to_string(),
340                    bo4e_type: "Prozessdaten".to_string(),
341                    companion_type: None,
342                    ahb_status: "Muss".to_string(),
343                    is_array: false,
344                    map_key: None,
345                    fields: vec![
346                        FieldRequirement {
347                            bo4e_name: "vorgangId".to_string(),
348                            ahb_status: "X".to_string(),
349                            is_companion: false,
350                            field_type: "data".to_string(),
351                            format: None,
352                            enum_name: None,
353                            valid_codes: vec![],
354                            child_group: None,
355                        },
356                        FieldRequirement {
357                            bo4e_name: "transaktionsgrund".to_string(),
358                            ahb_status: "X".to_string(),
359                            is_companion: false,
360                            field_type: "code".to_string(),
361                            format: None,
362                            enum_name: Some("Transaktionsgrund".to_string()),
363                            valid_codes: vec![
364                                CodeValue {
365                                    code: "E01".to_string(),
366                                    meaning: "Ein-/Auszug (Einzug)".to_string(),
367                                    enum_name: None,
368                                },
369                                CodeValue {
370                                    code: "E03".to_string(),
371                                    meaning: "Wechsel".to_string(),
372                                    enum_name: None,
373                                },
374                            ],
375                            child_group: None,
376                        },
377                    ],
378                },
379                EntityRequirement {
380                    entity: "Marktlokation".to_string(),
381                    bo4e_type: "Marktlokation".to_string(),
382                    companion_type: Some("MarktlokationEdifact".to_string()),
383                    ahb_status: "Muss".to_string(),
384                    is_array: false,
385                    map_key: None,
386                    fields: vec![
387                        FieldRequirement {
388                            bo4e_name: "marktlokationsId".to_string(),
389                            ahb_status: "X".to_string(),
390                            is_companion: false,
391                            field_type: "data".to_string(),
392                            format: None,
393                            enum_name: None,
394                            valid_codes: vec![],
395                            child_group: None,
396                        },
397                        FieldRequirement {
398                            bo4e_name: "haushaltskunde".to_string(),
399                            ahb_status: "X".to_string(),
400                            is_companion: false,
401                            field_type: "code".to_string(),
402                            format: None,
403                            enum_name: Some("Haushaltskunde".to_string()),
404                            valid_codes: vec![
405                                CodeValue {
406                                    code: "Z15".to_string(),
407                                    meaning: "Ja".to_string(),
408                                    enum_name: None,
409                                },
410                                CodeValue {
411                                    code: "Z18".to_string(),
412                                    meaning: "Nein".to_string(),
413                                    enum_name: None,
414                                },
415                            ],
416                            child_group: None,
417                        },
418                    ],
419                },
420                EntityRequirement {
421                    entity: "Geschaeftspartner".to_string(),
422                    bo4e_type: "Geschaeftspartner".to_string(),
423                    companion_type: Some("GeschaeftspartnerEdifact".to_string()),
424                    ahb_status: "Muss".to_string(),
425                    is_array: true,
426                    map_key: None,
427                    fields: vec![FieldRequirement {
428                        bo4e_name: "identifikation".to_string(),
429                        ahb_status: "X".to_string(),
430                        is_companion: false,
431                        field_type: "data".to_string(),
432                        format: None,
433                        enum_name: None,
434                        valid_codes: vec![],
435                        child_group: None,
436                    }],
437                },
438            ],
439        }
440    }
441
442    #[test]
443    fn test_validate_complete_json() {
444        let reqs = sample_requirements();
445        let json = json!({
446            "prozessdaten": {
447                "vorgangId": "ABC123",
448                "transaktionsgrund": "E01"
449            },
450            "marktlokation": {
451                "marktlokationsId": "51234567890",
452                "haushaltskunde": "Z15"
453            },
454            "geschaeftspartner": [
455                { "identifikation": "9900000000003" }
456            ]
457        });
458
459        let errors = validate_pid_json(&json, &reqs);
460        assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
461    }
462
463    #[test]
464    fn test_validate_missing_entity() {
465        let reqs = sample_requirements();
466        let json = json!({
467            "prozessdaten": {
468                "vorgangId": "ABC123",
469                "transaktionsgrund": "E01"
470            },
471            "geschaeftspartner": [
472                { "identifikation": "9900000000003" }
473            ]
474        });
475        // Marktlokation is missing
476
477        let errors = validate_pid_json(&json, &reqs);
478        assert_eq!(errors.len(), 1);
479        match &errors[0] {
480            PidValidationError::MissingEntity {
481                entity,
482                ahb_status,
483                severity,
484            } => {
485                assert_eq!(entity, "Marktlokation");
486                assert_eq!(ahb_status, "Muss");
487                assert_eq!(severity, &Severity::Error);
488            }
489            other => panic!("Expected MissingEntity, got: {other:?}"),
490        }
491
492        // Display check
493        let msg = errors[0].to_string();
494        assert!(msg.contains("ERROR"));
495        assert!(msg.contains("Marktlokation"));
496        assert!(msg.contains("Muss"));
497    }
498
499    #[test]
500    fn test_validate_missing_field() {
501        let reqs = sample_requirements();
502        let json = json!({
503            "prozessdaten": {
504                "transaktionsgrund": "E01"
505                // vorgangId is missing
506            },
507            "marktlokation": {
508                "marktlokationsId": "51234567890",
509                "haushaltskunde": "Z15"
510            },
511            "geschaeftspartner": [
512                { "identifikation": "9900000000003" }
513            ]
514        });
515
516        let errors = validate_pid_json(&json, &reqs);
517        assert_eq!(errors.len(), 1);
518        match &errors[0] {
519            PidValidationError::MissingField {
520                entity,
521                field,
522                ahb_status,
523                severity,
524                ..
525            } => {
526                assert_eq!(entity, "Prozessdaten");
527                assert_eq!(field, "vorgangId");
528                assert_eq!(ahb_status, "X");
529                assert_eq!(severity, &Severity::Error);
530            }
531            other => panic!("Expected MissingField, got: {other:?}"),
532        }
533
534        let msg = errors[0].to_string();
535        assert!(msg.contains("ERROR"));
536        assert!(msg.contains("Prozessdaten.vorgangId"));
537    }
538
539    #[test]
540    fn test_validate_invalid_code() {
541        let reqs = sample_requirements();
542        let json = json!({
543            "prozessdaten": {
544                "vorgangId": "ABC123",
545                "transaktionsgrund": "E01"
546            },
547            "marktlokation": {
548                "marktlokationsId": "51234567890",
549                "haushaltskunde": "Z99"  // Invalid code
550            },
551            "geschaeftspartner": [
552                { "identifikation": "9900000000003" }
553            ]
554        });
555
556        let errors = validate_pid_json(&json, &reqs);
557        assert_eq!(errors.len(), 1);
558        match &errors[0] {
559            PidValidationError::InvalidCode {
560                entity,
561                field,
562                value,
563                valid_values,
564            } => {
565                assert_eq!(entity, "Marktlokation");
566                assert_eq!(field, "haushaltskunde");
567                assert_eq!(value, "Z99");
568                assert_eq!(valid_values.len(), 2);
569                assert!(valid_values.iter().any(|(c, _)| c == "Z15"));
570                assert!(valid_values.iter().any(|(c, _)| c == "Z18"));
571            }
572            other => panic!("Expected InvalidCode, got: {other:?}"),
573        }
574
575        let msg = errors[0].to_string();
576        assert!(msg.contains("INVALID"));
577        assert!(msg.contains("Z99"));
578        assert!(msg.contains("Z15"));
579    }
580
581    #[test]
582    fn test_validate_array_entity() {
583        let reqs = sample_requirements();
584        let json = json!({
585            "prozessdaten": {
586                "vorgangId": "ABC123",
587                "transaktionsgrund": "E01"
588            },
589            "marktlokation": {
590                "marktlokationsId": "51234567890",
591                "haushaltskunde": "Z15"
592            },
593            "geschaeftspartner": [
594                { "identifikation": "9900000000003" },
595                { }  // Missing identifikation in second element
596            ]
597        });
598
599        let errors = validate_pid_json(&json, &reqs);
600        assert_eq!(errors.len(), 1);
601        match &errors[0] {
602            PidValidationError::MissingField { entity, field, .. } => {
603                assert_eq!(entity, "Geschaeftspartner");
604                assert_eq!(field, "identifikation");
605            }
606            other => panic!("Expected MissingField, got: {other:?}"),
607        }
608    }
609
610    #[test]
611    fn test_to_camel_case() {
612        assert_eq!(to_camel_case("Prozessdaten"), "prozessdaten");
613        assert_eq!(
614            to_camel_case("RuhendeMarktlokation"),
615            "ruhendeMarktlokation"
616        );
617        assert_eq!(to_camel_case("Marktlokation"), "marktlokation");
618        assert_eq!(to_camel_case(""), "");
619    }
620
621    #[test]
622    fn test_snake_to_camel_case() {
623        assert_eq!(snake_to_camel_case("code_codepflege"), "codeCodepflege");
624        assert_eq!(snake_to_camel_case("vorgang_id"), "vorgangId");
625        assert_eq!(snake_to_camel_case("marktlokation"), "marktlokation");
626        assert_eq!(snake_to_camel_case(""), "");
627        assert_eq!(snake_to_camel_case("a_b_c"), "aBC");
628    }
629
630    /// A field stored as snake_case in requirements (e.g. from TOML) must be found
631    /// in JSON that was produced by a typed struct using `#[serde(rename_all = "camelCase")]`.
632    #[test]
633    fn test_camel_case_fallback_for_snake_case_bo4e_name() {
634        let reqs = PidRequirements {
635            pid: "55077".to_string(),
636            beschreibung: "Test camelCase fallback".to_string(),
637            entities: vec![EntityRequirement {
638                entity: "Zuordnung".to_string(),
639                bo4e_type: "Zuordnung".to_string(),
640                companion_type: None,
641                ahb_status: "Muss".to_string(),
642                is_array: false,
643                map_key: None,
644                fields: vec![
645                    FieldRequirement {
646                        // snake_case as stored in TOML requirements
647                        bo4e_name: "code_codepflege".to_string(),
648                        ahb_status: "X".to_string(),
649                        is_companion: false,
650                        field_type: "data".to_string(),
651                        format: None,
652                        enum_name: None,
653                        valid_codes: vec![],
654                        child_group: None,
655                    },
656                    FieldRequirement {
657                        bo4e_name: "codeliste".to_string(),
658                        ahb_status: "X".to_string(),
659                        is_companion: false,
660                        field_type: "data".to_string(),
661                        format: None,
662                        enum_name: None,
663                        valid_codes: vec![],
664                        child_group: None,
665                    },
666                ],
667            }],
668        };
669
670        // JSON produced by a typed struct with #[serde(rename_all = "camelCase")]:
671        // code_codepflege → codeCodepflege
672        let json_camel = json!({
673            "zuordnung": {
674                "codeCodepflege": "DE_BDEW",
675                "codeliste": "6"
676            }
677        });
678
679        let errors = validate_pid_json(&json_camel, &reqs);
680        assert!(
681            errors.is_empty(),
682            "Expected no errors when field is present under camelCase key, got: {errors:?}"
683        );
684
685        // Also verify that snake_case key in JSON still works (backward compat).
686        let json_snake = json!({
687            "zuordnung": {
688                "code_codepflege": "DE_BDEW",
689                "codeliste": "6"
690            }
691        });
692
693        let errors = validate_pid_json(&json_snake, &reqs);
694        assert!(
695            errors.is_empty(),
696            "Expected no errors when field is present under snake_case key, got: {errors:?}"
697        );
698
699        // When the field is truly absent, a MissingField error must still be raised.
700        let json_missing = json!({
701            "zuordnung": {
702                "codeliste": "6"
703            }
704        });
705
706        let errors = validate_pid_json(&json_missing, &reqs);
707        assert_eq!(errors.len(), 1);
708        match &errors[0] {
709            PidValidationError::MissingField { field, .. } => {
710                assert_eq!(field, "code_codepflege");
711            }
712            other => panic!("Expected MissingField, got: {other:?}"),
713        }
714    }
715
716    #[test]
717    fn test_is_unconditionally_required() {
718        assert!(is_unconditionally_required("X"));
719        assert!(is_unconditionally_required("Muss"));
720        assert!(is_unconditionally_required("Soll"));
721        assert!(!is_unconditionally_required("Kann"));
722        assert!(!is_unconditionally_required("[1]"));
723        assert!(!is_unconditionally_required(""));
724    }
725
726    #[test]
727    fn test_validation_report_display() {
728        let errors = vec![
729            PidValidationError::MissingEntity {
730                entity: "Marktlokation".to_string(),
731                ahb_status: "Muss".to_string(),
732                severity: Severity::Error,
733            },
734            PidValidationError::MissingField {
735                entity: "Prozessdaten".to_string(),
736                field: "vorgangId".to_string(),
737                ahb_status: "X".to_string(),
738                rust_type: None,
739                valid_values: vec![],
740                severity: Severity::Error,
741            },
742        ];
743        let report = ValidationReport(errors);
744        assert!(report.has_errors());
745        assert_eq!(report.len(), 2);
746        assert!(!report.is_empty());
747
748        let display = report.to_string();
749        assert!(display.contains("missing entity 'Marktlokation'"));
750        assert!(display.contains("missing Prozessdaten.vorgangId"));
751    }
752
753    #[test]
754    fn test_missing_field_with_type_and_values_display() {
755        let err = PidValidationError::MissingField {
756            entity: "Marktlokation".to_string(),
757            field: "haushaltskunde".to_string(),
758            ahb_status: "Muss".to_string(),
759            rust_type: Some("Haushaltskunde".to_string()),
760            valid_values: vec![
761                ("Z15".to_string(), "Ja".to_string()),
762                ("Z18".to_string(), "Nein".to_string()),
763            ],
764            severity: Severity::Error,
765        };
766        let msg = err.to_string();
767        assert!(msg.contains("type: Haushaltskunde"));
768        assert!(msg.contains("valid: Z15 (Ja), Z18 (Nein)"));
769    }
770
771    #[test]
772    fn test_optional_fields_not_flagged() {
773        let reqs = PidRequirements {
774            pid: "99999".to_string(),
775            beschreibung: "Test".to_string(),
776            entities: vec![EntityRequirement {
777                entity: "Test".to_string(),
778                bo4e_type: "Test".to_string(),
779                companion_type: None,
780                ahb_status: "Kann".to_string(),
781                is_array: false,
782                map_key: None,
783                fields: vec![FieldRequirement {
784                    bo4e_name: "optionalField".to_string(),
785                    ahb_status: "Kann".to_string(),
786                    is_companion: false,
787                    field_type: "data".to_string(),
788                    format: None,
789                    enum_name: None,
790                    valid_codes: vec![],
791                    child_group: None,
792                }],
793            }],
794        };
795
796        // Entity missing but optional — no error
797        let errors = validate_pid_json(&json!({}), &reqs);
798        assert!(errors.is_empty());
799
800        // Entity present, field missing but optional — no error
801        let errors = validate_pid_json(&json!({ "test": {} }), &reqs);
802        assert!(errors.is_empty());
803    }
804}