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 cat(ceiling: Option<f64>, vis: Option<f64>) -> Option<FlightCategory> {
    FlightCategory::from_ceiling_visibility(ceiling, vis)
}

#[test]
fn category_is_the_worse_of_ceiling_and_visibility() {
    use FlightCategory::{Ifr, Lifr, Mvfr, Vfr};
    // VFR requires BOTH ceiling > 3000 AND visibility > 5.
    assert_eq!(cat(Some(5000.0), Some(10.0)), Some(Vfr));
    assert_eq!(cat(None, Some(10.0)), Some(Vfr), "no ceiling = unlimited");
    // A high ceiling but marginal visibility is MVFR (vis governs).
    assert_eq!(cat(Some(12000.0), Some(4.0)), Some(Mvfr));
    // Good visibility but a 900 ft ceiling is IFR (ceiling governs).
    assert_eq!(cat(Some(900.0), Some(10.0)), Some(Ifr));
    // Both poor: take the worse — LIFR ceiling beats IFR visibility.
    assert_eq!(cat(Some(300.0), Some(2.0)), Some(Lifr));
    assert_eq!(cat(Some(800.0), Some(0.5)), Some(Lifr), "vis < 1 = LIFR");
}

#[test]
fn ceiling_category_boundaries_are_inclusive_per_faa() {
    use FlightCategory::{Ifr, Lifr, Mvfr, Vfr};
    let vfr_vis = Some(10.0); // hold visibility VFR so the ceiling governs
    assert_eq!(cat(Some(499.0), vfr_vis), Some(Lifr));
    assert_eq!(cat(Some(500.0), vfr_vis), Some(Ifr), "500 is IFR");
    assert_eq!(cat(Some(999.0), vfr_vis), Some(Ifr));
    assert_eq!(cat(Some(1000.0), vfr_vis), Some(Mvfr), "1000 is MVFR");
    assert_eq!(cat(Some(3000.0), vfr_vis), Some(Mvfr), "3000 is MVFR");
    assert_eq!(cat(Some(3001.0), vfr_vis), Some(Vfr), ">3000 is VFR");
}

#[test]
fn visibility_category_boundaries_are_inclusive_per_faa() {
    use FlightCategory::{Ifr, Lifr, Mvfr, Vfr};
    let no_ceiling = None; // ceiling unlimited so visibility governs
    assert_eq!(cat(no_ceiling, Some(0.5)), Some(Lifr));
    assert_eq!(cat(no_ceiling, Some(1.0)), Some(Ifr), "1 sm is IFR");
    assert_eq!(cat(no_ceiling, Some(2.9)), Some(Ifr));
    assert_eq!(cat(no_ceiling, Some(3.0)), Some(Mvfr), "3 sm is MVFR");
    assert_eq!(cat(no_ceiling, Some(5.0)), Some(Mvfr), "5 sm is MVFR");
    assert_eq!(cat(no_ceiling, Some(5.1)), Some(Vfr), ">5 sm is VFR");
}

#[test]
fn missing_visibility_is_uncategorizable() {
    // Visibility is the dominant criterion; without it we never guess.
    assert_eq!(cat(Some(5000.0), None), None);
    assert_eq!(cat(None, None), None);
    assert_eq!(cat(None, Some(f64::NAN)), None);
}

#[test]
fn ceiling_is_the_lowest_broken_overcast_or_obscured_layer() {
    let obs = MetarObservation::new().with_clouds(vec![
        CloudLayer::new(CloudCover::Few, Some(2000.0)),
        CloudLayer::new(CloudCover::Scattered, Some(4000.0)),
        CloudLayer::new(CloudCover::Broken, Some(6000.0)),
        CloudLayer::new(CloudCover::Overcast, Some(9000.0)),
    ]);
    // FEW/SCT are not ceilings; the lowest BKN/OVC is.
    assert_eq!(obs.ceiling_ft(), Some(6000.0));

    let clear = MetarObservation::new().with_clouds(vec![
        CloudLayer::new(CloudCover::Few, Some(25000.0)),
        CloudLayer::new(CloudCover::Scattered, Some(30000.0)),
    ]);
    assert_eq!(clear.ceiling_ft(), None, "no BKN/OVC = no ceiling");

    let vv = MetarObservation::new()
        .with_clouds(vec![CloudLayer::new(CloudCover::Obscured, Some(200.0))]);
    assert_eq!(
        vv.ceiling_ft(),
        Some(200.0),
        "vertical visibility is a ceiling"
    );
}

