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)]
19
pub 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).
47
impl_option!(LocationFix, OptionLocationFix, [Debug, Clone, Copy, PartialEq]);
48

            
49
impl LocationFix {
50
40
    #[must_use] pub const fn altitude(&self) -> Option<f32> {
51
40
        if self.altitude_m.is_nan() {
52
8
            None
53
        } else {
54
32
            Some(self.altitude_m)
55
        }
56
40
    }
57

            
58
38
    #[must_use] pub const fn altitude_accuracy(&self) -> Option<f32> {
59
38
        if self.altitude_accuracy_m.is_nan() {
60
7
            None
61
        } else {
62
31
            Some(self.altitude_accuracy_m)
63
        }
64
38
    }
65

            
66
39
    #[must_use] pub const fn heading(&self) -> Option<f32> {
67
39
        if self.heading_deg.is_nan() {
68
8
            None
69
        } else {
70
31
            Some(self.heading_deg)
71
        }
72
39
    }
73

            
74
40
    #[must_use] pub const fn speed(&self) -> Option<f32> {
75
40
        if self.speed_mps.is_nan() {
76
8
            None
77
        } else {
78
32
            Some(self.speed_mps)
79
        }
80
40
    }
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)]
88
pub 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

            
108
impl Default for GeolocationProbeConfig {
109
14
    fn default() -> Self {
110
14
        Self {
111
14
            high_accuracy: false,
112
14
            background: false,
113
14
            max_accuracy_m: 0.0,
114
14
            min_interval_ms: 0,
115
14
        }
116
14
    }
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.
123
2212
const fn canon_bits(f: f32) -> u32 {
124
2212
    let bits = f.to_bits();
125
2212
    if bits.trailing_zeros() >= 31 {
126
821
        0 // +0.0 and -0.0 collapse to +0.0
127
1391
    } else if f.is_nan() {
128
548
        f32::NAN.to_bits() // all NaN payloads -> one canonical NaN
129
    } else {
130
843
        bits
131
    }
132
2212
}
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.
138
impl PartialEq for GeolocationProbeConfig {
139
307
    fn eq(&self, other: &Self) -> bool {
140
307
        self.high_accuracy == other.high_accuracy
141
226
            && self.background == other.background
142
198
            && canon_bits(self.max_accuracy_m) == canon_bits(other.max_accuracy_m)
143
178
            && self.min_interval_ms == other.min_interval_ms
144
307
    }
145
}
146

            
147
impl Eq for GeolocationProbeConfig {}
148

            
149
impl PartialOrd for GeolocationProbeConfig {
150
58
    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
151
58
        Some(self.cmp(other))
152
58
    }
153
}
154

            
155
impl Ord for GeolocationProbeConfig {
156
888
    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
888
        (
160
888
            self.high_accuracy,
161
888
            self.background,
162
888
            canon_bits(self.max_accuracy_m),
163
888
            self.min_interval_ms,
164
888
        )
165
888
            .cmp(&(
166
888
                other.high_accuracy,
167
888
                other.background,
168
888
                canon_bits(other.max_accuracy_m),
169
888
                other.min_interval_ms,
170
888
            ))
171
888
    }
172
}
173

            
174
impl core::hash::Hash for GeolocationProbeConfig {
175
40
    fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
176
40
        self.high_accuracy.hash(state);
177
40
        self.background.hash(state);
178
40
        canon_bits(self.max_accuracy_m).hash(state);
179
40
        self.min_interval_ms.hash(state);
180
40
    }
181
}
182

            
183
#[cfg(test)]
184
#[allow(clippy::float_cmp, clippy::eq_op, clippy::unusual_byte_groupings)]
185
mod 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
}