1use std::collections::{BTreeMap, HashMap, HashSet};
4
5use crate::expr::{ConditionExpr, ConditionParser, StatusKind};
6
7use crate::eval::{
8 ConditionEvaluator, ConditionExprEvaluator, ConditionResult, EvaluationContext,
9 ExternalConditionProvider, GroupScope, StatusResolution,
10};
11use mig_types::navigator::GroupNavigator;
12use mig_types::segment::OwnedSegment;
13
14use super::tree::{AhbGroupNode, AhbNode, ValidatedTree};
15
16use super::issue::{Severity, ValidationIssue};
17use super::level::ValidationLevel;
18use super::report::ValidationReport;
19use crate::{IssueKind, UnresolvedConditions};
20
21pub use ahb_types::{AhbCodeRule, AhbFieldRule, AhbWorkflow};
27
28pub struct EdifactValidator<E: ConditionEvaluator> {
61 evaluator: E,
62}
63
64impl<E: ConditionEvaluator> EdifactValidator<E> {
65 pub fn new(evaluator: E) -> Self {
67 Self { evaluator }
68 }
69
70 pub fn validate(
83 &self,
84 segments: &[OwnedSegment],
85 workflow: &AhbWorkflow,
86 external: &dyn ExternalConditionProvider,
87 level: ValidationLevel,
88 ) -> ValidationReport {
89 let mut report = ValidationReport::new(self.evaluator.message_type(), level)
90 .with_format_version(self.evaluator.format_version())
91 .with_pruefidentifikator(&workflow.pruefidentifikator);
92
93 let ctx = EvaluationContext::new(&workflow.pruefidentifikator, external, segments);
94
95 if matches!(level, ValidationLevel::Conditions | ValidationLevel::Full) {
96 self.validate_conditions(workflow, &ctx, &mut report);
97 }
98
99 report
100 }
101
102 pub fn validate_with_navigator(
108 &self,
109 segments: &[OwnedSegment],
110 workflow: &AhbWorkflow,
111 external: &dyn ExternalConditionProvider,
112 level: ValidationLevel,
113 navigator: &dyn GroupNavigator,
114 ) -> ValidationReport {
115 let mut report = ValidationReport::new(self.evaluator.message_type(), level)
116 .with_format_version(self.evaluator.format_version())
117 .with_pruefidentifikator(&workflow.pruefidentifikator);
118
119 let ctx = EvaluationContext::with_navigator(
120 &workflow.pruefidentifikator,
121 external,
122 segments,
123 navigator,
124 );
125
126 if matches!(level, ValidationLevel::Conditions | ValidationLevel::Full) {
127 self.validate_conditions(workflow, &ctx, &mut report);
128 }
129
130 report
131 }
132
133 pub fn validate_tree(
140 &self,
141 validated_tree: &ValidatedTree,
142 segments: &[OwnedSegment],
143 external: &dyn ExternalConditionProvider,
144 level: ValidationLevel,
145 navigator: Option<&dyn GroupNavigator>,
146 ) -> ValidationReport {
147 let mut report = ValidationReport::new(self.evaluator.message_type(), level)
148 .with_format_version(self.evaluator.format_version())
149 .with_pruefidentifikator(validated_tree.pruefidentifikator);
150
151 if !matches!(level, ValidationLevel::Conditions | ValidationLevel::Full) {
152 return report;
153 }
154
155 let ctx = match navigator {
156 Some(nav) => EvaluationContext::with_navigator(
157 validated_tree.pruefidentifikator,
158 external,
159 segments,
160 nav,
161 ),
162 None => EvaluationContext::new(validated_tree.pruefidentifikator, external, segments),
163 };
164
165 let expr_eval = ConditionExprEvaluator::new(&self.evaluator);
166
167 let mut all_nodes: Vec<&AhbNode> = Vec::new();
170 all_nodes.extend(validated_tree.root_fields.iter());
171 for group in &validated_tree.groups {
172 collect_nodes_depth_first(group, &mut all_nodes);
173 }
174
175 let mut tag_migs: HashMap<String, HashSet<&str>> = HashMap::new();
182 for node in &all_nodes {
183 if let Some(ref m) = node.rule.mig_number {
184 tag_migs
185 .entry(extract_segment_id(&node.rule.segment_path))
186 .or_default()
187 .insert(m.as_str());
188 }
189 }
190 for rule in &validated_tree.unmatched_rules {
191 if let Some(ref m) = rule.mig_number {
192 tag_migs
193 .entry(extract_segment_id(&rule.segment_path))
194 .or_default()
195 .insert(m.as_str());
196 }
197 }
198
199 for node in &validated_tree.root_fields {
201 evaluate_node(
202 node,
203 &ctx,
204 &expr_eval,
205 &self.evaluator,
206 validated_tree.ub_definitions,
207 &tag_migs,
208 None,
209 &mut report,
210 );
211 }
212
213 let mut instance_counter: HashMap<&str, usize> = HashMap::new();
218 for group in &validated_tree.groups {
219 let instance_index = *instance_counter
220 .entry(group.group_id)
221 .and_modify(|c| *c += 1)
222 .or_insert(0);
223
224 let path_storage = [(group.group_id, instance_index)];
225 let scoped_ctx = ctx.with_scope(GroupScope {
228 path: &path_storage,
229 });
230
231 walk_group_instance(
232 group,
233 &path_storage,
234 &scoped_ctx,
235 &expr_eval,
236 &self.evaluator,
237 validated_tree.ub_definitions,
238 &tag_migs,
239 instance_index,
240 &mut report,
241 );
242 }
243
244 for field in &validated_tree.unmatched_rules {
250 if should_skip_for_parent_group(field, &expr_eval, &ctx, validated_tree.ub_definitions)
251 {
252 continue;
253 }
254
255 let status =
256 expr_eval.resolve_status(&field.ahb_status, &ctx, validated_tree.ub_definitions);
257
258 if status.result == ConditionResult::True
259 && status.kind == Some(StatusKind::Muss)
260 && !is_field_present(&ctx, field)
261 && !is_group_variant_absent(
262 &ctx,
263 field,
264 is_parent_group_required(
265 field,
266 &expr_eval,
267 &ctx,
268 validated_tree.ub_definitions,
269 ),
270 )
271 {
272 let mut issue = ValidationIssue::new(
273 Severity::Error,
274 IssueKind::MissingRequiredField {
275 field_name: field.name.clone(),
276 },
277 )
278 .with_field_path(&field.segment_path)
279 .with_rule(&field.ahb_status);
280 if let Some(first_code) = field.codes.first() {
281 issue.expected_value = Some(first_code.value.clone());
282 }
283 issue = attach_field_position(issue, field);
284 report.add_issue(issue);
285 }
286 }
287
288 if matches!(level, ValidationLevel::Full) {
292 let mut all_fields: Vec<AhbFieldRule> = Vec::new();
293 for node in &all_nodes {
294 all_fields.push(node.rule.clone());
295 }
296 for rule in &validated_tree.unmatched_rules {
297 all_fields.push((*rule).clone());
298 }
299 let synthetic_workflow = AhbWorkflow {
300 pruefidentifikator: validated_tree.pruefidentifikator.to_string(),
301 description: String::new(),
302 communication_direction: None,
303 fields: all_fields,
304 ub_definitions: validated_tree.ub_definitions.clone(),
305 };
306 self.validate_codes_cross_field(&synthetic_workflow, &ctx, &mut report);
307 self.validate_package_cardinality(&synthetic_workflow, &ctx, &mut report);
308 }
309
310 report
311 }
312
313 fn validate_conditions(
315 &self,
316 workflow: &AhbWorkflow,
317 ctx: &EvaluationContext,
318 report: &mut ValidationReport,
319 ) {
320 let expr_eval = ConditionExprEvaluator::new(&self.evaluator);
321
322 for field in &workflow.fields {
323 if should_skip_for_parent_group(field, &expr_eval, ctx, &workflow.ub_definitions) {
325 continue;
326 }
327
328 let StatusResolution {
331 result: condition_result,
332 kind,
333 unknown_ids,
334 } = expr_eval.resolve_status(&field.ahb_status, ctx, &workflow.ub_definitions);
335 let mandatory = kind == Some(StatusKind::Muss);
336
337 match condition_result {
338 ConditionResult::True => {
339 if mandatory
341 && !is_field_present(ctx, field)
342 && !is_group_variant_absent(
343 ctx,
344 field,
345 is_parent_group_required(
346 field,
347 &expr_eval,
348 ctx,
349 &workflow.ub_definitions,
350 ),
351 )
352 {
353 let mut issue = ValidationIssue::new(
354 Severity::Error,
355 IssueKind::MissingRequiredField {
356 field_name: field.name.clone(),
357 },
358 )
359 .with_field_path(&field.segment_path)
360 .with_rule(&field.ahb_status);
361 if let Some(first_code) = field.codes.first() {
365 issue.expected_value = Some(first_code.value.clone());
366 }
367 report.add_issue(issue);
368 }
369 }
370 ConditionResult::False => {
371 if mandatory && is_field_present(ctx, field) {
377 report.add_issue(
378 ValidationIssue::new(
379 Severity::Error,
380 IssueKind::FieldConditionNotSatisfied {
381 field_name: field.name.clone(),
382 },
383 )
384 .with_field_path(&field.segment_path)
385 .with_rule(&field.ahb_status),
386 );
387 }
388 }
389 ConditionResult::Unknown => {
390 let mut external_ids = Vec::new();
395 let mut undetermined_ids = Vec::new();
396 let mut missing_ids = Vec::new();
397 for id in unknown_ids {
398 if self.evaluator.is_external(id) {
399 external_ids.push(id);
400 } else if self.evaluator.is_known(id) {
401 undetermined_ids.push(id);
402 } else {
403 missing_ids.push(id);
404 }
405 }
406
407 report.add_issue(
408 ValidationIssue::new(
409 Severity::Info,
410 IssueKind::ConditionUnknown {
411 field_name: field.name.clone(),
412 unresolved: UnresolvedConditions {
413 external: external_ids,
414 undetermined: undetermined_ids,
415 missing: missing_ids,
416 },
417 },
418 )
419 .with_field_path(&field.segment_path)
420 .with_rule(&field.ahb_status),
421 );
422 }
423 }
424 }
425
426 self.validate_codes_cross_field(workflow, ctx, report);
431
432 self.validate_package_cardinality(workflow, ctx, report);
435 }
436
437 fn validate_package_cardinality(
444 &self,
445 workflow: &AhbWorkflow,
446 ctx: &EvaluationContext,
447 report: &mut ValidationReport,
448 ) {
449 struct PackageGroup {
452 min: u32,
453 max: u32,
454 code_values: Vec<String>,
455 element_index: usize,
456 component_index: usize,
457 }
458
459 let mut groups: HashMap<(String, Option<String>, u32), PackageGroup> = HashMap::new();
468
469 let expr_eval = ConditionExprEvaluator::new(&self.evaluator);
470
471 for field in &workflow.fields {
472 let el_idx = field.element_index.unwrap_or(0);
473 let comp_idx = field.component_index.unwrap_or(0);
474
475 if should_skip_for_parent_group(field, &expr_eval, ctx, &workflow.ub_definitions) {
477 continue;
478 }
479
480 for code in &field.codes {
481 if let Ok(Some(expr)) = ConditionParser::parse(&code.ahb_status) {
483 let mut packages = Vec::new();
485 collect_packages(&expr, &mut packages);
486
487 for (pkg_id, pkg_min, pkg_max) in packages {
488 let key = (field.segment_path.clone(), field.mig_number.clone(), pkg_id);
489 let group = groups.entry(key).or_insert_with(|| PackageGroup {
490 min: pkg_min,
491 max: pkg_max,
492 code_values: Vec::new(),
493 element_index: el_idx,
494 component_index: comp_idx,
495 });
496 group.min = group.min.max(pkg_min);
499 group.max = group.max.min(pkg_max);
500 group.code_values.push(code.value.clone());
501 }
502 }
503 }
504 }
505
506 for ((seg_path, mig_number, pkg_id), group) in &groups {
514 let segment_id = extract_segment_id(seg_path);
515
516 let mut unique_codes: Vec<&str> =
520 group.code_values.iter().map(|s| s.as_str()).collect();
521 unique_codes.sort_unstable();
522 unique_codes.dedup();
523 let code_set: HashSet<&str> = unique_codes.iter().copied().collect();
524
525 let group_path_str = extract_group_path_key(seg_path);
526 let group_path: Vec<&str> = if group_path_str.is_empty() {
527 Vec::new()
528 } else {
529 group_path_str.split('/').collect()
530 };
531
532 let min = group.min as usize;
533 let max = group.max as usize;
534
535 let per_instance_counts: Option<Vec<usize>> =
536 match (ctx.navigator, group_path.is_empty()) {
537 (Some(nav), false) => {
538 let instance_count = nav.group_instance_count(&group_path);
539 if instance_count == 0 {
540 None
541 } else {
542 Some(
550 (0..instance_count)
551 .filter(|i| match mig_number.as_deref() {
552 Some(m) => nav.instance_has_mig_number(&group_path, *i, m),
553 None => true,
554 })
555 .map(|i| {
556 nav.find_segments_in_group(&segment_id, &group_path, i)
557 .iter()
558 .filter_map(|seg| {
559 seg.elements
560 .get(group.element_index)
561 .and_then(|e| e.get(group.component_index))
562 .filter(|v| !v.is_empty())
563 .cloned()
564 })
565 .filter(|v| code_set.contains(v.as_str()))
566 .count()
567 })
568 .collect(),
569 )
570 }
571 }
572 _ => None,
573 };
574
575 let counts: Vec<usize> = per_instance_counts.unwrap_or_else(|| {
576 let segments = ctx.find_segments(&segment_id);
577 let count = segments
578 .iter()
579 .filter_map(|seg| {
580 seg.elements
581 .get(group.element_index)
582 .and_then(|e| e.get(group.component_index))
583 .filter(|v| !v.is_empty())
584 .map(|s| s.as_str())
585 })
586 .filter(|v| code_set.contains(v))
587 .count();
588 vec![count]
589 });
590
591 let mut reported_counts: HashSet<usize> = HashSet::new();
595 for present_count in counts {
596 if (present_count < min || present_count > max)
597 && reported_counts.insert(present_count)
598 {
599 report.add_issue(
600 ValidationIssue::new(
601 Severity::Error,
602 IssueKind::PackageCardinality {
603 package_id: pkg_id.to_string(),
604 present: present_count,
605 min,
606 max,
607 codes: unique_codes.iter().map(|s| s.to_string()).collect(),
608 },
609 )
610 .with_field_path(seg_path)
611 .with_expected(format!("{}..{}", group.min, group.max))
612 .with_actual(present_count.to_string()),
613 );
614 }
615 }
616 }
617 }
618
619 fn validate_codes_cross_field(
631 &self,
632 workflow: &AhbWorkflow,
633 ctx: &EvaluationContext,
634 report: &mut ValidationReport,
635 ) {
636 if ctx.navigator.is_some() {
637 self.validate_codes_group_scoped(workflow, ctx, report);
638 } else {
639 self.validate_codes_tag_scoped(workflow, ctx, report);
640 }
641 }
642
643 fn validate_codes_group_scoped(
646 &self,
647 workflow: &AhbWorkflow,
648 ctx: &EvaluationContext,
649 report: &mut ValidationReport,
650 ) {
651 let by_loc = partition_codes_by_mig(workflow);
652 let known_qualifiers = global_qualifiers_by_tag(&by_loc);
653 let nav = ctx.navigator.unwrap();
654
655 for ((group_key, tag), migs) in &by_loc {
656 let field_path = if group_key.is_empty() {
657 format!("{tag}/qualifier")
658 } else {
659 format!("{group_key}/{tag}/qualifier")
660 };
661
662 let group_path: Vec<&str> = if group_key.is_empty() {
663 Vec::new()
664 } else {
665 group_key.split('/').collect()
666 };
667
668 let tag_qualifiers = known_qualifiers.get(tag);
669
670 if group_path.is_empty() {
671 Self::validate_segments_per_mig(
672 &ctx.find_segments(tag),
673 migs,
674 tag_qualifiers,
675 tag,
676 &field_path,
677 report,
678 );
679 } else {
680 let instance_count = nav.group_instance_count(&group_path);
681 for i in 0..instance_count {
682 let owned = nav.find_segments_in_group(tag, &group_path, i);
683 let refs: Vec<&OwnedSegment> = owned.iter().collect();
684 Self::validate_segments_per_mig(
685 &refs,
686 migs,
687 tag_qualifiers,
688 tag,
689 &field_path,
690 report,
691 );
692 }
693 }
694 }
695 }
696
697 fn validate_codes_tag_scoped(
701 &self,
702 workflow: &AhbWorkflow,
703 ctx: &EvaluationContext,
704 report: &mut ValidationReport,
705 ) {
706 let by_loc = partition_codes_by_mig(workflow);
707 let known_qualifiers = global_qualifiers_by_tag(&by_loc);
708 let mut by_tag: HashMap<String, HashMap<Option<String>, MigCodeBucket>> = HashMap::new();
710 for ((_group_key, tag), migs) in by_loc {
711 let merged = by_tag.entry(tag).or_default();
712 for (mig_key, bucket) in migs {
713 let entry = merged.entry(mig_key).or_default();
714 if entry.qualifier_position.is_none() {
715 entry.qualifier_position = bucket.qualifier_position;
716 }
717 if entry.qualifier_position == bucket.qualifier_position {
718 entry.qualifier_values.extend(&bucket.qualifier_values);
719 }
720 for (pos, codes) in bucket.codes {
721 entry.codes.entry(pos).or_default().extend(codes);
722 }
723 }
724 }
725
726 for (tag, migs) in &by_tag {
727 let field_path = format!("{tag}/qualifier");
728 Self::validate_segments_per_mig(
729 &ctx.find_segments(tag),
730 migs,
731 known_qualifiers.get(tag),
732 tag,
733 &field_path,
734 report,
735 );
736 }
737 }
738
739 fn validate_segments_per_mig(
748 segments: &[&OwnedSegment],
749 migs: &HashMap<Option<String>, MigCodeBucket>,
750 tag_qualifiers: Option<&HashMap<(usize, usize), HashSet<String>>>,
751 tag: &str,
752 field_path: &str,
753 report: &mut ValidationReport,
754 ) {
755 for seg in segments {
756 match match_segment_to_mig(seg, migs) {
757 Some(bucket) => {
758 for ((el, c), allowed) in &bucket.codes {
759 if allowed.is_empty() {
760 continue;
761 }
762 Self::check_segments_against_codes(
763 vec![*seg],
764 allowed,
765 tag,
766 *el,
767 *c,
768 field_path,
769 report,
770 );
771 }
772 }
773 None => {
774 let Some(qualifiers) = tag_qualifiers else {
775 continue;
776 };
777 for ((el, c), allowed) in qualifiers {
782 let Some(actual) = seg
783 .elements
784 .get(*el)
785 .and_then(|e| e.get(*c))
786 .filter(|v| !v.is_empty())
787 .map(|s| s.as_str())
788 else {
789 continue;
790 };
791 if allowed.iter().any(|v| v == actual) {
792 break;
794 }
795 let bucket_values: HashSet<&str> = migs
798 .values()
799 .filter(|b| b.qualifier_position == Some((*el, *c)))
800 .flat_map(|b| b.qualifier_values.iter().copied())
801 .collect();
802 if !bucket_values.is_empty() {
803 Self::check_segments_against_codes(
804 vec![*seg],
805 &bucket_values,
806 tag,
807 *el,
808 *c,
809 field_path,
810 report,
811 );
812 break;
813 }
814 }
815 }
816 }
817 }
818 }
819
820 fn check_segments_against_codes(
822 segments: Vec<&OwnedSegment>,
823 allowed_codes: &HashSet<&str>,
824 _tag: &str,
825 el_idx: usize,
826 comp_idx: usize,
827 field_path: &str,
828 report: &mut ValidationReport,
829 ) {
830 for segment in segments {
831 if let Some(code_value) = segment
832 .elements
833 .get(el_idx)
834 .and_then(|e| e.get(comp_idx))
835 .filter(|v| !v.is_empty())
836 {
837 if !allowed_codes.contains(code_value.as_str()) {
838 let mut sorted_codes: Vec<&str> = allowed_codes.iter().copied().collect();
839 sorted_codes.sort_unstable();
840 let kind = IssueKind::CodeNotAllowedForPid {
841 value: code_value.to_string(),
842 allowed: sorted_codes.iter().map(|s| (*s).to_string()).collect(),
843 };
844 report.add_issue(
845 ValidationIssue::new(Severity::Error, kind)
846 .with_field_path(field_path)
847 .with_actual(code_value)
848 .with_expected(sorted_codes.join(", ")),
849 );
850 }
851 }
852 }
853 }
854}
855
856fn governing_status<'a>(field: &'a AhbFieldRule, value: Option<&str>) -> &'a str {
866 let derived_from_codes = field
867 .codes
868 .first()
869 .is_some_and(|c| c.ahb_status == field.ahb_status);
870 if !derived_from_codes {
871 return &field.ahb_status;
872 }
873 value
874 .and_then(|v| field.codes.iter().find(|c| c.value == v))
875 .map_or(&field.ahb_status, |c| &c.ahb_status)
876}
877
878fn should_skip_for_parent_group<E: ConditionEvaluator>(
884 field: &AhbFieldRule,
885 expr_eval: &ConditionExprEvaluator<E>,
886 ctx: &EvaluationContext,
887 ub_definitions: &BTreeMap<String, ConditionExpr>,
888) -> bool {
889 if let Some(ref group_status) = field.parent_group_ahb_status {
890 if group_status.contains('[') {
891 let result = expr_eval.evaluate_status_with_ub(group_status, ctx, ub_definitions);
892 return matches!(result, ConditionResult::False | ConditionResult::Unknown);
893 }
894 }
895 false
896}
897
898fn is_parent_group_required<E: ConditionEvaluator>(
905 field: &AhbFieldRule,
906 expr_eval: &ConditionExprEvaluator<E>,
907 ctx: &EvaluationContext,
908 ub_definitions: &BTreeMap<String, ConditionExpr>,
909) -> bool {
910 field
911 .parent_group_ahb_status
912 .as_deref()
913 .is_some_and(|status| {
914 let status = expr_eval.resolve_status(status, ctx, ub_definitions);
915 status.result == ConditionResult::True && status.kind == Some(StatusKind::Muss)
916 })
917}
918
919fn is_field_present(ctx: &EvaluationContext, field: &AhbFieldRule) -> bool {
928 let segment_id = extract_segment_id(&field.segment_path);
929
930 if !field.codes.is_empty() {
936 if let (Some(el_idx), Some(comp_idx)) = (field.element_index, field.component_index) {
937 let required_codes: Vec<&str> = field.codes.iter().map(|c| c.value.as_str()).collect();
938 let matching = ctx.find_segments(&segment_id);
939 return matching.iter().any(|seg| {
940 seg.elements
941 .get(el_idx)
942 .and_then(|e| e.get(comp_idx))
943 .is_some_and(|v| required_codes.contains(&v.as_str()))
944 });
945 }
946 if is_qualifier_field(&field.segment_path) {
949 let required_codes: Vec<&str> = field.codes.iter().map(|c| c.value.as_str()).collect();
950 let el_idx = field.element_index.unwrap_or(0);
951 let comp_idx = field.component_index.unwrap_or(0);
952 let matching = ctx.find_segments(&segment_id);
953 return matching.iter().any(|seg| {
954 seg.elements
955 .get(el_idx)
956 .and_then(|e| e.get(comp_idx))
957 .is_some_and(|v| required_codes.contains(&v.as_str()))
958 });
959 }
960 }
961
962 ctx.has_segment(&segment_id)
963}
964
965fn is_group_variant_absent(
980 ctx: &EvaluationContext,
981 field: &AhbFieldRule,
982 is_group_mandatory: bool,
983) -> bool {
984 let group_path: Vec<&str> = field
985 .segment_path
986 .split('/')
987 .take_while(|p| p.starts_with("SG"))
988 .collect();
989
990 if group_path.is_empty() {
991 return false;
992 }
993
994 let nav = match ctx.navigator {
995 Some(nav) => nav,
996 None => return false,
997 };
998
999 let instance_count = nav.group_instance_count(&group_path);
1000
1001 if instance_count == 0 {
1006 if !is_group_mandatory {
1007 return true;
1008 }
1009 return false;
1011 }
1012
1013 if field.parent_group_ahb_status.is_some() && !is_group_mandatory {
1018 if !field.codes.is_empty() && is_qualifier_field(&field.segment_path) {
1020 let segment_id = extract_segment_id(&field.segment_path);
1021 let required_codes: Vec<&str> = field.codes.iter().map(|c| c.value.as_str()).collect();
1022
1023 let any_instance_has_qualifier = (0..instance_count).any(|i| {
1024 nav.find_segments_in_group(&segment_id, &group_path, i)
1025 .iter()
1026 .any(|seg| {
1027 seg.elements
1028 .first()
1029 .and_then(|e| e.first())
1030 .is_some_and(|v| required_codes.contains(&v.as_str()))
1031 })
1032 });
1033
1034 if !any_instance_has_qualifier {
1035 return true; }
1037 }
1038 }
1039
1040 let segment_id = extract_segment_id(&field.segment_path);
1049 let segment_absent_from_all = (0..instance_count).all(|i| {
1050 nav.find_segments_in_group(&segment_id, &group_path, i)
1051 .is_empty()
1052 });
1053 if segment_absent_from_all {
1054 let group_has_other_segments =
1055 (0..instance_count).any(|i| nav.has_any_segment_in_group(&group_path, i));
1056 if group_has_other_segments {
1057 return true;
1058 }
1059 }
1060
1061 false
1062}
1063
1064fn collect_nodes_depth_first<'a, 'b>(group: &'b AhbGroupNode<'a>, out: &mut Vec<&'b AhbNode<'a>>) {
1066 out.extend(group.fields.iter());
1067 for child in &group.children {
1068 collect_nodes_depth_first(child, out);
1069 }
1070}
1071
1072#[allow(clippy::too_many_arguments)]
1076fn evaluate_node<E: ConditionEvaluator>(
1077 node: &AhbNode,
1078 ctx: &EvaluationContext,
1079 expr_eval: &ConditionExprEvaluator<E>,
1080 evaluator: &E,
1081 ub_definitions: &BTreeMap<String, ConditionExpr>,
1082 tag_migs: &HashMap<String, HashSet<&str>>,
1083 instance_index: Option<usize>,
1084 report: &mut ValidationReport,
1085) {
1086 let field = node.rule;
1087
1088 let node_ctx = ctx.with_resolved(node.value, node.segment_elements);
1090
1091 if should_skip_for_parent_group(field, expr_eval, ctx, ub_definitions) {
1093 return;
1094 }
1095
1096 let status = governing_status(field, node.value);
1099 let StatusResolution {
1100 result: condition_result,
1101 kind,
1102 unknown_ids,
1103 } = expr_eval.resolve_status(status, &node_ctx, ub_definitions);
1104 let mandatory = kind == Some(StatusKind::Muss);
1105
1106 match condition_result {
1107 ConditionResult::True => {
1108 if mandatory && node.value.is_none() {
1117 let tag = extract_segment_id(&field.segment_path);
1118 let single_variant_present = node.segment_elements.is_none()
1119 && tag_migs.get(&tag).is_some_and(|ms| ms.len() == 1)
1120 && is_field_present(ctx, field);
1121 let segment_optional_and_absent = node.segment_elements.is_none()
1126 && field
1127 .segment_ahb_status
1128 .as_deref()
1129 .is_some_and(is_optional_segment_status);
1130 if !single_variant_present && !segment_optional_and_absent {
1131 let mut issue = ValidationIssue::new(
1132 Severity::Error,
1133 IssueKind::MissingRequiredField {
1134 field_name: field.name.clone(),
1135 },
1136 )
1137 .with_field_path(&field.segment_path)
1138 .with_rule(&field.ahb_status);
1139 if let Some(first_code) = field.codes.first() {
1140 issue.expected_value = Some(first_code.value.clone());
1141 }
1142 if let Some(idx) = instance_index {
1143 issue = issue.with_instance_index(idx);
1144 }
1145 if let Some(num) = node.matched_segment_number {
1146 issue = issue.with_position(crate::SegmentPosition {
1147 segment_number: num,
1148 byte_offset: 0,
1149 message_number: 1,
1150 });
1151 }
1152 issue = attach_field_position(issue, field);
1153 report.add_issue(issue);
1154 }
1155 }
1156 }
1157 ConditionResult::False => {
1158 if mandatory && node.value.is_some() {
1161 let mut issue = ValidationIssue::new(
1162 Severity::Error,
1163 IssueKind::FieldConditionNotSatisfied {
1164 field_name: field.name.clone(),
1165 },
1166 )
1167 .with_field_path(&field.segment_path)
1168 .with_rule(&field.ahb_status);
1169 if let Some(idx) = instance_index {
1170 issue = issue.with_instance_index(idx);
1171 }
1172 if let Some(num) = node.matched_segment_number {
1173 issue = issue.with_position(crate::SegmentPosition {
1174 segment_number: num,
1175 byte_offset: 0,
1176 message_number: 1,
1177 });
1178 }
1179 issue = attach_field_position(issue, field);
1180 report.add_issue(issue);
1181 }
1182 }
1183 ConditionResult::Unknown => {
1184 let mut external_ids = Vec::new();
1186 let mut undetermined_ids = Vec::new();
1187 let mut missing_ids = Vec::new();
1188 for id in unknown_ids {
1189 if evaluator.is_external(id) {
1190 external_ids.push(id);
1191 } else if evaluator.is_known(id) {
1192 undetermined_ids.push(id);
1193 } else {
1194 missing_ids.push(id);
1195 }
1196 }
1197
1198 let mut issue = ValidationIssue::new(
1199 Severity::Info,
1200 IssueKind::ConditionUnknown {
1201 field_name: field.name.clone(),
1202 unresolved: UnresolvedConditions {
1203 external: external_ids,
1204 undetermined: undetermined_ids,
1205 missing: missing_ids,
1206 },
1207 },
1208 )
1209 .with_field_path(&field.segment_path)
1210 .with_rule(&field.ahb_status);
1211 if let Some(idx) = instance_index {
1212 issue = issue.with_instance_index(idx);
1213 }
1214 if let Some(num) = node.matched_segment_number {
1215 issue = issue.with_position(crate::SegmentPosition {
1216 segment_number: num,
1217 byte_offset: 0,
1218 message_number: 1,
1219 });
1220 }
1221 issue = attach_field_position(issue, field);
1222 report.add_issue(issue);
1223 }
1224 }
1225}
1226
1227fn attach_field_position(issue: ValidationIssue, field: &AhbFieldRule) -> ValidationIssue {
1232 match field.element_index {
1233 Some(el) => {
1234 let element_pos = (el as u32) + 2;
1235 let component_pos = field.component_index.map(|c| (c as u32) + 1);
1236 issue.with_field_position(element_pos, component_pos)
1237 }
1238 None => issue,
1239 }
1240}
1241
1242#[allow(clippy::too_many_arguments)]
1251fn walk_group_instance<E: ConditionEvaluator>(
1252 group: &AhbGroupNode,
1253 path: &[(&str, usize)],
1254 scoped_ctx: &EvaluationContext,
1255 expr_eval: &ConditionExprEvaluator<E>,
1256 evaluator: &E,
1257 ub_definitions: &BTreeMap<String, ConditionExpr>,
1258 tag_migs: &HashMap<String, HashSet<&str>>,
1259 instance_index: usize,
1260 report: &mut ValidationReport,
1261) {
1262 for node in &group.fields {
1263 evaluate_node(
1264 node,
1265 scoped_ctx,
1266 expr_eval,
1267 evaluator,
1268 ub_definitions,
1269 tag_migs,
1270 Some(instance_index),
1271 report,
1272 );
1273 }
1274 for child in &group.children {
1275 let mut child_path = path.to_vec();
1276 child_path.push((child.group_id, child.instance_index));
1277 let child_ctx = scoped_ctx.with_scope(GroupScope { path: &child_path });
1278 walk_group_instance(
1279 child,
1280 &child_path,
1281 &child_ctx,
1282 expr_eval,
1283 evaluator,
1284 ub_definitions,
1285 tag_migs,
1286 instance_index,
1287 report,
1288 );
1289 }
1290}
1291
1292fn collect_packages(expr: &ConditionExpr, out: &mut Vec<(u32, u32, u32)>) {
1294 match expr {
1295 ConditionExpr::Package { id, min, max } => {
1296 out.push((*id, *min, *max));
1297 }
1298 ConditionExpr::And(exprs) | ConditionExpr::Or(exprs) => {
1299 for e in exprs {
1300 collect_packages(e, out);
1301 }
1302 }
1303 ConditionExpr::Xor(left, right) => {
1304 collect_packages(left, out);
1305 collect_packages(right, out);
1306 }
1307 ConditionExpr::Not(inner) => {
1308 collect_packages(inner, out);
1309 }
1310 ConditionExpr::Ref(_) => {}
1311 }
1312}
1313
1314fn is_optional_segment_status(status: &str) -> bool {
1324 let trimmed = status.trim();
1325 trimmed.starts_with("Kann") || trimmed.starts_with("Soll")
1326}
1327
1328fn is_qualifier_field(path: &str) -> bool {
1338 let parts: Vec<&str> = path.split('/').filter(|p| !p.starts_with("SG")).collect();
1339 matches!(parts.len(), 2 | 3)
1341}
1342
1343#[derive(Default)]
1351struct MigCodeBucket<'a> {
1352 qualifier_position: Option<(usize, usize)>,
1355 qualifier_values: HashSet<&'a str>,
1358 codes: HashMap<(usize, usize), HashSet<&'a str>>,
1361}
1362
1363fn partition_codes_by_mig(
1368 workflow: &AhbWorkflow,
1369) -> HashMap<(String, String), HashMap<Option<String>, MigCodeBucket<'_>>> {
1370 let mut out: HashMap<(String, String), HashMap<Option<String>, MigCodeBucket>> = HashMap::new();
1371 for field in &workflow.fields {
1372 if field.codes.is_empty() || !is_qualifier_field(&field.segment_path) {
1373 continue;
1374 }
1375 let tag = extract_segment_id(&field.segment_path);
1376 let group_key = extract_group_path_key(&field.segment_path);
1377 let mig = field.mig_number.clone();
1378 let el = field.element_index.unwrap_or(0);
1379 let c = field.component_index.unwrap_or(0);
1380
1381 let bucket = out
1382 .entry((group_key, tag))
1383 .or_default()
1384 .entry(mig)
1385 .or_default();
1386
1387 let required: Vec<&str> = field
1394 .codes
1395 .iter()
1396 .filter(|code| code.ahb_status.starts_with('X') || code.ahb_status.starts_with("Muss"))
1397 .map(|code| code.value.as_str())
1398 .collect();
1399
1400 if !required.is_empty() {
1407 if bucket.qualifier_position.is_none() {
1408 bucket.qualifier_position = Some((el, c));
1409 }
1410 if bucket.qualifier_position == Some((el, c)) {
1411 bucket.qualifier_values.extend(required.iter().copied());
1412 }
1413 }
1414
1415 for v in required {
1416 bucket.codes.entry((el, c)).or_default().insert(v);
1417 }
1418 }
1419 out
1420}
1421
1422fn global_qualifiers_by_tag(
1428 by_loc: &HashMap<(String, String), HashMap<Option<String>, MigCodeBucket<'_>>>,
1429) -> HashMap<String, HashMap<(usize, usize), HashSet<String>>> {
1430 let mut out: HashMap<String, HashMap<(usize, usize), HashSet<String>>> = HashMap::new();
1431 for ((_group, tag), migs) in by_loc {
1432 let tag_entry = out.entry(tag.clone()).or_default();
1433 for bucket in migs.values() {
1434 if let Some(pos) = bucket.qualifier_position {
1435 tag_entry
1436 .entry(pos)
1437 .or_default()
1438 .extend(bucket.qualifier_values.iter().map(|s| s.to_string()));
1439 }
1440 }
1441 }
1442 out
1443}
1444
1445fn match_segment_to_mig<'a, 'b>(
1456 seg: &OwnedSegment,
1457 migs: &'a HashMap<Option<String>, MigCodeBucket<'b>>,
1458) -> Option<&'a MigCodeBucket<'b>> {
1459 let actual_at = |el: usize, c: usize| -> &str {
1460 seg.elements
1461 .get(el)
1462 .and_then(|e| e.get(c))
1463 .map(|s| s.as_str())
1464 .unwrap_or("")
1465 };
1466
1467 let mut best: Option<&MigCodeBucket> = None;
1468 let mut best_matches = 0usize;
1469
1470 for bucket in migs.values() {
1471 let Some((el, c)) = bucket.qualifier_position else {
1472 continue;
1473 };
1474 if !bucket.qualifier_values.contains(actual_at(el, c)) {
1475 continue;
1476 }
1477 let extra_matches = bucket
1478 .codes
1479 .iter()
1480 .filter(|(pos, _)| **pos != (el, c))
1481 .filter(|((e, k), allowed)| {
1482 let v = actual_at(*e, *k);
1483 !v.is_empty() && allowed.contains(v)
1484 })
1485 .count();
1486 if best.is_none() || extra_matches > best_matches {
1487 best = Some(bucket);
1488 best_matches = extra_matches;
1489 }
1490 }
1491 best
1492}
1493
1494fn extract_group_path_key(path: &str) -> String {
1499 let sg_parts: Vec<&str> = path
1500 .split('/')
1501 .take_while(|p| p.starts_with("SG"))
1502 .collect();
1503 sg_parts.join("/")
1504}
1505
1506fn extract_segment_id(path: &str) -> String {
1508 for part in path.split('/') {
1509 if part.starts_with("SG") || part.starts_with("C_") || part.starts_with("D_") {
1511 continue;
1512 }
1513 if part.len() >= 3
1515 && part
1516 .chars()
1517 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
1518 {
1519 return part.to_string();
1520 }
1521 }
1522 path.split('/').next_back().unwrap_or(path).to_string()
1524}
1525
1526pub fn validate_unt_segment_count(segments: &[OwnedSegment]) -> Option<ValidationIssue> {
1538 let unh_count = segments.iter().filter(|s| s.id == "UNH").count();
1540 if unh_count > 1 {
1541 return Some(ValidationIssue::new(
1542 Severity::Error,
1543 IssueKind::UntCountNotVerifiable { unh_count },
1544 ));
1545 }
1546
1547 let unt = segments.iter().rfind(|s| s.id == "UNT")?;
1549 let declared: usize = unt.get_element(0).parse().ok()?;
1550
1551 let actual = segments
1553 .iter()
1554 .filter(|s| s.id != "UNA" && s.id != "UNB" && s.id != "UNZ")
1555 .count();
1556
1557 if declared != actual {
1558 Some(
1559 ValidationIssue::new(
1560 Severity::Error,
1561 IssueKind::UntSegmentCountMismatch { declared, actual },
1562 )
1563 .with_field_path("UNT/0074")
1564 .with_expected(actual.to_string())
1565 .with_actual(declared.to_string()),
1566 )
1567 } else {
1568 None
1569 }
1570}
1571
1572#[cfg(test)]
1573mod tests {
1574 use super::super::codes::ErrorCodes;
1575 use super::super::issue::ValidationCategory;
1576 use super::*;
1577 use crate::display::{EdifactView, IssueNarrator, IssueView, TechnicalNarrator};
1578 use crate::eval::{ConditionResult as CR, NoOpExternalProvider};
1579 use std::collections::HashMap;
1580
1581 fn narrate(issue: &ValidationIssue) -> String {
1584 TechnicalNarrator.describe(issue, EdifactView.location(issue).as_deref())
1585 }
1586
1587 struct MockEvaluator {
1589 results: HashMap<u32, CR>,
1590 }
1591
1592 impl MockEvaluator {
1593 fn new(results: Vec<(u32, CR)>) -> Self {
1594 Self {
1595 results: results.into_iter().collect(),
1596 }
1597 }
1598
1599 fn all_true(ids: &[u32]) -> Self {
1600 Self::new(ids.iter().map(|&id| (id, CR::True)).collect())
1601 }
1602 }
1603
1604 impl ConditionEvaluator for MockEvaluator {
1605 fn evaluate(&self, condition: u32, _ctx: &EvaluationContext) -> CR {
1606 self.results.get(&condition).copied().unwrap_or(CR::Unknown)
1607 }
1608 fn is_external(&self, _condition: u32) -> bool {
1609 false
1610 }
1611 fn message_type(&self) -> &str {
1612 "UTILMD"
1613 }
1614 fn format_version(&self) -> &str {
1615 "FV2510"
1616 }
1617 }
1618
1619 #[test]
1622 fn a_present_code_is_judged_by_its_own_status() {
1623 let code = |value: &str, status: &str| AhbCodeRule {
1625 value: value.to_string(),
1626 description: String::new(),
1627 ahb_status: status.to_string(),
1628 };
1629 let field = AhbFieldRule {
1630 segment_path: "SG4/STS/C556/9013".to_string(),
1631 ahb_status: "X [192]".to_string(),
1632 codes: vec![code("E01", "X [192]"), code("E03", "X")],
1633 ..Default::default()
1634 };
1635 assert_eq!(governing_status(&field, Some("E03")), "X");
1636 assert_eq!(governing_status(&field, Some("E01")), "X [192]");
1637 assert_eq!(governing_status(&field, None), "X [192]");
1638 assert_eq!(governing_status(&field, Some("ZZZ")), "X [192]");
1639
1640 let own = AhbFieldRule {
1642 ahb_status: "X [931]".to_string(),
1643 codes: vec![code("303", "X")],
1644 ..Default::default()
1645 };
1646 assert_eq!(governing_status(&own, Some("303")), "X [931]");
1647 }
1648
1649 #[test]
1650 fn test_extract_segment_id_simple() {
1651 assert_eq!(extract_segment_id("NAD"), "NAD");
1652 }
1653
1654 #[test]
1655 fn test_extract_segment_id_with_sg_prefix() {
1656 assert_eq!(extract_segment_id("SG2/NAD/C082/3039"), "NAD");
1657 }
1658
1659 #[test]
1660 fn test_extract_segment_id_nested_sg() {
1661 assert_eq!(extract_segment_id("SG4/SG8/SEQ/C286/6350"), "SEQ");
1662 }
1663
1664 #[test]
1667 fn test_validate_missing_mandatory_field() {
1668 let evaluator = MockEvaluator::all_true(&[182, 152]);
1669 let validator = EdifactValidator::new(evaluator);
1670 let external = NoOpExternalProvider;
1671
1672 let workflow = AhbWorkflow {
1673 pruefidentifikator: "11001".to_string(),
1674 description: "Test".to_string(),
1675 communication_direction: None,
1676 fields: vec![AhbFieldRule {
1677 segment_path: "SG2/NAD/C082/3039".to_string(),
1678 name: "MP-ID des MSB".to_string(),
1679 ahb_status: "Muss [182] ∧ [152]".to_string(),
1680 codes: vec![],
1681 parent_group_ahb_status: None,
1682 segment_ahb_status: None,
1683 ..Default::default()
1684 }],
1685 ub_definitions: BTreeMap::new(),
1686 };
1687
1688 let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
1690
1691 assert!(!report.is_valid());
1693 let errors: Vec<_> = report.errors().collect();
1694 assert_eq!(errors.len(), 1);
1695 assert_eq!(errors[0].code(), ErrorCodes::MISSING_REQUIRED_FIELD);
1696 assert!(narrate(errors[0]).contains("MP-ID des MSB"));
1697 }
1698
1699 #[test]
1700 fn test_validate_condition_false_no_error() {
1701 let evaluator = MockEvaluator::new(vec![(182, CR::True), (152, CR::False)]);
1703 let validator = EdifactValidator::new(evaluator);
1704 let external = NoOpExternalProvider;
1705
1706 let workflow = AhbWorkflow {
1707 pruefidentifikator: "11001".to_string(),
1708 description: "Test".to_string(),
1709 communication_direction: None,
1710 fields: vec![AhbFieldRule {
1711 segment_path: "NAD".to_string(),
1712 name: "Partnerrolle".to_string(),
1713 ahb_status: "Muss [182] ∧ [152]".to_string(),
1714 codes: vec![],
1715 parent_group_ahb_status: None,
1716 segment_ahb_status: None,
1717 ..Default::default()
1718 }],
1719 ub_definitions: BTreeMap::new(),
1720 };
1721
1722 let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
1723
1724 assert!(report.is_valid());
1726 }
1727
1728 fn validate_absent_field(status: &str, conditions: Vec<(u32, CR)>) -> ValidationReport {
1730 let validator = EdifactValidator::new(MockEvaluator::new(conditions));
1731 let workflow = AhbWorkflow {
1732 pruefidentifikator: "11001".to_string(),
1733 description: "Test".to_string(),
1734 communication_direction: None,
1735 fields: vec![AhbFieldRule {
1736 segment_path: "SG2/NAD/C082/3039".to_string(),
1737 name: "MP-ID".to_string(),
1738 ahb_status: status.to_string(),
1739 ..Default::default()
1740 }],
1741 ub_definitions: BTreeMap::new(),
1742 };
1743 validator.validate(
1744 &[],
1745 &workflow,
1746 &NoOpExternalProvider,
1747 ValidationLevel::Conditions,
1748 )
1749 }
1750
1751 #[test]
1752 fn a_later_soll_line_alone_does_not_require_the_field() {
1753 let report = validate_absent_field(
1755 "Muss [2119] \r\nSoll [130]",
1756 vec![(2119, CR::False), (130, CR::True)],
1757 );
1758 assert!(
1759 report.is_valid(),
1760 "{:?}",
1761 report.errors().collect::<Vec<_>>()
1762 );
1763 }
1764
1765 #[test]
1766 fn a_later_muss_line_requires_the_field() {
1767 let report = validate_absent_field(
1769 "Soll [47]\r\nMuss [46]",
1770 vec![(47, CR::False), (46, CR::True)],
1771 );
1772 let errors: Vec<_> = report.errors().collect();
1773 assert_eq!(errors.len(), 1);
1774 assert_eq!(errors[0].code(), ErrorCodes::MISSING_REQUIRED_FIELD);
1775 }
1776
1777 #[test]
1778 fn a_weaker_unknown_line_does_not_stop_a_muss() {
1779 let report = validate_absent_field("Muss [2119]\nSoll [130]", vec![(2119, CR::True)]);
1781 let errors: Vec<_> = report.errors().collect();
1782 assert_eq!(errors.len(), 1);
1783 assert_eq!(errors[0].code(), ErrorCodes::MISSING_REQUIRED_FIELD);
1784 }
1785
1786 #[test]
1787 fn a_stricter_unknown_line_leaves_the_status_undecided() {
1788 let report = validate_absent_field("Muss [2119]\nSoll [130]", vec![(130, CR::True)]);
1789 assert!(report.is_valid());
1790 let infos: Vec<_> = report.infos().collect();
1791 assert_eq!(infos.len(), 1, "{infos:?}");
1792 match &infos[0].kind {
1793 IssueKind::ConditionUnknown { unresolved, .. } => {
1794 assert_eq!(unresolved.missing, vec![2119]);
1795 }
1796 other => panic!("expected ConditionUnknown, got {other:?}"),
1797 }
1798 }
1799
1800 #[test]
1801 fn test_validate_condition_unknown_adds_info() {
1802 let evaluator = MockEvaluator::new(vec![(182, CR::True)]);
1804 let validator = EdifactValidator::new(evaluator);
1806 let external = NoOpExternalProvider;
1807
1808 let workflow = AhbWorkflow {
1809 pruefidentifikator: "11001".to_string(),
1810 description: "Test".to_string(),
1811 communication_direction: None,
1812 fields: vec![AhbFieldRule {
1813 segment_path: "NAD".to_string(),
1814 name: "Partnerrolle".to_string(),
1815 ahb_status: "Muss [182] ∧ [152]".to_string(),
1816 codes: vec![],
1817 parent_group_ahb_status: None,
1818 segment_ahb_status: None,
1819 ..Default::default()
1820 }],
1821 ub_definitions: BTreeMap::new(),
1822 };
1823
1824 let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
1825
1826 assert!(report.is_valid());
1828 let infos: Vec<_> = report.infos().collect();
1829 assert_eq!(infos.len(), 1);
1830 assert_eq!(infos[0].code(), ErrorCodes::CONDITION_UNKNOWN);
1831 }
1832
1833 #[test]
1834 fn test_validate_structure_level_skips_conditions() {
1835 let evaluator = MockEvaluator::all_true(&[182, 152]);
1836 let validator = EdifactValidator::new(evaluator);
1837 let external = NoOpExternalProvider;
1838
1839 let workflow = AhbWorkflow {
1840 pruefidentifikator: "11001".to_string(),
1841 description: "Test".to_string(),
1842 communication_direction: None,
1843 fields: vec![AhbFieldRule {
1844 segment_path: "NAD".to_string(),
1845 name: "Partnerrolle".to_string(),
1846 ahb_status: "Muss [182] ∧ [152]".to_string(),
1847 codes: vec![],
1848 parent_group_ahb_status: None,
1849 segment_ahb_status: None,
1850 ..Default::default()
1851 }],
1852 ub_definitions: BTreeMap::new(),
1853 };
1854
1855 let report = validator.validate(&[], &workflow, &external, ValidationLevel::Structure);
1857
1858 assert!(report.is_valid());
1860 assert_eq!(report.by_category(ValidationCategory::Ahb).count(), 0);
1861 }
1862
1863 #[test]
1864 fn test_validate_empty_workflow_no_condition_errors() {
1865 let evaluator = MockEvaluator::all_true(&[]);
1866 let validator = EdifactValidator::new(evaluator);
1867 let external = NoOpExternalProvider;
1868
1869 let empty_workflow = AhbWorkflow {
1870 pruefidentifikator: String::new(),
1871 description: String::new(),
1872 communication_direction: None,
1873 fields: vec![],
1874 ub_definitions: BTreeMap::new(),
1875 };
1876
1877 let report = validator.validate(&[], &empty_workflow, &external, ValidationLevel::Full);
1878
1879 assert!(report.is_valid());
1880 }
1881
1882 #[test]
1883 fn test_validate_bare_muss_always_required() {
1884 let evaluator = MockEvaluator::new(vec![]);
1885 let validator = EdifactValidator::new(evaluator);
1886 let external = NoOpExternalProvider;
1887
1888 let workflow = AhbWorkflow {
1889 pruefidentifikator: "55001".to_string(),
1890 description: "Test".to_string(),
1891 communication_direction: Some("NB an LF".to_string()),
1892 fields: vec![AhbFieldRule {
1893 segment_path: "SG2/NAD/3035".to_string(),
1894 name: "Partnerrolle".to_string(),
1895 ahb_status: "Muss".to_string(), codes: vec![],
1897 parent_group_ahb_status: None,
1898 segment_ahb_status: None,
1899 ..Default::default()
1900 }],
1901 ub_definitions: BTreeMap::new(),
1902 };
1903
1904 let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
1905
1906 assert!(!report.is_valid());
1908 assert_eq!(report.error_count(), 1);
1909 }
1910
1911 #[test]
1912 fn test_validate_x_status_is_mandatory() {
1913 let evaluator = MockEvaluator::new(vec![]);
1914 let validator = EdifactValidator::new(evaluator);
1915 let external = NoOpExternalProvider;
1916
1917 let workflow = AhbWorkflow {
1918 pruefidentifikator: "55001".to_string(),
1919 description: "Test".to_string(),
1920 communication_direction: None,
1921 fields: vec![AhbFieldRule {
1922 segment_path: "DTM".to_string(),
1923 name: "Datum".to_string(),
1924 ahb_status: "X".to_string(),
1925 codes: vec![],
1926 parent_group_ahb_status: None,
1927 segment_ahb_status: None,
1928 ..Default::default()
1929 }],
1930 ub_definitions: BTreeMap::new(),
1931 };
1932
1933 let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
1934
1935 assert!(!report.is_valid());
1936 let errors: Vec<_> = report.errors().collect();
1937 assert_eq!(errors[0].code(), ErrorCodes::MISSING_REQUIRED_FIELD);
1938 }
1939
1940 #[test]
1941 fn test_validate_soll_not_mandatory() {
1942 let evaluator = MockEvaluator::new(vec![]);
1943 let validator = EdifactValidator::new(evaluator);
1944 let external = NoOpExternalProvider;
1945
1946 let workflow = AhbWorkflow {
1947 pruefidentifikator: "55001".to_string(),
1948 description: "Test".to_string(),
1949 communication_direction: None,
1950 fields: vec![AhbFieldRule {
1951 segment_path: "DTM".to_string(),
1952 name: "Datum".to_string(),
1953 ahb_status: "Soll".to_string(),
1954 codes: vec![],
1955 parent_group_ahb_status: None,
1956 segment_ahb_status: None,
1957 ..Default::default()
1958 }],
1959 ub_definitions: BTreeMap::new(),
1960 };
1961
1962 let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
1963
1964 assert!(report.is_valid());
1966 }
1967
1968 #[test]
1969 fn test_report_includes_metadata() {
1970 let evaluator = MockEvaluator::new(vec![]);
1971 let validator = EdifactValidator::new(evaluator);
1972 let external = NoOpExternalProvider;
1973
1974 let workflow = AhbWorkflow {
1975 pruefidentifikator: "55001".to_string(),
1976 description: String::new(),
1977 communication_direction: None,
1978 fields: vec![],
1979 ub_definitions: BTreeMap::new(),
1980 };
1981
1982 let report = validator.validate(&[], &workflow, &external, ValidationLevel::Full);
1983
1984 assert_eq!(report.format_version.as_deref(), Some("FV2510"));
1985 assert_eq!(report.level, ValidationLevel::Full);
1986 assert_eq!(report.message_type, "UTILMD");
1987 assert_eq!(report.pruefidentifikator.as_deref(), Some("55001"));
1988 }
1989
1990 #[test]
1991 fn test_validate_with_navigator_returns_report() {
1992 let evaluator = MockEvaluator::all_true(&[]);
1993 let validator = EdifactValidator::new(evaluator);
1994 let external = NoOpExternalProvider;
1995 let nav = crate::eval::NoOpGroupNavigator;
1996
1997 let workflow = AhbWorkflow {
1998 pruefidentifikator: "55001".to_string(),
1999 description: "Test".to_string(),
2000 communication_direction: None,
2001 fields: vec![],
2002 ub_definitions: BTreeMap::new(),
2003 };
2004
2005 let report = validator.validate_with_navigator(
2006 &[],
2007 &workflow,
2008 &external,
2009 ValidationLevel::Full,
2010 &nav,
2011 );
2012 assert!(report.is_valid());
2013 }
2014
2015 #[test]
2016 fn test_code_validation_composite_paths_valid_codes() {
2017 let evaluator = MockEvaluator::new(vec![]);
2021 let validator = EdifactValidator::new(evaluator);
2022 let external = NoOpExternalProvider;
2023
2024 let unh_segment = OwnedSegment {
2025 id: "UNH".to_string(),
2026 elements: vec![
2027 vec!["ALEXANDE951842".to_string()],
2028 vec![
2029 "UTILMD".to_string(),
2030 "D".to_string(),
2031 "11A".to_string(),
2032 "UN".to_string(),
2033 "S2.1".to_string(),
2034 ],
2035 ],
2036 segment_number: 1,
2037 };
2038
2039 let workflow = AhbWorkflow {
2040 pruefidentifikator: "55001".to_string(),
2041 description: "Test".to_string(),
2042 communication_direction: None,
2043 fields: vec![
2044 AhbFieldRule {
2045 segment_path: "UNH/S009/0065".to_string(),
2046 name: "Nachrichtentyp".to_string(),
2047 ahb_status: "X".to_string(),
2048 codes: vec![AhbCodeRule {
2049 value: "UTILMD".to_string(),
2050 description: "Stammdaten".to_string(),
2051 ahb_status: "X".to_string(),
2052 }],
2053 parent_group_ahb_status: None,
2054 segment_ahb_status: None,
2055 element_index: Some(1),
2056 component_index: Some(0),
2057 ..Default::default()
2058 },
2059 AhbFieldRule {
2060 segment_path: "UNH/S009/0052".to_string(),
2061 name: "Version".to_string(),
2062 ahb_status: "X".to_string(),
2063 codes: vec![AhbCodeRule {
2064 value: "D".to_string(),
2065 description: "Draft".to_string(),
2066 ahb_status: "X".to_string(),
2067 }],
2068 parent_group_ahb_status: None,
2069 segment_ahb_status: None,
2070 element_index: Some(1),
2071 component_index: Some(1),
2072 ..Default::default()
2073 },
2074 ],
2075 ub_definitions: BTreeMap::new(),
2076 };
2077
2078 let report = validator.validate(
2079 &[unh_segment],
2080 &workflow,
2081 &external,
2082 ValidationLevel::Conditions,
2083 );
2084
2085 let code_errors: Vec<_> = report
2086 .by_category(ValidationCategory::Code)
2087 .filter(|i| i.severity == Severity::Error)
2088 .collect();
2089 assert!(
2090 code_errors.is_empty(),
2091 "Expected no code errors when composite values match allowed codes, got: {:?}",
2092 code_errors
2093 );
2094 }
2095
2096 #[test]
2097 fn test_code_validation_partitions_by_mig_number() {
2098 let evaluator = MockEvaluator::new(vec![]);
2102 let validator = EdifactValidator::new(evaluator);
2103 let external = NoOpExternalProvider;
2104
2105 let sts_7 = OwnedSegment {
2106 id: "STS".to_string(),
2107 elements: vec![
2108 vec!["7".to_string()],
2109 vec![String::new()],
2110 vec!["GH02".to_string()],
2111 vec!["ZW4".to_string()],
2112 ],
2113 segment_number: 1,
2114 };
2115 let sts_e01 = OwnedSegment {
2116 id: "STS".to_string(),
2117 elements: vec![
2118 vec!["E01".to_string()],
2119 vec![String::new()],
2120 vec!["A99".to_string(), "E_0614".to_string()],
2121 ],
2122 segment_number: 2,
2123 };
2124
2125 let workflow = AhbWorkflow {
2126 pruefidentifikator: "55018".to_string(),
2127 description: "Test".to_string(),
2128 communication_direction: None,
2129 fields: vec![
2130 AhbFieldRule {
2132 segment_path: "SG4/STS/C601/9015".to_string(),
2133 name: "Statuskategorie".to_string(),
2134 ahb_status: "X".to_string(),
2135 codes: vec![AhbCodeRule {
2136 value: "7".to_string(),
2137 description: "Transaktionsgrund".to_string(),
2138 ahb_status: "X".to_string(),
2139 }],
2140 parent_group_ahb_status: None,
2141 segment_ahb_status: None,
2142 element_index: Some(0),
2143 component_index: Some(0),
2144 mig_number: Some("00035".to_string()),
2145 },
2146 AhbFieldRule {
2147 segment_path: "SG4/STS/C556/9013".to_string(),
2148 name: "Statusanlaß".to_string(),
2149 ahb_status: "X".to_string(),
2150 codes: vec![AhbCodeRule {
2151 value: "E03".to_string(),
2152 description: "Transaktionsgrund".to_string(),
2153 ahb_status: "X".to_string(),
2154 }],
2155 parent_group_ahb_status: None,
2156 segment_ahb_status: None,
2157 element_index: Some(2),
2158 component_index: Some(0),
2159 mig_number: Some("00035".to_string()),
2160 },
2161 AhbFieldRule {
2163 segment_path: "SG4/STS/C601/9015".to_string(),
2164 name: "Statuskategorie".to_string(),
2165 ahb_status: "X".to_string(),
2166 codes: vec![AhbCodeRule {
2167 value: "E01".to_string(),
2168 description: "Antwort".to_string(),
2169 ahb_status: "X".to_string(),
2170 }],
2171 parent_group_ahb_status: None,
2172 segment_ahb_status: None,
2173 element_index: Some(0),
2174 component_index: Some(0),
2175 mig_number: Some("00036".to_string()),
2176 },
2177 ],
2178 ub_definitions: BTreeMap::new(),
2179 };
2180
2181 let report = validator.validate(
2182 &[sts_7, sts_e01],
2183 &workflow,
2184 &external,
2185 ValidationLevel::Conditions,
2186 );
2187
2188 let code_errors: Vec<_> = report
2189 .by_category(ValidationCategory::Code)
2190 .filter(|i| {
2191 i.severity == Severity::Error && i.code() == ErrorCodes::CODE_NOT_ALLOWED_FOR_PID
2192 })
2193 .collect();
2194 assert_eq!(
2195 code_errors.len(),
2196 1,
2197 "Expected one COD002 (for GH02 only), got: {:?}",
2198 code_errors
2199 );
2200 assert_eq!(code_errors[0].actual_value.as_deref(), Some("GH02"));
2201 }
2202
2203 #[test]
2204 fn test_code_validation_composite_paths_detects_invalid_code() {
2205 let evaluator = MockEvaluator::new(vec![]);
2208 let validator = EdifactValidator::new(evaluator);
2209 let external = NoOpExternalProvider;
2210
2211 let sts_segment = OwnedSegment {
2212 id: "STS".to_string(),
2213 elements: vec![
2214 vec!["7".to_string()],
2215 vec![String::new()],
2216 vec!["GH02".to_string()],
2217 vec!["ZW4".to_string()],
2218 ],
2219 segment_number: 1,
2220 };
2221
2222 let workflow = AhbWorkflow {
2223 pruefidentifikator: "55018".to_string(),
2224 description: "Test".to_string(),
2225 communication_direction: None,
2226 fields: vec![AhbFieldRule {
2227 segment_path: "SG4/STS/C556/9013".to_string(),
2228 name: "Statusanlaß".to_string(),
2229 ahb_status: "X".to_string(),
2230 codes: vec![AhbCodeRule {
2231 value: "E03".to_string(),
2232 description: "Transaktionsgrund".to_string(),
2233 ahb_status: "X".to_string(),
2234 }],
2235 parent_group_ahb_status: None,
2236 segment_ahb_status: None,
2237 element_index: Some(2),
2238 component_index: Some(0),
2239 ..Default::default()
2240 }],
2241 ub_definitions: BTreeMap::new(),
2242 };
2243
2244 let report = validator.validate(
2245 &[sts_segment],
2246 &workflow,
2247 &external,
2248 ValidationLevel::Conditions,
2249 );
2250
2251 let code_errors: Vec<_> = report
2252 .by_category(ValidationCategory::Code)
2253 .filter(|i| {
2254 i.severity == Severity::Error && i.code() == ErrorCodes::CODE_NOT_ALLOWED_FOR_PID
2255 })
2256 .collect();
2257 assert_eq!(
2258 code_errors.len(),
2259 1,
2260 "Expected COD002 for GH02, got: {:?}",
2261 code_errors
2262 );
2263 assert_eq!(code_errors[0].actual_value.as_deref(), Some("GH02"));
2264 }
2265
2266 #[test]
2267 fn test_cross_field_code_validation_valid_qualifiers() {
2268 let evaluator = MockEvaluator::new(vec![]);
2271 let validator = EdifactValidator::new(evaluator);
2272 let external = NoOpExternalProvider;
2273
2274 let nad_ms = OwnedSegment {
2275 id: "NAD".to_string(),
2276 elements: vec![vec!["MS".to_string()]],
2277 segment_number: 4,
2278 };
2279 let nad_mr = OwnedSegment {
2280 id: "NAD".to_string(),
2281 elements: vec![vec!["MR".to_string()]],
2282 segment_number: 5,
2283 };
2284
2285 let workflow = AhbWorkflow {
2286 pruefidentifikator: "55001".to_string(),
2287 description: "Test".to_string(),
2288 communication_direction: None,
2289 fields: vec![
2290 AhbFieldRule {
2291 segment_path: "SG2/NAD/3035".to_string(),
2292 name: "Absender".to_string(),
2293 ahb_status: "X".to_string(),
2294 codes: vec![AhbCodeRule {
2295 value: "MS".to_string(),
2296 description: "Absender".to_string(),
2297 ahb_status: "X".to_string(),
2298 }],
2299 parent_group_ahb_status: None,
2300 segment_ahb_status: None,
2301 ..Default::default()
2302 },
2303 AhbFieldRule {
2304 segment_path: "SG2/NAD/3035".to_string(),
2305 name: "Empfaenger".to_string(),
2306 ahb_status: "X".to_string(),
2307 codes: vec![AhbCodeRule {
2308 value: "MR".to_string(),
2309 description: "Empfaenger".to_string(),
2310 ahb_status: "X".to_string(),
2311 }],
2312 parent_group_ahb_status: None,
2313 segment_ahb_status: None,
2314 ..Default::default()
2315 },
2316 ],
2317 ub_definitions: BTreeMap::new(),
2318 };
2319
2320 let report = validator.validate(
2321 &[nad_ms, nad_mr],
2322 &workflow,
2323 &external,
2324 ValidationLevel::Conditions,
2325 );
2326
2327 let code_errors: Vec<_> = report
2328 .by_category(ValidationCategory::Code)
2329 .filter(|i| i.severity == Severity::Error)
2330 .collect();
2331 assert!(
2332 code_errors.is_empty(),
2333 "Expected no code errors for valid qualifiers, got: {:?}",
2334 code_errors
2335 );
2336 }
2337
2338 #[test]
2339 fn test_cross_field_code_validation_catches_invalid_qualifier() {
2340 let evaluator = MockEvaluator::new(vec![]);
2342 let validator = EdifactValidator::new(evaluator);
2343 let external = NoOpExternalProvider;
2344
2345 let nad_ms = OwnedSegment {
2346 id: "NAD".to_string(),
2347 elements: vec![vec!["MS".to_string()]],
2348 segment_number: 4,
2349 };
2350 let nad_mt = OwnedSegment {
2351 id: "NAD".to_string(),
2352 elements: vec![vec!["MT".to_string()]], segment_number: 5,
2354 };
2355
2356 let workflow = AhbWorkflow {
2357 pruefidentifikator: "55001".to_string(),
2358 description: "Test".to_string(),
2359 communication_direction: None,
2360 fields: vec![
2361 AhbFieldRule {
2362 segment_path: "SG2/NAD/3035".to_string(),
2363 name: "Absender".to_string(),
2364 ahb_status: "X".to_string(),
2365 codes: vec![AhbCodeRule {
2366 value: "MS".to_string(),
2367 description: "Absender".to_string(),
2368 ahb_status: "X".to_string(),
2369 }],
2370 parent_group_ahb_status: None,
2371 segment_ahb_status: None,
2372 ..Default::default()
2373 },
2374 AhbFieldRule {
2375 segment_path: "SG2/NAD/3035".to_string(),
2376 name: "Empfaenger".to_string(),
2377 ahb_status: "X".to_string(),
2378 codes: vec![AhbCodeRule {
2379 value: "MR".to_string(),
2380 description: "Empfaenger".to_string(),
2381 ahb_status: "X".to_string(),
2382 }],
2383 parent_group_ahb_status: None,
2384 segment_ahb_status: None,
2385 ..Default::default()
2386 },
2387 ],
2388 ub_definitions: BTreeMap::new(),
2389 };
2390
2391 let report = validator.validate(
2392 &[nad_ms, nad_mt],
2393 &workflow,
2394 &external,
2395 ValidationLevel::Conditions,
2396 );
2397
2398 let code_errors: Vec<_> = report
2399 .by_category(ValidationCategory::Code)
2400 .filter(|i| i.severity == Severity::Error)
2401 .collect();
2402 assert_eq!(code_errors.len(), 1, "Expected one COD002 error for MT");
2403 assert!(narrate(code_errors[0]).contains("MT"));
2404 assert!(narrate(code_errors[0]).contains("MR"));
2405 assert!(narrate(code_errors[0]).contains("MS"));
2406 }
2407
2408 #[test]
2409 fn test_cross_field_code_validation_unions_across_groups() {
2410 let evaluator = MockEvaluator::new(vec![]);
2414 let validator = EdifactValidator::new(evaluator);
2415 let external = NoOpExternalProvider;
2416
2417 let segments = vec![
2418 OwnedSegment {
2419 id: "NAD".to_string(),
2420 elements: vec![vec!["MS".to_string()]],
2421 segment_number: 3,
2422 },
2423 OwnedSegment {
2424 id: "NAD".to_string(),
2425 elements: vec![vec!["MR".to_string()]],
2426 segment_number: 4,
2427 },
2428 OwnedSegment {
2429 id: "NAD".to_string(),
2430 elements: vec![vec!["Z04".to_string()]],
2431 segment_number: 20,
2432 },
2433 OwnedSegment {
2434 id: "NAD".to_string(),
2435 elements: vec![vec!["Z09".to_string()]],
2436 segment_number: 21,
2437 },
2438 OwnedSegment {
2439 id: "NAD".to_string(),
2440 elements: vec![vec!["MT".to_string()]], segment_number: 22,
2442 },
2443 ];
2444
2445 let workflow = AhbWorkflow {
2446 pruefidentifikator: "55001".to_string(),
2447 description: "Test".to_string(),
2448 communication_direction: None,
2449 fields: vec![
2450 AhbFieldRule {
2451 segment_path: "SG2/NAD/3035".to_string(),
2452 name: "Absender".to_string(),
2453 ahb_status: "X".to_string(),
2454 codes: vec![AhbCodeRule {
2455 value: "MS".to_string(),
2456 description: "Absender".to_string(),
2457 ahb_status: "X".to_string(),
2458 }],
2459 parent_group_ahb_status: None,
2460 segment_ahb_status: None,
2461 ..Default::default()
2462 },
2463 AhbFieldRule {
2464 segment_path: "SG2/NAD/3035".to_string(),
2465 name: "Empfaenger".to_string(),
2466 ahb_status: "X".to_string(),
2467 codes: vec![AhbCodeRule {
2468 value: "MR".to_string(),
2469 description: "Empfaenger".to_string(),
2470 ahb_status: "X".to_string(),
2471 }],
2472 parent_group_ahb_status: None,
2473 segment_ahb_status: None,
2474 ..Default::default()
2475 },
2476 AhbFieldRule {
2477 segment_path: "SG4/SG12/NAD/3035".to_string(),
2478 name: "Anschlussnutzer".to_string(),
2479 ahb_status: "X".to_string(),
2480 codes: vec![AhbCodeRule {
2481 value: "Z04".to_string(),
2482 description: "Anschlussnutzer".to_string(),
2483 ahb_status: "X".to_string(),
2484 }],
2485 parent_group_ahb_status: None,
2486 segment_ahb_status: None,
2487 ..Default::default()
2488 },
2489 AhbFieldRule {
2490 segment_path: "SG4/SG12/NAD/3035".to_string(),
2491 name: "Korrespondenzanschrift".to_string(),
2492 ahb_status: "X".to_string(),
2493 codes: vec![AhbCodeRule {
2494 value: "Z09".to_string(),
2495 description: "Korrespondenzanschrift".to_string(),
2496 ahb_status: "X".to_string(),
2497 }],
2498 parent_group_ahb_status: None,
2499 segment_ahb_status: None,
2500 ..Default::default()
2501 },
2502 ],
2503 ub_definitions: BTreeMap::new(),
2504 };
2505
2506 let report =
2507 validator.validate(&segments, &workflow, &external, ValidationLevel::Conditions);
2508
2509 let code_errors: Vec<_> = report
2510 .by_category(ValidationCategory::Code)
2511 .filter(|i| i.severity == Severity::Error)
2512 .collect();
2513 assert_eq!(
2514 code_errors.len(),
2515 1,
2516 "Expected exactly one COD002 error for MT, got: {:?}",
2517 code_errors
2518 );
2519 assert!(narrate(code_errors[0]).contains("MT"));
2520 }
2521
2522 #[test]
2523 fn test_cross_field_code_validation_accepts_conditionally_allowed_codes() {
2524 let evaluator = MockEvaluator::new(vec![]);
2533 let validator = EdifactValidator::new(evaluator);
2534 let external = NoOpExternalProvider;
2535
2536 let qty_67 = OwnedSegment {
2537 id: "QTY".to_string(),
2538 elements: vec![vec!["67".to_string(), "0.185".to_string()]],
2539 segment_number: 10,
2540 };
2541
2542 let workflow = AhbWorkflow {
2543 pruefidentifikator: "13025".to_string(),
2544 description: "Test".to_string(),
2545 communication_direction: None,
2546 fields: vec![AhbFieldRule {
2547 segment_path: "SG5/SG6/SG9/SG10/QTY/qualifier".to_string(),
2548 name: "Menge, Qualifier".to_string(),
2549 ahb_status: "X".to_string(),
2550 codes: vec![
2551 AhbCodeRule {
2552 value: "220".to_string(),
2553 description: "Wahrer Wert".to_string(),
2554 ahb_status: "X".to_string(),
2555 },
2556 AhbCodeRule {
2557 value: "67".to_string(),
2558 description: "Ersatzwert".to_string(),
2559 ahb_status: "X [35] ∨ ([32] ∧ [77])".to_string(),
2560 },
2561 AhbCodeRule {
2562 value: "Z18".to_string(),
2563 description: "Vorläufiger Wert".to_string(),
2564 ahb_status: "X [35]".to_string(),
2565 },
2566 ],
2567 parent_group_ahb_status: None,
2568 segment_ahb_status: None,
2569 element_index: Some(0),
2570 component_index: Some(0),
2571 ..Default::default()
2572 }],
2573 ub_definitions: BTreeMap::new(),
2574 };
2575
2576 let report =
2577 validator.validate(&[qty_67], &workflow, &external, ValidationLevel::Conditions);
2578
2579 let code_errors: Vec<_> = report
2580 .by_category(ValidationCategory::Code)
2581 .filter(|i| i.severity == Severity::Error)
2582 .collect();
2583 assert!(
2584 code_errors.is_empty(),
2585 "QTY+67 should be accepted because code '67' is conditionally allowed for this PID (X [35] ∨ ([32] ∧ [77])). Got errors: {:?}",
2586 code_errors
2587 );
2588 }
2589
2590 #[test]
2591 fn test_is_qualifier_field_simple_paths() {
2592 assert!(is_qualifier_field("NAD/3035"));
2593 assert!(is_qualifier_field("SG2/NAD/3035"));
2594 assert!(is_qualifier_field("SG4/SG8/SEQ/6350"));
2595 assert!(is_qualifier_field("LOC/3227"));
2596 }
2597
2598 #[test]
2599 fn test_is_qualifier_field_composite_paths() {
2600 assert!(is_qualifier_field("UNH/S009/0065"));
2604 assert!(is_qualifier_field("NAD/C082/3039"));
2605 assert!(is_qualifier_field("SG2/NAD/C082/3039"));
2606 assert!(is_qualifier_field("SG4/STS/C556/9013"));
2607 }
2608
2609 #[test]
2610 fn test_is_qualifier_field_bare_segment() {
2611 assert!(!is_qualifier_field("NAD"));
2612 assert!(!is_qualifier_field("SG2/NAD"));
2613 }
2614
2615 #[test]
2616 fn test_is_qualifier_field_rejects_deep_paths() {
2617 assert!(!is_qualifier_field("SEG/A/B/C/D"));
2619 }
2620
2621 #[test]
2622 fn test_missing_qualifier_instance_is_detected() {
2623 let evaluator = MockEvaluator::new(vec![]);
2626 let validator = EdifactValidator::new(evaluator);
2627 let external = NoOpExternalProvider;
2628
2629 let nad_ms = OwnedSegment {
2630 id: "NAD".to_string(),
2631 elements: vec![vec!["MS".to_string()]],
2632 segment_number: 3,
2633 };
2634
2635 let workflow = AhbWorkflow {
2636 pruefidentifikator: "55001".to_string(),
2637 description: "Test".to_string(),
2638 communication_direction: None,
2639 fields: vec![
2640 AhbFieldRule {
2641 segment_path: "SG2/NAD/3035".to_string(),
2642 name: "Absender".to_string(),
2643 ahb_status: "X".to_string(),
2644 codes: vec![AhbCodeRule {
2645 value: "MS".to_string(),
2646 description: "Absender".to_string(),
2647 ahb_status: "X".to_string(),
2648 }],
2649 parent_group_ahb_status: None,
2650 segment_ahb_status: None,
2651 ..Default::default()
2652 },
2653 AhbFieldRule {
2654 segment_path: "SG2/NAD/3035".to_string(),
2655 name: "Empfaenger".to_string(),
2656 ahb_status: "Muss".to_string(),
2657 codes: vec![AhbCodeRule {
2658 value: "MR".to_string(),
2659 description: "Empfaenger".to_string(),
2660 ahb_status: "X".to_string(),
2661 }],
2662 parent_group_ahb_status: None,
2663 segment_ahb_status: None,
2664 ..Default::default()
2665 },
2666 ],
2667 ub_definitions: BTreeMap::new(),
2668 };
2669
2670 let report =
2671 validator.validate(&[nad_ms], &workflow, &external, ValidationLevel::Conditions);
2672
2673 let ahb_errors: Vec<_> = report
2674 .by_category(ValidationCategory::Ahb)
2675 .filter(|i| i.severity == Severity::Error)
2676 .collect();
2677 assert_eq!(
2678 ahb_errors.len(),
2679 1,
2680 "Expected AHB001 for missing NAD+MR, got: {:?}",
2681 ahb_errors
2682 );
2683 assert!(narrate(ahb_errors[0]).contains("Empfaenger"));
2684 }
2685
2686 #[test]
2687 fn test_present_qualifier_instance_no_error() {
2688 let evaluator = MockEvaluator::new(vec![]);
2690 let validator = EdifactValidator::new(evaluator);
2691 let external = NoOpExternalProvider;
2692
2693 let segments = vec![
2694 OwnedSegment {
2695 id: "NAD".to_string(),
2696 elements: vec![vec!["MS".to_string()]],
2697 segment_number: 3,
2698 },
2699 OwnedSegment {
2700 id: "NAD".to_string(),
2701 elements: vec![vec!["MR".to_string()]],
2702 segment_number: 4,
2703 },
2704 ];
2705
2706 let workflow = AhbWorkflow {
2707 pruefidentifikator: "55001".to_string(),
2708 description: "Test".to_string(),
2709 communication_direction: None,
2710 fields: vec![
2711 AhbFieldRule {
2712 segment_path: "SG2/NAD/3035".to_string(),
2713 name: "Absender".to_string(),
2714 ahb_status: "Muss".to_string(),
2715 codes: vec![AhbCodeRule {
2716 value: "MS".to_string(),
2717 description: "Absender".to_string(),
2718 ahb_status: "X".to_string(),
2719 }],
2720 parent_group_ahb_status: None,
2721 segment_ahb_status: None,
2722 ..Default::default()
2723 },
2724 AhbFieldRule {
2725 segment_path: "SG2/NAD/3035".to_string(),
2726 name: "Empfaenger".to_string(),
2727 ahb_status: "Muss".to_string(),
2728 codes: vec![AhbCodeRule {
2729 value: "MR".to_string(),
2730 description: "Empfaenger".to_string(),
2731 ahb_status: "X".to_string(),
2732 }],
2733 parent_group_ahb_status: None,
2734 segment_ahb_status: None,
2735 ..Default::default()
2736 },
2737 ],
2738 ub_definitions: BTreeMap::new(),
2739 };
2740
2741 let report =
2742 validator.validate(&segments, &workflow, &external, ValidationLevel::Conditions);
2743
2744 let ahb_errors: Vec<_> = report
2745 .by_category(ValidationCategory::Ahb)
2746 .filter(|i| i.severity == Severity::Error)
2747 .collect();
2748 assert!(
2749 ahb_errors.is_empty(),
2750 "Expected no AHB001 errors, got: {:?}",
2751 ahb_errors
2752 );
2753 }
2754
2755 #[test]
2756 fn test_extract_group_path_key() {
2757 assert_eq!(extract_group_path_key("SG2/NAD/3035"), "SG2");
2758 assert_eq!(extract_group_path_key("SG4/SG12/NAD/3035"), "SG4/SG12");
2759 assert_eq!(extract_group_path_key("NAD/3035"), "");
2760 assert_eq!(extract_group_path_key("SG4/SG8/SEQ/6350"), "SG4/SG8");
2761 }
2762
2763 fn validate_field_of_absent_group(
2765 group_status: &str,
2766 conditions: Vec<(u32, CR)>,
2767 ) -> ValidationReport {
2768 use mig_types::navigator::GroupNavigator;
2769
2770 struct NoSg9;
2771 impl GroupNavigator for NoSg9 {
2772 fn find_segments_in_group(&self, _: &str, _: &[&str], _: usize) -> Vec<OwnedSegment> {
2773 vec![]
2774 }
2775 fn find_segments_with_qualifier_in_group(
2776 &self,
2777 _: &str,
2778 _: usize,
2779 _: &str,
2780 _: &[&str],
2781 _: usize,
2782 ) -> Vec<OwnedSegment> {
2783 vec![]
2784 }
2785 fn group_instance_count(&self, _: &[&str]) -> usize {
2786 0
2787 }
2788 }
2789
2790 let validator = EdifactValidator::new(MockEvaluator::new(conditions));
2791 let workflow = AhbWorkflow {
2792 pruefidentifikator: "44001".to_string(),
2793 description: "Test".to_string(),
2794 communication_direction: None,
2795 fields: vec![AhbFieldRule {
2796 segment_path: "SG9/QTY/C186/6060".to_string(),
2797 name: "Menge".to_string(),
2798 ahb_status: "X".to_string(),
2799 parent_group_ahb_status: Some(group_status.to_string()),
2800 ..Default::default()
2801 }],
2802 ub_definitions: BTreeMap::new(),
2803 };
2804 validator.validate_with_navigator(
2805 &[],
2806 &workflow,
2807 &NoOpExternalProvider,
2808 ValidationLevel::Conditions,
2809 &NoSg9,
2810 )
2811 }
2812
2813 #[test]
2814 fn an_absent_group_whose_holding_line_is_soll_is_not_missing() {
2815 let report = validate_field_of_absent_group(
2817 "Soll [47]\r\nMuss [46]",
2818 vec![(47, CR::True), (46, CR::False)],
2819 );
2820 assert!(
2821 report.is_valid(),
2822 "{:?}",
2823 report.errors().collect::<Vec<_>>()
2824 );
2825 }
2826
2827 #[test]
2828 fn an_absent_group_whose_holding_line_is_muss_is_missing() {
2829 let report = validate_field_of_absent_group(
2830 "Soll [47]\r\nMuss [46]",
2831 vec![(47, CR::False), (46, CR::True)],
2832 );
2833 let errors: Vec<_> = report.errors().collect();
2834 assert_eq!(errors.len(), 1, "{errors:?}");
2835 assert_eq!(errors[0].code(), ErrorCodes::MISSING_REQUIRED_FIELD);
2836 }
2837
2838 #[test]
2839 fn test_absent_optional_group_no_missing_field_error() {
2840 use mig_types::navigator::GroupNavigator;
2843
2844 struct NavWithoutSG3;
2845 impl GroupNavigator for NavWithoutSG3 {
2846 fn find_segments_in_group(&self, _: &str, _: &[&str], _: usize) -> Vec<OwnedSegment> {
2847 vec![]
2848 }
2849 fn find_segments_with_qualifier_in_group(
2850 &self,
2851 _: &str,
2852 _: usize,
2853 _: &str,
2854 _: &[&str],
2855 _: usize,
2856 ) -> Vec<OwnedSegment> {
2857 vec![]
2858 }
2859 fn group_instance_count(&self, group_path: &[&str]) -> usize {
2860 match group_path {
2861 ["SG2"] => 2, ["SG2", "SG3"] => 0, _ => 0,
2864 }
2865 }
2866 }
2867
2868 let evaluator = MockEvaluator::new(vec![]);
2869 let validator = EdifactValidator::new(evaluator);
2870 let external = NoOpExternalProvider;
2871 let nav = NavWithoutSG3;
2872
2873 let segments = vec![
2875 OwnedSegment {
2876 id: "NAD".into(),
2877 elements: vec![vec!["MS".into()]],
2878 segment_number: 3,
2879 },
2880 OwnedSegment {
2881 id: "NAD".into(),
2882 elements: vec![vec!["MR".into()]],
2883 segment_number: 4,
2884 },
2885 ];
2886
2887 let workflow = AhbWorkflow {
2888 pruefidentifikator: "55001".to_string(),
2889 description: "Test".to_string(),
2890 communication_direction: None,
2891 fields: vec![
2892 AhbFieldRule {
2893 segment_path: "SG2/SG3/CTA/3139".to_string(),
2894 name: "Funktion des Ansprechpartners, Code".to_string(),
2895 ahb_status: "Muss".to_string(),
2896 codes: vec![],
2897 parent_group_ahb_status: None,
2898 segment_ahb_status: None,
2899 ..Default::default()
2900 },
2901 AhbFieldRule {
2902 segment_path: "SG2/SG3/CTA/C056/3412".to_string(),
2903 name: "Name vom Ansprechpartner".to_string(),
2904 ahb_status: "X".to_string(),
2905 codes: vec![],
2906 parent_group_ahb_status: None,
2907 segment_ahb_status: None,
2908 ..Default::default()
2909 },
2910 ],
2911 ub_definitions: BTreeMap::new(),
2912 };
2913
2914 let report = validator.validate_with_navigator(
2915 &segments,
2916 &workflow,
2917 &external,
2918 ValidationLevel::Conditions,
2919 &nav,
2920 );
2921
2922 let ahb_errors: Vec<_> = report
2923 .by_category(ValidationCategory::Ahb)
2924 .filter(|i| i.severity == Severity::Error)
2925 .collect();
2926 assert!(
2927 ahb_errors.is_empty(),
2928 "Expected no AHB001 errors when SG3 is absent, got: {:?}",
2929 ahb_errors
2930 );
2931 }
2932
2933 #[test]
2934 fn test_present_group_still_checks_mandatory_fields() {
2935 use mig_types::navigator::GroupNavigator;
2937
2938 struct NavWithSG3;
2939 impl GroupNavigator for NavWithSG3 {
2940 fn find_segments_in_group(&self, _: &str, _: &[&str], _: usize) -> Vec<OwnedSegment> {
2941 vec![]
2942 }
2943 fn find_segments_with_qualifier_in_group(
2944 &self,
2945 _: &str,
2946 _: usize,
2947 _: &str,
2948 _: &[&str],
2949 _: usize,
2950 ) -> Vec<OwnedSegment> {
2951 vec![]
2952 }
2953 fn group_instance_count(&self, group_path: &[&str]) -> usize {
2954 match group_path {
2955 ["SG2"] => 1,
2956 ["SG2", "SG3"] => 1, _ => 0,
2958 }
2959 }
2960 }
2961
2962 let evaluator = MockEvaluator::new(vec![]);
2963 let validator = EdifactValidator::new(evaluator);
2964 let external = NoOpExternalProvider;
2965 let nav = NavWithSG3;
2966
2967 let segments = vec![OwnedSegment {
2969 id: "NAD".into(),
2970 elements: vec![vec!["MS".into()]],
2971 segment_number: 3,
2972 }];
2973
2974 let workflow = AhbWorkflow {
2975 pruefidentifikator: "55001".to_string(),
2976 description: "Test".to_string(),
2977 communication_direction: None,
2978 fields: vec![AhbFieldRule {
2979 segment_path: "SG2/SG3/CTA/3139".to_string(),
2980 name: "Funktion des Ansprechpartners, Code".to_string(),
2981 ahb_status: "Muss".to_string(),
2982 codes: vec![],
2983 parent_group_ahb_status: None,
2984 segment_ahb_status: None,
2985 ..Default::default()
2986 }],
2987 ub_definitions: BTreeMap::new(),
2988 };
2989
2990 let report = validator.validate_with_navigator(
2991 &segments,
2992 &workflow,
2993 &external,
2994 ValidationLevel::Conditions,
2995 &nav,
2996 );
2997
2998 let ahb_errors: Vec<_> = report
2999 .by_category(ValidationCategory::Ahb)
3000 .filter(|i| i.severity == Severity::Error)
3001 .collect();
3002 assert_eq!(
3003 ahb_errors.len(),
3004 1,
3005 "Expected AHB001 error when SG3 is present but CTA missing"
3006 );
3007 assert!(narrate(ahb_errors[0]).contains("CTA"));
3008 }
3009
3010 #[test]
3011 fn test_missing_qualifier_with_navigator_is_detected() {
3012 use mig_types::navigator::GroupNavigator;
3015
3016 struct NavWithSG2;
3017 impl GroupNavigator for NavWithSG2 {
3018 fn find_segments_in_group(
3019 &self,
3020 segment_id: &str,
3021 group_path: &[&str],
3022 instance_index: usize,
3023 ) -> Vec<OwnedSegment> {
3024 if segment_id == "NAD" && group_path == ["SG2"] && instance_index == 0 {
3025 vec![OwnedSegment {
3026 id: "NAD".into(),
3027 elements: vec![vec!["MS".into()]],
3028 segment_number: 3,
3029 }]
3030 } else {
3031 vec![]
3032 }
3033 }
3034 fn find_segments_with_qualifier_in_group(
3035 &self,
3036 _: &str,
3037 _: usize,
3038 _: &str,
3039 _: &[&str],
3040 _: usize,
3041 ) -> Vec<OwnedSegment> {
3042 vec![]
3043 }
3044 fn group_instance_count(&self, group_path: &[&str]) -> usize {
3045 match group_path {
3046 ["SG2"] => 1,
3047 _ => 0,
3048 }
3049 }
3050 }
3051
3052 let evaluator = MockEvaluator::new(vec![]);
3053 let validator = EdifactValidator::new(evaluator);
3054 let external = NoOpExternalProvider;
3055 let nav = NavWithSG2;
3056
3057 let segments = vec![OwnedSegment {
3058 id: "NAD".into(),
3059 elements: vec![vec!["MS".into()]],
3060 segment_number: 3,
3061 }];
3062
3063 let workflow = AhbWorkflow {
3064 pruefidentifikator: "55001".to_string(),
3065 description: "Test".to_string(),
3066 communication_direction: None,
3067 fields: vec![
3068 AhbFieldRule {
3069 segment_path: "SG2/NAD/3035".to_string(),
3070 name: "Absender".to_string(),
3071 ahb_status: "X".to_string(),
3072 codes: vec![AhbCodeRule {
3073 value: "MS".to_string(),
3074 description: "Absender".to_string(),
3075 ahb_status: "X".to_string(),
3076 }],
3077 parent_group_ahb_status: None,
3078 segment_ahb_status: None,
3079 ..Default::default()
3080 },
3081 AhbFieldRule {
3082 segment_path: "SG2/NAD/3035".to_string(),
3083 name: "Empfaenger".to_string(),
3084 ahb_status: "Muss".to_string(),
3085 codes: vec![AhbCodeRule {
3086 value: "MR".to_string(),
3087 description: "Empfaenger".to_string(),
3088 ahb_status: "X".to_string(),
3089 }],
3090 parent_group_ahb_status: None,
3091 segment_ahb_status: None,
3092 ..Default::default()
3093 },
3094 ],
3095 ub_definitions: BTreeMap::new(),
3096 };
3097
3098 let report = validator.validate_with_navigator(
3099 &segments,
3100 &workflow,
3101 &external,
3102 ValidationLevel::Conditions,
3103 &nav,
3104 );
3105
3106 let ahb_errors: Vec<_> = report
3107 .by_category(ValidationCategory::Ahb)
3108 .filter(|i| i.severity == Severity::Error)
3109 .collect();
3110 assert_eq!(
3111 ahb_errors.len(),
3112 1,
3113 "Expected AHB001 for missing NAD+MR even with navigator, got: {:?}",
3114 ahb_errors
3115 );
3116 assert!(narrate(ahb_errors[0]).contains("Empfaenger"));
3117 }
3118
3119 #[test]
3120 fn test_optional_group_variant_absent_no_error() {
3121 use mig_types::navigator::GroupNavigator;
3126
3127 struct TestNav;
3128 impl GroupNavigator for TestNav {
3129 fn find_segments_in_group(
3130 &self,
3131 segment_id: &str,
3132 group_path: &[&str],
3133 instance_index: usize,
3134 ) -> Vec<OwnedSegment> {
3135 match (segment_id, group_path, instance_index) {
3136 ("LOC", ["SG4", "SG5"], 0) => vec![OwnedSegment {
3137 id: "LOC".into(),
3138 elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
3139 segment_number: 10,
3140 }],
3141 ("NAD", ["SG2"], 0) => vec![OwnedSegment {
3142 id: "NAD".into(),
3143 elements: vec![vec!["MS".into()]],
3144 segment_number: 3,
3145 }],
3146 _ => vec![],
3147 }
3148 }
3149 fn find_segments_with_qualifier_in_group(
3150 &self,
3151 _: &str,
3152 _: usize,
3153 _: &str,
3154 _: &[&str],
3155 _: usize,
3156 ) -> Vec<OwnedSegment> {
3157 vec![]
3158 }
3159 fn group_instance_count(&self, group_path: &[&str]) -> usize {
3160 match group_path {
3161 ["SG2"] => 1,
3162 ["SG4"] => 1,
3163 ["SG4", "SG5"] => 1, _ => 0,
3165 }
3166 }
3167 }
3168
3169 let evaluator = MockEvaluator::new(vec![]);
3170 let validator = EdifactValidator::new(evaluator);
3171 let external = NoOpExternalProvider;
3172 let nav = TestNav;
3173
3174 let segments = vec![
3175 OwnedSegment {
3176 id: "NAD".into(),
3177 elements: vec![vec!["MS".into()]],
3178 segment_number: 3,
3179 },
3180 OwnedSegment {
3181 id: "LOC".into(),
3182 elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
3183 segment_number: 10,
3184 },
3185 ];
3186
3187 let workflow = AhbWorkflow {
3188 pruefidentifikator: "55001".to_string(),
3189 description: "Test".to_string(),
3190 communication_direction: None,
3191 fields: vec![
3192 AhbFieldRule {
3194 segment_path: "SG2/NAD/3035".to_string(),
3195 name: "Absender".to_string(),
3196 ahb_status: "X".to_string(),
3197 codes: vec![AhbCodeRule {
3198 value: "MS".to_string(),
3199 description: "Absender".to_string(),
3200 ahb_status: "X".to_string(),
3201 }],
3202 parent_group_ahb_status: Some("Muss".to_string()),
3203 segment_ahb_status: None,
3204 ..Default::default()
3205 },
3206 AhbFieldRule {
3207 segment_path: "SG2/NAD/3035".to_string(),
3208 name: "Empfaenger".to_string(),
3209 ahb_status: "Muss".to_string(),
3210 codes: vec![AhbCodeRule {
3211 value: "MR".to_string(),
3212 description: "Empfaenger".to_string(),
3213 ahb_status: "X".to_string(),
3214 }],
3215 parent_group_ahb_status: Some("Muss".to_string()),
3216 segment_ahb_status: None,
3217 ..Default::default()
3218 },
3219 AhbFieldRule {
3221 segment_path: "SG4/SG5/LOC/3227".to_string(),
3222 name: "Ortsangabe, Qualifier (Z16)".to_string(),
3223 ahb_status: "X".to_string(),
3224 codes: vec![AhbCodeRule {
3225 value: "Z16".to_string(),
3226 description: "Marktlokation".to_string(),
3227 ahb_status: "X".to_string(),
3228 }],
3229 parent_group_ahb_status: Some("Kann".to_string()),
3230 segment_ahb_status: None,
3231 ..Default::default()
3232 },
3233 AhbFieldRule {
3234 segment_path: "SG4/SG5/LOC/3227".to_string(),
3235 name: "Ortsangabe, Qualifier (Z17)".to_string(),
3236 ahb_status: "Muss".to_string(),
3237 codes: vec![AhbCodeRule {
3238 value: "Z17".to_string(),
3239 description: "Messlokation".to_string(),
3240 ahb_status: "X".to_string(),
3241 }],
3242 parent_group_ahb_status: Some("Kann".to_string()),
3243 segment_ahb_status: None,
3244 ..Default::default()
3245 },
3246 ],
3247 ub_definitions: BTreeMap::new(),
3248 };
3249
3250 let report = validator.validate_with_navigator(
3251 &segments,
3252 &workflow,
3253 &external,
3254 ValidationLevel::Conditions,
3255 &nav,
3256 );
3257
3258 let ahb_errors: Vec<_> = report
3259 .by_category(ValidationCategory::Ahb)
3260 .filter(|i| i.severity == Severity::Error)
3261 .collect();
3262
3263 assert_eq!(
3266 ahb_errors.len(),
3267 1,
3268 "Expected only AHB001 for missing NAD+MR, got: {:?}",
3269 ahb_errors
3270 );
3271 assert!(
3272 narrate(ahb_errors[0]).contains("Empfaenger"),
3273 "Error should be for missing NAD+MR (Empfaenger)"
3274 );
3275 }
3276
3277 #[test]
3281 fn test_optional_group_variant_with_true_condition_absent_no_error() {
3282 use mig_types::navigator::GroupNavigator;
3283
3284 struct TestNav;
3285 impl GroupNavigator for TestNav {
3286 fn find_segments_in_group(
3287 &self,
3288 segment_id: &str,
3289 group_path: &[&str],
3290 instance_index: usize,
3291 ) -> Vec<OwnedSegment> {
3292 if segment_id == "SEQ" && group_path == ["SG4", "SG8"] && instance_index == 0 {
3293 vec![OwnedSegment {
3294 id: "SEQ".into(),
3295 elements: vec![vec!["Z03".into()]],
3296 segment_number: 10,
3297 }]
3298 } else {
3299 vec![]
3300 }
3301 }
3302 fn find_segments_with_qualifier_in_group(
3303 &self,
3304 _: &str,
3305 _: usize,
3306 _: &str,
3307 _: &[&str],
3308 _: usize,
3309 ) -> Vec<OwnedSegment> {
3310 vec![]
3311 }
3312 fn group_instance_count(&self, group_path: &[&str]) -> usize {
3313 match group_path {
3314 ["SG4"] => 1,
3315 ["SG4", "SG8"] => 1,
3316 _ => 0,
3317 }
3318 }
3319 }
3320
3321 let evaluator = MockEvaluator::new(vec![(345, CR::True)]);
3322 let validator = EdifactValidator::new(evaluator);
3323 let segments = vec![OwnedSegment {
3324 id: "SEQ".into(),
3325 elements: vec![vec!["Z03".into()]],
3326 segment_number: 10,
3327 }];
3328 let seq = |code: &str, group: &str| AhbFieldRule {
3329 segment_path: "SG4/SG8/SEQ/1229".to_string(),
3330 name: format!("Handlung, Code ({code})"),
3331 ahb_status: "X".to_string(),
3332 codes: vec![AhbCodeRule {
3333 value: code.to_string(),
3334 description: String::new(),
3335 ahb_status: "X".to_string(),
3336 }],
3337 parent_group_ahb_status: Some(group.to_string()),
3338 segment_ahb_status: None,
3339 ..Default::default()
3340 };
3341 let workflow = AhbWorkflow {
3342 pruefidentifikator: "44143".to_string(),
3343 description: "Test".to_string(),
3344 communication_direction: None,
3345 fields: vec![seq("Z03", "Kann"), seq("Z50", "Kann [345]")],
3346 ub_definitions: BTreeMap::new(),
3347 };
3348
3349 let report = validator.validate_with_navigator(
3350 &segments,
3351 &workflow,
3352 &NoOpExternalProvider,
3353 ValidationLevel::Conditions,
3354 &TestNav,
3355 );
3356 let missing: Vec<_> = report
3357 .issues
3358 .iter()
3359 .filter(|i| matches!(i.kind, IssueKind::MissingRequiredField { .. }))
3360 .collect();
3361 assert!(missing.is_empty(), "{missing:#?}");
3362 }
3363
3364 #[test]
3365 fn test_conditional_group_variant_absent_no_error() {
3366 use mig_types::navigator::GroupNavigator;
3371
3372 struct TestNav;
3373 impl GroupNavigator for TestNav {
3374 fn find_segments_in_group(
3375 &self,
3376 segment_id: &str,
3377 group_path: &[&str],
3378 instance_index: usize,
3379 ) -> Vec<OwnedSegment> {
3380 if segment_id == "LOC" && group_path == ["SG4", "SG5"] && instance_index == 0 {
3381 vec![OwnedSegment {
3382 id: "LOC".into(),
3383 elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
3384 segment_number: 10,
3385 }]
3386 } else {
3387 vec![]
3388 }
3389 }
3390 fn find_segments_with_qualifier_in_group(
3391 &self,
3392 _: &str,
3393 _: usize,
3394 _: &str,
3395 _: &[&str],
3396 _: usize,
3397 ) -> Vec<OwnedSegment> {
3398 vec![]
3399 }
3400 fn group_instance_count(&self, group_path: &[&str]) -> usize {
3401 match group_path {
3402 ["SG4"] => 1,
3403 ["SG4", "SG5"] => 1, _ => 0,
3405 }
3406 }
3407 }
3408
3409 let evaluator = MockEvaluator::new(vec![(165, CR::False), (2061, CR::True)]);
3412 let validator = EdifactValidator::new(evaluator);
3413 let external = NoOpExternalProvider;
3414 let nav = TestNav;
3415
3416 let segments = vec![OwnedSegment {
3417 id: "LOC".into(),
3418 elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
3419 segment_number: 10,
3420 }];
3421
3422 let workflow = AhbWorkflow {
3423 pruefidentifikator: "55001".to_string(),
3424 description: "Test".to_string(),
3425 communication_direction: None,
3426 fields: vec![
3427 AhbFieldRule {
3429 segment_path: "SG4/SG5/LOC/3227".to_string(),
3430 name: "Ortsangabe, Qualifier (Z16)".to_string(),
3431 ahb_status: "X".to_string(),
3432 codes: vec![AhbCodeRule {
3433 value: "Z16".to_string(),
3434 description: "Marktlokation".to_string(),
3435 ahb_status: "X".to_string(),
3436 }],
3437 parent_group_ahb_status: Some("Muss [2061]".to_string()),
3438 segment_ahb_status: None,
3439 ..Default::default()
3440 },
3441 AhbFieldRule {
3443 segment_path: "SG4/SG5/LOC/3227".to_string(),
3444 name: "Ortsangabe, Qualifier (Z17)".to_string(),
3445 ahb_status: "X".to_string(),
3446 codes: vec![AhbCodeRule {
3447 value: "Z17".to_string(),
3448 description: "Messlokation".to_string(),
3449 ahb_status: "X".to_string(),
3450 }],
3451 parent_group_ahb_status: Some("Soll [165]".to_string()),
3452 segment_ahb_status: None,
3453 ..Default::default()
3454 },
3455 ],
3456 ub_definitions: BTreeMap::new(),
3457 };
3458
3459 let report = validator.validate_with_navigator(
3460 &segments,
3461 &workflow,
3462 &external,
3463 ValidationLevel::Conditions,
3464 &nav,
3465 );
3466
3467 let ahb_errors: Vec<_> = report
3468 .by_category(ValidationCategory::Ahb)
3469 .filter(|i| i.severity == Severity::Error)
3470 .collect();
3471
3472 assert!(
3474 ahb_errors.is_empty(),
3475 "Expected no errors when conditional group variant [165]=False, got: {:?}",
3476 ahb_errors
3477 );
3478 }
3479
3480 #[test]
3481 fn test_conditional_group_variant_unknown_no_error() {
3482 let evaluator = MockEvaluator::new(vec![]);
3488 let validator = EdifactValidator::new(evaluator);
3489 let external = NoOpExternalProvider;
3490
3491 let workflow = AhbWorkflow {
3492 pruefidentifikator: "55001".to_string(),
3493 description: "Test".to_string(),
3494 communication_direction: None,
3495 fields: vec![AhbFieldRule {
3496 segment_path: "SG4/SG5/LOC/3227".to_string(),
3497 name: "Ortsangabe, Qualifier (Z17)".to_string(),
3498 ahb_status: "X".to_string(),
3499 codes: vec![AhbCodeRule {
3500 value: "Z17".to_string(),
3501 description: "Messlokation".to_string(),
3502 ahb_status: "X".to_string(),
3503 }],
3504 parent_group_ahb_status: Some("Soll [165]".to_string()),
3505 segment_ahb_status: None,
3506 ..Default::default()
3507 }],
3508 ub_definitions: BTreeMap::new(),
3509 };
3510
3511 let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
3512
3513 let ahb_errors: Vec<_> = report
3514 .by_category(ValidationCategory::Ahb)
3515 .filter(|i| i.severity == Severity::Error)
3516 .collect();
3517
3518 assert!(
3520 ahb_errors.is_empty(),
3521 "Expected no errors when parent group condition is Unknown, got: {:?}",
3522 ahb_errors
3523 );
3524 }
3525
3526 #[test]
3527 fn test_segment_absent_within_present_group_no_error() {
3528 use mig_types::navigator::GroupNavigator;
3532
3533 struct TestNav;
3534 impl GroupNavigator for TestNav {
3535 fn find_segments_in_group(
3536 &self,
3537 segment_id: &str,
3538 group_path: &[&str],
3539 instance_index: usize,
3540 ) -> Vec<OwnedSegment> {
3541 if segment_id == "QTY"
3543 && group_path == ["SG5", "SG6", "SG9", "SG10"]
3544 && instance_index == 0
3545 {
3546 vec![OwnedSegment {
3547 id: "QTY".into(),
3548 elements: vec![vec!["220".into(), "0".into()]],
3549 segment_number: 14,
3550 }]
3551 } else {
3552 vec![]
3553 }
3554 }
3555 fn find_segments_with_qualifier_in_group(
3556 &self,
3557 _: &str,
3558 _: usize,
3559 _: &str,
3560 _: &[&str],
3561 _: usize,
3562 ) -> Vec<OwnedSegment> {
3563 vec![]
3564 }
3565 fn group_instance_count(&self, group_path: &[&str]) -> usize {
3566 match group_path {
3567 ["SG5"] => 1,
3568 ["SG5", "SG6"] => 1,
3569 ["SG5", "SG6", "SG9"] => 1,
3570 ["SG5", "SG6", "SG9", "SG10"] => 1,
3571 _ => 0,
3572 }
3573 }
3574 fn has_any_segment_in_group(&self, group_path: &[&str], instance_index: usize) -> bool {
3575 group_path == ["SG5", "SG6", "SG9", "SG10"] && instance_index == 0
3577 }
3578 }
3579
3580 let evaluator = MockEvaluator::all_true(&[]);
3581 let validator = EdifactValidator::new(evaluator);
3582 let external = NoOpExternalProvider;
3583 let nav = TestNav;
3584
3585 let segments = vec![OwnedSegment {
3586 id: "QTY".into(),
3587 elements: vec![vec!["220".into(), "0".into()]],
3588 segment_number: 14,
3589 }];
3590
3591 let workflow = AhbWorkflow {
3592 pruefidentifikator: "13017".to_string(),
3593 description: "Test".to_string(),
3594 communication_direction: None,
3595 fields: vec![
3596 AhbFieldRule {
3598 segment_path: "SG5/SG6/SG9/SG10/STS/C601/9015".to_string(),
3599 name: "Statuskategorie, Code".to_string(),
3600 ahb_status: "X".to_string(),
3601 codes: vec![],
3602 parent_group_ahb_status: Some("Muss".to_string()),
3603 segment_ahb_status: None,
3604 ..Default::default()
3605 },
3606 AhbFieldRule {
3608 segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
3609 name: "Statusanlaß, Code".to_string(),
3610 ahb_status: "X [5]".to_string(),
3611 codes: vec![],
3612 parent_group_ahb_status: Some("Muss".to_string()),
3613 segment_ahb_status: None,
3614 ..Default::default()
3615 },
3616 ],
3617 ub_definitions: BTreeMap::new(),
3618 };
3619
3620 let report = validator.validate_with_navigator(
3621 &segments,
3622 &workflow,
3623 &external,
3624 ValidationLevel::Conditions,
3625 &nav,
3626 );
3627
3628 let ahb_errors: Vec<_> = report
3629 .by_category(ValidationCategory::Ahb)
3630 .filter(|i| i.severity == Severity::Error)
3631 .collect();
3632
3633 assert!(
3634 ahb_errors.is_empty(),
3635 "Expected no AHB001 errors when STS segment is absent from SG10, got: {:?}",
3636 ahb_errors
3637 );
3638 }
3639
3640 #[test]
3641 fn test_group_scoped_code_validation_with_navigator() {
3642 use mig_types::navigator::GroupNavigator;
3646
3647 struct TestNav;
3648 impl GroupNavigator for TestNav {
3649 fn find_segments_in_group(
3650 &self,
3651 segment_id: &str,
3652 group_path: &[&str],
3653 _instance_index: usize,
3654 ) -> Vec<OwnedSegment> {
3655 if segment_id != "NAD" {
3656 return vec![];
3657 }
3658 match group_path {
3659 ["SG2"] => vec![
3660 OwnedSegment {
3661 id: "NAD".into(),
3662 elements: vec![vec!["MS".into()]],
3663 segment_number: 3,
3664 },
3665 OwnedSegment {
3666 id: "NAD".into(),
3667 elements: vec![vec!["MT".into()]], segment_number: 4,
3669 },
3670 ],
3671 ["SG4", "SG12"] => vec![
3672 OwnedSegment {
3673 id: "NAD".into(),
3674 elements: vec![vec!["Z04".into()]],
3675 segment_number: 20,
3676 },
3677 OwnedSegment {
3678 id: "NAD".into(),
3679 elements: vec![vec!["Z09".into()]],
3680 segment_number: 21,
3681 },
3682 ],
3683 _ => vec![],
3684 }
3685 }
3686 fn find_segments_with_qualifier_in_group(
3687 &self,
3688 _: &str,
3689 _: usize,
3690 _: &str,
3691 _: &[&str],
3692 _: usize,
3693 ) -> Vec<OwnedSegment> {
3694 vec![]
3695 }
3696 fn group_instance_count(&self, group_path: &[&str]) -> usize {
3697 match group_path {
3698 ["SG2"] | ["SG4", "SG12"] => 1,
3699 _ => 0,
3700 }
3701 }
3702 }
3703
3704 let evaluator = MockEvaluator::new(vec![]);
3705 let validator = EdifactValidator::new(evaluator);
3706 let external = NoOpExternalProvider;
3707 let nav = TestNav;
3708
3709 let workflow = AhbWorkflow {
3710 pruefidentifikator: "55001".to_string(),
3711 description: "Test".to_string(),
3712 communication_direction: None,
3713 fields: vec![
3714 AhbFieldRule {
3715 segment_path: "SG2/NAD/3035".to_string(),
3716 name: "Absender".to_string(),
3717 ahb_status: "X".to_string(),
3718 codes: vec![AhbCodeRule {
3719 value: "MS".to_string(),
3720 description: "Absender".to_string(),
3721 ahb_status: "X".to_string(),
3722 }],
3723 parent_group_ahb_status: None,
3724 segment_ahb_status: None,
3725 ..Default::default()
3726 },
3727 AhbFieldRule {
3728 segment_path: "SG2/NAD/3035".to_string(),
3729 name: "Empfaenger".to_string(),
3730 ahb_status: "X".to_string(),
3731 codes: vec![AhbCodeRule {
3732 value: "MR".to_string(),
3733 description: "Empfaenger".to_string(),
3734 ahb_status: "X".to_string(),
3735 }],
3736 parent_group_ahb_status: None,
3737 segment_ahb_status: None,
3738 ..Default::default()
3739 },
3740 AhbFieldRule {
3741 segment_path: "SG4/SG12/NAD/3035".to_string(),
3742 name: "Anschlussnutzer".to_string(),
3743 ahb_status: "X".to_string(),
3744 codes: vec![AhbCodeRule {
3745 value: "Z04".to_string(),
3746 description: "Anschlussnutzer".to_string(),
3747 ahb_status: "X".to_string(),
3748 }],
3749 parent_group_ahb_status: None,
3750 segment_ahb_status: None,
3751 ..Default::default()
3752 },
3753 AhbFieldRule {
3754 segment_path: "SG4/SG12/NAD/3035".to_string(),
3755 name: "Korrespondenzanschrift".to_string(),
3756 ahb_status: "X".to_string(),
3757 codes: vec![AhbCodeRule {
3758 value: "Z09".to_string(),
3759 description: "Korrespondenzanschrift".to_string(),
3760 ahb_status: "X".to_string(),
3761 }],
3762 parent_group_ahb_status: None,
3763 segment_ahb_status: None,
3764 ..Default::default()
3765 },
3766 ],
3767 ub_definitions: BTreeMap::new(),
3768 };
3769
3770 let all_segments = vec![
3772 OwnedSegment {
3773 id: "NAD".into(),
3774 elements: vec![vec!["MS".into()]],
3775 segment_number: 3,
3776 },
3777 OwnedSegment {
3778 id: "NAD".into(),
3779 elements: vec![vec!["MT".into()]],
3780 segment_number: 4,
3781 },
3782 OwnedSegment {
3783 id: "NAD".into(),
3784 elements: vec![vec!["Z04".into()]],
3785 segment_number: 20,
3786 },
3787 OwnedSegment {
3788 id: "NAD".into(),
3789 elements: vec![vec!["Z09".into()]],
3790 segment_number: 21,
3791 },
3792 ];
3793
3794 let report = validator.validate_with_navigator(
3795 &all_segments,
3796 &workflow,
3797 &external,
3798 ValidationLevel::Conditions,
3799 &nav,
3800 );
3801
3802 let code_errors: Vec<_> = report
3803 .by_category(ValidationCategory::Code)
3804 .filter(|i| i.severity == Severity::Error)
3805 .collect();
3806
3807 assert_eq!(
3810 code_errors.len(),
3811 1,
3812 "Expected exactly one COD002 error for MT in SG2, got: {:?}",
3813 code_errors
3814 );
3815 assert!(narrate(code_errors[0]).contains("MT"));
3816 assert!(narrate(code_errors[0]).contains("MR"));
3818 assert!(narrate(code_errors[0]).contains("MS"));
3819 assert!(
3820 !narrate(code_errors[0]).contains("Z04"),
3821 "SG4/SG12 codes should not leak into SG2 error"
3822 );
3823 assert!(
3825 code_errors[0]
3826 .field_path
3827 .as_deref()
3828 .unwrap_or("")
3829 .contains("SG2"),
3830 "Error field_path should reference SG2, got: {:?}",
3831 code_errors[0].field_path
3832 );
3833 }
3834
3835 #[test]
3838 fn test_package_cardinality_within_bounds() {
3839 let evaluator = MockEvaluator::all_true(&[]);
3841 let validator = EdifactValidator::new(evaluator);
3842 let external = NoOpExternalProvider;
3843
3844 let segments = vec![OwnedSegment {
3845 id: "STS".into(),
3846 elements: vec![
3847 vec!["Z33".into()], vec![], vec!["E01".into()], ],
3851 segment_number: 5,
3852 }];
3853
3854 let workflow = AhbWorkflow {
3855 pruefidentifikator: "13017".to_string(),
3856 description: "Test".to_string(),
3857 communication_direction: None,
3858 ub_definitions: BTreeMap::new(),
3859 fields: vec![AhbFieldRule {
3860 segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
3861 name: "Statusanlaß, Code".to_string(),
3862 ahb_status: "X".to_string(),
3863 element_index: Some(2),
3864 component_index: Some(0),
3865 codes: vec![
3866 AhbCodeRule {
3867 value: "E01".into(),
3868 description: "Code 1".into(),
3869 ahb_status: "X [4P0..1]".into(),
3870 },
3871 AhbCodeRule {
3872 value: "E02".into(),
3873 description: "Code 2".into(),
3874 ahb_status: "X [4P0..1]".into(),
3875 },
3876 ],
3877 parent_group_ahb_status: Some("Muss".to_string()),
3878 segment_ahb_status: None,
3879 mig_number: None,
3880 }],
3881 };
3882
3883 let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
3884 let pkg_errors: Vec<_> = report
3885 .by_category(ValidationCategory::Ahb)
3886 .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
3887 .collect();
3888 assert!(
3889 pkg_errors.is_empty(),
3890 "1 code within [4P0..1] bounds — no error expected, got: {:?}",
3891 pkg_errors
3892 );
3893 }
3894
3895 #[test]
3896 fn test_package_cardinality_zero_present_min_zero() {
3897 let evaluator = MockEvaluator::all_true(&[]);
3899 let validator = EdifactValidator::new(evaluator);
3900 let external = NoOpExternalProvider;
3901
3902 let segments = vec![OwnedSegment {
3903 id: "STS".into(),
3904 elements: vec![
3905 vec!["Z33".into()],
3906 vec![],
3907 vec!["X99".into()], ],
3909 segment_number: 5,
3910 }];
3911
3912 let workflow = AhbWorkflow {
3913 pruefidentifikator: "13017".to_string(),
3914 description: "Test".to_string(),
3915 communication_direction: None,
3916 ub_definitions: BTreeMap::new(),
3917 fields: vec![AhbFieldRule {
3918 segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
3919 name: "Statusanlaß, Code".to_string(),
3920 ahb_status: "X".to_string(),
3921 element_index: Some(2),
3922 component_index: Some(0),
3923 codes: vec![
3924 AhbCodeRule {
3925 value: "E01".into(),
3926 description: "Code 1".into(),
3927 ahb_status: "X [4P0..1]".into(),
3928 },
3929 AhbCodeRule {
3930 value: "E02".into(),
3931 description: "Code 2".into(),
3932 ahb_status: "X [4P0..1]".into(),
3933 },
3934 ],
3935 parent_group_ahb_status: Some("Muss".to_string()),
3936 segment_ahb_status: None,
3937 mig_number: None,
3938 }],
3939 };
3940
3941 let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
3942 let pkg_errors: Vec<_> = report
3943 .by_category(ValidationCategory::Ahb)
3944 .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
3945 .collect();
3946 assert!(
3947 pkg_errors.is_empty(),
3948 "0 codes, min=0 — no error expected, got: {:?}",
3949 pkg_errors
3950 );
3951 }
3952
3953 #[test]
3954 fn test_package_cardinality_too_many() {
3955 let evaluator = MockEvaluator::all_true(&[]);
3957 let validator = EdifactValidator::new(evaluator);
3958 let external = NoOpExternalProvider;
3959
3960 let segments = vec![
3962 OwnedSegment {
3963 id: "STS".into(),
3964 elements: vec![vec!["Z33".into()], vec![], vec!["E01".into()]],
3965 segment_number: 5,
3966 },
3967 OwnedSegment {
3968 id: "STS".into(),
3969 elements: vec![vec!["Z33".into()], vec![], vec!["E02".into()]],
3970 segment_number: 6,
3971 },
3972 ];
3973
3974 let workflow = AhbWorkflow {
3975 pruefidentifikator: "13017".to_string(),
3976 description: "Test".to_string(),
3977 communication_direction: None,
3978 ub_definitions: BTreeMap::new(),
3979 fields: vec![AhbFieldRule {
3980 segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
3981 name: "Statusanlaß, Code".to_string(),
3982 ahb_status: "X".to_string(),
3983 element_index: Some(2),
3984 component_index: Some(0),
3985 codes: vec![
3986 AhbCodeRule {
3987 value: "E01".into(),
3988 description: "Code 1".into(),
3989 ahb_status: "X [4P0..1]".into(),
3990 },
3991 AhbCodeRule {
3992 value: "E02".into(),
3993 description: "Code 2".into(),
3994 ahb_status: "X [4P0..1]".into(),
3995 },
3996 ],
3997 parent_group_ahb_status: Some("Muss".to_string()),
3998 segment_ahb_status: None,
3999 mig_number: None,
4000 }],
4001 };
4002
4003 let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
4004 let pkg_errors: Vec<_> = report
4005 .by_category(ValidationCategory::Ahb)
4006 .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
4007 .collect();
4008 assert_eq!(
4009 pkg_errors.len(),
4010 1,
4011 "2 codes present, max=1 — expected 1 error, got: {:?}",
4012 pkg_errors
4013 );
4014 assert!(narrate(pkg_errors[0]).contains("[4P0..1]"));
4015 assert_eq!(pkg_errors[0].actual_value.as_deref(), Some("2"));
4016 assert_eq!(pkg_errors[0].expected_value.as_deref(), Some("0..1"));
4017 }
4018
4019 #[test]
4020 fn test_package_cardinality_too_few() {
4021 let evaluator = MockEvaluator::all_true(&[]);
4023 let validator = EdifactValidator::new(evaluator);
4024 let external = NoOpExternalProvider;
4025
4026 let segments = vec![OwnedSegment {
4027 id: "STS".into(),
4028 elements: vec![
4029 vec!["Z33".into()],
4030 vec![],
4031 vec!["X99".into()], ],
4033 segment_number: 5,
4034 }];
4035
4036 let workflow = AhbWorkflow {
4037 pruefidentifikator: "13017".to_string(),
4038 description: "Test".to_string(),
4039 communication_direction: None,
4040 ub_definitions: BTreeMap::new(),
4041 fields: vec![AhbFieldRule {
4042 segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
4043 name: "Statusanlaß, Code".to_string(),
4044 ahb_status: "X".to_string(),
4045 element_index: Some(2),
4046 component_index: Some(0),
4047 codes: vec![
4048 AhbCodeRule {
4049 value: "E01".into(),
4050 description: "Code 1".into(),
4051 ahb_status: "X [5P1..3]".into(),
4052 },
4053 AhbCodeRule {
4054 value: "E02".into(),
4055 description: "Code 2".into(),
4056 ahb_status: "X [5P1..3]".into(),
4057 },
4058 AhbCodeRule {
4059 value: "E03".into(),
4060 description: "Code 3".into(),
4061 ahb_status: "X [5P1..3]".into(),
4062 },
4063 ],
4064 parent_group_ahb_status: Some("Muss".to_string()),
4065 segment_ahb_status: None,
4066 mig_number: None,
4067 }],
4068 };
4069
4070 let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
4071 let pkg_errors: Vec<_> = report
4072 .by_category(ValidationCategory::Ahb)
4073 .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
4074 .collect();
4075 assert_eq!(
4076 pkg_errors.len(),
4077 1,
4078 "0 codes present, min=1 — expected 1 error, got: {:?}",
4079 pkg_errors
4080 );
4081 assert!(narrate(pkg_errors[0]).contains("[5P1..3]"));
4082 assert_eq!(pkg_errors[0].actual_value.as_deref(), Some("0"));
4083 assert_eq!(pkg_errors[0].expected_value.as_deref(), Some("1..3"));
4084 }
4085
4086 #[test]
4087 fn test_package_cardinality_no_packages_in_workflow() {
4088 let evaluator = MockEvaluator::all_true(&[]);
4090 let validator = EdifactValidator::new(evaluator);
4091 let external = NoOpExternalProvider;
4092
4093 let segments = vec![OwnedSegment {
4094 id: "STS".into(),
4095 elements: vec![vec!["E01".into()]],
4096 segment_number: 5,
4097 }];
4098
4099 let workflow = AhbWorkflow {
4100 pruefidentifikator: "13017".to_string(),
4101 description: "Test".to_string(),
4102 communication_direction: None,
4103 ub_definitions: BTreeMap::new(),
4104 fields: vec![AhbFieldRule {
4105 segment_path: "STS/9015".to_string(),
4106 name: "Status Code".to_string(),
4107 ahb_status: "X".to_string(),
4108 codes: vec![AhbCodeRule {
4109 value: "E01".into(),
4110 description: "Code 1".into(),
4111 ahb_status: "X".into(),
4112 }],
4113 parent_group_ahb_status: Some("Muss".to_string()),
4114 segment_ahb_status: None,
4115 ..Default::default()
4116 }],
4117 };
4118
4119 let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
4120 let pkg_errors: Vec<_> = report
4121 .by_category(ValidationCategory::Ahb)
4122 .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
4123 .collect();
4124 assert!(
4125 pkg_errors.is_empty(),
4126 "No packages in workflow — no errors expected"
4127 );
4128 }
4129
4130 #[test]
4131 fn test_package_cardinality_with_condition_and_package() {
4132 let evaluator = MockEvaluator::all_true(&[901]);
4134 let validator = EdifactValidator::new(evaluator);
4135 let external = NoOpExternalProvider;
4136
4137 let segments = vec![OwnedSegment {
4138 id: "STS".into(),
4139 elements: vec![vec![], vec![], vec!["E01".into()]],
4140 segment_number: 5,
4141 }];
4142
4143 let workflow = AhbWorkflow {
4144 pruefidentifikator: "13017".to_string(),
4145 description: "Test".to_string(),
4146 communication_direction: None,
4147 ub_definitions: BTreeMap::new(),
4148 fields: vec![AhbFieldRule {
4149 segment_path: "SG10/STS/C556/9013".to_string(),
4150 name: "Code".to_string(),
4151 ahb_status: "X".to_string(),
4152 element_index: Some(2),
4153 component_index: Some(0),
4154 codes: vec![
4155 AhbCodeRule {
4156 value: "E01".into(),
4157 description: "Code 1".into(),
4158 ahb_status: "X [901] [4P0..1]".into(),
4159 },
4160 AhbCodeRule {
4161 value: "E02".into(),
4162 description: "Code 2".into(),
4163 ahb_status: "X [901] [4P0..1]".into(),
4164 },
4165 ],
4166 parent_group_ahb_status: Some("Muss".to_string()),
4167 segment_ahb_status: None,
4168 mig_number: None,
4169 }],
4170 };
4171
4172 let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
4173 let pkg_errors: Vec<_> = report
4174 .by_category(ValidationCategory::Ahb)
4175 .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
4176 .collect();
4177 assert!(
4178 pkg_errors.is_empty(),
4179 "1 code within [4P0..1] bounds — no error, got: {:?}",
4180 pkg_errors
4181 );
4182 }
4183
4184 #[test]
4185 fn test_package_cardinality_scoped_per_group_instance() {
4186 use mig_types::navigator::GroupNavigator;
4194
4195 struct TwoSg10s {
4196 sts_a: OwnedSegment,
4197 sts_b: OwnedSegment,
4198 }
4199 impl GroupNavigator for TwoSg10s {
4200 fn find_segments_in_group(
4201 &self,
4202 segment_id: &str,
4203 group_path: &[&str],
4204 instance_index: usize,
4205 ) -> Vec<OwnedSegment> {
4206 if group_path == ["SG5", "SG6", "SG9", "SG10"] && segment_id == "STS" {
4207 match instance_index {
4208 0 => vec![self.sts_a.clone()],
4209 1 => vec![self.sts_b.clone()],
4210 _ => vec![],
4211 }
4212 } else {
4213 vec![]
4214 }
4215 }
4216 fn find_segments_with_qualifier_in_group(
4217 &self,
4218 _: &str,
4219 _: usize,
4220 _: &str,
4221 _: &[&str],
4222 _: usize,
4223 ) -> Vec<OwnedSegment> {
4224 vec![]
4225 }
4226 fn group_instance_count(&self, group_path: &[&str]) -> usize {
4227 match group_path {
4228 ["SG5"] | ["SG5", "SG6"] | ["SG5", "SG6", "SG9"] => 1,
4229 ["SG5", "SG6", "SG9", "SG10"] => 2,
4230 _ => 0,
4231 }
4232 }
4233 }
4234
4235 let sts_a = OwnedSegment {
4236 id: "STS".into(),
4237 elements: vec![vec!["Z32".into()], vec![], vec!["E01".into()]],
4238 segment_number: 10,
4239 };
4240 let sts_b = OwnedSegment {
4241 id: "STS".into(),
4242 elements: vec![vec!["Z32".into()], vec![], vec!["E01".into()]],
4243 segment_number: 15,
4244 };
4245 let nav = TwoSg10s {
4246 sts_a: sts_a.clone(),
4247 sts_b: sts_b.clone(),
4248 };
4249
4250 let evaluator = MockEvaluator::all_true(&[]);
4251 let validator = EdifactValidator::new(evaluator);
4252 let external = NoOpExternalProvider;
4253
4254 let workflow = AhbWorkflow {
4255 pruefidentifikator: "13025".to_string(),
4256 description: "Test".to_string(),
4257 communication_direction: None,
4258 ub_definitions: BTreeMap::new(),
4259 fields: vec![AhbFieldRule {
4260 segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
4261 name: "Statusanlaß, Code".to_string(),
4262 ahb_status: "X".to_string(),
4263 element_index: Some(2),
4264 component_index: Some(0),
4265 codes: vec![
4266 AhbCodeRule {
4267 value: "E01".into(),
4268 description: "Code 1".into(),
4269 ahb_status: "X [4P0..1]".into(),
4270 },
4271 AhbCodeRule {
4272 value: "E02".into(),
4273 description: "Code 2".into(),
4274 ahb_status: "X [4P0..1]".into(),
4275 },
4276 ],
4277 parent_group_ahb_status: Some("Muss".to_string()),
4278 segment_ahb_status: None,
4279 mig_number: None,
4280 }],
4281 };
4282
4283 let report = validator.validate_with_navigator(
4284 &[sts_a, sts_b],
4285 &workflow,
4286 &external,
4287 ValidationLevel::Full,
4288 &nav,
4289 );
4290 let pkg_errors: Vec<_> = report
4291 .by_category(ValidationCategory::Ahb)
4292 .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
4293 .collect();
4294 assert!(
4295 pkg_errors.is_empty(),
4296 "Package cardinality is per-instance: 1 code per SG10 rep is within [4P0..1]. Got: {:?}",
4297 pkg_errors
4298 );
4299 }
4300
4301 fn make_segment(id: &str, elements: Vec<Vec<&str>>) -> OwnedSegment {
4302 OwnedSegment {
4303 id: id.to_string(),
4304 elements: elements
4305 .into_iter()
4306 .map(|e| e.into_iter().map(|s| s.to_string()).collect())
4307 .collect(),
4308 segment_number: 0,
4309 }
4310 }
4311
4312 #[test]
4323 fn test_package_cardinality_scoped_to_rule_mig_variant() {
4324 use mig_types::navigator::GroupNavigator;
4325
4326 struct TwoSg8Variants {
4327 seq_z01: OwnedSegment,
4328 seq_z45: OwnedSegment,
4329 }
4330 impl GroupNavigator for TwoSg8Variants {
4331 fn find_segments_in_group(
4332 &self,
4333 segment_id: &str,
4334 group_path: &[&str],
4335 instance_index: usize,
4336 ) -> Vec<OwnedSegment> {
4337 if group_path == ["SG4", "SG8"] && segment_id == "SEQ" {
4338 match instance_index {
4339 0 => vec![self.seq_z01.clone()],
4340 1 => vec![self.seq_z45.clone()],
4341 _ => vec![],
4342 }
4343 } else {
4344 vec![]
4345 }
4346 }
4347 fn find_segments_with_qualifier_in_group(
4348 &self,
4349 _: &str,
4350 _: usize,
4351 _: &str,
4352 _: &[&str],
4353 _: usize,
4354 ) -> Vec<OwnedSegment> {
4355 vec![]
4356 }
4357 fn group_instance_count(&self, group_path: &[&str]) -> usize {
4358 match group_path {
4359 ["SG4"] => 1,
4360 ["SG4", "SG8"] => 2,
4361 _ => 0,
4362 }
4363 }
4364 fn instance_has_mig_number(
4365 &self,
4366 group_path: &[&str],
4367 instance_index: usize,
4368 mig_number: &str,
4369 ) -> bool {
4370 if group_path != ["SG4", "SG8"] {
4371 return true;
4372 }
4373 match (instance_index, mig_number) {
4374 (0, "00115") => true,
4375 (0, _) => false,
4376 (1, "00171") => true,
4377 (1, _) => false,
4378 _ => false,
4379 }
4380 }
4381 }
4382
4383 let seq_z01 = OwnedSegment {
4384 id: "SEQ".into(),
4385 elements: vec![vec!["Z01".into()], vec!["1".into()]],
4386 segment_number: 10,
4387 };
4388 let seq_z45 = OwnedSegment {
4389 id: "SEQ".into(),
4390 elements: vec![vec!["Z45".into()], vec!["1".into()]],
4391 segment_number: 20,
4392 };
4393 let nav = TwoSg8Variants {
4394 seq_z01: seq_z01.clone(),
4395 seq_z45: seq_z45.clone(),
4396 };
4397
4398 let evaluator = MockEvaluator::all_true(&[]);
4399 let validator = EdifactValidator::new(evaluator);
4400 let external = NoOpExternalProvider;
4401
4402 let workflow = AhbWorkflow {
4403 pruefidentifikator: "55218".to_string(),
4404 description: "Test".to_string(),
4405 communication_direction: None,
4406 ub_definitions: BTreeMap::new(),
4407 fields: vec![AhbFieldRule {
4408 segment_path: "SG4/SG8/SEQ/1229".to_string(),
4409 name: "Handlung, Code".to_string(),
4410 ahb_status: "X".to_string(),
4411 element_index: Some(0),
4412 component_index: Some(0),
4413 codes: vec![
4414 AhbCodeRule {
4415 value: "Z45".into(),
4416 description: "NNA".into(),
4417 ahb_status: "X [1P1..4294967295]".into(),
4418 },
4419 AhbCodeRule {
4420 value: "Z84".into(),
4421 description: "Differenz-NNA".into(),
4422 ahb_status: "X [1P0..4294967295]".into(),
4423 },
4424 ],
4425 parent_group_ahb_status: Some("Muss".to_string()),
4426 segment_ahb_status: None,
4427 mig_number: Some("00171".to_string()),
4428 }],
4429 };
4430
4431 let report = validator.validate_with_navigator(
4432 &[seq_z01, seq_z45],
4433 &workflow,
4434 &external,
4435 ValidationLevel::Full,
4436 &nav,
4437 );
4438 let pkg_errors: Vec<_> = report
4439 .by_category(ValidationCategory::Ahb)
4440 .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
4441 .collect();
4442 assert!(
4443 pkg_errors.is_empty(),
4444 "Package rule with mig=00171 must only count the Z45 instance (which has 1 code), not the sibling Z01 variant. Got: {:?}",
4445 pkg_errors
4446 );
4447 }
4448
4449 #[test]
4450 fn test_unt_count_correct() {
4451 let segments = vec![
4453 make_segment("UNH", vec![vec!["001"]]),
4454 make_segment("BGM", vec![vec!["E01"]]),
4455 make_segment("DTM", vec![vec!["137", "20250401"]]),
4456 make_segment("UNT", vec![vec!["4", "001"]]),
4457 ];
4458 assert!(
4459 validate_unt_segment_count(&segments).is_none(),
4460 "Correct count should produce no issue"
4461 );
4462 }
4463
4464 #[test]
4465 fn test_unt_count_mismatch() {
4466 let segments = vec![
4468 make_segment("UNH", vec![vec!["001"]]),
4469 make_segment("BGM", vec![vec!["E01"]]),
4470 make_segment("UNT", vec![vec!["5", "001"]]),
4471 ];
4472 let issue =
4473 validate_unt_segment_count(&segments).expect("Mismatch should produce an issue");
4474 assert_eq!(issue.code(), ErrorCodes::UNT_SEGMENT_COUNT_MISMATCH);
4475 assert_eq!(issue.severity, Severity::Error);
4476 assert!(narrate(&issue).contains("declared 5"));
4477 assert!(narrate(&issue).contains("actual 3"));
4478 }
4479
4480 #[test]
4481 fn test_unt_count_excludes_envelope() {
4482 let segments = vec![
4484 make_segment("UNA", vec![]),
4485 make_segment("UNB", vec![vec!["UNOC", "3"]]),
4486 make_segment("UNH", vec![vec!["001"]]),
4487 make_segment("BGM", vec![vec!["E01"]]),
4488 make_segment("UNT", vec![vec!["3", "001"]]),
4489 make_segment("UNZ", vec![vec!["1"]]),
4490 ];
4491 assert!(
4492 validate_unt_segment_count(&segments).is_none(),
4493 "Envelope segments excluded — count should be 3 (UNH+BGM+UNT)"
4494 );
4495 }
4496
4497 #[test]
4498 fn test_unt_count_no_unt_returns_none() {
4499 let segments = vec![
4500 make_segment("UNH", vec![vec!["001"]]),
4501 make_segment("BGM", vec![vec!["E01"]]),
4502 ];
4503 assert!(
4504 validate_unt_segment_count(&segments).is_none(),
4505 "No UNT segment should return None (not our problem)"
4506 );
4507 }
4508
4509 #[test]
4510 fn test_unt_count_rejects_multi_message_input() {
4511 let segments = vec![
4513 make_segment("UNH", vec![vec!["001"]]),
4514 make_segment("BGM", vec![vec!["E01"]]),
4515 make_segment("UNT", vec![vec!["3", "001"]]),
4516 make_segment("UNH", vec![vec!["002"]]),
4517 make_segment("BGM", vec![vec!["E02"]]),
4518 make_segment("UNT", vec![vec!["3", "002"]]),
4519 ];
4520 let issue = validate_unt_segment_count(&segments)
4521 .expect("Multi-message input should produce an error");
4522 assert_eq!(issue.code(), ErrorCodes::UNT_SEGMENT_COUNT_MISMATCH);
4523 assert!(
4524 narrate(&issue).contains("2 UNH"),
4525 "Should mention UNH count: {}",
4526 narrate(&issue)
4527 );
4528 }
4529
4530 #[test]
4531 fn test_code_validation_accepts_multi_code_variant_qualifier() {
4532 let evaluator = MockEvaluator::new(vec![]);
4539 let validator = EdifactValidator::new(evaluator);
4540 let external = NoOpExternalProvider;
4541
4542 let rff_z39 = OwnedSegment {
4543 id: "RFF".to_string(),
4544 elements: vec![
4545 vec!["RFF".to_string()],
4546 vec!["Z39".to_string(), "REF1".to_string()],
4547 ],
4548 segment_number: 1,
4549 };
4550
4551 let workflow = AhbWorkflow {
4552 pruefidentifikator: "55035".to_string(),
4553 description: "Test".to_string(),
4554 communication_direction: None,
4555 fields: vec![
4556 AhbFieldRule {
4558 segment_path: "SG4/SG8/RFF/C506/1153".to_string(),
4559 name: "Referenznummer Qualifier".to_string(),
4560 ahb_status: "Muss".to_string(),
4561 codes: vec![
4562 AhbCodeRule {
4563 value: "Z31".to_string(),
4564 description: "".to_string(),
4565 ahb_status: "X".to_string(),
4566 },
4567 AhbCodeRule {
4568 value: "Z39".to_string(),
4569 description: "".to_string(),
4570 ahb_status: "X".to_string(),
4571 },
4572 ],
4573 parent_group_ahb_status: None,
4574 segment_ahb_status: None,
4575 element_index: Some(1),
4576 component_index: Some(0),
4577 mig_number: Some("00075".to_string()),
4578 },
4579 AhbFieldRule {
4581 segment_path: "SG4/SG8/RFF/C506/1153".to_string(),
4582 name: "Referenznummer Qualifier".to_string(),
4583 ahb_status: "Muss".to_string(),
4584 codes: vec![AhbCodeRule {
4585 value: "Z33".to_string(),
4586 description: "".to_string(),
4587 ahb_status: "X".to_string(),
4588 }],
4589 parent_group_ahb_status: None,
4590 segment_ahb_status: None,
4591 element_index: Some(1),
4592 component_index: Some(0),
4593 mig_number: Some("00078".to_string()),
4594 },
4595 ],
4596 ub_definitions: BTreeMap::new(),
4597 };
4598
4599 let report = validator.validate(
4600 &[rff_z39],
4601 &workflow,
4602 &external,
4603 ValidationLevel::Conditions,
4604 );
4605
4606 let code_errors: Vec<_> = report
4607 .by_category(ValidationCategory::Code)
4608 .filter(|i| {
4609 i.severity == Severity::Error && i.code() == ErrorCodes::CODE_NOT_ALLOWED_FOR_PID
4610 })
4611 .collect();
4612 assert!(
4613 code_errors.is_empty(),
4614 "RFF+Z39 must be accepted (Z39 is valid for mig=00075). Got: {:?}",
4615 code_errors
4616 );
4617 }
4618
4619 #[test]
4620 fn test_code_validation_disambiguates_migs_by_full_code_profile() {
4621 let evaluator = MockEvaluator::new(vec![]);
4627 let validator = EdifactValidator::new(evaluator);
4628 let external = NoOpExternalProvider;
4629
4630 let pia_5_z12 = OwnedSegment {
4634 id: "PIA".to_string(),
4635 elements: vec![vec!["5".to_string()], vec!["Z12".to_string()]],
4636 segment_number: 1,
4637 };
4638 let pia_5_srw = OwnedSegment {
4639 id: "PIA".to_string(),
4640 elements: vec![vec!["5".to_string()], vec!["SRW".to_string()]],
4641 segment_number: 2,
4642 };
4643
4644 let make_rules = |mig: &str, composite_code: &str| {
4645 vec![
4646 AhbFieldRule {
4647 segment_path: "SG4/SG8/PIA/4347".to_string(),
4648 name: "Produkt-ID-Funktion".to_string(),
4649 ahb_status: "Muss".to_string(),
4650 codes: vec![AhbCodeRule {
4651 value: "5".to_string(),
4652 description: "".to_string(),
4653 ahb_status: "X".to_string(),
4654 }],
4655 parent_group_ahb_status: None,
4656 segment_ahb_status: None,
4657 element_index: Some(0),
4658 component_index: Some(0),
4659 mig_number: Some(mig.to_string()),
4660 },
4661 AhbFieldRule {
4662 segment_path: "SG4/SG8/PIA/C212/7143".to_string(),
4663 name: "Artikel/Dienstleistung-Identifikator".to_string(),
4664 ahb_status: "Muss".to_string(),
4665 codes: vec![AhbCodeRule {
4666 value: composite_code.to_string(),
4667 description: "".to_string(),
4668 ahb_status: "X".to_string(),
4669 }],
4670 parent_group_ahb_status: None,
4671 segment_ahb_status: None,
4672 element_index: Some(1),
4673 component_index: Some(0),
4674 mig_number: Some(mig.to_string()),
4675 },
4676 ]
4677 };
4678
4679 let mut fields = make_rules("00108", "Z12");
4680 fields.extend(make_rules("00197", "SRW"));
4681
4682 let workflow = AhbWorkflow {
4683 pruefidentifikator: "55035".to_string(),
4684 description: "Test".to_string(),
4685 communication_direction: None,
4686 fields,
4687 ub_definitions: BTreeMap::new(),
4688 };
4689
4690 let report = validator.validate(
4691 &[pia_5_z12, pia_5_srw],
4692 &workflow,
4693 &external,
4694 ValidationLevel::Conditions,
4695 );
4696
4697 let code_errors: Vec<_> = report
4698 .by_category(ValidationCategory::Code)
4699 .filter(|i| {
4700 i.severity == Severity::Error && i.code() == ErrorCodes::CODE_NOT_ALLOWED_FOR_PID
4701 })
4702 .collect();
4703 assert!(
4704 code_errors.is_empty(),
4705 "Both PIA+5+Z12 (mig=00108) and PIA+5+SRW (mig=00197) must be accepted. Got: {:?}",
4706 code_errors
4707 );
4708 }
4709}