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