From 9e88609efc9d542e24d17497129c2886abb27193 Mon Sep 17 00:00:00 2001 From: atsunatsu Date: Sat, 19 Sep 2026 04:27:19 +0800 Subject: [PATCH] feat(amsat): show consecutive same-status report count in status cells Day-cell numbers now count the most recent consecutive reports sharing the newest status (counting back from the newest report, stopping at the first different status), instead of the total report count of the day. Empty gaps do not break the streak; statuses compare by exact report text; counting does not cross day boundaries. - SatDay gains streakCount (computed in AmSatRepository.buildStatuses) - DayCell renders day.streakCount instead of slot count sum - Add real org.json to core:data unit tests (android.jar stub cannot parse) - Add 2 streak tests: mixed-status slot breaks, all-same-status counts all --- .../look4sat/convention/CoreDataPlugin.kt | 3 + .../core/data/repository/AmSatRepository.kt | 17 ++++- .../data/repository/AmSatRepositoryTest.kt | 68 ++++++++++++++++++- .../look4sat/core/domain/model/SatStatus.kt | 3 +- .../feature/status/SatStatusScreen.kt | 11 +-- gradle/libs.versions.toml | 2 + 6 files changed, 95 insertions(+), 9 deletions(-) diff --git a/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/CoreDataPlugin.kt b/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/CoreDataPlugin.kt index 3a1e7ab1..2bd6b4a8 100644 --- a/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/CoreDataPlugin.kt +++ b/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/CoreDataPlugin.kt @@ -35,6 +35,9 @@ internal class CoreDataPlugin : Plugin { ksp(libs.androidx.room.compiler) implementation(libs.kotlin.coroutines) implementation(libs.other.okhttp) + // android.jar's org.json is a stub ("not mocked") in unit tests; + // provide a real implementation so JSON parsing is testable. + testImplementation(libs.test.json) } } } 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 19c97bfe..598a2595 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 @@ -221,7 +221,22 @@ class AmSatRepository( } } val days = (0 until 3).map { d -> - SatDay(dateLabel = labels[d], slots = slots.subList(d * 12, (d + 1) * 12)) + // 当天最近连续相同状态报告数:从最新一条往回数,遇状态不同即停。 + // 空时段(无报告)不打断;按状态文本严格比较,不跨天。 + val dayStart = nowSec - (d + 1) * 86400L + val dayEnd = nowSec - d * 86400L + val dayReports = byName[name].orEmpty() + .filter { it.reportedTimeUtcSec in dayStart until dayEnd } + .sortedByDescending { it.reportedTimeUtcSec } + val streakCount = if (dayReports.isEmpty()) 0 else { + val newestStatus = dayReports.first().report + dayReports.takeWhile { it.report == newestStatus }.size + } + SatDay( + dateLabel = labels[d], + slots = slots.subList(d * 12, (d + 1) * 12), + streakCount = streakCount + ) } SatStatus(name = name, days = days) } 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 7b883fba..579f94a5 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 @@ -12,6 +12,10 @@ import org.junit.Assert.assertNull import org.junit.Assert.assertSame import org.junit.Test import java.io.InputStream +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone class AmSatRepositoryTest { @@ -75,6 +79,52 @@ class AmSatRepositoryTest { assertEquals(1, remoteSource.catalogRequests) assertEquals(1, remoteSource.reportRequests) } + + @Test + fun buildStatusesStreakCountsConsecutiveSameStatusReportsPerDay() = runTest { + val nowSec = System.currentTimeMillis() / 1000 + val remoteSource = FakeAmSatRemoteSource( + reportsJson = amSatReportsJson( + // 当天(day 0)最新槽(slot 0)混合状态:最新 Heard、次新 Heard、再旧 Not Heard → 连续=2 + report("r1", "AO-7", "Heard", nowSec - 3600), + report("r2", "AO-7", "Heard", nowSec - 5400), + report("r3", "AO-7", "Not Heard", nowSec - 7000), + // 空时段(无报告)不打断:slot 8 的 Heard 更旧,不应计入(被 r3 打断) + report("r4", "AO-7", "Heard", nowSec - 63000), + // 前一天(day 1):最新 Heard,更旧的 Not Heard → 连续=1,且不跨天合并 + report("r5", "AO-7", "Heard", nowSec - 90000), + report("r6", "AO-7", "Not Heard", nowSec - 100000) + // day 2 无报告 → 0 + ) + ) + val repository = AmSatRepository(remoteSource) + + val page = repository.fetchStatus() + val status = page?.statuses?.single() + assertEquals("AO-7", status?.name) + assertEquals(3, status?.days?.size) + + assertEquals(2, status?.days?.get(0)?.streakCount) + assertEquals(1, status?.days?.get(1)?.streakCount) + assertEquals(0, status?.days?.get(2)?.streakCount) + } + + @Test + fun buildStatusesStreakCountsAllReportsWhenStatusNeverChanges() = runTest { + val nowSec = System.currentTimeMillis() / 1000 + val remoteSource = FakeAmSatRemoteSource( + reportsJson = amSatReportsJson( + report("r1", "AO-7", "Heard", nowSec - 1800), + report("r2", "AO-7", "Heard", nowSec - 3600), + report("r3", "AO-7", "Heard", nowSec - 5400), + report("r4", "AO-7", "Heard", nowSec - 30000) + ) + ) + val repository = AmSatRepository(remoteSource) + + val page = repository.fetchStatus() + assertEquals(4, page?.statuses?.single()?.days?.get(0)?.streakCount) + } } private fun AmSatRepository.seedStatusCache(page: SatStatusPage) { @@ -83,7 +133,10 @@ private fun AmSatRepository.seedStatusCache(page: SatStatusPage) { cacheField.set(this, page) } -private class FakeAmSatRemoteSource(private val responseDelayMillis: Long = 0L) : IRemoteSource { +private class FakeAmSatRemoteSource( + private val responseDelayMillis: Long = 0L, + private val reportsJson: String = """{"data":[]}""" +) : IRemoteSource { var catalogRequests = 0 var reportRequests = 0 @@ -100,8 +153,19 @@ private class FakeAmSatRemoteSource(private val responseDelayMillis: Long = 0L) override suspend fun getAmSatReports(hours: Int, limit: Int): String? { if (responseDelayMillis > 0L) delay(responseDelayMillis) reportRequests += 1 - return """{"data":[]}""" + return reportsJson } override suspend fun submitAmSatReport(payloadJson: String): Pair? = null } + +private fun amSatReportsJson(vararg reports: String): String = + """{"data":[${reports.joinToString(",")}]}""" + +private fun report(id: String, name: String, status: String, epochSec: Long): String = + """{"id":"$id","name":"$name","callsign":"BA7OPF","report":"$status","grid_square":"OL62","reported_time":"${isoUtc(epochSec)}"}""" + +private fun isoUtc(epochSec: Long): String = + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + }.format(Date(epochSec * 1000)) 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 062ff42e..08610bf4 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 @@ -20,7 +20,8 @@ data class SatSlot( /** One satellite day (12 two-hour slots) */ data class SatDay( val dateLabel: String, // "Aug 4" - val slots: List // 12 槽(00-02 ... 22-24) + val slots: List, // 12 槽(00-02 ... 22-24) + val streakCount: Int = 0 // 当天最近连续相同状态报告数(0 = 当天无报告) ) /** One satellite, 3 days of state */ 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 7a59be99..cd599a3b 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 @@ -338,15 +338,16 @@ private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) { /** * Day block: colored by the newest reported status among the day's 12 slots - * (gray when none); the number is the TOTAL report count of the whole day - * (sum over all slots), not the count of the newest slot alone. + * (gray when none); the number is the count of the most recent consecutive + * reports sharing that same status (day.streakCount), not the total report + * count of the whole day. */ @Composable private fun DayCell(day: SatDay, modifier: Modifier, onClick: () -> Unit) { val noReportGray = 0xFFC0C0C0L val slot = day.slots.firstOrNull { it.statusColor != noReportGray } ?: day.slots.first() val color = Color(slot.statusColor) - val totalCount = day.slots.sumOf { it.count } + val streakCount = day.streakCount Box( modifier = modifier .height(28.dp) @@ -355,8 +356,8 @@ private fun DayCell(day: SatDay, modifier: Modifier, onClick: () -> Unit) { .clickable(onClick = onClick), contentAlignment = Alignment.Center ) { - if (totalCount > 0) { - Text(text = totalCount.toString(), fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White) + if (streakCount > 0) { + Text(text = streakCount.toString(), fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White) } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ffce4874..b13bdeb9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -79,6 +79,8 @@ other-osmdroid = { module = "org.osmdroid:osmdroid-android", version.ref = "othe test-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlin-coroutines" } test-junit4 = { module = "junit:junit", version.ref = "test-junit4" } +# Real org.json for unit tests (android.jar stub throws "not mocked") +test-json = { module = "org.json:json", version = "20240303" } androidTest-junit = { module = "androidx.test.ext:junit", version.ref = "androidTest-junit" } androidTest-espresso = { module = "androidx.test.espresso:espresso-core", version.ref = "androidTest-espresso" }