Skip to main content

automapper_validation/validator/
validate.rs

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