feat(amsat): 时隙判定照官网链(严格多数/并列=冲突/crew 紫)+ 弹窗「冲突」徽标

- 旧逻辑取槽内最新一条报告取色 → 官网图例「Conflicting」永不显示
- 判定链照 AMSAT 官方源码 index.php:crew>0 取紫;严格多数取色;并列/无多数=「冲突」
- SatSlot 新增 isConflicted 字段;弹窗冲突槽内全部报告加「冲突/Conflicting」小徽标(随系统语言)
- 天格子外观不变(B 未采纳);AmSatRepositoryTest +4 用例(共 9 例)
This commit is contained in:
atsunatsu committed 2026-10-05 03:54:22 +08:00
1 parent cbe3e50f86
commit 6f83740983
4 files changed
+178 -14

No files matched your search

@@ -52,6 +52,10 @@ private data class ApiReport(
val reportedTimeUtcSec: Long
)
/** One slot's verdict: ARGB colour, the count the page prints, and whether the slot
* has no strict majority (= the page's "Conflicting reports" state). */
private data class SlotStatus(val color: Long, val count: Int, val conflicted: Boolean)
/** AMSAT status repository using RemoteSource (Clean Architecture: data layer handles HTTP). */
class AmSatRepository(
private val remoteSource: IRemoteSource,
@@ -214,7 +218,8 @@ class AmSatRepository(
}
}
/** Build one SatStatus (3 days x 12 slots) per catalog satellite, slotting reports by age. */
/** Build one SatStatus (3 days x 12 slots) per catalog satellite, slotting reports by age.
* Each slot's colour/count follows the AMSAT status page rule — see [slotStatusOf]. */
private fun buildStatuses(names: List<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> {
val byName = reports.groupBy { it.name }
val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
@@ -230,11 +235,12 @@ class AmSatRepository(
if (inSlot.isEmpty()) {
SatSlot(statusColor = NO_REPORT_GRAY, count = 0)
} else {
val newest = inSlot.maxByOrNull { it.reportedTimeUtcSec }!!
val status = slotStatusOf(inSlot)
SatSlot(
statusColor = statusColorOf(newest.report),
count = inSlot.size,
reportIds = inSlot.map { it.id }
statusColor = status.color,
count = status.count,
reportIds = inSlot.map { it.id },
isConflicted = status.conflicted
)
}
}
@@ -287,12 +293,28 @@ class AmSatRepository(
)
}
/** Map status text to color value (for UI rendering). */
private fun statusColorOf(report: String): Long = when (report.lowercase()) {
"heard", "crew active" -> ACTIVE_BLUE
"telemetry only" -> TLM_ORANGE
"not heard" -> NOT_HEARD_PINK
else -> CONFLICT_DEEP_ORANGE
/**
* The AMSAT status page's per-block rule, mirrored from the official page source
* (gitlab.amsat.org/open-source/satellite-status, index.php):
* - any Crew Active report wins: purple, count = crew + heard;
* - otherwise one status must be a STRICT majority to take its colour, and the
* count shown is the majority group's count (not the block total);
* - any tie of the remaining statuses is the "Conflicting reports" state;
* - a block whose reports all carry an unknown status stays blank.
*/
private fun slotStatusOf(inSlot: List<ApiReport>): SlotStatus {
val heard = inSlot.count { it.report.equals("Heard", ignoreCase = true) }
val notHeard = inSlot.count { it.report.equals("Not Heard", ignoreCase = true) }
val telemetry = inSlot.count { it.report.equals("Telemetry Only", ignoreCase = true) }
val crew = inSlot.count { it.report.equals("Crew Active", ignoreCase = true) }
return when {
crew > 0 -> SlotStatus(CREW_PURPLE, crew + heard, conflicted = false)
heard > notHeard && heard > telemetry -> SlotStatus(ACTIVE_BLUE, heard, conflicted = false)
notHeard > heard && notHeard > telemetry -> SlotStatus(NOT_HEARD_PINK, notHeard, conflicted = false)
telemetry > notHeard && telemetry > heard -> SlotStatus(TLM_ORANGE, telemetry, conflicted = false)
telemetry + notHeard + heard >= 1 -> SlotStatus(CONFLICT_DEEP_ORANGE, inSlot.size, conflicted = true)
else -> SlotStatus(NO_REPORT_GRAY, inSlot.size, conflicted = false)
}
}
companion object {
@@ -301,6 +323,7 @@ class AmSatRepository(
private const val TLM_ORANGE = 0xFFFFB000
private const val NOT_HEARD_PINK = 0xFFDC267F
private const val CONFLICT_DEEP_ORANGE = 0xFFFE6100
private const val CREW_PURPLE = 0xFF785EF0
private const val NO_REPORT_GRAY = 0xFFC0C0C0
}
}
@@ -18,6 +18,7 @@
*/
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.model.SatSlot
import com.rtbishop.look4sat.core.domain.model.SatStatus
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
@@ -143,6 +144,123 @@ class AmSatRepositoryTest {
val page = repository.fetchStatus()
assertEquals(4, page?.statuses?.single()?.days?.get(0)?.streakCount)
}
@Test
fun buildStatusesStrictMajorityTakesItsColorAndCount() = runTest {
val nowSec = System.currentTimeMillis() / 1000
val heardMost = firstSlotStatus(
report("h1", "AO-7", "Heard", nowSec - 3600),
report("h2", "AO-7", "Heard", nowSec - 4500),
report("t1", "AO-7", "Telemetry Only", nowSec - 5400)
)
assertEquals(0xFF648FFFL, heardMost?.statusColor) // blue
assertEquals(2, heardMost?.count) // majority count, not the block total
val telemetryMost = firstSlotStatus(
report("t1", "AO-7", "Telemetry Only", nowSec - 3600),
report("t2", "AO-7", "Telemetry Only", nowSec - 4500),
report("h1", "AO-7", "Heard", nowSec - 5400)
)
assertEquals(0xFFFFB000L, telemetryMost?.statusColor) // amber
assertEquals(2, telemetryMost?.count)
val notHeardMost = firstSlotStatus(
report("n1", "AO-7", "Not Heard", nowSec - 3600),
report("n2", "AO-7", "Not Heard", nowSec - 4500),
report("n3", "AO-7", "Not Heard", nowSec - 5400),
report("t1", "AO-7", "Telemetry Only", nowSec - 6000)
)
assertEquals(0xFFDC267FL, notHeardMost?.statusColor) // pink
assertEquals(3, notHeardMost?.count)
val single = firstSlotStatus(report("h1", "AO-7", "Heard", nowSec - 3600))
assertEquals(0xFF648FFFL, single?.statusColor)
assertEquals(1, single?.count)
}
@Test
fun buildStatusesShowsConflictWhenNoStrictMajority() = runTest {
val nowSec = System.currentTimeMillis() / 1000
val oneToOne = firstSlotStatus(
report("h1", "AO-7", "Heard", nowSec - 3600),
report("n1", "AO-7", "Not Heard", nowSec - 4500)
)
assertEquals(0xFFFE6100L, oneToOne?.statusColor) // conflicting
assertEquals(2, oneToOne?.count)
val twoToTwo = firstSlotStatus(
report("h1", "AO-7", "Heard", nowSec - 3600),
report("h2", "AO-7", "Heard", nowSec - 4500),
report("t1", "AO-7", "Telemetry Only", nowSec - 5400),
report("t2", "AO-7", "Telemetry Only", nowSec - 6000)
)
assertEquals(0xFFFE6100L, twoToTwo?.statusColor)
val threeWay = firstSlotStatus(
report("h1", "AO-7", "Heard", nowSec - 3600),
report("n1", "AO-7", "Not Heard", nowSec - 4500),
report("t1", "AO-7", "Telemetry Only", nowSec - 5400)
)
assertEquals(0xFFFE6100L, threeWay?.statusColor)
}
@Test
fun buildStatusesCrewActiveWinsWithCrewPlusHeardCount() = runTest {
val nowSec = System.currentTimeMillis() / 1000
val crewWithHeard = firstSlotStatus(
report("c1", "AO-7", "Crew Active", nowSec - 3600),
report("h1", "AO-7", "Heard", nowSec - 4500),
report("h2", "AO-7", "Heard", nowSec - 5400),
report("h3", "AO-7", "Heard", nowSec - 6000)
)
assertEquals(0xFF785EF0L, crewWithHeard?.statusColor) // purple
assertEquals(4, crewWithHeard?.count) // crew + heard
val crewAlone = firstSlotStatus(
report("c1", "AO-7", "Crew Active", nowSec - 3600),
report("n1", "AO-7", "Not Heard", nowSec - 4500),
report("n2", "AO-7", "Not Heard", nowSec - 5400)
)
assertEquals(0xFF785EF0L, crewAlone?.statusColor)
assertEquals(1, crewAlone?.count) // crew + heard (0)
}
@Test
fun buildStatusesMarksOnlyNoStrictMajoritySlotsAsConflicted() = runTest {
val nowSec = System.currentTimeMillis() / 1000
val majority = firstSlotStatus(
report("h1", "AO-7", "Heard", nowSec - 3600),
report("h2", "AO-7", "Heard", nowSec - 4500),
report("t1", "AO-7", "Telemetry Only", nowSec - 5400)
)
assertEquals(false, majority?.isConflicted)
val tie = firstSlotStatus(
report("h1", "AO-7", "Heard", nowSec - 3600),
report("t1", "AO-7", "Telemetry Only", nowSec - 4500)
)
assertEquals(true, tie?.isConflicted)
val crew = firstSlotStatus(
report("c1", "AO-7", "Crew Active", nowSec - 3600),
report("n1", "AO-7", "Not Heard", nowSec - 4500)
)
assertEquals(false, crew?.isConflicted)
val empty = firstSlotStatus()
assertEquals(false, empty?.isConflicted)
}
}
private suspend fun firstSlotStatus(vararg reports: String): SatSlot? {
val page = AmSatRepository(
FakeAmSatRemoteSource(reportsJson = amSatReportsJson(*reports))
).fetchStatus()
return page?.statuses?.single()?.days?.get(0)?.slots?.get(0)
}
private fun AmSatRepository.seedStatusCache(page: SatStatusPage) {
@@ -30,9 +30,10 @@ data class SatReport(
/** State of one 2-hour slot */
data class SatSlot(
val statusColor: Long, // ARGB 状态色(-1 = 无报告)
val count: Int, // 报告数量(0 = 无)
val reportIds: List<String> = emptyList() // 该槽报告 ID 列表
val statusColor: Long, // ARGB 状态色(灰=无报告;判定见 AmSatRepository.slotStatusOf)
val count: Int, // 官网页口径:多数方计数(冲突槽/异常槽=总条数;0 = 无)
val reportIds: List<String> = emptyList(), // 该槽报告 ID 列表
val isConflicted: Boolean = false // 官网页"Conflicting reports":该槽无严格多数(弹窗据此标注)
)
/** One satellite day (12 two-hour slots) */
@@ -400,6 +400,7 @@ private fun ReportDialog(
onDismiss: () -> Unit
) {
val dayReports = day.slots.flatMap { it.reportIds }.mapNotNull { reports[it] }
val conflictedIds = day.slots.filter { it.isConflicted }.flatMap { it.reportIds }.toSet()
InfoDialog(
title = "$statusName · ${day.dateLabel}",
onDismiss = onDismiss,
@@ -448,6 +449,10 @@ private fun ReportDialog(
)
Spacer(modifier = Modifier.width(6.dp))
Text(text = report.statusText, fontSize = 14.sp, fontWeight = FontWeight.Bold)
if (report.id in conflictedIds) {
Spacer(modifier = Modifier.width(8.dp))
ConflictBadge()
}
}
Text(
text = "${report.call} ${report.dateUtc} ${report.timeUtc}" +
@@ -463,6 +468,23 @@ private fun ReportDialog(
}
}
/** Orange "冲突" tag on reports whose 2h slot has no strict majority (Conflicting reports). */
@Composable
private fun ConflictBadge() {
Box(
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.background(Color(0xFFFE6100).copy(alpha = 0.15f))
.padding(horizontal = 5.dp, vertical = 1.dp)
) {
Text(
text = stringResource(id = R.string.amsat_conflict),
fontSize = 11.sp,
color = Color(0xFFFE6100)
)
}
}
@Composable
private fun AmSatUploadPanel(
statusName: String,