Skip to main content

automapper_validation/eval/
context.rs

1//! Evaluation context for condition evaluation.
2
3use super::codelist::ArtikelIdCodelist;
4use super::ebd_cluster::EbdClusterLookup;
5use super::evaluator::{ConditionResult, ExternalConditionProvider};
6use mig_types::navigator::GroupNavigator;
7use mig_types::segment::OwnedSegment;
8
9/// Context passed to condition evaluators during evaluation.
10///
11/// Carries references to the transaction data and external condition
12/// provider needed to evaluate AHB conditions.
13pub struct EvaluationContext<'a> {
14    /// The Pruefidentifikator (e.g., "11001", "55001") that identifies
15    /// the specific AHB workflow being validated against.
16    pub pruefidentifikator: &'a str,
17
18    /// Provider for external conditions that depend on business context
19    /// outside the EDIFACT message.
20    pub external: &'a dyn ExternalConditionProvider,
21
22    /// Parsed EDIFACT segments for direct segment inspection by condition
23    /// evaluators. Conditions often need to check specific segment values.
24    pub segments: &'a [OwnedSegment],
25
26    /// Optional group navigator for group-scoped condition queries.
27    /// When None, group-scoped methods return empty / false / 0.
28    pub navigator: Option<&'a dyn GroupNavigator>,
29
30    /// The resolved value of the data element currently being validated.
31    ///
32    /// Set per-field during tree-based validation from `AhbNode.value`.
33    /// Format/value conditions (e.g., [931] "ZZZ=+00", [932] "HHMM=2200")
34    /// check this first instead of searching message-wide.
35    /// When None, conditions fall back to message-wide segment searches.
36    pub resolved_value: Option<&'a str>,
37
38    /// The full segment elements for the segment being validated.
39    ///
40    /// Allows cross-element access within the same segment (e.g., check
41    /// qualifier in element 0 while validating value in element 1).
42    /// When None, conditions fall back to message-wide segment searches.
43    pub resolved_segment: Option<&'a [Vec<String>]>,
44
45    /// BDEW Artikel-ID / Artikelnummer codelist, used by conditions like
46    /// [73] and [284] that reference "Codeliste der Gruppenartikel- und
47    /// Artikel-ID". Defaults to [`ArtikelIdCodelist::embedded`].
48    pub codelist: &'a ArtikelIdCodelist,
49
50    /// Per-EBD answer-code cluster lookup, used by conditions like
51    /// `[359]` / `[360]` / `[366]` / `[368]` that restrict STS response
52    /// codes to the Zustimmung or Ablehnung cluster of a specific EBD.
53    /// Defaults to [`EbdClusterLookup::embedded`].
54    pub ebd_clusters: &'a EbdClusterLookup,
55
56    /// Optional group scope — when Some, scope-aware helpers like
57    /// `scoped_find_segments` narrow lookups to that group instance.
58    /// When None, scoped helpers fall back to message-wide semantics.
59    pub scope: Option<GroupScope<'a>>,
60}
61
62/// A no-op group navigator that returns empty results for all queries.
63pub struct NoOpGroupNavigator;
64
65/// A scope identifying a specific group instance (e.g., the 3rd SG8 under SG4).
66///
67/// When attached to an [`EvaluationContext`] via [`EvaluationContext::with_scope`],
68/// scope-aware helpers narrow segment lookups to that instance instead of searching
69/// message-wide.
70#[derive(Debug, Clone, Copy)]
71pub struct GroupScope<'a> {
72    pub group_path: &'a [&'a str],
73    pub instance_index: usize,
74}
75
76impl GroupNavigator for NoOpGroupNavigator {
77    fn find_segments_in_group(&self, _: &str, _: &[&str], _: usize) -> Vec<OwnedSegment> {
78        Vec::new()
79    }
80    fn find_segments_with_qualifier_in_group(
81        &self,
82        _: &str,
83        _: usize,
84        _: &str,
85        _: &[&str],
86        _: usize,
87    ) -> Vec<OwnedSegment> {
88        Vec::new()
89    }
90    fn group_instance_count(&self, _: &[&str]) -> usize {
91        0
92    }
93}
94
95impl<'a> EvaluationContext<'a> {
96    /// Create a new evaluation context (without group navigator).
97    pub fn new(
98        pruefidentifikator: &'a str,
99        external: &'a dyn ExternalConditionProvider,
100        segments: &'a [OwnedSegment],
101    ) -> Self {
102        Self {
103            pruefidentifikator,
104            external,
105            segments,
106            navigator: None,
107            resolved_value: None,
108            resolved_segment: None,
109            codelist: ArtikelIdCodelist::embedded(),
110            ebd_clusters: EbdClusterLookup::embedded(),
111            scope: None,
112        }
113    }
114
115    /// Create a new evaluation context with a group navigator.
116    pub fn with_navigator(
117        pruefidentifikator: &'a str,
118        external: &'a dyn ExternalConditionProvider,
119        segments: &'a [OwnedSegment],
120        navigator: &'a dyn GroupNavigator,
121    ) -> Self {
122        Self {
123            pruefidentifikator,
124            external,
125            segments,
126            navigator: Some(navigator),
127            resolved_value: None,
128            resolved_segment: None,
129            codelist: ArtikelIdCodelist::embedded(),
130            ebd_clusters: EbdClusterLookup::embedded(),
131            scope: None,
132        }
133    }
134
135    /// Override the codelist (tests only — defaults to embedded).
136    pub fn with_codelist(mut self, codelist: &'a ArtikelIdCodelist) -> Self {
137        self.codelist = codelist;
138        self
139    }
140
141    /// Override the EBD cluster lookup (tests only — defaults to embedded).
142    pub fn with_ebd_clusters(mut self, ebd_clusters: &'a EbdClusterLookup) -> Self {
143        self.ebd_clusters = ebd_clusters;
144        self
145    }
146
147    /// Attach a group scope; returns a fresh context without consuming self.
148    /// Mirrors `with_resolved`'s `&self` shape so `validate_tree` can build
149    /// a scoped context inside a loop without moving the outer one.
150    pub fn with_scope(&self, scope: GroupScope<'a>) -> Self {
151        Self {
152            scope: Some(scope),
153            ..*self
154        }
155    }
156
157    /// Create a context with a resolved field value for tree-based validation.
158    ///
159    /// The resolved value comes from the `ValidatedTree` — the specific data
160    /// element value at the current tree position. Conditions check this first.
161    pub fn with_resolved(
162        &self,
163        value: Option<&'a str>,
164        segment: Option<&'a [Vec<String>]>,
165    ) -> Self {
166        Self {
167            resolved_value: value,
168            resolved_segment: segment,
169            ..*self
170        }
171    }
172
173    /// Get the group navigator, if one is set.
174    pub fn navigator(&self) -> Option<&'a dyn GroupNavigator> {
175        self.navigator
176    }
177
178    /// Find the first segment with the given ID.
179    pub fn find_segment(&self, segment_id: &str) -> Option<&'a OwnedSegment> {
180        self.segments.iter().find(|s| s.id == segment_id)
181    }
182
183    /// Find all segments with the given ID.
184    pub fn find_segments(&self, segment_id: &str) -> Vec<&'a OwnedSegment> {
185        self.segments
186            .iter()
187            .filter(|s| s.id == segment_id)
188            .collect()
189    }
190
191    /// Find segments with a specific qualifier value on a given element.
192    pub fn find_segments_with_qualifier(
193        &self,
194        segment_id: &str,
195        element_index: usize,
196        qualifier: &str,
197    ) -> Vec<&'a OwnedSegment> {
198        self.segments
199            .iter()
200            .filter(|s| {
201                s.id == segment_id
202                    && s.elements
203                        .get(element_index)
204                        .and_then(|e| e.first())
205                        .is_some_and(|v| v == qualifier)
206            })
207            .collect()
208    }
209
210    /// Find segments `tag` within the current scope, or message-wide if no
211    /// scope is set. Scope + navigator required for per-instance narrowing;
212    /// falls back to `find_segments` when either is absent.
213    ///
214    /// Returns owned `OwnedSegment` values because
215    /// `GroupNavigator::find_segments_in_group` returns owned values — the
216    /// navigator may synthesise segments from an assembled tree rather than
217    /// borrowing from `ctx.segments`. Callers that only need a `&OwnedSegment`
218    /// should use `find_segments` directly when they know message-wide is correct.
219    pub fn scoped_find_segments(&self, tag: &str) -> Vec<OwnedSegment> {
220        match (self.scope, self.navigator) {
221            (Some(scope), Some(nav)) => {
222                nav.find_segments_in_group(tag, scope.group_path, scope.instance_index)
223            }
224            _ => self.find_segments(tag).into_iter().cloned().collect(),
225        }
226    }
227
228    /// Find segments `tag` with a qualifier value on element `element_index`,
229    /// narrowed to the current scope or message-wide.
230    pub fn scoped_find_segments_with_qualifier(
231        &self,
232        tag: &str,
233        element_index: usize,
234        qualifier: &str,
235    ) -> Vec<OwnedSegment> {
236        match (self.scope, self.navigator) {
237            (Some(scope), Some(nav)) => nav.find_segments_with_qualifier_in_group(
238                tag,
239                element_index,
240                qualifier,
241                scope.group_path,
242                scope.instance_index,
243            ),
244            _ => self
245                .find_segments_with_qualifier(tag, element_index, qualifier)
246                .into_iter()
247                .cloned()
248                .collect(),
249        }
250    }
251
252    /// Validate a format condition on the current field value.
253    ///
254    /// Prefers `resolved_value` (exact field from tree-based validation).
255    /// Falls back to searching segments by tag and extracting `elements[elem][comp]`,
256    /// but only when exactly one segment of that tag exists — otherwise the
257    /// fallback would pick an arbitrary occurrence and validate the wrong field.
258    /// Returns `ConditionResult::Unknown` when no unambiguous value is available.
259    ///
260    /// Usage: `ctx.format_check("DTM", 0, 1, validate_timezone_utc)`
261    pub fn format_check(
262        &self,
263        tag: &str,
264        elem: usize,
265        comp: usize,
266        validate: impl FnOnce(&str) -> ConditionResult,
267    ) -> ConditionResult {
268        // Tree-based validation: the exact field value is authoritative.
269        if let Some(val) = self.resolved_value {
270            return validate(val);
271        }
272        // Non-tree fallback: use the segment only when unambiguous. Picking
273        // `.first()` from multiple same-tag segments (e.g. several DTMs in the
274        // message) would validate an arbitrary one, producing both false
275        // positives and false negatives. Unknown is the honest answer.
276        let segs = self.find_segments(tag);
277        if segs.len() != 1 {
278            return ConditionResult::Unknown;
279        }
280        match segs[0]
281            .elements
282            .get(elem)
283            .and_then(|e| e.get(comp))
284            .map(|s| s.as_str())
285        {
286            Some(val) => validate(val),
287            None => ConditionResult::Unknown,
288        }
289    }
290
291    /// Validate a format condition with qualifier-filtered segment fallback.
292    ///
293    /// Like `format_check`, but the fallback searches for segments matching
294    /// `tag` where `elements[qual_elem][0] == qualifier`, then extracts
295    /// `elements[val_elem][val_comp]`. Returns `Unknown` when the qualifier
296    /// filter doesn't narrow down to exactly one segment.
297    ///
298    /// Usage: `ctx.format_check_qualified("DTM", 0, "163", 0, 1, |v| validate_hhmm_equals(v, "2200"))`
299    pub fn format_check_qualified(
300        &self,
301        tag: &str,
302        qual_elem: usize,
303        qualifier: &str,
304        val_elem: usize,
305        val_comp: usize,
306        validate: impl FnOnce(&str) -> ConditionResult,
307    ) -> ConditionResult {
308        if let Some(val) = self.resolved_value {
309            return validate(val);
310        }
311        let segs = self.find_segments_with_qualifier(tag, qual_elem, qualifier);
312        if segs.len() != 1 {
313            return ConditionResult::Unknown;
314        }
315        match segs[0]
316            .elements
317            .get(val_elem)
318            .and_then(|e| e.get(val_comp))
319            .map(|s| s.as_str())
320        {
321            Some(val) => validate(val),
322            None => ConditionResult::Unknown,
323        }
324    }
325
326    /// Read a value at `elements[elem][comp]` of the segment currently being
327    /// validated ("im selben Segment" conditions).
328    ///
329    /// Prefers `resolved_segment` (the exact segment the field lives in,
330    /// bound per-field during tree-based validation). Falls back to the
331    /// first message-wide segment matching `tag` when no tree context is
332    /// available — matches the prior `find_segments(tag).first()` behavior.
333    ///
334    /// Conditions phrased "Wenn im selben Segment im DE2379 der Code 303
335    /// vorhanden ist" should use this instead of iterating every `tag`
336    /// segment in the message, which finds spurious matches elsewhere.
337    pub fn self_segment_value(&self, tag: &str, elem: usize, comp: usize) -> Option<&str> {
338        if let Some(elements) = self.resolved_segment {
339            return elements
340                .get(elem)
341                .and_then(|e| e.get(comp))
342                .map(|s| s.as_str());
343        }
344        self.segments
345            .iter()
346            .find(|s| s.id == tag)
347            .and_then(|s| s.elements.get(elem))
348            .and_then(|e| e.get(comp))
349            .map(|s| s.as_str())
350    }
351
352    /// Convenience wrapper — returns `true` when `self_segment_value` equals
353    /// `expected`. The common shape for "im selben Segment" code-equality
354    /// conditions.
355    pub fn self_segment_value_equals(
356        &self,
357        tag: &str,
358        elem: usize,
359        comp: usize,
360        expected: &str,
361    ) -> bool {
362        self.self_segment_value(tag, elem, comp)
363            .is_some_and(|v| v == expected)
364    }
365
366    /// Check if a segment with the given ID exists.
367    pub fn has_segment(&self, segment_id: &str) -> bool {
368        self.segments.iter().any(|s| s.id == segment_id)
369    }
370
371    /// Count SG8 instances whose PIA+5 OBIS code satisfies `obis_pred`,
372    /// restricted to SG8 instances with:
373    /// - SEQ qualifier (DE1229) == current SEQ's qualifier, AND
374    /// - SEQ DE1050 (Zeitraum-ID in C286) == current SEQ's DE1050.
375    ///
376    /// "Current" is taken from `resolved_segment` (tree-based validation),
377    /// which must point at a SEQ segment with a qualifier in
378    /// `{Z02, ZA1, ZA2}` (OBIS-Daten der Marktlokation). Returns 0 when the
379    /// context isn't anchored to such a SEQ.
380    ///
381    /// Used by UTILMD_Strom conditions [413]/[414]/[415]/[416] which ask
382    /// "does another SG8 with matching Zeitraum-ID + qualifier have a
383    /// Wirkarbeit kumuliert / 1/4 Stunde OBIS?"
384    ///
385    /// Implementation: linear scan of `self.segments`. Each encountered SEQ
386    /// defines the "current group's" matching state; subsequent PIA+5
387    /// segments (before the next SEQ) belong to that SG8 instance and are
388    /// tested against `obis_pred` when the SEQ matches current's qualifier +
389    /// Zeitraum-ID. This relies on the standard EDIFACT group layout where
390    /// SEQ is SG8's entry segment and child segments (SG9 PIA …) appear
391    /// consecutively after it.
392    pub fn count_sg8_obis_matches<F>(&self, obis_pred: F) -> usize
393    where
394        F: Fn(&str) -> bool,
395    {
396        let resolved = match self.resolved_segment {
397            Some(s) => s,
398            None => return 0,
399        };
400        let cur_qual = match resolved.first().and_then(|e| e.first()) {
401            Some(v) => v.as_str(),
402            None => return 0,
403        };
404        if !matches!(cur_qual, "Z02" | "ZA1" | "ZA2") {
405            return 0;
406        }
407        let cur_zid = match resolved.get(1).and_then(|e| e.first()) {
408            Some(v) => v.as_str(),
409            None => return 0,
410        };
411
412        let mut count = 0usize;
413        let mut in_matching_seq = false;
414        for seg in self.segments {
415            if seg.id == "SEQ" {
416                let qual = seg
417                    .elements
418                    .first()
419                    .and_then(|e| e.first())
420                    .map(String::as_str);
421                let zid = seg
422                    .elements
423                    .get(1)
424                    .and_then(|e| e.first())
425                    .map(String::as_str);
426                in_matching_seq = qual == Some(cur_qual) && zid == Some(cur_zid);
427            } else if seg.id == "PIA" && in_matching_seq {
428                let p_qual = seg
429                    .elements
430                    .first()
431                    .and_then(|e| e.first())
432                    .map(String::as_str);
433                if p_qual == Some("5") {
434                    if let Some(obis) = seg.elements.get(1).and_then(|e| e.first()) {
435                        if obis_pred(obis) {
436                            count += 1;
437                        }
438                    }
439                }
440            }
441        }
442        count
443    }
444
445    /// Find all segments with the given tag within a specific group instance.
446    /// Returns empty if no navigator is set.
447    pub fn find_segments_in_group(
448        &self,
449        segment_id: &str,
450        group_path: &[&str],
451        instance_index: usize,
452    ) -> Vec<OwnedSegment> {
453        match self.navigator {
454            Some(nav) => nav.find_segments_in_group(segment_id, group_path, instance_index),
455            None => Vec::new(),
456        }
457    }
458
459    /// Find segments matching a tag + qualifier within a group instance.
460    /// Returns empty if no navigator is set.
461    pub fn find_segments_with_qualifier_in_group(
462        &self,
463        segment_id: &str,
464        element_index: usize,
465        qualifier: &str,
466        group_path: &[&str],
467        instance_index: usize,
468    ) -> Vec<OwnedSegment> {
469        match self.navigator {
470            Some(nav) => nav.find_segments_with_qualifier_in_group(
471                segment_id,
472                element_index,
473                qualifier,
474                group_path,
475                instance_index,
476            ),
477            None => Vec::new(),
478        }
479    }
480
481    /// Check if a segment exists in a specific group instance.
482    /// Returns false if no navigator is set.
483    pub fn has_segment_in_group(
484        &self,
485        segment_id: &str,
486        group_path: &[&str],
487        instance_index: usize,
488    ) -> bool {
489        !self
490            .find_segments_in_group(segment_id, group_path, instance_index)
491            .is_empty()
492    }
493
494    /// Count repetitions of a group at the given path.
495    /// Returns 0 if no navigator is set.
496    pub fn group_instance_count(&self, group_path: &[&str]) -> usize {
497        match self.navigator {
498            Some(nav) => nav.group_instance_count(group_path),
499            None => 0,
500        }
501    }
502
503    /// Count child group repetitions within a specific parent group instance.
504    /// Returns 0 if no navigator is set.
505    pub fn child_group_instance_count(
506        &self,
507        parent_path: &[&str],
508        parent_instance: usize,
509        child_group_id: &str,
510    ) -> usize {
511        match self.navigator {
512            Some(nav) => {
513                nav.child_group_instance_count(parent_path, parent_instance, child_group_id)
514            }
515            None => 0,
516        }
517    }
518
519    /// Find segments in a child group within a specific parent group instance.
520    /// Returns empty if no navigator is set.
521    pub fn find_segments_in_child_group(
522        &self,
523        segment_id: &str,
524        parent_path: &[&str],
525        parent_instance: usize,
526        child_group_id: &str,
527        child_instance: usize,
528    ) -> Vec<OwnedSegment> {
529        match self.navigator {
530            Some(nav) => nav.find_segments_in_child_group(
531                segment_id,
532                parent_path,
533                parent_instance,
534                child_group_id,
535                child_instance,
536            ),
537            None => Vec::new(),
538        }
539    }
540
541    /// Extract a single value from the first matching segment in a group instance.
542    /// Returns None if no navigator is set or value not found.
543    pub fn extract_value_in_group(
544        &self,
545        segment_id: &str,
546        element_index: usize,
547        component_index: usize,
548        group_path: &[&str],
549        instance_index: usize,
550    ) -> Option<String> {
551        self.navigator?.extract_value_in_group(
552            segment_id,
553            element_index,
554            component_index,
555            group_path,
556            instance_index,
557        )
558    }
559
560    // --- High-level condition helpers ---
561    // These reduce generated condition evaluator boilerplate by ~50%.
562
563    /// Check if any segment with the given tag + qualifier exists (message-wide).
564    /// Returns `True` if found, `False` if not.
565    pub fn has_qualifier(
566        &self,
567        tag: &str,
568        element_index: usize,
569        qualifier: &str,
570    ) -> ConditionResult {
571        ConditionResult::from(
572            !self
573                .find_segments_with_qualifier(tag, element_index, qualifier)
574                .is_empty(),
575        )
576    }
577
578    /// Check if a segment with given tag + qualifier does NOT exist (message-wide).
579    /// Returns `True` if absent, `False` if present.
580    pub fn lacks_qualifier(
581        &self,
582        tag: &str,
583        element_index: usize,
584        qualifier: &str,
585    ) -> ConditionResult {
586        ConditionResult::from(
587            self.find_segments_with_qualifier(tag, element_index, qualifier)
588                .is_empty(),
589        )
590    }
591
592    /// Check if any segment with the given tag + qualifier has a specific sub-element value.
593    ///
594    /// Finds segments matching `tag` with `elements[qual_elem][0] == qualifier`,
595    /// then checks if `elements[value_elem][value_comp]` matches any of `values`.
596    pub fn has_qualified_value(
597        &self,
598        tag: &str,
599        qual_elem: usize,
600        qualifier: &str,
601        value_elem: usize,
602        value_comp: usize,
603        values: &[&str],
604    ) -> ConditionResult {
605        let segments = self.find_segments_with_qualifier(tag, qual_elem, qualifier);
606        if segments.is_empty() {
607            return ConditionResult::Unknown;
608        }
609        for seg in &segments {
610            if let Some(v) = seg
611                .elements
612                .get(value_elem)
613                .and_then(|e| e.get(value_comp))
614                .map(|s| s.as_str())
615            {
616                if values.contains(&v) {
617                    return ConditionResult::True;
618                }
619            }
620        }
621        ConditionResult::False
622    }
623
624    /// Group-scoped qualifier existence check with message-wide fallback.
625    ///
626    /// Checks if any group instance at `group_path` contains a segment matching
627    /// `tag` with `elements[element_index][0] == qualifier`. Falls back to
628    /// message-wide search if no group navigator is available.
629    pub fn any_group_has_qualifier(
630        &self,
631        tag: &str,
632        element_index: usize,
633        qualifier: &str,
634        group_path: &[&str],
635    ) -> ConditionResult {
636        let instance_count = self.group_instance_count(group_path);
637        if instance_count > 0 {
638            for i in 0..instance_count {
639                if !self
640                    .find_segments_with_qualifier_in_group(
641                        tag,
642                        element_index,
643                        qualifier,
644                        group_path,
645                        i,
646                    )
647                    .is_empty()
648                {
649                    return ConditionResult::True;
650                }
651            }
652            return ConditionResult::False;
653        }
654        // Fallback: message-wide search
655        self.has_qualifier(tag, element_index, qualifier)
656    }
657
658    /// Group-scoped segment existence check (any tag match, no qualifier).
659    ///
660    /// Checks if any group instance at `group_path` contains a segment with
661    /// `elements[element_index][0]` matching any of `qualifiers`. Falls back
662    /// to message-wide search if no group navigator is available.
663    pub fn any_group_has_any_qualifier(
664        &self,
665        tag: &str,
666        element_index: usize,
667        qualifiers: &[&str],
668        group_path: &[&str],
669    ) -> ConditionResult {
670        let instance_count = self.group_instance_count(group_path);
671        if instance_count > 0 {
672            for i in 0..instance_count {
673                let segs = self.find_segments_in_group(tag, group_path, i);
674                if segs.iter().any(|seg| {
675                    seg.elements
676                        .get(element_index)
677                        .and_then(|e| e.first())
678                        .map(|s| s.as_str())
679                        .is_some_and(|v| qualifiers.contains(&v))
680                }) {
681                    return ConditionResult::True;
682                }
683            }
684            return ConditionResult::False;
685        }
686        // Fallback: message-wide search
687        let found = self.find_segments(tag).iter().any(|seg| {
688            seg.elements
689                .get(element_index)
690                .and_then(|e| e.first())
691                .map(|s| s.as_str())
692                .is_some_and(|v| qualifiers.contains(&v))
693        });
694        ConditionResult::from(found)
695    }
696
697    /// Group-scoped check for sub-element value within qualified segments.
698    ///
699    /// For each group instance at `group_path`, checks if a segment matching
700    /// `tag` with `elements[qual_elem][0] == qualifier` has
701    /// `elements[value_elem][value_comp]` in `values`. Falls back to message-wide.
702    pub fn any_group_has_qualified_value(
703        &self,
704        tag: &str,
705        qual_elem: usize,
706        qualifier: &str,
707        value_elem: usize,
708        value_comp: usize,
709        values: &[&str],
710        group_path: &[&str],
711    ) -> ConditionResult {
712        let instance_count = self.group_instance_count(group_path);
713        if instance_count > 0 {
714            for i in 0..instance_count {
715                let segs = self.find_segments_with_qualifier_in_group(
716                    tag, qual_elem, qualifier, group_path, i,
717                );
718                for seg in &segs {
719                    if seg
720                        .elements
721                        .get(value_elem)
722                        .and_then(|e| e.get(value_comp))
723                        .map(|s| s.as_str())
724                        .is_some_and(|v| values.contains(&v))
725                    {
726                        return ConditionResult::True;
727                    }
728                }
729            }
730            return ConditionResult::False;
731        }
732        // Fallback: message-wide search
733        self.has_qualified_value(tag, qual_elem, qualifier, value_elem, value_comp, values)
734    }
735
736    // --- Parent-child group navigation helpers ---
737
738    /// Pattern A: Check if parent group instances matching a qualifier have a child
739    /// group containing a specific qualifier.
740    ///
741    /// Example: "In the SG8 with SEQ+Z98, does its SG10 child have CCI+Z23?"
742    ///
743    /// Falls back to message-wide search if no navigator is available.
744    #[allow(clippy::too_many_arguments)]
745    pub fn filtered_parent_child_has_qualifier(
746        &self,
747        parent_path: &[&str],
748        parent_tag: &str,
749        parent_elem: usize,
750        parent_qual: &str,
751        child_group_id: &str,
752        child_tag: &str,
753        child_elem: usize,
754        child_qual: &str,
755    ) -> ConditionResult {
756        let parent_count = self.group_instance_count(parent_path);
757        if parent_count > 0 {
758            for pi in 0..parent_count {
759                // Check if this parent instance has the required qualifier
760                let parent_segs = self.find_segments_with_qualifier_in_group(
761                    parent_tag,
762                    parent_elem,
763                    parent_qual,
764                    parent_path,
765                    pi,
766                );
767                if parent_segs.is_empty() {
768                    continue;
769                }
770                // Check child group instances for the child qualifier
771                let child_count = self.child_group_instance_count(parent_path, pi, child_group_id);
772                for ci in 0..child_count {
773                    let child_segs = self.find_segments_in_child_group(
774                        child_tag,
775                        parent_path,
776                        pi,
777                        child_group_id,
778                        ci,
779                    );
780                    if child_segs.iter().any(|s| {
781                        s.elements
782                            .get(child_elem)
783                            .and_then(|e| e.first())
784                            .is_some_and(|v| v == child_qual)
785                    }) {
786                        return ConditionResult::True;
787                    }
788                }
789            }
790            return ConditionResult::False;
791        }
792        // Fallback: message-wide — check both qualifiers exist independently
793        let has_parent = !self
794            .find_segments_with_qualifier(parent_tag, parent_elem, parent_qual)
795            .is_empty();
796        let has_child = !self
797            .find_segments_with_qualifier(child_tag, child_elem, child_qual)
798            .is_empty();
799        ConditionResult::from(has_parent && has_child)
800    }
801
802    /// Pattern B: Check if any group instance has one qualifier present but another absent.
803    ///
804    /// Example: "In any SG8, SEQ+Z59 is present but CCI+11 is absent"
805    ///
806    /// Falls back to message-wide search if no navigator is available.
807    #[allow(clippy::too_many_arguments)]
808    pub fn any_group_has_qualifier_without(
809        &self,
810        present_tag: &str,
811        present_elem: usize,
812        present_qual: &str,
813        absent_tag: &str,
814        absent_elem: usize,
815        absent_qual: &str,
816        group_path: &[&str],
817    ) -> ConditionResult {
818        let instance_count = self.group_instance_count(group_path);
819        if instance_count > 0 {
820            for i in 0..instance_count {
821                let has_present = !self
822                    .find_segments_with_qualifier_in_group(
823                        present_tag,
824                        present_elem,
825                        present_qual,
826                        group_path,
827                        i,
828                    )
829                    .is_empty();
830                let has_absent = !self
831                    .find_segments_with_qualifier_in_group(
832                        absent_tag,
833                        absent_elem,
834                        absent_qual,
835                        group_path,
836                        i,
837                    )
838                    .is_empty();
839                if has_present && !has_absent {
840                    return ConditionResult::True;
841                }
842            }
843            return ConditionResult::False;
844        }
845        // Fallback: message-wide
846        let has_present = !self
847            .find_segments_with_qualifier(present_tag, present_elem, present_qual)
848            .is_empty();
849        let has_absent = !self
850            .find_segments_with_qualifier(absent_tag, absent_elem, absent_qual)
851            .is_empty();
852        ConditionResult::from(has_present && !has_absent)
853    }
854
855    /// Pattern C helper: Collect all values at a specific element+component across group instances.
856    ///
857    /// Returns `(instance_index, value)` pairs for non-empty values.
858    pub fn collect_group_values(
859        &self,
860        tag: &str,
861        elem: usize,
862        comp: usize,
863        group_path: &[&str],
864    ) -> Vec<(usize, String)> {
865        let instance_count = self.group_instance_count(group_path);
866        let mut results = Vec::new();
867        for i in 0..instance_count {
868            if let Some(val) = self
869                .navigator
870                .and_then(|nav| nav.extract_value_in_group(tag, elem, comp, group_path, i))
871            {
872                if !val.is_empty() {
873                    results.push((i, val));
874                }
875            }
876        }
877        results
878    }
879
880    /// Pattern C: Check if a value from one group path matches a value in another group path.
881    ///
882    /// Example: "Zeitraum-ID in SG6 RFF+Z49 matches reference in SG8 SEQ.c286"
883    ///
884    /// Finds qualified segments in source_path, extracts their value, then checks if
885    /// any instance in target_path has the same value at the target position.
886    ///
887    /// Falls back to message-wide search if no navigator is available.
888    #[allow(clippy::too_many_arguments)]
889    pub fn groups_share_qualified_value(
890        &self,
891        source_tag: &str,
892        source_qual_elem: usize,
893        source_qual: &str,
894        source_value_elem: usize,
895        source_value_comp: usize,
896        source_path: &[&str],
897        target_tag: &str,
898        target_elem: usize,
899        target_comp: usize,
900        target_path: &[&str],
901    ) -> ConditionResult {
902        let source_count = self.group_instance_count(source_path);
903        let target_count = self.group_instance_count(target_path);
904        if source_count > 0 && target_count > 0 {
905            // Collect source values from qualified segments
906            let mut source_values = Vec::new();
907            for si in 0..source_count {
908                let segs = self.find_segments_with_qualifier_in_group(
909                    source_tag,
910                    source_qual_elem,
911                    source_qual,
912                    source_path,
913                    si,
914                );
915                for seg in &segs {
916                    if let Some(val) = seg
917                        .elements
918                        .get(source_value_elem)
919                        .and_then(|e| e.get(source_value_comp))
920                    {
921                        if !val.is_empty() {
922                            source_values.push(val.clone());
923                        }
924                    }
925                }
926            }
927            if source_values.is_empty() {
928                return ConditionResult::Unknown;
929            }
930            // Check if any target instance has a matching value
931            let target_values =
932                self.collect_group_values(target_tag, target_elem, target_comp, target_path);
933            for (_, tv) in &target_values {
934                if source_values.iter().any(|sv| sv == tv) {
935                    return ConditionResult::True;
936                }
937            }
938            return ConditionResult::False;
939        }
940        // Fallback: message-wide search
941        let source_segs =
942            self.find_segments_with_qualifier(source_tag, source_qual_elem, source_qual);
943        let source_vals: Vec<&str> = source_segs
944            .iter()
945            .filter_map(|s| {
946                s.elements
947                    .get(source_value_elem)
948                    .and_then(|e| e.get(source_value_comp))
949                    .map(|v| v.as_str())
950            })
951            .filter(|v| !v.is_empty())
952            .collect();
953        if source_vals.is_empty() {
954            return ConditionResult::Unknown;
955        }
956        let target_segs = self.find_segments(target_tag);
957        let has_match = target_segs.iter().any(|s| {
958            s.elements
959                .get(target_elem)
960                .and_then(|e| e.get(target_comp))
961                .map(|v| v.as_str())
962                .is_some_and(|v| source_vals.contains(&v))
963        });
964        ConditionResult::from(has_match)
965    }
966
967    /// Group-scoped co-occurrence check: two segment conditions must both be true
968    /// in the same group instance.
969    ///
970    /// For each group instance, checks that:
971    /// 1. A segment with `tag_a` has `elements[elem_a][0]` in `quals_a`
972    /// 2. A segment with `tag_b` has `elements[elem_b][comp_b]` in `vals_b`
973    ///
974    /// Falls back to message-wide search.
975    #[allow(clippy::too_many_arguments)]
976    pub fn any_group_has_co_occurrence(
977        &self,
978        tag_a: &str,
979        elem_a: usize,
980        quals_a: &[&str],
981        tag_b: &str,
982        elem_b: usize,
983        comp_b: usize,
984        vals_b: &[&str],
985        group_path: &[&str],
986    ) -> ConditionResult {
987        let instance_count = self.group_instance_count(group_path);
988        if instance_count > 0 {
989            for i in 0..instance_count {
990                let a_present = self
991                    .find_segments_in_group(tag_a, group_path, i)
992                    .iter()
993                    .any(|seg| {
994                        seg.elements
995                            .get(elem_a)
996                            .and_then(|e| e.first())
997                            .map(|s| s.as_str())
998                            .is_some_and(|v| quals_a.contains(&v))
999                    });
1000                let b_present = self
1001                    .find_segments_in_group(tag_b, group_path, i)
1002                    .iter()
1003                    .any(|seg| {
1004                        seg.elements
1005                            .get(elem_b)
1006                            .and_then(|e| e.get(comp_b))
1007                            .map(|s| s.as_str())
1008                            .is_some_and(|v| vals_b.contains(&v))
1009                    });
1010                if a_present && b_present {
1011                    return ConditionResult::True;
1012                }
1013            }
1014            return ConditionResult::False;
1015        }
1016        // Fallback: message-wide
1017        let a_found = self.find_segments(tag_a).iter().any(|seg| {
1018            seg.elements
1019                .get(elem_a)
1020                .and_then(|e| e.first())
1021                .map(|s| s.as_str())
1022                .is_some_and(|v| quals_a.contains(&v))
1023        });
1024        if !a_found {
1025            return ConditionResult::False;
1026        }
1027        let b_found = self.find_segments(tag_b).iter().any(|seg| {
1028            seg.elements
1029                .get(elem_b)
1030                .and_then(|e| e.get(comp_b))
1031                .map(|s| s.as_str())
1032                .is_some_and(|v| vals_b.contains(&v))
1033        });
1034        ConditionResult::from(b_found)
1035    }
1036
1037    // --- Multi-element matching helpers ---
1038
1039    /// Check if any segment with the given tag has ALL specified element/component values.
1040    ///
1041    /// Each check is `(element_index, component_index, expected_value)`.
1042    /// Returns `True` if a matching segment exists, `False` if segments exist but none match,
1043    /// `Unknown` if no segments with the tag exist.
1044    ///
1045    /// Example: `ctx.has_segment_matching("STS", &[(0, 0, "Z20"), (1, 0, "Z32"), (2, 0, "A99")])`
1046    pub fn has_segment_matching(
1047        &self,
1048        tag: &str,
1049        checks: &[(usize, usize, &str)],
1050    ) -> ConditionResult {
1051        let segments = self.find_segments(tag);
1052        if segments.is_empty() {
1053            return ConditionResult::Unknown;
1054        }
1055        let found = segments.iter().any(|seg| {
1056            checks.iter().all(|(elem, comp, val)| {
1057                seg.elements
1058                    .get(*elem)
1059                    .and_then(|e| e.get(*comp))
1060                    .is_some_and(|v| v == val)
1061            })
1062        });
1063        ConditionResult::from(found)
1064    }
1065
1066    /// Group-scoped multi-element match with message-wide fallback.
1067    ///
1068    /// Checks if any group instance at `group_path` contains a segment with `tag`
1069    /// where ALL element/component checks match.
1070    pub fn has_segment_matching_in_group(
1071        &self,
1072        tag: &str,
1073        checks: &[(usize, usize, &str)],
1074        group_path: &[&str],
1075    ) -> ConditionResult {
1076        let instance_count = self.group_instance_count(group_path);
1077        if instance_count > 0 {
1078            for i in 0..instance_count {
1079                let segs = self.find_segments_in_group(tag, group_path, i);
1080                if segs.iter().any(|seg| {
1081                    checks.iter().all(|(elem, comp, val)| {
1082                        seg.elements
1083                            .get(*elem)
1084                            .and_then(|e| e.get(*comp))
1085                            .is_some_and(|v| v == val)
1086                    })
1087                }) {
1088                    return ConditionResult::True;
1089                }
1090            }
1091            return ConditionResult::False;
1092        }
1093        // Fallback: message-wide
1094        self.has_segment_matching(tag, checks)
1095    }
1096
1097    // --- DTM date comparison helpers ---
1098
1099    /// Check if a DTM segment with the given qualifier has a value >= threshold.
1100    ///
1101    /// Both the DTM value and threshold should be in EDIFACT format 303 (CCYYMMDDHHMM).
1102    /// Returns `Unknown` if no DTM with the qualifier exists.
1103    pub fn dtm_ge(&self, qualifier: &str, threshold: &str) -> ConditionResult {
1104        let segs = self.find_segments_with_qualifier("DTM", 0, qualifier);
1105        match segs.first() {
1106            Some(dtm) => match dtm.elements.first().and_then(|e| e.get(1)) {
1107                Some(val) => ConditionResult::from(val.as_str() >= threshold),
1108                None => ConditionResult::Unknown,
1109            },
1110            None => ConditionResult::Unknown,
1111        }
1112    }
1113
1114    /// Check if a DTM segment with the given qualifier has a value < threshold.
1115    pub fn dtm_lt(&self, qualifier: &str, threshold: &str) -> ConditionResult {
1116        let segs = self.find_segments_with_qualifier("DTM", 0, qualifier);
1117        match segs.first() {
1118            Some(dtm) => match dtm.elements.first().and_then(|e| e.get(1)) {
1119                Some(val) => ConditionResult::from((val.as_str()) < threshold),
1120                None => ConditionResult::Unknown,
1121            },
1122            None => ConditionResult::Unknown,
1123        }
1124    }
1125
1126    /// Check if a DTM segment with the given qualifier has a value <= threshold.
1127    pub fn dtm_le(&self, qualifier: &str, threshold: &str) -> ConditionResult {
1128        let segs = self.find_segments_with_qualifier("DTM", 0, qualifier);
1129        match segs.first() {
1130            Some(dtm) => match dtm.elements.first().and_then(|e| e.get(1)) {
1131                Some(val) => ConditionResult::from(val.as_str() <= threshold),
1132                None => ConditionResult::Unknown,
1133            },
1134            None => ConditionResult::Unknown,
1135        }
1136    }
1137
1138    // --- Group-scoped cardinality helpers ---
1139
1140    /// Count segments matching tag + qualifier within a group path (across all instances).
1141    ///
1142    /// Returns the total count across all group instances.
1143    /// Falls back to message-wide count if no navigator.
1144    pub fn count_qualified_in_group(
1145        &self,
1146        tag: &str,
1147        element_index: usize,
1148        qualifier: &str,
1149        group_path: &[&str],
1150    ) -> usize {
1151        let instance_count = self.group_instance_count(group_path);
1152        if instance_count > 0 {
1153            let mut total = 0;
1154            for i in 0..instance_count {
1155                total += self
1156                    .find_segments_with_qualifier_in_group(
1157                        tag,
1158                        element_index,
1159                        qualifier,
1160                        group_path,
1161                        i,
1162                    )
1163                    .len();
1164            }
1165            return total;
1166        }
1167        // Fallback: message-wide
1168        self.find_segments_with_qualifier(tag, element_index, qualifier)
1169            .len()
1170    }
1171
1172    /// Count segments matching tag (any qualifier) within a group path.
1173    pub fn count_in_group(&self, tag: &str, group_path: &[&str]) -> usize {
1174        let instance_count = self.group_instance_count(group_path);
1175        if instance_count > 0 {
1176            let mut total = 0;
1177            for i in 0..instance_count {
1178                total += self.find_segments_in_group(tag, group_path, i).len();
1179            }
1180            return total;
1181        }
1182        // Fallback: message-wide
1183        self.find_segments(tag).len()
1184    }
1185}
1186
1187#[cfg(test)]
1188mod tests {
1189    use super::super::evaluator::NoOpExternalProvider;
1190    use super::*;
1191    use mig_types::navigator::GroupNavigator;
1192
1193    fn make_segment(id: &str, elements: Vec<Vec<&str>>) -> OwnedSegment {
1194        OwnedSegment {
1195            id: id.to_string(),
1196            elements: elements
1197                .into_iter()
1198                .map(|e| e.into_iter().map(|c| c.to_string()).collect())
1199                .collect(),
1200            segment_number: 0,
1201        }
1202    }
1203
1204    // --- Mock navigator for testing ---
1205    struct MockGroupNavigator {
1206        groups: Vec<(Vec<String>, usize, Vec<OwnedSegment>)>,
1207        /// Children: (parent_path, parent_instance, child_group_id, child_instance, segments)
1208        children: Vec<(Vec<String>, usize, String, usize, Vec<OwnedSegment>)>,
1209    }
1210
1211    impl MockGroupNavigator {
1212        fn new() -> Self {
1213            Self {
1214                groups: vec![],
1215                children: vec![],
1216            }
1217        }
1218        fn with_group(mut self, path: &[&str], instance: usize, segs: Vec<OwnedSegment>) -> Self {
1219            self.groups
1220                .push((path.iter().map(|s| s.to_string()).collect(), instance, segs));
1221            self
1222        }
1223        fn with_child_group(
1224            mut self,
1225            parent_path: &[&str],
1226            parent_instance: usize,
1227            child_id: &str,
1228            child_instance: usize,
1229            segs: Vec<OwnedSegment>,
1230        ) -> Self {
1231            self.children.push((
1232                parent_path.iter().map(|s| s.to_string()).collect(),
1233                parent_instance,
1234                child_id.to_string(),
1235                child_instance,
1236                segs,
1237            ));
1238            self
1239        }
1240        fn find_instance(&self, group_path: &[&str], idx: usize) -> Option<&[OwnedSegment]> {
1241            self.groups
1242                .iter()
1243                .find(|(p, i, _)| {
1244                    let ps: Vec<&str> = p.iter().map(|s| s.as_str()).collect();
1245                    ps.as_slice() == group_path && *i == idx
1246                })
1247                .map(|(_, _, segs)| segs.as_slice())
1248        }
1249    }
1250
1251    impl GroupNavigator for MockGroupNavigator {
1252        fn find_segments_in_group(
1253            &self,
1254            segment_id: &str,
1255            group_path: &[&str],
1256            instance_index: usize,
1257        ) -> Vec<OwnedSegment> {
1258            self.find_instance(group_path, instance_index)
1259                .map(|segs| {
1260                    segs.iter()
1261                        .filter(|s| s.id == segment_id)
1262                        .cloned()
1263                        .collect()
1264                })
1265                .unwrap_or_default()
1266        }
1267        fn find_segments_with_qualifier_in_group(
1268            &self,
1269            segment_id: &str,
1270            element_index: usize,
1271            qualifier: &str,
1272            group_path: &[&str],
1273            instance_index: usize,
1274        ) -> Vec<OwnedSegment> {
1275            self.find_segments_in_group(segment_id, group_path, instance_index)
1276                .into_iter()
1277                .filter(|s| {
1278                    s.elements
1279                        .get(element_index)
1280                        .and_then(|e| e.first())
1281                        .is_some_and(|v| v == qualifier)
1282                })
1283                .collect()
1284        }
1285        fn group_instance_count(&self, group_path: &[&str]) -> usize {
1286            self.groups
1287                .iter()
1288                .filter(|(p, _, _)| {
1289                    let ps: Vec<&str> = p.iter().map(|s| s.as_str()).collect();
1290                    ps.as_slice() == group_path
1291                })
1292                .count()
1293        }
1294        fn child_group_instance_count(
1295            &self,
1296            parent_path: &[&str],
1297            parent_instance: usize,
1298            child_group_id: &str,
1299        ) -> usize {
1300            self.children
1301                .iter()
1302                .filter(|(pp, pi, cid, _, _)| {
1303                    let ps: Vec<&str> = pp.iter().map(|s| s.as_str()).collect();
1304                    ps.as_slice() == parent_path && *pi == parent_instance && cid == child_group_id
1305                })
1306                .count()
1307        }
1308        fn find_segments_in_child_group(
1309            &self,
1310            segment_id: &str,
1311            parent_path: &[&str],
1312            parent_instance: usize,
1313            child_group_id: &str,
1314            child_instance: usize,
1315        ) -> Vec<OwnedSegment> {
1316            self.children
1317                .iter()
1318                .find(|(pp, pi, cid, ci, _)| {
1319                    let ps: Vec<&str> = pp.iter().map(|s| s.as_str()).collect();
1320                    ps.as_slice() == parent_path
1321                        && *pi == parent_instance
1322                        && cid == child_group_id
1323                        && *ci == child_instance
1324                })
1325                .map(|(_, _, _, _, segs)| {
1326                    segs.iter()
1327                        .filter(|s| s.id == segment_id)
1328                        .cloned()
1329                        .collect()
1330                })
1331                .unwrap_or_default()
1332        }
1333        fn extract_value_in_group(
1334            &self,
1335            segment_id: &str,
1336            element_index: usize,
1337            component_index: usize,
1338            group_path: &[&str],
1339            instance_index: usize,
1340        ) -> Option<String> {
1341            let segs = self.find_instance(group_path, instance_index)?;
1342            let seg = segs.iter().find(|s| s.id == segment_id)?;
1343            seg.elements
1344                .get(element_index)?
1345                .get(component_index)
1346                .cloned()
1347        }
1348    }
1349
1350    #[test]
1351    fn test_find_segment() {
1352        let segments = vec![
1353            make_segment("UNH", vec![vec!["test"]]),
1354            make_segment("NAD", vec![vec!["MS"], vec!["123456789", "", "293"]]),
1355        ];
1356        let external = NoOpExternalProvider;
1357        let ctx = EvaluationContext::new("11001", &external, &segments);
1358
1359        assert!(ctx.find_segment("NAD").is_some());
1360        assert!(ctx.find_segment("DTM").is_none());
1361    }
1362
1363    #[test]
1364    fn test_find_segments_with_qualifier() {
1365        let segments = vec![
1366            make_segment("NAD", vec![vec!["MS"], vec!["111"]]),
1367            make_segment("NAD", vec![vec!["MR"], vec!["222"]]),
1368            make_segment("NAD", vec![vec!["MS"], vec!["333"]]),
1369        ];
1370        let external = NoOpExternalProvider;
1371        let ctx = EvaluationContext::new("11001", &external, &segments);
1372
1373        let ms_nads = ctx.find_segments_with_qualifier("NAD", 0, "MS");
1374        assert_eq!(ms_nads.len(), 2);
1375    }
1376
1377    #[test]
1378    fn test_has_segment() {
1379        let segments = vec![make_segment("UNH", vec![vec!["test"]])];
1380        let external = NoOpExternalProvider;
1381        let ctx = EvaluationContext::new("11001", &external, &segments);
1382
1383        assert!(ctx.has_segment("UNH"));
1384        assert!(!ctx.has_segment("NAD"));
1385    }
1386
1387    // --- Group navigator tests ---
1388
1389    #[test]
1390    fn test_no_navigator_group_find_returns_empty() {
1391        let segments = vec![make_segment("SEQ", vec![vec!["Z98"]])];
1392        let external = NoOpExternalProvider;
1393        let ctx = EvaluationContext::new("55001", &external, &segments);
1394        assert!(ctx
1395            .find_segments_in_group("SEQ", &["SG4", "SG8"], 0)
1396            .is_empty());
1397    }
1398
1399    #[test]
1400    fn test_no_navigator_group_instance_count_zero() {
1401        let external = NoOpExternalProvider;
1402        let ctx = EvaluationContext::new("55001", &external, &[]);
1403        assert_eq!(ctx.group_instance_count(&["SG4"]), 0);
1404    }
1405
1406    #[test]
1407    fn test_with_navigator_finds_segments_in_group() {
1408        let external = NoOpExternalProvider;
1409        let nav = MockGroupNavigator::new().with_group(
1410            &["SG4", "SG8"],
1411            0,
1412            vec![
1413                make_segment("SEQ", vec![vec!["Z98"]]),
1414                make_segment("CCI", vec![vec!["Z30"], vec![], vec!["Z07"]]),
1415            ],
1416        );
1417        let ctx = EvaluationContext::with_navigator("55001", &external, &[], &nav);
1418        let result = ctx.find_segments_in_group("SEQ", &["SG4", "SG8"], 0);
1419        assert_eq!(result.len(), 1);
1420        assert_eq!(result[0].id, "SEQ");
1421    }
1422
1423    #[test]
1424    fn test_with_navigator_qualifier_in_group() {
1425        let external = NoOpExternalProvider;
1426        let nav = MockGroupNavigator::new().with_group(
1427            &["SG4", "SG8"],
1428            0,
1429            vec![
1430                make_segment("SEQ", vec![vec!["Z98"]]),
1431                make_segment("SEQ", vec![vec!["Z01"]]),
1432            ],
1433        );
1434        let ctx = EvaluationContext::with_navigator("55001", &external, &[], &nav);
1435        let result = ctx.find_segments_with_qualifier_in_group("SEQ", 0, "Z98", &["SG4", "SG8"], 0);
1436        assert_eq!(result.len(), 1);
1437    }
1438
1439    #[test]
1440    fn test_group_instance_count_with_navigator() {
1441        let external = NoOpExternalProvider;
1442        let nav = MockGroupNavigator::new()
1443            .with_group(
1444                &["SG4", "SG8"],
1445                0,
1446                vec![make_segment("SEQ", vec![vec!["Z98"]])],
1447            )
1448            .with_group(
1449                &["SG4", "SG8"],
1450                1,
1451                vec![make_segment("SEQ", vec![vec!["Z01"]])],
1452            );
1453        let ctx = EvaluationContext::with_navigator("55001", &external, &[], &nav);
1454        assert_eq!(ctx.group_instance_count(&["SG4", "SG8"]), 2);
1455    }
1456
1457    #[test]
1458    fn test_has_segment_in_group() {
1459        let external = NoOpExternalProvider;
1460        let nav = MockGroupNavigator::new().with_group(
1461            &["SG4", "SG8"],
1462            0,
1463            vec![make_segment("SEQ", vec![vec!["Z98"]])],
1464        );
1465        let ctx = EvaluationContext::with_navigator("55001", &external, &[], &nav);
1466        assert!(ctx.has_segment_in_group("SEQ", &["SG4", "SG8"], 0));
1467        assert!(!ctx.has_segment_in_group("CCI", &["SG4", "SG8"], 0));
1468        assert!(!ctx.has_segment_in_group("SEQ", &["SG4", "SG5"], 0));
1469    }
1470
1471    // --- High-level helper tests ---
1472
1473    #[test]
1474    fn test_has_qualifier() {
1475        let segments = vec![
1476            make_segment("NAD", vec![vec!["MS"], vec!["111"]]),
1477            make_segment("NAD", vec![vec!["MR"], vec!["222"]]),
1478        ];
1479        let external = NoOpExternalProvider;
1480        let ctx = EvaluationContext::new("11001", &external, &segments);
1481
1482        assert_eq!(ctx.has_qualifier("NAD", 0, "MS"), ConditionResult::True);
1483        assert_eq!(ctx.has_qualifier("NAD", 0, "DP"), ConditionResult::False);
1484    }
1485
1486    #[test]
1487    fn test_lacks_qualifier() {
1488        let segments = vec![make_segment("DTM", vec![vec!["92", "2025"]])];
1489        let external = NoOpExternalProvider;
1490        let ctx = EvaluationContext::new("11001", &external, &segments);
1491
1492        assert_eq!(ctx.lacks_qualifier("DTM", 0, "93"), ConditionResult::True);
1493        assert_eq!(ctx.lacks_qualifier("DTM", 0, "92"), ConditionResult::False);
1494    }
1495
1496    #[test]
1497    fn test_has_qualified_value() {
1498        let segments = vec![make_segment("STS", vec![vec!["7"], vec![], vec!["ZG9"]])];
1499        let external = NoOpExternalProvider;
1500        let ctx = EvaluationContext::new("55001", &external, &segments);
1501
1502        assert_eq!(
1503            ctx.has_qualified_value("STS", 0, "7", 2, 0, &["ZG9", "ZH1", "ZH2"]),
1504            ConditionResult::True,
1505        );
1506        assert_eq!(
1507            ctx.has_qualified_value("STS", 0, "7", 2, 0, &["E01"]),
1508            ConditionResult::False,
1509        );
1510        // No STS+E01 → Unknown
1511        assert_eq!(
1512            ctx.has_qualified_value("STS", 0, "E01", 2, 0, &["Z01"]),
1513            ConditionResult::Unknown,
1514        );
1515    }
1516
1517    #[test]
1518    fn test_any_group_has_qualifier() {
1519        let external = NoOpExternalProvider;
1520        let nav = MockGroupNavigator::new()
1521            .with_group(
1522                &["SG4", "SG8"],
1523                0,
1524                vec![make_segment("SEQ", vec![vec!["Z01"]])],
1525            )
1526            .with_group(
1527                &["SG4", "SG8"],
1528                1,
1529                vec![make_segment("SEQ", vec![vec!["Z98"]])],
1530            );
1531        let ctx = EvaluationContext::with_navigator("55001", &external, &[], &nav);
1532
1533        assert_eq!(
1534            ctx.any_group_has_qualifier("SEQ", 0, "Z98", &["SG4", "SG8"]),
1535            ConditionResult::True,
1536        );
1537        assert_eq!(
1538            ctx.any_group_has_qualifier("SEQ", 0, "Z99", &["SG4", "SG8"]),
1539            ConditionResult::False,
1540        );
1541    }
1542
1543    #[test]
1544    fn test_any_group_has_qualifier_fallback() {
1545        // No navigator — falls back to message-wide search
1546        let segments = vec![make_segment("SEQ", vec![vec!["Z98"]])];
1547        let external = NoOpExternalProvider;
1548        let ctx = EvaluationContext::new("55001", &external, &segments);
1549
1550        assert_eq!(
1551            ctx.any_group_has_qualifier("SEQ", 0, "Z98", &["SG4", "SG8"]),
1552            ConditionResult::True,
1553        );
1554    }
1555
1556    #[test]
1557    fn test_any_group_has_any_qualifier() {
1558        let external = NoOpExternalProvider;
1559        let nav = MockGroupNavigator::new().with_group(
1560            &["SG4", "SG8"],
1561            0,
1562            vec![make_segment("SEQ", vec![vec!["Z80"]])],
1563        );
1564        let ctx = EvaluationContext::with_navigator("55001", &external, &[], &nav);
1565
1566        assert_eq!(
1567            ctx.any_group_has_any_qualifier("SEQ", 0, &["Z01", "Z80", "Z81"], &["SG4", "SG8"]),
1568            ConditionResult::True,
1569        );
1570        assert_eq!(
1571            ctx.any_group_has_any_qualifier("SEQ", 0, &["Z98"], &["SG4", "SG8"]),
1572            ConditionResult::False,
1573        );
1574    }
1575
1576    #[test]
1577    fn test_any_group_has_co_occurrence() {
1578        let external = NoOpExternalProvider;
1579        let nav = MockGroupNavigator::new().with_group(
1580            &["SG4", "SG8"],
1581            0,
1582            vec![
1583                make_segment("SEQ", vec![vec!["Z01"]]),
1584                make_segment("CCI", vec![vec!["Z30"], vec![], vec!["Z07"]]),
1585            ],
1586        );
1587        let ctx = EvaluationContext::with_navigator("55001", &external, &[], &nav);
1588
1589        assert_eq!(
1590            ctx.any_group_has_co_occurrence(
1591                "SEQ",
1592                0,
1593                &["Z01"],
1594                "CCI",
1595                2,
1596                0,
1597                &["Z07"],
1598                &["SG4", "SG8"],
1599            ),
1600            ConditionResult::True,
1601        );
1602        // Wrong CCI value
1603        assert_eq!(
1604            ctx.any_group_has_co_occurrence(
1605                "SEQ",
1606                0,
1607                &["Z01"],
1608                "CCI",
1609                2,
1610                0,
1611                &["ZC0"],
1612                &["SG4", "SG8"],
1613            ),
1614            ConditionResult::False,
1615        );
1616    }
1617
1618    // --- Parent-child navigation tests ---
1619
1620    #[test]
1621    fn test_filtered_parent_child_has_qualifier() {
1622        let external = NoOpExternalProvider;
1623        // SG8[0] has SEQ+Z98, with SG10 child having CCI+Z23
1624        // SG8[1] has SEQ+Z01, no SG10 children
1625        let nav = MockGroupNavigator::new()
1626            .with_group(
1627                &["SG4", "SG8"],
1628                0,
1629                vec![make_segment("SEQ", vec![vec!["Z98"]])],
1630            )
1631            .with_group(
1632                &["SG4", "SG8"],
1633                1,
1634                vec![make_segment("SEQ", vec![vec!["Z01"]])],
1635            )
1636            .with_child_group(
1637                &["SG4", "SG8"],
1638                0,
1639                "SG10",
1640                0,
1641                vec![make_segment("CCI", vec![vec!["Z23"]])],
1642            );
1643        let ctx = EvaluationContext::with_navigator("55001", &external, &[], &nav);
1644
1645        // SG8 with SEQ+Z98 has SG10 child with CCI+Z23 → True
1646        assert_eq!(
1647            ctx.filtered_parent_child_has_qualifier(
1648                &["SG4", "SG8"],
1649                "SEQ",
1650                0,
1651                "Z98",
1652                "SG10",
1653                "CCI",
1654                0,
1655                "Z23",
1656            ),
1657            ConditionResult::True,
1658        );
1659        // SG8 with SEQ+Z01 has no SG10 children → False
1660        assert_eq!(
1661            ctx.filtered_parent_child_has_qualifier(
1662                &["SG4", "SG8"],
1663                "SEQ",
1664                0,
1665                "Z01",
1666                "SG10",
1667                "CCI",
1668                0,
1669                "Z23",
1670            ),
1671            ConditionResult::False,
1672        );
1673        // Wrong child qualifier → False
1674        assert_eq!(
1675            ctx.filtered_parent_child_has_qualifier(
1676                &["SG4", "SG8"],
1677                "SEQ",
1678                0,
1679                "Z98",
1680                "SG10",
1681                "CCI",
1682                0,
1683                "Z99",
1684            ),
1685            ConditionResult::False,
1686        );
1687    }
1688
1689    #[test]
1690    fn test_filtered_parent_child_fallback() {
1691        // No navigator — falls back to message-wide
1692        let segments = vec![
1693            make_segment("SEQ", vec![vec!["Z98"]]),
1694            make_segment("CCI", vec![vec!["Z23"]]),
1695        ];
1696        let external = NoOpExternalProvider;
1697        let ctx = EvaluationContext::new("55001", &external, &segments);
1698
1699        assert_eq!(
1700            ctx.filtered_parent_child_has_qualifier(
1701                &["SG4", "SG8"],
1702                "SEQ",
1703                0,
1704                "Z98",
1705                "SG10",
1706                "CCI",
1707                0,
1708                "Z23",
1709            ),
1710            ConditionResult::True,
1711        );
1712        // Missing child qualifier in message-wide → False
1713        assert_eq!(
1714            ctx.filtered_parent_child_has_qualifier(
1715                &["SG4", "SG8"],
1716                "SEQ",
1717                0,
1718                "Z98",
1719                "SG10",
1720                "CCI",
1721                0,
1722                "Z99",
1723            ),
1724            ConditionResult::False,
1725        );
1726    }
1727
1728    #[test]
1729    fn test_any_group_has_qualifier_without() {
1730        let external = NoOpExternalProvider;
1731        // SG8[0]: SEQ+Z59 present, CCI+11 absent
1732        // SG8[1]: SEQ+Z01 present, CCI+11 present
1733        let nav = MockGroupNavigator::new()
1734            .with_group(
1735                &["SG4", "SG8"],
1736                0,
1737                vec![make_segment("SEQ", vec![vec!["Z59"]])],
1738            )
1739            .with_group(
1740                &["SG4", "SG8"],
1741                1,
1742                vec![
1743                    make_segment("SEQ", vec![vec!["Z01"]]),
1744                    make_segment("CCI", vec![vec!["11"]]),
1745                ],
1746            );
1747        let ctx = EvaluationContext::with_navigator("55001", &external, &[], &nav);
1748
1749        // SG8[0] has SEQ+Z59 without CCI+11 → True
1750        assert_eq!(
1751            ctx.any_group_has_qualifier_without("SEQ", 0, "Z59", "CCI", 0, "11", &["SG4", "SG8"]),
1752            ConditionResult::True,
1753        );
1754        // Looking for SEQ+Z01 without CCI+11 → False (SG8[1] has both)
1755        assert_eq!(
1756            ctx.any_group_has_qualifier_without("SEQ", 0, "Z01", "CCI", 0, "11", &["SG4", "SG8"]),
1757            ConditionResult::False,
1758        );
1759        // Looking for SEQ+Z99 (doesn't exist) → False
1760        assert_eq!(
1761            ctx.any_group_has_qualifier_without("SEQ", 0, "Z99", "CCI", 0, "11", &["SG4", "SG8"]),
1762            ConditionResult::False,
1763        );
1764    }
1765
1766    #[test]
1767    fn test_any_group_has_qualifier_without_fallback() {
1768        let segments = vec![make_segment("SEQ", vec![vec!["Z59"]])];
1769        let external = NoOpExternalProvider;
1770        let ctx = EvaluationContext::new("55001", &external, &segments);
1771
1772        // Message-wide: SEQ+Z59 present, CCI+11 absent → True
1773        assert_eq!(
1774            ctx.any_group_has_qualifier_without("SEQ", 0, "Z59", "CCI", 0, "11", &["SG4", "SG8"]),
1775            ConditionResult::True,
1776        );
1777    }
1778
1779    #[test]
1780    fn test_collect_group_values() {
1781        let external = NoOpExternalProvider;
1782        let nav = MockGroupNavigator::new()
1783            .with_group(
1784                &["SG4", "SG6"],
1785                0,
1786                vec![make_segment("RFF", vec![vec!["Z49", "REF001"]])],
1787            )
1788            .with_group(
1789                &["SG4", "SG6"],
1790                1,
1791                vec![make_segment("RFF", vec![vec!["Z49", "REF002"]])],
1792            );
1793        let ctx = EvaluationContext::with_navigator("55001", &external, &[], &nav);
1794
1795        let values = ctx.collect_group_values("RFF", 0, 1, &["SG4", "SG6"]);
1796        assert_eq!(values.len(), 2);
1797        assert_eq!(values[0], (0, "REF001".to_string()));
1798        assert_eq!(values[1], (1, "REF002".to_string()));
1799    }
1800
1801    #[test]
1802    fn test_groups_share_qualified_value() {
1803        let external = NoOpExternalProvider;
1804        // SG6[0]: RFF+Z49 with value "TS001"
1805        // SG8[0]: SEQ with c286 value "TS001" (matches)
1806        // SG8[1]: SEQ with c286 value "TS999" (no match)
1807        let nav = MockGroupNavigator::new()
1808            .with_group(
1809                &["SG4", "SG6"],
1810                0,
1811                vec![make_segment("RFF", vec![vec!["Z49", "TS001"]])],
1812            )
1813            .with_group(
1814                &["SG4", "SG8"],
1815                0,
1816                vec![make_segment("SEQ", vec![vec!["Z98"], vec!["TS001"]])],
1817            )
1818            .with_group(
1819                &["SG4", "SG8"],
1820                1,
1821                vec![make_segment("SEQ", vec![vec!["Z01"], vec!["TS999"]])],
1822            );
1823        let ctx = EvaluationContext::with_navigator("55001", &external, &[], &nav);
1824
1825        // RFF+Z49 value "TS001" matches SEQ value at [1][0] → True
1826        assert_eq!(
1827            ctx.groups_share_qualified_value(
1828                "RFF",
1829                0,
1830                "Z49",
1831                0,
1832                1,
1833                &["SG4", "SG6"],
1834                "SEQ",
1835                1,
1836                0,
1837                &["SG4", "SG8"],
1838            ),
1839            ConditionResult::True,
1840        );
1841    }
1842
1843    #[test]
1844    fn test_groups_share_qualified_value_no_match() {
1845        let external = NoOpExternalProvider;
1846        let nav = MockGroupNavigator::new()
1847            .with_group(
1848                &["SG4", "SG6"],
1849                0,
1850                vec![make_segment("RFF", vec![vec!["Z49", "TS001"]])],
1851            )
1852            .with_group(
1853                &["SG4", "SG8"],
1854                0,
1855                vec![make_segment("SEQ", vec![vec!["Z98"], vec!["TS999"]])],
1856            );
1857        let ctx = EvaluationContext::with_navigator("55001", &external, &[], &nav);
1858
1859        // No matching value → False
1860        assert_eq!(
1861            ctx.groups_share_qualified_value(
1862                "RFF",
1863                0,
1864                "Z49",
1865                0,
1866                1,
1867                &["SG4", "SG6"],
1868                "SEQ",
1869                1,
1870                0,
1871                &["SG4", "SG8"],
1872            ),
1873            ConditionResult::False,
1874        );
1875    }
1876
1877    #[test]
1878    fn test_groups_share_qualified_value_no_source() {
1879        let external = NoOpExternalProvider;
1880        // No RFF+Z49 at all
1881        let nav = MockGroupNavigator::new()
1882            .with_group(
1883                &["SG4", "SG6"],
1884                0,
1885                vec![make_segment("RFF", vec![vec!["Z13", "55001"]])],
1886            )
1887            .with_group(
1888                &["SG4", "SG8"],
1889                0,
1890                vec![make_segment("SEQ", vec![vec!["Z98"], vec!["TS001"]])],
1891            );
1892        let ctx = EvaluationContext::with_navigator("55001", &external, &[], &nav);
1893
1894        // No source qualifier match → Unknown
1895        assert_eq!(
1896            ctx.groups_share_qualified_value(
1897                "RFF",
1898                0,
1899                "Z49",
1900                0,
1901                1,
1902                &["SG4", "SG6"],
1903                "SEQ",
1904                1,
1905                0,
1906                &["SG4", "SG8"],
1907            ),
1908            ConditionResult::Unknown,
1909        );
1910    }
1911
1912    #[test]
1913    fn test_groups_share_qualified_value_fallback() {
1914        // No navigator — falls back to message-wide
1915        let segments = vec![
1916            make_segment("RFF", vec![vec!["Z49", "TS001"]]),
1917            make_segment("SEQ", vec![vec!["Z98"], vec!["TS001"]]),
1918        ];
1919        let external = NoOpExternalProvider;
1920        let ctx = EvaluationContext::new("55001", &external, &segments);
1921
1922        assert_eq!(
1923            ctx.groups_share_qualified_value(
1924                "RFF",
1925                0,
1926                "Z49",
1927                0,
1928                1,
1929                &["SG4", "SG6"],
1930                "SEQ",
1931                1,
1932                0,
1933                &["SG4", "SG8"],
1934            ),
1935            ConditionResult::True,
1936        );
1937    }
1938
1939    #[test]
1940    fn test_child_group_pass_throughs_no_navigator() {
1941        let external = NoOpExternalProvider;
1942        let ctx = EvaluationContext::new("55001", &external, &[]);
1943
1944        assert_eq!(
1945            ctx.child_group_instance_count(&["SG4", "SG8"], 0, "SG10"),
1946            0
1947        );
1948        assert!(ctx
1949            .find_segments_in_child_group("CCI", &["SG4", "SG8"], 0, "SG10", 0)
1950            .is_empty());
1951        assert_eq!(
1952            ctx.extract_value_in_group("SEQ", 0, 0, &["SG4", "SG8"], 0),
1953            None
1954        );
1955    }
1956
1957    // --- has_segment_matching tests ---
1958
1959    #[test]
1960    fn test_has_segment_matching_found() {
1961        let segments = vec![
1962            make_segment("STS", vec![vec!["7"], vec!["E01"], vec!["ZW4"]]),
1963            make_segment("STS", vec![vec!["Z20"], vec!["Z32"], vec!["A99"]]),
1964        ];
1965        let external = NoOpExternalProvider;
1966        let ctx = EvaluationContext::new("55001", &external, &segments);
1967
1968        assert_eq!(
1969            ctx.has_segment_matching("STS", &[(0, 0, "Z20"), (1, 0, "Z32"), (2, 0, "A99")]),
1970            ConditionResult::True,
1971        );
1972    }
1973
1974    #[test]
1975    fn test_has_segment_matching_not_found() {
1976        let segments = vec![make_segment(
1977            "STS",
1978            vec![vec!["7"], vec!["E01"], vec!["ZW4"]],
1979        )];
1980        let external = NoOpExternalProvider;
1981        let ctx = EvaluationContext::new("55001", &external, &segments);
1982
1983        assert_eq!(
1984            ctx.has_segment_matching("STS", &[(0, 0, "Z20"), (1, 0, "Z32")]),
1985            ConditionResult::False,
1986        );
1987    }
1988
1989    #[test]
1990    fn test_has_segment_matching_no_segments() {
1991        let external = NoOpExternalProvider;
1992        let ctx = EvaluationContext::new("55001", &external, &[]);
1993
1994        assert_eq!(
1995            ctx.has_segment_matching("STS", &[(0, 0, "Z20")]),
1996            ConditionResult::Unknown,
1997        );
1998    }
1999
2000    #[test]
2001    fn test_has_segment_matching_in_group() {
2002        let nav = MockGroupNavigator::new()
2003            .with_group(
2004                &["SG4"],
2005                0,
2006                vec![make_segment("STS", vec![vec!["7"], vec!["E01"]])],
2007            )
2008            .with_group(
2009                &["SG4"],
2010                1,
2011                vec![make_segment("STS", vec![vec!["Z20"], vec!["Z32"]])],
2012            );
2013        let segments = vec![
2014            make_segment("STS", vec![vec!["7"], vec!["E01"]]),
2015            make_segment("STS", vec![vec!["Z20"], vec!["Z32"]]),
2016        ];
2017        let external = NoOpExternalProvider;
2018        let ctx = EvaluationContext::with_navigator("55001", &external, &segments, &nav);
2019
2020        assert_eq!(
2021            ctx.has_segment_matching_in_group("STS", &[(0, 0, "Z20"), (1, 0, "Z32")], &["SG4"]),
2022            ConditionResult::True,
2023        );
2024    }
2025
2026    // --- DTM comparison tests ---
2027
2028    #[test]
2029    fn test_dtm_ge() {
2030        let segments = vec![make_segment(
2031            "DTM",
2032            vec![vec!["137", "202601010000", "303"]],
2033        )];
2034        let external = NoOpExternalProvider;
2035        let ctx = EvaluationContext::new("55001", &external, &segments);
2036
2037        assert_eq!(ctx.dtm_ge("137", "202601010000"), ConditionResult::True);
2038        assert_eq!(ctx.dtm_ge("137", "202501010000"), ConditionResult::True);
2039        assert_eq!(ctx.dtm_ge("137", "202701010000"), ConditionResult::False);
2040        assert_eq!(ctx.dtm_ge("999", "202601010000"), ConditionResult::Unknown);
2041    }
2042
2043    #[test]
2044    fn test_dtm_lt() {
2045        let segments = vec![make_segment(
2046            "DTM",
2047            vec![vec!["137", "202601010000", "303"]],
2048        )];
2049        let external = NoOpExternalProvider;
2050        let ctx = EvaluationContext::new("55001", &external, &segments);
2051
2052        assert_eq!(ctx.dtm_lt("137", "202701010000"), ConditionResult::True);
2053        assert_eq!(ctx.dtm_lt("137", "202601010000"), ConditionResult::False);
2054        assert_eq!(ctx.dtm_lt("137", "202501010000"), ConditionResult::False);
2055    }
2056
2057    #[test]
2058    fn test_dtm_le() {
2059        let segments = vec![make_segment(
2060            "DTM",
2061            vec![vec!["137", "202601010000", "303"]],
2062        )];
2063        let external = NoOpExternalProvider;
2064        let ctx = EvaluationContext::new("55001", &external, &segments);
2065
2066        assert_eq!(ctx.dtm_le("137", "202601010000"), ConditionResult::True);
2067        assert_eq!(ctx.dtm_le("137", "202701010000"), ConditionResult::True);
2068        assert_eq!(ctx.dtm_le("137", "202501010000"), ConditionResult::False);
2069    }
2070
2071    // --- Count helpers tests ---
2072
2073    #[test]
2074    fn test_count_qualified_in_group() {
2075        let nav = MockGroupNavigator::new()
2076            .with_group(
2077                &["SG4", "SG8"],
2078                0,
2079                vec![
2080                    make_segment("CCI", vec![vec!["Z23"]]),
2081                    make_segment("CCI", vec![vec!["Z30"]]),
2082                ],
2083            )
2084            .with_group(
2085                &["SG4", "SG8"],
2086                1,
2087                vec![make_segment("CCI", vec![vec!["Z23"]])],
2088            );
2089        let segments = vec![
2090            make_segment("CCI", vec![vec!["Z23"]]),
2091            make_segment("CCI", vec![vec!["Z30"]]),
2092            make_segment("CCI", vec![vec!["Z23"]]),
2093        ];
2094        let external = NoOpExternalProvider;
2095        let ctx = EvaluationContext::with_navigator("55001", &external, &segments, &nav);
2096
2097        assert_eq!(
2098            ctx.count_qualified_in_group("CCI", 0, "Z23", &["SG4", "SG8"]),
2099            2
2100        );
2101        assert_eq!(
2102            ctx.count_qualified_in_group("CCI", 0, "Z30", &["SG4", "SG8"]),
2103            1
2104        );
2105        assert_eq!(
2106            ctx.count_qualified_in_group("CCI", 0, "Z99", &["SG4", "SG8"]),
2107            0
2108        );
2109    }
2110
2111    #[test]
2112    fn test_count_in_group() {
2113        let nav = MockGroupNavigator::new()
2114            .with_group(
2115                &["SG4", "SG8"],
2116                0,
2117                vec![
2118                    make_segment("SEQ", vec![vec!["Z98"]]),
2119                    make_segment("CCI", vec![vec!["Z23"]]),
2120                ],
2121            )
2122            .with_group(
2123                &["SG4", "SG8"],
2124                1,
2125                vec![make_segment("SEQ", vec![vec!["Z01"]])],
2126            );
2127        let segments = vec![
2128            make_segment("SEQ", vec![vec!["Z98"]]),
2129            make_segment("CCI", vec![vec!["Z23"]]),
2130            make_segment("SEQ", vec![vec!["Z01"]]),
2131        ];
2132        let external = NoOpExternalProvider;
2133        let ctx = EvaluationContext::with_navigator("55001", &external, &segments, &nav);
2134
2135        assert_eq!(ctx.count_in_group("SEQ", &["SG4", "SG8"]), 2);
2136        assert_eq!(ctx.count_in_group("CCI", &["SG4", "SG8"]), 1);
2137        assert_eq!(ctx.count_in_group("DTM", &["SG4", "SG8"]), 0);
2138    }
2139
2140    #[test]
2141    fn test_count_fallback_no_navigator() {
2142        let segments = vec![
2143            make_segment("CCI", vec![vec!["Z23"]]),
2144            make_segment("CCI", vec![vec!["Z30"]]),
2145            make_segment("CCI", vec![vec!["Z23"]]),
2146        ];
2147        let external = NoOpExternalProvider;
2148        let ctx = EvaluationContext::new("55001", &external, &segments);
2149
2150        // Falls back to message-wide count
2151        assert_eq!(
2152            ctx.count_qualified_in_group("CCI", 0, "Z23", &["SG4", "SG8"]),
2153            2
2154        );
2155        assert_eq!(ctx.count_in_group("CCI", &["SG4", "SG8"]), 3);
2156    }
2157
2158    // --- format_check / format_check_qualified tests ---
2159
2160    #[test]
2161    fn format_check_prefers_resolved_value() {
2162        // Segments have "WRONG" but resolved_value is "CORRECT"
2163        let segments = vec![make_segment("DTM", vec![vec!["92", "WRONG"]])];
2164        let external = NoOpExternalProvider;
2165        let ctx = EvaluationContext::new("55001", &external, &segments)
2166            .with_resolved(Some("CORRECT"), None);
2167
2168        let result = ctx.format_check("DTM", 0, 1, |v| ConditionResult::from(v == "CORRECT"));
2169        assert_eq!(result, ConditionResult::True);
2170    }
2171
2172    #[test]
2173    fn format_check_falls_back_to_segment_search() {
2174        // No resolved_value, segments have the value
2175        let segments = vec![make_segment("DTM", vec![vec!["92", "202501011200"]])];
2176        let external = NoOpExternalProvider;
2177        let ctx = EvaluationContext::new("55001", &external, &segments);
2178
2179        let result = ctx.format_check("DTM", 0, 1, |v| ConditionResult::from(v == "202501011200"));
2180        assert_eq!(result, ConditionResult::True);
2181    }
2182
2183    #[test]
2184    fn format_check_returns_unknown_when_segment_absent() {
2185        // No resolved_value, no matching segments — we can't decide.
2186        let segments: Vec<OwnedSegment> = vec![];
2187        let external = NoOpExternalProvider;
2188        let ctx = EvaluationContext::new("55001", &external, &segments);
2189
2190        let result = ctx.format_check("DTM", 0, 1, |_| ConditionResult::True);
2191        assert_eq!(result, ConditionResult::Unknown);
2192    }
2193
2194    #[test]
2195    fn format_check_returns_unknown_when_multiple_segments() {
2196        // Two DTMs, no resolved_value. Picking .first() would silently pick
2197        // one; Unknown is the honest answer when we can't tell which is being
2198        // validated.
2199        let segments = vec![
2200            make_segment("DTM", vec![vec!["92", "OK"]]),
2201            make_segment("DTM", vec![vec!["163", "BAD"]]),
2202        ];
2203        let external = NoOpExternalProvider;
2204        let ctx = EvaluationContext::new("55001", &external, &segments);
2205
2206        let result = ctx.format_check("DTM", 0, 1, |v| ConditionResult::from(v == "OK"));
2207        assert_eq!(result, ConditionResult::Unknown);
2208    }
2209
2210    #[test]
2211    fn format_check_qualified_prefers_resolved_value() {
2212        // Two DTM segments but resolved_value overrides
2213        let segments = vec![
2214            make_segment("DTM", vec![vec!["92", "WRONG"]]),
2215            make_segment("DTM", vec![vec!["163", "ALSO_WRONG"]]),
2216        ];
2217        let external = NoOpExternalProvider;
2218        let ctx = EvaluationContext::new("55001", &external, &segments)
2219            .with_resolved(Some("CORRECT"), None);
2220
2221        let result = ctx.format_check_qualified("DTM", 0, "163", 0, 1, |v| {
2222            ConditionResult::from(v == "CORRECT")
2223        });
2224        assert_eq!(result, ConditionResult::True);
2225    }
2226
2227    #[test]
2228    fn format_check_qualified_falls_back_to_qualified_search() {
2229        // Two DTM segments with different qualifiers, no resolved_value
2230        let segments = vec![
2231            make_segment("DTM", vec![vec!["92", "2200"]]),
2232            make_segment("DTM", vec![vec!["163", "0800"]]),
2233        ];
2234        let external = NoOpExternalProvider;
2235        let ctx = EvaluationContext::new("55001", &external, &segments);
2236
2237        // Should find DTM+163 and extract "0800", not DTM+92's "2200"
2238        let result = ctx.format_check_qualified("DTM", 0, "163", 0, 1, |v| {
2239            ConditionResult::from(v == "0800")
2240        });
2241        assert_eq!(result, ConditionResult::True);
2242
2243        // Verify it does NOT pick DTM+92's value
2244        let result2 = ctx.format_check_qualified("DTM", 0, "163", 0, 1, |v| {
2245            ConditionResult::from(v == "2200")
2246        });
2247        assert_eq!(result2, ConditionResult::False);
2248    }
2249
2250    // --- self_segment_value / self_segment_value_equals tests ---
2251
2252    #[test]
2253    fn self_segment_value_prefers_resolved_segment() {
2254        // Two DTM segments in the message: the first has "OTHER", the
2255        // resolved_segment (the one being validated) has "303". Per-field
2256        // condition must read "303", not "OTHER".
2257        let segments = vec![
2258            make_segment("DTM", vec![vec!["472", "", "OTHER"]]),
2259            make_segment("DTM", vec![vec!["Z25", "", "303"]]),
2260        ];
2261        let external = NoOpExternalProvider;
2262        let resolved_elements = vec![vec!["Z25".to_string(), String::new(), "303".to_string()]];
2263        let ctx = EvaluationContext::new("55001", &external, &segments)
2264            .with_resolved(None, Some(&resolved_elements));
2265
2266        assert_eq!(ctx.self_segment_value("DTM", 0, 2), Some("303"));
2267        assert!(ctx.self_segment_value_equals("DTM", 0, 2, "303"));
2268        assert!(!ctx.self_segment_value_equals("DTM", 0, 2, "OTHER"));
2269    }
2270
2271    #[test]
2272    fn self_segment_value_falls_back_when_no_resolved_segment() {
2273        // Outside tree context — fall back to the first tag match.
2274        let segments = vec![
2275            make_segment("DTM", vec![vec!["Z25", "", "303"]]),
2276            make_segment("DTM", vec![vec!["472", "", "OTHER"]]),
2277        ];
2278        let external = NoOpExternalProvider;
2279        let ctx = EvaluationContext::new("55001", &external, &segments);
2280
2281        assert_eq!(ctx.self_segment_value("DTM", 0, 2), Some("303"));
2282    }
2283
2284    #[test]
2285    fn self_segment_value_missing_returns_none() {
2286        let segments: Vec<OwnedSegment> = vec![];
2287        let external = NoOpExternalProvider;
2288        let ctx = EvaluationContext::new("55001", &external, &segments);
2289
2290        assert_eq!(ctx.self_segment_value("DTM", 0, 2), None);
2291        assert!(!ctx.self_segment_value_equals("DTM", 0, 2, "303"));
2292    }
2293
2294    /// Build a UTILMD-like segment sequence where each SG8 is:
2295    ///   SEQ+Q[:zid] then PIA+5+<obis>
2296    /// The input `sg8s` is a list of `(qualifier, zeitraum_id, obis)`.
2297    fn make_sg8_sequence(sg8s: &[(&str, &str, &str)]) -> Vec<OwnedSegment> {
2298        let mut segments = Vec::new();
2299        for (i, (qual, zid, obis)) in sg8s.iter().enumerate() {
2300            segments.push(OwnedSegment {
2301                id: "SEQ".to_string(),
2302                elements: vec![vec![qual.to_string()], vec![zid.to_string()]],
2303                segment_number: (2 * i + 1) as u32,
2304            });
2305            segments.push(OwnedSegment {
2306                id: "PIA".to_string(),
2307                elements: vec![vec!["5".to_string()], vec![obis.to_string()]],
2308                segment_number: (2 * i + 2) as u32,
2309            });
2310        }
2311        segments
2312    }
2313
2314    #[test]
2315    fn count_sg8_obis_matches_counts_matching_kumuliert() {
2316        // Two SG8s with same qualifier Z02 + zid "ZR1", two kumuliert OBIS.
2317        let segments = make_sg8_sequence(&[
2318            ("Z02", "ZR1", "1-1:1.8.0"), // kumuliert
2319            ("Z02", "ZR1", "1-1:2.8.0"), // kumuliert
2320            ("Z02", "ZR2", "1-1:1.8.0"), // different zid — shouldn't count
2321            ("ZA1", "ZR1", "1-1:1.8.0"), // different qualifier — shouldn't count
2322        ]);
2323        let external = NoOpExternalProvider;
2324        // Anchor on first SEQ (Z02, ZR1).
2325        let seq = &segments[0].elements;
2326        let ctx =
2327            EvaluationContext::new("55001", &external, &segments).with_resolved(None, Some(seq));
2328
2329        let n = ctx
2330            .count_sg8_obis_matches(super::super::format_validators::is_obis_wirkarbeit_kumuliert);
2331        assert_eq!(n, 2);
2332    }
2333
2334    #[test]
2335    fn count_sg8_obis_matches_returns_zero_without_resolved_segment() {
2336        let segments = make_sg8_sequence(&[("Z02", "ZR1", "1-1:1.8.0")]);
2337        let external = NoOpExternalProvider;
2338        let ctx = EvaluationContext::new("55001", &external, &segments);
2339        let n = ctx
2340            .count_sg8_obis_matches(super::super::format_validators::is_obis_wirkarbeit_kumuliert);
2341        assert_eq!(n, 0);
2342    }
2343
2344    #[test]
2345    fn count_sg8_obis_matches_quarter_hour_and_kumuliert_are_disjoint() {
2346        let segments = make_sg8_sequence(&[
2347            ("Z02", "ZR1", "1-1:1.8.0"),  // kumuliert
2348            ("Z02", "ZR1", "1-1:1.29.0"), // 1/4 h
2349        ]);
2350        let external = NoOpExternalProvider;
2351        let seq = &segments[0].elements;
2352        let ctx =
2353            EvaluationContext::new("55001", &external, &segments).with_resolved(None, Some(seq));
2354
2355        assert_eq!(
2356            ctx.count_sg8_obis_matches(
2357                super::super::format_validators::is_obis_wirkarbeit_kumuliert
2358            ),
2359            1
2360        );
2361        assert_eq!(
2362            ctx.count_sg8_obis_matches(
2363                super::super::format_validators::is_obis_wirkarbeit_quarter_hour
2364            ),
2365            1
2366        );
2367    }
2368}