fix(map): pick the pass by time instead of a field that is never set

The map info panel chose which pass to describe with

    allPasses.find { it.catNum == catnum && it.progress < 1 }

but OrbitalPass.progress defaults to 0 and nothing in the repository or the
prediction layer ever assigns it - PassesViewModel computes progress on its own
local copy of the list and never writes it back. The predicate was therefore
always true, so the lookup returned the satellite's *first* pass forever.

Simulated over an ISS timeline with three passes (10:00, 12:00, 14:00), 4 of 6
sampled instants were wrong: from 10:30 onwards the panel still pointed at the
10:00 pass with its countdown frozen at 00:00:00, instead of counting down to the
12:00 and 14:00 passes. Only re-fetching the pass list refreshed it.

Select by time now: the pass currently in progress, otherwise the earliest one
still upcoming. Extracted as the pure internal selectCurrentOrNextPass so it is
testable, with the reasoning recorded so the progress field is not reintroduced
as a filter here.

Restoring the old predicate fails 5 of the 6 new tests; with the fix
:feature:map:testDebugUnitTest, :core:domain:test, :core:data:testDebugUnitTest
and :feature:roaming:testDebugUnitTest are all green.
This commit is contained in:
mckero committed 2026-08-14 19:49:32 +00:00
1 parent c7bb253981
commit aac1fa0da5
2 files changed
+120 -18

No files matched your search

