use aerocontext_core::{
Airway, AirwayLocation, AirwayPoint, GeoPoint, NavDataCycle, NavDataSnapshot, NavPoint,
NavPointKind,
};
use chrono::NaiveDate;
use super::*;
fn point(ident: &str, lat: f64, lon: f64) -> NavPoint {
NavPoint::new(ident, NavPointKind::Waypoint, GeoPoint { lat, lon })
}
fn airway(ident: &str, points: &[&str]) -> Airway {
Airway::new(
ident,
AirwayLocation::Conus,
points.iter().map(|p| AirwayPoint::new(*p)).collect(),
)
}
fn snapshot() -> NavDataSnapshot {
let cycle = NavDataCycle::faa_nasr(NaiveDate::from_ymd_opt(2026, 6, 11).unwrap()).unwrap();
let idents = [
("ALB", 42.75, -73.80),
("CTR1", 41.9, -74.6),
("CTR2", 40.8, -75.9),
("BUMPY", 39.6, -77.3),
("MID1", 37.8, -80.2),
("MID2", 35.9, -83.4),
("BHM", 33.56, -86.75),
("ORRCA", 38.2, -121.0),
("GALLI", 39.5, -117.4),
("KSFO", 37.62, -122.37),
];
NavDataSnapshot::new(
cycle,
idents
.iter()
.map(|(ident, lat, lon)| point(ident, *lat, *lon))
.collect(),
)
.with_airways(vec![
airway("J37", &["ALB", "CTR1", "CTR2", "BUMPY"]),
airway("J14", &["BUMPY", "MID1", "MID2", "BHM"]),
airway("Q120", &["ORRCA", "GALLI"]),
])
}
#[test]
fn tokenizer_classifies_every_kind() {
let tokens = parse("KSFO DCT ORRCA Q120 GALLI 4530N07315W 45N073W TRUKN2.AAALL BHM").unwrap();
assert_eq!(tokens[0], RouteToken::Ident("KSFO".to_owned()));
assert_eq!(tokens[1], RouteToken::Dct);
assert_eq!(tokens[3], RouteToken::Airway("Q120".to_owned()));
assert!(matches!(tokens[5], RouteToken::LatLon(p) if (p.lat - 45.5).abs() < 1e-9));
assert!(matches!(tokens[6], RouteToken::LatLon(p) if (p.lon + 73.0).abs() < 1e-9));
assert!(matches!(
&tokens[7],
RouteToken::Procedure { name, transition: Some(t) } if name == "TRUKN2" && t == "AAALL"
));
}
#[test]
fn aim_two_airway_transition_expands_in_order() {
let route = expand_str("ALB J37 BUMPY J14 BHM", &snapshot()).unwrap();
let idents: Vec<&str> = route
.points
.iter()
.filter_map(|p| p.ident.as_deref())
.collect();
assert_eq!(
idents,
vec!["ALB", "CTR1", "CTR2", "BUMPY", "MID1", "MID2", "BHM"]
);
assert_eq!(route.points[1].via_airway.as_deref(), Some("J37"));
assert_eq!(route.points[4].via_airway.as_deref(), Some("J14"));
}
#[test]
fn reverse_direction_traversal_works() {
let route = expand_str("BUMPY J37 ALB", &snapshot()).unwrap();
let idents: Vec<&str> = route
.points
.iter()
.filter_map(|p| p.ident.as_deref())
.collect();
assert_eq!(idents, vec!["BUMPY", "CTR2", "CTR1", "ALB"]);
}
#[test]
fn frd_tokens_reject_loudly() {
let error = parse("FLACK DCT IRW DCT IRW12503").unwrap_err();
assert!(matches!(
error,
RouteError::UnsupportedToken {
kind: UnsupportedKind::FixRadialDistance,
ref token,
} if token == "IRW12503"
));
}
#[test]
fn speed_altitude_blocks_reject_loudly() {
let error = parse("DCT APN J177 LEXOR/N0467F380").unwrap_err();
assert!(matches!(
error,
RouteError::UnsupportedToken {
kind: UnsupportedKind::SpeedAltitudeChange,
..
}
));
}
#[test]
fn structural_errors_are_caught_at_parse_time() {
assert!(matches!(
parse("J37 BUMPY"),
Err(RouteError::AirwayAtRouteEdge { .. })
));
assert!(matches!(
parse("ALB J37"),
Err(RouteError::AirwayAtRouteEdge { .. })
));
assert!(matches!(
parse("ALB J37 J14 BHM"),
Err(RouteError::AdjacentAirways { .. })
));
assert!(matches!(parse(" "), Err(RouteError::Empty)));
}
#[test]
fn airway_membership_errors_name_the_side() {
let snap = snapshot();
assert!(matches!(
expand_str("ORRCA J37 BUMPY", &snap),
Err(RouteError::EntryFixNotOnAirway { ref fix, .. }) if fix == "ORRCA"
));
assert!(matches!(
expand_str("ALB J37 GALLI", &snap),
Err(RouteError::ExitFixNotOnAirway { ref fix, .. }) if fix == "GALLI"
));
assert!(matches!(
expand_str("ALB J99 BUMPY", &snap),
Err(RouteError::UnknownAirway { .. })
));
}
#[test]
fn published_gaps_refuse_traversal() {
let cycle = NavDataCycle::faa_nasr(NaiveDate::from_ymd_opt(2026, 6, 11).unwrap()).unwrap();
let snap = NavDataSnapshot::new(
cycle,
vec![
point("AAAAA", 40.0, -100.0),
point("BBBBB", 41.0, -101.0),
point("CCCCC", 42.0, -102.0),
],
)
.with_airways(vec![Airway::new(
"V9",
AirwayLocation::Conus,
vec![
AirwayPoint::new("AAAAA"),
AirwayPoint::new("BBBBB").with_gap_to_next(true),
AirwayPoint::new("CCCCC"),
],
)]);
assert!(matches!(
expand_str("AAAAA V9 CCCCC", &snap),
Err(RouteError::DiscontinuedSegment { ref from, ref to, .. })
if from == "BBBBB" && to == "CCCCC"
));
assert!(expand_str("AAAAA V9 BBBBB", &snap).is_ok());
}
#[test]
fn edge_procedures_expand_without_geometry() {
let route = expand_str("TRUKN2 ORRCA Q120 GALLI PUCKY1", &snapshot()).unwrap();
let idents: Vec<&str> = route
.points
.iter()
.filter_map(|p| p.ident.as_deref())
.collect();
assert_eq!(idents, vec!["ORRCA", "GALLI"]);
assert_eq!(route.procedures.len(), 2, "TRUKN2 and PUCKY1 collected");
}
#[test]
fn interior_unresolved_idents_still_error() {
assert!(matches!(
expand_str("ALB NOPE2 BHM", &snapshot()),
Err(RouteError::UnresolvedIdent { ref ident, .. }) if ident == "NOPE2"
));
}
#[test]
fn min_span_pair_wins_on_looping_airways() {
let cycle = NavDataCycle::faa_nasr(NaiveDate::from_ymd_opt(2026, 6, 11).unwrap()).unwrap();
let snap = NavDataSnapshot::new(
cycle,
vec![
point("START", 40.0, -100.0),
point("LOOPY", 41.0, -101.0),
point("FAARR", 45.0, -105.0),
],
)
.with_airways(vec![Airway::new(
"V8",
AirwayLocation::Conus,
vec![
AirwayPoint::new("START"),
AirwayPoint::new("LOOPY"),
AirwayPoint::new("FAARR"),
AirwayPoint::new("LOOPY"),
],
)]);
let route = expand_str("START V8 LOOPY", &snap).unwrap();
assert_eq!(route.points.len(), 2, "shortest span: START -> first LOOPY");
}
#[test]
fn non_ascii_tokens_reject_without_panicking() {
for token in ["0\u{e9}073W", "4530N0731\u{e9}", "\u{00c9}45N073W"] {
let result = parse(token);
let classified_lat_lon = matches!(result.as_deref(), Ok([RouteToken::LatLon(_)]));
assert!(!classified_lat_lon, "{token:?} must not decode as lat/lon");
}
}