aerocontext-awc 0.4.2

aviationweather.gov Data API adapter for aerocontext-core
Documentation
use super::*;

#[test]
fn metar_decodes_field_names_from_spec() {
    let body = r#"[{
        "icaoId": "KORD",
        "rawOb": "KORD 041951Z 27012KT 10SM FEW250 28/14 A3002",
        "obsTime": 1717530660,
        "temp": 28.0,
        "altim": 1016.6
    }]"#;
    let products = metars(body).unwrap();
    assert_eq!(products.len(), 1);
    assert_eq!(products[0].kind, ProductKind::Metar);
    assert_eq!(products[0].location.as_deref(), Some("KORD"));
    assert!(products[0].raw_text.starts_with("KORD 041951Z"));
    assert_eq!(
        products[0].issued_at.map(|t| t.timestamp()),
        Some(1717530660)
    );
}

#[test]
fn metar_without_raw_text_is_dropped_not_fatal() {
    let body = r#"[{"icaoId": "KORD"}, {"icaoId": "KMDW", "rawOb": "KMDW ..."}]"#;
    let products = metars(body).unwrap();
    assert_eq!(products.len(), 1);
    assert_eq!(products[0].location.as_deref(), Some("KMDW"));
}

#[test]
fn taf_uses_raw_taf_not_raw_ob() {
    let body = r#"[{
        "icaoId": "KORD",
        "rawTAF": "KORD 041730Z 0418/0524 27010KT P6SM SCT250",
        "issueTime": "2026-06-04 17:30:00"
    }]"#;
    let products = tafs(body).unwrap();
    assert_eq!(products.len(), 1);
    assert_eq!(products[0].kind, ProductKind::Taf);
    assert!(products[0].raw_text.starts_with("KORD 041730Z"));
    assert!(products[0].issued_at.is_some());
}

#[test]
fn pirep_decodes_raw_ob() {
    let body = r#"[{
        "rawOb": "ORD UA /OV ORD/TM 1955/FL080/TP B738/TB LGT",
        "obsTime": 1717531000
    }]"#;
    let products = pireps(body).unwrap();
    assert_eq!(products.len(), 1);
    assert_eq!(products[0].kind, ProductKind::Pirep);
    assert!(products[0].location.is_none());
}

#[test]
fn pirep_fractional_obs_time_does_not_fail_the_batch() {
    // The spec types PIREP obsTime as `number`, so the endpoint may emit
    // a fractional epoch; one float must not abort the whole array.
    let body = r#"[
        {"rawOb": "FLOAT .0", "obsTime": 1699379880.0},
        {"rawOb": "FLOAT .5", "obsTime": 1699379880.5},
        {"rawOb": "INTEGER", "obsTime": 1699379880}
    ]"#;
    let products = pireps(body).unwrap();
    assert_eq!(products.len(), 3);
    for product in &products {
        assert_eq!(
            product.issued_at.map(|t| t.timestamp()),
            Some(1699379880),
            "{} keeps its (truncated) timestamp",
            product.raw_text
        );
    }
}

#[test]
fn airsigmet_carries_polygon_for_clipping() {
    let body = r#"[{
        "icaoId": "KKCI",
        "rawAirSigmet": "WSUS31 KKCI 042000 ...",
        "creationTime": "2026-06-04T19:48:24.974Z",
        "coords": [{"lat": 40.0, "lon": -90.0}, {"lat": 42.0, "lon": -88.0}]
    }]"#;
    let entries = air_sigmets(body).unwrap();
    assert_eq!(entries.len(), 1);
    assert_eq!(entries[0].0.kind, ProductKind::Sigmet);
    assert_eq!(entries[0].1.len(), 2);
    assert!(entries[0].0.issued_at.is_some());
}

#[test]
fn clip_keeps_intersecting_and_geometryless_drops_outside() {
    let inside = (
        Product::new(ProductKind::Sigmet, "INSIDE"),
        vec![
            GeoPoint {
                lat: 40.0,
                lon: -90.0,
            },
            GeoPoint {
                lat: 42.0,
                lon: -88.0,
            },
        ],
    );
    let outside = (
        Product::new(ProductKind::Sigmet, "OUTSIDE"),
        vec![
            GeoPoint {
                lat: 10.0,
                lon: 50.0,
            },
            GeoPoint {
                lat: 12.0,
                lon: 52.0,
            },
        ],
    );
    let no_geometry = (Product::new(ProductKind::Sigmet, "NO-GEOMETRY"), vec![]);
    // Overlap on exactly one axis must still be clipped: these two pin
    // the longitude check and the latitude check independently, so a
    // single-axis regression cannot pass.
    let lat_overlap_only = (
        Product::new(ProductKind::Sigmet, "LAT-ONLY"),
        vec![
            GeoPoint {
                lat: 40.0,
                lon: 50.0,
            },
            GeoPoint {
                lat: 41.0,
                lon: 52.0,
            },
        ],
    );
    let lon_overlap_only = (
        Product::new(ProductKind::Sigmet, "LON-ONLY"),
        vec![
            GeoPoint {
                lat: 10.0,
                lon: -90.0,
            },
            GeoPoint {
                lat: 12.0,
                lon: -88.0,
            },
        ],
    );
    let kept = clip_to_bbox(
        vec![
            inside,
            outside,
            no_geometry,
            lat_overlap_only,
            lon_overlap_only,
        ],
        (
            GeoPoint {
                lat: 39.0,
                lon: -91.0,
            },
            GeoPoint {
                lat: 41.0,
                lon: -87.0,
            },
        ),
    );
    let texts: Vec<&str> = kept.iter().map(|p| p.raw_text.as_str()).collect();
    assert_eq!(texts, ["INSIDE", "NO-GEOMETRY"]);
}