@@ -220,26 +220,23 @@ class MapViewModel(
var orbitalPass = defaultPass
var aosTime = 0L.toTimerString()
var isTimeAos = true
allPasses.find { pass -> pass.catNum == sat.data.catnum && pass.progress < 1 }
?.let { satPass ->
orbitalPass = satPass
if (!satPass.isDeepSpace) {
when {
date.time < satPass.aosTime -> {
// Pass hasn't started yet — count down to AOS
isTimeAos = true
aosTime = (satPass.aosTime - date.time).toTimerString()
}
date.time < satPass.losTime -> {
// Pass is in progress — count down to LOS
isTimeAos = false
aosTime = (satPass.losTime - date.time).toTimerString()
}
// else: pass has ended (losTime <= date.time) — keep default "00:00:00"
}
// Select by time, not by OrbitalPass.progress: that field defaults to 0
// and is only ever filled in by PassesViewModel's own local copy, never
// written back to the repository. Filtering on `progress < 1` therefore
// always matched the satellite's *first* pass, so once it ended the map
// countdown stuck at 00:00:00 and never advanced to the next pass.
currentOrNextPass(sat.data.catnum, date.time)?.let { satPass ->
orbitalPass = satPass
if (!satPass.isDeepSpace) {
if (date.time < satPass.aosTime) {
isTimeAos = true
aosTime = (satPass.aosTime - date.time).toTimerString()
} else if (date.time < satPass.losTime) {
isTimeAos = false
aosTime = (satPass.losTime - date.time).toTimerString()
}
}
}
val azimuth = satPos.azimuth.toDegrees()
val elevation = satPos.elevation.toDegrees()
val osmPos = satPos.toMapGeoPos()
@@ -266,6 +263,10 @@ class MapViewModel(
return satData to orbitalPass
}
/** Ongoing pass for [catnum] if any, otherwise its next upcoming pass. */
private fun currentOrNextPass(catnum: Int, timeMillis: Long): OrbitalPass? =
selectCurrentOrNextPass(allPasses, catnum, timeMillis)
private suspend fun getSatTrack(orbitalObject: OrbitalObject, pos: GeoPos, date: Date) {
val endDate = Date(date.time + (orbitalObject.data.orbitalPeriod * 2.4 * 60000L).toLong())
val track = satelliteRepo.getTrack(orbitalObject, pos, date.time, endDate.time)
@@ -331,3 +332,20 @@ internal fun crossingLatitude(from: GeoPos, to: GeoPos, edgeLongitude: Double):
val fraction = (edgeLongitude - from.longitude) / span
return from.latitude + (to.latitude - from.latitude) * fraction
}
/**
* Picks the pass to describe for [catnum] at [timeMillis]: the one in progress,
* or else the earliest one still to come.
*
* Deliberately ignores OrbitalPass.progress. That field is a UI-side value filled
* in by PassesViewModel on its own copy of the list; the repository always
* reports 0, so any `progress` predicate here silently matches everything.
*/
internal fun selectCurrentOrNextPass(
passes: List<OrbitalPass>,
catnum: Int,
timeMillis: Long
): OrbitalPass? {
val forSatellite = passes.filter { it.catNum == catnum }
return forSatellite.firstOrNull { timeMillis >= it.aosTime && timeMillis < it.losTime }
?: forSatellite.filter { it.aosTime > timeMillis }.minByOrNull { it.aosTime }
}
@@ -0,0 +1,84 @@
package com.rtbishop.look4sat.feature.map
import com.rtbishop.look4sat.core.domain.predict.NearEarthObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalData
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* The map info panel must describe the pass that is happening now, or the next
* one if none is.
*
* Regression: selection used to filter on `OrbitalPass.progress < 1`. That field
* defaults to 0 and is only ever updated on PassesViewModel's private copy, so
* the predicate matched every pass and the map always described the satellite's
* first pass - leaving the countdown frozen at 00:00:00 once it had ended.
*/
class CurrentOrNextPassTest {
private val hour = 3_600_000L
private val iss = 25544
private val noaa = 33591
private fun orbitalData(catnum: Int) = OrbitalData(
name = "sat-$catnum",
epoch = 21320.51955234,
meanmo = 15.48582035,
eccn = 0.0004694,
incl = 51.6447,
raan = 309.4881,
argper = 203.6966,
meanan = 299.8876,
catnum = catnum,
bstar = 0.31985e-4
)
private fun pass(catnum: Int, aos: Long, los: Long) = OrbitalPass(
aosTime = aos,
losTime = los,
orbitalObject = NearEarthObject(orbitalData(catnum))
)
private val issFirst = pass(iss, 10 * hour, 10 * hour + 600_000)
private val issSecond = pass(iss, 12 * hour, 12 * hour + 600_000)
private val issThird = pass(iss, 14 * hour, 14 * hour + 600_000)
private val other = pass(noaa, 11 * hour, 11 * hour + 600_000)
private val passes = listOf(issFirst, other, issSecond, issThird)
@Test
fun `before the first pass returns the first pass`() {
assertEquals(issFirst, selectCurrentOrNextPass(passes, iss, (9.5 * hour).toLong()))
}
@Test
fun `during a pass returns that pass`() {
assertEquals(issFirst, selectCurrentOrNextPass(passes, iss, 10 * hour + 300_000))
assertEquals(issSecond, selectCurrentOrNextPass(passes, iss, 12 * hour + 300_000))
}
@Test
fun `after a pass ends returns the following pass`() {
// The case the progress predicate got wrong: it kept returning issFirst.
assertEquals(issSecond, selectCurrentOrNextPass(passes, iss, (10.5 * hour).toLong()))
assertEquals(issThird, selectCurrentOrNextPass(passes, iss, 13 * hour))
}
@Test
fun `after the last pass returns null`() {
assertNull(selectCurrentOrNextPass(passes, iss, 15 * hour))
}
@Test
fun `other satellites are ignored`() {
assertEquals(other, selectCurrentOrNextPass(passes, noaa, 11 * hour + 60_000))
assertNull(selectCurrentOrNextPass(passes, noaa, 12 * hour))
}
@Test
fun `exact aos belongs to the pass and exact los does not`() {
assertEquals(issFirst, selectCurrentOrNextPass(passes, iss, 10 * hour))
assertEquals(issSecond, selectCurrentOrNextPass(passes, iss, 10 * hour + 600_000))
}
}