mgrs-convert 0.1.0

Convert WGS84 latitude-longitude to and from UTM, UPS, and MGRS grid references, round-tripping exactly at every precision from 100 km to 1 um
Documentation
//! Regression tests named for the five defect mechanisms recorded against
//! `geoconvert` 1.0.2, plus reachability of every error variant and the
//! pre-release review findings. Each test pins the behavior that forecloses
//! the recorded defect.

mod util;

use mgrs_convert::{Error, LatLon, Mgrs, ParseErrorKind, Precision, UtmUps};

/// M1: the incumbent accumulated MGRS digits in i32 and wrapped at precision 8
/// to 11 in release builds (4,295 km errors under `Ok`), panicking in debug.
/// This parser is i64 throughout; maximum-magnitude precision-11 strings parse
/// exactly in the debug profile, where any overflow that did sneak in panics
/// instead of wrapping. CI runs this suite in both profiles.
#[test]
fn defect_m1_precision_8_to_11_parse_without_overflow() {
    // All-nines digit blocks are the maximum digit magnitude at each precision.
    for p in 8usize..=11 {
        let digits = "9".repeat(p);
        let s = format!("18TWL{digits}{digits}");
        let cell: Mgrs = s.parse().unwrap_or_else(|e| panic!("{s}: {e}"));
        assert_eq!(cell.to_string(), s, "identity at p{p}");
        let c = cell.to_utmups();
        // The center stays inside the named 100 km square: WL in zone 18 is
        // easting 500..600 km, absolute northing 4,500..4,600 km.
        assert!(c.easting() > 599_999.0 && c.easting() < 600_000.0);
        assert!(c.northing() > 4_599_999.0 && c.northing() < 4_600_000.0);
    }
    // V7: the audit's p8 string re-parses and extends to p9, p10, p11.
    for extra in ["7", "77", "777"] {
        let s = format!("18TWL85664121{extra}11315421{extra}");
        let cell: Mgrs = s.parse().unwrap_or_else(|e| panic!("{s}: {e}"));
        assert_eq!(cell.to_string(), s);
        let c = cell.to_utmups();
        assert!((c.easting() - 585_664.121).abs() < 0.01);
        assert!((c.northing() - 4_511_315.421).abs() < 0.01);
    }
    // The incumbent could not re-parse its own precision-8 output.
    let p8 = Precision::new(8).unwrap();
    let cell = LatLon::new(40.748333, -73.985278).unwrap().to_mgrs(p8);
    let reparsed: Mgrs = cell.to_string().parse().unwrap();
    assert_eq!(reparsed, cell);
}

/// M2: the incumbent accepted a slop ring at construction and panicked in
/// `Display`. Here a value that exists is formattable: the only fallible step
/// is `to_mgrs` itself, and it returns `Err(OutsideMgrsExtent)` for the ring.
#[test]
fn defect_m2_transfer_ring_errs_instead_of_panicking() {
    // UTM easting ring, both sides, both hemispheres.
    let p5 = Precision::ONE_METER;
    for &e in &[
        0.0,
        50_000.0,
        99_999.999,
        900_000.001,
        950_000.0,
        1_000_000.0,
    ] {
        for &north in &[true, false] {
            let n = if north { 4_511_315.0 } else { 5_488_685.0 };
            let u = UtmUps::utm(18, north, e, n).unwrap();
            assert!(
                matches!(u.to_mgrs(p5), Err(Error::OutsideMgrsExtent { .. })),
                "easting {e} north {north}"
            );
            let _ = u.to_latlon(); // the ring still reverse-projects
        }
    }
    // UTM northing ring: absolute northing beyond [-9,000, 9,500] km.
    for &(north, n) in &[
        (true, 9_500_000.001),
        (true, 9_600_000.0),
        (true, -9_000_000.001),
        (true, -9_100_000.0),
        (false, 999_999.999),
        (false, 900_000.0),
        (false, 19_500_000.001),
        (false, 19_600_000.0),
    ] {
        let u = UtmUps::utm(18, north, 500_000.0, n).unwrap();
        assert!(
            matches!(u.to_mgrs(p5), Err(Error::OutsideMgrsExtent { .. })),
            "northing {n} north {north}"
        );
    }
    // UPS ring: the polar path the audit proved broken (3,186 of 17,230
    // accepted points panicked on formatting in the incumbent).
    for &(north, e, n) in &[
        (true, 1_250_000.0, 2_000_000.0),
        (true, 2_750_000.0, 2_000_000.0),
        (true, 2_000_000.0, 1_299_999.999),
        (true, 2_000_000.0, 2_700_000.001),
        (false, 750_000.0, 2_000_000.0),
        (false, 3_250_000.0, 2_000_000.0),
        (false, 2_000_000.0, 799_999.999),
        (false, 2_000_000.0, 3_200_000.001),
    ] {
        let u = UtmUps::ups(north, e, n).unwrap();
        assert!(
            matches!(u.to_mgrs(p5), Err(Error::OutsideMgrsExtent { .. })),
            "ups {e} {n} north {north}"
        );
        let _ = u.to_latlon();
    }
    // Sweep the accepted UTM and UPS spaces: every accepted value either
    // formats or errs; formatting never panics and always re-parses.
    let mut formatted = 0u32;
    let mut ringed = 0u32;
    let mut e = 0.0;
    while e <= 1_000_000.0 {
        let mut n = -9_100_000.0;
        while n <= 9_600_000.0 {
            for &north in &[true, false] {
                let frame = if north { n } else { n + 10_000_000.0 };
                let u = UtmUps::utm(31, north, e, frame).unwrap();
                match u.to_mgrs(p5) {
                    Ok(m) => {
                        let s = m.to_string();
                        let back: Mgrs = s.parse().unwrap();
                        assert_eq!(back, m, "{s}");
                        formatted += 1;
                    }
                    Err(Error::OutsideMgrsExtent { .. }) => ringed += 1,
                    Err(other) => panic!("unexpected {other}"),
                }
            }
            n += 471_119.0;
        }
        e += 61_237.0;
    }
    assert!(formatted > 500 && ringed > 100, "{formatted} {ringed}");
}

