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