umsh_mobile_core/
regions.rs

1//! Geographic region suggestions from a `.regiondb` database.
2//!
3//! The database proposes; it never writes to a radio. Everything here
4//! produces values for the existing repeater-settings surfaces —
5//! `regions` strings and a 2-octet `default_region` — and the editor's
6//! own apply path remains the only thing that transmits.
7//!
8//! Region-list policy lives on this side of the boundary deliberately.
9//! Whether a configured region "already matches" a suggestion is a
10//! question about derived wire codes (`SJC`, `sjc`, and `0x7853` are one
11//! region to a radio), and how much a position's uncertainty widens a
12//! suggestion is geographic policy. Both must answer identically for
13//! every caller, so neither is left to platform code.
14
15use std::sync::{Arc, Mutex};
16
17use umsh_core::RegionCode;
18use umsh_node::location::NodeLocation;
19use umsh_regiondb::{Membership, RegionDb, RegionDbError, RegionLookup, RegionMatch, sampling};
20
21/// Widest positional uncertainty a proposal accepts, in meters.
22///
23/// Against 2 km expansion margins and 100 km airport radii, sampling the
24/// corners of a coarser position returns dozens of "uncertain" regions —
25/// noise dressed up as diligence. 25 km admits advert cells of three
26/// bytes (≈ 9.8 km at the equator) and finer, and refuses one- and
27/// two-byte cells (≈ 2,500 km and ≈ 156 km).
28const MAX_PROPOSAL_UNCERTAINTY_M: f64 = 25_000.0;
29
30/// Meters per degree of longitude at the equator, matching
31/// [`crate::ulcp_location_cell_meters`].
32const EQUATOR_METERS_PER_DEGREE: f64 = 111_320.0;
33
34/// Anything that can go wrong opening or consulting a region database.
35#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Error)]
36pub enum MobileRegionError {
37    /// The database file could not be opened or read.
38    DatabaseUnavailable,
39    /// The file is not a region database at all.
40    NotARegionDatabase,
41    /// The file declares a format version this build does not implement.
42    UnsupportedFormat,
43    /// The database needs SQLite's R-tree module and this build has none.
44    MissingSpatialIndex,
45    /// The database opened but its contents could not be decoded.
46    Corrupt,
47    /// The position is not a place: a non-finite or out-of-range
48    /// coordinate, or an undecodable location cell.
49    InvalidPosition,
50    /// The position is real but too uncertain to propose from. The
51    /// answer is a better source, not a wider guess.
52    PositionTooCoarse,
53    /// A configured region string or default-region code could not be
54    /// read.
55    InvalidRegionCode,
56}
57
58impl MobileRegionError {
59    /// Stable localization key. Rust prose is never shown directly in
60    /// the UI.
61    pub const fn summary_key(self) -> &'static str {
62        match self {
63            Self::DatabaseUnavailable => "mobile.error.regiondb.unavailable",
64            Self::NotARegionDatabase => "mobile.error.regiondb.not_a_region_database",
65            Self::UnsupportedFormat => "mobile.error.regiondb.unsupported_format",
66            Self::MissingSpatialIndex => "mobile.error.regiondb.missing_spatial_index",
67            Self::Corrupt => "mobile.error.regiondb.corrupt",
68            Self::InvalidPosition => "mobile.error.regiondb.invalid_position",
69            Self::PositionTooCoarse => "mobile.error.regiondb.position_too_coarse",
70            Self::InvalidRegionCode => "mobile.error.region_code.invalid",
71        }
72    }
73
74    /// Redacted diagnostic code suitable for logs and support bundles.
75    pub const fn diagnostic_code(self) -> &'static str {
76        match self {
77            Self::DatabaseUnavailable => "REGIONDB_UNAVAILABLE",
78            Self::NotARegionDatabase => "REGIONDB_NOT_A_REGION_DATABASE",
79            Self::UnsupportedFormat => "REGIONDB_UNSUPPORTED_FORMAT",
80            Self::MissingSpatialIndex => "REGIONDB_MISSING_SPATIAL_INDEX",
81            Self::Corrupt => "REGIONDB_CORRUPT",
82            Self::InvalidPosition => "REGION_POSITION_INVALID",
83            Self::PositionTooCoarse => "REGION_POSITION_TOO_COARSE",
84            Self::InvalidRegionCode => "REGION_CODE_INVALID",
85        }
86    }
87}
88
89impl core::fmt::Display for MobileRegionError {
90    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
91        formatter.write_str(self.diagnostic_code())
92    }
93}
94
95impl std::error::Error for MobileRegionError {}
96
97impl From<RegionDbError> for MobileRegionError {
98    fn from(error: RegionDbError) -> Self {
99        // Payloads are dropped deliberately: invalid input is not copied
100        // into the error, preventing accidental disclosure through
101        // diagnostics.
102        match error {
103            RegionDbError::Sqlite(_) => Self::DatabaseUnavailable,
104            RegionDbError::NotARegionDatabase => Self::NotARegionDatabase,
105            RegionDbError::UnsupportedFormat { .. } => Self::UnsupportedFormat,
106            RegionDbError::MissingSpatialIndex => Self::MissingSpatialIndex,
107            RegionDbError::Geometry(_) => Self::Corrupt,
108            RegionDbError::Position(_) => Self::InvalidPosition,
109        }
110    }
111}
112
113/// Whether a position falls in a region's own area or only in its
114/// expansion margin.
115#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
116pub enum MobileRegionMembership {
117    /// The position is inside the region's core geometry.
118    Core,
119    /// Only the sampled expansion margin reaches the position.
120    Expanded,
121}
122
123impl From<Membership> for MobileRegionMembership {
124    fn from(membership: Membership) -> Self {
125        match membership {
126            Membership::Core => Self::Core,
127            Membership::Expanded => Self::Expanded,
128        }
129    }
130}
131
132/// A place to propose regions for, with its honest uncertainty.
133///
134/// Positions come from sources of very different quality — a node's
135/// advertised identity cell, a live GNSS fix, hand-entered coordinates —
136/// and the proposal widens itself to match. At most one of
137/// `location_bytes` and `accuracy_m` should be set; the cell wins when
138/// both are.
139#[derive(Clone, Debug, PartialEq, uniffi::Record)]
140pub struct MobileRegionPositionRecord {
141    /// Latitude in degrees.
142    pub latitude: f64,
143    /// Longitude in degrees.
144    pub longitude: f64,
145    /// The encoded identity cell this position came from, verbatim. Its
146    /// bounds are the uncertainty, and supersede `accuracy_m`.
147    pub location_bytes: Option<Vec<u8>>,
148    /// Horizontal uncertainty of a measured fix, in meters.
149    pub accuracy_m: Option<f64>,
150}
151
152/// One semantic match from a lookup, mirroring the reader's
153/// `RegionMatch`.
154#[derive(Clone, Debug, PartialEq, uniffi::Record)]
155pub struct MobileRegionMatchRecord {
156    /// Namespaced identity, such as `iata-airport:SFO`.
157    pub region_key: String,
158    /// The namespace half of the key.
159    pub namespace: String,
160    /// The string a radio is configured with.
161    pub radio_name: String,
162    /// The derived 2-octet wire code.
163    pub wire_code: Vec<u8>,
164    /// The layer that produced the match, such as `commercial_airport`.
165    pub layer: String,
166    /// Core geometry or expansion margin.
167    pub membership: MobileRegionMembership,
168    /// The region's site, for nearest-site layers.
169    pub site_latitude: Option<f64>,
170    /// See `site_latitude`.
171    pub site_longitude: Option<f64>,
172}
173
174/// A region as a radio is configured with it: the string form and the
175/// 2-octet code the string derives to.
176#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
177pub struct MobileRegionRecord {
178    /// The configuration string, such as `SFO` or `SF Bay Area`.
179    pub name: String,
180    /// The derived wire code, always two octets.
181    pub code: Vec<u8>,
182}
183
184/// What the database says covers a position.
185#[derive(Clone, Debug, PartialEq, uniffi::Record)]
186pub struct MobileRegionLookupRecord {
187    /// The queried latitude, degrees.
188    pub latitude: f64,
189    /// The queried longitude, degrees.
190    pub longitude: f64,
191    /// Every semantic match, ordered by layer priority.
192    pub matches: Vec<MobileRegionMatchRecord>,
193    /// The deduplicated radio-facing list the matches produce.
194    pub radio_regions: Vec<MobileRegionRecord>,
195    /// The best default-tag candidate, when any layer offers one.
196    pub suggested_default_region: Option<MobileRegionRecord>,
197    /// The data release the answers came from, such as `2026.34.1`.
198    pub dataset_version: String,
199}
200
201/// One way of applying a proposal: the complete resulting settings, not
202/// a delta. The caller assigns both fields into the editor and thinks no
203/// further.
204#[derive(Clone, Debug, PartialEq, uniffi::Record)]
205pub struct MobileRegionOutcomeRecord {
206    /// The resulting forwarding list, as region strings.
207    pub regions: Vec<String>,
208    /// The resulting default tag, as a 2-octet code.
209    pub default_region: Option<Vec<u8>>,
210    /// Whether applying this outcome changes the device at all. When
211    /// false, the UI can say "already matches" instead of offering a
212    /// write that does nothing.
213    pub changes_anything: bool,
214}
215
216/// A location-derived region proposal, ready for the operator to accept
217/// wholesale, take additively, or dismiss.
218#[derive(Clone, Debug, PartialEq, uniffi::Record)]
219pub struct MobileRegionProposalRecord {
220    /// The merged view across the position's uncertainty: every region
221    /// any sample point hit, each match reported once with its best
222    /// membership. `radio_regions` here is the suggested list both
223    /// outcomes are built from.
224    pub lookup: MobileRegionLookupRecord,
225    /// Accept wholesale: regions and default tag become the suggestion,
226    /// including a `None` default when the lookup suggests nothing.
227    pub replace: MobileRegionOutcomeRecord,
228    /// Accept additively: missing suggestions are appended, nothing is
229    /// removed, and the suggested default tag is adopted only when the
230    /// device has none.
231    pub add_missing: MobileRegionOutcomeRecord,
232    /// Suggested regions the device already forwards, by derived code.
233    pub already_present: Vec<String>,
234    /// Configured regions this position does not account for. Kept by
235    /// `add_missing`, dropped by `replace`.
236    pub not_suggested: Vec<String>,
237    /// Suggested regions the position's samples do not agree on, as
238    /// radio names matching entries in `lookup.radio_regions`. The
239    /// position's uncertainty straddles these regions' boundaries.
240    pub uncertain_regions: Vec<String>,
241    /// Width of the position's uncertainty, in meters, for the UI to
242    /// state. `None` for an exact position.
243    pub cell_meters: Option<f64>,
244}
245
246/// An opened region database.
247///
248/// Read-only, and cheap to hold open for the life of the app. The lock
249/// exists because the underlying SQLite connection is single-threaded,
250/// not because anything here blocks for long: a worst-case lookup is
251/// about a millisecond.
252#[derive(uniffi::Object)]
253pub struct MobileRegionDatabase {
254    inner: Mutex<RegionDb>,
255}
256
257#[uniffi::export]
258impl MobileRegionDatabase {
259    /// Open the database at an absolute filesystem path, read-only.
260    #[uniffi::constructor]
261    pub fn open(path: String) -> Result<Arc<Self>, MobileRegionError> {
262        if !std::path::Path::new(&path).is_absolute() {
263            return Err(MobileRegionError::DatabaseUnavailable);
264        }
265        let db = RegionDb::open(&path)?;
266        Ok(Arc::new(Self {
267            inner: Mutex::new(db),
268        }))
269    }
270
271    /// The data release this database carries, such as `2026.34.1`.
272    pub fn dataset_version(&self) -> String {
273        self.locked(|db| db.dataset_version().to_owned())
274    }
275
276    /// The database format version.
277    pub fn format_version(&self) -> u32 {
278        self.locked(|db| db.format_version() as u32)
279    }
280
281    /// How many regions the database holds.
282    pub fn region_count(&self) -> u32 {
283        self.locked(|db| db.region_count() as u32)
284    }
285
286    /// Every region covering one exact position, with semantic detail.
287    pub fn lookup(
288        &self,
289        latitude: f64,
290        longitude: f64,
291    ) -> Result<MobileRegionLookupRecord, MobileRegionError> {
292        check_coordinates(latitude, longitude)?;
293        let lookup = self.locked(|db| db.lookup_detailed(latitude, longitude))?;
294        Ok(lookup_record(&lookup))
295    }
296
297    /// Propose a region configuration for a position, against what the
298    /// device currently holds.
299    ///
300    /// The proposal samples the position's uncertainty — an identity
301    /// cell's center and four corners, or a measured fix's center and
302    /// the four cardinal points of its accuracy circle — and suggests
303    /// every region any sample hit. A node whose position straddles a
304    /// boundary should usually forward both sides, the same reasoning
305    /// that gives the database its expansion margins; the non-unanimous
306    /// regions are named so the operator can judge.
307    pub fn propose(
308        &self,
309        position: MobileRegionPositionRecord,
310        current_regions: Vec<String>,
311        current_default_region: Option<Vec<u8>>,
312    ) -> Result<MobileRegionProposalRecord, MobileRegionError> {
313        check_coordinates(position.latitude, position.longitude)?;
314        let plan = sample_points(&position)?;
315
316        let current_codes = current_regions
317            .iter()
318            .map(|text| {
319                text.parse::<RegionCode>()
320                    .map_err(|_| MobileRegionError::InvalidRegionCode)
321            })
322            .collect::<Result<Vec<_>, _>>()?;
323        let current_default = current_default_region
324            .as_deref()
325            .map(|bytes| {
326                let bytes: [u8; 2] = bytes
327                    .try_into()
328                    .map_err(|_| MobileRegionError::InvalidRegionCode)?;
329                Ok::<_, MobileRegionError>(RegionCode::from_bytes(bytes))
330            })
331            .transpose()?;
332
333        let lookups = self.locked(|db| {
334            plan.points
335                .iter()
336                .map(|&(latitude, longitude)| db.lookup_detailed(latitude, longitude))
337                .collect::<Result<Vec<_>, _>>()
338        })?;
339
340        // The merged view: every match any sample hit, once, with its
341        // best membership — a core hit anywhere beats an expanded one.
342        let mut merged: Vec<RegionMatch> = Vec::new();
343        for lookup in &lookups {
344            for candidate in &lookup.matches {
345                match merged
346                    .iter_mut()
347                    .find(|held| held.region_key == candidate.region_key)
348                {
349                    Some(held) => held.membership = held.membership.min(candidate.membership),
350                    None => merged.push(candidate.clone()),
351                }
352            }
353        }
354        merged.sort_by(|a, b| {
355            (a.priority, a.membership, &a.region_key).cmp(&(
356                b.priority,
357                b.membership,
358                &b.region_key,
359            ))
360        });
361
362        // The radio-facing suggestion: first entry per distinct wire
363        // code, mirroring the reader's own deduplication.
364        let mut suggested: Vec<MobileRegionRecord> = Vec::new();
365        for candidate in &merged {
366            if !suggested
367                .iter()
368                .any(|held| held.code == candidate.wire_code.to_bytes())
369            {
370                suggested.push(MobileRegionRecord {
371                    name: candidate.radio_name.clone(),
372                    code: candidate.wire_code.to_bytes().to_vec(),
373                });
374            }
375        }
376
377        // A suggestion the samples disagree on straddles the position's
378        // uncertainty. The center-only lookup is index zero.
379        let uncertain_regions = suggested
380            .iter()
381            .filter(|region| {
382                !lookups.iter().all(|lookup| {
383                    lookup
384                        .radio_regions
385                        .iter()
386                        .any(|held| held.code.to_bytes().as_slice() == region.code.as_slice())
387                })
388            })
389            .map(|region| region.name.clone())
390            .collect();
391
392        let suggested_default =
393            lookups[0]
394                .suggested_default_region
395                .as_ref()
396                .map(|region| MobileRegionRecord {
397                    name: region.name.clone(),
398                    code: region.code.to_bytes().to_vec(),
399                });
400
401        let already_present: Vec<String> = suggested
402            .iter()
403            .filter(|region| {
404                current_codes
405                    .iter()
406                    .any(|held| held.to_bytes().as_slice() == region.code.as_slice())
407            })
408            .map(|region| region.name.clone())
409            .collect();
410        let not_suggested: Vec<String> = current_regions
411            .iter()
412            .zip(&current_codes)
413            .filter(|(_, code)| {
414                !suggested
415                    .iter()
416                    .any(|region| region.code.as_slice() == code.to_bytes().as_slice())
417            })
418            .map(|(text, _)| text.clone())
419            .collect();
420
421        let suggested_default_code = suggested_default
422            .as_ref()
423            .map(|region| RegionCode::from_bytes([region.code[0], region.code[1]]));
424        let suggested_codes: Vec<[u8; 2]> = suggested
425            .iter()
426            .map(|region| [region.code[0], region.code[1]])
427            .collect();
428        let current_code_bytes: Vec<[u8; 2]> =
429            current_codes.iter().map(|code| code.to_bytes()).collect();
430        let replace = MobileRegionOutcomeRecord {
431            regions: suggested.iter().map(|region| region.name.clone()).collect(),
432            default_region: suggested_default.as_ref().map(|region| region.code.clone()),
433            changes_anything: suggested_codes != current_code_bytes
434                || suggested_default_code != current_default,
435        };
436
437        let missing: Vec<&MobileRegionRecord> = suggested
438            .iter()
439            .filter(|region| {
440                !current_codes
441                    .iter()
442                    .any(|held| held.to_bytes().as_slice() == region.code.as_slice())
443            })
444            .collect();
445        let adopts_default = current_default.is_none() && suggested_default_code.is_some();
446        let add_missing = MobileRegionOutcomeRecord {
447            regions: current_regions
448                .iter()
449                .cloned()
450                .chain(missing.iter().map(|region| region.name.clone()))
451                .collect(),
452            default_region: if adopts_default {
453                suggested_default.as_ref().map(|region| region.code.clone())
454            } else {
455                current_default_region.clone()
456            },
457            changes_anything: !missing.is_empty() || adopts_default,
458        };
459
460        Ok(MobileRegionProposalRecord {
461            lookup: MobileRegionLookupRecord {
462                latitude: position.latitude,
463                longitude: position.longitude,
464                matches: merged.iter().map(match_record).collect(),
465                radio_regions: suggested,
466                suggested_default_region: suggested_default,
467                dataset_version: lookups[0].dataset_version.clone(),
468            },
469            replace,
470            add_missing,
471            already_present,
472            not_suggested,
473            uncertain_regions,
474            cell_meters: plan.width_m,
475        })
476    }
477}
478
479impl MobileRegionDatabase {
480    fn locked<T>(&self, operation: impl FnOnce(&RegionDb) -> T) -> T {
481        // A poisoned lock means a panic mid-read of a read-only
482        // database; the data cannot be inconsistent, so continue.
483        let guard = match self.inner.lock() {
484            Ok(guard) => guard,
485            Err(poisoned) => poisoned.into_inner(),
486        };
487        operation(&guard)
488    }
489}
490
491fn check_coordinates(latitude: f64, longitude: f64) -> Result<(), MobileRegionError> {
492    if !latitude.is_finite()
493        || latitude.abs() > 90.0
494        || !longitude.is_finite()
495        || longitude.abs() > 180.0
496    {
497        return Err(MobileRegionError::InvalidPosition);
498    }
499    Ok(())
500}
501
502/// The points a proposal tests, the position itself first.
503struct SamplePlan {
504    points: Vec<(f64, f64)>,
505    /// The stated width of the uncertainty, for the UI. `None` when the
506    /// position is exact.
507    width_m: Option<f64>,
508}
509
510fn sample_points(position: &MobileRegionPositionRecord) -> Result<SamplePlan, MobileRegionError> {
511    let center = (position.latitude, position.longitude);
512
513    if let Some(bytes) = position.location_bytes.as_deref() {
514        if bytes.is_empty() || bytes.len() > 7 {
515            return Err(MobileRegionError::InvalidPosition);
516        }
517        // Equatorial cell width, matching `ulcp_location_cell_meters`.
518        let cell_meters = 360.0 * EQUATOR_METERS_PER_DEGREE / 16f64.powi(bytes.len() as i32);
519        if cell_meters > MAX_PROPOSAL_UNCERTAINTY_M {
520            return Err(MobileRegionError::PositionTooCoarse);
521        }
522        let location = NodeLocation::from_bytes(bytes);
523        let ((lat_lo, lon_lo), (lat_hi, lon_hi)) = location.bounds();
524        let (lat_lo, lon_lo, lat_hi, lon_hi) =
525            (lat_lo as f64, lon_lo as f64, lat_hi as f64, lon_hi as f64);
526        return Ok(SamplePlan {
527            points: vec![
528                center,
529                (lat_lo, lon_lo),
530                (lat_lo, lon_hi),
531                (lat_hi, lon_lo),
532                (lat_hi, lon_hi),
533            ],
534            width_m: Some(cell_meters),
535        });
536    }
537
538    if let Some(accuracy_m) = position.accuracy_m {
539        if !accuracy_m.is_finite() || accuracy_m < 0.0 {
540            return Err(MobileRegionError::InvalidPosition);
541        }
542        if accuracy_m > MAX_PROPOSAL_UNCERTAINTY_M {
543            return Err(MobileRegionError::PositionTooCoarse);
544        }
545        if accuracy_m > 0.0 {
546            // The cardinal points of the accuracy circle, not a bounding
547            // box: the box's corners lie √2 beyond the stated
548            // uncertainty and would widen the proposal past what was
549            // measured.
550            let mut samples = vec![center];
551            for bearing in [0.0, 90.0, 180.0, 270.0] {
552                samples.push(sampling::destination(
553                    position.latitude,
554                    position.longitude,
555                    bearing,
556                    accuracy_m,
557                ));
558            }
559            return Ok(SamplePlan {
560                points: samples,
561                width_m: Some(accuracy_m * 2.0),
562            });
563        }
564    }
565
566    Ok(SamplePlan {
567        points: vec![center],
568        width_m: None,
569    })
570}
571
572fn lookup_record(lookup: &RegionLookup) -> MobileRegionLookupRecord {
573    MobileRegionLookupRecord {
574        latitude: lookup.latitude,
575        longitude: lookup.longitude,
576        matches: lookup.matches.iter().map(match_record).collect(),
577        radio_regions: lookup
578            .radio_regions
579            .iter()
580            .map(|region| MobileRegionRecord {
581                name: region.name.clone(),
582                code: region.code.to_bytes().to_vec(),
583            })
584            .collect(),
585        suggested_default_region: lookup.suggested_default_region.as_ref().map(|region| {
586            MobileRegionRecord {
587                name: region.name.clone(),
588                code: region.code.to_bytes().to_vec(),
589            }
590        }),
591        dataset_version: lookup.dataset_version.clone(),
592    }
593}
594
595fn match_record(entry: &RegionMatch) -> MobileRegionMatchRecord {
596    MobileRegionMatchRecord {
597        region_key: entry.region_key.clone(),
598        namespace: entry.namespace.clone(),
599        radio_name: entry.radio_name.clone(),
600        wire_code: entry.wire_code.to_bytes().to_vec(),
601        layer: entry.layer.clone(),
602        membership: entry.membership.into(),
603        site_latitude: entry.site.map(|(_, latitude)| latitude),
604        site_longitude: entry.site.map(|(longitude, _)| longitude),
605    }
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611
612    fn fixture() -> Arc<MobileRegionDatabase> {
613        let path = concat!(
614            env!("CARGO_MANIFEST_DIR"),
615            "/../../regions/tests/fixture/fixture.regiondb"
616        );
617        MobileRegionDatabase::open(path.to_owned()).expect("fixture opens")
618    }
619
620    fn exact(latitude: f64, longitude: f64) -> MobileRegionPositionRecord {
621        MobileRegionPositionRecord {
622            latitude,
623            longitude,
624            location_bytes: None,
625            accuracy_m: None,
626        }
627    }
628
629    // San Carlos Airport: the regression the fixture exists for.
630    const SAN_CARLOS: (f64, f64) = (37.5119, -122.2495);
631    // SFO terminal area.
632    const SFO: (f64, f64) = (37.6189, -122.3750);
633
634    #[test]
635    fn opens_and_describes_the_fixture() {
636        let db = fixture();
637        assert_eq!(db.dataset_version(), "fixture-1");
638        assert_eq!(db.format_version(), 1);
639        assert_eq!(db.region_count(), 12);
640    }
641
642    #[test]
643    fn relative_path_is_refused() {
644        assert_eq!(
645            MobileRegionDatabase::open("fixture.regiondb".to_owned()).err(),
646            Some(MobileRegionError::DatabaseUnavailable)
647        );
648    }
649
650    #[test]
651    fn lookup_matches_the_reference_answers() {
652        let lookup = fixture().lookup(SAN_CARLOS.0, SAN_CARLOS.1).unwrap();
653        let keys: Vec<&str> = lookup
654            .matches
655            .iter()
656            .map(|entry| entry.region_key.as_str())
657            .collect();
658        assert!(keys.contains(&"iata-location:SQL"));
659        assert!(keys.contains(&"iata-airport:SFO"));
660        assert!(!keys.contains(&"iata-airport:SQL"));
661        assert_eq!(
662            lookup
663                .suggested_default_region
664                .as_ref()
665                .map(|r| r.name.as_str()),
666            Some("XSF")
667        );
668    }
669
670    #[test]
671    fn exact_position_proposes_the_lookup_verbatim() {
672        let db = fixture();
673        let proposal = db.propose(exact(SFO.0, SFO.1), Vec::new(), None).unwrap();
674        let direct = db.lookup(SFO.0, SFO.1).unwrap();
675        assert_eq!(proposal.lookup.radio_regions, direct.radio_regions);
676        assert!(proposal.uncertain_regions.is_empty());
677        assert_eq!(proposal.cell_meters, None);
678        assert_eq!(
679            proposal.replace.regions,
680            direct
681                .radio_regions
682                .iter()
683                .map(|region| region.name.clone())
684                .collect::<Vec<_>>()
685        );
686        assert!(proposal.replace.changes_anything);
687        assert_eq!(proposal.replace, proposal.add_missing);
688    }
689
690    #[test]
691    fn comparison_is_by_derived_wire_code() {
692        // "sfo" and the hex spelling of XSF name the same regions the
693        // suggestion does, however they are written.
694        let proposal = fixture()
695            .propose(
696                exact(SFO.0, SFO.1),
697                vec!["sfo".to_owned(), "0x98FE".to_owned()],
698                None,
699            )
700            .unwrap();
701        assert_eq!(proposal.already_present, vec!["SFO", "XSF"]);
702        assert!(proposal.not_suggested.is_empty());
703        // Add-missing keeps the operator's spellings and appends only
704        // what is genuinely absent.
705        assert_eq!(proposal.add_missing.regions[..2], ["sfo", "0x98FE"]);
706        assert!(!proposal.add_missing.regions.contains(&"SFO".to_owned()));
707        assert!(proposal.add_missing.regions.contains(&"US".to_owned()));
708        assert!(proposal.add_missing.changes_anything);
709    }
710
711    #[test]
712    fn foreign_regions_are_kept_by_add_and_dropped_by_replace() {
713        let proposal = fixture()
714            .propose(exact(SFO.0, SFO.1), vec!["LAX".to_owned()], None)
715            .unwrap();
716        assert_eq!(proposal.not_suggested, vec!["LAX"]);
717        assert!(proposal.add_missing.regions.contains(&"LAX".to_owned()));
718        assert!(!proposal.replace.regions.contains(&"LAX".to_owned()));
719    }
720
721    #[test]
722    fn default_tag_is_adopted_only_when_unset() {
723        let db = fixture();
724        let xsf = vec![0x98, 0xFE];
725        let sfo = vec![0x77, 0xBF];
726
727        let unset = db.propose(exact(SFO.0, SFO.1), Vec::new(), None).unwrap();
728        assert_eq!(unset.add_missing.default_region, Some(xsf.clone()));
729        assert_eq!(unset.replace.default_region, Some(xsf.clone()));
730
731        let held = db
732            .propose(
733                exact(SFO.0, SFO.1),
734                vec!["SFO".to_owned()],
735                Some(sfo.clone()),
736            )
737            .unwrap();
738        assert_eq!(held.add_missing.default_region, Some(sfo));
739        assert_eq!(held.replace.default_region, Some(xsf));
740    }
741
742    #[test]
743    fn matching_configuration_changes_nothing() {
744        let db = fixture();
745        let direct = db.lookup(SFO.0, SFO.1).unwrap();
746        let current: Vec<String> = direct
747            .radio_regions
748            .iter()
749            .map(|region| region.name.clone())
750            .collect();
751        let default = direct
752            .suggested_default_region
753            .as_ref()
754            .map(|r| r.code.clone());
755        let proposal = db.propose(exact(SFO.0, SFO.1), current, default).unwrap();
756        assert!(!proposal.replace.changes_anything);
757        assert!(!proposal.add_missing.changes_anything);
758    }
759
760    #[test]
761    fn accuracy_circle_reaching_a_boundary_is_uncertain() {
762        // At this point OAK answers and SFO does not, but a 20 km circle
763        // reaches across their boundary: the southern and western samples
764        // return SFO and lose OAK. Both airports join the suggestion and
765        // both are uncertain — the honest answer near a bisector — while
766        // the regions every sample agrees on stay certain.
767        let position = MobileRegionPositionRecord {
768            accuracy_m: Some(20_000.0),
769            ..exact(37.70, -122.27)
770        };
771        let proposal = fixture().propose(position, Vec::new(), None).unwrap();
772        let names: Vec<&str> = proposal
773            .lookup
774            .radio_regions
775            .iter()
776            .map(|region| region.name.as_str())
777            .collect();
778        assert!(names.contains(&"OAK"));
779        assert!(names.contains(&"SFO"));
780        assert!(proposal.uncertain_regions.contains(&"SFO".to_owned()));
781        assert!(proposal.uncertain_regions.contains(&"OAK".to_owned()));
782        assert!(!proposal.uncertain_regions.contains(&"XSF".to_owned()));
783        assert!(!proposal.uncertain_regions.contains(&"US".to_owned()));
784        assert_eq!(proposal.cell_meters, Some(40_000.0));
785    }
786
787    #[test]
788    fn advert_cell_samples_its_corners() {
789        let cell = NodeLocation::from_lat_lon(SAN_CARLOS.0 as f32, SAN_CARLOS.1 as f32, 4);
790        let position = MobileRegionPositionRecord {
791            location_bytes: Some(cell.as_bytes().to_vec()),
792            ..exact(SAN_CARLOS.0, SAN_CARLOS.1)
793        };
794        let proposal = fixture().propose(position, Vec::new(), None).unwrap();
795        // A ~600 m cell stays within one answer here; what matters is
796        // that the cell path runs and states its width.
797        let width = proposal.cell_meters.unwrap();
798        assert!((610.0..613.0).contains(&width), "width {width}");
799        assert!(
800            proposal
801                .lookup
802                .radio_regions
803                .iter()
804                .any(|r| r.name == "SQL")
805        );
806    }
807
808    #[test]
809    fn coarse_positions_are_refused() {
810        let db = fixture();
811        let cell = NodeLocation::from_lat_lon(SAN_CARLOS.0 as f32, SAN_CARLOS.1 as f32, 2);
812        let coarse_cell = MobileRegionPositionRecord {
813            location_bytes: Some(cell.as_bytes().to_vec()),
814            ..exact(SAN_CARLOS.0, SAN_CARLOS.1)
815        };
816        assert_eq!(
817            db.propose(coarse_cell, Vec::new(), None).err(),
818            Some(MobileRegionError::PositionTooCoarse)
819        );
820        let coarse_fix = MobileRegionPositionRecord {
821            accuracy_m: Some(30_000.0),
822            ..exact(SAN_CARLOS.0, SAN_CARLOS.1)
823        };
824        assert_eq!(
825            db.propose(coarse_fix, Vec::new(), None).err(),
826            Some(MobileRegionError::PositionTooCoarse)
827        );
828    }
829
830    #[test]
831    fn invalid_inputs_are_named() {
832        let db = fixture();
833        assert_eq!(
834            db.lookup(91.0, 0.0).err(),
835            Some(MobileRegionError::InvalidPosition)
836        );
837        assert_eq!(
838            db.propose(
839                MobileRegionPositionRecord {
840                    location_bytes: Some(Vec::new()),
841                    ..exact(0.0, 0.0)
842                },
843                Vec::new(),
844                None,
845            )
846            .err(),
847            Some(MobileRegionError::InvalidPosition)
848        );
849        assert_eq!(
850            db.propose(exact(0.0, 0.0), Vec::new(), Some(vec![0x12]))
851                .err(),
852            Some(MobileRegionError::InvalidRegionCode)
853        );
854        assert_eq!(
855            db.propose(exact(0.0, 0.0), vec![String::new()], None).err(),
856            Some(MobileRegionError::InvalidRegionCode)
857        );
858    }
859}