/// M3: the incumbent documented precision validation and never checked,
/// walking past a fixed buffer at precision 12 and accepting negatives.
/// `Precision` makes both unrepresentable.
#[test]
fn defect_m3_precision_is_validated_at_construction() {
    for p in 0u8..=11 {
        assert_eq!(Precision::new(p).map(Precision::get), Ok(p));
    }
    for p in [12u8, 13, 27, 255] {
        assert_eq!(
            Precision::new(p),
            Err(Error::PrecisionOutOfRange { found: p })
        );
    }
    // Precision 0, the 100 km square form, exists and round-trips.
    let p0 = Precision::new(0).unwrap();
    let cell = LatLon::new(40.748333, -73.985278).unwrap().to_mgrs(p0);
    assert_eq!(cell.to_string(), "18TWL");
    assert_eq!("18TWL".parse::<Mgrs>().unwrap(), cell);
    // Parsing derives precision from the digit count and rejects 24 digits
    // before touching any digit value.
    let too_many = format!("18TWL{}", "4".repeat(24));
    assert_eq!(
        too_many.parse::<Mgrs>(),
        Err(Error::Parse(ParseErrorKind::TooManyDigits))
    );
}

/// M4: the incumbent derived the boundary epsilon from the decimal-digit-count
/// constant (15, where the mantissa bit count 53 was meant) and shipped 1024 m
/// instead of 2^-28 m. The constant's value is pinned in a unit
/// test next to its definition; this test pins the observable behavior on a
/// one-ulp ladder around both audit vectors.
#[test]
fn defect_m4_boundary_epsilon_behavior() {
    let p8 = Precision::new(8).unwrap();
    // Exactly on the excluded easting edge: last cell of its own square.
    let at = UtmUps::utm(18, true, 900_000.0, 4_511_315.422).unwrap();
    assert_eq!(at.to_mgrs(p8).unwrap().to_string(), "18TZL9999999911315422");
    // One ulp below: same cell.
    let below = UtmUps::utm(
        18,
        true,
        f64::from_bits(900_000.0_f64.to_bits() - 1),
        4_511_315.422,
    )
    .unwrap();
    assert_eq!(
        below.to_mgrs(p8).unwrap().to_string(),
        "18TZL9999999911315422"
    );
    // One ulp above: outside the grid, an error, not a panic and not a wrap.
    let above = UtmUps::utm(
        18,
        true,
        f64::from_bits(900_000.0_f64.to_bits() + 1),
        4_511_315.422,
    )
    .unwrap();
    assert!(matches!(
        above.to_mgrs(p8),
        Err(Error::OutsideMgrsExtent { .. })
    ));
    // The northing audit vector at 1 mm resolution on either side.
    let s1 = UtmUps::utm(18, false, 500_000.0, 9_999_999.999_5).unwrap();
    assert_eq!(s1.to_mgrs(p8).unwrap().to_string(), "18MWE0000000099999999");
    let s2 = UtmUps::utm(18, false, 500_000.0, 10_000_000.0).unwrap();
    assert_eq!(s2.to_mgrs(p8).unwrap().to_string(), "18MWE0000000099999999");
}

