oxiproj-engine 0.1.2

Proj-string parser, operation dispatch, and transformation pipelines for OxiProj.
Documentation
//! Property-based hardening tests for `oxiproj-engine`'s proj-string parser
//! and construction/transformation pipeline.
//!
//! Two families of properties are checked:
//!
//! 1. **No-panic on adversarial proj-strings.** [`oxiproj_engine::parse`]
//!    (the low-level tokenizer) and [`oxiproj_engine::create`] (the
//!    top-level `proj_create`-equivalent entry point, which drives ellipsoid
//!    resolution, pipeline assembly, and per-step dispatch across
//!    `oxiproj-projections`/`oxiproj-transformations`) must return `Err`
//!    rather than panic for arbitrary strings. A proj-string is exactly the
//!    kind of untrusted external input PROJ itself has to harden against
//!    (`+proj=...` text can arrive from a file, a URL query parameter, a
//!    user-typed CLI argument, ...).
//! 2. **Forward ∘ inverse round-trip.** For twelve major projections spanning
//!    the cylindrical, transverse-cylindrical, conic, azimuthal, and
//!    pseudocylindrical families, projecting a geodetic coordinate forward
//!    and then back through the same [`Pj`](oxiproj_engine::Pj) must recover
//!    the original longitude/latitude, over a bounded domain chosen to stay
//!    well clear of each projection's singularities (antimeridian-adjacent
//!    values, poles, antipodal points, etc.).
//!
//! Domains and tolerances were derived empirically (a throwaway
//! path-dependency harness driving exactly the `create`/`trans` calls used
//! below) rather than assumed: every configured case round-trips to within
//! ~1e-13 rad, so the 1e-7 rad tolerance used here has roughly six orders of
//! magnitude of margin and will not flake.
//!
//! Bounded per the workspace `proptest.toml` (64 cases/target by default);
//! this file overrides explicitly so the case counts are visible in-file.
//! Total runtime is well under a second: `create`+`trans` on these simple
//! single-step proj-strings is microseconds-scale.

use oxiproj_core::{Coord, Direction, DEG_TO_RAD};
use oxiproj_engine::{create, parse, trans, Pj};
use proptest::prelude::*;
use proptest::test_runner::TestCaseError;

// ---------------------------------------------------------------------------
// Property 1: no panic on arbitrary proj-strings.
// ---------------------------------------------------------------------------

/// Purely arbitrary printable-ish text, unconstrained by proj-string syntax.
fn arb_plain_text() -> impl Strategy<Value = String> {
    "\\PC{0,200}"
}

/// A single `+key[=value]` proj-string token built from a mix of realistic
/// keys/values and short random alphanumeric noise, so that a much larger
/// fraction of generated strings reach the parts of `create` beyond simple
/// tokenization (ellipsoid resolution, pipeline/step dispatch, per-operation
/// parameter validation) than uniformly random text would.
fn arb_token() -> impl Strategy<Value = String> {
    let key = prop_oneof![
        3 => "[a-zA-Z0-9_]{0,10}".prop_map(|s| s),
        1 => Just("proj".to_string()),
        1 => Just("ellps".to_string()),
        1 => Just("datum".to_string()),
        1 => Just("towgs84".to_string()),
        1 => Just("pipeline".to_string()),
        1 => Just("step".to_string()),
        1 => Just("inv".to_string()),
        1 => Just("axis".to_string()),
        1 => Just("order".to_string()),
        1 => Just("zone".to_string()),
        1 => Just("lat_0".to_string()),
        1 => Just("lon_0".to_string()),
        1 => Just("nadgrids".to_string()),
        1 => Just("units".to_string()),
    ];
    let value = prop_oneof![
        3 => "[a-zA-Z0-9_.,+-]{0,12}".prop_map(|s| s),
        1 => Just("merc".to_string()),
        1 => Just("utm".to_string()),
        1 => Just("WGS84".to_string()),
        1 => Just("pipeline".to_string()),
        1 => (-400i64..400).prop_map(|n| n.to_string()),
    ];
    (key, proptest::option::of(value)).prop_map(|(k, v)| match v {
        Some(v) if !v.is_empty() => format!("+{k}={v}"),
        _ => format!("+{k}"),
    })
}

fn arb_proj_string() -> impl Strategy<Value = String> {
    prop::collection::vec(arb_token(), 0..8).prop_map(|toks| toks.join(" "))
}

fn arb_any_proj_input() -> impl Strategy<Value = String> {
    prop_oneof![3 => arb_proj_string(), 1 => arb_plain_text()]
}

