use std::collections::{HashMap, HashSet};
use crate::expr::{ConditionExpr, ConditionParser};
use crate::eval::{
ConditionEvaluator, ConditionExprEvaluator, ConditionResult, EvaluationContext, GroupScope,
ExternalConditionProvider,
};
use mig_types::navigator::GroupNavigator;
use mig_types::segment::OwnedSegment;
use super::tree::{AhbGroupNode, AhbNode, ValidatedTree};
use super::codes::ErrorCodes;
use super::issue::{Severity, ValidationCategory, ValidationIssue};
use super::level::ValidationLevel;
use super::report::ValidationReport;
#[derive(Debug, Clone, Default)]
pub struct AhbFieldRule {
pub segment_path: String,
pub name: String,
pub ahb_status: String,
pub codes: Vec<AhbCodeRule>,
pub parent_group_ahb_status: Option<String>,
pub segment_ahb_status: Option<String>,
pub element_index: Option<usize>,
pub component_index: Option<usize>,
pub mig_number: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct AhbCodeRule {
pub value: String,
pub description: String,
pub ahb_status: String,
}
#[derive(Debug, Clone)]
pub struct AhbWorkflow {
pub pruefidentifikator: String,
pub description: String,
pub communication_direction: Option<String>,
pub fields: Vec<AhbFieldRule>,
pub ub_definitions: HashMap<String, ConditionExpr>,
}
pub struct EdifactValidator<E: ConditionEvaluator> {
evaluator: E,
}
impl<E: ConditionEvaluator> EdifactValidator<E> {
pub fn new(evaluator: E) -> Self {
Self { evaluator }
}
pub fn validate(
&self,
segments: &[OwnedSegment],
workflow: &AhbWorkflow,
external: &dyn ExternalConditionProvider,
level: ValidationLevel,
) -> ValidationReport {
let mut report = ValidationReport::new(self.evaluator.message_type(), level)
.with_format_version(self.evaluator.format_version())
.with_pruefidentifikator(&workflow.pruefidentifikator);
let ctx = EvaluationContext::new(&workflow.pruefidentifikator, external, segments);
if matches!(level, ValidationLevel::Conditions | ValidationLevel::Full) {
self.validate_conditions(workflow, &ctx, &mut report);
}
report
}
pub fn validate_with_navigator(
&self,
segments: &[OwnedSegment],
workflow: &AhbWorkflow,
external: &dyn ExternalConditionProvider,
level: ValidationLevel,
navigator: &dyn GroupNavigator,
) -> ValidationReport {
let mut report = ValidationReport::new(self.evaluator.message_type(), level)
.with_format_version(self.evaluator.format_version())
.with_pruefidentifikator(&workflow.pruefidentifikator);
let ctx = EvaluationContext::with_navigator(
&workflow.pruefidentifikator,
external,
segments,
navigator,
);
if matches!(level, ValidationLevel::Conditions | ValidationLevel::Full) {
self.validate_conditions(workflow, &ctx, &mut report);
}
report
}
pub fn validate_tree(
&self,
validated_tree: &ValidatedTree,
segments: &[OwnedSegment],
external: &dyn ExternalConditionProvider,
level: ValidationLevel,
navigator: Option<&dyn GroupNavigator>,
) -> ValidationReport {
let mut report = ValidationReport::new(self.evaluator.message_type(), level)
.with_format_version(self.evaluator.format_version())
.with_pruefidentifikator(validated_tree.pruefidentifikator);
if !matches!(level, ValidationLevel::Conditions | ValidationLevel::Full) {
return report;
}
let ctx = match navigator {
Some(nav) => EvaluationContext::with_navigator(
validated_tree.pruefidentifikator,
external,
segments,
nav,
),
None => EvaluationContext::new(validated_tree.pruefidentifikator, external, segments),
};
let expr_eval = ConditionExprEvaluator::new(&self.evaluator);
let mut all_nodes: Vec<&AhbNode> = Vec::new();
all_nodes.extend(validated_tree.root_fields.iter());
for group in &validated_tree.groups {
collect_nodes_depth_first(group, &mut all_nodes);
}
let mut tag_migs: HashMap<String, HashSet<&str>> = HashMap::new();
for node in &all_nodes {
if let Some(ref m) = node.rule.mig_number {
tag_migs
.entry(extract_segment_id(&node.rule.segment_path))
.or_default()
.insert(m.as_str());
}
}
for rule in &validated_tree.unmatched_rules {
if let Some(ref m) = rule.mig_number {
tag_migs
.entry(extract_segment_id(&rule.segment_path))
.or_default()
.insert(m.as_str());
}
}
for node in &validated_tree.root_fields {
evaluate_node(
node,
&ctx,
&expr_eval,
&self.evaluator,
validated_tree.ub_definitions,
&tag_migs,
None,
&mut report,
);
}
let mut instance_counter: HashMap<&str, usize> = HashMap::new();
for group in &validated_tree.groups {
let instance_index = *instance_counter
.entry(group.group_id)
.and_modify(|c| *c += 1)
.or_insert(0);
let group_path_storage = [group.group_id];
let scoped_ctx = ctx.with_scope(GroupScope {
group_path: &group_path_storage,
instance_index,
});
walk_group_instance(
group,
&scoped_ctx,
&expr_eval,
&self.evaluator,
validated_tree.ub_definitions,
&tag_migs,
instance_index,
&mut report,
);
}
for field in &validated_tree.unmatched_rules {
if should_skip_for_parent_group(field, &expr_eval, &ctx, validated_tree.ub_definitions)
{
continue;
}
let (condition_result, _) = expr_eval.evaluate_status_detailed_with_ub(
&field.ahb_status,
&ctx,
validated_tree.ub_definitions,
);
if matches!(condition_result, ConditionResult::True)
&& is_mandatory_status(&field.ahb_status)
&& !is_field_present(&ctx, field)
&& !is_group_variant_absent(&ctx, field)
{
let mut issue = ValidationIssue::new(
Severity::Error,
ValidationCategory::Ahb,
ErrorCodes::MISSING_REQUIRED_FIELD,
format!(
"Required field '{}' at {} is missing",
field.name, field.segment_path
),
)
.with_field_path(&field.segment_path)
.with_rule(&field.ahb_status);
if let Some(first_code) = field.codes.first() {
issue.expected_value = Some(first_code.value.clone());
}
issue = attach_field_position(issue, field);
report.add_issue(issue);
}
}
if matches!(level, ValidationLevel::Full) {
let mut all_fields: Vec<AhbFieldRule> = Vec::new();
for node in &all_nodes {
all_fields.push(node.rule.clone());
}
for rule in &validated_tree.unmatched_rules {
all_fields.push((*rule).clone());
}
let synthetic_workflow = AhbWorkflow {
pruefidentifikator: validated_tree.pruefidentifikator.to_string(),
description: String::new(),
communication_direction: None,
fields: all_fields,
ub_definitions: validated_tree.ub_definitions.clone(),
};
self.validate_codes_cross_field(&synthetic_workflow, &ctx, &mut report);
self.validate_package_cardinality(&synthetic_workflow, &ctx, &mut report);
}
report
}
fn validate_conditions(
&self,
workflow: &AhbWorkflow,
ctx: &EvaluationContext,
report: &mut ValidationReport,
) {
let expr_eval = ConditionExprEvaluator::new(&self.evaluator);
for field in &workflow.fields {
if should_skip_for_parent_group(field, &expr_eval, ctx, &workflow.ub_definitions) {
continue;
}
let (condition_result, unknown_ids) = expr_eval.evaluate_status_detailed_with_ub(
&field.ahb_status,
ctx,
&workflow.ub_definitions,
);
match condition_result {
ConditionResult::True => {
if is_mandatory_status(&field.ahb_status)
&& !is_field_present(ctx, field)
&& !is_group_variant_absent(ctx, field)
{
let mut issue = ValidationIssue::new(
Severity::Error,
ValidationCategory::Ahb,
ErrorCodes::MISSING_REQUIRED_FIELD,
format!(
"Required field '{}' at {} is missing",
field.name, field.segment_path
),
)
.with_field_path(&field.segment_path)
.with_rule(&field.ahb_status);
if let Some(first_code) = field.codes.first() {
issue.expected_value = Some(first_code.value.clone());
}
report.add_issue(issue);
}
}
ConditionResult::False => {
if is_mandatory_status(&field.ahb_status) && is_field_present(ctx, field) {
report.add_issue(
ValidationIssue::new(
Severity::Error,
ValidationCategory::Ahb,
ErrorCodes::CONDITIONAL_RULE_VIOLATION,
format!(
"Field '{}' at {} is present but does not satisfy condition: {}",
field.name, field.segment_path, field.ahb_status
),
)
.with_field_path(&field.segment_path)
.with_rule(&field.ahb_status),
);
}
}
ConditionResult::Unknown => {
let mut external_ids = Vec::new();
let mut undetermined_ids = Vec::new();
let mut missing_ids = Vec::new();
for id in unknown_ids {
if self.evaluator.is_external(id) {
external_ids.push(id);
} else if self.evaluator.is_known(id) {
undetermined_ids.push(id);
} else {
missing_ids.push(id);
}
}
let mut parts = Vec::new();
if !external_ids.is_empty() {
let ids: Vec<String> =
external_ids.iter().map(|id| format!("[{id}]")).collect();
parts.push(format!(
"external conditions require provider: {}",
ids.join(", ")
));
}
if !undetermined_ids.is_empty() {
let ids: Vec<String> = undetermined_ids
.iter()
.map(|id| format!("[{id}]"))
.collect();
parts.push(format!(
"conditions could not be determined from message data: {}",
ids.join(", ")
));
}
if !missing_ids.is_empty() {
let ids: Vec<String> =
missing_ids.iter().map(|id| format!("[{id}]")).collect();
parts.push(format!("missing conditions: {}", ids.join(", ")));
}
let detail = if parts.is_empty() {
String::new()
} else {
format!(" ({})", parts.join("; "))
};
report.add_issue(
ValidationIssue::new(
Severity::Info,
ValidationCategory::Ahb,
ErrorCodes::CONDITION_UNKNOWN,
format!(
"Condition for field '{}' could not be fully evaluated{}",
field.name, detail
),
)
.with_field_path(&field.segment_path)
.with_rule(&field.ahb_status),
);
}
}
}
self.validate_codes_cross_field(workflow, ctx, report);
self.validate_package_cardinality(workflow, ctx, report);
}
fn validate_package_cardinality(
&self,
workflow: &AhbWorkflow,
ctx: &EvaluationContext,
report: &mut ValidationReport,
) {
struct PackageGroup {
min: u32,
max: u32,
code_values: Vec<String>,
element_index: usize,
component_index: usize,
}
let mut groups: HashMap<(String, Option<String>, u32), PackageGroup> = HashMap::new();
let expr_eval = ConditionExprEvaluator::new(&self.evaluator);
for field in &workflow.fields {
let el_idx = field.element_index.unwrap_or(0);
let comp_idx = field.component_index.unwrap_or(0);
if should_skip_for_parent_group(field, &expr_eval, ctx, &workflow.ub_definitions) {
continue;
}
for code in &field.codes {
if let Ok(Some(expr)) = ConditionParser::parse(&code.ahb_status) {
let mut packages = Vec::new();
collect_packages(&expr, &mut packages);
for (pkg_id, pkg_min, pkg_max) in packages {
let key = (field.segment_path.clone(), field.mig_number.clone(), pkg_id);
let group = groups.entry(key).or_insert_with(|| PackageGroup {
min: pkg_min,
max: pkg_max,
code_values: Vec::new(),
element_index: el_idx,
component_index: comp_idx,
});
group.min = group.min.max(pkg_min);
group.max = group.max.min(pkg_max);
group.code_values.push(code.value.clone());
}
}
}
}
for ((seg_path, mig_number, pkg_id), group) in &groups {
let segment_id = extract_segment_id(seg_path);
let mut unique_codes: Vec<&str> =
group.code_values.iter().map(|s| s.as_str()).collect();
unique_codes.sort_unstable();
unique_codes.dedup();
let code_set: HashSet<&str> = unique_codes.iter().copied().collect();
let group_path_str = extract_group_path_key(seg_path);
let group_path: Vec<&str> = if group_path_str.is_empty() {
Vec::new()
} else {
group_path_str.split('/').collect()
};
let min = group.min as usize;
let max = group.max as usize;
let per_instance_counts: Option<Vec<usize>> =
match (ctx.navigator, group_path.is_empty()) {
(Some(nav), false) => {
let instance_count = nav.group_instance_count(&group_path);
if instance_count == 0 {
None
} else {
Some(
(0..instance_count)
.filter(|i| match mig_number.as_deref() {
Some(m) => {
nav.instance_has_mig_number(&group_path, *i, m)
}
None => true,
})
.map(|i| {
nav.find_segments_in_group(&segment_id, &group_path, i)
.iter()
.filter_map(|seg| {
seg.elements
.get(group.element_index)
.and_then(|e| e.get(group.component_index))
.filter(|v| !v.is_empty())
.cloned()
})
.filter(|v| code_set.contains(v.as_str()))
.count()
})
.collect(),
)
}
}
_ => None,
};
let counts: Vec<usize> = per_instance_counts.unwrap_or_else(|| {
let segments = ctx.find_segments(&segment_id);
let count = segments
.iter()
.filter_map(|seg| {
seg.elements
.get(group.element_index)
.and_then(|e| e.get(group.component_index))
.filter(|v| !v.is_empty())
.map(|s| s.as_str())
})
.filter(|v| code_set.contains(v))
.count();
vec![count]
});
let mut reported_counts: HashSet<usize> = HashSet::new();
for present_count in counts {
if (present_count < min || present_count > max)
&& reported_counts.insert(present_count)
{
let code_list = unique_codes.join(", ");
report.add_issue(
ValidationIssue::new(
Severity::Error,
ValidationCategory::Ahb,
ErrorCodes::PACKAGE_CARDINALITY_VIOLATION,
format!(
"Package [{}P{}..{}] at {}: {} code(s) present (allowed {}..{}). Codes in package: [{}]",
pkg_id, group.min, group.max, seg_path, present_count, group.min, group.max, code_list
),
)
.with_field_path(seg_path)
.with_expected(format!("{}..{}", group.min, group.max))
.with_actual(present_count.to_string()),
);
}
}
}
}
fn validate_codes_cross_field(
&self,
workflow: &AhbWorkflow,
ctx: &EvaluationContext,
report: &mut ValidationReport,
) {
if ctx.navigator.is_some() {
self.validate_codes_group_scoped(workflow, ctx, report);
} else {
self.validate_codes_tag_scoped(workflow, ctx, report);
}
}
fn validate_codes_group_scoped(
&self,
workflow: &AhbWorkflow,
ctx: &EvaluationContext,
report: &mut ValidationReport,
) {
let by_loc = partition_codes_by_mig(workflow);
let known_qualifiers = global_qualifiers_by_tag(&by_loc);
let nav = ctx.navigator.unwrap();
for ((group_key, tag), migs) in &by_loc {
let field_path = if group_key.is_empty() {
format!("{tag}/qualifier")
} else {
format!("{group_key}/{tag}/qualifier")
};
let group_path: Vec<&str> = if group_key.is_empty() {
Vec::new()
} else {
group_key.split('/').collect()
};
let tag_qualifiers = known_qualifiers.get(tag);
if group_path.is_empty() {
Self::validate_segments_per_mig(
&ctx.find_segments(tag),
migs,
tag_qualifiers,
tag,
&field_path,
report,
);
} else {
let instance_count = nav.group_instance_count(&group_path);
for i in 0..instance_count {
let owned = nav.find_segments_in_group(tag, &group_path, i);
let refs: Vec<&OwnedSegment> = owned.iter().collect();
Self::validate_segments_per_mig(
&refs,
migs,
tag_qualifiers,
tag,
&field_path,
report,
);
}
}
}
}
fn validate_codes_tag_scoped(
&self,
workflow: &AhbWorkflow,
ctx: &EvaluationContext,
report: &mut ValidationReport,
) {
let by_loc = partition_codes_by_mig(workflow);
let known_qualifiers = global_qualifiers_by_tag(&by_loc);
let mut by_tag: HashMap<String, HashMap<Option<String>, MigCodeBucket>> = HashMap::new();
for ((_group_key, tag), migs) in by_loc {
let merged = by_tag.entry(tag).or_default();
for (mig_key, bucket) in migs {
let entry = merged.entry(mig_key).or_default();
if entry.qualifier_position.is_none() {
entry.qualifier_position = bucket.qualifier_position;
}
if entry.qualifier_position == bucket.qualifier_position {
entry.qualifier_values.extend(&bucket.qualifier_values);
}
for (pos, codes) in bucket.codes {
entry.codes.entry(pos).or_default().extend(codes);
}
}
}
for (tag, migs) in &by_tag {
let field_path = format!("{tag}/qualifier");
Self::validate_segments_per_mig(
&ctx.find_segments(tag),
migs,
known_qualifiers.get(tag),
tag,
&field_path,
report,
);
}
}
fn validate_segments_per_mig(
segments: &[&OwnedSegment],
migs: &HashMap<Option<String>, MigCodeBucket>,
tag_qualifiers: Option<&HashMap<(usize, usize), HashSet<String>>>,
tag: &str,
field_path: &str,
report: &mut ValidationReport,
) {
for seg in segments {
match match_segment_to_mig(seg, migs) {
Some(bucket) => {
for ((el, c), allowed) in &bucket.codes {
if allowed.is_empty() {
continue;
}
Self::check_segments_against_codes(
vec![*seg],
allowed,
tag,
*el,
*c,
field_path,
report,
);
}
}
None => {
let Some(qualifiers) = tag_qualifiers else {
continue;
};
for ((el, c), allowed) in qualifiers {
let Some(actual) = seg
.elements
.get(*el)
.and_then(|e| e.get(*c))
.filter(|v| !v.is_empty())
.map(|s| s.as_str())
else {
continue;
};
if allowed.iter().any(|v| v == actual) {
break;
}
let bucket_values: HashSet<&str> = migs
.values()
.filter(|b| b.qualifier_position == Some((*el, *c)))
.flat_map(|b| b.qualifier_values.iter().copied())
.collect();
if !bucket_values.is_empty() {
Self::check_segments_against_codes(
vec![*seg],
&bucket_values,
tag,
*el,
*c,
field_path,
report,
);
break;
}
}
}
}
}
}
fn check_segments_against_codes(
segments: Vec<&OwnedSegment>,
allowed_codes: &HashSet<&str>,
_tag: &str,
el_idx: usize,
comp_idx: usize,
field_path: &str,
report: &mut ValidationReport,
) {
for segment in segments {
if let Some(code_value) = segment
.elements
.get(el_idx)
.and_then(|e| e.get(comp_idx))
.filter(|v| !v.is_empty())
{
if !allowed_codes.contains(code_value.as_str()) {
let mut sorted_codes: Vec<&str> = allowed_codes.iter().copied().collect();
sorted_codes.sort_unstable();
report.add_issue(
ValidationIssue::new(
Severity::Error,
ValidationCategory::Code,
ErrorCodes::CODE_NOT_ALLOWED_FOR_PID,
format!(
"Code '{}' is not allowed for this PID. Allowed: [{}]",
code_value,
sorted_codes.join(", ")
),
)
.with_field_path(field_path)
.with_actual(code_value)
.with_expected(sorted_codes.join(", ")),
);
}
}
}
}
}
fn should_skip_for_parent_group<E: ConditionEvaluator>(
field: &AhbFieldRule,
expr_eval: &ConditionExprEvaluator<E>,
ctx: &EvaluationContext,
ub_definitions: &HashMap<String, ConditionExpr>,
) -> bool {
if let Some(ref group_status) = field.parent_group_ahb_status {
if group_status.contains('[') {
let result = expr_eval.evaluate_status_with_ub(group_status, ctx, ub_definitions);
return matches!(result, ConditionResult::False | ConditionResult::Unknown);
}
}
false
}
fn is_field_present(ctx: &EvaluationContext, field: &AhbFieldRule) -> bool {
let segment_id = extract_segment_id(&field.segment_path);
if !field.codes.is_empty() {
if let (Some(el_idx), Some(comp_idx)) = (field.element_index, field.component_index) {
let required_codes: Vec<&str> = field.codes.iter().map(|c| c.value.as_str()).collect();
let matching = ctx.find_segments(&segment_id);
return matching.iter().any(|seg| {
seg.elements
.get(el_idx)
.and_then(|e| e.get(comp_idx))
.is_some_and(|v| required_codes.contains(&v.as_str()))
});
}
if is_qualifier_field(&field.segment_path) {
let required_codes: Vec<&str> = field.codes.iter().map(|c| c.value.as_str()).collect();
let el_idx = field.element_index.unwrap_or(0);
let comp_idx = field.component_index.unwrap_or(0);
let matching = ctx.find_segments(&segment_id);
return matching.iter().any(|seg| {
seg.elements
.get(el_idx)
.and_then(|e| e.get(comp_idx))
.is_some_and(|v| required_codes.contains(&v.as_str()))
});
}
}
ctx.has_segment(&segment_id)
}
fn is_group_variant_absent(ctx: &EvaluationContext, field: &AhbFieldRule) -> bool {
let group_path: Vec<&str> = field
.segment_path
.split('/')
.take_while(|p| p.starts_with("SG"))
.collect();
if group_path.is_empty() {
return false;
}
let nav = match ctx.navigator {
Some(nav) => nav,
None => return false,
};
let instance_count = nav.group_instance_count(&group_path);
if instance_count == 0 {
let is_group_mandatory = field
.parent_group_ahb_status
.as_deref()
.is_some_and(is_mandatory_status);
if !is_group_mandatory {
return true;
}
return false;
}
if let Some(ref group_status) = field.parent_group_ahb_status {
if !is_mandatory_status(group_status) && !group_status.contains('[') {
if !field.codes.is_empty() && is_qualifier_field(&field.segment_path) {
let segment_id = extract_segment_id(&field.segment_path);
let required_codes: Vec<&str> =
field.codes.iter().map(|c| c.value.as_str()).collect();
let any_instance_has_qualifier = (0..instance_count).any(|i| {
nav.find_segments_in_group(&segment_id, &group_path, i)
.iter()
.any(|seg| {
seg.elements
.first()
.and_then(|e| e.first())
.is_some_and(|v| required_codes.contains(&v.as_str()))
})
});
if !any_instance_has_qualifier {
return true; }
}
}
}
let segment_id = extract_segment_id(&field.segment_path);
let segment_absent_from_all = (0..instance_count).all(|i| {
nav.find_segments_in_group(&segment_id, &group_path, i)
.is_empty()
});
if segment_absent_from_all {
let group_has_other_segments =
(0..instance_count).any(|i| nav.has_any_segment_in_group(&group_path, i));
if group_has_other_segments {
return true;
}
}
false
}
fn collect_nodes_depth_first<'a, 'b>(group: &'b AhbGroupNode<'a>, out: &mut Vec<&'b AhbNode<'a>>) {
out.extend(group.fields.iter());
for child in &group.children {
collect_nodes_depth_first(child, out);
}
}
#[allow(clippy::too_many_arguments)]
fn evaluate_node<E: ConditionEvaluator>(
node: &AhbNode,
ctx: &EvaluationContext,
expr_eval: &ConditionExprEvaluator<E>,
evaluator: &E,
ub_definitions: &HashMap<String, ConditionExpr>,
tag_migs: &HashMap<String, HashSet<&str>>,
instance_index: Option<usize>,
report: &mut ValidationReport,
) {
let field = node.rule;
let node_ctx = ctx.with_resolved(node.value, node.segment_elements);
if should_skip_for_parent_group(field, expr_eval, ctx, ub_definitions) {
return;
}
let (condition_result, unknown_ids) =
expr_eval.evaluate_status_detailed_with_ub(&field.ahb_status, &node_ctx, ub_definitions);
match condition_result {
ConditionResult::True => {
if is_mandatory_status(&field.ahb_status) && node.value.is_none() {
let tag = extract_segment_id(&field.segment_path);
let single_variant_present = node.segment_elements.is_none()
&& tag_migs.get(&tag).is_some_and(|ms| ms.len() == 1)
&& is_field_present(ctx, field);
let segment_optional_and_absent = node.segment_elements.is_none()
&& field
.segment_ahb_status
.as_deref()
.is_some_and(is_optional_segment_status);
if !single_variant_present && !segment_optional_and_absent {
let mut issue = ValidationIssue::new(
Severity::Error,
ValidationCategory::Ahb,
ErrorCodes::MISSING_REQUIRED_FIELD,
format!(
"Required field '{}' at {} is missing",
field.name, field.segment_path
),
)
.with_field_path(&field.segment_path)
.with_rule(&field.ahb_status);
if let Some(first_code) = field.codes.first() {
issue.expected_value = Some(first_code.value.clone());
}
if let Some(idx) = instance_index {
issue = issue.with_instance_index(idx);
}
if let Some(num) = node.matched_segment_number {
issue = issue.with_position(crate::SegmentPosition {
segment_number: num,
byte_offset: 0,
message_number: 1,
});
}
issue = attach_field_position(issue, field);
report.add_issue(issue);
}
}
}
ConditionResult::False => {
if is_mandatory_status(&field.ahb_status) && node.value.is_some() {
let mut issue = ValidationIssue::new(
Severity::Error,
ValidationCategory::Ahb,
ErrorCodes::CONDITIONAL_RULE_VIOLATION,
format!(
"Field '{}' at {} is present but does not satisfy condition: {}",
field.name, field.segment_path, field.ahb_status
),
)
.with_field_path(&field.segment_path)
.with_rule(&field.ahb_status);
if let Some(idx) = instance_index {
issue = issue.with_instance_index(idx);
}
if let Some(num) = node.matched_segment_number {
issue = issue.with_position(crate::SegmentPosition {
segment_number: num,
byte_offset: 0,
message_number: 1,
});
}
issue = attach_field_position(issue, field);
report.add_issue(issue);
}
}
ConditionResult::Unknown => {
let mut external_ids = Vec::new();
let mut undetermined_ids = Vec::new();
let mut missing_ids = Vec::new();
for id in unknown_ids {
if evaluator.is_external(id) {
external_ids.push(id);
} else if evaluator.is_known(id) {
undetermined_ids.push(id);
} else {
missing_ids.push(id);
}
}
let mut parts = Vec::new();
if !external_ids.is_empty() {
let ids: Vec<String> = external_ids.iter().map(|id| format!("[{id}]")).collect();
parts.push(format!(
"external conditions require provider: {}",
ids.join(", ")
));
}
if !undetermined_ids.is_empty() {
let ids: Vec<String> = undetermined_ids
.iter()
.map(|id| format!("[{id}]"))
.collect();
parts.push(format!(
"conditions could not be determined from message data: {}",
ids.join(", ")
));
}
if !missing_ids.is_empty() {
let ids: Vec<String> = missing_ids.iter().map(|id| format!("[{id}]")).collect();
parts.push(format!("missing conditions: {}", ids.join(", ")));
}
let detail = if parts.is_empty() {
String::new()
} else {
format!(" ({})", parts.join("; "))
};
let mut issue = ValidationIssue::new(
Severity::Info,
ValidationCategory::Ahb,
ErrorCodes::CONDITION_UNKNOWN,
format!(
"Condition for field '{}' could not be fully evaluated{}",
field.name, detail
),
)
.with_field_path(&field.segment_path)
.with_rule(&field.ahb_status);
if let Some(idx) = instance_index {
issue = issue.with_instance_index(idx);
}
if let Some(num) = node.matched_segment_number {
issue = issue.with_position(crate::SegmentPosition {
segment_number: num,
byte_offset: 0,
message_number: 1,
});
}
issue = attach_field_position(issue, field);
report.add_issue(issue);
}
}
}
fn attach_field_position(issue: ValidationIssue, field: &AhbFieldRule) -> ValidationIssue {
match field.element_index {
Some(el) => {
let element_pos = (el as u32) + 2;
let component_pos = field.component_index.map(|c| (c as u32) + 1);
issue.with_field_position(element_pos, component_pos)
}
None => issue,
}
}
#[allow(clippy::too_many_arguments)]
fn walk_group_instance<E: ConditionEvaluator>(
group: &AhbGroupNode,
scoped_ctx: &EvaluationContext,
expr_eval: &ConditionExprEvaluator<E>,
evaluator: &E,
ub_definitions: &HashMap<String, ConditionExpr>,
tag_migs: &HashMap<String, HashSet<&str>>,
instance_index: usize,
report: &mut ValidationReport,
) {
for node in &group.fields {
evaluate_node(
node,
scoped_ctx,
expr_eval,
evaluator,
ub_definitions,
tag_migs,
Some(instance_index),
report,
);
}
for child in &group.children {
walk_group_instance(
child,
scoped_ctx,
expr_eval,
evaluator,
ub_definitions,
tag_migs,
instance_index,
report,
);
}
}
fn collect_packages(expr: &ConditionExpr, out: &mut Vec<(u32, u32, u32)>) {
match expr {
ConditionExpr::Package { id, min, max } => {
out.push((*id, *min, *max));
}
ConditionExpr::And(exprs) | ConditionExpr::Or(exprs) => {
for e in exprs {
collect_packages(e, out);
}
}
ConditionExpr::Xor(left, right) => {
collect_packages(left, out);
collect_packages(right, out);
}
ConditionExpr::Not(inner) => {
collect_packages(inner, out);
}
ConditionExpr::Ref(_) => {}
}
}
fn is_mandatory_status(status: &str) -> bool {
let trimmed = status.trim();
trimmed.starts_with("Muss") || trimmed.starts_with('X')
}
fn is_optional_segment_status(status: &str) -> bool {
let trimmed = status.trim();
trimmed.starts_with("Kann") || trimmed.starts_with("Soll")
}
fn is_qualifier_field(path: &str) -> bool {
let parts: Vec<&str> = path.split('/').filter(|p| !p.starts_with("SG")).collect();
matches!(parts.len(), 2 | 3)
}
#[derive(Default)]
struct MigCodeBucket<'a> {
qualifier_position: Option<(usize, usize)>,
qualifier_values: HashSet<&'a str>,
codes: HashMap<(usize, usize), HashSet<&'a str>>,
}
fn partition_codes_by_mig(
workflow: &AhbWorkflow,
) -> HashMap<(String, String), HashMap<Option<String>, MigCodeBucket<'_>>> {
let mut out: HashMap<(String, String), HashMap<Option<String>, MigCodeBucket>> = HashMap::new();
for field in &workflow.fields {
if field.codes.is_empty() || !is_qualifier_field(&field.segment_path) {
continue;
}
let tag = extract_segment_id(&field.segment_path);
let group_key = extract_group_path_key(&field.segment_path);
let mig = field.mig_number.clone();
let el = field.element_index.unwrap_or(0);
let c = field.component_index.unwrap_or(0);
let bucket = out
.entry((group_key, tag))
.or_default()
.entry(mig)
.or_default();
let required: Vec<&str> = field
.codes
.iter()
.filter(|code| code.ahb_status.starts_with('X') || code.ahb_status.starts_with("Muss"))
.map(|code| code.value.as_str())
.collect();
if !required.is_empty() {
if bucket.qualifier_position.is_none() {
bucket.qualifier_position = Some((el, c));
}
if bucket.qualifier_position == Some((el, c)) {
bucket.qualifier_values.extend(required.iter().copied());
}
}
for v in required {
bucket.codes.entry((el, c)).or_default().insert(v);
}
}
out
}
fn global_qualifiers_by_tag(
by_loc: &HashMap<(String, String), HashMap<Option<String>, MigCodeBucket<'_>>>,
) -> HashMap<String, HashMap<(usize, usize), HashSet<String>>> {
let mut out: HashMap<String, HashMap<(usize, usize), HashSet<String>>> = HashMap::new();
for ((_group, tag), migs) in by_loc {
let tag_entry = out.entry(tag.clone()).or_default();
for bucket in migs.values() {
if let Some(pos) = bucket.qualifier_position {
tag_entry
.entry(pos)
.or_default()
.extend(bucket.qualifier_values.iter().map(|s| s.to_string()));
}
}
}
out
}
fn match_segment_to_mig<'a, 'b>(
seg: &OwnedSegment,
migs: &'a HashMap<Option<String>, MigCodeBucket<'b>>,
) -> Option<&'a MigCodeBucket<'b>> {
let actual_at = |el: usize, c: usize| -> &str {
seg.elements
.get(el)
.and_then(|e| e.get(c))
.map(|s| s.as_str())
.unwrap_or("")
};
let mut best: Option<&MigCodeBucket> = None;
let mut best_matches = 0usize;
for bucket in migs.values() {
let Some((el, c)) = bucket.qualifier_position else {
continue;
};
if !bucket.qualifier_values.contains(actual_at(el, c)) {
continue;
}
let extra_matches = bucket
.codes
.iter()
.filter(|(pos, _)| **pos != (el, c))
.filter(|((e, k), allowed)| {
let v = actual_at(*e, *k);
!v.is_empty() && allowed.contains(v)
})
.count();
if best.is_none() || extra_matches > best_matches {
best = Some(bucket);
best_matches = extra_matches;
}
}
best
}
fn extract_group_path_key(path: &str) -> String {
let sg_parts: Vec<&str> = path
.split('/')
.take_while(|p| p.starts_with("SG"))
.collect();
sg_parts.join("/")
}
fn extract_segment_id(path: &str) -> String {
for part in path.split('/') {
if part.starts_with("SG") || part.starts_with("C_") || part.starts_with("D_") {
continue;
}
if part.len() >= 3
&& part
.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
{
return part.to_string();
}
}
path.split('/').next_back().unwrap_or(path).to_string()
}
pub fn validate_unt_segment_count(segments: &[OwnedSegment]) -> Option<ValidationIssue> {
let unh_count = segments.iter().filter(|s| s.id == "UNH").count();
if unh_count > 1 {
return Some(ValidationIssue::new(
Severity::Error,
ValidationCategory::Structure,
ErrorCodes::UNT_SEGMENT_COUNT_MISMATCH,
format!("UNT validation requires per-message segments, found {unh_count} UNH segments"),
));
}
let unt = segments.iter().rfind(|s| s.id == "UNT")?;
let declared: usize = unt.get_element(0).parse().ok()?;
let actual = segments
.iter()
.filter(|s| s.id != "UNA" && s.id != "UNB" && s.id != "UNZ")
.count();
if declared != actual {
Some(
ValidationIssue::new(
Severity::Error,
ValidationCategory::Structure,
ErrorCodes::UNT_SEGMENT_COUNT_MISMATCH,
format!("UNT segment count mismatch: declared {declared}, actual {actual}"),
)
.with_field_path("UNT/0074")
.with_expected(actual.to_string())
.with_actual(declared.to_string()),
)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::eval::{ConditionResult as CR, NoOpExternalProvider};
use std::collections::HashMap;
struct MockEvaluator {
results: HashMap<u32, CR>,
}
impl MockEvaluator {
fn new(results: Vec<(u32, CR)>) -> Self {
Self {
results: results.into_iter().collect(),
}
}
fn all_true(ids: &[u32]) -> Self {
Self::new(ids.iter().map(|&id| (id, CR::True)).collect())
}
}
impl ConditionEvaluator for MockEvaluator {
fn evaluate(&self, condition: u32, _ctx: &EvaluationContext) -> CR {
self.results.get(&condition).copied().unwrap_or(CR::Unknown)
}
fn is_external(&self, _condition: u32) -> bool {
false
}
fn message_type(&self) -> &str {
"UTILMD"
}
fn format_version(&self) -> &str {
"FV2510"
}
}
#[test]
fn test_is_mandatory_status() {
assert!(is_mandatory_status("Muss"));
assert!(is_mandatory_status("Muss [182] ∧ [152]"));
assert!(is_mandatory_status("X"));
assert!(is_mandatory_status("X [567]"));
assert!(!is_mandatory_status("Soll [1]"));
assert!(!is_mandatory_status("Kann [1]"));
assert!(!is_mandatory_status(""));
}
#[test]
fn test_extract_segment_id_simple() {
assert_eq!(extract_segment_id("NAD"), "NAD");
}
#[test]
fn test_extract_segment_id_with_sg_prefix() {
assert_eq!(extract_segment_id("SG2/NAD/C082/3039"), "NAD");
}
#[test]
fn test_extract_segment_id_nested_sg() {
assert_eq!(extract_segment_id("SG4/SG8/SEQ/C286/6350"), "SEQ");
}
#[test]
fn test_validate_missing_mandatory_field() {
let evaluator = MockEvaluator::all_true(&[182, 152]);
let validator = EdifactValidator::new(evaluator);
let external = NoOpExternalProvider;
let workflow = AhbWorkflow {
pruefidentifikator: "11001".to_string(),
description: "Test".to_string(),
communication_direction: None,
fields: vec![AhbFieldRule {
segment_path: "SG2/NAD/C082/3039".to_string(),
name: "MP-ID des MSB".to_string(),
ahb_status: "Muss [182] ∧ [152]".to_string(),
codes: vec![],
parent_group_ahb_status: None,
segment_ahb_status: None,
..Default::default()
}],
ub_definitions: HashMap::new(),
};
let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
assert!(!report.is_valid());
let errors: Vec<_> = report.errors().collect();
assert_eq!(errors.len(), 1);
assert_eq!(errors[0].code, ErrorCodes::MISSING_REQUIRED_FIELD);
assert!(errors[0].message.contains("MP-ID des MSB"));
}
#[test]
fn test_validate_condition_false_no_error() {
let evaluator = MockEvaluator::new(vec![(182, CR::True), (152, CR::False)]);
let validator = EdifactValidator::new(evaluator);
let external = NoOpExternalProvider;
let workflow = AhbWorkflow {
pruefidentifikator: "11001".to_string(),
description: "Test".to_string(),
communication_direction: None,
fields: vec![AhbFieldRule {
segment_path: "NAD".to_string(),
name: "Partnerrolle".to_string(),
ahb_status: "Muss [182] ∧ [152]".to_string(),
codes: vec![],
parent_group_ahb_status: None,
segment_ahb_status: None,
..Default::default()
}],
ub_definitions: HashMap::new(),
};
let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
assert!(report.is_valid());
}
#[test]
fn test_validate_condition_unknown_adds_info() {
let evaluator = MockEvaluator::new(vec![(182, CR::True)]);
let validator = EdifactValidator::new(evaluator);
let external = NoOpExternalProvider;
let workflow = AhbWorkflow {
pruefidentifikator: "11001".to_string(),
description: "Test".to_string(),
communication_direction: None,
fields: vec![AhbFieldRule {
segment_path: "NAD".to_string(),
name: "Partnerrolle".to_string(),
ahb_status: "Muss [182] ∧ [152]".to_string(),
codes: vec![],
parent_group_ahb_status: None,
segment_ahb_status: None,
..Default::default()
}],
ub_definitions: HashMap::new(),
};
let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
assert!(report.is_valid());
let infos: Vec<_> = report.infos().collect();
assert_eq!(infos.len(), 1);
assert_eq!(infos[0].code, ErrorCodes::CONDITION_UNKNOWN);
}
#[test]
fn test_validate_structure_level_skips_conditions() {
let evaluator = MockEvaluator::all_true(&[182, 152]);
let validator = EdifactValidator::new(evaluator);
let external = NoOpExternalProvider;
let workflow = AhbWorkflow {
pruefidentifikator: "11001".to_string(),
description: "Test".to_string(),
communication_direction: None,
fields: vec![AhbFieldRule {
segment_path: "NAD".to_string(),
name: "Partnerrolle".to_string(),
ahb_status: "Muss [182] ∧ [152]".to_string(),
codes: vec![],
parent_group_ahb_status: None,
segment_ahb_status: None,
..Default::default()
}],
ub_definitions: HashMap::new(),
};
let report = validator.validate(&[], &workflow, &external, ValidationLevel::Structure);
assert!(report.is_valid());
assert_eq!(report.by_category(ValidationCategory::Ahb).count(), 0);
}
#[test]
fn test_validate_empty_workflow_no_condition_errors() {
let evaluator = MockEvaluator::all_true(&[]);
let validator = EdifactValidator::new(evaluator);
let external = NoOpExternalProvider;
let empty_workflow = AhbWorkflow {
pruefidentifikator: String::new(),
description: String::new(),
communication_direction: None,
fields: vec![],
ub_definitions: HashMap::new(),
};
let report = validator.validate(&[], &empty_workflow, &external, ValidationLevel::Full);
assert!(report.is_valid());
}
#[test]
fn test_validate_bare_muss_always_required() {
let evaluator = MockEvaluator::new(vec![]);
let validator = EdifactValidator::new(evaluator);
let external = NoOpExternalProvider;
let workflow = AhbWorkflow {
pruefidentifikator: "55001".to_string(),
description: "Test".to_string(),
communication_direction: Some("NB an LF".to_string()),
fields: vec![AhbFieldRule {
segment_path: "SG2/NAD/3035".to_string(),
name: "Partnerrolle".to_string(),
ahb_status: "Muss".to_string(), codes: vec![],
parent_group_ahb_status: None,
segment_ahb_status: None,
..Default::default()
}],
ub_definitions: HashMap::new(),
};
let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
assert!(!report.is_valid());
assert_eq!(report.error_count(), 1);
}
#[test]
fn test_validate_x_status_is_mandatory() {
let evaluator = MockEvaluator::new(vec![]);
let validator = EdifactValidator::new(evaluator);
let external = NoOpExternalProvider;
let workflow = AhbWorkflow {
pruefidentifikator: "55001".to_string(),
description: "Test".to_string(),
communication_direction: None,
fields: vec![AhbFieldRule {
segment_path: "DTM".to_string(),
name: "Datum".to_string(),
ahb_status: "X".to_string(),
codes: vec![],
parent_group_ahb_status: None,
segment_ahb_status: None,
..Default::default()
}],
ub_definitions: HashMap::new(),
};
let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
assert!(!report.is_valid());
let errors: Vec<_> = report.errors().collect();
assert_eq!(errors[0].code, ErrorCodes::MISSING_REQUIRED_FIELD);
}
#[test]
fn test_validate_soll_not_mandatory() {
let evaluator = MockEvaluator::new(vec![]);
let validator = EdifactValidator::new(evaluator);
let external = NoOpExternalProvider;
let workflow = AhbWorkflow {
pruefidentifikator: "55001".to_string(),
description: "Test".to_string(),
communication_direction: None,
fields: vec![AhbFieldRule {
segment_path: "DTM".to_string(),
name: "Datum".to_string(),
ahb_status: "Soll".to_string(),
codes: vec![],
parent_group_ahb_status: None,
segment_ahb_status: None,
..Default::default()
}],
ub_definitions: HashMap::new(),
};
let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
assert!(report.is_valid());
}
#[test]
fn test_report_includes_metadata() {
let evaluator = MockEvaluator::new(vec![]);
let validator = EdifactValidator::new(evaluator);
let external = NoOpExternalProvider;
let workflow = AhbWorkflow {
pruefidentifikator: "55001".to_string(),
description: String::new(),
communication_direction: None,
fields: vec![],
ub_definitions: HashMap::new(),
};
let report = validator.validate(&[], &workflow, &external, ValidationLevel::Full);
assert_eq!(report.format_version.as_deref(), Some("FV2510"));
assert_eq!(report.level, ValidationLevel::Full);
assert_eq!(report.message_type, "UTILMD");
assert_eq!(report.pruefidentifikator.as_deref(), Some("55001"));
}
#[test]
fn test_validate_with_navigator_returns_report() {
let evaluator = MockEvaluator::all_true(&[]);
let validator = EdifactValidator::new(evaluator);
let external = NoOpExternalProvider;
let nav = crate::eval::NoOpGroupNavigator;
let workflow = AhbWorkflow {
pruefidentifikator: "55001".to_string(),
description: "Test".to_string(),
communication_direction: None,
fields: vec![],
ub_definitions: HashMap::new(),
};
let report = validator.validate_with_navigator(
&[],
&workflow,
&external,
ValidationLevel::Full,
&nav,
);
assert!(report.is_valid());
}
#[test]
fn test_code_validation_composite_paths_valid_codes() {
let evaluator = MockEvaluator::new(vec![]);
let validator = EdifactValidator::new(evaluator);
let external = NoOpExternalProvider;
let unh_segment = OwnedSegment {
id: "UNH".to_string(),
elements: vec![
vec!["ALEXANDE951842".to_string()],
vec![
"UTILMD".to_string(),
"D".to_string(),
"11A".to_string(),
"UN".to_string(),
"S2.1".to_string(),
],
],
segment_number: 1,
};
let workflow = AhbWorkflow {
pruefidentifikator: "55001".to_string(),
description: "Test".to_string(),
communication_direction: None,
fields: vec![
AhbFieldRule {
segment_path: "UNH/S009/0065".to_string(),
name: "Nachrichtentyp".to_string(),
ahb_status: "X".to_string(),
codes: vec![AhbCodeRule {
value: "UTILMD".to_string(),
description: "Stammdaten".to_string(),
ahb_status: "X".to_string(),
}],
parent_group_ahb_status: None,
segment_ahb_status: None,
element_index: Some(1),
component_index: Some(0),
..Default::default()
},
AhbFieldRule {
segment_path: "UNH/S009/0052".to_string(),
name: "Version".to_string(),
ahb_status: "X".to_string(),
codes: vec![AhbCodeRule {
value: "D".to_string(),
description: "Draft".to_string(),
ahb_status: "X".to_string(),
}],
parent_group_ahb_status: None,
segment_ahb_status: None,
element_index: Some(1),
component_index: Some(1),
..Default::default()
},
],
ub_definitions: HashMap::new(),
};
let report = validator.validate(
&[unh_segment],
&workflow,
&external,
ValidationLevel::Conditions,
);
let code_errors: Vec<_> = report
.by_category(ValidationCategory::Code)
.filter(|i| i.severity == Severity::Error)
.collect();
assert!(
code_errors.is_empty(),
"Expected no code errors when composite values match allowed codes, got: {:?}",
code_errors
);
}
#[test]
fn test_code_validation_partitions_by_mig_number() {
let evaluator = MockEvaluator::new(vec![]);
let validator = EdifactValidator::new(evaluator);
let external = NoOpExternalProvider;
let sts_7 = OwnedSegment {
id: "STS".to_string(),
elements: vec![
vec!["7".to_string()],
vec![String::new()],
vec!["GH02".to_string()],
vec!["ZW4".to_string()],
],
segment_number: 1,
};
let sts_e01 = OwnedSegment {
id: "STS".to_string(),
elements: vec![
vec!["E01".to_string()],
vec![String::new()],
vec!["A99".to_string(), "E_0614".to_string()],
],
segment_number: 2,
};
let workflow = AhbWorkflow {
pruefidentifikator: "55018".to_string(),
description: "Test".to_string(),
communication_direction: None,
fields: vec![
AhbFieldRule {
segment_path: "SG4/STS/C601/9015".to_string(),
name: "Statuskategorie".to_string(),
ahb_status: "X".to_string(),
codes: vec![AhbCodeRule {
value: "7".to_string(),
description: "Transaktionsgrund".to_string(),
ahb_status: "X".to_string(),
}],
parent_group_ahb_status: None,
segment_ahb_status: None,
element_index: Some(0),
component_index: Some(0),
mig_number: Some("00035".to_string()),
},
AhbFieldRule {
segment_path: "SG4/STS/C556/9013".to_string(),
name: "Statusanlaß".to_string(),
ahb_status: "X".to_string(),
codes: vec![AhbCodeRule {
value: "E03".to_string(),
description: "Transaktionsgrund".to_string(),
ahb_status: "X".to_string(),
}],
parent_group_ahb_status: None,
segment_ahb_status: None,
element_index: Some(2),
component_index: Some(0),
mig_number: Some("00035".to_string()),
},
AhbFieldRule {
segment_path: "SG4/STS/C601/9015".to_string(),
name: "Statuskategorie".to_string(),
ahb_status: "X".to_string(),
codes: vec![AhbCodeRule {
value: "E01".to_string(),
description: "Antwort".to_string(),
ahb_status: "X".to_string(),
}],
parent_group_ahb_status: None,
segment_ahb_status: None,
element_index: Some(0),
component_index: Some(0),
mig_number: Some("00036".to_string()),
},
],
ub_definitions: HashMap::new(),
};
let report = validator.validate(
&[sts_7, sts_e01],
&workflow,
&external,
ValidationLevel::Conditions,
);
let code_errors: Vec<_> = report
.by_category(ValidationCategory::Code)
.filter(|i| {
i.severity == Severity::Error && i.code == ErrorCodes::CODE_NOT_ALLOWED_FOR_PID
})
.collect();
assert_eq!(
code_errors.len(),
1,
"Expected one COD002 (for GH02 only), got: {:?}",
code_errors
);
assert_eq!(code_errors[0].actual_value.as_deref(), Some("GH02"));
}
#[test]
fn test_code_validation_composite_paths_detects_invalid_code() {
let evaluator = MockEvaluator::new(vec![]);
let validator = EdifactValidator::new(evaluator);
let external = NoOpExternalProvider;
let sts_segment = OwnedSegment {
id: "STS".to_string(),
elements: vec![
vec!["7".to_string()],
vec![String::new()],
vec!["GH02".to_string()],
vec!["ZW4".to_string()],
],
segment_number: 1,
};
let workflow = AhbWorkflow {
pruefidentifikator: "55018".to_string(),
description: "Test".to_string(),
communication_direction: None,
fields: vec![AhbFieldRule {
segment_path: "SG4/STS/C556/9013".to_string(),
name: "Statusanlaß".to_string(),
ahb_status: "X".to_string(),
codes: vec![AhbCodeRule {
value: "E03".to_string(),
description: "Transaktionsgrund".to_string(),
ahb_status: "X".to_string(),
}],
parent_group_ahb_status: None,
segment_ahb_status: None,
element_index: Some(2),
component_index: Some(0),
..Default::default()
}],
ub_definitions: HashMap::new(),
};
let report = validator.validate(
&[sts_segment],
&workflow,
&external,
ValidationLevel::Conditions,
);
let code_errors: Vec<_> = report
.by_category(ValidationCategory::Code)
.filter(|i| {
i.severity == Severity::Error && i.code == ErrorCodes::CODE_NOT_ALLOWED_FOR_PID
})
.collect();
assert_eq!(
code_errors.len(),
1,
"Expected COD002 for GH02, got: {:?}",
code_errors
);
assert_eq!(code_errors[0].actual_value.as_deref(), Some("GH02"));
}
#[test]
fn test_cross_field_code_validation_valid_qualifiers() {
let evaluator = MockEvaluator::new(vec![]);
let validator = EdifactValidator::new(evaluator);
let external = NoOpExternalProvider;
let nad_ms = OwnedSegment {
id: "NAD".to_string(),
elements: vec![vec!["MS".to_string()]],
segment_number: 4,
};
let nad_mr = OwnedSegment {
id: "NAD".to_string(),
elements: vec![vec!["MR".to_string()]],
segment_number: 5,
};
let workflow = AhbWorkflow {
pruefidentifikator: "55001".to_string(),
description: "Test".to_string(),
communication_direction: None,
fields: vec![
AhbFieldRule {
segment_path: "SG2/NAD/3035".to_string(),
name: "Absender".to_string(),
ahb_status: "X".to_string(),
codes: vec![AhbCodeRule {
value: "MS".to_string(),
description: "Absender".to_string(),
ahb_status: "X".to_string(),
}],
parent_group_ahb_status: None,
segment_ahb_status: None,
..Default::default()
},
AhbFieldRule {
segment_path: "SG2/NAD/3035".to_string(),
name: "Empfaenger".to_string(),
ahb_status: "X".to_string(),
codes: vec![AhbCodeRule {
value: "MR".to_string(),
description: "Empfaenger".to_string(),
ahb_status: "X".to_string(),
}],
parent_group_ahb_status: None,
segment_ahb_status: None,
..Default::default()
},
],
ub_definitions: HashMap::new(),
};
let report = validator.validate(
&[nad_ms, nad_mr],
&workflow,
&external,
ValidationLevel::Conditions,
);
let code_errors: Vec<_> = report
.by_category(ValidationCategory::Code)
.filter(|i| i.severity == Severity::Error)
.collect();
assert!(
code_errors.is_empty(),
"Expected no code errors for valid qualifiers, got: {:?}",
code_errors
);
}
#[test]
fn test_cross_field_code_validation_catches_invalid_qualifier() {
let evaluator = MockEvaluator::new(vec![]);
let validator = EdifactValidator::new(evaluator);
let external = NoOpExternalProvider;
let nad_ms = OwnedSegment {
id: "NAD".to_string(),
elements: vec![vec!["MS".to_string()]],
segment_number: 4,
};
let nad_mt = OwnedSegment {
id: "NAD".to_string(),
elements: vec![vec!["MT".to_string()]], segment_number: 5,
};
let workflow = AhbWorkflow {
pruefidentifikator: "55001".to_string(),
description: "Test".to_string(),
communication_direction: None,
fields: vec![
AhbFieldRule {
segment_path: "SG2/NAD/3035".to_string(),
name: "Absender".to_string(),
ahb_status: "X".to_string(),
codes: vec![AhbCodeRule {
value: "MS".to_string(),
description: "Absender".to_string(),
ahb_status: "X".to_string(),
}],
parent_group_ahb_status: None,
segment_ahb_status: None,
..Default::default()
},
AhbFieldRule {
segment_path: "SG2/NAD/3035".to_string(),
name: "Empfaenger".to_string(),
ahb_status: "X".to_string(),
codes: vec![AhbCodeRule {
value: "MR".to_string(),
description: "Empfaenger".to_string(),
ahb_status: "X".to_string(),
}],
parent_group_ahb_status: None,
segment_ahb_status: None,
..Default::default()
},
],
ub_definitions: HashMap::new(),
};
let report = validator.validate(
&[nad_ms, nad_mt],
&workflow,
&external,
ValidationLevel::Conditions,
);
let code_errors: Vec<_> = report
.by_category(ValidationCategory::Code)
.filter(|i| i.severity == Severity::Error)
.collect();
assert_eq!(code_errors.len(), 1, "Expected one COD002 error for MT");
assert!(code_errors[0].message.contains("MT"));
assert!(code_errors[0].message.contains("MR"));
assert!(code_errors[0].message.contains("MS"));
}
#[test]
fn test_cross_field_code_validation_unions_across_groups() {
let evaluator = MockEvaluator::new(vec![]);
let validator = EdifactValidator::new(evaluator);
let external = NoOpExternalProvider;
let segments = vec![
OwnedSegment {
id: "NAD".to_string(),
elements: vec![vec!["MS".to_string()]],
segment_number: 3,
},
OwnedSegment {
id: "NAD".to_string(),
elements: vec![vec!["MR".to_string()]],
segment_number: 4,
},
OwnedSegment {
id: "NAD".to_string(),
elements: vec![vec!["Z04".to_string()]],
segment_number: 20,
},
OwnedSegment {
id: "NAD".to_string(),
elements: vec![vec!["Z09".to_string()]],
segment_number: 21,
},
OwnedSegment {
id: "NAD".to_string(),
elements: vec![vec!["MT".to_string()]], segment_number: 22,
},
];
let workflow = AhbWorkflow {
pruefidentifikator: "55001".to_string(),
description: "Test".to_string(),
communication_direction: None,
fields: vec![
AhbFieldRule {
segment_path: "SG2/NAD/3035".to_string(),
name: "Absender".to_string(),
ahb_status: "X".to_string(),
codes: vec![AhbCodeRule {
value: "MS".to_string(),
description: "Absender".to_string(),
ahb_status: "X".to_string(),
}],
parent_group_ahb_status: None,
segment_ahb_status: None,
..Default::default()
},
AhbFieldRule {
segment_path: "SG2/NAD/3035".to_string(),
name: "Empfaenger".to_string(),
ahb_status: "X".to_string(),
codes: vec![AhbCodeRule {
value: "MR".to_string(),
description: "Empfaenger".to_string(),
ahb_status: "X".to_string(),
}],
parent_group_ahb_status: None,
segment_ahb_status: None,
..Default::default()
},
AhbFieldRule {
segment_path: "SG4/SG12/NAD/3035".to_string(),
name: "Anschlussnutzer".to_string(),
ahb_status: "X".to_string(),
codes: vec![AhbCodeRule {
value: "Z04".to_string(),
description: "Anschlussnutzer".to_string(),
ahb_status: "X".to_string(),
}],
parent_group_ahb_status: None,
segment_ahb_status: None,
..Default::default()
},
AhbFieldRule {
segment_path: "SG4/SG12/NAD/3035".to_string(),
name: "Korrespondenzanschrift".to_string(),
ahb_status: "X".to_string(),
codes: vec![AhbCodeRule {
value: "Z09".to_string(),
description: "Korrespondenzanschrift".to_string(),
ahb_status: "X".to_string(),
}],
parent_group_ahb_status: None,
segment_ahb_status: None,
..Default::default()
},
],
ub_definitions: HashMap::new(),
};
let report =
validator.validate(&segments, &workflow, &external, ValidationLevel::Conditions);
let code_errors: Vec<_> = report
.by_category(ValidationCategory::Code)
.filter(|i| i.severity == Severity::Error)
.collect();
assert_eq!(
code_errors.len(),
1,
"Expected exactly one COD002 error for MT, got: {:?}",
code_errors
);
assert!(code_errors[0].message.contains("MT"));
}
#[test]
fn test_cross_field_code_validation_accepts_conditionally_allowed_codes() {
let evaluator = MockEvaluator::new(vec![]);
let validator = EdifactValidator::new(evaluator);
let external = NoOpExternalProvider;
let qty_67 = OwnedSegment {
id: "QTY".to_string(),
elements: vec![vec!["67".to_string(), "0.185".to_string()]],
segment_number: 10,
};
let workflow = AhbWorkflow {
pruefidentifikator: "13025".to_string(),
description: "Test".to_string(),
communication_direction: None,
fields: vec![AhbFieldRule {
segment_path: "SG5/SG6/SG9/SG10/QTY/qualifier".to_string(),
name: "Menge, Qualifier".to_string(),
ahb_status: "X".to_string(),
codes: vec![
AhbCodeRule {
value: "220".to_string(),
description: "Wahrer Wert".to_string(),
ahb_status: "X".to_string(),
},
AhbCodeRule {
value: "67".to_string(),
description: "Ersatzwert".to_string(),
ahb_status: "X [35] ∨ ([32] ∧ [77])".to_string(),
},
AhbCodeRule {
value: "Z18".to_string(),
description: "Vorläufiger Wert".to_string(),
ahb_status: "X [35]".to_string(),
},
],
parent_group_ahb_status: None,
segment_ahb_status: None,
element_index: Some(0),
component_index: Some(0),
..Default::default()
}],
ub_definitions: HashMap::new(),
};
let report =
validator.validate(&[qty_67], &workflow, &external, ValidationLevel::Conditions);
let code_errors: Vec<_> = report
.by_category(ValidationCategory::Code)
.filter(|i| i.severity == Severity::Error)
.collect();
assert!(
code_errors.is_empty(),
"QTY+67 should be accepted because code '67' is conditionally allowed for this PID (X [35] ∨ ([32] ∧ [77])). Got errors: {:?}",
code_errors
);
}
#[test]
fn test_is_qualifier_field_simple_paths() {
assert!(is_qualifier_field("NAD/3035"));
assert!(is_qualifier_field("SG2/NAD/3035"));
assert!(is_qualifier_field("SG4/SG8/SEQ/6350"));
assert!(is_qualifier_field("LOC/3227"));
}
#[test]
fn test_is_qualifier_field_composite_paths() {
assert!(is_qualifier_field("UNH/S009/0065"));
assert!(is_qualifier_field("NAD/C082/3039"));
assert!(is_qualifier_field("SG2/NAD/C082/3039"));
assert!(is_qualifier_field("SG4/STS/C556/9013"));
}
#[test]
fn test_is_qualifier_field_bare_segment() {
assert!(!is_qualifier_field("NAD"));
assert!(!is_qualifier_field("SG2/NAD"));
}
#[test]
fn test_is_qualifier_field_rejects_deep_paths() {
assert!(!is_qualifier_field("SEG/A/B/C/D"));
}
#[test]
fn test_missing_qualifier_instance_is_detected() {
let evaluator = MockEvaluator::new(vec![]);
let validator = EdifactValidator::new(evaluator);
let external = NoOpExternalProvider;
let nad_ms = OwnedSegment {
id: "NAD".to_string(),
elements: vec![vec!["MS".to_string()]],
segment_number: 3,
};
let workflow = AhbWorkflow {
pruefidentifikator: "55001".to_string(),
description: "Test".to_string(),
communication_direction: None,
fields: vec![
AhbFieldRule {
segment_path: "SG2/NAD/3035".to_string(),
name: "Absender".to_string(),
ahb_status: "X".to_string(),
codes: vec![AhbCodeRule {
value: "MS".to_string(),
description: "Absender".to_string(),
ahb_status: "X".to_string(),
}],
parent_group_ahb_status: None,
segment_ahb_status: None,
..Default::default()
},
AhbFieldRule {
segment_path: "SG2/NAD/3035".to_string(),
name: "Empfaenger".to_string(),
ahb_status: "Muss".to_string(),
codes: vec![AhbCodeRule {
value: "MR".to_string(),
description: "Empfaenger".to_string(),
ahb_status: "X".to_string(),
}],
parent_group_ahb_status: None,
segment_ahb_status: None,
..Default::default()
},
],
ub_definitions: HashMap::new(),
};
let report =
validator.validate(&[nad_ms], &workflow, &external, ValidationLevel::Conditions);
let ahb_errors: Vec<_> = report
.by_category(ValidationCategory::Ahb)
.filter(|i| i.severity == Severity::Error)
.collect();
assert_eq!(
ahb_errors.len(),
1,
"Expected AHB001 for missing NAD+MR, got: {:?}",
ahb_errors
);
assert!(ahb_errors[0].message.contains("Empfaenger"));
}
#[test]
fn test_present_qualifier_instance_no_error() {
let evaluator = MockEvaluator::new(vec![]);
let validator = EdifactValidator::new(evaluator);
let external = NoOpExternalProvider;
let segments = vec![
OwnedSegment {
id: "NAD".to_string(),
elements: vec![vec!["MS".to_string()]],
segment_number: 3,
},
OwnedSegment {
id: "NAD".to_string(),
elements: vec![vec!["MR".to_string()]],
segment_number: 4,
},
];
let workflow = AhbWorkflow {
pruefidentifikator: "55001".to_string(),
description: "Test".to_string(),
communication_direction: None,
fields: vec![
AhbFieldRule {
segment_path: "SG2/NAD/3035".to_string(),
name: "Absender".to_string(),
ahb_status: "Muss".to_string(),
codes: vec![AhbCodeRule {
value: "MS".to_string(),
description: "Absender".to_string(),
ahb_status: "X".to_string(),
}],
parent_group_ahb_status: None,
segment_ahb_status: None,
..Default::default()
},
AhbFieldRule {
segment_path: "SG2/NAD/3035".to_string(),
name: "Empfaenger".to_string(),
ahb_status: "Muss".to_string(),
codes: vec![AhbCodeRule {
value: "MR".to_string(),
description: "Empfaenger".to_string(),
ahb_status: "X".to_string(),
}],
parent_group_ahb_status: None,
segment_ahb_status: None,
..Default::default()
},
],
ub_definitions: HashMap::new(),
};
let report =
validator.validate(&segments, &workflow, &external, ValidationLevel::Conditions);
let ahb_errors: Vec<_> = report
.by_category(ValidationCategory::Ahb)
.filter(|i| i.severity == Severity::Error)
.collect();
assert!(
ahb_errors.is_empty(),
"Expected no AHB001 errors, got: {:?}",
ahb_errors
);
}
#[test]
fn test_extract_group_path_key() {
assert_eq!(extract_group_path_key("SG2/NAD/3035"), "SG2");
assert_eq!(extract_group_path_key("SG4/SG12/NAD/3035"), "SG4/SG12");
assert_eq!(extract_group_path_key("NAD/3035"), "");
assert_eq!(extract_group_path_key("SG4/SG8/SEQ/6350"), "SG4/SG8");
}
#[test]
fn test_absent_optional_group_no_missing_field_error() {
use mig_types::navigator::GroupNavigator;
struct NavWithoutSG3;
impl GroupNavigator for NavWithoutSG3 {
fn find_segments_in_group(&self, _: &str, _: &[&str], _: usize) -> Vec<OwnedSegment> {
vec![]
}
fn find_segments_with_qualifier_in_group(
&self,
_: &str,
_: usize,
_: &str,
_: &[&str],
_: usize,
) -> Vec<OwnedSegment> {
vec![]
}
fn group_instance_count(&self, group_path: &[&str]) -> usize {
match group_path {
["SG2"] => 2, ["SG2", "SG3"] => 0, _ => 0,
}
}
}
let evaluator = MockEvaluator::new(vec![]);
let validator = EdifactValidator::new(evaluator);
let external = NoOpExternalProvider;
let nav = NavWithoutSG3;
let segments = vec![
OwnedSegment {
id: "NAD".into(),
elements: vec![vec!["MS".into()]],
segment_number: 3,
},
OwnedSegment {
id: "NAD".into(),
elements: vec![vec!["MR".into()]],
segment_number: 4,
},
];
let workflow = AhbWorkflow {
pruefidentifikator: "55001".to_string(),
description: "Test".to_string(),
communication_direction: None,
fields: vec![
AhbFieldRule {
segment_path: "SG2/SG3/CTA/3139".to_string(),
name: "Funktion des Ansprechpartners, Code".to_string(),
ahb_status: "Muss".to_string(),
codes: vec![],
parent_group_ahb_status: None,
segment_ahb_status: None,
..Default::default()
},
AhbFieldRule {
segment_path: "SG2/SG3/CTA/C056/3412".to_string(),
name: "Name vom Ansprechpartner".to_string(),
ahb_status: "X".to_string(),
codes: vec![],
parent_group_ahb_status: None,
segment_ahb_status: None,
..Default::default()
},
],
ub_definitions: HashMap::new(),
};
let report = validator.validate_with_navigator(
&segments,
&workflow,
&external,
ValidationLevel::Conditions,
&nav,
);
let ahb_errors: Vec<_> = report
.by_category(ValidationCategory::Ahb)
.filter(|i| i.severity == Severity::Error)
.collect();
assert!(
ahb_errors.is_empty(),
"Expected no AHB001 errors when SG3 is absent, got: {:?}",
ahb_errors
);
}
#[test]
fn test_present_group_still_checks_mandatory_fields() {
use mig_types::navigator::GroupNavigator;
struct NavWithSG3;
impl GroupNavigator for NavWithSG3 {
fn find_segments_in_group(&self, _: &str, _: &[&str], _: usize) -> Vec<OwnedSegment> {
vec![]
}
fn find_segments_with_qualifier_in_group(
&self,
_: &str,
_: usize,
_: &str,
_: &[&str],
_: usize,
) -> Vec<OwnedSegment> {
vec![]
}
fn group_instance_count(&self, group_path: &[&str]) -> usize {
match group_path {
["SG2"] => 1,
["SG2", "SG3"] => 1, _ => 0,
}
}
}
let evaluator = MockEvaluator::new(vec![]);
let validator = EdifactValidator::new(evaluator);
let external = NoOpExternalProvider;
let nav = NavWithSG3;
let segments = vec![OwnedSegment {
id: "NAD".into(),
elements: vec![vec!["MS".into()]],
segment_number: 3,
}];
let workflow = AhbWorkflow {
pruefidentifikator: "55001".to_string(),
description: "Test".to_string(),
communication_direction: None,
fields: vec![AhbFieldRule {
segment_path: "SG2/SG3/CTA/3139".to_string(),
name: "Funktion des Ansprechpartners, Code".to_string(),
ahb_status: "Muss".to_string(),
codes: vec![],
parent_group_ahb_status: None,
segment_ahb_status: None,
..Default::default()
}],
ub_definitions: HashMap::new(),
};
let report = validator.validate_with_navigator(
&segments,
&workflow,
&external,
ValidationLevel::Conditions,
&nav,
);
let ahb_errors: Vec<_> = report
.by_category(ValidationCategory::Ahb)
.filter(|i| i.severity == Severity::Error)
.collect();
assert_eq!(
ahb_errors.len(),
1,
"Expected AHB001 error when SG3 is present but CTA missing"
);
assert!(ahb_errors[0].message.contains("CTA"));
}
#[test]
fn test_missing_qualifier_with_navigator_is_detected() {
use mig_types::navigator::GroupNavigator;
struct NavWithSG2;
impl GroupNavigator for NavWithSG2 {
fn find_segments_in_group(
&self,
segment_id: &str,
group_path: &[&str],
instance_index: usize,
) -> Vec<OwnedSegment> {
if segment_id == "NAD" && group_path == ["SG2"] && instance_index == 0 {
vec![OwnedSegment {
id: "NAD".into(),
elements: vec![vec!["MS".into()]],
segment_number: 3,
}]
} else {
vec![]
}
}
fn find_segments_with_qualifier_in_group(
&self,
_: &str,
_: usize,
_: &str,
_: &[&str],
_: usize,
) -> Vec<OwnedSegment> {
vec![]
}
fn group_instance_count(&self, group_path: &[&str]) -> usize {
match group_path {
["SG2"] => 1,
_ => 0,
}
}
}
let evaluator = MockEvaluator::new(vec![]);
let validator = EdifactValidator::new(evaluator);
let external = NoOpExternalProvider;
let nav = NavWithSG2;
let segments = vec![OwnedSegment {
id: "NAD".into(),
elements: vec![vec!["MS".into()]],
segment_number: 3,
}];
let workflow = AhbWorkflow {
pruefidentifikator: "55001".to_string(),
description: "Test".to_string(),
communication_direction: None,
fields: vec![
AhbFieldRule {
segment_path: "SG2/NAD/3035".to_string(),
name: "Absender".to_string(),
ahb_status: "X".to_string(),
codes: vec![AhbCodeRule {
value: "MS".to_string(),
description: "Absender".to_string(),
ahb_status: "X".to_string(),
}],
parent_group_ahb_status: None,
segment_ahb_status: None,
..Default::default()
},
AhbFieldRule {
segment_path: "SG2/NAD/3035".to_string(),
name: "Empfaenger".to_string(),
ahb_status: "Muss".to_string(),
codes: vec![AhbCodeRule {
value: "MR".to_string(),
description: "Empfaenger".to_string(),
ahb_status: "X".to_string(),
}],
parent_group_ahb_status: None,
segment_ahb_status: None,
..Default::default()
},
],
ub_definitions: HashMap::new(),
};
let report = validator.validate_with_navigator(
&segments,
&workflow,
&external,
ValidationLevel::Conditions,
&nav,
);
let ahb_errors: Vec<_> = report
.by_category(ValidationCategory::Ahb)
.filter(|i| i.severity == Severity::Error)
.collect();
assert_eq!(
ahb_errors.len(),
1,
"Expected AHB001 for missing NAD+MR even with navigator, got: {:?}",
ahb_errors
);
assert!(ahb_errors[0].message.contains("Empfaenger"));
}
#[test]
fn test_optional_group_variant_absent_no_error() {
use mig_types::navigator::GroupNavigator;
struct TestNav;
impl GroupNavigator for TestNav {
fn find_segments_in_group(
&self,
segment_id: &str,
group_path: &[&str],
instance_index: usize,
) -> Vec<OwnedSegment> {
match (segment_id, group_path, instance_index) {
("LOC", ["SG4", "SG5"], 0) => vec![OwnedSegment {
id: "LOC".into(),
elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
segment_number: 10,
}],
("NAD", ["SG2"], 0) => vec![OwnedSegment {
id: "NAD".into(),
elements: vec![vec!["MS".into()]],
segment_number: 3,
}],
_ => vec![],
}
}
fn find_segments_with_qualifier_in_group(
&self,
_: &str,
_: usize,
_: &str,
_: &[&str],
_: usize,
) -> Vec<OwnedSegment> {
vec![]
}
fn group_instance_count(&self, group_path: &[&str]) -> usize {
match group_path {
["SG2"] => 1,
["SG4"] => 1,
["SG4", "SG5"] => 1, _ => 0,
}
}
}
let evaluator = MockEvaluator::new(vec![]);
let validator = EdifactValidator::new(evaluator);
let external = NoOpExternalProvider;
let nav = TestNav;
let segments = vec![
OwnedSegment {
id: "NAD".into(),
elements: vec![vec!["MS".into()]],
segment_number: 3,
},
OwnedSegment {
id: "LOC".into(),
elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
segment_number: 10,
},
];
let workflow = AhbWorkflow {
pruefidentifikator: "55001".to_string(),
description: "Test".to_string(),
communication_direction: None,
fields: vec![
AhbFieldRule {
segment_path: "SG2/NAD/3035".to_string(),
name: "Absender".to_string(),
ahb_status: "X".to_string(),
codes: vec![AhbCodeRule {
value: "MS".to_string(),
description: "Absender".to_string(),
ahb_status: "X".to_string(),
}],
parent_group_ahb_status: Some("Muss".to_string()),
segment_ahb_status: None,
..Default::default()
},
AhbFieldRule {
segment_path: "SG2/NAD/3035".to_string(),
name: "Empfaenger".to_string(),
ahb_status: "Muss".to_string(),
codes: vec![AhbCodeRule {
value: "MR".to_string(),
description: "Empfaenger".to_string(),
ahb_status: "X".to_string(),
}],
parent_group_ahb_status: Some("Muss".to_string()),
segment_ahb_status: None,
..Default::default()
},
AhbFieldRule {
segment_path: "SG4/SG5/LOC/3227".to_string(),
name: "Ortsangabe, Qualifier (Z16)".to_string(),
ahb_status: "X".to_string(),
codes: vec![AhbCodeRule {
value: "Z16".to_string(),
description: "Marktlokation".to_string(),
ahb_status: "X".to_string(),
}],
parent_group_ahb_status: Some("Kann".to_string()),
segment_ahb_status: None,
..Default::default()
},
AhbFieldRule {
segment_path: "SG4/SG5/LOC/3227".to_string(),
name: "Ortsangabe, Qualifier (Z17)".to_string(),
ahb_status: "Muss".to_string(),
codes: vec![AhbCodeRule {
value: "Z17".to_string(),
description: "Messlokation".to_string(),
ahb_status: "X".to_string(),
}],
parent_group_ahb_status: Some("Kann".to_string()),
segment_ahb_status: None,
..Default::default()
},
],
ub_definitions: HashMap::new(),
};
let report = validator.validate_with_navigator(
&segments,
&workflow,
&external,
ValidationLevel::Conditions,
&nav,
);
let ahb_errors: Vec<_> = report
.by_category(ValidationCategory::Ahb)
.filter(|i| i.severity == Severity::Error)
.collect();
assert_eq!(
ahb_errors.len(),
1,
"Expected only AHB001 for missing NAD+MR, got: {:?}",
ahb_errors
);
assert!(
ahb_errors[0].message.contains("Empfaenger"),
"Error should be for missing NAD+MR (Empfaenger)"
);
}
#[test]
fn test_conditional_group_variant_absent_no_error() {
use mig_types::navigator::GroupNavigator;
struct TestNav;
impl GroupNavigator for TestNav {
fn find_segments_in_group(
&self,
segment_id: &str,
group_path: &[&str],
instance_index: usize,
) -> Vec<OwnedSegment> {
if segment_id == "LOC" && group_path == ["SG4", "SG5"] && instance_index == 0 {
vec![OwnedSegment {
id: "LOC".into(),
elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
segment_number: 10,
}]
} else {
vec![]
}
}
fn find_segments_with_qualifier_in_group(
&self,
_: &str,
_: usize,
_: &str,
_: &[&str],
_: usize,
) -> Vec<OwnedSegment> {
vec![]
}
fn group_instance_count(&self, group_path: &[&str]) -> usize {
match group_path {
["SG4"] => 1,
["SG4", "SG5"] => 1, _ => 0,
}
}
}
let evaluator = MockEvaluator::new(vec![(165, CR::False), (2061, CR::True)]);
let validator = EdifactValidator::new(evaluator);
let external = NoOpExternalProvider;
let nav = TestNav;
let segments = vec![OwnedSegment {
id: "LOC".into(),
elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
segment_number: 10,
}];
let workflow = AhbWorkflow {
pruefidentifikator: "55001".to_string(),
description: "Test".to_string(),
communication_direction: None,
fields: vec![
AhbFieldRule {
segment_path: "SG4/SG5/LOC/3227".to_string(),
name: "Ortsangabe, Qualifier (Z16)".to_string(),
ahb_status: "X".to_string(),
codes: vec![AhbCodeRule {
value: "Z16".to_string(),
description: "Marktlokation".to_string(),
ahb_status: "X".to_string(),
}],
parent_group_ahb_status: Some("Muss [2061]".to_string()),
segment_ahb_status: None,
..Default::default()
},
AhbFieldRule {
segment_path: "SG4/SG5/LOC/3227".to_string(),
name: "Ortsangabe, Qualifier (Z17)".to_string(),
ahb_status: "X".to_string(),
codes: vec![AhbCodeRule {
value: "Z17".to_string(),
description: "Messlokation".to_string(),
ahb_status: "X".to_string(),
}],
parent_group_ahb_status: Some("Soll [165]".to_string()),
segment_ahb_status: None,
..Default::default()
},
],
ub_definitions: HashMap::new(),
};
let report = validator.validate_with_navigator(
&segments,
&workflow,
&external,
ValidationLevel::Conditions,
&nav,
);
let ahb_errors: Vec<_> = report
.by_category(ValidationCategory::Ahb)
.filter(|i| i.severity == Severity::Error)
.collect();
assert!(
ahb_errors.is_empty(),
"Expected no errors when conditional group variant [165]=False, got: {:?}",
ahb_errors
);
}
#[test]
fn test_conditional_group_variant_unknown_no_error() {
let evaluator = MockEvaluator::new(vec![]);
let validator = EdifactValidator::new(evaluator);
let external = NoOpExternalProvider;
let workflow = AhbWorkflow {
pruefidentifikator: "55001".to_string(),
description: "Test".to_string(),
communication_direction: None,
fields: vec![AhbFieldRule {
segment_path: "SG4/SG5/LOC/3227".to_string(),
name: "Ortsangabe, Qualifier (Z17)".to_string(),
ahb_status: "X".to_string(),
codes: vec![AhbCodeRule {
value: "Z17".to_string(),
description: "Messlokation".to_string(),
ahb_status: "X".to_string(),
}],
parent_group_ahb_status: Some("Soll [165]".to_string()),
segment_ahb_status: None,
..Default::default()
}],
ub_definitions: HashMap::new(),
};
let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
let ahb_errors: Vec<_> = report
.by_category(ValidationCategory::Ahb)
.filter(|i| i.severity == Severity::Error)
.collect();
assert!(
ahb_errors.is_empty(),
"Expected no errors when parent group condition is Unknown, got: {:?}",
ahb_errors
);
}
#[test]
fn test_segment_absent_within_present_group_no_error() {
use mig_types::navigator::GroupNavigator;
struct TestNav;
impl GroupNavigator for TestNav {
fn find_segments_in_group(
&self,
segment_id: &str,
group_path: &[&str],
instance_index: usize,
) -> Vec<OwnedSegment> {
if segment_id == "QTY"
&& group_path == ["SG5", "SG6", "SG9", "SG10"]
&& instance_index == 0
{
vec![OwnedSegment {
id: "QTY".into(),
elements: vec![vec!["220".into(), "0".into()]],
segment_number: 14,
}]
} else {
vec![]
}
}
fn find_segments_with_qualifier_in_group(
&self,
_: &str,
_: usize,
_: &str,
_: &[&str],
_: usize,
) -> Vec<OwnedSegment> {
vec![]
}
fn group_instance_count(&self, group_path: &[&str]) -> usize {
match group_path {
["SG5"] => 1,
["SG5", "SG6"] => 1,
["SG5", "SG6", "SG9"] => 1,
["SG5", "SG6", "SG9", "SG10"] => 1,
_ => 0,
}
}
fn has_any_segment_in_group(&self, group_path: &[&str], instance_index: usize) -> bool {
group_path == ["SG5", "SG6", "SG9", "SG10"] && instance_index == 0
}
}
let evaluator = MockEvaluator::all_true(&[]);
let validator = EdifactValidator::new(evaluator);
let external = NoOpExternalProvider;
let nav = TestNav;
let segments = vec![OwnedSegment {
id: "QTY".into(),
elements: vec![vec!["220".into(), "0".into()]],
segment_number: 14,
}];
let workflow = AhbWorkflow {
pruefidentifikator: "13017".to_string(),
description: "Test".to_string(),
communication_direction: None,
fields: vec![
AhbFieldRule {
segment_path: "SG5/SG6/SG9/SG10/STS/C601/9015".to_string(),
name: "Statuskategorie, Code".to_string(),
ahb_status: "X".to_string(),
codes: vec![],
parent_group_ahb_status: Some("Muss".to_string()),
segment_ahb_status: None,
..Default::default()
},
AhbFieldRule {
segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
name: "Statusanlaß, Code".to_string(),
ahb_status: "X [5]".to_string(),
codes: vec![],
parent_group_ahb_status: Some("Muss".to_string()),
segment_ahb_status: None,
..Default::default()
},
],
ub_definitions: HashMap::new(),
};
let report = validator.validate_with_navigator(
&segments,
&workflow,
&external,
ValidationLevel::Conditions,
&nav,
);
let ahb_errors: Vec<_> = report
.by_category(ValidationCategory::Ahb)
.filter(|i| i.severity == Severity::Error)
.collect();
assert!(
ahb_errors.is_empty(),
"Expected no AHB001 errors when STS segment is absent from SG10, got: {:?}",
ahb_errors
);
}
#[test]
fn test_group_scoped_code_validation_with_navigator() {
use mig_types::navigator::GroupNavigator;
struct TestNav;
impl GroupNavigator for TestNav {
fn find_segments_in_group(
&self,
segment_id: &str,
group_path: &[&str],
_instance_index: usize,
) -> Vec<OwnedSegment> {
if segment_id != "NAD" {
return vec![];
}
match group_path {
["SG2"] => vec![
OwnedSegment {
id: "NAD".into(),
elements: vec![vec!["MS".into()]],
segment_number: 3,
},
OwnedSegment {
id: "NAD".into(),
elements: vec![vec!["MT".into()]], segment_number: 4,
},
],
["SG4", "SG12"] => vec![
OwnedSegment {
id: "NAD".into(),
elements: vec![vec!["Z04".into()]],
segment_number: 20,
},
OwnedSegment {
id: "NAD".into(),
elements: vec![vec!["Z09".into()]],
segment_number: 21,
},
],
_ => vec![],
}
}
fn find_segments_with_qualifier_in_group(
&self,
_: &str,
_: usize,
_: &str,
_: &[&str],
_: usize,
) -> Vec<OwnedSegment> {
vec![]
}
fn group_instance_count(&self, group_path: &[&str]) -> usize {
match group_path {
["SG2"] | ["SG4", "SG12"] => 1,
_ => 0,
}
}
}
let evaluator = MockEvaluator::new(vec![]);
let validator = EdifactValidator::new(evaluator);
let external = NoOpExternalProvider;
let nav = TestNav;
let workflow = AhbWorkflow {
pruefidentifikator: "55001".to_string(),
description: "Test".to_string(),
communication_direction: None,
fields: vec![
AhbFieldRule {
segment_path: "SG2/NAD/3035".to_string(),
name: "Absender".to_string(),
ahb_status: "X".to_string(),
codes: vec![AhbCodeRule {
value: "MS".to_string(),
description: "Absender".to_string(),
ahb_status: "X".to_string(),
}],
parent_group_ahb_status: None,
segment_ahb_status: None,
..Default::default()
},
AhbFieldRule {
segment_path: "SG2/NAD/3035".to_string(),
name: "Empfaenger".to_string(),
ahb_status: "X".to_string(),
codes: vec![AhbCodeRule {
value: "MR".to_string(),
description: "Empfaenger".to_string(),
ahb_status: "X".to_string(),
}],
parent_group_ahb_status: None,
segment_ahb_status: None,
..Default::default()
},
AhbFieldRule {
segment_path: "SG4/SG12/NAD/3035".to_string(),
name: "Anschlussnutzer".to_string(),
ahb_status: "X".to_string(),
codes: vec![AhbCodeRule {
value: "Z04".to_string(),
description: "Anschlussnutzer".to_string(),
ahb_status: "X".to_string(),
}],
parent_group_ahb_status: None,
segment_ahb_status: None,
..Default::default()
},
AhbFieldRule {
segment_path: "SG4/SG12/NAD/3035".to_string(),
name: "Korrespondenzanschrift".to_string(),
ahb_status: "X".to_string(),
codes: vec![AhbCodeRule {
value: "Z09".to_string(),
description: "Korrespondenzanschrift".to_string(),
ahb_status: "X".to_string(),
}],
parent_group_ahb_status: None,
segment_ahb_status: None,
..Default::default()
},
],
ub_definitions: HashMap::new(),
};
let all_segments = vec![
OwnedSegment {
id: "NAD".into(),
elements: vec![vec!["MS".into()]],
segment_number: 3,
},
OwnedSegment {
id: "NAD".into(),
elements: vec![vec!["MT".into()]],
segment_number: 4,
},
OwnedSegment {
id: "NAD".into(),
elements: vec![vec!["Z04".into()]],
segment_number: 20,
},
OwnedSegment {
id: "NAD".into(),
elements: vec![vec!["Z09".into()]],
segment_number: 21,
},
];
let report = validator.validate_with_navigator(
&all_segments,
&workflow,
&external,
ValidationLevel::Conditions,
&nav,
);
let code_errors: Vec<_> = report
.by_category(ValidationCategory::Code)
.filter(|i| i.severity == Severity::Error)
.collect();
assert_eq!(
code_errors.len(),
1,
"Expected exactly one COD002 error for MT in SG2, got: {:?}",
code_errors
);
assert!(code_errors[0].message.contains("MT"));
assert!(code_errors[0].message.contains("MR"));
assert!(code_errors[0].message.contains("MS"));
assert!(
!code_errors[0].message.contains("Z04"),
"SG4/SG12 codes should not leak into SG2 error"
);
assert!(
code_errors[0]
.field_path
.as_deref()
.unwrap_or("")
.contains("SG2"),
"Error field_path should reference SG2, got: {:?}",
code_errors[0].field_path
);
}
#[test]
fn test_package_cardinality_within_bounds() {
let evaluator = MockEvaluator::all_true(&[]);
let validator = EdifactValidator::new(evaluator);
let external = NoOpExternalProvider;
let segments = vec![OwnedSegment {
id: "STS".into(),
elements: vec![
vec!["Z33".into()], vec![], vec!["E01".into()], ],
segment_number: 5,
}];
let workflow = AhbWorkflow {
pruefidentifikator: "13017".to_string(),
description: "Test".to_string(),
communication_direction: None,
ub_definitions: HashMap::new(),
fields: vec![AhbFieldRule {
segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
name: "Statusanlaß, Code".to_string(),
ahb_status: "X".to_string(),
element_index: Some(2),
component_index: Some(0),
codes: vec![
AhbCodeRule {
value: "E01".into(),
description: "Code 1".into(),
ahb_status: "X [4P0..1]".into(),
},
AhbCodeRule {
value: "E02".into(),
description: "Code 2".into(),
ahb_status: "X [4P0..1]".into(),
},
],
parent_group_ahb_status: Some("Muss".to_string()),
segment_ahb_status: None,
mig_number: None,
}],
};
let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
let pkg_errors: Vec<_> = report
.by_category(ValidationCategory::Ahb)
.filter(|i| i.code == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
.collect();
assert!(
pkg_errors.is_empty(),
"1 code within [4P0..1] bounds — no error expected, got: {:?}",
pkg_errors
);
}
#[test]
fn test_package_cardinality_zero_present_min_zero() {
let evaluator = MockEvaluator::all_true(&[]);
let validator = EdifactValidator::new(evaluator);
let external = NoOpExternalProvider;
let segments = vec![OwnedSegment {
id: "STS".into(),
elements: vec![
vec!["Z33".into()],
vec![],
vec!["X99".into()], ],
segment_number: 5,
}];
let workflow = AhbWorkflow {
pruefidentifikator: "13017".to_string(),
description: "Test".to_string(),
communication_direction: None,
ub_definitions: HashMap::new(),
fields: vec![AhbFieldRule {
segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
name: "Statusanlaß, Code".to_string(),
ahb_status: "X".to_string(),
element_index: Some(2),
component_index: Some(0),
codes: vec![
AhbCodeRule {
value: "E01".into(),
description: "Code 1".into(),
ahb_status: "X [4P0..1]".into(),
},
AhbCodeRule {
value: "E02".into(),
description: "Code 2".into(),
ahb_status: "X [4P0..1]".into(),
},
],
parent_group_ahb_status: Some("Muss".to_string()),
segment_ahb_status: None,
mig_number: None,
}],
};
let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
let pkg_errors: Vec<_> = report
.by_category(ValidationCategory::Ahb)
.filter(|i| i.code == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
.collect();
assert!(
pkg_errors.is_empty(),
"0 codes, min=0 — no error expected, got: {:?}",
pkg_errors
);
}
#[test]
fn test_package_cardinality_too_many() {
let evaluator = MockEvaluator::all_true(&[]);
let validator = EdifactValidator::new(evaluator);
let external = NoOpExternalProvider;
let segments = vec![
OwnedSegment {
id: "STS".into(),
elements: vec![vec!["Z33".into()], vec![], vec!["E01".into()]],
segment_number: 5,
},
OwnedSegment {
id: "STS".into(),
elements: vec![vec!["Z33".into()], vec![], vec!["E02".into()]],
segment_number: 6,
},
];
let workflow = AhbWorkflow {
pruefidentifikator: "13017".to_string(),
description: "Test".to_string(),
communication_direction: None,
ub_definitions: HashMap::new(),
fields: vec![AhbFieldRule {
segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
name: "Statusanlaß, Code".to_string(),
ahb_status: "X".to_string(),
element_index: Some(2),
component_index: Some(0),
codes: vec![
AhbCodeRule {
value: "E01".into(),
description: "Code 1".into(),
ahb_status: "X [4P0..1]".into(),
},
AhbCodeRule {
value: "E02".into(),
description: "Code 2".into(),
ahb_status: "X [4P0..1]".into(),
},
],
parent_group_ahb_status: Some("Muss".to_string()),
segment_ahb_status: None,
mig_number: None,
}],
};
let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
let pkg_errors: Vec<_> = report
.by_category(ValidationCategory::Ahb)
.filter(|i| i.code == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
.collect();
assert_eq!(
pkg_errors.len(),
1,
"2 codes present, max=1 — expected 1 error, got: {:?}",
pkg_errors
);
assert!(pkg_errors[0].message.contains("[4P0..1]"));
assert_eq!(pkg_errors[0].actual_value.as_deref(), Some("2"));
assert_eq!(pkg_errors[0].expected_value.as_deref(), Some("0..1"));
}
#[test]
fn test_package_cardinality_too_few() {
let evaluator = MockEvaluator::all_true(&[]);
let validator = EdifactValidator::new(evaluator);
let external = NoOpExternalProvider;
let segments = vec![OwnedSegment {
id: "STS".into(),
elements: vec![
vec!["Z33".into()],
vec![],
vec!["X99".into()], ],
segment_number: 5,
}];
let workflow = AhbWorkflow {
pruefidentifikator: "13017".to_string(),
description: "Test".to_string(),
communication_direction: None,
ub_definitions: HashMap::new(),
fields: vec![AhbFieldRule {
segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
name: "Statusanlaß, Code".to_string(),
ahb_status: "X".to_string(),
element_index: Some(2),
component_index: Some(0),
codes: vec![
AhbCodeRule {
value: "E01".into(),
description: "Code 1".into(),
ahb_status: "X [5P1..3]".into(),
},
AhbCodeRule {
value: "E02".into(),
description: "Code 2".into(),
ahb_status: "X [5P1..3]".into(),
},
AhbCodeRule {
value: "E03".into(),
description: "Code 3".into(),
ahb_status: "X [5P1..3]".into(),
},
],
parent_group_ahb_status: Some("Muss".to_string()),
segment_ahb_status: None,
mig_number: None,
}],
};
let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
let pkg_errors: Vec<_> = report
.by_category(ValidationCategory::Ahb)
.filter(|i| i.code == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
.collect();
assert_eq!(
pkg_errors.len(),
1,
"0 codes present, min=1 — expected 1 error, got: {:?}",
pkg_errors
);
assert!(pkg_errors[0].message.contains("[5P1..3]"));
assert_eq!(pkg_errors[0].actual_value.as_deref(), Some("0"));
assert_eq!(pkg_errors[0].expected_value.as_deref(), Some("1..3"));
}
#[test]
fn test_package_cardinality_no_packages_in_workflow() {
let evaluator = MockEvaluator::all_true(&[]);
let validator = EdifactValidator::new(evaluator);
let external = NoOpExternalProvider;
let segments = vec![OwnedSegment {
id: "STS".into(),
elements: vec![vec!["E01".into()]],
segment_number: 5,
}];
let workflow = AhbWorkflow {
pruefidentifikator: "13017".to_string(),
description: "Test".to_string(),
communication_direction: None,
ub_definitions: HashMap::new(),
fields: vec![AhbFieldRule {
segment_path: "STS/9015".to_string(),
name: "Status Code".to_string(),
ahb_status: "X".to_string(),
codes: vec![AhbCodeRule {
value: "E01".into(),
description: "Code 1".into(),
ahb_status: "X".into(),
}],
parent_group_ahb_status: Some("Muss".to_string()),
segment_ahb_status: None,
..Default::default()
}],
};
let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
let pkg_errors: Vec<_> = report
.by_category(ValidationCategory::Ahb)
.filter(|i| i.code == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
.collect();
assert!(
pkg_errors.is_empty(),
"No packages in workflow — no errors expected"
);
}
#[test]
fn test_package_cardinality_with_condition_and_package() {
let evaluator = MockEvaluator::all_true(&[901]);
let validator = EdifactValidator::new(evaluator);
let external = NoOpExternalProvider;
let segments = vec![OwnedSegment {
id: "STS".into(),
elements: vec![vec![], vec![], vec!["E01".into()]],
segment_number: 5,
}];
let workflow = AhbWorkflow {
pruefidentifikator: "13017".to_string(),
description: "Test".to_string(),
communication_direction: None,
ub_definitions: HashMap::new(),
fields: vec![AhbFieldRule {
segment_path: "SG10/STS/C556/9013".to_string(),
name: "Code".to_string(),
ahb_status: "X".to_string(),
element_index: Some(2),
component_index: Some(0),
codes: vec![
AhbCodeRule {
value: "E01".into(),
description: "Code 1".into(),
ahb_status: "X [901] [4P0..1]".into(),
},
AhbCodeRule {
value: "E02".into(),
description: "Code 2".into(),
ahb_status: "X [901] [4P0..1]".into(),
},
],
parent_group_ahb_status: Some("Muss".to_string()),
segment_ahb_status: None,
mig_number: None,
}],
};
let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
let pkg_errors: Vec<_> = report
.by_category(ValidationCategory::Ahb)
.filter(|i| i.code == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
.collect();
assert!(
pkg_errors.is_empty(),
"1 code within [4P0..1] bounds — no error, got: {:?}",
pkg_errors
);
}
#[test]
fn test_package_cardinality_scoped_per_group_instance() {
use mig_types::navigator::GroupNavigator;
struct TwoSg10s {
sts_a: OwnedSegment,
sts_b: OwnedSegment,
}
impl GroupNavigator for TwoSg10s {
fn find_segments_in_group(
&self,
segment_id: &str,
group_path: &[&str],
instance_index: usize,
) -> Vec<OwnedSegment> {
if group_path == ["SG5", "SG6", "SG9", "SG10"] && segment_id == "STS" {
match instance_index {
0 => vec![self.sts_a.clone()],
1 => vec![self.sts_b.clone()],
_ => vec![],
}
} else {
vec![]
}
}
fn find_segments_with_qualifier_in_group(
&self,
_: &str,
_: usize,
_: &str,
_: &[&str],
_: usize,
) -> Vec<OwnedSegment> {
vec![]
}
fn group_instance_count(&self, group_path: &[&str]) -> usize {
match group_path {
["SG5"] | ["SG5", "SG6"] | ["SG5", "SG6", "SG9"] => 1,
["SG5", "SG6", "SG9", "SG10"] => 2,
_ => 0,
}
}
}
let sts_a = OwnedSegment {
id: "STS".into(),
elements: vec![vec!["Z32".into()], vec![], vec!["E01".into()]],
segment_number: 10,
};
let sts_b = OwnedSegment {
id: "STS".into(),
elements: vec![vec!["Z32".into()], vec![], vec!["E01".into()]],
segment_number: 15,
};
let nav = TwoSg10s {
sts_a: sts_a.clone(),
sts_b: sts_b.clone(),
};
let evaluator = MockEvaluator::all_true(&[]);
let validator = EdifactValidator::new(evaluator);
let external = NoOpExternalProvider;
let workflow = AhbWorkflow {
pruefidentifikator: "13025".to_string(),
description: "Test".to_string(),
communication_direction: None,
ub_definitions: HashMap::new(),
fields: vec![AhbFieldRule {
segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
name: "Statusanlaß, Code".to_string(),
ahb_status: "X".to_string(),
element_index: Some(2),
component_index: Some(0),
codes: vec![
AhbCodeRule {
value: "E01".into(),
description: "Code 1".into(),
ahb_status: "X [4P0..1]".into(),
},
AhbCodeRule {
value: "E02".into(),
description: "Code 2".into(),
ahb_status: "X [4P0..1]".into(),
},
],
parent_group_ahb_status: Some("Muss".to_string()),
segment_ahb_status: None,
mig_number: None,
}],
};
let report = validator.validate_with_navigator(
&[sts_a, sts_b],
&workflow,
&external,
ValidationLevel::Full,
&nav,
);
let pkg_errors: Vec<_> = report
.by_category(ValidationCategory::Ahb)
.filter(|i| i.code == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
.collect();
assert!(
pkg_errors.is_empty(),
"Package cardinality is per-instance: 1 code per SG10 rep is within [4P0..1]. Got: {:?}",
pkg_errors
);
}
fn make_segment(id: &str, elements: Vec<Vec<&str>>) -> OwnedSegment {
OwnedSegment {
id: id.to_string(),
elements: elements
.into_iter()
.map(|e| e.into_iter().map(|s| s.to_string()).collect())
.collect(),
segment_number: 0,
}
}
#[test]
fn test_package_cardinality_scoped_to_rule_mig_variant() {
use mig_types::navigator::GroupNavigator;
struct TwoSg8Variants {
seq_z01: OwnedSegment,
seq_z45: OwnedSegment,
}
impl GroupNavigator for TwoSg8Variants {
fn find_segments_in_group(
&self,
segment_id: &str,
group_path: &[&str],
instance_index: usize,
) -> Vec<OwnedSegment> {
if group_path == ["SG4", "SG8"] && segment_id == "SEQ" {
match instance_index {
0 => vec![self.seq_z01.clone()],
1 => vec![self.seq_z45.clone()],
_ => vec![],
}
} else {
vec![]
}
}
fn find_segments_with_qualifier_in_group(
&self,
_: &str,
_: usize,
_: &str,
_: &[&str],
_: usize,
) -> Vec<OwnedSegment> {
vec![]
}
fn group_instance_count(&self, group_path: &[&str]) -> usize {
match group_path {
["SG4"] => 1,
["SG4", "SG8"] => 2,
_ => 0,
}
}
fn instance_has_mig_number(
&self,
group_path: &[&str],
instance_index: usize,
mig_number: &str,
) -> bool {
if group_path != ["SG4", "SG8"] {
return true;
}
match (instance_index, mig_number) {
(0, "00115") => true,
(0, _) => false,
(1, "00171") => true,
(1, _) => false,
_ => false,
}
}
}
let seq_z01 = OwnedSegment {
id: "SEQ".into(),
elements: vec![vec!["Z01".into()], vec!["1".into()]],
segment_number: 10,
};
let seq_z45 = OwnedSegment {
id: "SEQ".into(),
elements: vec![vec!["Z45".into()], vec!["1".into()]],
segment_number: 20,
};
let nav = TwoSg8Variants {
seq_z01: seq_z01.clone(),
seq_z45: seq_z45.clone(),
};
let evaluator = MockEvaluator::all_true(&[]);
let validator = EdifactValidator::new(evaluator);
let external = NoOpExternalProvider;
let workflow = AhbWorkflow {
pruefidentifikator: "55218".to_string(),
description: "Test".to_string(),
communication_direction: None,
ub_definitions: HashMap::new(),
fields: vec![AhbFieldRule {
segment_path: "SG4/SG8/SEQ/1229".to_string(),
name: "Handlung, Code".to_string(),
ahb_status: "X".to_string(),
element_index: Some(0),
component_index: Some(0),
codes: vec![
AhbCodeRule {
value: "Z45".into(),
description: "NNA".into(),
ahb_status: "X [1P1..4294967295]".into(),
},
AhbCodeRule {
value: "Z84".into(),
description: "Differenz-NNA".into(),
ahb_status: "X [1P0..4294967295]".into(),
},
],
parent_group_ahb_status: Some("Muss".to_string()),
segment_ahb_status: None,
mig_number: Some("00171".to_string()),
}],
};
let report = validator.validate_with_navigator(
&[seq_z01, seq_z45],
&workflow,
&external,
ValidationLevel::Full,
&nav,
);
let pkg_errors: Vec<_> = report
.by_category(ValidationCategory::Ahb)
.filter(|i| i.code == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
.collect();
assert!(
pkg_errors.is_empty(),
"Package rule with mig=00171 must only count the Z45 instance (which has 1 code), not the sibling Z01 variant. Got: {:?}",
pkg_errors
);
}
#[test]
fn test_unt_count_correct() {
let segments = vec![
make_segment("UNH", vec![vec!["001"]]),
make_segment("BGM", vec![vec!["E01"]]),
make_segment("DTM", vec![vec!["137", "20250401"]]),
make_segment("UNT", vec![vec!["4", "001"]]),
];
assert!(
validate_unt_segment_count(&segments).is_none(),
"Correct count should produce no issue"
);
}
#[test]
fn test_unt_count_mismatch() {
let segments = vec![
make_segment("UNH", vec![vec!["001"]]),
make_segment("BGM", vec![vec!["E01"]]),
make_segment("UNT", vec![vec!["5", "001"]]),
];
let issue =
validate_unt_segment_count(&segments).expect("Mismatch should produce an issue");
assert_eq!(issue.code, ErrorCodes::UNT_SEGMENT_COUNT_MISMATCH);
assert_eq!(issue.severity, Severity::Error);
assert!(issue.message.contains("declared 5"));
assert!(issue.message.contains("actual 3"));
}
#[test]
fn test_unt_count_excludes_envelope() {
let segments = vec![
make_segment("UNA", vec![]),
make_segment("UNB", vec![vec!["UNOC", "3"]]),
make_segment("UNH", vec![vec!["001"]]),
make_segment("BGM", vec![vec!["E01"]]),
make_segment("UNT", vec![vec!["3", "001"]]),
make_segment("UNZ", vec![vec!["1"]]),
];
assert!(
validate_unt_segment_count(&segments).is_none(),
"Envelope segments excluded — count should be 3 (UNH+BGM+UNT)"
);
}
#[test]
fn test_unt_count_no_unt_returns_none() {
let segments = vec![
make_segment("UNH", vec![vec!["001"]]),
make_segment("BGM", vec![vec!["E01"]]),
];
assert!(
validate_unt_segment_count(&segments).is_none(),
"No UNT segment should return None (not our problem)"
);
}
#[test]
fn test_unt_count_rejects_multi_message_input() {
let segments = vec![
make_segment("UNH", vec![vec!["001"]]),
make_segment("BGM", vec![vec!["E01"]]),
make_segment("UNT", vec![vec!["3", "001"]]),
make_segment("UNH", vec![vec!["002"]]),
make_segment("BGM", vec![vec!["E02"]]),
make_segment("UNT", vec![vec!["3", "002"]]),
];
let issue = validate_unt_segment_count(&segments)
.expect("Multi-message input should produce an error");
assert_eq!(issue.code, ErrorCodes::UNT_SEGMENT_COUNT_MISMATCH);
assert!(
issue.message.contains("2 UNH"),
"Should mention UNH count: {}",
issue.message
);
}
#[test]
fn test_code_validation_accepts_multi_code_variant_qualifier() {
let evaluator = MockEvaluator::new(vec![]);
let validator = EdifactValidator::new(evaluator);
let external = NoOpExternalProvider;
let rff_z39 = OwnedSegment {
id: "RFF".to_string(),
elements: vec![
vec!["RFF".to_string()],
vec!["Z39".to_string(), "REF1".to_string()],
],
segment_number: 1,
};
let workflow = AhbWorkflow {
pruefidentifikator: "55035".to_string(),
description: "Test".to_string(),
communication_direction: None,
fields: vec![
AhbFieldRule {
segment_path: "SG4/SG8/RFF/C506/1153".to_string(),
name: "Referenznummer Qualifier".to_string(),
ahb_status: "Muss".to_string(),
codes: vec![
AhbCodeRule {
value: "Z31".to_string(),
description: "".to_string(),
ahb_status: "X".to_string(),
},
AhbCodeRule {
value: "Z39".to_string(),
description: "".to_string(),
ahb_status: "X".to_string(),
},
],
parent_group_ahb_status: None,
segment_ahb_status: None,
element_index: Some(1),
component_index: Some(0),
mig_number: Some("00075".to_string()),
},
AhbFieldRule {
segment_path: "SG4/SG8/RFF/C506/1153".to_string(),
name: "Referenznummer Qualifier".to_string(),
ahb_status: "Muss".to_string(),
codes: vec![AhbCodeRule {
value: "Z33".to_string(),
description: "".to_string(),
ahb_status: "X".to_string(),
}],
parent_group_ahb_status: None,
segment_ahb_status: None,
element_index: Some(1),
component_index: Some(0),
mig_number: Some("00078".to_string()),
},
],
ub_definitions: HashMap::new(),
};
let report = validator.validate(
&[rff_z39],
&workflow,
&external,
ValidationLevel::Conditions,
);
let code_errors: Vec<_> = report
.by_category(ValidationCategory::Code)
.filter(|i| {
i.severity == Severity::Error && i.code == ErrorCodes::CODE_NOT_ALLOWED_FOR_PID
})
.collect();
assert!(
code_errors.is_empty(),
"RFF+Z39 must be accepted (Z39 is valid for mig=00075). Got: {:?}",
code_errors
);
}
#[test]
fn test_code_validation_disambiguates_migs_by_full_code_profile() {
let evaluator = MockEvaluator::new(vec![]);
let validator = EdifactValidator::new(evaluator);
let external = NoOpExternalProvider;
let pia_5_z12 = OwnedSegment {
id: "PIA".to_string(),
elements: vec![vec!["5".to_string()], vec!["Z12".to_string()]],
segment_number: 1,
};
let pia_5_srw = OwnedSegment {
id: "PIA".to_string(),
elements: vec![vec!["5".to_string()], vec!["SRW".to_string()]],
segment_number: 2,
};
let make_rules = |mig: &str, composite_code: &str| {
vec![
AhbFieldRule {
segment_path: "SG4/SG8/PIA/4347".to_string(),
name: "Produkt-ID-Funktion".to_string(),
ahb_status: "Muss".to_string(),
codes: vec![AhbCodeRule {
value: "5".to_string(),
description: "".to_string(),
ahb_status: "X".to_string(),
}],
parent_group_ahb_status: None,
segment_ahb_status: None,
element_index: Some(0),
component_index: Some(0),
mig_number: Some(mig.to_string()),
},
AhbFieldRule {
segment_path: "SG4/SG8/PIA/C212/7143".to_string(),
name: "Artikel/Dienstleistung-Identifikator".to_string(),
ahb_status: "Muss".to_string(),
codes: vec![AhbCodeRule {
value: composite_code.to_string(),
description: "".to_string(),
ahb_status: "X".to_string(),
}],
parent_group_ahb_status: None,
segment_ahb_status: None,
element_index: Some(1),
component_index: Some(0),
mig_number: Some(mig.to_string()),
},
]
};
let mut fields = make_rules("00108", "Z12");
fields.extend(make_rules("00197", "SRW"));
let workflow = AhbWorkflow {
pruefidentifikator: "55035".to_string(),
description: "Test".to_string(),
communication_direction: None,
fields,
ub_definitions: HashMap::new(),
};
let report = validator.validate(
&[pia_5_z12, pia_5_srw],
&workflow,
&external,
ValidationLevel::Conditions,
);
let code_errors: Vec<_> = report
.by_category(ValidationCategory::Code)
.filter(|i| {
i.severity == Severity::Error && i.code == ErrorCodes::CODE_NOT_ALLOWED_FOR_PID
})
.collect();
assert!(
code_errors.is_empty(),
"Both PIA+5+Z12 (mig=00108) and PIA+5+SRW (mig=00197) must be accepted. Got: {:?}",
code_errors
);
}
}