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 if let Some(status) = group.ahb_status {
1265 if crate::eval::refuses_presence(status, evaluator, scoped_ctx, ub_definitions) {
1266 let group_path: Vec<&str> = path.iter().map(|(g, _)| *g).collect();
1267 let mut issue = ValidationIssue::new(
1268 Severity::Warning,
1269 IssueKind::GroupNotAllowed {
1270 group: group_path.join("/"),
1271 },
1272 )
1273 .with_field_path(group_path.join("/"))
1274 .with_rule(status)
1275 .with_instance_index(instance_index);
1276 if let Some(num) = group.fields.iter().find_map(|n| n.matched_segment_number) {
1277 issue = issue.with_position(crate::SegmentPosition {
1278 segment_number: num,
1279 byte_offset: 0,
1280 message_number: 1,
1281 });
1282 }
1283 report.add_issue(issue);
1284 }
1285 }
1286 for node in &group.fields {
1287 evaluate_node(
1288 node,
1289 scoped_ctx,
1290 expr_eval,
1291 evaluator,
1292 ub_definitions,
1293 tag_migs,
1294 Some(instance_index),
1295 report,
1296 );
1297 }
1298 for child in &group.children {
1299 let mut child_path = path.to_vec();
1300 child_path.push((child.group_id, child.instance_index));
1301 let child_ctx = scoped_ctx.with_scope(GroupScope { path: &child_path });
1302 walk_group_instance(
1303 child,
1304 &child_path,
1305 &child_ctx,
1306 expr_eval,
1307 evaluator,
1308 ub_definitions,
1309 tag_migs,
1310 instance_index,
1311 report,
1312 );
1313 }
1314}
1315
1316fn collect_packages(expr: &ConditionExpr, out: &mut Vec<(u32, u32, u32)>) {
1318 match expr {
1319 ConditionExpr::Package { id, min, max } => {
1320 out.push((*id, *min, *max));
1321 }
1322 ConditionExpr::And(exprs) | ConditionExpr::Or(exprs) => {
1323 for e in exprs {
1324 collect_packages(e, out);
1325 }
1326 }
1327 ConditionExpr::Xor(left, right) => {
1328 collect_packages(left, out);
1329 collect_packages(right, out);
1330 }
1331 ConditionExpr::Not(inner) => {
1332 collect_packages(inner, out);
1333 }
1334 ConditionExpr::Ref(_) => {}
1335 }
1336}
1337
1338fn is_optional_segment_status(status: &str) -> bool {
1348 let trimmed = status.trim();
1349 trimmed.starts_with("Kann") || trimmed.starts_with("Soll")
1350}
1351
1352fn is_qualifier_field(path: &str) -> bool {
1362 let parts: Vec<&str> = path.split('/').filter(|p| !p.starts_with("SG")).collect();
1363 matches!(parts.len(), 2 | 3)
1365}
1366
1367#[derive(Default)]
1375struct MigCodeBucket<'a> {
1376 qualifier_position: Option<(usize, usize)>,
1379 qualifier_values: HashSet<&'a str>,
1382 codes: HashMap<(usize, usize), HashSet<&'a str>>,
1385}
1386
1387fn partition_codes_by_mig(
1392 workflow: &AhbWorkflow,
1393) -> HashMap<(String, String), HashMap<Option<String>, MigCodeBucket<'_>>> {
1394 let mut out: HashMap<(String, String), HashMap<Option<String>, MigCodeBucket>> = HashMap::new();
1395 for field in &workflow.fields {
1396 if field.codes.is_empty() || !is_qualifier_field(&field.segment_path) {
1397 continue;
1398 }
1399 let tag = extract_segment_id(&field.segment_path);
1400 let group_key = extract_group_path_key(&field.segment_path);
1401 let mig = field.mig_number.clone();
1402 let el = field.element_index.unwrap_or(0);
1403 let c = field.component_index.unwrap_or(0);
1404
1405 let bucket = out
1406 .entry((group_key, tag))
1407 .or_default()
1408 .entry(mig)
1409 .or_default();
1410
1411 let required: Vec<&str> = field
1418 .codes
1419 .iter()
1420 .filter(|code| code.ahb_status.starts_with('X') || code.ahb_status.starts_with("Muss"))
1421 .map(|code| code.value.as_str())
1422 .collect();
1423
1424 if !required.is_empty() {
1431 if bucket.qualifier_position.is_none() {
1432 bucket.qualifier_position = Some((el, c));
1433 }
1434 if bucket.qualifier_position == Some((el, c)) {
1435 bucket.qualifier_values.extend(required.iter().copied());
1436 }
1437 }
1438
1439 for v in required {
1440 bucket.codes.entry((el, c)).or_default().insert(v);
1441 }
1442 }
1443 out
1444}
1445
1446fn global_qualifiers_by_tag(
1452 by_loc: &HashMap<(String, String), HashMap<Option<String>, MigCodeBucket<'_>>>,
1453) -> HashMap<String, HashMap<(usize, usize), HashSet<String>>> {
1454 let mut out: HashMap<String, HashMap<(usize, usize), HashSet<String>>> = HashMap::new();
1455 for ((_group, tag), migs) in by_loc {
1456 let tag_entry = out.entry(tag.clone()).or_default();
1457 for bucket in migs.values() {
1458 if let Some(pos) = bucket.qualifier_position {
1459 tag_entry
1460 .entry(pos)
1461 .or_default()
1462 .extend(bucket.qualifier_values.iter().map(|s| s.to_string()));
1463 }
1464 }
1465 }
1466 out
1467}
1468
1469fn match_segment_to_mig<'a, 'b>(
1480 seg: &OwnedSegment,
1481 migs: &'a HashMap<Option<String>, MigCodeBucket<'b>>,
1482) -> Option<&'a MigCodeBucket<'b>> {
1483 let actual_at = |el: usize, c: usize| -> &str {
1484 seg.elements
1485 .get(el)
1486 .and_then(|e| e.get(c))
1487 .map(|s| s.as_str())
1488 .unwrap_or("")
1489 };
1490
1491 let mut best: Option<&MigCodeBucket> = None;
1492 let mut best_matches = 0usize;
1493
1494 for bucket in migs.values() {
1495 let Some((el, c)) = bucket.qualifier_position else {
1496 continue;
1497 };
1498 if !bucket.qualifier_values.contains(actual_at(el, c)) {
1499 continue;
1500 }
1501 let extra_matches = bucket
1502 .codes
1503 .iter()
1504 .filter(|(pos, _)| **pos != (el, c))
1505 .filter(|((e, k), allowed)| {
1506 let v = actual_at(*e, *k);
1507 !v.is_empty() && allowed.contains(v)
1508 })
1509 .count();
1510 if best.is_none() || extra_matches > best_matches {
1511 best = Some(bucket);
1512 best_matches = extra_matches;
1513 }
1514 }
1515 best
1516}
1517
1518fn extract_group_path_key(path: &str) -> String {
1523 let sg_parts: Vec<&str> = path
1524 .split('/')
1525 .take_while(|p| p.starts_with("SG"))
1526 .collect();
1527 sg_parts.join("/")
1528}
1529
1530fn extract_segment_id(path: &str) -> String {
1532 for part in path.split('/') {
1533 if part.starts_with("SG") || part.starts_with("C_") || part.starts_with("D_") {
1535 continue;
1536 }
1537 if part.len() >= 3
1539 && part
1540 .chars()
1541 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
1542 {
1543 return part.to_string();
1544 }
1545 }
1546 path.split('/').next_back().unwrap_or(path).to_string()
1548}
1549
1550pub fn validate_unt_segment_count(segments: &[OwnedSegment]) -> Option<ValidationIssue> {
1562 let unh_count = segments.iter().filter(|s| s.id == "UNH").count();
1564 if unh_count > 1 {
1565 return Some(ValidationIssue::new(
1566 Severity::Error,
1567 IssueKind::UntCountNotVerifiable { unh_count },
1568 ));
1569 }
1570
1571 let unt = segments.iter().rfind(|s| s.id == "UNT")?;
1573 let declared: usize = unt.get_element(0).parse().ok()?;
1574
1575 let actual = segments
1577 .iter()
1578 .filter(|s| s.id != "UNA" && s.id != "UNB" && s.id != "UNZ")
1579 .count();
1580
1581 if declared != actual {
1582 Some(
1583 ValidationIssue::new(
1584 Severity::Error,
1585 IssueKind::UntSegmentCountMismatch { declared, actual },
1586 )
1587 .with_field_path("UNT/0074")
1588 .with_expected(actual.to_string())
1589 .with_actual(declared.to_string()),
1590 )
1591 } else {
1592 None
1593 }
1594}
1595
1596#[cfg(test)]
1597mod tests {
1598 use super::super::codes::ErrorCodes;
1599 use super::super::issue::ValidationCategory;
1600 use super::*;
1601 use crate::display::{EdifactView, IssueNarrator, IssueView, TechnicalNarrator};
1602 use crate::eval::{ConditionResult as CR, NoOpExternalProvider};
1603 use std::collections::HashMap;
1604
1605 fn narrate(issue: &ValidationIssue) -> String {
1608 TechnicalNarrator.describe(issue, EdifactView.location(issue).as_deref())
1609 }
1610
1611 struct MockEvaluator {
1613 results: HashMap<u32, CR>,
1614 }
1615
1616 impl MockEvaluator {
1617 fn new(results: Vec<(u32, CR)>) -> Self {
1618 Self {
1619 results: results.into_iter().collect(),
1620 }
1621 }
1622
1623 fn all_true(ids: &[u32]) -> Self {
1624 Self::new(ids.iter().map(|&id| (id, CR::True)).collect())
1625 }
1626 }
1627
1628 impl ConditionEvaluator for MockEvaluator {
1629 fn evaluate(&self, condition: u32, _ctx: &EvaluationContext) -> CR {
1630 self.results.get(&condition).copied().unwrap_or(CR::Unknown)
1631 }
1632 fn is_external(&self, _condition: u32) -> bool {
1633 false
1634 }
1635 fn message_type(&self) -> &str {
1636 "UTILMD"
1637 }
1638 fn format_version(&self) -> &str {
1639 "FV2510"
1640 }
1641 }
1642
1643 #[test]
1646 fn a_present_code_is_judged_by_its_own_status() {
1647 let code = |value: &str, status: &str| AhbCodeRule {
1649 value: value.to_string(),
1650 description: String::new(),
1651 ahb_status: status.to_string(),
1652 };
1653 let field = AhbFieldRule {
1654 segment_path: "SG4/STS/C556/9013".to_string(),
1655 ahb_status: "X [192]".to_string(),
1656 codes: vec![code("E01", "X [192]"), code("E03", "X")],
1657 ..Default::default()
1658 };
1659 assert_eq!(governing_status(&field, Some("E03")), "X");
1660 assert_eq!(governing_status(&field, Some("E01")), "X [192]");
1661 assert_eq!(governing_status(&field, None), "X [192]");
1662 assert_eq!(governing_status(&field, Some("ZZZ")), "X [192]");
1663
1664 let own = AhbFieldRule {
1666 ahb_status: "X [931]".to_string(),
1667 codes: vec![code("303", "X")],
1668 ..Default::default()
1669 };
1670 assert_eq!(governing_status(&own, Some("303")), "X [931]");
1671 }
1672
1673 #[test]
1674 fn test_extract_segment_id_simple() {
1675 assert_eq!(extract_segment_id("NAD"), "NAD");
1676 }
1677
1678 #[test]
1679 fn test_extract_segment_id_with_sg_prefix() {
1680 assert_eq!(extract_segment_id("SG2/NAD/C082/3039"), "NAD");
1681 }
1682
1683 #[test]
1684 fn test_extract_segment_id_nested_sg() {
1685 assert_eq!(extract_segment_id("SG4/SG8/SEQ/C286/6350"), "SEQ");
1686 }
1687
1688 #[test]
1691 fn test_validate_missing_mandatory_field() {
1692 let evaluator = MockEvaluator::all_true(&[182, 152]);
1693 let validator = EdifactValidator::new(evaluator);
1694 let external = NoOpExternalProvider;
1695
1696 let workflow = AhbWorkflow {
1697 pruefidentifikator: "11001".to_string(),
1698 description: "Test".to_string(),
1699 communication_direction: None,
1700 fields: vec![AhbFieldRule {
1701 segment_path: "SG2/NAD/C082/3039".to_string(),
1702 name: "MP-ID des MSB".to_string(),
1703 ahb_status: "Muss [182] ∧ [152]".to_string(),
1704 codes: vec![],
1705 parent_group_ahb_status: None,
1706 segment_ahb_status: None,
1707 ..Default::default()
1708 }],
1709 ub_definitions: BTreeMap::new(),
1710 };
1711
1712 let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
1714
1715 assert!(!report.is_valid());
1717 let errors: Vec<_> = report.errors().collect();
1718 assert_eq!(errors.len(), 1);
1719 assert_eq!(errors[0].code(), ErrorCodes::MISSING_REQUIRED_FIELD);
1720 assert!(narrate(errors[0]).contains("MP-ID des MSB"));
1721 }
1722
1723 #[test]
1724 fn test_validate_condition_false_no_error() {
1725 let evaluator = MockEvaluator::new(vec![(182, CR::True), (152, CR::False)]);
1727 let validator = EdifactValidator::new(evaluator);
1728 let external = NoOpExternalProvider;
1729
1730 let workflow = AhbWorkflow {
1731 pruefidentifikator: "11001".to_string(),
1732 description: "Test".to_string(),
1733 communication_direction: None,
1734 fields: vec![AhbFieldRule {
1735 segment_path: "NAD".to_string(),
1736 name: "Partnerrolle".to_string(),
1737 ahb_status: "Muss [182] ∧ [152]".to_string(),
1738 codes: vec![],
1739 parent_group_ahb_status: None,
1740 segment_ahb_status: None,
1741 ..Default::default()
1742 }],
1743 ub_definitions: BTreeMap::new(),
1744 };
1745
1746 let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
1747
1748 assert!(report.is_valid());
1750 }
1751
1752 fn validate_absent_field(status: &str, conditions: Vec<(u32, CR)>) -> ValidationReport {
1754 let validator = EdifactValidator::new(MockEvaluator::new(conditions));
1755 let workflow = AhbWorkflow {
1756 pruefidentifikator: "11001".to_string(),
1757 description: "Test".to_string(),
1758 communication_direction: None,
1759 fields: vec![AhbFieldRule {
1760 segment_path: "SG2/NAD/C082/3039".to_string(),
1761 name: "MP-ID".to_string(),
1762 ahb_status: status.to_string(),
1763 ..Default::default()
1764 }],
1765 ub_definitions: BTreeMap::new(),
1766 };
1767 validator.validate(
1768 &[],
1769 &workflow,
1770 &NoOpExternalProvider,
1771 ValidationLevel::Conditions,
1772 )
1773 }
1774
1775 #[test]
1776 fn a_later_soll_line_alone_does_not_require_the_field() {
1777 let report = validate_absent_field(
1779 "Muss [2119] \r\nSoll [130]",
1780 vec![(2119, CR::False), (130, CR::True)],
1781 );
1782 assert!(
1783 report.is_valid(),
1784 "{:?}",
1785 report.errors().collect::<Vec<_>>()
1786 );
1787 }
1788
1789 #[test]
1790 fn a_later_muss_line_requires_the_field() {
1791 let report = validate_absent_field(
1793 "Soll [47]\r\nMuss [46]",
1794 vec![(47, CR::False), (46, CR::True)],
1795 );
1796 let errors: Vec<_> = report.errors().collect();
1797 assert_eq!(errors.len(), 1);
1798 assert_eq!(errors[0].code(), ErrorCodes::MISSING_REQUIRED_FIELD);
1799 }
1800
1801 #[test]
1802 fn a_weaker_unknown_line_does_not_stop_a_muss() {
1803 let report = validate_absent_field("Muss [2119]\nSoll [130]", vec![(2119, CR::True)]);
1805 let errors: Vec<_> = report.errors().collect();
1806 assert_eq!(errors.len(), 1);
1807 assert_eq!(errors[0].code(), ErrorCodes::MISSING_REQUIRED_FIELD);
1808 }
1809
1810 #[test]
1811 fn a_stricter_unknown_line_leaves_the_status_undecided() {
1812 let report = validate_absent_field("Muss [2119]\nSoll [130]", vec![(130, CR::True)]);
1813 assert!(report.is_valid());
1814 let infos: Vec<_> = report.infos().collect();
1815 assert_eq!(infos.len(), 1, "{infos:?}");
1816 match &infos[0].kind {
1817 IssueKind::ConditionUnknown { unresolved, .. } => {
1818 assert_eq!(unresolved.missing, vec![2119]);
1819 }
1820 other => panic!("expected ConditionUnknown, got {other:?}"),
1821 }
1822 }
1823
1824 #[test]
1825 fn test_validate_condition_unknown_adds_info() {
1826 let evaluator = MockEvaluator::new(vec![(182, CR::True)]);
1828 let validator = EdifactValidator::new(evaluator);
1830 let external = NoOpExternalProvider;
1831
1832 let workflow = AhbWorkflow {
1833 pruefidentifikator: "11001".to_string(),
1834 description: "Test".to_string(),
1835 communication_direction: None,
1836 fields: vec![AhbFieldRule {
1837 segment_path: "NAD".to_string(),
1838 name: "Partnerrolle".to_string(),
1839 ahb_status: "Muss [182] ∧ [152]".to_string(),
1840 codes: vec![],
1841 parent_group_ahb_status: None,
1842 segment_ahb_status: None,
1843 ..Default::default()
1844 }],
1845 ub_definitions: BTreeMap::new(),
1846 };
1847
1848 let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
1849
1850 assert!(report.is_valid());
1852 let infos: Vec<_> = report.infos().collect();
1853 assert_eq!(infos.len(), 1);
1854 assert_eq!(infos[0].code(), ErrorCodes::CONDITION_UNKNOWN);
1855 }
1856
1857 #[test]
1858 fn test_validate_structure_level_skips_conditions() {
1859 let evaluator = MockEvaluator::all_true(&[182, 152]);
1860 let validator = EdifactValidator::new(evaluator);
1861 let external = NoOpExternalProvider;
1862
1863 let workflow = AhbWorkflow {
1864 pruefidentifikator: "11001".to_string(),
1865 description: "Test".to_string(),
1866 communication_direction: None,
1867 fields: vec![AhbFieldRule {
1868 segment_path: "NAD".to_string(),
1869 name: "Partnerrolle".to_string(),
1870 ahb_status: "Muss [182] ∧ [152]".to_string(),
1871 codes: vec![],
1872 parent_group_ahb_status: None,
1873 segment_ahb_status: None,
1874 ..Default::default()
1875 }],
1876 ub_definitions: BTreeMap::new(),
1877 };
1878
1879 let report = validator.validate(&[], &workflow, &external, ValidationLevel::Structure);
1881
1882 assert!(report.is_valid());
1884 assert_eq!(report.by_category(ValidationCategory::Ahb).count(), 0);
1885 }
1886
1887 #[test]
1888 fn test_validate_empty_workflow_no_condition_errors() {
1889 let evaluator = MockEvaluator::all_true(&[]);
1890 let validator = EdifactValidator::new(evaluator);
1891 let external = NoOpExternalProvider;
1892
1893 let empty_workflow = AhbWorkflow {
1894 pruefidentifikator: String::new(),
1895 description: String::new(),
1896 communication_direction: None,
1897 fields: vec![],
1898 ub_definitions: BTreeMap::new(),
1899 };
1900
1901 let report = validator.validate(&[], &empty_workflow, &external, ValidationLevel::Full);
1902
1903 assert!(report.is_valid());
1904 }
1905
1906 #[test]
1907 fn test_validate_bare_muss_always_required() {
1908 let evaluator = MockEvaluator::new(vec![]);
1909 let validator = EdifactValidator::new(evaluator);
1910 let external = NoOpExternalProvider;
1911
1912 let workflow = AhbWorkflow {
1913 pruefidentifikator: "55001".to_string(),
1914 description: "Test".to_string(),
1915 communication_direction: Some("NB an LF".to_string()),
1916 fields: vec![AhbFieldRule {
1917 segment_path: "SG2/NAD/3035".to_string(),
1918 name: "Partnerrolle".to_string(),
1919 ahb_status: "Muss".to_string(), codes: vec![],
1921 parent_group_ahb_status: None,
1922 segment_ahb_status: None,
1923 ..Default::default()
1924 }],
1925 ub_definitions: BTreeMap::new(),
1926 };
1927
1928 let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
1929
1930 assert!(!report.is_valid());
1932 assert_eq!(report.error_count(), 1);
1933 }
1934
1935 #[test]
1936 fn test_validate_x_status_is_mandatory() {
1937 let evaluator = MockEvaluator::new(vec![]);
1938 let validator = EdifactValidator::new(evaluator);
1939 let external = NoOpExternalProvider;
1940
1941 let workflow = AhbWorkflow {
1942 pruefidentifikator: "55001".to_string(),
1943 description: "Test".to_string(),
1944 communication_direction: None,
1945 fields: vec![AhbFieldRule {
1946 segment_path: "DTM".to_string(),
1947 name: "Datum".to_string(),
1948 ahb_status: "X".to_string(),
1949 codes: vec![],
1950 parent_group_ahb_status: None,
1951 segment_ahb_status: None,
1952 ..Default::default()
1953 }],
1954 ub_definitions: BTreeMap::new(),
1955 };
1956
1957 let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
1958
1959 assert!(!report.is_valid());
1960 let errors: Vec<_> = report.errors().collect();
1961 assert_eq!(errors[0].code(), ErrorCodes::MISSING_REQUIRED_FIELD);
1962 }
1963
1964 #[test]
1965 fn test_validate_soll_not_mandatory() {
1966 let evaluator = MockEvaluator::new(vec![]);
1967 let validator = EdifactValidator::new(evaluator);
1968 let external = NoOpExternalProvider;
1969
1970 let workflow = AhbWorkflow {
1971 pruefidentifikator: "55001".to_string(),
1972 description: "Test".to_string(),
1973 communication_direction: None,
1974 fields: vec![AhbFieldRule {
1975 segment_path: "DTM".to_string(),
1976 name: "Datum".to_string(),
1977 ahb_status: "Soll".to_string(),
1978 codes: vec![],
1979 parent_group_ahb_status: None,
1980 segment_ahb_status: None,
1981 ..Default::default()
1982 }],
1983 ub_definitions: BTreeMap::new(),
1984 };
1985
1986 let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
1987
1988 assert!(report.is_valid());
1990 }
1991
1992 #[test]
1993 fn test_report_includes_metadata() {
1994 let evaluator = MockEvaluator::new(vec![]);
1995 let validator = EdifactValidator::new(evaluator);
1996 let external = NoOpExternalProvider;
1997
1998 let workflow = AhbWorkflow {
1999 pruefidentifikator: "55001".to_string(),
2000 description: String::new(),
2001 communication_direction: None,
2002 fields: vec![],
2003 ub_definitions: BTreeMap::new(),
2004 };
2005
2006 let report = validator.validate(&[], &workflow, &external, ValidationLevel::Full);
2007
2008 assert_eq!(report.format_version.as_deref(), Some("FV2510"));
2009 assert_eq!(report.level, ValidationLevel::Full);
2010 assert_eq!(report.message_type, "UTILMD");
2011 assert_eq!(report.pruefidentifikator.as_deref(), Some("55001"));
2012 }
2013
2014 #[test]
2015 fn test_validate_with_navigator_returns_report() {
2016 let evaluator = MockEvaluator::all_true(&[]);
2017 let validator = EdifactValidator::new(evaluator);
2018 let external = NoOpExternalProvider;
2019 let nav = crate::eval::NoOpGroupNavigator;
2020
2021 let workflow = AhbWorkflow {
2022 pruefidentifikator: "55001".to_string(),
2023 description: "Test".to_string(),
2024 communication_direction: None,
2025 fields: vec![],
2026 ub_definitions: BTreeMap::new(),
2027 };
2028
2029 let report = validator.validate_with_navigator(
2030 &[],
2031 &workflow,
2032 &external,
2033 ValidationLevel::Full,
2034 &nav,
2035 );
2036 assert!(report.is_valid());
2037 }
2038
2039 #[test]
2040 fn test_code_validation_composite_paths_valid_codes() {
2041 let evaluator = MockEvaluator::new(vec![]);
2045 let validator = EdifactValidator::new(evaluator);
2046 let external = NoOpExternalProvider;
2047
2048 let unh_segment = OwnedSegment {
2049 id: "UNH".to_string(),
2050 elements: vec![
2051 vec!["ALEXANDE951842".to_string()],
2052 vec![
2053 "UTILMD".to_string(),
2054 "D".to_string(),
2055 "11A".to_string(),
2056 "UN".to_string(),
2057 "S2.1".to_string(),
2058 ],
2059 ],
2060 segment_number: 1,
2061 };
2062
2063 let workflow = AhbWorkflow {
2064 pruefidentifikator: "55001".to_string(),
2065 description: "Test".to_string(),
2066 communication_direction: None,
2067 fields: vec![
2068 AhbFieldRule {
2069 segment_path: "UNH/S009/0065".to_string(),
2070 name: "Nachrichtentyp".to_string(),
2071 ahb_status: "X".to_string(),
2072 codes: vec![AhbCodeRule {
2073 value: "UTILMD".to_string(),
2074 description: "Stammdaten".to_string(),
2075 ahb_status: "X".to_string(),
2076 }],
2077 parent_group_ahb_status: None,
2078 segment_ahb_status: None,
2079 element_index: Some(1),
2080 component_index: Some(0),
2081 ..Default::default()
2082 },
2083 AhbFieldRule {
2084 segment_path: "UNH/S009/0052".to_string(),
2085 name: "Version".to_string(),
2086 ahb_status: "X".to_string(),
2087 codes: vec![AhbCodeRule {
2088 value: "D".to_string(),
2089 description: "Draft".to_string(),
2090 ahb_status: "X".to_string(),
2091 }],
2092 parent_group_ahb_status: None,
2093 segment_ahb_status: None,
2094 element_index: Some(1),
2095 component_index: Some(1),
2096 ..Default::default()
2097 },
2098 ],
2099 ub_definitions: BTreeMap::new(),
2100 };
2101
2102 let report = validator.validate(
2103 &[unh_segment],
2104 &workflow,
2105 &external,
2106 ValidationLevel::Conditions,
2107 );
2108
2109 let code_errors: Vec<_> = report
2110 .by_category(ValidationCategory::Code)
2111 .filter(|i| i.severity == Severity::Error)
2112 .collect();
2113 assert!(
2114 code_errors.is_empty(),
2115 "Expected no code errors when composite values match allowed codes, got: {:?}",
2116 code_errors
2117 );
2118 }
2119
2120 #[test]
2121 fn test_code_validation_partitions_by_mig_number() {
2122 let evaluator = MockEvaluator::new(vec![]);
2126 let validator = EdifactValidator::new(evaluator);
2127 let external = NoOpExternalProvider;
2128
2129 let sts_7 = OwnedSegment {
2130 id: "STS".to_string(),
2131 elements: vec![
2132 vec!["7".to_string()],
2133 vec![String::new()],
2134 vec!["GH02".to_string()],
2135 vec!["ZW4".to_string()],
2136 ],
2137 segment_number: 1,
2138 };
2139 let sts_e01 = OwnedSegment {
2140 id: "STS".to_string(),
2141 elements: vec![
2142 vec!["E01".to_string()],
2143 vec![String::new()],
2144 vec!["A99".to_string(), "E_0614".to_string()],
2145 ],
2146 segment_number: 2,
2147 };
2148
2149 let workflow = AhbWorkflow {
2150 pruefidentifikator: "55018".to_string(),
2151 description: "Test".to_string(),
2152 communication_direction: None,
2153 fields: vec![
2154 AhbFieldRule {
2156 segment_path: "SG4/STS/C601/9015".to_string(),
2157 name: "Statuskategorie".to_string(),
2158 ahb_status: "X".to_string(),
2159 codes: vec![AhbCodeRule {
2160 value: "7".to_string(),
2161 description: "Transaktionsgrund".to_string(),
2162 ahb_status: "X".to_string(),
2163 }],
2164 parent_group_ahb_status: None,
2165 segment_ahb_status: None,
2166 element_index: Some(0),
2167 component_index: Some(0),
2168 mig_number: Some("00035".to_string()),
2169 },
2170 AhbFieldRule {
2171 segment_path: "SG4/STS/C556/9013".to_string(),
2172 name: "Statusanlaß".to_string(),
2173 ahb_status: "X".to_string(),
2174 codes: vec![AhbCodeRule {
2175 value: "E03".to_string(),
2176 description: "Transaktionsgrund".to_string(),
2177 ahb_status: "X".to_string(),
2178 }],
2179 parent_group_ahb_status: None,
2180 segment_ahb_status: None,
2181 element_index: Some(2),
2182 component_index: Some(0),
2183 mig_number: Some("00035".to_string()),
2184 },
2185 AhbFieldRule {
2187 segment_path: "SG4/STS/C601/9015".to_string(),
2188 name: "Statuskategorie".to_string(),
2189 ahb_status: "X".to_string(),
2190 codes: vec![AhbCodeRule {
2191 value: "E01".to_string(),
2192 description: "Antwort".to_string(),
2193 ahb_status: "X".to_string(),
2194 }],
2195 parent_group_ahb_status: None,
2196 segment_ahb_status: None,
2197 element_index: Some(0),
2198 component_index: Some(0),
2199 mig_number: Some("00036".to_string()),
2200 },
2201 ],
2202 ub_definitions: BTreeMap::new(),
2203 };
2204
2205 let report = validator.validate(
2206 &[sts_7, sts_e01],
2207 &workflow,
2208 &external,
2209 ValidationLevel::Conditions,
2210 );
2211
2212 let code_errors: Vec<_> = report
2213 .by_category(ValidationCategory::Code)
2214 .filter(|i| {
2215 i.severity == Severity::Error && i.code() == ErrorCodes::CODE_NOT_ALLOWED_FOR_PID
2216 })
2217 .collect();
2218 assert_eq!(
2219 code_errors.len(),
2220 1,
2221 "Expected one COD002 (for GH02 only), got: {:?}",
2222 code_errors
2223 );
2224 assert_eq!(code_errors[0].actual_value.as_deref(), Some("GH02"));
2225 }
2226
2227 #[test]
2228 fn test_code_validation_composite_paths_detects_invalid_code() {
2229 let evaluator = MockEvaluator::new(vec![]);
2232 let validator = EdifactValidator::new(evaluator);
2233 let external = NoOpExternalProvider;
2234
2235 let sts_segment = OwnedSegment {
2236 id: "STS".to_string(),
2237 elements: vec![
2238 vec!["7".to_string()],
2239 vec![String::new()],
2240 vec!["GH02".to_string()],
2241 vec!["ZW4".to_string()],
2242 ],
2243 segment_number: 1,
2244 };
2245
2246 let workflow = AhbWorkflow {
2247 pruefidentifikator: "55018".to_string(),
2248 description: "Test".to_string(),
2249 communication_direction: None,
2250 fields: vec![AhbFieldRule {
2251 segment_path: "SG4/STS/C556/9013".to_string(),
2252 name: "Statusanlaß".to_string(),
2253 ahb_status: "X".to_string(),
2254 codes: vec![AhbCodeRule {
2255 value: "E03".to_string(),
2256 description: "Transaktionsgrund".to_string(),
2257 ahb_status: "X".to_string(),
2258 }],
2259 parent_group_ahb_status: None,
2260 segment_ahb_status: None,
2261 element_index: Some(2),
2262 component_index: Some(0),
2263 ..Default::default()
2264 }],
2265 ub_definitions: BTreeMap::new(),
2266 };
2267
2268 let report = validator.validate(
2269 &[sts_segment],
2270 &workflow,
2271 &external,
2272 ValidationLevel::Conditions,
2273 );
2274
2275 let code_errors: Vec<_> = report
2276 .by_category(ValidationCategory::Code)
2277 .filter(|i| {
2278 i.severity == Severity::Error && i.code() == ErrorCodes::CODE_NOT_ALLOWED_FOR_PID
2279 })
2280 .collect();
2281 assert_eq!(
2282 code_errors.len(),
2283 1,
2284 "Expected COD002 for GH02, got: {:?}",
2285 code_errors
2286 );
2287 assert_eq!(code_errors[0].actual_value.as_deref(), Some("GH02"));
2288 }
2289
2290 #[test]
2291 fn test_cross_field_code_validation_valid_qualifiers() {
2292 let evaluator = MockEvaluator::new(vec![]);
2295 let validator = EdifactValidator::new(evaluator);
2296 let external = NoOpExternalProvider;
2297
2298 let nad_ms = OwnedSegment {
2299 id: "NAD".to_string(),
2300 elements: vec![vec!["MS".to_string()]],
2301 segment_number: 4,
2302 };
2303 let nad_mr = OwnedSegment {
2304 id: "NAD".to_string(),
2305 elements: vec![vec!["MR".to_string()]],
2306 segment_number: 5,
2307 };
2308
2309 let workflow = AhbWorkflow {
2310 pruefidentifikator: "55001".to_string(),
2311 description: "Test".to_string(),
2312 communication_direction: None,
2313 fields: vec![
2314 AhbFieldRule {
2315 segment_path: "SG2/NAD/3035".to_string(),
2316 name: "Absender".to_string(),
2317 ahb_status: "X".to_string(),
2318 codes: vec![AhbCodeRule {
2319 value: "MS".to_string(),
2320 description: "Absender".to_string(),
2321 ahb_status: "X".to_string(),
2322 }],
2323 parent_group_ahb_status: None,
2324 segment_ahb_status: None,
2325 ..Default::default()
2326 },
2327 AhbFieldRule {
2328 segment_path: "SG2/NAD/3035".to_string(),
2329 name: "Empfaenger".to_string(),
2330 ahb_status: "X".to_string(),
2331 codes: vec![AhbCodeRule {
2332 value: "MR".to_string(),
2333 description: "Empfaenger".to_string(),
2334 ahb_status: "X".to_string(),
2335 }],
2336 parent_group_ahb_status: None,
2337 segment_ahb_status: None,
2338 ..Default::default()
2339 },
2340 ],
2341 ub_definitions: BTreeMap::new(),
2342 };
2343
2344 let report = validator.validate(
2345 &[nad_ms, nad_mr],
2346 &workflow,
2347 &external,
2348 ValidationLevel::Conditions,
2349 );
2350
2351 let code_errors: Vec<_> = report
2352 .by_category(ValidationCategory::Code)
2353 .filter(|i| i.severity == Severity::Error)
2354 .collect();
2355 assert!(
2356 code_errors.is_empty(),
2357 "Expected no code errors for valid qualifiers, got: {:?}",
2358 code_errors
2359 );
2360 }
2361
2362 #[test]
2363 fn test_cross_field_code_validation_catches_invalid_qualifier() {
2364 let evaluator = MockEvaluator::new(vec![]);
2366 let validator = EdifactValidator::new(evaluator);
2367 let external = NoOpExternalProvider;
2368
2369 let nad_ms = OwnedSegment {
2370 id: "NAD".to_string(),
2371 elements: vec![vec!["MS".to_string()]],
2372 segment_number: 4,
2373 };
2374 let nad_mt = OwnedSegment {
2375 id: "NAD".to_string(),
2376 elements: vec![vec!["MT".to_string()]], segment_number: 5,
2378 };
2379
2380 let workflow = AhbWorkflow {
2381 pruefidentifikator: "55001".to_string(),
2382 description: "Test".to_string(),
2383 communication_direction: None,
2384 fields: vec![
2385 AhbFieldRule {
2386 segment_path: "SG2/NAD/3035".to_string(),
2387 name: "Absender".to_string(),
2388 ahb_status: "X".to_string(),
2389 codes: vec![AhbCodeRule {
2390 value: "MS".to_string(),
2391 description: "Absender".to_string(),
2392 ahb_status: "X".to_string(),
2393 }],
2394 parent_group_ahb_status: None,
2395 segment_ahb_status: None,
2396 ..Default::default()
2397 },
2398 AhbFieldRule {
2399 segment_path: "SG2/NAD/3035".to_string(),
2400 name: "Empfaenger".to_string(),
2401 ahb_status: "X".to_string(),
2402 codes: vec![AhbCodeRule {
2403 value: "MR".to_string(),
2404 description: "Empfaenger".to_string(),
2405 ahb_status: "X".to_string(),
2406 }],
2407 parent_group_ahb_status: None,
2408 segment_ahb_status: None,
2409 ..Default::default()
2410 },
2411 ],
2412 ub_definitions: BTreeMap::new(),
2413 };
2414
2415 let report = validator.validate(
2416 &[nad_ms, nad_mt],
2417 &workflow,
2418 &external,
2419 ValidationLevel::Conditions,
2420 );
2421
2422 let code_errors: Vec<_> = report
2423 .by_category(ValidationCategory::Code)
2424 .filter(|i| i.severity == Severity::Error)
2425 .collect();
2426 assert_eq!(code_errors.len(), 1, "Expected one COD002 error for MT");
2427 assert!(narrate(code_errors[0]).contains("MT"));
2428 assert!(narrate(code_errors[0]).contains("MR"));
2429 assert!(narrate(code_errors[0]).contains("MS"));
2430 }
2431
2432 #[test]
2433 fn test_cross_field_code_validation_unions_across_groups() {
2434 let evaluator = MockEvaluator::new(vec![]);
2438 let validator = EdifactValidator::new(evaluator);
2439 let external = NoOpExternalProvider;
2440
2441 let segments = vec![
2442 OwnedSegment {
2443 id: "NAD".to_string(),
2444 elements: vec![vec!["MS".to_string()]],
2445 segment_number: 3,
2446 },
2447 OwnedSegment {
2448 id: "NAD".to_string(),
2449 elements: vec![vec!["MR".to_string()]],
2450 segment_number: 4,
2451 },
2452 OwnedSegment {
2453 id: "NAD".to_string(),
2454 elements: vec![vec!["Z04".to_string()]],
2455 segment_number: 20,
2456 },
2457 OwnedSegment {
2458 id: "NAD".to_string(),
2459 elements: vec![vec!["Z09".to_string()]],
2460 segment_number: 21,
2461 },
2462 OwnedSegment {
2463 id: "NAD".to_string(),
2464 elements: vec![vec!["MT".to_string()]], segment_number: 22,
2466 },
2467 ];
2468
2469 let workflow = AhbWorkflow {
2470 pruefidentifikator: "55001".to_string(),
2471 description: "Test".to_string(),
2472 communication_direction: None,
2473 fields: vec![
2474 AhbFieldRule {
2475 segment_path: "SG2/NAD/3035".to_string(),
2476 name: "Absender".to_string(),
2477 ahb_status: "X".to_string(),
2478 codes: vec![AhbCodeRule {
2479 value: "MS".to_string(),
2480 description: "Absender".to_string(),
2481 ahb_status: "X".to_string(),
2482 }],
2483 parent_group_ahb_status: None,
2484 segment_ahb_status: None,
2485 ..Default::default()
2486 },
2487 AhbFieldRule {
2488 segment_path: "SG2/NAD/3035".to_string(),
2489 name: "Empfaenger".to_string(),
2490 ahb_status: "X".to_string(),
2491 codes: vec![AhbCodeRule {
2492 value: "MR".to_string(),
2493 description: "Empfaenger".to_string(),
2494 ahb_status: "X".to_string(),
2495 }],
2496 parent_group_ahb_status: None,
2497 segment_ahb_status: None,
2498 ..Default::default()
2499 },
2500 AhbFieldRule {
2501 segment_path: "SG4/SG12/NAD/3035".to_string(),
2502 name: "Anschlussnutzer".to_string(),
2503 ahb_status: "X".to_string(),
2504 codes: vec![AhbCodeRule {
2505 value: "Z04".to_string(),
2506 description: "Anschlussnutzer".to_string(),
2507 ahb_status: "X".to_string(),
2508 }],
2509 parent_group_ahb_status: None,
2510 segment_ahb_status: None,
2511 ..Default::default()
2512 },
2513 AhbFieldRule {
2514 segment_path: "SG4/SG12/NAD/3035".to_string(),
2515 name: "Korrespondenzanschrift".to_string(),
2516 ahb_status: "X".to_string(),
2517 codes: vec![AhbCodeRule {
2518 value: "Z09".to_string(),
2519 description: "Korrespondenzanschrift".to_string(),
2520 ahb_status: "X".to_string(),
2521 }],
2522 parent_group_ahb_status: None,
2523 segment_ahb_status: None,
2524 ..Default::default()
2525 },
2526 ],
2527 ub_definitions: BTreeMap::new(),
2528 };
2529
2530 let report =
2531 validator.validate(&segments, &workflow, &external, ValidationLevel::Conditions);
2532
2533 let code_errors: Vec<_> = report
2534 .by_category(ValidationCategory::Code)
2535 .filter(|i| i.severity == Severity::Error)
2536 .collect();
2537 assert_eq!(
2538 code_errors.len(),
2539 1,
2540 "Expected exactly one COD002 error for MT, got: {:?}",
2541 code_errors
2542 );
2543 assert!(narrate(code_errors[0]).contains("MT"));
2544 }
2545
2546 #[test]
2547 fn test_cross_field_code_validation_accepts_conditionally_allowed_codes() {
2548 let evaluator = MockEvaluator::new(vec![]);
2557 let validator = EdifactValidator::new(evaluator);
2558 let external = NoOpExternalProvider;
2559
2560 let qty_67 = OwnedSegment {
2561 id: "QTY".to_string(),
2562 elements: vec![vec!["67".to_string(), "0.185".to_string()]],
2563 segment_number: 10,
2564 };
2565
2566 let workflow = AhbWorkflow {
2567 pruefidentifikator: "13025".to_string(),
2568 description: "Test".to_string(),
2569 communication_direction: None,
2570 fields: vec![AhbFieldRule {
2571 segment_path: "SG5/SG6/SG9/SG10/QTY/qualifier".to_string(),
2572 name: "Menge, Qualifier".to_string(),
2573 ahb_status: "X".to_string(),
2574 codes: vec![
2575 AhbCodeRule {
2576 value: "220".to_string(),
2577 description: "Wahrer Wert".to_string(),
2578 ahb_status: "X".to_string(),
2579 },
2580 AhbCodeRule {
2581 value: "67".to_string(),
2582 description: "Ersatzwert".to_string(),
2583 ahb_status: "X [35] ∨ ([32] ∧ [77])".to_string(),
2584 },
2585 AhbCodeRule {
2586 value: "Z18".to_string(),
2587 description: "Vorläufiger Wert".to_string(),
2588 ahb_status: "X [35]".to_string(),
2589 },
2590 ],
2591 parent_group_ahb_status: None,
2592 segment_ahb_status: None,
2593 element_index: Some(0),
2594 component_index: Some(0),
2595 ..Default::default()
2596 }],
2597 ub_definitions: BTreeMap::new(),
2598 };
2599
2600 let report =
2601 validator.validate(&[qty_67], &workflow, &external, ValidationLevel::Conditions);
2602
2603 let code_errors: Vec<_> = report
2604 .by_category(ValidationCategory::Code)
2605 .filter(|i| i.severity == Severity::Error)
2606 .collect();
2607 assert!(
2608 code_errors.is_empty(),
2609 "QTY+67 should be accepted because code '67' is conditionally allowed for this PID (X [35] ∨ ([32] ∧ [77])). Got errors: {:?}",
2610 code_errors
2611 );
2612 }
2613
2614 #[test]
2615 fn test_is_qualifier_field_simple_paths() {
2616 assert!(is_qualifier_field("NAD/3035"));
2617 assert!(is_qualifier_field("SG2/NAD/3035"));
2618 assert!(is_qualifier_field("SG4/SG8/SEQ/6350"));
2619 assert!(is_qualifier_field("LOC/3227"));
2620 }
2621
2622 #[test]
2623 fn test_is_qualifier_field_composite_paths() {
2624 assert!(is_qualifier_field("UNH/S009/0065"));
2628 assert!(is_qualifier_field("NAD/C082/3039"));
2629 assert!(is_qualifier_field("SG2/NAD/C082/3039"));
2630 assert!(is_qualifier_field("SG4/STS/C556/9013"));
2631 }
2632
2633 #[test]
2634 fn test_is_qualifier_field_bare_segment() {
2635 assert!(!is_qualifier_field("NAD"));
2636 assert!(!is_qualifier_field("SG2/NAD"));
2637 }
2638
2639 #[test]
2640 fn test_is_qualifier_field_rejects_deep_paths() {
2641 assert!(!is_qualifier_field("SEG/A/B/C/D"));
2643 }
2644
2645 #[test]
2646 fn test_missing_qualifier_instance_is_detected() {
2647 let evaluator = MockEvaluator::new(vec![]);
2650 let validator = EdifactValidator::new(evaluator);
2651 let external = NoOpExternalProvider;
2652
2653 let nad_ms = OwnedSegment {
2654 id: "NAD".to_string(),
2655 elements: vec![vec!["MS".to_string()]],
2656 segment_number: 3,
2657 };
2658
2659 let workflow = AhbWorkflow {
2660 pruefidentifikator: "55001".to_string(),
2661 description: "Test".to_string(),
2662 communication_direction: None,
2663 fields: vec![
2664 AhbFieldRule {
2665 segment_path: "SG2/NAD/3035".to_string(),
2666 name: "Absender".to_string(),
2667 ahb_status: "X".to_string(),
2668 codes: vec![AhbCodeRule {
2669 value: "MS".to_string(),
2670 description: "Absender".to_string(),
2671 ahb_status: "X".to_string(),
2672 }],
2673 parent_group_ahb_status: None,
2674 segment_ahb_status: None,
2675 ..Default::default()
2676 },
2677 AhbFieldRule {
2678 segment_path: "SG2/NAD/3035".to_string(),
2679 name: "Empfaenger".to_string(),
2680 ahb_status: "Muss".to_string(),
2681 codes: vec![AhbCodeRule {
2682 value: "MR".to_string(),
2683 description: "Empfaenger".to_string(),
2684 ahb_status: "X".to_string(),
2685 }],
2686 parent_group_ahb_status: None,
2687 segment_ahb_status: None,
2688 ..Default::default()
2689 },
2690 ],
2691 ub_definitions: BTreeMap::new(),
2692 };
2693
2694 let report =
2695 validator.validate(&[nad_ms], &workflow, &external, ValidationLevel::Conditions);
2696
2697 let ahb_errors: Vec<_> = report
2698 .by_category(ValidationCategory::Ahb)
2699 .filter(|i| i.severity == Severity::Error)
2700 .collect();
2701 assert_eq!(
2702 ahb_errors.len(),
2703 1,
2704 "Expected AHB001 for missing NAD+MR, got: {:?}",
2705 ahb_errors
2706 );
2707 assert!(narrate(ahb_errors[0]).contains("Empfaenger"));
2708 }
2709
2710 #[test]
2711 fn test_present_qualifier_instance_no_error() {
2712 let evaluator = MockEvaluator::new(vec![]);
2714 let validator = EdifactValidator::new(evaluator);
2715 let external = NoOpExternalProvider;
2716
2717 let segments = vec![
2718 OwnedSegment {
2719 id: "NAD".to_string(),
2720 elements: vec![vec!["MS".to_string()]],
2721 segment_number: 3,
2722 },
2723 OwnedSegment {
2724 id: "NAD".to_string(),
2725 elements: vec![vec!["MR".to_string()]],
2726 segment_number: 4,
2727 },
2728 ];
2729
2730 let workflow = AhbWorkflow {
2731 pruefidentifikator: "55001".to_string(),
2732 description: "Test".to_string(),
2733 communication_direction: None,
2734 fields: vec![
2735 AhbFieldRule {
2736 segment_path: "SG2/NAD/3035".to_string(),
2737 name: "Absender".to_string(),
2738 ahb_status: "Muss".to_string(),
2739 codes: vec![AhbCodeRule {
2740 value: "MS".to_string(),
2741 description: "Absender".to_string(),
2742 ahb_status: "X".to_string(),
2743 }],
2744 parent_group_ahb_status: None,
2745 segment_ahb_status: None,
2746 ..Default::default()
2747 },
2748 AhbFieldRule {
2749 segment_path: "SG2/NAD/3035".to_string(),
2750 name: "Empfaenger".to_string(),
2751 ahb_status: "Muss".to_string(),
2752 codes: vec![AhbCodeRule {
2753 value: "MR".to_string(),
2754 description: "Empfaenger".to_string(),
2755 ahb_status: "X".to_string(),
2756 }],
2757 parent_group_ahb_status: None,
2758 segment_ahb_status: None,
2759 ..Default::default()
2760 },
2761 ],
2762 ub_definitions: BTreeMap::new(),
2763 };
2764
2765 let report =
2766 validator.validate(&segments, &workflow, &external, ValidationLevel::Conditions);
2767
2768 let ahb_errors: Vec<_> = report
2769 .by_category(ValidationCategory::Ahb)
2770 .filter(|i| i.severity == Severity::Error)
2771 .collect();
2772 assert!(
2773 ahb_errors.is_empty(),
2774 "Expected no AHB001 errors, got: {:?}",
2775 ahb_errors
2776 );
2777 }
2778
2779 #[test]
2780 fn test_extract_group_path_key() {
2781 assert_eq!(extract_group_path_key("SG2/NAD/3035"), "SG2");
2782 assert_eq!(extract_group_path_key("SG4/SG12/NAD/3035"), "SG4/SG12");
2783 assert_eq!(extract_group_path_key("NAD/3035"), "");
2784 assert_eq!(extract_group_path_key("SG4/SG8/SEQ/6350"), "SG4/SG8");
2785 }
2786
2787 fn validate_field_of_absent_group(
2789 group_status: &str,
2790 conditions: Vec<(u32, CR)>,
2791 ) -> ValidationReport {
2792 use mig_types::navigator::GroupNavigator;
2793
2794 struct NoSg9;
2795 impl GroupNavigator for NoSg9 {
2796 fn find_segments_in_group(&self, _: &str, _: &[&str], _: usize) -> Vec<OwnedSegment> {
2797 vec![]
2798 }
2799 fn find_segments_with_qualifier_in_group(
2800 &self,
2801 _: &str,
2802 _: usize,
2803 _: &str,
2804 _: &[&str],
2805 _: usize,
2806 ) -> Vec<OwnedSegment> {
2807 vec![]
2808 }
2809 fn group_instance_count(&self, _: &[&str]) -> usize {
2810 0
2811 }
2812 }
2813
2814 let validator = EdifactValidator::new(MockEvaluator::new(conditions));
2815 let workflow = AhbWorkflow {
2816 pruefidentifikator: "44001".to_string(),
2817 description: "Test".to_string(),
2818 communication_direction: None,
2819 fields: vec![AhbFieldRule {
2820 segment_path: "SG9/QTY/C186/6060".to_string(),
2821 name: "Menge".to_string(),
2822 ahb_status: "X".to_string(),
2823 parent_group_ahb_status: Some(group_status.to_string()),
2824 ..Default::default()
2825 }],
2826 ub_definitions: BTreeMap::new(),
2827 };
2828 validator.validate_with_navigator(
2829 &[],
2830 &workflow,
2831 &NoOpExternalProvider,
2832 ValidationLevel::Conditions,
2833 &NoSg9,
2834 )
2835 }
2836
2837 #[test]
2838 fn an_absent_group_whose_holding_line_is_soll_is_not_missing() {
2839 let report = validate_field_of_absent_group(
2841 "Soll [47]\r\nMuss [46]",
2842 vec![(47, CR::True), (46, CR::False)],
2843 );
2844 assert!(
2845 report.is_valid(),
2846 "{:?}",
2847 report.errors().collect::<Vec<_>>()
2848 );
2849 }
2850
2851 #[test]
2852 fn an_absent_group_whose_holding_line_is_muss_is_missing() {
2853 let report = validate_field_of_absent_group(
2854 "Soll [47]\r\nMuss [46]",
2855 vec![(47, CR::False), (46, CR::True)],
2856 );
2857 let errors: Vec<_> = report.errors().collect();
2858 assert_eq!(errors.len(), 1, "{errors:?}");
2859 assert_eq!(errors[0].code(), ErrorCodes::MISSING_REQUIRED_FIELD);
2860 }
2861
2862 #[test]
2863 fn test_absent_optional_group_no_missing_field_error() {
2864 use mig_types::navigator::GroupNavigator;
2867
2868 struct NavWithoutSG3;
2869 impl GroupNavigator for NavWithoutSG3 {
2870 fn find_segments_in_group(&self, _: &str, _: &[&str], _: usize) -> Vec<OwnedSegment> {
2871 vec![]
2872 }
2873 fn find_segments_with_qualifier_in_group(
2874 &self,
2875 _: &str,
2876 _: usize,
2877 _: &str,
2878 _: &[&str],
2879 _: usize,
2880 ) -> Vec<OwnedSegment> {
2881 vec![]
2882 }
2883 fn group_instance_count(&self, group_path: &[&str]) -> usize {
2884 match group_path {
2885 ["SG2"] => 2, ["SG2", "SG3"] => 0, _ => 0,
2888 }
2889 }
2890 }
2891
2892 let evaluator = MockEvaluator::new(vec![]);
2893 let validator = EdifactValidator::new(evaluator);
2894 let external = NoOpExternalProvider;
2895 let nav = NavWithoutSG3;
2896
2897 let segments = vec![
2899 OwnedSegment {
2900 id: "NAD".into(),
2901 elements: vec![vec!["MS".into()]],
2902 segment_number: 3,
2903 },
2904 OwnedSegment {
2905 id: "NAD".into(),
2906 elements: vec![vec!["MR".into()]],
2907 segment_number: 4,
2908 },
2909 ];
2910
2911 let workflow = AhbWorkflow {
2912 pruefidentifikator: "55001".to_string(),
2913 description: "Test".to_string(),
2914 communication_direction: None,
2915 fields: vec![
2916 AhbFieldRule {
2917 segment_path: "SG2/SG3/CTA/3139".to_string(),
2918 name: "Funktion des Ansprechpartners, Code".to_string(),
2919 ahb_status: "Muss".to_string(),
2920 codes: vec![],
2921 parent_group_ahb_status: None,
2922 segment_ahb_status: None,
2923 ..Default::default()
2924 },
2925 AhbFieldRule {
2926 segment_path: "SG2/SG3/CTA/C056/3412".to_string(),
2927 name: "Name vom Ansprechpartner".to_string(),
2928 ahb_status: "X".to_string(),
2929 codes: vec![],
2930 parent_group_ahb_status: None,
2931 segment_ahb_status: None,
2932 ..Default::default()
2933 },
2934 ],
2935 ub_definitions: BTreeMap::new(),
2936 };
2937
2938 let report = validator.validate_with_navigator(
2939 &segments,
2940 &workflow,
2941 &external,
2942 ValidationLevel::Conditions,
2943 &nav,
2944 );
2945
2946 let ahb_errors: Vec<_> = report
2947 .by_category(ValidationCategory::Ahb)
2948 .filter(|i| i.severity == Severity::Error)
2949 .collect();
2950 assert!(
2951 ahb_errors.is_empty(),
2952 "Expected no AHB001 errors when SG3 is absent, got: {:?}",
2953 ahb_errors
2954 );
2955 }
2956
2957 #[test]
2958 fn test_present_group_still_checks_mandatory_fields() {
2959 use mig_types::navigator::GroupNavigator;
2961
2962 struct NavWithSG3;
2963 impl GroupNavigator for NavWithSG3 {
2964 fn find_segments_in_group(&self, _: &str, _: &[&str], _: usize) -> Vec<OwnedSegment> {
2965 vec![]
2966 }
2967 fn find_segments_with_qualifier_in_group(
2968 &self,
2969 _: &str,
2970 _: usize,
2971 _: &str,
2972 _: &[&str],
2973 _: usize,
2974 ) -> Vec<OwnedSegment> {
2975 vec![]
2976 }
2977 fn group_instance_count(&self, group_path: &[&str]) -> usize {
2978 match group_path {
2979 ["SG2"] => 1,
2980 ["SG2", "SG3"] => 1, _ => 0,
2982 }
2983 }
2984 }
2985
2986 let evaluator = MockEvaluator::new(vec![]);
2987 let validator = EdifactValidator::new(evaluator);
2988 let external = NoOpExternalProvider;
2989 let nav = NavWithSG3;
2990
2991 let segments = vec![OwnedSegment {
2993 id: "NAD".into(),
2994 elements: vec![vec!["MS".into()]],
2995 segment_number: 3,
2996 }];
2997
2998 let workflow = AhbWorkflow {
2999 pruefidentifikator: "55001".to_string(),
3000 description: "Test".to_string(),
3001 communication_direction: None,
3002 fields: vec![AhbFieldRule {
3003 segment_path: "SG2/SG3/CTA/3139".to_string(),
3004 name: "Funktion des Ansprechpartners, Code".to_string(),
3005 ahb_status: "Muss".to_string(),
3006 codes: vec![],
3007 parent_group_ahb_status: None,
3008 segment_ahb_status: None,
3009 ..Default::default()
3010 }],
3011 ub_definitions: BTreeMap::new(),
3012 };
3013
3014 let report = validator.validate_with_navigator(
3015 &segments,
3016 &workflow,
3017 &external,
3018 ValidationLevel::Conditions,
3019 &nav,
3020 );
3021
3022 let ahb_errors: Vec<_> = report
3023 .by_category(ValidationCategory::Ahb)
3024 .filter(|i| i.severity == Severity::Error)
3025 .collect();
3026 assert_eq!(
3027 ahb_errors.len(),
3028 1,
3029 "Expected AHB001 error when SG3 is present but CTA missing"
3030 );
3031 assert!(narrate(ahb_errors[0]).contains("CTA"));
3032 }
3033
3034 #[test]
3035 fn test_missing_qualifier_with_navigator_is_detected() {
3036 use mig_types::navigator::GroupNavigator;
3039
3040 struct NavWithSG2;
3041 impl GroupNavigator for NavWithSG2 {
3042 fn find_segments_in_group(
3043 &self,
3044 segment_id: &str,
3045 group_path: &[&str],
3046 instance_index: usize,
3047 ) -> Vec<OwnedSegment> {
3048 if segment_id == "NAD" && group_path == ["SG2"] && instance_index == 0 {
3049 vec![OwnedSegment {
3050 id: "NAD".into(),
3051 elements: vec![vec!["MS".into()]],
3052 segment_number: 3,
3053 }]
3054 } else {
3055 vec![]
3056 }
3057 }
3058 fn find_segments_with_qualifier_in_group(
3059 &self,
3060 _: &str,
3061 _: usize,
3062 _: &str,
3063 _: &[&str],
3064 _: usize,
3065 ) -> Vec<OwnedSegment> {
3066 vec![]
3067 }
3068 fn group_instance_count(&self, group_path: &[&str]) -> usize {
3069 match group_path {
3070 ["SG2"] => 1,
3071 _ => 0,
3072 }
3073 }
3074 }
3075
3076 let evaluator = MockEvaluator::new(vec![]);
3077 let validator = EdifactValidator::new(evaluator);
3078 let external = NoOpExternalProvider;
3079 let nav = NavWithSG2;
3080
3081 let segments = vec![OwnedSegment {
3082 id: "NAD".into(),
3083 elements: vec![vec!["MS".into()]],
3084 segment_number: 3,
3085 }];
3086
3087 let workflow = AhbWorkflow {
3088 pruefidentifikator: "55001".to_string(),
3089 description: "Test".to_string(),
3090 communication_direction: None,
3091 fields: vec![
3092 AhbFieldRule {
3093 segment_path: "SG2/NAD/3035".to_string(),
3094 name: "Absender".to_string(),
3095 ahb_status: "X".to_string(),
3096 codes: vec![AhbCodeRule {
3097 value: "MS".to_string(),
3098 description: "Absender".to_string(),
3099 ahb_status: "X".to_string(),
3100 }],
3101 parent_group_ahb_status: None,
3102 segment_ahb_status: None,
3103 ..Default::default()
3104 },
3105 AhbFieldRule {
3106 segment_path: "SG2/NAD/3035".to_string(),
3107 name: "Empfaenger".to_string(),
3108 ahb_status: "Muss".to_string(),
3109 codes: vec![AhbCodeRule {
3110 value: "MR".to_string(),
3111 description: "Empfaenger".to_string(),
3112 ahb_status: "X".to_string(),
3113 }],
3114 parent_group_ahb_status: None,
3115 segment_ahb_status: None,
3116 ..Default::default()
3117 },
3118 ],
3119 ub_definitions: BTreeMap::new(),
3120 };
3121
3122 let report = validator.validate_with_navigator(
3123 &segments,
3124 &workflow,
3125 &external,
3126 ValidationLevel::Conditions,
3127 &nav,
3128 );
3129
3130 let ahb_errors: Vec<_> = report
3131 .by_category(ValidationCategory::Ahb)
3132 .filter(|i| i.severity == Severity::Error)
3133 .collect();
3134 assert_eq!(
3135 ahb_errors.len(),
3136 1,
3137 "Expected AHB001 for missing NAD+MR even with navigator, got: {:?}",
3138 ahb_errors
3139 );
3140 assert!(narrate(ahb_errors[0]).contains("Empfaenger"));
3141 }
3142
3143 #[test]
3144 fn test_optional_group_variant_absent_no_error() {
3145 use mig_types::navigator::GroupNavigator;
3150
3151 struct TestNav;
3152 impl GroupNavigator for TestNav {
3153 fn find_segments_in_group(
3154 &self,
3155 segment_id: &str,
3156 group_path: &[&str],
3157 instance_index: usize,
3158 ) -> Vec<OwnedSegment> {
3159 match (segment_id, group_path, instance_index) {
3160 ("LOC", ["SG4", "SG5"], 0) => vec![OwnedSegment {
3161 id: "LOC".into(),
3162 elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
3163 segment_number: 10,
3164 }],
3165 ("NAD", ["SG2"], 0) => vec![OwnedSegment {
3166 id: "NAD".into(),
3167 elements: vec![vec!["MS".into()]],
3168 segment_number: 3,
3169 }],
3170 _ => vec![],
3171 }
3172 }
3173 fn find_segments_with_qualifier_in_group(
3174 &self,
3175 _: &str,
3176 _: usize,
3177 _: &str,
3178 _: &[&str],
3179 _: usize,
3180 ) -> Vec<OwnedSegment> {
3181 vec![]
3182 }
3183 fn group_instance_count(&self, group_path: &[&str]) -> usize {
3184 match group_path {
3185 ["SG2"] => 1,
3186 ["SG4"] => 1,
3187 ["SG4", "SG5"] => 1, _ => 0,
3189 }
3190 }
3191 }
3192
3193 let evaluator = MockEvaluator::new(vec![]);
3194 let validator = EdifactValidator::new(evaluator);
3195 let external = NoOpExternalProvider;
3196 let nav = TestNav;
3197
3198 let segments = vec![
3199 OwnedSegment {
3200 id: "NAD".into(),
3201 elements: vec![vec!["MS".into()]],
3202 segment_number: 3,
3203 },
3204 OwnedSegment {
3205 id: "LOC".into(),
3206 elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
3207 segment_number: 10,
3208 },
3209 ];
3210
3211 let workflow = AhbWorkflow {
3212 pruefidentifikator: "55001".to_string(),
3213 description: "Test".to_string(),
3214 communication_direction: None,
3215 fields: vec![
3216 AhbFieldRule {
3218 segment_path: "SG2/NAD/3035".to_string(),
3219 name: "Absender".to_string(),
3220 ahb_status: "X".to_string(),
3221 codes: vec![AhbCodeRule {
3222 value: "MS".to_string(),
3223 description: "Absender".to_string(),
3224 ahb_status: "X".to_string(),
3225 }],
3226 parent_group_ahb_status: Some("Muss".to_string()),
3227 segment_ahb_status: None,
3228 ..Default::default()
3229 },
3230 AhbFieldRule {
3231 segment_path: "SG2/NAD/3035".to_string(),
3232 name: "Empfaenger".to_string(),
3233 ahb_status: "Muss".to_string(),
3234 codes: vec![AhbCodeRule {
3235 value: "MR".to_string(),
3236 description: "Empfaenger".to_string(),
3237 ahb_status: "X".to_string(),
3238 }],
3239 parent_group_ahb_status: Some("Muss".to_string()),
3240 segment_ahb_status: None,
3241 ..Default::default()
3242 },
3243 AhbFieldRule {
3245 segment_path: "SG4/SG5/LOC/3227".to_string(),
3246 name: "Ortsangabe, Qualifier (Z16)".to_string(),
3247 ahb_status: "X".to_string(),
3248 codes: vec![AhbCodeRule {
3249 value: "Z16".to_string(),
3250 description: "Marktlokation".to_string(),
3251 ahb_status: "X".to_string(),
3252 }],
3253 parent_group_ahb_status: Some("Kann".to_string()),
3254 segment_ahb_status: None,
3255 ..Default::default()
3256 },
3257 AhbFieldRule {
3258 segment_path: "SG4/SG5/LOC/3227".to_string(),
3259 name: "Ortsangabe, Qualifier (Z17)".to_string(),
3260 ahb_status: "Muss".to_string(),
3261 codes: vec![AhbCodeRule {
3262 value: "Z17".to_string(),
3263 description: "Messlokation".to_string(),
3264 ahb_status: "X".to_string(),
3265 }],
3266 parent_group_ahb_status: Some("Kann".to_string()),
3267 segment_ahb_status: None,
3268 ..Default::default()
3269 },
3270 ],
3271 ub_definitions: BTreeMap::new(),
3272 };
3273
3274 let report = validator.validate_with_navigator(
3275 &segments,
3276 &workflow,
3277 &external,
3278 ValidationLevel::Conditions,
3279 &nav,
3280 );
3281
3282 let ahb_errors: Vec<_> = report
3283 .by_category(ValidationCategory::Ahb)
3284 .filter(|i| i.severity == Severity::Error)
3285 .collect();
3286
3287 assert_eq!(
3290 ahb_errors.len(),
3291 1,
3292 "Expected only AHB001 for missing NAD+MR, got: {:?}",
3293 ahb_errors
3294 );
3295 assert!(
3296 narrate(ahb_errors[0]).contains("Empfaenger"),
3297 "Error should be for missing NAD+MR (Empfaenger)"
3298 );
3299 }
3300
3301 #[test]
3305 fn test_optional_group_variant_with_true_condition_absent_no_error() {
3306 use mig_types::navigator::GroupNavigator;
3307
3308 struct TestNav;
3309 impl GroupNavigator for TestNav {
3310 fn find_segments_in_group(
3311 &self,
3312 segment_id: &str,
3313 group_path: &[&str],
3314 instance_index: usize,
3315 ) -> Vec<OwnedSegment> {
3316 if segment_id == "SEQ" && group_path == ["SG4", "SG8"] && instance_index == 0 {
3317 vec![OwnedSegment {
3318 id: "SEQ".into(),
3319 elements: vec![vec!["Z03".into()]],
3320 segment_number: 10,
3321 }]
3322 } else {
3323 vec![]
3324 }
3325 }
3326 fn find_segments_with_qualifier_in_group(
3327 &self,
3328 _: &str,
3329 _: usize,
3330 _: &str,
3331 _: &[&str],
3332 _: usize,
3333 ) -> Vec<OwnedSegment> {
3334 vec![]
3335 }
3336 fn group_instance_count(&self, group_path: &[&str]) -> usize {
3337 match group_path {
3338 ["SG4"] => 1,
3339 ["SG4", "SG8"] => 1,
3340 _ => 0,
3341 }
3342 }
3343 }
3344
3345 let evaluator = MockEvaluator::new(vec![(345, CR::True)]);
3346 let validator = EdifactValidator::new(evaluator);
3347 let segments = vec![OwnedSegment {
3348 id: "SEQ".into(),
3349 elements: vec![vec!["Z03".into()]],
3350 segment_number: 10,
3351 }];
3352 let seq = |code: &str, group: &str| AhbFieldRule {
3353 segment_path: "SG4/SG8/SEQ/1229".to_string(),
3354 name: format!("Handlung, Code ({code})"),
3355 ahb_status: "X".to_string(),
3356 codes: vec![AhbCodeRule {
3357 value: code.to_string(),
3358 description: String::new(),
3359 ahb_status: "X".to_string(),
3360 }],
3361 parent_group_ahb_status: Some(group.to_string()),
3362 segment_ahb_status: None,
3363 ..Default::default()
3364 };
3365 let workflow = AhbWorkflow {
3366 pruefidentifikator: "44143".to_string(),
3367 description: "Test".to_string(),
3368 communication_direction: None,
3369 fields: vec![seq("Z03", "Kann"), seq("Z50", "Kann [345]")],
3370 ub_definitions: BTreeMap::new(),
3371 };
3372
3373 let report = validator.validate_with_navigator(
3374 &segments,
3375 &workflow,
3376 &NoOpExternalProvider,
3377 ValidationLevel::Conditions,
3378 &TestNav,
3379 );
3380 let missing: Vec<_> = report
3381 .issues
3382 .iter()
3383 .filter(|i| matches!(i.kind, IssueKind::MissingRequiredField { .. }))
3384 .collect();
3385 assert!(missing.is_empty(), "{missing:#?}");
3386 }
3387
3388 #[test]
3389 fn test_conditional_group_variant_absent_no_error() {
3390 use mig_types::navigator::GroupNavigator;
3395
3396 struct TestNav;
3397 impl GroupNavigator for TestNav {
3398 fn find_segments_in_group(
3399 &self,
3400 segment_id: &str,
3401 group_path: &[&str],
3402 instance_index: usize,
3403 ) -> Vec<OwnedSegment> {
3404 if segment_id == "LOC" && group_path == ["SG4", "SG5"] && instance_index == 0 {
3405 vec![OwnedSegment {
3406 id: "LOC".into(),
3407 elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
3408 segment_number: 10,
3409 }]
3410 } else {
3411 vec![]
3412 }
3413 }
3414 fn find_segments_with_qualifier_in_group(
3415 &self,
3416 _: &str,
3417 _: usize,
3418 _: &str,
3419 _: &[&str],
3420 _: usize,
3421 ) -> Vec<OwnedSegment> {
3422 vec![]
3423 }
3424 fn group_instance_count(&self, group_path: &[&str]) -> usize {
3425 match group_path {
3426 ["SG4"] => 1,
3427 ["SG4", "SG5"] => 1, _ => 0,
3429 }
3430 }
3431 }
3432
3433 let evaluator = MockEvaluator::new(vec![(165, CR::False), (2061, CR::True)]);
3436 let validator = EdifactValidator::new(evaluator);
3437 let external = NoOpExternalProvider;
3438 let nav = TestNav;
3439
3440 let segments = vec![OwnedSegment {
3441 id: "LOC".into(),
3442 elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
3443 segment_number: 10,
3444 }];
3445
3446 let workflow = AhbWorkflow {
3447 pruefidentifikator: "55001".to_string(),
3448 description: "Test".to_string(),
3449 communication_direction: None,
3450 fields: vec![
3451 AhbFieldRule {
3453 segment_path: "SG4/SG5/LOC/3227".to_string(),
3454 name: "Ortsangabe, Qualifier (Z16)".to_string(),
3455 ahb_status: "X".to_string(),
3456 codes: vec![AhbCodeRule {
3457 value: "Z16".to_string(),
3458 description: "Marktlokation".to_string(),
3459 ahb_status: "X".to_string(),
3460 }],
3461 parent_group_ahb_status: Some("Muss [2061]".to_string()),
3462 segment_ahb_status: None,
3463 ..Default::default()
3464 },
3465 AhbFieldRule {
3467 segment_path: "SG4/SG5/LOC/3227".to_string(),
3468 name: "Ortsangabe, Qualifier (Z17)".to_string(),
3469 ahb_status: "X".to_string(),
3470 codes: vec![AhbCodeRule {
3471 value: "Z17".to_string(),
3472 description: "Messlokation".to_string(),
3473 ahb_status: "X".to_string(),
3474 }],
3475 parent_group_ahb_status: Some("Soll [165]".to_string()),
3476 segment_ahb_status: None,
3477 ..Default::default()
3478 },
3479 ],
3480 ub_definitions: BTreeMap::new(),
3481 };
3482
3483 let report = validator.validate_with_navigator(
3484 &segments,
3485 &workflow,
3486 &external,
3487 ValidationLevel::Conditions,
3488 &nav,
3489 );
3490
3491 let ahb_errors: Vec<_> = report
3492 .by_category(ValidationCategory::Ahb)
3493 .filter(|i| i.severity == Severity::Error)
3494 .collect();
3495
3496 assert!(
3498 ahb_errors.is_empty(),
3499 "Expected no errors when conditional group variant [165]=False, got: {:?}",
3500 ahb_errors
3501 );
3502 }
3503
3504 #[test]
3505 fn test_conditional_group_variant_unknown_no_error() {
3506 let evaluator = MockEvaluator::new(vec![]);
3512 let validator = EdifactValidator::new(evaluator);
3513 let external = NoOpExternalProvider;
3514
3515 let workflow = AhbWorkflow {
3516 pruefidentifikator: "55001".to_string(),
3517 description: "Test".to_string(),
3518 communication_direction: None,
3519 fields: vec![AhbFieldRule {
3520 segment_path: "SG4/SG5/LOC/3227".to_string(),
3521 name: "Ortsangabe, Qualifier (Z17)".to_string(),
3522 ahb_status: "X".to_string(),
3523 codes: vec![AhbCodeRule {
3524 value: "Z17".to_string(),
3525 description: "Messlokation".to_string(),
3526 ahb_status: "X".to_string(),
3527 }],
3528 parent_group_ahb_status: Some("Soll [165]".to_string()),
3529 segment_ahb_status: None,
3530 ..Default::default()
3531 }],
3532 ub_definitions: BTreeMap::new(),
3533 };
3534
3535 let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
3536
3537 let ahb_errors: Vec<_> = report
3538 .by_category(ValidationCategory::Ahb)
3539 .filter(|i| i.severity == Severity::Error)
3540 .collect();
3541
3542 assert!(
3544 ahb_errors.is_empty(),
3545 "Expected no errors when parent group condition is Unknown, got: {:?}",
3546 ahb_errors
3547 );
3548 }
3549
3550 #[test]
3551 fn test_segment_absent_within_present_group_no_error() {
3552 use mig_types::navigator::GroupNavigator;
3556
3557 struct TestNav;
3558 impl GroupNavigator for TestNav {
3559 fn find_segments_in_group(
3560 &self,
3561 segment_id: &str,
3562 group_path: &[&str],
3563 instance_index: usize,
3564 ) -> Vec<OwnedSegment> {
3565 if segment_id == "QTY"
3567 && group_path == ["SG5", "SG6", "SG9", "SG10"]
3568 && instance_index == 0
3569 {
3570 vec![OwnedSegment {
3571 id: "QTY".into(),
3572 elements: vec![vec!["220".into(), "0".into()]],
3573 segment_number: 14,
3574 }]
3575 } else {
3576 vec![]
3577 }
3578 }
3579 fn find_segments_with_qualifier_in_group(
3580 &self,
3581 _: &str,
3582 _: usize,
3583 _: &str,
3584 _: &[&str],
3585 _: usize,
3586 ) -> Vec<OwnedSegment> {
3587 vec![]
3588 }
3589 fn group_instance_count(&self, group_path: &[&str]) -> usize {
3590 match group_path {
3591 ["SG5"] => 1,
3592 ["SG5", "SG6"] => 1,
3593 ["SG5", "SG6", "SG9"] => 1,
3594 ["SG5", "SG6", "SG9", "SG10"] => 1,
3595 _ => 0,
3596 }
3597 }
3598 fn has_any_segment_in_group(&self, group_path: &[&str], instance_index: usize) -> bool {
3599 group_path == ["SG5", "SG6", "SG9", "SG10"] && instance_index == 0
3601 }
3602 }
3603
3604 let evaluator = MockEvaluator::all_true(&[]);
3605 let validator = EdifactValidator::new(evaluator);
3606 let external = NoOpExternalProvider;
3607 let nav = TestNav;
3608
3609 let segments = vec![OwnedSegment {
3610 id: "QTY".into(),
3611 elements: vec![vec!["220".into(), "0".into()]],
3612 segment_number: 14,
3613 }];
3614
3615 let workflow = AhbWorkflow {
3616 pruefidentifikator: "13017".to_string(),
3617 description: "Test".to_string(),
3618 communication_direction: None,
3619 fields: vec![
3620 AhbFieldRule {
3622 segment_path: "SG5/SG6/SG9/SG10/STS/C601/9015".to_string(),
3623 name: "Statuskategorie, Code".to_string(),
3624 ahb_status: "X".to_string(),
3625 codes: vec![],
3626 parent_group_ahb_status: Some("Muss".to_string()),
3627 segment_ahb_status: None,
3628 ..Default::default()
3629 },
3630 AhbFieldRule {
3632 segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
3633 name: "Statusanlaß, Code".to_string(),
3634 ahb_status: "X [5]".to_string(),
3635 codes: vec![],
3636 parent_group_ahb_status: Some("Muss".to_string()),
3637 segment_ahb_status: None,
3638 ..Default::default()
3639 },
3640 ],
3641 ub_definitions: BTreeMap::new(),
3642 };
3643
3644 let report = validator.validate_with_navigator(
3645 &segments,
3646 &workflow,
3647 &external,
3648 ValidationLevel::Conditions,
3649 &nav,
3650 );
3651
3652 let ahb_errors: Vec<_> = report
3653 .by_category(ValidationCategory::Ahb)
3654 .filter(|i| i.severity == Severity::Error)
3655 .collect();
3656
3657 assert!(
3658 ahb_errors.is_empty(),
3659 "Expected no AHB001 errors when STS segment is absent from SG10, got: {:?}",
3660 ahb_errors
3661 );
3662 }
3663
3664 #[test]
3665 fn test_group_scoped_code_validation_with_navigator() {
3666 use mig_types::navigator::GroupNavigator;
3670
3671 struct TestNav;
3672 impl GroupNavigator for TestNav {
3673 fn find_segments_in_group(
3674 &self,
3675 segment_id: &str,
3676 group_path: &[&str],
3677 _instance_index: usize,
3678 ) -> Vec<OwnedSegment> {
3679 if segment_id != "NAD" {
3680 return vec![];
3681 }
3682 match group_path {
3683 ["SG2"] => vec![
3684 OwnedSegment {
3685 id: "NAD".into(),
3686 elements: vec![vec!["MS".into()]],
3687 segment_number: 3,
3688 },
3689 OwnedSegment {
3690 id: "NAD".into(),
3691 elements: vec![vec!["MT".into()]], segment_number: 4,
3693 },
3694 ],
3695 ["SG4", "SG12"] => vec![
3696 OwnedSegment {
3697 id: "NAD".into(),
3698 elements: vec![vec!["Z04".into()]],
3699 segment_number: 20,
3700 },
3701 OwnedSegment {
3702 id: "NAD".into(),
3703 elements: vec![vec!["Z09".into()]],
3704 segment_number: 21,
3705 },
3706 ],
3707 _ => vec![],
3708 }
3709 }
3710 fn find_segments_with_qualifier_in_group(
3711 &self,
3712 _: &str,
3713 _: usize,
3714 _: &str,
3715 _: &[&str],
3716 _: usize,
3717 ) -> Vec<OwnedSegment> {
3718 vec![]
3719 }
3720 fn group_instance_count(&self, group_path: &[&str]) -> usize {
3721 match group_path {
3722 ["SG2"] | ["SG4", "SG12"] => 1,
3723 _ => 0,
3724 }
3725 }
3726 }
3727
3728 let evaluator = MockEvaluator::new(vec![]);
3729 let validator = EdifactValidator::new(evaluator);
3730 let external = NoOpExternalProvider;
3731 let nav = TestNav;
3732
3733 let workflow = AhbWorkflow {
3734 pruefidentifikator: "55001".to_string(),
3735 description: "Test".to_string(),
3736 communication_direction: None,
3737 fields: vec![
3738 AhbFieldRule {
3739 segment_path: "SG2/NAD/3035".to_string(),
3740 name: "Absender".to_string(),
3741 ahb_status: "X".to_string(),
3742 codes: vec![AhbCodeRule {
3743 value: "MS".to_string(),
3744 description: "Absender".to_string(),
3745 ahb_status: "X".to_string(),
3746 }],
3747 parent_group_ahb_status: None,
3748 segment_ahb_status: None,
3749 ..Default::default()
3750 },
3751 AhbFieldRule {
3752 segment_path: "SG2/NAD/3035".to_string(),
3753 name: "Empfaenger".to_string(),
3754 ahb_status: "X".to_string(),
3755 codes: vec![AhbCodeRule {
3756 value: "MR".to_string(),
3757 description: "Empfaenger".to_string(),
3758 ahb_status: "X".to_string(),
3759 }],
3760 parent_group_ahb_status: None,
3761 segment_ahb_status: None,
3762 ..Default::default()
3763 },
3764 AhbFieldRule {
3765 segment_path: "SG4/SG12/NAD/3035".to_string(),
3766 name: "Anschlussnutzer".to_string(),
3767 ahb_status: "X".to_string(),
3768 codes: vec![AhbCodeRule {
3769 value: "Z04".to_string(),
3770 description: "Anschlussnutzer".to_string(),
3771 ahb_status: "X".to_string(),
3772 }],
3773 parent_group_ahb_status: None,
3774 segment_ahb_status: None,
3775 ..Default::default()
3776 },
3777 AhbFieldRule {
3778 segment_path: "SG4/SG12/NAD/3035".to_string(),
3779 name: "Korrespondenzanschrift".to_string(),
3780 ahb_status: "X".to_string(),
3781 codes: vec![AhbCodeRule {
3782 value: "Z09".to_string(),
3783 description: "Korrespondenzanschrift".to_string(),
3784 ahb_status: "X".to_string(),
3785 }],
3786 parent_group_ahb_status: None,
3787 segment_ahb_status: None,
3788 ..Default::default()
3789 },
3790 ],
3791 ub_definitions: BTreeMap::new(),
3792 };
3793
3794 let all_segments = vec![
3796 OwnedSegment {
3797 id: "NAD".into(),
3798 elements: vec![vec!["MS".into()]],
3799 segment_number: 3,
3800 },
3801 OwnedSegment {
3802 id: "NAD".into(),
3803 elements: vec![vec!["MT".into()]],
3804 segment_number: 4,
3805 },
3806 OwnedSegment {
3807 id: "NAD".into(),
3808 elements: vec![vec!["Z04".into()]],
3809 segment_number: 20,
3810 },
3811 OwnedSegment {
3812 id: "NAD".into(),
3813 elements: vec![vec!["Z09".into()]],
3814 segment_number: 21,
3815 },
3816 ];
3817
3818 let report = validator.validate_with_navigator(
3819 &all_segments,
3820 &workflow,
3821 &external,
3822 ValidationLevel::Conditions,
3823 &nav,
3824 );
3825
3826 let code_errors: Vec<_> = report
3827 .by_category(ValidationCategory::Code)
3828 .filter(|i| i.severity == Severity::Error)
3829 .collect();
3830
3831 assert_eq!(
3834 code_errors.len(),
3835 1,
3836 "Expected exactly one COD002 error for MT in SG2, got: {:?}",
3837 code_errors
3838 );
3839 assert!(narrate(code_errors[0]).contains("MT"));
3840 assert!(narrate(code_errors[0]).contains("MR"));
3842 assert!(narrate(code_errors[0]).contains("MS"));
3843 assert!(
3844 !narrate(code_errors[0]).contains("Z04"),
3845 "SG4/SG12 codes should not leak into SG2 error"
3846 );
3847 assert!(
3849 code_errors[0]
3850 .field_path
3851 .as_deref()
3852 .unwrap_or("")
3853 .contains("SG2"),
3854 "Error field_path should reference SG2, got: {:?}",
3855 code_errors[0].field_path
3856 );
3857 }
3858
3859 #[test]
3862 fn test_package_cardinality_within_bounds() {
3863 let evaluator = MockEvaluator::all_true(&[]);
3865 let validator = EdifactValidator::new(evaluator);
3866 let external = NoOpExternalProvider;
3867
3868 let segments = vec![OwnedSegment {
3869 id: "STS".into(),
3870 elements: vec![
3871 vec!["Z33".into()], vec![], vec!["E01".into()], ],
3875 segment_number: 5,
3876 }];
3877
3878 let workflow = AhbWorkflow {
3879 pruefidentifikator: "13017".to_string(),
3880 description: "Test".to_string(),
3881 communication_direction: None,
3882 ub_definitions: BTreeMap::new(),
3883 fields: vec![AhbFieldRule {
3884 segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
3885 name: "Statusanlaß, Code".to_string(),
3886 ahb_status: "X".to_string(),
3887 element_index: Some(2),
3888 component_index: Some(0),
3889 codes: vec![
3890 AhbCodeRule {
3891 value: "E01".into(),
3892 description: "Code 1".into(),
3893 ahb_status: "X [4P0..1]".into(),
3894 },
3895 AhbCodeRule {
3896 value: "E02".into(),
3897 description: "Code 2".into(),
3898 ahb_status: "X [4P0..1]".into(),
3899 },
3900 ],
3901 parent_group_ahb_status: Some("Muss".to_string()),
3902 segment_ahb_status: None,
3903 mig_number: None,
3904 }],
3905 };
3906
3907 let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
3908 let pkg_errors: Vec<_> = report
3909 .by_category(ValidationCategory::Ahb)
3910 .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
3911 .collect();
3912 assert!(
3913 pkg_errors.is_empty(),
3914 "1 code within [4P0..1] bounds — no error expected, got: {:?}",
3915 pkg_errors
3916 );
3917 }
3918
3919 #[test]
3920 fn test_package_cardinality_zero_present_min_zero() {
3921 let evaluator = MockEvaluator::all_true(&[]);
3923 let validator = EdifactValidator::new(evaluator);
3924 let external = NoOpExternalProvider;
3925
3926 let segments = vec![OwnedSegment {
3927 id: "STS".into(),
3928 elements: vec![
3929 vec!["Z33".into()],
3930 vec![],
3931 vec!["X99".into()], ],
3933 segment_number: 5,
3934 }];
3935
3936 let workflow = AhbWorkflow {
3937 pruefidentifikator: "13017".to_string(),
3938 description: "Test".to_string(),
3939 communication_direction: None,
3940 ub_definitions: BTreeMap::new(),
3941 fields: vec![AhbFieldRule {
3942 segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
3943 name: "Statusanlaß, Code".to_string(),
3944 ahb_status: "X".to_string(),
3945 element_index: Some(2),
3946 component_index: Some(0),
3947 codes: vec![
3948 AhbCodeRule {
3949 value: "E01".into(),
3950 description: "Code 1".into(),
3951 ahb_status: "X [4P0..1]".into(),
3952 },
3953 AhbCodeRule {
3954 value: "E02".into(),
3955 description: "Code 2".into(),
3956 ahb_status: "X [4P0..1]".into(),
3957 },
3958 ],
3959 parent_group_ahb_status: Some("Muss".to_string()),
3960 segment_ahb_status: None,
3961 mig_number: None,
3962 }],
3963 };
3964
3965 let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
3966 let pkg_errors: Vec<_> = report
3967 .by_category(ValidationCategory::Ahb)
3968 .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
3969 .collect();
3970 assert!(
3971 pkg_errors.is_empty(),
3972 "0 codes, min=0 — no error expected, got: {:?}",
3973 pkg_errors
3974 );
3975 }
3976
3977 #[test]
3978 fn test_package_cardinality_too_many() {
3979 let evaluator = MockEvaluator::all_true(&[]);
3981 let validator = EdifactValidator::new(evaluator);
3982 let external = NoOpExternalProvider;
3983
3984 let segments = vec![
3986 OwnedSegment {
3987 id: "STS".into(),
3988 elements: vec![vec!["Z33".into()], vec![], vec!["E01".into()]],
3989 segment_number: 5,
3990 },
3991 OwnedSegment {
3992 id: "STS".into(),
3993 elements: vec![vec!["Z33".into()], vec![], vec!["E02".into()]],
3994 segment_number: 6,
3995 },
3996 ];
3997
3998 let workflow = AhbWorkflow {
3999 pruefidentifikator: "13017".to_string(),
4000 description: "Test".to_string(),
4001 communication_direction: None,
4002 ub_definitions: BTreeMap::new(),
4003 fields: vec![AhbFieldRule {
4004 segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
4005 name: "Statusanlaß, Code".to_string(),
4006 ahb_status: "X".to_string(),
4007 element_index: Some(2),
4008 component_index: Some(0),
4009 codes: vec![
4010 AhbCodeRule {
4011 value: "E01".into(),
4012 description: "Code 1".into(),
4013 ahb_status: "X [4P0..1]".into(),
4014 },
4015 AhbCodeRule {
4016 value: "E02".into(),
4017 description: "Code 2".into(),
4018 ahb_status: "X [4P0..1]".into(),
4019 },
4020 ],
4021 parent_group_ahb_status: Some("Muss".to_string()),
4022 segment_ahb_status: None,
4023 mig_number: None,
4024 }],
4025 };
4026
4027 let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
4028 let pkg_errors: Vec<_> = report
4029 .by_category(ValidationCategory::Ahb)
4030 .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
4031 .collect();
4032 assert_eq!(
4033 pkg_errors.len(),
4034 1,
4035 "2 codes present, max=1 — expected 1 error, got: {:?}",
4036 pkg_errors
4037 );
4038 assert!(narrate(pkg_errors[0]).contains("[4P0..1]"));
4039 assert_eq!(pkg_errors[0].actual_value.as_deref(), Some("2"));
4040 assert_eq!(pkg_errors[0].expected_value.as_deref(), Some("0..1"));
4041 }
4042
4043 #[test]
4044 fn test_package_cardinality_too_few() {
4045 let evaluator = MockEvaluator::all_true(&[]);
4047 let validator = EdifactValidator::new(evaluator);
4048 let external = NoOpExternalProvider;
4049
4050 let segments = vec![OwnedSegment {
4051 id: "STS".into(),
4052 elements: vec![
4053 vec!["Z33".into()],
4054 vec![],
4055 vec!["X99".into()], ],
4057 segment_number: 5,
4058 }];
4059
4060 let workflow = AhbWorkflow {
4061 pruefidentifikator: "13017".to_string(),
4062 description: "Test".to_string(),
4063 communication_direction: None,
4064 ub_definitions: BTreeMap::new(),
4065 fields: vec![AhbFieldRule {
4066 segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
4067 name: "Statusanlaß, Code".to_string(),
4068 ahb_status: "X".to_string(),
4069 element_index: Some(2),
4070 component_index: Some(0),
4071 codes: vec![
4072 AhbCodeRule {
4073 value: "E01".into(),
4074 description: "Code 1".into(),
4075 ahb_status: "X [5P1..3]".into(),
4076 },
4077 AhbCodeRule {
4078 value: "E02".into(),
4079 description: "Code 2".into(),
4080 ahb_status: "X [5P1..3]".into(),
4081 },
4082 AhbCodeRule {
4083 value: "E03".into(),
4084 description: "Code 3".into(),
4085 ahb_status: "X [5P1..3]".into(),
4086 },
4087 ],
4088 parent_group_ahb_status: Some("Muss".to_string()),
4089 segment_ahb_status: None,
4090 mig_number: None,
4091 }],
4092 };
4093
4094 let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
4095 let pkg_errors: Vec<_> = report
4096 .by_category(ValidationCategory::Ahb)
4097 .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
4098 .collect();
4099 assert_eq!(
4100 pkg_errors.len(),
4101 1,
4102 "0 codes present, min=1 — expected 1 error, got: {:?}",
4103 pkg_errors
4104 );
4105 assert!(narrate(pkg_errors[0]).contains("[5P1..3]"));
4106 assert_eq!(pkg_errors[0].actual_value.as_deref(), Some("0"));
4107 assert_eq!(pkg_errors[0].expected_value.as_deref(), Some("1..3"));
4108 }
4109
4110 #[test]
4111 fn test_package_cardinality_no_packages_in_workflow() {
4112 let evaluator = MockEvaluator::all_true(&[]);
4114 let validator = EdifactValidator::new(evaluator);
4115 let external = NoOpExternalProvider;
4116
4117 let segments = vec![OwnedSegment {
4118 id: "STS".into(),
4119 elements: vec![vec!["E01".into()]],
4120 segment_number: 5,
4121 }];
4122
4123 let workflow = AhbWorkflow {
4124 pruefidentifikator: "13017".to_string(),
4125 description: "Test".to_string(),
4126 communication_direction: None,
4127 ub_definitions: BTreeMap::new(),
4128 fields: vec![AhbFieldRule {
4129 segment_path: "STS/9015".to_string(),
4130 name: "Status Code".to_string(),
4131 ahb_status: "X".to_string(),
4132 codes: vec![AhbCodeRule {
4133 value: "E01".into(),
4134 description: "Code 1".into(),
4135 ahb_status: "X".into(),
4136 }],
4137 parent_group_ahb_status: Some("Muss".to_string()),
4138 segment_ahb_status: None,
4139 ..Default::default()
4140 }],
4141 };
4142
4143 let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
4144 let pkg_errors: Vec<_> = report
4145 .by_category(ValidationCategory::Ahb)
4146 .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
4147 .collect();
4148 assert!(
4149 pkg_errors.is_empty(),
4150 "No packages in workflow — no errors expected"
4151 );
4152 }
4153
4154 #[test]
4155 fn test_package_cardinality_with_condition_and_package() {
4156 let evaluator = MockEvaluator::all_true(&[901]);
4158 let validator = EdifactValidator::new(evaluator);
4159 let external = NoOpExternalProvider;
4160
4161 let segments = vec![OwnedSegment {
4162 id: "STS".into(),
4163 elements: vec![vec![], vec![], vec!["E01".into()]],
4164 segment_number: 5,
4165 }];
4166
4167 let workflow = AhbWorkflow {
4168 pruefidentifikator: "13017".to_string(),
4169 description: "Test".to_string(),
4170 communication_direction: None,
4171 ub_definitions: BTreeMap::new(),
4172 fields: vec![AhbFieldRule {
4173 segment_path: "SG10/STS/C556/9013".to_string(),
4174 name: "Code".to_string(),
4175 ahb_status: "X".to_string(),
4176 element_index: Some(2),
4177 component_index: Some(0),
4178 codes: vec![
4179 AhbCodeRule {
4180 value: "E01".into(),
4181 description: "Code 1".into(),
4182 ahb_status: "X [901] [4P0..1]".into(),
4183 },
4184 AhbCodeRule {
4185 value: "E02".into(),
4186 description: "Code 2".into(),
4187 ahb_status: "X [901] [4P0..1]".into(),
4188 },
4189 ],
4190 parent_group_ahb_status: Some("Muss".to_string()),
4191 segment_ahb_status: None,
4192 mig_number: None,
4193 }],
4194 };
4195
4196 let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
4197 let pkg_errors: Vec<_> = report
4198 .by_category(ValidationCategory::Ahb)
4199 .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
4200 .collect();
4201 assert!(
4202 pkg_errors.is_empty(),
4203 "1 code within [4P0..1] bounds — no error, got: {:?}",
4204 pkg_errors
4205 );
4206 }
4207
4208 #[test]
4209 fn test_package_cardinality_scoped_per_group_instance() {
4210 use mig_types::navigator::GroupNavigator;
4218
4219 struct TwoSg10s {
4220 sts_a: OwnedSegment,
4221 sts_b: OwnedSegment,
4222 }
4223 impl GroupNavigator for TwoSg10s {
4224 fn find_segments_in_group(
4225 &self,
4226 segment_id: &str,
4227 group_path: &[&str],
4228 instance_index: usize,
4229 ) -> Vec<OwnedSegment> {
4230 if group_path == ["SG5", "SG6", "SG9", "SG10"] && segment_id == "STS" {
4231 match instance_index {
4232 0 => vec![self.sts_a.clone()],
4233 1 => vec![self.sts_b.clone()],
4234 _ => vec![],
4235 }
4236 } else {
4237 vec![]
4238 }
4239 }
4240 fn find_segments_with_qualifier_in_group(
4241 &self,
4242 _: &str,
4243 _: usize,
4244 _: &str,
4245 _: &[&str],
4246 _: usize,
4247 ) -> Vec<OwnedSegment> {
4248 vec![]
4249 }
4250 fn group_instance_count(&self, group_path: &[&str]) -> usize {
4251 match group_path {
4252 ["SG5"] | ["SG5", "SG6"] | ["SG5", "SG6", "SG9"] => 1,
4253 ["SG5", "SG6", "SG9", "SG10"] => 2,
4254 _ => 0,
4255 }
4256 }
4257 }
4258
4259 let sts_a = OwnedSegment {
4260 id: "STS".into(),
4261 elements: vec![vec!["Z32".into()], vec![], vec!["E01".into()]],
4262 segment_number: 10,
4263 };
4264 let sts_b = OwnedSegment {
4265 id: "STS".into(),
4266 elements: vec![vec!["Z32".into()], vec![], vec!["E01".into()]],
4267 segment_number: 15,
4268 };
4269 let nav = TwoSg10s {
4270 sts_a: sts_a.clone(),
4271 sts_b: sts_b.clone(),
4272 };
4273
4274 let evaluator = MockEvaluator::all_true(&[]);
4275 let validator = EdifactValidator::new(evaluator);
4276 let external = NoOpExternalProvider;
4277
4278 let workflow = AhbWorkflow {
4279 pruefidentifikator: "13025".to_string(),
4280 description: "Test".to_string(),
4281 communication_direction: None,
4282 ub_definitions: BTreeMap::new(),
4283 fields: vec![AhbFieldRule {
4284 segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
4285 name: "Statusanlaß, Code".to_string(),
4286 ahb_status: "X".to_string(),
4287 element_index: Some(2),
4288 component_index: Some(0),
4289 codes: vec![
4290 AhbCodeRule {
4291 value: "E01".into(),
4292 description: "Code 1".into(),
4293 ahb_status: "X [4P0..1]".into(),
4294 },
4295 AhbCodeRule {
4296 value: "E02".into(),
4297 description: "Code 2".into(),
4298 ahb_status: "X [4P0..1]".into(),
4299 },
4300 ],
4301 parent_group_ahb_status: Some("Muss".to_string()),
4302 segment_ahb_status: None,
4303 mig_number: None,
4304 }],
4305 };
4306
4307 let report = validator.validate_with_navigator(
4308 &[sts_a, sts_b],
4309 &workflow,
4310 &external,
4311 ValidationLevel::Full,
4312 &nav,
4313 );
4314 let pkg_errors: Vec<_> = report
4315 .by_category(ValidationCategory::Ahb)
4316 .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
4317 .collect();
4318 assert!(
4319 pkg_errors.is_empty(),
4320 "Package cardinality is per-instance: 1 code per SG10 rep is within [4P0..1]. Got: {:?}",
4321 pkg_errors
4322 );
4323 }
4324
4325 fn make_segment(id: &str, elements: Vec<Vec<&str>>) -> OwnedSegment {
4326 OwnedSegment {
4327 id: id.to_string(),
4328 elements: elements
4329 .into_iter()
4330 .map(|e| e.into_iter().map(|s| s.to_string()).collect())
4331 .collect(),
4332 segment_number: 0,
4333 }
4334 }
4335
4336 #[test]
4347 fn test_package_cardinality_scoped_to_rule_mig_variant() {
4348 use mig_types::navigator::GroupNavigator;
4349
4350 struct TwoSg8Variants {
4351 seq_z01: OwnedSegment,
4352 seq_z45: OwnedSegment,
4353 }
4354 impl GroupNavigator for TwoSg8Variants {
4355 fn find_segments_in_group(
4356 &self,
4357 segment_id: &str,
4358 group_path: &[&str],
4359 instance_index: usize,
4360 ) -> Vec<OwnedSegment> {
4361 if group_path == ["SG4", "SG8"] && segment_id == "SEQ" {
4362 match instance_index {
4363 0 => vec![self.seq_z01.clone()],
4364 1 => vec![self.seq_z45.clone()],
4365 _ => vec![],
4366 }
4367 } else {
4368 vec![]
4369 }
4370 }
4371 fn find_segments_with_qualifier_in_group(
4372 &self,
4373 _: &str,
4374 _: usize,
4375 _: &str,
4376 _: &[&str],
4377 _: usize,
4378 ) -> Vec<OwnedSegment> {
4379 vec![]
4380 }
4381 fn group_instance_count(&self, group_path: &[&str]) -> usize {
4382 match group_path {
4383 ["SG4"] => 1,
4384 ["SG4", "SG8"] => 2,
4385 _ => 0,
4386 }
4387 }
4388 fn instance_has_mig_number(
4389 &self,
4390 group_path: &[&str],
4391 instance_index: usize,
4392 mig_number: &str,
4393 ) -> bool {
4394 if group_path != ["SG4", "SG8"] {
4395 return true;
4396 }
4397 match (instance_index, mig_number) {
4398 (0, "00115") => true,
4399 (0, _) => false,
4400 (1, "00171") => true,
4401 (1, _) => false,
4402 _ => false,
4403 }
4404 }
4405 }
4406
4407 let seq_z01 = OwnedSegment {
4408 id: "SEQ".into(),
4409 elements: vec![vec!["Z01".into()], vec!["1".into()]],
4410 segment_number: 10,
4411 };
4412 let seq_z45 = OwnedSegment {
4413 id: "SEQ".into(),
4414 elements: vec![vec!["Z45".into()], vec!["1".into()]],
4415 segment_number: 20,
4416 };
4417 let nav = TwoSg8Variants {
4418 seq_z01: seq_z01.clone(),
4419 seq_z45: seq_z45.clone(),
4420 };
4421
4422 let evaluator = MockEvaluator::all_true(&[]);
4423 let validator = EdifactValidator::new(evaluator);
4424 let external = NoOpExternalProvider;
4425
4426 let workflow = AhbWorkflow {
4427 pruefidentifikator: "55218".to_string(),
4428 description: "Test".to_string(),
4429 communication_direction: None,
4430 ub_definitions: BTreeMap::new(),
4431 fields: vec![AhbFieldRule {
4432 segment_path: "SG4/SG8/SEQ/1229".to_string(),
4433 name: "Handlung, Code".to_string(),
4434 ahb_status: "X".to_string(),
4435 element_index: Some(0),
4436 component_index: Some(0),
4437 codes: vec![
4438 AhbCodeRule {
4439 value: "Z45".into(),
4440 description: "NNA".into(),
4441 ahb_status: "X [1P1..4294967295]".into(),
4442 },
4443 AhbCodeRule {
4444 value: "Z84".into(),
4445 description: "Differenz-NNA".into(),
4446 ahb_status: "X [1P0..4294967295]".into(),
4447 },
4448 ],
4449 parent_group_ahb_status: Some("Muss".to_string()),
4450 segment_ahb_status: None,
4451 mig_number: Some("00171".to_string()),
4452 }],
4453 };
4454
4455 let report = validator.validate_with_navigator(
4456 &[seq_z01, seq_z45],
4457 &workflow,
4458 &external,
4459 ValidationLevel::Full,
4460 &nav,
4461 );
4462 let pkg_errors: Vec<_> = report
4463 .by_category(ValidationCategory::Ahb)
4464 .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
4465 .collect();
4466 assert!(
4467 pkg_errors.is_empty(),
4468 "Package rule with mig=00171 must only count the Z45 instance (which has 1 code), not the sibling Z01 variant. Got: {:?}",
4469 pkg_errors
4470 );
4471 }
4472
4473 #[test]
4474 fn test_unt_count_correct() {
4475 let segments = vec![
4477 make_segment("UNH", vec![vec!["001"]]),
4478 make_segment("BGM", vec![vec!["E01"]]),
4479 make_segment("DTM", vec![vec!["137", "20250401"]]),
4480 make_segment("UNT", vec![vec!["4", "001"]]),
4481 ];
4482 assert!(
4483 validate_unt_segment_count(&segments).is_none(),
4484 "Correct count should produce no issue"
4485 );
4486 }
4487
4488 #[test]
4489 fn test_unt_count_mismatch() {
4490 let segments = vec![
4492 make_segment("UNH", vec![vec!["001"]]),
4493 make_segment("BGM", vec![vec!["E01"]]),
4494 make_segment("UNT", vec![vec!["5", "001"]]),
4495 ];
4496 let issue =
4497 validate_unt_segment_count(&segments).expect("Mismatch should produce an issue");
4498 assert_eq!(issue.code(), ErrorCodes::UNT_SEGMENT_COUNT_MISMATCH);
4499 assert_eq!(issue.severity, Severity::Error);
4500 assert!(narrate(&issue).contains("declared 5"));
4501 assert!(narrate(&issue).contains("actual 3"));
4502 }
4503
4504 #[test]
4505 fn test_unt_count_excludes_envelope() {
4506 let segments = vec![
4508 make_segment("UNA", vec![]),
4509 make_segment("UNB", vec![vec!["UNOC", "3"]]),
4510 make_segment("UNH", vec![vec!["001"]]),
4511 make_segment("BGM", vec![vec!["E01"]]),
4512 make_segment("UNT", vec![vec!["3", "001"]]),
4513 make_segment("UNZ", vec![vec!["1"]]),
4514 ];
4515 assert!(
4516 validate_unt_segment_count(&segments).is_none(),
4517 "Envelope segments excluded — count should be 3 (UNH+BGM+UNT)"
4518 );
4519 }
4520
4521 #[test]
4522 fn test_unt_count_no_unt_returns_none() {
4523 let segments = vec![
4524 make_segment("UNH", vec![vec!["001"]]),
4525 make_segment("BGM", vec![vec!["E01"]]),
4526 ];
4527 assert!(
4528 validate_unt_segment_count(&segments).is_none(),
4529 "No UNT segment should return None (not our problem)"
4530 );
4531 }
4532
4533 #[test]
4534 fn test_unt_count_rejects_multi_message_input() {
4535 let segments = vec![
4537 make_segment("UNH", vec![vec!["001"]]),
4538 make_segment("BGM", vec![vec!["E01"]]),
4539 make_segment("UNT", vec![vec!["3", "001"]]),
4540 make_segment("UNH", vec![vec!["002"]]),
4541 make_segment("BGM", vec![vec!["E02"]]),
4542 make_segment("UNT", vec![vec!["3", "002"]]),
4543 ];
4544 let issue = validate_unt_segment_count(&segments)
4545 .expect("Multi-message input should produce an error");
4546 assert_eq!(issue.code(), ErrorCodes::UNT_SEGMENT_COUNT_MISMATCH);
4547 assert!(
4548 narrate(&issue).contains("2 UNH"),
4549 "Should mention UNH count: {}",
4550 narrate(&issue)
4551 );
4552 }
4553
4554 #[test]
4555 fn test_code_validation_accepts_multi_code_variant_qualifier() {
4556 let evaluator = MockEvaluator::new(vec![]);
4563 let validator = EdifactValidator::new(evaluator);
4564 let external = NoOpExternalProvider;
4565
4566 let rff_z39 = OwnedSegment {
4567 id: "RFF".to_string(),
4568 elements: vec![
4569 vec!["RFF".to_string()],
4570 vec!["Z39".to_string(), "REF1".to_string()],
4571 ],
4572 segment_number: 1,
4573 };
4574
4575 let workflow = AhbWorkflow {
4576 pruefidentifikator: "55035".to_string(),
4577 description: "Test".to_string(),
4578 communication_direction: None,
4579 fields: vec![
4580 AhbFieldRule {
4582 segment_path: "SG4/SG8/RFF/C506/1153".to_string(),
4583 name: "Referenznummer Qualifier".to_string(),
4584 ahb_status: "Muss".to_string(),
4585 codes: vec![
4586 AhbCodeRule {
4587 value: "Z31".to_string(),
4588 description: "".to_string(),
4589 ahb_status: "X".to_string(),
4590 },
4591 AhbCodeRule {
4592 value: "Z39".to_string(),
4593 description: "".to_string(),
4594 ahb_status: "X".to_string(),
4595 },
4596 ],
4597 parent_group_ahb_status: None,
4598 segment_ahb_status: None,
4599 element_index: Some(1),
4600 component_index: Some(0),
4601 mig_number: Some("00075".to_string()),
4602 },
4603 AhbFieldRule {
4605 segment_path: "SG4/SG8/RFF/C506/1153".to_string(),
4606 name: "Referenznummer Qualifier".to_string(),
4607 ahb_status: "Muss".to_string(),
4608 codes: vec![AhbCodeRule {
4609 value: "Z33".to_string(),
4610 description: "".to_string(),
4611 ahb_status: "X".to_string(),
4612 }],
4613 parent_group_ahb_status: None,
4614 segment_ahb_status: None,
4615 element_index: Some(1),
4616 component_index: Some(0),
4617 mig_number: Some("00078".to_string()),
4618 },
4619 ],
4620 ub_definitions: BTreeMap::new(),
4621 };
4622
4623 let report = validator.validate(
4624 &[rff_z39],
4625 &workflow,
4626 &external,
4627 ValidationLevel::Conditions,
4628 );
4629
4630 let code_errors: Vec<_> = report
4631 .by_category(ValidationCategory::Code)
4632 .filter(|i| {
4633 i.severity == Severity::Error && i.code() == ErrorCodes::CODE_NOT_ALLOWED_FOR_PID
4634 })
4635 .collect();
4636 assert!(
4637 code_errors.is_empty(),
4638 "RFF+Z39 must be accepted (Z39 is valid for mig=00075). Got: {:?}",
4639 code_errors
4640 );
4641 }
4642
4643 #[test]
4644 fn test_code_validation_disambiguates_migs_by_full_code_profile() {
4645 let evaluator = MockEvaluator::new(vec![]);
4651 let validator = EdifactValidator::new(evaluator);
4652 let external = NoOpExternalProvider;
4653
4654 let pia_5_z12 = OwnedSegment {
4658 id: "PIA".to_string(),
4659 elements: vec![vec!["5".to_string()], vec!["Z12".to_string()]],
4660 segment_number: 1,
4661 };
4662 let pia_5_srw = OwnedSegment {
4663 id: "PIA".to_string(),
4664 elements: vec![vec!["5".to_string()], vec!["SRW".to_string()]],
4665 segment_number: 2,
4666 };
4667
4668 let make_rules = |mig: &str, composite_code: &str| {
4669 vec![
4670 AhbFieldRule {
4671 segment_path: "SG4/SG8/PIA/4347".to_string(),
4672 name: "Produkt-ID-Funktion".to_string(),
4673 ahb_status: "Muss".to_string(),
4674 codes: vec![AhbCodeRule {
4675 value: "5".to_string(),
4676 description: "".to_string(),
4677 ahb_status: "X".to_string(),
4678 }],
4679 parent_group_ahb_status: None,
4680 segment_ahb_status: None,
4681 element_index: Some(0),
4682 component_index: Some(0),
4683 mig_number: Some(mig.to_string()),
4684 },
4685 AhbFieldRule {
4686 segment_path: "SG4/SG8/PIA/C212/7143".to_string(),
4687 name: "Artikel/Dienstleistung-Identifikator".to_string(),
4688 ahb_status: "Muss".to_string(),
4689 codes: vec![AhbCodeRule {
4690 value: composite_code.to_string(),
4691 description: "".to_string(),
4692 ahb_status: "X".to_string(),
4693 }],
4694 parent_group_ahb_status: None,
4695 segment_ahb_status: None,
4696 element_index: Some(1),
4697 component_index: Some(0),
4698 mig_number: Some(mig.to_string()),
4699 },
4700 ]
4701 };
4702
4703 let mut fields = make_rules("00108", "Z12");
4704 fields.extend(make_rules("00197", "SRW"));
4705
4706 let workflow = AhbWorkflow {
4707 pruefidentifikator: "55035".to_string(),
4708 description: "Test".to_string(),
4709 communication_direction: None,
4710 fields,
4711 ub_definitions: BTreeMap::new(),
4712 };
4713
4714 let report = validator.validate(
4715 &[pia_5_z12, pia_5_srw],
4716 &workflow,
4717 &external,
4718 ValidationLevel::Conditions,
4719 );
4720
4721 let code_errors: Vec<_> = report
4722 .by_category(ValidationCategory::Code)
4723 .filter(|i| {
4724 i.severity == Severity::Error && i.code() == ErrorCodes::CODE_NOT_ALLOWED_FOR_PID
4725 })
4726 .collect();
4727 assert!(
4728 code_errors.is_empty(),
4729 "Both PIA+5+Z12 (mig=00108) and PIA+5+SRW (mig=00197) must be accepted. Got: {:?}",
4730 code_errors
4731 );
4732 }
4733}