diff --git a/app/src/androidTest/java/edu/rpi/shuttletracker/feature/etas/components/EtaComponentsTest.kt b/app/src/androidTest/java/edu/rpi/shuttletracker/feature/etas/components/EtaComponentsTest.kt index 502231b5..6af47c3e 100644 --- a/app/src/androidTest/java/edu/rpi/shuttletracker/feature/etas/components/EtaComponentsTest.kt +++ b/app/src/androidTest/java/edu/rpi/shuttletracker/feature/etas/components/EtaComponentsTest.kt @@ -87,6 +87,17 @@ class EtaComponentsTest { composeRule.onAllNodesWithText("No live ETAs").assertCountEquals(2) } + @Test + fun etaChipShowsTheShuttleNameAndMinutes() { + composeRule.setContent { + ShuttleTrackerTheme(dynamicColor = false) { + EtaChip(vehicleName = "500", routeName = "NORTH", minutes = 2) + } + } + + composeRule.onNodeWithText("500 · 2m").assertIsDisplayed() + } + @Test fun tappingARouteFilterInvokesTheCallbackWithThatRoute() { var selectedFilter: String? = null @@ -131,6 +142,7 @@ class EtaComponentsTest { @Test fun sheetShowsEveryVehicleEtaForTheSelectedStop() { + var clickedVehicleId: String? = null val futureEta = Instant.now().plusSeconds(120).toString() val stop = buildStopsWithEtas( @@ -144,6 +156,7 @@ class EtaComponentsTest { stop = stop, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), onDismiss = {}, + onVehicleClick = { clickedVehicleId = it }, ) } } @@ -152,7 +165,8 @@ class EtaComponentsTest { composeRule.waitForIdle() composeRule.onNodeWithText("Student Union").assertIsDisplayed() - composeRule.onNodeWithText("NORTH Bus").assertIsDisplayed() + composeRule.onNodeWithText("NORTH Bus").performClick() + assertEquals("bus-1", clickedVehicleId) } @Test @@ -169,6 +183,7 @@ class EtaComponentsTest { stop = stop, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), onDismiss = {}, + onVehicleClick = {}, ) } } diff --git a/app/src/androidTest/java/edu/rpi/shuttletracker/feature/map/MapsScreenNavigationTest.kt b/app/src/androidTest/java/edu/rpi/shuttletracker/feature/map/MapsScreenNavigationTest.kt index edb674a7..cad9104b 100644 --- a/app/src/androidTest/java/edu/rpi/shuttletracker/feature/map/MapsScreenNavigationTest.kt +++ b/app/src/androidTest/java/edu/rpi/shuttletracker/feature/map/MapsScreenNavigationTest.kt @@ -8,6 +8,9 @@ import androidx.compose.ui.test.performClick import androidx.test.ext.junit.runners.AndroidJUnit4 import edu.rpi.shuttletracker.core.network.NetworkResult import edu.rpi.shuttletracker.core.ui.theme.ShuttleTrackerTheme +import edu.rpi.shuttletracker.data.models.VehicleLocation +import edu.rpi.shuttletracker.data.models.VehicleStopEta +import edu.rpi.shuttletracker.data.models.VehicleVelocities import edu.rpi.shuttletracker.feature.schedule.ScheduleViewModel import edu.rpi.shuttletracker.testing.fakes.FakeShuttleRepository import edu.rpi.shuttletracker.testing.fakes.FakeUserPreferences @@ -16,6 +19,7 @@ import edu.rpi.shuttletracker.testing.fixtures.testSchedule import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import java.time.Instant /** * Constructs MapsViewModel directly with fakes (bypassing hiltViewModel()) so the bottom @@ -72,11 +76,35 @@ class MapsScreenNavigationTest { composeRule.onNodeWithContentDescription("Open settings").assertIsDisplayed() } - private fun setContent() { + @Test + fun tappingAVehicleInTheEtaSheetSwitchesToMap() { + setContent { + vehicleLocations.tryEmit( + NetworkResult.Success( + mapOf("bus-1" to VehicleLocation("500", 42.730, -73.680, 12.0, Instant.now().toString(), 90)), + ), + ) + vehicleVelocities.tryEmit(NetworkResult.Success(mapOf("bus-1" to VehicleVelocities("NORTH", false, null)))) + vehicleEtas.tryEmit( + NetworkResult.Success( + mapOf("bus-1" to VehicleStopEta(mapOf("union" to Instant.now().plusSeconds(300).toString()))), + ), + ) + } + + composeRule.onNodeWithText("ETAs").performClick() + composeRule.onNodeWithText("Student Union").performClick() + composeRule.onNodeWithText("500").performClick() + + composeRule.onNodeWithContentDescription("Open settings").assertIsDisplayed() + } + + private fun setContent(configureRepository: FakeShuttleRepository.() -> Unit = {}) { val repository = FakeShuttleRepository().apply { routesResult = NetworkResult.Success(mapOf("NORTH" to testRoute())) scheduleResult = NetworkResult.Success(testSchedule()) + configureRepository() } val preferences = FakeUserPreferences() val viewModel = MapsViewModel(repository, preferences) diff --git a/app/src/main/java/edu/rpi/shuttletracker/data/mapper/ShuttleMappers.kt b/app/src/main/java/edu/rpi/shuttletracker/data/mapper/ShuttleMappers.kt index 2b32c716..1723bf19 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/data/mapper/ShuttleMappers.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/data/mapper/ShuttleMappers.kt @@ -27,7 +27,7 @@ fun VehicleLocationDto.toModel(): VehicleLocation { require(latitude.isFinite() && latitude in -90.0..90.0) { "Invalid vehicle latitude" } require(longitude.isFinite() && longitude in -180.0..180.0) { "Invalid vehicle longitude" } OffsetDateTime.parse(timestamp) - return VehicleLocation(name, latitude, longitude, speedMph, timestamp, headingDegrees) + return VehicleLocation(name, latitude, longitude, speedMph, timestamp, headingDegrees?.toInt()) } fun VehicleStopEtaDto.toModel() = VehicleStopEta(stopTimes) diff --git a/app/src/main/java/edu/rpi/shuttletracker/data/models/Vehicle.kt b/app/src/main/java/edu/rpi/shuttletracker/data/models/Vehicle.kt index 926b7ee8..b09c3bfb 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/data/models/Vehicle.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/data/models/Vehicle.kt @@ -87,7 +87,7 @@ data class VehicleStopEta( /** The `/velocities` endpoint's data, one per vehicle: which route it's on and its stop status. */ data class VehicleVelocities( - val routeName: String, + val routeName: String?, val isAtStop: Boolean, val currentStop: String?, ) diff --git a/app/src/main/java/edu/rpi/shuttletracker/data/remote/dto/VehicleDtos.kt b/app/src/main/java/edu/rpi/shuttletracker/data/remote/dto/VehicleDtos.kt index b6e1deb5..4f7c3454 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/data/remote/dto/VehicleDtos.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/data/remote/dto/VehicleDtos.kt @@ -14,7 +14,7 @@ data class VehicleLocationDto( val longitude: Double, @SerialName("speed_mph") val speedMph: Double, val timestamp: String, - @SerialName("heading_degrees") val headingDegrees: Int? = null, + @SerialName("heading_degrees") val headingDegrees: Double? = null, ) @Serializable @@ -24,7 +24,7 @@ data class VehicleStopEtaDto( @Serializable data class VehicleVelocitiesDto( - @SerialName("route_name") val routeName: String, + @SerialName("route_name") val routeName: String?, @SerialName("is_at_stop") val isAtStop: Boolean, @SerialName("current_stop") val currentStop: String? = null, ) diff --git a/app/src/main/java/edu/rpi/shuttletracker/feature/etas/EtasScreen.kt b/app/src/main/java/edu/rpi/shuttletracker/feature/etas/EtasScreen.kt index 22ba9d91..93c76f54 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/feature/etas/EtasScreen.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/feature/etas/EtasScreen.kt @@ -28,6 +28,7 @@ fun EtasScreen( routes: Map, vehicles: List, routesLoaded: Boolean, + onVehicleClick: (String) -> Unit, showTitle: Boolean = true, ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) @@ -52,6 +53,10 @@ fun EtasScreen( stop = selectedStop, sheetState = sheetState, onDismiss = { selectedStopKey = null }, + onVehicleClick = { vehicleId -> + selectedStopKey = null + onVehicleClick(vehicleId) + }, ) } } diff --git a/app/src/main/java/edu/rpi/shuttletracker/feature/etas/components/StopEtaList.kt b/app/src/main/java/edu/rpi/shuttletracker/feature/etas/components/StopEtaList.kt index a8584d88..a9b433bb 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/feature/etas/components/StopEtaList.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/feature/etas/components/StopEtaList.kt @@ -203,7 +203,11 @@ private fun StopEtaRow( modifier = Modifier.padding(top = 6.dp), ) { stop.etas.take(3).forEach { eta -> - EtaChip(routeName = eta.routeName, minutes = etaMinutesFromNow(eta.etaInstant)) + EtaChip( + vehicleName = eta.vehicleName, + routeName = eta.routeName, + minutes = etaMinutesFromNow(eta.etaInstant), + ) } } } @@ -217,6 +221,7 @@ private fun StopEtaRow( @Composable fun EtaChip( + vehicleName: String, routeName: String?, minutes: Long, ) { @@ -227,7 +232,7 @@ fun EtaChip( shape = RoundedCornerShape(6.dp), ) { Text( - text = etaLabelText(minutes), + text = stringResource(R.string.eta_vehicle_format, vehicleName, etaLabelText(minutes)), modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), style = MaterialTheme.typography.labelSmall, color = tagColor, diff --git a/app/src/main/java/edu/rpi/shuttletracker/feature/etas/components/StopEtaSheet.kt b/app/src/main/java/edu/rpi/shuttletracker/feature/etas/components/StopEtaSheet.kt index 1a0d7365..0acd36ff 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/feature/etas/components/StopEtaSheet.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/feature/etas/components/StopEtaSheet.kt @@ -36,6 +36,7 @@ fun StopEtaSheet( stop: StopWithEtas?, sheetState: SheetState, onDismiss: () -> Unit, + onVehicleClick: (String) -> Unit, ) { if (stop == null) return @@ -64,7 +65,7 @@ fun StopEtaSheet( ) } else { stop.etas.forEach { eta -> - StopEtaDetailRow(eta) + StopEtaDetailRow(eta, onVehicleClick) } Text( @@ -78,10 +79,14 @@ fun StopEtaSheet( } @Composable -private fun StopEtaDetailRow(eta: VehicleEta) { +private fun StopEtaDetailRow( + eta: VehicleEta, + onVehicleClick: (String) -> Unit, +) { val tagColor = routeAccentColor(eta.routeName) Surface( + onClick = { onVehicleClick(eta.vehicleId) }, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp), color = tagColor.copy(alpha = 0.10f), @@ -103,7 +108,11 @@ private fun StopEtaDetailRow(eta: VehicleEta) { ) } - EtaChip(routeName = eta.routeName, minutes = etaMinutesFromNow(eta.etaInstant)) + EtaChip( + vehicleName = eta.vehicleName, + routeName = eta.routeName, + minutes = etaMinutesFromNow(eta.etaInstant), + ) } } } diff --git a/app/src/main/java/edu/rpi/shuttletracker/feature/etas/utils/EtaUtils.kt b/app/src/main/java/edu/rpi/shuttletracker/feature/etas/utils/EtaUtils.kt index a75a38f2..6c6f4f2a 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/feature/etas/utils/EtaUtils.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/feature/etas/utils/EtaUtils.kt @@ -30,6 +30,9 @@ data class StopWithEtas( * */ val ETA_VISIBLE_ROUTES = listOf("NORTH", "WEST") +/** How long a just-passed arrival remains visible in the ETA tab. */ +internal const val ETA_PAST_GRACE_PERIOD_MINUTES = 1L + /** * Inverts each vehicle's [Vehicle.stopTimes] (stop key -> eta) into a per-stop view, one entry per * stop across [ETA_VISIBLE_ROUTES] (or just [routeFilter] if given), each carrying its own sorted @@ -39,8 +42,10 @@ fun buildStopsWithEtas( routes: Map, vehicles: List, routeFilter: String? = null, + now: Instant = Instant.now(), ): List { val stopsByKey = linkedMapOf>>() + val oldestVisibleEta = now.minusSeconds(ETA_PAST_GRACE_PERIOD_MINUTES * 60) for ((routeName, route) in routes) { if (routeName !in ETA_VISIBLE_ROUTES) continue @@ -61,8 +66,10 @@ fun buildStopsWithEtas( vehicles .mapNotNull { vehicle -> if (vehicle.routeName !in ETA_VISIBLE_ROUTES) return@mapNotNull null + if (routeFilter != null && vehicle.routeName != routeFilter) return@mapNotNull null val rawEta = vehicle.stopTimes[stopKey] ?: return@mapNotNull null val etaInstant = rawEta.toEtaInstantOrNull() ?: return@mapNotNull null + if (etaInstant.isBefore(oldestVisibleEta)) return@mapNotNull null VehicleEta( vehicleId = vehicle.id, @@ -78,7 +85,7 @@ fun buildStopsWithEtas( routeNames = routeNames.sorted(), etas = etas, ) - }.sortedBy { it.stop.name } + } } fun String.toEtaInstantOrNull(): Instant? = runCatching { OffsetDateTime.parse(trim()).toInstant() }.getOrNull() diff --git a/app/src/main/java/edu/rpi/shuttletracker/feature/map/MapContent.kt b/app/src/main/java/edu/rpi/shuttletracker/feature/map/MapContent.kt index af08a674..11341a1c 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/feature/map/MapContent.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/feature/map/MapContent.kt @@ -23,6 +23,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -65,6 +66,7 @@ private const val TILT_ZOOM = 18f // didn't mean to pan - only treat a gesture as a real pan (and drop out of follow mode) once it // moves the target further than incidental zoom/rotate drift would. private const val PAN_DETECTION_THRESHOLD_METERS = 20f +private const val VEHICLE_FOCUS_ZOOM = 17f /** * Mirrors the stock Google Maps app's location FAB: [NotFollowing] until tapped, then @@ -91,6 +93,8 @@ internal fun ShuttleMap( onSettingsClick: () -> Unit, onToggleMapTypeClick: () -> Unit, onAnnouncementsClick: () -> Unit, + focusedVehicleId: String?, + onFocusedVehicleHandled: () -> Unit, ) { val context = LocalContext.current val coroutineScope = rememberCoroutineScope() @@ -111,6 +115,27 @@ internal fun ShuttleMap( val useDarkMap = uiState.themeMode.isDarkTheme(isSystemInDarkTheme()) val fallbackRouteColor = MaterialTheme.colorScheme.primary + LaunchedEffect(focusedVehicleId) { + val vehicleId = focusedVehicleId ?: return@LaunchedEffect + val vehicle = + (uiState.vehicles + uiState.fakeVehicles).firstOrNull { it.id == vehicleId } + ?: run { + onFocusedVehicleHandled() + return@LaunchedEffect + } + + // Drop selection for one frame so repeatedly choosing the same shuttle reopens its window. + selectedDevVehicleId = null + withFrameNanos {} + selectedDevVehicleId = vehicle.id + followMode = LocationFollowMode.NotFollowing + cameraPositionState.animate( + CameraUpdateFactory.newLatLngZoom(vehicle.latLng(), VEHICLE_FOCUS_ZOOM), + durationMs = 1000, + ) + onFocusedVehicleHandled() + } + LaunchedEffect(cameraPositionState.isMoving) { if (cameraPositionState.isMoving) { if (cameraPositionState.cameraMoveStartedReason == CameraMoveStartedReason.GESTURE) { @@ -275,7 +300,7 @@ internal fun ShuttleMap( selectedDevVehicleId = vehicle.id coroutineScope.launch { cameraPositionState.animate( - CameraUpdateFactory.newLatLngZoom(vehicle.latLng(), 17f), + CameraUpdateFactory.newLatLngZoom(vehicle.latLng(), VEHICLE_FOCUS_ZOOM), durationMs = 1000, ) } diff --git a/app/src/main/java/edu/rpi/shuttletracker/feature/map/MapsScreen.kt b/app/src/main/java/edu/rpi/shuttletracker/feature/map/MapsScreen.kt index d9be873b..48be75d3 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/feature/map/MapsScreen.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/feature/map/MapsScreen.kt @@ -28,6 +28,7 @@ import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier @@ -75,6 +76,7 @@ fun MapsScreen( val pagerState = rememberPagerState { MainTab.entries.size } val selectedTab = MainTab.entries[pagerState.currentPage] var isAnnouncementsSheetVisible by rememberSaveable { mutableStateOf(false) } + var focusedVehicleId by remember { mutableStateOf(null) } val announcementsSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) // Compact is a phone in portrait; anything wider (a rotated phone, a foldable, a tablet) gets @@ -149,6 +151,8 @@ fun MapsScreen( isAnnouncementsSheetVisible = isAnnouncementsSheetVisible, onAnnouncementsSheetVisibleChange = { isAnnouncementsSheetVisible = it }, announcementsSheetState = announcementsSheetState, + focusedVehicleId = focusedVehicleId, + onFocusedVehicleHandled = { focusedVehicleId = null }, ) MainTab.Etas -> @@ -163,6 +167,10 @@ fun MapsScreen( routes = uiState.routes, vehicles = uiState.vehicles + uiState.fakeVehicles, routesLoaded = uiState.routesLoaded, + onVehicleClick = { vehicleId -> + focusedVehicleId = vehicleId + pagerState.requestScrollToPage(MainTab.Map.ordinal) + }, showTitle = !useNavigationRail, ) } @@ -199,6 +207,8 @@ private fun MapTab( isAnnouncementsSheetVisible: Boolean, onAnnouncementsSheetVisibleChange: (Boolean) -> Unit, announcementsSheetState: SheetState, + focusedVehicleId: String?, + onFocusedVehicleHandled: () -> Unit, ) { Box(Modifier.fillMaxSize()) { ShuttleMap( @@ -207,6 +217,8 @@ private fun MapTab( onSettingsClick = onSettingsClick, onToggleMapTypeClick = viewModel::toggleMapType, onAnnouncementsClick = { onAnnouncementsSheetVisibleChange(true) }, + focusedVehicleId = focusedVehicleId, + onFocusedVehicleHandled = onFocusedVehicleHandled, ) AnnouncementSheet( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 11ed2c34..3407b573 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -101,6 +101,7 @@ No live ETAs now %1$dm + %1$s · %2$s ETAs update automatically and may be off by a few minutes. diff --git a/app/src/test/java/edu/rpi/shuttletracker/data/mapper/ShuttleMappersTest.kt b/app/src/test/java/edu/rpi/shuttletracker/data/mapper/ShuttleMappersTest.kt index 66c7f625..3ca95c2f 100644 --- a/app/src/test/java/edu/rpi/shuttletracker/data/mapper/ShuttleMappersTest.kt +++ b/app/src/test/java/edu/rpi/shuttletracker/data/mapper/ShuttleMappersTest.kt @@ -19,13 +19,14 @@ class ShuttleMappersTest { fun `vehicle location JSON uses the backend field names`() { val dto = Json.decodeFromString( - """{"name":"North","latitude":42.73,"longitude":-73.68,"speed_mph":5.0,"timestamp":"2026-01-15T08:00:00-05:00","heading_degrees":90}""", + """{"name":"North","latitude":42.73,"longitude":-73.68,"speed_mph":5.0,"timestamp":"2026-01-15T08:00:00-05:00","heading_degrees":90.0}""", ) val location = dto.toModel() assertEquals("North", location.name) assertEquals(42.73, location.latitude, 0.0) + assertEquals(90, location.headingDegrees) } @Test @@ -37,7 +38,7 @@ class ShuttleMappersTest { longitude = -73.68, speedMph = 5.0, timestamp = "2026-01-15T08:00:00-05:00", - headingDegrees = 90, + headingDegrees = 90.0, ) assertThrows(IllegalArgumentException::class.java, dto::toModel) diff --git a/app/src/test/java/edu/rpi/shuttletracker/data/remote/RetrofitShuttleRemoteDataSourceIntegrationTest.kt b/app/src/test/java/edu/rpi/shuttletracker/data/remote/RetrofitShuttleRemoteDataSourceIntegrationTest.kt index 6e3c9a91..ef566168 100644 --- a/app/src/test/java/edu/rpi/shuttletracker/data/remote/RetrofitShuttleRemoteDataSourceIntegrationTest.kt +++ b/app/src/test/java/edu/rpi/shuttletracker/data/remote/RetrofitShuttleRemoteDataSourceIntegrationTest.kt @@ -150,6 +150,32 @@ class RetrofitShuttleRemoteDataSourceIntegrationTest { assertThat(server.takeRequest().path).isEqualTo("/velocities") } + @Test + fun `velocity endpoint accepts an unmatched vehicle with no route`() = + runTest { + server.enqueue( + jsonResponse( + """ + { + "bus-1": { + "speed_kmh": 0.0, + "timestamp": "2026-08-24T14:06:55+00:00", + "route_name": null, + "segment_index": null, + "polyline_index": null, + "is_at_stop": false, + "current_stop": null + } + } + """.trimIndent(), + ), + ) + + val result = dataSource.getVehicleVelocities() + + assertThat((result as NetworkResult.Success).data.getValue("bus-1").routeName).isNull() + } + private fun resource(name: String): String = checkNotNull(javaClass.getResource("/api/$name")) { "Missing fixture $name" }.readText() diff --git a/app/src/test/java/edu/rpi/shuttletracker/feature/etas/utils/EtaUtilsTest.kt b/app/src/test/java/edu/rpi/shuttletracker/feature/etas/utils/EtaUtilsTest.kt index a420d195..515cd14c 100644 --- a/app/src/test/java/edu/rpi/shuttletracker/feature/etas/utils/EtaUtilsTest.kt +++ b/app/src/test/java/edu/rpi/shuttletracker/feature/etas/utils/EtaUtilsTest.kt @@ -56,29 +56,36 @@ class EtaUtilsTest { vehicles = emptyList(), ) - assertThat(stops.map { it.stopKey }).containsExactly("academy", "union") + assertThat(stops.map { it.stopKey }).containsExactly("union", "academy").inOrder() assertThat(stops.single { it.stopKey == "union" }.routeNames).containsExactly("NORTH", "WEST").inOrder() } @Test fun `route filter limits stops to that route`() { + val now = Instant.parse("2026-07-19T12:00:00Z") + val northBus = vehicle("bus-1", "500", "NORTH", mapOf("union" to "2026-07-19T12:05:00Z")) + val westBus = vehicle("bus-2", "410", "WEST", mapOf("union" to "2026-07-19T12:06:00Z")) val stops = buildStopsWithEtas( routes = mapOf("NORTH" to northRoute, "WEST" to westRoute), - vehicles = emptyList(), + vehicles = listOf(northBus, westBus), routeFilter = "NORTH", + now = now, ) assertThat(stops.map { it.stopKey }).containsExactly("academy", "union") + assertThat(stops.single { it.stopKey == "union" }.etas.map { it.routeName }).containsExactly("NORTH") val westOnly = buildStopsWithEtas( routes = mapOf("NORTH" to northRoute, "WEST" to westRoute), - vehicles = emptyList(), + vehicles = listOf(northBus, westBus), routeFilter = "WEST", + now = now, ) assertThat(westOnly.map { it.stopKey }).containsExactly("union") + assertThat(westOnly.single().etas.map { it.routeName }).containsExactly("WEST") } @Test @@ -110,12 +117,45 @@ class EtaUtilsTest { buildStopsWithEtas( routes = mapOf("NORTH" to northRoute, "WEST" to westRoute), vehicles = listOf(bus1, bus2), + now = Instant.parse("2026-07-19T12:00:00Z"), ) val unionEtas = stops.single { it.stopKey == "union" }.etas assertThat(unionEtas.map { it.vehicleId }).containsExactly("bus-2", "bus-1").inOrder() } + @Test + fun `etas stay visible for the configured grace period after passing`() { + val now = Instant.parse("2026-07-19T12:00:00Z") + val graceSeconds = ETA_PAST_GRACE_PERIOD_MINUTES * 60 + val withinGrace = + vehicle( + "bus-1", + "500", + "NORTH", + mapOf("union" to now.minusSeconds(graceSeconds - 1).toString()), + ) + val expired = + vehicle( + "bus-2", + "410", + "NORTH", + mapOf("union" to now.minusSeconds(graceSeconds + 1).toString()), + ) + val upcoming = vehicle("bus-3", "450", "NORTH", mapOf("union" to now.plusSeconds(60).toString())) + + val stops = + buildStopsWithEtas( + routes = mapOf("NORTH" to northRoute), + vehicles = listOf(withinGrace, expired, upcoming), + now = now, + ) + + assertThat(stops.single { it.stopKey == "union" }.etas.map { it.vehicleName }) + .containsExactly("500", "450") + .inOrder() + } + @Test fun `a vehicle on a route outside the visible list is excluded from eta chips too`() { val strayBus = vehicle("bus-3", "Test Bus", "ACADEMY_SHUTTLE", mapOf("union" to "2026-07-19T12:01:00Z"))