use kevy_geo::*;
const EPS_COORD: f64 = 1e-3; const EPS_DIST: f64 = 1.0; const EPS_BIT_PERFECT: u64 = 0;
fn within(a: f64, b: f64, eps: f64) -> bool {
(a - b).abs() <= eps
}
#[test]
fn encode_rejects_out_of_range() {
assert!(encode_score(0.0, 86.0).is_none(), "lat > GEO_LAT_MAX");
assert!(encode_score(0.0, -86.0).is_none(), "lat < GEO_LAT_MIN");
assert!(encode_score(181.0, 0.0).is_none(), "lon > 180");
assert!(encode_score(-181.0, 0.0).is_none(), "lon < -180");
assert!(encode_score(f64::NAN, 0.0).is_none());
assert!(encode_score(0.0, f64::NAN).is_none());
assert!(encode_score(f64::INFINITY, 0.0).is_none());
}
#[test]
fn encode_accepts_extremes() {
assert!(encode_score(0.0, GEO_LAT_MAX).is_some());
assert!(encode_score(0.0, GEO_LAT_MIN).is_some());
assert!(encode_score(180.0, 0.0).is_some());
assert!(encode_score(-180.0, 0.0).is_some());
}
#[test]
fn encode_decode_round_trip_known_cities() {
let cases = [
("Tokyo", 139.6917, 35.6895),
("New York", -74.0060, 40.7128),
("Sydney", 151.2093, -33.8688),
("São Paulo", -46.6333, -23.5505),
("Equator+Prime", 0.0, 0.0),
];
for (name, lon, lat) in cases {
let score = encode_score(lon, lat).expect(name);
let (lon_out, lat_out) = decode_score(score);
assert!(
within(lon_out, lon, EPS_COORD),
"{name}: lon round-trip lost precision: {lon} → {lon_out}",
);
assert!(
within(lat_out, lat, EPS_COORD),
"{name}: lat round-trip lost precision: {lat} → {lat_out}",
);
}
}
#[test]
fn score_matches_redis_for_palermo() {
let score = encode_score(13.361389, 38.115556).expect("Palermo");
let bits = score as u64;
assert_eq!(
bits, 3479099956230698,
"Palermo score must match Redis bit-for-bit (got {bits})",
);
assert_eq!(score, 3479099956230698f64 + EPS_BIT_PERFECT as f64);
}
#[test]
fn score_matches_redis_for_catania() {
let score = encode_score(15.087269, 37.502669).expect("Catania");
assert_eq!(score as u64, 3479447370796909);
}
#[test]
fn haversine_palermo_to_catania_matches_redis() {
let d = haversine_meters(13.361389, 38.115556, 15.087269, 37.502669);
let target = 166_274.151_6_f64;
assert!(
(d - target).abs() < 5.0,
"Palermo→Catania distance {d} m differs from Redis {target} m by more than 5 m",
);
}
#[test]
fn haversine_identical_points_is_zero() {
let d = haversine_meters(13.361389, 38.115556, 13.361389, 38.115556);
assert!(d < EPS_DIST, "expected ~0, got {d}");
}
#[test]
fn haversine_antipode_is_half_circumference() {
let d = haversine_meters(0.0, 0.0, 180.0, 0.0);
let expected = std::f64::consts::PI * EARTH_RADIUS_METERS;
assert!(
(d - expected).abs() < 1.0,
"antipode distance {d} ≠ πR ≈ {expected}",
);
}
fn geohash_via_score(lon: f64, lat: f64) -> String {
let score = encode_score(lon, lat).expect("in range");
let (lon_c, lat_c) = decode_score(score);
let buf = encode_base32_geohash(lon_c, lat_c);
String::from_utf8(buf.to_vec()).unwrap()
}
#[test]
fn base32_geohash_palermo_matches_redis_to_10_chars() {
let got = geohash_via_score(13.361389, 38.115556);
assert_eq!(&got[..10], "sqc8b49rny", "got: {got}");
}
#[test]
fn base32_geohash_catania_matches_redis_to_10_chars() {
let got = geohash_via_score(15.087269, 37.502669);
assert_eq!(&got[..10], "sqdtr74hyu", "got: {got}");
}
#[test]
fn decode_garbage_score_does_not_panic() {
let _ = decode_score(f64::NAN);
let _ = decode_score(f64::INFINITY);
let _ = decode_score(-1.0);
let _ = decode_score(1e30);
}
#[test]
fn neighbor_ranges_for_zero_radius_returns_full_keyspace() {
let r = neighbor_score_ranges(13.36, 38.11, 0.0);
assert_eq!(r.len(), 1);
assert_eq!(r[0].0, 0.0);
assert!(r[0].1 >= (1u64 << 52) as f64 - 1.0);
}
#[test]
fn neighbor_ranges_small_radius_returns_compact_ranges() {
let palermo_score = encode_score(13.361389, 38.115556).unwrap();
let ranges = neighbor_score_ranges(13.361389, 38.115556, 1_000.0);
assert!(!ranges.is_empty(), "expected ≥ 1 range");
assert!(
ranges.iter().any(|(min, max)| palermo_score >= *min && palermo_score <= *max),
"Palermo's score {palermo_score} not covered by any range: {ranges:?}",
);
for (min, max) in &ranges {
assert!(min <= max, "inverted range: ({min}, {max})");
}
}
#[test]
fn neighbor_ranges_medium_radius_includes_known_neighbour() {
let catania_score = encode_score(15.087269, 37.502669).unwrap();
let ranges = neighbor_score_ranges(13.361389, 38.115556, 200_000.0);
assert!(
ranges.iter().any(|(min, max)| catania_score >= *min && catania_score <= *max),
"Catania score not covered for 200 km radius: {ranges:?}",
);
}
#[test]
fn neighbor_ranges_sorted_and_disjoint() {
let ranges = neighbor_score_ranges(0.0, 0.0, 50_000.0);
for w in ranges.windows(2) {
assert!(
w[0].1 < w[1].0,
"ranges should be disjoint after merge: {w:?}",
);
}
}
#[test]
fn score_is_within_52_bits() {
let cases = [
(GEO_LON_MAX, GEO_LAT_MAX),
(GEO_LON_MIN, GEO_LAT_MIN),
(0.0, 0.0),
(-180.0, 85.0),
];
for (lon, lat) in cases {
let score = encode_score(lon, lat).expect("in range");
assert!(score >= 0.0);
assert!((score as u64) < (1u64 << 52));
assert_eq!(score, (score as u64) as f64);
}
}