From 6f837409830dc551c687ef2195e4018a0dce9202 Mon Sep 17 00:00:00 2001 From: atsunatsu Date: Mon, 5 Oct 2026 03:54:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(amsat):=20=E6=97=B6=E9=9A=99=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=E7=85=A7=E5=AE=98=E7=BD=91=E9=93=BE=EF=BC=88=E4=B8=A5?= =?UTF-8?q?=E6=A0=BC=E5=A4=9A=E6=95=B0/=E5=B9=B6=E5=88=97=3D=E5=86=B2?= =?UTF-8?q?=E7=AA=81/crew=20=E7=B4=AB=EF=BC=89+=20=E5=BC=B9=E7=AA=97?= =?UTF-8?q?=E3=80=8C=E5=86=B2=E7=AA=81=E3=80=8D=E5=BE=BD=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 旧逻辑取槽内最新一条报告取色 → 官网图例「Conflicting」永不显示 - 判定链照 AMSAT 官方源码 index.php:crew>0 取紫;严格多数取色;并列/无多数=「冲突」 - SatSlot 新增 isConflicted 字段;弹窗冲突槽内全部报告加「冲突/Conflicting」小徽标(随系统语言) - 天格子外观不变(B 未采纳);AmSatRepositoryTest +4 用例(共 9 例) --- .../core/data/repository/AmSatRepository.kt | 45 +++++-- .../data/repository/AmSatRepositoryTest.kt | 118 ++++++++++++++++++ .../look4sat/core/domain/model/SatStatus.kt | 7 +- .../feature/status/SatStatusScreen.kt | 22 ++++ 4 files changed, 178 insertions(+), 14 deletions(-) diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/AmSatRepository.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/AmSatRepository.kt index 7aa00b36..1ce80fac 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/AmSatRepository.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/AmSatRepository.kt @@ -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, reports: List, nowSec: Long): List { 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): 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 } } diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/AmSatRepositoryTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/AmSatRepositoryTest.kt index 9d1aab34..5ea8c447 100644 --- a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/AmSatRepositoryTest.kt +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/AmSatRepositoryTest.kt @@ -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) { diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/SatStatus.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/SatStatus.kt index 33bf239f..da300d3f 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/SatStatus.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/SatStatus.kt @@ -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 = emptyList() // 该槽报告 ID 列表 + val statusColor: Long, // ARGB 状态色(灰=无报告;判定见 AmSatRepository.slotStatusOf) + val count: Int, // 官网页口径:多数方计数(冲突槽/异常槽=总条数;0 = 无) + val reportIds: List = emptyList(), // 该槽报告 ID 列表 + val isConflicted: Boolean = false // 官网页"Conflicting reports":该槽无严格多数(弹窗据此标注) ) /** One satellite day (12 two-hour slots) */ diff --git a/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusScreen.kt b/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusScreen.kt index 62975ddb..8122dd7f 100644 --- a/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusScreen.kt +++ b/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusScreen.kt @@ -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,