1use std::collections::{HashMap, HashSet};
4
5use crate::expr::{ConditionExpr, ConditionParser};
6
7use crate::eval::{
8 ConditionEvaluator, ConditionExprEvaluator, ConditionResult, EvaluationContext,
9 ExternalConditionProvider,
10};
11use mig_types::navigator::GroupNavigator;
12use mig_types::segment::OwnedSegment;
13
14use super::tree::{AhbGroupNode, AhbNode, ValidatedTree};
15
16use super::codes::ErrorCodes;
17use super::issue::{Severity, ValidationCategory, ValidationIssue};
18use super::level::ValidationLevel;
19use super::report::ValidationReport;
20
21#[derive(Debug, Clone, Default)]
26pub struct AhbFieldRule {
27 pub segment_path: String,
29
30 pub name: String,
32
33 pub ahb_status: String,
35
36 pub codes: Vec<AhbCodeRule>,
38
39 pub parent_group_ahb_status: Option<String>,
44
45 pub element_index: Option<usize>,
48
49 pub component_index: Option<usize>,
52
53 pub mig_number: Option<String>,
56}
57
58#[derive(Debug, Clone, Default)]
60pub struct AhbCodeRule {
61 pub value: String,
63
64 pub description: String,
66
67 pub ahb_status: String,
69}
70
71#[derive(Debug, Clone)]
73pub struct AhbWorkflow {
74 pub pruefidentifikator: String,
76
77 pub description: String,
79
80 pub communication_direction: Option<String>,
82
83 pub fields: Vec<AhbFieldRule>,
85
86 pub ub_definitions: HashMap<String, ConditionExpr>,
92}
93
94pub struct EdifactValidator<E: ConditionEvaluator> {
127 evaluator: E,
128}
129
130impl<E: ConditionEvaluator> EdifactValidator<E> {
131 pub fn new(evaluator: E) -> Self {
133 Self { evaluator }
134 }
135
136 pub fn validate(
149 &self,
150 segments: &[OwnedSegment],
151 workflow: &AhbWorkflow,
152 external: &dyn ExternalConditionProvider,
153 level: ValidationLevel,
154 ) -> ValidationReport {
155 let mut report = ValidationReport::new(self.evaluator.message_type(), level)
156 .with_format_version(self.evaluator.format_version())
157 .with_pruefidentifikator(&workflow.pruefidentifikator);
158
159 let ctx = EvaluationContext::new(&workflow.pruefidentifikator, external, segments);
160
161 if matches!(level, ValidationLevel::Conditions | ValidationLevel::Full) {
162 self.validate_conditions(workflow, &ctx, &mut report);
163 }
164
165 report
166 }
167
168 pub fn validate_with_navigator(
174 &self,
175 segments: &[OwnedSegment],
176 workflow: &AhbWorkflow,
177 external: &dyn ExternalConditionProvider,
178 level: ValidationLevel,
179 navigator: &dyn GroupNavigator,
180 ) -> ValidationReport {
181 let mut report = ValidationReport::new(self.evaluator.message_type(), level)
182 .with_format_version(self.evaluator.format_version())
183 .with_pruefidentifikator(&workflow.pruefidentifikator);
184
185 let ctx = EvaluationContext::with_navigator(
186 &workflow.pruefidentifikator,
187 external,
188 segments,
189 navigator,
190 );
191
192 if matches!(level, ValidationLevel::Conditions | ValidationLevel::Full) {
193 self.validate_conditions(workflow, &ctx, &mut report);
194 }
195
196 report
197 }
198
199 pub fn validate_tree(
206 &self,
207 validated_tree: &ValidatedTree,
208 segments: &[OwnedSegment],
209 external: &dyn ExternalConditionProvider,
210 level: ValidationLevel,
211 navigator: Option<&dyn GroupNavigator>,
212 ) -> ValidationReport {
213 let mut report = ValidationReport::new(self.evaluator.message_type(), level)
214 .with_format_version(self.evaluator.format_version())
215 .with_pruefidentifikator(validated_tree.pruefidentifikator);
216
217 if !matches!(level, ValidationLevel::Conditions | ValidationLevel::Full) {
218 return report;
219 }
220
221 let ctx = match navigator {
222 Some(nav) => EvaluationContext::with_navigator(
223 validated_tree.pruefidentifikator,
224 external,
225 segments,
226 nav,
227 ),
228 None => EvaluationContext::new(
229 validated_tree.pruefidentifikator,
230 external,
231 segments,
232 ),
233 };
234
235 let expr_eval = ConditionExprEvaluator::new(&self.evaluator);
236
237 let mut all_nodes: Vec<&AhbNode> = Vec::new();
239 all_nodes.extend(validated_tree.root_fields.iter());
240 for group in &validated_tree.groups {
241 collect_nodes_depth_first(group, &mut all_nodes);
242 }
243
244 for node in &all_nodes {
246 let field = node.rule;
247
248 let node_ctx = ctx.with_resolved(node.value, node.segment_elements);
250
251 if should_skip_for_parent_group(field, &expr_eval, &ctx, validated_tree.ub_definitions)
253 {
254 continue;
255 }
256
257 let (condition_result, unknown_ids) = expr_eval.evaluate_status_detailed_with_ub(
260 &field.ahb_status,
261 &node_ctx,
262 validated_tree.ub_definitions,
263 );
264
265 match condition_result {
266 ConditionResult::True => {
267 if is_mandatory_status(&field.ahb_status) && node.value.is_none() {
271 let mut issue = ValidationIssue::new(
272 Severity::Error,
273 ValidationCategory::Ahb,
274 ErrorCodes::MISSING_REQUIRED_FIELD,
275 format!(
276 "Required field '{}' at {} is missing",
277 field.name, field.segment_path
278 ),
279 )
280 .with_field_path(&field.segment_path)
281 .with_rule(&field.ahb_status);
282 if let Some(first_code) = field.codes.first() {
283 issue.expected_value = Some(first_code.value.clone());
284 }
285 report.add_issue(issue);
286 }
287 }
288 ConditionResult::False => {
289 if is_mandatory_status(&field.ahb_status) && node.value.is_some() {
292 report.add_issue(
293 ValidationIssue::new(
294 Severity::Error,
295 ValidationCategory::Ahb,
296 ErrorCodes::CONDITIONAL_RULE_VIOLATION,
297 format!(
298 "Field '{}' at {} is present but does not satisfy condition: {}",
299 field.name, field.segment_path, field.ahb_status
300 ),
301 )
302 .with_field_path(&field.segment_path)
303 .with_rule(&field.ahb_status),
304 );
305 }
306 }
307 ConditionResult::Unknown => {
308 let mut external_ids = Vec::new();
310 let mut undetermined_ids = Vec::new();
311 let mut missing_ids = Vec::new();
312 for id in unknown_ids {
313 if self.evaluator.is_external(id) {
314 external_ids.push(id);
315 } else if self.evaluator.is_known(id) {
316 undetermined_ids.push(id);
317 } else {
318 missing_ids.push(id);
319 }
320 }
321
322 let mut parts = Vec::new();
323 if !external_ids.is_empty() {
324 let ids: Vec<String> =
325 external_ids.iter().map(|id| format!("[{id}]")).collect();
326 parts.push(format!(
327 "external conditions require provider: {}",
328 ids.join(", ")
329 ));
330 }
331 if !undetermined_ids.is_empty() {
332 let ids: Vec<String> = undetermined_ids
333 .iter()
334 .map(|id| format!("[{id}]"))
335 .collect();
336 parts.push(format!(
337 "conditions could not be determined from message data: {}",
338 ids.join(", ")
339 ));
340 }
341 if !missing_ids.is_empty() {
342 let ids: Vec<String> =
343 missing_ids.iter().map(|id| format!("[{id}]")).collect();
344 parts.push(format!("missing conditions: {}", ids.join(", ")));
345 }
346 let detail = if parts.is_empty() {
347 String::new()
348 } else {
349 format!(" ({})", parts.join("; "))
350 };
351 report.add_issue(
352 ValidationIssue::new(
353 Severity::Info,
354 ValidationCategory::Ahb,
355 ErrorCodes::CONDITION_UNKNOWN,
356 format!(
357 "Condition for field '{}' could not be fully evaluated{}",
358 field.name, detail
359 ),
360 )
361 .with_field_path(&field.segment_path)
362 .with_rule(&field.ahb_status),
363 );
364 }
365 }
366 }
367
368 for field in &validated_tree.unmatched_rules {
374 if should_skip_for_parent_group(field, &expr_eval, &ctx, validated_tree.ub_definitions)
375 {
376 continue;
377 }
378
379 let (condition_result, _) = expr_eval.evaluate_status_detailed_with_ub(
380 &field.ahb_status,
381 &ctx,
382 validated_tree.ub_definitions,
383 );
384
385 if matches!(condition_result, ConditionResult::True)
386 && is_mandatory_status(&field.ahb_status)
387 && !is_field_present(&ctx, field)
388 && !is_group_variant_absent(&ctx, field)
389 {
390 let mut issue = ValidationIssue::new(
391 Severity::Error,
392 ValidationCategory::Ahb,
393 ErrorCodes::MISSING_REQUIRED_FIELD,
394 format!(
395 "Required field '{}' at {} is missing",
396 field.name, field.segment_path
397 ),
398 )
399 .with_field_path(&field.segment_path)
400 .with_rule(&field.ahb_status);
401 if let Some(first_code) = field.codes.first() {
402 issue.expected_value = Some(first_code.value.clone());
403 }
404 report.add_issue(issue);
405 }
406 }
407
408 report
415 }
416
417 fn validate_conditions(
419 &self,
420 workflow: &AhbWorkflow,
421 ctx: &EvaluationContext,
422 report: &mut ValidationReport,
423 ) {
424 let expr_eval = ConditionExprEvaluator::new(&self.evaluator);
425
426 for field in &workflow.fields {
427 if should_skip_for_parent_group(field, &expr_eval, ctx, &workflow.ub_definitions) {
429 continue;
430 }
431
432 let (condition_result, unknown_ids) = expr_eval.evaluate_status_detailed_with_ub(
435 &field.ahb_status,
436 ctx,
437 &workflow.ub_definitions,
438 );
439
440 match condition_result {
441 ConditionResult::True => {
442 if is_mandatory_status(&field.ahb_status)
444 && !is_field_present(ctx, field)
445 && !is_group_variant_absent(ctx, field)
446 {
447 let mut issue = ValidationIssue::new(
448 Severity::Error,
449 ValidationCategory::Ahb,
450 ErrorCodes::MISSING_REQUIRED_FIELD,
451 format!(
452 "Required field '{}' at {} is missing",
453 field.name, field.segment_path
454 ),
455 )
456 .with_field_path(&field.segment_path)
457 .with_rule(&field.ahb_status);
458 if let Some(first_code) = field.codes.first() {
462 issue.expected_value = Some(first_code.value.clone());
463 }
464 report.add_issue(issue);
465 }
466 }
467 ConditionResult::False => {
468 if is_mandatory_status(&field.ahb_status)
474 && is_field_present(ctx, field)
475 {
476 report.add_issue(
477 ValidationIssue::new(
478 Severity::Error,
479 ValidationCategory::Ahb,
480 ErrorCodes::CONDITIONAL_RULE_VIOLATION,
481 format!(
482 "Field '{}' at {} is present but does not satisfy condition: {}",
483 field.name, field.segment_path, field.ahb_status
484 ),
485 )
486 .with_field_path(&field.segment_path)
487 .with_rule(&field.ahb_status),
488 );
489 }
490 }
491 ConditionResult::Unknown => {
492 let mut external_ids = Vec::new();
497 let mut undetermined_ids = Vec::new();
498 let mut missing_ids = Vec::new();
499 for id in unknown_ids {
500 if self.evaluator.is_external(id) {
501 external_ids.push(id);
502 } else if self.evaluator.is_known(id) {
503 undetermined_ids.push(id);
504 } else {
505 missing_ids.push(id);
506 }
507 }
508
509 let mut parts = Vec::new();
510 if !external_ids.is_empty() {
511 let ids: Vec<String> =
512 external_ids.iter().map(|id| format!("[{id}]")).collect();
513 parts.push(format!(
514 "external conditions require provider: {}",
515 ids.join(", ")
516 ));
517 }
518 if !undetermined_ids.is_empty() {
519 let ids: Vec<String> = undetermined_ids
520 .iter()
521 .map(|id| format!("[{id}]"))
522 .collect();
523 parts.push(format!(
524 "conditions could not be determined from message data: {}",
525 ids.join(", ")
526 ));
527 }
528 if !missing_ids.is_empty() {
529 let ids: Vec<String> =
530 missing_ids.iter().map(|id| format!("[{id}]")).collect();
531 parts.push(format!("missing conditions: {}", ids.join(", ")));
532 }
533 let detail = if parts.is_empty() {
534 String::new()
535 } else {
536 format!(" ({})", parts.join("; "))
537 };
538 report.add_issue(
539 ValidationIssue::new(
540 Severity::Info,
541 ValidationCategory::Ahb,
542 ErrorCodes::CONDITION_UNKNOWN,
543 format!(
544 "Condition for field '{}' could not be fully evaluated{}",
545 field.name, detail
546 ),
547 )
548 .with_field_path(&field.segment_path)
549 .with_rule(&field.ahb_status),
550 );
551 }
552 }
553 }
554
555 self.validate_codes_cross_field(workflow, ctx, report);
560
561 self.validate_package_cardinality(workflow, ctx, report);
564 }
565
566 fn validate_package_cardinality(
573 &self,
574 workflow: &AhbWorkflow,
575 ctx: &EvaluationContext,
576 report: &mut ValidationReport,
577 ) {
578 struct PackageGroup {
581 min: u32,
582 max: u32,
583 code_values: Vec<String>,
584 element_index: usize,
585 component_index: usize,
586 }
587
588 let mut groups: HashMap<(String, u32), PackageGroup> = HashMap::new();
590
591 let expr_eval = ConditionExprEvaluator::new(&self.evaluator);
592
593 for field in &workflow.fields {
594 let el_idx = field.element_index.unwrap_or(0);
595 let comp_idx = field.component_index.unwrap_or(0);
596
597 if should_skip_for_parent_group(field, &expr_eval, ctx, &workflow.ub_definitions) {
599 continue;
600 }
601
602 for code in &field.codes {
603 if let Ok(Some(expr)) = ConditionParser::parse(&code.ahb_status) {
605 let mut packages = Vec::new();
607 collect_packages(&expr, &mut packages);
608
609 for (pkg_id, pkg_min, pkg_max) in packages {
610 let key = (field.segment_path.clone(), pkg_id);
611 let group = groups.entry(key).or_insert_with(|| PackageGroup {
612 min: pkg_min,
613 max: pkg_max,
614 code_values: Vec::new(),
615 element_index: el_idx,
616 component_index: comp_idx,
617 });
618 group.min = group.min.max(pkg_min);
621 group.max = group.max.min(pkg_max);
622 group.code_values.push(code.value.clone());
623 }
624 }
625 }
626 }
627
628 for ((seg_path, pkg_id), group) in &groups {
630 let segment_id = extract_segment_id(seg_path);
631 let segments = ctx.find_segments(&segment_id);
632
633 let mut present_count: usize = 0;
635 for seg in &segments {
636 if let Some(value) = seg
637 .elements
638 .get(group.element_index)
639 .and_then(|e| e.get(group.component_index))
640 .filter(|v| !v.is_empty())
641 {
642 if group.code_values.contains(value) {
643 present_count += 1;
644 }
645 }
646 }
647
648 let min = group.min as usize;
649 let max = group.max as usize;
650
651 if present_count < min || present_count > max {
652 let code_list = group.code_values.join(", ");
653 report.add_issue(
654 ValidationIssue::new(
655 Severity::Error,
656 ValidationCategory::Ahb,
657 ErrorCodes::PACKAGE_CARDINALITY_VIOLATION,
658 format!(
659 "Package [{}P{}..{}] at {}: {} code(s) present (allowed {}..{}). Codes in package: [{}]",
660 pkg_id, group.min, group.max, seg_path, present_count, group.min, group.max, code_list
661 ),
662 )
663 .with_field_path(seg_path)
664 .with_expected(format!("{}..{}", group.min, group.max))
665 .with_actual(present_count.to_string()),
666 );
667 }
668 }
669 }
670
671 fn validate_codes_cross_field(
683 &self,
684 workflow: &AhbWorkflow,
685 ctx: &EvaluationContext,
686 report: &mut ValidationReport,
687 ) {
688 if ctx.navigator.is_some() {
689 self.validate_codes_group_scoped(workflow, ctx, report);
690 } else {
691 self.validate_codes_tag_scoped(workflow, ctx, report);
692 }
693 }
694
695 fn validate_codes_group_scoped(
698 &self,
699 workflow: &AhbWorkflow,
700 ctx: &EvaluationContext,
701 report: &mut ValidationReport,
702 ) {
703 type CodeKey = (String, String, usize, usize);
706 let mut codes_by_group: HashMap<CodeKey, HashSet<&str>> = HashMap::new();
707
708 for field in &workflow.fields {
709 if field.codes.is_empty() || !is_qualifier_field(&field.segment_path) {
710 continue;
711 }
712 let tag = extract_segment_id(&field.segment_path);
713 let group_key = extract_group_path_key(&field.segment_path);
714 let el_idx = field.element_index.unwrap_or(0);
715 let comp_idx = field.component_index.unwrap_or(0);
716 let entry = codes_by_group
717 .entry((group_key, tag, el_idx, comp_idx))
718 .or_default();
719 for code in &field.codes {
720 if code.ahb_status == "X" || code.ahb_status.starts_with("Muss") {
721 entry.insert(&code.value);
722 }
723 }
724 }
725
726 let nav = ctx.navigator.unwrap();
727
728 for ((group_key, tag, el_idx, comp_idx), allowed_codes) in &codes_by_group {
729 if allowed_codes.is_empty() {
730 continue;
731 }
732
733 let group_path: Vec<&str> = if group_key.is_empty() {
734 Vec::new()
735 } else {
736 group_key.split('/').collect()
737 };
738
739 if group_path.is_empty() {
741 Self::check_segments_against_codes(
743 ctx.find_segments(tag),
744 allowed_codes,
745 tag,
746 *el_idx,
747 *comp_idx,
748 &format!("{tag}/qualifier"),
749 report,
750 );
751 } else {
752 let instance_count = nav.group_instance_count(&group_path);
753 for i in 0..instance_count {
754 let segments = nav.find_segments_in_group(tag, &group_path, i);
755 let refs: Vec<&OwnedSegment> = segments.iter().collect();
756 Self::check_segments_against_codes(
757 refs,
758 allowed_codes,
759 tag,
760 *el_idx,
761 *comp_idx,
762 &format!("{group_key}/{tag}/qualifier"),
763 report,
764 );
765 }
766 }
767 }
768 }
769
770 fn validate_codes_tag_scoped(
773 &self,
774 workflow: &AhbWorkflow,
775 ctx: &EvaluationContext,
776 report: &mut ValidationReport,
777 ) {
778 let mut codes_by_tag: HashMap<(String, usize, usize), HashSet<&str>> = HashMap::new();
780
781 for field in &workflow.fields {
782 if field.codes.is_empty() || !is_qualifier_field(&field.segment_path) {
783 continue;
784 }
785 let tag = extract_segment_id(&field.segment_path);
786 let el_idx = field.element_index.unwrap_or(0);
787 let comp_idx = field.component_index.unwrap_or(0);
788 let entry = codes_by_tag.entry((tag, el_idx, comp_idx)).or_default();
789 for code in &field.codes {
790 if code.ahb_status == "X" || code.ahb_status.starts_with("Muss") {
791 entry.insert(&code.value);
792 }
793 }
794 }
795
796 for ((tag, el_idx, comp_idx), allowed_codes) in &codes_by_tag {
797 if allowed_codes.is_empty() {
798 continue;
799 }
800 Self::check_segments_against_codes(
801 ctx.find_segments(tag),
802 allowed_codes,
803 tag,
804 *el_idx,
805 *comp_idx,
806 &format!("{tag}/qualifier"),
807 report,
808 );
809 }
810 }
811
812 fn check_segments_against_codes(
814 segments: Vec<&OwnedSegment>,
815 allowed_codes: &HashSet<&str>,
816 _tag: &str,
817 el_idx: usize,
818 comp_idx: usize,
819 field_path: &str,
820 report: &mut ValidationReport,
821 ) {
822 for segment in segments {
823 if let Some(code_value) = segment
824 .elements
825 .get(el_idx)
826 .and_then(|e| e.get(comp_idx))
827 .filter(|v| !v.is_empty())
828 {
829 if !allowed_codes.contains(code_value.as_str()) {
830 let mut sorted_codes: Vec<&str> = allowed_codes.iter().copied().collect();
831 sorted_codes.sort_unstable();
832 report.add_issue(
833 ValidationIssue::new(
834 Severity::Error,
835 ValidationCategory::Code,
836 ErrorCodes::CODE_NOT_ALLOWED_FOR_PID,
837 format!(
838 "Code '{}' is not allowed for this PID. Allowed: [{}]",
839 code_value,
840 sorted_codes.join(", ")
841 ),
842 )
843 .with_field_path(field_path)
844 .with_actual(code_value)
845 .with_expected(sorted_codes.join(", ")),
846 );
847 }
848 }
849 }
850 }
851}
852
853fn should_skip_for_parent_group<E: ConditionEvaluator>(
859 field: &AhbFieldRule,
860 expr_eval: &ConditionExprEvaluator<E>,
861 ctx: &EvaluationContext,
862 ub_definitions: &HashMap<String, ConditionExpr>,
863) -> bool {
864 if let Some(ref group_status) = field.parent_group_ahb_status {
865 if group_status.contains('[') {
866 let result = expr_eval.evaluate_status_with_ub(group_status, ctx, ub_definitions);
867 return matches!(result, ConditionResult::False | ConditionResult::Unknown);
868 }
869 }
870 false
871}
872
873fn is_field_present(ctx: &EvaluationContext, field: &AhbFieldRule) -> bool {
882 let segment_id = extract_segment_id(&field.segment_path);
883
884 if !field.codes.is_empty() {
890 if let (Some(el_idx), Some(comp_idx)) = (field.element_index, field.component_index) {
891 let required_codes: Vec<&str> =
892 field.codes.iter().map(|c| c.value.as_str()).collect();
893 let matching = ctx.find_segments(&segment_id);
894 return matching.iter().any(|seg| {
895 seg.elements
896 .get(el_idx)
897 .and_then(|e| e.get(comp_idx))
898 .is_some_and(|v| required_codes.contains(&v.as_str()))
899 });
900 }
901 if is_qualifier_field(&field.segment_path) {
904 let required_codes: Vec<&str> =
905 field.codes.iter().map(|c| c.value.as_str()).collect();
906 let el_idx = field.element_index.unwrap_or(0);
907 let comp_idx = field.component_index.unwrap_or(0);
908 let matching = ctx.find_segments(&segment_id);
909 return matching.iter().any(|seg| {
910 seg.elements
911 .get(el_idx)
912 .and_then(|e| e.get(comp_idx))
913 .is_some_and(|v| required_codes.contains(&v.as_str()))
914 });
915 }
916 }
917
918 ctx.has_segment(&segment_id)
919}
920
921fn is_group_variant_absent(ctx: &EvaluationContext, field: &AhbFieldRule) -> bool {
936 let group_path: Vec<&str> = field
937 .segment_path
938 .split('/')
939 .take_while(|p| p.starts_with("SG"))
940 .collect();
941
942 if group_path.is_empty() {
943 return false;
944 }
945
946 let nav = match ctx.navigator {
947 Some(nav) => nav,
948 None => return false,
949 };
950
951 let instance_count = nav.group_instance_count(&group_path);
952
953 if instance_count == 0 {
958 let is_group_mandatory = field
959 .parent_group_ahb_status
960 .as_deref()
961 .is_some_and(is_mandatory_status);
962 if !is_group_mandatory {
963 return true;
964 }
965 return false;
967 }
968
969 if let Some(ref group_status) = field.parent_group_ahb_status {
973 if !is_mandatory_status(group_status) && !group_status.contains('[') {
974 if !field.codes.is_empty() && is_qualifier_field(&field.segment_path) {
977 let segment_id = extract_segment_id(&field.segment_path);
978 let required_codes: Vec<&str> =
979 field.codes.iter().map(|c| c.value.as_str()).collect();
980
981 let any_instance_has_qualifier = (0..instance_count).any(|i| {
982 nav.find_segments_in_group(&segment_id, &group_path, i)
983 .iter()
984 .any(|seg| {
985 seg.elements
986 .first()
987 .and_then(|e| e.first())
988 .is_some_and(|v| required_codes.contains(&v.as_str()))
989 })
990 });
991
992 if !any_instance_has_qualifier {
993 return true; }
995 }
996 }
997 }
998
999 let segment_id = extract_segment_id(&field.segment_path);
1008 let segment_absent_from_all = (0..instance_count).all(|i| {
1009 nav.find_segments_in_group(&segment_id, &group_path, i)
1010 .is_empty()
1011 });
1012 if segment_absent_from_all {
1013 let group_has_other_segments =
1014 (0..instance_count).any(|i| nav.has_any_segment_in_group(&group_path, i));
1015 if group_has_other_segments {
1016 return true;
1017 }
1018 }
1019
1020 false
1021}
1022
1023fn collect_nodes_depth_first<'a, 'b>(group: &'b AhbGroupNode<'a>, out: &mut Vec<&'b AhbNode<'a>>) {
1025 out.extend(group.fields.iter());
1026 for child in &group.children {
1027 collect_nodes_depth_first(child, out);
1028 }
1029}
1030
1031fn collect_packages(expr: &ConditionExpr, out: &mut Vec<(u32, u32, u32)>) {
1033 match expr {
1034 ConditionExpr::Package { id, min, max } => {
1035 out.push((*id, *min, *max));
1036 }
1037 ConditionExpr::And(exprs) | ConditionExpr::Or(exprs) => {
1038 for e in exprs {
1039 collect_packages(e, out);
1040 }
1041 }
1042 ConditionExpr::Xor(left, right) => {
1043 collect_packages(left, out);
1044 collect_packages(right, out);
1045 }
1046 ConditionExpr::Not(inner) => {
1047 collect_packages(inner, out);
1048 }
1049 ConditionExpr::Ref(_) => {}
1050 }
1051}
1052
1053fn is_mandatory_status(status: &str) -> bool {
1055 let trimmed = status.trim();
1056 trimmed.starts_with("Muss") || trimmed.starts_with('X')
1057}
1058
1059fn is_qualifier_field(path: &str) -> bool {
1068 let parts: Vec<&str> = path.split('/').filter(|p| !p.starts_with("SG")).collect();
1069 parts.len() == 2
1072}
1073
1074fn extract_group_path_key(path: &str) -> String {
1079 let sg_parts: Vec<&str> = path
1080 .split('/')
1081 .take_while(|p| p.starts_with("SG"))
1082 .collect();
1083 sg_parts.join("/")
1084}
1085
1086fn extract_segment_id(path: &str) -> String {
1088 for part in path.split('/') {
1089 if part.starts_with("SG") || part.starts_with("C_") || part.starts_with("D_") {
1091 continue;
1092 }
1093 if part.len() >= 3
1095 && part
1096 .chars()
1097 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
1098 {
1099 return part.to_string();
1100 }
1101 }
1102 path.split('/').next_back().unwrap_or(path).to_string()
1104}
1105
1106pub fn validate_unt_segment_count(segments: &[OwnedSegment]) -> Option<ValidationIssue> {
1118 let unt = segments.iter().rfind(|s| s.id == "UNT")?;
1120 let declared: usize = unt.get_element(0).parse().ok()?;
1121
1122 let actual = segments
1124 .iter()
1125 .filter(|s| s.id != "UNA" && s.id != "UNB" && s.id != "UNZ")
1126 .count();
1127
1128 if declared != actual {
1129 Some(
1130 ValidationIssue::new(
1131 Severity::Error,
1132 ValidationCategory::Structure,
1133 ErrorCodes::UNT_SEGMENT_COUNT_MISMATCH,
1134 format!(
1135 "UNT segment count mismatch: declared {declared}, actual {actual}"
1136 ),
1137 )
1138 .with_field_path("UNT/0074")
1139 .with_expected(actual.to_string())
1140 .with_actual(declared.to_string()),
1141 )
1142 } else {
1143 None
1144 }
1145}
1146
1147#[cfg(test)]
1148mod tests {
1149 use super::*;
1150 use crate::eval::{ConditionResult as CR, NoOpExternalProvider};
1151 use std::collections::HashMap;
1152
1153 struct MockEvaluator {
1155 results: HashMap<u32, CR>,
1156 }
1157
1158 impl MockEvaluator {
1159 fn new(results: Vec<(u32, CR)>) -> Self {
1160 Self {
1161 results: results.into_iter().collect(),
1162 }
1163 }
1164
1165 fn all_true(ids: &[u32]) -> Self {
1166 Self::new(ids.iter().map(|&id| (id, CR::True)).collect())
1167 }
1168 }
1169
1170 impl ConditionEvaluator for MockEvaluator {
1171 fn evaluate(&self, condition: u32, _ctx: &EvaluationContext) -> CR {
1172 self.results.get(&condition).copied().unwrap_or(CR::Unknown)
1173 }
1174 fn is_external(&self, _condition: u32) -> bool {
1175 false
1176 }
1177 fn message_type(&self) -> &str {
1178 "UTILMD"
1179 }
1180 fn format_version(&self) -> &str {
1181 "FV2510"
1182 }
1183 }
1184
1185 #[test]
1188 fn test_is_mandatory_status() {
1189 assert!(is_mandatory_status("Muss"));
1190 assert!(is_mandatory_status("Muss [182] ∧ [152]"));
1191 assert!(is_mandatory_status("X"));
1192 assert!(is_mandatory_status("X [567]"));
1193 assert!(!is_mandatory_status("Soll [1]"));
1194 assert!(!is_mandatory_status("Kann [1]"));
1195 assert!(!is_mandatory_status(""));
1196 }
1197
1198 #[test]
1199 fn test_extract_segment_id_simple() {
1200 assert_eq!(extract_segment_id("NAD"), "NAD");
1201 }
1202
1203 #[test]
1204 fn test_extract_segment_id_with_sg_prefix() {
1205 assert_eq!(extract_segment_id("SG2/NAD/C082/3039"), "NAD");
1206 }
1207
1208 #[test]
1209 fn test_extract_segment_id_nested_sg() {
1210 assert_eq!(extract_segment_id("SG4/SG8/SEQ/C286/6350"), "SEQ");
1211 }
1212
1213 #[test]
1216 fn test_validate_missing_mandatory_field() {
1217 let evaluator = MockEvaluator::all_true(&[182, 152]);
1218 let validator = EdifactValidator::new(evaluator);
1219 let external = NoOpExternalProvider;
1220
1221 let workflow = AhbWorkflow {
1222 pruefidentifikator: "11001".to_string(),
1223 description: "Test".to_string(),
1224 communication_direction: None,
1225 fields: vec![AhbFieldRule {
1226 segment_path: "SG2/NAD/C082/3039".to_string(),
1227 name: "MP-ID des MSB".to_string(),
1228 ahb_status: "Muss [182] ∧ [152]".to_string(),
1229 codes: vec![],
1230 parent_group_ahb_status: None,
1231 ..Default::default()
1232 }],
1233 ub_definitions: HashMap::new(),
1234 };
1235
1236 let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
1238
1239 assert!(!report.is_valid());
1241 let errors: Vec<_> = report.errors().collect();
1242 assert_eq!(errors.len(), 1);
1243 assert_eq!(errors[0].code, ErrorCodes::MISSING_REQUIRED_FIELD);
1244 assert!(errors[0].message.contains("MP-ID des MSB"));
1245 }
1246
1247 #[test]
1248 fn test_validate_condition_false_no_error() {
1249 let evaluator = MockEvaluator::new(vec![(182, CR::True), (152, CR::False)]);
1251 let validator = EdifactValidator::new(evaluator);
1252 let external = NoOpExternalProvider;
1253
1254 let workflow = AhbWorkflow {
1255 pruefidentifikator: "11001".to_string(),
1256 description: "Test".to_string(),
1257 communication_direction: None,
1258 fields: vec![AhbFieldRule {
1259 segment_path: "NAD".to_string(),
1260 name: "Partnerrolle".to_string(),
1261 ahb_status: "Muss [182] ∧ [152]".to_string(),
1262 codes: vec![],
1263 parent_group_ahb_status: None,
1264 ..Default::default()
1265 }],
1266 ub_definitions: HashMap::new(),
1267 };
1268
1269 let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
1270
1271 assert!(report.is_valid());
1273 }
1274
1275 #[test]
1276 fn test_validate_condition_unknown_adds_info() {
1277 let evaluator = MockEvaluator::new(vec![(182, CR::True)]);
1279 let validator = EdifactValidator::new(evaluator);
1281 let external = NoOpExternalProvider;
1282
1283 let workflow = AhbWorkflow {
1284 pruefidentifikator: "11001".to_string(),
1285 description: "Test".to_string(),
1286 communication_direction: None,
1287 fields: vec![AhbFieldRule {
1288 segment_path: "NAD".to_string(),
1289 name: "Partnerrolle".to_string(),
1290 ahb_status: "Muss [182] ∧ [152]".to_string(),
1291 codes: vec![],
1292 parent_group_ahb_status: None,
1293 ..Default::default()
1294 }],
1295 ub_definitions: HashMap::new(),
1296 };
1297
1298 let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
1299
1300 assert!(report.is_valid());
1302 let infos: Vec<_> = report.infos().collect();
1303 assert_eq!(infos.len(), 1);
1304 assert_eq!(infos[0].code, ErrorCodes::CONDITION_UNKNOWN);
1305 }
1306
1307 #[test]
1308 fn test_validate_structure_level_skips_conditions() {
1309 let evaluator = MockEvaluator::all_true(&[182, 152]);
1310 let validator = EdifactValidator::new(evaluator);
1311 let external = NoOpExternalProvider;
1312
1313 let workflow = AhbWorkflow {
1314 pruefidentifikator: "11001".to_string(),
1315 description: "Test".to_string(),
1316 communication_direction: None,
1317 fields: vec![AhbFieldRule {
1318 segment_path: "NAD".to_string(),
1319 name: "Partnerrolle".to_string(),
1320 ahb_status: "Muss [182] ∧ [152]".to_string(),
1321 codes: vec![],
1322 parent_group_ahb_status: None,
1323 ..Default::default()
1324 }],
1325 ub_definitions: HashMap::new(),
1326 };
1327
1328 let report = validator.validate(&[], &workflow, &external, ValidationLevel::Structure);
1330
1331 assert!(report.is_valid());
1333 assert_eq!(report.by_category(ValidationCategory::Ahb).count(), 0);
1334 }
1335
1336 #[test]
1337 fn test_validate_empty_workflow_no_condition_errors() {
1338 let evaluator = MockEvaluator::all_true(&[]);
1339 let validator = EdifactValidator::new(evaluator);
1340 let external = NoOpExternalProvider;
1341
1342 let empty_workflow = AhbWorkflow {
1343 pruefidentifikator: String::new(),
1344 description: String::new(),
1345 communication_direction: None,
1346 fields: vec![],
1347 ub_definitions: HashMap::new(),
1348 };
1349
1350 let report = validator.validate(&[], &empty_workflow, &external, ValidationLevel::Full);
1351
1352 assert!(report.is_valid());
1353 }
1354
1355 #[test]
1356 fn test_validate_bare_muss_always_required() {
1357 let evaluator = MockEvaluator::new(vec![]);
1358 let validator = EdifactValidator::new(evaluator);
1359 let external = NoOpExternalProvider;
1360
1361 let workflow = AhbWorkflow {
1362 pruefidentifikator: "55001".to_string(),
1363 description: "Test".to_string(),
1364 communication_direction: Some("NB an LF".to_string()),
1365 fields: vec![AhbFieldRule {
1366 segment_path: "SG2/NAD/3035".to_string(),
1367 name: "Partnerrolle".to_string(),
1368 ahb_status: "Muss".to_string(), codes: vec![],
1370 parent_group_ahb_status: None,
1371 ..Default::default()
1372 }],
1373 ub_definitions: HashMap::new(),
1374 };
1375
1376 let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
1377
1378 assert!(!report.is_valid());
1380 assert_eq!(report.error_count(), 1);
1381 }
1382
1383 #[test]
1384 fn test_validate_x_status_is_mandatory() {
1385 let evaluator = MockEvaluator::new(vec![]);
1386 let validator = EdifactValidator::new(evaluator);
1387 let external = NoOpExternalProvider;
1388
1389 let workflow = AhbWorkflow {
1390 pruefidentifikator: "55001".to_string(),
1391 description: "Test".to_string(),
1392 communication_direction: None,
1393 fields: vec![AhbFieldRule {
1394 segment_path: "DTM".to_string(),
1395 name: "Datum".to_string(),
1396 ahb_status: "X".to_string(),
1397 codes: vec![],
1398 parent_group_ahb_status: None,
1399 ..Default::default()
1400 }],
1401 ub_definitions: HashMap::new(),
1402 };
1403
1404 let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
1405
1406 assert!(!report.is_valid());
1407 let errors: Vec<_> = report.errors().collect();
1408 assert_eq!(errors[0].code, ErrorCodes::MISSING_REQUIRED_FIELD);
1409 }
1410
1411 #[test]
1412 fn test_validate_soll_not_mandatory() {
1413 let evaluator = MockEvaluator::new(vec![]);
1414 let validator = EdifactValidator::new(evaluator);
1415 let external = NoOpExternalProvider;
1416
1417 let workflow = AhbWorkflow {
1418 pruefidentifikator: "55001".to_string(),
1419 description: "Test".to_string(),
1420 communication_direction: None,
1421 fields: vec![AhbFieldRule {
1422 segment_path: "DTM".to_string(),
1423 name: "Datum".to_string(),
1424 ahb_status: "Soll".to_string(),
1425 codes: vec![],
1426 parent_group_ahb_status: None,
1427 ..Default::default()
1428 }],
1429 ub_definitions: HashMap::new(),
1430 };
1431
1432 let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
1433
1434 assert!(report.is_valid());
1436 }
1437
1438 #[test]
1439 fn test_report_includes_metadata() {
1440 let evaluator = MockEvaluator::new(vec![]);
1441 let validator = EdifactValidator::new(evaluator);
1442 let external = NoOpExternalProvider;
1443
1444 let workflow = AhbWorkflow {
1445 pruefidentifikator: "55001".to_string(),
1446 description: String::new(),
1447 communication_direction: None,
1448 fields: vec![],
1449 ub_definitions: HashMap::new(),
1450 };
1451
1452 let report = validator.validate(&[], &workflow, &external, ValidationLevel::Full);
1453
1454 assert_eq!(report.format_version.as_deref(), Some("FV2510"));
1455 assert_eq!(report.level, ValidationLevel::Full);
1456 assert_eq!(report.message_type, "UTILMD");
1457 assert_eq!(report.pruefidentifikator.as_deref(), Some("55001"));
1458 }
1459
1460 #[test]
1461 fn test_validate_with_navigator_returns_report() {
1462 let evaluator = MockEvaluator::all_true(&[]);
1463 let validator = EdifactValidator::new(evaluator);
1464 let external = NoOpExternalProvider;
1465 let nav = crate::eval::NoOpGroupNavigator;
1466
1467 let workflow = AhbWorkflow {
1468 pruefidentifikator: "55001".to_string(),
1469 description: "Test".to_string(),
1470 communication_direction: None,
1471 fields: vec![],
1472 ub_definitions: HashMap::new(),
1473 };
1474
1475 let report = validator.validate_with_navigator(
1476 &[],
1477 &workflow,
1478 &external,
1479 ValidationLevel::Full,
1480 &nav,
1481 );
1482 assert!(report.is_valid());
1483 }
1484
1485 #[test]
1486 fn test_code_validation_skips_composite_paths() {
1487 let evaluator = MockEvaluator::new(vec![]);
1491 let validator = EdifactValidator::new(evaluator);
1492 let external = NoOpExternalProvider;
1493
1494 let unh_segment = OwnedSegment {
1495 id: "UNH".to_string(),
1496 elements: vec![
1497 vec!["ALEXANDE951842".to_string()], vec![
1499 "UTILMD".to_string(),
1500 "D".to_string(),
1501 "11A".to_string(),
1502 "UN".to_string(),
1503 "S2.1".to_string(),
1504 ],
1505 ],
1506 segment_number: 1,
1507 };
1508
1509 let workflow = AhbWorkflow {
1510 pruefidentifikator: "55001".to_string(),
1511 description: "Test".to_string(),
1512 communication_direction: None,
1513 fields: vec![
1514 AhbFieldRule {
1515 segment_path: "UNH/S009/0065".to_string(),
1516 name: "Nachrichtentyp".to_string(),
1517 ahb_status: "X".to_string(),
1518 codes: vec![AhbCodeRule {
1519 value: "UTILMD".to_string(),
1520 description: "Stammdaten".to_string(),
1521 ahb_status: "X".to_string(),
1522 }],
1523 parent_group_ahb_status: None,
1524 ..Default::default()
1525 },
1526 AhbFieldRule {
1527 segment_path: "UNH/S009/0052".to_string(),
1528 name: "Version".to_string(),
1529 ahb_status: "X".to_string(),
1530 codes: vec![AhbCodeRule {
1531 value: "D".to_string(),
1532 description: "Draft".to_string(),
1533 ahb_status: "X".to_string(),
1534 }],
1535 parent_group_ahb_status: None,
1536 ..Default::default()
1537 },
1538 ],
1539 ub_definitions: HashMap::new(),
1540 };
1541
1542 let report = validator.validate(
1543 &[unh_segment],
1544 &workflow,
1545 &external,
1546 ValidationLevel::Conditions,
1547 );
1548
1549 let code_errors: Vec<_> = report
1551 .by_category(ValidationCategory::Code)
1552 .filter(|i| i.severity == Severity::Error)
1553 .collect();
1554 assert!(
1555 code_errors.is_empty(),
1556 "Expected no code errors for composite paths, got: {:?}",
1557 code_errors
1558 );
1559 }
1560
1561 #[test]
1562 fn test_cross_field_code_validation_valid_qualifiers() {
1563 let evaluator = MockEvaluator::new(vec![]);
1566 let validator = EdifactValidator::new(evaluator);
1567 let external = NoOpExternalProvider;
1568
1569 let nad_ms = OwnedSegment {
1570 id: "NAD".to_string(),
1571 elements: vec![vec!["MS".to_string()]],
1572 segment_number: 4,
1573 };
1574 let nad_mr = OwnedSegment {
1575 id: "NAD".to_string(),
1576 elements: vec![vec!["MR".to_string()]],
1577 segment_number: 5,
1578 };
1579
1580 let workflow = AhbWorkflow {
1581 pruefidentifikator: "55001".to_string(),
1582 description: "Test".to_string(),
1583 communication_direction: None,
1584 fields: vec![
1585 AhbFieldRule {
1586 segment_path: "SG2/NAD/3035".to_string(),
1587 name: "Absender".to_string(),
1588 ahb_status: "X".to_string(),
1589 codes: vec![AhbCodeRule {
1590 value: "MS".to_string(),
1591 description: "Absender".to_string(),
1592 ahb_status: "X".to_string(),
1593 }],
1594 parent_group_ahb_status: None,
1595 ..Default::default()
1596 },
1597 AhbFieldRule {
1598 segment_path: "SG2/NAD/3035".to_string(),
1599 name: "Empfaenger".to_string(),
1600 ahb_status: "X".to_string(),
1601 codes: vec![AhbCodeRule {
1602 value: "MR".to_string(),
1603 description: "Empfaenger".to_string(),
1604 ahb_status: "X".to_string(),
1605 }],
1606 parent_group_ahb_status: None,
1607 ..Default::default()
1608 },
1609 ],
1610 ub_definitions: HashMap::new(),
1611 };
1612
1613 let report = validator.validate(
1614 &[nad_ms, nad_mr],
1615 &workflow,
1616 &external,
1617 ValidationLevel::Conditions,
1618 );
1619
1620 let code_errors: Vec<_> = report
1621 .by_category(ValidationCategory::Code)
1622 .filter(|i| i.severity == Severity::Error)
1623 .collect();
1624 assert!(
1625 code_errors.is_empty(),
1626 "Expected no code errors for valid qualifiers, got: {:?}",
1627 code_errors
1628 );
1629 }
1630
1631 #[test]
1632 fn test_cross_field_code_validation_catches_invalid_qualifier() {
1633 let evaluator = MockEvaluator::new(vec![]);
1635 let validator = EdifactValidator::new(evaluator);
1636 let external = NoOpExternalProvider;
1637
1638 let nad_ms = OwnedSegment {
1639 id: "NAD".to_string(),
1640 elements: vec![vec!["MS".to_string()]],
1641 segment_number: 4,
1642 };
1643 let nad_mt = OwnedSegment {
1644 id: "NAD".to_string(),
1645 elements: vec![vec!["MT".to_string()]], segment_number: 5,
1647 };
1648
1649 let workflow = AhbWorkflow {
1650 pruefidentifikator: "55001".to_string(),
1651 description: "Test".to_string(),
1652 communication_direction: None,
1653 fields: vec![
1654 AhbFieldRule {
1655 segment_path: "SG2/NAD/3035".to_string(),
1656 name: "Absender".to_string(),
1657 ahb_status: "X".to_string(),
1658 codes: vec![AhbCodeRule {
1659 value: "MS".to_string(),
1660 description: "Absender".to_string(),
1661 ahb_status: "X".to_string(),
1662 }],
1663 parent_group_ahb_status: None,
1664 ..Default::default()
1665 },
1666 AhbFieldRule {
1667 segment_path: "SG2/NAD/3035".to_string(),
1668 name: "Empfaenger".to_string(),
1669 ahb_status: "X".to_string(),
1670 codes: vec![AhbCodeRule {
1671 value: "MR".to_string(),
1672 description: "Empfaenger".to_string(),
1673 ahb_status: "X".to_string(),
1674 }],
1675 parent_group_ahb_status: None,
1676 ..Default::default()
1677 },
1678 ],
1679 ub_definitions: HashMap::new(),
1680 };
1681
1682 let report = validator.validate(
1683 &[nad_ms, nad_mt],
1684 &workflow,
1685 &external,
1686 ValidationLevel::Conditions,
1687 );
1688
1689 let code_errors: Vec<_> = report
1690 .by_category(ValidationCategory::Code)
1691 .filter(|i| i.severity == Severity::Error)
1692 .collect();
1693 assert_eq!(code_errors.len(), 1, "Expected one COD002 error for MT");
1694 assert!(code_errors[0].message.contains("MT"));
1695 assert!(code_errors[0].message.contains("MR"));
1696 assert!(code_errors[0].message.contains("MS"));
1697 }
1698
1699 #[test]
1700 fn test_cross_field_code_validation_unions_across_groups() {
1701 let evaluator = MockEvaluator::new(vec![]);
1705 let validator = EdifactValidator::new(evaluator);
1706 let external = NoOpExternalProvider;
1707
1708 let segments = vec![
1709 OwnedSegment {
1710 id: "NAD".to_string(),
1711 elements: vec![vec!["MS".to_string()]],
1712 segment_number: 3,
1713 },
1714 OwnedSegment {
1715 id: "NAD".to_string(),
1716 elements: vec![vec!["MR".to_string()]],
1717 segment_number: 4,
1718 },
1719 OwnedSegment {
1720 id: "NAD".to_string(),
1721 elements: vec![vec!["Z04".to_string()]],
1722 segment_number: 20,
1723 },
1724 OwnedSegment {
1725 id: "NAD".to_string(),
1726 elements: vec![vec!["Z09".to_string()]],
1727 segment_number: 21,
1728 },
1729 OwnedSegment {
1730 id: "NAD".to_string(),
1731 elements: vec![vec!["MT".to_string()]], segment_number: 22,
1733 },
1734 ];
1735
1736 let workflow = AhbWorkflow {
1737 pruefidentifikator: "55001".to_string(),
1738 description: "Test".to_string(),
1739 communication_direction: None,
1740 fields: vec![
1741 AhbFieldRule {
1742 segment_path: "SG2/NAD/3035".to_string(),
1743 name: "Absender".to_string(),
1744 ahb_status: "X".to_string(),
1745 codes: vec![AhbCodeRule {
1746 value: "MS".to_string(),
1747 description: "Absender".to_string(),
1748 ahb_status: "X".to_string(),
1749 }],
1750 parent_group_ahb_status: None,
1751 ..Default::default()
1752 },
1753 AhbFieldRule {
1754 segment_path: "SG2/NAD/3035".to_string(),
1755 name: "Empfaenger".to_string(),
1756 ahb_status: "X".to_string(),
1757 codes: vec![AhbCodeRule {
1758 value: "MR".to_string(),
1759 description: "Empfaenger".to_string(),
1760 ahb_status: "X".to_string(),
1761 }],
1762 parent_group_ahb_status: None,
1763 ..Default::default()
1764 },
1765 AhbFieldRule {
1766 segment_path: "SG4/SG12/NAD/3035".to_string(),
1767 name: "Anschlussnutzer".to_string(),
1768 ahb_status: "X".to_string(),
1769 codes: vec![AhbCodeRule {
1770 value: "Z04".to_string(),
1771 description: "Anschlussnutzer".to_string(),
1772 ahb_status: "X".to_string(),
1773 }],
1774 parent_group_ahb_status: None,
1775 ..Default::default()
1776 },
1777 AhbFieldRule {
1778 segment_path: "SG4/SG12/NAD/3035".to_string(),
1779 name: "Korrespondenzanschrift".to_string(),
1780 ahb_status: "X".to_string(),
1781 codes: vec![AhbCodeRule {
1782 value: "Z09".to_string(),
1783 description: "Korrespondenzanschrift".to_string(),
1784 ahb_status: "X".to_string(),
1785 }],
1786 parent_group_ahb_status: None,
1787 ..Default::default()
1788 },
1789 ],
1790 ub_definitions: HashMap::new(),
1791 };
1792
1793 let report =
1794 validator.validate(&segments, &workflow, &external, ValidationLevel::Conditions);
1795
1796 let code_errors: Vec<_> = report
1797 .by_category(ValidationCategory::Code)
1798 .filter(|i| i.severity == Severity::Error)
1799 .collect();
1800 assert_eq!(
1801 code_errors.len(),
1802 1,
1803 "Expected exactly one COD002 error for MT, got: {:?}",
1804 code_errors
1805 );
1806 assert!(code_errors[0].message.contains("MT"));
1807 }
1808
1809 #[test]
1810 fn test_is_qualifier_field_simple_paths() {
1811 assert!(is_qualifier_field("NAD/3035"));
1812 assert!(is_qualifier_field("SG2/NAD/3035"));
1813 assert!(is_qualifier_field("SG4/SG8/SEQ/6350"));
1814 assert!(is_qualifier_field("LOC/3227"));
1815 }
1816
1817 #[test]
1818 fn test_is_qualifier_field_composite_paths() {
1819 assert!(!is_qualifier_field("UNH/S009/0065"));
1820 assert!(!is_qualifier_field("NAD/C082/3039"));
1821 assert!(!is_qualifier_field("SG2/NAD/C082/3039"));
1822 }
1823
1824 #[test]
1825 fn test_is_qualifier_field_bare_segment() {
1826 assert!(!is_qualifier_field("NAD"));
1827 assert!(!is_qualifier_field("SG2/NAD"));
1828 }
1829
1830 #[test]
1831 fn test_missing_qualifier_instance_is_detected() {
1832 let evaluator = MockEvaluator::new(vec![]);
1835 let validator = EdifactValidator::new(evaluator);
1836 let external = NoOpExternalProvider;
1837
1838 let nad_ms = OwnedSegment {
1839 id: "NAD".to_string(),
1840 elements: vec![vec!["MS".to_string()]],
1841 segment_number: 3,
1842 };
1843
1844 let workflow = AhbWorkflow {
1845 pruefidentifikator: "55001".to_string(),
1846 description: "Test".to_string(),
1847 communication_direction: None,
1848 fields: vec![
1849 AhbFieldRule {
1850 segment_path: "SG2/NAD/3035".to_string(),
1851 name: "Absender".to_string(),
1852 ahb_status: "X".to_string(),
1853 codes: vec![AhbCodeRule {
1854 value: "MS".to_string(),
1855 description: "Absender".to_string(),
1856 ahb_status: "X".to_string(),
1857 }],
1858 parent_group_ahb_status: None,
1859 ..Default::default()
1860 },
1861 AhbFieldRule {
1862 segment_path: "SG2/NAD/3035".to_string(),
1863 name: "Empfaenger".to_string(),
1864 ahb_status: "Muss".to_string(),
1865 codes: vec![AhbCodeRule {
1866 value: "MR".to_string(),
1867 description: "Empfaenger".to_string(),
1868 ahb_status: "X".to_string(),
1869 }],
1870 parent_group_ahb_status: None,
1871 ..Default::default()
1872 },
1873 ],
1874 ub_definitions: HashMap::new(),
1875 };
1876
1877 let report =
1878 validator.validate(&[nad_ms], &workflow, &external, ValidationLevel::Conditions);
1879
1880 let ahb_errors: Vec<_> = report
1881 .by_category(ValidationCategory::Ahb)
1882 .filter(|i| i.severity == Severity::Error)
1883 .collect();
1884 assert_eq!(
1885 ahb_errors.len(),
1886 1,
1887 "Expected AHB001 for missing NAD+MR, got: {:?}",
1888 ahb_errors
1889 );
1890 assert!(ahb_errors[0].message.contains("Empfaenger"));
1891 }
1892
1893 #[test]
1894 fn test_present_qualifier_instance_no_error() {
1895 let evaluator = MockEvaluator::new(vec![]);
1897 let validator = EdifactValidator::new(evaluator);
1898 let external = NoOpExternalProvider;
1899
1900 let segments = vec![
1901 OwnedSegment {
1902 id: "NAD".to_string(),
1903 elements: vec![vec!["MS".to_string()]],
1904 segment_number: 3,
1905 },
1906 OwnedSegment {
1907 id: "NAD".to_string(),
1908 elements: vec![vec!["MR".to_string()]],
1909 segment_number: 4,
1910 },
1911 ];
1912
1913 let workflow = AhbWorkflow {
1914 pruefidentifikator: "55001".to_string(),
1915 description: "Test".to_string(),
1916 communication_direction: None,
1917 fields: vec![
1918 AhbFieldRule {
1919 segment_path: "SG2/NAD/3035".to_string(),
1920 name: "Absender".to_string(),
1921 ahb_status: "Muss".to_string(),
1922 codes: vec![AhbCodeRule {
1923 value: "MS".to_string(),
1924 description: "Absender".to_string(),
1925 ahb_status: "X".to_string(),
1926 }],
1927 parent_group_ahb_status: None,
1928 ..Default::default()
1929 },
1930 AhbFieldRule {
1931 segment_path: "SG2/NAD/3035".to_string(),
1932 name: "Empfaenger".to_string(),
1933 ahb_status: "Muss".to_string(),
1934 codes: vec![AhbCodeRule {
1935 value: "MR".to_string(),
1936 description: "Empfaenger".to_string(),
1937 ahb_status: "X".to_string(),
1938 }],
1939 parent_group_ahb_status: None,
1940 ..Default::default()
1941 },
1942 ],
1943 ub_definitions: HashMap::new(),
1944 };
1945
1946 let report =
1947 validator.validate(&segments, &workflow, &external, ValidationLevel::Conditions);
1948
1949 let ahb_errors: Vec<_> = report
1950 .by_category(ValidationCategory::Ahb)
1951 .filter(|i| i.severity == Severity::Error)
1952 .collect();
1953 assert!(
1954 ahb_errors.is_empty(),
1955 "Expected no AHB001 errors, got: {:?}",
1956 ahb_errors
1957 );
1958 }
1959
1960 #[test]
1961 fn test_extract_group_path_key() {
1962 assert_eq!(extract_group_path_key("SG2/NAD/3035"), "SG2");
1963 assert_eq!(extract_group_path_key("SG4/SG12/NAD/3035"), "SG4/SG12");
1964 assert_eq!(extract_group_path_key("NAD/3035"), "");
1965 assert_eq!(extract_group_path_key("SG4/SG8/SEQ/6350"), "SG4/SG8");
1966 }
1967
1968 #[test]
1969 fn test_absent_optional_group_no_missing_field_error() {
1970 use mig_types::navigator::GroupNavigator;
1973
1974 struct NavWithoutSG3;
1975 impl GroupNavigator for NavWithoutSG3 {
1976 fn find_segments_in_group(&self, _: &str, _: &[&str], _: usize) -> Vec<OwnedSegment> {
1977 vec![]
1978 }
1979 fn find_segments_with_qualifier_in_group(
1980 &self,
1981 _: &str,
1982 _: usize,
1983 _: &str,
1984 _: &[&str],
1985 _: usize,
1986 ) -> Vec<OwnedSegment> {
1987 vec![]
1988 }
1989 fn group_instance_count(&self, group_path: &[&str]) -> usize {
1990 match group_path {
1991 ["SG2"] => 2, ["SG2", "SG3"] => 0, _ => 0,
1994 }
1995 }
1996 }
1997
1998 let evaluator = MockEvaluator::new(vec![]);
1999 let validator = EdifactValidator::new(evaluator);
2000 let external = NoOpExternalProvider;
2001 let nav = NavWithoutSG3;
2002
2003 let segments = vec![
2005 OwnedSegment {
2006 id: "NAD".into(),
2007 elements: vec![vec!["MS".into()]],
2008 segment_number: 3,
2009 },
2010 OwnedSegment {
2011 id: "NAD".into(),
2012 elements: vec![vec!["MR".into()]],
2013 segment_number: 4,
2014 },
2015 ];
2016
2017 let workflow = AhbWorkflow {
2018 pruefidentifikator: "55001".to_string(),
2019 description: "Test".to_string(),
2020 communication_direction: None,
2021 fields: vec![
2022 AhbFieldRule {
2023 segment_path: "SG2/SG3/CTA/3139".to_string(),
2024 name: "Funktion des Ansprechpartners, Code".to_string(),
2025 ahb_status: "Muss".to_string(),
2026 codes: vec![],
2027 parent_group_ahb_status: None,
2028 ..Default::default()
2029 },
2030 AhbFieldRule {
2031 segment_path: "SG2/SG3/CTA/C056/3412".to_string(),
2032 name: "Name vom Ansprechpartner".to_string(),
2033 ahb_status: "X".to_string(),
2034 codes: vec![],
2035 parent_group_ahb_status: None,
2036 ..Default::default()
2037 },
2038 ],
2039 ub_definitions: HashMap::new(),
2040 };
2041
2042 let report = validator.validate_with_navigator(
2043 &segments,
2044 &workflow,
2045 &external,
2046 ValidationLevel::Conditions,
2047 &nav,
2048 );
2049
2050 let ahb_errors: Vec<_> = report
2051 .by_category(ValidationCategory::Ahb)
2052 .filter(|i| i.severity == Severity::Error)
2053 .collect();
2054 assert!(
2055 ahb_errors.is_empty(),
2056 "Expected no AHB001 errors when SG3 is absent, got: {:?}",
2057 ahb_errors
2058 );
2059 }
2060
2061 #[test]
2062 fn test_present_group_still_checks_mandatory_fields() {
2063 use mig_types::navigator::GroupNavigator;
2065
2066 struct NavWithSG3;
2067 impl GroupNavigator for NavWithSG3 {
2068 fn find_segments_in_group(&self, _: &str, _: &[&str], _: usize) -> Vec<OwnedSegment> {
2069 vec![]
2070 }
2071 fn find_segments_with_qualifier_in_group(
2072 &self,
2073 _: &str,
2074 _: usize,
2075 _: &str,
2076 _: &[&str],
2077 _: usize,
2078 ) -> Vec<OwnedSegment> {
2079 vec![]
2080 }
2081 fn group_instance_count(&self, group_path: &[&str]) -> usize {
2082 match group_path {
2083 ["SG2"] => 1,
2084 ["SG2", "SG3"] => 1, _ => 0,
2086 }
2087 }
2088 }
2089
2090 let evaluator = MockEvaluator::new(vec![]);
2091 let validator = EdifactValidator::new(evaluator);
2092 let external = NoOpExternalProvider;
2093 let nav = NavWithSG3;
2094
2095 let segments = vec![OwnedSegment {
2097 id: "NAD".into(),
2098 elements: vec![vec!["MS".into()]],
2099 segment_number: 3,
2100 }];
2101
2102 let workflow = AhbWorkflow {
2103 pruefidentifikator: "55001".to_string(),
2104 description: "Test".to_string(),
2105 communication_direction: None,
2106 fields: vec![AhbFieldRule {
2107 segment_path: "SG2/SG3/CTA/3139".to_string(),
2108 name: "Funktion des Ansprechpartners, Code".to_string(),
2109 ahb_status: "Muss".to_string(),
2110 codes: vec![],
2111 parent_group_ahb_status: None,
2112 ..Default::default()
2113 }],
2114 ub_definitions: HashMap::new(),
2115 };
2116
2117 let report = validator.validate_with_navigator(
2118 &segments,
2119 &workflow,
2120 &external,
2121 ValidationLevel::Conditions,
2122 &nav,
2123 );
2124
2125 let ahb_errors: Vec<_> = report
2126 .by_category(ValidationCategory::Ahb)
2127 .filter(|i| i.severity == Severity::Error)
2128 .collect();
2129 assert_eq!(
2130 ahb_errors.len(),
2131 1,
2132 "Expected AHB001 error when SG3 is present but CTA missing"
2133 );
2134 assert!(ahb_errors[0].message.contains("CTA"));
2135 }
2136
2137 #[test]
2138 fn test_missing_qualifier_with_navigator_is_detected() {
2139 use mig_types::navigator::GroupNavigator;
2142
2143 struct NavWithSG2;
2144 impl GroupNavigator for NavWithSG2 {
2145 fn find_segments_in_group(
2146 &self,
2147 segment_id: &str,
2148 group_path: &[&str],
2149 instance_index: usize,
2150 ) -> Vec<OwnedSegment> {
2151 if segment_id == "NAD" && group_path == ["SG2"] && instance_index == 0 {
2152 vec![OwnedSegment {
2153 id: "NAD".into(),
2154 elements: vec![vec!["MS".into()]],
2155 segment_number: 3,
2156 }]
2157 } else {
2158 vec![]
2159 }
2160 }
2161 fn find_segments_with_qualifier_in_group(
2162 &self,
2163 _: &str,
2164 _: usize,
2165 _: &str,
2166 _: &[&str],
2167 _: usize,
2168 ) -> Vec<OwnedSegment> {
2169 vec![]
2170 }
2171 fn group_instance_count(&self, group_path: &[&str]) -> usize {
2172 match group_path {
2173 ["SG2"] => 1,
2174 _ => 0,
2175 }
2176 }
2177 }
2178
2179 let evaluator = MockEvaluator::new(vec![]);
2180 let validator = EdifactValidator::new(evaluator);
2181 let external = NoOpExternalProvider;
2182 let nav = NavWithSG2;
2183
2184 let segments = vec![OwnedSegment {
2185 id: "NAD".into(),
2186 elements: vec![vec!["MS".into()]],
2187 segment_number: 3,
2188 }];
2189
2190 let workflow = AhbWorkflow {
2191 pruefidentifikator: "55001".to_string(),
2192 description: "Test".to_string(),
2193 communication_direction: None,
2194 fields: vec![
2195 AhbFieldRule {
2196 segment_path: "SG2/NAD/3035".to_string(),
2197 name: "Absender".to_string(),
2198 ahb_status: "X".to_string(),
2199 codes: vec![AhbCodeRule {
2200 value: "MS".to_string(),
2201 description: "Absender".to_string(),
2202 ahb_status: "X".to_string(),
2203 }],
2204 parent_group_ahb_status: None,
2205 ..Default::default()
2206 },
2207 AhbFieldRule {
2208 segment_path: "SG2/NAD/3035".to_string(),
2209 name: "Empfaenger".to_string(),
2210 ahb_status: "Muss".to_string(),
2211 codes: vec![AhbCodeRule {
2212 value: "MR".to_string(),
2213 description: "Empfaenger".to_string(),
2214 ahb_status: "X".to_string(),
2215 }],
2216 parent_group_ahb_status: None,
2217 ..Default::default()
2218 },
2219 ],
2220 ub_definitions: HashMap::new(),
2221 };
2222
2223 let report = validator.validate_with_navigator(
2224 &segments,
2225 &workflow,
2226 &external,
2227 ValidationLevel::Conditions,
2228 &nav,
2229 );
2230
2231 let ahb_errors: Vec<_> = report
2232 .by_category(ValidationCategory::Ahb)
2233 .filter(|i| i.severity == Severity::Error)
2234 .collect();
2235 assert_eq!(
2236 ahb_errors.len(),
2237 1,
2238 "Expected AHB001 for missing NAD+MR even with navigator, got: {:?}",
2239 ahb_errors
2240 );
2241 assert!(ahb_errors[0].message.contains("Empfaenger"));
2242 }
2243
2244 #[test]
2245 fn test_optional_group_variant_absent_no_error() {
2246 use mig_types::navigator::GroupNavigator;
2251
2252 struct TestNav;
2253 impl GroupNavigator for TestNav {
2254 fn find_segments_in_group(
2255 &self,
2256 segment_id: &str,
2257 group_path: &[&str],
2258 instance_index: usize,
2259 ) -> Vec<OwnedSegment> {
2260 match (segment_id, group_path, instance_index) {
2261 ("LOC", ["SG4", "SG5"], 0) => vec![OwnedSegment {
2262 id: "LOC".into(),
2263 elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
2264 segment_number: 10,
2265 }],
2266 ("NAD", ["SG2"], 0) => vec![OwnedSegment {
2267 id: "NAD".into(),
2268 elements: vec![vec!["MS".into()]],
2269 segment_number: 3,
2270 }],
2271 _ => vec![],
2272 }
2273 }
2274 fn find_segments_with_qualifier_in_group(
2275 &self,
2276 _: &str,
2277 _: usize,
2278 _: &str,
2279 _: &[&str],
2280 _: usize,
2281 ) -> Vec<OwnedSegment> {
2282 vec![]
2283 }
2284 fn group_instance_count(&self, group_path: &[&str]) -> usize {
2285 match group_path {
2286 ["SG2"] => 1,
2287 ["SG4"] => 1,
2288 ["SG4", "SG5"] => 1, _ => 0,
2290 }
2291 }
2292 }
2293
2294 let evaluator = MockEvaluator::new(vec![]);
2295 let validator = EdifactValidator::new(evaluator);
2296 let external = NoOpExternalProvider;
2297 let nav = TestNav;
2298
2299 let segments = vec![
2300 OwnedSegment {
2301 id: "NAD".into(),
2302 elements: vec![vec!["MS".into()]],
2303 segment_number: 3,
2304 },
2305 OwnedSegment {
2306 id: "LOC".into(),
2307 elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
2308 segment_number: 10,
2309 },
2310 ];
2311
2312 let workflow = AhbWorkflow {
2313 pruefidentifikator: "55001".to_string(),
2314 description: "Test".to_string(),
2315 communication_direction: None,
2316 fields: vec![
2317 AhbFieldRule {
2319 segment_path: "SG2/NAD/3035".to_string(),
2320 name: "Absender".to_string(),
2321 ahb_status: "X".to_string(),
2322 codes: vec![AhbCodeRule {
2323 value: "MS".to_string(),
2324 description: "Absender".to_string(),
2325 ahb_status: "X".to_string(),
2326 }],
2327 parent_group_ahb_status: Some("Muss".to_string()),
2328 ..Default::default()
2329 },
2330 AhbFieldRule {
2331 segment_path: "SG2/NAD/3035".to_string(),
2332 name: "Empfaenger".to_string(),
2333 ahb_status: "Muss".to_string(),
2334 codes: vec![AhbCodeRule {
2335 value: "MR".to_string(),
2336 description: "Empfaenger".to_string(),
2337 ahb_status: "X".to_string(),
2338 }],
2339 parent_group_ahb_status: Some("Muss".to_string()),
2340 ..Default::default()
2341 },
2342 AhbFieldRule {
2344 segment_path: "SG4/SG5/LOC/3227".to_string(),
2345 name: "Ortsangabe, Qualifier (Z16)".to_string(),
2346 ahb_status: "X".to_string(),
2347 codes: vec![AhbCodeRule {
2348 value: "Z16".to_string(),
2349 description: "Marktlokation".to_string(),
2350 ahb_status: "X".to_string(),
2351 }],
2352 parent_group_ahb_status: Some("Kann".to_string()),
2353 ..Default::default()
2354 },
2355 AhbFieldRule {
2356 segment_path: "SG4/SG5/LOC/3227".to_string(),
2357 name: "Ortsangabe, Qualifier (Z17)".to_string(),
2358 ahb_status: "Muss".to_string(),
2359 codes: vec![AhbCodeRule {
2360 value: "Z17".to_string(),
2361 description: "Messlokation".to_string(),
2362 ahb_status: "X".to_string(),
2363 }],
2364 parent_group_ahb_status: Some("Kann".to_string()),
2365 ..Default::default()
2366 },
2367 ],
2368 ub_definitions: HashMap::new(),
2369 };
2370
2371 let report = validator.validate_with_navigator(
2372 &segments,
2373 &workflow,
2374 &external,
2375 ValidationLevel::Conditions,
2376 &nav,
2377 );
2378
2379 let ahb_errors: Vec<_> = report
2380 .by_category(ValidationCategory::Ahb)
2381 .filter(|i| i.severity == Severity::Error)
2382 .collect();
2383
2384 assert_eq!(
2387 ahb_errors.len(),
2388 1,
2389 "Expected only AHB001 for missing NAD+MR, got: {:?}",
2390 ahb_errors
2391 );
2392 assert!(
2393 ahb_errors[0].message.contains("Empfaenger"),
2394 "Error should be for missing NAD+MR (Empfaenger)"
2395 );
2396 }
2397
2398 #[test]
2399 fn test_conditional_group_variant_absent_no_error() {
2400 use mig_types::navigator::GroupNavigator;
2405
2406 struct TestNav;
2407 impl GroupNavigator for TestNav {
2408 fn find_segments_in_group(
2409 &self,
2410 segment_id: &str,
2411 group_path: &[&str],
2412 instance_index: usize,
2413 ) -> Vec<OwnedSegment> {
2414 if segment_id == "LOC" && group_path == ["SG4", "SG5"] && instance_index == 0 {
2415 vec![OwnedSegment {
2416 id: "LOC".into(),
2417 elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
2418 segment_number: 10,
2419 }]
2420 } else {
2421 vec![]
2422 }
2423 }
2424 fn find_segments_with_qualifier_in_group(
2425 &self,
2426 _: &str,
2427 _: usize,
2428 _: &str,
2429 _: &[&str],
2430 _: usize,
2431 ) -> Vec<OwnedSegment> {
2432 vec![]
2433 }
2434 fn group_instance_count(&self, group_path: &[&str]) -> usize {
2435 match group_path {
2436 ["SG4"] => 1,
2437 ["SG4", "SG5"] => 1, _ => 0,
2439 }
2440 }
2441 }
2442
2443 let evaluator = MockEvaluator::new(vec![(165, CR::False), (2061, CR::True)]);
2446 let validator = EdifactValidator::new(evaluator);
2447 let external = NoOpExternalProvider;
2448 let nav = TestNav;
2449
2450 let segments = vec![OwnedSegment {
2451 id: "LOC".into(),
2452 elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
2453 segment_number: 10,
2454 }];
2455
2456 let workflow = AhbWorkflow {
2457 pruefidentifikator: "55001".to_string(),
2458 description: "Test".to_string(),
2459 communication_direction: None,
2460 fields: vec![
2461 AhbFieldRule {
2463 segment_path: "SG4/SG5/LOC/3227".to_string(),
2464 name: "Ortsangabe, Qualifier (Z16)".to_string(),
2465 ahb_status: "X".to_string(),
2466 codes: vec![AhbCodeRule {
2467 value: "Z16".to_string(),
2468 description: "Marktlokation".to_string(),
2469 ahb_status: "X".to_string(),
2470 }],
2471 parent_group_ahb_status: Some("Muss [2061]".to_string()),
2472 ..Default::default()
2473 },
2474 AhbFieldRule {
2476 segment_path: "SG4/SG5/LOC/3227".to_string(),
2477 name: "Ortsangabe, Qualifier (Z17)".to_string(),
2478 ahb_status: "X".to_string(),
2479 codes: vec![AhbCodeRule {
2480 value: "Z17".to_string(),
2481 description: "Messlokation".to_string(),
2482 ahb_status: "X".to_string(),
2483 }],
2484 parent_group_ahb_status: Some("Soll [165]".to_string()),
2485 ..Default::default()
2486 },
2487 ],
2488 ub_definitions: HashMap::new(),
2489 };
2490
2491 let report = validator.validate_with_navigator(
2492 &segments,
2493 &workflow,
2494 &external,
2495 ValidationLevel::Conditions,
2496 &nav,
2497 );
2498
2499 let ahb_errors: Vec<_> = report
2500 .by_category(ValidationCategory::Ahb)
2501 .filter(|i| i.severity == Severity::Error)
2502 .collect();
2503
2504 assert!(
2506 ahb_errors.is_empty(),
2507 "Expected no errors when conditional group variant [165]=False, got: {:?}",
2508 ahb_errors
2509 );
2510 }
2511
2512 #[test]
2513 fn test_conditional_group_variant_unknown_no_error() {
2514 let evaluator = MockEvaluator::new(vec![]);
2520 let validator = EdifactValidator::new(evaluator);
2521 let external = NoOpExternalProvider;
2522
2523 let workflow = AhbWorkflow {
2524 pruefidentifikator: "55001".to_string(),
2525 description: "Test".to_string(),
2526 communication_direction: None,
2527 fields: vec![AhbFieldRule {
2528 segment_path: "SG4/SG5/LOC/3227".to_string(),
2529 name: "Ortsangabe, Qualifier (Z17)".to_string(),
2530 ahb_status: "X".to_string(),
2531 codes: vec![AhbCodeRule {
2532 value: "Z17".to_string(),
2533 description: "Messlokation".to_string(),
2534 ahb_status: "X".to_string(),
2535 }],
2536 parent_group_ahb_status: Some("Soll [165]".to_string()),
2537 ..Default::default()
2538 }],
2539 ub_definitions: HashMap::new(),
2540 };
2541
2542 let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
2543
2544 let ahb_errors: Vec<_> = report
2545 .by_category(ValidationCategory::Ahb)
2546 .filter(|i| i.severity == Severity::Error)
2547 .collect();
2548
2549 assert!(
2551 ahb_errors.is_empty(),
2552 "Expected no errors when parent group condition is Unknown, got: {:?}",
2553 ahb_errors
2554 );
2555 }
2556
2557 #[test]
2558 fn test_segment_absent_within_present_group_no_error() {
2559 use mig_types::navigator::GroupNavigator;
2563
2564 struct TestNav;
2565 impl GroupNavigator for TestNav {
2566 fn find_segments_in_group(
2567 &self,
2568 segment_id: &str,
2569 group_path: &[&str],
2570 instance_index: usize,
2571 ) -> Vec<OwnedSegment> {
2572 if segment_id == "QTY"
2574 && group_path == ["SG5", "SG6", "SG9", "SG10"]
2575 && instance_index == 0
2576 {
2577 vec![OwnedSegment {
2578 id: "QTY".into(),
2579 elements: vec![vec!["220".into(), "0".into()]],
2580 segment_number: 14,
2581 }]
2582 } else {
2583 vec![]
2584 }
2585 }
2586 fn find_segments_with_qualifier_in_group(
2587 &self,
2588 _: &str,
2589 _: usize,
2590 _: &str,
2591 _: &[&str],
2592 _: usize,
2593 ) -> Vec<OwnedSegment> {
2594 vec![]
2595 }
2596 fn group_instance_count(&self, group_path: &[&str]) -> usize {
2597 match group_path {
2598 ["SG5"] => 1,
2599 ["SG5", "SG6"] => 1,
2600 ["SG5", "SG6", "SG9"] => 1,
2601 ["SG5", "SG6", "SG9", "SG10"] => 1,
2602 _ => 0,
2603 }
2604 }
2605 fn has_any_segment_in_group(&self, group_path: &[&str], instance_index: usize) -> bool {
2606 group_path == ["SG5", "SG6", "SG9", "SG10"] && instance_index == 0
2608 }
2609 }
2610
2611 let evaluator = MockEvaluator::all_true(&[]);
2612 let validator = EdifactValidator::new(evaluator);
2613 let external = NoOpExternalProvider;
2614 let nav = TestNav;
2615
2616 let segments = vec![OwnedSegment {
2617 id: "QTY".into(),
2618 elements: vec![vec!["220".into(), "0".into()]],
2619 segment_number: 14,
2620 }];
2621
2622 let workflow = AhbWorkflow {
2623 pruefidentifikator: "13017".to_string(),
2624 description: "Test".to_string(),
2625 communication_direction: None,
2626 fields: vec![
2627 AhbFieldRule {
2629 segment_path: "SG5/SG6/SG9/SG10/STS/C601/9015".to_string(),
2630 name: "Statuskategorie, Code".to_string(),
2631 ahb_status: "X".to_string(),
2632 codes: vec![],
2633 parent_group_ahb_status: Some("Muss".to_string()),
2634 ..Default::default()
2635 },
2636 AhbFieldRule {
2638 segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
2639 name: "Statusanlaß, Code".to_string(),
2640 ahb_status: "X [5]".to_string(),
2641 codes: vec![],
2642 parent_group_ahb_status: Some("Muss".to_string()),
2643 ..Default::default()
2644 },
2645 ],
2646 ub_definitions: HashMap::new(),
2647 };
2648
2649 let report = validator.validate_with_navigator(
2650 &segments,
2651 &workflow,
2652 &external,
2653 ValidationLevel::Conditions,
2654 &nav,
2655 );
2656
2657 let ahb_errors: Vec<_> = report
2658 .by_category(ValidationCategory::Ahb)
2659 .filter(|i| i.severity == Severity::Error)
2660 .collect();
2661
2662 assert!(
2663 ahb_errors.is_empty(),
2664 "Expected no AHB001 errors when STS segment is absent from SG10, got: {:?}",
2665 ahb_errors
2666 );
2667 }
2668
2669 #[test]
2670 fn test_group_scoped_code_validation_with_navigator() {
2671 use mig_types::navigator::GroupNavigator;
2675
2676 struct TestNav;
2677 impl GroupNavigator for TestNav {
2678 fn find_segments_in_group(
2679 &self,
2680 segment_id: &str,
2681 group_path: &[&str],
2682 _instance_index: usize,
2683 ) -> Vec<OwnedSegment> {
2684 if segment_id != "NAD" {
2685 return vec![];
2686 }
2687 match group_path {
2688 ["SG2"] => vec![
2689 OwnedSegment {
2690 id: "NAD".into(),
2691 elements: vec![vec!["MS".into()]],
2692 segment_number: 3,
2693 },
2694 OwnedSegment {
2695 id: "NAD".into(),
2696 elements: vec![vec!["MT".into()]], segment_number: 4,
2698 },
2699 ],
2700 ["SG4", "SG12"] => vec![
2701 OwnedSegment {
2702 id: "NAD".into(),
2703 elements: vec![vec!["Z04".into()]],
2704 segment_number: 20,
2705 },
2706 OwnedSegment {
2707 id: "NAD".into(),
2708 elements: vec![vec!["Z09".into()]],
2709 segment_number: 21,
2710 },
2711 ],
2712 _ => vec![],
2713 }
2714 }
2715 fn find_segments_with_qualifier_in_group(
2716 &self,
2717 _: &str,
2718 _: usize,
2719 _: &str,
2720 _: &[&str],
2721 _: usize,
2722 ) -> Vec<OwnedSegment> {
2723 vec![]
2724 }
2725 fn group_instance_count(&self, group_path: &[&str]) -> usize {
2726 match group_path {
2727 ["SG2"] | ["SG4", "SG12"] => 1,
2728 _ => 0,
2729 }
2730 }
2731 }
2732
2733 let evaluator = MockEvaluator::new(vec![]);
2734 let validator = EdifactValidator::new(evaluator);
2735 let external = NoOpExternalProvider;
2736 let nav = TestNav;
2737
2738 let workflow = AhbWorkflow {
2739 pruefidentifikator: "55001".to_string(),
2740 description: "Test".to_string(),
2741 communication_direction: None,
2742 fields: vec![
2743 AhbFieldRule {
2744 segment_path: "SG2/NAD/3035".to_string(),
2745 name: "Absender".to_string(),
2746 ahb_status: "X".to_string(),
2747 codes: vec![AhbCodeRule {
2748 value: "MS".to_string(),
2749 description: "Absender".to_string(),
2750 ahb_status: "X".to_string(),
2751 }],
2752 parent_group_ahb_status: None,
2753 ..Default::default()
2754 },
2755 AhbFieldRule {
2756 segment_path: "SG2/NAD/3035".to_string(),
2757 name: "Empfaenger".to_string(),
2758 ahb_status: "X".to_string(),
2759 codes: vec![AhbCodeRule {
2760 value: "MR".to_string(),
2761 description: "Empfaenger".to_string(),
2762 ahb_status: "X".to_string(),
2763 }],
2764 parent_group_ahb_status: None,
2765 ..Default::default()
2766 },
2767 AhbFieldRule {
2768 segment_path: "SG4/SG12/NAD/3035".to_string(),
2769 name: "Anschlussnutzer".to_string(),
2770 ahb_status: "X".to_string(),
2771 codes: vec![AhbCodeRule {
2772 value: "Z04".to_string(),
2773 description: "Anschlussnutzer".to_string(),
2774 ahb_status: "X".to_string(),
2775 }],
2776 parent_group_ahb_status: None,
2777 ..Default::default()
2778 },
2779 AhbFieldRule {
2780 segment_path: "SG4/SG12/NAD/3035".to_string(),
2781 name: "Korrespondenzanschrift".to_string(),
2782 ahb_status: "X".to_string(),
2783 codes: vec![AhbCodeRule {
2784 value: "Z09".to_string(),
2785 description: "Korrespondenzanschrift".to_string(),
2786 ahb_status: "X".to_string(),
2787 }],
2788 parent_group_ahb_status: None,
2789 ..Default::default()
2790 },
2791 ],
2792 ub_definitions: HashMap::new(),
2793 };
2794
2795 let all_segments = vec![
2797 OwnedSegment {
2798 id: "NAD".into(),
2799 elements: vec![vec!["MS".into()]],
2800 segment_number: 3,
2801 },
2802 OwnedSegment {
2803 id: "NAD".into(),
2804 elements: vec![vec!["MT".into()]],
2805 segment_number: 4,
2806 },
2807 OwnedSegment {
2808 id: "NAD".into(),
2809 elements: vec![vec!["Z04".into()]],
2810 segment_number: 20,
2811 },
2812 OwnedSegment {
2813 id: "NAD".into(),
2814 elements: vec![vec!["Z09".into()]],
2815 segment_number: 21,
2816 },
2817 ];
2818
2819 let report = validator.validate_with_navigator(
2820 &all_segments,
2821 &workflow,
2822 &external,
2823 ValidationLevel::Conditions,
2824 &nav,
2825 );
2826
2827 let code_errors: Vec<_> = report
2828 .by_category(ValidationCategory::Code)
2829 .filter(|i| i.severity == Severity::Error)
2830 .collect();
2831
2832 assert_eq!(
2835 code_errors.len(),
2836 1,
2837 "Expected exactly one COD002 error for MT in SG2, got: {:?}",
2838 code_errors
2839 );
2840 assert!(code_errors[0].message.contains("MT"));
2841 assert!(code_errors[0].message.contains("MR"));
2843 assert!(code_errors[0].message.contains("MS"));
2844 assert!(
2845 !code_errors[0].message.contains("Z04"),
2846 "SG4/SG12 codes should not leak into SG2 error"
2847 );
2848 assert!(
2850 code_errors[0]
2851 .field_path
2852 .as_deref()
2853 .unwrap_or("")
2854 .contains("SG2"),
2855 "Error field_path should reference SG2, got: {:?}",
2856 code_errors[0].field_path
2857 );
2858 }
2859
2860 #[test]
2863 fn test_package_cardinality_within_bounds() {
2864 let evaluator = MockEvaluator::all_true(&[]);
2866 let validator = EdifactValidator::new(evaluator);
2867 let external = NoOpExternalProvider;
2868
2869 let segments = vec![OwnedSegment {
2870 id: "STS".into(),
2871 elements: vec![
2872 vec!["Z33".into()], vec![], vec!["E01".into()], ],
2876 segment_number: 5,
2877 }];
2878
2879 let workflow = AhbWorkflow {
2880 pruefidentifikator: "13017".to_string(),
2881 description: "Test".to_string(),
2882 communication_direction: None,
2883 ub_definitions: HashMap::new(),
2884 fields: vec![AhbFieldRule {
2885 segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
2886 name: "Statusanlaß, Code".to_string(),
2887 ahb_status: "X".to_string(),
2888 element_index: Some(2),
2889 component_index: Some(0),
2890 codes: vec![
2891 AhbCodeRule {
2892 value: "E01".into(),
2893 description: "Code 1".into(),
2894 ahb_status: "X [4P0..1]".into(),
2895 },
2896 AhbCodeRule {
2897 value: "E02".into(),
2898 description: "Code 2".into(),
2899 ahb_status: "X [4P0..1]".into(),
2900 },
2901 ],
2902 parent_group_ahb_status: Some("Muss".to_string()),
2903 mig_number: None,
2904 }],
2905 };
2906
2907 let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
2908 let pkg_errors: Vec<_> = report
2909 .by_category(ValidationCategory::Ahb)
2910 .filter(|i| i.code == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
2911 .collect();
2912 assert!(
2913 pkg_errors.is_empty(),
2914 "1 code within [4P0..1] bounds — no error expected, got: {:?}",
2915 pkg_errors
2916 );
2917 }
2918
2919 #[test]
2920 fn test_package_cardinality_zero_present_min_zero() {
2921 let evaluator = MockEvaluator::all_true(&[]);
2923 let validator = EdifactValidator::new(evaluator);
2924 let external = NoOpExternalProvider;
2925
2926 let segments = vec![OwnedSegment {
2927 id: "STS".into(),
2928 elements: vec![
2929 vec!["Z33".into()],
2930 vec![],
2931 vec!["X99".into()], ],
2933 segment_number: 5,
2934 }];
2935
2936 let workflow = AhbWorkflow {
2937 pruefidentifikator: "13017".to_string(),
2938 description: "Test".to_string(),
2939 communication_direction: None,
2940 ub_definitions: HashMap::new(),
2941 fields: vec![AhbFieldRule {
2942 segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
2943 name: "Statusanlaß, Code".to_string(),
2944 ahb_status: "X".to_string(),
2945 element_index: Some(2),
2946 component_index: Some(0),
2947 codes: vec![
2948 AhbCodeRule {
2949 value: "E01".into(),
2950 description: "Code 1".into(),
2951 ahb_status: "X [4P0..1]".into(),
2952 },
2953 AhbCodeRule {
2954 value: "E02".into(),
2955 description: "Code 2".into(),
2956 ahb_status: "X [4P0..1]".into(),
2957 },
2958 ],
2959 parent_group_ahb_status: Some("Muss".to_string()),
2960 mig_number: None,
2961 }],
2962 };
2963
2964 let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
2965 let pkg_errors: Vec<_> = report
2966 .by_category(ValidationCategory::Ahb)
2967 .filter(|i| i.code == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
2968 .collect();
2969 assert!(
2970 pkg_errors.is_empty(),
2971 "0 codes, min=0 — no error expected, got: {:?}",
2972 pkg_errors
2973 );
2974 }
2975
2976 #[test]
2977 fn test_package_cardinality_too_many() {
2978 let evaluator = MockEvaluator::all_true(&[]);
2980 let validator = EdifactValidator::new(evaluator);
2981 let external = NoOpExternalProvider;
2982
2983 let segments = vec![
2985 OwnedSegment {
2986 id: "STS".into(),
2987 elements: vec![vec!["Z33".into()], vec![], vec!["E01".into()]],
2988 segment_number: 5,
2989 },
2990 OwnedSegment {
2991 id: "STS".into(),
2992 elements: vec![vec!["Z33".into()], vec![], vec!["E02".into()]],
2993 segment_number: 6,
2994 },
2995 ];
2996
2997 let workflow = AhbWorkflow {
2998 pruefidentifikator: "13017".to_string(),
2999 description: "Test".to_string(),
3000 communication_direction: None,
3001 ub_definitions: HashMap::new(),
3002 fields: vec![AhbFieldRule {
3003 segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
3004 name: "Statusanlaß, Code".to_string(),
3005 ahb_status: "X".to_string(),
3006 element_index: Some(2),
3007 component_index: Some(0),
3008 codes: vec![
3009 AhbCodeRule {
3010 value: "E01".into(),
3011 description: "Code 1".into(),
3012 ahb_status: "X [4P0..1]".into(),
3013 },
3014 AhbCodeRule {
3015 value: "E02".into(),
3016 description: "Code 2".into(),
3017 ahb_status: "X [4P0..1]".into(),
3018 },
3019 ],
3020 parent_group_ahb_status: Some("Muss".to_string()),
3021 mig_number: None,
3022 }],
3023 };
3024
3025 let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
3026 let pkg_errors: Vec<_> = report
3027 .by_category(ValidationCategory::Ahb)
3028 .filter(|i| i.code == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
3029 .collect();
3030 assert_eq!(
3031 pkg_errors.len(),
3032 1,
3033 "2 codes present, max=1 — expected 1 error, got: {:?}",
3034 pkg_errors
3035 );
3036 assert!(pkg_errors[0].message.contains("[4P0..1]"));
3037 assert_eq!(pkg_errors[0].actual_value.as_deref(), Some("2"));
3038 assert_eq!(pkg_errors[0].expected_value.as_deref(), Some("0..1"));
3039 }
3040
3041 #[test]
3042 fn test_package_cardinality_too_few() {
3043 let evaluator = MockEvaluator::all_true(&[]);
3045 let validator = EdifactValidator::new(evaluator);
3046 let external = NoOpExternalProvider;
3047
3048 let segments = vec![OwnedSegment {
3049 id: "STS".into(),
3050 elements: vec![
3051 vec!["Z33".into()],
3052 vec![],
3053 vec!["X99".into()], ],
3055 segment_number: 5,
3056 }];
3057
3058 let workflow = AhbWorkflow {
3059 pruefidentifikator: "13017".to_string(),
3060 description: "Test".to_string(),
3061 communication_direction: None,
3062 ub_definitions: HashMap::new(),
3063 fields: vec![AhbFieldRule {
3064 segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
3065 name: "Statusanlaß, Code".to_string(),
3066 ahb_status: "X".to_string(),
3067 element_index: Some(2),
3068 component_index: Some(0),
3069 codes: vec![
3070 AhbCodeRule {
3071 value: "E01".into(),
3072 description: "Code 1".into(),
3073 ahb_status: "X [5P1..3]".into(),
3074 },
3075 AhbCodeRule {
3076 value: "E02".into(),
3077 description: "Code 2".into(),
3078 ahb_status: "X [5P1..3]".into(),
3079 },
3080 AhbCodeRule {
3081 value: "E03".into(),
3082 description: "Code 3".into(),
3083 ahb_status: "X [5P1..3]".into(),
3084 },
3085 ],
3086 parent_group_ahb_status: Some("Muss".to_string()),
3087 mig_number: None,
3088 }],
3089 };
3090
3091 let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
3092 let pkg_errors: Vec<_> = report
3093 .by_category(ValidationCategory::Ahb)
3094 .filter(|i| i.code == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
3095 .collect();
3096 assert_eq!(
3097 pkg_errors.len(),
3098 1,
3099 "0 codes present, min=1 — expected 1 error, got: {:?}",
3100 pkg_errors
3101 );
3102 assert!(pkg_errors[0].message.contains("[5P1..3]"));
3103 assert_eq!(pkg_errors[0].actual_value.as_deref(), Some("0"));
3104 assert_eq!(pkg_errors[0].expected_value.as_deref(), Some("1..3"));
3105 }
3106
3107 #[test]
3108 fn test_package_cardinality_no_packages_in_workflow() {
3109 let evaluator = MockEvaluator::all_true(&[]);
3111 let validator = EdifactValidator::new(evaluator);
3112 let external = NoOpExternalProvider;
3113
3114 let segments = vec![OwnedSegment {
3115 id: "STS".into(),
3116 elements: vec![vec!["E01".into()]],
3117 segment_number: 5,
3118 }];
3119
3120 let workflow = AhbWorkflow {
3121 pruefidentifikator: "13017".to_string(),
3122 description: "Test".to_string(),
3123 communication_direction: None,
3124 ub_definitions: HashMap::new(),
3125 fields: vec![AhbFieldRule {
3126 segment_path: "STS/9015".to_string(),
3127 name: "Status Code".to_string(),
3128 ahb_status: "X".to_string(),
3129 codes: vec![AhbCodeRule {
3130 value: "E01".into(),
3131 description: "Code 1".into(),
3132 ahb_status: "X".into(),
3133 }],
3134 parent_group_ahb_status: Some("Muss".to_string()),
3135 ..Default::default()
3136 }],
3137 };
3138
3139 let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
3140 let pkg_errors: Vec<_> = report
3141 .by_category(ValidationCategory::Ahb)
3142 .filter(|i| i.code == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
3143 .collect();
3144 assert!(
3145 pkg_errors.is_empty(),
3146 "No packages in workflow — no errors expected"
3147 );
3148 }
3149
3150 #[test]
3151 fn test_package_cardinality_with_condition_and_package() {
3152 let evaluator = MockEvaluator::all_true(&[901]);
3154 let validator = EdifactValidator::new(evaluator);
3155 let external = NoOpExternalProvider;
3156
3157 let segments = vec![OwnedSegment {
3158 id: "STS".into(),
3159 elements: vec![vec![], vec![], vec!["E01".into()]],
3160 segment_number: 5,
3161 }];
3162
3163 let workflow = AhbWorkflow {
3164 pruefidentifikator: "13017".to_string(),
3165 description: "Test".to_string(),
3166 communication_direction: None,
3167 ub_definitions: HashMap::new(),
3168 fields: vec![AhbFieldRule {
3169 segment_path: "SG10/STS/C556/9013".to_string(),
3170 name: "Code".to_string(),
3171 ahb_status: "X".to_string(),
3172 element_index: Some(2),
3173 component_index: Some(0),
3174 codes: vec![
3175 AhbCodeRule {
3176 value: "E01".into(),
3177 description: "Code 1".into(),
3178 ahb_status: "X [901] [4P0..1]".into(),
3179 },
3180 AhbCodeRule {
3181 value: "E02".into(),
3182 description: "Code 2".into(),
3183 ahb_status: "X [901] [4P0..1]".into(),
3184 },
3185 ],
3186 parent_group_ahb_status: Some("Muss".to_string()),
3187 mig_number: None,
3188 }],
3189 };
3190
3191 let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
3192 let pkg_errors: Vec<_> = report
3193 .by_category(ValidationCategory::Ahb)
3194 .filter(|i| i.code == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
3195 .collect();
3196 assert!(
3197 pkg_errors.is_empty(),
3198 "1 code within [4P0..1] bounds — no error, got: {:?}",
3199 pkg_errors
3200 );
3201 }
3202
3203 fn make_segment(id: &str, elements: Vec<Vec<&str>>) -> OwnedSegment {
3204 OwnedSegment {
3205 id: id.to_string(),
3206 elements: elements
3207 .into_iter()
3208 .map(|e| e.into_iter().map(|s| s.to_string()).collect())
3209 .collect(),
3210 segment_number: 0,
3211 }
3212 }
3213
3214 #[test]
3215 fn test_unt_count_correct() {
3216 let segments = vec![
3218 make_segment("UNH", vec![vec!["001"]]),
3219 make_segment("BGM", vec![vec!["E01"]]),
3220 make_segment("DTM", vec![vec!["137", "20250401"]]),
3221 make_segment("UNT", vec![vec!["4", "001"]]),
3222 ];
3223 assert!(
3224 validate_unt_segment_count(&segments).is_none(),
3225 "Correct count should produce no issue"
3226 );
3227 }
3228
3229 #[test]
3230 fn test_unt_count_mismatch() {
3231 let segments = vec![
3233 make_segment("UNH", vec![vec!["001"]]),
3234 make_segment("BGM", vec![vec!["E01"]]),
3235 make_segment("UNT", vec![vec!["5", "001"]]),
3236 ];
3237 let issue = validate_unt_segment_count(&segments)
3238 .expect("Mismatch should produce an issue");
3239 assert_eq!(issue.code, ErrorCodes::UNT_SEGMENT_COUNT_MISMATCH);
3240 assert_eq!(issue.severity, Severity::Error);
3241 assert!(issue.message.contains("declared 5"));
3242 assert!(issue.message.contains("actual 3"));
3243 }
3244
3245 #[test]
3246 fn test_unt_count_excludes_envelope() {
3247 let segments = vec![
3249 make_segment("UNA", vec![]),
3250 make_segment("UNB", vec![vec!["UNOC", "3"]]),
3251 make_segment("UNH", vec![vec!["001"]]),
3252 make_segment("BGM", vec![vec!["E01"]]),
3253 make_segment("UNT", vec![vec!["3", "001"]]),
3254 make_segment("UNZ", vec![vec!["1"]]),
3255 ];
3256 assert!(
3257 validate_unt_segment_count(&segments).is_none(),
3258 "Envelope segments excluded — count should be 3 (UNH+BGM+UNT)"
3259 );
3260 }
3261
3262 #[test]
3263 fn test_unt_count_no_unt_returns_none() {
3264 let segments = vec![
3265 make_segment("UNH", vec![vec!["001"]]),
3266 make_segment("BGM", vec![vec!["E01"]]),
3267 ];
3268 assert!(
3269 validate_unt_segment_count(&segments).is_none(),
3270 "No UNT segment should return None (not our problem)"
3271 );
3272 }
3273}