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