package com.example.androidapp.gps import com.example.androidapp.data.model.SensorData import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import org.junit.Assert.* import org.junit.Test class LocationServiceTest { private fun service() = LocationService() // ── snapshot with no data ───────────────────────────────────────────────── @Test fun snapshot_noData_allFieldsNull() { val snap = service().snapshot() assertNull(snap.windSpeedKt) assertNull(snap.windDirectionDeg) assertNull(snap.currentSpeedKt) assertNull(snap.currentDirectionDeg) } // ── true-wind resolution ────────────────────────────────────────────────── @Test fun updateSensorData_withFullReading_resolvesTrueWind() = runBlocking { val svc = service() // Head north (hdg = 0°), AWS = 10 kt coming from ahead (AWA = 0°), BSP = 5 kt // → TW comes FROM ahead at 5 kt svc.updateSensorData( SensorData( headingTrueDeg = 0.0, apparentWindSpeedKt = 10.0, apparentWindAngleDeg = 0.0, speedOverGroundKt = 5.0 ) ) val tw = svc.latestTrueWind.first() assertNotNull(tw) assertTrue("Expected TWS > 0", tw!!.speedKt > 0.0) } @Test fun updateSensorData_missingHeading_doesNotResolveTrueWind() = runBlocking { val svc = service() svc.updateSensorData( SensorData( apparentWindSpeedKt = 10.0, apparentWindAngleDeg = 45.0, speedOverGroundKt = 5.0 // headingTrueDeg omitted ) ) assertNull(svc.latestTrueWind.first()) } // ── current conditions ──────────────────────────────────────────────────── @Test fun updateCurrentConditions_reflectedInSnapshot() { val svc = service() svc.updateCurrentConditions(speedKt = 1.5, directionDeg = 135.0) val snap = svc.snapshot() assertEquals(1.5, snap.currentSpeedKt!!, 0.001) assertEquals(135.0, snap.currentDirectionDeg!!, 0.001) } @Test fun updateCurrentConditions_nullClears() { val svc = service() svc.updateCurrentConditions(speedKt = 2.0, directionDeg = 90.0) svc.updateCurrentConditions(speedKt = null, directionDeg = null) val snap = svc.snapshot() assertNull(snap.currentSpeedKt) assertNull(snap.currentDirectionDeg) } // ── combined snapshot ───────────────────────────────────────────────────── @Test fun snapshot_afterFullUpdate_populatesAllFields() = runBlocking { val svc = service() // Head east (hdg = 90°), wind from starboard bow, BSP proxy = 6 kt svc.updateSensorData( SensorData( headingTrueDeg = 90.0, apparentWindSpeedKt = 12.0, apparentWindAngleDeg = 45.0, speedOverGroundKt = 6.0 ) ) svc.updateCurrentConditions(speedKt = 0.8, directionDeg = 270.0) val snap = svc.snapshot() assertNotNull(snap.windSpeedKt) assertNotNull(snap.windDirectionDeg) assertEquals(0.8, snap.currentSpeedKt!!, 0.001) assertEquals(270.0, snap.currentDirectionDeg!!, 0.001) } // ── latestSensor flow ───────────────────────────────────────────────────── @Test fun updateSensorData_updatesLatestSensorFlow() = runBlocking { val svc = service() assertNull(svc.latestSensor.first()) val data = SensorData(latitude = 41.5, longitude = -71.3) svc.updateSensorData(data) assertEquals(data, svc.latestSensor.first()) } // ── GPS sensor fusion ───────────────────────────────────────────────────── private fun fusionService( nmeaStalenessThresholdMs: Long = 5_000L, nmeaExtendedThresholdMs: Long = 10_000L, clockMs: () -> Long = System::currentTimeMillis ) = LocationService( nmeaStalenessThresholdMs = nmeaStalenessThresholdMs, nmeaExtendedThresholdMs = nmeaExtendedThresholdMs, clockMs = clockMs ) private fun pos(lat: Double, lon: Double, timestampMs: Long) = GpsPosition(lat, lon, sog = 0.0, cog = 0.0, timestampMs = timestampMs) private fun posWithAccuracy(lat: Double, lon: Double, timestampMs: Long, accuracyMeters: Double) = GpsPosition(lat, lon, sog = 0.0, cog = 0.0, timestampMs = timestampMs, accuracyMeters = accuracyMeters) @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()) } // ── fix-quality (accuracy) tie-breaking ────────────────────────────────── @Test fun marginallyStaleNmea_betterAccuracy_preferredOverAndroid() = runBlocking { // NMEA is 7 s old (> primary 5 s, ≤ extended 10 s) but has accuracy 3 m vs Android 15 m. val nmeaTime = 0L val now = 7_000L val svc = fusionService( nmeaStalenessThresholdMs = 5_000L, nmeaExtendedThresholdMs = 10_000L, clockMs = { now } ) val nmeaFix = posWithAccuracy(41.0, -71.0, nmeaTime, accuracyMeters = 3.0) val androidFix = posWithAccuracy(42.0, -72.0, now, accuracyMeters = 15.0) svc.updateNmeaGps(nmeaFix) svc.updateAndroidGps(androidFix) assertEquals(GpsSource.NMEA, svc.activeGpsSource.first()) assertEquals(nmeaFix, svc.bestPosition.first()) } @Test fun marginallyStaleNmea_worseAccuracy_fallsBackToAndroid() = runBlocking { // NMEA is 7 s old with accuracy 15 m; Android has accuracy 3 m → Android wins. val nmeaTime = 0L val now = 7_000L val svc = fusionService( nmeaStalenessThresholdMs = 5_000L, nmeaExtendedThresholdMs = 10_000L, clockMs = { now } ) val nmeaFix = posWithAccuracy(41.0, -71.0, nmeaTime, accuracyMeters = 15.0) val androidFix = posWithAccuracy(42.0, -72.0, now, accuracyMeters = 3.0) svc.updateNmeaGps(nmeaFix) svc.updateAndroidGps(androidFix) assertEquals(GpsSource.ANDROID, svc.activeGpsSource.first()) assertEquals(androidFix, svc.bestPosition.first()) } @Test fun marginallyStaleNmea_noAccuracyData_fallsBackToAndroid() = runBlocking { // Neither source has accuracy metadata — conservative: prefer Android. val nmeaTime = 0L val now = 7_000L val svc = fusionService( nmeaStalenessThresholdMs = 5_000L, nmeaExtendedThresholdMs = 10_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()) } @Test fun veryStaleNmea_beyondExtendedThreshold_androidPreferred() = runBlocking { // NMEA is 15 s old (beyond extended 10 s); Android wins even if NMEA has better accuracy. val nmeaTime = 0L val now = 15_000L val svc = fusionService( nmeaStalenessThresholdMs = 5_000L, nmeaExtendedThresholdMs = 10_000L, clockMs = { now } ) val nmeaFix = posWithAccuracy(41.0, -71.0, nmeaTime, accuracyMeters = 2.0) val androidFix = posWithAccuracy(42.0, -72.0, now, accuracyMeters = 20.0) svc.updateNmeaGps(nmeaFix) svc.updateAndroidGps(androidFix) assertEquals(GpsSource.ANDROID, svc.activeGpsSource.first()) assertEquals(androidFix, svc.bestPosition.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) } }