Skip to main content

automapper_validation/validator/
validate.rs

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