diff options
| author | Claude Agent <agent@example.com> | 2026-03-25 02:00:17 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-03-25 04:55:58 +0000 |
| commit | 62c27bf28de30979bc58ef7808185ac189f71197 (patch) | |
| tree | 1d828faf273022b4fb8d32b73479b9c0a6eb8f81 /android-app/app/src/test/kotlin/com/example | |
| parent | 0294c6fccc5a1dac7d4fb0ac084b273683e47d32 (diff) | |
feat(gps): implement NMEA/Android GPS sensor fusion in LocationService
Adds priority-based selection between NMEA GPS (dedicated marine GPS,
higher priority) and Android system GPS (fallback) within LocationService.
Selection policy:
1. Prefer NMEA when its most recent fix is fresh (≤ nmeaStalenessThresholdMs, default 5 s)
2. Fall back to Android GPS when NMEA is stale
3. Use stale NMEA only when Android has never reported a fix
4. bestPosition is null until at least one source reports
New public API:
- GpsSource enum (NONE, NMEA, ANDROID)
- LocationService.updateNmeaGps(GpsPosition)
- LocationService.updateAndroidGps(GpsPosition)
- LocationService.bestPosition: StateFlow<GpsPosition?>
- LocationService.activeGpsSource: StateFlow<GpsSource>
- Injectable clockMs parameter for deterministic unit tests
Adds 7 unit tests covering: no-data state, fresh NMEA priority,
stale NMEA fallback, only-NMEA/only-Android scenarios, exact-threshold
edge case, and NMEA recovery after Android takeover.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'android-app/app/src/test/kotlin/com/example')
| -rw-r--r-- | android-app/app/src/test/kotlin/com/example/androidapp/gps/LocationServiceTest.kt | 110 |
1 files changed, 110 insertions, 0 deletions
diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/gps/LocationServiceTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/gps/LocationServiceTest.kt index d9192c6..237004b 100644 --- a/android-app/app/src/test/kotlin/com/example/androidapp/gps/LocationServiceTest.kt +++ b/android-app/app/src/test/kotlin/com/example/androidapp/gps/LocationServiceTest.kt @@ -114,4 +114,114 @@ class LocationServiceTest { assertEquals(data, svc.latestSensor.first()) } + + // ── GPS sensor fusion ───────────────────────────────────────────────────── + + private fun fusionService( + nmeaStalenessThresholdMs: Long = 5_000L, + clockMs: () -> Long = System::currentTimeMillis + ) = LocationService( + nmeaStalenessThresholdMs = nmeaStalenessThresholdMs, + clockMs = clockMs + ) + + private fun pos(lat: Double, lon: Double, timestampMs: Long) = + GpsPosition(lat, lon, sog = 0.0, cog = 0.0, timestampMs = timestampMs) + + @Test + fun noGpsData_bestPositionNullAndSourceNone() = runBlocking { + val svc = fusionService() + assertNull(svc.bestPosition.first()) + assertEquals(GpsSource.NONE, svc.activeGpsSource.first()) + } + + @Test + fun freshNmea_preferredOverAndroid() = runBlocking { + val now = 10_000L + val svc = fusionService(nmeaStalenessThresholdMs = 5_000L, clockMs = { now }) + + val nmeaFix = pos(41.0, -71.0, now) + val androidFix = pos(42.0, -72.0, now - 1_000L) + + svc.updateAndroidGps(androidFix) + svc.updateNmeaGps(nmeaFix) + + assertEquals(GpsSource.NMEA, svc.activeGpsSource.first()) + assertEquals(nmeaFix, svc.bestPosition.first()) + } + + @Test + fun staleNmea_androidFallback() = runBlocking { + val nmeaTime = 0L + val now = 10_000L // 10 s later — NMEA is stale (threshold 5 s) + val svc = fusionService(nmeaStalenessThresholdMs = 5_000L, clockMs = { now }) + + val nmeaFix = pos(41.0, -71.0, nmeaTime) + val androidFix = pos(42.0, -72.0, now) + + svc.updateNmeaGps(nmeaFix) + svc.updateAndroidGps(androidFix) + + assertEquals(GpsSource.ANDROID, svc.activeGpsSource.first()) + assertEquals(androidFix, svc.bestPosition.first()) + } + + @Test + fun onlyNmeaAvailable_usedEvenWhenStale() = runBlocking { + val now = 60_000L // 60 s after fix — very stale + val svc = fusionService(nmeaStalenessThresholdMs = 5_000L, clockMs = { now }) + + val nmeaFix = pos(41.0, -71.0, 0L) + svc.updateNmeaGps(nmeaFix) + + assertEquals(GpsSource.NMEA, svc.activeGpsSource.first()) + assertEquals(nmeaFix, svc.bestPosition.first()) + } + + @Test + fun onlyAndroidAvailable_isUsed() = runBlocking { + val svc = fusionService() + val androidFix = pos(42.0, -72.0, System.currentTimeMillis()) + svc.updateAndroidGps(androidFix) + + assertEquals(GpsSource.ANDROID, svc.activeGpsSource.first()) + assertEquals(androidFix, svc.bestPosition.first()) + } + + @Test + fun nmeaAtExactThreshold_isConsideredFresh() = runBlocking { + val fixTime = 0L + val now = 5_000L // exactly at threshold + val svc = fusionService(nmeaStalenessThresholdMs = 5_000L, clockMs = { now }) + + val nmeaFix = pos(41.0, -71.0, fixTime) + val androidFix = pos(42.0, -72.0, now) + + svc.updateNmeaGps(nmeaFix) + svc.updateAndroidGps(androidFix) + + assertEquals(GpsSource.NMEA, svc.activeGpsSource.first()) + } + + @Test + fun nmeaRecovery_switchesBackFromAndroid() = runBlocking { + var now = 0L + val svc = fusionService(nmeaStalenessThresholdMs = 5_000L, clockMs = { now }) + + // Fresh NMEA + svc.updateNmeaGps(pos(41.0, -71.0, 0L)) + assertEquals(GpsSource.NMEA, svc.activeGpsSource.value) + + // NMEA goes stale; Android takes over + now = 10_000L + val androidFix = pos(42.0, -72.0, 10_000L) + svc.updateAndroidGps(androidFix) + assertEquals(GpsSource.ANDROID, svc.activeGpsSource.value) + + // NMEA recovers with a fresh fix + val freshNmea = pos(41.1, -71.1, 10_000L) + svc.updateNmeaGps(freshNmea) + assertEquals(GpsSource.NMEA, svc.activeGpsSource.value) + assertEquals(freshNmea, svc.bestPosition.value) + } } |
