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.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()
|
||||||
|
}
|
||||||
+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