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" }