aerocontext-core 0.4.2

Provider-neutral aeronautical-context model and the pluggable ContextProvider contract
Documentation
#![allow(clippy::expect_used, clippy::panic)]

use super::*;

fn sfo_28r() -> RunwayEnd {
    // SFO 28R: true alignment 298°, threshold elevation 13 ft.
    RunwayEnd::new("28R")
        .with_true_alignment_deg(Some(298.0))
        .with_elevation_ft(Some(13.0))
}

#[test]
fn a_direct_headwind_has_no_crosswind() {
    // Wind straight down 28R (from 298°) at 20 kt: all headwind.
    let (headwind, crosswind) = sfo_28r().wind_components_kt(298.0, 20.0).expect("aligned");
    assert!((headwind - 20.0).abs() < 1e-6);
    assert!(crosswind.abs() < 1e-6);
}

#[test]
fn a_direct_crosswind_has_no_headwind() {
    // Wind 90° off the runway (298-90 = 208°) at 20 kt: all crosswind.
    let (headwind, crosswind) = sfo_28r().wind_components_kt(208.0, 20.0).expect("aligned");
    assert!(headwind.abs() < 1e-6);
    assert!((crosswind - 20.0).abs() < 1e-6);
}

#[test]
fn a_tailwind_is_a_negative_headwind() {
    // Wind from behind (118°, the reciprocal) is a tailwind.
    let (headwind, crosswind) = sfo_28r().wind_components_kt(118.0, 15.0).expect("aligned");
    assert!((headwind + 15.0).abs() < 1e-6, "tailwind = -15");
    assert!(crosswind.abs() < 1e-6);
}

#[test]
fn forty_five_degree_wind_splits_evenly() {
    // 45° off at ~14.14 kt → ~10 kt each component.
    let (headwind, crosswind) = sfo_28r()
        .wind_components_kt(298.0 - 45.0, 14.142)
        .expect("aligned");
    assert!((headwind - 10.0).abs() < 0.01);
    assert!((crosswind - 10.0).abs() < 0.01);
}

#[test]
fn reciprocal_ends_have_equal_crosswind_opposite_headwind() {
    // SFO 10R/28L: ends 118° and 298° (reciprocals). A 130° wind gives
    // both ends the same crosswind magnitude; only the headwind sign
    // differs (10R into-wind, 28L tailwind).
    let rwy = Runway::new("SFO", "10R/28L").with_ends(vec![
        RunwayEnd::new("10R").with_true_alignment_deg(Some(118.0)),
        RunwayEnd::new("28L").with_true_alignment_deg(Some(298.0)),
    ]);
    let (_, xw_10r) = rwy.ends[0].wind_components_kt(130.0, 20.0).expect("10r");
    let (hw_10r, _) = rwy.ends[0].wind_components_kt(130.0, 20.0).expect("10r");
    let (_, xw_28l) = rwy.ends[1].wind_components_kt(130.0, 20.0).expect("28l");
    let (hw_28l, _) = rwy.ends[1].wind_components_kt(130.0, 20.0).expect("28l");
    assert!(
        (xw_10r - xw_28l).abs() < 1e-6,
        "reciprocal crosswinds equal"
    );
    assert!(hw_10r > 0.0 && hw_28l < 0.0, "one into-wind, one tailwind");
    // The runway-level crosswind is that shared magnitude.
    assert!((rwy.crosswind_kt(130.0, 20.0).expect("xw") - xw_10r).abs() < 1e-6);
}

#[test]
fn best_runway_is_a_min_across_different_runways() {
    // 130° wind: a 12/30 runway (~120° aligned) takes it nearly on the
    // nose; an 04/22 runway (~040°) takes a big crosswind. The airport's
    // best is the small one.
    let near = Runway::new("X", "12/30").with_ends(vec![
        RunwayEnd::new("12").with_true_alignment_deg(Some(120.0)),
    ]);
    let cross = Runway::new("X", "04/22").with_ends(vec![
        RunwayEnd::new("04").with_true_alignment_deg(Some(40.0)),
    ]);
    let best = [&near, &cross]
        .iter()
        .filter_map(|r| r.crosswind_kt(130.0, 20.0))
        .min_by(|a, b| a.partial_cmp(b).unwrap())
        .expect("some runway");
    assert!((best - 20.0 * (130.0_f64 - 120.0).to_radians().sin().abs()).abs() < 1e-6);
}

#[test]
fn no_alignment_yields_no_components() {
    assert!(
        RunwayEnd::new("01L")
            .wind_components_kt(280.0, 10.0)
            .is_none()
    );
    assert!(
        Runway::new("SFO", "01L/19R")
            .with_ends(vec![RunwayEnd::new("01L")])
            .crosswind_kt(280.0, 10.0)
            .is_none()
    );
}

#[test]
fn non_finite_wind_is_rejected() {
    assert!(sfo_28r().wind_components_kt(f64::NAN, 10.0).is_none());
    assert!(sfo_28r().wind_components_kt(298.0, f64::INFINITY).is_none());
}