Skip to main content

automapper_validation/validator/
validate.rs

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