diff --git a/core/test/build.gradle.kts b/core/test/build.gradle.kts index 737ed6bc..f29151a7 100644 --- a/core/test/build.gradle.kts +++ b/core/test/build.gradle.kts @@ -43,6 +43,7 @@ kotlin { commonTest { dependencies { implementation(projects.core.analytics) + implementation(projects.core.dateTime) implementation(projects.core.log) implementation(projects.sandook) implementation(projects.feature.tripPlanner.ui) @@ -53,6 +54,7 @@ kotlin { implementation(libs.test.kotlinxCoroutineTest) implementation(libs.test.turbine) implementation(libs.kotlinx.collections.immutable) + implementation(libs.kotlinx.datetime) } } diff --git a/core/test/src/commonTest/kotlin/xyz/ksharma/core/test/fakes/FakeTripPlanningService.kt b/core/test/src/commonTest/kotlin/xyz/ksharma/core/test/fakes/FakeTripPlanningService.kt index e1f02a89..1eccd02c 100644 --- a/core/test/src/commonTest/kotlin/xyz/ksharma/core/test/fakes/FakeTripPlanningService.kt +++ b/core/test/src/commonTest/kotlin/xyz/ksharma/core/test/fakes/FakeTripPlanningService.kt @@ -1,12 +1,8 @@ package xyz.ksharma.core.test.fakes -import xyz.ksharma.core.test.fakes.FakeTripResponseBuilder.buildDestinationStopSequence -import xyz.ksharma.core.test.fakes.FakeTripResponseBuilder.buildOriginStopSequence -import xyz.ksharma.core.test.fakes.FakeTripResponseBuilder.buildTransportation import xyz.ksharma.krail.trip.planner.network.api.model.StopFinderResponse import xyz.ksharma.krail.trip.planner.network.api.model.StopType import xyz.ksharma.krail.trip.planner.network.api.model.TripResponse -import xyz.ksharma.krail.trip.planner.network.api.model.TripResponse.StopSequence import xyz.ksharma.krail.trip.planner.network.api.service.DepArr import xyz.ksharma.krail.trip.planner.network.api.service.TripPlanningService @@ -21,26 +17,7 @@ class FakeTripPlanningService : TripPlanningService { date: String?, time: String?, ): TripResponse { - return if (isSuccess) - - // Return a fake TripResponse - TripResponse( - journeys = listOf( - TripResponse.Journey( - legs = listOf( - TripResponse.Leg( - origin = buildOriginStopSequence(), - destination = buildDestinationStopSequence(), - stopSequence = listOf( - buildOriginStopSequence(), - ), - transportation = buildTransportation(), - duration = 100, - ), - ), - ), - ) - ) + return if (isSuccess) FakeTripResponseBuilder.buildTripResponse() else throw IllegalStateException("Failed to fetch trip") } diff --git a/core/test/src/commonTest/kotlin/xyz/ksharma/core/test/fakes/FakeTripResponseBuilder.kt b/core/test/src/commonTest/kotlin/xyz/ksharma/core/test/fakes/FakeTripResponseBuilder.kt index c2019389..c502d40a 100644 --- a/core/test/src/commonTest/kotlin/xyz/ksharma/core/test/fakes/FakeTripResponseBuilder.kt +++ b/core/test/src/commonTest/kotlin/xyz/ksharma/core/test/fakes/FakeTripResponseBuilder.kt @@ -1,34 +1,62 @@ package xyz.ksharma.core.test.fakes +import kotlinx.datetime.Clock import xyz.ksharma.krail.trip.planner.network.api.model.StopType import xyz.ksharma.krail.trip.planner.network.api.model.TripResponse import xyz.ksharma.krail.trip.planner.network.api.model.TripResponse.StopSequence +import kotlin.time.Duration.Companion.minutes object FakeTripResponseBuilder { + private var originStopSequence: StopSequence = buildStopSequence() + private var destinationStopSequence: StopSequence = buildStopSequence() + private var stopSequence: List = listOf(buildStopSequence()) + private var transportation: TripResponse.Transportation = + buildTransportation() + private var duration: Long = 200 - fun buildOriginStopSequence() = StopSequence( - arrivalTimePlanned = "2024-09-24T19:00:00Z", - arrivalTimeEstimated = "2024-09-24T19:10:00Z", - departureTimePlanned = "2024-09-24T19:10:00Z", - departureTimeEstimated = "2024-09-24T19:10:00Z", - name = "Origin Stop", - disassembledName = "Origin Name", - id = "Origin_stop_id", - type = StopType.STOP.type, - ) + fun setOriginStopSequence(originStopSequence: StopSequence) = + apply { this.originStopSequence = originStopSequence } + + fun setDestinationStopSequence(destinationStopSequence: StopSequence) = + apply { this.destinationStopSequence = destinationStopSequence } + + fun setStopSequence(stopSequence: List) = + apply { this.stopSequence = stopSequence } - fun buildDestinationStopSequence() = StopSequence( - arrivalTimePlanned = "2024-09-24T20:00:00Z", - arrivalTimeEstimated = "2024-09-24T20:10:00Z", - departureTimePlanned = "2024-09-24T20:10:00Z", - departureTimeEstimated = "2024-09-24T20:10:00Z", - name = "Destination Stop", - disassembledName = "Destination Name", - id = "Destination_stop_id", + fun setTransportation(transportation: TripResponse.Transportation) = + apply { this.transportation = transportation } + + fun setDuration(duration: Long) = apply { this.duration = duration } + + fun build(): TripResponse.Leg { + return TripResponse.Leg( + origin = originStopSequence, + destination = destinationStopSequence, + stopSequence = stopSequence, + transportation = transportation, + duration = duration + ) + } + + private fun buildStopSequence( + arrivalTimePlanned: String = "2024-09-24T19:00:00Z", + arrivalTimeEstimated: String = "2024-09-24T19:00:00Z", + departureTimePlanned: String = "2024-09-24T19:10:00Z", + departureTimeEstimated: String = "2024-09-24T19:10:00Z", + name: String = "Stop", + id: String = "stop_id", + ) = StopSequence( + arrivalTimePlanned = arrivalTimePlanned, + arrivalTimeEstimated = arrivalTimeEstimated, + departureTimePlanned = departureTimePlanned, + departureTimeEstimated = departureTimeEstimated, + name = name, + disassembledName = name, + id = id, type = StopType.STOP.type, ) - fun buildTransportation() = TripResponse.Transportation( + private fun buildTransportation(transportationId: String = "Transportation Id") = TripResponse.Transportation( disassembledName = "Transportation Name", product = TripResponse.Product( productClass = 1, @@ -39,7 +67,77 @@ object FakeTripResponseBuilder { id = "Destination Operator Id", ), name = "Transportation Name", - id = "Transportation Id", + id = transportationId, description = "Transportation Description", ) + + private fun buildJourneyLeg(legIndex: Int, stops: Int, journeyIndex: Int = 0) = + TripResponse.Leg( + origin = buildStopSequence( + name = "Origin Stop $legIndex", + arrivalTimeEstimated = Clock.System.now().plus(5.minutes * journeyIndex).toString(), + arrivalTimePlanned = Clock.System.now().plus(5.minutes * journeyIndex).toString(), + departureTimeEstimated = Clock.System.now().plus(5.minutes * journeyIndex).toString(), + departureTimePlanned = Clock.System.now().plus(5.minutes * journeyIndex).toString(), + ), + destination = buildStopSequence( + name = "Destination Stop $legIndex", + arrivalTimeEstimated = Clock.System.now().plus(10.minutes * journeyIndex).toString(), + arrivalTimePlanned = Clock.System.now().plus(10.minutes * journeyIndex).toString(), + departureTimeEstimated = Clock.System.now().plus(10.minutes * journeyIndex).toString(), + departureTimePlanned = Clock.System.now().plus(10.minutes * journeyIndex).toString(), + ), + stopSequence = List(stops) { index -> + buildStopSequence( + id = "stop_id_${index + 1}", + name = "Stop ${index + 1}", + arrivalTimeEstimated = Clock.System.now().plus(5.minutes + journeyIndex.minutes) + .toString(), + arrivalTimePlanned = Clock.System.now().plus(5.minutes + journeyIndex.minutes) + .toString(), + departureTimeEstimated = Clock.System.now().plus(5.minutes + journeyIndex.minutes) + .toString(), + departureTimePlanned = Clock.System.now().plus(5.minutes + journeyIndex.minutes) + .toString(), + ) + }, + transportation = buildTransportation( + transportationId = "Transportation Id $journeyIndex", + ), + duration = 120, + ) + + private fun buildJourneyList( + numberOfJourney: Int = 1, + numberOfLegs: Int = 1, + reverseTimeOrder: Boolean = false, + ): List { + val journeyList = List(numberOfJourney) { buildJourney(numberOfLegs = numberOfLegs, journeyIndex = it) } + return if (reverseTimeOrder) journeyList.reversed() else journeyList + } + + private fun buildJourney( + numberOfLegs: Int = 1, + stops: Int = 1, + journeyIndex: Int = 0, + ): TripResponse.Journey { + return TripResponse.Journey( + legs = List(numberOfLegs) { index -> + buildJourneyLeg(legIndex = index, stops = stops, journeyIndex = journeyIndex) + }, + ) + } + + fun buildTripResponse( + numberOfJourney: Int = 1, numberOfLegs: Int = 1, + reverseTimeOrder: Boolean = false, + ): TripResponse { + return TripResponse( + journeys = buildJourneyList( + numberOfJourney = numberOfJourney, + numberOfLegs = numberOfLegs, + reverseTimeOrder = reverseTimeOrder, + ), + ) + } } diff --git a/core/test/src/commonTest/kotlin/xyz/ksharma/core/test/viewmodels/TimeTableViewModelTest.kt b/core/test/src/commonTest/kotlin/xyz/ksharma/core/test/viewmodels/TimeTableViewModelTest.kt index 235cff48..4d50f192 100644 --- a/core/test/src/commonTest/kotlin/xyz/ksharma/core/test/viewmodels/TimeTableViewModelTest.kt +++ b/core/test/src/commonTest/kotlin/xyz/ksharma/core/test/viewmodels/TimeTableViewModelTest.kt @@ -1,6 +1,7 @@ package xyz.ksharma.core.test.viewmodels import app.cash.turbine.test +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher @@ -8,12 +9,20 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import kotlinx.datetime.Clock import xyz.ksharma.core.test.fakes.FakeAnalytics import xyz.ksharma.core.test.fakes.FakeRateLimiter import xyz.ksharma.core.test.fakes.FakeSandook import xyz.ksharma.core.test.fakes.FakeTripPlanningService +import xyz.ksharma.core.test.fakes.FakeTripResponseBuilder.buildTripResponse import xyz.ksharma.krail.core.analytics.Analytics +import xyz.ksharma.krail.core.datetime.DateTimeHelper.formatTo12HourTime import xyz.ksharma.krail.sandook.Sandook +import xyz.ksharma.krail.trip.planner.network.api.model.TripResponse +import xyz.ksharma.krail.trip.planner.ui.state.TransportMode +import xyz.ksharma.krail.trip.planner.ui.state.TransportModeLine +import xyz.ksharma.krail.trip.planner.ui.state.timetable.TimeTableState +import xyz.ksharma.krail.trip.planner.ui.state.timetable.TimeTableState.JourneyCardInfo.Stop import xyz.ksharma.krail.trip.planner.ui.state.timetable.TimeTableUiEvent import xyz.ksharma.krail.trip.planner.ui.state.timetable.Trip import xyz.ksharma.krail.trip.planner.ui.timetable.TimeTableViewModel @@ -22,9 +31,10 @@ import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse -import kotlin.test.assertIs import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes @OptIn(ExperimentalCoroutinesApi::class) class TimeTableViewModelTest { @@ -71,6 +81,8 @@ class TimeTableViewModelTest { } } + // region Test for fetchTrip / Trip API call + @Test fun `GIVEN a trip WHEN LoadTimeTable event is triggered and Trip API is success response THEN UI State must update with journeyList`() = runTest { @@ -105,14 +117,14 @@ class TimeTableViewModelTest { // need to skip two items, because silentLoading will be toggled, as we manually call fetchTrip() skipItems(2) -/* - awaitItem().run { - assertTrue(silentLoading) - } - awaitItem().run { - assertFalse(silentLoading) - } -*/ + /* + awaitItem().run { + assertTrue(silentLoading) + } + awaitItem().run { + assertFalse(silentLoading) + } + */ awaitItem().run { assertFalse(isLoading) @@ -179,4 +191,131 @@ class TimeTableViewModelTest { } } + // endregion + + // region Test for updateTripsCache + + @Test + fun `GIVEN journeys returned from Trip api WHEN updateTripsCache is called THEN journeys object in ViewModel should be updated`() = + runTest { + // GIVEN Trip Response + val tripResponse = buildTripResponse( + numberOfJourney = 2, + reverseTimeOrder = false, + ) + viewModel.journeys.clear() + tripResponse.journeys?.forEachIndexed { index, item -> + println("tripResponse Journey #$index: ${item.legs?.get(0)?.origin?.arrivalTimeEstimated?.formatTo12HourTime()}") + } + + // WHEN + viewModel.updateTripsCache(tripResponse) + + // THEN + val viewmodelJourneysList = viewModel.journeys.values.toList() + assertEquals(2, viewmodelJourneysList.size) + } + + @Test + fun `GIVEN started journeys in cache are more than threshold WHEN updateTripsCache is called THEN extra started journeys should be removed from viewmodel`() = + runTest { + // GIVEN Trip Response + val tripResponse = buildTripResponse( + numberOfJourney = 2, + reverseTimeOrder = false, + ) + viewModel.journeys.putAll( + buildStartedJourneysList(numberOfStartedJourneys = 5) + ) + tripResponse.journeys?.forEachIndexed { index, item -> + println("tripResponse Journey #$index: ${item.legs?.get(0)?.origin?.arrivalTimeEstimated?.formatTo12HourTime()}") + } + + // WHEN + viewModel.updateTripsCache(tripResponse) + + // THEN + val viewmodelJourneysList = viewModel.journeys.values.toList() + // 4 because 2 are from API response and 2 is threshold of started journeys + assertEquals(4, viewmodelJourneysList.size) + } + + @Test + fun `GIVEN multiple started journeys in cache WHEN updateTripsCache is called THEN journeys should be sorted and updated`() = + runTest { + // GIVEN Trip Response + val tripResponse = TripResponse() + viewModel.journeys.putAll( + buildStartedJourneysList(numberOfStartedJourneys = 4, distortSortOrder = true) + ) + tripResponse.journeys?.forEachIndexed { index, item -> + println("tripResponse Journey #$index: ${item.legs?.get(0)?.origin?.arrivalTimeEstimated?.formatTo12HourTime()}") + } + + // WHEN + viewModel.updateTripsCache(tripResponse) + + // THEN + val viewmodelJourneysList = viewModel.journeys.values.toList() + assertEquals(2, viewmodelJourneysList.size) + // Check if the journeys are sorted by originUtcDateTime + assertTrue(viewmodelJourneysList[0].originUtcDateTime < viewmodelJourneysList[1].originUtcDateTime) + } + + // endregion + + /** + * Builds a list of started journeys, i.e. journeys that have origin time in past. + * + * @param numberOfStartedJourneys The number of started journeys to create. + * + * @param distortSortOrder If true, the order of the journeys will be shuffled,means time will no longer be in ascending or descending. + * + * @return A map of journey IDs to JourneyCardInfo objects. + */ + private fun buildStartedJourneysList( + numberOfStartedJourneys: Int, + distortSortOrder: Boolean = false, + ): Map { + val startedJourneys = mutableMapOf() + for (i in 1..numberOfStartedJourneys) { + + // Calculate the origin time for each journey, decreasing by 5 minutes for each subsequent journey + val originTime = Clock.System.now().minus(5.minutes * i) + + startedJourneys["startedJourney$i"] = TimeTableState.JourneyCardInfo( + originUtcDateTime = originTime.toString(), + destinationUtcDateTime = originTime.plus(1.hours).toString(), + timeText = "1", + platformText = "1", + platformNumber = "1", + originTime = "", + destinationTime = "", + travelTime = "", + totalWalkTime = "", + transportModeLines = persistentListOf(), + legs = persistentListOf( + TimeTableState.JourneyCardInfo.Leg.TransportLeg( + transportModeLine = TransportModeLine( + transportMode = TransportMode.Train(), + lineName = "T1", + ), + displayText = "A via B", + totalDuration = "1 hour", + stops = persistentListOf( + Stop(name = "", time = "", isWheelchairAccessible = true), + ), + tripId = "id_$i", + ) + + ), + totalUniqueServiceAlerts = 1, + ) + } + + // If distortSortOrder is true, shuffle the list of journeys before returning + return if (distortSortOrder) { + startedJourneys.toList().shuffled().toMap() + } else startedJourneys + } } diff --git a/feature/trip-planner/ui/src/commonMain/kotlin/xyz/ksharma/krail/trip/planner/ui/timetable/TimeTableViewModel.kt b/feature/trip-planner/ui/src/commonMain/kotlin/xyz/ksharma/krail/trip/planner/ui/timetable/TimeTableViewModel.kt index fd860e42..ed3cff67 100644 --- a/feature/trip-planner/ui/src/commonMain/kotlin/xyz/ksharma/krail/trip/planner/ui/timetable/TimeTableViewModel.kt +++ b/feature/trip-planner/ui/src/commonMain/kotlin/xyz/ksharma/krail/trip/planner/ui/timetable/TimeTableViewModel.kt @@ -114,7 +114,8 @@ class TimeTableViewModel( * * This list will be displayed in the UI. */ - private val journeys: MutableMap = mutableMapOf() + @VisibleForTesting + val journeys: MutableMap = mutableMapOf() fun onEvent(event: TimeTableUiEvent) { when (event) { @@ -183,7 +184,6 @@ class TimeTableViewModel( }.catch { e -> log("Error while fetching trip: $e") }.collectLatest { result -> - println("result Success: $result") updateUiState { copy(silentLoading = false) } result.onSuccess { response -> updateTripsCache(response) @@ -195,8 +195,8 @@ class TimeTableViewModel( } } - // TODO - Write UT for this method - private suspend fun updateTripsCache(response: TripResponse) = withContext(ioDispatcher) { + @VisibleForTesting + suspend fun updateTripsCache(response: TripResponse) = withContext(ioDispatcher) { val newJourneyList = response.buildJourneyList() val startedJourneyList = journeys.values .filter { @@ -242,13 +242,9 @@ class TimeTableViewModel( } private fun updateUiStateWithFilteredTrips() { - println("updateUiStateWithFilteredTrips") - val journeyList = updateJourneyCardInfoTimeText(journeys.values.toList()) .sortedBy { it.originUtcDateTime.utcToLocalDateTimeAEST() } .toImmutableList() - - println("updateUiStateWithFilteredTrips: ${journeyList.size}") updateUiState { copy( isLoading = false, @@ -276,8 +272,6 @@ class TimeTableViewModel( else -> DepArr.DEP } ) - println("tripResponse: $tripResponse") - Result.success(tripResponse) }.getOrElse { error -> Result.failure(error) diff --git a/feature/trip-planner/ui/src/commonMain/kotlin/xyz/ksharma/krail/trip/planner/ui/timetable/business/TripResponseMapper.kt b/feature/trip-planner/ui/src/commonMain/kotlin/xyz/ksharma/krail/trip/planner/ui/timetable/business/TripResponseMapper.kt index fff405b6..21d3c9fb 100644 --- a/feature/trip-planner/ui/src/commonMain/kotlin/xyz/ksharma/krail/trip/planner/ui/timetable/business/TripResponseMapper.kt +++ b/feature/trip-planner/ui/src/commonMain/kotlin/xyz/ksharma/krail/trip/planner/ui/timetable/business/TripResponseMapper.kt @@ -182,6 +182,7 @@ private fun TripResponse.Leg.toUiModel(): TimeTableState.JourneyCardInfo.Leg? { if (transportMode != null && lineName != null && displayText != null && numberOfStops != null && stops != null && displayDuration != null ) { + println("Adding Transport Leg") TimeTableState.JourneyCardInfo.Leg.TransportLeg( transportModeLine = TransportModeLine( transportMode = transportMode, @@ -198,6 +199,10 @@ private fun TripResponse.Leg.toUiModel(): TimeTableState.JourneyCardInfo.Leg? { tripId = transportation?.id + transportation?.properties?.realtimeTripId, ) } else { + println("Something is null - NOT adding Transport LEG: " + + "TransportMode: $transportMode, lineName: $lineName, displayText: $displayText, " + + "numberOfStops: $numberOfStops, stops: $stops, displayDuration: $displayDuration", + ) null } }