Skip to main content

azul_layout/managers/
geolocation.rs

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
23use alloc::collections::btree_map::BTreeMap;
24use alloc::vec::Vec;
25
26use azul_core::dom::DomNodeId;
27use azul_core::events::{
28    EventData, EventProvider, EventSource as CoreEventSource, EventType, SyntheticEvent,
29};
30use 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.
37pub 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)]
44pub 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)]
59pub 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)]
91pub 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
98impl GeolocationManager {
99    #[must_use] pub fn new() -> Self {
100        Self::default()
101    }
102
103    #[must_use] pub const fn latest_fix(&self) -> Option<LocationFix> {
104        self.latest_fix
105    }
106
107    #[must_use] pub const fn refcount(&self) -> u32 {
108        self.refcount
109    }
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    pub fn set_latest_fix(&mut self, fix: LocationFix) -> bool {
120        let changed = self
121            .latest_fix
122            .is_none_or(|prev| !Self::location_fix_bitwise_eq(&prev, &fix));
123        self.latest_fix = Some(fix);
124        if changed {
125            self.pending_event = true;
126            // A successful fix supersedes any earlier error.
127            self.last_error = None;
128        }
129        changed
130    }
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    pub fn set_last_error(&mut self, error: LocationError) {
136        self.last_error = Some(error);
137        self.pending_error_event = true;
138    }
139
140    /// Clear the pending-event flags (fix + error). The dll calls this
141    /// after the event pass has collected the geolocation events.
142    pub const fn clear_pending_event(&mut self) {
143        self.pending_event = false;
144        self.pending_error_event = false;
145    }
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    #[must_use] pub const fn has_active_subscription(&self) -> bool {
152        self.refcount > 0
153    }
154
155    const fn location_fix_bitwise_eq(a: &LocationFix, b: &LocationFix) -> bool {
156        a.latitude_deg.to_bits() == b.latitude_deg.to_bits()
157            && a.longitude_deg.to_bits() == b.longitude_deg.to_bits()
158            && a.accuracy_m.to_bits() == b.accuracy_m.to_bits()
159            && a.altitude_m.to_bits() == b.altitude_m.to_bits()
160            && a.altitude_accuracy_m.to_bits() == b.altitude_accuracy_m.to_bits()
161            && a.heading_deg.to_bits() == b.heading_deg.to_bits()
162            && a.speed_mps.to_bits() == b.speed_mps.to_bits()
163            && a.timestamp_ms == b.timestamp_ms
164    }
165
166    /// Drain queued diff events. Platform backend calls this once per
167    /// frame.
168    pub fn take_pending_events(&mut self) -> Vec<GeolocationDiffEvent> {
169        core::mem::take(&mut self.pending_events)
170    }
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    pub fn diff_layout<F>(&mut self, mut for_each_probe: F)
178    where
179        F: FnMut(&mut dyn FnMut(GeolocationProbeConfig)),
180    {
181        let mut new_count: u32 = 0;
182        let mut next_config: Option<GeolocationProbeConfig> = None;
183        for_each_probe(&mut |cfg| {
184            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            if next_config.is_none() {
190                next_config = Some(cfg);
191            }
192        });
193
194        let old_count = self.refcount;
195        self.refcount = new_count;
196
197        match (old_count, new_count) {
198            (0, n) if n > 0 => {
199                let config = next_config.unwrap_or_default();
200                self.active_config = Some(config);
201                self.pending_events
202                    .push(GeolocationDiffEvent::Subscribe { config });
203            }
204            (m, 0) if m > 0 => {
205                self.active_config = None;
206                self.latest_fix = None;
207                self.pending_events.push(GeolocationDiffEvent::Release);
208            }
209            (m, n) if m > 0 && n > 0 => {
210                // Both frames have probes. Emit Reconfigure if the
211                // config actually drifted.
212                let new_config = next_config.unwrap_or_default();
213                if Some(new_config) != self.active_config {
214                    self.active_config = Some(new_config);
215                    self.pending_events
216                        .push(GeolocationDiffEvent::Reconfigure { config: new_config });
217                }
218            }
219            _ => {
220                // 0 → 0 — nothing to do.
221            }
222        }
223    }
224}
225
226impl 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    fn get_pending_events(&self, timestamp: Instant) -> Vec<SyntheticEvent> {
233        let mut events = Vec::new();
234        if self.pending_event {
235            events.push(SyntheticEvent::new(
236                EventType::GeolocationFix,
237                CoreEventSource::User,
238                DomNodeId::ROOT,
239                timestamp.clone(),
240                EventData::None,
241            ));
242        }
243        if self.pending_error_event {
244            events.push(SyntheticEvent::new(
245                EventType::GeolocationError,
246                CoreEventSource::User,
247                DomNodeId::ROOT,
248                timestamp,
249                EventData::None,
250            ));
251        }
252        events
253    }
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
268static 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.
274pub fn push_location_fix(fix: LocationFix) {
275    let mut q = PENDING_FIXES.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
276    q.push(fix);
277}
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).
282pub fn drain_location_fixes() -> Vec<LocationFix> {
283    let mut q = PENDING_FIXES.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
284    core::mem::take(&mut *q)
285}
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
291static 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.
296pub fn push_location_error(error: LocationError) {
297    let mut q = PENDING_ERRORS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
298    q.push(error);
299}
300
301/// Drain every error parked by [`push_location_error`], in arrival order.
302pub fn drain_location_errors() -> Vec<LocationError> {
303    let mut q = PENDING_ERRORS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
304    core::mem::take(&mut *q)
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    fn cfg() -> GeolocationProbeConfig {
312        GeolocationProbeConfig::default()
313    }
314
315    fn high_accuracy_cfg() -> GeolocationProbeConfig {
316        GeolocationProbeConfig {
317            high_accuracy: true,
318            ..GeolocationProbeConfig::default()
319        }
320    }
321
322    fn fix(lat: f64, lon: f64) -> LocationFix {
323        LocationFix {
324            latitude_deg: lat,
325            longitude_deg: lon,
326            accuracy_m: 10.0,
327            altitude_m: f32::NAN,
328            altitude_accuracy_m: f32::NAN,
329            heading_deg: f32::NAN,
330            speed_mps: f32::NAN,
331            timestamp_ms: 0,
332        }
333    }
334
335    #[test]
336    fn first_probe_emits_subscribe_with_config() {
337        let mut mgr = GeolocationManager::new();
338        mgr.diff_layout(|emit| emit(cfg()));
339        assert_eq!(mgr.refcount(), 1);
340        let events = mgr.take_pending_events();
341        assert_eq!(events.len(), 1);
342        assert!(matches!(events[0], GeolocationDiffEvent::Subscribe { .. }));
343    }
344
345    #[test]
346    fn last_probe_drop_emits_release_and_clears_fix() {
347        let mut mgr = GeolocationManager::new();
348        mgr.diff_layout(|emit| emit(cfg()));
349        mgr.set_latest_fix(fix(37.0, -122.0));
350        drop(mgr.take_pending_events());
351
352        mgr.diff_layout(|_emit| {});
353        assert_eq!(mgr.refcount(), 0);
354        assert_eq!(mgr.latest_fix(), None);
355        let events = mgr.take_pending_events();
356        assert_eq!(events.len(), 1);
357        assert!(matches!(events[0], GeolocationDiffEvent::Release));
358    }
359
360    #[test]
361    fn config_drift_emits_reconfigure() {
362        let mut mgr = GeolocationManager::new();
363        mgr.diff_layout(|emit| emit(cfg()));
364        drop(mgr.take_pending_events());
365
366        mgr.diff_layout(|emit| emit(high_accuracy_cfg()));
367        let events = mgr.take_pending_events();
368        assert_eq!(events.len(), 1);
369        let ev = &events[0];
370        match ev {
371            GeolocationDiffEvent::Reconfigure { config } => {
372                assert!(config.high_accuracy);
373            }
374            _ => panic!("expected Reconfigure, got {ev:?}"),
375        }
376    }
377
378    #[test]
379    fn stable_config_does_not_re_emit() {
380        let mut mgr = GeolocationManager::new();
381        mgr.diff_layout(|emit| emit(cfg()));
382        drop(mgr.take_pending_events());
383
384        // Same config across frames — no events.
385        mgr.diff_layout(|emit| emit(cfg()));
386        assert!(mgr.take_pending_events().is_empty());
387    }
388
389    #[test]
390    fn set_latest_fix_returns_change_flag() {
391        let mut mgr = GeolocationManager::new();
392        assert!(mgr.set_latest_fix(fix(37.0, -122.0)));
393        assert!(!mgr.set_latest_fix(fix(37.0, -122.0)));
394        assert!(mgr.set_latest_fix(fix(37.7749, -122.4194)));
395    }
396
397    #[test]
398    fn missing_fields_decode_to_none() {
399        let f = fix(0.0, 0.0);
400        assert_eq!(f.altitude(), None);
401        assert_eq!(f.heading(), None);
402        assert_eq!(f.speed(), None);
403    }
404
405    #[test]
406    fn provider_yields_fix_event_then_clears() {
407        use azul_core::task::{Instant, SystemTick};
408
409        let ts = Instant::Tick(SystemTick::new(0));
410        let mut mgr = GeolocationManager::new();
411        assert!(mgr.get_pending_events(ts.clone()).is_empty(), "no fix yet");
412
413        mgr.set_latest_fix(fix(37.0, -122.0));
414        let events = mgr.get_pending_events(ts.clone());
415        assert_eq!(events.len(), 1);
416        assert_eq!(events[0].event_type, EventType::GeolocationFix);
417
418        mgr.clear_pending_event();
419        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        mgr.set_latest_fix(fix(37.0, -122.0));
423        assert!(mgr.get_pending_events(ts).is_empty());
424    }
425
426    #[test]
427    fn error_channel_and_provider_event() {
428        use azul_core::task::{Instant, SystemTick};
429
430        drop(drain_location_errors());
431        push_location_error(LocationError { code: 1, message: "denied".into() });
432        let errs = drain_location_errors();
433        assert_eq!(errs.len(), 1);
434        assert!(drain_location_errors().is_empty());
435
436        let ts = Instant::Tick(SystemTick::new(0));
437        let mut mgr = GeolocationManager::new();
438        mgr.set_last_error(errs[0].clone());
439        let events = mgr.get_pending_events(ts.clone());
440        assert_eq!(events.len(), 1);
441        assert_eq!(
442            events[0].event_type,
443            EventType::GeolocationError
444        );
445        mgr.clear_pending_event();
446        assert!(mgr.get_pending_events(ts).is_empty());
447
448        // a successful (changed) fix supersedes the stored error
449        mgr.set_latest_fix(fix(1.0, 2.0));
450        assert!(mgr.last_error.is_none());
451    }
452
453    #[test]
454    fn subscription_flag_follows_probe_refcount() {
455        let mut mgr = GeolocationManager::new();
456        assert!(!mgr.has_active_subscription());
457        mgr.diff_layout(|emit| emit(cfg()));
458        assert!(mgr.has_active_subscription());
459        mgr.diff_layout(|_emit| {});
460        assert!(!mgr.has_active_subscription());
461    }
462
463    #[test]
464    #[allow(clippy::float_cmp)] // test asserts exact float equality on deterministic values
465    fn async_fixes_round_trip_through_manager() {
466        // The channel is process-global; clear any residue first.
467        drop(drain_location_fixes());
468
469        push_location_fix(fix(37.0, -122.0));
470        push_location_fix(fix(48.8566, 2.3522)); // Paris — last wins
471        let drained = drain_location_fixes();
472        assert_eq!(drained.len(), 2, "both parked fixes drain in order");
473        assert_eq!(drained[0].latitude_deg, 37.0);
474        assert_eq!(drained[1].latitude_deg, 48.8566);
475
476        // Applying them reflects in latest_fix() — what the layout pass does.
477        let mut mgr = GeolocationManager::new();
478        for f in &drained {
479            mgr.set_latest_fix(*f);
480        }
481        let got = mgr.latest_fix().expect("a fix was applied");
482        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        assert!(drain_location_fixes().is_empty());
486    }
487}
488
489#[cfg(test)]
490#[allow(clippy::float_cmp, clippy::eq_op, clippy::redundant_clone)]
491mod 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() {
1000        let mut mgr = GeolocationManager::new();
1001
1002        mgr.diff_layout(|emit| {
1003            for _ in 0..10_000 {
1004                emit(probe_cfg(false, 0.0));
1005            }
1006        });
1007        assert_eq!(mgr.refcount(), 10_000);
1008        assert!(mgr.has_active_subscription());
1009        assert_eq!(mgr.take_pending_events().len(), 1, "one Subscribe, not 10k");
1010
1011        // Probe count churn with an unchanged config emits nothing at all.
1012        mgr.diff_layout(|emit| {
1013            for _ in 0..3 {
1014                emit(probe_cfg(false, 0.0));
1015            }
1016        });
1017        assert_eq!(mgr.refcount(), 3);
1018        assert!(mgr.has_active_subscription());
1019        assert!(mgr.take_pending_events().is_empty());
1020
1021        // The boundary: exactly one probe still counts as subscribed.
1022        mgr.diff_layout(|emit| emit(probe_cfg(false, 0.0)));
1023        assert_eq!(mgr.refcount(), 1);
1024        assert!(mgr.has_active_subscription());
1025
1026        mgr.diff_layout(|_emit| {});
1027        assert_eq!(mgr.refcount(), 0);
1028        assert!(!mgr.has_active_subscription(), "0 probes ⇒ no subscription");
1029        assert_eq!(mgr.take_pending_events(), vec![GeolocationDiffEvent::Release]);
1030    }
1031
1032    #[test]
1033    fn the_first_probes_config_wins_the_subscribe() {
1034        let mut mgr = GeolocationManager::new();
1035        mgr.diff_layout(|emit| {
1036            emit(probe_cfg(true, 25.0));
1037            emit(probe_cfg(false, 999.0)); // disagreeing second probe
1038            emit(probe_cfg(false, 0.0));
1039        });
1040        assert_eq!(mgr.refcount(), 3);
1041
1042        let events = mgr.take_pending_events();
1043        assert_eq!(events.len(), 1);
1044        match events[0] {
1045            GeolocationDiffEvent::Subscribe { config } => {
1046                assert!(config.high_accuracy);
1047                assert_eq!(config.max_accuracy_m, 25.0);
1048            }
1049            ref other => panic!("expected Subscribe, got {other:?}"),
1050        }
1051        assert_eq!(mgr.active_config, Some(probe_cfg(true, 25.0)));
1052
1053        // A frame whose *first* probe keeps the active config emits nothing,
1054        // even when a later probe disagrees.
1055        mgr.diff_layout(|emit| {
1056            emit(probe_cfg(true, 25.0));
1057            emit(probe_cfg(false, 0.0));
1058        });
1059        assert!(mgr.take_pending_events().is_empty());
1060    }
1061
1062    #[test]
1063    fn release_clears_the_fix_and_a_resubscribe_starts_from_scratch() {
1064        let mut mgr = GeolocationManager::new();
1065        mgr.diff_layout(|emit| emit(probe_cfg(false, 0.0)));
1066        assert!(mgr.set_latest_fix(full_fix()));
1067        assert!(mgr.take_pending_events().len() == 1);
1068
1069        mgr.diff_layout(|_emit| {}); // Release
1070        assert_eq!(mgr.latest_fix(), None, "Release drops the stale fix");
1071        assert_eq!(mgr.active_config, None);
1072        assert_eq!(mgr.take_pending_events(), vec![GeolocationDiffEvent::Release]);
1073
1074        // Re-mounting emits a fresh Subscribe (not a Reconfigure) and the fix
1075        // stays None until the backend delivers a new sample.
1076        mgr.diff_layout(|emit| emit(probe_cfg(true, 5.0)));
1077        assert_eq!(mgr.refcount(), 1);
1078        assert_eq!(mgr.latest_fix(), None);
1079        let events = mgr.take_pending_events();
1080        assert_eq!(events.len(), 1);
1081        assert_eq!(
1082            events[0],
1083            GeolocationDiffEvent::Subscribe {
1084                config: probe_cfg(true, 5.0)
1085            }
1086        );
1087    }
1088
1089    #[test]
1090    fn release_leaves_the_fix_event_flag_raised_with_no_fix_behind_it() {
1091        // Characterisation of the current behaviour: `diff_layout` clears
1092        // `latest_fix` on Release but not `pending_event`, so the event pass
1093        // still dispatches a GeolocationFix whose payload is already gone.
1094        // Callbacks must therefore tolerate `get_geolocation_fix() == None`
1095        // inside a GeolocationFix handler.
1096        let mut mgr = GeolocationManager::new();
1097        mgr.diff_layout(|emit| emit(probe_cfg(false, 0.0)));
1098        assert!(mgr.set_latest_fix(full_fix()));
1099
1100        mgr.diff_layout(|_emit| {}); // Release
1101        assert_eq!(mgr.latest_fix(), None);
1102
1103        let events = mgr.get_pending_events(tick());
1104        assert_eq!(events.len(), 1);
1105        assert_eq!(events[0].event_type, EventType::GeolocationFix);
1106
1107        mgr.clear_pending_event();
1108        assert!(mgr.get_pending_events(tick()).is_empty());
1109    }
1110
1111    #[test]
1112    fn a_nan_probe_config_settles_like_any_other() {
1113        // FIXED (was "re_emits_reconfigure_on_every_frame"): the Reconfigure check uses
1114        // `PartialEq` on `GeolocationProbeConfig`, whose `max_accuracy_m` is an f32.
1115        // That PartialEq now compares by BIT PATTERN (see core/src/geolocation.rs), so a
1116        // NaN config equals itself and an *unchanged* config no longer looks drifted —
1117        // it settles after one Reconfigure instead of re-queuing every frame.
1118        let nan_cfg = probe_cfg(false, f32::NAN);
1119
1120        let mut mgr = GeolocationManager::new();
1121        mgr.diff_layout(|emit| emit(nan_cfg));
1122        assert_eq!(mgr.take_pending_events().len(), 1, "the initial Subscribe");
1123
1124        for _ in 0..3 {
1125            mgr.diff_layout(|emit| emit(nan_cfg));
1126            assert!(
1127                mgr.take_pending_events().is_empty(),
1128                "an unchanged NaN config must NOT re-queue a Reconfigure"
1129            );
1130        }
1131
1132        // A sane config also settles immediately.
1133        mgr.diff_layout(|emit| emit(probe_cfg(false, 0.0)));
1134        assert_eq!(mgr.take_pending_events().len(), 1, "one Reconfigure to sane");
1135        mgr.diff_layout(|emit| emit(probe_cfg(false, 0.0)));
1136        assert!(mgr.take_pending_events().is_empty(), "then quiet");
1137    }
1138
1139    #[test]
1140    fn extreme_probe_configs_survive_the_diff() {
1141        let extremes = [
1142            probe_cfg(true, f32::MAX),
1143            probe_cfg(false, f32::INFINITY),
1144            probe_cfg(true, -0.0),
1145            GeolocationProbeConfig {
1146                high_accuracy: true,
1147                background: true,
1148                max_accuracy_m: f32::MIN_POSITIVE,
1149                min_interval_ms: u32::MAX,
1150            },
1151        ];
1152
1153        let mut mgr = GeolocationManager::new();
1154        for cfg in extremes {
1155            mgr.diff_layout(|emit| emit(cfg));
1156            assert_eq!(mgr.refcount(), 1);
1157            assert_eq!(mgr.active_config, Some(cfg));
1158            let events = mgr.take_pending_events();
1159            assert_eq!(events.len(), 1, "each distinct config emits exactly one event");
1160            match events[0] {
1161                GeolocationDiffEvent::Subscribe { config }
1162                | GeolocationDiffEvent::Reconfigure { config } => {
1163                    assert_eq!(config.max_accuracy_m.to_bits(), cfg.max_accuracy_m.to_bits());
1164                    assert_eq!(config.min_interval_ms, cfg.min_interval_ms);
1165                }
1166                GeolocationDiffEvent::Release => panic!("probes are mounted, not released"),
1167            }
1168        }
1169    }
1170
1171    // ─────────────────────── equality invariants ────────────────────
1172
1173    #[test]
1174    fn manager_equality_is_bit_blind_for_nan_fixes() {
1175        // The derived `PartialEq` on the manager inherits float semantics:
1176        // a manager holding a NaN-carrying fix is not even equal to its own
1177        // clone. Callers must not use `==` on the manager to detect movement
1178        // — that is what `set_latest_fix`'s return value is for.
1179        let mut with_nan = GeolocationManager::new();
1180        with_nan.set_latest_fix(nan_fix());
1181        assert_ne!(with_nan, with_nan.clone());
1182
1183        let mut without_nan = GeolocationManager::new();
1184        without_nan.set_latest_fix(full_fix());
1185        assert_eq!(without_nan, without_nan.clone());
1186    }
1187}