Compare commits

...
Author SHA1 Message Date
atsunatsu 623d859c97 test(mutual): 钉死 fixture 时间基准,消除时段性 flaky
fixture 用 2024-03-09 固定 ISS TLE + System.currentTimeMillis() 起点;SGP4 带阻力衰减 942 天后高度仅 ≈262km,12h 窗口内可能无广州过境 → 3 条依赖过境窗的用例时段性失败(Gradle 缓存 FROM-CACHE 会掩盖上一次的真跑)。

- TestOrbits.FIXTURE_BASE(2024-03-10T00:00Z,历元后约 18h)取代默认 System.currentTimeMillis()

- MutualViewModel 墙钟可注入:nowMillis: () -> Long = System::currentTimeMillis(第 4 个默认参数,生产行为逐位不变;工厂具名构造零改动)

- 断言里的 now 改用同一基准;prefill/scroll 测试不碰时间未动

修后::feature:mutual 28/28,全量 ./gradlew test 451/451。
2026-10-08 12:36:40 +08:00
atsunatsu 08803e0f55 feat(logbook): LoTW 卫星别名补 RS95S(QMR-KWT 2)+内置 config.tq6 升 11.35
RS-95S(QMR-KWT 2,NORAD 67291)QSO 上传时发现:别名表无该星、内置 config 11.34 也无此官方名(11.34 里没有 RS95S,会被 every-alias-target 校验挡住)。

- config.tq6 11.34 → 11.35(ARRL 现行版本,新增 RS95S=QMR-KWT-2)

- 别名表补 QMRKWT2→RS95S(SatNOGS 名 QMR-KWT 2)与 RS95S→RS95S(兼容 RS-95S 连字符写法,resolve 的包含回退逐字符比会漏)

- 单测补断言:QMR-KWT 2 lookup / RS-95S+QMR-KWT 2 resolve / 用真实 config 解析 RS95S
2026-10-08 12:36:35 +08:00
8 changed files with 30 additions and 10 deletions

No files matched your search