/// M5: the incumbent's northing-to-band fold panicked at latitude exactly 0.
/// The band comes from latitude; latitude 0 and -0.0 are band N, row 0.
#[test]
fn defect_m5_latitude_zero_is_band_n() {
    let p6 = Precision::new(6).unwrap();
    for lon in [-180.0, -73.985278, 0.0, 0.5, 77.0, 179.999999] {
        let plus = LatLon::new(0.0, lon).unwrap().to_mgrs(p6).to_string();
        let minus = LatLon::new(-0.0, lon).unwrap().to_mgrs(p6).to_string();
        assert_eq!(plus, minus, "lon {lon}");
        let band = plus.chars().nth(2).unwrap();
        assert_eq!(band, 'N', "lon {lon}: {plus}");
    }
    assert_eq!(
        LatLon::new(0.0, 0.0).unwrap().to_mgrs(p6).to_string(),
        "31NAA660214000000"
    );
    // One-ulp neighbors of zero stay adjacent, no panic, correct hemisphere.
    let up = LatLon::new(f64::from_bits(1), 0.0).unwrap();
    assert!(up.to_utmups().is_north());
    let down = LatLon::new(-f64::from_bits(1), 0.0).unwrap();
    assert!(!down.to_utmups().is_north());
    let _ = up.to_mgrs(p6);
    let _ = down.to_mgrs(p6);
    // The UtmUps entry point at northing exactly 0.
    let u = UtmUps::utm(31, true, 166_021.443, 0.0).unwrap();
    let m = u.to_mgrs(p6).unwrap().to_string();
    assert!(m.starts_with("31NAA"), "{m}");
}

/// The 2026-08-04 numerical-robustness review: `utm_extent` folded the
/// southern-frame northing before comparing it, and the subtraction rounded a
/// just-outside value onto the -9,000,000 m limit. Eight consecutive f64
/// values below the closed 1,000,000 m southern frame limit passed the check,
/// truncated to row -91, and clamped into a cell 100 km away, so `to_mgrs`
/// returned `Ok` for coordinates GeoConvert rejects. The check now compares
/// in the frame. This pins the whole ladder as transfer-ring errors, the
/// closed limits as the bottom row of band C, and the included grid top as
/// the nudged all-nines row, in both frames, against GeoConvert 2.7 strings.
#[test]
fn review_southern_limit_cancellation_is_a_transfer_ring_error() {
    let precisions = [0u8, 5, 8, 11].map(|p| Precision::new(p).unwrap());
    // The eight representable northings below the limit, starting at the
    // reported repro value 999,999.9999999999.
    let mut n = 1_000_000.0_f64;
    for step in 1..=8u32 {
        n = f64::from_bits(n.to_bits() - 1);
        let u = UtmUps::utm(18, false, 500_000.0, n).unwrap();
        for p in precisions {
            assert!(
                matches!(u.to_mgrs(p), Err(Error::OutsideMgrsExtent { .. })),
                "step {step} northing {n} p{}",
                p.get()
            );
        }
    }
    // Mirror: one f64 above the northern limit in each frame is an error.
    let p11 = Precision::new(11).unwrap();
    for (north, top) in [(true, 9_500_000.0_f64), (false, 19_500_000.0_f64)] {
        let above = f64::from_bits(top.to_bits() + 1);
        let u = UtmUps::utm(18, north, 500_000.0, above).unwrap();
        assert!(
            matches!(u.to_mgrs(p11), Err(Error::OutsideMgrsExtent { .. })),
            "above {top}"
        );
    }
    // The limits themselves, in both frames.
    for (north, n, want) in [
        (false, 1_000_000.0, "18CWR0000000000000000000000"),
        (true, -9_000_000.0, "18CWR0000000000000000000000"),
        (true, 9_500_000.0, "18XWV0000000000099999999999"),
        (false, 19_500_000.0, "18XWV0000000000099999999999"),
    ] {
        let u = UtmUps::utm(18, north, 500_000.0, n).unwrap();
        assert_eq!(u.to_mgrs(p11).unwrap().to_string(), want, "northing {n}");
    }
}

