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