1use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
4
5use crate::expr::{ConditionExpr, ConditionParser, StatusKind};
6
7use crate::eval::answer_lists::{answer_path, is_answer_list_id};
8use crate::eval::{
9 AnswerCodeLists, ConditionEvaluator, ConditionExprEvaluator, ConditionResult,
10 EvaluationContext, ExternalConditionProvider, GroupScope, StatusResolution,
11};
12use mig_assembly::assembler::AssembledTree;
13use mig_assembly::navigator::AssembledTreeNavigator;
14use mig_types::navigator::GroupNavigator;
15use mig_types::segment::OwnedSegment;
16
17use super::tree::{AhbGroupNode, AhbNode, ValidatedTree};
18
19use super::issue::{Severity, ValidationIssue};
20use super::level::ValidationLevel;
21use super::report::ValidationReport;
22use crate::{IssueKind, UnresolvedConditions};
23
24pub use ahb_types::{AhbCodeRule, AhbFieldRule, AhbWorkflow};
30
31pub struct EdifactValidator<E: ConditionEvaluator> {
64 evaluator: E,
65 report_segments: bool,
66}
67
68impl<E: ConditionEvaluator> EdifactValidator<E> {
69 pub fn new(evaluator: E) -> Self {
71 Self {
72 evaluator,
73 report_segments: false,
74 }
75 }
76
77 pub fn reporting_segments(mut self) -> Self {
83 self.report_segments = true;
84 self
85 }
86
87 pub fn validate(
100 &self,
101 segments: &[OwnedSegment],
102 workflow: &AhbWorkflow,
103 external: &dyn ExternalConditionProvider,
104 level: ValidationLevel,
105 ) -> ValidationReport {
106 let mut report = ValidationReport::new(self.evaluator.message_type(), level)
107 .with_format_version(self.evaluator.format_version())
108 .with_pruefidentifikator(&workflow.pruefidentifikator);
109
110 let ctx = EvaluationContext::new(&workflow.pruefidentifikator, external, segments);
111
112 if matches!(level, ValidationLevel::Conditions | ValidationLevel::Full) {
113 self.validate_conditions(workflow, &ctx, &mut report);
114 }
115
116 report
117 }
118
119 pub fn validate_with_navigator(
125 &self,
126 segments: &[OwnedSegment],
127 workflow: &AhbWorkflow,
128 external: &dyn ExternalConditionProvider,
129 level: ValidationLevel,
130 navigator: &dyn GroupNavigator,
131 ) -> ValidationReport {
132 let mut report = ValidationReport::new(self.evaluator.message_type(), level)
133 .with_format_version(self.evaluator.format_version())
134 .with_pruefidentifikator(&workflow.pruefidentifikator);
135
136 let ctx = EvaluationContext::with_navigator(
137 &workflow.pruefidentifikator,
138 external,
139 segments,
140 navigator,
141 );
142
143 if matches!(level, ValidationLevel::Conditions | ValidationLevel::Full) {
144 self.validate_conditions(workflow, &ctx, &mut report);
145 }
146
147 report
148 }
149
150 pub fn validate_tree(
157 &self,
158 validated_tree: &ValidatedTree,
159 segments: &[OwnedSegment],
160 external: &dyn ExternalConditionProvider,
161 level: ValidationLevel,
162 navigator: Option<&dyn GroupNavigator>,
163 ) -> ValidationReport {
164 self.validate_tree_in(validated_tree, segments, external, level, navigator, None)
165 }
166
167 pub fn validate_assembled_tree(
174 &self,
175 validated_tree: &ValidatedTree,
176 segments: &[OwnedSegment],
177 external: &dyn ExternalConditionProvider,
178 level: ValidationLevel,
179 tree: &AssembledTree,
180 ) -> ValidationReport {
181 let navigator = AssembledTreeNavigator::new(tree);
182 self.validate_tree_in(
183 validated_tree,
184 segments,
185 external,
186 level,
187 Some(&navigator),
188 Some(tree),
189 )
190 }
191
192 fn validate_tree_in(
193 &self,
194 validated_tree: &ValidatedTree,
195 segments: &[OwnedSegment],
196 external: &dyn ExternalConditionProvider,
197 level: ValidationLevel,
198 navigator: Option<&dyn GroupNavigator>,
199 tree: Option<&AssembledTree>,
200 ) -> ValidationReport {
201 let mut report = ValidationReport::new(self.evaluator.message_type(), level)
202 .with_format_version(self.evaluator.format_version())
203 .with_pruefidentifikator(validated_tree.pruefidentifikator);
204
205 if !matches!(level, ValidationLevel::Conditions | ValidationLevel::Full) {
206 return report;
207 }
208
209 let ctx = match navigator {
210 Some(nav) => EvaluationContext::with_navigator(
211 validated_tree.pruefidentifikator,
212 external,
213 segments,
214 nav,
215 ),
216 None => EvaluationContext::new(validated_tree.pruefidentifikator, external, segments),
217 };
218
219 let expr_eval = ConditionExprEvaluator::new(&self.evaluator);
220
221 let mut all_nodes: Vec<&AhbNode> = Vec::new();
224 all_nodes.extend(validated_tree.root_fields.iter());
225 for group in &validated_tree.groups {
226 collect_nodes_depth_first(group, &mut all_nodes);
227 }
228
229 let mut tag_migs: HashMap<String, HashSet<&str>> = HashMap::new();
236 for node in &all_nodes {
237 if let Some(ref m) = node.rule.mig_number {
238 tag_migs
239 .entry(extract_segment_id(&node.rule.segment_path))
240 .or_default()
241 .insert(m.as_str());
242 }
243 }
244 for rule in &validated_tree.unmatched_rules {
245 if let Some(ref m) = rule.mig_number {
246 tag_migs
247 .entry(extract_segment_id(&rule.segment_path))
248 .or_default()
249 .insert(m.as_str());
250 }
251 }
252
253 for node in &validated_tree.root_fields {
255 evaluate_node(
256 node,
257 &ctx,
258 &expr_eval,
259 &self.evaluator,
260 validated_tree.ub_definitions,
261 &tag_migs,
262 None,
263 true,
264 &mut report,
265 );
266 }
267
268 if self.report_segments {
269 refuse_segments(
270 &validated_tree.root_fields,
271 None,
272 &[],
273 &ctx,
274 &self.evaluator,
275 validated_tree.ub_definitions,
276 0,
277 &mut report,
278 );
279 }
280
281 let transaction = crate::eval::transaction_group(self.evaluator.message_type());
286 let absent = crate::eval::AbsentTarget(&self.evaluator);
287 let absent_eval = ConditionExprEvaluator::new(&absent);
288 let mut per_transaction: HashSet<&str> = HashSet::new();
292 let mut instance_counter: HashMap<&str, usize> = HashMap::new();
293 for group in &validated_tree.groups {
294 let instance_index = *instance_counter
295 .entry(group.group_id)
296 .and_modify(|c| *c += 1)
297 .or_insert(0);
298
299 let view = tree
302 .filter(|_| transaction == Some(group.group_id))
303 .and_then(|t| t.transaction_view(group.group_id, instance_index));
304 if let Some(view) = view {
305 let view_segments: Vec<OwnedSegment> = segments
306 .iter()
307 .filter(|s| view.left_out.binary_search(&s.segment_number).is_err())
308 .cloned()
309 .collect();
310 let view_navigator = AssembledTreeNavigator::new(&view.tree);
311 let view_ctx = ctx.with_view(&view_segments, &view_navigator);
312 let path_storage = [(group.group_id, 0)];
313 let scoped_ctx = view_ctx.with_scope(GroupScope {
314 path: &path_storage,
315 });
316 walk_group_instance(
317 group,
318 &path_storage,
319 &scoped_ctx,
320 &expr_eval,
321 &self.evaluator,
322 validated_tree.ub_definitions,
323 &tag_migs,
324 instance_index,
325 self.report_segments,
326 &mut report,
327 );
328 report_absent_children(
329 group,
330 &path_storage,
331 &scoped_ctx,
332 &absent_eval,
333 &self.evaluator,
334 validated_tree.ub_definitions,
335 instance_index,
336 &mut report,
337 );
338 per_transaction.extend(
339 group
340 .variant_rules
341 .iter()
342 .filter_map(|r| r.mig_number.as_deref()),
343 );
344 continue;
345 }
346
347 let path_storage = [(group.group_id, instance_index)];
348 let scoped_ctx = ctx.with_scope(GroupScope {
351 path: &path_storage,
352 });
353
354 walk_group_instance(
355 group,
356 &path_storage,
357 &scoped_ctx,
358 &expr_eval,
359 &self.evaluator,
360 validated_tree.ub_definitions,
361 &tag_migs,
362 instance_index,
363 self.report_segments,
364 &mut report,
365 );
366 }
367
368 if let Some((once, transaction)) =
370 crate::eval::once_per_transaction(self.evaluator.message_type())
371 {
372 let mut index: HashMap<&str, usize> = HashMap::new();
373 for group in &validated_tree.groups {
374 let i = *index
375 .entry(group.group_id)
376 .and_modify(|c| *c += 1)
377 .or_insert(0);
378 if group.group_id == transaction {
379 report_repeated_in_transaction(group, i, once, &ctx, &mut report);
380 }
381 }
382 }
383
384 for field in &validated_tree.unmatched_rules {
394 if field
395 .mig_number
396 .as_deref()
397 .is_some_and(|n| per_transaction.contains(n))
398 {
399 continue;
400 }
401 if should_skip_for_parent_group(
402 field,
403 &absent_eval,
404 &ctx,
405 validated_tree.ub_definitions,
406 ) {
407 continue;
408 }
409
410 let status =
411 absent_eval.resolve_status(&field.ahb_status, &ctx, validated_tree.ub_definitions);
412
413 if status.result == ConditionResult::True
414 && status.kind == Some(StatusKind::Muss)
415 && !is_field_present(&ctx, field)
416 && !is_group_variant_absent(
417 &ctx,
418 field,
419 is_parent_group_required(
420 field,
421 &absent_eval,
422 &ctx,
423 validated_tree.ub_definitions,
424 ),
425 )
426 {
427 let mut issue = ValidationIssue::new(
428 Severity::Error,
429 IssueKind::MissingRequiredField {
430 field_name: field.name.clone(),
431 },
432 )
433 .with_field_path(&field.segment_path)
434 .with_rule(&field.ahb_status);
435 if let Some(first_code) = field.codes.first() {
436 issue.expected_value = Some(first_code.value.clone());
437 }
438 issue = attach_field_position(issue, field);
439 report.add_issue(issue);
440 }
441 }
442
443 if matches!(level, ValidationLevel::Full) {
447 let mut all_fields: Vec<AhbFieldRule> = Vec::new();
448 for node in &all_nodes {
449 all_fields.push(node.rule.clone());
450 }
451 for rule in &validated_tree.unmatched_rules {
452 all_fields.push((*rule).clone());
453 }
454 let synthetic_workflow = AhbWorkflow {
455 pruefidentifikator: validated_tree.pruefidentifikator.to_string(),
456 description: String::new(),
457 communication_direction: None,
458 fields: all_fields,
459 ub_definitions: validated_tree.ub_definitions.clone(),
460 package_conditions: validated_tree.package_conditions.clone(),
461 };
462 self.validate_codes_cross_field(&synthetic_workflow, &ctx, &mut report);
463 self.validate_answer_lists(&synthetic_workflow, &ctx, &mut report);
464 self.validate_package_cardinality(&synthetic_workflow, &ctx, &mut report);
465 }
466
467 report
468 }
469
470 fn validate_conditions(
472 &self,
473 workflow: &AhbWorkflow,
474 ctx: &EvaluationContext,
475 report: &mut ValidationReport,
476 ) {
477 let expr_eval = ConditionExprEvaluator::new(&self.evaluator);
478
479 for field in &workflow.fields {
480 if should_skip_for_parent_group(field, &expr_eval, ctx, &workflow.ub_definitions) {
482 continue;
483 }
484
485 let StatusResolution {
488 result: condition_result,
489 kind,
490 unknown_ids,
491 } = expr_eval.resolve_status(&field.ahb_status, ctx, &workflow.ub_definitions);
492 let mandatory = kind == Some(StatusKind::Muss);
493
494 match condition_result {
495 ConditionResult::True => {
496 if mandatory
498 && !is_field_present(ctx, field)
499 && !is_group_variant_absent(
500 ctx,
501 field,
502 is_parent_group_required(
503 field,
504 &expr_eval,
505 ctx,
506 &workflow.ub_definitions,
507 ),
508 )
509 {
510 let mut issue = ValidationIssue::new(
511 Severity::Error,
512 IssueKind::MissingRequiredField {
513 field_name: field.name.clone(),
514 },
515 )
516 .with_field_path(&field.segment_path)
517 .with_rule(&field.ahb_status);
518 if let Some(first_code) = field.codes.first() {
522 issue.expected_value = Some(first_code.value.clone());
523 }
524 report.add_issue(issue);
525 }
526 }
527 ConditionResult::False => {
528 if mandatory && is_field_present(ctx, field) {
534 report.add_issue(
535 ValidationIssue::new(
536 Severity::Error,
537 IssueKind::FieldConditionNotSatisfied {
538 field_name: field.name.clone(),
539 },
540 )
541 .with_field_path(&field.segment_path)
542 .with_rule(&field.ahb_status),
543 );
544 }
545 }
546 ConditionResult::Unknown => {
547 let mut external_ids = Vec::new();
552 let mut undetermined_ids = Vec::new();
553 let mut missing_ids = Vec::new();
554 for id in unknown_ids {
555 if self.evaluator.is_external(id) {
556 external_ids.push(id);
557 } else if self.evaluator.is_known(id) {
558 undetermined_ids.push(id);
559 } else {
560 missing_ids.push(id);
561 }
562 }
563
564 report.add_issue(
565 ValidationIssue::new(
566 Severity::Info,
567 IssueKind::ConditionUnknown {
568 field_name: field.name.clone(),
569 unresolved: UnresolvedConditions {
570 external: external_ids,
571 undetermined: undetermined_ids,
572 missing: missing_ids,
573 },
574 },
575 )
576 .with_field_path(&field.segment_path)
577 .with_rule(&field.ahb_status),
578 );
579 }
580 }
581 }
582
583 self.validate_codes_cross_field(workflow, ctx, report);
588 self.validate_answer_lists(workflow, ctx, report);
589
590 self.validate_package_cardinality(workflow, ctx, report);
593 }
594
595 fn validate_package_cardinality(
602 &self,
603 workflow: &AhbWorkflow,
604 ctx: &EvaluationContext,
605 report: &mut ValidationReport,
606 ) {
607 struct PackageGroup {
610 min: u32,
611 max: u32,
612 max_sum: u32,
615 bounds: BTreeSet<(u32, u32)>,
616 code_values: Vec<String>,
617 element_index: usize,
618 component_index: usize,
619 }
620
621 let mut groups: HashMap<(String, Option<String>, u32, u32, u32), PackageGroup> =
630 HashMap::new();
631 let mut alternatives: HashMap<(String, Option<String>, u32), BTreeSet<u32>> =
635 HashMap::new();
636
637 let expr_eval = ConditionExprEvaluator::new(&self.evaluator);
638
639 for field in &workflow.fields {
640 let el_idx = field.element_index.unwrap_or(0);
641 let comp_idx = field.component_index.unwrap_or(0);
642
643 if should_skip_for_parent_group(field, &expr_eval, ctx, &workflow.ub_definitions) {
645 continue;
646 }
647
648 for code in &field.codes {
649 if let Ok(Some(expr)) = ConditionParser::parse(&code.ahb_status) {
651 let mut packages = Vec::new();
653 collect_packages(&expr, &mut packages);
654
655 if packages.len() > 1 {
656 let ids: BTreeSet<u32> = packages.iter().map(|(id, _, _)| *id).collect();
657 for id in &ids {
658 alternatives
659 .entry((field.segment_path.clone(), field.mig_number.clone(), *id))
660 .or_default()
661 .extend(ids.iter().copied().filter(|other| other != id));
662 }
663 }
664
665 for (pkg_id, pkg_min, pkg_max) in packages {
666 let key = (
667 field.segment_path.clone(),
668 field.mig_number.clone(),
669 pkg_id,
670 0,
671 0,
672 );
673 let group = groups.entry(key).or_insert_with(|| PackageGroup {
674 min: pkg_min,
675 max: pkg_max,
676 max_sum: 0,
677 bounds: BTreeSet::new(),
678 code_values: Vec::new(),
679 element_index: el_idx,
680 component_index: comp_idx,
681 });
682 group.bounds.insert((pkg_min, pkg_max));
689 group.min = group.min.min(pkg_min);
690 if !group.code_values.contains(&code.value) {
691 group.max_sum = group.max_sum.saturating_add(pkg_max);
692 }
693 group.max = group.max.max(pkg_max);
694 group.code_values.push(code.value.clone());
695 }
696 }
697 }
698 }
699
700 let mut field_packages: HashMap<(String, Option<String>), BTreeSet<u32>> = HashMap::new();
711 for (seg_path, mig_number, pkg_id, _, _) in groups.keys() {
712 field_packages
713 .entry((seg_path.clone(), mig_number.clone()))
714 .or_default()
715 .insert(*pkg_id);
716 }
717 let with_alternatives: Vec<(String, Option<String>)> = alternatives
718 .keys()
719 .map(|(p, m, _)| (p.clone(), m.clone()))
720 .collect();
721 for field in with_alternatives {
722 let Some(ids) = field_packages.get(&field) else {
723 continue;
724 };
725 for id in ids {
726 alternatives
727 .entry((field.0.clone(), field.1.clone(), *id))
728 .or_default()
729 .extend(ids.iter().copied().filter(|other| other != id));
730 }
731 }
732
733 type Counted = (Vec<usize>, Vec<bool>, usize, usize, Vec<String>);
735 let mut counted: HashMap<(String, Option<String>, u32, u32, u32), Counted> = HashMap::new();
736 for ((seg_path, mig_number, pkg_id, _, _), group) in &groups {
737 let segment_id = extract_segment_id(seg_path);
738
739 let mut unique_codes: Vec<&str> =
743 group.code_values.iter().map(|s| s.as_str()).collect();
744 unique_codes.sort_unstable();
745 unique_codes.dedup();
746 let code_set: HashSet<&str> = unique_codes.iter().copied().collect();
747
748 let group_path_str = extract_group_path_key(seg_path);
749 let group_path: Vec<&str> = if group_path_str.is_empty() {
750 Vec::new()
751 } else {
752 group_path_str.split('/').collect()
753 };
754
755 let min = group.min as usize;
756 let max = if group.bounds.len() > 1 {
757 group.max_sum as usize
758 } else {
759 group.max as usize
760 };
761
762 type Instance<'p> = (usize, Option<Vec<(&'p str, usize)>>);
765 let per_instance_counts: Option<Vec<Instance>> =
766 match (ctx.navigator, group_path.is_empty()) {
767 (Some(nav), false)
775 if group_path.len() > 1
776 && !full_instance_paths(nav, &group_path).is_empty() =>
777 {
778 let paths = full_instance_paths(nav, &group_path);
779 let variant_entries: Vec<(usize, VariantEntry)> = match mig_number
787 .as_deref()
788 {
789 Some(m) => (0..group_path.len())
790 .filter_map(|level| {
791 let prefix = group_path[..=level].join("/");
792 merged_variant_entry(workflow, &prefix, m).map(|e| (level, e))
793 })
794 .collect(),
795 None => Vec::new(),
796 };
797 if paths.is_empty() {
798 None
799 } else {
800 Some(
801 paths
802 .into_iter()
803 .filter(|path| match mig_number.as_deref() {
804 Some(m) => nav.instance_at_has_mig_number(path, m),
805 None => true,
806 })
807 .filter(|path| {
808 variant_entries.iter().all(|(level, entry)| {
809 nav.find_segments_in_instance(
810 &entry.tag,
811 &path[..=*level],
812 )
813 .first()
814 .and_then(|seg| {
815 seg.elements
816 .get(entry.element)
817 .and_then(|e| e.get(entry.component))
818 .cloned()
819 })
820 .map_or(true, |v| entry.codes.contains(v.as_str()))
821 })
822 })
823 .map(|path| {
824 let count = nav
825 .find_segments_in_instance(&segment_id, &path)
826 .iter()
827 .filter_map(|seg| {
828 seg.elements
829 .get(group.element_index)
830 .and_then(|e| e.get(group.component_index))
831 .filter(|v| !v.is_empty())
832 .cloned()
833 })
834 .filter(|v| code_set.contains(v.as_str()))
835 .count();
836 (count, Some(path))
837 })
838 .collect(),
839 )
840 }
841 }
842 (Some(nav), false) => {
843 let instance_count = nav.group_instance_count(&group_path);
844 if instance_count == 0 {
845 None
846 } else {
847 Some(
855 (0..instance_count)
856 .filter(|i| match mig_number.as_deref() {
857 Some(m) => nav.instance_has_mig_number(&group_path, *i, m),
858 None => true,
859 })
860 .map(|i| {
861 let count = nav
862 .find_segments_in_group(&segment_id, &group_path, i)
863 .iter()
864 .filter_map(|seg| {
865 seg.elements
866 .get(group.element_index)
867 .and_then(|e| e.get(group.component_index))
868 .filter(|v| !v.is_empty())
869 .cloned()
870 })
871 .filter(|v| code_set.contains(v.as_str()))
872 .count();
873 let path = (group_path.len() == 1)
875 .then(|| vec![(group_path[0], i)]);
876 (count, path)
877 })
878 .collect(),
879 )
880 }
881 }
882 _ => None,
883 };
884
885 let instances = per_instance_counts.unwrap_or_else(|| {
886 let segments = ctx.find_segments(&segment_id);
887 let count = segments
888 .iter()
889 .filter_map(|seg| {
890 seg.elements
891 .get(group.element_index)
892 .and_then(|e| e.get(group.component_index))
893 .filter(|v| !v.is_empty())
894 .map(|s| s.as_str())
895 })
896 .filter(|v| code_set.contains(v))
897 .count();
898 vec![(count, None)]
899 });
900 let applies: Vec<bool> = instances
903 .iter()
904 .map(|(_, path)| match workflow.package_conditions.get(pkg_id) {
905 None => true,
906 Some(condition) => {
907 let result = match path {
908 Some(path) => expr_eval.evaluate(
909 condition,
910 &ctx.with_scope(crate::eval::GroupScope { path }),
911 ),
912 None => expr_eval.evaluate(condition, ctx),
913 };
914 result == ConditionResult::True
915 }
916 })
917 .collect();
918 let counts: Vec<usize> = instances.iter().map(|(c, _)| *c).collect();
919
920 counted.insert(
921 (
922 seg_path.clone(),
923 mig_number.clone(),
924 *pkg_id,
925 group.min,
926 group.max,
927 ),
928 (
929 counts,
930 applies,
931 min,
932 max,
933 unique_codes.iter().map(|s| s.to_string()).collect(),
934 ),
935 );
936 }
937
938 let mut keys: Vec<&(String, Option<String>, u32, u32, u32)> = counted.keys().collect();
943 keys.sort();
944 for key in keys {
945 let (counts, applies, min, max, codes) = &counted[key];
946 let (seg_path, mig_number, pkg_id, _, _) = key;
947 let holds = |c: &[usize], a: &[bool], lo: usize, hi: usize, i: usize| {
948 c.len() == counts.len() && a[i] && (lo..=hi).contains(&c[i]) && c[i] > 0
949 };
950 let alternative_holds = |i: usize| {
951 alternatives
952 .get(&(seg_path.clone(), mig_number.clone(), *pkg_id))
953 .is_some_and(|alts| {
954 counted.iter().any(|((p, m, id, _, _), (c, a, lo, hi, _))| {
955 p == seg_path
956 && m == mig_number
957 && alts.contains(id)
958 && holds(c, a, *lo, *hi, i)
959 })
960 })
961 };
962 let applicable_alternative_holds = |i: usize| {
967 *min > 0
968 && counted.iter().any(|((p, m, id, _, _), (c, a, lo, hi, _))| {
969 p == seg_path
970 && m == mig_number
971 && id != pkg_id
972 && *lo > 0
973 && holds(c, a, *lo, *hi, i)
974 })
975 };
976 let mut reported_counts: HashSet<usize> = HashSet::new();
977 for (i, &present_count) in counts.iter().enumerate() {
978 if applies[i]
979 && (present_count < *min || present_count > *max)
980 && !alternative_holds(i)
981 && !(present_count < *min && applicable_alternative_holds(i))
982 && reported_counts.insert(present_count)
983 {
984 report.add_issue(
985 ValidationIssue::new(
986 Severity::Error,
987 IssueKind::PackageCardinality {
988 package_id: pkg_id.to_string(),
989 present: present_count,
990 min: *min,
991 max: *max,
992 codes: codes.clone(),
993 },
994 )
995 .with_field_path(seg_path)
996 .with_expected(format!("{min}..{max}"))
997 .with_actual(present_count.to_string()),
998 );
999 }
1000 }
1001 }
1002 }
1003
1004 fn validate_codes_cross_field(
1016 &self,
1017 workflow: &AhbWorkflow,
1018 ctx: &EvaluationContext,
1019 report: &mut ValidationReport,
1020 ) {
1021 if ctx.navigator.is_some() {
1022 self.validate_codes_group_scoped(workflow, ctx, report);
1023 } else {
1024 self.validate_codes_tag_scoped(workflow, ctx, report);
1025 }
1026 }
1027
1028 fn validate_codes_group_scoped(
1031 &self,
1032 workflow: &AhbWorkflow,
1033 ctx: &EvaluationContext,
1034 report: &mut ValidationReport,
1035 ) {
1036 let by_loc = partition_codes_by_mig(workflow);
1037 let known_qualifiers = global_qualifiers_by_tag(&by_loc);
1038 let nav = ctx.navigator.unwrap();
1039
1040 for ((group_key, tag), migs) in &by_loc {
1041 let field_path = if group_key.is_empty() {
1042 format!("{tag}/qualifier")
1043 } else {
1044 format!("{group_key}/{tag}/qualifier")
1045 };
1046
1047 let group_path: Vec<&str> = if group_key.is_empty() {
1048 Vec::new()
1049 } else {
1050 group_key.split('/').collect()
1051 };
1052
1053 let tag_qualifiers = known_qualifiers.get(tag);
1054
1055 if group_path.is_empty() {
1056 Self::validate_segments_per_mig(
1057 &ctx.find_segments(tag),
1058 migs,
1059 tag_qualifiers,
1060 tag,
1061 &field_path,
1062 report,
1063 );
1064 } else {
1065 let instance_count = nav.group_instance_count(&group_path);
1066 for i in 0..instance_count {
1067 let owned = nav.find_segments_in_group(tag, &group_path, i);
1068 let refs: Vec<&OwnedSegment> = owned.iter().collect();
1069 Self::validate_segments_per_mig(
1070 &refs,
1071 migs,
1072 tag_qualifiers,
1073 tag,
1074 &field_path,
1075 report,
1076 );
1077 }
1078 }
1079 }
1080 }
1081
1082 fn validate_codes_tag_scoped(
1086 &self,
1087 workflow: &AhbWorkflow,
1088 ctx: &EvaluationContext,
1089 report: &mut ValidationReport,
1090 ) {
1091 let by_loc = partition_codes_by_mig(workflow);
1092 let known_qualifiers = global_qualifiers_by_tag(&by_loc);
1093 let mut by_tag: HashMap<String, HashMap<Option<String>, MigCodeBucket>> = HashMap::new();
1095 for ((_group_key, tag), migs) in by_loc {
1096 let merged = by_tag.entry(tag).or_default();
1097 for (mig_key, bucket) in migs {
1098 let entry = merged.entry(mig_key).or_default();
1099 if entry.qualifier_position.is_none() {
1100 entry.qualifier_position = bucket.qualifier_position;
1101 }
1102 if entry.qualifier_position == bucket.qualifier_position {
1103 entry.qualifier_values.extend(&bucket.qualifier_values);
1104 }
1105 for (pos, codes) in bucket.codes {
1106 entry.codes.entry(pos).or_default().extend(codes);
1107 }
1108 }
1109 }
1110
1111 for (tag, migs) in &by_tag {
1112 let field_path = format!("{tag}/qualifier");
1113 Self::validate_segments_per_mig(
1114 &ctx.find_segments(tag),
1115 migs,
1116 known_qualifiers.get(tag),
1117 tag,
1118 &field_path,
1119 report,
1120 );
1121 }
1122 }
1123
1124 fn validate_segments_per_mig(
1133 segments: &[&OwnedSegment],
1134 migs: &HashMap<Option<String>, MigCodeBucket>,
1135 tag_qualifiers: Option<&HashMap<(usize, usize), HashSet<String>>>,
1136 tag: &str,
1137 field_path: &str,
1138 report: &mut ValidationReport,
1139 ) {
1140 for seg in segments {
1141 match match_segment_to_mig(seg, migs) {
1142 Some(bucket) => {
1143 for ((el, c), allowed) in &bucket.codes {
1144 if allowed.is_empty() {
1145 continue;
1146 }
1147 Self::check_segments_against_codes(
1148 vec![*seg],
1149 allowed,
1150 tag,
1151 *el,
1152 *c,
1153 field_path,
1154 report,
1155 );
1156 }
1157 }
1158 None => {
1159 let Some(qualifiers) = tag_qualifiers else {
1160 continue;
1161 };
1162 for ((el, c), allowed) in qualifiers {
1167 let Some(actual) = seg
1168 .elements
1169 .get(*el)
1170 .and_then(|e| e.get(*c))
1171 .filter(|v| !v.is_empty())
1172 .map(|s| s.as_str())
1173 else {
1174 continue;
1175 };
1176 if allowed.iter().any(|v| v == actual) {
1177 break;
1179 }
1180 let bucket_values: HashSet<&str> = migs
1183 .values()
1184 .filter(|b| b.qualifier_position == Some((*el, *c)))
1185 .flat_map(|b| b.qualifier_values.iter().copied())
1186 .collect();
1187 if !bucket_values.is_empty() {
1188 Self::check_segments_against_codes(
1189 vec![*seg],
1190 &bucket_values,
1191 tag,
1192 *el,
1193 *c,
1194 field_path,
1195 report,
1196 );
1197 break;
1198 }
1199 }
1200 }
1201 }
1202 }
1203 }
1204
1205 fn validate_answer_lists(
1214 &self,
1215 workflow: &AhbWorkflow,
1216 ctx: &EvaluationContext,
1217 report: &mut ValidationReport,
1218 ) {
1219 let lists = AnswerCodeLists::embedded();
1220 let fv = self.evaluator.format_version();
1221 let mut seen: HashSet<(*const OwnedSegment, usize, usize)> = HashSet::new();
1222 for list_rule in &workflow.fields {
1223 let Some(answer_path) = answer_path(&list_rule.segment_path) else {
1224 continue;
1225 };
1226 if list_rule.codes.is_empty()
1227 || !list_rule.codes.iter().all(|c| is_answer_list_id(&c.value))
1228 {
1229 continue;
1230 }
1231 let Some(answer_rule) = workflow
1232 .fields
1233 .iter()
1234 .find(|f| f.segment_path == answer_path && f.mig_number == list_rule.mig_number)
1235 else {
1236 continue;
1237 };
1238 let at = |rule: &AhbFieldRule| {
1239 (
1240 rule.element_index.unwrap_or(0),
1241 rule.component_index.unwrap_or(0),
1242 )
1243 };
1244 let (list_el, list_c) = at(list_rule);
1245 let (answer_el, answer_c) = at(answer_rule);
1246 let value = |seg: &OwnedSegment, el: usize, c: usize| {
1247 seg.elements
1248 .get(el)
1249 .and_then(|e| e.get(c))
1250 .filter(|v| !v.is_empty())
1251 .cloned()
1252 };
1253 for seg in ctx.find_segments(&extract_segment_id(&list_rule.segment_path)) {
1254 if !seen.insert((seg as *const OwnedSegment, list_el, list_c)) {
1255 continue;
1256 }
1257 let (Some(list), Some(answer)) =
1258 (value(seg, list_el, list_c), value(seg, answer_el, answer_c))
1259 else {
1260 continue;
1261 };
1262 if !list_rule.codes.iter().any(|c| c.value == list) {
1263 continue;
1264 }
1265 if answer_rule.codes.iter().any(|c| c.value == answer) {
1269 continue;
1270 }
1271 let Some(codes) = lists.codes(fv, &list) else {
1272 continue;
1273 };
1274 if codes.contains(&answer) {
1275 continue;
1276 }
1277 let mut allowed: Vec<String> = codes.iter().cloned().collect();
1278 allowed.sort_unstable();
1279 report.add_issue(
1280 ValidationIssue::new(
1281 Severity::Warning,
1282 IssueKind::AnswerNotInList {
1283 answer: answer.clone(),
1284 list: list.clone(),
1285 allowed: allowed.clone(),
1286 },
1287 )
1288 .with_field_path(&answer_rule.segment_path)
1289 .with_actual(&answer)
1290 .with_expected(allowed.join(", ")),
1291 );
1292 }
1293 }
1294 }
1295
1296 fn check_segments_against_codes(
1298 segments: Vec<&OwnedSegment>,
1299 allowed_codes: &HashSet<&str>,
1300 _tag: &str,
1301 el_idx: usize,
1302 comp_idx: usize,
1303 field_path: &str,
1304 report: &mut ValidationReport,
1305 ) {
1306 for segment in segments {
1307 if let Some(code_value) = segment
1308 .elements
1309 .get(el_idx)
1310 .and_then(|e| e.get(comp_idx))
1311 .filter(|v| !v.is_empty())
1312 {
1313 if !allowed_codes.contains(code_value.as_str()) {
1314 let mut sorted_codes: Vec<&str> = allowed_codes.iter().copied().collect();
1315 sorted_codes.sort_unstable();
1316 let kind = IssueKind::CodeNotAllowedForPid {
1317 value: code_value.to_string(),
1318 allowed: sorted_codes.iter().map(|s| (*s).to_string()).collect(),
1319 };
1320 report.add_issue(
1321 ValidationIssue::new(Severity::Error, kind)
1322 .with_field_path(field_path)
1323 .with_actual(code_value)
1324 .with_expected(sorted_codes.join(", ")),
1325 );
1326 }
1327 }
1328 }
1329 }
1330}
1331
1332fn full_instance_paths<'p>(
1335 nav: &dyn GroupNavigator,
1336 group_path: &[&'p str],
1337) -> Vec<Vec<(&'p str, usize)>> {
1338 let mut paths: Vec<Vec<(&'p str, usize)>> = vec![Vec::new()];
1339 for g in group_path {
1340 paths = paths
1341 .into_iter()
1342 .flat_map(|parent| {
1343 (0..nav.child_instance_count(&parent, g)).map(move |i| {
1344 let mut path = parent.clone();
1345 path.push((*g, i));
1346 path
1347 })
1348 })
1349 .collect();
1350 }
1351 paths
1352}
1353
1354struct VariantEntry<'a> {
1357 tag: String,
1358 element: usize,
1359 component: usize,
1360 codes: HashSet<&'a str>,
1361}
1362
1363fn merged_variant_entry<'a>(
1369 workflow: &'a AhbWorkflow,
1370 group_path: &str,
1371 mig: &str,
1372) -> Option<VariantEntry<'a>> {
1373 let mig: u32 = mig.parse().ok()?;
1374 let prefix = format!("{group_path}/");
1375 let direct: Vec<(&AhbFieldRule, u32, &str)> = workflow
1377 .fields
1378 .iter()
1379 .filter_map(|r| {
1380 let rest = r.segment_path.strip_prefix(&prefix)?;
1381 let tag = rest.split('/').next()?;
1382 if tag.starts_with("SG") {
1383 return None;
1384 }
1385 Some((r, r.mig_number.as_deref()?.parse::<u32>().ok()?, tag))
1386 })
1387 .collect();
1388 let (_, _, entry_tag) = *direct.iter().min_by_key(|(_, n, _)| *n)?;
1390 let entries: Vec<&(&AhbFieldRule, u32, &str)> = direct
1391 .iter()
1392 .filter(|(r, _, tag)| *tag == entry_tag && !r.codes.is_empty())
1393 .collect();
1394 let numbers: HashSet<u32> = entries.iter().map(|(_, n, _)| *n).collect();
1395 if numbers.len() < 2 {
1396 return None;
1397 }
1398 let own = entries
1399 .iter()
1400 .filter(|(_, n, _)| *n <= mig)
1401 .map(|(_, n, _)| *n)
1402 .max()?;
1403 let (rule, _, tag) = entries
1405 .iter()
1406 .filter(|(_, n, _)| *n == own)
1407 .min_by_key(|(r, _, _)| (r.element_index.unwrap_or(0), r.component_index.unwrap_or(0)))?;
1408 Some(VariantEntry {
1409 tag: tag.to_string(),
1410 element: rule.element_index.unwrap_or(0),
1411 component: rule.component_index.unwrap_or(0),
1412 codes: rule.codes.iter().map(|c| c.value.as_str()).collect(),
1413 })
1414}
1415
1416fn value_length(format: &str, value: &str) -> usize {
1420 let format = format.trim();
1421 if format.starts_with('n') {
1422 value
1423 .chars()
1424 .filter(|c| !matches!(c, '.' | ',' | '-'))
1425 .count()
1426 } else {
1427 value.chars().count()
1428 }
1429}
1430
1431fn max_length(format: &str) -> Option<usize> {
1434 let digits = format
1435 .trim()
1436 .strip_prefix("an")
1437 .or_else(|| format.trim().strip_prefix('a'))
1438 .or_else(|| format.trim().strip_prefix('n'))?;
1439 digits.strip_prefix("..").unwrap_or(digits).parse().ok()
1440}
1441
1442fn governing_status<'a>(field: &'a AhbFieldRule, value: Option<&str>) -> &'a str {
1453 let derived_from_codes = field
1454 .codes
1455 .first()
1456 .is_some_and(|c| c.ahb_status == field.ahb_status);
1457 if !derived_from_codes && !codes_are_alternatives(field) {
1458 return &field.ahb_status;
1459 }
1460 value
1461 .and_then(|v| field.codes.iter().find(|c| c.value == v))
1462 .map_or(&field.ahb_status, |c| &c.ahb_status)
1463}
1464
1465fn codes_are_alternatives(field: &AhbFieldRule) -> bool {
1476 !field.codes.is_empty()
1477 && field.ahb_status.contains('[')
1478 && field
1479 .codes
1480 .iter()
1481 .all(|c| c.ahb_status.contains('[') && c.ahb_status != field.ahb_status)
1482}
1483
1484fn should_skip_for_parent_group<E: ConditionEvaluator>(
1490 field: &AhbFieldRule,
1491 expr_eval: &ConditionExprEvaluator<E>,
1492 ctx: &EvaluationContext,
1493 ub_definitions: &BTreeMap<String, ConditionExpr>,
1494) -> bool {
1495 if let Some(ref group_status) = field.parent_group_ahb_status {
1496 if group_status.contains('[') {
1497 let result = expr_eval.evaluate_status_with_ub(group_status, ctx, ub_definitions);
1498 return matches!(result, ConditionResult::False | ConditionResult::Unknown);
1499 }
1500 }
1501 false
1502}
1503
1504fn is_parent_group_required<E: ConditionEvaluator>(
1511 field: &AhbFieldRule,
1512 expr_eval: &ConditionExprEvaluator<E>,
1513 ctx: &EvaluationContext,
1514 ub_definitions: &BTreeMap<String, ConditionExpr>,
1515) -> bool {
1516 field
1517 .parent_group_ahb_status
1518 .as_deref()
1519 .is_some_and(|status| {
1520 let status = expr_eval.resolve_status(status, ctx, ub_definitions);
1521 status.result == ConditionResult::True && status.kind == Some(StatusKind::Muss)
1522 })
1523}
1524
1525fn is_field_present(ctx: &EvaluationContext, field: &AhbFieldRule) -> bool {
1534 let segment_id = extract_segment_id(&field.segment_path);
1535
1536 if !field.codes.is_empty() {
1542 if let (Some(el_idx), Some(comp_idx)) = (field.element_index, field.component_index) {
1543 let required_codes: Vec<&str> = field.codes.iter().map(|c| c.value.as_str()).collect();
1544 let matching = ctx.find_segments(&segment_id);
1545 return matching.iter().any(|seg| {
1546 seg.elements
1547 .get(el_idx)
1548 .and_then(|e| e.get(comp_idx))
1549 .is_some_and(|v| required_codes.contains(&v.as_str()))
1550 });
1551 }
1552 if is_qualifier_field(&field.segment_path) {
1555 let required_codes: Vec<&str> = field.codes.iter().map(|c| c.value.as_str()).collect();
1556 let el_idx = field.element_index.unwrap_or(0);
1557 let comp_idx = field.component_index.unwrap_or(0);
1558 let matching = ctx.find_segments(&segment_id);
1559 return matching.iter().any(|seg| {
1560 seg.elements
1561 .get(el_idx)
1562 .and_then(|e| e.get(comp_idx))
1563 .is_some_and(|v| required_codes.contains(&v.as_str()))
1564 });
1565 }
1566 }
1567
1568 ctx.has_segment(&segment_id)
1569}
1570
1571fn is_group_variant_absent(
1586 ctx: &EvaluationContext,
1587 field: &AhbFieldRule,
1588 is_group_mandatory: bool,
1589) -> bool {
1590 let group_path: Vec<&str> = field
1591 .segment_path
1592 .split('/')
1593 .take_while(|p| p.starts_with("SG"))
1594 .collect();
1595
1596 if group_path.is_empty() {
1597 return false;
1598 }
1599
1600 let nav = match ctx.navigator {
1601 Some(nav) => nav,
1602 None => return false,
1603 };
1604
1605 let instance_count = nav.group_instance_count(&group_path);
1606
1607 if instance_count == 0 {
1612 if !is_group_mandatory {
1613 return true;
1614 }
1615 return false;
1617 }
1618
1619 if field.parent_group_ahb_status.is_some() && !is_group_mandatory {
1624 if !field.codes.is_empty() && is_qualifier_field(&field.segment_path) {
1626 let segment_id = extract_segment_id(&field.segment_path);
1627 let required_codes: Vec<&str> = field.codes.iter().map(|c| c.value.as_str()).collect();
1628
1629 let any_instance_has_qualifier = (0..instance_count).any(|i| {
1630 nav.find_segments_in_group(&segment_id, &group_path, i)
1631 .iter()
1632 .any(|seg| {
1633 seg.elements
1634 .first()
1635 .and_then(|e| e.first())
1636 .is_some_and(|v| required_codes.contains(&v.as_str()))
1637 })
1638 });
1639
1640 if !any_instance_has_qualifier {
1641 return true; }
1643 }
1644 }
1645
1646 let segment_id = extract_segment_id(&field.segment_path);
1655 let segment_absent_from_all = (0..instance_count).all(|i| {
1656 nav.find_segments_in_group(&segment_id, &group_path, i)
1657 .is_empty()
1658 });
1659 if segment_absent_from_all {
1660 let group_has_other_segments =
1661 (0..instance_count).any(|i| nav.has_any_segment_in_group(&group_path, i));
1662 if group_has_other_segments {
1663 return true;
1664 }
1665 }
1666
1667 false
1668}
1669
1670fn collect_nodes_depth_first<'a, 'b>(group: &'b AhbGroupNode<'a>, out: &mut Vec<&'b AhbNode<'a>>) {
1672 out.extend(group.fields.iter());
1673 for child in &group.children {
1674 collect_nodes_depth_first(child, out);
1675 }
1676}
1677
1678#[allow(clippy::too_many_arguments)]
1682fn evaluate_node<E: ConditionEvaluator>(
1683 node: &AhbNode,
1684 ctx: &EvaluationContext,
1685 expr_eval: &ConditionExprEvaluator<E>,
1686 evaluator: &E,
1687 ub_definitions: &BTreeMap<String, ConditionExpr>,
1688 tag_migs: &HashMap<String, HashSet<&str>>,
1689 instance_index: Option<usize>,
1690 slot_holds: bool,
1691 report: &mut ValidationReport,
1692) {
1693 let field = node.rule;
1694
1695 let node_ctx = ctx.with_resolved(node.value, node.segment_elements);
1697
1698 if let (Some(value), Some(max)) = (node.value, field.format.as_deref().and_then(max_length)) {
1703 let length = value_length(field.format.as_deref().unwrap_or_default(), value);
1704 if length > max {
1705 let mut issue = ValidationIssue::new(
1706 Severity::Error,
1707 IssueKind::ValueTooLong {
1708 field_name: field.name.clone(),
1709 length,
1710 max,
1711 },
1712 )
1713 .with_field_path(&field.segment_path)
1714 .with_actual(value)
1715 .with_expected(field.format.clone().unwrap_or_default());
1716 if let Some(idx) = instance_index {
1717 issue = issue.with_instance_index(idx);
1718 }
1719 if let Some(num) = node.matched_segment_number {
1720 issue = issue.with_position(crate::SegmentPosition {
1721 segment_number: num,
1722 byte_offset: 0,
1723 message_number: 1,
1724 });
1725 }
1726 report.add_issue(attach_field_position(issue, field));
1727 }
1728 }
1729
1730 if let (Some(value), Some(format)) = (node.value, field.format.as_deref()) {
1733 if slot_holds
1734 && format.trim().starts_with('n')
1735 && field.codes.is_empty()
1736 && !value.is_empty()
1737 && !crate::eval::format_validators::is_edifact_number(value)
1738 {
1739 let mut issue = ValidationIssue::new(
1740 Severity::Error,
1741 IssueKind::ValueNotNumeric {
1742 field_name: field.name.clone(),
1743 },
1744 )
1745 .with_field_path(&field.segment_path)
1746 .with_actual(value)
1747 .with_expected(format);
1748 if let Some(idx) = instance_index {
1749 issue = issue.with_instance_index(idx);
1750 }
1751 if let Some(num) = node.matched_segment_number {
1752 issue = issue.with_position(crate::SegmentPosition {
1753 segment_number: num,
1754 byte_offset: 0,
1755 message_number: 1,
1756 });
1757 }
1758 report.add_issue(attach_field_position(issue, field));
1759 }
1760 }
1761
1762 if should_skip_for_parent_group(field, expr_eval, ctx, ub_definitions) {
1764 return;
1765 }
1766
1767 let status = governing_status(field, node.value);
1770 let StatusResolution {
1771 result: condition_result,
1772 kind,
1773 unknown_ids,
1774 } = expr_eval.resolve_status(status, &node_ctx, ub_definitions);
1775 let mandatory = kind == Some(StatusKind::Muss);
1776
1777 match condition_result {
1778 ConditionResult::True => {
1779 if mandatory && node.value.is_none() {
1788 let tag = extract_segment_id(&field.segment_path);
1789 let single_variant_present = node.segment_elements.is_none()
1790 && tag_migs.get(&tag).is_some_and(|ms| ms.len() == 1)
1791 && is_field_present(ctx, field);
1792 let segment_optional_and_absent = node.segment_elements.is_none()
1797 && field
1798 .segment_ahb_status
1799 .as_deref()
1800 .is_some_and(is_optional_segment_status);
1801 let segment_not_required_and_absent = node.segment_elements.is_none()
1807 && field.segment_ahb_status.as_deref().is_some_and(|st| {
1808 expr_eval
1809 .resolve_status(st, &node_ctx, ub_definitions)
1810 .result
1811 != ConditionResult::True
1812 });
1813 if !single_variant_present
1814 && !segment_optional_and_absent
1815 && !segment_not_required_and_absent
1816 {
1817 let mut issue = ValidationIssue::new(
1818 Severity::Error,
1819 IssueKind::MissingRequiredField {
1820 field_name: field.name.clone(),
1821 },
1822 )
1823 .with_field_path(&field.segment_path)
1824 .with_rule(&field.ahb_status);
1825 if let Some(first_code) = field.codes.first() {
1826 issue.expected_value = Some(first_code.value.clone());
1827 }
1828 if let Some(idx) = instance_index {
1829 issue = issue.with_instance_index(idx);
1830 }
1831 if let Some(num) = node.matched_segment_number {
1832 issue = issue.with_position(crate::SegmentPosition {
1833 segment_number: num,
1834 byte_offset: 0,
1835 message_number: 1,
1836 });
1837 }
1838 issue = attach_field_position(issue, field);
1839 report.add_issue(issue);
1840 }
1841 }
1842 }
1843 ConditionResult::False => {
1844 if mandatory && node.value.is_some() {
1847 let mut issue = ValidationIssue::new(
1848 Severity::Error,
1849 IssueKind::FieldConditionNotSatisfied {
1850 field_name: field.name.clone(),
1851 },
1852 )
1853 .with_field_path(&field.segment_path)
1854 .with_rule(&field.ahb_status);
1855 if let Some(idx) = instance_index {
1856 issue = issue.with_instance_index(idx);
1857 }
1858 if let Some(num) = node.matched_segment_number {
1859 issue = issue.with_position(crate::SegmentPosition {
1860 segment_number: num,
1861 byte_offset: 0,
1862 message_number: 1,
1863 });
1864 }
1865 issue = attach_field_position(issue, field);
1866 report.add_issue(issue);
1867 }
1868 }
1869 ConditionResult::Unknown => {
1870 let mut external_ids = Vec::new();
1872 let mut undetermined_ids = Vec::new();
1873 let mut missing_ids = Vec::new();
1874 for id in unknown_ids {
1875 if evaluator.is_external(id) {
1876 external_ids.push(id);
1877 } else if evaluator.is_known(id) {
1878 undetermined_ids.push(id);
1879 } else {
1880 missing_ids.push(id);
1881 }
1882 }
1883
1884 let mut issue = ValidationIssue::new(
1885 Severity::Info,
1886 IssueKind::ConditionUnknown {
1887 field_name: field.name.clone(),
1888 unresolved: UnresolvedConditions {
1889 external: external_ids,
1890 undetermined: undetermined_ids,
1891 missing: missing_ids,
1892 },
1893 },
1894 )
1895 .with_field_path(&field.segment_path)
1896 .with_rule(&field.ahb_status);
1897 if let Some(idx) = instance_index {
1898 issue = issue.with_instance_index(idx);
1899 }
1900 if let Some(num) = node.matched_segment_number {
1901 issue = issue.with_position(crate::SegmentPosition {
1902 segment_number: num,
1903 byte_offset: 0,
1904 message_number: 1,
1905 });
1906 }
1907 issue = attach_field_position(issue, field);
1908 report.add_issue(issue);
1909 }
1910 }
1911}
1912
1913fn attach_field_position(issue: ValidationIssue, field: &AhbFieldRule) -> ValidationIssue {
1918 match field.element_index {
1919 Some(el) => {
1920 let element_pos = (el as u32) + 2;
1921 let component_pos = field.component_index.map(|c| (c as u32) + 1);
1922 issue.with_field_position(element_pos, component_pos)
1923 }
1924 None => issue,
1925 }
1926}
1927
1928fn report_repeated_in_transaction(
1935 transaction: &AhbGroupNode,
1936 transaction_index: usize,
1937 once: u32,
1938 ctx: &EvaluationContext,
1939 report: &mut ValidationReport,
1940) {
1941 let mut counts: BTreeMap<(String, String), (usize, String)> = BTreeMap::new();
1943 fn visit<'a>(
1944 node: &'a AhbGroupNode<'a>,
1945 path: &mut Vec<(&'a str, usize)>,
1946 once: u32,
1947 ctx: &EvaluationContext,
1948 counts: &mut BTreeMap<(String, String), (usize, String)>,
1949 ) {
1950 let ids: Vec<&str> = path.iter().map(|(g, _)| *g).collect();
1951 let mut seen: HashSet<Option<&str>> = HashSet::new();
1952 for n in &node.fields {
1953 let rule = n.rule;
1954 let Some(status) = rule.segment_ahb_status.as_deref() else {
1955 continue;
1956 };
1957 if Some(status) == node.ahb_status
1958 || rule.element_index.unwrap_or(0) != 0
1959 || rule.component_index.unwrap_or(0) != 0
1960 || rule.codes.is_empty()
1961 || !crate::eval::binds_once(status, once)
1962 || !seen.insert(rule.mig_number.as_deref())
1963 {
1964 continue;
1965 }
1966 let tag = extract_segment_id(&rule.segment_path);
1967 let sent = ctx.navigator().map_or(0, |nav| {
1968 nav.find_segments_in_instance(&tag, path)
1969 .iter()
1970 .filter(|s| rule.codes.iter().any(|c| c.value == s.get_component(0, 0)))
1971 .count()
1972 });
1973 let entry = counts
1974 .entry((
1975 format!("{}/{tag}", ids.join("/")),
1976 rule.mig_number.clone().unwrap_or_default(),
1977 ))
1978 .or_insert((0, status.to_string()));
1979 entry.0 += sent;
1980 }
1981 for child in &node.children {
1982 if let Some(status) = child
1983 .ahb_status
1984 .filter(|s| crate::eval::binds_once(s, once))
1985 {
1986 let variant = child
1987 .fields
1988 .first()
1989 .and_then(|n| n.rule.mig_number.clone())
1990 .unwrap_or_default();
1991 let entry = counts
1992 .entry((format!("{}/{}", ids.join("/"), child.group_id), variant))
1993 .or_insert((0, status.to_string()));
1994 entry.0 += 1;
1995 }
1996 path.push((child.group_id, child.instance_index));
1997 visit(child, path, once, ctx, counts);
1998 path.pop();
1999 }
2000 }
2001 let mut path = vec![(transaction.group_id, transaction_index)];
2002 visit(transaction, &mut path, once, ctx, &mut counts);
2003 for ((path, _), (count, status)) in counts {
2004 if count > 1 {
2005 report.add_issue(
2006 ValidationIssue::new(
2007 Severity::Error,
2008 IssueKind::RepeatedInTransaction {
2009 path: path.clone(),
2010 count,
2011 },
2012 )
2013 .with_field_path(path)
2014 .with_rule(status)
2015 .with_instance_index(transaction_index),
2016 );
2017 }
2018 }
2019}
2020
2021#[allow(clippy::too_many_arguments)]
2028fn refuse_segments<E: ConditionEvaluator>(
2029 fields: &[AhbNode],
2030 group_status: Option<&str>,
2031 path: &[(&str, usize)],
2032 scoped_ctx: &EvaluationContext,
2033 evaluator: &E,
2034 ub_definitions: &BTreeMap<String, ConditionExpr>,
2035 instance_index: usize,
2036 report: &mut ValidationReport,
2037) {
2038 let mut seen: Vec<u32> = Vec::new();
2039 for node in fields {
2040 let rule = node.rule;
2041 let (Some(status), Some(value), Some(number)) = (
2042 rule.segment_ahb_status.as_deref(),
2043 node.value,
2044 node.matched_segment_number,
2045 ) else {
2046 continue;
2047 };
2048 let qualifier =
2051 rule.element_index.unwrap_or(0) == 0 && rule.component_index.unwrap_or(0) == 0;
2052 if !qualifier
2053 || seen.contains(&number)
2054 || Some(status) == group_status
2055 || !rule.codes.iter().any(|c| c.value == value)
2056 {
2057 continue;
2058 }
2059 seen.push(number);
2060 if !crate::eval::refuses_presence(status, evaluator, scoped_ctx, ub_definitions) {
2061 continue;
2062 }
2063 let mut segment: Vec<&str> = path.iter().map(|(g, _)| *g).collect();
2064 segment.extend(rule.segment_path.split('/').find(|p| !p.starts_with("SG")));
2065 report.add_issue(
2066 ValidationIssue::new(
2067 Severity::Warning,
2068 IssueKind::SegmentNotAllowed {
2069 segment: segment.join("/"),
2070 },
2071 )
2072 .with_field_path(segment.join("/"))
2073 .with_rule(status)
2074 .with_instance_index(instance_index)
2075 .with_position(crate::SegmentPosition {
2076 segment_number: number,
2077 byte_offset: 0,
2078 message_number: 1,
2079 }),
2080 );
2081 }
2082}
2083
2084#[allow(clippy::too_many_arguments)]
2093fn walk_group_instance<E: ConditionEvaluator>(
2094 group: &AhbGroupNode,
2095 path: &[(&str, usize)],
2096 scoped_ctx: &EvaluationContext,
2097 expr_eval: &ConditionExprEvaluator<E>,
2098 evaluator: &E,
2099 ub_definitions: &BTreeMap<String, ConditionExpr>,
2100 tag_migs: &HashMap<String, HashSet<&str>>,
2101 instance_index: usize,
2102 report_segments: bool,
2103 report: &mut ValidationReport,
2104) {
2105 let mut group_refused = false;
2108 if let Some(status) = group.ahb_status {
2109 if crate::eval::refuses_presence(status, evaluator, scoped_ctx, ub_definitions) {
2110 group_refused = true;
2111 let group_path: Vec<&str> = path.iter().map(|(g, _)| *g).collect();
2112 let mut issue = ValidationIssue::new(
2113 Severity::Warning,
2114 IssueKind::GroupNotAllowed {
2115 group: group_path.join("/"),
2116 },
2117 )
2118 .with_field_path(group_path.join("/"))
2119 .with_rule(status)
2120 .with_instance_index(instance_index);
2121 if let Some(num) = group.fields.iter().find_map(|n| n.matched_segment_number) {
2122 issue = issue.with_position(crate::SegmentPosition {
2123 segment_number: num,
2124 byte_offset: 0,
2125 message_number: 1,
2126 });
2127 }
2128 report.add_issue(issue);
2129 }
2130 }
2131 if report_segments && !group_refused {
2132 refuse_segments(
2133 &group.fields,
2134 group.ahb_status,
2135 path,
2136 scoped_ctx,
2137 evaluator,
2138 ub_definitions,
2139 instance_index,
2140 report,
2141 );
2142 }
2143 let slot_codes: HashMap<Option<&str>, &[AhbCodeRule]> = group
2147 .fields
2148 .iter()
2149 .filter(|n| {
2150 n.rule.element_index.unwrap_or(0) == 0
2151 && n.rule.component_index.unwrap_or(0) == 0
2152 && !n.rule.codes.is_empty()
2153 })
2154 .map(|n| (n.rule.mig_number.as_deref(), n.rule.codes.as_slice()))
2155 .collect();
2156 let holds = |node: &AhbNode| {
2157 let (Some(codes), Some(elements)) = (
2158 slot_codes.get(&node.rule.mig_number.as_deref()),
2159 node.segment_elements,
2160 ) else {
2161 return true;
2162 };
2163 let qualifier = elements
2164 .first()
2165 .and_then(|e| e.first())
2166 .map(String::as_str)
2167 .unwrap_or_default();
2168 codes.iter().any(|c| c.value == qualifier)
2169 };
2170 for node in &group.fields {
2171 evaluate_node(
2172 node,
2173 scoped_ctx,
2174 expr_eval,
2175 evaluator,
2176 ub_definitions,
2177 tag_migs,
2178 Some(instance_index),
2179 holds(node),
2180 report,
2181 );
2182 }
2183 for child in &group.children {
2184 let mut child_path = path.to_vec();
2185 child_path.push((child.group_id, child.instance_index));
2186 let child_ctx = scoped_ctx.with_scope(GroupScope { path: &child_path });
2187 walk_group_instance(
2188 child,
2189 &child_path,
2190 &child_ctx,
2191 expr_eval,
2192 evaluator,
2193 ub_definitions,
2194 tag_migs,
2195 instance_index,
2196 report_segments,
2197 report,
2198 );
2199 }
2200}
2201
2202#[allow(clippy::too_many_arguments)]
2212fn report_absent_children<E: ConditionEvaluator>(
2213 group: &AhbGroupNode,
2214 path: &[(&str, usize)],
2215 scoped_ctx: &EvaluationContext,
2216 absent_eval: &ConditionExprEvaluator<crate::eval::AbsentTarget<'_, E>>,
2217 evaluator: &E,
2218 ub_definitions: &BTreeMap<String, ConditionExpr>,
2219 instance_index: usize,
2220 report: &mut ValidationReport,
2221) {
2222 if group.ahb_status.is_some_and(|status| {
2223 crate::eval::refuses_presence(status, evaluator, scoped_ctx, ub_definitions)
2224 }) {
2225 return;
2226 }
2227 for rule in &group.absent_children {
2228 if should_skip_for_parent_group(rule, absent_eval, scoped_ctx, ub_definitions)
2229 || !is_parent_group_required(rule, absent_eval, scoped_ctx, ub_definitions)
2230 {
2231 continue;
2232 }
2233 let required = |status: &str| {
2234 let resolved = absent_eval.resolve_status(status, scoped_ctx, ub_definitions);
2235 resolved.result == ConditionResult::True && resolved.kind == Some(StatusKind::Muss)
2236 };
2237 if !required(&rule.ahb_status)
2238 || rule
2239 .segment_ahb_status
2240 .as_deref()
2241 .is_some_and(|status| !required(status))
2242 {
2243 continue;
2244 }
2245 let mut issue = ValidationIssue::new(
2246 Severity::Error,
2247 IssueKind::MissingRequiredField {
2248 field_name: rule.name.clone(),
2249 },
2250 )
2251 .with_field_path(&rule.segment_path)
2252 .with_rule(&rule.ahb_status)
2253 .with_instance_index(instance_index);
2254 if let Some(first_code) = rule.codes.first() {
2255 issue.expected_value = Some(first_code.value.clone());
2256 }
2257 report.add_issue(attach_field_position(issue, rule));
2258 }
2259 for child in &group.children {
2260 let mut child_path = path.to_vec();
2261 child_path.push((child.group_id, child.instance_index));
2262 let child_ctx = scoped_ctx.with_scope(GroupScope { path: &child_path });
2263 report_absent_children(
2264 child,
2265 &child_path,
2266 &child_ctx,
2267 absent_eval,
2268 evaluator,
2269 ub_definitions,
2270 instance_index,
2271 report,
2272 );
2273 }
2274}
2275
2276fn collect_packages(expr: &ConditionExpr, out: &mut Vec<(u32, u32, u32)>) {
2277 match expr {
2278 ConditionExpr::Package { id, min, max } => {
2279 out.push((*id, *min, *max));
2280 }
2281 ConditionExpr::And(exprs) | ConditionExpr::Or(exprs) => {
2282 for e in exprs {
2283 collect_packages(e, out);
2284 }
2285 }
2286 ConditionExpr::Xor(left, right) => {
2287 collect_packages(left, out);
2288 collect_packages(right, out);
2289 }
2290 ConditionExpr::Not(inner) => {
2291 collect_packages(inner, out);
2292 }
2293 ConditionExpr::Ref(_) => {}
2294 }
2295}
2296
2297fn is_optional_segment_status(status: &str) -> bool {
2307 let trimmed = status.trim();
2308 trimmed.starts_with("Kann") || trimmed.starts_with("Soll")
2309}
2310
2311fn is_qualifier_field(path: &str) -> bool {
2321 let parts: Vec<&str> = path.split('/').filter(|p| !p.starts_with("SG")).collect();
2322 matches!(parts.len(), 2 | 3)
2324}
2325
2326#[derive(Default)]
2334struct MigCodeBucket<'a> {
2335 qualifier_position: Option<(usize, usize)>,
2338 qualifier_values: HashSet<&'a str>,
2341 codes: HashMap<(usize, usize), HashSet<&'a str>>,
2344}
2345
2346fn partition_codes_by_mig(
2351 workflow: &AhbWorkflow,
2352) -> HashMap<(String, String), HashMap<Option<String>, MigCodeBucket<'_>>> {
2353 let mut out: HashMap<(String, String), HashMap<Option<String>, MigCodeBucket>> = HashMap::new();
2354 for field in &workflow.fields {
2355 if field.codes.is_empty() || !is_qualifier_field(&field.segment_path) {
2356 continue;
2357 }
2358 if codes_are_alternatives(field) {
2361 continue;
2362 }
2363 let tag = extract_segment_id(&field.segment_path);
2364 let group_key = extract_group_path_key(&field.segment_path);
2365 let mig = field.mig_number.clone();
2366 let el = field.element_index.unwrap_or(0);
2367 let c = field.component_index.unwrap_or(0);
2368
2369 let bucket = out
2370 .entry((group_key, tag))
2371 .or_default()
2372 .entry(mig)
2373 .or_default();
2374
2375 let required: Vec<&str> = field
2382 .codes
2383 .iter()
2384 .filter(|code| code.ahb_status.starts_with('X') || code.ahb_status.starts_with("Muss"))
2385 .map(|code| code.value.as_str())
2386 .collect();
2387
2388 if !required.is_empty() {
2395 if bucket.qualifier_position.is_none() {
2396 bucket.qualifier_position = Some((el, c));
2397 }
2398 if bucket.qualifier_position == Some((el, c)) {
2399 bucket.qualifier_values.extend(required.iter().copied());
2400 }
2401 }
2402
2403 for v in required {
2404 bucket.codes.entry((el, c)).or_default().insert(v);
2405 }
2406 }
2407 out
2408}
2409
2410fn global_qualifiers_by_tag(
2416 by_loc: &HashMap<(String, String), HashMap<Option<String>, MigCodeBucket<'_>>>,
2417) -> HashMap<String, HashMap<(usize, usize), HashSet<String>>> {
2418 let mut out: HashMap<String, HashMap<(usize, usize), HashSet<String>>> = HashMap::new();
2419 for ((_group, tag), migs) in by_loc {
2420 let tag_entry = out.entry(tag.clone()).or_default();
2421 for bucket in migs.values() {
2422 if let Some(pos) = bucket.qualifier_position {
2423 tag_entry
2424 .entry(pos)
2425 .or_default()
2426 .extend(bucket.qualifier_values.iter().map(|s| s.to_string()));
2427 }
2428 }
2429 }
2430 out
2431}
2432
2433fn match_segment_to_mig<'a, 'b>(
2444 seg: &OwnedSegment,
2445 migs: &'a HashMap<Option<String>, MigCodeBucket<'b>>,
2446) -> Option<&'a MigCodeBucket<'b>> {
2447 let actual_at = |el: usize, c: usize| -> &str {
2448 seg.elements
2449 .get(el)
2450 .and_then(|e| e.get(c))
2451 .map(|s| s.as_str())
2452 .unwrap_or("")
2453 };
2454
2455 let mut best: Option<&MigCodeBucket> = None;
2456 let mut best_matches = 0usize;
2457
2458 for bucket in migs.values() {
2459 let Some((el, c)) = bucket.qualifier_position else {
2460 continue;
2461 };
2462 if !bucket.qualifier_values.contains(actual_at(el, c)) {
2463 continue;
2464 }
2465 let extra_matches = bucket
2466 .codes
2467 .iter()
2468 .filter(|(pos, _)| **pos != (el, c))
2469 .filter(|((e, k), allowed)| {
2470 let v = actual_at(*e, *k);
2471 !v.is_empty() && allowed.contains(v)
2472 })
2473 .count();
2474 if best.is_none() || extra_matches > best_matches {
2475 best = Some(bucket);
2476 best_matches = extra_matches;
2477 }
2478 }
2479 best
2480}
2481
2482fn extract_group_path_key(path: &str) -> String {
2487 let sg_parts: Vec<&str> = path
2488 .split('/')
2489 .take_while(|p| p.starts_with("SG"))
2490 .collect();
2491 sg_parts.join("/")
2492}
2493
2494fn extract_segment_id(path: &str) -> String {
2496 for part in path.split('/') {
2497 if part.starts_with("SG") || part.starts_with("C_") || part.starts_with("D_") {
2499 continue;
2500 }
2501 if part.len() >= 3
2503 && part
2504 .chars()
2505 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
2506 {
2507 return part.to_string();
2508 }
2509 }
2510 path.split('/').next_back().unwrap_or(path).to_string()
2512}
2513
2514pub fn validate_unt_segment_count(segments: &[OwnedSegment]) -> Option<ValidationIssue> {
2526 let unh_count = segments.iter().filter(|s| s.id == "UNH").count();
2528 if unh_count > 1 {
2529 return Some(ValidationIssue::new(
2530 Severity::Error,
2531 IssueKind::UntCountNotVerifiable { unh_count },
2532 ));
2533 }
2534
2535 let unt = segments.iter().rfind(|s| s.id == "UNT")?;
2537 let declared: usize = unt.get_element(0).parse().ok()?;
2538
2539 let actual = segments
2541 .iter()
2542 .filter(|s| s.id != "UNA" && s.id != "UNB" && s.id != "UNZ")
2543 .count();
2544
2545 if declared != actual {
2546 Some(
2547 ValidationIssue::new(
2548 Severity::Error,
2549 IssueKind::UntSegmentCountMismatch { declared, actual },
2550 )
2551 .with_field_path("UNT/0074")
2552 .with_expected(actual.to_string())
2553 .with_actual(declared.to_string()),
2554 )
2555 } else {
2556 None
2557 }
2558}
2559
2560#[cfg(test)]
2561mod tests {
2562
2563 #[test]
2564 fn a_numeric_length_counts_digits_only() {
2565 assert_eq!(value_length("n..15", "-12.50"), 4);
2566 assert_eq!(value_length("n..6", "JMMHAYJXGIJT"), 12);
2567 assert_eq!(value_length("n5", "55001"), 5);
2568 assert_eq!(value_length("an..3", "Z.1"), 3);
2569 assert_eq!(value_length("an..35", "Müller"), 6);
2570 }
2571 use super::super::codes::ErrorCodes;
2572 use super::super::issue::ValidationCategory;
2573 use super::*;
2574 use crate::display::{EdifactView, IssueNarrator, IssueView, TechnicalNarrator};
2575 use crate::eval::{ConditionResult as CR, NoOpExternalProvider};
2576 use std::collections::HashMap;
2577
2578 fn narrate(issue: &ValidationIssue) -> String {
2581 TechnicalNarrator.describe(issue, EdifactView.location(issue).as_deref())
2582 }
2583
2584 struct MockEvaluator {
2586 results: HashMap<u32, CR>,
2587 }
2588
2589 impl MockEvaluator {
2590 fn new(results: Vec<(u32, CR)>) -> Self {
2591 Self {
2592 results: results.into_iter().collect(),
2593 }
2594 }
2595
2596 fn all_true(ids: &[u32]) -> Self {
2597 Self::new(ids.iter().map(|&id| (id, CR::True)).collect())
2598 }
2599 }
2600
2601 impl ConditionEvaluator for MockEvaluator {
2602 fn evaluate(&self, condition: u32, _ctx: &EvaluationContext) -> CR {
2603 self.results.get(&condition).copied().unwrap_or(CR::Unknown)
2604 }
2605 fn is_external(&self, _condition: u32) -> bool {
2606 false
2607 }
2608 fn message_type(&self) -> &str {
2609 "UTILMD"
2610 }
2611 fn format_version(&self) -> &str {
2612 "FV2510"
2613 }
2614 }
2615
2616 #[test]
2619 fn a_present_code_is_judged_by_its_own_status() {
2620 let code = |value: &str, status: &str| AhbCodeRule {
2622 value: value.to_string(),
2623 description: String::new(),
2624 ahb_status: status.to_string(),
2625 };
2626 let field = AhbFieldRule {
2627 segment_path: "SG4/STS/C556/9013".to_string(),
2628 ahb_status: "X [192]".to_string(),
2629 codes: vec![code("E01", "X [192]"), code("E03", "X")],
2630 ..Default::default()
2631 };
2632 assert_eq!(governing_status(&field, Some("E03")), "X");
2633 assert_eq!(governing_status(&field, Some("E01")), "X [192]");
2634 assert_eq!(governing_status(&field, None), "X [192]");
2635 assert_eq!(governing_status(&field, Some("ZZZ")), "X [192]");
2636
2637 let own = AhbFieldRule {
2639 ahb_status: "X [931]".to_string(),
2640 codes: vec![code("303", "X")],
2641 ..Default::default()
2642 };
2643 assert_eq!(governing_status(&own, Some("303")), "X [931]");
2644 assert!(!codes_are_alternatives(&own));
2645
2646 let alternatives = AhbFieldRule {
2649 ahb_status: "X [153] ∧ [538]".to_string(),
2650 codes: vec![
2651 code("Z74", "X [103] ∧ [537]"),
2652 code("Z75", "X [103] ∧ [537]"),
2653 ],
2654 ..Default::default()
2655 };
2656 assert!(codes_are_alternatives(&alternatives));
2657 assert_eq!(
2658 governing_status(&alternatives, Some("Z74")),
2659 "X [103] ∧ [537]"
2660 );
2661 assert_eq!(
2662 governing_status(&alternatives, Some("A01")),
2663 "X [153] ∧ [538]"
2664 );
2665 assert_eq!(governing_status(&alternatives, None), "X [153] ∧ [538]");
2666
2667 let restricting = AhbFieldRule {
2669 ahb_status: "X [2] ∨ [3]".to_string(),
2670 codes: vec![code("UNH", "X"), code("UNT", "X")],
2671 ..Default::default()
2672 };
2673 assert!(!codes_are_alternatives(&restricting));
2674 assert_eq!(governing_status(&restricting, Some("UNH")), "X [2] ∨ [3]");
2675 }
2676
2677 #[test]
2678 fn test_extract_segment_id_simple() {
2679 assert_eq!(extract_segment_id("NAD"), "NAD");
2680 }
2681
2682 #[test]
2683 fn test_extract_segment_id_with_sg_prefix() {
2684 assert_eq!(extract_segment_id("SG2/NAD/C082/3039"), "NAD");
2685 }
2686
2687 #[test]
2688 fn test_extract_segment_id_nested_sg() {
2689 assert_eq!(extract_segment_id("SG4/SG8/SEQ/C286/6350"), "SEQ");
2690 }
2691
2692 #[test]
2695 fn test_validate_missing_mandatory_field() {
2696 let evaluator = MockEvaluator::all_true(&[182, 152]);
2697 let validator = EdifactValidator::new(evaluator);
2698 let external = NoOpExternalProvider;
2699
2700 let workflow = AhbWorkflow {
2701 pruefidentifikator: "11001".to_string(),
2702 description: "Test".to_string(),
2703 communication_direction: None,
2704 fields: vec![AhbFieldRule {
2705 segment_path: "SG2/NAD/C082/3039".to_string(),
2706 name: "MP-ID des MSB".to_string(),
2707 ahb_status: "Muss [182] ∧ [152]".to_string(),
2708 codes: vec![],
2709 parent_group_ahb_status: None,
2710 segment_ahb_status: None,
2711 ..Default::default()
2712 }],
2713 ub_definitions: BTreeMap::new(),
2714 package_conditions: Default::default(),
2715 };
2716
2717 let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
2719
2720 assert!(!report.is_valid());
2722 let errors: Vec<_> = report.errors().collect();
2723 assert_eq!(errors.len(), 1);
2724 assert_eq!(errors[0].code(), ErrorCodes::MISSING_REQUIRED_FIELD);
2725 assert!(narrate(errors[0]).contains("MP-ID des MSB"));
2726 }
2727
2728 #[test]
2729 fn test_validate_condition_false_no_error() {
2730 let evaluator = MockEvaluator::new(vec![(182, CR::True), (152, CR::False)]);
2732 let validator = EdifactValidator::new(evaluator);
2733 let external = NoOpExternalProvider;
2734
2735 let workflow = AhbWorkflow {
2736 pruefidentifikator: "11001".to_string(),
2737 description: "Test".to_string(),
2738 communication_direction: None,
2739 fields: vec![AhbFieldRule {
2740 segment_path: "NAD".to_string(),
2741 name: "Partnerrolle".to_string(),
2742 ahb_status: "Muss [182] ∧ [152]".to_string(),
2743 codes: vec![],
2744 parent_group_ahb_status: None,
2745 segment_ahb_status: None,
2746 ..Default::default()
2747 }],
2748 ub_definitions: BTreeMap::new(),
2749 package_conditions: Default::default(),
2750 };
2751
2752 let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
2753
2754 assert!(report.is_valid());
2756 }
2757
2758 fn validate_absent_field(status: &str, conditions: Vec<(u32, CR)>) -> ValidationReport {
2760 let validator = EdifactValidator::new(MockEvaluator::new(conditions));
2761 let workflow = AhbWorkflow {
2762 pruefidentifikator: "11001".to_string(),
2763 description: "Test".to_string(),
2764 communication_direction: None,
2765 fields: vec![AhbFieldRule {
2766 segment_path: "SG2/NAD/C082/3039".to_string(),
2767 name: "MP-ID".to_string(),
2768 ahb_status: status.to_string(),
2769 ..Default::default()
2770 }],
2771 ub_definitions: BTreeMap::new(),
2772 package_conditions: Default::default(),
2773 };
2774 validator.validate(
2775 &[],
2776 &workflow,
2777 &NoOpExternalProvider,
2778 ValidationLevel::Conditions,
2779 )
2780 }
2781
2782 #[test]
2783 fn a_later_soll_line_alone_does_not_require_the_field() {
2784 let report = validate_absent_field(
2786 "Muss [2119] \r\nSoll [130]",
2787 vec![(2119, CR::False), (130, CR::True)],
2788 );
2789 assert!(
2790 report.is_valid(),
2791 "{:?}",
2792 report.errors().collect::<Vec<_>>()
2793 );
2794 }
2795
2796 #[test]
2797 fn a_later_muss_line_requires_the_field() {
2798 let report = validate_absent_field(
2800 "Soll [47]\r\nMuss [46]",
2801 vec![(47, CR::False), (46, CR::True)],
2802 );
2803 let errors: Vec<_> = report.errors().collect();
2804 assert_eq!(errors.len(), 1);
2805 assert_eq!(errors[0].code(), ErrorCodes::MISSING_REQUIRED_FIELD);
2806 }
2807
2808 #[test]
2809 fn a_weaker_unknown_line_does_not_stop_a_muss() {
2810 let report = validate_absent_field("Muss [2119]\nSoll [130]", vec![(2119, CR::True)]);
2812 let errors: Vec<_> = report.errors().collect();
2813 assert_eq!(errors.len(), 1);
2814 assert_eq!(errors[0].code(), ErrorCodes::MISSING_REQUIRED_FIELD);
2815 }
2816
2817 #[test]
2818 fn a_stricter_unknown_line_leaves_the_status_undecided() {
2819 let report = validate_absent_field("Muss [2119]\nSoll [130]", vec![(130, CR::True)]);
2820 assert!(report.is_valid());
2821 let infos: Vec<_> = report.infos().collect();
2822 assert_eq!(infos.len(), 1, "{infos:?}");
2823 match &infos[0].kind {
2824 IssueKind::ConditionUnknown { unresolved, .. } => {
2825 assert_eq!(unresolved.missing, vec![2119]);
2826 }
2827 other => panic!("expected ConditionUnknown, got {other:?}"),
2828 }
2829 }
2830
2831 #[test]
2832 fn test_validate_condition_unknown_adds_info() {
2833 let evaluator = MockEvaluator::new(vec![(182, CR::True)]);
2835 let validator = EdifactValidator::new(evaluator);
2837 let external = NoOpExternalProvider;
2838
2839 let workflow = AhbWorkflow {
2840 pruefidentifikator: "11001".to_string(),
2841 description: "Test".to_string(),
2842 communication_direction: None,
2843 fields: vec![AhbFieldRule {
2844 segment_path: "NAD".to_string(),
2845 name: "Partnerrolle".to_string(),
2846 ahb_status: "Muss [182] ∧ [152]".to_string(),
2847 codes: vec![],
2848 parent_group_ahb_status: None,
2849 segment_ahb_status: None,
2850 ..Default::default()
2851 }],
2852 ub_definitions: BTreeMap::new(),
2853 package_conditions: Default::default(),
2854 };
2855
2856 let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
2857
2858 assert!(report.is_valid());
2860 let infos: Vec<_> = report.infos().collect();
2861 assert_eq!(infos.len(), 1);
2862 assert_eq!(infos[0].code(), ErrorCodes::CONDITION_UNKNOWN);
2863 }
2864
2865 #[test]
2866 fn test_validate_structure_level_skips_conditions() {
2867 let evaluator = MockEvaluator::all_true(&[182, 152]);
2868 let validator = EdifactValidator::new(evaluator);
2869 let external = NoOpExternalProvider;
2870
2871 let workflow = AhbWorkflow {
2872 pruefidentifikator: "11001".to_string(),
2873 description: "Test".to_string(),
2874 communication_direction: None,
2875 fields: vec![AhbFieldRule {
2876 segment_path: "NAD".to_string(),
2877 name: "Partnerrolle".to_string(),
2878 ahb_status: "Muss [182] ∧ [152]".to_string(),
2879 codes: vec![],
2880 parent_group_ahb_status: None,
2881 segment_ahb_status: None,
2882 ..Default::default()
2883 }],
2884 ub_definitions: BTreeMap::new(),
2885 package_conditions: Default::default(),
2886 };
2887
2888 let report = validator.validate(&[], &workflow, &external, ValidationLevel::Structure);
2890
2891 assert!(report.is_valid());
2893 assert_eq!(report.by_category(ValidationCategory::Ahb).count(), 0);
2894 }
2895
2896 #[test]
2897 fn test_validate_empty_workflow_no_condition_errors() {
2898 let evaluator = MockEvaluator::all_true(&[]);
2899 let validator = EdifactValidator::new(evaluator);
2900 let external = NoOpExternalProvider;
2901
2902 let empty_workflow = AhbWorkflow {
2903 pruefidentifikator: String::new(),
2904 description: String::new(),
2905 communication_direction: None,
2906 fields: vec![],
2907 ub_definitions: BTreeMap::new(),
2908 package_conditions: Default::default(),
2909 };
2910
2911 let report = validator.validate(&[], &empty_workflow, &external, ValidationLevel::Full);
2912
2913 assert!(report.is_valid());
2914 }
2915
2916 #[test]
2917 fn test_validate_bare_muss_always_required() {
2918 let evaluator = MockEvaluator::new(vec![]);
2919 let validator = EdifactValidator::new(evaluator);
2920 let external = NoOpExternalProvider;
2921
2922 let workflow = AhbWorkflow {
2923 pruefidentifikator: "55001".to_string(),
2924 description: "Test".to_string(),
2925 communication_direction: Some("NB an LF".to_string()),
2926 fields: vec![AhbFieldRule {
2927 segment_path: "SG2/NAD/3035".to_string(),
2928 name: "Partnerrolle".to_string(),
2929 ahb_status: "Muss".to_string(), codes: vec![],
2931 parent_group_ahb_status: None,
2932 segment_ahb_status: None,
2933 ..Default::default()
2934 }],
2935 ub_definitions: BTreeMap::new(),
2936 package_conditions: Default::default(),
2937 };
2938
2939 let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
2940
2941 assert!(!report.is_valid());
2943 assert_eq!(report.error_count(), 1);
2944 }
2945
2946 #[test]
2947 fn test_validate_x_status_is_mandatory() {
2948 let evaluator = MockEvaluator::new(vec![]);
2949 let validator = EdifactValidator::new(evaluator);
2950 let external = NoOpExternalProvider;
2951
2952 let workflow = AhbWorkflow {
2953 pruefidentifikator: "55001".to_string(),
2954 description: "Test".to_string(),
2955 communication_direction: None,
2956 fields: vec![AhbFieldRule {
2957 segment_path: "DTM".to_string(),
2958 name: "Datum".to_string(),
2959 ahb_status: "X".to_string(),
2960 codes: vec![],
2961 parent_group_ahb_status: None,
2962 segment_ahb_status: None,
2963 ..Default::default()
2964 }],
2965 ub_definitions: BTreeMap::new(),
2966 package_conditions: Default::default(),
2967 };
2968
2969 let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
2970
2971 assert!(!report.is_valid());
2972 let errors: Vec<_> = report.errors().collect();
2973 assert_eq!(errors[0].code(), ErrorCodes::MISSING_REQUIRED_FIELD);
2974 }
2975
2976 #[test]
2977 fn test_validate_soll_not_mandatory() {
2978 let evaluator = MockEvaluator::new(vec![]);
2979 let validator = EdifactValidator::new(evaluator);
2980 let external = NoOpExternalProvider;
2981
2982 let workflow = AhbWorkflow {
2983 pruefidentifikator: "55001".to_string(),
2984 description: "Test".to_string(),
2985 communication_direction: None,
2986 fields: vec![AhbFieldRule {
2987 segment_path: "DTM".to_string(),
2988 name: "Datum".to_string(),
2989 ahb_status: "Soll".to_string(),
2990 codes: vec![],
2991 parent_group_ahb_status: None,
2992 segment_ahb_status: None,
2993 ..Default::default()
2994 }],
2995 ub_definitions: BTreeMap::new(),
2996 package_conditions: Default::default(),
2997 };
2998
2999 let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
3000
3001 assert!(report.is_valid());
3003 }
3004
3005 #[test]
3006 fn test_report_includes_metadata() {
3007 let evaluator = MockEvaluator::new(vec![]);
3008 let validator = EdifactValidator::new(evaluator);
3009 let external = NoOpExternalProvider;
3010
3011 let workflow = AhbWorkflow {
3012 pruefidentifikator: "55001".to_string(),
3013 description: String::new(),
3014 communication_direction: None,
3015 fields: vec![],
3016 ub_definitions: BTreeMap::new(),
3017 package_conditions: Default::default(),
3018 };
3019
3020 let report = validator.validate(&[], &workflow, &external, ValidationLevel::Full);
3021
3022 assert_eq!(report.format_version.as_deref(), Some("FV2510"));
3023 assert_eq!(report.level, ValidationLevel::Full);
3024 assert_eq!(report.message_type, "UTILMD");
3025 assert_eq!(report.pruefidentifikator.as_deref(), Some("55001"));
3026 }
3027
3028 #[test]
3029 fn test_validate_with_navigator_returns_report() {
3030 let evaluator = MockEvaluator::all_true(&[]);
3031 let validator = EdifactValidator::new(evaluator);
3032 let external = NoOpExternalProvider;
3033 let nav = crate::eval::NoOpGroupNavigator;
3034
3035 let workflow = AhbWorkflow {
3036 pruefidentifikator: "55001".to_string(),
3037 description: "Test".to_string(),
3038 communication_direction: None,
3039 fields: vec![],
3040 ub_definitions: BTreeMap::new(),
3041 package_conditions: Default::default(),
3042 };
3043
3044 let report = validator.validate_with_navigator(
3045 &[],
3046 &workflow,
3047 &external,
3048 ValidationLevel::Full,
3049 &nav,
3050 );
3051 assert!(report.is_valid());
3052 }
3053
3054 #[test]
3055 fn test_code_validation_composite_paths_valid_codes() {
3056 let evaluator = MockEvaluator::new(vec![]);
3060 let validator = EdifactValidator::new(evaluator);
3061 let external = NoOpExternalProvider;
3062
3063 let unh_segment = OwnedSegment {
3064 id: "UNH".to_string(),
3065 elements: vec![
3066 vec!["ALEXANDE951842".to_string()],
3067 vec![
3068 "UTILMD".to_string(),
3069 "D".to_string(),
3070 "11A".to_string(),
3071 "UN".to_string(),
3072 "S2.1".to_string(),
3073 ],
3074 ],
3075 segment_number: 1,
3076 };
3077
3078 let workflow = AhbWorkflow {
3079 pruefidentifikator: "55001".to_string(),
3080 description: "Test".to_string(),
3081 communication_direction: None,
3082 fields: vec![
3083 AhbFieldRule {
3084 segment_path: "UNH/S009/0065".to_string(),
3085 name: "Nachrichtentyp".to_string(),
3086 ahb_status: "X".to_string(),
3087 codes: vec![AhbCodeRule {
3088 value: "UTILMD".to_string(),
3089 description: "Stammdaten".to_string(),
3090 ahb_status: "X".to_string(),
3091 }],
3092 parent_group_ahb_status: None,
3093 segment_ahb_status: None,
3094 element_index: Some(1),
3095 component_index: Some(0),
3096 ..Default::default()
3097 },
3098 AhbFieldRule {
3099 segment_path: "UNH/S009/0052".to_string(),
3100 name: "Version".to_string(),
3101 ahb_status: "X".to_string(),
3102 codes: vec![AhbCodeRule {
3103 value: "D".to_string(),
3104 description: "Draft".to_string(),
3105 ahb_status: "X".to_string(),
3106 }],
3107 parent_group_ahb_status: None,
3108 segment_ahb_status: None,
3109 element_index: Some(1),
3110 component_index: Some(1),
3111 ..Default::default()
3112 },
3113 ],
3114 ub_definitions: BTreeMap::new(),
3115 package_conditions: Default::default(),
3116 };
3117
3118 let report = validator.validate(
3119 &[unh_segment],
3120 &workflow,
3121 &external,
3122 ValidationLevel::Conditions,
3123 );
3124
3125 let code_errors: Vec<_> = report
3126 .by_category(ValidationCategory::Code)
3127 .filter(|i| i.severity == Severity::Error)
3128 .collect();
3129 assert!(
3130 code_errors.is_empty(),
3131 "Expected no code errors when composite values match allowed codes, got: {:?}",
3132 code_errors
3133 );
3134 }
3135
3136 #[test]
3137 fn test_code_validation_partitions_by_mig_number() {
3138 let evaluator = MockEvaluator::new(vec![]);
3142 let validator = EdifactValidator::new(evaluator);
3143 let external = NoOpExternalProvider;
3144
3145 let sts_7 = OwnedSegment {
3146 id: "STS".to_string(),
3147 elements: vec![
3148 vec!["7".to_string()],
3149 vec![String::new()],
3150 vec!["GH02".to_string()],
3151 vec!["ZW4".to_string()],
3152 ],
3153 segment_number: 1,
3154 };
3155 let sts_e01 = OwnedSegment {
3156 id: "STS".to_string(),
3157 elements: vec![
3158 vec!["E01".to_string()],
3159 vec![String::new()],
3160 vec!["A99".to_string(), "E_0614".to_string()],
3161 ],
3162 segment_number: 2,
3163 };
3164
3165 let workflow = AhbWorkflow {
3166 pruefidentifikator: "55018".to_string(),
3167 description: "Test".to_string(),
3168 communication_direction: None,
3169 fields: vec![
3170 AhbFieldRule {
3172 segment_path: "SG4/STS/C601/9015".to_string(),
3173 name: "Statuskategorie".to_string(),
3174 ahb_status: "X".to_string(),
3175 codes: vec![AhbCodeRule {
3176 value: "7".to_string(),
3177 description: "Transaktionsgrund".to_string(),
3178 ahb_status: "X".to_string(),
3179 }],
3180 parent_group_ahb_status: None,
3181 segment_ahb_status: None,
3182 element_index: Some(0),
3183 component_index: Some(0),
3184 mig_number: Some("00035".to_string()),
3185 format: None,
3186 },
3187 AhbFieldRule {
3188 segment_path: "SG4/STS/C556/9013".to_string(),
3189 name: "Statusanlaß".to_string(),
3190 ahb_status: "X".to_string(),
3191 codes: vec![AhbCodeRule {
3192 value: "E03".to_string(),
3193 description: "Transaktionsgrund".to_string(),
3194 ahb_status: "X".to_string(),
3195 }],
3196 parent_group_ahb_status: None,
3197 segment_ahb_status: None,
3198 element_index: Some(2),
3199 component_index: Some(0),
3200 mig_number: Some("00035".to_string()),
3201 format: None,
3202 },
3203 AhbFieldRule {
3205 segment_path: "SG4/STS/C601/9015".to_string(),
3206 name: "Statuskategorie".to_string(),
3207 ahb_status: "X".to_string(),
3208 codes: vec![AhbCodeRule {
3209 value: "E01".to_string(),
3210 description: "Antwort".to_string(),
3211 ahb_status: "X".to_string(),
3212 }],
3213 parent_group_ahb_status: None,
3214 segment_ahb_status: None,
3215 element_index: Some(0),
3216 component_index: Some(0),
3217 mig_number: Some("00036".to_string()),
3218 format: None,
3219 },
3220 ],
3221 ub_definitions: BTreeMap::new(),
3222 package_conditions: Default::default(),
3223 };
3224
3225 let report = validator.validate(
3226 &[sts_7, sts_e01],
3227 &workflow,
3228 &external,
3229 ValidationLevel::Conditions,
3230 );
3231
3232 let code_errors: Vec<_> = report
3233 .by_category(ValidationCategory::Code)
3234 .filter(|i| {
3235 i.severity == Severity::Error && i.code() == ErrorCodes::CODE_NOT_ALLOWED_FOR_PID
3236 })
3237 .collect();
3238 assert_eq!(
3239 code_errors.len(),
3240 1,
3241 "Expected one COD002 (for GH02 only), got: {:?}",
3242 code_errors
3243 );
3244 assert_eq!(code_errors[0].actual_value.as_deref(), Some("GH02"));
3245 }
3246
3247 #[test]
3248 fn test_code_validation_composite_paths_detects_invalid_code() {
3249 let evaluator = MockEvaluator::new(vec![]);
3252 let validator = EdifactValidator::new(evaluator);
3253 let external = NoOpExternalProvider;
3254
3255 let sts_segment = OwnedSegment {
3256 id: "STS".to_string(),
3257 elements: vec![
3258 vec!["7".to_string()],
3259 vec![String::new()],
3260 vec!["GH02".to_string()],
3261 vec!["ZW4".to_string()],
3262 ],
3263 segment_number: 1,
3264 };
3265
3266 let workflow = AhbWorkflow {
3267 pruefidentifikator: "55018".to_string(),
3268 description: "Test".to_string(),
3269 communication_direction: None,
3270 fields: vec![AhbFieldRule {
3271 segment_path: "SG4/STS/C556/9013".to_string(),
3272 name: "Statusanlaß".to_string(),
3273 ahb_status: "X".to_string(),
3274 codes: vec![AhbCodeRule {
3275 value: "E03".to_string(),
3276 description: "Transaktionsgrund".to_string(),
3277 ahb_status: "X".to_string(),
3278 }],
3279 parent_group_ahb_status: None,
3280 segment_ahb_status: None,
3281 element_index: Some(2),
3282 component_index: Some(0),
3283 ..Default::default()
3284 }],
3285 ub_definitions: BTreeMap::new(),
3286 package_conditions: Default::default(),
3287 };
3288
3289 let report = validator.validate(
3290 &[sts_segment],
3291 &workflow,
3292 &external,
3293 ValidationLevel::Conditions,
3294 );
3295
3296 let code_errors: Vec<_> = report
3297 .by_category(ValidationCategory::Code)
3298 .filter(|i| {
3299 i.severity == Severity::Error && i.code() == ErrorCodes::CODE_NOT_ALLOWED_FOR_PID
3300 })
3301 .collect();
3302 assert_eq!(
3303 code_errors.len(),
3304 1,
3305 "Expected COD002 for GH02, got: {:?}",
3306 code_errors
3307 );
3308 assert_eq!(code_errors[0].actual_value.as_deref(), Some("GH02"));
3309 }
3310
3311 #[test]
3312 fn test_cross_field_code_validation_valid_qualifiers() {
3313 let evaluator = MockEvaluator::new(vec![]);
3316 let validator = EdifactValidator::new(evaluator);
3317 let external = NoOpExternalProvider;
3318
3319 let nad_ms = OwnedSegment {
3320 id: "NAD".to_string(),
3321 elements: vec![vec!["MS".to_string()]],
3322 segment_number: 4,
3323 };
3324 let nad_mr = OwnedSegment {
3325 id: "NAD".to_string(),
3326 elements: vec![vec!["MR".to_string()]],
3327 segment_number: 5,
3328 };
3329
3330 let workflow = AhbWorkflow {
3331 pruefidentifikator: "55001".to_string(),
3332 description: "Test".to_string(),
3333 communication_direction: None,
3334 fields: vec![
3335 AhbFieldRule {
3336 segment_path: "SG2/NAD/3035".to_string(),
3337 name: "Absender".to_string(),
3338 ahb_status: "X".to_string(),
3339 codes: vec![AhbCodeRule {
3340 value: "MS".to_string(),
3341 description: "Absender".to_string(),
3342 ahb_status: "X".to_string(),
3343 }],
3344 parent_group_ahb_status: None,
3345 segment_ahb_status: None,
3346 ..Default::default()
3347 },
3348 AhbFieldRule {
3349 segment_path: "SG2/NAD/3035".to_string(),
3350 name: "Empfaenger".to_string(),
3351 ahb_status: "X".to_string(),
3352 codes: vec![AhbCodeRule {
3353 value: "MR".to_string(),
3354 description: "Empfaenger".to_string(),
3355 ahb_status: "X".to_string(),
3356 }],
3357 parent_group_ahb_status: None,
3358 segment_ahb_status: None,
3359 ..Default::default()
3360 },
3361 ],
3362 ub_definitions: BTreeMap::new(),
3363 package_conditions: Default::default(),
3364 };
3365
3366 let report = validator.validate(
3367 &[nad_ms, nad_mr],
3368 &workflow,
3369 &external,
3370 ValidationLevel::Conditions,
3371 );
3372
3373 let code_errors: Vec<_> = report
3374 .by_category(ValidationCategory::Code)
3375 .filter(|i| i.severity == Severity::Error)
3376 .collect();
3377 assert!(
3378 code_errors.is_empty(),
3379 "Expected no code errors for valid qualifiers, got: {:?}",
3380 code_errors
3381 );
3382 }
3383
3384 #[test]
3385 fn test_cross_field_code_validation_catches_invalid_qualifier() {
3386 let evaluator = MockEvaluator::new(vec![]);
3388 let validator = EdifactValidator::new(evaluator);
3389 let external = NoOpExternalProvider;
3390
3391 let nad_ms = OwnedSegment {
3392 id: "NAD".to_string(),
3393 elements: vec![vec!["MS".to_string()]],
3394 segment_number: 4,
3395 };
3396 let nad_mt = OwnedSegment {
3397 id: "NAD".to_string(),
3398 elements: vec![vec!["MT".to_string()]], segment_number: 5,
3400 };
3401
3402 let workflow = AhbWorkflow {
3403 pruefidentifikator: "55001".to_string(),
3404 description: "Test".to_string(),
3405 communication_direction: None,
3406 fields: vec![
3407 AhbFieldRule {
3408 segment_path: "SG2/NAD/3035".to_string(),
3409 name: "Absender".to_string(),
3410 ahb_status: "X".to_string(),
3411 codes: vec![AhbCodeRule {
3412 value: "MS".to_string(),
3413 description: "Absender".to_string(),
3414 ahb_status: "X".to_string(),
3415 }],
3416 parent_group_ahb_status: None,
3417 segment_ahb_status: None,
3418 ..Default::default()
3419 },
3420 AhbFieldRule {
3421 segment_path: "SG2/NAD/3035".to_string(),
3422 name: "Empfaenger".to_string(),
3423 ahb_status: "X".to_string(),
3424 codes: vec![AhbCodeRule {
3425 value: "MR".to_string(),
3426 description: "Empfaenger".to_string(),
3427 ahb_status: "X".to_string(),
3428 }],
3429 parent_group_ahb_status: None,
3430 segment_ahb_status: None,
3431 ..Default::default()
3432 },
3433 ],
3434 ub_definitions: BTreeMap::new(),
3435 package_conditions: Default::default(),
3436 };
3437
3438 let report = validator.validate(
3439 &[nad_ms, nad_mt],
3440 &workflow,
3441 &external,
3442 ValidationLevel::Conditions,
3443 );
3444
3445 let code_errors: Vec<_> = report
3446 .by_category(ValidationCategory::Code)
3447 .filter(|i| i.severity == Severity::Error)
3448 .collect();
3449 assert_eq!(code_errors.len(), 1, "Expected one COD002 error for MT");
3450 assert!(narrate(code_errors[0]).contains("MT"));
3451 assert!(narrate(code_errors[0]).contains("MR"));
3452 assert!(narrate(code_errors[0]).contains("MS"));
3453 }
3454
3455 #[test]
3456 fn test_cross_field_code_validation_unions_across_groups() {
3457 let evaluator = MockEvaluator::new(vec![]);
3461 let validator = EdifactValidator::new(evaluator);
3462 let external = NoOpExternalProvider;
3463
3464 let segments = vec![
3465 OwnedSegment {
3466 id: "NAD".to_string(),
3467 elements: vec![vec!["MS".to_string()]],
3468 segment_number: 3,
3469 },
3470 OwnedSegment {
3471 id: "NAD".to_string(),
3472 elements: vec![vec!["MR".to_string()]],
3473 segment_number: 4,
3474 },
3475 OwnedSegment {
3476 id: "NAD".to_string(),
3477 elements: vec![vec!["Z04".to_string()]],
3478 segment_number: 20,
3479 },
3480 OwnedSegment {
3481 id: "NAD".to_string(),
3482 elements: vec![vec!["Z09".to_string()]],
3483 segment_number: 21,
3484 },
3485 OwnedSegment {
3486 id: "NAD".to_string(),
3487 elements: vec![vec!["MT".to_string()]], segment_number: 22,
3489 },
3490 ];
3491
3492 let workflow = AhbWorkflow {
3493 pruefidentifikator: "55001".to_string(),
3494 description: "Test".to_string(),
3495 communication_direction: None,
3496 fields: vec![
3497 AhbFieldRule {
3498 segment_path: "SG2/NAD/3035".to_string(),
3499 name: "Absender".to_string(),
3500 ahb_status: "X".to_string(),
3501 codes: vec![AhbCodeRule {
3502 value: "MS".to_string(),
3503 description: "Absender".to_string(),
3504 ahb_status: "X".to_string(),
3505 }],
3506 parent_group_ahb_status: None,
3507 segment_ahb_status: None,
3508 ..Default::default()
3509 },
3510 AhbFieldRule {
3511 segment_path: "SG2/NAD/3035".to_string(),
3512 name: "Empfaenger".to_string(),
3513 ahb_status: "X".to_string(),
3514 codes: vec![AhbCodeRule {
3515 value: "MR".to_string(),
3516 description: "Empfaenger".to_string(),
3517 ahb_status: "X".to_string(),
3518 }],
3519 parent_group_ahb_status: None,
3520 segment_ahb_status: None,
3521 ..Default::default()
3522 },
3523 AhbFieldRule {
3524 segment_path: "SG4/SG12/NAD/3035".to_string(),
3525 name: "Anschlussnutzer".to_string(),
3526 ahb_status: "X".to_string(),
3527 codes: vec![AhbCodeRule {
3528 value: "Z04".to_string(),
3529 description: "Anschlussnutzer".to_string(),
3530 ahb_status: "X".to_string(),
3531 }],
3532 parent_group_ahb_status: None,
3533 segment_ahb_status: None,
3534 ..Default::default()
3535 },
3536 AhbFieldRule {
3537 segment_path: "SG4/SG12/NAD/3035".to_string(),
3538 name: "Korrespondenzanschrift".to_string(),
3539 ahb_status: "X".to_string(),
3540 codes: vec![AhbCodeRule {
3541 value: "Z09".to_string(),
3542 description: "Korrespondenzanschrift".to_string(),
3543 ahb_status: "X".to_string(),
3544 }],
3545 parent_group_ahb_status: None,
3546 segment_ahb_status: None,
3547 ..Default::default()
3548 },
3549 ],
3550 ub_definitions: BTreeMap::new(),
3551 package_conditions: Default::default(),
3552 };
3553
3554 let report =
3555 validator.validate(&segments, &workflow, &external, ValidationLevel::Conditions);
3556
3557 let code_errors: Vec<_> = report
3558 .by_category(ValidationCategory::Code)
3559 .filter(|i| i.severity == Severity::Error)
3560 .collect();
3561 assert_eq!(
3562 code_errors.len(),
3563 1,
3564 "Expected exactly one COD002 error for MT, got: {:?}",
3565 code_errors
3566 );
3567 assert!(narrate(code_errors[0]).contains("MT"));
3568 }
3569
3570 #[test]
3571 fn test_cross_field_code_validation_accepts_conditionally_allowed_codes() {
3572 let evaluator = MockEvaluator::new(vec![]);
3581 let validator = EdifactValidator::new(evaluator);
3582 let external = NoOpExternalProvider;
3583
3584 let qty_67 = OwnedSegment {
3585 id: "QTY".to_string(),
3586 elements: vec![vec!["67".to_string(), "0.185".to_string()]],
3587 segment_number: 10,
3588 };
3589
3590 let workflow = AhbWorkflow {
3591 pruefidentifikator: "13025".to_string(),
3592 description: "Test".to_string(),
3593 communication_direction: None,
3594 fields: vec![AhbFieldRule {
3595 segment_path: "SG5/SG6/SG9/SG10/QTY/qualifier".to_string(),
3596 name: "Menge, Qualifier".to_string(),
3597 ahb_status: "X".to_string(),
3598 codes: vec![
3599 AhbCodeRule {
3600 value: "220".to_string(),
3601 description: "Wahrer Wert".to_string(),
3602 ahb_status: "X".to_string(),
3603 },
3604 AhbCodeRule {
3605 value: "67".to_string(),
3606 description: "Ersatzwert".to_string(),
3607 ahb_status: "X [35] ∨ ([32] ∧ [77])".to_string(),
3608 },
3609 AhbCodeRule {
3610 value: "Z18".to_string(),
3611 description: "Vorläufiger Wert".to_string(),
3612 ahb_status: "X [35]".to_string(),
3613 },
3614 ],
3615 parent_group_ahb_status: None,
3616 segment_ahb_status: None,
3617 element_index: Some(0),
3618 component_index: Some(0),
3619 ..Default::default()
3620 }],
3621 ub_definitions: BTreeMap::new(),
3622 package_conditions: Default::default(),
3623 };
3624
3625 let report =
3626 validator.validate(&[qty_67], &workflow, &external, ValidationLevel::Conditions);
3627
3628 let code_errors: Vec<_> = report
3629 .by_category(ValidationCategory::Code)
3630 .filter(|i| i.severity == Severity::Error)
3631 .collect();
3632 assert!(
3633 code_errors.is_empty(),
3634 "QTY+67 should be accepted because code '67' is conditionally allowed for this PID (X [35] ∨ ([32] ∧ [77])). Got errors: {:?}",
3635 code_errors
3636 );
3637 }
3638
3639 #[test]
3640 fn test_is_qualifier_field_simple_paths() {
3641 assert!(is_qualifier_field("NAD/3035"));
3642 assert!(is_qualifier_field("SG2/NAD/3035"));
3643 assert!(is_qualifier_field("SG4/SG8/SEQ/6350"));
3644 assert!(is_qualifier_field("LOC/3227"));
3645 }
3646
3647 #[test]
3648 fn test_is_qualifier_field_composite_paths() {
3649 assert!(is_qualifier_field("UNH/S009/0065"));
3653 assert!(is_qualifier_field("NAD/C082/3039"));
3654 assert!(is_qualifier_field("SG2/NAD/C082/3039"));
3655 assert!(is_qualifier_field("SG4/STS/C556/9013"));
3656 }
3657
3658 #[test]
3659 fn test_is_qualifier_field_bare_segment() {
3660 assert!(!is_qualifier_field("NAD"));
3661 assert!(!is_qualifier_field("SG2/NAD"));
3662 }
3663
3664 #[test]
3665 fn test_is_qualifier_field_rejects_deep_paths() {
3666 assert!(!is_qualifier_field("SEG/A/B/C/D"));
3668 }
3669
3670 #[test]
3671 fn test_missing_qualifier_instance_is_detected() {
3672 let evaluator = MockEvaluator::new(vec![]);
3675 let validator = EdifactValidator::new(evaluator);
3676 let external = NoOpExternalProvider;
3677
3678 let nad_ms = OwnedSegment {
3679 id: "NAD".to_string(),
3680 elements: vec![vec!["MS".to_string()]],
3681 segment_number: 3,
3682 };
3683
3684 let workflow = AhbWorkflow {
3685 pruefidentifikator: "55001".to_string(),
3686 description: "Test".to_string(),
3687 communication_direction: None,
3688 fields: vec![
3689 AhbFieldRule {
3690 segment_path: "SG2/NAD/3035".to_string(),
3691 name: "Absender".to_string(),
3692 ahb_status: "X".to_string(),
3693 codes: vec![AhbCodeRule {
3694 value: "MS".to_string(),
3695 description: "Absender".to_string(),
3696 ahb_status: "X".to_string(),
3697 }],
3698 parent_group_ahb_status: None,
3699 segment_ahb_status: None,
3700 ..Default::default()
3701 },
3702 AhbFieldRule {
3703 segment_path: "SG2/NAD/3035".to_string(),
3704 name: "Empfaenger".to_string(),
3705 ahb_status: "Muss".to_string(),
3706 codes: vec![AhbCodeRule {
3707 value: "MR".to_string(),
3708 description: "Empfaenger".to_string(),
3709 ahb_status: "X".to_string(),
3710 }],
3711 parent_group_ahb_status: None,
3712 segment_ahb_status: None,
3713 ..Default::default()
3714 },
3715 ],
3716 ub_definitions: BTreeMap::new(),
3717 package_conditions: Default::default(),
3718 };
3719
3720 let report =
3721 validator.validate(&[nad_ms], &workflow, &external, ValidationLevel::Conditions);
3722
3723 let ahb_errors: Vec<_> = report
3724 .by_category(ValidationCategory::Ahb)
3725 .filter(|i| i.severity == Severity::Error)
3726 .collect();
3727 assert_eq!(
3728 ahb_errors.len(),
3729 1,
3730 "Expected AHB001 for missing NAD+MR, got: {:?}",
3731 ahb_errors
3732 );
3733 assert!(narrate(ahb_errors[0]).contains("Empfaenger"));
3734 }
3735
3736 #[test]
3737 fn test_present_qualifier_instance_no_error() {
3738 let evaluator = MockEvaluator::new(vec![]);
3740 let validator = EdifactValidator::new(evaluator);
3741 let external = NoOpExternalProvider;
3742
3743 let segments = vec![
3744 OwnedSegment {
3745 id: "NAD".to_string(),
3746 elements: vec![vec!["MS".to_string()]],
3747 segment_number: 3,
3748 },
3749 OwnedSegment {
3750 id: "NAD".to_string(),
3751 elements: vec![vec!["MR".to_string()]],
3752 segment_number: 4,
3753 },
3754 ];
3755
3756 let workflow = AhbWorkflow {
3757 pruefidentifikator: "55001".to_string(),
3758 description: "Test".to_string(),
3759 communication_direction: None,
3760 fields: vec![
3761 AhbFieldRule {
3762 segment_path: "SG2/NAD/3035".to_string(),
3763 name: "Absender".to_string(),
3764 ahb_status: "Muss".to_string(),
3765 codes: vec![AhbCodeRule {
3766 value: "MS".to_string(),
3767 description: "Absender".to_string(),
3768 ahb_status: "X".to_string(),
3769 }],
3770 parent_group_ahb_status: None,
3771 segment_ahb_status: None,
3772 ..Default::default()
3773 },
3774 AhbFieldRule {
3775 segment_path: "SG2/NAD/3035".to_string(),
3776 name: "Empfaenger".to_string(),
3777 ahb_status: "Muss".to_string(),
3778 codes: vec![AhbCodeRule {
3779 value: "MR".to_string(),
3780 description: "Empfaenger".to_string(),
3781 ahb_status: "X".to_string(),
3782 }],
3783 parent_group_ahb_status: None,
3784 segment_ahb_status: None,
3785 ..Default::default()
3786 },
3787 ],
3788 ub_definitions: BTreeMap::new(),
3789 package_conditions: Default::default(),
3790 };
3791
3792 let report =
3793 validator.validate(&segments, &workflow, &external, ValidationLevel::Conditions);
3794
3795 let ahb_errors: Vec<_> = report
3796 .by_category(ValidationCategory::Ahb)
3797 .filter(|i| i.severity == Severity::Error)
3798 .collect();
3799 assert!(
3800 ahb_errors.is_empty(),
3801 "Expected no AHB001 errors, got: {:?}",
3802 ahb_errors
3803 );
3804 }
3805
3806 #[test]
3807 fn test_extract_group_path_key() {
3808 assert_eq!(extract_group_path_key("SG2/NAD/3035"), "SG2");
3809 assert_eq!(extract_group_path_key("SG4/SG12/NAD/3035"), "SG4/SG12");
3810 assert_eq!(extract_group_path_key("NAD/3035"), "");
3811 assert_eq!(extract_group_path_key("SG4/SG8/SEQ/6350"), "SG4/SG8");
3812 }
3813
3814 fn validate_field_of_absent_group(
3816 group_status: &str,
3817 conditions: Vec<(u32, CR)>,
3818 ) -> ValidationReport {
3819 use mig_types::navigator::GroupNavigator;
3820
3821 struct NoSg9;
3822 impl GroupNavigator for NoSg9 {
3823 fn find_segments_in_group(&self, _: &str, _: &[&str], _: usize) -> Vec<OwnedSegment> {
3824 vec![]
3825 }
3826 fn find_segments_with_qualifier_in_group(
3827 &self,
3828 _: &str,
3829 _: usize,
3830 _: &str,
3831 _: &[&str],
3832 _: usize,
3833 ) -> Vec<OwnedSegment> {
3834 vec![]
3835 }
3836 fn group_instance_count(&self, _: &[&str]) -> usize {
3837 0
3838 }
3839 }
3840
3841 let validator = EdifactValidator::new(MockEvaluator::new(conditions));
3842 let workflow = AhbWorkflow {
3843 pruefidentifikator: "44001".to_string(),
3844 description: "Test".to_string(),
3845 communication_direction: None,
3846 fields: vec![AhbFieldRule {
3847 segment_path: "SG9/QTY/C186/6060".to_string(),
3848 name: "Menge".to_string(),
3849 ahb_status: "X".to_string(),
3850 parent_group_ahb_status: Some(group_status.to_string()),
3851 ..Default::default()
3852 }],
3853 ub_definitions: BTreeMap::new(),
3854 package_conditions: Default::default(),
3855 };
3856 validator.validate_with_navigator(
3857 &[],
3858 &workflow,
3859 &NoOpExternalProvider,
3860 ValidationLevel::Conditions,
3861 &NoSg9,
3862 )
3863 }
3864
3865 #[test]
3866 fn an_absent_group_whose_holding_line_is_soll_is_not_missing() {
3867 let report = validate_field_of_absent_group(
3869 "Soll [47]\r\nMuss [46]",
3870 vec![(47, CR::True), (46, CR::False)],
3871 );
3872 assert!(
3873 report.is_valid(),
3874 "{:?}",
3875 report.errors().collect::<Vec<_>>()
3876 );
3877 }
3878
3879 #[test]
3880 fn an_absent_group_whose_holding_line_is_muss_is_missing() {
3881 let report = validate_field_of_absent_group(
3882 "Soll [47]\r\nMuss [46]",
3883 vec![(47, CR::False), (46, CR::True)],
3884 );
3885 let errors: Vec<_> = report.errors().collect();
3886 assert_eq!(errors.len(), 1, "{errors:?}");
3887 assert_eq!(errors[0].code(), ErrorCodes::MISSING_REQUIRED_FIELD);
3888 }
3889
3890 #[test]
3891 fn test_absent_optional_group_no_missing_field_error() {
3892 use mig_types::navigator::GroupNavigator;
3895
3896 struct NavWithoutSG3;
3897 impl GroupNavigator for NavWithoutSG3 {
3898 fn find_segments_in_group(&self, _: &str, _: &[&str], _: usize) -> Vec<OwnedSegment> {
3899 vec![]
3900 }
3901 fn find_segments_with_qualifier_in_group(
3902 &self,
3903 _: &str,
3904 _: usize,
3905 _: &str,
3906 _: &[&str],
3907 _: usize,
3908 ) -> Vec<OwnedSegment> {
3909 vec![]
3910 }
3911 fn group_instance_count(&self, group_path: &[&str]) -> usize {
3912 match group_path {
3913 ["SG2"] => 2, ["SG2", "SG3"] => 0, _ => 0,
3916 }
3917 }
3918 }
3919
3920 let evaluator = MockEvaluator::new(vec![]);
3921 let validator = EdifactValidator::new(evaluator);
3922 let external = NoOpExternalProvider;
3923 let nav = NavWithoutSG3;
3924
3925 let segments = vec![
3927 OwnedSegment {
3928 id: "NAD".into(),
3929 elements: vec![vec!["MS".into()]],
3930 segment_number: 3,
3931 },
3932 OwnedSegment {
3933 id: "NAD".into(),
3934 elements: vec![vec!["MR".into()]],
3935 segment_number: 4,
3936 },
3937 ];
3938
3939 let workflow = AhbWorkflow {
3940 pruefidentifikator: "55001".to_string(),
3941 description: "Test".to_string(),
3942 communication_direction: None,
3943 fields: vec![
3944 AhbFieldRule {
3945 segment_path: "SG2/SG3/CTA/3139".to_string(),
3946 name: "Funktion des Ansprechpartners, Code".to_string(),
3947 ahb_status: "Muss".to_string(),
3948 codes: vec![],
3949 parent_group_ahb_status: None,
3950 segment_ahb_status: None,
3951 ..Default::default()
3952 },
3953 AhbFieldRule {
3954 segment_path: "SG2/SG3/CTA/C056/3412".to_string(),
3955 name: "Name vom Ansprechpartner".to_string(),
3956 ahb_status: "X".to_string(),
3957 codes: vec![],
3958 parent_group_ahb_status: None,
3959 segment_ahb_status: None,
3960 ..Default::default()
3961 },
3962 ],
3963 ub_definitions: BTreeMap::new(),
3964 package_conditions: Default::default(),
3965 };
3966
3967 let report = validator.validate_with_navigator(
3968 &segments,
3969 &workflow,
3970 &external,
3971 ValidationLevel::Conditions,
3972 &nav,
3973 );
3974
3975 let ahb_errors: Vec<_> = report
3976 .by_category(ValidationCategory::Ahb)
3977 .filter(|i| i.severity == Severity::Error)
3978 .collect();
3979 assert!(
3980 ahb_errors.is_empty(),
3981 "Expected no AHB001 errors when SG3 is absent, got: {:?}",
3982 ahb_errors
3983 );
3984 }
3985
3986 #[test]
3987 fn test_present_group_still_checks_mandatory_fields() {
3988 use mig_types::navigator::GroupNavigator;
3990
3991 struct NavWithSG3;
3992 impl GroupNavigator for NavWithSG3 {
3993 fn find_segments_in_group(&self, _: &str, _: &[&str], _: usize) -> Vec<OwnedSegment> {
3994 vec![]
3995 }
3996 fn find_segments_with_qualifier_in_group(
3997 &self,
3998 _: &str,
3999 _: usize,
4000 _: &str,
4001 _: &[&str],
4002 _: usize,
4003 ) -> Vec<OwnedSegment> {
4004 vec![]
4005 }
4006 fn group_instance_count(&self, group_path: &[&str]) -> usize {
4007 match group_path {
4008 ["SG2"] => 1,
4009 ["SG2", "SG3"] => 1, _ => 0,
4011 }
4012 }
4013 }
4014
4015 let evaluator = MockEvaluator::new(vec![]);
4016 let validator = EdifactValidator::new(evaluator);
4017 let external = NoOpExternalProvider;
4018 let nav = NavWithSG3;
4019
4020 let segments = vec![OwnedSegment {
4022 id: "NAD".into(),
4023 elements: vec![vec!["MS".into()]],
4024 segment_number: 3,
4025 }];
4026
4027 let workflow = AhbWorkflow {
4028 pruefidentifikator: "55001".to_string(),
4029 description: "Test".to_string(),
4030 communication_direction: None,
4031 fields: vec![AhbFieldRule {
4032 segment_path: "SG2/SG3/CTA/3139".to_string(),
4033 name: "Funktion des Ansprechpartners, Code".to_string(),
4034 ahb_status: "Muss".to_string(),
4035 codes: vec![],
4036 parent_group_ahb_status: None,
4037 segment_ahb_status: None,
4038 ..Default::default()
4039 }],
4040 ub_definitions: BTreeMap::new(),
4041 package_conditions: Default::default(),
4042 };
4043
4044 let report = validator.validate_with_navigator(
4045 &segments,
4046 &workflow,
4047 &external,
4048 ValidationLevel::Conditions,
4049 &nav,
4050 );
4051
4052 let ahb_errors: Vec<_> = report
4053 .by_category(ValidationCategory::Ahb)
4054 .filter(|i| i.severity == Severity::Error)
4055 .collect();
4056 assert_eq!(
4057 ahb_errors.len(),
4058 1,
4059 "Expected AHB001 error when SG3 is present but CTA missing"
4060 );
4061 assert!(narrate(ahb_errors[0]).contains("CTA"));
4062 }
4063
4064 #[test]
4065 fn test_missing_qualifier_with_navigator_is_detected() {
4066 use mig_types::navigator::GroupNavigator;
4069
4070 struct NavWithSG2;
4071 impl GroupNavigator for NavWithSG2 {
4072 fn find_segments_in_group(
4073 &self,
4074 segment_id: &str,
4075 group_path: &[&str],
4076 instance_index: usize,
4077 ) -> Vec<OwnedSegment> {
4078 if segment_id == "NAD" && group_path == ["SG2"] && instance_index == 0 {
4079 vec![OwnedSegment {
4080 id: "NAD".into(),
4081 elements: vec![vec!["MS".into()]],
4082 segment_number: 3,
4083 }]
4084 } else {
4085 vec![]
4086 }
4087 }
4088 fn find_segments_with_qualifier_in_group(
4089 &self,
4090 _: &str,
4091 _: usize,
4092 _: &str,
4093 _: &[&str],
4094 _: usize,
4095 ) -> Vec<OwnedSegment> {
4096 vec![]
4097 }
4098 fn group_instance_count(&self, group_path: &[&str]) -> usize {
4099 match group_path {
4100 ["SG2"] => 1,
4101 _ => 0,
4102 }
4103 }
4104 }
4105
4106 let evaluator = MockEvaluator::new(vec![]);
4107 let validator = EdifactValidator::new(evaluator);
4108 let external = NoOpExternalProvider;
4109 let nav = NavWithSG2;
4110
4111 let segments = vec![OwnedSegment {
4112 id: "NAD".into(),
4113 elements: vec![vec!["MS".into()]],
4114 segment_number: 3,
4115 }];
4116
4117 let workflow = AhbWorkflow {
4118 pruefidentifikator: "55001".to_string(),
4119 description: "Test".to_string(),
4120 communication_direction: None,
4121 fields: vec![
4122 AhbFieldRule {
4123 segment_path: "SG2/NAD/3035".to_string(),
4124 name: "Absender".to_string(),
4125 ahb_status: "X".to_string(),
4126 codes: vec![AhbCodeRule {
4127 value: "MS".to_string(),
4128 description: "Absender".to_string(),
4129 ahb_status: "X".to_string(),
4130 }],
4131 parent_group_ahb_status: None,
4132 segment_ahb_status: None,
4133 ..Default::default()
4134 },
4135 AhbFieldRule {
4136 segment_path: "SG2/NAD/3035".to_string(),
4137 name: "Empfaenger".to_string(),
4138 ahb_status: "Muss".to_string(),
4139 codes: vec![AhbCodeRule {
4140 value: "MR".to_string(),
4141 description: "Empfaenger".to_string(),
4142 ahb_status: "X".to_string(),
4143 }],
4144 parent_group_ahb_status: None,
4145 segment_ahb_status: None,
4146 ..Default::default()
4147 },
4148 ],
4149 ub_definitions: BTreeMap::new(),
4150 package_conditions: Default::default(),
4151 };
4152
4153 let report = validator.validate_with_navigator(
4154 &segments,
4155 &workflow,
4156 &external,
4157 ValidationLevel::Conditions,
4158 &nav,
4159 );
4160
4161 let ahb_errors: Vec<_> = report
4162 .by_category(ValidationCategory::Ahb)
4163 .filter(|i| i.severity == Severity::Error)
4164 .collect();
4165 assert_eq!(
4166 ahb_errors.len(),
4167 1,
4168 "Expected AHB001 for missing NAD+MR even with navigator, got: {:?}",
4169 ahb_errors
4170 );
4171 assert!(narrate(ahb_errors[0]).contains("Empfaenger"));
4172 }
4173
4174 #[test]
4175 fn test_optional_group_variant_absent_no_error() {
4176 use mig_types::navigator::GroupNavigator;
4181
4182 struct TestNav;
4183 impl GroupNavigator for TestNav {
4184 fn find_segments_in_group(
4185 &self,
4186 segment_id: &str,
4187 group_path: &[&str],
4188 instance_index: usize,
4189 ) -> Vec<OwnedSegment> {
4190 match (segment_id, group_path, instance_index) {
4191 ("LOC", ["SG4", "SG5"], 0) => vec![OwnedSegment {
4192 id: "LOC".into(),
4193 elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
4194 segment_number: 10,
4195 }],
4196 ("NAD", ["SG2"], 0) => vec![OwnedSegment {
4197 id: "NAD".into(),
4198 elements: vec![vec!["MS".into()]],
4199 segment_number: 3,
4200 }],
4201 _ => vec![],
4202 }
4203 }
4204 fn find_segments_with_qualifier_in_group(
4205 &self,
4206 _: &str,
4207 _: usize,
4208 _: &str,
4209 _: &[&str],
4210 _: usize,
4211 ) -> Vec<OwnedSegment> {
4212 vec![]
4213 }
4214 fn group_instance_count(&self, group_path: &[&str]) -> usize {
4215 match group_path {
4216 ["SG2"] => 1,
4217 ["SG4"] => 1,
4218 ["SG4", "SG5"] => 1, _ => 0,
4220 }
4221 }
4222 }
4223
4224 let evaluator = MockEvaluator::new(vec![]);
4225 let validator = EdifactValidator::new(evaluator);
4226 let external = NoOpExternalProvider;
4227 let nav = TestNav;
4228
4229 let segments = vec![
4230 OwnedSegment {
4231 id: "NAD".into(),
4232 elements: vec![vec!["MS".into()]],
4233 segment_number: 3,
4234 },
4235 OwnedSegment {
4236 id: "LOC".into(),
4237 elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
4238 segment_number: 10,
4239 },
4240 ];
4241
4242 let workflow = AhbWorkflow {
4243 pruefidentifikator: "55001".to_string(),
4244 description: "Test".to_string(),
4245 communication_direction: None,
4246 fields: vec![
4247 AhbFieldRule {
4249 segment_path: "SG2/NAD/3035".to_string(),
4250 name: "Absender".to_string(),
4251 ahb_status: "X".to_string(),
4252 codes: vec![AhbCodeRule {
4253 value: "MS".to_string(),
4254 description: "Absender".to_string(),
4255 ahb_status: "X".to_string(),
4256 }],
4257 parent_group_ahb_status: Some("Muss".to_string()),
4258 segment_ahb_status: None,
4259 ..Default::default()
4260 },
4261 AhbFieldRule {
4262 segment_path: "SG2/NAD/3035".to_string(),
4263 name: "Empfaenger".to_string(),
4264 ahb_status: "Muss".to_string(),
4265 codes: vec![AhbCodeRule {
4266 value: "MR".to_string(),
4267 description: "Empfaenger".to_string(),
4268 ahb_status: "X".to_string(),
4269 }],
4270 parent_group_ahb_status: Some("Muss".to_string()),
4271 segment_ahb_status: None,
4272 ..Default::default()
4273 },
4274 AhbFieldRule {
4276 segment_path: "SG4/SG5/LOC/3227".to_string(),
4277 name: "Ortsangabe, Qualifier (Z16)".to_string(),
4278 ahb_status: "X".to_string(),
4279 codes: vec![AhbCodeRule {
4280 value: "Z16".to_string(),
4281 description: "Marktlokation".to_string(),
4282 ahb_status: "X".to_string(),
4283 }],
4284 parent_group_ahb_status: Some("Kann".to_string()),
4285 segment_ahb_status: None,
4286 ..Default::default()
4287 },
4288 AhbFieldRule {
4289 segment_path: "SG4/SG5/LOC/3227".to_string(),
4290 name: "Ortsangabe, Qualifier (Z17)".to_string(),
4291 ahb_status: "Muss".to_string(),
4292 codes: vec![AhbCodeRule {
4293 value: "Z17".to_string(),
4294 description: "Messlokation".to_string(),
4295 ahb_status: "X".to_string(),
4296 }],
4297 parent_group_ahb_status: Some("Kann".to_string()),
4298 segment_ahb_status: None,
4299 ..Default::default()
4300 },
4301 ],
4302 ub_definitions: BTreeMap::new(),
4303 package_conditions: Default::default(),
4304 };
4305
4306 let report = validator.validate_with_navigator(
4307 &segments,
4308 &workflow,
4309 &external,
4310 ValidationLevel::Conditions,
4311 &nav,
4312 );
4313
4314 let ahb_errors: Vec<_> = report
4315 .by_category(ValidationCategory::Ahb)
4316 .filter(|i| i.severity == Severity::Error)
4317 .collect();
4318
4319 assert_eq!(
4322 ahb_errors.len(),
4323 1,
4324 "Expected only AHB001 for missing NAD+MR, got: {:?}",
4325 ahb_errors
4326 );
4327 assert!(
4328 narrate(ahb_errors[0]).contains("Empfaenger"),
4329 "Error should be for missing NAD+MR (Empfaenger)"
4330 );
4331 }
4332
4333 #[test]
4337 fn test_optional_group_variant_with_true_condition_absent_no_error() {
4338 use mig_types::navigator::GroupNavigator;
4339
4340 struct TestNav;
4341 impl GroupNavigator for TestNav {
4342 fn find_segments_in_group(
4343 &self,
4344 segment_id: &str,
4345 group_path: &[&str],
4346 instance_index: usize,
4347 ) -> Vec<OwnedSegment> {
4348 if segment_id == "SEQ" && group_path == ["SG4", "SG8"] && instance_index == 0 {
4349 vec![OwnedSegment {
4350 id: "SEQ".into(),
4351 elements: vec![vec!["Z03".into()]],
4352 segment_number: 10,
4353 }]
4354 } else {
4355 vec![]
4356 }
4357 }
4358 fn find_segments_with_qualifier_in_group(
4359 &self,
4360 _: &str,
4361 _: usize,
4362 _: &str,
4363 _: &[&str],
4364 _: usize,
4365 ) -> Vec<OwnedSegment> {
4366 vec![]
4367 }
4368 fn group_instance_count(&self, group_path: &[&str]) -> usize {
4369 match group_path {
4370 ["SG4"] => 1,
4371 ["SG4", "SG8"] => 1,
4372 _ => 0,
4373 }
4374 }
4375 }
4376
4377 let evaluator = MockEvaluator::new(vec![(345, CR::True)]);
4378 let validator = EdifactValidator::new(evaluator);
4379 let segments = vec![OwnedSegment {
4380 id: "SEQ".into(),
4381 elements: vec![vec!["Z03".into()]],
4382 segment_number: 10,
4383 }];
4384 let seq = |code: &str, group: &str| AhbFieldRule {
4385 segment_path: "SG4/SG8/SEQ/1229".to_string(),
4386 name: format!("Handlung, Code ({code})"),
4387 ahb_status: "X".to_string(),
4388 codes: vec![AhbCodeRule {
4389 value: code.to_string(),
4390 description: String::new(),
4391 ahb_status: "X".to_string(),
4392 }],
4393 parent_group_ahb_status: Some(group.to_string()),
4394 segment_ahb_status: None,
4395 ..Default::default()
4396 };
4397 let workflow = AhbWorkflow {
4398 pruefidentifikator: "44143".to_string(),
4399 description: "Test".to_string(),
4400 communication_direction: None,
4401 fields: vec![seq("Z03", "Kann"), seq("Z50", "Kann [345]")],
4402 ub_definitions: BTreeMap::new(),
4403 package_conditions: Default::default(),
4404 };
4405
4406 let report = validator.validate_with_navigator(
4407 &segments,
4408 &workflow,
4409 &NoOpExternalProvider,
4410 ValidationLevel::Conditions,
4411 &TestNav,
4412 );
4413 let missing: Vec<_> = report
4414 .issues
4415 .iter()
4416 .filter(|i| matches!(i.kind, IssueKind::MissingRequiredField { .. }))
4417 .collect();
4418 assert!(missing.is_empty(), "{missing:#?}");
4419 }
4420
4421 #[test]
4422 fn test_conditional_group_variant_absent_no_error() {
4423 use mig_types::navigator::GroupNavigator;
4428
4429 struct TestNav;
4430 impl GroupNavigator for TestNav {
4431 fn find_segments_in_group(
4432 &self,
4433 segment_id: &str,
4434 group_path: &[&str],
4435 instance_index: usize,
4436 ) -> Vec<OwnedSegment> {
4437 if segment_id == "LOC" && group_path == ["SG4", "SG5"] && instance_index == 0 {
4438 vec![OwnedSegment {
4439 id: "LOC".into(),
4440 elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
4441 segment_number: 10,
4442 }]
4443 } else {
4444 vec![]
4445 }
4446 }
4447 fn find_segments_with_qualifier_in_group(
4448 &self,
4449 _: &str,
4450 _: usize,
4451 _: &str,
4452 _: &[&str],
4453 _: usize,
4454 ) -> Vec<OwnedSegment> {
4455 vec![]
4456 }
4457 fn group_instance_count(&self, group_path: &[&str]) -> usize {
4458 match group_path {
4459 ["SG4"] => 1,
4460 ["SG4", "SG5"] => 1, _ => 0,
4462 }
4463 }
4464 }
4465
4466 let evaluator = MockEvaluator::new(vec![(165, CR::False), (2061, CR::True)]);
4469 let validator = EdifactValidator::new(evaluator);
4470 let external = NoOpExternalProvider;
4471 let nav = TestNav;
4472
4473 let segments = vec![OwnedSegment {
4474 id: "LOC".into(),
4475 elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
4476 segment_number: 10,
4477 }];
4478
4479 let workflow = AhbWorkflow {
4480 pruefidentifikator: "55001".to_string(),
4481 description: "Test".to_string(),
4482 communication_direction: None,
4483 fields: vec![
4484 AhbFieldRule {
4486 segment_path: "SG4/SG5/LOC/3227".to_string(),
4487 name: "Ortsangabe, Qualifier (Z16)".to_string(),
4488 ahb_status: "X".to_string(),
4489 codes: vec![AhbCodeRule {
4490 value: "Z16".to_string(),
4491 description: "Marktlokation".to_string(),
4492 ahb_status: "X".to_string(),
4493 }],
4494 parent_group_ahb_status: Some("Muss [2061]".to_string()),
4495 segment_ahb_status: None,
4496 ..Default::default()
4497 },
4498 AhbFieldRule {
4500 segment_path: "SG4/SG5/LOC/3227".to_string(),
4501 name: "Ortsangabe, Qualifier (Z17)".to_string(),
4502 ahb_status: "X".to_string(),
4503 codes: vec![AhbCodeRule {
4504 value: "Z17".to_string(),
4505 description: "Messlokation".to_string(),
4506 ahb_status: "X".to_string(),
4507 }],
4508 parent_group_ahb_status: Some("Soll [165]".to_string()),
4509 segment_ahb_status: None,
4510 ..Default::default()
4511 },
4512 ],
4513 ub_definitions: BTreeMap::new(),
4514 package_conditions: Default::default(),
4515 };
4516
4517 let report = validator.validate_with_navigator(
4518 &segments,
4519 &workflow,
4520 &external,
4521 ValidationLevel::Conditions,
4522 &nav,
4523 );
4524
4525 let ahb_errors: Vec<_> = report
4526 .by_category(ValidationCategory::Ahb)
4527 .filter(|i| i.severity == Severity::Error)
4528 .collect();
4529
4530 assert!(
4532 ahb_errors.is_empty(),
4533 "Expected no errors when conditional group variant [165]=False, got: {:?}",
4534 ahb_errors
4535 );
4536 }
4537
4538 #[test]
4539 fn test_conditional_group_variant_unknown_no_error() {
4540 let evaluator = MockEvaluator::new(vec![]);
4546 let validator = EdifactValidator::new(evaluator);
4547 let external = NoOpExternalProvider;
4548
4549 let workflow = AhbWorkflow {
4550 pruefidentifikator: "55001".to_string(),
4551 description: "Test".to_string(),
4552 communication_direction: None,
4553 fields: vec![AhbFieldRule {
4554 segment_path: "SG4/SG5/LOC/3227".to_string(),
4555 name: "Ortsangabe, Qualifier (Z17)".to_string(),
4556 ahb_status: "X".to_string(),
4557 codes: vec![AhbCodeRule {
4558 value: "Z17".to_string(),
4559 description: "Messlokation".to_string(),
4560 ahb_status: "X".to_string(),
4561 }],
4562 parent_group_ahb_status: Some("Soll [165]".to_string()),
4563 segment_ahb_status: None,
4564 ..Default::default()
4565 }],
4566 ub_definitions: BTreeMap::new(),
4567 package_conditions: Default::default(),
4568 };
4569
4570 let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
4571
4572 let ahb_errors: Vec<_> = report
4573 .by_category(ValidationCategory::Ahb)
4574 .filter(|i| i.severity == Severity::Error)
4575 .collect();
4576
4577 assert!(
4579 ahb_errors.is_empty(),
4580 "Expected no errors when parent group condition is Unknown, got: {:?}",
4581 ahb_errors
4582 );
4583 }
4584
4585 #[test]
4586 fn test_segment_absent_within_present_group_no_error() {
4587 use mig_types::navigator::GroupNavigator;
4591
4592 struct TestNav;
4593 impl GroupNavigator for TestNav {
4594 fn find_segments_in_group(
4595 &self,
4596 segment_id: &str,
4597 group_path: &[&str],
4598 instance_index: usize,
4599 ) -> Vec<OwnedSegment> {
4600 if segment_id == "QTY"
4602 && group_path == ["SG5", "SG6", "SG9", "SG10"]
4603 && instance_index == 0
4604 {
4605 vec![OwnedSegment {
4606 id: "QTY".into(),
4607 elements: vec![vec!["220".into(), "0".into()]],
4608 segment_number: 14,
4609 }]
4610 } else {
4611 vec![]
4612 }
4613 }
4614 fn find_segments_with_qualifier_in_group(
4615 &self,
4616 _: &str,
4617 _: usize,
4618 _: &str,
4619 _: &[&str],
4620 _: usize,
4621 ) -> Vec<OwnedSegment> {
4622 vec![]
4623 }
4624 fn group_instance_count(&self, group_path: &[&str]) -> usize {
4625 match group_path {
4626 ["SG5"] => 1,
4627 ["SG5", "SG6"] => 1,
4628 ["SG5", "SG6", "SG9"] => 1,
4629 ["SG5", "SG6", "SG9", "SG10"] => 1,
4630 _ => 0,
4631 }
4632 }
4633 fn has_any_segment_in_group(&self, group_path: &[&str], instance_index: usize) -> bool {
4634 group_path == ["SG5", "SG6", "SG9", "SG10"] && instance_index == 0
4636 }
4637 }
4638
4639 let evaluator = MockEvaluator::all_true(&[]);
4640 let validator = EdifactValidator::new(evaluator);
4641 let external = NoOpExternalProvider;
4642 let nav = TestNav;
4643
4644 let segments = vec![OwnedSegment {
4645 id: "QTY".into(),
4646 elements: vec![vec!["220".into(), "0".into()]],
4647 segment_number: 14,
4648 }];
4649
4650 let workflow = AhbWorkflow {
4651 pruefidentifikator: "13017".to_string(),
4652 description: "Test".to_string(),
4653 communication_direction: None,
4654 fields: vec![
4655 AhbFieldRule {
4657 segment_path: "SG5/SG6/SG9/SG10/STS/C601/9015".to_string(),
4658 name: "Statuskategorie, Code".to_string(),
4659 ahb_status: "X".to_string(),
4660 codes: vec![],
4661 parent_group_ahb_status: Some("Muss".to_string()),
4662 segment_ahb_status: None,
4663 ..Default::default()
4664 },
4665 AhbFieldRule {
4667 segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
4668 name: "Statusanlaß, Code".to_string(),
4669 ahb_status: "X [5]".to_string(),
4670 codes: vec![],
4671 parent_group_ahb_status: Some("Muss".to_string()),
4672 segment_ahb_status: None,
4673 ..Default::default()
4674 },
4675 ],
4676 ub_definitions: BTreeMap::new(),
4677 package_conditions: Default::default(),
4678 };
4679
4680 let report = validator.validate_with_navigator(
4681 &segments,
4682 &workflow,
4683 &external,
4684 ValidationLevel::Conditions,
4685 &nav,
4686 );
4687
4688 let ahb_errors: Vec<_> = report
4689 .by_category(ValidationCategory::Ahb)
4690 .filter(|i| i.severity == Severity::Error)
4691 .collect();
4692
4693 assert!(
4694 ahb_errors.is_empty(),
4695 "Expected no AHB001 errors when STS segment is absent from SG10, got: {:?}",
4696 ahb_errors
4697 );
4698 }
4699
4700 #[test]
4701 fn test_group_scoped_code_validation_with_navigator() {
4702 use mig_types::navigator::GroupNavigator;
4706
4707 struct TestNav;
4708 impl GroupNavigator for TestNav {
4709 fn find_segments_in_group(
4710 &self,
4711 segment_id: &str,
4712 group_path: &[&str],
4713 _instance_index: usize,
4714 ) -> Vec<OwnedSegment> {
4715 if segment_id != "NAD" {
4716 return vec![];
4717 }
4718 match group_path {
4719 ["SG2"] => vec![
4720 OwnedSegment {
4721 id: "NAD".into(),
4722 elements: vec![vec!["MS".into()]],
4723 segment_number: 3,
4724 },
4725 OwnedSegment {
4726 id: "NAD".into(),
4727 elements: vec![vec!["MT".into()]], segment_number: 4,
4729 },
4730 ],
4731 ["SG4", "SG12"] => vec![
4732 OwnedSegment {
4733 id: "NAD".into(),
4734 elements: vec![vec!["Z04".into()]],
4735 segment_number: 20,
4736 },
4737 OwnedSegment {
4738 id: "NAD".into(),
4739 elements: vec![vec!["Z09".into()]],
4740 segment_number: 21,
4741 },
4742 ],
4743 _ => vec![],
4744 }
4745 }
4746 fn find_segments_with_qualifier_in_group(
4747 &self,
4748 _: &str,
4749 _: usize,
4750 _: &str,
4751 _: &[&str],
4752 _: usize,
4753 ) -> Vec<OwnedSegment> {
4754 vec![]
4755 }
4756 fn group_instance_count(&self, group_path: &[&str]) -> usize {
4757 match group_path {
4758 ["SG2"] | ["SG4", "SG12"] => 1,
4759 _ => 0,
4760 }
4761 }
4762 }
4763
4764 let evaluator = MockEvaluator::new(vec![]);
4765 let validator = EdifactValidator::new(evaluator);
4766 let external = NoOpExternalProvider;
4767 let nav = TestNav;
4768
4769 let workflow = AhbWorkflow {
4770 pruefidentifikator: "55001".to_string(),
4771 description: "Test".to_string(),
4772 communication_direction: None,
4773 fields: vec![
4774 AhbFieldRule {
4775 segment_path: "SG2/NAD/3035".to_string(),
4776 name: "Absender".to_string(),
4777 ahb_status: "X".to_string(),
4778 codes: vec![AhbCodeRule {
4779 value: "MS".to_string(),
4780 description: "Absender".to_string(),
4781 ahb_status: "X".to_string(),
4782 }],
4783 parent_group_ahb_status: None,
4784 segment_ahb_status: None,
4785 ..Default::default()
4786 },
4787 AhbFieldRule {
4788 segment_path: "SG2/NAD/3035".to_string(),
4789 name: "Empfaenger".to_string(),
4790 ahb_status: "X".to_string(),
4791 codes: vec![AhbCodeRule {
4792 value: "MR".to_string(),
4793 description: "Empfaenger".to_string(),
4794 ahb_status: "X".to_string(),
4795 }],
4796 parent_group_ahb_status: None,
4797 segment_ahb_status: None,
4798 ..Default::default()
4799 },
4800 AhbFieldRule {
4801 segment_path: "SG4/SG12/NAD/3035".to_string(),
4802 name: "Anschlussnutzer".to_string(),
4803 ahb_status: "X".to_string(),
4804 codes: vec![AhbCodeRule {
4805 value: "Z04".to_string(),
4806 description: "Anschlussnutzer".to_string(),
4807 ahb_status: "X".to_string(),
4808 }],
4809 parent_group_ahb_status: None,
4810 segment_ahb_status: None,
4811 ..Default::default()
4812 },
4813 AhbFieldRule {
4814 segment_path: "SG4/SG12/NAD/3035".to_string(),
4815 name: "Korrespondenzanschrift".to_string(),
4816 ahb_status: "X".to_string(),
4817 codes: vec![AhbCodeRule {
4818 value: "Z09".to_string(),
4819 description: "Korrespondenzanschrift".to_string(),
4820 ahb_status: "X".to_string(),
4821 }],
4822 parent_group_ahb_status: None,
4823 segment_ahb_status: None,
4824 ..Default::default()
4825 },
4826 ],
4827 ub_definitions: BTreeMap::new(),
4828 package_conditions: Default::default(),
4829 };
4830
4831 let all_segments = vec![
4833 OwnedSegment {
4834 id: "NAD".into(),
4835 elements: vec![vec!["MS".into()]],
4836 segment_number: 3,
4837 },
4838 OwnedSegment {
4839 id: "NAD".into(),
4840 elements: vec![vec!["MT".into()]],
4841 segment_number: 4,
4842 },
4843 OwnedSegment {
4844 id: "NAD".into(),
4845 elements: vec![vec!["Z04".into()]],
4846 segment_number: 20,
4847 },
4848 OwnedSegment {
4849 id: "NAD".into(),
4850 elements: vec![vec!["Z09".into()]],
4851 segment_number: 21,
4852 },
4853 ];
4854
4855 let report = validator.validate_with_navigator(
4856 &all_segments,
4857 &workflow,
4858 &external,
4859 ValidationLevel::Conditions,
4860 &nav,
4861 );
4862
4863 let code_errors: Vec<_> = report
4864 .by_category(ValidationCategory::Code)
4865 .filter(|i| i.severity == Severity::Error)
4866 .collect();
4867
4868 assert_eq!(
4871 code_errors.len(),
4872 1,
4873 "Expected exactly one COD002 error for MT in SG2, got: {:?}",
4874 code_errors
4875 );
4876 assert!(narrate(code_errors[0]).contains("MT"));
4877 assert!(narrate(code_errors[0]).contains("MR"));
4879 assert!(narrate(code_errors[0]).contains("MS"));
4880 assert!(
4881 !narrate(code_errors[0]).contains("Z04"),
4882 "SG4/SG12 codes should not leak into SG2 error"
4883 );
4884 assert!(
4886 code_errors[0]
4887 .field_path
4888 .as_deref()
4889 .unwrap_or("")
4890 .contains("SG2"),
4891 "Error field_path should reference SG2, got: {:?}",
4892 code_errors[0].field_path
4893 );
4894 }
4895
4896 #[test]
4899 fn test_package_cardinality_within_bounds() {
4900 let evaluator = MockEvaluator::all_true(&[]);
4902 let validator = EdifactValidator::new(evaluator);
4903 let external = NoOpExternalProvider;
4904
4905 let segments = vec![OwnedSegment {
4906 id: "STS".into(),
4907 elements: vec![
4908 vec!["Z33".into()], vec![], vec!["E01".into()], ],
4912 segment_number: 5,
4913 }];
4914
4915 let workflow = AhbWorkflow {
4916 pruefidentifikator: "13017".to_string(),
4917 description: "Test".to_string(),
4918 communication_direction: None,
4919 ub_definitions: BTreeMap::new(),
4920 package_conditions: Default::default(),
4921 fields: vec![AhbFieldRule {
4922 segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
4923 name: "Statusanlaß, Code".to_string(),
4924 ahb_status: "X".to_string(),
4925 element_index: Some(2),
4926 component_index: Some(0),
4927 codes: vec![
4928 AhbCodeRule {
4929 value: "E01".into(),
4930 description: "Code 1".into(),
4931 ahb_status: "X [4P0..1]".into(),
4932 },
4933 AhbCodeRule {
4934 value: "E02".into(),
4935 description: "Code 2".into(),
4936 ahb_status: "X [4P0..1]".into(),
4937 },
4938 ],
4939 parent_group_ahb_status: Some("Muss".to_string()),
4940 segment_ahb_status: None,
4941 mig_number: None,
4942 format: None,
4943 }],
4944 };
4945
4946 let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
4947 let pkg_errors: Vec<_> = report
4948 .by_category(ValidationCategory::Ahb)
4949 .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
4950 .collect();
4951 assert!(
4952 pkg_errors.is_empty(),
4953 "1 code within [4P0..1] bounds — no error expected, got: {:?}",
4954 pkg_errors
4955 );
4956 }
4957
4958 #[test]
4959 fn test_package_cardinality_zero_present_min_zero() {
4960 let evaluator = MockEvaluator::all_true(&[]);
4962 let validator = EdifactValidator::new(evaluator);
4963 let external = NoOpExternalProvider;
4964
4965 let segments = vec![OwnedSegment {
4966 id: "STS".into(),
4967 elements: vec![
4968 vec!["Z33".into()],
4969 vec![],
4970 vec!["X99".into()], ],
4972 segment_number: 5,
4973 }];
4974
4975 let workflow = AhbWorkflow {
4976 pruefidentifikator: "13017".to_string(),
4977 description: "Test".to_string(),
4978 communication_direction: None,
4979 ub_definitions: BTreeMap::new(),
4980 package_conditions: Default::default(),
4981 fields: vec![AhbFieldRule {
4982 segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
4983 name: "Statusanlaß, Code".to_string(),
4984 ahb_status: "X".to_string(),
4985 element_index: Some(2),
4986 component_index: Some(0),
4987 codes: vec![
4988 AhbCodeRule {
4989 value: "E01".into(),
4990 description: "Code 1".into(),
4991 ahb_status: "X [4P0..1]".into(),
4992 },
4993 AhbCodeRule {
4994 value: "E02".into(),
4995 description: "Code 2".into(),
4996 ahb_status: "X [4P0..1]".into(),
4997 },
4998 ],
4999 parent_group_ahb_status: Some("Muss".to_string()),
5000 segment_ahb_status: None,
5001 mig_number: None,
5002 format: None,
5003 }],
5004 };
5005
5006 let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
5007 let pkg_errors: Vec<_> = report
5008 .by_category(ValidationCategory::Ahb)
5009 .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
5010 .collect();
5011 assert!(
5012 pkg_errors.is_empty(),
5013 "0 codes, min=0 — no error expected, got: {:?}",
5014 pkg_errors
5015 );
5016 }
5017
5018 #[test]
5019 fn test_package_cardinality_too_many() {
5020 let evaluator = MockEvaluator::all_true(&[]);
5022 let validator = EdifactValidator::new(evaluator);
5023 let external = NoOpExternalProvider;
5024
5025 let segments = vec![
5027 OwnedSegment {
5028 id: "STS".into(),
5029 elements: vec![vec!["Z33".into()], vec![], vec!["E01".into()]],
5030 segment_number: 5,
5031 },
5032 OwnedSegment {
5033 id: "STS".into(),
5034 elements: vec![vec!["Z33".into()], vec![], vec!["E02".into()]],
5035 segment_number: 6,
5036 },
5037 ];
5038
5039 let workflow = AhbWorkflow {
5040 pruefidentifikator: "13017".to_string(),
5041 description: "Test".to_string(),
5042 communication_direction: None,
5043 ub_definitions: BTreeMap::new(),
5044 package_conditions: Default::default(),
5045 fields: vec![AhbFieldRule {
5046 segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
5047 name: "Statusanlaß, Code".to_string(),
5048 ahb_status: "X".to_string(),
5049 element_index: Some(2),
5050 component_index: Some(0),
5051 codes: vec![
5052 AhbCodeRule {
5053 value: "E01".into(),
5054 description: "Code 1".into(),
5055 ahb_status: "X [4P0..1]".into(),
5056 },
5057 AhbCodeRule {
5058 value: "E02".into(),
5059 description: "Code 2".into(),
5060 ahb_status: "X [4P0..1]".into(),
5061 },
5062 ],
5063 parent_group_ahb_status: Some("Muss".to_string()),
5064 segment_ahb_status: None,
5065 mig_number: None,
5066 format: None,
5067 }],
5068 };
5069
5070 let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
5071 let pkg_errors: Vec<_> = report
5072 .by_category(ValidationCategory::Ahb)
5073 .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
5074 .collect();
5075 assert_eq!(
5076 pkg_errors.len(),
5077 1,
5078 "2 codes present, max=1 — expected 1 error, got: {:?}",
5079 pkg_errors
5080 );
5081 assert!(narrate(pkg_errors[0]).contains("[4P0..1]"));
5082 assert_eq!(pkg_errors[0].actual_value.as_deref(), Some("2"));
5083 assert_eq!(pkg_errors[0].expected_value.as_deref(), Some("0..1"));
5084 }
5085
5086 #[test]
5087 fn test_package_cardinality_too_few() {
5088 let evaluator = MockEvaluator::all_true(&[]);
5090 let validator = EdifactValidator::new(evaluator);
5091 let external = NoOpExternalProvider;
5092
5093 let segments = vec![OwnedSegment {
5094 id: "STS".into(),
5095 elements: vec![
5096 vec!["Z33".into()],
5097 vec![],
5098 vec!["X99".into()], ],
5100 segment_number: 5,
5101 }];
5102
5103 let workflow = AhbWorkflow {
5104 pruefidentifikator: "13017".to_string(),
5105 description: "Test".to_string(),
5106 communication_direction: None,
5107 ub_definitions: BTreeMap::new(),
5108 package_conditions: Default::default(),
5109 fields: vec![AhbFieldRule {
5110 segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
5111 name: "Statusanlaß, Code".to_string(),
5112 ahb_status: "X".to_string(),
5113 element_index: Some(2),
5114 component_index: Some(0),
5115 codes: vec![
5116 AhbCodeRule {
5117 value: "E01".into(),
5118 description: "Code 1".into(),
5119 ahb_status: "X [5P1..3]".into(),
5120 },
5121 AhbCodeRule {
5122 value: "E02".into(),
5123 description: "Code 2".into(),
5124 ahb_status: "X [5P1..3]".into(),
5125 },
5126 AhbCodeRule {
5127 value: "E03".into(),
5128 description: "Code 3".into(),
5129 ahb_status: "X [5P1..3]".into(),
5130 },
5131 ],
5132 parent_group_ahb_status: Some("Muss".to_string()),
5133 segment_ahb_status: None,
5134 mig_number: None,
5135 format: None,
5136 }],
5137 };
5138
5139 let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
5140 let pkg_errors: Vec<_> = report
5141 .by_category(ValidationCategory::Ahb)
5142 .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
5143 .collect();
5144 assert_eq!(
5145 pkg_errors.len(),
5146 1,
5147 "0 codes present, min=1 — expected 1 error, got: {:?}",
5148 pkg_errors
5149 );
5150 assert!(narrate(pkg_errors[0]).contains("[5P1..3]"));
5151 assert_eq!(pkg_errors[0].actual_value.as_deref(), Some("0"));
5152 assert_eq!(pkg_errors[0].expected_value.as_deref(), Some("1..3"));
5153 }
5154
5155 #[test]
5156 fn test_package_cardinality_no_packages_in_workflow() {
5157 let evaluator = MockEvaluator::all_true(&[]);
5159 let validator = EdifactValidator::new(evaluator);
5160 let external = NoOpExternalProvider;
5161
5162 let segments = vec![OwnedSegment {
5163 id: "STS".into(),
5164 elements: vec![vec!["E01".into()]],
5165 segment_number: 5,
5166 }];
5167
5168 let workflow = AhbWorkflow {
5169 pruefidentifikator: "13017".to_string(),
5170 description: "Test".to_string(),
5171 communication_direction: None,
5172 ub_definitions: BTreeMap::new(),
5173 package_conditions: Default::default(),
5174 fields: vec![AhbFieldRule {
5175 segment_path: "STS/9015".to_string(),
5176 name: "Status Code".to_string(),
5177 ahb_status: "X".to_string(),
5178 codes: vec![AhbCodeRule {
5179 value: "E01".into(),
5180 description: "Code 1".into(),
5181 ahb_status: "X".into(),
5182 }],
5183 parent_group_ahb_status: Some("Muss".to_string()),
5184 segment_ahb_status: None,
5185 ..Default::default()
5186 }],
5187 };
5188
5189 let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
5190 let pkg_errors: Vec<_> = report
5191 .by_category(ValidationCategory::Ahb)
5192 .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
5193 .collect();
5194 assert!(
5195 pkg_errors.is_empty(),
5196 "No packages in workflow — no errors expected"
5197 );
5198 }
5199
5200 #[test]
5201 fn test_package_cardinality_with_condition_and_package() {
5202 let evaluator = MockEvaluator::all_true(&[901]);
5204 let validator = EdifactValidator::new(evaluator);
5205 let external = NoOpExternalProvider;
5206
5207 let segments = vec![OwnedSegment {
5208 id: "STS".into(),
5209 elements: vec![vec![], vec![], vec!["E01".into()]],
5210 segment_number: 5,
5211 }];
5212
5213 let workflow = AhbWorkflow {
5214 pruefidentifikator: "13017".to_string(),
5215 description: "Test".to_string(),
5216 communication_direction: None,
5217 ub_definitions: BTreeMap::new(),
5218 package_conditions: Default::default(),
5219 fields: vec![AhbFieldRule {
5220 segment_path: "SG10/STS/C556/9013".to_string(),
5221 name: "Code".to_string(),
5222 ahb_status: "X".to_string(),
5223 element_index: Some(2),
5224 component_index: Some(0),
5225 codes: vec![
5226 AhbCodeRule {
5227 value: "E01".into(),
5228 description: "Code 1".into(),
5229 ahb_status: "X [901] [4P0..1]".into(),
5230 },
5231 AhbCodeRule {
5232 value: "E02".into(),
5233 description: "Code 2".into(),
5234 ahb_status: "X [901] [4P0..1]".into(),
5235 },
5236 ],
5237 parent_group_ahb_status: Some("Muss".to_string()),
5238 segment_ahb_status: None,
5239 mig_number: None,
5240 format: None,
5241 }],
5242 };
5243
5244 let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
5245 let pkg_errors: Vec<_> = report
5246 .by_category(ValidationCategory::Ahb)
5247 .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
5248 .collect();
5249 assert!(
5250 pkg_errors.is_empty(),
5251 "1 code within [4P0..1] bounds — no error, got: {:?}",
5252 pkg_errors
5253 );
5254 }
5255
5256 #[test]
5257 fn test_package_binds_only_where_its_condition_holds() {
5258 let report_for = |package_condition: CR| {
5262 let validator =
5263 EdifactValidator::new(MockEvaluator::new(vec![(902, package_condition)]));
5264 let segments = vec![OwnedSegment {
5265 id: "STS".into(),
5266 elements: vec![vec![], vec![], vec!["E09".into()]],
5267 segment_number: 5,
5268 }];
5269 let workflow = AhbWorkflow {
5270 pruefidentifikator: "13017".to_string(),
5271 description: "Test".to_string(),
5272 communication_direction: None,
5273 ub_definitions: BTreeMap::new(),
5274 package_conditions: BTreeMap::from([(5, ConditionExpr::Ref(902))]),
5275 fields: vec![AhbFieldRule {
5276 segment_path: "SG10/STS/C556/9013".to_string(),
5277 name: "Code".to_string(),
5278 ahb_status: "X".to_string(),
5279 element_index: Some(2),
5280 component_index: Some(0),
5281 codes: vec![
5282 AhbCodeRule {
5283 value: "E01".into(),
5284 description: "Code 1".into(),
5285 ahb_status: "X [5P1..1]".into(),
5286 },
5287 AhbCodeRule {
5288 value: "E09".into(),
5289 description: "Code 9".into(),
5290 ahb_status: "X".into(),
5291 },
5292 ],
5293 parent_group_ahb_status: Some("Muss".to_string()),
5294 segment_ahb_status: None,
5295 mig_number: None,
5296 format: None,
5297 }],
5298 };
5299 validator
5300 .validate(
5301 &segments,
5302 &workflow,
5303 &NoOpExternalProvider,
5304 ValidationLevel::Full,
5305 )
5306 .by_category(ValidationCategory::Ahb)
5307 .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
5308 .count()
5309 };
5310 assert_eq!(report_for(CR::False), 0, "[902] false: [5P] does not bind");
5311 assert_eq!(report_for(CR::True), 1, "[902] true: [5P1..1] binds");
5312 }
5313
5314 #[test]
5315 fn test_package_cardinality_scoped_per_group_instance() {
5316 use mig_types::navigator::GroupNavigator;
5324
5325 struct TwoSg10s {
5326 sts_a: OwnedSegment,
5327 sts_b: OwnedSegment,
5328 }
5329 impl GroupNavigator for TwoSg10s {
5330 fn find_segments_in_group(
5331 &self,
5332 segment_id: &str,
5333 group_path: &[&str],
5334 instance_index: usize,
5335 ) -> Vec<OwnedSegment> {
5336 if group_path == ["SG5", "SG6", "SG9", "SG10"] && segment_id == "STS" {
5337 match instance_index {
5338 0 => vec![self.sts_a.clone()],
5339 1 => vec![self.sts_b.clone()],
5340 _ => vec![],
5341 }
5342 } else {
5343 vec![]
5344 }
5345 }
5346 fn find_segments_with_qualifier_in_group(
5347 &self,
5348 _: &str,
5349 _: usize,
5350 _: &str,
5351 _: &[&str],
5352 _: usize,
5353 ) -> Vec<OwnedSegment> {
5354 vec![]
5355 }
5356 fn group_instance_count(&self, group_path: &[&str]) -> usize {
5357 match group_path {
5358 ["SG5"] | ["SG5", "SG6"] | ["SG5", "SG6", "SG9"] => 1,
5359 ["SG5", "SG6", "SG9", "SG10"] => 2,
5360 _ => 0,
5361 }
5362 }
5363 }
5364
5365 let sts_a = OwnedSegment {
5366 id: "STS".into(),
5367 elements: vec![vec!["Z32".into()], vec![], vec!["E01".into()]],
5368 segment_number: 10,
5369 };
5370 let sts_b = OwnedSegment {
5371 id: "STS".into(),
5372 elements: vec![vec!["Z32".into()], vec![], vec!["E01".into()]],
5373 segment_number: 15,
5374 };
5375 let nav = TwoSg10s {
5376 sts_a: sts_a.clone(),
5377 sts_b: sts_b.clone(),
5378 };
5379
5380 let evaluator = MockEvaluator::all_true(&[]);
5381 let validator = EdifactValidator::new(evaluator);
5382 let external = NoOpExternalProvider;
5383
5384 let workflow = AhbWorkflow {
5385 pruefidentifikator: "13025".to_string(),
5386 description: "Test".to_string(),
5387 communication_direction: None,
5388 ub_definitions: BTreeMap::new(),
5389 package_conditions: Default::default(),
5390 fields: vec![AhbFieldRule {
5391 segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
5392 name: "Statusanlaß, Code".to_string(),
5393 ahb_status: "X".to_string(),
5394 element_index: Some(2),
5395 component_index: Some(0),
5396 codes: vec![
5397 AhbCodeRule {
5398 value: "E01".into(),
5399 description: "Code 1".into(),
5400 ahb_status: "X [4P0..1]".into(),
5401 },
5402 AhbCodeRule {
5403 value: "E02".into(),
5404 description: "Code 2".into(),
5405 ahb_status: "X [4P0..1]".into(),
5406 },
5407 ],
5408 parent_group_ahb_status: Some("Muss".to_string()),
5409 segment_ahb_status: None,
5410 mig_number: None,
5411 format: None,
5412 }],
5413 };
5414
5415 let report = validator.validate_with_navigator(
5416 &[sts_a, sts_b],
5417 &workflow,
5418 &external,
5419 ValidationLevel::Full,
5420 &nav,
5421 );
5422 let pkg_errors: Vec<_> = report
5423 .by_category(ValidationCategory::Ahb)
5424 .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
5425 .collect();
5426 assert!(
5427 pkg_errors.is_empty(),
5428 "Package cardinality is per-instance: 1 code per SG10 rep is within [4P0..1]. Got: {:?}",
5429 pkg_errors
5430 );
5431 }
5432
5433 fn make_segment(id: &str, elements: Vec<Vec<&str>>) -> OwnedSegment {
5434 OwnedSegment {
5435 id: id.to_string(),
5436 elements: elements
5437 .into_iter()
5438 .map(|e| e.into_iter().map(|s| s.to_string()).collect())
5439 .collect(),
5440 segment_number: 0,
5441 }
5442 }
5443
5444 #[test]
5455 fn test_package_cardinality_scoped_to_rule_mig_variant() {
5456 use mig_types::navigator::GroupNavigator;
5457
5458 struct TwoSg8Variants {
5459 seq_z01: OwnedSegment,
5460 seq_z45: OwnedSegment,
5461 }
5462 impl GroupNavigator for TwoSg8Variants {
5463 fn find_segments_in_group(
5464 &self,
5465 segment_id: &str,
5466 group_path: &[&str],
5467 instance_index: usize,
5468 ) -> Vec<OwnedSegment> {
5469 if group_path == ["SG4", "SG8"] && segment_id == "SEQ" {
5470 match instance_index {
5471 0 => vec![self.seq_z01.clone()],
5472 1 => vec![self.seq_z45.clone()],
5473 _ => vec![],
5474 }
5475 } else {
5476 vec![]
5477 }
5478 }
5479 fn find_segments_with_qualifier_in_group(
5480 &self,
5481 _: &str,
5482 _: usize,
5483 _: &str,
5484 _: &[&str],
5485 _: usize,
5486 ) -> Vec<OwnedSegment> {
5487 vec![]
5488 }
5489 fn group_instance_count(&self, group_path: &[&str]) -> usize {
5490 match group_path {
5491 ["SG4"] => 1,
5492 ["SG4", "SG8"] => 2,
5493 _ => 0,
5494 }
5495 }
5496 fn instance_has_mig_number(
5497 &self,
5498 group_path: &[&str],
5499 instance_index: usize,
5500 mig_number: &str,
5501 ) -> bool {
5502 if group_path != ["SG4", "SG8"] {
5503 return true;
5504 }
5505 match (instance_index, mig_number) {
5506 (0, "00115") => true,
5507 (0, _) => false,
5508 (1, "00171") => true,
5509 (1, _) => false,
5510 _ => false,
5511 }
5512 }
5513 }
5514
5515 let seq_z01 = OwnedSegment {
5516 id: "SEQ".into(),
5517 elements: vec![vec!["Z01".into()], vec!["1".into()]],
5518 segment_number: 10,
5519 };
5520 let seq_z45 = OwnedSegment {
5521 id: "SEQ".into(),
5522 elements: vec![vec!["Z45".into()], vec!["1".into()]],
5523 segment_number: 20,
5524 };
5525 let nav = TwoSg8Variants {
5526 seq_z01: seq_z01.clone(),
5527 seq_z45: seq_z45.clone(),
5528 };
5529
5530 let evaluator = MockEvaluator::all_true(&[]);
5531 let validator = EdifactValidator::new(evaluator);
5532 let external = NoOpExternalProvider;
5533
5534 let workflow = AhbWorkflow {
5535 pruefidentifikator: "55218".to_string(),
5536 description: "Test".to_string(),
5537 communication_direction: None,
5538 ub_definitions: BTreeMap::new(),
5539 package_conditions: Default::default(),
5540 fields: vec![AhbFieldRule {
5541 segment_path: "SG4/SG8/SEQ/1229".to_string(),
5542 name: "Handlung, Code".to_string(),
5543 ahb_status: "X".to_string(),
5544 element_index: Some(0),
5545 component_index: Some(0),
5546 codes: vec![
5547 AhbCodeRule {
5548 value: "Z45".into(),
5549 description: "NNA".into(),
5550 ahb_status: "X [1P1..4294967295]".into(),
5551 },
5552 AhbCodeRule {
5553 value: "Z84".into(),
5554 description: "Differenz-NNA".into(),
5555 ahb_status: "X [1P0..4294967295]".into(),
5556 },
5557 ],
5558 parent_group_ahb_status: Some("Muss".to_string()),
5559 segment_ahb_status: None,
5560 mig_number: Some("00171".to_string()),
5561 format: None,
5562 }],
5563 };
5564
5565 let report = validator.validate_with_navigator(
5566 &[seq_z01, seq_z45],
5567 &workflow,
5568 &external,
5569 ValidationLevel::Full,
5570 &nav,
5571 );
5572 let pkg_errors: Vec<_> = report
5573 .by_category(ValidationCategory::Ahb)
5574 .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
5575 .collect();
5576 assert!(
5577 pkg_errors.is_empty(),
5578 "Package rule with mig=00171 must only count the Z45 instance (which has 1 code), not the sibling Z01 variant. Got: {:?}",
5579 pkg_errors
5580 );
5581 }
5582
5583 #[test]
5584 fn test_unt_count_correct() {
5585 let segments = vec![
5587 make_segment("UNH", vec![vec!["001"]]),
5588 make_segment("BGM", vec![vec!["E01"]]),
5589 make_segment("DTM", vec![vec!["137", "20250401"]]),
5590 make_segment("UNT", vec![vec!["4", "001"]]),
5591 ];
5592 assert!(
5593 validate_unt_segment_count(&segments).is_none(),
5594 "Correct count should produce no issue"
5595 );
5596 }
5597
5598 #[test]
5599 fn test_unt_count_mismatch() {
5600 let segments = vec![
5602 make_segment("UNH", vec![vec!["001"]]),
5603 make_segment("BGM", vec![vec!["E01"]]),
5604 make_segment("UNT", vec![vec!["5", "001"]]),
5605 ];
5606 let issue =
5607 validate_unt_segment_count(&segments).expect("Mismatch should produce an issue");
5608 assert_eq!(issue.code(), ErrorCodes::UNT_SEGMENT_COUNT_MISMATCH);
5609 assert_eq!(issue.severity, Severity::Error);
5610 assert!(narrate(&issue).contains("declared 5"));
5611 assert!(narrate(&issue).contains("actual 3"));
5612 }
5613
5614 #[test]
5615 fn test_unt_count_excludes_envelope() {
5616 let segments = vec![
5618 make_segment("UNA", vec![]),
5619 make_segment("UNB", vec![vec!["UNOC", "3"]]),
5620 make_segment("UNH", vec![vec!["001"]]),
5621 make_segment("BGM", vec![vec!["E01"]]),
5622 make_segment("UNT", vec![vec!["3", "001"]]),
5623 make_segment("UNZ", vec![vec!["1"]]),
5624 ];
5625 assert!(
5626 validate_unt_segment_count(&segments).is_none(),
5627 "Envelope segments excluded — count should be 3 (UNH+BGM+UNT)"
5628 );
5629 }
5630
5631 #[test]
5632 fn test_unt_count_no_unt_returns_none() {
5633 let segments = vec![
5634 make_segment("UNH", vec![vec!["001"]]),
5635 make_segment("BGM", vec![vec!["E01"]]),
5636 ];
5637 assert!(
5638 validate_unt_segment_count(&segments).is_none(),
5639 "No UNT segment should return None (not our problem)"
5640 );
5641 }
5642
5643 #[test]
5644 fn test_unt_count_rejects_multi_message_input() {
5645 let segments = vec![
5647 make_segment("UNH", vec![vec!["001"]]),
5648 make_segment("BGM", vec![vec!["E01"]]),
5649 make_segment("UNT", vec![vec!["3", "001"]]),
5650 make_segment("UNH", vec![vec!["002"]]),
5651 make_segment("BGM", vec![vec!["E02"]]),
5652 make_segment("UNT", vec![vec!["3", "002"]]),
5653 ];
5654 let issue = validate_unt_segment_count(&segments)
5655 .expect("Multi-message input should produce an error");
5656 assert_eq!(issue.code(), ErrorCodes::UNT_SEGMENT_COUNT_MISMATCH);
5657 assert!(
5658 narrate(&issue).contains("2 UNH"),
5659 "Should mention UNH count: {}",
5660 narrate(&issue)
5661 );
5662 }
5663
5664 #[test]
5665 fn test_code_validation_accepts_multi_code_variant_qualifier() {
5666 let evaluator = MockEvaluator::new(vec![]);
5673 let validator = EdifactValidator::new(evaluator);
5674 let external = NoOpExternalProvider;
5675
5676 let rff_z39 = OwnedSegment {
5677 id: "RFF".to_string(),
5678 elements: vec![
5679 vec!["RFF".to_string()],
5680 vec!["Z39".to_string(), "REF1".to_string()],
5681 ],
5682 segment_number: 1,
5683 };
5684
5685 let workflow = AhbWorkflow {
5686 pruefidentifikator: "55035".to_string(),
5687 description: "Test".to_string(),
5688 communication_direction: None,
5689 fields: vec![
5690 AhbFieldRule {
5692 segment_path: "SG4/SG8/RFF/C506/1153".to_string(),
5693 name: "Referenznummer Qualifier".to_string(),
5694 ahb_status: "Muss".to_string(),
5695 codes: vec![
5696 AhbCodeRule {
5697 value: "Z31".to_string(),
5698 description: "".to_string(),
5699 ahb_status: "X".to_string(),
5700 },
5701 AhbCodeRule {
5702 value: "Z39".to_string(),
5703 description: "".to_string(),
5704 ahb_status: "X".to_string(),
5705 },
5706 ],
5707 parent_group_ahb_status: None,
5708 segment_ahb_status: None,
5709 element_index: Some(1),
5710 component_index: Some(0),
5711 mig_number: Some("00075".to_string()),
5712 format: None,
5713 },
5714 AhbFieldRule {
5716 segment_path: "SG4/SG8/RFF/C506/1153".to_string(),
5717 name: "Referenznummer Qualifier".to_string(),
5718 ahb_status: "Muss".to_string(),
5719 codes: vec![AhbCodeRule {
5720 value: "Z33".to_string(),
5721 description: "".to_string(),
5722 ahb_status: "X".to_string(),
5723 }],
5724 parent_group_ahb_status: None,
5725 segment_ahb_status: None,
5726 element_index: Some(1),
5727 component_index: Some(0),
5728 mig_number: Some("00078".to_string()),
5729 format: None,
5730 },
5731 ],
5732 ub_definitions: BTreeMap::new(),
5733 package_conditions: Default::default(),
5734 };
5735
5736 let report = validator.validate(
5737 &[rff_z39],
5738 &workflow,
5739 &external,
5740 ValidationLevel::Conditions,
5741 );
5742
5743 let code_errors: Vec<_> = report
5744 .by_category(ValidationCategory::Code)
5745 .filter(|i| {
5746 i.severity == Severity::Error && i.code() == ErrorCodes::CODE_NOT_ALLOWED_FOR_PID
5747 })
5748 .collect();
5749 assert!(
5750 code_errors.is_empty(),
5751 "RFF+Z39 must be accepted (Z39 is valid for mig=00075). Got: {:?}",
5752 code_errors
5753 );
5754 }
5755
5756 #[test]
5757 fn test_code_validation_disambiguates_migs_by_full_code_profile() {
5758 let evaluator = MockEvaluator::new(vec![]);
5764 let validator = EdifactValidator::new(evaluator);
5765 let external = NoOpExternalProvider;
5766
5767 let pia_5_z12 = OwnedSegment {
5771 id: "PIA".to_string(),
5772 elements: vec![vec!["5".to_string()], vec!["Z12".to_string()]],
5773 segment_number: 1,
5774 };
5775 let pia_5_srw = OwnedSegment {
5776 id: "PIA".to_string(),
5777 elements: vec![vec!["5".to_string()], vec!["SRW".to_string()]],
5778 segment_number: 2,
5779 };
5780
5781 let make_rules = |mig: &str, composite_code: &str| {
5782 vec![
5783 AhbFieldRule {
5784 segment_path: "SG4/SG8/PIA/4347".to_string(),
5785 name: "Produkt-ID-Funktion".to_string(),
5786 ahb_status: "Muss".to_string(),
5787 codes: vec![AhbCodeRule {
5788 value: "5".to_string(),
5789 description: "".to_string(),
5790 ahb_status: "X".to_string(),
5791 }],
5792 parent_group_ahb_status: None,
5793 segment_ahb_status: None,
5794 element_index: Some(0),
5795 component_index: Some(0),
5796 mig_number: Some(mig.to_string()),
5797 format: None,
5798 },
5799 AhbFieldRule {
5800 segment_path: "SG4/SG8/PIA/C212/7143".to_string(),
5801 name: "Artikel/Dienstleistung-Identifikator".to_string(),
5802 ahb_status: "Muss".to_string(),
5803 codes: vec![AhbCodeRule {
5804 value: composite_code.to_string(),
5805 description: "".to_string(),
5806 ahb_status: "X".to_string(),
5807 }],
5808 parent_group_ahb_status: None,
5809 segment_ahb_status: None,
5810 element_index: Some(1),
5811 component_index: Some(0),
5812 mig_number: Some(mig.to_string()),
5813 format: None,
5814 },
5815 ]
5816 };
5817
5818 let mut fields = make_rules("00108", "Z12");
5819 fields.extend(make_rules("00197", "SRW"));
5820
5821 let workflow = AhbWorkflow {
5822 pruefidentifikator: "55035".to_string(),
5823 description: "Test".to_string(),
5824 communication_direction: None,
5825 fields,
5826 ub_definitions: BTreeMap::new(),
5827 package_conditions: Default::default(),
5828 };
5829
5830 let report = validator.validate(
5831 &[pia_5_z12, pia_5_srw],
5832 &workflow,
5833 &external,
5834 ValidationLevel::Conditions,
5835 );
5836
5837 let code_errors: Vec<_> = report
5838 .by_category(ValidationCategory::Code)
5839 .filter(|i| {
5840 i.severity == Severity::Error && i.code() == ErrorCodes::CODE_NOT_ALLOWED_FOR_PID
5841 })
5842 .collect();
5843 assert!(
5844 code_errors.is_empty(),
5845 "Both PIA+5+Z12 (mig=00108) and PIA+5+SRW (mig=00197) must be accepted. Got: {:?}",
5846 code_errors
5847 );
5848 }
5849}