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