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