Skip to main content

mig_bo4e/
code_lookup.rs

1//! Code enrichment lookup — maps EDIFACT companion field codes to human-readable meanings.
2//!
3//! Built from PID schema JSON files. Used by the mapping engine to automatically
4//! enrich companion field values during forward mapping (EDIFACT → BO4E).
5
6use serde_json::Value;
7use std::collections::{BTreeMap, HashMap, HashSet};
8use std::path::Path;
9
10/// Lookup key: (source_path, segment_tag, qualifier, element_index, component_index).
11///
12/// `source_path` matches the TOML `source_path` field (e.g., "sg4.sg8_z01.sg10").
13/// `segment_tag` is uppercase (e.g., "CCI", "CAV").
14/// `qualifier` is the segment's discriminating qualifier when one applies (RFF/STS/CCI:
15/// element 0 component 0; DTM: c507.d2005). `None` for segments without a qualifier
16/// convention. The qualifier slot scopes lookups so that, for instance, RFF+TN's
17/// type=data d1154 (free-text Vorgangsnummer) is not confused with RFF+Z13's
18/// type=code d1154 (PID-identifier) at the same path/elem/comp.
19pub type CodeLookupKey = (String, String, Option<String>, usize, usize);
20
21/// Enrichment data for a single EDIFACT code value.
22#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
23pub struct CodeEnrichment {
24    pub meaning: String,
25    pub enum_key: Option<String>,
26}
27
28/// Maps EDIFACT code values to their enrichment data (meaning + optional enum key).
29/// E.g., "Z15" → CodeEnrichment { meaning: "Haushaltskunde gem. EnWG", enum_key: Some("HAUSHALTSKUNDE_ENWG") }.
30pub type CodeMeanings = BTreeMap<String, CodeEnrichment>;
31
32/// Complete code lookup table built from a PID schema JSON.
33///
34/// Entries are scoped by the segment variant's qualifier. Tags with a qualifier
35/// convention (RFF/STS/CCI/DTM) are stored *only* under their qualifier. Other
36/// tags (NAD, CAV, FTX, …) are stored under `None` (the union of all same-tag
37/// segments, for unqualified lookups) and additionally under their own leading
38/// code when the schema fixes it to one value (e.g. `CAV+Z91`), so a qualified
39/// lookup (`cav[Z91]`) sees only the codes of that segment variant.
40#[derive(Debug, Clone, Default)]
41pub struct CodeLookup {
42    entries: BTreeMap<CodeLookupKey, CodeMeanings>,
43    /// `(source_path, segment_tag, qualifier)` of every qualifier-scoped entry.
44    /// Derived from `entries` (not serialized). A qualifier listed here names a
45    /// known segment variant, whose entries are authoritative: lookups for it
46    /// never fall back to the unqualified union.
47    variants: HashSet<(String, String, String)>,
48}
49
50// Custom serialization: convert tuple keys to "source_path|segment_tag|qualifier|elem|comp"
51// strings. An empty qualifier slot serializes as the empty string between the surrounding
52// pipes (e.g., "sg4|DTM||0|0"). Entries are written sorted by that key so the committed
53// cache files are byte-stable.
54impl serde::Serialize for CodeLookup {
55    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
56        use serde::ser::SerializeMap;
57        let mut entries: Vec<(String, &CodeMeanings)> = self
58            .entries
59            .iter()
60            .map(|((path, tag, qual, elem, comp), meanings)| {
61                let q = qual.as_deref().unwrap_or("");
62                (format!("{path}|{tag}|{q}|{elem}|{comp}"), meanings)
63            })
64            .collect();
65        entries.sort_by(|a, b| a.0.cmp(&b.0));
66        let mut map = serializer.serialize_map(Some(entries.len()))?;
67        for (key, meanings) in entries {
68            map.serialize_entry(&key, meanings)?;
69        }
70        map.end()
71    }
72}
73
74impl<'de> serde::Deserialize<'de> for CodeLookup {
75    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
76        let raw: HashMap<String, CodeMeanings> = HashMap::deserialize(deserializer)?;
77        let mut entries = BTreeMap::new();
78        for (key_str, meanings) in raw {
79            let parts: Vec<&str> = key_str.splitn(5, '|').collect();
80            if parts.len() == 5 {
81                let qual = if parts[2].is_empty() {
82                    None
83                } else {
84                    Some(parts[2].to_string())
85                };
86                let elem: usize = parts[3].parse().map_err(serde::de::Error::custom)?;
87                let comp: usize = parts[4].parse().map_err(serde::de::Error::custom)?;
88                entries.insert(
89                    (parts[0].to_string(), parts[1].to_string(), qual, elem, comp),
90                    meanings,
91                );
92            }
93        }
94        Ok(Self::from_entries(entries))
95    }
96}
97
98impl CodeLookup {
99    /// Build a CodeLookup from a PID schema JSON file.
100    pub fn from_schema_file(path: &Path) -> Result<Self, std::io::Error> {
101        let content = std::fs::read_to_string(path)?;
102        let schema: Value = serde_json::from_str(&content)
103            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
104        Ok(Self::from_schema_value(&schema))
105    }
106
107    /// Build a CodeLookup from an already-parsed PID schema JSON value.
108    pub fn from_schema_value(schema: &Value) -> Self {
109        let mut entries = BTreeMap::new();
110        if let Some(fields) = schema.get("fields").and_then(|f| f.as_object()) {
111            for (group_key, group_value) in fields {
112                Self::walk_group(group_key, group_value, &mut entries);
113            }
114        }
115        // Root-level segments (BGM, DTM, etc.) use empty source_path.
116        if let Some(root_segments) = schema.get("root_segments").and_then(|s| s.as_array()) {
117            for segment in root_segments {
118                let seg_id = segment
119                    .get("id")
120                    .and_then(|v| v.as_str())
121                    .unwrap_or("")
122                    .to_uppercase();
123                Self::process_segment("", &seg_id, segment, &mut entries);
124            }
125        }
126        Self::add_base_path_aggregates(&mut entries);
127        Self::from_entries(entries)
128    }
129
130    fn from_entries(entries: BTreeMap<CodeLookupKey, CodeMeanings>) -> Self {
131        let variants = entries
132            .keys()
133            .filter_map(|(path, tag, qual, _, _)| {
134                qual.as_ref()
135                    .map(|q| (path.clone(), tag.clone(), q.clone()))
136            })
137            .collect();
138        Self { entries, variants }
139    }
140
141    /// Codes a mapped field is enriched with (`{code, meaning}`), `None` if the
142    /// engine writes it as a plain value.
143    ///
144    /// `path_qualifier` is the field path's `tag[Q]` selector, `disc_qualifier`
145    /// the definition's discriminator value when the discriminator is on the same
146    /// segment tag. Which positions get enriched follows the discriminator (as it
147    /// always has); a path qualifier only narrows the codes to the segment variant
148    /// the field actually reads, and suppresses enrichment where that variant has
149    /// no code at the position (e.g. `rff[ACW].c506.d1154` next to RFF+Z13).
150    pub fn enrichment_codes(
151        &self,
152        source_path: &str,
153        segment_tag: &str,
154        path_qualifier: Option<&str>,
155        disc_qualifier: Option<&str>,
156        element_index: usize,
157        component_index: usize,
158    ) -> Option<&CodeMeanings> {
159        let at = |q| self.resolve_q(source_path, segment_tag, q, element_index, component_index);
160        let path_variant =
161            path_qualifier.filter(|q| self.is_known_variant(source_path, segment_tag, q));
162        match (path_variant, disc_qualifier) {
163            (Some(p), Some(d)) if p != d => at(Some(p)),
164            (Some(p), None) => at(None).and(at(Some(p))),
165            _ => at(disc_qualifier),
166        }
167    }
168
169    /// Codes a mapped field can hold, whether enriched or not: those of the
170    /// segment variant selected by the path qualifier or the same-tag
171    /// discriminator; without either, the union over every variant (the field
172    /// reads whichever segment instance is present). `None` if not a code field.
173    pub fn field_codes(
174        &self,
175        source_path: &str,
176        segment_tag: &str,
177        path_qualifier: Option<&str>,
178        disc_qualifier: Option<&str>,
179        element_index: usize,
180        component_index: usize,
181    ) -> Option<CodeMeanings> {
182        match path_qualifier.or(disc_qualifier) {
183            Some(q) => self
184                .resolve_q(
185                    source_path,
186                    segment_tag,
187                    Some(q),
188                    element_index,
189                    component_index,
190                )
191                .cloned(),
192            None => Some(self.codes_all_qualifiers(
193                source_path,
194                segment_tag,
195                element_index,
196                component_index,
197            ))
198            .filter(|c| !c.is_empty()),
199        }
200    }
201
202    /// Whether `qualifier` names a segment variant of `segment_tag` at `source_path`
203    /// (i.e. the schema has a `segment_tag` segment whose leading code is fixed to it).
204    pub fn is_known_variant(&self, source_path: &str, segment_tag: &str, qualifier: &str) -> bool {
205        self.variants.contains(&(
206            source_path.to_string(),
207            segment_tag.to_string(),
208            qualifier.to_string(),
209        ))
210    }
211
212    /// The entry a qualifier-aware lookup resolves to: the qualifier's own entry;
213    /// for a qualifier that names no segment variant of the tag (or no qualifier),
214    /// the unqualified entry. A known variant never falls back to the unqualified
215    /// union — that would hand it the codes of a *different* segment variant (e.g.
216    /// RFF+Z13's PID code for RFF+ACW's free reference number).
217    fn resolve_q(
218        &self,
219        source_path: &str,
220        segment_tag: &str,
221        qualifier: Option<&str>,
222        element_index: usize,
223        component_index: usize,
224    ) -> Option<&CodeMeanings> {
225        let key = |q: Option<&str>| {
226            (
227                source_path.to_string(),
228                segment_tag.to_string(),
229                q.map(String::from),
230                element_index,
231                component_index,
232            )
233        };
234        match qualifier {
235            Some(q) if self.is_known_variant(source_path, segment_tag, q) => {
236                self.entries.get(&key(Some(q)))
237            }
238            Some(q) => self
239                .entries
240                .get(&key(Some(q)))
241                .or_else(|| self.entries.get(&key(None))),
242            None => self.entries.get(&key(None)),
243        }
244    }
245
246    /// Check if the field at the given position is a code-type field.
247    ///
248    /// Legacy shim — scans across all qualifier slots and returns true if ANY
249    /// matching entry exists for the (path, tag, elem, comp) tuple. This drifts
250    /// from the original "call _q with None" prescription but is more useful
251    /// for tests that don't have a qualifier handy. Production code paths use
252    /// [`is_code_field_q`] with the discriminator qualifier and a `None`
253    /// fallback for tags without a stored qualifier convention.
254    #[deprecated(
255        note = "use is_code_field_q with the discriminator qualifier; this shim scans across all qualifiers"
256    )]
257    pub fn is_code_field(
258        &self,
259        source_path: &str,
260        segment_tag: &str,
261        element_index: usize,
262        component_index: usize,
263    ) -> bool {
264        // Match if either the unqualified entry exists or any qualifier-scoped
265        // entry matches the path/tag/elem/comp.
266        self.entries.iter().any(|((p, t, _q, e, c), _)| {
267            p == source_path && t == segment_tag && *e == element_index && *c == component_index
268        })
269    }
270
271    /// Qualifier-aware variant: check if the position is a code field for the
272    /// given qualifier.
273    ///
274    /// When `qualifier` names a segment variant of the tag (see
275    /// [`is_known_variant`](Self::is_known_variant)), only that variant's entry
276    /// counts: RFF+TN d1154 (data) is not a code field even though RFF+Z13 d1154
277    /// is. A qualifier that names no variant (e.g. a discriminator value of
278    /// another segment) and `None` use the unqualified entry.
279    pub fn is_code_field_q(
280        &self,
281        source_path: &str,
282        segment_tag: &str,
283        qualifier: Option<&str>,
284        element_index: usize,
285        component_index: usize,
286    ) -> bool {
287        self.resolve_q(
288            source_path,
289            segment_tag,
290            qualifier,
291            element_index,
292            component_index,
293        )
294        .is_some()
295    }
296
297    /// All codes (with their enrichment data) of the code field at the given
298    /// position, resolved exactly like [`is_code_field_q`]. `None` if the
299    /// position is not a code field (for that segment variant).
300    ///
301    /// [`is_code_field_q`]: Self::is_code_field_q
302    pub fn codes_q(
303        &self,
304        source_path: &str,
305        segment_tag: &str,
306        qualifier: Option<&str>,
307        element_index: usize,
308        component_index: usize,
309    ) -> Option<&CodeMeanings> {
310        self.resolve_q(
311            source_path,
312            segment_tag,
313            qualifier,
314            element_index,
315            component_index,
316        )
317    }
318
319    /// Codes of the given position under every qualifier slot, merged. A
320    /// definition without a discriminator reads whichever segment instance is
321    /// present, so its values range over all of them.
322    pub fn codes_all_qualifiers(
323        &self,
324        source_path: &str,
325        segment_tag: &str,
326        element_index: usize,
327        component_index: usize,
328    ) -> CodeMeanings {
329        // Deterministic merge: the unqualified slot first, then by qualifier.
330        let mut slots: Vec<(&Option<String>, &CodeMeanings)> = self
331            .entries
332            .iter()
333            .filter(|((p, t, _, e, c), _)| {
334                p == source_path && t == segment_tag && *e == element_index && *c == component_index
335            })
336            .map(|((_, _, q, _, _), meanings)| (q, meanings))
337            .collect();
338        slots.sort_by(|a, b| a.0.cmp(b.0));
339        let mut merged = CodeMeanings::new();
340        for (_, meanings) in slots {
341            for (code, enrichment) in meanings {
342                merged
343                    .entry(code.clone())
344                    .or_insert_with(|| enrichment.clone());
345            }
346        }
347        merged
348    }
349
350    /// Get the full enrichment data for a code value at the given position.
351    ///
352    /// Legacy shim — scans across all qualifier slots. See [`enrichment_for_q`]
353    /// for the qualifier-aware version used by the engine. Kept for tests.
354    #[deprecated(
355        note = "use enrichment_for_q with the discriminator qualifier; this shim scans across all qualifiers"
356    )]
357    pub fn enrichment_for(
358        &self,
359        source_path: &str,
360        segment_tag: &str,
361        element_index: usize,
362        component_index: usize,
363        value: &str,
364    ) -> Option<&CodeEnrichment> {
365        // Try unqualified first, then any qualifier-scoped match.
366        let unqualified_key = (
367            source_path.to_string(),
368            segment_tag.to_string(),
369            None,
370            element_index,
371            component_index,
372        );
373        if let Some(e) = self
374            .entries
375            .get(&unqualified_key)
376            .and_then(|meanings| meanings.get(value))
377        {
378            return Some(e);
379        }
380        self.entries
381            .iter()
382            .filter(|((p, t, q, e, c), _)| {
383                p == source_path
384                    && t == segment_tag
385                    && q.is_some()
386                    && *e == element_index
387                    && *c == component_index
388            })
389            .find_map(|(_, meanings)| meanings.get(value))
390    }
391
392    /// Qualifier-aware enrichment lookup, resolved like
393    /// [`is_code_field_q`](Self::is_code_field_q).
394    pub fn enrichment_for_q(
395        &self,
396        source_path: &str,
397        segment_tag: &str,
398        qualifier: Option<&str>,
399        element_index: usize,
400        component_index: usize,
401        value: &str,
402    ) -> Option<&CodeEnrichment> {
403        self.resolve_q(
404            source_path,
405            segment_tag,
406            qualifier,
407            element_index,
408            component_index,
409        )
410        .and_then(|meanings| meanings.get(value))
411    }
412
413    /// Get the human-readable meaning for a code value at the given position.
414    /// Returns `None` if the position is not a code field or the value is unknown.
415    ///
416    /// Legacy shim — scans across all qualifier slots via [`enrichment_for`].
417    /// Kept for tests; production code paths use the qualifier-aware
418    /// `enrichment_for_q`.
419    #[deprecated(
420        note = "use enrichment_for_q with the discriminator qualifier; this shim scans across all qualifiers"
421    )]
422    pub fn meaning_for(
423        &self,
424        source_path: &str,
425        segment_tag: &str,
426        element_index: usize,
427        component_index: usize,
428        value: &str,
429    ) -> Option<&str> {
430        #[allow(deprecated)]
431        self.enrichment_for(
432            source_path,
433            segment_tag,
434            element_index,
435            component_index,
436            value,
437        )
438        .map(|e| e.meaning.as_str())
439    }
440
441    /// Whether this code-field's only allowed value equals the given PID.
442    /// Used to suppress decoration for self-referential PID-identifier fields
443    /// (Class C in the 2026-04-28 audit). The qualifier scopes the lookup —
444    /// e.g., RFF+Z13's d1154 in PID 55002 has `value=55002` as the lone code,
445    /// so calling with `qualifier=Some("Z13"), pid="55002"` returns true.
446    pub fn is_pid_self_reference(
447        &self,
448        source_path: &str,
449        segment_tag: &str,
450        qualifier: Option<&str>,
451        element_index: usize,
452        component_index: usize,
453        pid: &str,
454    ) -> bool {
455        let key = (
456            source_path.to_string(),
457            segment_tag.to_string(),
458            qualifier.map(String::from),
459            element_index,
460            component_index,
461        );
462        if let Some(meanings) = self.entries.get(&key) {
463            meanings.len() == 1 && meanings.contains_key(pid)
464        } else {
465            false
466        }
467    }
468
469    /// Walk a group node recursively, collecting code entries.
470    fn walk_group(
471        path_prefix: &str,
472        group: &Value,
473        entries: &mut BTreeMap<CodeLookupKey, CodeMeanings>,
474    ) {
475        if let Some(segments) = group.get("segments").and_then(|s| s.as_array()) {
476            for segment in segments {
477                let seg_id = segment
478                    .get("id")
479                    .and_then(|v| v.as_str())
480                    .unwrap_or("")
481                    .to_uppercase();
482                Self::process_segment(path_prefix, &seg_id, segment, entries);
483            }
484        }
485        if let Some(children) = group.get("children").and_then(|c| c.as_object()) {
486            for (child_key, child_value) in children {
487                let child_path = format!("{}.{}", path_prefix, child_key);
488                Self::walk_group(&child_path, child_value, entries);
489            }
490        }
491    }
492
493    /// Process a single segment, collecting code entries for its elements/components.
494    ///
495    /// Extracts the segment's qualifier (per-tag convention) and uses it to scope
496    /// the entries. This avoids the (path, tag, elem, comp) collision between
497    /// type=code and type=data segments at the same position (e.g., RFF+Z13's
498    /// PID-identifier d1154 vs RFF+TN's free-text Vorgangsnummer d1154).
499    fn process_segment(
500        source_path: &str,
501        segment_tag: &str,
502        segment: &Value,
503        entries: &mut BTreeMap<CodeLookupKey, CodeMeanings>,
504    ) {
505        let Some(elements) = segment.get("elements").and_then(|e| e.as_array()) else {
506            return;
507        };
508
509        let qualifier = Self::extract_qualifier(segment_tag, elements);
510        // Tags without a qualifier convention keep their unqualified entries and
511        // are additionally registered under their own fixed leading code.
512        let own_variant = if qualifier.is_none() && !Self::has_qualifier_convention(segment_tag) {
513            Self::single_leading_code(elements)
514        } else {
515            None
516        };
517        let slots: Vec<Option<String>> = std::iter::once(qualifier)
518            .chain(own_variant.map(Some))
519            .collect();
520
521        for element in elements {
522            let element_index = element.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
523
524            // Simple element (no composite) with codes
525            if let Some("code") = element.get("type").and_then(|v| v.as_str()) {
526                if let Some(codes) = element.get("codes").and_then(|c| c.as_array()) {
527                    let meanings = Self::extract_codes(codes);
528                    if !meanings.is_empty() {
529                        for slot in &slots {
530                            let key = (
531                                source_path.to_string(),
532                                segment_tag.to_string(),
533                                slot.clone(),
534                                element_index,
535                                0,
536                            );
537                            entries.entry(key).or_default().extend(meanings.clone());
538                        }
539                    }
540                }
541            }
542
543            // Composite components
544            if let Some(components) = element.get("components").and_then(|c| c.as_array()) {
545                for component in components {
546                    if let Some("code") = component.get("type").and_then(|v| v.as_str()) {
547                        let sub_index = component
548                            .get("sub_index")
549                            .and_then(|v| v.as_u64())
550                            .unwrap_or(0) as usize;
551                        if let Some(codes) = component.get("codes").and_then(|c| c.as_array()) {
552                            let meanings = Self::extract_codes(codes);
553                            if !meanings.is_empty() {
554                                for slot in &slots {
555                                    let key = (
556                                        source_path.to_string(),
557                                        segment_tag.to_string(),
558                                        slot.clone(),
559                                        element_index,
560                                        sub_index,
561                                    );
562                                    entries.entry(key).or_default().extend(meanings.clone());
563                                }
564                            }
565                        }
566                    }
567                }
568            }
569        }
570    }
571
572    /// Extract a segment's discriminating qualifier from its schema element list.
573    ///
574    /// Conventions:
575    /// - `RFF`, `STS`, `CCI`: qualifier is the type=code value at element 0,
576    ///   component 0 (RFF d1153, STS d9013, CCI d7059).
577    /// - `DTM`: qualifier is at composite c507's component 0 (d2005). This is
578    ///   the same physical position (element 0 component 0) — DTM's element 0
579    ///   IS the c507 composite — so the same lookup applies.
580    /// - All other tags: no qualifier convention; returns `None`.
581    ///
582    /// Only single-value enumerations count as a qualifier (the schema lists
583    /// exactly one allowed code at that position). Segments whose first
584    /// component lists multiple codes don't have a discriminating qualifier
585    /// at the schema level and fall back to `None`.
586    fn extract_qualifier(segment_tag: &str, elements: &[Value]) -> Option<String> {
587        if !Self::has_qualifier_convention(segment_tag) {
588            return None;
589        }
590        Self::single_leading_code(elements)
591    }
592
593    /// Tags whose entries are stored exclusively under their qualifier.
594    fn has_qualifier_convention(segment_tag: &str) -> bool {
595        matches!(segment_tag, "RFF" | "STS" | "CCI" | "DTM")
596    }
597
598    /// The segment's leading code (element 0, component 0) when the schema fixes
599    /// it to exactly one value — the value a `tag[QUAL]` field path selects on.
600    fn single_leading_code(elements: &[Value]) -> Option<String> {
601        // Find element index 0 (or the first element if no index 0 is set).
602        let element0 = elements
603            .iter()
604            .find(|el| el.get("index").and_then(|v| v.as_u64()) == Some(0))
605            .or_else(|| elements.first())?;
606
607        // Inspect component sub_index 0.
608        let component0 = element0
609            .get("components")
610            .and_then(|c| c.as_array())
611            .and_then(|comps| {
612                comps
613                    .iter()
614                    .find(|c| c.get("sub_index").and_then(|v| v.as_u64()) == Some(0))
615                    .or_else(|| comps.first())
616            });
617
618        let codes_node = if let Some(comp) = component0 {
619            // Composite case (RFF/DTM/CCI/STS — qualifier nested inside composite).
620            if comp.get("type").and_then(|v| v.as_str()) == Some("code") {
621                comp.get("codes").and_then(|c| c.as_array())
622            } else {
623                None
624            }
625        } else if element0.get("type").and_then(|v| v.as_str()) == Some("code") {
626            // Simple-element case.
627            element0.get("codes").and_then(|c| c.as_array())
628        } else {
629            None
630        };
631
632        let codes = codes_node?;
633        if codes.len() != 1 {
634            return None; // Multiple allowed qualifiers — not a single discriminator.
635        }
636        codes[0]
637            .get("value")
638            .and_then(|v| v.as_str())
639            .map(|s| s.to_string())
640    }
641
642    /// Register every entry under the base paths of its variant segments too.
643    ///
644    /// The schema keys a group variant by its qualifier (`sg12_z63`,
645    /// `sg2_ms`); a TOML may read the group without it (`sg4.sg12`, the
646    /// Geschaeftspartner pattern) or read a child below it that way
647    /// (`sg2.sg5_ic`). Each path is registered once per combination of its
648    /// variant segments reduced to their base: `sg2_ms.sg5_ic` also as
649    /// `sg2.sg5_ic`, `sg2_ms.sg5` and `sg2.sg5`. Codes of all variants union
650    /// at a base path; the qualifier slot stays in the key, so NAD+Z63 and
651    /// NAD+Z65 remain apart. This holds at the top level and for a single
652    /// variant as well — INVOIC 31004 has only `sg2_ms`/`sg2_mr` at the root.
653    fn add_base_path_aggregates(entries: &mut BTreeMap<CodeLookupKey, CodeMeanings>) {
654        let mut aggregates: BTreeMap<CodeLookupKey, CodeMeanings> = BTreeMap::new();
655        for ((path, tag, qual, elem, comp), meanings) in entries.iter() {
656            let segments: Vec<&str> = path.split('.').collect();
657            let variants: Vec<usize> = (0..segments.len())
658                .filter(|&i| segments[i].contains('_'))
659                .collect();
660            for mask in 1u32..(1 << variants.len()) {
661                let mut base = segments.clone();
662                for (bit, &i) in variants.iter().enumerate() {
663                    if mask & (1 << bit) != 0 {
664                        base[i] = segments[i].split('_').next().unwrap_or(segments[i]);
665                    }
666                }
667                aggregates
668                    .entry((base.join("."), tag.clone(), qual.clone(), *elem, *comp))
669                    .or_default()
670                    .extend(meanings.iter().map(|(k, v)| (k.clone(), v.clone())));
671            }
672        }
673        for (key, meanings) in aggregates {
674            entries.entry(key).or_default().extend(meanings);
675        }
676    }
677
678    /// Extract code value→enrichment mappings from a JSON codes array.
679    fn extract_codes(codes: &[Value]) -> CodeMeanings {
680        let mut meanings = BTreeMap::new();
681        for code in codes {
682            if let (Some(value), Some(name)) = (
683                code.get("value").and_then(|v| v.as_str()),
684                code.get("name").and_then(|v| v.as_str()),
685            ) {
686                let enum_key = code
687                    .get("enum")
688                    .and_then(|v| v.as_str())
689                    .map(|s| s.to_string());
690                meanings.insert(
691                    value.to_string(),
692                    CodeEnrichment {
693                        meaning: name.to_string(),
694                        enum_key,
695                    },
696                );
697            }
698        }
699        meanings
700    }
701}
702
703#[cfg(test)]
704#[allow(deprecated)]
705mod tests {
706    use super::*;
707
708    #[test]
709    fn test_parse_pid_55001_schema() {
710        let schema_path = Path::new(concat!(
711            env!("CARGO_MANIFEST_DIR"),
712            "/../../crates/mig-types/src/generated/fv2504/utilmd/pids/pid_55001_schema.json"
713        ));
714        if !schema_path.exists() {
715            eprintln!("Skipping: PID schema not found");
716            return;
717        }
718
719        let lookup = CodeLookup::from_schema_file(schema_path).unwrap();
720
721        // CCI element 2 component 0 in sg4.sg8_z01.sg10 — Haushaltskunde codes
722        assert!(lookup.is_code_field("sg4.sg8_z01.sg10", "CCI", 2, 0));
723        assert_eq!(
724            lookup.meaning_for("sg4.sg8_z01.sg10", "CCI", 2, 0, "Z15"),
725            Some("Haushaltskunde gem. EnWG")
726        );
727        assert_eq!(
728            lookup.meaning_for("sg4.sg8_z01.sg10", "CCI", 2, 0, "Z18"),
729            Some("Kein Haushaltskunde gem. EnWG")
730        );
731
732        // CCI element 0 in sg4.sg8_z79.sg10 — Produkteigenschaft
733        assert!(lookup.is_code_field("sg4.sg8_z79.sg10", "CCI", 0, 0));
734        assert_eq!(
735            lookup.meaning_for("sg4.sg8_z79.sg10", "CCI", 0, 0, "Z66"),
736            Some("Produkteigenschaft")
737        );
738
739        // CAV element 0 component 0 — code field
740        assert!(lookup.is_code_field("sg4.sg8_z79.sg10", "CAV", 0, 0));
741
742        // CAV element 0 component 3 — data field, NOT a code
743        assert!(!lookup.is_code_field("sg4.sg8_z79.sg10", "CAV", 0, 3));
744
745        // LOC element 1 — data field
746        assert!(!lookup.is_code_field("sg4.sg5_z16", "LOC", 1, 0));
747    }
748
749    #[test]
750    fn test_from_inline_schema() {
751        let schema = serde_json::json!({
752            "fields": {
753                "sg4": {
754                    "children": {
755                        "sg8_test": {
756                            "children": {
757                                "sg10": {
758                                    "segments": [{
759                                        "id": "CCI",
760                                        "elements": [{
761                                            "index": 2,
762                                            "components": [{
763                                                "sub_index": 0,
764                                                "type": "code",
765                                                "codes": [
766                                                    {"value": "A1", "name": "Alpha"},
767                                                    {"value": "B2", "name": "Beta"}
768                                                ]
769                                            }]
770                                        }]
771                                    }],
772                                    "source_group": "SG10"
773                                }
774                            },
775                            "segments": [],
776                            "source_group": "SG8"
777                        }
778                    },
779                    "segments": [],
780                    "source_group": "SG4"
781                }
782            }
783        });
784
785        let lookup = CodeLookup::from_schema_value(&schema);
786
787        assert!(lookup.is_code_field("sg4.sg8_test.sg10", "CCI", 2, 0));
788        assert_eq!(
789            lookup.meaning_for("sg4.sg8_test.sg10", "CCI", 2, 0, "A1"),
790            Some("Alpha")
791        );
792        assert_eq!(
793            lookup.meaning_for("sg4.sg8_test.sg10", "CCI", 2, 0, "B2"),
794            Some("Beta")
795        );
796        assert_eq!(
797            lookup.meaning_for("sg4.sg8_test.sg10", "CCI", 2, 0, "XX"),
798            None
799        );
800        assert!(!lookup.is_code_field("sg4.sg8_test.sg10", "CCI", 0, 0));
801    }
802
803    #[test]
804    fn test_discriminated_variant_merge() {
805        // Schema with discriminated SG12 variants (sg12_z63, sg12_z65)
806        let schema = serde_json::json!({
807            "fields": {
808                "sg4": {
809                    "children": {
810                        "sg12_z63": {
811                            "segments": [{
812                                "id": "NAD",
813                                "elements": [{
814                                    "index": 0,
815                                    "type": "code",
816                                    "codes": [{"value": "Z63", "name": "Standortadresse"}]
817                                }]
818                            }],
819                            "source_group": "SG12"
820                        },
821                        "sg12_z65": {
822                            "segments": [{
823                                "id": "NAD",
824                                "elements": [
825                                    {
826                                        "index": 0,
827                                        "type": "code",
828                                        "codes": [{"value": "Z65", "name": "Kunde des LF"}]
829                                    },
830                                    {
831                                        "index": 3,
832                                        "components": [{
833                                            "sub_index": 5,
834                                            "type": "code",
835                                            "codes": [
836                                                {"value": "Z01", "name": "Herr"},
837                                                {"value": "Z02", "name": "Frau"}
838                                            ]
839                                        }]
840                                    }
841                                ]
842                            }],
843                            "source_group": "SG12"
844                        }
845                    },
846                    "segments": [],
847                    "source_group": "SG4"
848                }
849            }
850        });
851
852        let lookup = CodeLookup::from_schema_value(&schema);
853
854        // Variant-specific paths still work
855        assert!(lookup.is_code_field("sg4.sg12_z63", "NAD", 0, 0));
856        assert!(lookup.is_code_field("sg4.sg12_z65", "NAD", 0, 0));
857
858        // Base path also works (merged from variants)
859        assert!(lookup.is_code_field("sg4.sg12", "NAD", 0, 0));
860        assert_eq!(
861            lookup.meaning_for("sg4.sg12", "NAD", 0, 0, "Z63"),
862            Some("Standortadresse")
863        );
864        assert_eq!(
865            lookup.meaning_for("sg4.sg12", "NAD", 0, 0, "Z65"),
866            Some("Kunde des LF")
867        );
868
869        // Anrede code from z65 also available at base path
870        assert!(lookup.is_code_field("sg4.sg12", "NAD", 3, 5));
871        assert_eq!(
872            lookup.meaning_for("sg4.sg12", "NAD", 3, 5, "Z01"),
873            Some("Herr")
874        );
875    }
876
877    /// INVOIC 31004 has only top-level variants (`sg2_ms`, `sg2_mr`) and a
878    /// child under one of them (`sg2_ms.sg5_ic`); its mappings read `sg2` and
879    /// `sg2.sg5_ic`. Aggregates were built only for a group's children with
880    /// two or more variants, so both lookups missed and the codes went
881    /// unenriched and unaudited.
882    #[test]
883    fn base_paths_cover_top_level_variants_and_their_descendants() {
884        let nad = |q: &str| {
885            serde_json::json!({
886                "id": "NAD",
887                "elements": [{"index": 0, "type": "code", "codes": [{"value": q, "name": q}]}]
888            })
889        };
890        let schema = serde_json::json!({
891            "fields": {
892                "sg2_ms": {
893                    "segments": [nad("MS")],
894                    "children": {
895                        "sg5_ic": {
896                            "segments": [{
897                                "id": "CTA",
898                                "elements": [{"index": 0, "type": "code",
899                                              "codes": [{"value": "IC", "name": "Informationskontakt"}]}]
900                            }]
901                        }
902                    }
903                },
904                "sg2_mr": { "segments": [nad("MR")] },
905                "sg50_z01": { "segments": [nad("Z01")] }
906            }
907        });
908        let lookup = CodeLookup::from_schema_value(&schema);
909
910        let codes = |path: &str, tag: &str| {
911            lookup
912                .field_codes(path, tag, None, None, 0, 0)
913                .map(|c| c.keys().cloned().collect::<Vec<_>>())
914        };
915        assert_eq!(codes("sg2", "NAD"), Some(vec!["MR".into(), "MS".into()]));
916        assert_eq!(codes("sg2.sg5_ic", "CTA"), Some(vec!["IC".into()]));
917        assert_eq!(codes("sg2.sg5", "CTA"), Some(vec!["IC".into()]));
918        // A single variant has a base path too.
919        assert_eq!(codes("sg50", "NAD"), Some(vec!["Z01".into()]));
920        // Variant paths are untouched.
921        assert_eq!(codes("sg2_ms", "NAD"), Some(vec!["MS".into()]));
922    }
923
924    #[test]
925    fn test_pid_55013_sg12_base_path() {
926        let schema_path = Path::new(concat!(
927            env!("CARGO_MANIFEST_DIR"),
928            "/../../crates/mig-types/src/generated/fv2504/utilmd/pids/pid_55013_schema.json"
929        ));
930        if !schema_path.exists() {
931            eprintln!("Skipping: PID schema not found");
932            return;
933        }
934
935        let lookup = CodeLookup::from_schema_file(schema_path).unwrap();
936
937        // Base path "sg4.sg12" should have merged NAD qualifier codes from all variants
938        assert!(lookup.is_code_field("sg4.sg12", "NAD", 0, 0));
939        // Z67 meaning comes from sg12_z67 variant
940        assert!(lookup.meaning_for("sg4.sg12", "NAD", 0, 0, "Z67").is_some());
941        // All 7 SG12 qualifiers should be present
942        for code in &["Z63", "Z65", "Z66", "Z67", "Z68", "Z69", "Z70"] {
943            assert!(
944                lookup.meaning_for("sg4.sg12", "NAD", 0, 0, code).is_some(),
945                "Missing meaning for NAD qualifier {code} at base path sg4.sg12"
946            );
947        }
948    }
949
950    #[test]
951    fn test_multi_segment_code_merge() {
952        // SG10 with 3 CCI segments at same element position but different codes.
953        // All codes should be merged, not overwritten by last CCI.
954        let schema = serde_json::json!({
955            "fields": {
956                "sg4": {
957                    "children": {
958                        "sg8_z98": {
959                            "children": {
960                                "sg10": {
961                                    "segments": [
962                                        {
963                                            "id": "CCI",
964                                            "elements": [{"index": 2, "components": [{
965                                                "sub_index": 0, "type": "code",
966                                                "codes": [{"value": "ZB3", "name": "Zugeordneter Marktpartner"}]
967                                            }]}]
968                                        },
969                                        {
970                                            "id": "CAV",
971                                            "elements": [{"index": 0, "components": [{
972                                                "sub_index": 0, "type": "code",
973                                                "codes": [{"value": "Z91", "name": "MSB"}]
974                                            }]}]
975                                        },
976                                        {
977                                            "id": "CCI",
978                                            "elements": [{"index": 2, "components": [{
979                                                "sub_index": 0, "type": "code",
980                                                "codes": [{"value": "E03", "name": "Spannungsebene"}]
981                                            }]}]
982                                        },
983                                        {
984                                            "id": "CAV",
985                                            "elements": [{"index": 0, "components": [{
986                                                "sub_index": 0, "type": "code",
987                                                "codes": [
988                                                    {"value": "E05", "name": "Mittelspannung"},
989                                                    {"value": "E06", "name": "Niederspannung"}
990                                                ]
991                                            }]}]
992                                        },
993                                        {
994                                            "id": "CCI",
995                                            "elements": [{"index": 2, "components": [{
996                                                "sub_index": 0, "type": "code",
997                                                "codes": [
998                                                    {"value": "Z15", "name": "Haushaltskunde"},
999                                                    {"value": "Z18", "name": "Kein Haushaltskunde"}
1000                                                ]
1001                                            }]}]
1002                                        }
1003                                    ],
1004                                    "source_group": "SG10"
1005                                }
1006                            },
1007                            "segments": [],
1008                            "source_group": "SG8"
1009                        }
1010                    },
1011                    "segments": [],
1012                    "source_group": "SG4"
1013                }
1014            }
1015        });
1016
1017        let lookup = CodeLookup::from_schema_value(&schema);
1018
1019        // All CCI codes at (2,0) should be present (merged, not overwritten)
1020        assert_eq!(
1021            lookup.meaning_for("sg4.sg8_z98.sg10", "CCI", 2, 0, "ZB3"),
1022            Some("Zugeordneter Marktpartner")
1023        );
1024        assert_eq!(
1025            lookup.meaning_for("sg4.sg8_z98.sg10", "CCI", 2, 0, "E03"),
1026            Some("Spannungsebene")
1027        );
1028        assert_eq!(
1029            lookup.meaning_for("sg4.sg8_z98.sg10", "CCI", 2, 0, "Z15"),
1030            Some("Haushaltskunde")
1031        );
1032
1033        // All CAV codes at (0,0) should be present
1034        assert_eq!(
1035            lookup.meaning_for("sg4.sg8_z98.sg10", "CAV", 0, 0, "Z91"),
1036            Some("MSB")
1037        );
1038        assert_eq!(
1039            lookup.meaning_for("sg4.sg8_z98.sg10", "CAV", 0, 0, "E06"),
1040            Some("Niederspannung")
1041        );
1042    }
1043
1044    #[test]
1045    fn test_enrichment_for_with_enum() {
1046        let schema = serde_json::json!({
1047            "fields": {
1048                "sg4": {
1049                    "children": {
1050                        "sg10": {
1051                            "segments": [{
1052                                "id": "CCI",
1053                                "elements": [{
1054                                    "index": 2,
1055                                    "components": [{
1056                                        "sub_index": 0,
1057                                        "type": "code",
1058                                        "codes": [
1059                                            {"value": "Z15", "name": "Haushaltskunde", "enum": "HAUSHALTSKUNDE"},
1060                                            {"value": "Z18", "name": "Kein Haushaltskunde", "enum": "KEIN_HAUSHALTSKUNDE"}
1061                                        ]
1062                                    }]
1063                                }]
1064                            }],
1065                            "source_group": "SG10"
1066                        }
1067                    },
1068                    "segments": [],
1069                    "source_group": "SG4"
1070                }
1071            }
1072        });
1073
1074        let lookup = CodeLookup::from_schema_value(&schema);
1075
1076        let enrichment = lookup.enrichment_for("sg4.sg10", "CCI", 2, 0, "Z15");
1077        assert!(enrichment.is_some());
1078        let e = enrichment.unwrap();
1079        assert_eq!(e.meaning, "Haushaltskunde");
1080        assert_eq!(e.enum_key.as_deref(), Some("HAUSHALTSKUNDE"));
1081
1082        let e2 = lookup
1083            .enrichment_for("sg4.sg10", "CCI", 2, 0, "Z18")
1084            .unwrap();
1085        assert_eq!(e2.enum_key.as_deref(), Some("KEIN_HAUSHALTSKUNDE"));
1086
1087        // meaning_for still works
1088        assert_eq!(
1089            lookup.meaning_for("sg4.sg10", "CCI", 2, 0, "Z15"),
1090            Some("Haushaltskunde")
1091        );
1092    }
1093
1094    #[test]
1095    fn test_backward_compat_no_enum() {
1096        // Old schema format without "enum" field — should still work, enum_key is None
1097        let schema = serde_json::json!({
1098            "fields": {
1099                "sg4": {
1100                    "children": {
1101                        "sg10": {
1102                            "segments": [{
1103                                "id": "CCI",
1104                                "elements": [{
1105                                    "index": 2,
1106                                    "components": [{
1107                                        "sub_index": 0,
1108                                        "type": "code",
1109                                        "codes": [
1110                                            {"value": "Z15", "name": "Haushaltskunde"}
1111                                        ]
1112                                    }]
1113                                }]
1114                            }],
1115                            "source_group": "SG10"
1116                        }
1117                    },
1118                    "segments": [],
1119                    "source_group": "SG4"
1120                }
1121            }
1122        });
1123
1124        let lookup = CodeLookup::from_schema_value(&schema);
1125        let enrichment = lookup.enrichment_for("sg4.sg10", "CCI", 2, 0, "Z15");
1126        assert!(enrichment.is_some());
1127        let e = enrichment.unwrap();
1128        assert_eq!(e.meaning, "Haushaltskunde");
1129        assert_eq!(e.enum_key, None); // No enum in old schema
1130    }
1131
1132    /// SG15 of IFTSTA 21037: RFF+Z13 (d1154 = the PID, a code), RFF+ACW and
1133    /// RFF+ACE (d1154 = free reference, data); plus two CAV variants whose value
1134    /// component carries different code lists.
1135    fn qualified_variants_schema() -> Value {
1136        let rff = |qual: &str, name: &str, id_codes: Option<Value>| {
1137            let id = match id_codes {
1138                Some(codes) => {
1139                    serde_json::json!({"sub_index": 1, "id": "1154", "type": "code", "codes": codes})
1140                }
1141                None => serde_json::json!({"sub_index": 1, "id": "1154", "type": "data"}),
1142            };
1143            serde_json::json!({"id": "RFF", "elements": [{"index": 0, "composite": "C506", "components": [
1144                {"sub_index": 0, "id": "1153", "type": "code", "codes": [{"value": qual, "name": name}]},
1145                id,
1146            ]}]})
1147        };
1148        let cav = |qual: &str, codes: Value| {
1149            serde_json::json!({"id": "CAV", "elements": [{"index": 0, "composite": "C889", "components": [
1150                {"sub_index": 0, "id": "7111", "type": "code", "codes": [{"value": qual, "name": qual}]},
1151                {"sub_index": 1, "id": "7110", "type": "code", "codes": codes},
1152            ]}]})
1153        };
1154        serde_json::json!({"fields": {"sg14": {"segments": [], "children": {"sg15": {"segments": [
1155            rff("Z13", "Prüfidentifikator", Some(serde_json::json!([{"value": "21037", "name": "RD / NB-Bewertung"}]))),
1156            rff("ACW", "Referenznummer einer vorangegangenen Nachricht", None),
1157            rff("ACE", "Nummer des zugehörigen Dokuments", None),
1158            cav("Z91", serde_json::json!([{"value": "A", "name": "Alpha"}, {"value": "B", "name": "Beta"}])),
1159            cav("ZF0", serde_json::json!([{"value": "C", "name": "Gamma"}])),
1160        ]}}}}})
1161    }
1162
1163    #[test]
1164    fn qualified_lookup_uses_only_codes_of_that_segment_variant() {
1165        let lookup = CodeLookup::from_schema_value(&qualified_variants_schema());
1166        let sp = "sg14.sg15";
1167
1168        // RFF+ACW / RFF+ACE d1154 are data: no fallback to RFF+Z13's PID code.
1169        for qual in ["ACW", "ACE"] {
1170            assert!(
1171                lookup.codes_q(sp, "RFF", Some(qual), 0, 1).is_none(),
1172                "{qual}"
1173            );
1174            assert!(
1175                !lookup.is_code_field_q(sp, "RFF", Some(qual), 0, 1),
1176                "{qual}"
1177            );
1178            assert!(lookup
1179                .enrichment_for_q(sp, "RFF", Some(qual), 0, 1, "21037")
1180                .is_none());
1181        }
1182        let z13: Vec<&String> = lookup
1183            .codes_q(sp, "RFF", Some("Z13"), 0, 1)
1184            .unwrap()
1185            .keys()
1186            .collect();
1187        assert_eq!(z13, ["21037"]);
1188        let acw: Vec<&String> = lookup
1189            .codes_q(sp, "RFF", Some("ACW"), 0, 0)
1190            .unwrap()
1191            .keys()
1192            .collect();
1193        assert_eq!(acw, ["ACW"]);
1194
1195        // Tags without a qualifier convention (CAV) are variant-scoped too when a
1196        // qualifier is given; without one they keep the union of all variants.
1197        let z91: Vec<&String> = lookup
1198            .codes_q(sp, "CAV", Some("Z91"), 0, 1)
1199            .unwrap()
1200            .keys()
1201            .collect();
1202        assert_eq!(z91, ["A", "B"]);
1203        assert!(lookup
1204            .enrichment_for_q(sp, "CAV", Some("Z91"), 0, 1, "C")
1205            .is_none());
1206        let zf0: Vec<&String> = lookup
1207            .codes_q(sp, "CAV", Some("ZF0"), 0, 1)
1208            .unwrap()
1209            .keys()
1210            .collect();
1211        assert_eq!(zf0, ["C"]);
1212        let all: Vec<&String> = lookup
1213            .codes_q(sp, "CAV", None, 0, 1)
1214            .unwrap()
1215            .keys()
1216            .collect();
1217        assert_eq!(all, ["A", "B", "C"]);
1218        // A qualifier that names no variant of the tag (e.g. a discriminator on
1219        // another segment) still falls back to the unqualified entry.
1220        assert!(lookup.is_code_field_q(sp, "CAV", Some("Z98"), 0, 1));
1221    }
1222
1223    #[test]
1224    fn qualified_lookup_survives_cache_serialization() {
1225        let lookup = CodeLookup::from_schema_value(&qualified_variants_schema());
1226        let back: CodeLookup =
1227            serde_json::from_str(&serde_json::to_string(&lookup).unwrap()).unwrap();
1228        assert!(back
1229            .codes_q("sg14.sg15", "RFF", Some("ACW"), 0, 1)
1230            .is_none());
1231        let z91: Vec<&String> = back
1232            .codes_q("sg14.sg15", "CAV", Some("Z91"), 0, 1)
1233            .unwrap()
1234            .keys()
1235            .collect();
1236        assert_eq!(z91, ["A", "B"]);
1237    }
1238
1239    #[test]
1240    fn rff_tn_in_55002_is_not_a_code_field() {
1241        let schema_path = Path::new(concat!(
1242            env!("CARGO_MANIFEST_DIR"),
1243            "/../../crates/mig-types/src/generated/fv2504/utilmd/pids/pid_55002_schema.json"
1244        ));
1245        if !schema_path.exists() {
1246            return;
1247        }
1248        let lookup = CodeLookup::from_schema_file(schema_path).unwrap();
1249
1250        // RFF+TN component 1 is type=data (Vorgangsnummer), must NOT be a code field.
1251        assert!(
1252            !lookup.is_code_field_q("sg4.sg6", "RFF", Some("TN"), 0, 1),
1253            "RFF+TN d1154 is free-text Vorgangsnummer, must not be classified as code"
1254        );
1255
1256        // RFF+Z13 component 1 IS a code field with the PID value (Class C; suppression
1257        // happens elsewhere — here we just confirm the lookup classifies it as code).
1258        assert!(
1259            lookup.is_code_field_q("sg4.sg6", "RFF", Some("Z13"), 0, 1),
1260            "RFF+Z13 d1154 is type=code with PID-identifier value"
1261        );
1262    }
1263
1264    #[test]
1265    fn pid_self_reference_detection() {
1266        let schema_path = Path::new(concat!(
1267            env!("CARGO_MANIFEST_DIR"),
1268            "/../../crates/mig-types/src/generated/fv2504/utilmd/pids/pid_55002_schema.json"
1269        ));
1270        if !schema_path.exists() {
1271            return;
1272        }
1273        let lookup = CodeLookup::from_schema_file(schema_path).unwrap();
1274
1275        // RFF+Z13 d1154's only allowed value is "55002" — the PID itself.
1276        assert!(
1277            lookup.is_pid_self_reference("sg4.sg6", "RFF", Some("Z13"), 0, 1, "55002"),
1278            "Z13 d1154 with single value '55002' must be detected as PID self-ref"
1279        );
1280        // Same field for a different PID should NOT count as self-reference.
1281        assert!(
1282            !lookup.is_pid_self_reference("sg4.sg6", "RFF", Some("Z13"), 0, 1, "55001"),
1283            "Z13 d1154's '55002' should not count as self-ref for PID 55001"
1284        );
1285    }
1286}