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