/// Every `Error` variant is reachable through the public API.
#[test]
fn every_error_variant_is_reachable() {
    assert!(matches!(
        LatLon::new(90.5, 0.0),
        Err(Error::LatOutOfRange { .. })
    ));
    assert!(matches!(
        LatLon::new(f64::NAN, 0.0),
        Err(Error::LatOutOfRange { .. })
    ));
    assert!(matches!(
        LatLon::new(0.0, f64::INFINITY),
        Err(Error::LonOutOfRange { .. })
    ));
    assert!(matches!(
        UtmUps::utm(0, true, 500_000.0, 0.0),
        Err(Error::ZoneOutOfRange { found: 0 })
    ));
    assert!(matches!(
        UtmUps::utm(61, true, 500_000.0, 0.0),
        Err(Error::ZoneOutOfRange { found: 61 })
    ));
    assert!(matches!(
        UtmUps::utm(18, true, -0.001, 0.0),
        Err(Error::EastingOutOfRange { .. })
    ));
    assert!(matches!(
        UtmUps::utm(18, true, f64::NAN, 0.0),
        Err(Error::EastingOutOfRange { .. })
    ));
    assert!(matches!(
        UtmUps::utm(18, true, 500_000.0, 9_600_000.001),
        Err(Error::NorthingOutOfRange { .. })
    ));
    assert!(matches!(
        UtmUps::ups(true, 1_199_999.999, 2_000_000.0),
        Err(Error::EastingOutOfRange { .. })
    ));
    assert!(matches!(
        UtmUps::ups(false, 2_000_000.0, 3_300_000.001),
        Err(Error::NorthingOutOfRange { .. })
    ));
    assert!(matches!(
        Precision::new(12),
        Err(Error::PrecisionOutOfRange { found: 12 })
    ));
    let ring = UtmUps::utm(18, true, 50_000.0, 4_511_315.0).unwrap();
    assert!(matches!(
        ring.to_mgrs(Precision::ONE_METER),
        Err(Error::OutsideMgrsExtent { .. })
    ));
}

/// Every `ParseErrorKind` is reachable, one witness per kind.
#[test]
fn every_parse_error_kind_is_reachable() {
    use ParseErrorKind as K;
    let kind = |s: &str| -> K {
        match s.parse::<Mgrs>() {
            Err(Error::Parse(k)) => k,
            other => panic!("{s}: expected parse error, got {other:?}"),
        }
    };
    assert_eq!(kind(""), K::Empty);
    assert_eq!(kind("   "), K::Empty);
    assert_eq!(kind("18T"), K::GridZoneOnly);
    assert_eq!(kind("Y"), K::GridZoneOnly);
    assert_eq!(kind("00AAA00"), K::BadZoneNumber);
    assert_eq!(kind("61CAA00"), K::BadZoneNumber);
    assert_eq!(kind("123ABC"), K::BadZoneNumber);
    assert_eq!(kind("18IAA00"), K::BadBandLetter);
    assert_eq!(kind("18"), K::BadBandLetter);
    assert_eq!(kind("Q"), K::BadBandLetter);
    assert_eq!(kind("18TIL8566411315"), K::BadSquareLetters);
    assert_eq!(kind("18TW"), K::BadSquareLetters);
    assert_eq!(kind("18TW1"), K::BadSquareLetters);
    assert_eq!(kind("18TAL0000000000"), K::SquareZoneMismatch);
    assert_eq!(kind("YAA00"), K::SquareZoneMismatch);
    assert_eq!(kind("18TWA0000000000"), K::SquareBandMismatch);
    assert_eq!(kind("18TWL856641131"), K::OddDigitCount);
    assert_eq!(kind(&format!("18TWL{}", "4".repeat(24))), K::TooManyDigits);
    assert_eq!(kind("18TWL 85664 11315"), K::NonAsciiOrWhitespace);
    assert_eq!(kind("18TWл8566411315"), K::NonAsciiOrWhitespace);
    assert_eq!(kind("18TWL85664x1315"), K::TrailingInput);
    // Case folding and outer whitespace are accepted, not errors.
    assert!("  18twl8566411315\t".parse::<Mgrs>().is_ok());
    assert!("08UNC00".parse::<Mgrs>().is_ok());
}

/// The error type formats and implements std::error::Error.
#[test]
fn error_display_is_useful() {
    let e = Error::OutsideMgrsExtent {
        easting: 50_000.0,
        northing: 4_511_315.0,
    };
    let msg = format!("{e}");
    assert!(msg.contains("50000"), "{msg}");
    let dynerr: &dyn std::error::Error = &e;
    assert!(!dynerr.to_string().is_empty());
    let p = Error::Parse(ParseErrorKind::GridZoneOnly);
    assert!(format!("{p}").contains("grid-zone-only"));
}