#[test]
fn awc_time_formats_parse_best_effort() {
    assert!(parse_awc_time("2026-06-04T19:48:24.974Z").is_some());
    assert!(parse_awc_time("2026-06-04 19:48:24.974Z").is_some());
    assert!(parse_awc_time("2026-06-04 19:48:24").is_some());
    assert!(parse_awc_time("not a time").is_none());
}

#[test]
fn malformed_body_is_an_error_not_a_panic() {
    assert!(metars("{\"not\": \"an array\"}").is_err());
    assert!(metars("").is_err());
}

#[test]
fn metar_decodes_structured_observation_and_keeps_raw() {
    // A real-shaped AWC METAR: BKN008 at 800 ft, 2 sm, VRB wind, with
    // AWC's own fltCat=IFR — our derivation must agree.
    let body = r#"[{
        "icaoId": "KSFO",
        "rawOb": "KSFO 130956Z VRB03KT 2SM BR BKN008 OVC015 14/13 A2998",
        "obsTime": 1717530660,
        "temp": 14.0,
        "dewp": 13.0,
        "wdir": "VRB",
        "wspd": 3,
        "visib": "2",
        "altim": 1015.2,
        "clouds": [
            {"cover": "BKN", "base": 800},
            {"cover": "OVC", "base": 1500}
        ],
        "fltCat": "IFR"
    }]"#;
    let products = metars(body).unwrap();
    let obs = products[0]
        .observation
        .as_ref()
        .expect("METAR carries a decoded observation");
    // Raw text is always preserved verbatim.
    assert!(products[0].raw_text.starts_with("KSFO 130956Z"));
    assert_eq!(obs.ceiling_ft(), Some(800.0), "lowest BKN/OVC base");
    assert_eq!(obs.visibility_sm, Some(2.0));
    assert_eq!(obs.temperature_c, Some(14.0));
    assert_eq!(obs.dewpoint_c, Some(13.0));
    assert!(obs.wind_variable && obs.wind_dir_deg.is_none());
    assert_eq!(obs.wind_speed_kt, Some(3));
    // Our derived category matches AWC's reported one.
    assert_eq!(
        obs.flight_category(),
        Some(aerocontext_core::FlightCategory::Ifr)
    );
    assert_eq!(obs.flight_category(), obs.reported_category);
}

#[test]
fn metar_with_plus_visibility_and_no_ceiling_is_vfr() {
    let body = r#"[{
        "icaoId": "KDEN",
        "rawOb": "KDEN 130953Z 28008KT 10SM FEW250 25/05 A3001",
        "wdir": 280,
        "wspd": 8,
        "visib": "10+",
        "clouds": [{"cover": "FEW", "base": 25000}],
        "fltCat": "VFR"
    }]"#;
    let obs = metars(body).unwrap()[0]
        .observation
        .clone()
        .expect("observation");
    assert_eq!(obs.visibility_sm, Some(10.0), "10+ decodes to 10");
    assert_eq!(obs.ceiling_ft(), None, "FEW is not a ceiling");
    assert_eq!(obs.wind_dir_deg, Some(280));
    assert_eq!(
        obs.flight_category(),
        Some(aerocontext_core::FlightCategory::Vfr)
    );
}

#[test]
fn obscured_sky_uses_vert_vis_as_the_ceiling() {
    // VV002: fully obscured at 200 ft. AWC puts the height in vertVis and
    // the layer base is null — the ceiling must still be 200 ft (LIFR),
    // never "unlimited".
    let body = r#"[{
        "icaoId": "KSEA",
        "rawOb": "KSEA 131053Z 00000KT 1/4SM FG VV002 09/09 A2990",
        "visib": "1/4",
        "clouds": [{"cover": "OVX", "base": null}],
        "vertVis": 200,
        "fltCat": "LIFR"
    }]"#;
    let obs = metars(body).unwrap()[0]
        .observation
        .clone()
        .expect("observation");
    assert_eq!(
        obs.ceiling_ft(),
        Some(200.0),
        "vertVis is the obscured ceiling"
    );
    assert!(!obs.has_unknown_height_ceiling());
    assert_eq!(
        obs.flight_category(),
        Some(aerocontext_core::FlightCategory::Lifr)
    );
    assert_eq!(obs.flight_category(), obs.reported_category);
}