aerocontext-planning 0.4.2

Flight-route planning over aerocontext: Garmin .fpl import/export, route expansion, corridor generation, and the vertical cross-section
Documentation
#![allow(clippy::expect_used, clippy::panic)]

use super::*;

const SAMPLE: &str = r#"<?xml version="1.0" encoding="utf-8"?>
<flight-plan xmlns="http://www8.garmin.com/xmlschemas/FlightPlan/v1">
<created>20260612T04:31:24Z</created>
<aircraft>
    <aircraft-tailnumber>N123AB</aircraft-tailnumber>
</aircraft>
<flight-data>
    <etd-zulu>20260612T03:20:00Z</etd-zulu>
    <altitude-ft>40000</altitude-ft>
</flight-data>
<waypoint-table>
    <waypoint>
        <identifier>KEWR</identifier>
        <type>AIRPORT</type>
        <lat>40.692481</lat>
        <lon>-74.168688</lon>
        <altitude-ft></altitude-ft>
    </waypoint>
    <waypoint>
        <identifier>PSB</identifier>
        <type>VOR</type>
        <lat>40.916258</lat>
        <lon>-77.992717</lon>
    </waypoint>
    <waypoint>
        <identifier>KAAO</identifier>
        <type>AIRPORT</type>
        <lat>37.746111</lat>
        <lon>-97.221111</lon>
    </waypoint>
</waypoint-table>
<route>
    <route-name>KEWR TO KAAO</route-name>
    <flight-plan-index>1</flight-plan-index>
    <route-point>
        <waypoint-identifier>KEWR</waypoint-identifier>
        <waypoint-type>AIRPORT</waypoint-type>
    </route-point>
    <route-point>
        <waypoint-identifier>PSB</waypoint-identifier>
        <waypoint-type>VOR</waypoint-type>
    </route-point>
    <route-point>
        <waypoint-identifier>KAAO</waypoint-identifier>
        <waypoint-type>AIRPORT</waypoint-type>
    </route-point>
</route>
</flight-plan>
"#;

/// Encode a UTF-8 string as UTF-16LE with a BOM, the way ForeFlight does.
fn to_utf16le(text: &str) -> Vec<u8> {
    let mut bytes = vec![0xFF, 0xFE];
    for unit in text.encode_utf16() {
        bytes.extend_from_slice(&unit.to_le_bytes());
    }
    bytes
}

#[test]
fn parses_the_foreflight_dialect_from_utf16() {
    let plan = FlightPlan::from_fpl_bytes(&to_utf16le(SAMPLE)).expect("parses");
    assert_eq!(plan.name.as_deref(), Some("KEWR TO KAAO"));
    assert_eq!(plan.aircraft_tail.as_deref(), Some("N123AB"));
    assert_eq!(plan.cruise_altitude_ft, Some(40000));
    assert_eq!(plan.etd, Some("2026-06-12T03:20:00Z".parse().expect("etd")));
    assert_eq!(plan.departure(), Some("KEWR"));
    assert_eq!(plan.destination(), Some("KAAO"));
    assert_eq!(plan.route.len(), 3);
    assert_eq!(plan.route[1].kind, WaypointType::Vor);
    assert!((plan.route[1].position.lat - 40.916258).abs() < 1e-6);
}

#[test]
fn parses_utf8_input_too() {
    let plan = FlightPlan::from_fpl_bytes(SAMPLE.as_bytes()).expect("parses utf-8");
    assert_eq!(plan.route.len(), 3);
}

#[test]
fn export_round_trips_through_a_reparse() {
    let plan = FlightPlan::from_fpl_bytes(SAMPLE.as_bytes()).expect("parses");
    let xml = plan.to_fpl_string();
    let again = FlightPlan::from_fpl_bytes(xml.as_bytes()).expect("reparses our own output");
    assert_eq!(plan, again);
}

#[test]
fn a_route_point_not_in_the_table_is_a_hard_error() {
    let broken = SAMPLE.replace(
        "<waypoint-identifier>PSB</waypoint-identifier>",
        "<waypoint-identifier>GHOST</waypoint-identifier>",
    );
    let error = FlightPlan::from_fpl_bytes(broken.as_bytes()).expect_err("must reject");
    assert!(matches!(
        error,
        FplError::UnknownRoutePoint { identifier } if identifier == "GHOST"
    ));
}

#[test]
fn bridges_to_a_route_request_carrying_etd_and_altitude() {
    let plan = FlightPlan::from_fpl_bytes(SAMPLE.as_bytes()).expect("parses");
    let request = plan.to_route_briefing_request(25.0, aerocontext_core::FlightRules::Ifr);
    assert_eq!(request.idents(), vec!["KEWR", "PSB", "KAAO"]);
    assert_eq!(request.cruise_altitude_ft, Some(40000));
    assert_eq!(request.departure_at, plan.etd);
    // The corridor covers the whole route in a few padded boxes.
    assert!(!request.segment_bboxes(300.0).is_empty());
}

#[test]
fn expands_to_a_corridor_without_a_snapshot() {
    let plan = FlightPlan::from_fpl_bytes(SAMPLE.as_bytes()).expect("parses");
    let route = plan.to_expanded_route();
    let corridor = crate::Corridor::around_route(&route, 25.0).expect("corridor builds");
    // A point on the EWR→PSB leg is inside the corridor.
    assert!(corridor.contains(aerocontext_core::GeoPoint {
        lat: 40.8,
        lon: -76.0,
    }));
}

#[test]
fn country_code_round_trips_when_present() {
    let with_country = SAMPLE.replace(
        "<type>VOR</type>\n        <lat>40.916258</lat>",
        "<type>VOR</type>\n        <country-code>US</country-code>\n        <lat>40.916258</lat>",
    );
    let plan = FlightPlan::from_fpl_bytes(with_country.as_bytes()).expect("parses");
    assert_eq!(plan.route[1].country_code.as_deref(), Some("US"));
    let again = FlightPlan::from_fpl_bytes(plan.to_fpl_string().as_bytes()).expect("reparses");
    assert_eq!(again.route[1].country_code.as_deref(), Some("US"));
    assert_eq!(plan, again);
}