Binary file not shown.
@@ -36,7 +36,7 @@ import java.util.Locale
import java.util.zip.GZIPInputStream import java.util.zip.GZIPInputStream
import javax.xml.parsers.DocumentBuilderFactory import javax.xml.parsers.DocumentBuilderFactory
/** Bundled ARRL configuration 11.34 from https://lotw.arrl.org/lotw/config.tq6. */ /** Bundled ARRL configuration 11.35 from https://lotw.arrl.org/lotw/config.tq6. */
internal class LoTWConfig(input: InputStream) { internal class LoTWConfig(input: InputStream) {
private val root = input.use { source -> GZIPInputStream(source).use { gzip -> private val root = input.use { source -> GZIPInputStream(source).use { gzip ->
val xml = gzip.bufferedReader(Charsets.UTF_8).readText() val xml = gzip.bufferedReader(Charsets.UTF_8).readText()
@@ -87,6 +87,7 @@ class LoTWConfigTest {
assertEquals("AO-91", config.resolveSatellite("FOX-1B")) assertEquals("AO-91", config.resolveSatellite("FOX-1B"))
assertEquals("IO-86", config.resolveSatellite("LAPAN-A2")) assertEquals("IO-86", config.resolveSatellite("LAPAN-A2"))
assertEquals("RS-44", config.resolveSatellite("DOSAAF-85")) assertEquals("RS-44", config.resolveSatellite("DOSAAF-85"))
assertEquals("RS95S", config.resolveSatellite("QMR-KWT 2"))
assertEquals("BO-102", config.resolveSatellite("CAS-7B")) assertEquals("BO-102", config.resolveSatellite("CAS-7B"))
assertEquals("SO-50", config.resolveSatellite("SO-50")) assertEquals("SO-50", config.resolveSatellite("SO-50"))
} }
@@ -34,6 +34,8 @@ import java.util.Locale
* - live SatNOGS 3LE names and Celestrak amateur names joined to the same NORAD id, * - live SatNOGS 3LE names and Celestrak amateur names joined to the same NORAD id,
* - designator tokens of the config.tq6 satellite descriptions (e.g. CAS-7B -> BO-102), * - designator tokens of the config.tq6 satellite descriptions (e.g. CAS-7B -> BO-102),
* - the ISS family, which SatNOGS does not list under its ARRL name "ARISS". * - the ISS family, which SatNOGS does not list under its ARRL name "ARISS".
* Added 2026-10-08: config.tq6 v11.35 lists QMR-KWT 2 as "RS95S"; SatNOGS DB carries it as
* "QMR-KWT 2" (alias "RS95S") and other sources spell it "RS-95S", hence the identity entry.
* Placeholder names ("OBJECT AY" and friends) are deliberately NOT mapped: they are recycled * Placeholder names ("OBJECT AY" and friends) are deliberately NOT mapped: they are recycled
* between objects and would sign the wrong satellite. LoTWConfigTest asserts that every target * between objects and would sign the wrong satellite. LoTWConfigTest asserts that every target
* here is a real config.tq6 name. * here is a real config.tq6 name.
@@ -79,11 +81,11 @@ object LoTWSatelliteAliases {
"OSCAR16PACSAT" to "AO-16", "OSCAR19LUSAT" to "LO-19", "OSCAR64" to "DO-64", "OSCAR16PACSAT" to "AO-16", "OSCAR19LUSAT" to "LO-19", "OSCAR64" to "DO-64",
"OSCAR7" to "AO-7", "PACSAT" to "AO-16", "PARKINSONSAT" to "NO-84", "OSCAR7" to "AO-7", "PACSAT" to "AO-16", "PARKINSONSAT" to "NO-84",
"PCSAT" to "NO-44", "PHILLIPINESOSCAR101" to "PO-101", "PO101" to "PO-101", "PCSAT" to "NO-44", "PHILLIPINESOSCAR101" to "PO-101", "PO101" to "PO-101",
"PSAT" to "NO-84", "PSAT2" to "NO-104", "QB50P1" to "EO-79", "PSAT" to "NO-84", "PSAT2" to "NO-104", "QB50P1" to "EO-79", "QMRKWT2" to "RS95S",
"QO100" to "QO-100", "RADFXSAT" to "AO-91", "RADIOROSTO" to "RS-15", "QO100" to "QO-100", "RADFXSAT" to "AO-91", "RADIOROSTO" to "RS-15",
"RS0ISS" to "ARISS", "RS14" to "AO-21", "RS15" to "RS-15", "RS0ISS" to "ARISS", "RS14" to "AO-21", "RS15" to "RS-15",
"RS44" to "RS-44", "RS64S" to "AO-123", "SAUDISAT1C" to "SO-50", "RS44" to "RS-44", "RS64S" to "AO-123", "RS95S" to "RS95S",
"SO121" to "SO-121", "SO124" to "SO-124", "SO125" to "SO-125", "SAUDISAT1C" to "SO-50", "SO121" to "SO-121", "SO124" to "SO-124", "SO125" to "SO-125",
"SO50" to "SO-50", "SOLUTUSNANOSATTELITE" to "SONATE", "SONATE2" to "SONATE", "SO50" to "SO-50", "SOLUTUSNANOSATTELITE" to "SONATE", "SONATE2" to "SONATE",
"TAURUS1" to "TAURUS", "TEVEL21" to "TEV2-1", "TEVEL22" to "TEV2-2", "TAURUS1" to "TAURUS", "TEVEL21" to "TEV2-1", "TEVEL22" to "TEV2-2",
"TEVEL23" to "TEV2-3", "TEVEL24" to "TEV2-4", "TEVEL25" to "TEV2-5", "TEVEL23" to "TEV2-3", "TEVEL24" to "TEV2-4", "TEVEL25" to "TEV2-5",
@@ -27,7 +27,7 @@ import org.junit.Test
/** Alias table that maps tracker catalogue names onto the names ARRL's config.tq6 uses. */ /** Alias table that maps tracker catalogue names onto the names ARRL's config.tq6 uses. */
class LoTWSatelliteAliasesTest { class LoTWSatelliteAliasesTest {
private val catalogue = listOf("SO-50", "ARISS", "PO-101", "BO-102", "AO-91", "RS-44", "IO-117") private val catalogue = listOf("SO-50", "ARISS", "PO-101", "BO-102", "AO-91", "RS-44", "IO-117", "RS95S")
@Test @Test
fun `lookup maps SatNOGS catalogue names to ARRL names`() { fun `lookup maps SatNOGS catalogue names to ARRL names`() {
@@ -37,6 +37,7 @@ class LoTWSatelliteAliasesTest {
assertEquals("AO-91", LoTWSatelliteAliases.lookup("FOX-1B")) assertEquals("AO-91", LoTWSatelliteAliases.lookup("FOX-1B"))
assertEquals("RS-44", LoTWSatelliteAliases.lookup("DOSAAF-85")) assertEquals("RS-44", LoTWSatelliteAliases.lookup("DOSAAF-85"))
assertEquals("IO-117", LoTWSatelliteAliases.lookup("GREENCUBE")) assertEquals("IO-117", LoTWSatelliteAliases.lookup("GREENCUBE"))
assertEquals("RS95S", LoTWSatelliteAliases.lookup("QMR-KWT 2"))
} }
@Test @Test
@@ -59,6 +60,8 @@ class LoTWSatelliteAliasesTest {
assertEquals("SO-50", LoTWSatelliteAliases.resolve("SAUDISAT 1C (SO-50)", catalogue)) assertEquals("SO-50", LoTWSatelliteAliases.resolve("SAUDISAT 1C (SO-50)", catalogue))
assertEquals("ARISS", LoTWSatelliteAliases.resolve("ISS (ZARYA)", catalogue)) assertEquals("ARISS", LoTWSatelliteAliases.resolve("ISS (ZARYA)", catalogue))
assertEquals("AO-91", LoTWSatelliteAliases.resolve("AO-91 (RadFxSat)", catalogue)) assertEquals("AO-91", LoTWSatelliteAliases.resolve("AO-91 (RadFxSat)", catalogue))
assertEquals("RS95S", LoTWSatelliteAliases.resolve("RS-95S", catalogue))
assertEquals("RS95S", LoTWSatelliteAliases.resolve("QMR-KWT 2", catalogue))
} }
@Test @Test
@@ -82,7 +82,10 @@ class MutualViewModel(
private val settingsRepo: ISettingsRepo, private val settingsRepo: ISettingsRepo,
// Injected so unit tests can run the pass computation on a test dispatcher; // Injected so unit tests can run the pass computation on a test dispatcher;
// production callers keep the CPU-bound pool default. // production callers keep the CPU-bound pool default.
private val computeDispatcher: CoroutineDispatcher = Dispatchers.Default private val computeDispatcher: CoroutineDispatcher = Dispatchers.Default,
// Injected so unit tests can pin the wall clock to the fixture TLE epoch and
// stay deterministic; production callers keep the real clock default.
private val nowMillis: () -> Long = System::currentTimeMillis
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(MutualUiState()) private val _uiState = MutableStateFlow(MutualUiState())
@@ -303,7 +306,7 @@ class MutualViewModel(
queryGeneration += 1 queryGeneration += 1
viewModelScope.launch { viewModelScope.launch {
val now = System.currentTimeMillis() val now = nowMillis()
// 转发器类型筛选: 使用硬编码 AMSAT Live 清单 (见 Sources): // 转发器类型筛选: 使用硬编码 AMSAT Live 清单 (见 Sources):
// FM = {SO-50, ISS ZARYA, AO-123}, Linear = {RS-44, FO-29, // FM = {SO-50, ISS ZARYA, AO-123}, Linear = {RS-44, FO-29,
// AO-7, AO-73, JO-97}. 不再依赖 AMSAT 网页同步: 任何网络下都 // AO-7, AO-73, JO-97}. 不再依赖 AMSAT 网页同步: 任何网络下都
@@ -53,7 +53,9 @@ class MutualViewModelTest {
): MutualViewModel = MutualViewModel( ): MutualViewModel = MutualViewModel(
satelliteRepo = FakeSatelliteRepo(satellites, passes), satelliteRepo = FakeSatelliteRepo(satellites, passes),
settingsRepo = FakeSettingsRepo(position), settingsRepo = FakeSettingsRepo(position),
computeDispatcher = StandardTestDispatcher(mainDispatcherRule.dispatcher.scheduler) computeDispatcher = StandardTestDispatcher(mainDispatcherRule.dispatcher.scheduler),
// Pin the clock to the fixture base so results never depend on wall time.
nowMillis = { TestOrbits.FIXTURE_BASE }
) )
private fun TestScope.queryAndSettle(vm: MutualViewModel) { private fun TestScope.queryAndSettle(vm: MutualViewModel) {
@@ -219,7 +221,7 @@ class MutualViewModelTest {
assertTrue(state.mutualPasses.all { it.catNum == 25544 }) assertTrue(state.mutualPasses.all { it.catNum == 25544 })
assertTrue(state.mutualPasses.all { it.name == "ISS (ZARYA)" }) assertTrue(state.mutualPasses.all { it.name == "ISS (ZARYA)" })
// AOS/LOS within the searched horizon // AOS/LOS within the searched horizon
val now = System.currentTimeMillis() val now = TestOrbits.FIXTURE_BASE
assertTrue(state.mutualPasses.all { it.startTime >= now - 120_000L }) assertTrue(state.mutualPasses.all { it.startTime >= now - 120_000L })
assertTrue(state.mutualPasses.all { it.endTime <= now + 12 * 3600_000L + 120_000L }) assertTrue(state.mutualPasses.all { it.endTime <= now + 12 * 3600_000L + 120_000L })
} }
@@ -48,6 +48,15 @@ object TestOrbits {
) )
) )
/**
* Fixed wall-clock base for every fixture computation: 2024-03-10T00:00Z,
* ~18 h after [ISS]'s TLE epoch, so SGP4 propagates an accurate, healthy
* orbit. Tests pin the ViewModel clock to this value as well
* (MutualViewModel.nowMillis), which makes pass windows deterministic
* instead of rotting as the fixture ages against System.currentTimeMillis().
*/
val FIXTURE_BASE: Long = 1710028800000L
/** Guangzhou (default BA7OPF grid OL62/OL63 area). */ /** Guangzhou (default BA7OPF grid OL62/OL63 area). */
val GUANGZHOU = GeoPos(23.13, 113.26) val GUANGZHOU = GeoPos(23.13, 113.26)
@@ -85,7 +94,7 @@ object TestOrbits {
fun findPassWindows( fun findPassWindows(
sat: OrbitalObject = ISS, sat: OrbitalObject = ISS,
pos: GeoPos = GUANGZHOU, pos: GeoPos = GUANGZHOU,
time: Long = System.currentTimeMillis(), time: Long = FIXTURE_BASE,
hoursAhead: Int = 12 hoursAhead: Int = 12
): List<OrbitalPass> { ): List<OrbitalPass> {
if (!sat.willBeSeen(pos)) return emptyList() if (!sat.willBeSeen(pos)) return emptyList()