umsh_node/
location.rs

1//! Variable-precision geographic location encoding.
2//!
3//! [`NodeLocation`] represents a geographic position as a 1–7 byte grid code.
4//! Each byte refines the location to a 16×16 sub-grid of the parent cell, with the
5//! high nibble indexing latitude and the low nibble indexing longitude.
6//!
7//! The encoding has a useful truncation property: dropping trailing bytes gives the
8//! correct lower-precision encoding of the same position — no recomputation needed.
9//!
10//! Every coordinate pair in this module is `(latitude, longitude)`, in that
11//! order, without exception — parameters, return tuples, and rendered text alike.
12//!
13//! # Encoding
14//!
15//! For a given precision N (1–7 bytes), two 4N-bit indices are computed:
16//!
17//! ```text
18//! lat_index = floor((lat +  90) × 16^N / 180)
19//! lon_index = floor((lon + 180) × 16^N / 360)
20//! ```
21//!
22//! Nibbles are extracted most-significant-first into bytes:
23//!
24//! ```text
25//! byte[k] = ((lat_index >> (4×(N-1-k))) & 0xF) << 4
26//!         | ((lon_index >> (4×(N-1-k))) & 0xF)
27//! ```
28//!
29//! # Precision
30//!
31//! | Bytes | Equator cell, lat × lon (approx.) |
32//! |------:|:---------------------------------:|
33//! |   1   | 1,250 × 2,500 km                 |
34//! |   2   | 78 × 156 km                      |
35//! |   3   | 4.9 × 9.8 km                     |
36//! |   4   | 305 × 610 m                      |
37//! |   5   | 19 × 38 m                        |
38//! |   6   | 1.2 × 2.4 m                      |
39//! |   7   | 7.5 × 15 cm                      |
40//!
41//! # Feature: `f64`
42//!
43//! By default all floating-point arithmetic uses `f32`, which is adequate for
44//! precisions 1–5 (cells ≥ 19 m). Enable the `f64` crate feature for accurate
45//! encoding and decoding at 6–7 byte precision.
46
47use core::fmt;
48
49/// Maximum supported precision in bytes.
50pub const MAX_PRECISION: u8 = 7;
51
52/// A variable-precision geographic location encoded as a 1–7 byte grid code.
53///
54/// Each byte refines the location to a 16×16 sub-grid. Within each byte, the
55/// high nibble indexes latitude and the low nibble indexes longitude. Every
56/// coordinate pair in this type is `(latitude, longitude)`, in that order.
57///
58/// The zero-length `UNSPECIFIED` sentinel represents an unknown location.
59#[derive(Clone, Copy, PartialEq, Eq, Hash)]
60pub struct NodeLocation {
61    len: u8,
62    bytes: [u8; MAX_PRECISION as usize],
63}
64
65impl NodeLocation {
66    /// An unspecified location with zero-byte precision.
67    pub const UNSPECIFIED: NodeLocation = NodeLocation {
68        len: 0,
69        bytes: [0; MAX_PRECISION as usize],
70    };
71
72    /// Construct from a byte slice, silently truncating to [`MAX_PRECISION`].
73    /// Never panics.
74    pub fn from_bytes(bytes: &[u8]) -> Self {
75        let len = bytes.len().min(MAX_PRECISION as usize) as u8;
76        let mut buf = [0u8; MAX_PRECISION as usize];
77        buf[..len as usize].copy_from_slice(&bytes[..len as usize]);
78        Self { len, bytes: buf }
79    }
80
81    /// Encode a `(latitude, longitude)` position in degrees at the given precision.
82    ///
83    /// `precision` is clamped to [`MAX_PRECISION`]. Inputs are clamped to valid
84    /// ranges (`[-90, +90]` and `[-180, +180]`).
85    ///
86    /// Internal arithmetic uses `f32` by default. Enable the `f64` crate feature
87    /// for accurate results at 6–7 byte precision.
88    pub fn from_lat_lon(lat: f32, lon: f32, precision: u8) -> Self {
89        let precision = precision.min(MAX_PRECISION);
90        if precision == 0 {
91            return Self::UNSPECIFIED;
92        }
93        let lat = lat.clamp(-90.0, 90.0);
94        let lon = lon.clamp(-180.0, 180.0);
95        let (lat_idx, lon_idx) = encode_indices(lat, lon, precision as u32);
96
97        let mut bytes = [0u8; MAX_PRECISION as usize];
98        for k in 0..precision as usize {
99            let shift = 4 * (precision as usize - 1 - k);
100            let hi = ((lat_idx >> shift) & 0xF) as u8;
101            let lo = ((lon_idx >> shift) & 0xF) as u8;
102            bytes[k] = (hi << 4) | lo;
103        }
104        Self {
105            len: precision,
106            bytes,
107        }
108    }
109
110    /// Encode a `(latitude, longitude)` position in degrees at the given precision.
111    ///
112    /// Only available with the `f64` crate feature. Prefer this over
113    /// [`from_lat_lon`](Self::from_lat_lon) when working with f64 coordinates and
114    /// 6–7 byte precision.
115    #[cfg(feature = "f64")]
116    pub fn from_lat_lon_f64(lat: f64, lon: f64, precision: u8) -> Self {
117        Self::from_lat_lon(lat as f32, lon as f32, precision)
118    }
119
120    /// Encode a `(latitude, longitude)` position given in units of 1e-7
121    /// degrees, exactly and without floating point.
122    ///
123    /// This is the constructor to use for a position that arrived as
124    /// decimal digits — a GNSS receiver's `ddmm.mmmm` fields, most of
125    /// all. [`from_lat_lon`](Self::from_lat_lon) has to round the value
126    /// into a binary float first, which at 6–7 byte precision can land it
127    /// in the neighbouring cell; this cannot, at any precision, with or
128    /// without the `f64` feature.
129    ///
130    /// `precision` is clamped to [`MAX_PRECISION`]; coordinates are
131    /// clamped to their valid ranges.
132    pub fn from_e7(lat_e7: i32, lon_e7: i32, precision: u8) -> Self {
133        const LAT_SPAN_E7: i64 = 1_800_000_000;
134        const LON_SPAN_E7: i64 = 3_600_000_000;
135
136        let precision = precision.min(MAX_PRECISION);
137        if precision == 0 {
138            return Self::UNSPECIFIED;
139        }
140        let lat = i64::from(lat_e7).clamp(-LAT_SPAN_E7 / 2, LAT_SPAN_E7 / 2);
141        let lon = i64::from(lon_e7).clamp(-LON_SPAN_E7 / 2, LON_SPAN_E7 / 2);
142        // 16^7 × 3.6e9 is ~2.6e17, comfortably inside i64.
143        let cells = 1i64 << (4 * precision as u32);
144        let max_index = (cells - 1) as u32;
145        let lat_idx = (((lat + LAT_SPAN_E7 / 2) * cells) / LAT_SPAN_E7).min(i64::from(max_index));
146        let lon_idx = (((lon + LON_SPAN_E7 / 2) * cells) / LON_SPAN_E7).min(i64::from(max_index));
147
148        let mut bytes = [0u8; MAX_PRECISION as usize];
149        for k in 0..precision as usize {
150            let shift = 4 * (precision as usize - 1 - k);
151            let hi = ((lat_idx >> shift) & 0xF) as u8;
152            let lo = ((lon_idx >> shift) & 0xF) as u8;
153            bytes[k] = (hi << 4) | lo;
154        }
155        Self {
156            len: precision,
157            bytes,
158        }
159    }
160
161    /// The raw encoded bytes.
162    pub fn as_bytes(&self) -> &[u8] {
163        &self.bytes[..self.len as usize]
164    }
165
166    /// Number of encoded bytes (0 if unspecified).
167    pub fn len(&self) -> usize {
168        self.len as usize
169    }
170
171    /// Returns `true` if this location is unspecified (zero-byte precision).
172    pub fn is_unspecified(&self) -> bool {
173        self.len == 0
174    }
175
176    /// Precision level (0–7). Zero means unspecified.
177    pub fn precision(&self) -> u8 {
178        self.len
179    }
180
181    /// Return a copy truncated to at most `precision` bytes.
182    ///
183    /// Because the encoding is strictly hierarchical, the truncated value is the
184    /// correct encoding of the same position at the lower precision.
185    pub fn clamped(&self, precision: u8) -> Self {
186        let len = self.len.min(precision.min(MAX_PRECISION));
187        // Bytes past `len` are cleared, not merely ignored. Every
188        // constructor maintains that invariant, and the derived
189        // `PartialEq`/`Hash` compare the whole array — so a `clamped`
190        // value that kept its dropped tail would fail to equal the
191        // identical encoding built any other way, and callers that
192        // compare cells to decide whether a position has moved would see
193        // a change that did not happen.
194        let mut bytes = [0u8; MAX_PRECISION as usize];
195        bytes[..len as usize].copy_from_slice(&self.bytes[..len as usize]);
196        Self { len, bytes }
197    }
198
199    /// The grid cell as `((lat_min, lon_min), (lat_max, lon_max))`, in degrees.
200    ///
201    /// Cell bounds are half-open `[lo, hi)`, matching the floor-based encoding.
202    /// An unspecified location returns the full globe `((-90, -180), (90, 180))`.
203    pub fn bounds(&self) -> ((f32, f32), (f32, f32)) {
204        if self.len == 0 {
205            return ((-90.0, -180.0), (90.0, 180.0));
206        }
207        let (lat_idx, lon_idx) = self.decode_indices();
208        let n = self.len as u32;
209        let (lat_lo, lat_hi) = decode_range(lat_idx, 180.0, -90.0, n);
210        let (lon_lo, lon_hi) = decode_range(lon_idx, 360.0, -180.0, n);
211        ((lat_lo, lon_lo), (lat_hi, lon_hi))
212    }
213
214    /// Center of the encoded grid cell as `(latitude, longitude)`, in degrees.
215    pub fn center(&self) -> (f32, f32) {
216        let ((lat_lo, lon_lo), (lat_hi, lon_hi)) = self.bounds();
217        ((lat_lo + lat_hi) * 0.5, (lon_lo + lon_hi) * 0.5)
218    }
219
220    /// Returns `true` if `(latitude, longitude)` falls within this cell.
221    ///
222    /// An unspecified location contains all points.
223    pub fn contains(&self, lat: f32, lon: f32) -> bool {
224        let ((lat_lo, lon_lo), (lat_hi, lon_hi)) = self.bounds();
225        lat >= lat_lo && lat < lat_hi && lon >= lon_lo && lon < lon_hi
226    }
227
228    /// Returns `true` if `other` is the same cell or a sub-cell of this one.
229    ///
230    /// An unspecified location contains everything. A finer location cannot
231    /// contain a coarser one.
232    pub fn contains_location(&self, other: &Self) -> bool {
233        if self.len == 0 {
234            return true;
235        }
236        if other.len < self.len {
237            return false;
238        }
239        other.bytes[..self.len as usize] == self.bytes[..self.len as usize]
240    }
241
242    /// Reconstruct the latitude and longitude grid indices from the stored bytes.
243    fn decode_indices(&self) -> (u32, u32) {
244        let mut lat = 0u32;
245        let mut lon = 0u32;
246        for &b in &self.bytes[..self.len as usize] {
247            lat = (lat << 4) | ((b >> 4) as u32);
248            lon = (lon << 4) | ((b & 0xF) as u32);
249        }
250        (lat, lon)
251    }
252}
253
254// --- Internal float helpers (cfg-selected) ---
255
256/// Compute (lat_idx, lon_idx) from clamped f32 coordinates and precision.
257#[inline]
258fn encode_indices(lat: f32, lon: f32, n: u32) -> (u32, u32) {
259    #[cfg(feature = "f64")]
260    {
261        let scale = (1u64 << (4 * n)) as f64;
262        let lat_idx = ((lat as f64 + 90.0) * scale / 180.0) as u32;
263        let lon_idx = ((lon as f64 + 180.0) * scale / 360.0) as u32;
264        let max_idx = (scale as u32).saturating_sub(1);
265        (lat_idx.min(max_idx), lon_idx.min(max_idx))
266    }
267    #[cfg(not(feature = "f64"))]
268    {
269        let scale = (1u64 << (4 * n)) as f32;
270        let lat_idx = ((lat + 90.0) * scale / 180.0) as u32;
271        let lon_idx = ((lon + 180.0) * scale / 360.0) as u32;
272        let max_idx = (scale as u32).saturating_sub(1);
273        (lat_idx.min(max_idx), lon_idx.min(max_idx))
274    }
275}
276
277/// Decode one axis: returns (cell_lo, cell_hi) in degrees.
278#[inline]
279fn decode_range(idx: u32, range: f32, offset: f32, n: u32) -> (f32, f32) {
280    #[cfg(feature = "f64")]
281    {
282        let scale = (1u64 << (4 * n)) as f64;
283        let lo = (idx as f64 * range as f64 / scale + offset as f64) as f32;
284        let hi = ((idx as f64 + 1.0) * range as f64 / scale + offset as f64) as f32;
285        (lo, hi)
286    }
287    #[cfg(not(feature = "f64"))]
288    {
289        let scale = (1u64 << (4 * n)) as f32;
290        let lo = idx as f32 * range / scale + offset;
291        let hi = (idx as f32 + 1.0) * range / scale + offset;
292        (lo, hi)
293    }
294}
295
296// --- Trait impls ---
297
298impl Default for NodeLocation {
299    fn default() -> Self {
300        Self::UNSPECIFIED
301    }
302}
303
304/// Displays as `"latitude, longitude"` with decimal places matched to the encoded precision.
305///
306/// An unspecified location displays as `"(unspecified)"`.
307impl fmt::Display for NodeLocation {
308    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
309        if self.len == 0 {
310            return f.write_str("(unspecified)");
311        }
312        let (lat, lon) = self.center();
313        let dp = self.len.saturating_sub(1) as usize;
314        write!(f, "{:.*}, {:.*}", dp, lat, dp, lon)
315    }
316}
317
318impl fmt::Debug for NodeLocation {
319    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
320        if self.len == 0 {
321            return write!(f, "NodeLocation(unspecified)");
322        }
323        write!(f, "NodeLocation({} @ precision {})", self, self.len)
324    }
325}
326
327/// Converts to the `(latitude, longitude)` center of the cell.
328impl From<NodeLocation> for (f32, f32) {
329    fn from(loc: NodeLocation) -> Self {
330        loc.center()
331    }
332}
333
334/// Encodes a `(latitude, longitude)` pair at maximum precision (7 bytes).
335impl From<(f32, f32)> for NodeLocation {
336    fn from((lat, lon): (f32, f32)) -> Self {
337        Self::from_lat_lon(lat, lon, MAX_PRECISION)
338    }
339}
340
341/// Encodes a `(latitude, longitude)` pair at maximum precision (7 bytes).
342///
343/// Only available with the `f64` crate feature.
344#[cfg(feature = "f64")]
345impl From<(f64, f64)> for NodeLocation {
346    fn from((lat, lon): (f64, f64)) -> Self {
347        Self::from_lat_lon(lat as f32, lon as f32, MAX_PRECISION)
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354
355    // --- from_bytes ---
356
357    #[test]
358    fn from_bytes_roundtrips() {
359        let src = [0xB2, 0x59, 0x15];
360        let loc = NodeLocation::from_bytes(&src);
361        assert_eq!(loc.as_bytes(), &src);
362        assert_eq!(loc.len(), 3);
363    }
364
365    #[test]
366    fn from_bytes_truncates_to_max_precision() {
367        let loc = NodeLocation::from_bytes(&[0u8; 10]);
368        assert_eq!(loc.len(), MAX_PRECISION as usize);
369    }
370
371    #[test]
372    fn from_bytes_empty_is_unspecified() {
373        let loc = NodeLocation::from_bytes(&[]);
374        assert!(loc.is_unspecified());
375        assert_eq!(loc, NodeLocation::UNSPECIFIED);
376    }
377
378    // --- encoding ---
379
380    #[test]
381    fn san_jose_3_byte() {
382        // (LAT, LON) = (37.331°, −121.883°) → B2 59 15 per spec worked example.
383        let loc = NodeLocation::from_lat_lon(37.331, -121.883, 3);
384        assert_eq!(loc.as_bytes(), &[0xB2, 0x59, 0x15]);
385    }
386
387    // --- from_e7 ---
388
389    #[test]
390    fn from_e7_matches_the_spec_worked_example() {
391        // The same San Jose point, from integer degrees.
392        let loc = NodeLocation::from_e7(373_310_000, -1_218_830_000, 3);
393        assert_eq!(loc.as_bytes(), &[0xB2, 0x59, 0x15]);
394    }
395
396    /// The integer path agrees with the float one wherever the float one
397    /// is trustworthy — precisions 1 through 5, which is exactly the
398    /// range `from_lat_lon` documents as reliable under `f32`.
399    #[test]
400    fn from_e7_agrees_with_the_float_path_where_that_path_is_sound() {
401        let places = [
402            (373_310_000i32, -1_218_830_000i32),
403            (525_200_000, 134_050_000),
404            (0, 0),
405            (-410_000_000, 1_746_000_000),
406            (-330_000_000, -700_000_000),
407        ];
408        for (lat_e7, lon_e7) in places {
409            for precision in 1..=5u8 {
410                let integer = NodeLocation::from_e7(lat_e7, lon_e7, precision);
411                let float =
412                    NodeLocation::from_lat_lon(lat_e7 as f32 / 1e7, lon_e7 as f32 / 1e7, precision);
413                assert_eq!(
414                    integer.as_bytes(),
415                    float.as_bytes(),
416                    "disagreement at ({lat_e7}, {lon_e7}) precision {precision}"
417                );
418            }
419        }
420    }
421
422    /// The truncation property has to survive the integer path too: a
423    /// shorter encoding of a point is the prefix of a longer one.
424    #[test]
425    fn from_e7_is_prefix_truncation_safe_at_every_precision() {
426        let (lat_e7, lon_e7) = (373_310_456, -1_218_830_123);
427        let full = NodeLocation::from_e7(lat_e7, lon_e7, MAX_PRECISION);
428        for precision in 1..=MAX_PRECISION {
429            let short = NodeLocation::from_e7(lat_e7, lon_e7, precision);
430            assert_eq!(
431                short.as_bytes(),
432                &full.as_bytes()[..precision as usize],
433                "precision {precision} is not a prefix of the full encoding"
434            );
435            assert_eq!(short, full.clamped(precision));
436        }
437    }
438
439    /// Two locations naming the same cell at the same precision must
440    /// compare equal however each was built. They did not: `clamped` kept
441    /// the bytes it had dropped, and equality compares the whole array.
442    #[test]
443    fn a_clamped_location_equals_the_same_cell_built_directly() {
444        let full = NodeLocation::from_e7(373_310_456, -1_218_830_123, MAX_PRECISION);
445        for precision in 0..=MAX_PRECISION {
446            let clamped = full.clamped(precision);
447            let direct = NodeLocation::from_bytes(&full.as_bytes()[..precision as usize]);
448            assert_eq!(clamped, direct, "at precision {precision}");
449        }
450    }
451
452    #[test]
453    fn from_e7_clamps_rather_than_wrapping_at_the_extremes() {
454        // The poles and the antimeridian land in the last cell, not the
455        // first: an index one past the end would read as the far side of
456        // the world.
457        let corner = NodeLocation::from_e7(900_000_000, 1_800_000_000, 2);
458        assert_eq!(corner.as_bytes(), &[0xFF, 0xFF]);
459        let opposite = NodeLocation::from_e7(-900_000_000, -1_800_000_000, 2);
460        assert_eq!(opposite.as_bytes(), &[0x00, 0x00]);
461        // Out-of-range inputs clamp to the same cells rather than wrap.
462        assert_eq!(
463            NodeLocation::from_e7(i32::MAX, i32::MAX, 2).as_bytes(),
464            corner.as_bytes()
465        );
466        assert_eq!(
467            NodeLocation::from_e7(i32::MIN, i32::MIN, 2).as_bytes(),
468            opposite.as_bytes()
469        );
470    }
471
472    #[test]
473    fn from_e7_at_zero_precision_is_unspecified() {
474        assert!(NodeLocation::from_e7(525_200_000, 134_050_000, 0).is_unspecified());
475        // And past the maximum it clamps rather than overflowing.
476        assert_eq!(
477            NodeLocation::from_e7(525_200_000, 134_050_000, 20).len(),
478            MAX_PRECISION as usize
479        );
480    }
481
482    #[test]
483    fn encode_contains_source_point() {
484        let (lat, lon) = (52.52f32, 13.405f32); // Berlin
485        // f32 inputs have ~2 m resolution near this longitude; at precision 6–7
486        // (cells ≤ 1.2 m) mixed f32/f64 rounding can place the boundary at the
487        // input value, making the round-trip unreliable. Cap at precision 5.
488        for precision in 1..=5u8 {
489            let loc = NodeLocation::from_lat_lon(lat, lon, precision);
490            assert!(loc.contains(lat, lon), "failed at precision={precision}");
491        }
492    }
493
494    /// With the `f64` feature the decode path uses f64 arithmetic. Verify the cell
495    /// width at precision 5 (scale = 16^5 = 2^20, width ≈ 3.43e-4°) where f32
496    /// output still has enough resolution to represent the difference accurately.
497    #[cfg(feature = "f64")]
498    #[test]
499    fn f64_decode_cell_width_precision_5() {
500        let loc = NodeLocation::from_lat_lon(52.52, 13.405, 5);
501        let ((_, lon_lo), (_, lon_hi)) = loc.bounds();
502        let expected = 360.0f64 / (1u64 << 20) as f64;
503        let actual = (lon_hi - lon_lo) as f64;
504        assert!(
505            (actual - expected).abs() < 1e-7,
506            "cell width {actual} != {expected}"
507        );
508    }
509
510    #[test]
511    fn antimeridian_does_not_panic() {
512        let _ = NodeLocation::from_lat_lon(0.0, 180.0, 7);
513        let _ = NodeLocation::from_lat_lon(0.0, -180.0, 7);
514    }
515
516    #[test]
517    fn poles_do_not_panic() {
518        let _ = NodeLocation::from_lat_lon(90.0, 0.0, 7);
519        let _ = NodeLocation::from_lat_lon(-90.0, 0.0, 7);
520    }
521
522    #[test]
523    fn zero_precision_gives_unspecified() {
524        assert_eq!(
525            NodeLocation::from_lat_lon(0.0, 0.0, 0),
526            NodeLocation::UNSPECIFIED
527        );
528    }
529
530    #[test]
531    fn excess_precision_clamped_to_max() {
532        assert_eq!(
533            NodeLocation::from_lat_lon(0.0, 0.0, 255).len(),
534            MAX_PRECISION as usize
535        );
536    }
537
538    // --- truncation property ---
539
540    #[test]
541    fn truncation_matches_direct_lower_precision() {
542        let (lat, lon) = (51.509f32, -0.118f32); // London
543        let full = NodeLocation::from_lat_lon(lat, lon, 7);
544        for k in 1..=7u8 {
545            let direct = NodeLocation::from_lat_lon(lat, lon, k);
546            let truncated = full.clamped(k);
547            assert_eq!(
548                direct.as_bytes(),
549                truncated.as_bytes(),
550                "mismatch at precision={k}"
551            );
552        }
553    }
554
555    // --- bounds and center ---
556
557    #[test]
558    fn center_is_within_bounds() {
559        let loc = NodeLocation::from_lat_lon(48.864, 2.349, 5); // Paris
560        let (lat_c, lon_c) = loc.center();
561        assert!(loc.contains(lat_c, lon_c));
562    }
563
564    #[test]
565    fn unspecified_bounds_is_whole_globe() {
566        let ((lat_lo, lon_lo), (lat_hi, lon_hi)) = NodeLocation::UNSPECIFIED.bounds();
567        assert_eq!(
568            (lat_lo, lon_lo, lat_hi, lon_hi),
569            (-90.0, -180.0, 90.0, 180.0)
570        );
571    }
572
573    #[test]
574    fn bounds_span_shrinks_by_16_per_byte() {
575        let (lat, lon) = (0.0f32, 0.0f32);
576        let loc1 = NodeLocation::from_lat_lon(lat, lon, 1);
577        let loc2 = NodeLocation::from_lat_lon(lat, lon, 2);
578        let ((lo1, _), (hi1, _)) = loc1.bounds();
579        let ((lo2, _), (hi2, _)) = loc2.bounds();
580        let ratio = (hi1 - lo1) / (hi2 - lo2);
581        assert!((ratio - 16.0).abs() < 1e-4, "expected 16×, got {ratio}");
582    }
583
584    // --- contains ---
585
586    #[test]
587    fn contains_source_point() {
588        let loc = NodeLocation::from_lat_lon(41.878, -87.629, 4); // Chicago
589        assert!(loc.contains(41.878, -87.629));
590    }
591
592    #[test]
593    fn contains_location_coarser_contains_finer() {
594        let coarse = NodeLocation::from_lat_lon(35.689, 139.691, 3); // Tokyo area
595        let fine = NodeLocation::from_lat_lon(35.689, 139.691, 6);
596        assert!(coarse.contains_location(&fine));
597        assert!(!fine.contains_location(&coarse));
598    }
599
600    #[test]
601    fn unspecified_contains_everything() {
602        let anywhere = NodeLocation::from_lat_lon(28.614, 77.209, 7); // New Delhi
603        assert!(NodeLocation::UNSPECIFIED.contains_location(&anywhere));
604    }
605
606    // --- From traits ---
607
608    #[test]
609    fn from_f32_tuple_roundtrips_approximately() {
610        let (lat, lon) = (-33.868f32, 151.209f32); // Sydney
611        let loc = NodeLocation::from((lat, lon));
612        let (out_lat, out_lon): (f32, f32) = loc.into();
613        assert!((out_lat - lat).abs() < 0.001, "lat drift={}", out_lat - lat);
614        assert!((out_lon - lon).abs() < 0.001, "lon drift={}", out_lon - lon);
615    }
616
617    // --- Display ---
618
619    #[test]
620    fn display_unspecified() {
621        assert_eq!(NodeLocation::UNSPECIFIED.to_string(), "(unspecified)");
622    }
623
624    #[test]
625    fn display_precision_one_no_decimal_point() {
626        let loc = NodeLocation::from_lat_lon(0.0, 0.0, 1);
627        let s = loc.to_string();
628        assert!(!s.contains('.'), "unexpected decimal in '{s}'");
629    }
630
631    #[test]
632    fn display_precision_four_has_three_decimal_places() {
633        let loc = NodeLocation::from_lat_lon(0.0, 0.0, 4);
634        let s = loc.to_string();
635        for part in s.split(", ") {
636            let dp = part.find('.').map(|i| part.len() - i - 1).unwrap_or(0);
637            assert_eq!(dp, 3, "wrong decimal places in '{s}'");
638        }
639    }
640}