diff --git a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/AlertAssociatedStop.kt b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/AlertAssociatedStop.kt index df845fa32..d0ebe84df 100644 --- a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/AlertAssociatedStop.kt +++ b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/AlertAssociatedStop.kt @@ -26,6 +26,12 @@ class AlertAssociatedStop( childAlerts ) ) + + constructor( + stop: Stop, + relevantAlerts: List, + serviceStatus: StopServiceStatus + ) : this(stop, relevantAlerts, getServiceAlerts(relevantAlerts), mapOf(), serviceStatus) } enum class StopServiceStatus { diff --git a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/RouteSegment.kt b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/RouteSegment.kt index 4ac4b0ba8..2a52da48b 100644 --- a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/RouteSegment.kt +++ b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/RouteSegment.kt @@ -3,6 +3,19 @@ package com.mbta.tid.mbta_app.model import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +interface IRouteSegment { + val sourceRoutePatternId: String + val sourceRouteId: String + val stopIds: List + val otherPatternsByStopId: Map> +} + +@Serializable +data class RoutePatternKey( + @SerialName("route_id") val routeId: String, + @SerialName("route_pattern_id") val routePatternId: String +) + @Serializable /** * A sequential chunk of stops on a route pattern that don't overlap with segments for other route @@ -10,15 +23,135 @@ import kotlinx.serialization.Serializable */ data class RouteSegment( val id: String, - @SerialName("source_route_pattern_id") val sourceRoutePatternId: String, - @SerialName("source_route_id") val sourceRouteId: String, - @SerialName("stop_ids") val stopIds: List, + @SerialName("source_route_pattern_id") override val sourceRoutePatternId: String, + @SerialName("source_route_id") override val sourceRouteId: String, + @SerialName("stop_ids") override val stopIds: List, @SerialName("other_patterns_by_stop_id") - val otherPatternsByStopId: Map> -) { - @Serializable - data class RoutePatternKey( - @SerialName("route_id") val routeId: String, - @SerialName("route_pattern_id") val routePatternId: String - ) + override val otherPatternsByStopId: Map> +) : IRouteSegment { + + /** + * Split this route segment into one or more `AlertAwareRouteSegments` based on the alerts for + * the stops within this segment. + */ + fun splitAlertingSegments( + alertsByStop: Map + ): List { + val stopsWithServiceAlerts = hasServiceAlertByStopId(alertsByStop) + + val alertingSegments = RouteSegment.alertingSegments(stopIds, stopsWithServiceAlerts) + + return alertingSegments.mapIndexed { index, (isAlerting, segmentStops) -> + val stopIdSet = segmentStops.toSet() + + AlertAwareRouteSegment( + id = "$id-$index", + sourceRoutePatternId = sourceRoutePatternId, + sourceRouteId = sourceRouteId, + stopIds = segmentStops, + isAlerting = isAlerting, + otherPatternsByStopId = otherPatternsByStopId.filter { stopIdSet.contains(it.key) } + ) + } + } + + /** + * Get the set of stop IDs that have a service alert relevant to this route segment. A service + * alert for a stop is relevant if it applies to the `sourceRouteId` for the segment or any + * route included in the `otherPatternsByStopId` for that stop. + */ + fun hasServiceAlertByStopId(alertsByStop: Map): Set { + return stopIds + .filter { stopId -> + if (!alertsByStop.containsKey(stopId)) { + false + } else { + + var routes: Set = + otherPatternsByStopId + .getOrElse(stopId) { listOf() } + .map { it.routeId } + .toSet() + routes = routes.plus(sourceRouteId) + + val hasServiceAlert: Boolean = + alertsByStop[stopId]?.serviceAlerts?.any { alert -> + alert.anyInformedEntity { informedEntity -> + informedEntity.route != null && + routes.contains(informedEntity.route) + } + } + ?: false + hasServiceAlert + } + } + .toSet() + } + + /** + * Split the list of stops into segments based on whether or not they are alerting. At a + * boundary where the segment switches from non-alerting to alerting or alerting to + * non-alerting, the alerting stop is included in both segments. + */ + companion object { + fun alertingSegments( + stopIds: List, + alertingStopIds: Set + ): List>> { + + if (stopIds.isEmpty()) { + return listOf() + } + + val firstStopId = stopIds[0] + + var accSegments: MutableList>> = mutableListOf() + // Seed the first segment with the first stop + var inProgressSegment: Pair> = + Pair(alertingStopIds.contains(firstStopId), mutableListOf(firstStopId)) + // the first stop was seeded - skip it + stopIds.drop(1).forEach { stopId -> + val stopHasAlert = alertingStopIds.contains(stopId) + val inProgressSegmentHasAlert = inProgressSegment.first + + if (stopHasAlert == inProgressSegmentHasAlert) { + inProgressSegment.second.add(stopId) + } else { + inProgressSegment = + if (stopHasAlert && !inProgressSegmentHasAlert) { + // add this stop as the last stop in the in progress segment, and move + // that + // segment to the accumulator + inProgressSegment.second.add(stopId) + accSegments.add(inProgressSegment) + Pair(stopHasAlert, mutableListOf(stopId)) + } else { + // this stop doesn't have an alert, the previous did one did. + // move the in progress segment to the accumulator and start a new one + // with the last alerting stop from the previous segment + accSegments.add(inProgressSegment) + Pair( + stopHasAlert, + mutableListOf(inProgressSegment.second.last(), stopId) + ) + } + } + } + return accSegments.plus(inProgressSegment) + } + } } + +/** + * A route segment of consecutive stops that form an alerting or non-alerting segment. Non-alerting + * segments that are adjacent to an alerting segment will include the consecutive stops up to and + * including the adjacent alerting stop. + */ +data class AlertAwareRouteSegment( + val id: String, + override val sourceRoutePatternId: String, + override val sourceRouteId: String, + override val stopIds: List, + override val otherPatternsByStopId: Map>, + val isAlerting: Boolean +) : IRouteSegment diff --git a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/response/RouteResponse.kt b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/response/RouteResponse.kt deleted file mode 100644 index 2c3b0ee2d..000000000 --- a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/response/RouteResponse.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.mbta.tid.mbta_app.model.response - -import com.mbta.tid.mbta_app.model.Route -import com.mbta.tid.mbta_app.model.RoutePattern -import com.mbta.tid.mbta_app.model.Shape -import com.mbta.tid.mbta_app.model.Trip -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class RouteResponse( - val routes: List, - @SerialName("route_patterns") val routePatterns: Map, - val shapes: Map, - val trips: Map -) diff --git a/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/model/RouteSegmentTest.kt b/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/model/RouteSegmentTest.kt new file mode 100644 index 000000000..29cd4de9a --- /dev/null +++ b/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/model/RouteSegmentTest.kt @@ -0,0 +1,498 @@ +package com.mbta.tid.mbta_app.model + +import com.mbta.tid.mbta_app.model.ObjectCollectionBuilder.Single.alert +import com.mbta.tid.mbta_app.model.ObjectCollectionBuilder.Single.stop +import kotlin.test.Test +import kotlin.test.assertEquals + +class RouteSegmentTest { + + @Test + fun `hasServiceAlertByStopId excludes stops without alerts`() { + + val segment = + RouteSegment( + id = "id", + sourceRouteId = "sourceRoute", + sourceRoutePatternId = "sourceRoutePattern", + stopIds = listOf("place-davis"), + otherPatternsByStopId = mapOf() + ) + + assertEquals(setOf(), segment.hasServiceAlertByStopId(mapOf())) + } + + @Test + fun `hasServiceAlertByStopId excludes stop when alerts for stop are not service alerts`() { + val segment = + RouteSegment( + id = "id", + sourceRouteId = "sourceRoute", + sourceRoutePatternId = "sourceRoutePattern", + stopIds = listOf("place-davis"), + otherPatternsByStopId = mapOf() + ) + + val alertsForStop = + AlertAssociatedStop( + stop = stop { id = "place-davis" }, + relevantAlerts = + listOf( + alert { + effect = Alert.Effect.Delay + informedEntity( + listOf( + Alert.InformedEntity.Activity.Board, + Alert.InformedEntity.Activity.Exit, + Alert.InformedEntity.Activity.Ride + ), + route = "sourceRoute", + routeType = RouteType.HEAVY_RAIL, + stop = "place-davis" + ) + } + ), + serviceStatus = StopServiceStatus.NORMAL + ) + + assertEquals( + setOf(), + segment.hasServiceAlertByStopId(mapOf("place-davis" to alertsForStop)) + ) + } + + @Test + fun `hasServiceAlertByStopId includes stop when at least one service alert`() { + val segment = + RouteSegment( + id = "id", + sourceRouteId = "sourceRoute", + sourceRoutePatternId = "sourceRoutePattern", + stopIds = listOf("place-davis"), + otherPatternsByStopId = mapOf() + ) + + val alertsForStop = + AlertAssociatedStop( + stop = stop { id = "place-davis" }, + relevantAlerts = + listOf( + alert { + effect = Alert.Effect.Shuttle + informedEntity( + listOf( + Alert.InformedEntity.Activity.Board, + Alert.InformedEntity.Activity.Exit, + Alert.InformedEntity.Activity.Ride + ), + route = "sourceRoute", + routeType = RouteType.HEAVY_RAIL, + stop = "place-davis" + ) + }, + alert { + effect = Alert.Effect.Delay + informedEntity( + listOf( + Alert.InformedEntity.Activity.Board, + Alert.InformedEntity.Activity.Exit, + Alert.InformedEntity.Activity.Ride + ), + route = "sourceRoute", + routeType = RouteType.HEAVY_RAIL, + stop = "place-davis" + ) + } + ), + serviceStatus = StopServiceStatus.NORMAL + ) + + assertEquals( + setOf("place-davis"), + segment.hasServiceAlertByStopId(mapOf("place-davis" to alertsForStop)) + ) + } + + @Test + fun `hasServiceAlertByStopId excludes stop when service alert is not for the segment's route`() { + val segment = + RouteSegment( + id = "id", + sourceRouteId = "sourceRoute", + sourceRoutePatternId = "sourceRoutePattern", + stopIds = listOf("place-davis"), + otherPatternsByStopId = mapOf() + ) + + val alertsForStop = + AlertAssociatedStop( + stop = stop { id = "place-davis" }, + relevantAlerts = + listOf( + alert { + effect = Alert.Effect.Shuttle + informedEntity( + listOf( + Alert.InformedEntity.Activity.Board, + Alert.InformedEntity.Activity.Exit, + Alert.InformedEntity.Activity.Ride + ), + route = "otherRoute", + routeType = RouteType.HEAVY_RAIL, + stop = "place-davis" + ) + } + ), + serviceStatus = StopServiceStatus.NORMAL + ) + + assertEquals( + setOf(), + segment.hasServiceAlertByStopId(mapOf("place-davis" to alertsForStop)) + ) + } + + @Test + fun `hasServiceAlertByStopId has stop when service alert is for included route of segment`() { + val segment = + RouteSegment( + id = "id", + sourceRouteId = "sourceRoute", + sourceRoutePatternId = "sourceRoutePattern", + stopIds = listOf("place-davis"), + otherPatternsByStopId = + mapOf( + "place-davis" to listOf(RoutePatternKey("otherRoute", "otherRoutePattern")) + ) + ) + + val alertsForStop = + AlertAssociatedStop( + stop = stop { id = "place-davis" }, + relevantAlerts = + listOf( + alert { + effect = Alert.Effect.Shuttle + informedEntity( + listOf( + Alert.InformedEntity.Activity.Board, + Alert.InformedEntity.Activity.Exit, + Alert.InformedEntity.Activity.Ride + ), + route = "sourceRoute", + routeType = RouteType.HEAVY_RAIL, + stop = "place-davis" + ) + } + ), + serviceStatus = StopServiceStatus.NORMAL + ) + + assertEquals( + setOf("place-davis"), + segment.hasServiceAlertByStopId(mapOf("place-davis" to alertsForStop)) + ) + } + + @Test + fun `alertingSegments when alerting segment in the middle splits so alert in each segment`() { + + assertEquals( + listOf( + Pair(false, listOf("alewife", "davis", "porter")), + Pair(true, listOf("porter", "harvard")), + Pair(false, listOf("harvard", "central")) + ), + RouteSegment.alertingSegments( + listOf("alewife", "davis", "porter", "harvard", "central"), + setOf("porter", "harvard") + ) + ) + } + + @Test + fun `alertingSegments when alerting segment at the ends splits so alert in each segment`() { + + assertEquals( + listOf( + Pair(true, listOf("alewife", "davis")), + Pair(false, listOf("davis", "porter", "harvard", "central")), + Pair(true, listOf("central")) + ), + RouteSegment.alertingSegments( + listOf("alewife", "davis", "porter", "harvard", "central"), + setOf("alewife", "davis", "central") + ) + ) + } + + @Test + fun `alertingSegments when all alerting returns one segment`() { + + assertEquals( + listOf( + Pair(true, listOf("alewife", "davis", "porter", "harvard", "central")), + ), + RouteSegment.alertingSegments( + listOf("alewife", "davis", "porter", "harvard", "central"), + setOf("alewife", "davis", "porter", "harvard", "central") + ) + ) + } + + @Test + fun `alertingSegments when none alerting returns one segment`() { + + assertEquals( + listOf( + Pair(false, listOf("alewife", "davis", "porter", "harvard", "central")), + ), + RouteSegment.alertingSegments( + listOf("alewife", "davis", "porter", "harvard", "central"), + setOf() + ) + ) + } + + @Test + fun `splitAlertingSegments when alerting segment in the middle splits so alert in each segment`() { + var routeSegment = + RouteSegment( + id = "id", + sourceRouteId = "sourceRoute", + sourceRoutePatternId = "sourceRoutePattern", + stopIds = listOf("alewife", "davis", "porter", "harvard", "central"), + otherPatternsByStopId = + mapOf( + "alewife" to + listOf( + RoutePatternKey(routeId = "otherRoute", routePatternId = "otherRp") + ) + ) + ) + + val alertsForStop = + mapOf( + "davis" to serviceAlert("davis", routeSegment.sourceRouteId), + "porter" to serviceAlert("porter", routeSegment.sourceRouteId) + ) + + assertEquals( + listOf( + AlertAwareRouteSegment( + id = "id-0", + sourceRoutePatternId = routeSegment.sourceRoutePatternId, + sourceRouteId = routeSegment.sourceRouteId, + stopIds = listOf("alewife", "davis"), + otherPatternsByStopId = + mapOf( + "alewife" to + listOf( + RoutePatternKey( + routeId = "otherRoute", + routePatternId = "otherRp" + ) + ) + ), + isAlerting = false + ), + AlertAwareRouteSegment( + id = "id-1", + sourceRoutePatternId = routeSegment.sourceRoutePatternId, + sourceRouteId = routeSegment.sourceRouteId, + stopIds = listOf("davis", "porter"), + otherPatternsByStopId = mapOf(), + isAlerting = true + ), + AlertAwareRouteSegment( + id = "id-2", + sourceRoutePatternId = routeSegment.sourceRoutePatternId, + sourceRouteId = routeSegment.sourceRouteId, + stopIds = listOf("porter", "harvard", "central"), + otherPatternsByStopId = mapOf(), + isAlerting = false + ), + ), + routeSegment.splitAlertingSegments(alertsForStop) + ) + } + + @Test + fun `splitAlertingSegments when alerting segment at the ends splits so alert in each segment`() { + var routeSegment = + RouteSegment( + id = "id", + sourceRouteId = "sourceRoute", + sourceRoutePatternId = "sourceRoutePattern", + stopIds = listOf("alewife", "davis", "porter", "harvard", "central"), + otherPatternsByStopId = + mapOf( + "alewife" to + listOf( + RoutePatternKey(routeId = "otherRoute", routePatternId = "otherRp") + ) + ) + ) + + val alertsForStop = + mapOf( + "alewife" to serviceAlert("alewife", routeSegment.sourceRouteId), + "davis" to serviceAlert("davis", routeSegment.sourceRouteId), + "central" to serviceAlert("alewife", routeSegment.sourceRouteId) + ) + + assertEquals( + listOf( + AlertAwareRouteSegment( + id = "id-0", + sourceRoutePatternId = routeSegment.sourceRoutePatternId, + sourceRouteId = routeSegment.sourceRouteId, + stopIds = listOf("alewife", "davis"), + otherPatternsByStopId = + mapOf( + "alewife" to + listOf( + RoutePatternKey( + routeId = "otherRoute", + routePatternId = "otherRp" + ) + ) + ), + isAlerting = true + ), + AlertAwareRouteSegment( + id = "id-1", + sourceRoutePatternId = routeSegment.sourceRoutePatternId, + sourceRouteId = routeSegment.sourceRouteId, + stopIds = listOf("davis", "porter", "harvard", "central"), + otherPatternsByStopId = mapOf(), + isAlerting = false + ), + AlertAwareRouteSegment( + id = "id-2", + sourceRoutePatternId = routeSegment.sourceRoutePatternId, + sourceRouteId = routeSegment.sourceRouteId, + stopIds = listOf("central"), + otherPatternsByStopId = mapOf(), + isAlerting = true + ), + ), + routeSegment.splitAlertingSegments(alertsForStop) + ) + } + + @Test + fun `splitAlertingSegments when all alerting returns one segment`() { + var routeSegment = + RouteSegment( + id = "id", + sourceRouteId = "sourceRoute", + sourceRoutePatternId = "sourceRoutePattern", + stopIds = listOf("alewife", "davis", "porter"), + otherPatternsByStopId = + mapOf( + "alewife" to + listOf( + RoutePatternKey(routeId = "otherRoute", routePatternId = "otherRp") + ) + ) + ) + + val alertsForStop = + mapOf( + "alewife" to serviceAlert("alewife", routeSegment.sourceRouteId), + "davis" to serviceAlert("davis", routeSegment.sourceRouteId), + "porter" to serviceAlert("porter", routeSegment.sourceRouteId) + ) + + assertEquals( + listOf( + AlertAwareRouteSegment( + id = "id-0", + sourceRoutePatternId = routeSegment.sourceRoutePatternId, + sourceRouteId = routeSegment.sourceRouteId, + stopIds = listOf("alewife", "davis", "porter"), + otherPatternsByStopId = + mapOf( + "alewife" to + listOf( + RoutePatternKey( + routeId = "otherRoute", + routePatternId = "otherRp" + ) + ) + ), + isAlerting = true + ), + ), + routeSegment.splitAlertingSegments(alertsForStop) + ) + } + + @Test + fun `splitAlertingSegments when none alerting returns one segment`() { + + var routeSegment = + RouteSegment( + id = "id", + sourceRouteId = "sourceRoute", + sourceRoutePatternId = "sourceRoutePattern", + stopIds = listOf("alewife", "davis", "porter"), + otherPatternsByStopId = + mapOf( + "alewife" to + listOf( + RoutePatternKey(routeId = "otherRoute", routePatternId = "otherRp") + ) + ) + ) + + val alertsForStop: Map = mapOf() + + assertEquals( + listOf( + AlertAwareRouteSegment( + id = "id-0", + sourceRoutePatternId = routeSegment.sourceRoutePatternId, + sourceRouteId = routeSegment.sourceRouteId, + stopIds = listOf("alewife", "davis", "porter"), + otherPatternsByStopId = + mapOf( + "alewife" to + listOf( + RoutePatternKey( + routeId = "otherRoute", + routePatternId = "otherRp" + ) + ) + ), + isAlerting = false + ), + ), + routeSegment.splitAlertingSegments(alertsForStop) + ) + } + + private fun serviceAlert(stopId: String, routeId: String): AlertAssociatedStop { + return AlertAssociatedStop( + stop = stop { id = stopId }, + relevantAlerts = + listOf( + alert { + effect = Alert.Effect.Shuttle + informedEntity( + listOf( + Alert.InformedEntity.Activity.Board, + Alert.InformedEntity.Activity.Exit, + Alert.InformedEntity.Activity.Ride + ), + route = routeId, + routeType = RouteType.HEAVY_RAIL, + stop = stopId + ) + }, + ), + serviceStatus = StopServiceStatus.NORMAL + ) + } +}