Skip to main content

azul_core/
geolocation.rs

1//! POD types for the geolocation surface. Defined here in `azul-core`
2//! so `NodeType::GeolocationProbe(GeolocationProbeConfig)` can carry the
3//! config without `azul-layout` having to be a `azul-core` dependency.
4//!
5//! The stateful side (refcount, diff queue, latest-fix storage) lives
6//! in `azul_layout::managers::geolocation::GeolocationManager` and
7//! re-exports these types for the existing import paths.
8
9/// One GPS / network-located fix. Mirrors the W3C
10/// [`GeolocationPosition`](https://www.w3.org/TR/geolocation/#position_interface)
11/// shape so the future web backend lands without API churn.
12///
13/// `accuracy_m` is the 1-sigma radius in metres. `altitude_m` /
14/// `altitude_accuracy_m` / `heading_deg` / `speed_mps` are reported as
15/// `f32::NAN` when the platform doesn't supply them — iOS / Android
16/// always supply lat/lon but the other fields depend on hardware.
17#[derive(Debug, Clone, Copy, PartialEq)]
18#[repr(C)]
19pub struct LocationFix {
20    /// Latitude in WGS-84 degrees (positive = north, negative = south).
21    pub latitude_deg: f64,
22    /// Longitude in WGS-84 degrees (positive = east, negative = west).
23    pub longitude_deg: f64,
24    /// 1-sigma horizontal accuracy radius in metres.
25    pub accuracy_m: f32,
26    /// Altitude above the WGS-84 ellipsoid in metres. `NaN` if not
27    /// reported (the platform couldn't measure it).
28    pub altitude_m: f32,
29    /// 1-sigma altitude accuracy in metres. `NaN` if `altitude_m` is
30    /// `NaN` or the platform doesn't report it.
31    pub altitude_accuracy_m: f32,
32    /// Bearing in degrees clockwise from true north, `0..360`. `NaN`
33    /// if the device is stationary or the platform doesn't report it.
34    pub heading_deg: f32,
35    /// Ground speed in metres per second. `NaN` if not reported.
36    pub speed_mps: f32,
37    /// Monotonic timestamp in milliseconds since program start. Lets
38    /// callers detect stale fixes without depending on wall-clock time.
39    pub timestamp_ms: u64,
40}
41
42// FFI Option wrapper (mirrors OptionPenState). Lets `CallbackInfo::
43// get_location_fix() -> Option<LocationFix>` cross the C ABI once the
44// matching api.json type entry + getter are registered via the autofix
45// workflow. Unused internally today; this is the no-codegen prerequisite
46// for that exposure (see MOBILE_SESSION_LOG P3.1h).
47impl_option!(LocationFix, OptionLocationFix, [Debug, Clone, Copy, PartialEq]);
48
49impl LocationFix {
50    #[must_use] pub const fn altitude(&self) -> Option<f32> {
51        if self.altitude_m.is_nan() {
52            None
53        } else {
54            Some(self.altitude_m)
55        }
56    }
57
58    #[must_use] pub const fn altitude_accuracy(&self) -> Option<f32> {
59        if self.altitude_accuracy_m.is_nan() {
60            None
61        } else {
62            Some(self.altitude_accuracy_m)
63        }
64    }
65
66    #[must_use] pub const fn heading(&self) -> Option<f32> {
67        if self.heading_deg.is_nan() {
68            None
69        } else {
70            Some(self.heading_deg)
71        }
72    }
73
74    #[must_use] pub const fn speed(&self) -> Option<f32> {
75        if self.speed_mps.is_nan() {
76            None
77        } else {
78            Some(self.speed_mps)
79        }
80    }
81}
82
83/// Configuration the user attaches to a `NodeType::GeolocationProbe`
84/// to tune the platform subscription. Maps to W3C `PositionOptions`
85/// (`enableHighAccuracy` + `maximumAge` + `timeout`).
86#[derive(Debug, Clone, Copy, PartialEq)]
87#[repr(C)]
88pub struct GeolocationProbeConfig {
89    /// `true` requests precise (GPS-driven) location. iOS maps this to
90    /// `CLLocationManager.desiredAccuracy = kCLLocationAccuracyBest`;
91    /// Android to `LocationRequest.PRIORITY_HIGH_ACCURACY`. Costs
92    /// battery — leave `false` for city-block-level apps.
93    pub high_accuracy: bool,
94    /// Subscribe to *background* location updates. Requires extra
95    /// per-platform manifest declarations and a separate
96    /// `Capability::GeolocationBackground` permission grant. `false`
97    /// is the safe default.
98    pub background: bool,
99    /// Reject any fix whose `accuracy_m` exceeds this radius. `0`
100    /// disables the filter — every native sample is delivered.
101    pub max_accuracy_m: f32,
102    /// Minimum time between delivered updates, in milliseconds. `0`
103    /// disables throttling (every native sample is delivered;
104    /// expensive when the platform fires at 10 Hz indoors).
105    pub min_interval_ms: u32,
106}
107
108impl Default for GeolocationProbeConfig {
109    fn default() -> Self {
110        Self {
111            high_accuracy: false,
112            background: false,
113            max_accuracy_m: 0.0,
114            min_interval_ms: 0,
115        }
116    }
117}
118
119impl Eq for GeolocationProbeConfig {}
120
121impl PartialOrd for GeolocationProbeConfig {
122    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
123        Some(self.cmp(other))
124    }
125}
126
127impl Ord for GeolocationProbeConfig {
128    fn cmp(&self, other: &Self) -> core::cmp::Ordering {
129        // f32 comparison via to_bits — gives a total order even with
130        // NaNs and matches NodeType::Eq + Hash requirements.
131        (
132            self.high_accuracy,
133            self.background,
134            self.max_accuracy_m.to_bits(),
135            self.min_interval_ms,
136        )
137            .cmp(&(
138                other.high_accuracy,
139                other.background,
140                other.max_accuracy_m.to_bits(),
141                other.min_interval_ms,
142            ))
143    }
144}
145
146impl core::hash::Hash for GeolocationProbeConfig {
147    fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
148        self.high_accuracy.hash(state);
149        self.background.hash(state);
150        self.max_accuracy_m.to_bits().hash(state);
151        self.min_interval_ms.hash(state);
152    }
153}
154
155#[cfg(test)]
156#[allow(clippy::float_cmp, clippy::eq_op, clippy::unusual_byte_groupings)]
157mod autotest_generated {
158    use core::{
159        cmp::Ordering,
160        hash::{Hash, Hasher},
161    };
162
163    use super::*;
164
165    // ---------------------------------------------------------------
166    // helpers
167    // ---------------------------------------------------------------
168
169    /// A fix with every optional field *present* (no NaN anywhere), so a
170    /// test can knock out exactly one field and watch only that getter flip.
171    const fn fix_all_present() -> LocationFix {
172        LocationFix {
173            latitude_deg: 48.208_8,
174            longitude_deg: 16.372_1,
175            accuracy_m: 5.0,
176            altitude_m: 171.0,
177            altitude_accuracy_m: 3.0,
178            heading_deg: 90.0,
179            speed_mps: 1.5,
180            timestamp_ms: 1_234,
181        }
182    }
183
184    /// The "platform reported nothing but lat/lon" fix the module docs describe.
185    const fn fix_all_absent() -> LocationFix {
186        LocationFix {
187            latitude_deg: 0.0,
188            longitude_deg: 0.0,
189            accuracy_m: 0.0,
190            altitude_m: f32::NAN,
191            altitude_accuracy_m: f32::NAN,
192            heading_deg: f32::NAN,
193            speed_mps: f32::NAN,
194            timestamp_ms: 0,
195        }
196    }
197
198    const fn cfg(
199        high_accuracy: bool,
200        background: bool,
201        max_accuracy_m: f32,
202        min_interval_ms: u32,
203    ) -> GeolocationProbeConfig {
204        GeolocationProbeConfig {
205            high_accuracy,
206            background,
207            max_accuracy_m,
208            min_interval_ms,
209        }
210    }
211
212    /// Bit-exact float identity (so `-0.0 != 0.0`), but NaN-tolerant: any NaN
213    /// matches any NaN. A plain `==` would report `NaN != NaN` and make every
214    /// round-trip assertion below vacuously fail; comparing raw payload bits
215    /// would over-constrain (Rust does not guarantee NaN payload propagation).
216    fn same_f32(a: f32, b: f32) -> bool {
217        if a.is_nan() {
218            b.is_nan()
219        } else {
220            a.to_bits() == b.to_bits()
221        }
222    }
223
224    fn same_f64(a: f64, b: f64) -> bool {
225        if a.is_nan() {
226            b.is_nan()
227        } else {
228            a.to_bits() == b.to_bits()
229        }
230    }
231
232    fn same_fix(a: &LocationFix, b: &LocationFix) -> bool {
233        same_f64(a.latitude_deg, b.latitude_deg)
234            && same_f64(a.longitude_deg, b.longitude_deg)
235            && same_f32(a.accuracy_m, b.accuracy_m)
236            && same_f32(a.altitude_m, b.altitude_m)
237            && same_f32(a.altitude_accuracy_m, b.altitude_accuracy_m)
238            && same_f32(a.heading_deg, b.heading_deg)
239            && same_f32(a.speed_mps, b.speed_mps)
240            && a.timestamp_ms == b.timestamp_ms
241    }
242
243    /// FNV-1a — a `no_std`-safe, deterministic stand-in for `DefaultHasher`
244    /// (this crate is `no_std` without the `std` feature).
245    struct Fnv1a(u64);
246
247    impl Fnv1a {
248        const fn new() -> Self {
249            Self(0xcbf2_9ce4_8422_2325)
250        }
251    }
252
253    impl Hasher for Fnv1a {
254        fn finish(&self) -> u64 {
255            self.0
256        }
257        fn write(&mut self, bytes: &[u8]) {
258            for b in bytes {
259                self.0 ^= u64::from(*b);
260                self.0 = self.0.wrapping_mul(0x0000_0100_0000_01b3);
261            }
262        }
263    }
264
265    fn hash_of(c: &GeolocationProbeConfig) -> u64 {
266        let mut h = Fnv1a::new();
267        c.hash(&mut h);
268        h.finish()
269    }
270
271    /// Every interesting `f32` class, as raw bits, including four distinct NaN
272    /// encodings (quiet, negative-quiet, signalling, all-ones payload).
273    const F32_PATTERNS: [u32; 16] = [
274        0x0000_0000, // +0.0
275        0x8000_0000, // -0.0
276        0x0000_0001, // smallest positive subnormal
277        0x8000_0001, // smallest negative subnormal
278        0x007f_ffff, // largest subnormal
279        0x0080_0000, // f32::MIN_POSITIVE
280        0x3f80_0000, // 1.0
281        0xbf80_0000, // -1.0
282        0x7f7f_ffff, // f32::MAX
283        0xff7f_ffff, // f32::MIN
284        0x7f80_0000, // +inf
285        0xff80_0000, // -inf
286        0x7fc0_0000, // quiet NaN
287        0xffc0_0000, // negative quiet NaN
288        0x7f80_0001, // signalling NaN
289        0x7fff_ffff, // NaN, all-ones payload
290    ];
291
292    // ---------------------------------------------------------------
293    // LocationFix getters — the documented contract is
294    // "NaN (and only NaN) means the platform did not report the field"
295    // ---------------------------------------------------------------
296
297    /// The core invariant, swept over every float class: a getter returns
298    /// `None` **iff** its backing field is NaN, and otherwise hands back the
299    /// value bit-for-bit — including infinities, subnormals and `-0.0`, which
300    /// are all "reported" values and must NOT be swallowed like NaN is.
301    #[test]
302    fn getter_is_none_exactly_when_field_is_nan() {
303        for bits in F32_PATTERNS {
304            let v = f32::from_bits(bits);
305
306            // Each getter is exercised on a fix where *only* its own field
307            // carries the pattern, so a getter reading the wrong field is caught.
308            let mut alt = fix_all_present();
309            alt.altitude_m = v;
310            let mut alt_acc = fix_all_present();
311            alt_acc.altitude_accuracy_m = v;
312            let mut head = fix_all_present();
313            head.heading_deg = v;
314            let mut spd = fix_all_present();
315            spd.speed_mps = v;
316
317            let got = [
318                ("altitude", alt.altitude()),
319                ("altitude_accuracy", alt_acc.altitude_accuracy()),
320                ("heading", head.heading()),
321                ("speed", spd.speed()),
322            ];
323
324            for (name, out) in got {
325                if v.is_nan() {
326                    assert!(
327                        out.is_none(),
328                        "{name}() must be None for NaN bits {bits:#010x}, got {out:?}"
329                    );
330                } else {
331                    let inner = out.unwrap_or_else(|| {
332                        panic!("{name}() must be Some for non-NaN bits {bits:#010x}")
333                    });
334                    assert_eq!(
335                        inner.to_bits(),
336                        bits,
337                        "{name}() must return the field verbatim for {bits:#010x}"
338                    );
339                }
340            }
341
342            // The other three getters are untouched by the pattern under test.
343            assert_eq!(alt.heading(), Some(90.0));
344            assert_eq!(alt_acc.speed(), Some(1.5));
345            assert_eq!(head.altitude(), Some(171.0));
346            assert_eq!(spd.altitude_accuracy(), Some(3.0));
347        }
348    }
349
350    /// `-0.0` is a legitimate reported value (a stationary device at sea level,
351    /// a southbound heading rounded to zero). It must survive the getter with
352    /// its sign bit intact rather than being normalised to `+0.0`.
353    #[test]
354    fn getters_preserve_negative_zero() {
355        let mut fix = fix_all_present();
356        fix.altitude_m = -0.0;
357        fix.altitude_accuracy_m = -0.0;
358        fix.heading_deg = -0.0;
359        fix.speed_mps = -0.0;
360
361        for out in [
362            fix.altitude(),
363            fix.altitude_accuracy(),
364            fix.heading(),
365            fix.speed(),
366        ] {
367            let v = out.expect("-0.0 is not NaN, so the field is 'reported'");
368            assert_eq!(v.to_bits(), 0x8000_0000, "sign bit of -0.0 was dropped");
369            assert!(v == 0.0, "-0.0 must still compare equal to 0.0");
370        }
371    }
372
373    /// Infinities are not NaN, so per the documented contract they are handed
374    /// through as `Some(inf)` — the getters must not "helpfully" filter them.
375    #[test]
376    fn getters_pass_infinities_through() {
377        let mut fix = fix_all_absent();
378        fix.altitude_m = f32::INFINITY;
379        fix.altitude_accuracy_m = f32::NEG_INFINITY;
380        fix.heading_deg = f32::INFINITY;
381        fix.speed_mps = f32::NEG_INFINITY;
382
383        assert_eq!(fix.altitude(), Some(f32::INFINITY));
384        assert_eq!(fix.altitude_accuracy(), Some(f32::NEG_INFINITY));
385        assert_eq!(fix.heading(), Some(f32::INFINITY));
386        assert_eq!(fix.speed(), Some(f32::NEG_INFINITY));
387    }
388
389    /// The "platform reported nothing" fix, plus a saturated timestamp and
390    /// out-of-range WGS-84 coordinates: nothing here may panic, and every
391    /// optional getter must report absence.
392    #[test]
393    fn getters_on_extreme_and_all_absent_fix() {
394        let mut fix = fix_all_absent();
395        fix.latitude_deg = f64::MAX;
396        fix.longitude_deg = f64::MIN;
397        fix.accuracy_m = f32::MAX;
398        fix.timestamp_ms = u64::MAX;
399
400        assert_eq!(fix.altitude(), None);
401        assert_eq!(fix.altitude_accuracy(), None);
402        assert_eq!(fix.heading(), None);
403        assert_eq!(fix.speed(), None);
404
405        // Getters are `&self` — they must not have mutated the receiver.
406        assert_eq!(fix.timestamp_ms, u64::MAX);
407        assert!(fix.latitude_deg == f64::MAX);
408
409        // Default-ish zeroed fix: 0.0 is not NaN, so everything is "reported".
410        let zeroed = LocationFix {
411            latitude_deg: 0.0,
412            longitude_deg: 0.0,
413            accuracy_m: 0.0,
414            altitude_m: 0.0,
415            altitude_accuracy_m: 0.0,
416            heading_deg: 0.0,
417            speed_mps: 0.0,
418            timestamp_ms: 0,
419        };
420        assert_eq!(zeroed.altitude(), Some(0.0));
421        assert_eq!(zeroed.altitude_accuracy(), Some(0.0));
422        assert_eq!(zeroed.heading(), Some(0.0));
423        assert_eq!(zeroed.speed(), Some(0.0));
424    }
425
426    /// The getters are declared `const fn`; that is part of the public API, so
427    /// pin it — a non-const rewrite would be a silent breaking change.
428    #[test]
429    fn getters_are_const_evaluable() {
430        const PRESENT: LocationFix = fix_all_present();
431        const ABSENT: LocationFix = fix_all_absent();
432
433        const ALT: Option<f32> = PRESENT.altitude();
434        const ALT_ACC: Option<f32> = PRESENT.altitude_accuracy();
435        const HEADING: Option<f32> = PRESENT.heading();
436        const SPEED: Option<f32> = PRESENT.speed();
437        const NO_ALT: Option<f32> = ABSENT.altitude();
438        const NO_HEADING: Option<f32> = ABSENT.heading();
439
440        assert_eq!(ALT, Some(171.0));
441        assert_eq!(ALT_ACC, Some(3.0));
442        assert_eq!(HEADING, Some(90.0));
443        assert_eq!(SPEED, Some(1.5));
444        assert_eq!(NO_ALT, None);
445        assert_eq!(NO_HEADING, None);
446    }
447
448    /// `LocationFix` derives `PartialEq` over raw floats, so a fix carrying an
449    /// unreported (NaN) field is *not* equal to itself. Pinned deliberately:
450    /// the type is (correctly) not `Eq`/`Hash`, and any future use as a map key
451    /// or in a `!=`-based change check would be broken by this. Callers must
452    /// compare via the getters, as `same_fix` does.
453    #[test]
454    fn location_fix_partial_eq_is_not_reflexive_when_fields_are_absent() {
455        let absent = fix_all_absent();
456        assert!(absent != absent, "IEEE semantics: NaN != NaN");
457        assert!(same_fix(&absent, &absent), "but it is the same fix");
458
459        let present = fix_all_present();
460        assert!(present == present, "a fully-reported fix is self-equal");
461        assert!(same_fix(&present, &present));
462
463        // `-0.0 == 0.0` under PartialEq, but the two are distinguishable.
464        let mut neg_zero = fix_all_present();
465        neg_zero.altitude_m = -0.0;
466        let mut pos_zero = fix_all_present();
467        pos_zero.altitude_m = 0.0;
468        assert!(neg_zero == pos_zero);
469        assert!(!same_fix(&neg_zero, &pos_zero));
470    }
471
472    // ---------------------------------------------------------------
473    // OptionLocationFix — the FFI wrapper: encode == decode
474    // ---------------------------------------------------------------
475
476    /// Round-trip `Option<LocationFix>` -> `OptionLocationFix` -> back, for both
477    /// variants, plus the full wrapper surface (`replace` must return the
478    /// *previous* value, `map`/`and_then` must short-circuit on `None`).
479    #[test]
480    fn option_location_fix_roundtrips() {
481        assert!(OptionLocationFix::default().is_none());
482        assert!(!OptionLocationFix::default().is_some());
483        assert_eq!(
484            Option::<LocationFix>::from(OptionLocationFix::default()),
485            None
486        );
487
488        let fix = fix_all_present();
489        let wrapped: OptionLocationFix = Some(fix).into();
490        assert!(wrapped.is_some());
491        assert!(!wrapped.is_none());
492        assert_eq!(wrapped.as_ref(), Some(&fix));
493        assert_eq!(wrapped.as_option(), Some(&fix));
494        assert_eq!(wrapped.into_option(), Some(fix));
495
496        let decoded = Option::<LocationFix>::from(wrapped).expect("Some in, Some out");
497        assert!(same_fix(&decoded, &fix), "encode == decode");
498
499        let none: OptionLocationFix = Option::<LocationFix>::None.into();
500        assert!(none.is_none());
501        assert_eq!(none.as_ref(), None);
502        assert_eq!(none.map(|f| f.timestamp_ms), None);
503        assert_eq!(none.and_then(|f| f.altitude()), None);
504        assert_eq!(wrapped.map(|f| f.timestamp_ms), Some(1_234));
505        assert_eq!(wrapped.and_then(|f| f.altitude()), Some(171.0));
506
507        // `replace` has mem::replace semantics: it yields the OLD value.
508        let mut slot = OptionLocationFix::None;
509        assert!(slot.replace(fix).is_none());
510        assert_eq!(slot.as_option(), Some(&fix));
511
512        let other = fix_all_absent();
513        let prev = slot.replace(other);
514        assert!(prev.is_some());
515        assert!(same_fix(
516            &Option::<LocationFix>::from(prev).expect("previous fix"),
517            &fix
518        ));
519        assert!(same_fix(
520            slot.as_option().expect("current fix"),
521            &fix_all_absent()
522        ));
523
524        // `as_mut` hands out the live payload.
525        if let Some(inner) = slot.as_mut() {
526            inner.timestamp_ms = u64::MAX;
527        }
528        assert_eq!(slot.as_option().expect("still Some").timestamp_ms, u64::MAX);
529    }
530
531    /// The wrapper must survive the exact payload the platform layer produces
532    /// most often: a fix whose optional fields are all NaN. `PartialEq` on the
533    /// payload is useless here (NaN != NaN), so the round-trip is checked
534    /// field-wise — this is the case a naive `assert_eq!` would hide.
535    #[test]
536    fn option_location_fix_roundtrip_survives_absent_fields() {
537        let mut fix = fix_all_absent();
538        fix.latitude_deg = -89.999_999_9;
539        fix.longitude_deg = 179.999_999_9;
540        fix.accuracy_m = f32::MIN_POSITIVE;
541        fix.timestamp_ms = u64::MAX;
542
543        let decoded = Option::<LocationFix>::from(OptionLocationFix::Some(fix))
544            .expect("Some survives the round-trip");
545
546        assert!(same_fix(&decoded, &fix), "encode == decode for a NaN-y fix");
547        assert_eq!(decoded.altitude(), None);
548        assert_eq!(decoded.altitude_accuracy(), None);
549        assert_eq!(decoded.heading(), None);
550        assert_eq!(decoded.speed(), None);
551        assert_eq!(decoded.timestamp_ms, u64::MAX);
552
553        // The wrapper's own derived PartialEq inherits the NaN quirk; assert it
554        // rather than trusting `assert_eq!` to mean anything here.
555        assert!(OptionLocationFix::Some(fix) != OptionLocationFix::Some(fix));
556        assert!(OptionLocationFix::None == OptionLocationFix::None);
557    }
558
559    /// `#[repr(C, u8)]` means the wrapper is tag + payload with no niche
560    /// packing — it must be strictly larger than the payload, or the C ABI
561    /// header generated for it would be wrong.
562    #[test]
563    fn option_location_fix_is_tagged_not_niche_packed() {
564        use core::mem::{align_of, size_of};
565        assert!(
566            size_of::<OptionLocationFix>() > size_of::<LocationFix>(),
567            "repr(C, u8) must carry an explicit discriminant"
568        );
569        assert!(align_of::<OptionLocationFix>() >= align_of::<LocationFix>());
570        assert!(size_of::<LocationFix>() >= 2 * size_of::<f64>() + 5 * size_of::<f32>());
571    }
572
573    // ---------------------------------------------------------------
574    // GeolocationProbeConfig — Default / Ord / Hash
575    // ---------------------------------------------------------------
576
577    /// The documented default: no high accuracy, no background, no accuracy
578    /// filter (`0` = "deliver every sample"), no throttle.
579    #[test]
580    fn probe_config_default_is_permissive_zero() {
581        let d = GeolocationProbeConfig::default();
582        assert!(!d.high_accuracy);
583        assert!(!d.background);
584        assert!(d.max_accuracy_m == 0.0);
585        assert_eq!(d.max_accuracy_m.to_bits(), 0, "default must be +0.0, not -0.0");
586        assert_eq!(d.min_interval_ms, 0);
587        assert_eq!(d, cfg(false, false, 0.0, 0));
588        assert_eq!(d.cmp(&GeolocationProbeConfig::default()), Ordering::Equal);
589    }
590
591    /// Field precedence of the hand-written `Ord`: high_accuracy, then
592    /// background, then accuracy bits, then interval. Each earlier field must
593    /// dominate *every* later one, so the loser is given maximal later fields.
594    #[test]
595    fn probe_config_ord_field_precedence() {
596        let low_but_maxed = cfg(false, true, f32::from_bits(u32::MAX), u32::MAX);
597        let high_but_minimal = cfg(true, false, 0.0, 0);
598        assert!(low_but_maxed < high_but_minimal, "high_accuracy dominates");
599
600        let fg_maxed = cfg(true, false, f32::from_bits(u32::MAX), u32::MAX);
601        let bg_minimal = cfg(true, true, 0.0, 0);
602        assert!(fg_maxed < bg_minimal, "background dominates the numeric fields");
603
604        let small_acc = cfg(true, true, 1.0, u32::MAX);
605        let big_acc = cfg(true, true, 2.0, 0);
606        assert!(small_acc < big_acc, "accuracy bits dominate the interval");
607
608        assert!(cfg(true, true, 1.0, 0) < cfg(true, true, 1.0, 1));
609        assert!(cfg(true, true, 1.0, u32::MAX - 1) < cfg(true, true, 1.0, u32::MAX));
610        assert_eq!(
611            cfg(true, true, 1.0, u32::MAX).cmp(&cfg(true, true, 1.0, u32::MAX)),
612            Ordering::Equal
613        );
614    }
615
616    /// The ordering is over `to_bits`, NOT over numeric value: sign-magnitude
617    /// bits mean every negative accuracy sorts *above* every positive one, and
618    /// NaN sorts above +inf. Pinned because it is load-bearing (`NodeType`
619    /// dedup/sort) and because it is exactly the trap a reader assumes away.
620    #[test]
621    fn probe_config_ord_is_bitwise_not_numeric() {
622        let neg = cfg(false, false, -1.0, 0);
623        let pos = cfg(false, false, 1.0, 0);
624        assert!(neg.max_accuracy_m < pos.max_accuracy_m, "numerically: -1 < 1");
625        assert_eq!(
626            neg.cmp(&pos),
627            Ordering::Greater,
628            "bitwise: 0xbf80_0000 > 0x3f80_0000"
629        );
630
631        let inf = cfg(false, false, f32::INFINITY, 0);
632        let nan = cfg(false, false, f32::NAN, 0);
633        assert_eq!(nan.cmp(&inf), Ordering::Greater, "NaN bits sort above +inf");
634        assert_eq!(inf.cmp(&pos), Ordering::Greater);
635    }
636
637    /// A NaN accuracy must not break the total order: `cmp` stays reflexive,
638    /// antisymmetric and transitive, and sorting a NaN-containing slice
639    /// terminates without panicking (an inconsistent comparator can make
640    /// `sort_unstable` misbehave).
641    #[test]
642    fn probe_config_ord_is_a_total_order_with_nan() {
643        let nan = cfg(false, false, f32::NAN, 7);
644        assert_eq!(nan.cmp(&nan), Ordering::Equal, "cmp must be reflexive");
645        assert_eq!(nan.partial_cmp(&nan), Some(Ordering::Equal));
646
647        let mut list = [
648            cfg(true, true, f32::NAN, u32::MAX),
649            cfg(false, false, -0.0, 0),
650            cfg(false, false, f32::INFINITY, 1),
651            nan,
652            cfg(true, false, f32::NEG_INFINITY, 3),
653            cfg(false, true, 0.0, u32::MAX),
654            GeolocationProbeConfig::default(),
655        ];
656        list.sort_unstable();
657        for w in list.windows(2) {
658            assert_ne!(
659                w[0].cmp(&w[1]),
660                Ordering::Greater,
661                "sort_unstable must leave the slice ordered under cmp"
662            );
663        }
664
665        // Antisymmetry + transitivity across every pair/triple in the slice.
666        for a in list {
667            for b in list {
668                assert_eq!(
669                    a.cmp(&b),
670                    b.cmp(&a).reverse(),
671                    "cmp must be antisymmetric"
672                );
673                for c in list {
674                    if a.cmp(&b) != Ordering::Greater && b.cmp(&c) != Ordering::Greater {
675                        assert_ne!(a.cmp(&c), Ordering::Greater, "cmp must be transitive");
676                    }
677                }
678            }
679        }
680    }
681
682    /// `PartialOrd` must agree with `Ord`, and — for the ordinary (finite,
683    /// non-negative) configs a user actually writes — `==`, `cmp == Equal` and
684    /// equal hashes must all coincide. This is the Eq/Ord/Hash contract holding
685    /// on the reachable domain; see the two `#[ignore]`d tests below for the
686    /// corners where it does not.
687    #[test]
688    fn probe_config_eq_ord_hash_agree_on_ordinary_configs() {
689        let configs = [
690            GeolocationProbeConfig::default(),
691            cfg(false, false, 25.0, 1_000),
692            cfg(true, false, 5.0, 0),
693            cfg(true, true, 0.5, u32::MAX),
694            cfg(false, true, f32::MAX, 1),
695            cfg(true, true, f32::MIN_POSITIVE, 16),
696        ];
697
698        for a in configs {
699            for b in configs {
700                assert_eq!(a.partial_cmp(&b), Some(a.cmp(&b)), "PartialOrd must mirror Ord");
701                assert_eq!(
702                    a == b,
703                    a.cmp(&b) == Ordering::Equal,
704                    "`==` must agree with `cmp == Equal`"
705                );
706                if a == b {
707                    assert_eq!(hash_of(&a), hash_of(&b), "Eq implies equal hashes");
708                }
709            }
710            // Reflexivity of the whole trio.
711            assert_eq!(a, a);
712            assert_eq!(a.cmp(&a), Ordering::Equal);
713            assert_eq!(hash_of(&a), hash_of(&a));
714        }
715    }
716
717    /// Hashing must be a pure function of the four fields, must survive NaN
718    /// (`to_bits` cannot panic), and must actually distinguish configs that
719    /// differ only in a late field — otherwise `NodeType` dedup collapses
720    /// distinct probes.
721    #[test]
722    fn probe_config_hash_is_stable_and_discriminating() {
723        let a = cfg(true, false, 12.5, 250);
724        assert_eq!(hash_of(&a), hash_of(&cfg(true, false, 12.5, 250)));
725
726        let nan = cfg(false, false, f32::NAN, 0);
727        assert_eq!(hash_of(&nan), hash_of(&nan), "hashing NaN must be stable");
728
729        for other in [
730            cfg(false, false, 12.5, 250),
731            cfg(true, true, 12.5, 250),
732            cfg(true, false, 12.75, 250),
733            cfg(true, false, 12.5, 251),
734        ] {
735            assert_ne!(a, other);
736            assert_ne!(
737                hash_of(&a),
738                hash_of(&other),
739                "configs differing in one field must not collide: {other:?}"
740            );
741        }
742    }
743
744    // ---------------------------------------------------------------
745    // KNOWN BUGS — these assert the *correct* invariant and fail today.
746    // Ignored so the suite stays green; un-ignore when the impl is fixed
747    // (implement PartialEq via `to_bits`, mirroring Ord and Hash).
748    // ---------------------------------------------------------------
749
750    /// `impl Eq` + `impl Hash` promise: `a == b` implies `hash(a) == hash(b)`
751    /// and `a.cmp(&b) == Equal`. Derived `PartialEq` compares `max_accuracy_m`
752    /// numerically, so `-0.0 == 0.0`, while `Hash`/`Ord` compare `to_bits`,
753    /// where they differ. A `HashMap`/`BTreeMap` keyed on a `NodeType` carrying
754    /// this config can therefore fail to find an entry that compares equal.
755    #[test]
756    #[ignore = "KNOWN BUG: -0.0 == 0.0 under derived PartialEq, but Hash/Ord use to_bits"]
757    fn probe_config_eq_implies_equal_hash_and_ordering() {
758        let pos = cfg(false, false, 0.0, 0);
759        let neg = cfg(false, false, -0.0, 0);
760
761        assert_eq!(pos, neg, "IEEE: -0.0 == 0.0");
762        assert_eq!(hash_of(&pos), hash_of(&neg), "Eq/Hash contract");
763        assert_eq!(pos.cmp(&neg), Ordering::Equal, "Eq/Ord contract");
764    }
765
766    /// `impl Eq for GeolocationProbeConfig {}` asserts reflexivity, but the
767    /// derived `PartialEq` compares `max_accuracy_m` with float `==`, so a
768    /// config with a NaN accuracy is not equal to itself — while `Ord` (via
769    /// `to_bits`) reports `Equal` for the very same pair.
770    #[test]
771    #[ignore = "KNOWN BUG: `impl Eq` claims reflexivity, but NaN max_accuracy_m breaks it"]
772    #[allow(clippy::eq_op)]
773    fn probe_config_eq_is_reflexive_with_nan() {
774        let c = cfg(false, false, f32::NAN, 0);
775
776        assert_eq!(c.cmp(&c), Ordering::Equal, "Ord says equal");
777        assert_eq!(c, c, "so Eq must too");
778    }
779}