#[test]
fn observation_category_matches_awc_reported_on_real_reports() {
    // KORD ... BKN110 OVC250 10SM → high ceiling, good vis → VFR.
    let ord = MetarObservation::new()
        .with_visibility_sm(Some(10.0))
        .with_clouds(vec![
            CloudLayer::new(CloudCover::Broken, Some(11000.0)),
            CloudLayer::new(CloudCover::Overcast, Some(25000.0)),
        ])
        .with_reported_category(FlightCategory::parse("VFR"));
    assert_eq!(ord.flight_category(), Some(FlightCategory::Vfr));
    assert_eq!(ord.flight_category(), ord.reported_category);

    // Ceiling 800 OVC, 2 sm → IFR, agreeing with a reported IFR.
    let ifr = MetarObservation::new()
        .with_visibility_sm(Some(2.0))
        .with_clouds(vec![CloudLayer::new(CloudCover::Overcast, Some(800.0))])
        .with_reported_category(FlightCategory::parse("IFR"));
    assert_eq!(ifr.flight_category(), Some(FlightCategory::Ifr));
    assert_eq!(ifr.flight_category(), ifr.reported_category);
}

#[test]
fn visibility_parses_numbers_plus_and_fractions() {
    assert_eq!(parse_visibility_sm("10+"), Some(10.0));
    assert_eq!(parse_visibility_sm("3"), Some(3.0));
    assert_eq!(parse_visibility_sm("6.0"), Some(6.0));
    assert_eq!(parse_visibility_sm("1/2"), Some(0.5));
    assert_eq!(parse_visibility_sm("1 1/2"), Some(1.5));
    assert_eq!(parse_visibility_sm("M1/4"), Some(0.25), "M = less than");
    assert_eq!(parse_visibility_sm("garbage"), None);
}

#[test]
fn altimeter_converts_hectopascals_to_inhg() {
    let obs = MetarObservation::new().with_altimeter_hpa(Some(1013.25));
    // 1013.25 hPa is the standard 29.92 inHg.
    let inhg = obs.altimeter_inhg().expect("inhg");
    assert!((inhg - 29.92).abs() < 0.01, "got {inhg}");
}

#[test]
fn category_ordering_puts_lifr_worst() {
    use FlightCategory::{Ifr, Lifr, Mvfr, Vfr};
    assert!(Lifr < Ifr && Ifr < Mvfr && Mvfr < Vfr);
    assert_eq!(Lifr.min(Vfr), Lifr, "min selects the more restrictive");
}

#[test]
fn ceiling_layer_of_unknown_height_is_not_unlimited() {
    // A broken layer with no reported base must NOT decode as VFR — the
    // true ceiling could be anywhere.
    let obs = MetarObservation::new()
        .with_visibility_sm(Some(10.0))
        .with_clouds(vec![CloudLayer::new(CloudCover::Broken, None)]);
    assert!(obs.has_unknown_height_ceiling());
    assert_eq!(obs.ceiling_ft(), None, "no known height");
    assert_eq!(
        obs.flight_category(),
        None,
        "unknown-height ceiling is uncategorizable, never VFR"
    );
}

#[test]
fn unknown_height_ceiling_still_reports_lifr_when_visibility_forces_it() {
    // If visibility alone is already LIFR, the unknown ceiling cannot make
    // it worse, so we can stand behind LIFR.
    let obs = MetarObservation::new()
        .with_visibility_sm(Some(0.5))
        .with_clouds(vec![CloudLayer::new(CloudCover::Overcast, None)]);
    assert_eq!(obs.flight_category(), Some(FlightCategory::Lifr));
}

#[test]
fn a_nan_cloud_base_cannot_mask_a_real_low_ceiling() {
    let obs = MetarObservation::new().with_clouds(vec![
        CloudLayer::new(CloudCover::Overcast, Some(f64::NAN)),
        CloudLayer::new(CloudCover::Broken, Some(700.0)),
    ]);
    // The real 700 ft ceiling wins; the NaN base is ignored.
    assert_eq!(obs.ceiling_ft(), Some(700.0));
}

#[test]
fn negative_or_garbage_visibility_is_unknown_not_a_value() {
    assert_eq!(parse_visibility_sm("-1"), None);
    assert_eq!(parse_visibility_sm("-1/2"), None);
    assert_eq!(
        parse_visibility_sm("1/0"),
        None,
        "no division by zero value"
    );
}