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)]
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
119/// Canonical bit pattern for hashing / total-ordering / equality of an f32 config
120/// field: -0.0 and +0.0 collapse to the same value (they compare numerically
121/// equal), and every NaN maps to one canonical NaN (so a NaN is equal to — and
122/// hashes like — itself). Used by `PartialEq`, `Ord` and `Hash` so all three agree.
123const fn canon_bits(f: f32) -> u32 {
124    let bits = f.to_bits();
125    if bits.trailing_zeros() >= 31 {
126        0 // +0.0 and -0.0 collapse to +0.0
127    } else if f.is_nan() {
128        f32::NAN.to_bits() // all NaN payloads -> one canonical NaN
129    } else {
130        bits
131    }
132}
133
134// PartialEq / Ord / Hash are hand-written to compare `max_accuracy_m` via
135// `canon_bits` so all three agree: a derived PartialEq's raw float `==` makes a
136// NaN unequal to itself, and raw `to_bits` makes -0.0 != +0.0 — either way
137// breaking the Eq/Hash/Ord contracts that NodeType (which embeds this) relies on.
138impl PartialEq for GeolocationProbeConfig {
139    fn eq(&self, other: &Self) -> bool {
140        self.high_accuracy == other.high_accuracy
141            && self.background == other.background
142            && canon_bits(self.max_accuracy_m) == canon_bits(other.max_accuracy_m)
143            && self.min_interval_ms == other.min_interval_ms
144    }
145}
146
147impl Eq for GeolocationProbeConfig {}
148
149impl PartialOrd for GeolocationProbeConfig {
150    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
151        Some(self.cmp(other))
152    }
153}
154
155impl Ord for GeolocationProbeConfig {
156    fn cmp(&self, other: &Self) -> core::cmp::Ordering {
157        // f32 comparison via to_bits — gives a total order even with
158        // NaNs and matches NodeType::Eq + Hash requirements.
159        (
160            self.high_accuracy,
161            self.background,
162            canon_bits(self.max_accuracy_m),
163            self.min_interval_ms,
164        )
165            .cmp(&(
166                other.high_accuracy,
167                other.background,
168                canon_bits(other.max_accuracy_m),
169                other.min_interval_ms,
170            ))
171    }
172}
173
174impl core::hash::Hash for GeolocationProbeConfig {
175    fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
176        self.high_accuracy.hash(state);
177        self.background.hash(state);
178        canon_bits(self.max_accuracy_m).hash(state);
179        self.min_interval_ms.hash(state);
180    }
181}
182
183#[cfg(test)]
184#[allow(clippy::float_cmp, clippy::eq_op, clippy::unusual_byte_groupings)]
185mod autotest_generated {
186    use core::{
187        cmp::Ordering,
188        hash::{Hash, Hasher},
189    };
190
191    use super::*;
192
193    // ---------------------------------------------------------------
194    // helpers
195    // ---------------------------------------------------------------
196
197    /// A fix with every optional field *present* (no NaN anywhere), so a
198    /// test can knock out exactly one field and watch only that getter flip.
199    const fn fix_all_present() -> LocationFix {
200        LocationFix {
201            latitude_deg: 48.208_8,
202            longitude_deg: 16.372_1,
203            accuracy_m: 5.0,
204            altitude_m: 171.0,
205            altitude_accuracy_m: 3.0,
206            heading_deg: 90.0,
207            speed_mps: 1.5,
208            timestamp_ms: 1_234,
209        }
210    }
211
212    /// The "platform reported nothing but lat/lon" fix the module docs describe.
213    const fn fix_all_absent() -> LocationFix {
214        LocationFix {
215            latitude_deg: 0.0,
216            longitude_deg: 0.0,
217            accuracy_m: 0.0,
218            altitude_m: f32::NAN,
219            altitude_accuracy_m: f32::NAN,
220            heading_deg: f32::NAN,
221            speed_mps: f32::NAN,
222            timestamp_ms: 0,
223        }
224    }
225
226    const fn cfg(
227        high_accuracy: bool,
228        background: bool,
229        max_accuracy_m: f32,
230        min_interval_ms: u32,
231    ) -> GeolocationProbeConfig {
232        GeolocationProbeConfig {
233            high_accuracy,
234            background,
235            max_accuracy_m,
236            min_interval_ms,
237        }
238    }
239
240    /// Bit-exact float identity (so `-0.0 != 0.0`), but NaN-tolerant: any NaN
241    /// matches any NaN. A plain `==` would report `NaN != NaN` and make every
242    /// round-trip assertion below vacuously fail; comparing raw payload bits
243    /// would over-constrain (Rust does not guarantee NaN payload propagation).
244    fn same_f32(a: f32, b: f32) -> bool {
245        if a.is_nan() {
246            b.is_nan()
247        } else {
248            a.to_bits() == b.to_bits()
249        }
250    }
251
252    fn same_f64(a: f64, b: f64) -> bool {
253        if a.is_nan() {
254            b.is_nan()
255        } else {
256            a.to_bits() == b.to_bits()
257        }
258    }
259
260    fn same_fix(a: &LocationFix, b: &LocationFix) -> bool {
261        same_f64(a.latitude_deg, b.latitude_deg)
262            && same_f64(a.longitude_deg, b.longitude_deg)
263            && same_f32(a.accuracy_m, b.accuracy_m)
264            && same_f32(a.altitude_m, b.altitude_m)
265            && same_f32(a.altitude_accuracy_m, b.altitude_accuracy_m)
266            && same_f32(a.heading_deg, b.heading_deg)
267            && same_f32(a.speed_mps, b.speed_mps)
268            && a.timestamp_ms == b.timestamp_ms
269    }
270
271    /// FNV-1a — a `no_std`-safe, deterministic stand-in for `DefaultHasher`
272    /// (this crate is `no_std` without the `std` feature).
273    struct Fnv1a(u64);
274
275    impl Fnv1a {
276        const fn new() -> Self {
277            Self(0xcbf2_9ce4_8422_2325)
278        }
279    }
280
281    impl Hasher for Fnv1a {
282        fn finish(&self) -> u64 {
283            self.0
284        }
285        fn write(&mut self, bytes: &[u8]) {
286            for b in bytes {
287                self.0 ^= u64::from(*b);
288                self.0 = self.0.wrapping_mul(0x0000_0100_0000_01b3);
289            }
290        }
291    }
292
293    fn hash_of(c: &GeolocationProbeConfig) -> u64 {
294        let mut h = Fnv1a::new();
295        c.hash(&mut h);
296        h.finish()
297    }
298
299    /// Every interesting `f32` class, as raw bits, including four distinct NaN
300    /// encodings (quiet, negative-quiet, signalling, all-ones payload).
301    const F32_PATTERNS: [u32; 16] = [
302        0x0000_0000, // +0.0
303        0x8000_0000, // -0.0
304        0x0000_0001, // smallest positive subnormal
305        0x8000_0001, // smallest negative subnormal
306        0x007f_ffff, // largest subnormal
307        0x0080_0000, // f32::MIN_POSITIVE
308        0x3f80_0000, // 1.0
309        0xbf80_0000, // -1.0
310        0x7f7f_ffff, // f32::MAX
311        0xff7f_ffff, // f32::MIN
312        0x7f80_0000, // +inf
313        0xff80_0000, // -inf
314        0x7fc0_0000, // quiet NaN
315        0xffc0_0000, // negative quiet NaN
316        0x7f80_0001, // signalling NaN
317        0x7fff_ffff, // NaN, all-ones payload
318    ];
319
320    // ---------------------------------------------------------------
321    // LocationFix getters — the documented contract is
322    // "NaN (and only NaN) means the platform did not report the field"
323    // ---------------------------------------------------------------
324
325    /// The core invariant, swept over every float class: a getter returns
326    /// `None` **iff** its backing field is NaN, and otherwise hands back the
327    /// value bit-for-bit — including infinities, subnormals and `-0.0`, which
328    /// are all "reported" values and must NOT be swallowed like NaN is.
329    #[test]
330    fn getter_is_none_exactly_when_field_is_nan() {
331        for bits in F32_PATTERNS {
332            let v = f32::from_bits(bits);
333
334            // Each getter is exercised on a fix where *only* its own field
335            // carries the pattern, so a getter reading the wrong field is caught.
336            let mut alt = fix_all_present();
337            alt.altitude_m = v;
338            let mut alt_acc = fix_all_present();
339            alt_acc.altitude_accuracy_m = v;
340            let mut head = fix_all_present();
341            head.heading_deg = v;
342            let mut spd = fix_all_present();
343            spd.speed_mps = v;
344
345            let got = [
346                ("altitude", alt.altitude()),
347                ("altitude_accuracy", alt_acc.altitude_accuracy()),
348                ("heading", head.heading()),
349                ("speed", spd.speed()),
350            ];
351
352            for (name, out) in got {
353                if v.is_nan() {
354                    assert!(
355                        out.is_none(),
356                        "{name}() must be None for NaN bits {bits:#010x}, got {out:?}"
357                    );
358                } else {
359                    let inner = out.unwrap_or_else(|| {
360                        panic!("{name}() must be Some for non-NaN bits {bits:#010x}")
361                    });
362                    assert_eq!(
363                        inner.to_bits(),
364                        bits,
365                        "{name}() must return the field verbatim for {bits:#010x}"
366                    );
367                }
368            }
369
370            // The other three getters are untouched by the pattern under test.
371            assert_eq!(alt.heading(), Some(90.0));
372            assert_eq!(alt_acc.speed(), Some(1.5));
373            assert_eq!(head.altitude(), Some(171.0));
374            assert_eq!(spd.altitude_accuracy(), Some(3.0));
375        }
376    }
377
378    /// `-0.0` is a legitimate reported value (a stationary device at sea level,
379    /// a southbound heading rounded to zero). It must survive the getter with
380    /// its sign bit intact rather than being normalised to `+0.0`.
381    #[test]
382    fn getters_preserve_negative_zero() {
383        let mut fix = fix_all_present();
384        fix.altitude_m = -0.0;
385        fix.altitude_accuracy_m = -0.0;
386        fix.heading_deg = -0.0;
387        fix.speed_mps = -0.0;
388
389        for out in [
390            fix.altitude(),
391            fix.altitude_accuracy(),
392            fix.heading(),
393            fix.speed(),
394        ] {
395            let v = out.expect("-0.0 is not NaN, so the field is 'reported'");
396            assert_eq!(v.to_bits(), 0x8000_0000, "sign bit of -0.0 was dropped");
397            assert!(v == 0.0, "-0.0 must still compare equal to 0.0");
398        }
399    }
400
401    /// Infinities are not NaN, so per the documented contract they are handed
402    /// through as `Some(inf)` — the getters must not "helpfully" filter them.
403    #[test]
404    fn getters_pass_infinities_through() {
405        let mut fix = fix_all_absent();
406        fix.altitude_m = f32::INFINITY;
407        fix.altitude_accuracy_m = f32::NEG_INFINITY;
408        fix.heading_deg = f32::INFINITY;
409        fix.speed_mps = f32::NEG_INFINITY;
410
411        assert_eq!(fix.altitude(), Some(f32::INFINITY));
412        assert_eq!(fix.altitude_accuracy(), Some(f32::NEG_INFINITY));
413        assert_eq!(fix.heading(), Some(f32::INFINITY));
414        assert_eq!(fix.speed(), Some(f32::NEG_INFINITY));
415    }
416
417    /// The "platform reported nothing" fix, plus a saturated timestamp and
418    /// out-of-range WGS-84 coordinates: nothing here may panic, and every
419    /// optional getter must report absence.
420    #[test]
421    fn getters_on_extreme_and_all_absent_fix() {
422        let mut fix = fix_all_absent();
423        fix.latitude_deg = f64::MAX;
424        fix.longitude_deg = f64::MIN;
425        fix.accuracy_m = f32::MAX;
426        fix.timestamp_ms = u64::MAX;
427
428        assert_eq!(fix.altitude(), None);
429        assert_eq!(fix.altitude_accuracy(), None);
430        assert_eq!(fix.heading(), None);
431        assert_eq!(fix.speed(), None);
432
433        // Getters are `&self` — they must not have mutated the receiver.
434        assert_eq!(fix.timestamp_ms, u64::MAX);
435        assert!(fix.latitude_deg == f64::MAX);
436
437        // Default-ish zeroed fix: 0.0 is not NaN, so everything is "reported".
438        let zeroed = LocationFix {
439            latitude_deg: 0.0,
440            longitude_deg: 0.0,
441            accuracy_m: 0.0,
442            altitude_m: 0.0,
443            altitude_accuracy_m: 0.0,
444            heading_deg: 0.0,
445            speed_mps: 0.0,
446            timestamp_ms: 0,
447        };
448        assert_eq!(zeroed.altitude(), Some(0.0));
449        assert_eq!(zeroed.altitude_accuracy(), Some(0.0));
450        assert_eq!(zeroed.heading(), Some(0.0));
451        assert_eq!(zeroed.speed(), Some(0.0));
452    }
453
454    /// The getters are declared `const fn`; that is part of the public API, so
455    /// pin it — a non-const rewrite would be a silent breaking change.
456    #[test]
457    fn getters_are_const_evaluable() {
458        const PRESENT: LocationFix = fix_all_present();
459        const ABSENT: LocationFix = fix_all_absent();
460
461        const ALT: Option<f32> = PRESENT.altitude();
462        const ALT_ACC: Option<f32> = PRESENT.altitude_accuracy();
463        const HEADING: Option<f32> = PRESENT.heading();
464        const SPEED: Option<f32> = PRESENT.speed();
465        const NO_ALT: Option<f32> = ABSENT.altitude();
466        const NO_HEADING: Option<f32> = ABSENT.heading();
467
468        assert_eq!(ALT, Some(171.0));
469        assert_eq!(ALT_ACC, Some(3.0));
470        assert_eq!(HEADING, Some(90.0));
471        assert_eq!(SPEED, Some(1.5));
472        assert_eq!(NO_ALT, None);
473        assert_eq!(NO_HEADING, None);
474    }
475
476    /// `LocationFix` derives `PartialEq` over raw floats, so a fix carrying an
477    /// unreported (NaN) field is *not* equal to itself. Pinned deliberately:
478    /// the type is (correctly) not `Eq`/`Hash`, and any future use as a map key
479    /// or in a `!=`-based change check would be broken by this. Callers must
480    /// compare via the getters, as `same_fix` does.
481    #[test]
482    fn location_fix_partial_eq_is_not_reflexive_when_fields_are_absent() {
483        let absent = fix_all_absent();
484        assert!(absent != absent, "IEEE semantics: NaN != NaN");
485        assert!(same_fix(&absent, &absent), "but it is the same fix");
486
487        let present = fix_all_present();
488        assert!(present == present, "a fully-reported fix is self-equal");
489        assert!(same_fix(&present, &present));
490
491        // `-0.0 == 0.0` under PartialEq, but the two are distinguishable.
492        let mut neg_zero = fix_all_present();
493        neg_zero.altitude_m = -0.0;
494        let mut pos_zero = fix_all_present();
495        pos_zero.altitude_m = 0.0;
496        assert!(neg_zero == pos_zero);
497        assert!(!same_fix(&neg_zero, &pos_zero));
498    }
499
500    // ---------------------------------------------------------------
501    // OptionLocationFix — the FFI wrapper: encode == decode
502    // ---------------------------------------------------------------
503
504    /// Round-trip `Option<LocationFix>` -> `OptionLocationFix` -> back, for both
505    /// variants, plus the full wrapper surface (`replace` must return the
506    /// *previous* value, `map`/`and_then` must short-circuit on `None`).
507    #[test]
508    fn option_location_fix_roundtrips() {
509        assert!(OptionLocationFix::default().is_none());
510        assert!(!OptionLocationFix::default().is_some());
511        assert_eq!(
512            Option::<LocationFix>::from(OptionLocationFix::default()),
513            None
514        );
515
516        let fix = fix_all_present();
517        let wrapped: OptionLocationFix = Some(fix).into();
518        assert!(wrapped.is_some());
519        assert!(!wrapped.is_none());
520        assert_eq!(wrapped.as_ref(), Some(&fix));
521        assert_eq!(wrapped.as_option(), Some(&fix));
522        assert_eq!(wrapped.into_option(), Some(fix));
523
524        let decoded = Option::<LocationFix>::from(wrapped).expect("Some in, Some out");
525        assert!(same_fix(&decoded, &fix), "encode == decode");
526
527        let none: OptionLocationFix = Option::<LocationFix>::None.into();
528        assert!(none.is_none());
529        assert_eq!(none.as_ref(), None);
530        assert_eq!(none.map(|f| f.timestamp_ms), None);
531        assert_eq!(none.and_then(|f| f.altitude()), None);
532        assert_eq!(wrapped.map(|f| f.timestamp_ms), Some(1_234));
533        assert_eq!(wrapped.and_then(|f| f.altitude()), Some(171.0));
534
535        // `replace` has mem::replace semantics: it yields the OLD value.
536        let mut slot = OptionLocationFix::None;
537        assert!(slot.replace(fix).is_none());
538        assert_eq!(slot.as_option(), Some(&fix));
539
540        let other = fix_all_absent();
541        let prev = slot.replace(other);
542        assert!(prev.is_some());
543        assert!(same_fix(
544            &Option::<LocationFix>::from(prev).expect("previous fix"),
545            &fix
546        ));
547        assert!(same_fix(
548            slot.as_option().expect("current fix"),
549            &fix_all_absent()
550        ));
551
552        // `as_mut` hands out the live payload.
553        if let Some(inner) = slot.as_mut() {
554            inner.timestamp_ms = u64::MAX;
555        }
556        assert_eq!(slot.as_option().expect("still Some").timestamp_ms, u64::MAX);
557    }
558
559    /// The wrapper must survive the exact payload the platform layer produces
560    /// most often: a fix whose optional fields are all NaN. `PartialEq` on the
561    /// payload is useless here (NaN != NaN), so the round-trip is checked
562    /// field-wise — this is the case a naive `assert_eq!` would hide.
563    #[test]
564    fn option_location_fix_roundtrip_survives_absent_fields() {
565        let mut fix = fix_all_absent();
566        fix.latitude_deg = -89.999_999_9;
567        fix.longitude_deg = 179.999_999_9;
568        fix.accuracy_m = f32::MIN_POSITIVE;
569        fix.timestamp_ms = u64::MAX;
570
571        let decoded = Option::<LocationFix>::from(OptionLocationFix::Some(fix))
572            .expect("Some survives the round-trip");
573
574        assert!(same_fix(&decoded, &fix), "encode == decode for a NaN-y fix");
575        assert_eq!(decoded.altitude(), None);
576        assert_eq!(decoded.altitude_accuracy(), None);
577        assert_eq!(decoded.heading(), None);
578        assert_eq!(decoded.speed(), None);
579        assert_eq!(decoded.timestamp_ms, u64::MAX);
580
581        // The wrapper's own derived PartialEq inherits the NaN quirk; assert it
582        // rather than trusting `assert_eq!` to mean anything here.
583        assert!(OptionLocationFix::Some(fix) != OptionLocationFix::Some(fix));
584        assert!(OptionLocationFix::None == OptionLocationFix::None);
585    }
586
587    /// `#[repr(C, u8)]` means the wrapper is tag + payload with no niche
588    /// packing — it must be strictly larger than the payload, or the C ABI
589    /// header generated for it would be wrong.
590    #[test]
591    fn option_location_fix_is_tagged_not_niche_packed() {
592        use core::mem::{align_of, size_of};
593        assert!(
594            size_of::<OptionLocationFix>() > size_of::<LocationFix>(),
595            "repr(C, u8) must carry an explicit discriminant"
596        );
597        assert!(align_of::<OptionLocationFix>() >= align_of::<LocationFix>());
598        assert!(size_of::<LocationFix>() >= 2 * size_of::<f64>() + 5 * size_of::<f32>());
599    }
600
601    // ---------------------------------------------------------------
602    // GeolocationProbeConfig — Default / Ord / Hash
603    // ---------------------------------------------------------------
604
605    /// The documented default: no high accuracy, no background, no accuracy
606    /// filter (`0` = "deliver every sample"), no throttle.
607    #[test]
608    fn probe_config_default_is_permissive_zero() {
609        let d = GeolocationProbeConfig::default();
610        assert!(!d.high_accuracy);
611        assert!(!d.background);
612        assert!(d.max_accuracy_m == 0.0);
613        assert_eq!(d.max_accuracy_m.to_bits(), 0, "default must be +0.0, not -0.0");
614        assert_eq!(d.min_interval_ms, 0);
615        assert_eq!(d, cfg(false, false, 0.0, 0));
616        assert_eq!(d.cmp(&GeolocationProbeConfig::default()), Ordering::Equal);
617    }
618
619    /// Field precedence of the hand-written `Ord`: high_accuracy, then
620    /// background, then accuracy bits, then interval. Each earlier field must
621    /// dominate *every* later one, so the loser is given maximal later fields.
622    #[test]
623    fn probe_config_ord_field_precedence() {
624        let low_but_maxed = cfg(false, true, f32::from_bits(u32::MAX), u32::MAX);
625        let high_but_minimal = cfg(true, false, 0.0, 0);
626        assert!(low_but_maxed < high_but_minimal, "high_accuracy dominates");
627
628        let fg_maxed = cfg(true, false, f32::from_bits(u32::MAX), u32::MAX);
629        let bg_minimal = cfg(true, true, 0.0, 0);
630        assert!(fg_maxed < bg_minimal, "background dominates the numeric fields");
631
632        let small_acc = cfg(true, true, 1.0, u32::MAX);
633        let big_acc = cfg(true, true, 2.0, 0);
634        assert!(small_acc < big_acc, "accuracy bits dominate the interval");
635
636        assert!(cfg(true, true, 1.0, 0) < cfg(true, true, 1.0, 1));
637        assert!(cfg(true, true, 1.0, u32::MAX - 1) < cfg(true, true, 1.0, u32::MAX));
638        assert_eq!(
639            cfg(true, true, 1.0, u32::MAX).cmp(&cfg(true, true, 1.0, u32::MAX)),
640            Ordering::Equal
641        );
642    }
643
644    /// The ordering is over `to_bits`, NOT over numeric value: sign-magnitude
645    /// bits mean every negative accuracy sorts *above* every positive one, and
646    /// NaN sorts above +inf. Pinned because it is load-bearing (`NodeType`
647    /// dedup/sort) and because it is exactly the trap a reader assumes away.
648    #[test]
649    fn probe_config_ord_is_bitwise_not_numeric() {
650        let neg = cfg(false, false, -1.0, 0);
651        let pos = cfg(false, false, 1.0, 0);
652        assert!(neg.max_accuracy_m < pos.max_accuracy_m, "numerically: -1 < 1");
653        assert_eq!(
654            neg.cmp(&pos),
655            Ordering::Greater,
656            "bitwise: 0xbf80_0000 > 0x3f80_0000"
657        );
658
659        let inf = cfg(false, false, f32::INFINITY, 0);
660        let nan = cfg(false, false, f32::NAN, 0);
661        assert_eq!(nan.cmp(&inf), Ordering::Greater, "NaN bits sort above +inf");
662        assert_eq!(inf.cmp(&pos), Ordering::Greater);
663    }
664
665    /// A NaN accuracy must not break the total order: `cmp` stays reflexive,
666    /// antisymmetric and transitive, and sorting a NaN-containing slice
667    /// terminates without panicking (an inconsistent comparator can make
668    /// `sort_unstable` misbehave).
669    #[test]
670    fn probe_config_ord_is_a_total_order_with_nan() {
671        let nan = cfg(false, false, f32::NAN, 7);
672        assert_eq!(nan.cmp(&nan), Ordering::Equal, "cmp must be reflexive");
673        assert_eq!(nan.partial_cmp(&nan), Some(Ordering::Equal));
674
675        let mut list = [
676            cfg(true, true, f32::NAN, u32::MAX),
677            cfg(false, false, -0.0, 0),
678            cfg(false, false, f32::INFINITY, 1),
679            nan,
680            cfg(true, false, f32::NEG_INFINITY, 3),
681            cfg(false, true, 0.0, u32::MAX),
682            GeolocationProbeConfig::default(),
683        ];
684        list.sort_unstable();
685        for w in list.windows(2) {
686            assert_ne!(
687                w[0].cmp(&w[1]),
688                Ordering::Greater,
689                "sort_unstable must leave the slice ordered under cmp"
690            );
691        }
692
693        // Antisymmetry + transitivity across every pair/triple in the slice.
694        for a in list {
695            for b in list {
696                assert_eq!(
697                    a.cmp(&b),
698                    b.cmp(&a).reverse(),
699                    "cmp must be antisymmetric"
700                );
701                for c in list {
702                    if a.cmp(&b) != Ordering::Greater && b.cmp(&c) != Ordering::Greater {
703                        assert_ne!(a.cmp(&c), Ordering::Greater, "cmp must be transitive");
704                    }
705                }
706            }
707        }
708    }
709
710    /// `PartialOrd` must agree with `Ord`, and — for the ordinary (finite,
711    /// non-negative) configs a user actually writes — `==`, `cmp == Equal` and
712    /// equal hashes must all coincide. This is the Eq/Ord/Hash contract holding
713    /// on the reachable domain; the two regression tests below pin the -0.0/+0.0
714    /// and NaN corners where a naive derived `PartialEq` would break it.
715    #[test]
716    fn probe_config_eq_ord_hash_agree_on_ordinary_configs() {
717        let configs = [
718            GeolocationProbeConfig::default(),
719            cfg(false, false, 25.0, 1_000),
720            cfg(true, false, 5.0, 0),
721            cfg(true, true, 0.5, u32::MAX),
722            cfg(false, true, f32::MAX, 1),
723            cfg(true, true, f32::MIN_POSITIVE, 16),
724        ];
725
726        for a in configs {
727            for b in configs {
728                assert_eq!(a.partial_cmp(&b), Some(a.cmp(&b)), "PartialOrd must mirror Ord");
729                assert_eq!(
730                    a == b,
731                    a.cmp(&b) == Ordering::Equal,
732                    "`==` must agree with `cmp == Equal`"
733                );
734                if a == b {
735                    assert_eq!(hash_of(&a), hash_of(&b), "Eq implies equal hashes");
736                }
737            }
738            // Reflexivity of the whole trio.
739            assert_eq!(a, a);
740            assert_eq!(a.cmp(&a), Ordering::Equal);
741            assert_eq!(hash_of(&a), hash_of(&a));
742        }
743    }
744
745    /// Hashing must be a pure function of the four fields, must survive NaN
746    /// (`to_bits` cannot panic), and must actually distinguish configs that
747    /// differ only in a late field — otherwise `NodeType` dedup collapses
748    /// distinct probes.
749    #[test]
750    fn probe_config_hash_is_stable_and_discriminating() {
751        let a = cfg(true, false, 12.5, 250);
752        assert_eq!(hash_of(&a), hash_of(&cfg(true, false, 12.5, 250)));
753
754        let nan = cfg(false, false, f32::NAN, 0);
755        assert_eq!(hash_of(&nan), hash_of(&nan), "hashing NaN must be stable");
756
757        for other in [
758            cfg(false, false, 12.5, 250),
759            cfg(true, true, 12.5, 250),
760            cfg(true, false, 12.75, 250),
761            cfg(true, false, 12.5, 251),
762        ] {
763            assert_ne!(a, other);
764            assert_ne!(
765                hash_of(&a),
766                hash_of(&other),
767                "configs differing in one field must not collide: {other:?}"
768            );
769        }
770    }
771
772    // ---------------------------------------------------------------
773    // Regression guards for Eq/Ord/Hash consistency of the f32 config field
774    // (canonicalized bits: -0.0 == +0.0, NaN == NaN). The derived PartialEq used
775    // to disagree with the hand-written Ord/Hash.
776    // ---------------------------------------------------------------
777
778    /// `impl Eq` + `impl Hash` promise: `a == b` implies `hash(a) == hash(b)`
779    /// and `a.cmp(&b) == Equal`. A derived `PartialEq` would compare
780    /// `max_accuracy_m` numerically (`-0.0 == 0.0`) while `Hash`/`Ord` compared
781    /// raw `to_bits` (where they differ), so a `HashMap`/`BTreeMap` keyed on a
782    /// `NodeType` carrying this config could fail to find an entry that compares
783    /// equal. The hand-written `PartialEq`/`Ord`/`Hash` all route through
784    /// `canon_bits`, keeping them consistent — this pins that.
785    #[test]
786    fn probe_config_eq_implies_equal_hash_and_ordering() {
787        let pos = cfg(false, false, 0.0, 0);
788        let neg = cfg(false, false, -0.0, 0);
789
790        assert_eq!(pos, neg, "IEEE: -0.0 == 0.0");
791        assert_eq!(hash_of(&pos), hash_of(&neg), "Eq/Hash contract");
792        assert_eq!(pos.cmp(&neg), Ordering::Equal, "Eq/Ord contract");
793    }
794
795    /// `impl Eq for GeolocationProbeConfig {}` asserts reflexivity. A derived
796    /// `PartialEq` comparing `max_accuracy_m` with float `==` would make a
797    /// config with a NaN accuracy unequal to itself — while `Ord` (via
798    /// `canon_bits`) reports `Equal` for the very same pair. The hand-written
799    /// `PartialEq` uses `canon_bits` too, so both agree; this pins it.
800    #[test]
801    #[allow(clippy::eq_op)]
802    fn probe_config_eq_is_reflexive_with_nan() {
803        let c = cfg(false, false, f32::NAN, 0);
804
805        assert_eq!(c.cmp(&c), Ordering::Equal, "Ord says equal");
806        assert_eq!(c, c, "so Eq must too");
807    }
808}