1
//! Geolocation manager — cross-platform state for the GPS/location surface
2
//! (`SUPER_PLAN_2` §1.5 + research/04 §3 + research/08 §6).
3
//!
4
//! Three callers drive it:
5
//!
6
//! - The **layout pass** scans the styled DOM for `GeolocationProbe`
7
//!   `NodeTypes`. When the first probe appears the framework fires
8
//!   `PermissionDiffEvent::Subscribe(Capability::Geolocation)` and the
9
//!   platform backend starts a native `CLLocationManager` /
10
//!   `LocationManager` / `geoclue` subscription. The reverse on the
11
//!   last probe leaving.
12
//!
13
//! - The **platform backend** (`dll/src/desktop/extra/geolocation/<plat>.rs`)
14
//!   calls `set_latest_fix(...)` whenever the native subscription
15
//!   delivers an update. The manager debounces and records the most
16
//!   recent value; callbacks read it via `CallbackInfo::get_geolocation_fix`.
17
//!
18
//! - **Callbacks** read `latest_fix()` synchronously to render the map
19
//!   centre, decide whether to show "acquiring signal…", etc.
20
//!
21
//! No platform deps; `no_std`-friendly via `alloc::collections::BTreeMap`.
22

            
23
use alloc::collections::btree_map::BTreeMap;
24
use alloc::vec::Vec;
25

            
26
use azul_core::dom::DomNodeId;
27
use azul_core::events::{
28
    EventData, EventProvider, EventSource as CoreEventSource, EventType, SyntheticEvent,
29
};
30
use azul_core::task::Instant;
31

            
32
// `LocationFix` + `GeolocationProbeConfig` live in `azul-core` so
33
// `NodeType::GeolocationProbe(GeolocationProbeConfig)` can reference
34
// the config struct without a cyclic dep on `azul-layout`. We re-export
35
// them here for the existing `azul_layout::managers::geolocation::*`
36
// import paths.
37
pub use azul_core::geolocation::{GeolocationProbeConfig, LocationFix};
38

            
39
/// Diff event the layout pass emits when a probe appears or disappears.
40
/// Symmetric to `PermissionDiffEvent` — drives the platform backend's
41
/// native subscribe / release calls.
42
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43
#[repr(C, u8)]
44
pub enum GeolocationDiffEvent {
45
    /// First probe of this config landed in the layout — start a
46
    /// native subscription with these options.
47
    Subscribe { config: GeolocationProbeConfig },
48
    /// Last probe left — stop the native subscription.
49
    Release,
50
    /// Probe config changed without subscriber churn — reconfigure
51
    /// the running subscription in place (e.g. `high_accuracy` false →
52
    /// true).
53
    Reconfigure { config: GeolocationProbeConfig },
54
}
55

            
56
/// Cross-platform geolocation state. One per `App` (the OS gives us
57
/// a single per-process subscription, not per-window).
58
#[derive(Debug, Clone, PartialEq, Default)]
59
pub struct GeolocationManager {
60
    /// Most recent fix from the platform backend, or `None` until the
61
    /// first native sample arrives (or `None` again after a Release).
62
    pub latest_fix: Option<LocationFix>,
63
    /// Active probe config — set on each Subscribe / Reconfigure,
64
    /// cleared on Release.
65
    pub active_config: Option<GeolocationProbeConfig>,
66
    /// Diff queue drained once per frame by the platform backend.
67
    pending_events: Vec<GeolocationDiffEvent>,
68
    /// Refcount of `GeolocationProbe` nodes currently in the layout.
69
    refcount: u32,
70
    /// `true` when a fix advanced since the last event-pass drain. Set by
71
    /// [`set_latest_fix`](Self::set_latest_fix), read by the `EventProvider`
72
    /// impl (yields `EventType::GeolocationFix`), cleared by
73
    /// [`clear_pending_event`](Self::clear_pending_event) after dispatch
74
    /// (MWA-A1 — this manager previously computed fixes nobody dispatched).
75
    pending_event: bool,
76
    /// Most recent backend error (subscription failed / timed out /
77
    /// revoked), or `None`. Cleared on the next successful fix (MWA-A1b).
78
    pub last_error: Option<LocationError>,
79
    /// `true` when an error arrived since the last event-pass drain — the
80
    /// `EventProvider` impl yields `EventType::GeolocationError` for it.
81
    pending_error_event: bool,
82
}
83

            
84
/// Error from the native geolocation backend (MWA-A1b).
85
///
86
/// Layout-internal
87
/// (not FFI-exposed) until the `CallbackInfo` getter lands with the Phase C
88
/// geolocation item — the `GeolocationError` EVENT carries no payload, so
89
/// callbacks poll this via the manager.
90
#[derive(Debug, Clone, PartialEq, Eq, Default)]
91
pub struct LocationError {
92
    /// Platform-specific error code (`CLError` code / `GError` code / HRESULT).
93
    pub code: u32,
94
    /// Human-readable message from the backend.
95
    pub message: String,
96
}
97

            
98
impl GeolocationManager {
99
5620
    #[must_use] pub fn new() -> Self {
100
5620
        Self::default()
101
5620
    }
102

            
103
37
    #[must_use] pub const fn latest_fix(&self) -> Option<LocationFix> {
104
37
        self.latest_fix
105
37
    }
106

            
107
36
    #[must_use] pub const fn refcount(&self) -> u32 {
108
36
        self.refcount
109
36
    }
110

            
111
    /// Platform backend writes the freshly-received fix. Returns true
112
    /// if the fix actually advanced (different from the previous one)
113
    /// so the caller can mark the window dirty for relayout.
114
    ///
115
    /// Compared via bit-pattern equality so missing fields (encoded as
116
    /// `f32::NAN`) compare equal — `PartialEq` returns `false` on
117
    /// NaN-vs-NaN, which would make every fix look "changed" even
118
    /// when nothing actually moved.
119
154
    pub fn set_latest_fix(&mut self, fix: LocationFix) -> bool {
120
154
        let changed = self
121
154
            .latest_fix
122
154
            .is_none_or(|prev| !Self::location_fix_bitwise_eq(&prev, &fix));
123
154
        self.latest_fix = Some(fix);
124
154
        if changed {
125
43
            self.pending_event = true;
126
43
            // A successful fix supersedes any earlier error.
127
43
            self.last_error = None;
128
111
        }
129
154
        changed
130
154
    }
131

            
132
    /// Platform backend reports a subscription error (MWA-A1b). Always
133
    /// raises the error-event flag — repeated errors each answer a live
