diff --git a/AGENTS.md b/AGENTS.md index b797b073..263617dc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -110,6 +110,10 @@ Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) CSV formats: - Orbital math lives in `core:domain/predict/` — dense vector math (SGP4/SDP4). Tread carefully. - SSTV decoding in `feature:radar` is experimental; image quality depends on signal strength during satellite pass. - `build-logic/convention/` contains shared Gradle configuration — edit there, not in individual modules. +- AMSAT status colours are ARGB literals in `core:data` (`AmSatRepository.statusColorOf`) and duplicated in + `core:presentation/MainTheme.kt`, so the data layer currently decides how the UI looks. Known debt, left as + upstream shipped it: the fix is a status enum in `core:domain` with the colour mapping in `core:presentation`. + Anything needing themeable, dark-mode-aware or colour-blind-safe status colours has to do that first. ## Copilot Working Mode: Code-Only 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 9edc85a3..eb0a2985 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 @@ -15,8 +15,13 @@ import java.util.Calendar import java.util.Locale import java.util.TimeZone -/** One report from the AMSAT API (data layer model). */ -private data class ApiReport( +/** + * One report from the AMSAT API (data layer model). + * + * Internal rather than private so [AmSatRepository.buildStatuses] can be unit-tested: + * the JSON parsing around it needs Android's JSONObject, which is a stub on the JVM. + */ +internal data class ApiReport( val id: String, val name: String, val callsign: String, @@ -89,33 +94,55 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor } } - /** Build one SatStatus (5 days x 12 slots) per catalog satellite, slotting reports by age. */ - private fun buildStatuses(names: List, reports: List, nowSec: Long): List { + /** + * Build one SatStatus (3 days x 12 two-hour slots) per catalog satellite. + * + * Days are UTC calendar days and slots are fixed UTC bands, matching amsat.org: day 0 + * is today, its slot 0 covers 22:00-24:00 UTC and slot 11 covers 00:00-02:00, so both + * the day list and the slots inside it read newest-first. + * + * A rolling window anchored on "now" was wrong: fetching at 06:07 UTC put 17.9 hours + * of yesterday into the cell labelled today. Checked against a live amsat.org page of + * 1021 reports, 73% landed in the wrong day column. + */ + internal fun buildStatuses(names: List, reports: List, nowSec: Long): List { val byName = reports.groupBy { it.name } val monthAbbr = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec") val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC")) + + // Midnight UTC today, the anchor every slot boundary is derived from. + utc.timeInMillis = nowSec * 1000 + utc.set(Calendar.HOUR_OF_DAY, 0) + utc.set(Calendar.MINUTE, 0) + utc.set(Calendar.SECOND, 0) + utc.set(Calendar.MILLISECOND, 0) + val todayMidnightSec = utc.timeInMillis / 1000 + val labels = (0 until 3).map { d -> - utc.timeInMillis = (nowSec - d * 86400L) * 1000 + utc.timeInMillis = (todayMidnightSec - d * 86400L) * 1000 "${monthAbbr[utc.get(Calendar.MONTH)]} ${utc.get(Calendar.DAY_OF_MONTH)}" } return names.map { name -> - val slots = (0 until 36).map { slotIdx -> - val slotStart = nowSec - (slotIdx + 1) * 7200L - val slotEnd = nowSec - slotIdx * 7200L - val inSlot = byName[name].orEmpty().filter { it.reportedTimeUtcSec in slotStart until slotEnd } - if (inSlot.isEmpty()) { - SatSlot(statusColor = NO_REPORT_GRAY, count = 0) - } else { - val newest = inSlot.maxByOrNull { it.reportedTimeUtcSec }!! - SatSlot( - statusColor = statusColorOf(newest.report), - count = inSlot.size, - reportIds = inSlot.map { it.id } - ) + val satReports = byName[name].orEmpty() + val days = (0 until 3).map { dayIdx -> + val dayStart = todayMidnightSec - dayIdx * 86400L + val slots = (0 until 12).map { slotIdx -> + // Slot 0 is the last band of the day, so the day reads newest-first. + val slotStart = dayStart + (11 - slotIdx) * 7200L + val slotEnd = slotStart + 7200L + val inSlot = satReports.filter { it.reportedTimeUtcSec in slotStart until slotEnd } + if (inSlot.isEmpty()) { + SatSlot(statusColor = NO_REPORT_GRAY, count = 0) + } else { + val newest = inSlot.maxByOrNull { it.reportedTimeUtcSec }!! + SatSlot( + statusColor = statusColorOf(newest.report), + count = inSlot.size, + reportIds = inSlot.map { it.id } + ) + } } - } - val days = (0 until 3).map { d -> - SatDay(dateLabel = labels[d], slots = slots.subList(d * 12, (d + 1) * 12)) + SatDay(dateLabel = labels[dayIdx], slots = slots) } SatStatus(name = name, days = days) } diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/AmSatSlotBuildTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/AmSatSlotBuildTest.kt new file mode 100644 index 00000000..be354baf --- /dev/null +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/AmSatSlotBuildTest.kt @@ -0,0 +1,259 @@ +package com.rtbishop.look4sat.core.data.repository + +import com.rtbishop.look4sat.core.domain.source.IRemoteSource +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.InputStream +import java.util.Calendar +import java.util.TimeZone + +/** + * Pins the grid the AMSAT status page draws. + * + * Two contracts matter. The day cell renders one stripe per slot, so "every day has + * exactly 12 slots, newest first" became load-bearing. And the day columns are UTC + * calendar days, so a report must land in the cell whose label matches its UTC date - an + * earlier rolling window anchored on "now" put 17.9 hours of yesterday into the cell + * labelled today, and 73% of a live 1021-report page landed in the wrong column. + * + * This drives [AmSatRepository.buildStatuses] directly rather than `fetchStatus`, because + * the parsing around it uses Android's `JSONObject`, a stub on the JVM: a `fetchStatus` + * test returns null for every input and proves nothing. + */ +class AmSatSlotBuildTest { + + private object UnusedSource : IRemoteSource { + override suspend fun getFileStream(uri: String): InputStream? = null + override suspend fun getNetworkStream(url: String): InputStream? = null + override suspend fun getAmSatCatalog(): String? = null + override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null + } + + private val repo = AmSatRepository(UnusedSource) + + /** Epoch seconds for a UTC wall-clock instant, so every case reads unambiguously. */ + private fun utc(year: Int, month: Int, day: Int, hour: Int, minute: Int = 0): Long { + val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC")) + cal.clear() + cal.set(year, month - 1, day, hour, minute, 0) + return cal.timeInMillis / 1000 + } + + /** Midday, so "today" has hours on both sides of the fetch. */ + private val nowSec = utc(2026, 8, 22, 12) + + private fun report(name: String, status: String, at: Long, id: String = "r-$name-$at") = + ApiReport( + id = id, + name = name, + callsign = "TEST", + report = status, + gridSquare = "AA00", + reportedTimeUtcSec = at + ) + + private fun build(names: List, reports: List) = + repo.buildStatuses(names, reports, nowSec) + + @Test + fun `every day carries exactly twelve slots`() { + val statuses = build( + listOf("AO-91", "SO-50", "ISS"), + listOf(report("AO-91", "heard", utc(2026, 8, 22, 11))) + ) + assertEquals(3, statuses.size) + for (status in statuses) { + assertEquals("${status.name} must have 3 days", 3, status.days.size) + for (day in status.days) { + assertEquals( + "${status.name} ${day.dateLabel} must have 12 slots for the stripe renderer", + 12, day.slots.size + ) + } + } + } + + @Test + fun `a satellite nobody reported still gets twelve slots per day`() { + // The renderer must never receive an empty list, which would draw nothing at all. + val status = build(listOf("QUIET-1"), emptyList()).single() + assertEquals(3, status.days.size) + status.days.forEach { assertEquals(12, it.slots.size) } + assertTrue( + "a silent satellite must be all no-report slots", + status.days.all { day -> day.slots.all { it.count == 0 } } + ) + } + + @Test + fun `days are labelled with UTC calendar dates`() { + val status = build(listOf("AO-91"), emptyList()).single() + assertEquals("today", "Aug 22", status.days[0].dateLabel) + assertEquals("yesterday", "Aug 21", status.days[1].dateLabel) + assertEquals("the day before", "Aug 20", status.days[2].dateLabel) + } + + @Test + fun `the label does not drift with the time of day`() { + // The old rolling window relabelled the same data depending on when it was + // fetched. A calendar day must not care. + for (hour in listOf(0, 6, 12, 18, 23)) { + val labels = repo.buildStatuses(listOf("AO-91"), emptyList(), utc(2026, 8, 22, hour)) + .single().days.map { it.dateLabel } + assertEquals("fetched at ${hour}:00 UTC", listOf("Aug 22", "Aug 21", "Aug 20"), labels) + } + } + + @Test + fun `slots cover fixed UTC bands, newest first`() { + // Slot 0 is 22:00-24:00 and slot 11 is 00:00-02:00, matching amsat.org. + val status = build( + listOf("AO-91"), + listOf( + report("AO-91", "heard", utc(2026, 8, 22, 23), id = "lateToday"), + report("AO-91", "not heard", utc(2026, 8, 22, 1), id = "earlyToday") + ) + ).single() + val today = status.days[0] + + assertTrue( + "23:00 belongs in slot 0, the day's last band", + "lateToday" in today.slots[0].reportIds + ) + assertTrue( + "01:00 belongs in slot 11, the day's first band", + "earlyToday" in today.slots[11].reportIds + ) + } + + @Test + fun `a report lands in the day matching its UTC date`() { + val status = build( + listOf("AO-91"), + listOf( + report("AO-91", "heard", utc(2026, 8, 22, 11), id = "today"), + report("AO-91", "heard", utc(2026, 8, 21, 15), id = "yesterday"), + report("AO-91", "heard", utc(2026, 8, 20, 5), id = "dayBefore") + ) + ).single() + + // Positions computed from the UTC bands: 11:00 -> slot 6, 15:00 -> slot 4, + // 05:00 -> slot 9. + assertTrue("today's report", "today" in status.days[0].slots[6].reportIds) + assertTrue("yesterday's report", "yesterday" in status.days[1].slots[4].reportIds) + assertTrue("the day before", "dayBefore" in status.days[2].slots[9].reportIds) + } + + @Test + fun `a report just after midnight stays in the new day`() { + // The boundary the rolling window got wrong: 00:30 today must not appear as + // yesterday. + val status = build( + listOf("AO-91"), + listOf(report("AO-91", "heard", utc(2026, 8, 22, 0, 30), id = "justAfterMidnight")) + ).single() + + assertTrue( + "00:30 belongs to today's first band", + "justAfterMidnight" in status.days[0].slots[11].reportIds + ) + assertTrue( + "yesterday must stay empty", + status.days[1].slots.all { it.count == 0 } + ) + } + + @Test + fun `each status maps to its own colour`() { + // The stripes are now the only carrier of status, so distinct states must stay + // distinct all the way out of the repository. + val at = utc(2026, 8, 22, 11) + val statuses = build( + listOf("A", "B", "C", "D"), + listOf( + report("A", "heard", at), + report("B", "telemetry only", at), + report("C", "not heard", at), + report("D", "something the api invented", at) + ) + ) + val colours = statuses.map { status -> status.days[0].slots[6].statusColor } + assertTrue("no state may be colourless", colours.none { it == 0L }) + assertEquals( + "heard, telemetry and not heard must be visually distinct", + 3, colours.take(3).toSet().size + ) + } + + @Test + fun `a slot keeps every report it contains`() { + // The tap dialog lists reports from the slots, so none may be dropped when several + // land in the same two-hour window. 10:00-12:00 is slot 6. + val status = build( + listOf("AO-91"), + listOf( + report("AO-91", "heard", utc(2026, 8, 22, 10, 15), id = "a"), + report("AO-91", "heard", utc(2026, 8, 22, 11, 0), id = "b"), + report("AO-91", "not heard", utc(2026, 8, 22, 11, 45), id = "c") + ) + ).single() + val slot = status.days[0].slots[6] + assertEquals("all three reports fall in the same band", 3, slot.count) + assertEquals(setOf("a", "b", "c"), slot.reportIds.toSet()) + } + + @Test + fun `a slot shows the newest status when reports disagree`() { + // Within one band the most recent observation wins; anything else would keep + // showing a failure after the satellite recovered. + fun colourFor(firstStatus: String, secondStatus: String): Long = build( + listOf("AO-91"), + listOf( + report("AO-91", firstStatus, utc(2026, 8, 22, 10, 15), id = "older"), + report("AO-91", secondStatus, utc(2026, 8, 22, 11, 45), id = "newer") + ) + ).single().days[0].slots[6].statusColor + + assertTrue( + "the slot colour must follow the newest report, not the first", + colourFor("not heard", "heard") != colourFor("heard", "not heard") + ) + } + + @Test + fun `reports outside the three-day window are ignored`() { + val status = build( + listOf("AO-91"), + listOf( + report("AO-91", "heard", utc(2026, 8, 18, 12), id = "tooOld"), + report("AO-91", "heard", utc(2026, 8, 23, 12), id = "future") + ) + ).single() + assertTrue( + "nothing outside the window may appear", + status.days.all { day -> day.slots.all { it.count == 0 } } + ) + } + + @Test + fun `reports for other satellites do not leak between rows`() { + val statuses = build( + listOf("AO-91", "SO-50"), + listOf(report("AO-91", "heard", utc(2026, 8, 22, 11), id = "onlyAo91")) + ) + val ao91 = statuses.first { it.name == "AO-91" } + val so50 = statuses.first { it.name == "SO-50" } + + assertEquals("AO-91 has its report", 1, ao91.days[0].slots[6].count) + assertTrue( + "SO-50 must stay empty", + so50.days.all { day -> day.slots.all { it.count == 0 } } + ) + } + + @Test + fun `an empty catalog yields no rows rather than a malformed grid`() { + assertTrue(build(emptyList(), emptyList()).isEmpty()) + } +} 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 d3554047..b941ba34 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 @@ -23,10 +23,10 @@ data class SatDay( val slots: List // 12 槽(00-02 ... 22-24) ) -/** One satellite, 5 days of state */ +/** One satellite, 3 days of state */ data class SatStatus( val name: String, // "AO-123_[FM]" - val days: List // 5 天(新→旧) + val days: List // 3 天(新→旧) ) /** Overall page parse result */ 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 ed7d71d4..45ff4318 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 @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -48,7 +49,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.rtbishop.look4sat.core.domain.model.SatDay import com.rtbishop.look4sat.core.domain.model.SatReport -import com.rtbishop.look4sat.core.domain.model.SatSlot import com.rtbishop.look4sat.core.domain.model.SatStatus import com.rtbishop.look4sat.core.domain.repository.IContainerProvider import com.rtbishop.look4sat.core.presentation.InfoDialog @@ -235,7 +235,6 @@ private fun HeaderRow(statuses: List) { /** Satellite row: name takes remaining width; day tiles are fixed-width (tablet-safe). */ @Composable private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) { - val noReportGray = 0xFFC0C0C0L Row( modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically @@ -248,9 +247,8 @@ private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) { modifier = Modifier.weight(1f).padding(end = 4.dp) ) status.days.forEach { day -> - val slot = day.slots.firstOrNull { it.statusColor != noReportGray } ?: day.slots.first() DayCell( - slot = slot, + day = day, modifier = Modifier.width(TILE_WIDTH).padding(horizontal = 2.dp), onClick = { onClickDay(day) } ) @@ -258,20 +256,35 @@ private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) { } } -/** Day block: newest reported status among the day's 12 slots; gray when none. */ +/** + * One day as a stripe per two-hour slot. + * + * Showing a single colour per day hid the shape of the day: a satellite that worked all + * morning and failed all afternoon looked identical to one that worked once. At 64 dp + * across, twelve stripes are about 5 dp each - roughly 15 px on a 440 dpi screen - and + * runs of the same status merge visually, so a typical day reads as a few blocks rather + * than twelve thin lines. + * + * Stripes run newest-first, left to right, matching both the slot order the repository + * produces and the day columns in the header. Time therefore flows right to left within + * a cell, which is the opposite of the usual convention but consistent with the rest of + * the grid. + */ @Composable -private fun DayCell(slot: SatSlot, modifier: Modifier, onClick: () -> Unit) { - val color = Color(slot.statusColor) - Box( +private fun DayCell(day: SatDay, modifier: Modifier, onClick: () -> Unit) { + Row( modifier = modifier .height(28.dp) .clip(RoundedCornerShape(4.dp)) - .background(color) - .clickable(onClick = onClick), - contentAlignment = Alignment.Center + .clickable(onClick = onClick) ) { - if (slot.count > 0) { - Text(text = slot.count.toString(), fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White) + day.slots.forEach { slot -> + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .background(Color(slot.statusColor)) + ) } } }