aerocontext-planning 0.4.2

Flight-route planning over aerocontext: Garmin .fpl import/export, route expansion, corridor generation, and the vertical cross-section
Documentation
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(),
    )
}

/// Synthetic snapshot shaped like the AIM 5-1-6b example: ALB J37 BUMPY
/// J14 BHM, plus a Q-route and assorted fixes.
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() {
    // AIM 5-1-6b: 'ALB J37 BUMPY J14 BHM'.
    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() {
    // Enter J37 at BUMPY, exit at ALB: traverse against sequence order.
    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() {
    // AIM fixture: 'FLACK DCT IRW DCT IRW12503'.
    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"
    ));
    // The unaffected span still expands.
    assert!(expand_str("AAAAA V9 BBBBB", &snap).is_ok());
}

#[test]
fn edge_procedures_expand_without_geometry() {
    // FlightAware-style: SID and STAR computer codes at the edges.
    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();
    // LOOPY appears twice on V8; the contiguous (short) traversal from
    // START must use the nearer occurrence.
    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");
}

/// Non-ASCII bytes at slice offsets must reject, not panic ("0é073W" is
/// 7 bytes with a char boundary inside the lat field).
#[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");
    }
}