feat: mutual pass FM/Linear transponder filters (AMSAT Live source)

Mutual match page gains FM/Linear transponder-type filters. The
authoritative source is AMSAT's human-maintained Live FM/Linear
Satellites pages, fetched during data sync and stored as catnum sets;
filters resolve to those sets (falling back to DB mode filter when
empty), and unchecking both shows a prompt instead of querying.

- DataParser.parseAmSatLivePage: table rows to satellite names,
  TEVEL2-1 thru TEVEL2-9 range expansion, bracket-alias matching
- SettingsRepo: AMSAT FM/Linear catnum sets persisted in prefs
- DatabaseRepo: fetch AMSAT pages in updateFromRemote, best-effort
  (failure keeps previous list, never blocks the update)
- getIdsWithModes: AND isAlive = 1
- MutualScreen: FilterChip row inside MatchSearchCard
- Tests: parser (4) + viewmodel (3) + repo fakes
This commit is contained in:
atsunatsu committed 2026-09-23 23:25:37 +08:00
1 parent c4c4dd682d
commit 85a4b9cfa3
17 files changed
+367 -6

No files matched your search

@@ -45,7 +45,7 @@ interface Look4SatDao {
@Query("DELETE FROM entries")
suspend fun deleteEntries()
@Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes)")
@Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes) AND isAlive = 1")
suspend fun getIdsWithModes(modes: List<String>): List<Int>
@Query("SELECT COUNT(*) FROM radios")
@@ -115,12 +115,48 @@ class DatabaseRepo(
localSource.insertRadios(importedRadios)
}
if (importedEntries.isNotEmpty()) localSource.insertEntries(importedEntries)
updateAmSatLiveLists()
setUpdateSuccessful(System.currentTimeMillis())
}
/**
* Refresh the AMSAT Live FM/Linear satellite lists (transponders currently
* on the air). Best effort: any failure keeps the previous lists and never
* blocks the rest of the data update, so the mutual-match filter simply
* falls back to the last successful snapshot.
*/
private suspend fun updateAmSatLiveLists() = withContext(dispatcher) {
runCatching {
val entries = localSource.getEntriesList() // catnum -> name
val jobs = Sources.amSatLiveUrls.map { (type, url) ->
async { type to remoteSource.getNetworkStream(url) }
}
val results = jobs.awaitAll()
val fmNames = results.firstOrNull { it.first == "FM" }?.second?.stream
?.let { dataParser.parseAmSatLivePage(it) }.orEmpty()
val linearNames = results.firstOrNull { it.first == "Linear" }?.second?.stream
?.let { dataParser.parseAmSatLivePage(it) }.orEmpty()
val nameToCatnum = entries.associate { it.name.uppercase() to it.catnum }
fun resolve(names: List<String>): Set<Int> = names.mapNotNull { name ->
val keys = dataParser.normalizeAmSatName(name)
nameToCatnum.entries.firstOrNull { (localName, _) ->
keys.any { key -> localName.contains(key) }
}?.value
}.toSet()
val fmCatnums = resolve(fmNames)
val linearCatnums = resolve(linearNames)
settingsRepo.setAmSatCatnums(fmCatnums, linearCatnums)
println("AMSAT live lists updated: FM=${fmCatnums.size}, Linear=${linearCatnums.size}")
}.onFailure {
// Keep the previous lists; the mutual filter stays on the last good snapshot.
println("AMSAT live lists update failed: $it")
}
}
override suspend fun clearAllData() = withContext(dispatcher) {
localSource.deleteEntries()
localSource.deleteRadios()
settingsRepo.setAmSatCatnums(emptySet(), emptySet())
setUpdateSuccessful(0L)
}
@@ -65,6 +65,8 @@ class SatelliteRepo(
override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
override suspend fun getSatelliteIdsWithModes(modes: List<String>) = localStorage.getIdsWithModes(modes)
override suspend fun initRepository() = withContext(dispatcher) {
combine(
settingsRepo.selectedIds,
@@ -107,6 +107,8 @@ class SettingsRepo(
private val keySatelliteEnabled = "satelliteEnabled"
private val keyTransceiversEnabled = "transceiversEnabled"
private val keySatnogsTleSourceMigration = "satnogsTleSourceMigration"
private val keyAmSatFmCatnums = "amSatFmCatnums"
private val keyAmSatLinearCatnums = "amSatLinearCatnums"
private val separatorComma = ","
private val separatorUrl = "\n"
private val legacyCelestrakSatnogsUrl =
@@ -745,6 +747,25 @@ class SettingsRepo(
}
//endregion
//region # AMSAT live-transponder lists
override fun getAmSatFmCatnums(): Set<Int> {
val raw = preferences.getString(keyAmSatFmCatnums, null) ?: return emptySet()
return raw.split(separatorComma).mapNotNull { it.toIntOrNull() }.toSet()
}
override fun getAmSatLinearCatnums(): Set<Int> {
val raw = preferences.getString(keyAmSatLinearCatnums, null) ?: return emptySet()
return raw.split(separatorComma).mapNotNull { it.toIntOrNull() }.toSet()
}
override fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>) {
preferences.edit {
putString(keyAmSatFmCatnums, fmCatnums.sorted().joinToString(separatorComma))
putString(keyAmSatLinearCatnums, linearCatnums.sorted().joinToString(separatorComma))
}
}
//endregion
//region # Radio control settings
private val keyRadioControlEnabled = "radioControlEnabled"
private val keyRadioModel = "radioModel"
@@ -269,6 +269,10 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
(dataSourcesStatus as? MutableStateFlow)?.value = status
}
override fun getAmSatFmCatnums(): Set<Int> = emptySet()
override fun getAmSatLinearCatnums(): Set<Int> = emptySet()
override fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>) = Unit
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
override fun getSatelliteOffset(catnum: Int): String = ""
@@ -65,4 +65,9 @@ interface ISatelliteRepo {
/** Fetch radio transceivers for a satellite by its catalog number. */
suspend fun getRadiosWithId(id: Int): List<SatRadio>
/** Satellite catnums whose radios carry any of the given downlink modes.
* Empty modes -> empty result; used to filter the mutual-match satellite
* set to transponder satellites (FM voice / linear). */
suspend fun getSatelliteIdsWithModes(modes: List<String>): List<Int>
}
@@ -79,6 +79,14 @@ interface ISettingsRepo {
fun updateDataSourcesStatus(status: Map<String, Int>)
//endregion
//region # AMSAT live-transponder lists
/** Catalog numbers of satellites listed on AMSAT's "Live FM Satellites" page. */
fun getAmSatFmCatnums(): Set<Int>
/** Catalog numbers of satellites listed on AMSAT's "Live Linear Satellites" page. */
fun getAmSatLinearCatnums(): Set<Int>
fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>)
//endregion
//region # Radio control settings
val radioControlSettings: StateFlow<RadioControlSettings>
fun updateRadioControlSettings(settings: RadioControlSettings)
@@ -55,4 +55,11 @@ object Sources {
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active",
"R4UAB" to "https://r4uab.ru/transmitters.json"
)
/** AMSAT "Live FM/Linear Satellites" pages: human-maintained lists of
* transponders currently on the air. Parsed into the mutual-match filter
* (satellites that actually carry a working FM-voice / linear repeater). */
val amSatLiveUrls = mapOf(
"FM" to "https://www.amsat.org/live-fm-satellites/",
"Linear" to "https://www.amsat.org/live-linear-satellites/"
)
}
@@ -58,6 +58,82 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
}.getOrDefault(emptyList())
}
/**
* Parse an AMSAT "Live FM/Linear Satellites" page into satellite names.
* The page is an HTML table whose first column is the satellite name,
* sometimes written as an alias list ("AO-91(RadFxSat / Fox-1B)") or a
* range ("TEVEL2-1 thru TEVEL2-9"). Returns the distinct expanded names.
*/
suspend fun parseAmSatLivePage(stream: InputStream): List<String> = withContext(dispatcher) {
runCatching {
val html = stream.bufferedReader().readText()
// Grab table rows, take the first cell of each row, keep rows that
// look like satellite names (start with letters/digits, not a header).
val rows = Regex("<tr[^>]*>(.*?)</tr>", RegexOption.IGNORE_CASE)
.findAll(html)
.mapNotNull { match ->
val cells = Regex("<t[dh][^>]*>(.*?)</t[dh]>", RegexOption.IGNORE_CASE)
.findAll(match.groupValues[1])
.map { cell -> stripHtml(cell.groupValues[1]).trim() }
.toList()
cells.firstOrNull()?.takeIf { it.isNotBlank() && !it.equals("Satellite", ignoreCase = true) }
}
.toList()
rows.flatMap { expandNameRange(it) }.distinct()
}.getOrDefault(emptyList())
}
private fun stripHtml(raw: String): String =
raw.replace(Regex("<[^>]+>"), " ").replace(Regex("\\s+"), " ").trim()
/** Expand "NAME1 thru NAME9" ranges (e.g. TEVEL2-1 thru TEVEL2-9). */
private fun expandNameRange(name: String): List<String> {
val m = Regex("^(.*?)(\\d+)\\s+thru\\s+(.*?)(\\d+)$", RegexOption.IGNORE_CASE).find(name)
if (m == null) return listOf(name)
val prefix1 = m.groupValues[1].trim()
val startNum = m.groupValues[2].toIntOrNull() ?: return listOf(name)
val endNum = m.groupValues[4].toIntOrNull() ?: return listOf(name)
val prefix2 = m.groupValues[3].trim()
if (startNum > endNum) return listOf(name)
// Suffix after the end number, if any (e.g. "TEVEL2-1 thru TEVEL2-9 ").
val suffix = name.substringAfterLast(m.groupValues[4]).trim()
return (startNum..endNum).map { "$prefix1$it$suffix" }
}
/**
* Normalize a satellite name from the AMSAT live pages into lookup keys
* used to match against the local entries table:
* - primary key: the leading designator ("AO-91" from "AO-91 (RadFxSat / Fox-1B)")
* - alias keys: every bracketed alias, uppercased, digits preserved
* The local entry names are normalized the same way in [matchesAmSatName].
*/
fun normalizeAmSatName(name: String): List<String> {
val keys = mutableListOf<String>()
// Primary: everything before the first '(' (or '['), then the first token.
val primary = name.substringBefore('(').substringBefore('[').trim()
primary.split(Regex("\\s+")).firstOrNull()?.takeIf { it.isNotBlank() }?.let {
keys += it.uppercase()
}
// Aliases inside parentheses / brackets: "RadFxSat / Fox-1B" -> two keys.
Regex("\\(([^)]*)\\)").findAll(name).forEach { m ->
m.groupValues[1].split('/', '|').forEach { alias ->
alias.trim().takeIf { it.isNotBlank() }?.let { keys += it.uppercase() }
}
}
Regex("\\[([^]]*)]").findAll(name).forEach { m ->
m.groupValues[1].split('/', '|').forEach { alias ->
alias.trim().takeIf { it.isNotBlank() }?.let { keys += it.uppercase() }
}
}
return keys.distinct()
}
/** True if a local entry name matches any of the AMSAT normalized keys. */
fun matchesAmSatName(localName: String, amSatKeys: List<String>): Boolean {
val localUpper = localName.uppercase()
return amSatKeys.any { key -> localUpper.contains(key) }
}
private fun parseCSV(values: List<String>): OrbitalData? = runCatching {
val name = values[0]
val timestamp = values[2]
@@ -239,4 +239,51 @@ class DataParserTest {
// Matches the CSV test data epoch: 2021-11-16 → day 320
assert(dataParser.getDayOfYear(2021, 11, 16) == 320)
}
@Test
fun `AMSAT FM page parses satellite names and expands thru ranges`() = runTest(testDispatcher) {
val html = """
<html><body><table>
<tr><th>Satellite</th><th>Uplink</th><th>Downlink</th><th>Comment</th></tr>
<tr><td>AO-91(RadFxSat / Fox-1B)</td><td>435.250</td><td>145.960</td></tr>
<tr><td>SO-50(SaudiSat-1C)</td><td>145.850</td><td>436.795</td></tr>
<tr><td>TEVEL2-1 thru TEVEL2-9</td><td>145.970</td><td>436.400</td></tr>
</table></body></html>
""".trimIndent().byteInputStream()
val names = dataParser.parseAmSatLivePage(html)
assert(names.contains("AO-91(RadFxSat / Fox-1B)"))
assert(names.contains("SO-50(SaudiSat-1C)"))
assert(names.contains("TEVEL2-1"))
assert(names.contains("TEVEL2-9"))
assert(names.size == 11) // 2 singles + 9 expanded TEVEL2
}
@Test
fun `AMSAT linear page keeps mode-suffixed names`() = runTest(testDispatcher) {
val html = """
<table>
<tr><th>Satellite</th><th>Frequencies</th><th>Comment</th></tr>
<tr><td>AO-7 Mode B U/v Inverting Analog</td><td>Uplink LSB</td></tr>
<tr><td>FO-29 (JAS-2)V/u Inverting Analog</td><td>Uplink LSB</td></tr>
</table>
""".trimIndent().byteInputStream()
val names = dataParser.parseAmSatLivePage(html)
assert(names.contains("AO-7 Mode B U/v Inverting Analog"))
assert(names.contains("FO-29 (JAS-2)V/u Inverting Analog"))
}
@Test
fun `AMSAT name normalization produces primary and alias keys`() {
assert(dataParser.normalizeAmSatName("AO-91(RadFxSat / Fox-1B)") == listOf("AO-91", "RADFXSAT", "FOX-1B"))
assert(dataParser.normalizeAmSatName("QO-100 (Es'hail-2/P4A) S/x") == listOf("QO-100", "ES'HAIL-2", "P4A"))
assert(dataParser.normalizeAmSatName("ISS") == listOf("ISS"))
assert(dataParser.matchesAmSatName("AO-91 (RADFXSAT)", listOf("AO-91", "RADFXSAT")))
assert(!dataParser.matchesAmSatName("SO-50", listOf("AO-91")))
}
@Test
fun `AMSAT live page with no table returns empty`() = runTest(testDispatcher) {
val html = "<html><body>No data</body></html>".byteInputStream()
assert(dataParser.parseAmSatLivePage(html).isEmpty())
}
}
@@ -283,6 +283,9 @@ class LoTWGridSyncTest {
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = TODO()
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = TODO()
override fun updateDataSourcesStatus(status: Map<String, Int>) = TODO()
override fun getAmSatFmCatnums(): Set<Int> = emptySet()
override fun getAmSatLinearCatnums(): Set<Int> = emptySet()
override fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>) = Unit
override fun updateRadioControlSettings(settings: RadioControlSettings) = TODO()
override fun getSatelliteOffset(catnum: Int): String = ""
override fun setSatelliteOffset(catnum: Int, offset: String) = TODO()
@@ -308,6 +308,9 @@
<string name="mutual_grid_hint">支持 4/6/8 位 Maidenhead 网格;也可直接输入经纬度</string>
<string name="mutual_min_elevation">最小仰角:%d°</string>
<string name="mutual_time_range">时间范围</string>
<string name="mutual_filter_transponder">转发器</string>
<string name="mutual_filter_fm">FM</string>
<string name="mutual_filter_linear">线性</string>
<string name="mutual_calculating">计算中…</string>
<string name="mutual_query">查询匹配</string>
<string name="mutual_elevation_you">你:%d°</string>
@@ -338,6 +338,9 @@
<string name="mutual_grid_hint">Supports 4/6/8-char Maidenhead grid; or enter lat/lon directly</string>
<string name="mutual_min_elevation">Min elevation: %d°</string>
<string name="mutual_time_range">Time range</string>
<string name="mutual_filter_transponder">Transponders</string>
<string name="mutual_filter_fm">FM</string>
<string name="mutual_filter_linear">Linear</string>
<string name="mutual_calculating">Calculating…</string>
<string name="mutual_query">Query Match</string>
<string name="mutual_elevation_you">You: %d°</string>
@@ -174,6 +174,8 @@ fun MutualScreen(
onStationBMinElev = viewModel::onStationBMinElev,
onUseCurrentPosition = viewModel::onUseCurrentPosition,
onHoursAhead = viewModel::onHoursAhead,
onFilterFM = viewModel::onFilterFM,
onFilterLinear = viewModel::onFilterLinear,
onClearError = viewModel::clearError
)
}
@@ -197,6 +199,8 @@ private fun MutualContent(
onStationBMinElev: (Double) -> Unit,
onUseCurrentPosition: () -> Unit,
onHoursAhead: (Int) -> Unit,
onFilterFM: (Boolean) -> Unit,
onFilterLinear: (Boolean) -> Unit,
onClearError: () -> Unit
) {
val timeFormat = remember(state.isUtc) {
@@ -353,6 +357,10 @@ private fun MutualContent(
MatchSearchCard(
hoursAhead = state.hoursAhead,
isCalculating = state.isCalculating,
filterFM = state.filterFM,
filterLinear = state.filterLinear,
onFilterFM = onFilterFM,
onFilterLinear = onFilterLinear,
onHoursAhead = onHoursAhead,
onQuery = onQuery
)
@@ -570,6 +578,10 @@ private fun StationInputCard(
private fun MatchSearchCard(
hoursAhead: Int,
isCalculating: Boolean,
filterFM: Boolean,
filterLinear: Boolean,
onFilterFM: (Boolean) -> Unit,
onFilterLinear: (Boolean) -> Unit,
onHoursAhead: (Int) -> Unit,
onQuery: () -> Unit,
modifier: Modifier = Modifier
@@ -596,6 +608,28 @@ private fun MatchSearchCard(
)
}
}
// 转发器类型筛选: 只匹配 AMSAT Live 清单里的 FM 话音 / 线性转发器卫星,
// 防止选入大量无转发器卫星占用匹配页.
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.mutual_filter_transponder),
style = MaterialTheme.typography.labelLarge
)
FilterChip(
selected = filterFM,
onClick = { onFilterFM(!filterFM) },
label = { Text(stringResource(R.string.mutual_filter_fm)) }
)
FilterChip(
selected = filterLinear,
onClick = { onFilterLinear(!filterLinear) },
label = { Text(stringResource(R.string.mutual_filter_linear)) }
)
}
Button(
onClick = onQuery,
enabled = !isCalculating,
@@ -55,6 +55,10 @@ data class MutualUiState(
/** 历史过境回看窗口(小时), 跟随主页面 Passes 设置; 匹配查询从
* now-hoursBefore 开始, 使设置过历史回看时匹配页也包含历史过境. */
val hoursBefore: Int = 0,
/** 转发器类型筛选: 只匹配搭载 FM 话音转发器的卫星. 默认开启. */
val filterFM: Boolean = true,
/** 转发器类型筛选: 只匹配搭载线性(USB/LSB/CW/SSB)转发器的卫星. 默认开启. */
val filterLinear: Boolean = true,
val mutualPasses: List<MutualPass> = emptyList(),
val isCalculating: Boolean = false,
val hasSearched: Boolean = false,
@@ -201,6 +205,8 @@ class MutualViewModel(
) }
}
fun onHoursAhead(value: Int) = _uiState.update { it.copy(hoursAhead = value) }
fun onFilterFM(value: Boolean) = _uiState.update { it.copy(filterFM = value) }
fun onFilterLinear(value: Boolean) = _uiState.update { it.copy(filterLinear = value) }
fun onSelectPass(index: Int) = _uiState.update { it.copy(selectedPassIndex = index) }
/**
@@ -264,6 +270,17 @@ class MutualViewModel(
return
}
// 转发器类型筛选: 至少勾选一种(默认 FM + Linear 都开), 否则提示不查询.
// 防止用户选入大量无转发器卫星占用匹配页.
if (!state.filterFM && !state.filterLinear) {
_uiState.update { it.copy(errorMessage = "Select at least one transponder type (FM / Linear) to run the match.") }
return
}
// AMSAT Live 清单(同步自 AMSAT "Live FM/Linear Satellites" 页面)是本过滤的
// 权威来源: 只匹配当前在轨且话音/线性转发器实际工作的卫星. 清单为空
// (从未成功同步过)时回退到数据库 downlinkMode 过滤, 避免首启匹配页空结果.
// 过滤本身在下面的协程里执行 (getSatelliteIdsWithModes 是 suspend).
_uiState.update {
it.copy(
isCalculating = true,
@@ -286,6 +303,24 @@ class MutualViewModel(
viewModelScope.launch {
val now = System.currentTimeMillis()
// 转发器类型过滤(协程上下文): AMSAT Live 清单优先, 为空时回退数据库.
val amSatFm = settingsRepo.getAmSatFmCatnums()
val amSatLinear = settingsRepo.getAmSatLinearCatnums()
val filteredSatellites = if (amSatFm.isEmpty() && amSatLinear.isEmpty()) {
val filterModes = buildList {
if (state.filterFM) add("FM")
if (state.filterLinear) addAll(listOf("USB", "LSB", "CW", "SSB"))
}
val idsWithModes = satelliteRepo.getSatelliteIdsWithModes(filterModes)
if (idsWithModes.isEmpty()) satellites
else satellites.filter { it.data.catnum in idsWithModes }
} else {
val allowed = buildSet {
if (state.filterFM) addAll(amSatFm)
if (state.filterLinear) addAll(amSatLinear)
}
satellites.filter { it.data.catnum in allowed }
}
// 历史回看: 查询窗口起点 = now - hoursBefore (跟随主页面 Passes 设置),
// 终点 = now + hoursAhead 不变.
val time = now - state.hoursBefore * 60L * 60L * 1000L
@@ -295,7 +330,7 @@ class MutualViewModel(
val results = try {
withContext(computeDispatcher) {
findMutualPasses(satellites, posA, posB, minElevA, minElevB, time, hours, state.hoursBefore)
findMutualPasses(filteredSatellites, posA, posB, minElevA, minElevB, time, hours, state.hoursBefore)
}
} catch (t: Throwable) {
// Never leave the query stuck in "calculating" (which would also
@@ -350,7 +385,10 @@ class MutualViewModel(
// Use the main page's pass list for AOS/LOS times, then sample the curves
// using the actual positions. The passes list is already computed by getLeoPass
// and its AOS/LOS times match the Passes page exactly.
val existingPasses = satelliteRepo.passes.value
// The satellites argument may be a subset (transponder-type filter), so
// only passes belonging to the filtered satellites are reused.
val allowedCatnums = satellites.map { it.data.catnum }.toSet()
val existingPasses = satelliteRepo.passes.value.filter { it.catNum in allowedCatnums }
val results = findMutualPassesFromList(existingPasses, satellites, posA, posB,
minElevADeg, minElevBDeg, time, endTime, sampleInterval)
if (results.isNotEmpty()) return results
@@ -44,13 +44,18 @@ class FakeSatelliteRepo(
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()
override suspend fun getSatelliteIdsWithModes(modes: List<String>): List<Int> = emptyList()
}
/**
* 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 {
class FakeSettingsRepo(
initialPosition: GeoPos = GeoPos(23.13, 113.26),
val amSatFm: Set<Int> = emptySet(),
val amSatLinear: Set<Int> = emptySet()
) : ISettingsRepo {
override val stationPosition = MutableStateFlow(initialPosition)
@@ -103,6 +108,10 @@ class FakeSettingsRepo(initialPosition: GeoPos = GeoPos(23.13, 113.26)) : ISetti
override fun updateDataSourcesStatus(status: Map<String, Int>) = TODO()
override fun getAmSatFmCatnums(): Set<Int> = amSatFm
override fun getAmSatLinearCatnums(): Set<Int> = amSatLinear
override fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>) = TODO()
override fun updateRadioControlSettings(settings: RadioControlSettings) = TODO()
override fun getSatelliteOffset(catnum: Int): String = ""
override fun setSatelliteOffset(catnum: Int, offset: String) = TODO()
@@ -31,10 +31,12 @@ class MutualViewModelTest {
private fun TestScope.createVm(
satellites: List<OrbitalObject> = emptyList(),
passes: List<OrbitalPass> = emptyList(),
position: GeoPos = TestOrbits.GUANGZHOU
position: GeoPos = TestOrbits.GUANGZHOU,
amSatFm: Set<Int> = emptySet(),
amSatLinear: Set<Int> = emptySet()
): MutualViewModel = MutualViewModel(
satelliteRepo = FakeSatelliteRepo(satellites, passes),
settingsRepo = FakeSettingsRepo(position),
settingsRepo = FakeSettingsRepo(position, amSatFm, amSatLinear),
computeDispatcher = StandardTestDispatcher(mainDispatcherRule.dispatcher.scheduler)
)
@@ -320,4 +322,67 @@ class MutualViewModelTest {
assertEquals(4, vm.listScrollIndex)
assertEquals(137, vm.listScrollOffset)
}
// --------------------------------------------- transponder filters (AMSAT)
@Test
fun `both filters off sets an error and does not search`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
val windows = TestOrbits.findPassWindows()
val vm = createVm(satellites = listOf(TestOrbits.ISS), passes = windows)
vm.onStationBGrid("OL62")
vm.onFilterFM(false)
vm.onFilterLinear(false)
queryAndSettle(vm)
assertEquals(
"Select at least one transponder type (FM / Linear) to run the match.",
vm.uiState.value.errorMessage
)
assertFalse(vm.uiState.value.hasSearched)
}
@Test
fun `AMSAT filter restricts results to listed catnums`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
val windows = TestOrbits.findPassWindows(hoursAhead = 24) +
TestOrbits.findPassWindows(sat = TestOrbits.ISS_VARIANT, hoursAhead = 24)
assertTrue("fixture must produce windows", windows.isNotEmpty())
// Only ISS (25544) is on the AMSAT live FM list; ISS_VARIANT (25545) is not.
val vm = createVm(
satellites = listOf(TestOrbits.ISS, TestOrbits.ISS_VARIANT),
passes = windows,
amSatFm = setOf(25544)
)
vm.onStationBGrid("OL62")
vm.onHoursAhead(24)
queryAndSettle(vm)
val state = vm.uiState.value
assertNull(state.errorMessage)
assertTrue(state.mutualPasses.isNotEmpty())
assertTrue("only AMSAT-listed satellites should appear", state.mutualPasses.all { it.catNum == 25544 })
}
@Test
fun `AMSAT filter off for one type excludes its satellites`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
val windows = TestOrbits.findPassWindows(hoursAhead = 24) +
TestOrbits.findPassWindows(sat = TestOrbits.ISS_VARIANT, hoursAhead = 24)
// FM filter off, Linear on: neither sat is linear-listed -> nothing matches.
val vm = createVm(
satellites = listOf(TestOrbits.ISS, TestOrbits.ISS_VARIANT),
passes = windows,
amSatFm = setOf(25544),
amSatLinear = setOf(25545)
)
vm.onStationBGrid("OL62")
vm.onHoursAhead(24)
vm.onFilterFM(false)
queryAndSettle(vm)
val state = vm.uiState.value
assertNull(state.errorMessage)
assertTrue("only linear-listed satellites should appear", state.mutualPasses.all { it.catNum == 25545 })
}
}