134
    /// subscription and callbacks should hear every one.
135
12
    pub fn set_last_error(&mut self, error: LocationError) {
136
12
        self.last_error = Some(error);
137
12
        self.pending_error_event = true;
138
12
    }
139

            
140
    /// Clear the pending-event flags (fix + error). The dll calls this
141
    /// after the event pass has collected the geolocation events.
142
97
    pub const fn clear_pending_event(&mut self) {
143
97
        self.pending_event = false;
144
97
        self.pending_error_event = false;
145
97
    }
146

            
147
    /// `true` while at least one `GeolocationProbe` is mounted — the
148
    /// capability pump keeps its drain timer armed while this holds, so
149
    /// fixes parked by the native backend reach callbacks without waiting
150
    /// for unrelated input (MWA-A1 arming signal).
151
23
    #[must_use] pub const fn has_active_subscription(&self) -> bool {
152
23
        self.refcount > 0
153
23
    }
154

            
155
204
    const fn location_fix_bitwise_eq(a: &LocationFix, b: &LocationFix) -> bool {
156
204
        a.latitude_deg.to_bits() == b.latitude_deg.to_bits()
157
156
            && a.longitude_deg.to_bits() == b.longitude_deg.to_bits()
158
153
            && a.accuracy_m.to_bits() == b.accuracy_m.to_bits()
159
150
            && a.altitude_m.to_bits() == b.altitude_m.to_bits()
160
147
            && a.altitude_accuracy_m.to_bits() == b.altitude_accuracy_m.to_bits()
161
144
            && a.heading_deg.to_bits() == b.heading_deg.to_bits()
162
140
            && a.speed_mps.to_bits() == b.speed_mps.to_bits()
163
137
            && a.timestamp_ms == b.timestamp_ms
164
204
    }
165

            
166
    /// Drain queued diff events. Platform backend calls this once per
167
    /// frame.
168
36
    pub fn take_pending_events(&mut self) -> Vec<GeolocationDiffEvent> {
169
36
        core::mem::take(&mut self.pending_events)
170
36
    }
171

            
172
    /// Diff entry point. The layout pass walks the styled DOM for
173
    /// `GeolocationProbe` nodes and feeds each `(config, node_id)`
174
    /// pair to the closure. The manager bumps the refcount, watches
175
    /// for config drift, and enqueues the right Subscribe / Release /
176
    /// Reconfigure event.
177
38
    pub fn diff_layout<F>(&mut self, mut for_each_probe: F)
178
38
    where
179
38
        F: FnMut(&mut dyn FnMut(GeolocationProbeConfig)),
180
    {
181
38
        let mut new_count: u32 = 0;
182
38
        let mut next_config: Option<GeolocationProbeConfig> = None;
183
10032
        for_each_probe(&mut |cfg| {
184
10032
            new_count += 1;
185
            // First probe's config wins. Subsequent probes that
186
            // disagree are accepted silently — a real app shouldn't
187
            // mount two `GeolocationProbe`s with different configs
188
            // but the framework can't assert that here.
189
10032
            if next_config.is_none() {
190
28
                next_config = Some(cfg);
191
10004
            }
192
10032
        });
193

            
194
38
        let old_count = self.refcount;
195
38
        self.refcount = new_count;
196

            
197
38
        match (old_count, new_count) {
198
18
            (0, n) if n > 0 => {
199
14
                let config = next_config.unwrap_or_default();
200
14
                self.active_config = Some(config);
201
14
                self.pending_events
202
14
                    .push(GeolocationDiffEvent::Subscribe { config });
203
14
            }
204
10
            (m, 0) if m > 0 => {
205
6
                self.active_config = None;
206
6
                self.latest_fix = None;
207
6
                self.pending_events.push(GeolocationDiffEvent::Release);
208
6
            }
209
18
            (m, n) if m > 0 && n > 0 => {
210
                // Both frames have probes. Emit Reconfigure if the
211
                // config actually drifted.
212
14
                let new_config = next_config.unwrap_or_default();
213
14
                if Some(new_config) != self.active_config {
214
6
                    self.active_config = Some(new_config);
215
6
                    self.pending_events
216
6
                        .push(GeolocationDiffEvent::Reconfigure { config: new_config });
217
8
                }
218
            }
219
4
            _ => {
220
4
                // 0 → 0 — nothing to do.
221
4
            }
222
        }
223
38
    }
224
}
225

            
226
impl EventProvider for GeolocationManager {
227
    /// Yield a window-level `GeolocationFix` event when a fix advanced since
228
    /// the last drain (target = root; read the value via
229
    /// `CallbackInfo::get_geolocation_fix` inside the callback). Mirrors the
230
    /// sensor / gamepad providers; before MWA-A1 nothing ever produced
231
    /// `EventType::GeolocationFix`, so fix callbacks could never fire.
232
112
    fn get_pending_events(&self, timestamp: Instant) -> Vec<SyntheticEvent> {
233
112
        let mut events = Vec::new();
234
112
        if self.pending_event {
235
14
            events.push(SyntheticEvent::new(
236
14
                EventType::GeolocationFix,
237
14
                CoreEventSource::User,
238
14
                DomNodeId::ROOT,
239
14
                timestamp.clone(),
240
14
                EventData::None,
241
14
            ));
242
98
        }
243
112
        if self.pending_error_event {
244
12
            events.push(SyntheticEvent::new(
245
12
                EventType::GeolocationError,
246
12
                CoreEventSource::User,
247
12
                DomNodeId::ROOT,
248
12
                timestamp,
249
12
                EventData::None,
250
12
            ));
251
100
        }
252
112
        events
253
112
    }
254
}
255

            
256
// ────────── Async fix channel (platform backend → manager) ────────────
257
//
258
// A native location callback (Android `FusedLocationProvider`
259
// `onLocationResult`, iOS `CLLocationManagerDelegate`) fires on an
260
// arbitrary thread with no handle to the live `GeolocationManager` (it
261
// lives inside the window's `LayoutWindow`). The backend parks each fix
262
// here; the layout pass drains it once per frame via
263
// [`drain_location_fixes`] and applies the latest through
264
// [`GeolocationManager::set_latest_fix`]. Pure Rust — no platform
265
// dependency (SUPER_PLAN_2 §0.5). Mirrors the permission manager's
266
// async-result channel.
267

            
268
static PENDING_FIXES: std::sync::Mutex<Vec<LocationFix>> =
269
    std::sync::Mutex::new(Vec::new());
