mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-05 03:57:24 +00:00
feat(amsat): 时隙判定照官网链(严格多数/并列=冲突/crew 紫)+ 弹窗「冲突」徽标
- 旧逻辑取槽内最新一条报告取色 → 官网图例「Conflicting」永不显示 - 判定链照 AMSAT 官方源码 index.php:crew>0 取紫;严格多数取色;并列/无多数=「冲突」 - SatSlot 新增 isConflicted 字段;弹窗冲突槽内全部报告加「冲突/Conflicting」小徽标(随系统语言) - 天格子外观不变(B 未采纳);AmSatRepositoryTest +4 用例(共 9 例)
This commit is contained in:
1 parent
cbe3e50f86
commit
6f83740983
4 files changed
+178
-14
No files matched your search
+34
-11
@@ -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
|
||||
}
|
||||
}
|
||||
+118
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user