proptest! {
    #![proptest_config(ProptestConfig { cases: 96, .. ProptestConfig::default() })]

    /// The low-level tokenizer is documented as infallible; pin that as a
    /// property so a future change that makes it fallible (or panicky) is
    /// caught immediately.
    #[test]
    fn parse_never_panics(s in arb_any_proj_input()) {
        let _ = parse(&s);
    }
}

proptest! {
    #![proptest_config(ProptestConfig { cases: 256, .. ProptestConfig::default() })]

    /// `create` (proj-string -> `Pj`) must return a `Result`, never panic,
    /// for any input: structured proj-string-shaped noise or fully
    /// arbitrary Unicode alike. This exercises ellipsoid resolution
    /// (`+ellps=`/`+datum=`/`+towgs84=`), ordinary single-operation
    /// construction, and `+proj=pipeline`/`+step`/`+inv` pipeline assembly.
    #[test]
    fn create_never_panics(s in arb_any_proj_input()) {
        let _ = create(&s);
    }
}

// ---------------------------------------------------------------------------
// Property 2: forward ∘ inverse round-trip for 12 major projections.
// ---------------------------------------------------------------------------

/// Projects `(lon_deg, lat_deg)` forward through `pj` and back, asserting
/// the recovered longitude/latitude match the input to within `tol` radians.
/// Returns a `proptest`-compatible `Result` so it can be used with `?`
/// inside a `proptest!` test body while still producing a good shrunk
/// failure report.
fn assert_roundtrip(pj: &Pj, lon_deg: f64, lat_deg: f64, tol: f64) -> Result<(), TestCaseError> {
    let lon_rad = lon_deg * DEG_TO_RAD;
    let lat_rad = lat_deg * DEG_TO_RAD;
    let input = Coord::new(lon_rad, lat_rad, 0.0, 0.0);

    let fwd = trans(pj, Direction::Fwd, input).map_err(|e| {
        TestCaseError::fail(format!("forward failed at ({lon_deg}, {lat_deg}): {e:?}"))
    })?;
    let back = trans(pj, Direction::Inv, fwd).map_err(|e| {
        TestCaseError::fail(format!("inverse failed at ({lon_deg}, {lat_deg}): {e:?}"))
    })?;

    let dlon = (back.v()[0] - lon_rad).abs();
    let dlat = (back.v()[1] - lat_rad).abs();
    prop_assert!(
        dlon < tol,
        "longitude round-trip diff {dlon} >= {tol} at ({lon_deg}, {lat_deg})"
    );
    prop_assert!(
        dlat < tol,
        "latitude round-trip diff {dlat} >= {tol} at ({lon_deg}, {lat_deg})"
    );
    Ok(())
}

/// Round-trip tolerance in radians. Empirically observed worst-case error
/// across all twelve configurations below is ~2.3e-14 rad; this leaves
/// roughly six orders of magnitude of margin.
const ROUNDTRIP_TOL: f64 = 1e-7;

