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    // When a field has known codes, check for a segment containing one of those
625    // specific values at the right element/component position. This applies to both
626    // simple qualifier paths (NAD/3035) and composite paths (CCI/C240/7037) —
627    // without this, a CCI segment from a different group variant (e.g., CCI+Z61)
628    // would falsely satisfy the check for CCI with C240/7037 codes Z15/Z18.
629    if !field.codes.is_empty() {
630        if let (Some(el_idx), Some(comp_idx)) = (field.element_index, field.component_index) {
631            let required_codes: Vec<&str> =
632                field.codes.iter().map(|c| c.value.as_str()).collect();
633            let matching = ctx.find_segments(&segment_id);
634            return matching.iter().any(|seg| {
635                seg.elements
636                    .get(el_idx)
637                    .and_then(|e| e.get(comp_idx))
638                    .is_some_and(|v| required_codes.contains(&v.as_str()))
639            });
640        }
641        // Fallback for fields with codes but no element/component indices:
642        // use simple qualifier check if it's a qualifier-style path.
643        if is_qualifier_field(&field.segment_path) {
644            let required_codes: Vec<&str> =
645                field.codes.iter().map(|c| c.value.as_str()).collect();
646            let el_idx = field.element_index.unwrap_or(0);
647            let comp_idx = field.component_index.unwrap_or(0);
648            let matching = ctx.find_segments(&segment_id);
649            return matching.iter().any(|seg| {
650                seg.elements
651                    .get(el_idx)
652                    .and_then(|e| e.get(comp_idx))
653                    .is_some_and(|v| required_codes.contains(&v.as_str()))
654            });
655        }
656    }
657
658    ctx.has_segment(&segment_id)
659}
660
661/// Check if the group variant for a field is absent from the message.
662///
663/// Uses tree-based logic:
664/// 1. **Group entirely absent**: If the group path has 0 instances, the
665///    whole group is absent and its children aren't required.
666/// 2. **Optional group variant absent**: If the parent group is optional
667///    ("Kann") and the specific qualifier variant isn't present, the variant
668///    is absent. E.g., SG5 "Kann" with LOC+Z17 — if no SG5 instance has
669///    LOC+Z17, all fields under that variant are skipped.
670///
671/// Returns `false` (not absent) when:
672/// - The field has no group prefix (e.g., `NAD/3035`)
673/// - No navigator is available (can't determine group presence)
674/// - The parent group is mandatory and has instances
675fn is_group_variant_absent(ctx: &EvaluationContext, field: &AhbFieldRule) -> bool {
676    let group_path: Vec<&str> = field
677        .segment_path
678        .split('/')
679        .take_while(|p| p.starts_with("SG"))
680        .collect();
681
682    if group_path.is_empty() {
683        return false;
684    }
685
686    let nav = match ctx.navigator {
687        Some(nav) => nav,
688        None => return false,
689    };
690
691    let instance_count = nav.group_instance_count(&group_path);
692
693    // Case 1: group entirely absent — only skip if the group is NOT mandatory.
694    // When a mandatory group (parent_group_ahb_status contains "Muss" or "X")
695    // has 0 instances, the group's absence is itself the error, so we should
696    // NOT skip field validation — let the field-level check report the missing field.
697    if instance_count == 0 {
698        let is_group_mandatory = field
699            .parent_group_ahb_status
700            .as_deref()
701            .is_some_and(is_mandatory_status);
702        if !is_group_mandatory {
703            return true;
704        }
705        // Mandatory group with 0 instances → don't suppress field errors
706        return false;
707    }
708
709    // Case 2: group has instances, but the specific qualifier variant may be absent.
710    // Only applies when the parent group is optional ("Kann") — mandatory groups
711    // ("Muss", "X") require all their qualifier variants to be present.
712    if let Some(ref group_status) = field.parent_group_ahb_status {
713        if !is_mandatory_status(group_status) && !group_status.contains('[') {
714            // Parent group is unconditionally optional (e.g., "Kann").
715            // Check if the field's qualifier variant is present in any instance.
716            if !field.codes.is_empty() && is_qualifier_field(&field.segment_path) {
717                let segment_id = extract_segment_id(&field.segment_path);
718                let required_codes: Vec<&str> =
719                    field.codes.iter().map(|c| c.value.as_str()).collect();
720
721                let any_instance_has_qualifier = (0..instance_count).any(|i| {
722                    nav.find_segments_in_group(&segment_id, &group_path, i)
723                        .iter()
724                        .any(|seg| {
725                            seg.elements
726                                .first()
727                                .and_then(|e| e.first())
728                                .is_some_and(|v| required_codes.contains(&v.as_str()))
729                        })
730                });
731
732                if !any_instance_has_qualifier {
733                    return true; // optional group variant absent
734                }
735            }
736        }
737    }
738
739    // Case 3: non-entry segment absent from all group instances.
740    // E.g., SG10 has QTY (entry) + optional STS segments. If no STS appears
741    // in any SG10 instance but the entry segment (QTY) is present, fields
742    // under STS are not required.
743    //
744    // We check `has_any_segment_in_group` to confirm the group instance is
745    // genuinely populated (proving our target segment is a non-entry optional
746    // one) vs a navigator that simply can't resolve segments.
747    let segment_id = extract_segment_id(&field.segment_path);
748    let segment_absent_from_all = (0..instance_count).all(|i| {
749        nav.find_segments_in_group(&segment_id, &group_path, i)
750            .is_empty()
751    });
752    if segment_absent_from_all {
753        let group_has_other_segments =
754            (0..instance_count).any(|i| nav.has_any_segment_in_group(&group_path, i));
755        if group_has_other_segments {
756            return true;
757        }
758    }
759
760    false
761}
762
763/// Recursively collect all Package nodes from a condition expression tree.
764fn collect_packages(expr: &ConditionExpr, out: &mut Vec<(u32, u32, u32)>) {
765    match expr {
766        ConditionExpr::Package { id, min, max } => {
767            out.push((*id, *min, *max));
768        }
769        ConditionExpr::And(exprs) | ConditionExpr::Or(exprs) => {
770            for e in exprs {
771                collect_packages(e, out);
772            }
773        }
774        ConditionExpr::Xor(left, right) => {
775            collect_packages(left, out);
776            collect_packages(right, out);
777        }
778        ConditionExpr::Not(inner) => {
779            collect_packages(inner, out);
780        }
781        ConditionExpr::Ref(_) => {}
782    }
783}
784
785/// Check if an AHB status is mandatory (Muss or X prefix).
786fn is_mandatory_status(status: &str) -> bool {
787    let trimmed = status.trim();
788    trimmed.starts_with("Muss") || trimmed.starts_with('X')
789}
790
791/// Check if a field path points to a simple qualifier element (element[0] of the segment).
792///
793/// Returns `true` for paths like `[SG/]*/SEG/ELEMENT` where the data element is
794/// directly under the segment (no composite wrapper). These fields have their code
795/// in `element[0][0]` and can be validated.
796///
797/// Returns `false` for composite paths like `SEG/COMPOSITE/ELEMENT` (e.g.,
798/// `UNH/S009/0065`) where the element is inside a composite at an unknown index.
799fn is_qualifier_field(path: &str) -> bool {
800    let parts: Vec<&str> = path.split('/').filter(|p| !p.starts_with("SG")).collect();
801    // Expected: [SEGMENT_TAG, ELEMENT_ID] — exactly 2 parts after SG stripping.
802    // If 3+ parts, there's a composite layer (e.g., [SEG, COMPOSITE, ELEMENT]).
803    parts.len() == 2
804}
805
806/// Extract the group path prefix from a field path.
807///
808/// `"SG2/NAD/3035"` → `"SG2"`, `"SG4/SG12/NAD/3035"` → `"SG4/SG12"`,
809/// `"NAD/3035"` → `""` (no group prefix).
810fn extract_group_path_key(path: &str) -> String {
811    let sg_parts: Vec<&str> = path
812        .split('/')
813        .take_while(|p| p.starts_with("SG"))
814        .collect();
815    sg_parts.join("/")
816}
817
818/// Extract the segment ID from a field path like "SG2/NAD/C082/3039" -> "NAD".
819fn extract_segment_id(path: &str) -> String {
820    for part in path.split('/') {
821        // Skip segment group identifiers and composite/element identifiers
822        if part.starts_with("SG") || part.starts_with("C_") || part.starts_with("D_") {
823            continue;
824        }
825        // Return first 3-letter uppercase segment identifier
826        if part.len() >= 3
827            && part
828                .chars()
829                .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
830        {
831            return part.to_string();
832        }
833    }
834    // Fallback: return the last part
835    path.split('/').next_back().unwrap_or(path).to_string()
836}
837
838#[cfg(test)]
839mod tests {
840    use super::*;
841    use crate::eval::{ConditionResult as CR, NoOpExternalProvider};
842    use std::collections::HashMap;
843
844    /// Mock evaluator for testing the validator.
845    struct MockEvaluator {
846        results: HashMap<u32, CR>,
847    }
848
849    impl MockEvaluator {
850        fn new(results: Vec<(u32, CR)>) -> Self {
851            Self {
852                results: results.into_iter().collect(),
853            }
854        }
855
856        fn all_true(ids: &[u32]) -> Self {
857            Self::new(ids.iter().map(|&id| (id, CR::True)).collect())
858        }
859    }
860
861    impl ConditionEvaluator for MockEvaluator {
862        fn evaluate(&self, condition: u32, _ctx: &EvaluationContext) -> CR {
863            self.results.get(&condition).copied().unwrap_or(CR::Unknown)
864        }
865        fn is_external(&self, _condition: u32) -> bool {
866            false
867        }
868        fn message_type(&self) -> &str {
869            "UTILMD"
870        }
871        fn format_version(&self) -> &str {
872            "FV2510"
873        }
874    }
875
876    // === Helper function tests ===
877
878    #[test]
879    fn test_is_mandatory_status() {
880        assert!(is_mandatory_status("Muss"));
881        assert!(is_mandatory_status("Muss [182] ∧ [152]"));
882        assert!(is_mandatory_status("X"));
883        assert!(is_mandatory_status("X [567]"));
884        assert!(!is_mandatory_status("Soll [1]"));
885        assert!(!is_mandatory_status("Kann [1]"));
886        assert!(!is_mandatory_status(""));
887    }
888
889    #[test]
890    fn test_extract_segment_id_simple() {
891        assert_eq!(extract_segment_id("NAD"), "NAD");
892    }
893
894    #[test]
895    fn test_extract_segment_id_with_sg_prefix() {
896        assert_eq!(extract_segment_id("SG2/NAD/C082/3039"), "NAD");
897    }
898
899    #[test]
900    fn test_extract_segment_id_nested_sg() {
901        assert_eq!(extract_segment_id("SG4/SG8/SEQ/C286/6350"), "SEQ");
902    }
903
904    // === Validator tests with mock data ===
905
906    #[test]
907    fn test_validate_missing_mandatory_field() {
908        let evaluator = MockEvaluator::all_true(&[182, 152]);
909        let validator = EdifactValidator::new(evaluator);
910        let external = NoOpExternalProvider;
911
912        let workflow = AhbWorkflow {
913            pruefidentifikator: "11001".to_string(),
914            description: "Test".to_string(),
915            communication_direction: None,
916            fields: vec![AhbFieldRule {
917                segment_path: "SG2/NAD/C082/3039".to_string(),
918                name: "MP-ID des MSB".to_string(),
919                ahb_status: "Muss [182] ∧ [152]".to_string(),
920                codes: vec![],
921                parent_group_ahb_status: None,
922                ..Default::default()
923            }],
924            ub_definitions: HashMap::new(),
925        };
926
927        // Validate with no segments
928        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
929
930        // Should have an error for missing mandatory field
931        assert!(!report.is_valid());
932        let errors: Vec<_> = report.errors().collect();
933        assert_eq!(errors.len(), 1);
934        assert_eq!(errors[0].code, ErrorCodes::MISSING_REQUIRED_FIELD);
935        assert!(errors[0].message.contains("MP-ID des MSB"));
936    }
937
938    #[test]
939    fn test_validate_condition_false_no_error() {
940        // When condition evaluates to False, field is not required
941        let evaluator = MockEvaluator::new(vec![(182, CR::True), (152, CR::False)]);
942        let validator = EdifactValidator::new(evaluator);
943        let external = NoOpExternalProvider;
944
945        let workflow = AhbWorkflow {
946            pruefidentifikator: "11001".to_string(),
947            description: "Test".to_string(),
948            communication_direction: None,
949            fields: vec![AhbFieldRule {
950                segment_path: "NAD".to_string(),
951                name: "Partnerrolle".to_string(),
952                ahb_status: "Muss [182] ∧ [152]".to_string(),
953                codes: vec![],
954                parent_group_ahb_status: None,
955                ..Default::default()
956            }],
957            ub_definitions: HashMap::new(),
958        };
959
960        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
961
962        // Condition is false, so field is not required - no error
963        assert!(report.is_valid());
964    }
965
966    #[test]
967    fn test_validate_condition_unknown_adds_info() {
968        // When condition is Unknown, add an info-level note
969        let evaluator = MockEvaluator::new(vec![(182, CR::True)]);
970        // 152 is not registered -> Unknown
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        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
990
991        // Should be valid (Unknown is not an error) but have an info issue
992        assert!(report.is_valid());
993        let infos: Vec<_> = report.infos().collect();
994        assert_eq!(infos.len(), 1);
995        assert_eq!(infos[0].code, ErrorCodes::CONDITION_UNKNOWN);
996    }
997
998    #[test]
999    fn test_validate_structure_level_skips_conditions() {
1000        let evaluator = MockEvaluator::all_true(&[182, 152]);
1001        let validator = EdifactValidator::new(evaluator);
1002        let external = NoOpExternalProvider;
1003
1004        let workflow = AhbWorkflow {
1005            pruefidentifikator: "11001".to_string(),
1006            description: "Test".to_string(),
1007            communication_direction: None,
1008            fields: vec![AhbFieldRule {
1009                segment_path: "NAD".to_string(),
1010                name: "Partnerrolle".to_string(),
1011                ahb_status: "Muss [182] ∧ [152]".to_string(),
1012                codes: vec![],
1013                parent_group_ahb_status: None,
1014                ..Default::default()
1015            }],
1016            ub_definitions: HashMap::new(),
1017        };
1018
1019        // With Structure level, conditions are not checked
1020        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Structure);
1021
1022        // No AHB errors because conditions were not evaluated
1023        assert!(report.is_valid());
1024        assert_eq!(report.by_category(ValidationCategory::Ahb).count(), 0);
1025    }
1026
1027    #[test]
1028    fn test_validate_empty_workflow_no_condition_errors() {
1029        let evaluator = MockEvaluator::all_true(&[]);
1030        let validator = EdifactValidator::new(evaluator);
1031        let external = NoOpExternalProvider;
1032
1033        let empty_workflow = AhbWorkflow {
1034            pruefidentifikator: String::new(),
1035            description: String::new(),
1036            communication_direction: None,
1037            fields: vec![],
1038            ub_definitions: HashMap::new(),
1039        };
1040
1041        let report = validator.validate(&[], &empty_workflow, &external, ValidationLevel::Full);
1042
1043        assert!(report.is_valid());
1044    }
1045
1046    #[test]
1047    fn test_validate_bare_muss_always_required() {
1048        let evaluator = MockEvaluator::new(vec![]);
1049        let validator = EdifactValidator::new(evaluator);
1050        let external = NoOpExternalProvider;
1051
1052        let workflow = AhbWorkflow {
1053            pruefidentifikator: "55001".to_string(),
1054            description: "Test".to_string(),
1055            communication_direction: Some("NB an LF".to_string()),
1056            fields: vec![AhbFieldRule {
1057                segment_path: "SG2/NAD/3035".to_string(),
1058                name: "Partnerrolle".to_string(),
1059                ahb_status: "Muss".to_string(), // No conditions
1060                codes: vec![],
1061                parent_group_ahb_status: None,
1062                ..Default::default()
1063            }],
1064            ub_definitions: HashMap::new(),
1065        };
1066
1067        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
1068
1069        // Bare "Muss" with no conditions -> unconditionally required -> missing = error
1070        assert!(!report.is_valid());
1071        assert_eq!(report.error_count(), 1);
1072    }
1073
1074    #[test]
1075    fn test_validate_x_status_is_mandatory() {
1076        let evaluator = MockEvaluator::new(vec![]);
1077        let validator = EdifactValidator::new(evaluator);
1078        let external = NoOpExternalProvider;
1079
1080        let workflow = AhbWorkflow {
1081            pruefidentifikator: "55001".to_string(),
1082            description: "Test".to_string(),
1083            communication_direction: None,
1084            fields: vec![AhbFieldRule {
1085                segment_path: "DTM".to_string(),
1086                name: "Datum".to_string(),
1087                ahb_status: "X".to_string(),
1088                codes: vec![],
1089                parent_group_ahb_status: None,
1090                ..Default::default()
1091            }],
1092            ub_definitions: HashMap::new(),
1093        };
1094
1095        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
1096
1097        assert!(!report.is_valid());
1098        let errors: Vec<_> = report.errors().collect();
1099        assert_eq!(errors[0].code, ErrorCodes::MISSING_REQUIRED_FIELD);
1100    }
1101
1102    #[test]
1103    fn test_validate_soll_not_mandatory() {
1104        let evaluator = MockEvaluator::new(vec![]);
1105        let validator = EdifactValidator::new(evaluator);
1106        let external = NoOpExternalProvider;
1107
1108        let workflow = AhbWorkflow {
1109            pruefidentifikator: "55001".to_string(),
1110            description: "Test".to_string(),
1111            communication_direction: None,
1112            fields: vec![AhbFieldRule {
1113                segment_path: "DTM".to_string(),
1114                name: "Datum".to_string(),
1115                ahb_status: "Soll".to_string(),
1116                codes: vec![],
1117                parent_group_ahb_status: None,
1118                ..Default::default()
1119            }],
1120            ub_definitions: HashMap::new(),
1121        };
1122
1123        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
1124
1125        // Soll is not mandatory, so missing is not an error
1126        assert!(report.is_valid());
1127    }
1128
1129    #[test]
1130    fn test_report_includes_metadata() {
1131        let evaluator = MockEvaluator::new(vec![]);
1132        let validator = EdifactValidator::new(evaluator);
1133        let external = NoOpExternalProvider;
1134
1135        let workflow = AhbWorkflow {
1136            pruefidentifikator: "55001".to_string(),
1137            description: String::new(),
1138            communication_direction: None,
1139            fields: vec![],
1140            ub_definitions: HashMap::new(),
1141        };
1142
1143        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Full);
1144
1145        assert_eq!(report.format_version.as_deref(), Some("FV2510"));
1146        assert_eq!(report.level, ValidationLevel::Full);
1147        assert_eq!(report.message_type, "UTILMD");
1148        assert_eq!(report.pruefidentifikator.as_deref(), Some("55001"));
1149    }
1150
1151    #[test]
1152    fn test_validate_with_navigator_returns_report() {
1153        let evaluator = MockEvaluator::all_true(&[]);
1154        let validator = EdifactValidator::new(evaluator);
1155        let external = NoOpExternalProvider;
1156        let nav = crate::eval::NoOpGroupNavigator;
1157
1158        let workflow = AhbWorkflow {
1159            pruefidentifikator: "55001".to_string(),
1160            description: "Test".to_string(),
1161            communication_direction: None,
1162            fields: vec![],
1163            ub_definitions: HashMap::new(),
1164        };
1165
1166        let report = validator.validate_with_navigator(
1167            &[],
1168            &workflow,
1169            &external,
1170            ValidationLevel::Full,
1171            &nav,
1172        );
1173        assert!(report.is_valid());
1174    }
1175
1176    #[test]
1177    fn test_code_validation_skips_composite_paths() {
1178        // UNH/S009/0065 has codes like ["UTILMD"], but the code is in element[1]
1179        // (composite S009), not element[0] (message reference).
1180        // The validator should skip code validation for composite paths.
1181        let evaluator = MockEvaluator::new(vec![]);
1182        let validator = EdifactValidator::new(evaluator);
1183        let external = NoOpExternalProvider;
1184
1185        let unh_segment = OwnedSegment {
1186            id: "UNH".to_string(),
1187            elements: vec![
1188                vec!["ALEXANDE951842".to_string()], // element 0: message ref
1189                vec![
1190                    "UTILMD".to_string(),
1191                    "D".to_string(),
1192                    "11A".to_string(),
1193                    "UN".to_string(),
1194                    "S2.1".to_string(),
1195                ],
1196            ],
1197            segment_number: 1,
1198        };
1199
1200        let workflow = AhbWorkflow {
1201            pruefidentifikator: "55001".to_string(),
1202            description: "Test".to_string(),
1203            communication_direction: None,
1204            fields: vec![
1205                AhbFieldRule {
1206                    segment_path: "UNH/S009/0065".to_string(),
1207                    name: "Nachrichtentyp".to_string(),
1208                    ahb_status: "X".to_string(),
1209                    codes: vec![AhbCodeRule {
1210                        value: "UTILMD".to_string(),
1211                        description: "Stammdaten".to_string(),
1212                        ahb_status: "X".to_string(),
1213                    }],
1214                    parent_group_ahb_status: None,
1215                    ..Default::default()
1216                },
1217                AhbFieldRule {
1218                    segment_path: "UNH/S009/0052".to_string(),
1219                    name: "Version".to_string(),
1220                    ahb_status: "X".to_string(),
1221                    codes: vec![AhbCodeRule {
1222                        value: "D".to_string(),
1223                        description: "Draft".to_string(),
1224                        ahb_status: "X".to_string(),
1225                    }],
1226                    parent_group_ahb_status: None,
1227                    ..Default::default()
1228                },
1229            ],
1230            ub_definitions: HashMap::new(),
1231        };
1232
1233        let report = validator.validate(
1234            &[unh_segment],
1235            &workflow,
1236            &external,
1237            ValidationLevel::Conditions,
1238        );
1239
1240        // Should NOT produce COD002 false positives for composite element paths
1241        let code_errors: Vec<_> = report
1242            .by_category(ValidationCategory::Code)
1243            .filter(|i| i.severity == Severity::Error)
1244            .collect();
1245        assert!(
1246            code_errors.is_empty(),
1247            "Expected no code errors for composite paths, got: {:?}",
1248            code_errors
1249        );
1250    }
1251
1252    #[test]
1253    fn test_cross_field_code_validation_valid_qualifiers() {
1254        // NAD/3035 has separate field rules: [MS] for sender, [MR] for receiver.
1255        // Cross-field validation unions them → {MS, MR}. Both segments are valid.
1256        let evaluator = MockEvaluator::new(vec![]);
1257        let validator = EdifactValidator::new(evaluator);
1258        let external = NoOpExternalProvider;
1259
1260        let nad_ms = OwnedSegment {
1261            id: "NAD".to_string(),
1262            elements: vec![vec!["MS".to_string()]],
1263            segment_number: 4,
1264        };
1265        let nad_mr = OwnedSegment {
1266            id: "NAD".to_string(),
1267            elements: vec![vec!["MR".to_string()]],
1268            segment_number: 5,
1269        };
1270
1271        let workflow = AhbWorkflow {
1272            pruefidentifikator: "55001".to_string(),
1273            description: "Test".to_string(),
1274            communication_direction: None,
1275            fields: vec![
1276                AhbFieldRule {
1277                    segment_path: "SG2/NAD/3035".to_string(),
1278                    name: "Absender".to_string(),
1279                    ahb_status: "X".to_string(),
1280                    codes: vec![AhbCodeRule {
1281                        value: "MS".to_string(),
1282                        description: "Absender".to_string(),
1283                        ahb_status: "X".to_string(),
1284                    }],
1285                    parent_group_ahb_status: None,
1286                    ..Default::default()
1287                },
1288                AhbFieldRule {
1289                    segment_path: "SG2/NAD/3035".to_string(),
1290                    name: "Empfaenger".to_string(),
1291                    ahb_status: "X".to_string(),
1292                    codes: vec![AhbCodeRule {
1293                        value: "MR".to_string(),
1294                        description: "Empfaenger".to_string(),
1295                        ahb_status: "X".to_string(),
1296                    }],
1297                    parent_group_ahb_status: None,
1298                    ..Default::default()
1299                },
1300            ],
1301            ub_definitions: HashMap::new(),
1302        };
1303
1304        let report = validator.validate(
1305            &[nad_ms, nad_mr],
1306            &workflow,
1307            &external,
1308            ValidationLevel::Conditions,
1309        );
1310
1311        let code_errors: Vec<_> = report
1312            .by_category(ValidationCategory::Code)
1313            .filter(|i| i.severity == Severity::Error)
1314            .collect();
1315        assert!(
1316            code_errors.is_empty(),
1317            "Expected no code errors for valid qualifiers, got: {:?}",
1318            code_errors
1319        );
1320    }
1321
1322    #[test]
1323    fn test_cross_field_code_validation_catches_invalid_qualifier() {
1324        // NAD+MT is not in the allowed set {MS, MR} → should produce COD002.
1325        let evaluator = MockEvaluator::new(vec![]);
1326        let validator = EdifactValidator::new(evaluator);
1327        let external = NoOpExternalProvider;
1328
1329        let nad_ms = OwnedSegment {
1330            id: "NAD".to_string(),
1331            elements: vec![vec!["MS".to_string()]],
1332            segment_number: 4,
1333        };
1334        let nad_mt = OwnedSegment {
1335            id: "NAD".to_string(),
1336            elements: vec![vec!["MT".to_string()]], // invalid
1337            segment_number: 5,
1338        };
1339
1340        let workflow = AhbWorkflow {
1341            pruefidentifikator: "55001".to_string(),
1342            description: "Test".to_string(),
1343            communication_direction: None,
1344            fields: vec![
1345                AhbFieldRule {
1346                    segment_path: "SG2/NAD/3035".to_string(),
1347                    name: "Absender".to_string(),
1348                    ahb_status: "X".to_string(),
1349                    codes: vec![AhbCodeRule {
1350                        value: "MS".to_string(),
1351                        description: "Absender".to_string(),
1352                        ahb_status: "X".to_string(),
1353                    }],
1354                    parent_group_ahb_status: None,
1355                    ..Default::default()
1356                },
1357                AhbFieldRule {
1358                    segment_path: "SG2/NAD/3035".to_string(),
1359                    name: "Empfaenger".to_string(),
1360                    ahb_status: "X".to_string(),
1361                    codes: vec![AhbCodeRule {
1362                        value: "MR".to_string(),
1363                        description: "Empfaenger".to_string(),
1364                        ahb_status: "X".to_string(),
1365                    }],
1366                    parent_group_ahb_status: None,
1367                    ..Default::default()
1368                },
1369            ],
1370            ub_definitions: HashMap::new(),
1371        };
1372
1373        let report = validator.validate(
1374            &[nad_ms, nad_mt],
1375            &workflow,
1376            &external,
1377            ValidationLevel::Conditions,
1378        );
1379
1380        let code_errors: Vec<_> = report
1381            .by_category(ValidationCategory::Code)
1382            .filter(|i| i.severity == Severity::Error)
1383            .collect();
1384        assert_eq!(code_errors.len(), 1, "Expected one COD002 error for MT");
1385        assert!(code_errors[0].message.contains("MT"));
1386        assert!(code_errors[0].message.contains("MR"));
1387        assert!(code_errors[0].message.contains("MS"));
1388    }
1389
1390    #[test]
1391    fn test_cross_field_code_validation_unions_across_groups() {
1392        // SG2/NAD/3035 allows {MS, MR}, SG4/SG12/NAD/3035 allows {Z04, Z09}.
1393        // Since find_segments("NAD") returns all NADs, codes must be unioned
1394        // by tag: {MS, MR, Z04, Z09}. NAD+MT should be caught, all others pass.
1395        let evaluator = MockEvaluator::new(vec![]);
1396        let validator = EdifactValidator::new(evaluator);
1397        let external = NoOpExternalProvider;
1398
1399        let segments = vec![
1400            OwnedSegment {
1401                id: "NAD".to_string(),
1402                elements: vec![vec!["MS".to_string()]],
1403                segment_number: 3,
1404            },
1405            OwnedSegment {
1406                id: "NAD".to_string(),
1407                elements: vec![vec!["MR".to_string()]],
1408                segment_number: 4,
1409            },
1410            OwnedSegment {
1411                id: "NAD".to_string(),
1412                elements: vec![vec!["Z04".to_string()]],
1413                segment_number: 20,
1414            },
1415            OwnedSegment {
1416                id: "NAD".to_string(),
1417                elements: vec![vec!["Z09".to_string()]],
1418                segment_number: 21,
1419            },
1420            OwnedSegment {
1421                id: "NAD".to_string(),
1422                elements: vec![vec!["MT".to_string()]], // invalid
1423                segment_number: 22,
1424            },
1425        ];
1426
1427        let workflow = AhbWorkflow {
1428            pruefidentifikator: "55001".to_string(),
1429            description: "Test".to_string(),
1430            communication_direction: None,
1431            fields: vec![
1432                AhbFieldRule {
1433                    segment_path: "SG2/NAD/3035".to_string(),
1434                    name: "Absender".to_string(),
1435                    ahb_status: "X".to_string(),
1436                    codes: vec![AhbCodeRule {
1437                        value: "MS".to_string(),
1438                        description: "Absender".to_string(),
1439                        ahb_status: "X".to_string(),
1440                    }],
1441                    parent_group_ahb_status: None,
1442                    ..Default::default()
1443                },
1444                AhbFieldRule {
1445                    segment_path: "SG2/NAD/3035".to_string(),
1446                    name: "Empfaenger".to_string(),
1447                    ahb_status: "X".to_string(),
1448                    codes: vec![AhbCodeRule {
1449                        value: "MR".to_string(),
1450                        description: "Empfaenger".to_string(),
1451                        ahb_status: "X".to_string(),
1452                    }],
1453                    parent_group_ahb_status: None,
1454                    ..Default::default()
1455                },
1456                AhbFieldRule {
1457                    segment_path: "SG4/SG12/NAD/3035".to_string(),
1458                    name: "Anschlussnutzer".to_string(),
1459                    ahb_status: "X".to_string(),
1460                    codes: vec![AhbCodeRule {
1461                        value: "Z04".to_string(),
1462                        description: "Anschlussnutzer".to_string(),
1463                        ahb_status: "X".to_string(),
1464                    }],
1465                    parent_group_ahb_status: None,
1466                    ..Default::default()
1467                },
1468                AhbFieldRule {
1469                    segment_path: "SG4/SG12/NAD/3035".to_string(),
1470                    name: "Korrespondenzanschrift".to_string(),
1471                    ahb_status: "X".to_string(),
1472                    codes: vec![AhbCodeRule {
1473                        value: "Z09".to_string(),
1474                        description: "Korrespondenzanschrift".to_string(),
1475                        ahb_status: "X".to_string(),
1476                    }],
1477                    parent_group_ahb_status: None,
1478                    ..Default::default()
1479                },
1480            ],
1481            ub_definitions: HashMap::new(),
1482        };
1483
1484        let report =
1485            validator.validate(&segments, &workflow, &external, ValidationLevel::Conditions);
1486
1487        let code_errors: Vec<_> = report
1488            .by_category(ValidationCategory::Code)
1489            .filter(|i| i.severity == Severity::Error)
1490            .collect();
1491        assert_eq!(
1492            code_errors.len(),
1493            1,
1494            "Expected exactly one COD002 error for MT, got: {:?}",
1495            code_errors
1496        );
1497        assert!(code_errors[0].message.contains("MT"));
1498    }
1499
1500    #[test]
1501    fn test_is_qualifier_field_simple_paths() {
1502        assert!(is_qualifier_field("NAD/3035"));
1503        assert!(is_qualifier_field("SG2/NAD/3035"));
1504        assert!(is_qualifier_field("SG4/SG8/SEQ/6350"));
1505        assert!(is_qualifier_field("LOC/3227"));
1506    }
1507
1508    #[test]
1509    fn test_is_qualifier_field_composite_paths() {
1510        assert!(!is_qualifier_field("UNH/S009/0065"));
1511        assert!(!is_qualifier_field("NAD/C082/3039"));
1512        assert!(!is_qualifier_field("SG2/NAD/C082/3039"));
1513    }
1514
1515    #[test]
1516    fn test_is_qualifier_field_bare_segment() {
1517        assert!(!is_qualifier_field("NAD"));
1518        assert!(!is_qualifier_field("SG2/NAD"));
1519    }
1520
1521    #[test]
1522    fn test_missing_qualifier_instance_is_detected() {
1523        // NAD+MS is present but NAD+MR is missing.
1524        // The Empfaenger field requires [MR] → should produce AHB001.
1525        let evaluator = MockEvaluator::new(vec![]);
1526        let validator = EdifactValidator::new(evaluator);
1527        let external = NoOpExternalProvider;
1528
1529        let nad_ms = OwnedSegment {
1530            id: "NAD".to_string(),
1531            elements: vec![vec!["MS".to_string()]],
1532            segment_number: 3,
1533        };
1534
1535        let workflow = AhbWorkflow {
1536            pruefidentifikator: "55001".to_string(),
1537            description: "Test".to_string(),
1538            communication_direction: None,
1539            fields: vec![
1540                AhbFieldRule {
1541                    segment_path: "SG2/NAD/3035".to_string(),
1542                    name: "Absender".to_string(),
1543                    ahb_status: "X".to_string(),
1544                    codes: vec![AhbCodeRule {
1545                        value: "MS".to_string(),
1546                        description: "Absender".to_string(),
1547                        ahb_status: "X".to_string(),
1548                    }],
1549                    parent_group_ahb_status: None,
1550                    ..Default::default()
1551                },
1552                AhbFieldRule {
1553                    segment_path: "SG2/NAD/3035".to_string(),
1554                    name: "Empfaenger".to_string(),
1555                    ahb_status: "Muss".to_string(),
1556                    codes: vec![AhbCodeRule {
1557                        value: "MR".to_string(),
1558                        description: "Empfaenger".to_string(),
1559                        ahb_status: "X".to_string(),
1560                    }],
1561                    parent_group_ahb_status: None,
1562                    ..Default::default()
1563                },
1564            ],
1565            ub_definitions: HashMap::new(),
1566        };
1567
1568        let report =
1569            validator.validate(&[nad_ms], &workflow, &external, ValidationLevel::Conditions);
1570
1571        let ahb_errors: Vec<_> = report
1572            .by_category(ValidationCategory::Ahb)
1573            .filter(|i| i.severity == Severity::Error)
1574            .collect();
1575        assert_eq!(
1576            ahb_errors.len(),
1577            1,
1578            "Expected AHB001 for missing NAD+MR, got: {:?}",
1579            ahb_errors
1580        );
1581        assert!(ahb_errors[0].message.contains("Empfaenger"));
1582    }
1583
1584    #[test]
1585    fn test_present_qualifier_instance_no_error() {
1586        // Both NAD+MS and NAD+MR present → no AHB001 for either.
1587        let evaluator = MockEvaluator::new(vec![]);
1588        let validator = EdifactValidator::new(evaluator);
1589        let external = NoOpExternalProvider;
1590
1591        let segments = vec![
1592            OwnedSegment {
1593                id: "NAD".to_string(),
1594                elements: vec![vec!["MS".to_string()]],
1595                segment_number: 3,
1596            },
1597            OwnedSegment {
1598                id: "NAD".to_string(),
1599                elements: vec![vec!["MR".to_string()]],
1600                segment_number: 4,
1601            },
1602        ];
1603
1604        let workflow = AhbWorkflow {
1605            pruefidentifikator: "55001".to_string(),
1606            description: "Test".to_string(),
1607            communication_direction: None,
1608            fields: vec![
1609                AhbFieldRule {
1610                    segment_path: "SG2/NAD/3035".to_string(),
1611                    name: "Absender".to_string(),
1612                    ahb_status: "Muss".to_string(),
1613                    codes: vec![AhbCodeRule {
1614                        value: "MS".to_string(),
1615                        description: "Absender".to_string(),
1616                        ahb_status: "X".to_string(),
1617                    }],
1618                    parent_group_ahb_status: None,
1619                    ..Default::default()
1620                },
1621                AhbFieldRule {
1622                    segment_path: "SG2/NAD/3035".to_string(),
1623                    name: "Empfaenger".to_string(),
1624                    ahb_status: "Muss".to_string(),
1625                    codes: vec![AhbCodeRule {
1626                        value: "MR".to_string(),
1627                        description: "Empfaenger".to_string(),
1628                        ahb_status: "X".to_string(),
1629                    }],
1630                    parent_group_ahb_status: None,
1631                    ..Default::default()
1632                },
1633            ],
1634            ub_definitions: HashMap::new(),
1635        };
1636
1637        let report =
1638            validator.validate(&segments, &workflow, &external, ValidationLevel::Conditions);
1639
1640        let ahb_errors: Vec<_> = report
1641            .by_category(ValidationCategory::Ahb)
1642            .filter(|i| i.severity == Severity::Error)
1643            .collect();
1644        assert!(
1645            ahb_errors.is_empty(),
1646            "Expected no AHB001 errors, got: {:?}",
1647            ahb_errors
1648        );
1649    }
1650
1651    #[test]
1652    fn test_extract_group_path_key() {
1653        assert_eq!(extract_group_path_key("SG2/NAD/3035"), "SG2");
1654        assert_eq!(extract_group_path_key("SG4/SG12/NAD/3035"), "SG4/SG12");
1655        assert_eq!(extract_group_path_key("NAD/3035"), "");
1656        assert_eq!(extract_group_path_key("SG4/SG8/SEQ/6350"), "SG4/SG8");
1657    }
1658
1659    #[test]
1660    fn test_absent_optional_group_no_missing_field_error() {
1661        // SG3 is optional ("Kann"). If SG3 is absent, its children CTA/3139
1662        // and CTA/C056/3412 should NOT produce AHB001 errors.
1663        use mig_types::navigator::GroupNavigator;
1664
1665        struct NavWithoutSG3;
1666        impl GroupNavigator for NavWithoutSG3 {
1667            fn find_segments_in_group(&self, _: &str, _: &[&str], _: usize) -> Vec<OwnedSegment> {
1668                vec![]
1669            }
1670            fn find_segments_with_qualifier_in_group(
1671                &self,
1672                _: &str,
1673                _: usize,
1674                _: &str,
1675                _: &[&str],
1676                _: usize,
1677            ) -> Vec<OwnedSegment> {
1678                vec![]
1679            }
1680            fn group_instance_count(&self, group_path: &[&str]) -> usize {
1681                match group_path {
1682                    ["SG2"] => 2,        // two NAD groups present
1683                    ["SG2", "SG3"] => 0, // SG3 is absent
1684                    _ => 0,
1685                }
1686            }
1687        }
1688
1689        let evaluator = MockEvaluator::new(vec![]);
1690        let validator = EdifactValidator::new(evaluator);
1691        let external = NoOpExternalProvider;
1692        let nav = NavWithoutSG3;
1693
1694        // Only NAD segments present, no CTA
1695        let segments = vec![
1696            OwnedSegment {
1697                id: "NAD".into(),
1698                elements: vec![vec!["MS".into()]],
1699                segment_number: 3,
1700            },
1701            OwnedSegment {
1702                id: "NAD".into(),
1703                elements: vec![vec!["MR".into()]],
1704                segment_number: 4,
1705            },
1706        ];
1707
1708        let workflow = AhbWorkflow {
1709            pruefidentifikator: "55001".to_string(),
1710            description: "Test".to_string(),
1711            communication_direction: None,
1712            fields: vec![
1713                AhbFieldRule {
1714                    segment_path: "SG2/SG3/CTA/3139".to_string(),
1715                    name: "Funktion des Ansprechpartners, Code".to_string(),
1716                    ahb_status: "Muss".to_string(),
1717                    codes: vec![],
1718                    parent_group_ahb_status: None,
1719                    ..Default::default()
1720                },
1721                AhbFieldRule {
1722                    segment_path: "SG2/SG3/CTA/C056/3412".to_string(),
1723                    name: "Name vom Ansprechpartner".to_string(),
1724                    ahb_status: "X".to_string(),
1725                    codes: vec![],
1726                    parent_group_ahb_status: None,
1727                    ..Default::default()
1728                },
1729            ],
1730            ub_definitions: HashMap::new(),
1731        };
1732
1733        let report = validator.validate_with_navigator(
1734            &segments,
1735            &workflow,
1736            &external,
1737            ValidationLevel::Conditions,
1738            &nav,
1739        );
1740
1741        let ahb_errors: Vec<_> = report
1742            .by_category(ValidationCategory::Ahb)
1743            .filter(|i| i.severity == Severity::Error)
1744            .collect();
1745        assert!(
1746            ahb_errors.is_empty(),
1747            "Expected no AHB001 errors when SG3 is absent, got: {:?}",
1748            ahb_errors
1749        );
1750    }
1751
1752    #[test]
1753    fn test_present_group_still_checks_mandatory_fields() {
1754        // If SG3 IS present but CTA is missing within it → AHB001 error.
1755        use mig_types::navigator::GroupNavigator;
1756
1757        struct NavWithSG3;
1758        impl GroupNavigator for NavWithSG3 {
1759            fn find_segments_in_group(&self, _: &str, _: &[&str], _: usize) -> Vec<OwnedSegment> {
1760                vec![]
1761            }
1762            fn find_segments_with_qualifier_in_group(
1763                &self,
1764                _: &str,
1765                _: usize,
1766                _: &str,
1767                _: &[&str],
1768                _: usize,
1769            ) -> Vec<OwnedSegment> {
1770                vec![]
1771            }
1772            fn group_instance_count(&self, group_path: &[&str]) -> usize {
1773                match group_path {
1774                    ["SG2"] => 1,
1775                    ["SG2", "SG3"] => 1, // SG3 is present
1776                    _ => 0,
1777                }
1778            }
1779        }
1780
1781        let evaluator = MockEvaluator::new(vec![]);
1782        let validator = EdifactValidator::new(evaluator);
1783        let external = NoOpExternalProvider;
1784        let nav = NavWithSG3;
1785
1786        // SG3 is present (nav says 1 instance) but CTA is not in flat segments
1787        let segments = vec![OwnedSegment {
1788            id: "NAD".into(),
1789            elements: vec![vec!["MS".into()]],
1790            segment_number: 3,
1791        }];
1792
1793        let workflow = AhbWorkflow {
1794            pruefidentifikator: "55001".to_string(),
1795            description: "Test".to_string(),
1796            communication_direction: None,
1797            fields: vec![AhbFieldRule {
1798                segment_path: "SG2/SG3/CTA/3139".to_string(),
1799                name: "Funktion des Ansprechpartners, Code".to_string(),
1800                ahb_status: "Muss".to_string(),
1801                codes: vec![],
1802                parent_group_ahb_status: None,
1803                ..Default::default()
1804            }],
1805            ub_definitions: HashMap::new(),
1806        };
1807
1808        let report = validator.validate_with_navigator(
1809            &segments,
1810            &workflow,
1811            &external,
1812            ValidationLevel::Conditions,
1813            &nav,
1814        );
1815
1816        let ahb_errors: Vec<_> = report
1817            .by_category(ValidationCategory::Ahb)
1818            .filter(|i| i.severity == Severity::Error)
1819            .collect();
1820        assert_eq!(
1821            ahb_errors.len(),
1822            1,
1823            "Expected AHB001 error when SG3 is present but CTA missing"
1824        );
1825        assert!(ahb_errors[0].message.contains("CTA"));
1826    }
1827
1828    #[test]
1829    fn test_missing_qualifier_with_navigator_is_detected() {
1830        // NAD+MS is in SG2 but NAD+MR is missing. With a navigator that
1831        // reports SG2 has 1 instance, the missing MR must still be flagged.
1832        use mig_types::navigator::GroupNavigator;
1833
1834        struct NavWithSG2;
1835        impl GroupNavigator for NavWithSG2 {
1836            fn find_segments_in_group(
1837                &self,
1838                segment_id: &str,
1839                group_path: &[&str],
1840                instance_index: usize,
1841            ) -> Vec<OwnedSegment> {
1842                if segment_id == "NAD" && group_path == ["SG2"] && instance_index == 0 {
1843                    vec![OwnedSegment {
1844                        id: "NAD".into(),
1845                        elements: vec![vec!["MS".into()]],
1846                        segment_number: 3,
1847                    }]
1848                } else {
1849                    vec![]
1850                }
1851            }
1852            fn find_segments_with_qualifier_in_group(
1853                &self,
1854                _: &str,
1855                _: usize,
1856                _: &str,
1857                _: &[&str],
1858                _: usize,
1859            ) -> Vec<OwnedSegment> {
1860                vec![]
1861            }
1862            fn group_instance_count(&self, group_path: &[&str]) -> usize {
1863                match group_path {
1864                    ["SG2"] => 1,
1865                    _ => 0,
1866                }
1867            }
1868        }
1869
1870        let evaluator = MockEvaluator::new(vec![]);
1871        let validator = EdifactValidator::new(evaluator);
1872        let external = NoOpExternalProvider;
1873        let nav = NavWithSG2;
1874
1875        let segments = vec![OwnedSegment {
1876            id: "NAD".into(),
1877            elements: vec![vec!["MS".into()]],
1878            segment_number: 3,
1879        }];
1880
1881        let workflow = AhbWorkflow {
1882            pruefidentifikator: "55001".to_string(),
1883            description: "Test".to_string(),
1884            communication_direction: None,
1885            fields: vec![
1886                AhbFieldRule {
1887                    segment_path: "SG2/NAD/3035".to_string(),
1888                    name: "Absender".to_string(),
1889                    ahb_status: "X".to_string(),
1890                    codes: vec![AhbCodeRule {
1891                        value: "MS".to_string(),
1892                        description: "Absender".to_string(),
1893                        ahb_status: "X".to_string(),
1894                    }],
1895                    parent_group_ahb_status: None,
1896                    ..Default::default()
1897                },
1898                AhbFieldRule {
1899                    segment_path: "SG2/NAD/3035".to_string(),
1900                    name: "Empfaenger".to_string(),
1901                    ahb_status: "Muss".to_string(),
1902                    codes: vec![AhbCodeRule {
1903                        value: "MR".to_string(),
1904                        description: "Empfaenger".to_string(),
1905                        ahb_status: "X".to_string(),
1906                    }],
1907                    parent_group_ahb_status: None,
1908                    ..Default::default()
1909                },
1910            ],
1911            ub_definitions: HashMap::new(),
1912        };
1913
1914        let report = validator.validate_with_navigator(
1915            &segments,
1916            &workflow,
1917            &external,
1918            ValidationLevel::Conditions,
1919            &nav,
1920        );
1921
1922        let ahb_errors: Vec<_> = report
1923            .by_category(ValidationCategory::Ahb)
1924            .filter(|i| i.severity == Severity::Error)
1925            .collect();
1926        assert_eq!(
1927            ahb_errors.len(),
1928            1,
1929            "Expected AHB001 for missing NAD+MR even with navigator, got: {:?}",
1930            ahb_errors
1931        );
1932        assert!(ahb_errors[0].message.contains("Empfaenger"));
1933    }
1934
1935    #[test]
1936    fn test_optional_group_variant_absent_no_error() {
1937        // SG5 is "Kann" (optional) with LOC+Z16 present but LOC+Z17 absent.
1938        // Field rules for LOC/3227 with Z17 and its children should NOT error
1939        // because the parent group is optional and the variant is absent.
1940        // Meanwhile, SG2 is "Muss" — missing NAD+MR MUST still error.
1941        use mig_types::navigator::GroupNavigator;
1942
1943        struct TestNav;
1944        impl GroupNavigator for TestNav {
1945            fn find_segments_in_group(
1946                &self,
1947                segment_id: &str,
1948                group_path: &[&str],
1949                instance_index: usize,
1950            ) -> Vec<OwnedSegment> {
1951                match (segment_id, group_path, instance_index) {
1952                    ("LOC", ["SG4", "SG5"], 0) => vec![OwnedSegment {
1953                        id: "LOC".into(),
1954                        elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
1955                        segment_number: 10,
1956                    }],
1957                    ("NAD", ["SG2"], 0) => vec![OwnedSegment {
1958                        id: "NAD".into(),
1959                        elements: vec![vec!["MS".into()]],
1960                        segment_number: 3,
1961                    }],
1962                    _ => vec![],
1963                }
1964            }
1965            fn find_segments_with_qualifier_in_group(
1966                &self,
1967                _: &str,
1968                _: usize,
1969                _: &str,
1970                _: &[&str],
1971                _: usize,
1972            ) -> Vec<OwnedSegment> {
1973                vec![]
1974            }
1975            fn group_instance_count(&self, group_path: &[&str]) -> usize {
1976                match group_path {
1977                    ["SG2"] => 1,
1978                    ["SG4"] => 1,
1979                    ["SG4", "SG5"] => 1, // only Z16 instance
1980                    _ => 0,
1981                }
1982            }
1983        }
1984
1985        let evaluator = MockEvaluator::new(vec![]);
1986        let validator = EdifactValidator::new(evaluator);
1987        let external = NoOpExternalProvider;
1988        let nav = TestNav;
1989
1990        let segments = vec![
1991            OwnedSegment {
1992                id: "NAD".into(),
1993                elements: vec![vec!["MS".into()]],
1994                segment_number: 3,
1995            },
1996            OwnedSegment {
1997                id: "LOC".into(),
1998                elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
1999                segment_number: 10,
2000            },
2001        ];
2002
2003        let workflow = AhbWorkflow {
2004            pruefidentifikator: "55001".to_string(),
2005            description: "Test".to_string(),
2006            communication_direction: None,
2007            fields: vec![
2008                // SG2 "Muss" — NAD+MS present, NAD+MR missing → should error
2009                AhbFieldRule {
2010                    segment_path: "SG2/NAD/3035".to_string(),
2011                    name: "Absender".to_string(),
2012                    ahb_status: "X".to_string(),
2013                    codes: vec![AhbCodeRule {
2014                        value: "MS".to_string(),
2015                        description: "Absender".to_string(),
2016                        ahb_status: "X".to_string(),
2017                    }],
2018                    parent_group_ahb_status: Some("Muss".to_string()),
2019                    ..Default::default()
2020                },
2021                AhbFieldRule {
2022                    segment_path: "SG2/NAD/3035".to_string(),
2023                    name: "Empfaenger".to_string(),
2024                    ahb_status: "Muss".to_string(),
2025                    codes: vec![AhbCodeRule {
2026                        value: "MR".to_string(),
2027                        description: "Empfaenger".to_string(),
2028                        ahb_status: "X".to_string(),
2029                    }],
2030                    parent_group_ahb_status: Some("Muss".to_string()),
2031                    ..Default::default()
2032                },
2033                // SG5 "Kann" — LOC+Z16 present, LOC+Z17 absent → should NOT error
2034                AhbFieldRule {
2035                    segment_path: "SG4/SG5/LOC/3227".to_string(),
2036                    name: "Ortsangabe, Qualifier (Z16)".to_string(),
2037                    ahb_status: "X".to_string(),
2038                    codes: vec![AhbCodeRule {
2039                        value: "Z16".to_string(),
2040                        description: "Marktlokation".to_string(),
2041                        ahb_status: "X".to_string(),
2042                    }],
2043                    parent_group_ahb_status: Some("Kann".to_string()),
2044                    ..Default::default()
2045                },
2046                AhbFieldRule {
2047                    segment_path: "SG4/SG5/LOC/3227".to_string(),
2048                    name: "Ortsangabe, Qualifier (Z17)".to_string(),
2049                    ahb_status: "Muss".to_string(),
2050                    codes: vec![AhbCodeRule {
2051                        value: "Z17".to_string(),
2052                        description: "Messlokation".to_string(),
2053                        ahb_status: "X".to_string(),
2054                    }],
2055                    parent_group_ahb_status: Some("Kann".to_string()),
2056                    ..Default::default()
2057                },
2058            ],
2059            ub_definitions: HashMap::new(),
2060        };
2061
2062        let report = validator.validate_with_navigator(
2063            &segments,
2064            &workflow,
2065            &external,
2066            ValidationLevel::Conditions,
2067            &nav,
2068        );
2069
2070        let ahb_errors: Vec<_> = report
2071            .by_category(ValidationCategory::Ahb)
2072            .filter(|i| i.severity == Severity::Error)
2073            .collect();
2074
2075        // Exactly 1 error: NAD+MR missing (mandatory group)
2076        // LOC+Z17 should NOT error (optional group variant absent)
2077        assert_eq!(
2078            ahb_errors.len(),
2079            1,
2080            "Expected only AHB001 for missing NAD+MR, got: {:?}",
2081            ahb_errors
2082        );
2083        assert!(
2084            ahb_errors[0].message.contains("Empfaenger"),
2085            "Error should be for missing NAD+MR (Empfaenger)"
2086        );
2087    }
2088
2089    #[test]
2090    fn test_conditional_group_variant_absent_no_error() {
2091        // Real-world scenario: SG5 "Messlokation" has AHB_Status="Soll [165]".
2092        // Condition [165] evaluates to False → LOC+Z17 should NOT error.
2093        // SG5 "Marktlokation" has AHB_Status="Muss [2061]".
2094        // Condition [2061] evaluates to True → LOC+Z16 is present → no error.
2095        use mig_types::navigator::GroupNavigator;
2096
2097        struct TestNav;
2098        impl GroupNavigator for TestNav {
2099            fn find_segments_in_group(
2100                &self,
2101                segment_id: &str,
2102                group_path: &[&str],
2103                instance_index: usize,
2104            ) -> Vec<OwnedSegment> {
2105                if segment_id == "LOC" && group_path == ["SG4", "SG5"] && instance_index == 0 {
2106                    vec![OwnedSegment {
2107                        id: "LOC".into(),
2108                        elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
2109                        segment_number: 10,
2110                    }]
2111                } else {
2112                    vec![]
2113                }
2114            }
2115            fn find_segments_with_qualifier_in_group(
2116                &self,
2117                _: &str,
2118                _: usize,
2119                _: &str,
2120                _: &[&str],
2121                _: usize,
2122            ) -> Vec<OwnedSegment> {
2123                vec![]
2124            }
2125            fn group_instance_count(&self, group_path: &[&str]) -> usize {
2126                match group_path {
2127                    ["SG4"] => 1,
2128                    ["SG4", "SG5"] => 1, // only Z16 instance
2129                    _ => 0,
2130                }
2131            }
2132        }
2133
2134        // Condition 165 → False (Messlokation not required)
2135        // Condition 2061 → True (Marktlokation required)
2136        let evaluator = MockEvaluator::new(vec![(165, CR::False), (2061, CR::True)]);
2137        let validator = EdifactValidator::new(evaluator);
2138        let external = NoOpExternalProvider;
2139        let nav = TestNav;
2140
2141        let segments = vec![OwnedSegment {
2142            id: "LOC".into(),
2143            elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
2144            segment_number: 10,
2145        }];
2146
2147        let workflow = AhbWorkflow {
2148            pruefidentifikator: "55001".to_string(),
2149            description: "Test".to_string(),
2150            communication_direction: None,
2151            fields: vec![
2152                // SG5 "Muss [2061]" with [2061]=True → LOC+Z16 required, present → OK
2153                AhbFieldRule {
2154                    segment_path: "SG4/SG5/LOC/3227".to_string(),
2155                    name: "Ortsangabe, Qualifier (Z16)".to_string(),
2156                    ahb_status: "X".to_string(),
2157                    codes: vec![AhbCodeRule {
2158                        value: "Z16".to_string(),
2159                        description: "Marktlokation".to_string(),
2160                        ahb_status: "X".to_string(),
2161                    }],
2162                    parent_group_ahb_status: Some("Muss [2061]".to_string()),
2163                    ..Default::default()
2164                },
2165                // SG5 "Soll [165]" with [165]=False → LOC+Z17 NOT required → skip
2166                AhbFieldRule {
2167                    segment_path: "SG4/SG5/LOC/3227".to_string(),
2168                    name: "Ortsangabe, Qualifier (Z17)".to_string(),
2169                    ahb_status: "X".to_string(),
2170                    codes: vec![AhbCodeRule {
2171                        value: "Z17".to_string(),
2172                        description: "Messlokation".to_string(),
2173                        ahb_status: "X".to_string(),
2174                    }],
2175                    parent_group_ahb_status: Some("Soll [165]".to_string()),
2176                    ..Default::default()
2177                },
2178            ],
2179            ub_definitions: HashMap::new(),
2180        };
2181
2182        let report = validator.validate_with_navigator(
2183            &segments,
2184            &workflow,
2185            &external,
2186            ValidationLevel::Conditions,
2187            &nav,
2188        );
2189
2190        let ahb_errors: Vec<_> = report
2191            .by_category(ValidationCategory::Ahb)
2192            .filter(|i| i.severity == Severity::Error)
2193            .collect();
2194
2195        // Zero errors: Z16 is present, and Z17's group condition is False
2196        assert!(
2197            ahb_errors.is_empty(),
2198            "Expected no errors when conditional group variant [165]=False, got: {:?}",
2199            ahb_errors
2200        );
2201    }
2202
2203    #[test]
2204    fn test_conditional_group_variant_unknown_no_error() {
2205        // When a parent group condition evaluates to Unknown (unimplemented
2206        // condition), child fields should NOT produce mandatory-missing errors.
2207        // The group-level entry itself will produce an Info "condition unknown".
2208
2209        // Condition 165 is NOT in the evaluator → returns Unknown
2210        let evaluator = MockEvaluator::new(vec![]);
2211        let validator = EdifactValidator::new(evaluator);
2212        let external = NoOpExternalProvider;
2213
2214        let workflow = AhbWorkflow {
2215            pruefidentifikator: "55001".to_string(),
2216            description: "Test".to_string(),
2217            communication_direction: None,
2218            fields: vec![AhbFieldRule {
2219                segment_path: "SG4/SG5/LOC/3227".to_string(),
2220                name: "Ortsangabe, Qualifier (Z17)".to_string(),
2221                ahb_status: "X".to_string(),
2222                codes: vec![AhbCodeRule {
2223                    value: "Z17".to_string(),
2224                    description: "Messlokation".to_string(),
2225                    ahb_status: "X".to_string(),
2226                }],
2227                parent_group_ahb_status: Some("Soll [165]".to_string()),
2228                ..Default::default()
2229            }],
2230            ub_definitions: HashMap::new(),
2231        };
2232
2233        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
2234
2235        let ahb_errors: Vec<_> = report
2236            .by_category(ValidationCategory::Ahb)
2237            .filter(|i| i.severity == Severity::Error)
2238            .collect();
2239
2240        // No errors: parent group condition [165] is Unknown → skip child fields
2241        assert!(
2242            ahb_errors.is_empty(),
2243            "Expected no errors when parent group condition is Unknown, got: {:?}",
2244            ahb_errors
2245        );
2246    }
2247
2248    #[test]
2249    fn test_segment_absent_within_present_group_no_error() {
2250        // MSCONS scenario: SG10 has QTY (entry) + optional STS segments.
2251        // SG10 is present (QTY+220 exists) but STS is absent.
2252        // Fields under STS should NOT produce AHB001 errors.
2253        use mig_types::navigator::GroupNavigator;
2254
2255        struct TestNav;
2256        impl GroupNavigator for TestNav {
2257            fn find_segments_in_group(
2258                &self,
2259                segment_id: &str,
2260                group_path: &[&str],
2261                instance_index: usize,
2262            ) -> Vec<OwnedSegment> {
2263                // SG10 has QTY but no STS
2264                if segment_id == "QTY"
2265                    && group_path == ["SG5", "SG6", "SG9", "SG10"]
2266                    && instance_index == 0
2267                {
2268                    vec![OwnedSegment {
2269                        id: "QTY".into(),
2270                        elements: vec![vec!["220".into(), "0".into()]],
2271                        segment_number: 14,
2272                    }]
2273                } else {
2274                    vec![]
2275                }
2276            }
2277            fn find_segments_with_qualifier_in_group(
2278                &self,
2279                _: &str,
2280                _: usize,
2281                _: &str,
2282                _: &[&str],
2283                _: usize,
2284            ) -> Vec<OwnedSegment> {
2285                vec![]
2286            }
2287            fn group_instance_count(&self, group_path: &[&str]) -> usize {
2288                match group_path {
2289                    ["SG5"] => 1,
2290                    ["SG5", "SG6"] => 1,
2291                    ["SG5", "SG6", "SG9"] => 1,
2292                    ["SG5", "SG6", "SG9", "SG10"] => 1,
2293                    _ => 0,
2294                }
2295            }
2296            fn has_any_segment_in_group(&self, group_path: &[&str], instance_index: usize) -> bool {
2297                // SG10 instance 0 has QTY (the entry segment)
2298                group_path == ["SG5", "SG6", "SG9", "SG10"] && instance_index == 0
2299            }
2300        }
2301
2302        let evaluator = MockEvaluator::all_true(&[]);
2303        let validator = EdifactValidator::new(evaluator);
2304        let external = NoOpExternalProvider;
2305        let nav = TestNav;
2306
2307        let segments = vec![OwnedSegment {
2308            id: "QTY".into(),
2309            elements: vec![vec!["220".into(), "0".into()]],
2310            segment_number: 14,
2311        }];
2312
2313        let workflow = AhbWorkflow {
2314            pruefidentifikator: "13017".to_string(),
2315            description: "Test".to_string(),
2316            communication_direction: None,
2317            fields: vec![
2318                // STS/C601/9015 — mandatory field under STS, but STS is absent from SG10
2319                AhbFieldRule {
2320                    segment_path: "SG5/SG6/SG9/SG10/STS/C601/9015".to_string(),
2321                    name: "Statuskategorie, Code".to_string(),
2322                    ahb_status: "X".to_string(),
2323                    codes: vec![],
2324                    parent_group_ahb_status: Some("Muss".to_string()),
2325                    ..Default::default()
2326                },
2327                // STS/C556/9013 — another field under STS
2328                AhbFieldRule {
2329                    segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
2330                    name: "Statusanlaß, Code".to_string(),
2331                    ahb_status: "X [5]".to_string(),
2332                    codes: vec![],
2333                    parent_group_ahb_status: Some("Muss".to_string()),
2334                    ..Default::default()
2335                },
2336            ],
2337            ub_definitions: HashMap::new(),
2338        };
2339
2340        let report = validator.validate_with_navigator(
2341            &segments,
2342            &workflow,
2343            &external,
2344            ValidationLevel::Conditions,
2345            &nav,
2346        );
2347
2348        let ahb_errors: Vec<_> = report
2349            .by_category(ValidationCategory::Ahb)
2350            .filter(|i| i.severity == Severity::Error)
2351            .collect();
2352
2353        assert!(
2354            ahb_errors.is_empty(),
2355            "Expected no AHB001 errors when STS segment is absent from SG10, got: {:?}",
2356            ahb_errors
2357        );
2358    }
2359
2360    #[test]
2361    fn test_group_scoped_code_validation_with_navigator() {
2362        // With a navigator, SG2/NAD is checked against {MS, MR} only,
2363        // and SG4/SG12/NAD is checked against {Z04, Z09} only.
2364        // NAD+MT in SG2 → error with allowed [MR, MS] (not the full union).
2365        use mig_types::navigator::GroupNavigator;
2366
2367        struct TestNav;
2368        impl GroupNavigator for TestNav {
2369            fn find_segments_in_group(
2370                &self,
2371                segment_id: &str,
2372                group_path: &[&str],
2373                _instance_index: usize,
2374            ) -> Vec<OwnedSegment> {
2375                if segment_id != "NAD" {
2376                    return vec![];
2377                }
2378                match group_path {
2379                    ["SG2"] => vec![
2380                        OwnedSegment {
2381                            id: "NAD".into(),
2382                            elements: vec![vec!["MS".into()]],
2383                            segment_number: 3,
2384                        },
2385                        OwnedSegment {
2386                            id: "NAD".into(),
2387                            elements: vec![vec!["MT".into()]], // invalid in SG2
2388                            segment_number: 4,
2389                        },
2390                    ],
2391                    ["SG4", "SG12"] => vec![
2392                        OwnedSegment {
2393                            id: "NAD".into(),
2394                            elements: vec![vec!["Z04".into()]],
2395                            segment_number: 20,
2396                        },
2397                        OwnedSegment {
2398                            id: "NAD".into(),
2399                            elements: vec![vec!["Z09".into()]],
2400                            segment_number: 21,
2401                        },
2402                    ],
2403                    _ => vec![],
2404                }
2405            }
2406            fn find_segments_with_qualifier_in_group(
2407                &self,
2408                _: &str,
2409                _: usize,
2410                _: &str,
2411                _: &[&str],
2412                _: usize,
2413            ) -> Vec<OwnedSegment> {
2414                vec![]
2415            }
2416            fn group_instance_count(&self, group_path: &[&str]) -> usize {
2417                match group_path {
2418                    ["SG2"] | ["SG4", "SG12"] => 1,
2419                    _ => 0,
2420                }
2421            }
2422        }
2423
2424        let evaluator = MockEvaluator::new(vec![]);
2425        let validator = EdifactValidator::new(evaluator);
2426        let external = NoOpExternalProvider;
2427        let nav = TestNav;
2428
2429        let workflow = AhbWorkflow {
2430            pruefidentifikator: "55001".to_string(),
2431            description: "Test".to_string(),
2432            communication_direction: None,
2433            fields: vec![
2434                AhbFieldRule {
2435                    segment_path: "SG2/NAD/3035".to_string(),
2436                    name: "Absender".to_string(),
2437                    ahb_status: "X".to_string(),
2438                    codes: vec![AhbCodeRule {
2439                        value: "MS".to_string(),
2440                        description: "Absender".to_string(),
2441                        ahb_status: "X".to_string(),
2442                    }],
2443                    parent_group_ahb_status: None,
2444                    ..Default::default()
2445                },
2446                AhbFieldRule {
2447                    segment_path: "SG2/NAD/3035".to_string(),
2448                    name: "Empfaenger".to_string(),
2449                    ahb_status: "X".to_string(),
2450                    codes: vec![AhbCodeRule {
2451                        value: "MR".to_string(),
2452                        description: "Empfaenger".to_string(),
2453                        ahb_status: "X".to_string(),
2454                    }],
2455                    parent_group_ahb_status: None,
2456                    ..Default::default()
2457                },
2458                AhbFieldRule {
2459                    segment_path: "SG4/SG12/NAD/3035".to_string(),
2460                    name: "Anschlussnutzer".to_string(),
2461                    ahb_status: "X".to_string(),
2462                    codes: vec![AhbCodeRule {
2463                        value: "Z04".to_string(),
2464                        description: "Anschlussnutzer".to_string(),
2465                        ahb_status: "X".to_string(),
2466                    }],
2467                    parent_group_ahb_status: None,
2468                    ..Default::default()
2469                },
2470                AhbFieldRule {
2471                    segment_path: "SG4/SG12/NAD/3035".to_string(),
2472                    name: "Korrespondenzanschrift".to_string(),
2473                    ahb_status: "X".to_string(),
2474                    codes: vec![AhbCodeRule {
2475                        value: "Z09".to_string(),
2476                        description: "Korrespondenzanschrift".to_string(),
2477                        ahb_status: "X".to_string(),
2478                    }],
2479                    parent_group_ahb_status: None,
2480                    ..Default::default()
2481                },
2482            ],
2483            ub_definitions: HashMap::new(),
2484        };
2485
2486        // All segments flat (for condition evaluation), navigator provides group scope.
2487        let all_segments = vec![
2488            OwnedSegment {
2489                id: "NAD".into(),
2490                elements: vec![vec!["MS".into()]],
2491                segment_number: 3,
2492            },
2493            OwnedSegment {
2494                id: "NAD".into(),
2495                elements: vec![vec!["MT".into()]],
2496                segment_number: 4,
2497            },
2498            OwnedSegment {
2499                id: "NAD".into(),
2500                elements: vec![vec!["Z04".into()]],
2501                segment_number: 20,
2502            },
2503            OwnedSegment {
2504                id: "NAD".into(),
2505                elements: vec![vec!["Z09".into()]],
2506                segment_number: 21,
2507            },
2508        ];
2509
2510        let report = validator.validate_with_navigator(
2511            &all_segments,
2512            &workflow,
2513            &external,
2514            ValidationLevel::Conditions,
2515            &nav,
2516        );
2517
2518        let code_errors: Vec<_> = report
2519            .by_category(ValidationCategory::Code)
2520            .filter(|i| i.severity == Severity::Error)
2521            .collect();
2522
2523        // Only one error: MT in SG2 (not allowed in {MS, MR}).
2524        // Z04 and Z09 are NOT checked against {MS, MR} — group-scoped.
2525        assert_eq!(
2526            code_errors.len(),
2527            1,
2528            "Expected exactly one COD002 error for MT in SG2, got: {:?}",
2529            code_errors
2530        );
2531        assert!(code_errors[0].message.contains("MT"));
2532        // Error should show only SG2's allowed codes, not the full union
2533        assert!(code_errors[0].message.contains("MR"));
2534        assert!(code_errors[0].message.contains("MS"));
2535        assert!(
2536            !code_errors[0].message.contains("Z04"),
2537            "SG4/SG12 codes should not leak into SG2 error"
2538        );
2539        // Field path should include the group
2540        assert!(
2541            code_errors[0]
2542                .field_path
2543                .as_deref()
2544                .unwrap_or("")
2545                .contains("SG2"),
2546            "Error field_path should reference SG2, got: {:?}",
2547            code_errors[0].field_path
2548        );
2549    }
2550
2551    // === Package cardinality tests ===
2552
2553    #[test]
2554    fn test_package_cardinality_within_bounds() {
2555        // 1 code present from package [4P0..1], max=1 -> OK
2556        let evaluator = MockEvaluator::all_true(&[]);
2557        let validator = EdifactValidator::new(evaluator);
2558        let external = NoOpExternalProvider;
2559
2560        let segments = vec![OwnedSegment {
2561            id: "STS".into(),
2562            elements: vec![
2563                vec!["Z33".into()], // element[0]
2564                vec![],             // element[1]
2565                vec!["E01".into()], // element[2], component[0]
2566            ],
2567            segment_number: 5,
2568        }];
2569
2570        let workflow = AhbWorkflow {
2571            pruefidentifikator: "13017".to_string(),
2572            description: "Test".to_string(),
2573            communication_direction: None,
2574            ub_definitions: HashMap::new(),
2575            fields: vec![AhbFieldRule {
2576                segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
2577                name: "Statusanlaß, Code".to_string(),
2578                ahb_status: "X".to_string(),
2579                element_index: Some(2),
2580                component_index: Some(0),
2581                codes: vec![
2582                    AhbCodeRule {
2583                        value: "E01".into(),
2584                        description: "Code 1".into(),
2585                        ahb_status: "X [4P0..1]".into(),
2586                    },
2587                    AhbCodeRule {
2588                        value: "E02".into(),
2589                        description: "Code 2".into(),
2590                        ahb_status: "X [4P0..1]".into(),
2591                    },
2592                ],
2593                parent_group_ahb_status: Some("Muss".to_string()),
2594            }],
2595        };
2596
2597        let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
2598        let pkg_errors: Vec<_> = report
2599            .by_category(ValidationCategory::Ahb)
2600            .filter(|i| i.code == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
2601            .collect();
2602        assert!(
2603            pkg_errors.is_empty(),
2604            "1 code within [4P0..1] bounds — no error expected, got: {:?}",
2605            pkg_errors
2606        );
2607    }
2608
2609    #[test]
2610    fn test_package_cardinality_zero_present_min_zero() {
2611        // No codes from the package present, min=0 -> OK
2612        let evaluator = MockEvaluator::all_true(&[]);
2613        let validator = EdifactValidator::new(evaluator);
2614        let external = NoOpExternalProvider;
2615
2616        let segments = vec![OwnedSegment {
2617            id: "STS".into(),
2618            elements: vec![
2619                vec!["Z33".into()],
2620                vec![],
2621                vec!["X99".into()], // X99 not in package
2622            ],
2623            segment_number: 5,
2624        }];
2625
2626        let workflow = AhbWorkflow {
2627            pruefidentifikator: "13017".to_string(),
2628            description: "Test".to_string(),
2629            communication_direction: None,
2630            ub_definitions: HashMap::new(),
2631            fields: vec![AhbFieldRule {
2632                segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
2633                name: "Statusanlaß, Code".to_string(),
2634                ahb_status: "X".to_string(),
2635                element_index: Some(2),
2636                component_index: Some(0),
2637                codes: vec![
2638                    AhbCodeRule {
2639                        value: "E01".into(),
2640                        description: "Code 1".into(),
2641                        ahb_status: "X [4P0..1]".into(),
2642                    },
2643                    AhbCodeRule {
2644                        value: "E02".into(),
2645                        description: "Code 2".into(),
2646                        ahb_status: "X [4P0..1]".into(),
2647                    },
2648                ],
2649                parent_group_ahb_status: Some("Muss".to_string()),
2650            }],
2651        };
2652
2653        let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
2654        let pkg_errors: Vec<_> = report
2655            .by_category(ValidationCategory::Ahb)
2656            .filter(|i| i.code == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
2657            .collect();
2658        assert!(
2659            pkg_errors.is_empty(),
2660            "0 codes, min=0 — no error expected, got: {:?}",
2661            pkg_errors
2662        );
2663    }
2664
2665    #[test]
2666    fn test_package_cardinality_too_many() {
2667        // 2 codes present from package [4P0..1], max=1 -> ERROR
2668        let evaluator = MockEvaluator::all_true(&[]);
2669        let validator = EdifactValidator::new(evaluator);
2670        let external = NoOpExternalProvider;
2671
2672        // Two STS segments, each with a different package code
2673        let segments = vec![
2674            OwnedSegment {
2675                id: "STS".into(),
2676                elements: vec![vec!["Z33".into()], vec![], vec!["E01".into()]],
2677                segment_number: 5,
2678            },
2679            OwnedSegment {
2680                id: "STS".into(),
2681                elements: vec![vec!["Z33".into()], vec![], vec!["E02".into()]],
2682                segment_number: 6,
2683            },
2684        ];
2685
2686        let workflow = AhbWorkflow {
2687            pruefidentifikator: "13017".to_string(),
2688            description: "Test".to_string(),
2689            communication_direction: None,
2690            ub_definitions: HashMap::new(),
2691            fields: vec![AhbFieldRule {
2692                segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
2693                name: "Statusanlaß, Code".to_string(),
2694                ahb_status: "X".to_string(),
2695                element_index: Some(2),
2696                component_index: Some(0),
2697                codes: vec![
2698                    AhbCodeRule {
2699                        value: "E01".into(),
2700                        description: "Code 1".into(),
2701                        ahb_status: "X [4P0..1]".into(),
2702                    },
2703                    AhbCodeRule {
2704                        value: "E02".into(),
2705                        description: "Code 2".into(),
2706                        ahb_status: "X [4P0..1]".into(),
2707                    },
2708                ],
2709                parent_group_ahb_status: Some("Muss".to_string()),
2710            }],
2711        };
2712
2713        let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
2714        let pkg_errors: Vec<_> = report
2715            .by_category(ValidationCategory::Ahb)
2716            .filter(|i| i.code == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
2717            .collect();
2718        assert_eq!(
2719            pkg_errors.len(),
2720            1,
2721            "2 codes present, max=1 — expected 1 error, got: {:?}",
2722            pkg_errors
2723        );
2724        assert!(pkg_errors[0].message.contains("[4P0..1]"));
2725        assert_eq!(pkg_errors[0].actual_value.as_deref(), Some("2"));
2726        assert_eq!(pkg_errors[0].expected_value.as_deref(), Some("0..1"));
2727    }
2728
2729    #[test]
2730    fn test_package_cardinality_too_few() {
2731        // 0 codes present from package [5P1..3], min=1 -> ERROR
2732        let evaluator = MockEvaluator::all_true(&[]);
2733        let validator = EdifactValidator::new(evaluator);
2734        let external = NoOpExternalProvider;
2735
2736        let segments = vec![OwnedSegment {
2737            id: "STS".into(),
2738            elements: vec![
2739                vec!["Z33".into()],
2740                vec![],
2741                vec!["X99".into()], // not in package
2742            ],
2743            segment_number: 5,
2744        }];
2745
2746        let workflow = AhbWorkflow {
2747            pruefidentifikator: "13017".to_string(),
2748            description: "Test".to_string(),
2749            communication_direction: None,
2750            ub_definitions: HashMap::new(),
2751            fields: vec![AhbFieldRule {
2752                segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
2753                name: "Statusanlaß, Code".to_string(),
2754                ahb_status: "X".to_string(),
2755                element_index: Some(2),
2756                component_index: Some(0),
2757                codes: vec![
2758                    AhbCodeRule {
2759                        value: "E01".into(),
2760                        description: "Code 1".into(),
2761                        ahb_status: "X [5P1..3]".into(),
2762                    },
2763                    AhbCodeRule {
2764                        value: "E02".into(),
2765                        description: "Code 2".into(),
2766                        ahb_status: "X [5P1..3]".into(),
2767                    },
2768                    AhbCodeRule {
2769                        value: "E03".into(),
2770                        description: "Code 3".into(),
2771                        ahb_status: "X [5P1..3]".into(),
2772                    },
2773                ],
2774                parent_group_ahb_status: Some("Muss".to_string()),
2775            }],
2776        };
2777
2778        let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
2779        let pkg_errors: Vec<_> = report
2780            .by_category(ValidationCategory::Ahb)
2781            .filter(|i| i.code == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
2782            .collect();
2783        assert_eq!(
2784            pkg_errors.len(),
2785            1,
2786            "0 codes present, min=1 — expected 1 error, got: {:?}",
2787            pkg_errors
2788        );
2789        assert!(pkg_errors[0].message.contains("[5P1..3]"));
2790        assert_eq!(pkg_errors[0].actual_value.as_deref(), Some("0"));
2791        assert_eq!(pkg_errors[0].expected_value.as_deref(), Some("1..3"));
2792    }
2793
2794    #[test]
2795    fn test_package_cardinality_no_packages_in_workflow() {
2796        // Codes without package annotations -> no package errors
2797        let evaluator = MockEvaluator::all_true(&[]);
2798        let validator = EdifactValidator::new(evaluator);
2799        let external = NoOpExternalProvider;
2800
2801        let segments = vec![OwnedSegment {
2802            id: "STS".into(),
2803            elements: vec![vec!["E01".into()]],
2804            segment_number: 5,
2805        }];
2806
2807        let workflow = AhbWorkflow {
2808            pruefidentifikator: "13017".to_string(),
2809            description: "Test".to_string(),
2810            communication_direction: None,
2811            ub_definitions: HashMap::new(),
2812            fields: vec![AhbFieldRule {
2813                segment_path: "STS/9015".to_string(),
2814                name: "Status Code".to_string(),
2815                ahb_status: "X".to_string(),
2816                codes: vec![AhbCodeRule {
2817                    value: "E01".into(),
2818                    description: "Code 1".into(),
2819                    ahb_status: "X".into(),
2820                }],
2821                parent_group_ahb_status: Some("Muss".to_string()),
2822                ..Default::default()
2823            }],
2824        };
2825
2826        let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
2827        let pkg_errors: Vec<_> = report
2828            .by_category(ValidationCategory::Ahb)
2829            .filter(|i| i.code == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
2830            .collect();
2831        assert!(
2832            pkg_errors.is_empty(),
2833            "No packages in workflow — no errors expected"
2834        );
2835    }
2836
2837    #[test]
2838    fn test_package_cardinality_with_condition_and_package() {
2839        // Code status "X [901] [4P0..1]" has both condition and package
2840        let evaluator = MockEvaluator::all_true(&[901]);
2841        let validator = EdifactValidator::new(evaluator);
2842        let external = NoOpExternalProvider;
2843
2844        let segments = vec![OwnedSegment {
2845            id: "STS".into(),
2846            elements: vec![vec![], vec![], vec!["E01".into()]],
2847            segment_number: 5,
2848        }];
2849
2850        let workflow = AhbWorkflow {
2851            pruefidentifikator: "13017".to_string(),
2852            description: "Test".to_string(),
2853            communication_direction: None,
2854            ub_definitions: HashMap::new(),
2855            fields: vec![AhbFieldRule {
2856                segment_path: "SG10/STS/C556/9013".to_string(),
2857                name: "Code".to_string(),
2858                ahb_status: "X".to_string(),
2859                element_index: Some(2),
2860                component_index: Some(0),
2861                codes: vec![
2862                    AhbCodeRule {
2863                        value: "E01".into(),
2864                        description: "Code 1".into(),
2865                        ahb_status: "X [901] [4P0..1]".into(),
2866                    },
2867                    AhbCodeRule {
2868                        value: "E02".into(),
2869                        description: "Code 2".into(),
2870                        ahb_status: "X [901] [4P0..1]".into(),
2871                    },
2872                ],
2873                parent_group_ahb_status: Some("Muss".to_string()),
2874            }],
2875        };
2876
2877        let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
2878        let pkg_errors: Vec<_> = report
2879            .by_category(ValidationCategory::Ahb)
2880            .filter(|i| i.code == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
2881            .collect();
2882        assert!(
2883            pkg_errors.is_empty(),
2884            "1 code within [4P0..1] bounds — no error, got: {:?}",
2885            pkg_errors
2886        );
2887    }
2888}