use super::store::LoginPoint;
const EARTH_RADIUS_KM: f64 = 6371.0088;
pub(crate) fn haversine_km(a: (f64, f64), b: (f64, f64)) -> f64 {
let (lat1, lon1) = (a.0.to_radians(), a.1.to_radians());
let (lat2, lon2) = (b.0.to_radians(), b.1.to_radians());
let dlat = lat2 - lat1;
let dlon = lon2 - lon1;
let h = (dlat / 2.0).sin().powi(2) + lat1.cos() * lat2.cos() * (dlon / 2.0).sin().powi(2);
let h = h.clamp(0.0, 1.0);
2.0 * EARTH_RADIUS_KM * h.sqrt().atan2((1.0 - h).sqrt())
}
pub(crate) fn sanitize_coords(c: Option<(f64, f64)>) -> Option<(f64, f64)> {
match c {
Some((lat, lon))
if lat.is_finite()
&& lon.is_finite()
&& (-90.0..=90.0).contains(&lat)
&& (-180.0..=180.0).contains(&lon) =>
{
Some((lat, lon))
}
_ => None,
}
}
pub(crate) fn location_changed(recorded: Option<&str>, current: Option<&str>) -> bool {
match (recorded, current) {
(Some(a), Some(b)) => !a.trim().eq_ignore_ascii_case(b.trim()),
_ => false,
}
}
pub(crate) fn impossible_travel(prev: &LoginPoint, cur: &LoginPoint, max_kmh: f64) -> Option<f64> {
let (a, b) = (prev.coords?, cur.coords?);
if cur.at <= prev.at {
return None;
}
let hours = (cur.at - prev.at) as f64 / 3600.0;
let kmh = haversine_km(a, b) / hours;
(kmh > max_kmh).then_some(kmh)
}
#[cfg(test)]
mod tests {
use super::*;
const BEIJING: (f64, f64) = (39.9042, 116.4074);
const NEW_YORK: (f64, f64) = (40.7128, -74.0060);
const SHANGHAI: (f64, f64) = (31.2304, 121.4737);
fn point(coords: Option<(f64, f64)>, at: u64) -> LoginPoint {
LoginPoint {
location: None,
coords,
at,
}
}
#[test]
fn haversine_zero_distance() {
assert!(haversine_km(BEIJING, BEIJING) < 0.001);
}
#[test]
fn haversine_beijing_to_new_york() {
let km = haversine_km(BEIJING, NEW_YORK);
assert!((10_500.0..11_500.0).contains(&km), "got {km}");
}
#[test]
fn haversine_beijing_to_shanghai() {
let km = haversine_km(BEIJING, SHANGHAI);
assert!((1_000.0..1_150.0).contains(&km), "got {km}");
}
#[test]
fn haversine_is_symmetric() {
let a = haversine_km(BEIJING, NEW_YORK);
let b = haversine_km(NEW_YORK, BEIJING);
assert!((a - b).abs() < 1e-9);
}
#[test]
fn haversine_finite_for_extreme_valid_coords() {
for (a, b) in [
((0.0, 0.0), (0.0, 0.0)),
((90.0, 0.0), (-90.0, 0.0)),
((90.0, 0.0), (90.0, 180.0)),
((-90.0, -180.0), (90.0, 180.0)),
((-89.9, -180.0), (89.9, 180.0)),
] {
let km = haversine_km(a, b);
assert!(km.is_finite(), "non-finite for {a:?} -> {b:?}");
assert!(km >= 0.0, "negative for {a:?} -> {b:?}");
}
}
#[test]
fn sanitize_coords_rejects_non_finite_and_out_of_range() {
assert_eq!(sanitize_coords(None), None);
assert_eq!(sanitize_coords(Some((f64::NAN, 0.0))), None);
assert_eq!(sanitize_coords(Some((0.0, f64::NAN))), None);
assert_eq!(sanitize_coords(Some((f64::INFINITY, 0.0))), None);
assert_eq!(sanitize_coords(Some((f64::NEG_INFINITY, 0.0))), None);
assert_eq!(sanitize_coords(Some((0.0, f64::INFINITY))), None);
assert_eq!(sanitize_coords(Some((91.0, 0.0))), None);
assert_eq!(sanitize_coords(Some((-91.0, 0.0))), None);
assert_eq!(sanitize_coords(Some((0.0, -181.0))), None);
assert_eq!(sanitize_coords(Some((0.0, 181.0))), None);
assert_eq!(sanitize_coords(Some((90.0, 180.0))), Some((90.0, 180.0)));
assert_eq!(
sanitize_coords(Some((-90.0, -180.0))),
Some((-90.0, -180.0))
);
assert_eq!(sanitize_coords(Some(BEIJING)), Some(BEIJING));
}
#[test]
fn location_changed_detects_different_region() {
assert!(location_changed(Some("CN-BJ"), Some("US-NY")));
}
#[test]
fn location_changed_ignores_case_and_whitespace() {
assert!(!location_changed(Some("CN-BJ"), Some("cn-bj")));
assert!(!location_changed(Some("cn-bj"), Some(" CN-BJ ")));
}
#[test]
fn location_changed_false_when_either_side_missing() {
assert!(!location_changed(None, Some("CN-BJ")));
assert!(!location_changed(Some("CN-BJ"), None));
assert!(!location_changed(None, None));
}
#[test]
fn impossible_travel_flags_unrealistic_speed() {
let prev = point(Some(BEIJING), 1_000);
let cur = point(Some(NEW_YORK), 1_000 + 3600);
let kmh = impossible_travel(&prev, &cur, 900.0).expect("should be impossible");
assert!(kmh > 10_000.0, "got {kmh}");
}
#[test]
fn impossible_travel_allows_realistic_flight() {
let prev = point(Some(BEIJING), 1_000);
let cur = point(Some(NEW_YORK), 1_000 + 14 * 3600);
assert!(impossible_travel(&prev, &cur, 900.0).is_none());
}
#[test]
fn impossible_travel_threshold_boundary() {
let prev = point(Some((39.9042, 116.4074)), 1_000);
let cur = point(Some((39.9582, 116.4074)), 1_030);
assert!(impossible_travel(&prev, &cur, 900.0).is_none());
assert!(impossible_travel(&prev, &cur, 500.0).is_some());
}
#[test]
fn impossible_travel_none_without_coords() {
let prev = point(None, 1_000);
let cur = point(Some(NEW_YORK), 1_000 + 60);
assert!(impossible_travel(&prev, &cur, 900.0).is_none());
let prev2 = point(Some(BEIJING), 1_000);
let cur2 = point(None, 1_000 + 60);
assert!(impossible_travel(&prev2, &cur2, 900.0).is_none());
}
#[test]
fn impossible_travel_none_when_time_not_advanced() {
let prev = point(Some(BEIJING), 2_000);
let cur = point(Some(NEW_YORK), 2_000);
assert!(impossible_travel(&prev, &cur, 900.0).is_none());
let cur_back = point(Some(NEW_YORK), 1_000);
assert!(impossible_travel(&prev, &cur_back, 900.0).is_none());
}
#[test]
fn impossible_travel_uses_inferred_speed_above_threshold() {
let prev = point(Some(BEIJING), 1_000);
let cur = point(Some(NEW_YORK), 1_000 + 3600);
let kmh = impossible_travel(&prev, &cur, 1.0).expect("very low threshold");
assert!(kmh > 10_500.0 && kmh < 11_500.0, "got {kmh}");
}
}