270

            
271
/// Park a location fix delivered by a platform backend (in the dll).
272
/// Thread-safe; recovers from a poisoned lock so one panicking applier
273
/// can't wedge delivery forever.
274
11
pub fn push_location_fix(fix: LocationFix) {
275
11
    let mut q = PENDING_FIXES.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
276
11
    q.push(fix);
277
11
}
278

            
279
/// Drain every fix parked by [`push_location_fix`], in arrival order.
280
/// Called once per layout pass; the caller applies them through
281
/// [`GeolocationManager::set_latest_fix`] (the last one wins).
282
21
pub fn drain_location_fixes() -> Vec<LocationFix> {
283
21
    let mut q = PENDING_FIXES.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
284
21
    core::mem::take(&mut *q)
285
21
}
286

            
287
// Error channel (MWA-A1b) — same shape as the fix channel: the native
288
// backend's error callback fires on an OS thread and parks here; the
289
// capability pump drains into `set_last_error`.
290

            
291
static PENDING_ERRORS: std::sync::Mutex<Vec<LocationError>> =
292
    std::sync::Mutex::new(Vec::new());
293

            
294
/// Park a geolocation error delivered by a platform backend (in the dll).
295
/// Thread-safe; poison-recovering.
296
1
pub fn push_location_error(error: LocationError) {
297
1
    let mut q = PENDING_ERRORS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
298
1
    q.push(error);
299
1
}
300

            
301
/// Drain every error parked by [`push_location_error`], in arrival order.
302
3
pub fn drain_location_errors() -> Vec<LocationError> {
303
3
    let mut q = PENDING_ERRORS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
304
3
    core::mem::take(&mut *q)
305
3
}
306

            
307
#[cfg(test)]
308
mod tests {
309
    use super::*;
310

            
311
6
    fn cfg() -> GeolocationProbeConfig {
312
6
        GeolocationProbeConfig::default()
313
6
    }
314

            
315
1
    fn high_accuracy_cfg() -> GeolocationProbeConfig {
316
1
        GeolocationProbeConfig {
317
1
            high_accuracy: true,
318
1
            ..GeolocationProbeConfig::default()
319
1
        }
320
1
    }
321

            
322
10
    fn fix(lat: f64, lon: f64) -> LocationFix {
323
10
        LocationFix {
324
10
            latitude_deg: lat,
325
10
            longitude_deg: lon,
326
10
            accuracy_m: 10.0,
327
10
            altitude_m: f32::NAN,
328
10
            altitude_accuracy_m: f32::NAN,
329
10
            heading_deg: f32::NAN,
330
10
            speed_mps: f32::NAN,
331
10
            timestamp_ms: 0,
332
10
        }
333
10
    }
334

            
335
    #[test]
336
1
    fn first_probe_emits_subscribe_with_config() {
337
1
        let mut mgr = GeolocationManager::new();
338
1
        mgr.diff_layout(|emit| emit(cfg()));
339
1
        assert_eq!(mgr.refcount(), 1);
340
1
        let events = mgr.take_pending_events();
341
1
        assert_eq!(events.len(), 1);
342
1
        assert!(matches!(events[0], GeolocationDiffEvent::Subscribe { .. }));
343
1
    }
344

            
345
    #[test]
346
1
    fn last_probe_drop_emits_release_and_clears_fix() {
347
1
        let mut mgr = GeolocationManager::new();
348
1
        mgr.diff_layout(|emit| emit(cfg()));
349
1
        mgr.set_latest_fix(fix(37.0, -122.0));
350
1
        drop(mgr.take_pending_events());
351

            
352
1
        mgr.diff_layout(|_emit| {});
353
1
        assert_eq!(mgr.refcount(), 0);
354
1
        assert_eq!(mgr.latest_fix(), None);
355
1
        let events = mgr.take_pending_events();
356
1
        assert_eq!(events.len(), 1);
357
1
        assert!(matches!(events[0], GeolocationDiffEvent::Release));
358
1
    }
359

            
360
    #[test]
361
1
    fn config_drift_emits_reconfigure() {
362
1
        let mut mgr = GeolocationManager::new();
363
1
        mgr.diff_layout(|emit| emit(cfg()));
364
1
        drop(mgr.take_pending_events());
365

            
366
1
        mgr.diff_layout(|emit| emit(high_accuracy_cfg()));
367
1
        let events = mgr.take_pending_events();
368
1
        assert_eq!(events.len(), 1);
369
1
        let ev = &events[0];
370
1
        match ev {
371
1
            GeolocationDiffEvent::Reconfigure { config } => {
372
1
                assert!(config.high_accuracy);
373
            }
374
            _ => panic!("expected Reconfigure, got {ev:?}"),
375
        }
376
1
    }
377

            
378
    #[test]
379
1
    fn stable_config_does_not_re_emit() {
380
1
        let mut mgr = GeolocationManager::new();
381
1
        mgr.diff_layout(|emit| emit(cfg()));
382
1
        drop(mgr.take_pending_events());
383

            
384
        // Same config across frames — no events.
385
1
        mgr.diff_layout(|emit| emit(cfg()));
386
1
        assert!(mgr.take_pending_events().is_empty());
387
1
    }
388

            
389
    #[test]
390
1
    fn set_latest_fix_returns_change_flag() {
391
1
        let mut mgr = GeolocationManager::new();
392
1
        assert!(mgr.set_latest_fix(fix(37.0, -122.0)));
393
1
        assert!(!mgr.set_latest_fix(fix(37.0, -122.0)));
394
1
        assert!(mgr.set_latest_fix(fix(37.7749, -122.4194)));
395
1
    }
396

            
397
    #[test]
398
1
    fn missing_fields_decode_to_none() {
399
1
        let f = fix(0.0, 0.0);
400
1
        assert_eq!(f.altitude(), None);
401
1
        assert_eq!(f.heading(), None);
402
1
        assert_eq!(f.speed(), None);
403
1
    }
404

            
405
    #[test]
406
1
    fn provider_yields_fix_event_then_clears() {
407
        use azul_core::task::{Instant, SystemTick};
408

            
409
1
        let ts = Instant::Tick(SystemTick::new(0));
410
1
        let mut mgr = GeolocationManager::new();
411
1
        assert!(mgr.get_pending_events(ts.clone()).is_empty(), "no fix yet");
412

            
413
1
        mgr.set_latest_fix(fix(37.0, -122.0));
414
1
        let events = mgr.get_pending_events(ts.clone());
415
1
        assert_eq!(events.len(), 1);
416
1
        assert_eq!(events[0].event_type, EventType::GeolocationFix);
417

            
418
1
        mgr.clear_pending_event();
419
1
        assert!(mgr.get_pending_events(ts.clone()).is_empty(), "cleared after dispatch");
420

            
421
        // An identical fix is not a change — no re-fire.
422
1
        mgr.set_latest_fix(fix(37.0, -122.0));
423
1
        assert!(mgr.get_pending_events(ts).is_empty());
424
1
    }
425

            
426
    #[test]
427
1
    fn error_channel_and_provider_event() {
428
        use azul_core::task::{Instant, SystemTick};
429

            
430
1
        drop(drain_location_errors());
431
1
        push_location_error(LocationError { code: 1, message: "denied".into() });
432
1
        let errs = drain_location_errors();
433
1
        assert_eq!(errs.len(), 1);
434
1
        assert!(drain_location_errors().is_empty());
435

            
436
1
        let ts = Instant::Tick(SystemTick::new(0));
437
1
        let mut mgr = GeolocationManager::new();
438
1
        mgr.set_last_error(errs[0].clone());
439
1
        let events = mgr.get_pending_events(ts.clone());
440
1
        assert_eq!(events.len(), 1);
441
1
        assert_eq!(
442
1
            events[0].event_type,
443
            EventType::GeolocationError
444
        );
445
1
        mgr.clear_pending_event();
446
1
        assert!(mgr.get_pending_events(ts).is_empty());
447

            
448
        // a successful (changed) fix supersedes the stored error
449
1
        mgr.set_latest_fix(fix(1.0, 2.0));
450
1
        assert!(mgr.last_error.is_none());
451
1
    }
452

            
453
    #[test]
454
1
    fn subscription_flag_follows_probe_refcount() {
455
1
        let mut mgr = GeolocationManager::new();
456
1
        assert!(!mgr.has_active_subscription());
457
1
        mgr.diff_layout(|emit| emit(cfg()));
458
1
        assert!(mgr.has_active_subscription());
459
1
        mgr.diff_layout(|_emit| {});
460
1
        assert!(!mgr.has_active_subscription());
461
1
    }
462

            
463
    #[test]
464
    #[allow(clippy::float_cmp)] // test asserts exact float equality on deterministic values
465
1
    fn async_fixes_round_trip_through_manager() {
466
        // The channel is process-global; clear any residue first.
467
1
        drop(drain_location_fixes());
468

            
469
1
        push_location_fix(fix(37.0, -122.0));
470
1
        push_location_fix(fix(48.8566, 2.3522)); // Paris — last wins
471
1
        let drained = drain_location_fixes();
472
1
        assert_eq!(drained.len(), 2, "both parked fixes drain in order");
473
1
        assert_eq!(drained[0].latitude_deg, 37.0);
474
1
        assert_eq!(drained[1].latitude_deg, 48.8566);
475

            
476
        // Applying them reflects in latest_fix() — what the layout pass does.
477
1
        let mut mgr = GeolocationManager::new();
478
3
        for f in &drained {
479
2
            mgr.set_latest_fix(*f);
480
2
        }
481
1
        let got = mgr.latest_fix().expect("a fix was applied");
482
1
        assert_eq!(got.latitude_deg, 48.8566, "the last applied fix wins");
483

            
484
        // A second drain is empty — the queue was taken, not copied.
485
1
        assert!(drain_location_fixes().is_empty());
486
1
    }
487
}
488

            
489
#[cfg(test)]
490
#[allow(clippy::float_cmp, clippy::eq_op, clippy::redundant_clone)]
491
mod autotest_generated {
492
    use azul_core::task::{Instant, SystemTick};
493

            
494
    use super::*;
495

            
496
    // ─────────────────────────── helpers ────────────────────────────
497

            
498
    /// A fix where every optional field is *present* — knocking out a single
499
    /// field then flips exactly one comparison.
500
    const fn full_fix() -> LocationFix {
501
        LocationFix {
502
            latitude_deg: 48.208_8,
503
            longitude_deg: 16.372_1,
504
            accuracy_m: 5.0,
505
            altitude_m: 171.0,
506
            altitude_accuracy_m: 3.0,
507
            heading_deg: 90.0,
508
            speed_mps: 1.5,
509
            timestamp_ms: 1_234,
510
        }
511
    }
512

            
513
    /// "Platform reported nothing but lat/lon" — the NaN-heavy shape
514
    /// `set_latest_fix` documents as the reason it compares bit patterns.
515
    const fn nan_fix() -> LocationFix {
516
        LocationFix {
517
            latitude_deg: 37.0,
518
            longitude_deg: -122.0,
519
            accuracy_m: 10.0,
520
            altitude_m: f32::NAN,
521
            altitude_accuracy_m: f32::NAN,
522
            heading_deg: f32::NAN,
523
            speed_mps: f32::NAN,
524
            timestamp_ms: 0,
525
        }
526
    }
527

            
528
    const fn probe_cfg(high_accuracy: bool, max_accuracy_m: f32) -> GeolocationProbeConfig {
529
        GeolocationProbeConfig {
530
            high_accuracy,
531
            background: false,
532
            max_accuracy_m,
533
            min_interval_ms: 0,
534
        }
535
    }
536

            
537
    fn tick() -> Instant {
538
        Instant::Tick(SystemTick::new(0))
539
    }
540

            
541
    /// The 8 single-field mutations of [`full_fix`], one per field the
542
    /// bit-pattern comparison has to look at.
543
    fn single_field_mutations() -> Vec<(&'static str, LocationFix)> {
544
        let base = full_fix();
545
        let mut out = Vec::new();
546

            
547
        let mut f = base;
548
        f.latitude_deg = -48.208_8;
549
        out.push(("latitude_deg", f));
550

            
551
        let mut f = base;
552
        f.longitude_deg = 16.372_2;
553
        out.push(("longitude_deg", f));
554

            
555
        let mut f = base;
556
        f.accuracy_m = 5.000_001;
557
        out.push(("accuracy_m", f));
558

            
559
        let mut f = base;
560
        f.altitude_m = f32::NAN;
561
        out.push(("altitude_m", f));
562

            
563
        let mut f = base;
564
        f.altitude_accuracy_m = f32::NAN;
565
        out.push(("altitude_accuracy_m", f));
566

            
567
        let mut f = base;
568
        f.heading_deg = 270.0;
569
        out.push(("heading_deg", f));
570

            
571
        let mut f = base;
572
        f.speed_mps = -1.5;
573
        out.push(("speed_mps", f));
574

            
575
        let mut f = base;
576
        f.timestamp_ms = u64::MAX;
577
        out.push(("timestamp_ms", f));
578

            
579
        out
580
    }
581

            
582
    // ───────────────────── constructor / getters ────────────────────
583

            
584
    #[test]
585
    fn new_equals_default_and_holds_construction_invariants() {
586
        let mgr = GeolocationManager::new();
587
        assert_eq!(mgr, GeolocationManager::default());
588
        assert_eq!(mgr, GeolocationManager::new(), "construction is deterministic");
589

            
590
        assert_eq!(mgr.latest_fix(), None);
591
        assert_eq!(mgr.refcount(), 0);
592
        assert_eq!(mgr.active_config, None);
593
        assert_eq!(mgr.last_error, None);
594
        assert!(!mgr.has_active_subscription());
595
        assert!(mgr.get_pending_events(tick()).is_empty());
596
    }
597

            
598
    #[test]
599
    fn getters_on_a_fresh_manager_do_not_panic() {
600
        let mut mgr = GeolocationManager::new();
601
        // Repeated reads of an empty manager stay None / 0 and never panic.
602
        for _ in 0..3 {
603
            assert_eq!(mgr.latest_fix(), None);
604
            assert_eq!(mgr.refcount(), 0);
605
            assert!(!mgr.has_active_subscription());
606
            assert!(mgr.take_pending_events().is_empty());
607
            mgr.clear_pending_event();
608
        }
609
    }
610

            
611
    #[test]
612
    fn latest_fix_round_trips_extreme_bit_patterns() {
613
        let extreme = LocationFix {
614
            latitude_deg: f64::INFINITY,
615
            longitude_deg: f64::NEG_INFINITY,
616
            accuracy_m: f32::MAX,
617
            altitude_m: f32::MIN_POSITIVE,
618
            altitude_accuracy_m: f32::from_bits(1), // subnormal
619
            heading_deg: -0.0,
620
            speed_mps: f32::NEG_INFINITY,
621
            timestamp_ms: u64::MAX,
622
        };
623

            
624
        let mut mgr = GeolocationManager::new();
625
        assert!(mgr.set_latest_fix(extreme), "first fix is always a change");
626

            
627
        let got = mgr.latest_fix().expect("a fix was stored");
628
        // encode == decode, bit for bit (not float-eq: -0.0 and subnormals).
629
        assert_eq!(got.latitude_deg.to_bits(), extreme.latitude_deg.to_bits());
630
        assert_eq!(got.longitude_deg.to_bits(), extreme.longitude_deg.to_bits());
631
        assert_eq!(got.accuracy_m.to_bits(), extreme.accuracy_m.to_bits());
632
        assert_eq!(got.altitude_m.to_bits(), extreme.altitude_m.to_bits());
633
        assert_eq!(
634
            got.altitude_accuracy_m.to_bits(),
635
            extreme.altitude_accuracy_m.to_bits()
636
        );
637
        assert_eq!(got.heading_deg.to_bits(), extreme.heading_deg.to_bits());
638
        assert_eq!(got.speed_mps.to_bits(), extreme.speed_mps.to_bits());
639
        assert_eq!(got.timestamp_ms, u64::MAX);
640

            
641
        // Infinities are *not* NaN, so the getters report them as present.
642
        assert_eq!(got.speed(), Some(f32::NEG_INFINITY));
643
        assert!(mgr.latest_fix().is_some());
644
        // Re-applying the identical extreme fix is not a change.
645
        assert!(!mgr.set_latest_fix(extreme));
646
    }
647

            
648
    #[test]
649
    fn nan_fix_round_trips_and_getters_still_report_missing() {
650
        let mut mgr = GeolocationManager::new();
651
        assert!(mgr.set_latest_fix(nan_fix()));
652

            
653
        let got = mgr.latest_fix().expect("a fix was stored");
654
        assert_eq!(got.altitude(), None);
655
        assert_eq!(got.altitude_accuracy(), None);
656
        assert_eq!(got.heading(), None);
657
        assert_eq!(got.speed(), None);
658
        assert_eq!(got.latitude_deg.to_bits(), 37.0_f64.to_bits());
659
    }
660

            
661
    // ───────────────── location_fix_bitwise_eq (private) ─────────────
662

            
663
    #[test]
664
    fn bitwise_eq_is_reflexive_on_nan_unlike_partial_eq() {
665
        let f = nan_fix();
666
        // Derived PartialEq is *not* reflexive here — that is the whole
667
        // reason `set_latest_fix` compares bit patterns instead.
668
        assert!(f != f, "PartialEq on a NaN-carrying fix is not reflexive");
669
        assert!(GeolocationManager::location_fix_bitwise_eq(&f, &f));
670
        assert!(GeolocationManager::location_fix_bitwise_eq(&nan_fix(), &nan_fix()));
671
    }
672

            
673
    #[test]
674
    fn bitwise_eq_is_reflexive_and_symmetric_over_extremes() {
675
        let extremes = [
676
            full_fix(),
677
            nan_fix(),
678
            LocationFix {
679
                latitude_deg: f64::INFINITY,
680
                longitude_deg: f64::NEG_INFINITY,
681
                accuracy_m: f32::INFINITY,
682
                altitude_m: f32::MAX,
683
                altitude_accuracy_m: f32::MIN,
684
                heading_deg: f32::from_bits(1),
685
                speed_mps: -0.0,
686
                timestamp_ms: u64::MAX,
687
            },
688
            LocationFix {
689
                latitude_deg: 0.0,
690
                longitude_deg: 0.0,
691
                accuracy_m: 0.0,
692
                altitude_m: 0.0,
693
                altitude_accuracy_m: 0.0,
694
                heading_deg: 0.0,
695
                speed_mps: 0.0,
696
                timestamp_ms: 0,
697
            },
698
        ];
699

            
700
        for (i, a) in extremes.iter().enumerate() {
701
            assert!(
702
                GeolocationManager::location_fix_bitwise_eq(a, a),
703
                "not reflexive for extreme #{i}"
704
            );
705
            for (j, b) in extremes.iter().enumerate() {
706
                assert_eq!(
707
                    GeolocationManager::location_fix_bitwise_eq(a, b),
708
                    GeolocationManager::location_fix_bitwise_eq(b, a),
709
                    "not symmetric for ({i}, {j})"
710
                );
711
                if i != j {
712
                    assert!(
713
                        !GeolocationManager::location_fix_bitwise_eq(a, b),
714
                        "distinct extremes #{i}/#{j} compared equal"
715
                    );
716
                }
717
            }
718
        }
719
    }
720

            
721
    #[test]
722
    fn bitwise_eq_looks_at_every_field() {
723
        let base = full_fix();
724
        for (field, mutated) in single_field_mutations() {
725
            assert!(
726
                !GeolocationManager::location_fix_bitwise_eq(&base, &mutated),
727
                "`{field}` is ignored by location_fix_bitwise_eq"
728
            );
729
            assert!(
730
                !GeolocationManager::location_fix_bitwise_eq(&mutated, &base),
731
                "`{field}` comparison is asymmetric"
732
            );
733
            assert!(GeolocationManager::location_fix_bitwise_eq(&mutated, &mutated));
734
        }
735
    }
736

            
737
    #[test]
738
    fn bitwise_eq_separates_positive_and_negative_zero() {
739
        let mut a = full_fix();
740
        a.heading_deg = 0.0;
741
        let mut b = a;
742
        b.heading_deg = -0.0;
743

            
744
        // Float equality says these are the same heading; the bit patterns
745
        // do not. `set_latest_fix` therefore treats +0 → -0 as a change.
746
        assert!(a.heading_deg == b.heading_deg);
747
        assert!(!GeolocationManager::location_fix_bitwise_eq(&a, &b));
748
    }
749

            
750
    // ─────────────────────── set_latest_fix ─────────────────────────
751

            
752
    #[test]
753
    fn repeated_identical_nan_fix_reports_no_change() {
754
        let mut mgr = GeolocationManager::new();
755
        assert!(mgr.set_latest_fix(nan_fix()), "first fix advances");
756
        for _ in 0..100 {
757
            assert!(
758
                !mgr.set_latest_fix(nan_fix()),
759
                "an unchanged NaN-carrying fix must not look like movement"
760
            );
761
        }
762
        // The one real change is still pending until the event pass drains it.
763
        assert_eq!(mgr.get_pending_events(tick()).len(), 1);
764
    }
765

            
766
    #[test]
767
    fn set_latest_fix_detects_a_change_in_every_field() {
768
        for (field, mutated) in single_field_mutations() {
769
            let mut mgr = GeolocationManager::new();
770
            assert!(mgr.set_latest_fix(full_fix()));
771
            mgr.clear_pending_event();
772

            
773
            assert!(
774
                mgr.set_latest_fix(mutated),
775
                "a change in `{field}` was not reported"
776
            );
777
            assert_eq!(
778
                mgr.get_pending_events(tick()).len(),
779
                1,
780
                "a change in `{field}` raised no GeolocationFix event"
781
            );
782
        }
783
    }
784

            
785
    #[test]
786
    fn set_latest_fix_survives_extreme_and_pathological_values() {
787
        let mut mgr = GeolocationManager::new();
788

            
789
        // Out-of-range coordinates: the manager stores whatever the backend
790
        // hands it, it is not a validator — but it must not panic.
791
        let pathological = [
792
            (f64::MAX, f64::MIN),
793
            (f64::INFINITY, f64::NEG_INFINITY),
794
            (f64::NAN, f64::NAN),
795
            (1e308, -1e308),
796
            (f64::MIN_POSITIVE, -f64::MIN_POSITIVE),
797
            (91.0, 181.0),    // beyond the WGS-84 range
798
            (-91.0, -181.0),  // …and the other way
799
        ];
800

            
801
        for (lat, lon) in pathological {
802
            let f = LocationFix {
803
                latitude_deg: lat,
804
                longitude_deg: lon,
805
                accuracy_m: f32::INFINITY,
806
                altitude_m: f32::NAN,
807
                altitude_accuracy_m: f32::NAN,
808
                heading_deg: f32::NAN,
809
                speed_mps: f32::NAN,
810
                timestamp_ms: u64::MAX,
811
            };
812
            mgr.set_latest_fix(f);
813
            let got = mgr.latest_fix().expect("stored");
814
            assert_eq!(got.latitude_deg.to_bits(), lat.to_bits());
815
            assert_eq!(got.longitude_deg.to_bits(), lon.to_bits());
816
            // Idempotent re-apply: even an all-NaN coordinate pair compares
817
            // equal to itself under bit-pattern equality.
818
            assert!(!mgr.set_latest_fix(f), "re-applying the same fix is not a change");
819
        }
820
    }
821

            
822
    #[test]
823
    fn timestamp_only_movement_counts_as_a_change() {
824
        let mut mgr = GeolocationManager::new();
825
        let mut f = nan_fix();
826
        assert!(mgr.set_latest_fix(f));
827

            
828
        f.timestamp_ms = u64::MAX;
829
        assert!(
830
            mgr.set_latest_fix(f),
831
            "a fresh sample at the same coordinates is still a new fix"
832
        );
833
        assert_eq!(mgr.latest_fix().expect("stored").timestamp_ms, u64::MAX);
834
    }
835

            
836
    #[test]
837
    fn a_changed_fix_supersedes_the_stored_error_but_an_unchanged_one_does_not() {
838
        let mut mgr = GeolocationManager::new();
839
        assert!(mgr.set_latest_fix(full_fix()));
840

            
841
        mgr.set_last_error(LocationError {
842
            code: 2,
843
            message: String::from("timed out"),
844
        });
845
        assert!(mgr.last_error.is_some());
846

            
847
        // Re-delivering the *same* fix is not a change, so the error survives.
848
        assert!(!mgr.set_latest_fix(full_fix()));
849
        assert!(
850
            mgr.last_error.is_some(),
851
            "an unchanged fix does not clear the error (documented `changed`-gated behaviour)"
852
        );
853

            
854
        // A fix that actually advanced clears it.
855
        assert!(mgr.set_latest_fix(nan_fix()));
856
        assert_eq!(mgr.last_error, None);
857
    }
858

            
859
    // ─────────────────────── set_last_error ─────────────────────────
860

            
861
    #[test]
862
    fn set_last_error_round_trips_extreme_payloads() {
863
        let huge = "x".repeat(1 << 16);
864
        let unicode = String::from("\u{0}dénié 🛰️\u{0301}\u{fffd}中文\u{1f680}");
865
        let payloads = [
866
            LocationError { code: 0, message: String::new() },
867
            LocationError { code: u32::MAX, message: huge.clone() },
868
            LocationError { code: 1, message: unicode.clone() },
869
        ];
870

            
871
        for err in payloads {
872
            let mut mgr = GeolocationManager::new();
873
            mgr.set_last_error(err.clone());
874
            let got = mgr.last_error.clone().expect("error was stored");
875
            assert_eq!(got, err, "error payload did not round-trip");
876
            assert_eq!(got.message.len(), err.message.len());
877

            
878
            let events = mgr.get_pending_events(tick());
879
            assert_eq!(events.len(), 1);
880
            assert_eq!(events[0].event_type, EventType::GeolocationError);
881
        }
882

            
883
        // The huge/unicode strings survived untouched.
884
        assert_eq!(huge.len(), 1 << 16);
885
        assert!(unicode.contains('\u{0}'));
886
    }
887

            
888
    #[test]
889
    fn every_repeated_error_raises_its_own_event() {
890
        let mut mgr = GeolocationManager::new();
891
        let err = LocationError {
892
            code: 7,
893
            message: String::from("revoked"),
894
        };
895

            
896
        for _ in 0..5 {
897
            mgr.set_last_error(err.clone());
898
            let events = mgr.get_pending_events(tick());
899
            assert_eq!(
900
                events.len(),
901
                1,
902
                "a repeated error must still answer the live subscription"
903
            );
904
            assert_eq!(events[0].event_type, EventType::GeolocationError);
905
            mgr.clear_pending_event();
906
            assert!(mgr.get_pending_events(tick()).is_empty());
907
        }
908
        assert_eq!(mgr.last_error, Some(err));
909
    }
910

            
911
    // ───────────── clear_pending_event / take_pending_events ─────────
912

            
913
    #[test]
914
    fn clear_pending_event_is_idempotent_and_spares_the_diff_queue() {
915
        let mut mgr = GeolocationManager::new();
916
        mgr.diff_layout(|emit| emit(probe_cfg(false, 0.0)));
917
        mgr.set_latest_fix(full_fix());
918
        mgr.set_last_error(LocationError {
919
            code: 1,
920
            message: String::from("e"),
921
        });
922

            
923
        for _ in 0..3 {
924
            mgr.clear_pending_event();
925
            assert!(
926
                mgr.get_pending_events(tick()).is_empty(),
927
                "clearing twice must stay cleared"
928
            );
929
        }
930

            
931
        // Clearing the *event* flags does not eat the queued *diff* events —
932
        // they are two independent queues with two independent drains.
933
        let diffs = mgr.take_pending_events();
934
        assert_eq!(diffs.len(), 1);
935
        assert!(matches!(diffs[0], GeolocationDiffEvent::Subscribe { .. }));
936
        // …nor the stored values themselves.
937
        assert!(mgr.latest_fix().is_some());
938
        assert!(mgr.last_error.is_some());
939
    }
940

            
941
    #[test]
942
    fn take_pending_events_drains_in_arrival_order_and_leaves_the_queue_empty() {
943
        let mut mgr = GeolocationManager::new();
944
        mgr.diff_layout(|emit| emit(probe_cfg(false, 0.0))); // Subscribe
945
        mgr.diff_layout(|emit| emit(probe_cfg(true, 0.0))); // Reconfigure
946
        mgr.diff_layout(|_emit| {}); // Release
947

            
948
        let events = mgr.take_pending_events();
949
        assert_eq!(events.len(), 3);
950
        assert!(matches!(events[0], GeolocationDiffEvent::Subscribe { .. }));
951
        assert!(matches!(events[1], GeolocationDiffEvent::Reconfigure { .. }));
952
        assert_eq!(events[2], GeolocationDiffEvent::Release);
953

            
954
        assert!(mgr.take_pending_events().is_empty(), "taken, not copied");
955
        assert!(mgr.take_pending_events().is_empty(), "still empty");
956
    }
957

            
958
    #[test]
959
    fn get_pending_events_does_not_consume_the_flags() {
960
        let mut mgr = GeolocationManager::new();
961
        mgr.set_latest_fix(full_fix());
962
        mgr.set_last_error(LocationError {
963
            code: 3,
964
            message: String::from("boom"),
965
        });
966

            
967
        // Both flags are up: fix first, then error.
968
        for _ in 0..3 {
969
            let events = mgr.get_pending_events(tick());
970
            assert_eq!(events.len(), 2, "the getter is a read, not a drain");
971
            assert_eq!(events[0].event_type, EventType::GeolocationFix);
972
            assert_eq!(events[1].event_type, EventType::GeolocationError);
973
            assert_eq!(events[0].target, DomNodeId::ROOT);
974
            assert_eq!(events[1].target, DomNodeId::ROOT);
975
        }
976

            
977
        mgr.clear_pending_event();
978
        assert!(mgr.get_pending_events(tick()).is_empty());
979
    }
980

            
981
    // ──────────────────────── diff_layout ───────────────────────────
982

            
983
    #[test]
984
    fn no_probes_on_a_fresh_manager_emits_nothing() {
985
        let mut mgr = GeolocationManager::new();
986
        for _ in 0..4 {
987
            mgr.diff_layout(|_emit| {});
988
            assert_eq!(mgr.refcount(), 0);
989
            assert!(!mgr.has_active_subscription());
990
            assert_eq!(mgr.active_config, None);
991
            assert!(
992
                mgr.take_pending_events().is_empty(),
993
                "0 → 0 must not emit a spurious Release"
994
            );
995
        }
996
    }
997

            
998
    #[test]
999
    fn refcount_tracks_the_probe_count_and_gates_the_subscription_flag() {
        let mut mgr = GeolocationManager::new();
        mgr.diff_layout(|emit| {
            for _ in 0..10_000 {
                emit(probe_cfg(false, 0.0));
            }
        });
        assert_eq!(mgr.refcount(), 10_000);
        assert!(mgr.has_active_subscription());
        assert_eq!(mgr.take_pending_events().len(), 1, "one Subscribe, not 10k");
        // Probe count churn with an unchanged config emits nothing at all.
        mgr.diff_layout(|emit| {
            for _ in 0..3 {
                emit(probe_cfg(false, 0.0));
            }
        });
        assert_eq!(mgr.refcount(), 3);
        assert!(mgr.has_active_subscription());
        assert!(mgr.take_pending_events().is_empty());
        // The boundary: exactly one probe still counts as subscribed.
        mgr.diff_layout(|emit| emit(probe_cfg(false, 0.0)));
        assert_eq!(mgr.refcount(), 1);
        assert!(mgr.has_active_subscription());
        mgr.diff_layout(|_emit| {});
        assert_eq!(mgr.refcount(), 0);
        assert!(!mgr.has_active_subscription(), "0 probes ⇒ no subscription");
        assert_eq!(mgr.take_pending_events(), vec![GeolocationDiffEvent::Release]);
    }
    #[test]
    fn the_first_probes_config_wins_the_subscribe() {
        let mut mgr = GeolocationManager::new();
        mgr.diff_layout(|emit| {
            emit(probe_cfg(true, 25.0));
            emit(probe_cfg(false, 999.0)); // disagreeing second probe
            emit(probe_cfg(false, 0.0));
        });
        assert_eq!(mgr.refcount(), 3);
        let events = mgr.take_pending_events();
        assert_eq!(events.len(), 1);
        match events[0] {
            GeolocationDiffEvent::Subscribe { config } => {
                assert!(config.high_accuracy);
                assert_eq!(config.max_accuracy_m, 25.0);
            }
            ref other => panic!("expected Subscribe, got {other:?}"),
        }
        assert_eq!(mgr.active_config, Some(probe_cfg(true, 25.0)));
        // A frame whose *first* probe keeps the active config emits nothing,
        // even when a later probe disagrees.
        mgr.diff_layout(|emit| {
            emit(probe_cfg(true, 25.0));
            emit(probe_cfg(false, 0.0));
        });
        assert!(mgr.take_pending_events().is_empty());
    }
    #[test]
    fn release_clears_the_fix_and_a_resubscribe_starts_from_scratch() {
        let mut mgr = GeolocationManager::new();
        mgr.diff_layout(|emit| emit(probe_cfg(false, 0.0)));
        assert!(mgr.set_latest_fix(full_fix()));
        assert!(mgr.take_pending_events().len() == 1);
        mgr.diff_layout(|_emit| {}); // Release
        assert_eq!(mgr.latest_fix(), None, "Release drops the stale fix");
        assert_eq!(mgr.active_config, None);
        assert_eq!(mgr.take_pending_events(), vec![GeolocationDiffEvent::Release]);
        // Re-mounting emits a fresh Subscribe (not a Reconfigure) and the fix
        // stays None until the backend delivers a new sample.
        mgr.diff_layout(|emit| emit(probe_cfg(true, 5.0)));
        assert_eq!(mgr.refcount(), 1);
        assert_eq!(mgr.latest_fix(), None);
        let events = mgr.take_pending_events();
        assert_eq!(events.len(), 1);
        assert_eq!(
            events[0],
            GeolocationDiffEvent::Subscribe {
                config: probe_cfg(true, 5.0)
            }
        );
    }
    #[test]
    fn release_leaves_the_fix_event_flag_raised_with_no_fix_behind_it() {
        // Characterisation of the current behaviour: `diff_layout` clears
        // `latest_fix` on Release but not `pending_event`, so the event pass
        // still dispatches a GeolocationFix whose payload is already gone.
        // Callbacks must therefore tolerate `get_geolocation_fix() == None`
        // inside a GeolocationFix handler.
        let mut mgr = GeolocationManager::new();
        mgr.diff_layout(|emit| emit(probe_cfg(false, 0.0)));
        assert!(mgr.set_latest_fix(full_fix()));
        mgr.diff_layout(|_emit| {}); // Release
        assert_eq!(mgr.latest_fix(), None);
        let events = mgr.get_pending_events(tick());
        assert_eq!(events.len(), 1);
        assert_eq!(events[0].event_type, EventType::GeolocationFix);
        mgr.clear_pending_event();
        assert!(mgr.get_pending_events(tick()).is_empty());
    }
    #[test]
    fn a_nan_probe_config_settles_like_any_other() {
        // FIXED (was "re_emits_reconfigure_on_every_frame"): the Reconfigure check uses
        // `PartialEq` on `GeolocationProbeConfig`, whose `max_accuracy_m` is an f32.
        // That PartialEq now compares by BIT PATTERN (see core/src/geolocation.rs), so a
        // NaN config equals itself and an *unchanged* config no longer looks drifted —
        // it settles after one Reconfigure instead of re-queuing every frame.
        let nan_cfg = probe_cfg(false, f32::NAN);
        let mut mgr = GeolocationManager::new();
        mgr.diff_layout(|emit| emit(nan_cfg));
        assert_eq!(mgr.take_pending_events().len(), 1, "the initial Subscribe");
        for _ in 0..3 {
            mgr.diff_layout(|emit| emit(nan_cfg));
            assert!(
                mgr.take_pending_events().is_empty(),
                "an unchanged NaN config must NOT re-queue a Reconfigure"
            );
        }
        // A sane config also settles immediately.
        mgr.diff_layout(|emit| emit(probe_cfg(false, 0.0)));
        assert_eq!(mgr.take_pending_events().len(), 1, "one Reconfigure to sane");
        mgr.diff_layout(|emit| emit(probe_cfg(false, 0.0)));
        assert!(mgr.take_pending_events().is_empty(), "then quiet");
    }
    #[test]
    fn extreme_probe_configs_survive_the_diff() {
        let extremes = [
            probe_cfg(true, f32::MAX),
            probe_cfg(false, f32::INFINITY),
            probe_cfg(true, -0.0),
            GeolocationProbeConfig {
                high_accuracy: true,
                background: true,
                max_accuracy_m: f32::MIN_POSITIVE,
                min_interval_ms: u32::MAX,
            },
        ];
        let mut mgr = GeolocationManager::new();
        for cfg in extremes {
            mgr.diff_layout(|emit| emit(cfg));
            assert_eq!(mgr.refcount(), 1);
            assert_eq!(mgr.active_config, Some(cfg));
            let events = mgr.take_pending_events();
            assert_eq!(events.len(), 1, "each distinct config emits exactly one event");
            match events[0] {
                GeolocationDiffEvent::Subscribe { config }
                | GeolocationDiffEvent::Reconfigure { config } => {
                    assert_eq!(config.max_accuracy_m.to_bits(), cfg.max_accuracy_m.to_bits());
                    assert_eq!(config.min_interval_ms, cfg.min_interval_ms);
                }
                GeolocationDiffEvent::Release => panic!("probes are mounted, not released"),
            }
        }
    }
    // ─────────────────────── equality invariants ────────────────────
    #[test]
    fn manager_equality_is_bit_blind_for_nan_fixes() {
        // The derived `PartialEq` on the manager inherits float semantics:
        // a manager holding a NaN-carrying fix is not even equal to its own
        // clone. Callers must not use `==` on the manager to detect movement
        // — that is what `set_latest_fix`'s return value is for.
        let mut with_nan = GeolocationManager::new();
        with_nan.set_latest_fix(nan_fix());
        assert_ne!(with_nan, with_nan.clone());
        let mut without_nan = GeolocationManager::new();
        without_nan.set_latest_fix(full_fix());
        assert_eq!(without_nan, without_nan.clone());
    }
}