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>(
1494 field: &AhbFieldRule,
1495 expr_eval: &ConditionExprEvaluator<E>,
1496 ctx: &EvaluationContext,
1497 ub_definitions: &BTreeMap<String, ConditionExpr>,
1498) -> bool {
1499 let Some(group_status) = field.parent_group_ahb_status.as_deref() else {
1500 return false;
1501 };
1502 if !group_status.contains('[') {
1503 return false;
1504 }
1505 match ConditionParser::parse_status_branches(group_status, ub_definitions) {
1506 Ok(branches) => !branches.iter().any(|branch| {
1507 branch.condition.as_ref().map_or(true, |c| {
1508 expr_eval.evaluate(c, ctx) == ConditionResult::True
1509 })
1510 }),
1511 Err(_) => true,
1512 }
1513}
1514
1515fn is_parent_group_required<E: ConditionEvaluator>(
1522 field: &AhbFieldRule,
1523 expr_eval: &ConditionExprEvaluator<E>,
1524 ctx: &EvaluationContext,
1525 ub_definitions: &BTreeMap<String, ConditionExpr>,
1526) -> bool {
1527 field
1528 .parent_group_ahb_status
1529 .as_deref()
1530 .is_some_and(|status| {
1531 let status = expr_eval.resolve_status(status, ctx, ub_definitions);
1532 status.result == ConditionResult::True && status.kind == Some(StatusKind::Muss)
1533 })
1534}
1535
1536fn is_field_present(ctx: &EvaluationContext, field: &AhbFieldRule) -> bool {
1545 let segment_id = extract_segment_id(&field.segment_path);
1546
1547 if !field.codes.is_empty() {
1553 if let (Some(el_idx), Some(comp_idx)) = (field.element_index, field.component_index) {
1554 let required_codes: Vec<&str> = field.codes.iter().map(|c| c.value.as_str()).collect();
1555 let matching = ctx.find_segments(&segment_id);
1556 return matching.iter().any(|seg| {
1557 seg.elements
1558 .get(el_idx)
1559 .and_then(|e| e.get(comp_idx))
1560 .is_some_and(|v| required_codes.contains(&v.as_str()))
1561 });
1562 }
1563 if is_qualifier_field(&field.segment_path) {
1566 let required_codes: Vec<&str> = field.codes.iter().map(|c| c.value.as_str()).collect();
1567 let el_idx = field.element_index.unwrap_or(0);
1568 let comp_idx = field.component_index.unwrap_or(0);
1569 let matching = ctx.find_segments(&segment_id);
1570 return matching.iter().any(|seg| {
1571 seg.elements
1572 .get(el_idx)
1573 .and_then(|e| e.get(comp_idx))
1574 .is_some_and(|v| required_codes.contains(&v.as_str()))
1575 });
1576 }
1577 }
1578
1579 ctx.has_segment(&segment_id)
1580}
1581
1582fn is_group_variant_absent(
1597 ctx: &EvaluationContext,
1598 field: &AhbFieldRule,
1599 is_group_mandatory: bool,
1600) -> bool {
1601 let group_path: Vec<&str> = field
1602 .segment_path
1603 .split('/')
1604 .take_while(|p| p.starts_with("SG"))
1605 .collect();
1606
1607 if group_path.is_empty() {
1608 return false;
1609 }
1610
1611 let nav = match ctx.navigator {
1612 Some(nav) => nav,
1613 None => return false,
1614 };
1615
1616 let instance_count = nav.group_instance_count(&group_path);
1617
1618 if instance_count == 0 {
1623 if !is_group_mandatory {
1624 return true;
1625 }
1626 return false;
1628 }
1629
1630 if field.parent_group_ahb_status.is_some() && !is_group_mandatory {
1635 if !field.codes.is_empty() && is_qualifier_field(&field.segment_path) {
1637 let segment_id = extract_segment_id(&field.segment_path);
1638 let required_codes: Vec<&str> = field.codes.iter().map(|c| c.value.as_str()).collect();
1639
1640 let any_instance_has_qualifier = (0..instance_count).any(|i| {
1641 nav.find_segments_in_group(&segment_id, &group_path, i)
1642 .iter()
1643 .any(|seg| {
1644 seg.elements
1645 .first()
1646 .and_then(|e| e.first())
1647 .is_some_and(|v| required_codes.contains(&v.as_str()))
1648 })
1649 });
1650
1651 if !any_instance_has_qualifier {
1652 return true; }
1654 }
1655 }
1656
1657 let segment_id = extract_segment_id(&field.segment_path);
1666 let segment_absent_from_all = (0..instance_count).all(|i| {
1667 nav.find_segments_in_group(&segment_id, &group_path, i)
1668 .is_empty()
1669 });
1670 if segment_absent_from_all {
1671 let group_has_other_segments =
1672 (0..instance_count).any(|i| nav.has_any_segment_in_group(&group_path, i));
1673 if group_has_other_segments {
1674 return true;
1675 }
1676 }
1677
1678 false
1679}
1680
1681fn collect_nodes_depth_first<'a, 'b>(group: &'b AhbGroupNode<'a>, out: &mut Vec<&'b AhbNode<'a>>) {
1683 out.extend(group.fields.iter());
1684 for child in &group.children {
1685 collect_nodes_depth_first(child, out);
1686 }
1687}
1688
1689#[allow(clippy::too_many_arguments)]
1693fn evaluate_node<E: ConditionEvaluator>(
1694 node: &AhbNode,
1695 ctx: &EvaluationContext,
1696 expr_eval: &ConditionExprEvaluator<E>,
1697 evaluator: &E,
1698 ub_definitions: &BTreeMap<String, ConditionExpr>,
1699 tag_migs: &HashMap<String, HashSet<&str>>,
1700 instance_index: Option<usize>,
1701 slot_holds: bool,
1702 report: &mut ValidationReport,
1703) {
1704 let field = node.rule;
1705
1706 let node_ctx = ctx.with_resolved(node.value, node.segment_elements);
1708
1709 if let (Some(value), Some(max)) = (node.value, field.format.as_deref().and_then(max_length)) {
1714 let length = value_length(field.format.as_deref().unwrap_or_default(), value);
1715 if length > max {
1716 let mut issue = ValidationIssue::new(
1717 Severity::Error,
1718 IssueKind::ValueTooLong {
1719 field_name: field.name.clone(),
1720 length,
1721 max,
1722 },
1723 )
1724 .with_field_path(&field.segment_path)
1725 .with_actual(value)
1726 .with_expected(field.format.clone().unwrap_or_default());
1727 if let Some(idx) = instance_index {
1728 issue = issue.with_instance_index(idx);
1729 }
1730 if let Some(num) = node.matched_segment_number {
1731 issue = issue.with_position(crate::SegmentPosition {
1732 segment_number: num,
1733 byte_offset: 0,
1734 message_number: 1,
1735 });
1736 }
1737 report.add_issue(attach_field_position(issue, field));
1738 }
1739 }
1740
1741 if let (Some(value), Some(format)) = (node.value, field.format.as_deref()) {
1744 if slot_holds
1745 && format.trim().starts_with('n')
1746 && field.codes.is_empty()
1747 && !value.is_empty()
1748 && !crate::eval::format_validators::is_edifact_number(value)
1749 {
1750 let mut issue = ValidationIssue::new(
1751 Severity::Error,
1752 IssueKind::ValueNotNumeric {
1753 field_name: field.name.clone(),
1754 },
1755 )
1756 .with_field_path(&field.segment_path)
1757 .with_actual(value)
1758 .with_expected(format);
1759 if let Some(idx) = instance_index {
1760 issue = issue.with_instance_index(idx);
1761 }
1762 if let Some(num) = node.matched_segment_number {
1763 issue = issue.with_position(crate::SegmentPosition {
1764 segment_number: num,
1765 byte_offset: 0,
1766 message_number: 1,
1767 });
1768 }
1769 report.add_issue(attach_field_position(issue, field));
1770 }
1771 }
1772
1773 if should_skip_for_parent_group(field, expr_eval, ctx, ub_definitions) {
1775 return;
1776 }
1777
1778 let status = governing_status(field, node.value);
1781 let StatusResolution {
1782 result: condition_result,
1783 kind,
1784 unknown_ids,
1785 } = expr_eval.resolve_status(status, &node_ctx, ub_definitions);
1786 let mandatory = kind == Some(StatusKind::Muss);
1787
1788 match condition_result {
1789 ConditionResult::True => {
1790 if mandatory && node.value.is_none() {
1799 let tag = extract_segment_id(&field.segment_path);
1800 let single_variant_present = node.segment_elements.is_none()
1801 && tag_migs.get(&tag).is_some_and(|ms| ms.len() == 1)
1802 && is_field_present(ctx, field);
1803 let segment_optional_and_absent = node.segment_elements.is_none()
1808 && field
1809 .segment_ahb_status
1810 .as_deref()
1811 .is_some_and(is_optional_segment_status);
1812 let segment_not_required_and_absent = node.segment_elements.is_none()
1819 && field.segment_ahb_status.as_deref().is_some_and(|st| {
1820 let status = expr_eval.resolve_status(st, &node_ctx, ub_definitions);
1821 status.result != ConditionResult::True
1822 || status.kind != Some(StatusKind::Muss)
1823 });
1824 if !single_variant_present
1825 && !segment_optional_and_absent
1826 && !segment_not_required_and_absent
1827 {
1828 let mut issue = ValidationIssue::new(
1829 Severity::Error,
1830 IssueKind::MissingRequiredField {
1831 field_name: field.name.clone(),
1832 },
1833 )
1834 .with_field_path(&field.segment_path)
1835 .with_rule(&field.ahb_status);
1836 if let Some(first_code) = field.codes.first() {
1837 issue.expected_value = Some(first_code.value.clone());
1838 }
1839 if let Some(idx) = instance_index {
1840 issue = issue.with_instance_index(idx);
1841 }
1842 if let Some(num) = node.matched_segment_number {
1843 issue = issue.with_position(crate::SegmentPosition {
1844 segment_number: num,
1845 byte_offset: 0,
1846 message_number: 1,
1847 });
1848 }
1849 issue = attach_field_position(issue, field);
1850 report.add_issue(issue);
1851 }
1852 }
1853 }
1854 ConditionResult::False => {
1855 if mandatory && node.value.is_some() {
1858 let mut issue = ValidationIssue::new(
1859 Severity::Error,
1860 IssueKind::FieldConditionNotSatisfied {
1861 field_name: field.name.clone(),
1862 },
1863 )
1864 .with_field_path(&field.segment_path)
1865 .with_rule(&field.ahb_status);
1866 if let Some(idx) = instance_index {
1867 issue = issue.with_instance_index(idx);
1868 }
1869 if let Some(num) = node.matched_segment_number {
1870 issue = issue.with_position(crate::SegmentPosition {
1871 segment_number: num,
1872 byte_offset: 0,
1873 message_number: 1,
1874 });
1875 }
1876 issue = attach_field_position(issue, field);
1877 report.add_issue(issue);
1878 }
1879 }
1880 ConditionResult::Unknown => {
1881 let mut external_ids = Vec::new();
1883 let mut undetermined_ids = Vec::new();
1884 let mut missing_ids = Vec::new();
1885 for id in unknown_ids {
1886 if evaluator.is_external(id) {
1887 external_ids.push(id);
1888 } else if evaluator.is_known(id) {
1889 undetermined_ids.push(id);
1890 } else {
1891 missing_ids.push(id);
1892 }
1893 }
1894
1895 let mut issue = ValidationIssue::new(
1896 Severity::Info,
1897 IssueKind::ConditionUnknown {
1898 field_name: field.name.clone(),
1899 unresolved: UnresolvedConditions {
1900 external: external_ids,
1901 undetermined: undetermined_ids,
1902 missing: missing_ids,
1903 },
1904 },
1905 )
1906 .with_field_path(&field.segment_path)
1907 .with_rule(&field.ahb_status);
1908 if let Some(idx) = instance_index {
1909 issue = issue.with_instance_index(idx);
1910 }
1911 if let Some(num) = node.matched_segment_number {
1912 issue = issue.with_position(crate::SegmentPosition {
1913 segment_number: num,
1914 byte_offset: 0,
1915 message_number: 1,
1916 });
1917 }
1918 issue = attach_field_position(issue, field);
1919 report.add_issue(issue);
1920 }
1921 }
1922}
1923
1924fn attach_field_position(issue: ValidationIssue, field: &AhbFieldRule) -> ValidationIssue {
1929 match field.element_index {
1930 Some(el) => {
1931 let element_pos = (el as u32) + 2;
1932 let component_pos = field.component_index.map(|c| (c as u32) + 1);
1933 issue.with_field_position(element_pos, component_pos)
1934 }
1935 None => issue,
1936 }
1937}
1938
1939fn report_repeated_in_transaction(
1946 transaction: &AhbGroupNode,
1947 transaction_index: usize,
1948 once: u32,
1949 ctx: &EvaluationContext,
1950 report: &mut ValidationReport,
1951) {
1952 let mut counts: BTreeMap<(String, String), (usize, String)> = BTreeMap::new();
1954 fn visit<'a>(
1955 node: &'a AhbGroupNode<'a>,
1956 path: &mut Vec<(&'a str, usize)>,
1957 once: u32,
1958 ctx: &EvaluationContext,
1959 counts: &mut BTreeMap<(String, String), (usize, String)>,
1960 ) {
1961 let ids: Vec<&str> = path.iter().map(|(g, _)| *g).collect();
1962 let mut seen: HashSet<Option<&str>> = HashSet::new();
1963 for n in &node.fields {
1964 let rule = n.rule;
1965 let Some(status) = rule.segment_ahb_status.as_deref() else {
1966 continue;
1967 };
1968 if Some(status) == node.ahb_status
1969 || rule.element_index.unwrap_or(0) != 0
1970 || rule.component_index.unwrap_or(0) != 0
1971 || rule.codes.is_empty()
1972 || !crate::eval::binds_once(status, once)
1973 || !seen.insert(rule.mig_number.as_deref())
1974 {
1975 continue;
1976 }
1977 let tag = extract_segment_id(&rule.segment_path);
1978 let sent = ctx.navigator().map_or(0, |nav| {
1979 nav.find_segments_in_instance(&tag, path)
1980 .iter()
1981 .filter(|s| rule.codes.iter().any(|c| c.value == s.get_component(0, 0)))
1982 .count()
1983 });
1984 let entry = counts
1985 .entry((
1986 format!("{}/{tag}", ids.join("/")),
1987 rule.mig_number.clone().unwrap_or_default(),
1988 ))
1989 .or_insert((0, status.to_string()));
1990 entry.0 += sent;
1991 }
1992 for child in &node.children {
1993 if let Some(status) = child
1994 .ahb_status
1995 .filter(|s| crate::eval::binds_once(s, once))
1996 {
1997 let variant = child
1998 .fields
1999 .first()
2000 .and_then(|n| n.rule.mig_number.clone())
2001 .unwrap_or_default();
2002 let entry = counts
2003 .entry((format!("{}/{}", ids.join("/"), child.group_id), variant))
2004 .or_insert((0, status.to_string()));
2005 entry.0 += 1;
2006 }
2007 path.push((child.group_id, child.instance_index));
2008 visit(child, path, once, ctx, counts);
2009 path.pop();
2010 }
2011 }
2012 let mut path = vec![(transaction.group_id, transaction_index)];
2013 visit(transaction, &mut path, once, ctx, &mut counts);
2014 for ((path, _), (count, status)) in counts {
2015 if count > 1 {
2016 report.add_issue(
2017 ValidationIssue::new(
2018 Severity::Error,
2019 IssueKind::RepeatedInTransaction {
2020 path: path.clone(),
2021 count,
2022 },
2023 )
2024 .with_field_path(path)
2025 .with_rule(status)
2026 .with_instance_index(transaction_index),
2027 );
2028 }
2029 }
2030}
2031
2032#[allow(clippy::too_many_arguments)]
2039fn refuse_segments<E: ConditionEvaluator>(
2040 fields: &[AhbNode],
2041 group_status: Option<&str>,
2042 path: &[(&str, usize)],
2043 scoped_ctx: &EvaluationContext,
2044 evaluator: &E,
2045 ub_definitions: &BTreeMap<String, ConditionExpr>,
2046 instance_index: usize,
2047 report: &mut ValidationReport,
2048) {
2049 let mut seen: Vec<u32> = Vec::new();
2050 for node in fields {
2051 let rule = node.rule;
2052 let (Some(status), Some(value), Some(number)) = (
2053 rule.segment_ahb_status.as_deref(),
2054 node.value,
2055 node.matched_segment_number,
2056 ) else {
2057 continue;
2058 };
2059 let qualifier =
2062 rule.element_index.unwrap_or(0) == 0 && rule.component_index.unwrap_or(0) == 0;
2063 if !qualifier
2064 || seen.contains(&number)
2065 || Some(status) == group_status
2066 || !rule.codes.iter().any(|c| c.value == value)
2067 {
2068 continue;
2069 }
2070 seen.push(number);
2071 if !crate::eval::refuses_presence(status, evaluator, scoped_ctx, ub_definitions) {
2072 continue;
2073 }
2074 let mut segment: Vec<&str> = path.iter().map(|(g, _)| *g).collect();
2075 segment.extend(rule.segment_path.split('/').find(|p| !p.starts_with("SG")));
2076 report.add_issue(
2077 ValidationIssue::new(
2078 Severity::Warning,
2079 IssueKind::SegmentNotAllowed {
2080 segment: segment.join("/"),
2081 },
2082 )
2083 .with_field_path(segment.join("/"))
2084 .with_rule(status)
2085 .with_instance_index(instance_index)
2086 .with_position(crate::SegmentPosition {
2087 segment_number: number,
2088 byte_offset: 0,
2089 message_number: 1,
2090 }),
2091 );
2092 }
2093}
2094
2095#[allow(clippy::too_many_arguments)]
2104fn walk_group_instance<E: ConditionEvaluator>(
2105 group: &AhbGroupNode,
2106 path: &[(&str, usize)],
2107 scoped_ctx: &EvaluationContext,
2108 expr_eval: &ConditionExprEvaluator<E>,
2109 evaluator: &E,
2110 ub_definitions: &BTreeMap<String, ConditionExpr>,
2111 tag_migs: &HashMap<String, HashSet<&str>>,
2112 instance_index: usize,
2113 report_segments: bool,
2114 report: &mut ValidationReport,
2115) {
2116 let mut group_refused = false;
2119 if let Some(status) = group.ahb_status {
2120 if crate::eval::refuses_presence(status, evaluator, scoped_ctx, ub_definitions) {
2121 group_refused = true;
2122 let group_path: Vec<&str> = path.iter().map(|(g, _)| *g).collect();
2123 let mut issue = ValidationIssue::new(
2124 Severity::Warning,
2125 IssueKind::GroupNotAllowed {
2126 group: group_path.join("/"),
2127 },
2128 )
2129 .with_field_path(group_path.join("/"))
2130 .with_rule(status)
2131 .with_instance_index(instance_index);
2132 if let Some(num) = group.fields.iter().find_map(|n| n.matched_segment_number) {
2133 issue = issue.with_position(crate::SegmentPosition {
2134 segment_number: num,
2135 byte_offset: 0,
2136 message_number: 1,
2137 });
2138 }
2139 report.add_issue(issue);
2140 }
2141 }
2142 if report_segments && !group_refused {
2143 refuse_segments(
2144 &group.fields,
2145 group.ahb_status,
2146 path,
2147 scoped_ctx,
2148 evaluator,
2149 ub_definitions,
2150 instance_index,
2151 report,
2152 );
2153 }
2154 let slot_codes: HashMap<Option<&str>, &[AhbCodeRule]> = group
2158 .fields
2159 .iter()
2160 .filter(|n| {
2161 n.rule.element_index.unwrap_or(0) == 0
2162 && n.rule.component_index.unwrap_or(0) == 0
2163 && !n.rule.codes.is_empty()
2164 })
2165 .map(|n| (n.rule.mig_number.as_deref(), n.rule.codes.as_slice()))
2166 .collect();
2167 let holds = |node: &AhbNode| {
2168 let (Some(codes), Some(elements)) = (
2169 slot_codes.get(&node.rule.mig_number.as_deref()),
2170 node.segment_elements,
2171 ) else {
2172 return true;
2173 };
2174 let qualifier = elements
2175 .first()
2176 .and_then(|e| e.first())
2177 .map(String::as_str)
2178 .unwrap_or_default();
2179 codes.iter().any(|c| c.value == qualifier)
2180 };
2181 for node in &group.fields {
2182 evaluate_node(
2183 node,
2184 scoped_ctx,
2185 expr_eval,
2186 evaluator,
2187 ub_definitions,
2188 tag_migs,
2189 Some(instance_index),
2190 holds(node),
2191 report,
2192 );
2193 }
2194 for child in &group.children {
2195 let mut child_path = path.to_vec();
2196 child_path.push((child.group_id, child.instance_index));
2197 let child_ctx = scoped_ctx.with_scope(GroupScope { path: &child_path });
2198 walk_group_instance(
2199 child,
2200 &child_path,
2201 &child_ctx,
2202 expr_eval,
2203 evaluator,
2204 ub_definitions,
2205 tag_migs,
2206 instance_index,
2207 report_segments,
2208 report,
2209 );
2210 }
2211}
2212
2213#[allow(clippy::too_many_arguments)]
2223fn report_absent_children<E: ConditionEvaluator>(
2224 group: &AhbGroupNode,
2225 path: &[(&str, usize)],
2226 scoped_ctx: &EvaluationContext,
2227 absent_eval: &ConditionExprEvaluator<crate::eval::AbsentTarget<'_, E>>,
2228 evaluator: &E,
2229 ub_definitions: &BTreeMap<String, ConditionExpr>,
2230 instance_index: usize,
2231 report: &mut ValidationReport,
2232) {
2233 if group.ahb_status.is_some_and(|status| {
2234 crate::eval::refuses_presence(status, evaluator, scoped_ctx, ub_definitions)
2235 }) {
2236 return;
2237 }
2238 for rule in &group.absent_children {
2239 if should_skip_for_parent_group(rule, absent_eval, scoped_ctx, ub_definitions)
2240 || !is_parent_group_required(rule, absent_eval, scoped_ctx, ub_definitions)
2241 {
2242 continue;
2243 }
2244 let required = |status: &str| {
2245 let resolved = absent_eval.resolve_status(status, scoped_ctx, ub_definitions);
2246 resolved.result == ConditionResult::True && resolved.kind == Some(StatusKind::Muss)
2247 };
2248 if !required(&rule.ahb_status)
2249 || rule
2250 .segment_ahb_status
2251 .as_deref()
2252 .is_some_and(|status| !required(status))
2253 {
2254 continue;
2255 }
2256 let mut issue = ValidationIssue::new(
2257 Severity::Error,
2258 IssueKind::MissingRequiredField {
2259 field_name: rule.name.clone(),
2260 },
2261 )
2262 .with_field_path(&rule.segment_path)
2263 .with_rule(&rule.ahb_status)
2264 .with_instance_index(instance_index);
2265 if let Some(first_code) = rule.codes.first() {
2266 issue.expected_value = Some(first_code.value.clone());
2267 }
2268 report.add_issue(attach_field_position(issue, rule));
2269 }
2270 for child in &group.children {
2271 let mut child_path = path.to_vec();
2272 child_path.push((child.group_id, child.instance_index));
2273 let child_ctx = scoped_ctx.with_scope(GroupScope { path: &child_path });
2274 report_absent_children(
2275 child,
2276 &child_path,
2277 &child_ctx,
2278 absent_eval,
2279 evaluator,
2280 ub_definitions,
2281 instance_index,
2282 report,
2283 );
2284 }
2285}
2286
2287fn collect_packages(expr: &ConditionExpr, out: &mut Vec<(u32, u32, u32)>) {
2288 match expr {
2289 ConditionExpr::Package { id, min, max } => {
2290 out.push((*id, *min, *max));
2291 }
2292 ConditionExpr::And(exprs) | ConditionExpr::Or(exprs) => {
2293 for e in exprs {
2294 collect_packages(e, out);
2295 }
2296 }
2297 ConditionExpr::Xor(left, right) => {
2298 collect_packages(left, out);
2299 collect_packages(right, out);
2300 }
2301 ConditionExpr::Not(inner) => {
2302 collect_packages(inner, out);
2303 }
2304 ConditionExpr::Ref(_) => {}
2305 }
2306}
2307
2308fn is_optional_segment_status(status: &str) -> bool {
2318 let trimmed = status.trim();
2319 trimmed.starts_with("Kann") || trimmed.starts_with("Soll")
2320}
2321
2322fn is_qualifier_field(path: &str) -> bool {
2332 let parts: Vec<&str> = path.split('/').filter(|p| !p.starts_with("SG")).collect();
2333 matches!(parts.len(), 2 | 3)
2335}
2336
2337#[derive(Default)]
2345struct MigCodeBucket<'a> {
2346 qualifier_position: Option<(usize, usize)>,
2349 qualifier_values: HashSet<&'a str>,
2352 codes: HashMap<(usize, usize), HashSet<&'a str>>,
2355}
2356
2357fn partition_codes_by_mig(
2362 workflow: &AhbWorkflow,
2363) -> HashMap<(String, String), HashMap<Option<String>, MigCodeBucket<'_>>> {
2364 let mut out: HashMap<(String, String), HashMap<Option<String>, MigCodeBucket>> = HashMap::new();
2365 for field in &workflow.fields {
2366 if field.codes.is_empty() || !is_qualifier_field(&field.segment_path) {
2367 continue;
2368 }
2369 if codes_are_alternatives(field) {
2372 continue;
2373 }
2374 let tag = extract_segment_id(&field.segment_path);
2375 let group_key = extract_group_path_key(&field.segment_path);
2376 let mig = field.mig_number.clone();
2377 let el = field.element_index.unwrap_or(0);
2378 let c = field.component_index.unwrap_or(0);
2379
2380 let bucket = out
2381 .entry((group_key, tag))
2382 .or_default()
2383 .entry(mig)
2384 .or_default();
2385
2386 let required: Vec<&str> = field
2393 .codes
2394 .iter()
2395 .filter(|code| code.ahb_status.starts_with('X') || code.ahb_status.starts_with("Muss"))
2396 .map(|code| code.value.as_str())
2397 .collect();
2398
2399 if !required.is_empty() {
2406 if bucket.qualifier_position.is_none() {
2407 bucket.qualifier_position = Some((el, c));
2408 }
2409 if bucket.qualifier_position == Some((el, c)) {
2410 bucket.qualifier_values.extend(required.iter().copied());
2411 }
2412 }
2413
2414 for v in required {
2415 bucket.codes.entry((el, c)).or_default().insert(v);
2416 }
2417 }
2418 out
2419}
2420
2421fn global_qualifiers_by_tag(
2427 by_loc: &HashMap<(String, String), HashMap<Option<String>, MigCodeBucket<'_>>>,
2428) -> HashMap<String, HashMap<(usize, usize), HashSet<String>>> {
2429 let mut out: HashMap<String, HashMap<(usize, usize), HashSet<String>>> = HashMap::new();
2430 for ((_group, tag), migs) in by_loc {
2431 let tag_entry = out.entry(tag.clone()).or_default();
2432 for bucket in migs.values() {
2433 if let Some(pos) = bucket.qualifier_position {
2434 tag_entry
2435 .entry(pos)
2436 .or_default()
2437 .extend(bucket.qualifier_values.iter().map(|s| s.to_string()));
2438 }
2439 }
2440 }
2441 out
2442}
2443
2444fn match_segment_to_mig<'a, 'b>(
2455 seg: &OwnedSegment,
2456 migs: &'a HashMap<Option<String>, MigCodeBucket<'b>>,
2457) -> Option<&'a MigCodeBucket<'b>> {
2458 let actual_at = |el: usize, c: usize| -> &str {
2459 seg.elements
2460 .get(el)
2461 .and_then(|e| e.get(c))
2462 .map(|s| s.as_str())
2463 .unwrap_or("")
2464 };
2465
2466 let mut best: Option<&MigCodeBucket> = None;
2467 let mut best_matches = 0usize;
2468
2469 for bucket in migs.values() {
2470 let Some((el, c)) = bucket.qualifier_position else {
2471 continue;
2472 };
2473 if !bucket.qualifier_values.contains(actual_at(el, c)) {
2474 continue;
2475 }
2476 let extra_matches = bucket
2477 .codes
2478 .iter()
2479 .filter(|(pos, _)| **pos != (el, c))
2480 .filter(|((e, k), allowed)| {
2481 let v = actual_at(*e, *k);
2482 !v.is_empty() && allowed.contains(v)
2483 })
2484 .count();
2485 if best.is_none() || extra_matches > best_matches {
2486 best = Some(bucket);
2487 best_matches = extra_matches;
2488 }
2489 }
2490 best
2491}
2492
2493fn extract_group_path_key(path: &str) -> String {
2498 let sg_parts: Vec<&str> = path
2499 .split('/')
2500 .take_while(|p| p.starts_with("SG"))
2501 .collect();
2502 sg_parts.join("/")
2503}
2504
2505fn extract_segment_id(path: &str) -> String {
2507 for part in path.split('/') {
2508 if part.starts_with("SG") || part.starts_with("C_") || part.starts_with("D_") {
2510 continue;
2511 }
2512 if part.len() >= 3
2514 && part
2515 .chars()
2516 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
2517 {
2518 return part.to_string();
2519 }
2520 }
2521 path.split('/').next_back().unwrap_or(path).to_string()
2523}
2524
2525pub fn validate_unt_segment_count(segments: &[OwnedSegment]) -> Option<ValidationIssue> {
2537 let unh_count = segments.iter().filter(|s| s.id == "UNH").count();
2539 if unh_count > 1 {
2540 return Some(ValidationIssue::new(
2541 Severity::Error,
2542 IssueKind::UntCountNotVerifiable { unh_count },
2543 ));
2544 }
2545
2546 let unt = segments.iter().rfind(|s| s.id == "UNT")?;
2548 let declared: usize = unt.get_element(0).parse().ok()?;
2549
2550 let actual = segments
2552 .iter()
2553 .filter(|s| s.id != "UNA" && s.id != "UNB" && s.id != "UNZ")
2554 .count();
2555
2556 if declared != actual {
2557 Some(
2558 ValidationIssue::new(
2559 Severity::Error,
2560 IssueKind::UntSegmentCountMismatch { declared, actual },
2561 )
2562 .with_field_path("UNT/0074")
2563 .with_expected(actual.to_string())
2564 .with_actual(declared.to_string()),
2565 )
2566 } else {
2567 None
2568 }
2569}
2570
2571#[cfg(test)]
2572mod tests {
2573
2574 #[test]
2575 fn a_numeric_length_counts_digits_only() {
2576 assert_eq!(value_length("n..15", "-12.50"), 4);
2577 assert_eq!(value_length("n..6", "JMMHAYJXGIJT"), 12);
2578 assert_eq!(value_length("n5", "55001"), 5);
2579 assert_eq!(value_length("an..3", "Z.1"), 3);
2580 assert_eq!(value_length("an..35", "Müller"), 6);
2581 }
2582 use super::super::codes::ErrorCodes;
2583 use super::super::issue::ValidationCategory;
2584 use super::*;
2585 use crate::display::{EdifactView, IssueNarrator, IssueView, TechnicalNarrator};
2586 use crate::eval::{ConditionResult as CR, NoOpExternalProvider};
2587 use std::collections::HashMap;
2588
2589 fn narrate(issue: &ValidationIssue) -> String {
2592 TechnicalNarrator.describe(issue, EdifactView.location(issue).as_deref())
2593 }
2594
2595 struct MockEvaluator {
2597 results: HashMap<u32, CR>,
2598 }
2599
2600 impl MockEvaluator {
2601 fn new(results: Vec<(u32, CR)>) -> Self {
2602 Self {
2603 results: results.into_iter().collect(),
2604 }
2605 }
2606
2607 fn all_true(ids: &[u32]) -> Self {
2608 Self::new(ids.iter().map(|&id| (id, CR::True)).collect())
2609 }
2610 }
2611
2612 impl ConditionEvaluator for MockEvaluator {
2613 fn evaluate(&self, condition: u32, _ctx: &EvaluationContext) -> CR {
2614 self.results.get(&condition).copied().unwrap_or(CR::Unknown)
2615 }
2616 fn is_external(&self, _condition: u32) -> bool {
2617 false
2618 }
2619 fn message_type(&self) -> &str {
2620 "UTILMD"
2621 }
2622 fn format_version(&self) -> &str {
2623 "FV2510"
2624 }
2625 }
2626
2627 #[test]
2630 fn a_present_code_is_judged_by_its_own_status() {
2631 let code = |value: &str, status: &str| AhbCodeRule {
2633 value: value.to_string(),
2634 description: String::new(),
2635 ahb_status: status.to_string(),
2636 };
2637 let field = AhbFieldRule {
2638 segment_path: "SG4/STS/C556/9013".to_string(),
2639 ahb_status: "X [192]".to_string(),
2640 codes: vec![code("E01", "X [192]"), code("E03", "X")],
2641 ..Default::default()
2642 };
2643 assert_eq!(governing_status(&field, Some("E03")), "X");
2644 assert_eq!(governing_status(&field, Some("E01")), "X [192]");
2645 assert_eq!(governing_status(&field, None), "X [192]");
2646 assert_eq!(governing_status(&field, Some("ZZZ")), "X [192]");
2647
2648 let own = AhbFieldRule {
2650 ahb_status: "X [931]".to_string(),
2651 codes: vec![code("303", "X")],
2652 ..Default::default()
2653 };
2654 assert_eq!(governing_status(&own, Some("303")), "X [931]");
2655 assert!(!codes_are_alternatives(&own));
2656
2657 let alternatives = AhbFieldRule {
2660 ahb_status: "X [153] ∧ [538]".to_string(),
2661 codes: vec![
2662 code("Z74", "X [103] ∧ [537]"),
2663 code("Z75", "X [103] ∧ [537]"),
2664 ],
2665 ..Default::default()
2666 };
2667 assert!(codes_are_alternatives(&alternatives));
2668 assert_eq!(
2669 governing_status(&alternatives, Some("Z74")),
2670 "X [103] ∧ [537]"
2671 );
2672 assert_eq!(
2673 governing_status(&alternatives, Some("A01")),
2674 "X [153] ∧ [538]"
2675 );
2676 assert_eq!(governing_status(&alternatives, None), "X [153] ∧ [538]");
2677
2678 let restricting = AhbFieldRule {
2680 ahb_status: "X [2] ∨ [3]".to_string(),
2681 codes: vec![code("UNH", "X"), code("UNT", "X")],
2682 ..Default::default()
2683 };
2684 assert!(!codes_are_alternatives(&restricting));
2685 assert_eq!(governing_status(&restricting, Some("UNH")), "X [2] ∨ [3]");
2686 }
2687
2688 #[test]
2689 fn test_extract_segment_id_simple() {
2690 assert_eq!(extract_segment_id("NAD"), "NAD");
2691 }
2692
2693 #[test]
2694 fn test_extract_segment_id_with_sg_prefix() {
2695 assert_eq!(extract_segment_id("SG2/NAD/C082/3039"), "NAD");
2696 }
2697
2698 #[test]
2699 fn test_extract_segment_id_nested_sg() {
2700 assert_eq!(extract_segment_id("SG4/SG8/SEQ/C286/6350"), "SEQ");
2701 }
2702
2703 #[test]
2706 fn test_validate_missing_mandatory_field() {
2707 let evaluator = MockEvaluator::all_true(&[182, 152]);
2708 let validator = EdifactValidator::new(evaluator);
2709 let external = NoOpExternalProvider;
2710
2711 let workflow = AhbWorkflow {
2712 pruefidentifikator: "11001".to_string(),
2713 description: "Test".to_string(),
2714 communication_direction: None,
2715 fields: vec![AhbFieldRule {
2716 segment_path: "SG2/NAD/C082/3039".to_string(),
2717 name: "MP-ID des MSB".to_string(),
2718 ahb_status: "Muss [182] ∧ [152]".to_string(),
2719 codes: vec![],
2720 parent_group_ahb_status: None,
2721 segment_ahb_status: None,
2722 ..Default::default()
2723 }],
2724 ub_definitions: BTreeMap::new(),
2725 package_conditions: Default::default(),
2726 };
2727
2728 let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
2730
2731 assert!(!report.is_valid());
2733 let errors: Vec<_> = report.errors().collect();
2734 assert_eq!(errors.len(), 1);
2735 assert_eq!(errors[0].code(), ErrorCodes::MISSING_REQUIRED_FIELD);
2736 assert!(narrate(errors[0]).contains("MP-ID des MSB"));
2737 }
2738
2739 #[test]
2740 fn test_validate_condition_false_no_error() {
2741 let evaluator = MockEvaluator::new(vec![(182, CR::True), (152, CR::False)]);
2743 let validator = EdifactValidator::new(evaluator);
2744 let external = NoOpExternalProvider;
2745
2746 let workflow = AhbWorkflow {
2747 pruefidentifikator: "11001".to_string(),
2748 description: "Test".to_string(),
2749 communication_direction: None,
2750 fields: vec![AhbFieldRule {
2751 segment_path: "NAD".to_string(),
2752 name: "Partnerrolle".to_string(),
2753 ahb_status: "Muss [182] ∧ [152]".to_string(),
2754 codes: vec![],
2755 parent_group_ahb_status: None,
2756 segment_ahb_status: None,
2757 ..Default::default()
2758 }],
2759 ub_definitions: BTreeMap::new(),
2760 package_conditions: Default::default(),
2761 };
2762
2763 let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
2764
2765 assert!(report.is_valid());
2767 }
2768
2769 fn validate_absent_field(status: &str, conditions: Vec<(u32, CR)>) -> ValidationReport {
2771 let validator = EdifactValidator::new(MockEvaluator::new(conditions));
2772 let workflow = AhbWorkflow {
2773 pruefidentifikator: "11001".to_string(),
2774 description: "Test".to_string(),
2775 communication_direction: None,
2776 fields: vec![AhbFieldRule {
2777 segment_path: "SG2/NAD/C082/3039".to_string(),
2778 name: "MP-ID".to_string(),
2779 ahb_status: status.to_string(),
2780 ..Default::default()
2781 }],
2782 ub_definitions: BTreeMap::new(),
2783 package_conditions: Default::default(),
2784 };
2785 validator.validate(
2786 &[],
2787 &workflow,
2788 &NoOpExternalProvider,
2789 ValidationLevel::Conditions,
2790 )
2791 }
2792
2793 #[test]
2794 fn a_later_soll_line_alone_does_not_require_the_field() {
2795 let report = validate_absent_field(
2797 "Muss [2119] \r\nSoll [130]",
2798 vec![(2119, CR::False), (130, CR::True)],
2799 );
2800 assert!(
2801 report.is_valid(),
2802 "{:?}",
2803 report.errors().collect::<Vec<_>>()
2804 );
2805 }
2806
2807 #[test]
2808 fn a_later_muss_line_requires_the_field() {
2809 let report = validate_absent_field(
2811 "Soll [47]\r\nMuss [46]",
2812 vec![(47, CR::False), (46, CR::True)],
2813 );
2814 let errors: Vec<_> = report.errors().collect();
2815 assert_eq!(errors.len(), 1);
2816 assert_eq!(errors[0].code(), ErrorCodes::MISSING_REQUIRED_FIELD);
2817 }
2818
2819 #[test]
2820 fn a_weaker_unknown_line_does_not_stop_a_muss() {
2821 let report = validate_absent_field("Muss [2119]\nSoll [130]", vec![(2119, CR::True)]);
2823 let errors: Vec<_> = report.errors().collect();
2824 assert_eq!(errors.len(), 1);
2825 assert_eq!(errors[0].code(), ErrorCodes::MISSING_REQUIRED_FIELD);
2826 }
2827
2828 #[test]
2829 fn a_stricter_unknown_line_leaves_the_status_undecided() {
2830 let report = validate_absent_field("Muss [2119]\nSoll [130]", vec![(130, CR::True)]);
2831 assert!(report.is_valid());
2832 let infos: Vec<_> = report.infos().collect();
2833 assert_eq!(infos.len(), 1, "{infos:?}");
2834 match &infos[0].kind {
2835 IssueKind::ConditionUnknown { unresolved, .. } => {
2836 assert_eq!(unresolved.missing, vec![2119]);
2837 }
2838 other => panic!("expected ConditionUnknown, got {other:?}"),
2839 }
2840 }
2841
2842 #[test]
2843 fn test_validate_condition_unknown_adds_info() {
2844 let evaluator = MockEvaluator::new(vec![(182, CR::True)]);
2846 let validator = EdifactValidator::new(evaluator);
2848 let external = NoOpExternalProvider;
2849
2850 let workflow = AhbWorkflow {
2851 pruefidentifikator: "11001".to_string(),
2852 description: "Test".to_string(),
2853 communication_direction: None,
2854 fields: vec![AhbFieldRule {
2855 segment_path: "NAD".to_string(),
2856 name: "Partnerrolle".to_string(),
2857 ahb_status: "Muss [182] ∧ [152]".to_string(),
2858 codes: vec![],
2859 parent_group_ahb_status: None,
2860 segment_ahb_status: None,
2861 ..Default::default()
2862 }],
2863 ub_definitions: BTreeMap::new(),
2864 package_conditions: Default::default(),
2865 };
2866
2867 let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
2868
2869 assert!(report.is_valid());
2871 let infos: Vec<_> = report.infos().collect();
2872 assert_eq!(infos.len(), 1);
2873 assert_eq!(infos[0].code(), ErrorCodes::CONDITION_UNKNOWN);
2874 }
2875
2876 #[test]
2877 fn test_validate_structure_level_skips_conditions() {
2878 let evaluator = MockEvaluator::all_true(&[182, 152]);
2879 let validator = EdifactValidator::new(evaluator);
2880 let external = NoOpExternalProvider;
2881
2882 let workflow = AhbWorkflow {
2883 pruefidentifikator: "11001".to_string(),
2884 description: "Test".to_string(),
2885 communication_direction: None,
2886 fields: vec![AhbFieldRule {
2887 segment_path: "NAD".to_string(),
2888 name: "Partnerrolle".to_string(),
2889 ahb_status: "Muss [182] ∧ [152]".to_string(),
2890 codes: vec![],
2891 parent_group_ahb_status: None,
2892 segment_ahb_status: None,
2893 ..Default::default()
2894 }],
2895 ub_definitions: BTreeMap::new(),
2896 package_conditions: Default::default(),
2897 };
2898
2899 let report = validator.validate(&[], &workflow, &external, ValidationLevel::Structure);
2901
2902 assert!(report.is_valid());
2904 assert_eq!(report.by_category(ValidationCategory::Ahb).count(), 0);
2905 }
2906
2907 #[test]
2908 fn test_validate_empty_workflow_no_condition_errors() {
2909 let evaluator = MockEvaluator::all_true(&[]);
2910 let validator = EdifactValidator::new(evaluator);
2911 let external = NoOpExternalProvider;
2912
2913 let empty_workflow = AhbWorkflow {
2914 pruefidentifikator: String::new(),
2915 description: String::new(),
2916 communication_direction: None,
2917 fields: vec![],
2918 ub_definitions: BTreeMap::new(),
2919 package_conditions: Default::default(),
2920 };
2921
2922 let report = validator.validate(&[], &empty_workflow, &external, ValidationLevel::Full);
2923
2924 assert!(report.is_valid());
2925 }
2926
2927 #[test]
2928 fn test_validate_bare_muss_always_required() {
2929 let evaluator = MockEvaluator::new(vec![]);
2930 let validator = EdifactValidator::new(evaluator);
2931 let external = NoOpExternalProvider;
2932
2933 let workflow = AhbWorkflow {
2934 pruefidentifikator: "55001".to_string(),
2935 description: "Test".to_string(),
2936 communication_direction: Some("NB an LF".to_string()),
2937 fields: vec![AhbFieldRule {
2938 segment_path: "SG2/NAD/3035".to_string(),
2939 name: "Partnerrolle".to_string(),
2940 ahb_status: "Muss".to_string(), codes: vec![],
2942 parent_group_ahb_status: None,
2943 segment_ahb_status: None,
2944 ..Default::default()
2945 }],
2946 ub_definitions: BTreeMap::new(),
2947 package_conditions: Default::default(),
2948 };
2949
2950 let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
2951
2952 assert!(!report.is_valid());
2954 assert_eq!(report.error_count(), 1);
2955 }
2956
2957 #[test]
2958 fn test_validate_x_status_is_mandatory() {
2959 let evaluator = MockEvaluator::new(vec![]);
2960 let validator = EdifactValidator::new(evaluator);
2961 let external = NoOpExternalProvider;
2962
2963 let workflow = AhbWorkflow {
2964 pruefidentifikator: "55001".to_string(),
2965 description: "Test".to_string(),
2966 communication_direction: None,
2967 fields: vec![AhbFieldRule {
2968 segment_path: "DTM".to_string(),
2969 name: "Datum".to_string(),
2970 ahb_status: "X".to_string(),
2971 codes: vec![],
2972 parent_group_ahb_status: None,
2973 segment_ahb_status: None,
2974 ..Default::default()
2975 }],
2976 ub_definitions: BTreeMap::new(),
2977 package_conditions: Default::default(),
2978 };
2979
2980 let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
2981
2982 assert!(!report.is_valid());
2983 let errors: Vec<_> = report.errors().collect();
2984 assert_eq!(errors[0].code(), ErrorCodes::MISSING_REQUIRED_FIELD);
2985 }
2986
2987 #[test]
2988 fn test_validate_soll_not_mandatory() {
2989 let evaluator = MockEvaluator::new(vec![]);
2990 let validator = EdifactValidator::new(evaluator);
2991 let external = NoOpExternalProvider;
2992
2993 let workflow = AhbWorkflow {
2994 pruefidentifikator: "55001".to_string(),
2995 description: "Test".to_string(),
2996 communication_direction: None,
2997 fields: vec![AhbFieldRule {
2998 segment_path: "DTM".to_string(),
2999 name: "Datum".to_string(),
3000 ahb_status: "Soll".to_string(),
3001 codes: vec![],
3002 parent_group_ahb_status: None,
3003 segment_ahb_status: None,
3004 ..Default::default()
3005 }],
3006 ub_definitions: BTreeMap::new(),
3007 package_conditions: Default::default(),
3008 };
3009
3010 let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
3011
3012 assert!(report.is_valid());
3014 }
3015
3016 #[test]
3017 fn test_report_includes_metadata() {
3018 let evaluator = MockEvaluator::new(vec![]);
3019 let validator = EdifactValidator::new(evaluator);
3020 let external = NoOpExternalProvider;
3021
3022 let workflow = AhbWorkflow {
3023 pruefidentifikator: "55001".to_string(),
3024 description: String::new(),
3025 communication_direction: None,
3026 fields: vec![],
3027 ub_definitions: BTreeMap::new(),
3028 package_conditions: Default::default(),
3029 };
3030
3031 let report = validator.validate(&[], &workflow, &external, ValidationLevel::Full);
3032
3033 assert_eq!(report.format_version.as_deref(), Some("FV2510"));
3034 assert_eq!(report.level, ValidationLevel::Full);
3035 assert_eq!(report.message_type, "UTILMD");
3036 assert_eq!(report.pruefidentifikator.as_deref(), Some("55001"));
3037 }
3038
3039 #[test]
3040 fn test_validate_with_navigator_returns_report() {
3041 let evaluator = MockEvaluator::all_true(&[]);
3042 let validator = EdifactValidator::new(evaluator);
3043 let external = NoOpExternalProvider;
3044 let nav = crate::eval::NoOpGroupNavigator;
3045
3046 let workflow = AhbWorkflow {
3047 pruefidentifikator: "55001".to_string(),
3048 description: "Test".to_string(),
3049 communication_direction: None,
3050 fields: vec![],
3051 ub_definitions: BTreeMap::new(),
3052 package_conditions: Default::default(),
3053 };
3054
3055 let report = validator.validate_with_navigator(
3056 &[],
3057 &workflow,
3058 &external,
3059 ValidationLevel::Full,
3060 &nav,
3061 );
3062 assert!(report.is_valid());
3063 }
3064
3065 #[test]
3066 fn test_code_validation_composite_paths_valid_codes() {
3067 let evaluator = MockEvaluator::new(vec![]);
3071 let validator = EdifactValidator::new(evaluator);
3072 let external = NoOpExternalProvider;
3073
3074 let unh_segment = OwnedSegment {
3075 id: "UNH".to_string(),
3076 elements: vec![
3077 vec!["ALEXANDE951842".to_string()],
3078 vec![
3079 "UTILMD".to_string(),
3080 "D".to_string(),
3081 "11A".to_string(),
3082 "UN".to_string(),
3083 "S2.1".to_string(),
3084 ],
3085 ],
3086 segment_number: 1,
3087 };
3088
3089 let workflow = AhbWorkflow {
3090 pruefidentifikator: "55001".to_string(),
3091 description: "Test".to_string(),
3092 communication_direction: None,
3093 fields: vec![
3094 AhbFieldRule {
3095 segment_path: "UNH/S009/0065".to_string(),
3096 name: "Nachrichtentyp".to_string(),
3097 ahb_status: "X".to_string(),
3098 codes: vec![AhbCodeRule {
3099 value: "UTILMD".to_string(),
3100 description: "Stammdaten".to_string(),
3101 ahb_status: "X".to_string(),
3102 }],
3103 parent_group_ahb_status: None,
3104 segment_ahb_status: None,
3105 element_index: Some(1),
3106 component_index: Some(0),
3107 ..Default::default()
3108 },
3109 AhbFieldRule {
3110 segment_path: "UNH/S009/0052".to_string(),
3111 name: "Version".to_string(),
3112 ahb_status: "X".to_string(),
3113 codes: vec![AhbCodeRule {
3114 value: "D".to_string(),
3115 description: "Draft".to_string(),
3116 ahb_status: "X".to_string(),
3117 }],
3118 parent_group_ahb_status: None,
3119 segment_ahb_status: None,
3120 element_index: Some(1),
3121 component_index: Some(1),
3122 ..Default::default()
3123 },
3124 ],
3125 ub_definitions: BTreeMap::new(),
3126 package_conditions: Default::default(),
3127 };
3128
3129 let report = validator.validate(
3130 &[unh_segment],
3131 &workflow,
3132 &external,
3133 ValidationLevel::Conditions,
3134 );
3135
3136 let code_errors: Vec<_> = report
3137 .by_category(ValidationCategory::Code)
3138 .filter(|i| i.severity == Severity::Error)
3139 .collect();
3140 assert!(
3141 code_errors.is_empty(),
3142 "Expected no code errors when composite values match allowed codes, got: {:?}",
3143 code_errors
3144 );
3145 }
3146
3147 #[test]
3148 fn test_code_validation_partitions_by_mig_number() {
3149 let evaluator = MockEvaluator::new(vec![]);
3153 let validator = EdifactValidator::new(evaluator);
3154 let external = NoOpExternalProvider;
3155
3156 let sts_7 = OwnedSegment {
3157 id: "STS".to_string(),
3158 elements: vec![
3159 vec!["7".to_string()],
3160 vec![String::new()],
3161 vec!["GH02".to_string()],
3162 vec!["ZW4".to_string()],
3163 ],
3164 segment_number: 1,
3165 };
3166 let sts_e01 = OwnedSegment {
3167 id: "STS".to_string(),
3168 elements: vec![
3169 vec!["E01".to_string()],
3170 vec![String::new()],
3171 vec!["A99".to_string(), "E_0614".to_string()],
3172 ],
3173 segment_number: 2,
3174 };
3175
3176 let workflow = AhbWorkflow {
3177 pruefidentifikator: "55018".to_string(),
3178 description: "Test".to_string(),
3179 communication_direction: None,
3180 fields: vec![
3181 AhbFieldRule {
3183 segment_path: "SG4/STS/C601/9015".to_string(),
3184 name: "Statuskategorie".to_string(),
3185 ahb_status: "X".to_string(),
3186 codes: vec![AhbCodeRule {
3187 value: "7".to_string(),
3188 description: "Transaktionsgrund".to_string(),
3189 ahb_status: "X".to_string(),
3190 }],
3191 parent_group_ahb_status: None,
3192 segment_ahb_status: None,
3193 element_index: Some(0),
3194 component_index: Some(0),
3195 mig_number: Some("00035".to_string()),
3196 format: None,
3197 },
3198 AhbFieldRule {
3199 segment_path: "SG4/STS/C556/9013".to_string(),
3200 name: "Statusanlaß".to_string(),
3201 ahb_status: "X".to_string(),
3202 codes: vec![AhbCodeRule {
3203 value: "E03".to_string(),
3204 description: "Transaktionsgrund".to_string(),
3205 ahb_status: "X".to_string(),
3206 }],
3207 parent_group_ahb_status: None,
3208 segment_ahb_status: None,
3209 element_index: Some(2),
3210 component_index: Some(0),
3211 mig_number: Some("00035".to_string()),
3212 format: None,
3213 },
3214 AhbFieldRule {
3216 segment_path: "SG4/STS/C601/9015".to_string(),
3217 name: "Statuskategorie".to_string(),
3218 ahb_status: "X".to_string(),
3219 codes: vec![AhbCodeRule {
3220 value: "E01".to_string(),
3221 description: "Antwort".to_string(),
3222 ahb_status: "X".to_string(),
3223 }],
3224 parent_group_ahb_status: None,
3225 segment_ahb_status: None,
3226 element_index: Some(0),
3227 component_index: Some(0),
3228 mig_number: Some("00036".to_string()),
3229 format: None,
3230 },
3231 ],
3232 ub_definitions: BTreeMap::new(),
3233 package_conditions: Default::default(),
3234 };
3235
3236 let report = validator.validate(
3237 &[sts_7, sts_e01],
3238 &workflow,
3239 &external,
3240 ValidationLevel::Conditions,
3241 );
3242
3243 let code_errors: Vec<_> = report
3244 .by_category(ValidationCategory::Code)
3245 .filter(|i| {
3246 i.severity == Severity::Error && i.code() == ErrorCodes::CODE_NOT_ALLOWED_FOR_PID
3247 })
3248 .collect();
3249 assert_eq!(
3250 code_errors.len(),
3251 1,
3252 "Expected one COD002 (for GH02 only), got: {:?}",
3253 code_errors
3254 );
3255 assert_eq!(code_errors[0].actual_value.as_deref(), Some("GH02"));
3256 }
3257
3258 #[test]
3259 fn test_code_validation_composite_paths_detects_invalid_code() {
3260 let evaluator = MockEvaluator::new(vec![]);
3263 let validator = EdifactValidator::new(evaluator);
3264 let external = NoOpExternalProvider;
3265
3266 let sts_segment = OwnedSegment {
3267 id: "STS".to_string(),
3268 elements: vec![
3269 vec!["7".to_string()],
3270 vec![String::new()],
3271 vec!["GH02".to_string()],
3272 vec!["ZW4".to_string()],
3273 ],
3274 segment_number: 1,
3275 };
3276
3277 let workflow = AhbWorkflow {
3278 pruefidentifikator: "55018".to_string(),
3279 description: "Test".to_string(),
3280 communication_direction: None,
3281 fields: vec![AhbFieldRule {
3282 segment_path: "SG4/STS/C556/9013".to_string(),
3283 name: "Statusanlaß".to_string(),
3284 ahb_status: "X".to_string(),
3285 codes: vec![AhbCodeRule {
3286 value: "E03".to_string(),
3287 description: "Transaktionsgrund".to_string(),
3288 ahb_status: "X".to_string(),
3289 }],
3290 parent_group_ahb_status: None,
3291 segment_ahb_status: None,
3292 element_index: Some(2),
3293 component_index: Some(0),
3294 ..Default::default()
3295 }],
3296 ub_definitions: BTreeMap::new(),
3297 package_conditions: Default::default(),
3298 };
3299
3300 let report = validator.validate(
3301 &[sts_segment],
3302 &workflow,
3303 &external,
3304 ValidationLevel::Conditions,
3305 );
3306
3307 let code_errors: Vec<_> = report
3308 .by_category(ValidationCategory::Code)
3309 .filter(|i| {
3310 i.severity == Severity::Error && i.code() == ErrorCodes::CODE_NOT_ALLOWED_FOR_PID
3311 })
3312 .collect();
3313 assert_eq!(
3314 code_errors.len(),
3315 1,
3316 "Expected COD002 for GH02, got: {:?}",
3317 code_errors
3318 );
3319 assert_eq!(code_errors[0].actual_value.as_deref(), Some("GH02"));
3320 }
3321
3322 #[test]
3323 fn test_cross_field_code_validation_valid_qualifiers() {
3324 let evaluator = MockEvaluator::new(vec![]);
3327 let validator = EdifactValidator::new(evaluator);
3328 let external = NoOpExternalProvider;
3329
3330 let nad_ms = OwnedSegment {
3331 id: "NAD".to_string(),
3332 elements: vec![vec!["MS".to_string()]],
3333 segment_number: 4,
3334 };
3335 let nad_mr = OwnedSegment {
3336 id: "NAD".to_string(),
3337 elements: vec![vec!["MR".to_string()]],
3338 segment_number: 5,
3339 };
3340
3341 let workflow = AhbWorkflow {
3342 pruefidentifikator: "55001".to_string(),
3343 description: "Test".to_string(),
3344 communication_direction: None,
3345 fields: vec![
3346 AhbFieldRule {
3347 segment_path: "SG2/NAD/3035".to_string(),
3348 name: "Absender".to_string(),
3349 ahb_status: "X".to_string(),
3350 codes: vec![AhbCodeRule {
3351 value: "MS".to_string(),
3352 description: "Absender".to_string(),
3353 ahb_status: "X".to_string(),
3354 }],
3355 parent_group_ahb_status: None,
3356 segment_ahb_status: None,
3357 ..Default::default()
3358 },
3359 AhbFieldRule {
3360 segment_path: "SG2/NAD/3035".to_string(),
3361 name: "Empfaenger".to_string(),
3362 ahb_status: "X".to_string(),
3363 codes: vec![AhbCodeRule {
3364 value: "MR".to_string(),
3365 description: "Empfaenger".to_string(),
3366 ahb_status: "X".to_string(),
3367 }],
3368 parent_group_ahb_status: None,
3369 segment_ahb_status: None,
3370 ..Default::default()
3371 },
3372 ],
3373 ub_definitions: BTreeMap::new(),
3374 package_conditions: Default::default(),
3375 };
3376
3377 let report = validator.validate(
3378 &[nad_ms, nad_mr],
3379 &workflow,
3380 &external,
3381 ValidationLevel::Conditions,
3382 );
3383
3384 let code_errors: Vec<_> = report
3385 .by_category(ValidationCategory::Code)
3386 .filter(|i| i.severity == Severity::Error)
3387 .collect();
3388 assert!(
3389 code_errors.is_empty(),
3390 "Expected no code errors for valid qualifiers, got: {:?}",
3391 code_errors
3392 );
3393 }
3394
3395 #[test]
3396 fn test_cross_field_code_validation_catches_invalid_qualifier() {
3397 let evaluator = MockEvaluator::new(vec![]);
3399 let validator = EdifactValidator::new(evaluator);
3400 let external = NoOpExternalProvider;
3401
3402 let nad_ms = OwnedSegment {
3403 id: "NAD".to_string(),
3404 elements: vec![vec!["MS".to_string()]],
3405 segment_number: 4,
3406 };
3407 let nad_mt = OwnedSegment {
3408 id: "NAD".to_string(),
3409 elements: vec![vec!["MT".to_string()]], segment_number: 5,
3411 };
3412
3413 let workflow = AhbWorkflow {
3414 pruefidentifikator: "55001".to_string(),
3415 description: "Test".to_string(),
3416 communication_direction: None,
3417 fields: vec![
3418 AhbFieldRule {
3419 segment_path: "SG2/NAD/3035".to_string(),
3420 name: "Absender".to_string(),
3421 ahb_status: "X".to_string(),
3422 codes: vec![AhbCodeRule {
3423 value: "MS".to_string(),
3424 description: "Absender".to_string(),
3425 ahb_status: "X".to_string(),
3426 }],
3427 parent_group_ahb_status: None,
3428 segment_ahb_status: None,
3429 ..Default::default()
3430 },
3431 AhbFieldRule {
3432 segment_path: "SG2/NAD/3035".to_string(),
3433 name: "Empfaenger".to_string(),
3434 ahb_status: "X".to_string(),
3435 codes: vec![AhbCodeRule {
3436 value: "MR".to_string(),
3437 description: "Empfaenger".to_string(),
3438 ahb_status: "X".to_string(),
3439 }],
3440 parent_group_ahb_status: None,
3441 segment_ahb_status: None,
3442 ..Default::default()
3443 },
3444 ],
3445 ub_definitions: BTreeMap::new(),
3446 package_conditions: Default::default(),
3447 };
3448
3449 let report = validator.validate(
3450 &[nad_ms, nad_mt],
3451 &workflow,
3452 &external,
3453 ValidationLevel::Conditions,
3454 );
3455
3456 let code_errors: Vec<_> = report
3457 .by_category(ValidationCategory::Code)
3458 .filter(|i| i.severity == Severity::Error)
3459 .collect();
3460 assert_eq!(code_errors.len(), 1, "Expected one COD002 error for MT");
3461 assert!(narrate(code_errors[0]).contains("MT"));
3462 assert!(narrate(code_errors[0]).contains("MR"));
3463 assert!(narrate(code_errors[0]).contains("MS"));
3464 }
3465
3466 #[test]
3467 fn test_cross_field_code_validation_unions_across_groups() {
3468 let evaluator = MockEvaluator::new(vec![]);
3472 let validator = EdifactValidator::new(evaluator);
3473 let external = NoOpExternalProvider;
3474
3475 let segments = vec![
3476 OwnedSegment {
3477 id: "NAD".to_string(),
3478 elements: vec![vec!["MS".to_string()]],
3479 segment_number: 3,
3480 },
3481 OwnedSegment {
3482 id: "NAD".to_string(),
3483 elements: vec![vec!["MR".to_string()]],
3484 segment_number: 4,
3485 },
3486 OwnedSegment {
3487 id: "NAD".to_string(),
3488 elements: vec![vec!["Z04".to_string()]],
3489 segment_number: 20,
3490 },
3491 OwnedSegment {
3492 id: "NAD".to_string(),
3493 elements: vec![vec!["Z09".to_string()]],
3494 segment_number: 21,
3495 },
3496 OwnedSegment {
3497 id: "NAD".to_string(),
3498 elements: vec![vec!["MT".to_string()]], segment_number: 22,
3500 },
3501 ];
3502
3503 let workflow = AhbWorkflow {
3504 pruefidentifikator: "55001".to_string(),
3505 description: "Test".to_string(),
3506 communication_direction: None,
3507 fields: vec![
3508 AhbFieldRule {
3509 segment_path: "SG2/NAD/3035".to_string(),
3510 name: "Absender".to_string(),
3511 ahb_status: "X".to_string(),
3512 codes: vec![AhbCodeRule {
3513 value: "MS".to_string(),
3514 description: "Absender".to_string(),
3515 ahb_status: "X".to_string(),
3516 }],
3517 parent_group_ahb_status: None,
3518 segment_ahb_status: None,
3519 ..Default::default()
3520 },
3521 AhbFieldRule {
3522 segment_path: "SG2/NAD/3035".to_string(),
3523 name: "Empfaenger".to_string(),
3524 ahb_status: "X".to_string(),
3525 codes: vec![AhbCodeRule {
3526 value: "MR".to_string(),
3527 description: "Empfaenger".to_string(),
3528 ahb_status: "X".to_string(),
3529 }],
3530 parent_group_ahb_status: None,
3531 segment_ahb_status: None,
3532 ..Default::default()
3533 },
3534 AhbFieldRule {
3535 segment_path: "SG4/SG12/NAD/3035".to_string(),
3536 name: "Anschlussnutzer".to_string(),
3537 ahb_status: "X".to_string(),
3538 codes: vec![AhbCodeRule {
3539 value: "Z04".to_string(),
3540 description: "Anschlussnutzer".to_string(),
3541 ahb_status: "X".to_string(),
3542 }],
3543 parent_group_ahb_status: None,
3544 segment_ahb_status: None,
3545 ..Default::default()
3546 },
3547 AhbFieldRule {
3548 segment_path: "SG4/SG12/NAD/3035".to_string(),
3549 name: "Korrespondenzanschrift".to_string(),
3550 ahb_status: "X".to_string(),
3551 codes: vec![AhbCodeRule {
3552 value: "Z09".to_string(),
3553 description: "Korrespondenzanschrift".to_string(),
3554 ahb_status: "X".to_string(),
3555 }],
3556 parent_group_ahb_status: None,
3557 segment_ahb_status: None,
3558 ..Default::default()
3559 },
3560 ],
3561 ub_definitions: BTreeMap::new(),
3562 package_conditions: Default::default(),
3563 };
3564
3565 let report =
3566 validator.validate(&segments, &workflow, &external, ValidationLevel::Conditions);
3567
3568 let code_errors: Vec<_> = report
3569 .by_category(ValidationCategory::Code)
3570 .filter(|i| i.severity == Severity::Error)
3571 .collect();
3572 assert_eq!(
3573 code_errors.len(),
3574 1,
3575 "Expected exactly one COD002 error for MT, got: {:?}",
3576 code_errors
3577 );
3578 assert!(narrate(code_errors[0]).contains("MT"));
3579 }
3580
3581 #[test]
3582 fn test_cross_field_code_validation_accepts_conditionally_allowed_codes() {
3583 let evaluator = MockEvaluator::new(vec![]);
3592 let validator = EdifactValidator::new(evaluator);
3593 let external = NoOpExternalProvider;
3594
3595 let qty_67 = OwnedSegment {
3596 id: "QTY".to_string(),
3597 elements: vec![vec!["67".to_string(), "0.185".to_string()]],
3598 segment_number: 10,
3599 };
3600
3601 let workflow = AhbWorkflow {
3602 pruefidentifikator: "13025".to_string(),
3603 description: "Test".to_string(),
3604 communication_direction: None,
3605 fields: vec![AhbFieldRule {
3606 segment_path: "SG5/SG6/SG9/SG10/QTY/qualifier".to_string(),
3607 name: "Menge, Qualifier".to_string(),
3608 ahb_status: "X".to_string(),
3609 codes: vec![
3610 AhbCodeRule {
3611 value: "220".to_string(),
3612 description: "Wahrer Wert".to_string(),
3613 ahb_status: "X".to_string(),
3614 },
3615 AhbCodeRule {
3616 value: "67".to_string(),
3617 description: "Ersatzwert".to_string(),
3618 ahb_status: "X [35] ∨ ([32] ∧ [77])".to_string(),
3619 },
3620 AhbCodeRule {
3621 value: "Z18".to_string(),
3622 description: "Vorläufiger Wert".to_string(),
3623 ahb_status: "X [35]".to_string(),
3624 },
3625 ],
3626 parent_group_ahb_status: None,
3627 segment_ahb_status: None,
3628 element_index: Some(0),
3629 component_index: Some(0),
3630 ..Default::default()
3631 }],
3632 ub_definitions: BTreeMap::new(),
3633 package_conditions: Default::default(),
3634 };
3635
3636 let report =
3637 validator.validate(&[qty_67], &workflow, &external, ValidationLevel::Conditions);
3638
3639 let code_errors: Vec<_> = report
3640 .by_category(ValidationCategory::Code)
3641 .filter(|i| i.severity == Severity::Error)
3642 .collect();
3643 assert!(
3644 code_errors.is_empty(),
3645 "QTY+67 should be accepted because code '67' is conditionally allowed for this PID (X [35] ∨ ([32] ∧ [77])). Got errors: {:?}",
3646 code_errors
3647 );
3648 }
3649
3650 #[test]
3651 fn test_is_qualifier_field_simple_paths() {
3652 assert!(is_qualifier_field("NAD/3035"));
3653 assert!(is_qualifier_field("SG2/NAD/3035"));
3654 assert!(is_qualifier_field("SG4/SG8/SEQ/6350"));
3655 assert!(is_qualifier_field("LOC/3227"));
3656 }
3657
3658 #[test]
3659 fn test_is_qualifier_field_composite_paths() {
3660 assert!(is_qualifier_field("UNH/S009/0065"));
3664 assert!(is_qualifier_field("NAD/C082/3039"));
3665 assert!(is_qualifier_field("SG2/NAD/C082/3039"));
3666 assert!(is_qualifier_field("SG4/STS/C556/9013"));
3667 }
3668
3669 #[test]
3670 fn test_is_qualifier_field_bare_segment() {
3671 assert!(!is_qualifier_field("NAD"));
3672 assert!(!is_qualifier_field("SG2/NAD"));
3673 }
3674
3675 #[test]
3676 fn test_is_qualifier_field_rejects_deep_paths() {
3677 assert!(!is_qualifier_field("SEG/A/B/C/D"));
3679 }
3680
3681 #[test]
3682 fn test_missing_qualifier_instance_is_detected() {
3683 let evaluator = MockEvaluator::new(vec![]);
3686 let validator = EdifactValidator::new(evaluator);
3687 let external = NoOpExternalProvider;
3688
3689 let nad_ms = OwnedSegment {
3690 id: "NAD".to_string(),
3691 elements: vec![vec!["MS".to_string()]],
3692 segment_number: 3,
3693 };
3694
3695 let workflow = AhbWorkflow {
3696 pruefidentifikator: "55001".to_string(),
3697 description: "Test".to_string(),
3698 communication_direction: None,
3699 fields: vec![
3700 AhbFieldRule {
3701 segment_path: "SG2/NAD/3035".to_string(),
3702 name: "Absender".to_string(),
3703 ahb_status: "X".to_string(),
3704 codes: vec![AhbCodeRule {
3705 value: "MS".to_string(),
3706 description: "Absender".to_string(),
3707 ahb_status: "X".to_string(),
3708 }],
3709 parent_group_ahb_status: None,
3710 segment_ahb_status: None,
3711 ..Default::default()
3712 },
3713 AhbFieldRule {
3714 segment_path: "SG2/NAD/3035".to_string(),
3715 name: "Empfaenger".to_string(),
3716 ahb_status: "Muss".to_string(),
3717 codes: vec![AhbCodeRule {
3718 value: "MR".to_string(),
3719 description: "Empfaenger".to_string(),
3720 ahb_status: "X".to_string(),
3721 }],
3722 parent_group_ahb_status: None,
3723 segment_ahb_status: None,
3724 ..Default::default()
3725 },
3726 ],
3727 ub_definitions: BTreeMap::new(),
3728 package_conditions: Default::default(),
3729 };
3730
3731 let report =
3732 validator.validate(&[nad_ms], &workflow, &external, ValidationLevel::Conditions);
3733
3734 let ahb_errors: Vec<_> = report
3735 .by_category(ValidationCategory::Ahb)
3736 .filter(|i| i.severity == Severity::Error)
3737 .collect();
3738 assert_eq!(
3739 ahb_errors.len(),
3740 1,
3741 "Expected AHB001 for missing NAD+MR, got: {:?}",
3742 ahb_errors
3743 );
3744 assert!(narrate(ahb_errors[0]).contains("Empfaenger"));
3745 }
3746
3747 #[test]
3748 fn test_present_qualifier_instance_no_error() {
3749 let evaluator = MockEvaluator::new(vec![]);
3751 let validator = EdifactValidator::new(evaluator);
3752 let external = NoOpExternalProvider;
3753
3754 let segments = vec![
3755 OwnedSegment {
3756 id: "NAD".to_string(),
3757 elements: vec![vec!["MS".to_string()]],
3758 segment_number: 3,
3759 },
3760 OwnedSegment {
3761 id: "NAD".to_string(),
3762 elements: vec![vec!["MR".to_string()]],
3763 segment_number: 4,
3764 },
3765 ];
3766
3767 let workflow = AhbWorkflow {
3768 pruefidentifikator: "55001".to_string(),
3769 description: "Test".to_string(),
3770 communication_direction: None,
3771 fields: vec![
3772 AhbFieldRule {
3773 segment_path: "SG2/NAD/3035".to_string(),
3774 name: "Absender".to_string(),
3775 ahb_status: "Muss".to_string(),
3776 codes: vec![AhbCodeRule {
3777 value: "MS".to_string(),
3778 description: "Absender".to_string(),
3779 ahb_status: "X".to_string(),
3780 }],
3781 parent_group_ahb_status: None,
3782 segment_ahb_status: None,
3783 ..Default::default()
3784 },
3785 AhbFieldRule {
3786 segment_path: "SG2/NAD/3035".to_string(),
3787 name: "Empfaenger".to_string(),
3788 ahb_status: "Muss".to_string(),
3789 codes: vec![AhbCodeRule {
3790 value: "MR".to_string(),
3791 description: "Empfaenger".to_string(),
3792 ahb_status: "X".to_string(),
3793 }],
3794 parent_group_ahb_status: None,
3795 segment_ahb_status: None,
3796 ..Default::default()
3797 },
3798 ],
3799 ub_definitions: BTreeMap::new(),
3800 package_conditions: Default::default(),
3801 };
3802
3803 let report =
3804 validator.validate(&segments, &workflow, &external, ValidationLevel::Conditions);
3805
3806 let ahb_errors: Vec<_> = report
3807 .by_category(ValidationCategory::Ahb)
3808 .filter(|i| i.severity == Severity::Error)
3809 .collect();
3810 assert!(
3811 ahb_errors.is_empty(),
3812 "Expected no AHB001 errors, got: {:?}",
3813 ahb_errors
3814 );
3815 }
3816
3817 #[test]
3818 fn test_extract_group_path_key() {
3819 assert_eq!(extract_group_path_key("SG2/NAD/3035"), "SG2");
3820 assert_eq!(extract_group_path_key("SG4/SG12/NAD/3035"), "SG4/SG12");
3821 assert_eq!(extract_group_path_key("NAD/3035"), "");
3822 assert_eq!(extract_group_path_key("SG4/SG8/SEQ/6350"), "SG4/SG8");
3823 }
3824
3825 fn validate_field_of_absent_group(
3827 group_status: &str,
3828 conditions: Vec<(u32, CR)>,
3829 ) -> ValidationReport {
3830 use mig_types::navigator::GroupNavigator;
3831
3832 struct NoSg9;
3833 impl GroupNavigator for NoSg9 {
3834 fn find_segments_in_group(&self, _: &str, _: &[&str], _: usize) -> Vec<OwnedSegment> {
3835 vec![]
3836 }
3837 fn find_segments_with_qualifier_in_group(
3838 &self,
3839 _: &str,
3840 _: usize,
3841 _: &str,
3842 _: &[&str],
3843 _: usize,
3844 ) -> Vec<OwnedSegment> {
3845 vec![]
3846 }
3847 fn group_instance_count(&self, _: &[&str]) -> usize {
3848 0
3849 }
3850 }
3851
3852 let validator = EdifactValidator::new(MockEvaluator::new(conditions));
3853 let workflow = AhbWorkflow {
3854 pruefidentifikator: "44001".to_string(),
3855 description: "Test".to_string(),
3856 communication_direction: None,
3857 fields: vec![AhbFieldRule {
3858 segment_path: "SG9/QTY/C186/6060".to_string(),
3859 name: "Menge".to_string(),
3860 ahb_status: "X".to_string(),
3861 parent_group_ahb_status: Some(group_status.to_string()),
3862 ..Default::default()
3863 }],
3864 ub_definitions: BTreeMap::new(),
3865 package_conditions: Default::default(),
3866 };
3867 validator.validate_with_navigator(
3868 &[],
3869 &workflow,
3870 &NoOpExternalProvider,
3871 ValidationLevel::Conditions,
3872 &NoSg9,
3873 )
3874 }
3875
3876 #[test]
3877 fn an_absent_group_whose_holding_line_is_soll_is_not_missing() {
3878 let report = validate_field_of_absent_group(
3880 "Soll [47]\r\nMuss [46]",
3881 vec![(47, CR::True), (46, CR::False)],
3882 );
3883 assert!(
3884 report.is_valid(),
3885 "{:?}",
3886 report.errors().collect::<Vec<_>>()
3887 );
3888 }
3889
3890 #[test]
3891 fn an_absent_group_whose_holding_line_is_muss_is_missing() {
3892 let report = validate_field_of_absent_group(
3893 "Soll [47]\r\nMuss [46]",
3894 vec![(47, CR::False), (46, CR::True)],
3895 );
3896 let errors: Vec<_> = report.errors().collect();
3897 assert_eq!(errors.len(), 1, "{errors:?}");
3898 assert_eq!(errors[0].code(), ErrorCodes::MISSING_REQUIRED_FIELD);
3899 }
3900
3901 #[test]
3902 fn test_absent_optional_group_no_missing_field_error() {
3903 use mig_types::navigator::GroupNavigator;
3906
3907 struct NavWithoutSG3;
3908 impl GroupNavigator for NavWithoutSG3 {
3909 fn find_segments_in_group(&self, _: &str, _: &[&str], _: usize) -> Vec<OwnedSegment> {
3910 vec![]
3911 }
3912 fn find_segments_with_qualifier_in_group(
3913 &self,
3914 _: &str,
3915 _: usize,
3916 _: &str,
3917 _: &[&str],
3918 _: usize,
3919 ) -> Vec<OwnedSegment> {
3920 vec![]
3921 }
3922 fn group_instance_count(&self, group_path: &[&str]) -> usize {
3923 match group_path {
3924 ["SG2"] => 2, ["SG2", "SG3"] => 0, _ => 0,
3927 }
3928 }
3929 }
3930
3931 let evaluator = MockEvaluator::new(vec![]);
3932 let validator = EdifactValidator::new(evaluator);
3933 let external = NoOpExternalProvider;
3934 let nav = NavWithoutSG3;
3935
3936 let segments = vec![
3938 OwnedSegment {
3939 id: "NAD".into(),
3940 elements: vec![vec!["MS".into()]],
3941 segment_number: 3,
3942 },
3943 OwnedSegment {
3944 id: "NAD".into(),
3945 elements: vec![vec!["MR".into()]],
3946 segment_number: 4,
3947 },
3948 ];
3949
3950 let workflow = AhbWorkflow {
3951 pruefidentifikator: "55001".to_string(),
3952 description: "Test".to_string(),
3953 communication_direction: None,
3954 fields: vec![
3955 AhbFieldRule {
3956 segment_path: "SG2/SG3/CTA/3139".to_string(),
3957 name: "Funktion des Ansprechpartners, Code".to_string(),
3958 ahb_status: "Muss".to_string(),
3959 codes: vec![],
3960 parent_group_ahb_status: None,
3961 segment_ahb_status: None,
3962 ..Default::default()
3963 },
3964 AhbFieldRule {
3965 segment_path: "SG2/SG3/CTA/C056/3412".to_string(),
3966 name: "Name vom Ansprechpartner".to_string(),
3967 ahb_status: "X".to_string(),
3968 codes: vec![],
3969 parent_group_ahb_status: None,
3970 segment_ahb_status: None,
3971 ..Default::default()
3972 },
3973 ],
3974 ub_definitions: BTreeMap::new(),
3975 package_conditions: Default::default(),
3976 };
3977
3978 let report = validator.validate_with_navigator(
3979 &segments,
3980 &workflow,
3981 &external,
3982 ValidationLevel::Conditions,
3983 &nav,
3984 );
3985
3986 let ahb_errors: Vec<_> = report
3987 .by_category(ValidationCategory::Ahb)
3988 .filter(|i| i.severity == Severity::Error)
3989 .collect();
3990 assert!(
3991 ahb_errors.is_empty(),
3992 "Expected no AHB001 errors when SG3 is absent, got: {:?}",
3993 ahb_errors
3994 );
3995 }
3996
3997 #[test]
3998 fn test_present_group_still_checks_mandatory_fields() {
3999 use mig_types::navigator::GroupNavigator;
4001
4002 struct NavWithSG3;
4003 impl GroupNavigator for NavWithSG3 {
4004 fn find_segments_in_group(&self, _: &str, _: &[&str], _: usize) -> Vec<OwnedSegment> {
4005 vec![]
4006 }
4007 fn find_segments_with_qualifier_in_group(
4008 &self,
4009 _: &str,
4010 _: usize,
4011 _: &str,
4012 _: &[&str],
4013 _: usize,
4014 ) -> Vec<OwnedSegment> {
4015 vec![]
4016 }
4017 fn group_instance_count(&self, group_path: &[&str]) -> usize {
4018 match group_path {
4019 ["SG2"] => 1,
4020 ["SG2", "SG3"] => 1, _ => 0,
4022 }
4023 }
4024 }
4025
4026 let evaluator = MockEvaluator::new(vec![]);
4027 let validator = EdifactValidator::new(evaluator);
4028 let external = NoOpExternalProvider;
4029 let nav = NavWithSG3;
4030
4031 let segments = vec![OwnedSegment {
4033 id: "NAD".into(),
4034 elements: vec![vec!["MS".into()]],
4035 segment_number: 3,
4036 }];
4037
4038 let workflow = AhbWorkflow {
4039 pruefidentifikator: "55001".to_string(),
4040 description: "Test".to_string(),
4041 communication_direction: None,
4042 fields: vec![AhbFieldRule {
4043 segment_path: "SG2/SG3/CTA/3139".to_string(),
4044 name: "Funktion des Ansprechpartners, Code".to_string(),
4045 ahb_status: "Muss".to_string(),
4046 codes: vec![],
4047 parent_group_ahb_status: None,
4048 segment_ahb_status: None,
4049 ..Default::default()
4050 }],
4051 ub_definitions: BTreeMap::new(),
4052 package_conditions: Default::default(),
4053 };
4054
4055 let report = validator.validate_with_navigator(
4056 &segments,
4057 &workflow,
4058 &external,
4059 ValidationLevel::Conditions,
4060 &nav,
4061 );
4062
4063 let ahb_errors: Vec<_> = report
4064 .by_category(ValidationCategory::Ahb)
4065 .filter(|i| i.severity == Severity::Error)
4066 .collect();
4067 assert_eq!(
4068 ahb_errors.len(),
4069 1,
4070 "Expected AHB001 error when SG3 is present but CTA missing"
4071 );
4072 assert!(narrate(ahb_errors[0]).contains("CTA"));
4073 }
4074
4075 #[test]
4076 fn test_missing_qualifier_with_navigator_is_detected() {
4077 use mig_types::navigator::GroupNavigator;
4080
4081 struct NavWithSG2;
4082 impl GroupNavigator for NavWithSG2 {
4083 fn find_segments_in_group(
4084 &self,
4085 segment_id: &str,
4086 group_path: &[&str],
4087 instance_index: usize,
4088 ) -> Vec<OwnedSegment> {
4089 if segment_id == "NAD" && group_path == ["SG2"] && instance_index == 0 {
4090 vec![OwnedSegment {
4091 id: "NAD".into(),
4092 elements: vec![vec!["MS".into()]],
4093 segment_number: 3,
4094 }]
4095 } else {
4096 vec![]
4097 }
4098 }
4099 fn find_segments_with_qualifier_in_group(
4100 &self,
4101 _: &str,
4102 _: usize,
4103 _: &str,
4104 _: &[&str],
4105 _: usize,
4106 ) -> Vec<OwnedSegment> {
4107 vec![]
4108 }
4109 fn group_instance_count(&self, group_path: &[&str]) -> usize {
4110 match group_path {
4111 ["SG2"] => 1,
4112 _ => 0,
4113 }
4114 }
4115 }
4116
4117 let evaluator = MockEvaluator::new(vec![]);
4118 let validator = EdifactValidator::new(evaluator);
4119 let external = NoOpExternalProvider;
4120 let nav = NavWithSG2;
4121
4122 let segments = vec![OwnedSegment {
4123 id: "NAD".into(),
4124 elements: vec![vec!["MS".into()]],
4125 segment_number: 3,
4126 }];
4127
4128 let workflow = AhbWorkflow {
4129 pruefidentifikator: "55001".to_string(),
4130 description: "Test".to_string(),
4131 communication_direction: None,
4132 fields: vec![
4133 AhbFieldRule {
4134 segment_path: "SG2/NAD/3035".to_string(),
4135 name: "Absender".to_string(),
4136 ahb_status: "X".to_string(),
4137 codes: vec![AhbCodeRule {
4138 value: "MS".to_string(),
4139 description: "Absender".to_string(),
4140 ahb_status: "X".to_string(),
4141 }],
4142 parent_group_ahb_status: None,
4143 segment_ahb_status: None,
4144 ..Default::default()
4145 },
4146 AhbFieldRule {
4147 segment_path: "SG2/NAD/3035".to_string(),
4148 name: "Empfaenger".to_string(),
4149 ahb_status: "Muss".to_string(),
4150 codes: vec![AhbCodeRule {
4151 value: "MR".to_string(),
4152 description: "Empfaenger".to_string(),
4153 ahb_status: "X".to_string(),
4154 }],
4155 parent_group_ahb_status: None,
4156 segment_ahb_status: None,
4157 ..Default::default()
4158 },
4159 ],
4160 ub_definitions: BTreeMap::new(),
4161 package_conditions: Default::default(),
4162 };
4163
4164 let report = validator.validate_with_navigator(
4165 &segments,
4166 &workflow,
4167 &external,
4168 ValidationLevel::Conditions,
4169 &nav,
4170 );
4171
4172 let ahb_errors: Vec<_> = report
4173 .by_category(ValidationCategory::Ahb)
4174 .filter(|i| i.severity == Severity::Error)
4175 .collect();
4176 assert_eq!(
4177 ahb_errors.len(),
4178 1,
4179 "Expected AHB001 for missing NAD+MR even with navigator, got: {:?}",
4180 ahb_errors
4181 );
4182 assert!(narrate(ahb_errors[0]).contains("Empfaenger"));
4183 }
4184
4185 #[test]
4186 fn test_optional_group_variant_absent_no_error() {
4187 use mig_types::navigator::GroupNavigator;
4192
4193 struct TestNav;
4194 impl GroupNavigator for TestNav {
4195 fn find_segments_in_group(
4196 &self,
4197 segment_id: &str,
4198 group_path: &[&str],
4199 instance_index: usize,
4200 ) -> Vec<OwnedSegment> {
4201 match (segment_id, group_path, instance_index) {
4202 ("LOC", ["SG4", "SG5"], 0) => vec![OwnedSegment {
4203 id: "LOC".into(),
4204 elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
4205 segment_number: 10,
4206 }],
4207 ("NAD", ["SG2"], 0) => vec![OwnedSegment {
4208 id: "NAD".into(),
4209 elements: vec![vec!["MS".into()]],
4210 segment_number: 3,
4211 }],
4212 _ => vec![],
4213 }
4214 }
4215 fn find_segments_with_qualifier_in_group(
4216 &self,
4217 _: &str,
4218 _: usize,
4219 _: &str,
4220 _: &[&str],
4221 _: usize,
4222 ) -> Vec<OwnedSegment> {
4223 vec![]
4224 }
4225 fn group_instance_count(&self, group_path: &[&str]) -> usize {
4226 match group_path {
4227 ["SG2"] => 1,
4228 ["SG4"] => 1,
4229 ["SG4", "SG5"] => 1, _ => 0,
4231 }
4232 }
4233 }
4234
4235 let evaluator = MockEvaluator::new(vec![]);
4236 let validator = EdifactValidator::new(evaluator);
4237 let external = NoOpExternalProvider;
4238 let nav = TestNav;
4239
4240 let segments = vec![
4241 OwnedSegment {
4242 id: "NAD".into(),
4243 elements: vec![vec!["MS".into()]],
4244 segment_number: 3,
4245 },
4246 OwnedSegment {
4247 id: "LOC".into(),
4248 elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
4249 segment_number: 10,
4250 },
4251 ];
4252
4253 let workflow = AhbWorkflow {
4254 pruefidentifikator: "55001".to_string(),
4255 description: "Test".to_string(),
4256 communication_direction: None,
4257 fields: vec![
4258 AhbFieldRule {
4260 segment_path: "SG2/NAD/3035".to_string(),
4261 name: "Absender".to_string(),
4262 ahb_status: "X".to_string(),
4263 codes: vec![AhbCodeRule {
4264 value: "MS".to_string(),
4265 description: "Absender".to_string(),
4266 ahb_status: "X".to_string(),
4267 }],
4268 parent_group_ahb_status: Some("Muss".to_string()),
4269 segment_ahb_status: None,
4270 ..Default::default()
4271 },
4272 AhbFieldRule {
4273 segment_path: "SG2/NAD/3035".to_string(),
4274 name: "Empfaenger".to_string(),
4275 ahb_status: "Muss".to_string(),
4276 codes: vec![AhbCodeRule {
4277 value: "MR".to_string(),
4278 description: "Empfaenger".to_string(),
4279 ahb_status: "X".to_string(),
4280 }],
4281 parent_group_ahb_status: Some("Muss".to_string()),
4282 segment_ahb_status: None,
4283 ..Default::default()
4284 },
4285 AhbFieldRule {
4287 segment_path: "SG4/SG5/LOC/3227".to_string(),
4288 name: "Ortsangabe, Qualifier (Z16)".to_string(),
4289 ahb_status: "X".to_string(),
4290 codes: vec![AhbCodeRule {
4291 value: "Z16".to_string(),
4292 description: "Marktlokation".to_string(),
4293 ahb_status: "X".to_string(),
4294 }],
4295 parent_group_ahb_status: Some("Kann".to_string()),
4296 segment_ahb_status: None,
4297 ..Default::default()
4298 },
4299 AhbFieldRule {
4300 segment_path: "SG4/SG5/LOC/3227".to_string(),
4301 name: "Ortsangabe, Qualifier (Z17)".to_string(),
4302 ahb_status: "Muss".to_string(),
4303 codes: vec![AhbCodeRule {
4304 value: "Z17".to_string(),
4305 description: "Messlokation".to_string(),
4306 ahb_status: "X".to_string(),
4307 }],
4308 parent_group_ahb_status: Some("Kann".to_string()),
4309 segment_ahb_status: None,
4310 ..Default::default()
4311 },
4312 ],
4313 ub_definitions: BTreeMap::new(),
4314 package_conditions: Default::default(),
4315 };
4316
4317 let report = validator.validate_with_navigator(
4318 &segments,
4319 &workflow,
4320 &external,
4321 ValidationLevel::Conditions,
4322 &nav,
4323 );
4324
4325 let ahb_errors: Vec<_> = report
4326 .by_category(ValidationCategory::Ahb)
4327 .filter(|i| i.severity == Severity::Error)
4328 .collect();
4329
4330 assert_eq!(
4333 ahb_errors.len(),
4334 1,
4335 "Expected only AHB001 for missing NAD+MR, got: {:?}",
4336 ahb_errors
4337 );
4338 assert!(
4339 narrate(ahb_errors[0]).contains("Empfaenger"),
4340 "Error should be for missing NAD+MR (Empfaenger)"
4341 );
4342 }
4343
4344 #[test]
4348 fn test_optional_group_variant_with_true_condition_absent_no_error() {
4349 use mig_types::navigator::GroupNavigator;
4350
4351 struct TestNav;
4352 impl GroupNavigator for TestNav {
4353 fn find_segments_in_group(
4354 &self,
4355 segment_id: &str,
4356 group_path: &[&str],
4357 instance_index: usize,
4358 ) -> Vec<OwnedSegment> {
4359 if segment_id == "SEQ" && group_path == ["SG4", "SG8"] && instance_index == 0 {
4360 vec![OwnedSegment {
4361 id: "SEQ".into(),
4362 elements: vec![vec!["Z03".into()]],
4363 segment_number: 10,
4364 }]
4365 } else {
4366 vec![]
4367 }
4368 }
4369 fn find_segments_with_qualifier_in_group(
4370 &self,
4371 _: &str,
4372 _: usize,
4373 _: &str,
4374 _: &[&str],
4375 _: usize,
4376 ) -> Vec<OwnedSegment> {
4377 vec![]
4378 }
4379 fn group_instance_count(&self, group_path: &[&str]) -> usize {
4380 match group_path {
4381 ["SG4"] => 1,
4382 ["SG4", "SG8"] => 1,
4383 _ => 0,
4384 }
4385 }
4386 }
4387
4388 let evaluator = MockEvaluator::new(vec![(345, CR::True)]);
4389 let validator = EdifactValidator::new(evaluator);
4390 let segments = vec![OwnedSegment {
4391 id: "SEQ".into(),
4392 elements: vec![vec!["Z03".into()]],
4393 segment_number: 10,
4394 }];
4395 let seq = |code: &str, group: &str| AhbFieldRule {
4396 segment_path: "SG4/SG8/SEQ/1229".to_string(),
4397 name: format!("Handlung, Code ({code})"),
4398 ahb_status: "X".to_string(),
4399 codes: vec![AhbCodeRule {
4400 value: code.to_string(),
4401 description: String::new(),
4402 ahb_status: "X".to_string(),
4403 }],
4404 parent_group_ahb_status: Some(group.to_string()),
4405 segment_ahb_status: None,
4406 ..Default::default()
4407 };
4408 let workflow = AhbWorkflow {
4409 pruefidentifikator: "44143".to_string(),
4410 description: "Test".to_string(),
4411 communication_direction: None,
4412 fields: vec![seq("Z03", "Kann"), seq("Z50", "Kann [345]")],
4413 ub_definitions: BTreeMap::new(),
4414 package_conditions: Default::default(),
4415 };
4416
4417 let report = validator.validate_with_navigator(
4418 &segments,
4419 &workflow,
4420 &NoOpExternalProvider,
4421 ValidationLevel::Conditions,
4422 &TestNav,
4423 );
4424 let missing: Vec<_> = report
4425 .issues
4426 .iter()
4427 .filter(|i| matches!(i.kind, IssueKind::MissingRequiredField { .. }))
4428 .collect();
4429 assert!(missing.is_empty(), "{missing:#?}");
4430 }
4431
4432 #[test]
4433 fn test_conditional_group_variant_absent_no_error() {
4434 use mig_types::navigator::GroupNavigator;
4439
4440 struct TestNav;
4441 impl GroupNavigator for TestNav {
4442 fn find_segments_in_group(
4443 &self,
4444 segment_id: &str,
4445 group_path: &[&str],
4446 instance_index: usize,
4447 ) -> Vec<OwnedSegment> {
4448 if segment_id == "LOC" && group_path == ["SG4", "SG5"] && instance_index == 0 {
4449 vec![OwnedSegment {
4450 id: "LOC".into(),
4451 elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
4452 segment_number: 10,
4453 }]
4454 } else {
4455 vec![]
4456 }
4457 }
4458 fn find_segments_with_qualifier_in_group(
4459 &self,
4460 _: &str,
4461 _: usize,
4462 _: &str,
4463 _: &[&str],
4464 _: usize,
4465 ) -> Vec<OwnedSegment> {
4466 vec![]
4467 }
4468 fn group_instance_count(&self, group_path: &[&str]) -> usize {
4469 match group_path {
4470 ["SG4"] => 1,
4471 ["SG4", "SG5"] => 1, _ => 0,
4473 }
4474 }
4475 }
4476
4477 let evaluator = MockEvaluator::new(vec![(165, CR::False), (2061, CR::True)]);
4480 let validator = EdifactValidator::new(evaluator);
4481 let external = NoOpExternalProvider;
4482 let nav = TestNav;
4483
4484 let segments = vec![OwnedSegment {
4485 id: "LOC".into(),
4486 elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
4487 segment_number: 10,
4488 }];
4489
4490 let workflow = AhbWorkflow {
4491 pruefidentifikator: "55001".to_string(),
4492 description: "Test".to_string(),
4493 communication_direction: None,
4494 fields: vec![
4495 AhbFieldRule {
4497 segment_path: "SG4/SG5/LOC/3227".to_string(),
4498 name: "Ortsangabe, Qualifier (Z16)".to_string(),
4499 ahb_status: "X".to_string(),
4500 codes: vec![AhbCodeRule {
4501 value: "Z16".to_string(),
4502 description: "Marktlokation".to_string(),
4503 ahb_status: "X".to_string(),
4504 }],
4505 parent_group_ahb_status: Some("Muss [2061]".to_string()),
4506 segment_ahb_status: None,
4507 ..Default::default()
4508 },
4509 AhbFieldRule {
4511 segment_path: "SG4/SG5/LOC/3227".to_string(),
4512 name: "Ortsangabe, Qualifier (Z17)".to_string(),
4513 ahb_status: "X".to_string(),
4514 codes: vec![AhbCodeRule {
4515 value: "Z17".to_string(),
4516 description: "Messlokation".to_string(),
4517 ahb_status: "X".to_string(),
4518 }],
4519 parent_group_ahb_status: Some("Soll [165]".to_string()),
4520 segment_ahb_status: None,
4521 ..Default::default()
4522 },
4523 ],
4524 ub_definitions: BTreeMap::new(),
4525 package_conditions: Default::default(),
4526 };
4527
4528 let report = validator.validate_with_navigator(
4529 &segments,
4530 &workflow,
4531 &external,
4532 ValidationLevel::Conditions,
4533 &nav,
4534 );
4535
4536 let ahb_errors: Vec<_> = report
4537 .by_category(ValidationCategory::Ahb)
4538 .filter(|i| i.severity == Severity::Error)
4539 .collect();
4540
4541 assert!(
4543 ahb_errors.is_empty(),
4544 "Expected no errors when conditional group variant [165]=False, got: {:?}",
4545 ahb_errors
4546 );
4547 }
4548
4549 #[test]
4550 fn test_conditional_group_variant_unknown_no_error() {
4551 let evaluator = MockEvaluator::new(vec![]);
4557 let validator = EdifactValidator::new(evaluator);
4558 let external = NoOpExternalProvider;
4559
4560 let workflow = AhbWorkflow {
4561 pruefidentifikator: "55001".to_string(),
4562 description: "Test".to_string(),
4563 communication_direction: None,
4564 fields: vec![AhbFieldRule {
4565 segment_path: "SG4/SG5/LOC/3227".to_string(),
4566 name: "Ortsangabe, Qualifier (Z17)".to_string(),
4567 ahb_status: "X".to_string(),
4568 codes: vec![AhbCodeRule {
4569 value: "Z17".to_string(),
4570 description: "Messlokation".to_string(),
4571 ahb_status: "X".to_string(),
4572 }],
4573 parent_group_ahb_status: Some("Soll [165]".to_string()),
4574 segment_ahb_status: None,
4575 ..Default::default()
4576 }],
4577 ub_definitions: BTreeMap::new(),
4578 package_conditions: Default::default(),
4579 };
4580
4581 let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
4582
4583 let ahb_errors: Vec<_> = report
4584 .by_category(ValidationCategory::Ahb)
4585 .filter(|i| i.severity == Severity::Error)
4586 .collect();
4587
4588 assert!(
4590 ahb_errors.is_empty(),
4591 "Expected no errors when parent group condition is Unknown, got: {:?}",
4592 ahb_errors
4593 );
4594 }
4595
4596 #[test]
4597 fn test_segment_absent_within_present_group_no_error() {
4598 use mig_types::navigator::GroupNavigator;
4602
4603 struct TestNav;
4604 impl GroupNavigator for TestNav {
4605 fn find_segments_in_group(
4606 &self,
4607 segment_id: &str,
4608 group_path: &[&str],
4609 instance_index: usize,
4610 ) -> Vec<OwnedSegment> {
4611 if segment_id == "QTY"
4613 && group_path == ["SG5", "SG6", "SG9", "SG10"]
4614 && instance_index == 0
4615 {
4616 vec![OwnedSegment {
4617 id: "QTY".into(),
4618 elements: vec![vec!["220".into(), "0".into()]],
4619 segment_number: 14,
4620 }]
4621 } else {
4622 vec![]
4623 }
4624 }
4625 fn find_segments_with_qualifier_in_group(
4626 &self,
4627 _: &str,
4628 _: usize,
4629 _: &str,
4630 _: &[&str],
4631 _: usize,
4632 ) -> Vec<OwnedSegment> {
4633 vec![]
4634 }
4635 fn group_instance_count(&self, group_path: &[&str]) -> usize {
4636 match group_path {
4637 ["SG5"] => 1,
4638 ["SG5", "SG6"] => 1,
4639 ["SG5", "SG6", "SG9"] => 1,
4640 ["SG5", "SG6", "SG9", "SG10"] => 1,
4641 _ => 0,
4642 }
4643 }
4644 fn has_any_segment_in_group(&self, group_path: &[&str], instance_index: usize) -> bool {
4645 group_path == ["SG5", "SG6", "SG9", "SG10"] && instance_index == 0
4647 }
4648 }
4649
4650 let evaluator = MockEvaluator::all_true(&[]);
4651 let validator = EdifactValidator::new(evaluator);
4652 let external = NoOpExternalProvider;
4653 let nav = TestNav;
4654
4655 let segments = vec![OwnedSegment {
4656 id: "QTY".into(),
4657 elements: vec![vec!["220".into(), "0".into()]],
4658 segment_number: 14,
4659 }];
4660
4661 let workflow = AhbWorkflow {
4662 pruefidentifikator: "13017".to_string(),
4663 description: "Test".to_string(),
4664 communication_direction: None,
4665 fields: vec![
4666 AhbFieldRule {
4668 segment_path: "SG5/SG6/SG9/SG10/STS/C601/9015".to_string(),
4669 name: "Statuskategorie, Code".to_string(),
4670 ahb_status: "X".to_string(),
4671 codes: vec![],
4672 parent_group_ahb_status: Some("Muss".to_string()),
4673 segment_ahb_status: None,
4674 ..Default::default()
4675 },
4676 AhbFieldRule {
4678 segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
4679 name: "Statusanlaß, Code".to_string(),
4680 ahb_status: "X [5]".to_string(),
4681 codes: vec![],
4682 parent_group_ahb_status: Some("Muss".to_string()),
4683 segment_ahb_status: None,
4684 ..Default::default()
4685 },
4686 ],
4687 ub_definitions: BTreeMap::new(),
4688 package_conditions: Default::default(),
4689 };
4690
4691 let report = validator.validate_with_navigator(
4692 &segments,
4693 &workflow,
4694 &external,
4695 ValidationLevel::Conditions,
4696 &nav,
4697 );
4698
4699 let ahb_errors: Vec<_> = report
4700 .by_category(ValidationCategory::Ahb)
4701 .filter(|i| i.severity == Severity::Error)
4702 .collect();
4703
4704 assert!(
4705 ahb_errors.is_empty(),
4706 "Expected no AHB001 errors when STS segment is absent from SG10, got: {:?}",
4707 ahb_errors
4708 );
4709 }
4710
4711 #[test]
4712 fn test_group_scoped_code_validation_with_navigator() {
4713 use mig_types::navigator::GroupNavigator;
4717
4718 struct TestNav;
4719 impl GroupNavigator for TestNav {
4720 fn find_segments_in_group(
4721 &self,
4722 segment_id: &str,
4723 group_path: &[&str],
4724 _instance_index: usize,
4725 ) -> Vec<OwnedSegment> {
4726 if segment_id != "NAD" {
4727 return vec![];
4728 }
4729 match group_path {
4730 ["SG2"] => vec![
4731 OwnedSegment {
4732 id: "NAD".into(),
4733 elements: vec![vec!["MS".into()]],
4734 segment_number: 3,
4735 },
4736 OwnedSegment {
4737 id: "NAD".into(),
4738 elements: vec![vec!["MT".into()]], segment_number: 4,
4740 },
4741 ],
4742 ["SG4", "SG12"] => vec![
4743 OwnedSegment {
4744 id: "NAD".into(),
4745 elements: vec![vec!["Z04".into()]],
4746 segment_number: 20,
4747 },
4748 OwnedSegment {
4749 id: "NAD".into(),
4750 elements: vec![vec!["Z09".into()]],
4751 segment_number: 21,
4752 },
4753 ],
4754 _ => vec![],
4755 }
4756 }
4757 fn find_segments_with_qualifier_in_group(
4758 &self,
4759 _: &str,
4760 _: usize,
4761 _: &str,
4762 _: &[&str],
4763 _: usize,
4764 ) -> Vec<OwnedSegment> {
4765 vec![]
4766 }
4767 fn group_instance_count(&self, group_path: &[&str]) -> usize {
4768 match group_path {
4769 ["SG2"] | ["SG4", "SG12"] => 1,
4770 _ => 0,
4771 }
4772 }
4773 }
4774
4775 let evaluator = MockEvaluator::new(vec![]);
4776 let validator = EdifactValidator::new(evaluator);
4777 let external = NoOpExternalProvider;
4778 let nav = TestNav;
4779
4780 let workflow = AhbWorkflow {
4781 pruefidentifikator: "55001".to_string(),
4782 description: "Test".to_string(),
4783 communication_direction: None,
4784 fields: vec![
4785 AhbFieldRule {
4786 segment_path: "SG2/NAD/3035".to_string(),
4787 name: "Absender".to_string(),
4788 ahb_status: "X".to_string(),
4789 codes: vec![AhbCodeRule {
4790 value: "MS".to_string(),
4791 description: "Absender".to_string(),
4792 ahb_status: "X".to_string(),
4793 }],
4794 parent_group_ahb_status: None,
4795 segment_ahb_status: None,
4796 ..Default::default()
4797 },
4798 AhbFieldRule {
4799 segment_path: "SG2/NAD/3035".to_string(),
4800 name: "Empfaenger".to_string(),
4801 ahb_status: "X".to_string(),
4802 codes: vec![AhbCodeRule {
4803 value: "MR".to_string(),
4804 description: "Empfaenger".to_string(),
4805 ahb_status: "X".to_string(),
4806 }],
4807 parent_group_ahb_status: None,
4808 segment_ahb_status: None,
4809 ..Default::default()
4810 },
4811 AhbFieldRule {
4812 segment_path: "SG4/SG12/NAD/3035".to_string(),
4813 name: "Anschlussnutzer".to_string(),
4814 ahb_status: "X".to_string(),
4815 codes: vec![AhbCodeRule {
4816 value: "Z04".to_string(),
4817 description: "Anschlussnutzer".to_string(),
4818 ahb_status: "X".to_string(),
4819 }],
4820 parent_group_ahb_status: None,
4821 segment_ahb_status: None,
4822 ..Default::default()
4823 },
4824 AhbFieldRule {
4825 segment_path: "SG4/SG12/NAD/3035".to_string(),
4826 name: "Korrespondenzanschrift".to_string(),
4827 ahb_status: "X".to_string(),
4828 codes: vec![AhbCodeRule {
4829 value: "Z09".to_string(),
4830 description: "Korrespondenzanschrift".to_string(),
4831 ahb_status: "X".to_string(),
4832 }],
4833 parent_group_ahb_status: None,
4834 segment_ahb_status: None,
4835 ..Default::default()
4836 },
4837 ],
4838 ub_definitions: BTreeMap::new(),
4839 package_conditions: Default::default(),
4840 };
4841
4842 let all_segments = vec![
4844 OwnedSegment {
4845 id: "NAD".into(),
4846 elements: vec![vec!["MS".into()]],
4847 segment_number: 3,
4848 },
4849 OwnedSegment {
4850 id: "NAD".into(),
4851 elements: vec![vec!["MT".into()]],
4852 segment_number: 4,
4853 },
4854 OwnedSegment {
4855 id: "NAD".into(),
4856 elements: vec![vec!["Z04".into()]],
4857 segment_number: 20,
4858 },
4859 OwnedSegment {
4860 id: "NAD".into(),
4861 elements: vec![vec!["Z09".into()]],
4862 segment_number: 21,
4863 },
4864 ];
4865
4866 let report = validator.validate_with_navigator(
4867 &all_segments,
4868 &workflow,
4869 &external,
4870 ValidationLevel::Conditions,
4871 &nav,
4872 );
4873
4874 let code_errors: Vec<_> = report
4875 .by_category(ValidationCategory::Code)
4876 .filter(|i| i.severity == Severity::Error)
4877 .collect();
4878
4879 assert_eq!(
4882 code_errors.len(),
4883 1,
4884 "Expected exactly one COD002 error for MT in SG2, got: {:?}",
4885 code_errors
4886 );
4887 assert!(narrate(code_errors[0]).contains("MT"));
4888 assert!(narrate(code_errors[0]).contains("MR"));
4890 assert!(narrate(code_errors[0]).contains("MS"));
4891 assert!(
4892 !narrate(code_errors[0]).contains("Z04"),
4893 "SG4/SG12 codes should not leak into SG2 error"
4894 );
4895 assert!(
4897 code_errors[0]
4898 .field_path
4899 .as_deref()
4900 .unwrap_or("")
4901 .contains("SG2"),
4902 "Error field_path should reference SG2, got: {:?}",
4903 code_errors[0].field_path
4904 );
4905 }
4906
4907 #[test]
4910 fn test_package_cardinality_within_bounds() {
4911 let evaluator = MockEvaluator::all_true(&[]);
4913 let validator = EdifactValidator::new(evaluator);
4914 let external = NoOpExternalProvider;
4915
4916 let segments = vec![OwnedSegment {
4917 id: "STS".into(),
4918 elements: vec![
4919 vec!["Z33".into()], vec![], vec!["E01".into()], ],
4923 segment_number: 5,
4924 }];
4925
4926 let workflow = AhbWorkflow {
4927 pruefidentifikator: "13017".to_string(),
4928 description: "Test".to_string(),
4929 communication_direction: None,
4930 ub_definitions: BTreeMap::new(),
4931 package_conditions: Default::default(),
4932 fields: vec![AhbFieldRule {
4933 segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
4934 name: "Statusanlaß, Code".to_string(),
4935 ahb_status: "X".to_string(),
4936 element_index: Some(2),
4937 component_index: Some(0),
4938 codes: vec![
4939 AhbCodeRule {
4940 value: "E01".into(),
4941 description: "Code 1".into(),
4942 ahb_status: "X [4P0..1]".into(),
4943 },
4944 AhbCodeRule {
4945 value: "E02".into(),
4946 description: "Code 2".into(),
4947 ahb_status: "X [4P0..1]".into(),
4948 },
4949 ],
4950 parent_group_ahb_status: Some("Muss".to_string()),
4951 segment_ahb_status: None,
4952 mig_number: None,
4953 format: None,
4954 }],
4955 };
4956
4957 let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
4958 let pkg_errors: Vec<_> = report
4959 .by_category(ValidationCategory::Ahb)
4960 .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
4961 .collect();
4962 assert!(
4963 pkg_errors.is_empty(),
4964 "1 code within [4P0..1] bounds — no error expected, got: {:?}",
4965 pkg_errors
4966 );
4967 }
4968
4969 #[test]
4970 fn test_package_cardinality_zero_present_min_zero() {
4971 let evaluator = MockEvaluator::all_true(&[]);
4973 let validator = EdifactValidator::new(evaluator);
4974 let external = NoOpExternalProvider;
4975
4976 let segments = vec![OwnedSegment {
4977 id: "STS".into(),
4978 elements: vec![
4979 vec!["Z33".into()],
4980 vec![],
4981 vec!["X99".into()], ],
4983 segment_number: 5,
4984 }];
4985
4986 let workflow = AhbWorkflow {
4987 pruefidentifikator: "13017".to_string(),
4988 description: "Test".to_string(),
4989 communication_direction: None,
4990 ub_definitions: BTreeMap::new(),
4991 package_conditions: Default::default(),
4992 fields: vec![AhbFieldRule {
4993 segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
4994 name: "Statusanlaß, Code".to_string(),
4995 ahb_status: "X".to_string(),
4996 element_index: Some(2),
4997 component_index: Some(0),
4998 codes: vec![
4999 AhbCodeRule {
5000 value: "E01".into(),
5001 description: "Code 1".into(),
5002 ahb_status: "X [4P0..1]".into(),
5003 },
5004 AhbCodeRule {
5005 value: "E02".into(),
5006 description: "Code 2".into(),
5007 ahb_status: "X [4P0..1]".into(),
5008 },
5009 ],
5010 parent_group_ahb_status: Some("Muss".to_string()),
5011 segment_ahb_status: None,
5012 mig_number: None,
5013 format: None,
5014 }],
5015 };
5016
5017 let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
5018 let pkg_errors: Vec<_> = report
5019 .by_category(ValidationCategory::Ahb)
5020 .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
5021 .collect();
5022 assert!(
5023 pkg_errors.is_empty(),
5024 "0 codes, min=0 — no error expected, got: {:?}",
5025 pkg_errors
5026 );
5027 }
5028
5029 #[test]
5030 fn test_package_cardinality_too_many() {
5031 let evaluator = MockEvaluator::all_true(&[]);
5033 let validator = EdifactValidator::new(evaluator);
5034 let external = NoOpExternalProvider;
5035
5036 let segments = vec![
5038 OwnedSegment {
5039 id: "STS".into(),
5040 elements: vec![vec!["Z33".into()], vec![], vec!["E01".into()]],
5041 segment_number: 5,
5042 },
5043 OwnedSegment {
5044 id: "STS".into(),
5045 elements: vec![vec!["Z33".into()], vec![], vec!["E02".into()]],
5046 segment_number: 6,
5047 },
5048 ];
5049
5050 let workflow = AhbWorkflow {
5051 pruefidentifikator: "13017".to_string(),
5052 description: "Test".to_string(),
5053 communication_direction: None,
5054 ub_definitions: BTreeMap::new(),
5055 package_conditions: Default::default(),
5056 fields: vec![AhbFieldRule {
5057 segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
5058 name: "Statusanlaß, Code".to_string(),
5059 ahb_status: "X".to_string(),
5060 element_index: Some(2),
5061 component_index: Some(0),
5062 codes: vec![
5063 AhbCodeRule {
5064 value: "E01".into(),
5065 description: "Code 1".into(),
5066 ahb_status: "X [4P0..1]".into(),
5067 },
5068 AhbCodeRule {
5069 value: "E02".into(),
5070 description: "Code 2".into(),
5071 ahb_status: "X [4P0..1]".into(),
5072 },
5073 ],
5074 parent_group_ahb_status: Some("Muss".to_string()),
5075 segment_ahb_status: None,
5076 mig_number: None,
5077 format: None,
5078 }],
5079 };
5080
5081 let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
5082 let pkg_errors: Vec<_> = report
5083 .by_category(ValidationCategory::Ahb)
5084 .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
5085 .collect();
5086 assert_eq!(
5087 pkg_errors.len(),
5088 1,
5089 "2 codes present, max=1 — expected 1 error, got: {:?}",
5090 pkg_errors
5091 );
5092 assert!(narrate(pkg_errors[0]).contains("[4P0..1]"));
5093 assert_eq!(pkg_errors[0].actual_value.as_deref(), Some("2"));
5094 assert_eq!(pkg_errors[0].expected_value.as_deref(), Some("0..1"));
5095 }
5096
5097 #[test]
5098 fn test_package_cardinality_too_few() {
5099 let evaluator = MockEvaluator::all_true(&[]);
5101 let validator = EdifactValidator::new(evaluator);
5102 let external = NoOpExternalProvider;
5103
5104 let segments = vec![OwnedSegment {
5105 id: "STS".into(),
5106 elements: vec![
5107 vec!["Z33".into()],
5108 vec![],
5109 vec!["X99".into()], ],
5111 segment_number: 5,
5112 }];
5113
5114 let workflow = AhbWorkflow {
5115 pruefidentifikator: "13017".to_string(),
5116 description: "Test".to_string(),
5117 communication_direction: None,
5118 ub_definitions: BTreeMap::new(),
5119 package_conditions: Default::default(),
5120 fields: vec![AhbFieldRule {
5121 segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
5122 name: "Statusanlaß, Code".to_string(),
5123 ahb_status: "X".to_string(),
5124 element_index: Some(2),
5125 component_index: Some(0),
5126 codes: vec![
5127 AhbCodeRule {
5128 value: "E01".into(),
5129 description: "Code 1".into(),
5130 ahb_status: "X [5P1..3]".into(),
5131 },
5132 AhbCodeRule {
5133 value: "E02".into(),
5134 description: "Code 2".into(),
5135 ahb_status: "X [5P1..3]".into(),
5136 },
5137 AhbCodeRule {
5138 value: "E03".into(),
5139 description: "Code 3".into(),
5140 ahb_status: "X [5P1..3]".into(),
5141 },
5142 ],
5143 parent_group_ahb_status: Some("Muss".to_string()),
5144 segment_ahb_status: None,
5145 mig_number: None,
5146 format: None,
5147 }],
5148 };
5149
5150 let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
5151 let pkg_errors: Vec<_> = report
5152 .by_category(ValidationCategory::Ahb)
5153 .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
5154 .collect();
5155 assert_eq!(
5156 pkg_errors.len(),
5157 1,
5158 "0 codes present, min=1 — expected 1 error, got: {:?}",
5159 pkg_errors
5160 );
5161 assert!(narrate(pkg_errors[0]).contains("[5P1..3]"));
5162 assert_eq!(pkg_errors[0].actual_value.as_deref(), Some("0"));
5163 assert_eq!(pkg_errors[0].expected_value.as_deref(), Some("1..3"));
5164 }
5165
5166 #[test]
5167 fn test_package_cardinality_no_packages_in_workflow() {
5168 let evaluator = MockEvaluator::all_true(&[]);
5170 let validator = EdifactValidator::new(evaluator);
5171 let external = NoOpExternalProvider;
5172
5173 let segments = vec![OwnedSegment {
5174 id: "STS".into(),
5175 elements: vec![vec!["E01".into()]],
5176 segment_number: 5,
5177 }];
5178
5179 let workflow = AhbWorkflow {
5180 pruefidentifikator: "13017".to_string(),
5181 description: "Test".to_string(),
5182 communication_direction: None,
5183 ub_definitions: BTreeMap::new(),
5184 package_conditions: Default::default(),
5185 fields: vec![AhbFieldRule {
5186 segment_path: "STS/9015".to_string(),
5187 name: "Status Code".to_string(),
5188 ahb_status: "X".to_string(),
5189 codes: vec![AhbCodeRule {
5190 value: "E01".into(),
5191 description: "Code 1".into(),
5192 ahb_status: "X".into(),
5193 }],
5194 parent_group_ahb_status: Some("Muss".to_string()),
5195 segment_ahb_status: None,
5196 ..Default::default()
5197 }],
5198 };
5199
5200 let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
5201 let pkg_errors: Vec<_> = report
5202 .by_category(ValidationCategory::Ahb)
5203 .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
5204 .collect();
5205 assert!(
5206 pkg_errors.is_empty(),
5207 "No packages in workflow — no errors expected"
5208 );
5209 }
5210
5211 #[test]
5212 fn test_package_cardinality_with_condition_and_package() {
5213 let evaluator = MockEvaluator::all_true(&[901]);
5215 let validator = EdifactValidator::new(evaluator);
5216 let external = NoOpExternalProvider;
5217
5218 let segments = vec![OwnedSegment {
5219 id: "STS".into(),
5220 elements: vec![vec![], vec![], vec!["E01".into()]],
5221 segment_number: 5,
5222 }];
5223
5224 let workflow = AhbWorkflow {
5225 pruefidentifikator: "13017".to_string(),
5226 description: "Test".to_string(),
5227 communication_direction: None,
5228 ub_definitions: BTreeMap::new(),
5229 package_conditions: Default::default(),
5230 fields: vec![AhbFieldRule {
5231 segment_path: "SG10/STS/C556/9013".to_string(),
5232 name: "Code".to_string(),
5233 ahb_status: "X".to_string(),
5234 element_index: Some(2),
5235 component_index: Some(0),
5236 codes: vec![
5237 AhbCodeRule {
5238 value: "E01".into(),
5239 description: "Code 1".into(),
5240 ahb_status: "X [901] [4P0..1]".into(),
5241 },
5242 AhbCodeRule {
5243 value: "E02".into(),
5244 description: "Code 2".into(),
5245 ahb_status: "X [901] [4P0..1]".into(),
5246 },
5247 ],
5248 parent_group_ahb_status: Some("Muss".to_string()),
5249 segment_ahb_status: None,
5250 mig_number: None,
5251 format: None,
5252 }],
5253 };
5254
5255 let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
5256 let pkg_errors: Vec<_> = report
5257 .by_category(ValidationCategory::Ahb)
5258 .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
5259 .collect();
5260 assert!(
5261 pkg_errors.is_empty(),
5262 "1 code within [4P0..1] bounds — no error, got: {:?}",
5263 pkg_errors
5264 );
5265 }
5266
5267 #[test]
5268 fn test_package_binds_only_where_its_condition_holds() {
5269 let report_for = |package_condition: CR| {
5273 let validator =
5274 EdifactValidator::new(MockEvaluator::new(vec![(902, package_condition)]));
5275 let segments = vec![OwnedSegment {
5276 id: "STS".into(),
5277 elements: vec![vec![], vec![], vec!["E09".into()]],
5278 segment_number: 5,
5279 }];
5280 let workflow = AhbWorkflow {
5281 pruefidentifikator: "13017".to_string(),
5282 description: "Test".to_string(),
5283 communication_direction: None,
5284 ub_definitions: BTreeMap::new(),
5285 package_conditions: BTreeMap::from([(5, ConditionExpr::Ref(902))]),
5286 fields: vec![AhbFieldRule {
5287 segment_path: "SG10/STS/C556/9013".to_string(),
5288 name: "Code".to_string(),
5289 ahb_status: "X".to_string(),
5290 element_index: Some(2),
5291 component_index: Some(0),
5292 codes: vec![
5293 AhbCodeRule {
5294 value: "E01".into(),
5295 description: "Code 1".into(),
5296 ahb_status: "X [5P1..1]".into(),
5297 },
5298 AhbCodeRule {
5299 value: "E09".into(),
5300 description: "Code 9".into(),
5301 ahb_status: "X".into(),
5302 },
5303 ],
5304 parent_group_ahb_status: Some("Muss".to_string()),
5305 segment_ahb_status: None,
5306 mig_number: None,
5307 format: None,
5308 }],
5309 };
5310 validator
5311 .validate(
5312 &segments,
5313 &workflow,
5314 &NoOpExternalProvider,
5315 ValidationLevel::Full,
5316 )
5317 .by_category(ValidationCategory::Ahb)
5318 .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
5319 .count()
5320 };
5321 assert_eq!(report_for(CR::False), 0, "[902] false: [5P] does not bind");
5322 assert_eq!(report_for(CR::True), 1, "[902] true: [5P1..1] binds");
5323 }
5324
5325 #[test]
5326 fn test_package_cardinality_scoped_per_group_instance() {
5327 use mig_types::navigator::GroupNavigator;
5335
5336 struct TwoSg10s {
5337 sts_a: OwnedSegment,
5338 sts_b: OwnedSegment,
5339 }
5340 impl GroupNavigator for TwoSg10s {
5341 fn find_segments_in_group(
5342 &self,
5343 segment_id: &str,
5344 group_path: &[&str],
5345 instance_index: usize,
5346 ) -> Vec<OwnedSegment> {
5347 if group_path == ["SG5", "SG6", "SG9", "SG10"] && segment_id == "STS" {
5348 match instance_index {
5349 0 => vec![self.sts_a.clone()],
5350 1 => vec![self.sts_b.clone()],
5351 _ => vec![],
5352 }
5353 } else {
5354 vec![]
5355 }
5356 }
5357 fn find_segments_with_qualifier_in_group(
5358 &self,
5359 _: &str,
5360 _: usize,
5361 _: &str,
5362 _: &[&str],
5363 _: usize,
5364 ) -> Vec<OwnedSegment> {
5365 vec![]
5366 }
5367 fn group_instance_count(&self, group_path: &[&str]) -> usize {
5368 match group_path {
5369 ["SG5"] | ["SG5", "SG6"] | ["SG5", "SG6", "SG9"] => 1,
5370 ["SG5", "SG6", "SG9", "SG10"] => 2,
5371 _ => 0,
5372 }
5373 }
5374 }
5375
5376 let sts_a = OwnedSegment {
5377 id: "STS".into(),
5378 elements: vec![vec!["Z32".into()], vec![], vec!["E01".into()]],
5379 segment_number: 10,
5380 };
5381 let sts_b = OwnedSegment {
5382 id: "STS".into(),
5383 elements: vec![vec!["Z32".into()], vec![], vec!["E01".into()]],
5384 segment_number: 15,
5385 };
5386 let nav = TwoSg10s {
5387 sts_a: sts_a.clone(),
5388 sts_b: sts_b.clone(),
5389 };
5390
5391 let evaluator = MockEvaluator::all_true(&[]);
5392 let validator = EdifactValidator::new(evaluator);
5393 let external = NoOpExternalProvider;
5394
5395 let workflow = AhbWorkflow {
5396 pruefidentifikator: "13025".to_string(),
5397 description: "Test".to_string(),
5398 communication_direction: None,
5399 ub_definitions: BTreeMap::new(),
5400 package_conditions: Default::default(),
5401 fields: vec![AhbFieldRule {
5402 segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
5403 name: "Statusanlaß, Code".to_string(),
5404 ahb_status: "X".to_string(),
5405 element_index: Some(2),
5406 component_index: Some(0),
5407 codes: vec![
5408 AhbCodeRule {
5409 value: "E01".into(),
5410 description: "Code 1".into(),
5411 ahb_status: "X [4P0..1]".into(),
5412 },
5413 AhbCodeRule {
5414 value: "E02".into(),
5415 description: "Code 2".into(),
5416 ahb_status: "X [4P0..1]".into(),
5417 },
5418 ],
5419 parent_group_ahb_status: Some("Muss".to_string()),
5420 segment_ahb_status: None,
5421 mig_number: None,
5422 format: None,
5423 }],
5424 };
5425
5426 let report = validator.validate_with_navigator(
5427 &[sts_a, sts_b],
5428 &workflow,
5429 &external,
5430 ValidationLevel::Full,
5431 &nav,
5432 );
5433 let pkg_errors: Vec<_> = report
5434 .by_category(ValidationCategory::Ahb)
5435 .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
5436 .collect();
5437 assert!(
5438 pkg_errors.is_empty(),
5439 "Package cardinality is per-instance: 1 code per SG10 rep is within [4P0..1]. Got: {:?}",
5440 pkg_errors
5441 );
5442 }
5443
5444 fn make_segment(id: &str, elements: Vec<Vec<&str>>) -> OwnedSegment {
5445 OwnedSegment {
5446 id: id.to_string(),
5447 elements: elements
5448 .into_iter()
5449 .map(|e| e.into_iter().map(|s| s.to_string()).collect())
5450 .collect(),
5451 segment_number: 0,
5452 }
5453 }
5454
5455 #[test]
5466 fn test_package_cardinality_scoped_to_rule_mig_variant() {
5467 use mig_types::navigator::GroupNavigator;
5468
5469 struct TwoSg8Variants {
5470 seq_z01: OwnedSegment,
5471 seq_z45: OwnedSegment,
5472 }
5473 impl GroupNavigator for TwoSg8Variants {
5474 fn find_segments_in_group(
5475 &self,
5476 segment_id: &str,
5477 group_path: &[&str],
5478 instance_index: usize,
5479 ) -> Vec<OwnedSegment> {
5480 if group_path == ["SG4", "SG8"] && segment_id == "SEQ" {
5481 match instance_index {
5482 0 => vec![self.seq_z01.clone()],
5483 1 => vec![self.seq_z45.clone()],
5484 _ => vec![],
5485 }
5486 } else {
5487 vec![]
5488 }
5489 }
5490 fn find_segments_with_qualifier_in_group(
5491 &self,
5492 _: &str,
5493 _: usize,
5494 _: &str,
5495 _: &[&str],
5496 _: usize,
5497 ) -> Vec<OwnedSegment> {
5498 vec![]
5499 }
5500 fn group_instance_count(&self, group_path: &[&str]) -> usize {
5501 match group_path {
5502 ["SG4"] => 1,
5503 ["SG4", "SG8"] => 2,
5504 _ => 0,
5505 }
5506 }
5507 fn instance_has_mig_number(
5508 &self,
5509 group_path: &[&str],
5510 instance_index: usize,
5511 mig_number: &str,
5512 ) -> bool {
5513 if group_path != ["SG4", "SG8"] {
5514 return true;
5515 }
5516 match (instance_index, mig_number) {
5517 (0, "00115") => true,
5518 (0, _) => false,
5519 (1, "00171") => true,
5520 (1, _) => false,
5521 _ => false,
5522 }
5523 }
5524 }
5525
5526 let seq_z01 = OwnedSegment {
5527 id: "SEQ".into(),
5528 elements: vec![vec!["Z01".into()], vec!["1".into()]],
5529 segment_number: 10,
5530 };
5531 let seq_z45 = OwnedSegment {
5532 id: "SEQ".into(),
5533 elements: vec![vec!["Z45".into()], vec!["1".into()]],
5534 segment_number: 20,
5535 };
5536 let nav = TwoSg8Variants {
5537 seq_z01: seq_z01.clone(),
5538 seq_z45: seq_z45.clone(),
5539 };
5540
5541 let evaluator = MockEvaluator::all_true(&[]);
5542 let validator = EdifactValidator::new(evaluator);
5543 let external = NoOpExternalProvider;
5544
5545 let workflow = AhbWorkflow {
5546 pruefidentifikator: "55218".to_string(),
5547 description: "Test".to_string(),
5548 communication_direction: None,
5549 ub_definitions: BTreeMap::new(),
5550 package_conditions: Default::default(),
5551 fields: vec![AhbFieldRule {
5552 segment_path: "SG4/SG8/SEQ/1229".to_string(),
5553 name: "Handlung, Code".to_string(),
5554 ahb_status: "X".to_string(),
5555 element_index: Some(0),
5556 component_index: Some(0),
5557 codes: vec![
5558 AhbCodeRule {
5559 value: "Z45".into(),
5560 description: "NNA".into(),
5561 ahb_status: "X [1P1..4294967295]".into(),
5562 },
5563 AhbCodeRule {
5564 value: "Z84".into(),
5565 description: "Differenz-NNA".into(),
5566 ahb_status: "X [1P0..4294967295]".into(),
5567 },
5568 ],
5569 parent_group_ahb_status: Some("Muss".to_string()),
5570 segment_ahb_status: None,
5571 mig_number: Some("00171".to_string()),
5572 format: None,
5573 }],
5574 };
5575
5576 let report = validator.validate_with_navigator(
5577 &[seq_z01, seq_z45],
5578 &workflow,
5579 &external,
5580 ValidationLevel::Full,
5581 &nav,
5582 );
5583 let pkg_errors: Vec<_> = report
5584 .by_category(ValidationCategory::Ahb)
5585 .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
5586 .collect();
5587 assert!(
5588 pkg_errors.is_empty(),
5589 "Package rule with mig=00171 must only count the Z45 instance (which has 1 code), not the sibling Z01 variant. Got: {:?}",
5590 pkg_errors
5591 );
5592 }
5593
5594 #[test]
5595 fn test_unt_count_correct() {
5596 let segments = vec![
5598 make_segment("UNH", vec![vec!["001"]]),
5599 make_segment("BGM", vec![vec!["E01"]]),
5600 make_segment("DTM", vec![vec!["137", "20250401"]]),
5601 make_segment("UNT", vec![vec!["4", "001"]]),
5602 ];
5603 assert!(
5604 validate_unt_segment_count(&segments).is_none(),
5605 "Correct count should produce no issue"
5606 );
5607 }
5608
5609 #[test]
5610 fn test_unt_count_mismatch() {
5611 let segments = vec![
5613 make_segment("UNH", vec![vec!["001"]]),
5614 make_segment("BGM", vec![vec!["E01"]]),
5615 make_segment("UNT", vec![vec!["5", "001"]]),
5616 ];
5617 let issue =
5618 validate_unt_segment_count(&segments).expect("Mismatch should produce an issue");
5619 assert_eq!(issue.code(), ErrorCodes::UNT_SEGMENT_COUNT_MISMATCH);
5620 assert_eq!(issue.severity, Severity::Error);
5621 assert!(narrate(&issue).contains("declared 5"));
5622 assert!(narrate(&issue).contains("actual 3"));
5623 }
5624
5625 #[test]
5626 fn test_unt_count_excludes_envelope() {
5627 let segments = vec![
5629 make_segment("UNA", vec![]),
5630 make_segment("UNB", vec![vec!["UNOC", "3"]]),
5631 make_segment("UNH", vec![vec!["001"]]),
5632 make_segment("BGM", vec![vec!["E01"]]),
5633 make_segment("UNT", vec![vec!["3", "001"]]),
5634 make_segment("UNZ", vec![vec!["1"]]),
5635 ];
5636 assert!(
5637 validate_unt_segment_count(&segments).is_none(),
5638 "Envelope segments excluded — count should be 3 (UNH+BGM+UNT)"
5639 );
5640 }
5641
5642 #[test]
5643 fn test_unt_count_no_unt_returns_none() {
5644 let segments = vec![
5645 make_segment("UNH", vec![vec!["001"]]),
5646 make_segment("BGM", vec![vec!["E01"]]),
5647 ];
5648 assert!(
5649 validate_unt_segment_count(&segments).is_none(),
5650 "No UNT segment should return None (not our problem)"
5651 );
5652 }
5653
5654 #[test]
5655 fn test_unt_count_rejects_multi_message_input() {
5656 let segments = vec![
5658 make_segment("UNH", vec![vec!["001"]]),
5659 make_segment("BGM", vec![vec!["E01"]]),
5660 make_segment("UNT", vec![vec!["3", "001"]]),
5661 make_segment("UNH", vec![vec!["002"]]),
5662 make_segment("BGM", vec![vec!["E02"]]),
5663 make_segment("UNT", vec![vec!["3", "002"]]),
5664 ];
5665 let issue = validate_unt_segment_count(&segments)
5666 .expect("Multi-message input should produce an error");
5667 assert_eq!(issue.code(), ErrorCodes::UNT_SEGMENT_COUNT_MISMATCH);
5668 assert!(
5669 narrate(&issue).contains("2 UNH"),
5670 "Should mention UNH count: {}",
5671 narrate(&issue)
5672 );
5673 }
5674
5675 #[test]
5676 fn test_code_validation_accepts_multi_code_variant_qualifier() {
5677 let evaluator = MockEvaluator::new(vec![]);
5684 let validator = EdifactValidator::new(evaluator);
5685 let external = NoOpExternalProvider;
5686
5687 let rff_z39 = OwnedSegment {
5688 id: "RFF".to_string(),
5689 elements: vec![
5690 vec!["RFF".to_string()],
5691 vec!["Z39".to_string(), "REF1".to_string()],
5692 ],
5693 segment_number: 1,
5694 };
5695
5696 let workflow = AhbWorkflow {
5697 pruefidentifikator: "55035".to_string(),
5698 description: "Test".to_string(),
5699 communication_direction: None,
5700 fields: vec![
5701 AhbFieldRule {
5703 segment_path: "SG4/SG8/RFF/C506/1153".to_string(),
5704 name: "Referenznummer Qualifier".to_string(),
5705 ahb_status: "Muss".to_string(),
5706 codes: vec![
5707 AhbCodeRule {
5708 value: "Z31".to_string(),
5709 description: "".to_string(),
5710 ahb_status: "X".to_string(),
5711 },
5712 AhbCodeRule {
5713 value: "Z39".to_string(),
5714 description: "".to_string(),
5715 ahb_status: "X".to_string(),
5716 },
5717 ],
5718 parent_group_ahb_status: None,
5719 segment_ahb_status: None,
5720 element_index: Some(1),
5721 component_index: Some(0),
5722 mig_number: Some("00075".to_string()),
5723 format: None,
5724 },
5725 AhbFieldRule {
5727 segment_path: "SG4/SG8/RFF/C506/1153".to_string(),
5728 name: "Referenznummer Qualifier".to_string(),
5729 ahb_status: "Muss".to_string(),
5730 codes: vec![AhbCodeRule {
5731 value: "Z33".to_string(),
5732 description: "".to_string(),
5733 ahb_status: "X".to_string(),
5734 }],
5735 parent_group_ahb_status: None,
5736 segment_ahb_status: None,
5737 element_index: Some(1),
5738 component_index: Some(0),
5739 mig_number: Some("00078".to_string()),
5740 format: None,
5741 },
5742 ],
5743 ub_definitions: BTreeMap::new(),
5744 package_conditions: Default::default(),
5745 };
5746
5747 let report = validator.validate(
5748 &[rff_z39],
5749 &workflow,
5750 &external,
5751 ValidationLevel::Conditions,
5752 );
5753
5754 let code_errors: Vec<_> = report
5755 .by_category(ValidationCategory::Code)
5756 .filter(|i| {
5757 i.severity == Severity::Error && i.code() == ErrorCodes::CODE_NOT_ALLOWED_FOR_PID
5758 })
5759 .collect();
5760 assert!(
5761 code_errors.is_empty(),
5762 "RFF+Z39 must be accepted (Z39 is valid for mig=00075). Got: {:?}",
5763 code_errors
5764 );
5765 }
5766
5767 #[test]
5768 fn test_code_validation_disambiguates_migs_by_full_code_profile() {
5769 let evaluator = MockEvaluator::new(vec![]);
5775 let validator = EdifactValidator::new(evaluator);
5776 let external = NoOpExternalProvider;
5777
5778 let pia_5_z12 = OwnedSegment {
5782 id: "PIA".to_string(),
5783 elements: vec![vec!["5".to_string()], vec!["Z12".to_string()]],
5784 segment_number: 1,
5785 };
5786 let pia_5_srw = OwnedSegment {
5787 id: "PIA".to_string(),
5788 elements: vec![vec!["5".to_string()], vec!["SRW".to_string()]],
5789 segment_number: 2,
5790 };
5791
5792 let make_rules = |mig: &str, composite_code: &str| {
5793 vec![
5794 AhbFieldRule {
5795 segment_path: "SG4/SG8/PIA/4347".to_string(),
5796 name: "Produkt-ID-Funktion".to_string(),
5797 ahb_status: "Muss".to_string(),
5798 codes: vec![AhbCodeRule {
5799 value: "5".to_string(),
5800 description: "".to_string(),
5801 ahb_status: "X".to_string(),
5802 }],
5803 parent_group_ahb_status: None,
5804 segment_ahb_status: None,
5805 element_index: Some(0),
5806 component_index: Some(0),
5807 mig_number: Some(mig.to_string()),
5808 format: None,
5809 },
5810 AhbFieldRule {
5811 segment_path: "SG4/SG8/PIA/C212/7143".to_string(),
5812 name: "Artikel/Dienstleistung-Identifikator".to_string(),
5813 ahb_status: "Muss".to_string(),
5814 codes: vec![AhbCodeRule {
5815 value: composite_code.to_string(),
5816 description: "".to_string(),
5817 ahb_status: "X".to_string(),
5818 }],
5819 parent_group_ahb_status: None,
5820 segment_ahb_status: None,
5821 element_index: Some(1),
5822 component_index: Some(0),
5823 mig_number: Some(mig.to_string()),
5824 format: None,
5825 },
5826 ]
5827 };
5828
5829 let mut fields = make_rules("00108", "Z12");
5830 fields.extend(make_rules("00197", "SRW"));
5831
5832 let workflow = AhbWorkflow {
5833 pruefidentifikator: "55035".to_string(),
5834 description: "Test".to_string(),
5835 communication_direction: None,
5836 fields,
5837 ub_definitions: BTreeMap::new(),
5838 package_conditions: Default::default(),
5839 };
5840
5841 let report = validator.validate(
5842 &[pia_5_z12, pia_5_srw],
5843 &workflow,
5844 &external,
5845 ValidationLevel::Conditions,
5846 );
5847
5848 let code_errors: Vec<_> = report
5849 .by_category(ValidationCategory::Code)
5850 .filter(|i| {
5851 i.severity == Severity::Error && i.code() == ErrorCodes::CODE_NOT_ALLOWED_FOR_PID
5852 })
5853 .collect();
5854 assert!(
5855 code_errors.is_empty(),
5856 "Both PIA+5+Z12 (mig=00108) and PIA+5+SRW (mig=00197) must be accepted. Got: {:?}",
5857 code_errors
5858 );
5859 }
5860}