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