Skip to main content

thrust_wasm/
nasr.rs

1use std::collections::HashMap;
2
3use wasm_bindgen::prelude::*;
4
5use thrust::data::faa::nasr::parse_resolver_data_from_nasr_bytes;
6
7use crate::models::{
8    normalize_airway_name, AirportRecord, AirspaceCompositeRecord, AirspaceLayerRecord, AirspaceRecord, AirwayRecord,
9    NavpointRecord, ProcedureRecord,
10};
11
12fn compose_airspace(records: Vec<AirspaceRecord>) -> Option<AirspaceCompositeRecord> {
13    let first = records.first()?;
14    let designator = first.designator.clone();
15    let source = first.source.clone();
16    let name = records.iter().find_map(|r| r.name.clone());
17    let type_ = records.iter().find_map(|r| r.type_.clone());
18    let layers = records
19        .into_iter()
20        .map(|r| AirspaceLayerRecord {
21            lower: r.lower,
22            upper: r.upper,
23            coordinates: r.coordinates,
24        })
25        .collect();
26
27    Some(AirspaceCompositeRecord {
28        designator,
29        name,
30        type_,
31        layers,
32        source,
33    })
34}
35
36#[wasm_bindgen]
37pub struct NasrResolver {
38    airports: Vec<AirportRecord>,
39    navaids: Vec<NavpointRecord>,
40    airways: Vec<AirwayRecord>,
41    procedures: Vec<ProcedureRecord>,
42    airspaces: Vec<AirspaceRecord>,
43    airport_index: HashMap<String, Vec<usize>>,
44    navaid_index: HashMap<String, Vec<usize>>,
45    airway_index: HashMap<String, Vec<usize>>,
46    sid_index: HashMap<String, Vec<usize>>,
47    star_index: HashMap<String, Vec<usize>>,
48    airspace_index: HashMap<String, Vec<usize>>,
49}
50
51#[wasm_bindgen]
52impl NasrResolver {
53    fn resolve_procedure_by_kind(&self, kind: &str, name: &str) -> Option<ProcedureRecord> {
54        let key = name.to_uppercase();
55        let idx = match kind {
56            "SID" => self.sid_index.get(&key).and_then(|v| v.first()).copied(),
57            "STAR" => self.star_index.get(&key).and_then(|v| v.first()).copied(),
58            _ => None,
59        }?;
60        self.procedures.get(idx).cloned()
61    }
62
63    fn enrich_route_segments_internal(&self, route: &str) -> Vec<crate::field15::RouteSegment> {
64        use crate::field15::ResolvedPoint as WasmPoint;
65        use crate::field15::RouteSegment;
66        use thrust::data::field15::{Connector, Field15Element, Field15Parser, Point};
67
68        let elements = Field15Parser::parse(route);
69        let mut segments: Vec<RouteSegment> = Vec::new();
70        let mut last_point: Option<WasmPoint> = None;
71        let mut pending_airway: Option<(String, WasmPoint)> = None;
72        let mut pending_procedure: Option<String> = None;
73        let mut current_connector: Option<String> = None;
74        let mut current_segment_type: Option<String> = None;
75
76        let resolve_code = |code: &str| -> Option<WasmPoint> {
77            let key = code.split('/').next().unwrap_or(code).trim().to_uppercase();
78            if let Some(idx) = self.airport_index.get(&key).and_then(|v| v.first()) {
79                if let Some(a) = self.airports.get(*idx) {
80                    return Some(WasmPoint {
81                        latitude: a.latitude,
82                        longitude: a.longitude,
83                        name: Some(a.code.clone()),
84                        kind: Some("airport".to_string()),
85                    });
86                }
87            }
88            if let Some(idx) = self.navaid_index.get(&key).and_then(|v| v.first()) {
89                if let Some(n) = self.navaids.get(*idx) {
90                    return Some(WasmPoint {
91                        latitude: n.latitude,
92                        longitude: n.longitude,
93                        name: Some(n.code.clone()),
94                        kind: Some(n.kind.clone()),
95                    });
96                }
97            }
98            None
99        };
100
101        let expand_airway =
102            |airway_name: &str, entry: &WasmPoint, exit: &WasmPoint, segs: &mut Vec<RouteSegment>| -> bool {
103                let key = crate::models::normalize_airway_name(airway_name);
104                let airway = match self
105                    .airway_index
106                    .get(&key)
107                    .and_then(|v| v.first())
108                    .and_then(|i| self.airways.get(*i))
109                {
110                    Some(a) => a,
111                    None => return false,
112                };
113                let pts = &airway.points;
114                let entry_name = entry.name.as_deref().unwrap_or("").to_uppercase();
115                let exit_name = exit.name.as_deref().unwrap_or("").to_uppercase();
116                let entry_pos = pts.iter().position(|p| p.code.to_uppercase() == entry_name);
117                let exit_pos = pts.iter().position(|p| p.code.to_uppercase() == exit_name);
118                let (from, to) = match (entry_pos, exit_pos) {
119                    (Some(f), Some(t)) => (f, t),
120                    _ => return false,
121                };
122                let slice: Vec<&crate::models::AirwayPointRecord> = if from <= to {
123                    pts[from..=to].iter().collect()
124                } else {
125                    pts[to..=from].iter().rev().collect()
126                };
127                if slice.len() < 2 {
128                    return false;
129                }
130                let mut prev = entry.clone();
131                for pt in &slice[1..] {
132                    let next = WasmPoint {
133                        latitude: pt.latitude,
134                        longitude: pt.longitude,
135                        name: Some(pt.code.clone()),
136                        kind: Some(pt.kind.clone()),
137                    };
138                    segs.push(RouteSegment {
139                        start: prev,
140                        end: next.clone(),
141                        name: Some(airway_name.to_string()),
142                        segment_type: Some("route".to_string()),
143                        connector: Some(airway_name.to_string()),
144                    });
145                    prev = next;
146                }
147                true
148            };
149
150        let resolve_procedure = |kind: &str, name: &str| -> Option<&ProcedureRecord> {
151            let key = name.to_uppercase();
152            let idx = match kind {
153                "SID" => self.sid_index.get(&key).and_then(|v| v.first()).copied(),
154                "STAR" => self.star_index.get(&key).and_then(|v| v.first()).copied(),
155                _ => None,
156            }?;
157            self.procedures.get(idx)
158        };
159
160        let expand_procedure_from_entry =
161            |procedure_name: &str, kind: &str, entry: &WasmPoint, segs: &mut Vec<RouteSegment>| -> Option<WasmPoint> {
162                let proc = resolve_procedure(kind, procedure_name)?;
163                let pts = &proc.points;
164                if pts.len() < 2 {
165                    return None;
166                }
167                let entry_name = entry.name.as_deref().unwrap_or("").to_uppercase();
168                let start_idx = pts.iter().position(|p| p.code.to_uppercase() == entry_name)?;
169                if start_idx >= pts.len() - 1 {
170                    return None;
171                }
172
173                let mut prev = entry.clone();
174                for pt in &pts[start_idx + 1..] {
175                    let next = WasmPoint {
176                        latitude: pt.latitude,
177                        longitude: pt.longitude,
178                        name: Some(pt.code.clone()),
179                        kind: Some(pt.kind.clone()),
180                    };
181                    segs.push(RouteSegment {
182                        start: prev,
183                        end: next.clone(),
184                        name: Some(procedure_name.to_string()),
185                        segment_type: Some(kind.to_string()),
186                        connector: Some(procedure_name.to_string()),
187                    });
188                    prev = next;
189                }
190                Some(prev)
191            };
192
193        for element in &elements {
194            match element {
195                Field15Element::Point(point) => {
196                    let resolved = match point {
197                        Point::Waypoint(name) | Point::Aerodrome(name) => resolve_code(name),
198                        Point::Coordinates((lat, lon)) => Some(WasmPoint {
199                            latitude: *lat,
200                            longitude: *lon,
201                            name: None,
202                            kind: Some("coords".to_string()),
203                        }),
204                        Point::BearingDistance { point, .. } => match point.as_ref() {
205                            Point::Waypoint(name) | Point::Aerodrome(name) => resolve_code(name),
206                            Point::Coordinates((lat, lon)) => Some(WasmPoint {
207                                latitude: *lat,
208                                longitude: *lon,
209                                name: None,
210                                kind: Some("coords".to_string()),
211                            }),
212                            _ => None,
213                        },
214                    };
215                    if let Some(exit) = resolved {
216                        if let Some((airway_name, entry)) = pending_airway.take() {
217                            let expanded = expand_airway(&airway_name, &entry, &exit, &mut segments);
218                            if !expanded {
219                                segments.push(RouteSegment {
220                                    start: entry,
221                                    end: exit.clone(),
222                                    name: Some(airway_name.clone()),
223                                    segment_type: Some("unresolved".to_string()),
224                                    connector: Some(airway_name),
225                                });
226                            }
227                        } else if let Some(procedure_name) = pending_procedure.take() {
228                            if let Some(prev) = last_point.take() {
229                                segments.push(RouteSegment {
230                                    start: prev,
231                                    end: exit.clone(),
232                                    name: Some(procedure_name.clone()),
233                                    segment_type: Some("unresolved".to_string()),
234                                    connector: Some(procedure_name),
235                                });
236                            }
237                        } else if let Some(prev) = last_point.take() {
238                            let seg_name = current_connector.take();
239                            let seg_type = current_segment_type.take();
240                            let seg_connector = if seg_type.as_deref() == Some("dct") {
241                                Some("DCT".to_string())
242                            } else {
243                                seg_name.clone()
244                            };
245                            segments.push(RouteSegment {
246                                start: prev,
247                                end: exit.clone(),
248                                name: seg_name,
249                                segment_type: seg_type,
250                                connector: seg_connector,
251                            });
252                        } else {
253                            current_connector = None;
254                            current_segment_type = None;
255                        }
256                        last_point = Some(exit);
257                    }
258                }
259                Field15Element::Connector(connector) => match connector {
260                    Connector::Airway(name) => {
261                        if let Some(entry) = last_point.take() {
262                            pending_airway = Some((name.clone(), entry));
263                            current_segment_type = None;
264                        } else {
265                            current_connector = Some(name.clone());
266                            current_segment_type = Some("unresolved".to_string());
267                        }
268                    }
269                    Connector::Direct => {
270                        current_connector = None;
271                        pending_procedure = None;
272                        current_segment_type = Some("dct".to_string());
273                    }
274                    Connector::Sid(name) => {
275                        if let Some(entry) = last_point.clone() {
276                            if let Some(end) = expand_procedure_from_entry(name, "SID", &entry, &mut segments) {
277                                last_point = Some(end);
278                                current_connector = None;
279                                pending_airway = None;
280                                pending_procedure = None;
281                                current_segment_type = None;
282                            } else {
283                                current_connector = Some(name.clone());
284                                pending_procedure = Some(name.clone());
285                                current_segment_type = Some("unresolved".to_string());
286                            }
287                        } else {
288                            current_connector = Some(name.clone());
289                            pending_procedure = Some(name.clone());
290                            current_segment_type = Some("unresolved".to_string());
291                        }
292                    }
293                    Connector::Star(name) => {
294                        if let Some(entry) = last_point.clone() {
295                            if let Some(end) = expand_procedure_from_entry(name, "STAR", &entry, &mut segments) {
296                                last_point = Some(end);
297                                current_connector = None;
298                                pending_airway = None;
299                                pending_procedure = None;
300                                current_segment_type = None;
301                            } else {
302                                current_connector = Some(name.clone());
303                                pending_procedure = Some(name.clone());
304                                current_segment_type = Some("unresolved".to_string());
305                            }
306                        } else {
307                            current_connector = Some(name.clone());
308                            pending_procedure = Some(name.clone());
309                            current_segment_type = Some("unresolved".to_string());
310                        }
311                    }
312                    Connector::Nat(name) => {
313                        current_connector = Some(name.clone());
314                        pending_procedure = None;
315                        current_segment_type = Some("NAT".to_string());
316                    }
317                    Connector::Pts(name) => {
318                        current_connector = Some(name.clone());
319                        pending_procedure = None;
320                        current_segment_type = Some("PTS".to_string());
321                    }
322                    _ => {}
323                },
324                Field15Element::Modifier(_) => {}
325            }
326        }
327
328        segments
329    }
330
331    #[wasm_bindgen(constructor)]
332    pub fn new(zip_bytes: &[u8]) -> Result<NasrResolver, JsValue> {
333        let dataset = parse_resolver_data_from_nasr_bytes(zip_bytes).map_err(|e| JsValue::from_str(&e.to_string()))?;
334        let airports: Vec<AirportRecord> = dataset.airports.into_iter().map(Into::into).collect();
335        let navaids: Vec<NavpointRecord> = dataset.navaids.into_iter().map(Into::into).collect();
336        let airways: Vec<AirwayRecord> = dataset.airways.into_iter().map(Into::into).collect();
337        let procedures: Vec<ProcedureRecord> = dataset.procedures.into_iter().map(Into::into).collect();
338        let airspaces: Vec<AirspaceRecord> = dataset
339            .airspaces
340            .into_iter()
341            .map(|a| AirspaceRecord {
342                designator: a.designator,
343                name: a.name,
344                type_: a.type_,
345                lower: a.lower,
346                upper: a.upper,
347                coordinates: a.coordinates,
348                source: "faa_nasr".to_string(),
349            })
350            .collect();
351
352        let mut airport_index: HashMap<String, Vec<usize>> = HashMap::new();
353        for (i, a) in airports.iter().enumerate() {
354            airport_index.entry(a.code.clone()).or_default().push(i);
355            if let Some(v) = &a.iata {
356                airport_index.entry(v.clone()).or_default().push(i);
357            }
358            if let Some(v) = &a.icao {
359                airport_index.entry(v.clone()).or_default().push(i);
360            }
361        }
362
363        let mut navaid_index: HashMap<String, Vec<usize>> = HashMap::new();
364        for (i, n) in navaids.iter().enumerate() {
365            navaid_index.entry(n.code.clone()).or_default().push(i);
366            navaid_index.entry(n.identifier.clone()).or_default().push(i);
367        }
368
369        let mut airway_index: HashMap<String, Vec<usize>> = HashMap::new();
370        for (i, a) in airways.iter().enumerate() {
371            airway_index.entry(normalize_airway_name(&a.name)).or_default().push(i);
372            airway_index.entry(a.name.to_uppercase()).or_default().push(i);
373        }
374
375        let mut sid_index: HashMap<String, Vec<usize>> = HashMap::new();
376        let mut star_index: HashMap<String, Vec<usize>> = HashMap::new();
377        for (i, p) in procedures.iter().enumerate() {
378            let key = p.name.to_uppercase();
379            match p.procedure_kind.to_uppercase().as_str() {
380                "SID" => sid_index.entry(key).or_default().push(i),
381                "STAR" => star_index.entry(key).or_default().push(i),
382                _ => {}
383            }
384        }
385
386        let mut airspace_index: HashMap<String, Vec<usize>> = HashMap::new();
387        for (i, a) in airspaces.iter().enumerate() {
388            airspace_index.entry(a.designator.to_uppercase()).or_default().push(i);
389        }
390
391        Ok(Self {
392            airports,
393            navaids,
394            airways,
395            procedures,
396            airspaces,
397            airport_index,
398            navaid_index,
399            airway_index,
400            sid_index,
401            star_index,
402            airspace_index,
403        })
404    }
405
406    pub fn airports(&self) -> Result<JsValue, JsValue> {
407        serde_wasm_bindgen::to_value(&self.airports).map_err(|e| JsValue::from_str(&e.to_string()))
408    }
409
410    pub fn resolve_airport(&self, code: String) -> Result<JsValue, JsValue> {
411        let key = code.to_uppercase();
412        let item = self
413            .airport_index
414            .get(&key)
415            .and_then(|idx| idx.first().copied())
416            .and_then(|i| self.airports.get(i))
417            .cloned();
418
419        serde_wasm_bindgen::to_value(&item).map_err(|e| JsValue::from_str(&e.to_string()))
420    }
421
422    pub fn navaids(&self) -> Result<JsValue, JsValue> {
423        serde_wasm_bindgen::to_value(&self.navaids).map_err(|e| JsValue::from_str(&e.to_string()))
424    }
425
426    pub fn fixes(&self) -> Result<JsValue, JsValue> {
427        serde_wasm_bindgen::to_value(&self.navaids).map_err(|e| JsValue::from_str(&e.to_string()))
428    }
429
430    pub fn airways(&self) -> Result<JsValue, JsValue> {
431        serde_wasm_bindgen::to_value(&self.airways).map_err(|e| JsValue::from_str(&e.to_string()))
432    }
433
434    pub fn procedures(&self) -> Result<JsValue, JsValue> {
435        serde_wasm_bindgen::to_value(&self.procedures).map_err(|e| JsValue::from_str(&e.to_string()))
436    }
437
438    pub fn airspaces(&self) -> Result<JsValue, JsValue> {
439        let mut keys = self.airspace_index.keys().cloned().collect::<Vec<_>>();
440        keys.sort();
441        let rows = keys
442            .into_iter()
443            .filter_map(|key| {
444                let records = self
445                    .airspace_index
446                    .get(&key)
447                    .into_iter()
448                    .flat_map(|indices| indices.iter().copied())
449                    .filter_map(|idx| self.airspaces.get(idx).cloned())
450                    .collect::<Vec<_>>();
451                compose_airspace(records)
452            })
453            .collect::<Vec<_>>();
454        serde_wasm_bindgen::to_value(&rows).map_err(|e| JsValue::from_str(&e.to_string()))
455    }
456
457    pub fn resolve_navaid(&self, code: String) -> Result<JsValue, JsValue> {
458        let key = code.to_uppercase();
459        let item = self
460            .navaid_index
461            .get(&key)
462            .and_then(|idx| idx.first().copied())
463            .and_then(|i| self.navaids.get(i))
464            .cloned();
465
466        serde_wasm_bindgen::to_value(&item).map_err(|e| JsValue::from_str(&e.to_string()))
467    }
468
469    pub fn resolve_fix(&self, code: String) -> Result<JsValue, JsValue> {
470        let key = code.to_uppercase();
471        let item = self
472            .navaid_index
473            .get(&key)
474            .and_then(|idx| idx.first().copied())
475            .and_then(|i| self.navaids.get(i))
476            .cloned();
477
478        serde_wasm_bindgen::to_value(&item).map_err(|e| JsValue::from_str(&e.to_string()))
479    }
480
481    pub fn resolve_airway(&self, name: String) -> Result<JsValue, JsValue> {
482        let key = normalize_airway_name(&name);
483        let item = self
484            .airway_index
485            .get(&key)
486            .and_then(|idx| idx.first().copied())
487            .and_then(|i| self.airways.get(i))
488            .cloned();
489
490        serde_wasm_bindgen::to_value(&item).map_err(|e| JsValue::from_str(&e.to_string()))
491    }
492
493    pub fn resolve_sid(&self, name: String) -> Result<JsValue, JsValue> {
494        let item = self.resolve_procedure_by_kind("SID", &name);
495
496        serde_wasm_bindgen::to_value(&item).map_err(|e| JsValue::from_str(&e.to_string()))
497    }
498
499    pub fn resolve_star(&self, name: String) -> Result<JsValue, JsValue> {
500        let item = self.resolve_procedure_by_kind("STAR", &name);
501
502        serde_wasm_bindgen::to_value(&item).map_err(|e| JsValue::from_str(&e.to_string()))
503    }
504
505    pub fn resolve_airspace(&self, designator: String) -> Result<JsValue, JsValue> {
506        let key = designator.to_uppercase();
507        let records = self
508            .airspace_index
509            .get(&key)
510            .into_iter()
511            .flat_map(|indices| indices.iter().copied())
512            .filter_map(|idx| self.airspaces.get(idx).cloned())
513            .collect::<Vec<_>>();
514
515        serde_wasm_bindgen::to_value(&compose_airspace(records)).map_err(|e| JsValue::from_str(&e.to_string()))
516    }
517
518    /// Parse and resolve a raw ICAO field 15 route string into geographic segments.
519    ///
520    /// Same contract as `EurocontrolResolver::enrichRoute` — returns a JS array of
521    /// `{ start, end, name? }` segment objects resolved against the FAA NASR nav data.
522    #[wasm_bindgen(js_name = enrichRoute)]
523    pub fn enrich_route(&self, route: String) -> Result<JsValue, JsValue> {
524        let segments = self.enrich_route_segments_internal(&route);
525        serde_wasm_bindgen::to_value(&segments).map_err(|e| JsValue::from_str(&e.to_string()))
526    }
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532    use crate::field15::RouteSegment;
533    use crate::models::AirwayPointRecord;
534
535    fn sample_resolver() -> NasrResolver {
536        let airports = vec![AirportRecord {
537            code: "LFBO".to_string(),
538            iata: Some("TLS".to_string()),
539            icao: Some("LFBO".to_string()),
540            name: Some("Toulouse Blagnac".to_string()),
541            latitude: 43.6293,
542            longitude: 1.363,
543            region: None,
544            source: "faa_nasr".to_string(),
545        }];
546
547        let navaids = vec![
548            NavpointRecord {
549                code: "KEPER".to_string(),
550                identifier: "KEPER".to_string(),
551                kind: "fix".to_string(),
552                name: Some("KEPER".to_string()),
553                latitude: 44.0,
554                longitude: 2.0,
555                description: None,
556                frequency: None,
557                point_type: None,
558                region: None,
559                source: "faa_nasr".to_string(),
560            },
561            NavpointRecord {
562                code: "NIMER".to_string(),
563                identifier: "NIMER".to_string(),
564                kind: "fix".to_string(),
565                name: Some("NIMER".to_string()),
566                latitude: 44.5,
567                longitude: 2.2,
568                description: None,
569                frequency: None,
570                point_type: None,
571                region: None,
572                source: "faa_nasr".to_string(),
573            },
574        ];
575
576        let procedures = vec![ProcedureRecord {
577            name: "KEPER9E".to_string(),
578            source: "faa_nasr".to_string(),
579            procedure_kind: "STAR".to_string(),
580            route_class: Some("AP".to_string()),
581            airport: Some("LFBO".to_string()),
582            points: vec![
583                AirwayPointRecord {
584                    code: "KEPER".to_string(),
585                    raw_code: "KEPER".to_string(),
586                    kind: "fix".to_string(),
587                    latitude: 44.0,
588                    longitude: 2.0,
589                },
590                AirwayPointRecord {
591                    code: "NIMER".to_string(),
592                    raw_code: "NIMER".to_string(),
593                    kind: "fix".to_string(),
594                    latitude: 44.5,
595                    longitude: 2.2,
596                },
597                AirwayPointRecord {
598                    code: "LFBO".to_string(),
599                    raw_code: "LFBO".to_string(),
600                    kind: "airport".to_string(),
601                    latitude: 43.6293,
602                    longitude: 1.363,
603                },
604            ],
605        }];
606
607        let mut airport_index = HashMap::new();
608        airport_index.insert("LFBO".to_string(), vec![0]);
609        airport_index.insert("TLS".to_string(), vec![0]);
610
611        let mut navaid_index = HashMap::new();
612        navaid_index.insert("KEPER".to_string(), vec![0]);
613        navaid_index.insert("NIMER".to_string(), vec![1]);
614
615        let mut sid_index = HashMap::new();
616        let mut star_index = HashMap::new();
617        sid_index.insert("FISTO5A".to_string(), vec![]);
618        star_index.insert("KEPER9E".to_string(), vec![0]);
619
620        NasrResolver {
621            airports,
622            navaids,
623            airways: Vec::new(),
624            procedures,
625            airspaces: Vec::new(),
626            airport_index,
627            navaid_index,
628            airway_index: HashMap::new(),
629            sid_index,
630            star_index,
631            airspace_index: HashMap::new(),
632        }
633    }
634
635    #[test]
636    fn resolve_star_returns_notebook_example_procedure() {
637        let resolver = sample_resolver();
638        let proc = resolver
639            .resolve_procedure_by_kind("STAR", "KEPER9E")
640            .expect("missing procedure");
641        assert_eq!(proc.name, "KEPER9E");
642        assert_eq!(proc.procedure_kind, "STAR");
643        assert_eq!(proc.route_class.as_deref(), Some("AP"));
644        assert_eq!(proc.points.first().map(|p| p.code.as_str()), Some("KEPER"));
645    }
646
647    #[test]
648    fn enrich_route_expands_terminal_star_segment() {
649        let resolver = sample_resolver();
650        let segments: Vec<RouteSegment> = resolver.enrich_route_segments_internal("N0430F300 KEPER KEPER9E");
651
652        assert_eq!(segments.len(), 2);
653        assert_eq!(segments[0].start.name.as_deref(), Some("KEPER"));
654        assert_eq!(segments[0].end.name.as_deref(), Some("NIMER"));
655        assert_eq!(segments[0].name.as_deref(), Some("KEPER9E"));
656        assert_eq!(segments[0].segment_type.as_deref(), Some("STAR"));
657        assert_eq!(segments[0].connector.as_deref(), Some("KEPER9E"));
658        assert_eq!(segments[1].start.name.as_deref(), Some("NIMER"));
659        assert_eq!(segments[1].end.name.as_deref(), Some("LFBO"));
660        assert_eq!(segments[1].name.as_deref(), Some("KEPER9E"));
661        assert_eq!(segments[1].segment_type.as_deref(), Some("STAR"));
662        assert_eq!(segments[1].connector.as_deref(), Some("KEPER9E"));
663    }
664
665    #[test]
666    fn enrich_route_nat_connector_is_preserved() {
667        let resolver = sample_resolver();
668        let segments: Vec<RouteSegment> = resolver.enrich_route_segments_internal("N0430F300 KEPER NATD NIMER");
669
670        assert_eq!(segments.len(), 1);
671        assert_eq!(segments[0].start.name.as_deref(), Some("KEPER"));
672        assert_eq!(segments[0].end.name.as_deref(), Some("NIMER"));
673        assert_eq!(segments[0].name.as_deref(), Some("NATD"));
674        assert_eq!(segments[0].segment_type.as_deref(), Some("NAT"));
675        assert_eq!(segments[0].connector.as_deref(), Some("NATD"));
676    }
677
678    #[test]
679    fn enrich_route_keeps_waypoint_with_slash_modifier_between_connectors() {
680        let resolver = sample_resolver();
681        let segments: Vec<RouteSegment> =
682            resolver.enrich_route_segments_internal("N0430F300 KEPER N756C NIMER/N0441F340 DCT LFBO");
683
684        assert_eq!(segments.len(), 2);
685        assert_eq!(segments[0].start.name.as_deref(), Some("KEPER"));
686        assert_eq!(segments[0].end.name.as_deref(), Some("NIMER"));
687        assert_eq!(segments[0].segment_type.as_deref(), Some("unresolved"));
688        assert_eq!(segments[0].connector.as_deref(), Some("N756C"));
689
690        assert_eq!(segments[1].start.name.as_deref(), Some("NIMER"));
691        assert_eq!(segments[1].end.name.as_deref(), Some("LFBO"));
692        assert_eq!(segments[1].segment_type.as_deref(), Some("dct"));
693        assert_eq!(segments[1].connector.as_deref(), Some("DCT"));
694    }
695}