1use std::fmt;
4
5use serde_json::Value;
6
7use crate::pid_requirements::{CodeValue, EntityRequirement, FieldRequirement, PidRequirements};
8
9#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum Severity {
12 Error,
14 Warning,
16}
17
18#[derive(Debug, Clone)]
20pub enum PidValidationError {
21 MissingEntity {
23 entity: String,
24 ahb_status: String,
25 severity: Severity,
26 },
27 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 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
128pub struct ValidationReport(pub Vec<PidValidationError>);
130
131impl ValidationReport {
132 pub fn has_errors(&self) -> bool {
134 self.0.iter().any(|e| e.is_error())
135 }
136
137 pub fn errors(&self) -> Vec<&PidValidationError> {
139 self.0.iter().filter(|e| e.is_error()).collect()
140 }
141
142 pub fn is_empty(&self) -> bool {
144 self.0.is_empty()
145 }
146
147 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
165pub 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
206fn get_nested<'a>(json: &'a Value, path: &str) -> Option<&'a Value> {
209 let mut current = json;
210 for part in path.split('.') {
211 current = current.get(part).or_else(|| {
212 if part.contains('_') {
213 current.get(snake_to_camel_case(part))
214 } else {
215 None
216 }
217 })?;
218 }
219 Some(current)
220}
221
222fn validate_entity_fields(
224 entity_json: &Value,
225 entity_req: &EntityRequirement,
226 errors: &mut Vec<PidValidationError>,
227) {
228 for field_req in &entity_req.fields {
229 let val = get_nested(entity_json, &field_req.bo4e_name).or_else(|| {
235 if field_req.is_companion {
236 if let Some(ref companion_type) = entity_req.companion_type {
237 let companion_key = to_camel_case(companion_type);
238 let companion_obj = entity_json.get(&companion_key)?;
239 get_nested(companion_obj, &field_req.bo4e_name)
240 } else {
241 None
242 }
243 } else {
244 None
245 }
246 });
247
248 let val = val.filter(|v| !v.is_null());
250
251 match val {
252 None => {
253 if is_unconditionally_required(&field_req.ahb_status) {
254 errors.push(PidValidationError::MissingField {
255 entity: entity_req.entity.clone(),
256 field: field_req.bo4e_name.clone(),
257 ahb_status: field_req.ahb_status.clone(),
258 rust_type: field_req.enum_name.clone(),
259 valid_values: code_values_to_tuples(&field_req.valid_codes),
260 severity: Severity::Error,
261 });
262 }
263 }
264 Some(val) => {
265 if !field_req.valid_codes.is_empty() {
266 validate_code_value(val, entity_req, field_req, errors);
267 }
268 }
269 }
270 }
271}
272
273fn validate_code_value(
275 val: &Value,
276 entity_req: &EntityRequirement,
277 field_req: &FieldRequirement,
278 errors: &mut Vec<PidValidationError>,
279) {
280 let value_str = match val.as_str() {
281 Some(s) => s,
282 None => return, };
284
285 let is_valid = field_req.valid_codes.iter().any(|cv| cv.code == value_str);
286 if !is_valid {
287 errors.push(PidValidationError::InvalidCode {
288 entity: entity_req.entity.clone(),
289 field: field_req.bo4e_name.clone(),
290 value: value_str.to_string(),
291 valid_values: code_values_to_tuples(&field_req.valid_codes),
292 });
293 }
294}
295
296fn code_values_to_tuples(codes: &[CodeValue]) -> Vec<(String, String)> {
298 codes
299 .iter()
300 .map(|cv| (cv.code.clone(), cv.meaning.clone()))
301 .collect()
302}
303
304fn to_camel_case(s: &str) -> String {
310 if s.is_empty() {
311 return String::new();
312 }
313 let mut chars = s.chars();
314 let first = chars.next().unwrap();
315 let mut result = first.to_lowercase().to_string();
316 result.extend(chars);
317 result
318}
319
320fn snake_to_camel_case(s: &str) -> String {
331 let mut result = String::with_capacity(s.len());
332 let mut capitalize_next = false;
333 for ch in s.chars() {
334 if ch == '_' {
335 capitalize_next = true;
336 } else if capitalize_next {
337 result.extend(ch.to_uppercase());
338 capitalize_next = false;
339 } else {
340 result.push(ch);
341 }
342 }
343 result
344}
345
346fn is_unconditionally_required(ahb_status: &str) -> bool {
348 matches!(ahb_status, "X" | "Muss" | "Soll")
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354 use crate::pid_requirements::{
355 CodeValue, EntityRequirement, FieldRequirement, PidRequirements,
356 };
357 use serde_json::json;
358
359 fn sample_requirements() -> PidRequirements {
360 PidRequirements {
361 pid: "55001".to_string(),
362 beschreibung: "Anmeldung verb. MaLo".to_string(),
363 entities: vec![
364 EntityRequirement {
365 entity: "Prozessdaten".to_string(),
366 bo4e_type: "Prozessdaten".to_string(),
367 companion_type: None,
368 ahb_status: "Muss".to_string(),
369 is_array: false,
370 map_key: None,
371 fields: vec![
372 FieldRequirement {
373 bo4e_name: "vorgangId".to_string(),
374 ahb_status: "X".to_string(),
375 is_companion: false,
376 field_type: "data".to_string(),
377 format: None,
378 enum_name: None,
379 valid_codes: vec![],
380 child_group: None,
381 },
382 FieldRequirement {
383 bo4e_name: "transaktionsgrund".to_string(),
384 ahb_status: "X".to_string(),
385 is_companion: false,
386 field_type: "code".to_string(),
387 format: None,
388 enum_name: Some("Transaktionsgrund".to_string()),
389 valid_codes: vec![
390 CodeValue {
391 code: "E01".to_string(),
392 meaning: "Ein-/Auszug (Einzug)".to_string(),
393 enum_name: None,
394 },
395 CodeValue {
396 code: "E03".to_string(),
397 meaning: "Wechsel".to_string(),
398 enum_name: None,
399 },
400 ],
401 child_group: None,
402 },
403 ],
404 },
405 EntityRequirement {
406 entity: "Marktlokation".to_string(),
407 bo4e_type: "Marktlokation".to_string(),
408 companion_type: Some("MarktlokationEdifact".to_string()),
409 ahb_status: "Muss".to_string(),
410 is_array: false,
411 map_key: None,
412 fields: vec![
413 FieldRequirement {
414 bo4e_name: "marktlokationsId".to_string(),
415 ahb_status: "X".to_string(),
416 is_companion: false,
417 field_type: "data".to_string(),
418 format: None,
419 enum_name: None,
420 valid_codes: vec![],
421 child_group: None,
422 },
423 FieldRequirement {
424 bo4e_name: "haushaltskunde".to_string(),
425 ahb_status: "X".to_string(),
426 is_companion: false,
427 field_type: "code".to_string(),
428 format: None,
429 enum_name: Some("Haushaltskunde".to_string()),
430 valid_codes: vec![
431 CodeValue {
432 code: "Z15".to_string(),
433 meaning: "Ja".to_string(),
434 enum_name: None,
435 },
436 CodeValue {
437 code: "Z18".to_string(),
438 meaning: "Nein".to_string(),
439 enum_name: None,
440 },
441 ],
442 child_group: None,
443 },
444 ],
445 },
446 EntityRequirement {
447 entity: "Geschaeftspartner".to_string(),
448 bo4e_type: "Geschaeftspartner".to_string(),
449 companion_type: Some("GeschaeftspartnerEdifact".to_string()),
450 ahb_status: "Muss".to_string(),
451 is_array: true,
452 map_key: None,
453 fields: vec![FieldRequirement {
454 bo4e_name: "identifikation".to_string(),
455 ahb_status: "X".to_string(),
456 is_companion: false,
457 field_type: "data".to_string(),
458 format: None,
459 enum_name: None,
460 valid_codes: vec![],
461 child_group: None,
462 }],
463 },
464 ],
465 }
466 }
467
468 #[test]
469 fn test_validate_complete_json() {
470 let reqs = sample_requirements();
471 let json = json!({
472 "prozessdaten": {
473 "vorgangId": "ABC123",
474 "transaktionsgrund": "E01"
475 },
476 "marktlokation": {
477 "marktlokationsId": "51234567890",
478 "haushaltskunde": "Z15"
479 },
480 "geschaeftspartner": [
481 { "identifikation": "9900000000003" }
482 ]
483 });
484
485 let errors = validate_pid_json(&json, &reqs);
486 assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
487 }
488
489 #[test]
490 fn test_validate_missing_entity() {
491 let reqs = sample_requirements();
492 let json = json!({
493 "prozessdaten": {
494 "vorgangId": "ABC123",
495 "transaktionsgrund": "E01"
496 },
497 "geschaeftspartner": [
498 { "identifikation": "9900000000003" }
499 ]
500 });
501 let errors = validate_pid_json(&json, &reqs);
504 assert_eq!(errors.len(), 1);
505 match &errors[0] {
506 PidValidationError::MissingEntity {
507 entity,
508 ahb_status,
509 severity,
510 } => {
511 assert_eq!(entity, "Marktlokation");
512 assert_eq!(ahb_status, "Muss");
513 assert_eq!(severity, &Severity::Error);
514 }
515 other => panic!("Expected MissingEntity, got: {other:?}"),
516 }
517
518 let msg = errors[0].to_string();
520 assert!(msg.contains("ERROR"));
521 assert!(msg.contains("Marktlokation"));
522 assert!(msg.contains("Muss"));
523 }
524
525 #[test]
526 fn test_validate_missing_field() {
527 let reqs = sample_requirements();
528 let json = json!({
529 "prozessdaten": {
530 "transaktionsgrund": "E01"
531 },
533 "marktlokation": {
534 "marktlokationsId": "51234567890",
535 "haushaltskunde": "Z15"
536 },
537 "geschaeftspartner": [
538 { "identifikation": "9900000000003" }
539 ]
540 });
541
542 let errors = validate_pid_json(&json, &reqs);
543 assert_eq!(errors.len(), 1);
544 match &errors[0] {
545 PidValidationError::MissingField {
546 entity,
547 field,
548 ahb_status,
549 severity,
550 ..
551 } => {
552 assert_eq!(entity, "Prozessdaten");
553 assert_eq!(field, "vorgangId");
554 assert_eq!(ahb_status, "X");
555 assert_eq!(severity, &Severity::Error);
556 }
557 other => panic!("Expected MissingField, got: {other:?}"),
558 }
559
560 let msg = errors[0].to_string();
561 assert!(msg.contains("ERROR"));
562 assert!(msg.contains("Prozessdaten.vorgangId"));
563 }
564
565 #[test]
566 fn test_validate_invalid_code() {
567 let reqs = sample_requirements();
568 let json = json!({
569 "prozessdaten": {
570 "vorgangId": "ABC123",
571 "transaktionsgrund": "E01"
572 },
573 "marktlokation": {
574 "marktlokationsId": "51234567890",
575 "haushaltskunde": "Z99" },
577 "geschaeftspartner": [
578 { "identifikation": "9900000000003" }
579 ]
580 });
581
582 let errors = validate_pid_json(&json, &reqs);
583 assert_eq!(errors.len(), 1);
584 match &errors[0] {
585 PidValidationError::InvalidCode {
586 entity,
587 field,
588 value,
589 valid_values,
590 } => {
591 assert_eq!(entity, "Marktlokation");
592 assert_eq!(field, "haushaltskunde");
593 assert_eq!(value, "Z99");
594 assert_eq!(valid_values.len(), 2);
595 assert!(valid_values.iter().any(|(c, _)| c == "Z15"));
596 assert!(valid_values.iter().any(|(c, _)| c == "Z18"));
597 }
598 other => panic!("Expected InvalidCode, got: {other:?}"),
599 }
600
601 let msg = errors[0].to_string();
602 assert!(msg.contains("INVALID"));
603 assert!(msg.contains("Z99"));
604 assert!(msg.contains("Z15"));
605 }
606
607 #[test]
608 fn test_validate_array_entity() {
609 let reqs = sample_requirements();
610 let json = json!({
611 "prozessdaten": {
612 "vorgangId": "ABC123",
613 "transaktionsgrund": "E01"
614 },
615 "marktlokation": {
616 "marktlokationsId": "51234567890",
617 "haushaltskunde": "Z15"
618 },
619 "geschaeftspartner": [
620 { "identifikation": "9900000000003" },
621 { } ]
623 });
624
625 let errors = validate_pid_json(&json, &reqs);
626 assert_eq!(errors.len(), 1);
627 match &errors[0] {
628 PidValidationError::MissingField { entity, field, .. } => {
629 assert_eq!(entity, "Geschaeftspartner");
630 assert_eq!(field, "identifikation");
631 }
632 other => panic!("Expected MissingField, got: {other:?}"),
633 }
634 }
635
636 #[test]
637 fn test_to_camel_case() {
638 assert_eq!(to_camel_case("Prozessdaten"), "prozessdaten");
639 assert_eq!(
640 to_camel_case("RuhendeMarktlokation"),
641 "ruhendeMarktlokation"
642 );
643 assert_eq!(to_camel_case("Marktlokation"), "marktlokation");
644 assert_eq!(to_camel_case(""), "");
645 }
646
647 #[test]
648 fn test_snake_to_camel_case() {
649 assert_eq!(snake_to_camel_case("code_codepflege"), "codeCodepflege");
650 assert_eq!(snake_to_camel_case("vorgang_id"), "vorgangId");
651 assert_eq!(snake_to_camel_case("marktlokation"), "marktlokation");
652 assert_eq!(snake_to_camel_case(""), "");
653 assert_eq!(snake_to_camel_case("a_b_c"), "aBC");
654 }
655
656 #[test]
659 fn test_camel_case_fallback_for_snake_case_bo4e_name() {
660 let reqs = PidRequirements {
661 pid: "55077".to_string(),
662 beschreibung: "Test camelCase fallback".to_string(),
663 entities: vec![EntityRequirement {
664 entity: "Zuordnung".to_string(),
665 bo4e_type: "Zuordnung".to_string(),
666 companion_type: None,
667 ahb_status: "Muss".to_string(),
668 is_array: false,
669 map_key: None,
670 fields: vec![
671 FieldRequirement {
672 bo4e_name: "code_codepflege".to_string(),
674 ahb_status: "X".to_string(),
675 is_companion: false,
676 field_type: "data".to_string(),
677 format: None,
678 enum_name: None,
679 valid_codes: vec![],
680 child_group: None,
681 },
682 FieldRequirement {
683 bo4e_name: "codeliste".to_string(),
684 ahb_status: "X".to_string(),
685 is_companion: false,
686 field_type: "data".to_string(),
687 format: None,
688 enum_name: None,
689 valid_codes: vec![],
690 child_group: None,
691 },
692 ],
693 }],
694 };
695
696 let json_camel = json!({
699 "zuordnung": {
700 "codeCodepflege": "DE_BDEW",
701 "codeliste": "6"
702 }
703 });
704
705 let errors = validate_pid_json(&json_camel, &reqs);
706 assert!(
707 errors.is_empty(),
708 "Expected no errors when field is present under camelCase key, got: {errors:?}"
709 );
710
711 let json_snake = json!({
713 "zuordnung": {
714 "code_codepflege": "DE_BDEW",
715 "codeliste": "6"
716 }
717 });
718
719 let errors = validate_pid_json(&json_snake, &reqs);
720 assert!(
721 errors.is_empty(),
722 "Expected no errors when field is present under snake_case key, got: {errors:?}"
723 );
724
725 let json_missing = json!({
727 "zuordnung": {
728 "codeliste": "6"
729 }
730 });
731
732 let errors = validate_pid_json(&json_missing, &reqs);
733 assert_eq!(errors.len(), 1);
734 match &errors[0] {
735 PidValidationError::MissingField { field, .. } => {
736 assert_eq!(field, "code_codepflege");
737 }
738 other => panic!("Expected MissingField, got: {other:?}"),
739 }
740 }
741
742 #[test]
743 fn test_is_unconditionally_required() {
744 assert!(is_unconditionally_required("X"));
745 assert!(is_unconditionally_required("Muss"));
746 assert!(is_unconditionally_required("Soll"));
747 assert!(!is_unconditionally_required("Kann"));
748 assert!(!is_unconditionally_required("[1]"));
749 assert!(!is_unconditionally_required(""));
750 }
751
752 #[test]
753 fn test_validation_report_display() {
754 let errors = vec![
755 PidValidationError::MissingEntity {
756 entity: "Marktlokation".to_string(),
757 ahb_status: "Muss".to_string(),
758 severity: Severity::Error,
759 },
760 PidValidationError::MissingField {
761 entity: "Prozessdaten".to_string(),
762 field: "vorgangId".to_string(),
763 ahb_status: "X".to_string(),
764 rust_type: None,
765 valid_values: vec![],
766 severity: Severity::Error,
767 },
768 ];
769 let report = ValidationReport(errors);
770 assert!(report.has_errors());
771 assert_eq!(report.len(), 2);
772 assert!(!report.is_empty());
773
774 let display = report.to_string();
775 assert!(display.contains("missing entity 'Marktlokation'"));
776 assert!(display.contains("missing Prozessdaten.vorgangId"));
777 }
778
779 #[test]
780 fn test_missing_field_with_type_and_values_display() {
781 let err = PidValidationError::MissingField {
782 entity: "Marktlokation".to_string(),
783 field: "haushaltskunde".to_string(),
784 ahb_status: "Muss".to_string(),
785 rust_type: Some("Haushaltskunde".to_string()),
786 valid_values: vec![
787 ("Z15".to_string(), "Ja".to_string()),
788 ("Z18".to_string(), "Nein".to_string()),
789 ],
790 severity: Severity::Error,
791 };
792 let msg = err.to_string();
793 assert!(msg.contains("type: Haushaltskunde"));
794 assert!(msg.contains("valid: Z15 (Ja), Z18 (Nein)"));
795 }
796
797 #[test]
798 fn test_optional_fields_not_flagged() {
799 let reqs = PidRequirements {
800 pid: "99999".to_string(),
801 beschreibung: "Test".to_string(),
802 entities: vec![EntityRequirement {
803 entity: "Test".to_string(),
804 bo4e_type: "Test".to_string(),
805 companion_type: None,
806 ahb_status: "Kann".to_string(),
807 is_array: false,
808 map_key: None,
809 fields: vec![FieldRequirement {
810 bo4e_name: "optionalField".to_string(),
811 ahb_status: "Kann".to_string(),
812 is_companion: false,
813 field_type: "data".to_string(),
814 format: None,
815 enum_name: None,
816 valid_codes: vec![],
817 child_group: None,
818 }],
819 }],
820 };
821
822 let errors = validate_pid_json(&json!({}), &reqs);
824 assert!(errors.is_empty());
825
826 let errors = validate_pid_json(&json!({ "test": {} }), &reqs);
828 assert!(errors.is_empty());
829 }
830
831 #[test]
834 fn test_nested_dot_path_fields_not_falsely_missing() {
835 let reqs = PidRequirements {
836 pid: "55001".to_string(),
837 beschreibung: "Test nested paths".to_string(),
838 entities: vec![EntityRequirement {
839 entity: "ProduktpaketDaten".to_string(),
840 bo4e_type: "ProduktpaketDaten".to_string(),
841 companion_type: None,
842 ahb_status: "Muss".to_string(),
843 is_array: true,
844 map_key: None,
845 fields: vec![
846 FieldRequirement {
847 bo4e_name: "produktIdentifikation.funktion".to_string(),
848 ahb_status: "X".to_string(),
849 is_companion: false,
850 field_type: "code".to_string(),
851 format: None,
852 enum_name: Some("Produktidentifikation".to_string()),
853 valid_codes: vec![CodeValue {
854 code: "5".to_string(),
855 meaning: "Produktidentifikation".to_string(),
856 enum_name: None,
857 }],
858 child_group: None,
859 },
860 FieldRequirement {
861 bo4e_name: "produktMerkmal.code".to_string(),
862 ahb_status: "X".to_string(),
863 is_companion: false,
864 field_type: "code".to_string(),
865 format: None,
866 enum_name: None,
867 valid_codes: vec![],
868 child_group: None,
869 },
870 ],
871 }],
872 };
873
874 let json = json!({
876 "produktpaketDaten": [{
877 "produktIdentifikation": { "funktion": "5", "id": "9991000002082", "typ": "Z11" },
878 "produktMerkmal": { "code": "ZH9" }
879 }]
880 });
881
882 let errors = validate_pid_json(&json, &reqs);
883 assert!(
884 errors.is_empty(),
885 "Nested dot-path fields should be found (issue #48), got: {errors:?}"
886 );
887 }
888
889 #[test]
890 fn test_nested_dot_path_truly_missing() {
891 let reqs = PidRequirements {
892 pid: "55001".to_string(),
893 beschreibung: "Test nested paths missing".to_string(),
894 entities: vec![EntityRequirement {
895 entity: "ProduktpaketDaten".to_string(),
896 bo4e_type: "ProduktpaketDaten".to_string(),
897 companion_type: None,
898 ahb_status: "Muss".to_string(),
899 is_array: true,
900 map_key: None,
901 fields: vec![FieldRequirement {
902 bo4e_name: "produktIdentifikation.funktion".to_string(),
903 ahb_status: "X".to_string(),
904 is_companion: false,
905 field_type: "data".to_string(),
906 format: None,
907 enum_name: None,
908 valid_codes: vec![],
909 child_group: None,
910 }],
911 }],
912 };
913
914 let json = json!({
916 "produktpaketDaten": [{
917 "produktIdentifikation": { "id": "123" }
918 }]
919 });
920
921 let errors = validate_pid_json(&json, &reqs);
922 assert_eq!(errors.len(), 1, "Should report missing nested field");
923 match &errors[0] {
924 PidValidationError::MissingField { field, .. } => {
925 assert_eq!(field, "produktIdentifikation.funktion");
926 }
927 other => panic!("Expected MissingField, got: {other:?}"),
928 }
929 }
930}