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