proptest! {
    #![proptest_config(ProptestConfig { cases: 64, .. ProptestConfig::default() })]

    /// Mercator (cylindrical, conformal). Domain avoids the poles, where
    /// `y -> infinity`.
    #[test]
    fn merc_roundtrip(lon_deg in -179.0f64..179.0, lat_deg in -80.0f64..80.0) {
        let pj = create("+proj=merc +ellps=WGS84").expect("merc must construct");
        assert_roundtrip(&pj, lon_deg, lat_deg, ROUNDTRIP_TOL)?;
    }

    /// Transverse Mercator (Krueger series), restricted to a band around
    /// its central meridian where the series converges accurately.
    #[test]
    fn tmerc_roundtrip(lon_deg in 3.0f64..15.0, lat_deg in -80.0f64..80.0) {
        let pj = create("+proj=tmerc +lon_0=9 +ellps=WGS84").expect("tmerc must construct");
        assert_roundtrip(&pj, lon_deg, lat_deg, ROUNDTRIP_TOL)?;
    }

    /// Extended Transverse Mercator (`etmerc`), valid much farther from the
    /// central meridian than classic `tmerc` but not literally the whole
    /// globe: the Krueger series' round-trip accuracy degrades sharply past
    /// roughly a 60-70 degree offset (verified empirically here, and
    /// cross-checked bit-for-bit against the system `proj` CLI, which shows
    /// the identical ~1e-4 degree round-trip residual at an 80 degree
    /// offset -- this is inherent etmerc numerical behavior, not an
    /// oxiproj-specific bug). The domain below (+-50 degrees) keeps the
    /// residual near 1e-14 rad, five orders of magnitude under `ROUNDTRIP_TOL`.
    #[test]
    fn etmerc_roundtrip(lon_deg in -41.0f64..59.0, lat_deg in -80.0f64..80.0) {
        let pj = create("+proj=etmerc +lon_0=9 +ellps=WGS84").expect("etmerc must construct");
        assert_roundtrip(&pj, lon_deg, lat_deg, ROUNDTRIP_TOL)?;
    }

    /// UTM zone 32 (Transverse Mercator specialisation with zone-derived
    /// `lon_0`/`k_0`/false easting).
    #[test]
    fn utm_roundtrip(lon_deg in 3.0f64..15.0, lat_deg in -80.0f64..80.0) {
        let pj = create("+proj=utm +zone=32 +ellps=WGS84").expect("utm must construct");
        assert_roundtrip(&pj, lon_deg, lat_deg, ROUNDTRIP_TOL)?;
    }

    /// Lambert Conformal Conic (2SP), CONUS-scale standard parallels.
    #[test]
    fn lcc_roundtrip(lon_deg in -125.0f64..-65.0, lat_deg in 20.0f64..60.0) {
        let pj = create("+proj=lcc +lat_1=33 +lat_2=45 +lat_0=39 +lon_0=-96 +ellps=WGS84")
            .expect("lcc must construct");
        assert_roundtrip(&pj, lon_deg, lat_deg, ROUNDTRIP_TOL)?;
    }

    /// Albers Equal-Area Conic, CONUS-scale standard parallels.
    #[test]
    fn aea_roundtrip(lon_deg in -125.0f64..-65.0, lat_deg in 15.0f64..55.0) {
        let pj = create("+proj=aea +lat_1=29.5 +lat_2=45.5 +lat_0=23 +lon_0=-96 +ellps=WGS84")
            .expect("aea must construct");
        assert_roundtrip(&pj, lon_deg, lat_deg, ROUNDTRIP_TOL)?;
    }

    /// Polar Stereographic (azimuthal), domain kept away from the antipodal
    /// south pole singularity.
    #[test]
    fn stere_polar_roundtrip(lon_deg in -179.0f64..179.0, lat_deg in 10.0f64..89.0) {
        let pj = create("+proj=stere +lat_0=90 +lon_0=0 +ellps=WGS84").expect("stere must construct");
        assert_roundtrip(&pj, lon_deg, lat_deg, ROUNDTRIP_TOL)?;
    }

    /// Lambert Azimuthal Equal-Area, oblique aspect centered away from the
    /// origin; domain kept well clear of the antipodal point.
    #[test]
    fn laea_roundtrip(lon_deg in -50.0f64..70.0, lat_deg in 5.0f64..80.0) {
        let pj = create("+proj=laea +lat_0=45 +lon_0=10 +ellps=WGS84").expect("laea must construct");
        assert_roundtrip(&pj, lon_deg, lat_deg, ROUNDTRIP_TOL)?;
    }

    /// Equidistant Cylindrical (Plate Carrée family).
    #[test]
    fn eqc_roundtrip(lon_deg in -179.0f64..179.0, lat_deg in -85.0f64..85.0) {
        let pj = create("+proj=eqc +ellps=WGS84").expect("eqc must construct");
        assert_roundtrip(&pj, lon_deg, lat_deg, ROUNDTRIP_TOL)?;
    }

    /// Cylindrical Equal-Area (Lambert's).
    #[test]
    fn cea_roundtrip(lon_deg in -179.0f64..179.0, lat_deg in -85.0f64..85.0) {
        let pj = create("+proj=cea +ellps=WGS84").expect("cea must construct");
        assert_roundtrip(&pj, lon_deg, lat_deg, ROUNDTRIP_TOL)?;
    }

    /// Mollweide (pseudocylindrical, Newton-iteration inverse).
    #[test]
    fn moll_roundtrip(lon_deg in -179.0f64..179.0, lat_deg in -85.0f64..85.0) {
        let pj = create("+proj=moll +ellps=WGS84").expect("moll must construct");
        assert_roundtrip(&pj, lon_deg, lat_deg, ROUNDTRIP_TOL)?;
    }

    /// Sinusoidal (pseudocylindrical, closed-form inverse).
    #[test]
    fn sinu_roundtrip(lon_deg in -179.0f64..179.0, lat_deg in -85.0f64..85.0) {
        let pj = create("+proj=sinu +ellps=WGS84").expect("sinu must construct");
        assert_roundtrip(&pj, lon_deg, lat_deg, ROUNDTRIP_TOL)?;
    }
}