mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
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:
1 parent
47b87a1604
commit
346803fe6e
5 files changed
+539
-86
No files matched your search
+31
-86
@@ -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.ISatelliteRepo
|
||||
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.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -34,7 +37,6 @@ import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
data class MutualUiState(
|
||||
@@ -56,7 +58,10 @@ data class MutualUiState(
|
||||
|
||||
class MutualViewModel(
|
||||
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() {
|
||||
|
||||
private val _uiState = MutableStateFlow(MutualUiState())
|
||||
@@ -66,7 +71,7 @@ class MutualViewModel(
|
||||
// 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
|
||||
val pos = settingsRepo.stationPosition.value
|
||||
val grid = latLonToGrid(pos.latitude, pos.longitude)
|
||||
val grid = positionToQth(pos.latitude, pos.longitude) ?: ""
|
||||
_uiState.update { it.copy(
|
||||
stationAGrid = grid,
|
||||
stationALat = "%.4f".format(pos.latitude),
|
||||
@@ -81,7 +86,9 @@ class MutualViewModel(
|
||||
val lat = value.toDoubleOrNull()
|
||||
val lon = _uiState.value.stationALon.toDoubleOrNull()
|
||||
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 lon = value.toDoubleOrNull()
|
||||
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) {
|
||||
val old = _uiState.value.stationAGrid
|
||||
_uiState.update { it.copy(stationAGrid = value) }
|
||||
if (value.trim().uppercase() == old.trim().uppercase()) return
|
||||
val pos = gridToLatLon(value.trim().uppercase())
|
||||
val newGrid = value.trim().uppercase()
|
||||
_uiState.update { it.copy(stationAGrid = newGrid) }
|
||||
if (newGrid == old.trim().uppercase()) return
|
||||
val pos = qthToPosition(newGrid)
|
||||
if (pos != null) {
|
||||
_uiState.update { it.copy(
|
||||
stationALat = "%.4f".format(pos.latitude),
|
||||
@@ -112,7 +122,9 @@ class MutualViewModel(
|
||||
val lat = value.toDoubleOrNull()
|
||||
val lon = _uiState.value.stationBLon.toDoubleOrNull()
|
||||
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 lon = value.toDoubleOrNull()
|
||||
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) {
|
||||
val old = _uiState.value.stationBGrid
|
||||
_uiState.update { it.copy(stationBGrid = value) }
|
||||
if (value.trim().uppercase() == old.trim().uppercase()) return
|
||||
val pos = gridToLatLon(value.trim().uppercase())
|
||||
val newGrid = value.trim().uppercase()
|
||||
_uiState.update { it.copy(stationBGrid = newGrid) }
|
||||
if (newGrid == old.trim().uppercase()) return
|
||||
val pos = qthToPosition(newGrid)
|
||||
if (pos != null) {
|
||||
_uiState.update { it.copy(
|
||||
stationBLat = "%.4f".format(pos.latitude),
|
||||
@@ -144,7 +159,7 @@ class MutualViewModel(
|
||||
_uiState.update { it.copy(
|
||||
stationALat = "%.4f".format(pos.latitude),
|
||||
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) }
|
||||
@@ -184,7 +199,7 @@ class MutualViewModel(
|
||||
val minElevB = state.stationBMinElev
|
||||
val hours = state.hoursAhead
|
||||
|
||||
val results = withContext(Dispatchers.Default) {
|
||||
val results = withContext(computeDispatcher) {
|
||||
findMutualPasses(satellites, posA, posB, minElevA, minElevB, time, hours)
|
||||
}
|
||||
|
||||
@@ -213,83 +228,13 @@ class MutualViewModel(
|
||||
private fun resolvePosition(latText: String, lonText: String, gridText: String): GeoPos? {
|
||||
val trimmed = gridText.trim().uppercase()
|
||||
if (trimmed.length in listOf(4, 6, 8) && trimmed.all { it.isLetterOrDigit() }) {
|
||||
return gridToLatLon(trimmed)
|
||||
return qthToPosition(trimmed)
|
||||
}
|
||||
val lat = latText.toDoubleOrNull()
|
||||
val lon = lonText.toDoubleOrNull()
|
||||
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(
|
||||
satellites: List<OrbitalObject>,
|
||||
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()
|
||||
}
|
||||
+25
@@ -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()
|
||||
}
|
||||
+288
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user