fix: normalize Maidenhead grid input to uppercase in mutual pass

- onStationAGrid/onStationBGrid store the trimmed upper-cased grid instead
  of the raw input (ol63 used to stay lowercase in the UI)
- drop the local latLonToGrid/gridToLatLon copies and reuse the domain
  QthConverter, removing the drift risk between two implementations
- inject computeDispatcher (default Dispatchers.Default, no behaviour
  change) so the pass computation is unit-testable on a test scheduler
- add MutualViewModelTest (15 cases): init prefill, grid/lat-lon linkage,
  error paths, main-list reuse, fallback search, sorting, clearError
This commit is contained in:
atsunatsu committed 2026-08-29 10:55:15 +08:00
1 parent 47b87a1604
commit 346803fe6e
5 files changed
+539 -86

No files matched your search

@@ -26,6 +26,9 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.repository.IMainContainer import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.utility.positionToQth
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -34,7 +37,6 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlin.math.PI import kotlin.math.PI
import kotlin.math.floor
import kotlin.math.roundToInt import kotlin.math.roundToInt
data class MutualUiState( data class MutualUiState(
@@ -56,7 +58,10 @@ data class MutualUiState(
class MutualViewModel( class MutualViewModel(
private val satelliteRepo: ISatelliteRepo, private val satelliteRepo: ISatelliteRepo,
private val settingsRepo: ISettingsRepo private val settingsRepo: ISettingsRepo,
// Injected so unit tests can run the pass computation on a test dispatcher;
// production callers keep the CPU-bound pool default.
private val computeDispatcher: CoroutineDispatcher = Dispatchers.Default
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(MutualUiState()) private val _uiState = MutableStateFlow(MutualUiState())
@@ -66,7 +71,7 @@ class MutualViewModel(
// Pre-fill station A with the user's current station position (as grid), // Pre-fill station A with the user's current station position (as grid),
// and default min elevation to the same value used by the main radar passes // and default min elevation to the same value used by the main radar passes
val pos = settingsRepo.stationPosition.value val pos = settingsRepo.stationPosition.value
val grid = latLonToGrid(pos.latitude, pos.longitude) val grid = positionToQth(pos.latitude, pos.longitude) ?: ""
_uiState.update { it.copy( _uiState.update { it.copy(
stationAGrid = grid, stationAGrid = grid,
stationALat = "%.4f".format(pos.latitude), stationALat = "%.4f".format(pos.latitude),
@@ -81,7 +86,9 @@ class MutualViewModel(
val lat = value.toDoubleOrNull() val lat = value.toDoubleOrNull()
val lon = _uiState.value.stationALon.toDoubleOrNull() val lon = _uiState.value.stationALon.toDoubleOrNull()
if (lat != null && lon != null) { if (lat != null && lon != null) {
_uiState.update { it.copy(stationAGrid = latLonToGrid(lat, lon)) } positionToQth(lat, lon)?.let { grid ->
_uiState.update { it.copy(stationAGrid = grid) }
}
} }
} }
@@ -90,15 +97,18 @@ class MutualViewModel(
val lat = _uiState.value.stationALat.toDoubleOrNull() val lat = _uiState.value.stationALat.toDoubleOrNull()
val lon = value.toDoubleOrNull() val lon = value.toDoubleOrNull()
if (lat != null && lon != null) { if (lat != null && lon != null) {
_uiState.update { it.copy(stationAGrid = latLonToGrid(lat, lon)) } positionToQth(lat, lon)?.let { grid ->
_uiState.update { it.copy(stationAGrid = grid) }
}
} }
} }
fun onStationAGrid(value: String) { fun onStationAGrid(value: String) {
val old = _uiState.value.stationAGrid val old = _uiState.value.stationAGrid
_uiState.update { it.copy(stationAGrid = value) } val newGrid = value.trim().uppercase()
if (value.trim().uppercase() == old.trim().uppercase()) return _uiState.update { it.copy(stationAGrid = newGrid) }
val pos = gridToLatLon(value.trim().uppercase()) if (newGrid == old.trim().uppercase()) return
val pos = qthToPosition(newGrid)
if (pos != null) { if (pos != null) {
_uiState.update { it.copy( _uiState.update { it.copy(
stationALat = "%.4f".format(pos.latitude), stationALat = "%.4f".format(pos.latitude),
@@ -112,7 +122,9 @@ class MutualViewModel(
val lat = value.toDoubleOrNull() val lat = value.toDoubleOrNull()
val lon = _uiState.value.stationBLon.toDoubleOrNull() val lon = _uiState.value.stationBLon.toDoubleOrNull()
if (lat != null && lon != null) { if (lat != null && lon != null) {
_uiState.update { it.copy(stationBGrid = latLonToGrid(lat, lon)) } positionToQth(lat, lon)?.let { grid ->
_uiState.update { it.copy(stationBGrid = grid) }
}
} }
} }
@@ -121,15 +133,18 @@ class MutualViewModel(
val lat = _uiState.value.stationBLat.toDoubleOrNull() val lat = _uiState.value.stationBLat.toDoubleOrNull()
val lon = value.toDoubleOrNull() val lon = value.toDoubleOrNull()
if (lat != null && lon != null) { if (lat != null && lon != null) {
_uiState.update { it.copy(stationBGrid = latLonToGrid(lat, lon)) } positionToQth(lat, lon)?.let { grid ->
_uiState.update { it.copy(stationBGrid = grid) }
}
} }
} }
fun onStationBGrid(value: String) { fun onStationBGrid(value: String) {
val old = _uiState.value.stationBGrid val old = _uiState.value.stationBGrid
_uiState.update { it.copy(stationBGrid = value) } val newGrid = value.trim().uppercase()
if (value.trim().uppercase() == old.trim().uppercase()) return _uiState.update { it.copy(stationBGrid = newGrid) }
val pos = gridToLatLon(value.trim().uppercase()) if (newGrid == old.trim().uppercase()) return
val pos = qthToPosition(newGrid)
if (pos != null) { if (pos != null) {
_uiState.update { it.copy( _uiState.update { it.copy(
stationBLat = "%.4f".format(pos.latitude), stationBLat = "%.4f".format(pos.latitude),
@@ -144,7 +159,7 @@ class MutualViewModel(
_uiState.update { it.copy( _uiState.update { it.copy(
stationALat = "%.4f".format(pos.latitude), stationALat = "%.4f".format(pos.latitude),
stationALon = "%.4f".format(pos.longitude), stationALon = "%.4f".format(pos.longitude),
stationAGrid = latLonToGrid(pos.latitude, pos.longitude) stationAGrid = positionToQth(pos.latitude, pos.longitude) ?: ""
) } ) }
} }
fun onHoursAhead(value: Int) = _uiState.update { it.copy(hoursAhead = value) } fun onHoursAhead(value: Int) = _uiState.update { it.copy(hoursAhead = value) }
@@ -184,7 +199,7 @@ class MutualViewModel(
val minElevB = state.stationBMinElev val minElevB = state.stationBMinElev
val hours = state.hoursAhead val hours = state.hoursAhead
val results = withContext(Dispatchers.Default) { val results = withContext(computeDispatcher) {
findMutualPasses(satellites, posA, posB, minElevA, minElevB, time, hours) findMutualPasses(satellites, posA, posB, minElevA, minElevB, time, hours)
} }
@@ -213,83 +228,13 @@ class MutualViewModel(
private fun resolvePosition(latText: String, lonText: String, gridText: String): GeoPos? { private fun resolvePosition(latText: String, lonText: String, gridText: String): GeoPos? {
val trimmed = gridText.trim().uppercase() val trimmed = gridText.trim().uppercase()
if (trimmed.length in listOf(4, 6, 8) && trimmed.all { it.isLetterOrDigit() }) { if (trimmed.length in listOf(4, 6, 8) && trimmed.all { it.isLetterOrDigit() }) {
return gridToLatLon(trimmed) return qthToPosition(trimmed)
} }
val lat = latText.toDoubleOrNull() val lat = latText.toDoubleOrNull()
val lon = lonText.toDoubleOrNull() val lon = lonText.toDoubleOrNull()
return if (lat != null && lon != null) GeoPos(lat, lon) else null return if (lat != null && lon != null) GeoPos(lat, lon) else null
} }
/** Convert lat/lon to Maidenhead grid (6-char). */
private fun latLonToGrid(lat: Double, lon: Double): String {
var adjLon = (lon + 180.0) % 360.0
var adjLat = (lat + 90.0) % 180.0
val fieldLon = (adjLon / 20.0).toInt()
val fieldLat = (adjLat / 10.0).toInt()
adjLon -= fieldLon * 20.0
adjLat -= fieldLat * 10.0
val squareLon = (adjLon / 2.0).toInt()
val squareLat = (adjLat / 1.0).toInt()
adjLon -= squareLon * 2.0
adjLat -= squareLat * 1.0
val subLon = (adjLon * 60.0 / 5.0).toInt()
val subLat = (adjLat * 60.0 / 2.5).toInt()
return buildString {
append('A' + fieldLon)
append('A' + fieldLat)
append('0' + squareLon)
append('0' + squareLat)
append('A' + subLon)
append('A' + subLat)
}
}
/** Convert Maidenhead grid (4, 6, or 8 chars) to lat/lon center of the square. */
private fun gridToLatLon(grid: String): GeoPos? {
val g = grid.uppercase()
if (g.length < 4) return null
val lonField = (g[0] - 'A').toDouble() * 20.0
val latField = (g[1] - 'A').toDouble() * 10.0
if (lonField < 0 || lonField > 340 || latField < 0 || latField > 170) return null
val lonSquare = (g[2] - '0').toDouble() * 2.0
val latSquare = (g[3] - '0').toDouble() * 1.0
if (lonSquare < 0 || lonSquare > 18 || latSquare < 0 || latSquare > 9) return null
var lon = lonField + lonSquare
var lat = latField + latSquare
if (g.length >= 6) {
val lonSub = (g[4] - 'A').toDouble() * 5.0 / 60.0
val latSub = (g[5] - 'A').toDouble() * 2.5 / 60.0
if (lonSub < 0 || lonSub > 115.0 / 60.0 || latSub < 0 || latSub > 57.5 / 60.0) return null
lon += lonSub
lat += latSub
if (g.length >= 8) {
val lonExt = (g[6] - '0').toDouble() * 30.0 / 3600.0
val latExt = (g[7] - '0').toDouble() * 15.0 / 3600.0
if (lonExt < 0 || lonExt > 270.0 / 3600.0 || latExt < 0 || latExt > 135.0 / 3600.0) return null
lon += lonExt + 15.0 / 3600.0
lat += latExt + 7.5 / 3600.0
} else {
lon += 2.5 / 60.0
lat += 1.25 / 60.0
}
} else {
lon += 1.0
lat += 0.5
}
lon = lon - 180.0
lat = lat - 90.0
return GeoPos(lat, lon)
}
private fun findMutualPasses( private fun findMutualPasses(
satellites: List<OrbitalObject>, satellites: List<OrbitalObject>,
posA: GeoPos, posB: GeoPos, posA: GeoPos, posB: GeoPos,
@@ -0,0 +1,91 @@
package com.rtbishop.look4sat.feature.mutual
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
import com.rtbishop.look4sat.core.domain.model.DatabaseState
import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Test fake for ISatelliteRepo. Only `satellites` and `passes` are backed by
* mutable state; everything else the ViewModel never touches fails loudly.
*/
class FakeSatelliteRepo(
satellites: List<OrbitalObject> = emptyList(),
passes: List<OrbitalPass> = emptyList()
) : ISatelliteRepo {
override val satellites = MutableStateFlow(satellites)
override val passes = MutableStateFlow(passes)
override val isCalculating = MutableStateFlow(false)
override val selectedPass = MutableStateFlow(Pair(0, 0L))
override fun selectPass(catNum: Int, aosTime: Long) = TODO()
override suspend fun initRepository() = TODO()
override suspend fun calculatePasses(
time: Long, hoursAhead: Int, minElevation: Double,
aosStartMinute: Int, aosEndMinute: Int,
invertAosTimeWindow: Boolean, modes: List<String>
) = TODO()
override suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos = TODO()
override suspend fun getTrack(sat: OrbitalObject, pos: GeoPos, start: Long, end: Long): List<OrbitalPos> = TODO()
override suspend fun getRadios(sat: OrbitalObject, pos: GeoPos, radios: List<SatRadio>, time: Long): List<SatRadio> = TODO()
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = TODO()
}
/**
* Test fake for ISettingsRepo. Only [stationPosition] is backed by mutable
* state; everything else the ViewModel never touches fails loudly.
*/
class FakeSettingsRepo(initialPosition: GeoPos = GeoPos(23.13, 113.26)) : ISettingsRepo {
override val stationPosition = MutableStateFlow(initialPosition)
override val appVersionName: String = "test"
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
override val selectedTypes: StateFlow<List<String>> = MutableStateFlow(emptyList())
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
PassesSettings(hoursAhead = 24, minElevation = 0.0, selectedModes = emptyList())
)
override val databaseState: StateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
)
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
OtherSettings(false, false, false, false, false, false, false, false)
)
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = MutableStateFlow(
DataSourcesSettings(satelliteUrls = emptyList(), transceiversUrls = emptyList())
)
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
)
override fun setSelectedIds(ids: List<Int>) = TODO()
override fun setSelectedTypes(types: List<String>) = TODO()
override fun setPassesSettings(settings: PassesSettings) = TODO()
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = TODO()
override fun setStationPosition(): Boolean = TODO()
override fun setStationPosition(locator: String): Boolean = TODO()
override fun getSatelliteTypesIds(types: List<String>): List<Int> = TODO()
override fun setSatelliteTypeIds(type: String, ids: List<Int>) = TODO()
override fun updateDatabaseState(state: DatabaseState) = TODO()
override fun updateRCSettings(settings: RCSettings) = TODO()
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = TODO()
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = TODO()
override fun updateRadioControlSettings(settings: RadioControlSettings) = TODO()
override fun getSatelliteOffset(catnum: Int): String = ""
override fun setSatelliteOffset(catnum: Int, offset: String) = TODO()
override fun getAmSatCallsign(): String = ""
override fun setAmSatCallsign(callsign: String) = TODO()
}
@@ -0,0 +1,25 @@
package com.rtbishop.look4sat.feature.mutual
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.rules.TestWatcher
import org.junit.runner.Description
/**
* Swaps the Main dispatcher for a [StandardTestDispatcher] so that
* `viewModelScope.launch` work can be driven deterministically with
* `runTest` / `advanceUntilIdle()` instead of racing real threads.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherRule(
val dispatcher: TestDispatcher = StandardTestDispatcher()
) : TestWatcher() {
override fun starting(description: Description) = Dispatchers.setMain(dispatcher)
override fun finished(description: Description) = Dispatchers.resetMain()
}
@@ -0,0 +1,288 @@
package com.rtbishop.look4sat.feature.mutual
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import kotlin.random.Random
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class MutualViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
/**
* ViewModel wired to fakes and a test dispatcher that shares the runTest
* scheduler, so [advanceUntilIdle] deterministically drains both the
* viewModelScope (Main) queue and the compute dispatcher.
*/
private fun TestScope.createVm(
satellites: List<OrbitalObject> = emptyList(),
passes: List<OrbitalPass> = emptyList(),
position: GeoPos = TestOrbits.GUANGZHOU
): MutualViewModel = MutualViewModel(
satelliteRepo = FakeSatelliteRepo(satellites, passes),
settingsRepo = FakeSettingsRepo(position),
computeDispatcher = StandardTestDispatcher(mainDispatcherRule.dispatcher.scheduler)
)
private fun TestScope.queryAndSettle(vm: MutualViewModel) {
vm.queryMutualPasses()
advanceUntilIdle()
}
// ---------------------------------------------------------------- init
@Test
fun `init prefills station A with the current position`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
val vm = createVm(position = TestOrbits.GUANGZHOU)
val state = vm.uiState.value
assertEquals("23.1300", state.stationALat)
assertEquals("113.2600", state.stationALon)
assertEquals(6, state.stationAGrid.length)
assertTrue(state.stationAGrid.startsWith("OL63"))
assertTrue(state.stationAGrid.uppercase() == state.stationAGrid)
assertEquals(0.0, state.stationAMinElev, 0.0)
}
// ------------------------------------------------------- station input
@Test
fun `entering latitude recomputes the maidenhead grid`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
val vm = createVm(position = TestOrbits.SHENZHEN) // station A pre-filled with SZ
vm.onStationALat("23.5")
// 23.5N 114.06E -> field OL, square 73 (Shenzhen), subsquare AM
assertEquals("OL73AM", vm.uiState.value.stationAGrid)
// raw input string is stored verbatim, not reformatted
assertEquals("23.5", vm.uiState.value.stationALat)
}
@Test
fun `entering longitude recomputes the maidenhead grid`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
val vm = createVm(position = TestOrbits.GUANGZHOU)
vm.onStationALon("113.5")
// 23.13N 113.5E -> Guangzhou field OL63, subsquare SD
assertEquals("OL63SD", vm.uiState.value.stationAGrid)
// raw input string is stored verbatim, not reformatted
assertEquals("113.5", vm.uiState.value.stationALon)
}
@Test
fun `entering a lowercase grid normalises to uppercase and updates lat lon`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
val vm = createVm(position = TestOrbits.NEW_YORK)
vm.onStationAGrid("ol63")
val state = vm.uiState.value
// The grid is normalised to upper case on input (Bug fix: raw input
// used to be stored verbatim, e.g. "ol63" stayed lowercase).
assertEquals("OL63", state.stationAGrid)
// 4-char grid resolves to the square centre
assertEquals(23.5, state.stationALat.toDouble(), 1e-9)
assertEquals(113.0, state.stationALon.toDouble(), 1e-9)
}
@Test
fun `invalid grid leaves lat and lon untouched`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
val vm = createVm(position = TestOrbits.GUANGZHOU)
val before = vm.uiState.value.stationALat
vm.onStationAGrid("ZZ99") // valid shape, out-of-range letters
assertEquals("ZZ99", vm.uiState.value.stationAGrid)
assertEquals(before, vm.uiState.value.stationALat)
}
@Test
fun `use current position restores station A from settings`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
val vm = createVm(position = TestOrbits.GUANGZHOU)
vm.onStationALat("10.0")
vm.onStationALon("20.0")
assertNotEquals("23.1300", vm.uiState.value.stationALat)
vm.onUseCurrentPosition()
val state = vm.uiState.value
assertEquals("23.1300", state.stationALat)
assertEquals("113.2600", state.stationALon)
assertTrue(state.stationAGrid.startsWith("OL63"))
}
@Test
fun `station B independent from station A`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
val vm = createVm()
vm.onStationBLat("22.54")
vm.onStationBLon("114.06")
// 22.54N 114.06E is Shenzhen: OL72 field (lon square 7, lat square 2)
assertEquals("OL72", vm.uiState.value.stationBGrid.take(4))
assertTrue(vm.uiState.value.stationBGrid.length == 6)
}
// ------------------------------------------------------ query: errors
@Test
fun `query with invalid coordinates sets an error and does not search`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
val vm = createVm(satellites = listOf(TestOrbits.ISS), passes = TestOrbits.findPassWindows())
vm.onStationAGrid("ZZ99")
vm.onStationBGrid("ZZ99")
queryAndSettle(vm)
val state = vm.uiState.value
assertTrue(state.errorMessage != null)
assertFalse(state.hasSearched)
assertFalse(state.isCalculating)
assertTrue(state.mutualPasses.isEmpty())
}
@Test
fun `query with no satellites sets an error`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
val vm = createVm(satellites = emptyList())
vm.onStationBGrid("OL62")
queryAndSettle(vm)
assertEquals(
"No satellite data. Select satellites from the list first.",
vm.uiState.value.errorMessage
)
}
@Test
fun `query with everything empty reports empty pass list`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
val vm = createVm(satellites = listOf(TestOrbits.ISS), passes = emptyList())
vm.onStationBGrid("OL62")
vm.onStationAMinElev(89.0)
vm.onStationBMinElev(89.0)
vm.onHoursAhead(2)
queryAndSettle(vm)
val state = vm.uiState.value
assertFalse(state.isCalculating)
assertEquals("Pass list is empty. Select satellites from the list first.", state.errorMessage)
}
// --------------------------------------------------- query: happy path
@Test
fun `query finds mutual passes reusing the main pass list`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
val windows = TestOrbits.findPassWindows(hoursAhead = 12)
assertTrue("test fixture must produce ISS passes over Guangzhou", windows.isNotEmpty())
val vm = createVm(
satellites = listOf(TestOrbits.ISS),
passes = windows
)
vm.onStationBGrid("OL62")
vm.onHoursAhead(12)
queryAndSettle(vm)
val state = vm.uiState.value
assertNull(state.errorMessage)
assertTrue(state.hasSearched)
assertFalse(state.isCalculating)
assertTrue("expected at least one mutual pass", state.mutualPasses.isNotEmpty())
assertTrue(state.mutualPasses.all { it.catNum == 25544 })
assertTrue(state.mutualPasses.all { it.name == "ISS (ZARYA)" })
// AOS/LOS within the searched horizon
val now = System.currentTimeMillis()
assertTrue(state.mutualPasses.all { it.startTime >= now - 120_000L })
assertTrue(state.mutualPasses.all { it.endTime <= now + 12 * 3600_000L + 120_000L })
}
@Test
fun `query results are sorted by start time even when input is shuffled`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
// A 24h horizon reliably yields several windows per satellite (ISS +
// staggered variant), guaranteeing >= 2 distinct passes.
val windows = TestOrbits.findPassWindows(hoursAhead = 24) +
TestOrbits.findPassWindows(sat = TestOrbits.ISS_VARIANT, hoursAhead = 24)
assertTrue("fixture must produce at least two windows", windows.size >= 2)
val shuffled = windows.shuffled(Random(42))
val vm = createVm(
satellites = listOf(TestOrbits.ISS, TestOrbits.ISS_VARIANT),
passes = shuffled
)
vm.onStationBGrid("OL62")
vm.onHoursAhead(24)
queryAndSettle(vm)
val starts = vm.uiState.value.mutualPasses.map { it.startTime }
assertEquals(starts.sorted(), starts)
// Refined AOS must not drift far from the coarse window
val byAos = windows.associateBy { it.aosTime }
vm.uiState.value.mutualPasses.forEach { mp ->
assertTrue("refined AOS ${mp.startTime} near a coarse AOS", byAos.keys.any { kotlin.math.abs(it - mp.startTime) < 5 * 60_000L })
}
}
// -------------------------------------------------- query: fallback
@Test
fun `query falls back to independent search when pass list is empty`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
// Two satellites (ISS + staggered variant) raise the odds of a window
// within the fallback horizon while keeping the search realistic.
val vm = createVm(
satellites = listOf(TestOrbits.ISS, TestOrbits.ISS_VARIANT),
passes = emptyList()
)
vm.onStationBGrid("OL62")
vm.onHoursAhead(12)
queryAndSettle(vm)
val state = vm.uiState.value
assertFalse(state.isCalculating)
assertTrue(state.hasSearched)
// GZ + SZ are ~100km apart: a 12h ISS window must yield at least one mutual pass
assertNull(state.errorMessage)
assertTrue("fallback should find mutual passes", state.mutualPasses.isNotEmpty())
}
@Test
fun `query over antipodal stations finds no mutual passes`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
val vm = createVm(satellites = listOf(TestOrbits.ISS), passes = emptyList())
vm.onStationBLat("40.71")
vm.onStationBLon("-74.01")
vm.onHoursAhead(2)
queryAndSettle(vm)
val state = vm.uiState.value
assertFalse(state.isCalculating)
assertTrue(state.mutualPasses.isEmpty())
assertTrue(state.errorMessage != null)
}
// -------------------------------------------------------------- misc
@Test
fun `clearError resets the error message`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
val vm = createVm()
vm.onStationAGrid("ZZ99")
vm.onStationBGrid("ZZ99")
queryAndSettle(vm)
assertTrue(vm.uiState.value.errorMessage != null)
vm.clearError()
assertNull(vm.uiState.value.errorMessage)
}
}
@@ -0,0 +1,104 @@
package com.rtbishop.look4sat.feature.mutual
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.NearEarthObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalData
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import kotlin.math.PI
/**
* Deterministic orbital test fixtures: real ISS TLE elements (2024-03-09,
* same source as DataParserTest) so SGP4 propagation produces genuine
* visibility windows instead of fabricated numbers.
*/
object TestOrbits {
/** ISS (ZARYA) from the 2024-03-09 TLE used in DataParserTest. */
val ISS: OrbitalObject = NearEarthObject(
OrbitalData(
name = "ISS (ZARYA)",
epoch = 24069.23963816,
meanmo = 15.49756209,
eccn = 0.0005741,
incl = 51.6418,
raan = 90.7424,
argper = 343.9724,
meanan = 92.8274,
catnum = 25544,
bstar = 0.00025016
)
)
/** Guangzhou (default BA7OPF grid OL62/OL63 area). */
val GUANGZHOU = GeoPos(23.13, 113.26)
/** Shenzhen, ~100 km from Guangzhou — nearly every LEO pass is mutual. */
val SHENZHEN = GeoPos(22.54, 114.06)
/** New York — on the opposite side of the globe, never mutual with GZ. */
val NEW_YORK = GeoPos(40.71, -74.01)
/**
* Second LEO with the same ISS elements but a different argument of
* perigee, so its visibility windows land at different times. Gives tests
* two distinct passes to exercise ordering/aggregation logic.
*/
val ISS_VARIANT: OrbitalObject = NearEarthObject(
OrbitalData(
name = "ISS-V2",
epoch = 24069.23963816,
meanmo = 15.49756209,
eccn = 0.0005741,
incl = 51.6418,
raan = 90.7424,
argper = 100.0,
meanan = 92.8274,
catnum = 25545,
bstar = 0.00025016
)
)
/**
* Coarse pass-window search (30s steps) producing OrbitalPass entries with
* honest AOS/LOS times. Mirrors the coarse phase of SatelliteRepo.getLeoPass;
* the ViewModel under test re-refines the edges to ~1s itself.
*/
fun findPassWindows(
sat: OrbitalObject = ISS,
pos: GeoPos = GUANGZHOU,
time: Long = System.currentTimeMillis(),
hoursAhead: Int = 12
): List<OrbitalPass> {
if (!sat.willBeSeen(pos)) return emptyList()
val passes = mutableListOf<OrbitalPass>()
val step = 30_000L
val end = time + hoursAhead * 3600_000L
var t = time
var inWindow = false
var aos = 0L
var maxElev = 0.0
while (t < end) {
val elev = sat.getElevation(pos, t) * 180.0 / PI
if (elev > 0.0 && !inWindow) {
inWindow = true
aos = t
maxElev = 0.0
}
if (inWindow && elev > maxElev) maxElev = elev
if (elev <= 0.0 && inWindow) {
inWindow = false
passes.add(
OrbitalPass(
aosTime = aos,
losTime = t,
maxElevation = maxElev,
orbitalObject = sat
)
)
}
t += step
}
return passes
}
}