From 346803fe6eaae9f3c84711048662539451aadff5 Mon Sep 17 00:00:00 2001 From: atsunatsu Date: Sat, 29 Aug 2026 10:55:15 +0800 Subject: [PATCH] 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 --- .../feature/mutual/MutualViewModel.kt | 117 ++----- .../rtbishop/look4sat/feature/mutual/Fakes.kt | 91 ++++++ .../feature/mutual/MainDispatcherRule.kt | 25 ++ .../feature/mutual/MutualViewModelTest.kt | 288 ++++++++++++++++++ .../look4sat/feature/mutual/TestOrbits.kt | 104 +++++++ 5 files changed, 539 insertions(+), 86 deletions(-) create mode 100644 feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/Fakes.kt create mode 100644 feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/MainDispatcherRule.kt create mode 100644 feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/MutualViewModelTest.kt create mode 100644 feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/TestOrbits.kt diff --git a/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualViewModel.kt b/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualViewModel.kt index 7d4e38ef..f7ed0659 100644 --- a/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualViewModel.kt +++ b/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualViewModel.kt @@ -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, posA: GeoPos, posB: GeoPos, diff --git a/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/Fakes.kt b/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/Fakes.kt new file mode 100644 index 00000000..1c9f68a7 --- /dev/null +++ b/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/Fakes.kt @@ -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 = emptyList(), + passes: List = 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 + ) = 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 = TODO() + override suspend fun getRadios(sat: OrbitalObject, pos: GeoPos, radios: List, time: Long): List = TODO() + override suspend fun getRadiosWithId(id: Int): List = 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> = MutableStateFlow(emptyList()) + override val selectedTypes: StateFlow> = MutableStateFlow(emptyList()) + override val passesSettings: StateFlow = MutableStateFlow( + PassesSettings(hoursAhead = 24, minElevation = 0.0, selectedModes = emptyList()) + ) + override val databaseState: StateFlow = MutableStateFlow(DatabaseState(0, 0, 0L)) + override val rcSettings: StateFlow = MutableStateFlow( + RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "") + ) + override val otherSettings: StateFlow = MutableStateFlow( + OtherSettings(false, false, false, false, false, false, false, false) + ) + override val dataSourcesSettings: StateFlow = MutableStateFlow( + DataSourcesSettings(satelliteUrls = emptyList(), transceiversUrls = emptyList()) + ) + override val radioControlSettings: StateFlow = MutableStateFlow( + RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600) + ) + + override fun setSelectedIds(ids: List) = TODO() + override fun setSelectedTypes(types: List) = 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): List = TODO() + override fun setSatelliteTypeIds(type: String, ids: List) = 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() +} \ No newline at end of file diff --git a/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/MainDispatcherRule.kt b/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/MainDispatcherRule.kt new file mode 100644 index 00000000..5776b71e --- /dev/null +++ b/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/MainDispatcherRule.kt @@ -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() +} \ No newline at end of file diff --git a/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/MutualViewModelTest.kt b/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/MutualViewModelTest.kt new file mode 100644 index 00000000..c9b79ff8 --- /dev/null +++ b/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/MutualViewModelTest.kt @@ -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 = emptyList(), + passes: List = 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) + } +} \ No newline at end of file diff --git a/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/TestOrbits.kt b/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/TestOrbits.kt new file mode 100644 index 00000000..34ba3d09 --- /dev/null +++ b/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/TestOrbits.kt @@ -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 { + if (!sat.willBeSeen(pos)) return emptyList() + val passes = mutableListOf() + 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 + } +} \ No newline at end of file