diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 2ef217213..7ba1ef015 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -125,7 +125,6 @@ 9A2A6B6E2D07F7EB00E39AF5 /* StopDetailsViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A2A6B6D2D07F7EB00E39AF5 /* StopDetailsViewModelTests.swift */; }; 9A2BCBDE2CED365200FB2913 /* StopDetailsPageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A2BCBDD2CED365200FB2913 /* StopDetailsPageTests.swift */; }; 9A2BCBE02CED366300FB2913 /* StopDetailsViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A2BCBDF2CED366300FB2913 /* StopDetailsViewTests.swift */; }; - 9A2BCBE22CEE8A9F00FB2913 /* StopDetailsPageHandlerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A2BCBE12CEE8A9F00FB2913 /* StopDetailsPageHandlerExtension.swift */; }; 9A320F962CD3E4CF0096D7B1 /* UpcomingTripAccessibilityFormatters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A320F952CD3E4CF0096D7B1 /* UpcomingTripAccessibilityFormatters.swift */; }; 9A37F3052BACCC40001714FE /* DoubleRoundedExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A37F3042BACCC40001714FE /* DoubleRoundedExtension.swift */; }; 9A37F3072BACCCA5001714FE /* CoordinateExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A37F3062BACCCA5001714FE /* CoordinateExtension.swift */; }; @@ -423,7 +422,6 @@ 9A2A6B6D2D07F7EB00E39AF5 /* StopDetailsViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopDetailsViewModelTests.swift; sourceTree = ""; }; 9A2BCBDD2CED365200FB2913 /* StopDetailsPageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopDetailsPageTests.swift; sourceTree = ""; }; 9A2BCBDF2CED366300FB2913 /* StopDetailsViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopDetailsViewTests.swift; sourceTree = ""; }; - 9A2BCBE12CEE8A9F00FB2913 /* StopDetailsPageHandlerExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopDetailsPageHandlerExtension.swift; sourceTree = ""; }; 9A320F952CD3E4CF0096D7B1 /* UpcomingTripAccessibilityFormatters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpcomingTripAccessibilityFormatters.swift; sourceTree = ""; }; 9A37F3042BACCC40001714FE /* DoubleRoundedExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoubleRoundedExtension.swift; sourceTree = ""; }; 9A37F3062BACCCA5001714FE /* CoordinateExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoordinateExtension.swift; sourceTree = ""; }; @@ -1125,7 +1123,6 @@ 9AF29DF52CF54319005AA4A3 /* DepartureTile.swift */, 9AF093772BD943A4001DF39F /* DirectionPicker.swift */, 9ACE4FCF2CE6707900FEB006 /* StopDetailsPage.swift */, - 9A2BCBE12CEE8A9F00FB2913 /* StopDetailsPageHandlerExtension.swift */, 9AF29E052CFE2C4C005AA4A3 /* StopDetailsFilteredDepartureDetails.swift */, 9AF29DFF2CF63330005AA4A3 /* StopDetailsFilteredHeader.swift */, 9AF29DF72CF5454E005AA4A3 /* StopDetailsFilteredView.swift */, @@ -1583,7 +1580,6 @@ 6E2027902BD989AC0037554F /* ProductionAppView.swift in Sources */, 6E04D4302C1A17340055FD99 /* StopDeparturesSummaryList.swift in Sources */, 9A74A2112BE2D71400E57102 /* AnnotationLabel.swift in Sources */, - 9A2BCBE22CEE8A9F00FB2913 /* StopDetailsPageHandlerExtension.swift in Sources */, 6EEF219E2BF2927E0023A3E9 /* VehicleCardView.swift in Sources */, 9A9E7DCF2C2200C9000DA1FD /* LineHeader.swift in Sources */, 8C05C5812CD568DE000381E8 /* MoreNavLink.swift in Sources */, diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index cd73cdd10..25decebdc 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -285,17 +285,19 @@ struct ContentView: View { stopDetailsVM: stopDetailsVM, viewportProvider: viewportProvider ) - .toolbar(.hidden, for: .tabBar) .onAppear { - let filtered = stopFilter != nil screenTracker.track( - screen: filtered ? .stopDetailsFiltered : .stopDetailsUnfiltered + screen: stopFilter != nil ? .stopDetailsFiltered : .stopDetailsUnfiltered ) } + .toolbar(.hidden, for: .tabBar) } // Set id per stop so that transitioning from one stop to another is handled by removing // the existing stop view & creating a new one .id(stopId) + .onChange(of: stopId) { nextStopId in stopDetailsVM.handleStopChange(nextStopId) } + .onAppear { stopDetailsVM.handleStopAppear(stopId) } + .onDisappear { stopDetailsVM.leaveStopPredictions() } .transition(transition) case let .legacyStopDetails(stop, filter): diff --git a/iosApp/iosApp/Pages/StopDetails/StopDetailsPage.swift b/iosApp/iosApp/Pages/StopDetails/StopDetailsPage.swift index 08a195bb8..b1d01535a 100644 --- a/iosApp/iosApp/Pages/StopDetails/StopDetailsPage.swift +++ b/iosApp/iosApp/Pages/StopDetails/StopDetailsPage.swift @@ -72,37 +72,69 @@ struct StopDetailsPage: View { var body: some View { stopDetails - .onChange(of: stopId) { nextStopId in changeStop(nextStopId) } .onChange(of: stopDetailsVM.global) { _ in updateDepartures() } .onChange(of: stopDetailsVM.pinnedRoutes) { _ in updateDepartures() } - .onChange(of: stopDetailsVM.predictionsByStop) { _ in updateDepartures() } - .onChange(of: stopDetailsVM.schedulesResponse) { _ in updateDepartures() } + .onChange(of: stopDetailsVM.stopData) { stopData in + errorBannerVM.loadingWhenPredictionsStale = !(stopData?.predictionsLoaded ?? true) + updateDepartures() + } .onChange(of: stopFilter) { nextStopFilter in setTripFilter(stopFilter: nextStopFilter) } .onChange(of: internalDepartures) { _ in let nextStopFilter = setStopFilter() setTripFilter(stopFilter: nextStopFilter) } - .onAppear { loadEverything() } - .onReceive(inspection.notice) { inspection.visit(self, $0) } .task(id: stopId) { while !Task.isCancelled { now = Date.now updateDepartures() - checkPredictionsStale() + stopDetailsVM.checkStopPredictionsStale() try? await Task.sleep(for: .seconds(5)) } } - .onDisappear { stopDetailsVM.leavePredictions() } + .onReceive(inspection.notice) { inspection.visit(self, $0) } .withScenePhaseHandlers( onActive: { stopDetailsVM.returnFromBackground() - joinPredictions() + stopDetailsVM.joinStopPredictions(stopId) }, - onInactive: stopDetailsVM.leavePredictions, + onInactive: stopDetailsVM.leaveStopPredictions, onBackground: { - stopDetailsVM.leavePredictions() + stopDetailsVM.leaveStopPredictions() errorBannerVM.loadingWhenPredictionsStale = true } ) } + + func setStopFilter() -> StopDetailsFilter? { + let nextStopFilter = stopFilter ?? internalDepartures?.autoStopFilter() + if stopFilter != nextStopFilter { + nearbyVM.setLastStopDetailsFilter(stopId, nextStopFilter) + } + return nextStopFilter + } + + func setTripFilter(stopFilter: StopDetailsFilter?) { + let tripFilter = internalDepartures?.autoTripFilter( + stopFilter: stopFilter, + currentTripFilter: tripFilter, + filterAtTime: now.toKotlinInstant() + ) + nearbyVM.setLastTripDetailsFilter(stopId, tripFilter) + } + + func updateDepartures() { + Task { + if stopId != stopDetailsVM.stopData?.stopId { return } + let nextDepartures = stopDetailsVM.getDepartures( + stopId: stopId, + alerts: nearbyVM.alerts, + useTripHeadsigns: nearbyVM.tripHeadsignsEnabled, + now: now + ) + Task { @MainActor in + nearbyVM.setDepartures(stopId, nextDepartures) + internalDepartures = nextDepartures + } + } + } } diff --git a/iosApp/iosApp/Pages/StopDetails/StopDetailsPageHandlerExtension.swift b/iosApp/iosApp/Pages/StopDetails/StopDetailsPageHandlerExtension.swift deleted file mode 100644 index a433cb383..000000000 --- a/iosApp/iosApp/Pages/StopDetails/StopDetailsPageHandlerExtension.swift +++ /dev/null @@ -1,111 +0,0 @@ -// -// StopDetailsPageHandlerExtension.swift -// iosApp -// -// Created by esimon on 11/20/24. -// Copyright © 2024 MBTA. All rights reserved. -// - -import shared -import SwiftPhoenixClient -import SwiftUI - -extension StopDetailsPage { - func changeStop(_ stopId: String) { - stopDetailsVM.leavePredictions() - stopDetailsVM.clearTripDetails() - fetchStopData(stopId) - } - - func checkPredictionsStale() { - Task { - if let lastPredictions = stopDetailsVM.predictionsRepository.lastUpdated { - errorBannerVM.errorRepository.checkPredictionsStale( - predictionsLastUpdated: lastPredictions, - predictionQuantity: Int32( - stopDetailsVM.predictionsByStop?.predictionQuantity() ?? 0 - ), - action: { - stopDetailsVM.leavePredictions() - joinPredictions() - } - ) - } - } - } - - func fetchStopData(_ stopId: String) { - getSchedule(stopId) - joinPredictions() - updateDepartures() - } - - func getSchedule(_ stopId: String) { - Task { - stopDetailsVM.schedulesResponse = nil - await fetchApi( - errorBannerVM.errorRepository, - errorKey: "StopDetailsPage.getSchedule", - getData: { try await stopDetailsVM.schedulesRepository.getSchedule(stopIds: [stopId]) }, - onSuccess: { @MainActor in stopDetailsVM.schedulesResponse = $0 }, - onRefreshAfterError: loadEverything - ) - } - } - - func joinPredictions() { - stopDetailsVM.joinPredictions( - stopId, - onSuccess: { checkPredictionsStale() }, - onComplete: { @MainActor in errorBannerVM.loadingWhenPredictionsStale = false } - ) - } - - func loadEverything() { - loadGlobalData() - fetchStopData(stopId) - stopDetailsVM.loadPinnedRoutes() - } - - func loadGlobalData() { - Task(priority: .high) { - await stopDetailsVM.activateGlobalListener() - } - Task { - await fetchApi( - errorBannerVM.errorRepository, - errorKey: "StopDetailsPage.loadGlobalData", - getData: { try await stopDetailsVM.globalRepository.getGlobalData() }, - onRefreshAfterError: loadEverything - ) - } - } - - func setStopFilter() -> StopDetailsFilter? { - let nextStopFilter = stopFilter ?? internalDepartures?.autoStopFilter() - if stopFilter != nextStopFilter { - nearbyVM.setLastStopDetailsFilter(stopId, nextStopFilter) - } - return nextStopFilter - } - - func setTripFilter(stopFilter: StopDetailsFilter?) { - let tripFilter = internalDepartures?.autoTripFilter( - stopFilter: stopFilter, - currentTripFilter: tripFilter, - filterAtTime: now.toKotlinInstant() - ) - nearbyVM.setLastTripDetailsFilter(stopId, tripFilter) - } - - func updateDepartures() { - let nextDepartures = stopDetailsVM.getDepartures( - stopId: stopId, - alerts: nearbyVM.alerts, - useTripHeadsigns: nearbyVM.tripHeadsignsEnabled, - now: now - ) - nearbyVM.setDepartures(stopId, nextDepartures) - internalDepartures = nextDepartures - } -} diff --git a/iosApp/iosApp/ViewModels/StopDetailsViewModel.swift b/iosApp/iosApp/ViewModels/StopDetailsViewModel.swift index f20f61202..05d929656 100644 --- a/iosApp/iosApp/ViewModels/StopDetailsViewModel.swift +++ b/iosApp/iosApp/ViewModels/StopDetailsViewModel.swift @@ -12,6 +12,13 @@ import shared import SwiftPhoenixClient import SwiftUI +struct StopData: Equatable { + let stopId: String + let schedules: ScheduleResponse + var predictionsByStop: PredictionsByStopJoinResponse? + var predictionsLoaded: Bool = false +} + struct TripData { let tripFilter: TripDetailsFilter let trip: Trip @@ -22,7 +29,7 @@ struct TripData { // A subset of route attributes only for displaying as UI accents, // this is split out to allow defaults for when a route may not exist -struct TripRouteAccents { +struct TripRouteAccents: Hashable { let color: Color let textColor: Color let type: RouteType @@ -43,9 +50,8 @@ struct TripRouteAccents { class StopDetailsViewModel: ObservableObject { @Published var global: GlobalResponse? @Published var pinnedRoutes: Set = [] - @Published var predictionsByStop: PredictionsByStopJoinResponse? - @Published var schedulesResponse: ScheduleResponse? + @Published var stopData: StopData? @Published var tripData: TripData? let errorBannerRepository: IErrorBannerStateRepository @@ -79,12 +85,37 @@ class StopDetailsViewModel: ObservableObject { self.vehicleRepository = vehicleRepository } - func activateGlobalListener() async { + private func activateGlobalListener() async { for await globalData in globalRepository.state { Task { @MainActor in self.global = globalData } } } + func checkStopPredictionsStale() { + Task { + if let lastPredictions = predictionsRepository.lastUpdated { + errorBannerRepository.checkPredictionsStale( + predictionsLastUpdated: lastPredictions, + predictionQuantity: Int32( + stopData?.predictionsByStop?.predictionQuantity() ?? 0 + ), + action: { + self.leaveStopPredictions() + if let stopId = self.stopData?.stopId { + self.joinStopPredictions(stopId) + } + } + ) + } + } + } + + @MainActor func clearStopDetails() { + leaveStopPredictions() + stopData = nil + errorBannerRepository.clearDataError(key: "StopDetailsPage.getSchedule") + } + @MainActor func clearTripDetails() { leaveTripChannels() tripData = nil @@ -106,12 +137,12 @@ class StopDetailsViewModel: ObservableObject { useTripHeadsigns: Bool, now: Date ) -> StopDetailsDepartures? { - if let global { + if let global, let schedules = stopData?.schedules { StopDetailsDepartures.companion.fromData( stopId: stopId, global: global, - schedules: schedulesResponse, - predictions: predictionsByStop?.toPredictionsStreamDataResponse(), + schedules: schedules, + predictions: stopData?.predictionsByStop?.toPredictionsStreamDataResponse(), alerts: alerts, pinnedRoutes: pinnedRoutes, filterAtTime: now.toKotlinInstant(), @@ -131,6 +162,23 @@ class StopDetailsViewModel: ObservableObject { return TripRouteAccents(route: route) } + func handleStopAppear(_ stopId: String) { + Task { + loadGlobalData() + loadPinnedRoutes() + await handleStopChange(stopId) + } + } + + @MainActor + func handleStopChange(_ stopId: String) { + clearStopDetails() + Task { + await self.loadStopDetails(stopId: stopId) + self.joinStopPredictions(stopId) + } + } + @MainActor func handleTripFilterChange(_ tripFilter: TripDetailsFilter?) { guard let tripFilter else { @@ -183,19 +231,19 @@ class StopDetailsViewModel: ObservableObject { clearAndLoadTripDetails(tripFilter) } - func joinPredictions(_ stopId: String, onSuccess: @escaping () -> Void, onComplete: @escaping () -> Void) { + func joinStopPredictions(_ stopId: String) { // no error handling since persistent errors cause stale predictions predictionsRepository.connectV2(stopIds: [stopId], onJoin: { outcome in Task { @MainActor in if case let .ok(result) = onEnum(of: outcome) { - self.predictionsByStop = result.data - onSuccess() + self.stopData?.predictionsByStop = result.data + self.checkStopPredictionsStale() } - onComplete() + self.stopData?.predictionsLoaded = true } }, onMessage: { outcome in if case let .ok(result) = onEnum(of: outcome) { - let nextPredictions = if let existingPredictionsByStop = self.predictionsByStop { + let nextPredictions = if let existingPredictionsByStop = self.stopData?.predictionsByStop { existingPredictionsByStop.mergePredictions(updatedPredictions: result.data) } else { PredictionsByStopJoinResponse( @@ -205,12 +253,12 @@ class StopDetailsViewModel: ObservableObject { ) } Task { @MainActor in - self.predictionsByStop = nextPredictions - onSuccess() + self.stopData?.predictionsByStop = nextPredictions + self.checkStopPredictionsStale() } } - Task { @MainActor in onComplete() } + Task { @MainActor in self.stopData?.predictionsLoaded = true } }) } @@ -257,7 +305,7 @@ class StopDetailsViewModel: ObservableObject { } } - func leavePredictions() { + func leaveStopPredictions() { predictionsRepository.disconnect() } @@ -274,6 +322,20 @@ class StopDetailsViewModel: ObservableObject { vehicleRepository.disconnect() } + func loadGlobalData() { + Task(priority: .high) { + await activateGlobalListener() + } + Task { + await fetchApi( + errorBannerRepository, + errorKey: "StopDetailsPage.loadGlobalData", + getData: { try await globalRepository.getGlobalData() }, + onRefreshAfterError: loadGlobalData + ) + } + } + func loadPinnedRoutes() { Task { do { @@ -288,6 +350,38 @@ class StopDetailsViewModel: ObservableObject { } } + func loadStopDetails(stopId: String) async { + let schedules = await loadStopSchedules(stopId: stopId) + let task = Task { @MainActor in + if let schedules { + self.stopData = StopData(stopId: stopId, schedules: schedules) + } else { + self.stopData = nil + } + } + await task.value + } + + private func loadStopSchedules(stopId: String) async -> ScheduleResponse? { + let task = Task { + var result: ScheduleResponse? + await fetchApi( + self.errorBannerRepository, + errorKey: "StopDetailsPage.getSchedule", + getData: { try await self.schedulesRepository.getSchedule(stopIds: [stopId]) }, + onSuccess: { @MainActor in result = $0 }, + onRefreshAfterError: { Task { await self.handleStopChange(stopId) } } + ) + return result + } + + do { + return try await task.value + } catch { + return nil + } + } + private func loadTripDetails(tripFilter: TripDetailsFilter) async { async let tripResult = loadTrip(tripFilter: tripFilter) async let scheduleResult = loadTripSchedules(tripFilter: tripFilter) @@ -349,15 +443,15 @@ class StopDetailsViewModel: ObservableObject { @MainActor func returnFromBackground() { - if let predictionsByStop, + if let stopPredictions = stopData?.predictionsByStop, predictionsRepository - .shouldForgetPredictions(predictionCount: predictionsByStop.predictionQuantity()) { - self.predictionsByStop = nil + .shouldForgetPredictions(predictionCount: stopPredictions.predictionQuantity()) { + stopData?.predictionsByStop = nil } - if let predictions = tripData?.tripPredictions, + if let tripPredictions = tripData?.tripPredictions, tripPredictionsRepository - .shouldForgetPredictions(predictionCount: predictions.predictionQuantity()) { + .shouldForgetPredictions(predictionCount: tripPredictions.predictionQuantity()) { tripData?.tripPredictions = nil } } diff --git a/iosApp/iosAppTests/Pages/StopDetails/StopDetailsPageTests.swift b/iosApp/iosAppTests/Pages/StopDetails/StopDetailsPageTests.swift index 272fbf4d9..8508665f5 100644 --- a/iosApp/iosAppTests/Pages/StopDetails/StopDetailsPageTests.swift +++ b/iosApp/iosAppTests/Pages/StopDetails/StopDetailsPageTests.swift @@ -20,50 +20,7 @@ final class StopDetailsPageTests: XCTestCase { executionTimeAllowance = 60 } - func testStopChangeFetchesNewData() throws { - let objects = ObjectCollectionBuilder() - let route = objects.route() - let stop = objects.stop { _ in } - let nextStop = objects.stop { $0.id = "next" } - let routePattern = objects.routePattern(route: route) { _ in } - - let viewportProvider: ViewportProvider = .init(viewport: .followPuck(zoom: 1)) - let stopFilter: StopDetailsFilter? = .init( - routeId: route.id, - directionId: routePattern.directionId - ) - - let newStopSchedulesFetchedExpectation = XCTestExpectation(description: "Fetched stops for next stop") - func callback(stopIds: [String]) { - if stopIds == [nextStop.id] { - newStopSchedulesFetchedExpectation.fulfill() - } - } - - let stopDetailsVM = StopDetailsViewModel( - predictionsRepository: MockPredictionsRepository(), - schedulesRepository: MockScheduleRepository(scheduleResponse: .init(objects: objects), callback: callback) - ) - - let sut = StopDetailsPage( - stopId: stop.id, - stopFilter: stopFilter, - tripFilter: nil, - errorBannerVM: .init(), - nearbyVM: .init(combinedStopAndTrip: true), - mapVM: .init(), - stopDetailsVM: stopDetailsVM, - viewportProvider: viewportProvider - ) - - ViewHosting.host(view: sut) - try sut.inspect().find(StopDetailsView.self).callOnChange(newValue: nextStop.id) - - wait(for: [newStopSchedulesFetchedExpectation], timeout: 5) - } - - @MainActor - func testDisplaysSchedules() { + @MainActor func testDisplaysSchedules() async throws { let objects = ObjectCollectionBuilder() let route = objects.route() let stop = objects.stop { _ in } @@ -76,8 +33,6 @@ final class StopDetailsPageTests: XCTestCase { } let routePattern = objects.routePattern(route: route) { _ in } - let schedulesLoadedPublisher = PassthroughSubject() - let viewportProvider: ViewportProvider = .init(viewport: .followPuck(zoom: 1)) let stopFilter: StopDetailsFilter? = .init( routeId: route.id, @@ -88,12 +43,14 @@ final class StopDetailsPageTests: XCTestCase { nearbyVM.alerts = .init(alerts: [:]) let stopDetailsVM = StopDetailsViewModel( - globalRepository: MockGlobalRepository(response: .init(objects: objects)), - predictionsRepository: MockPredictionsRepository(connectV2Response: .companion.empty), - schedulesRepository: MockScheduleRepository( - scheduleResponse: .init(objects: objects), - callback: { _ in schedulesLoadedPublisher.send(true) } - ) + globalRepository: MockGlobalRepository(response: .init(objects: objects)) + ) + + stopDetailsVM.stopData = .init( + stopId: stop.id, + schedules: .init(objects: objects), + predictionsByStop: .companion.empty, + predictionsLoaded: true ) let sut = StopDetailsPage( @@ -107,12 +64,12 @@ final class StopDetailsPageTests: XCTestCase { viewportProvider: viewportProvider ) - let exp = sut.inspection.inspect(onReceive: schedulesLoadedPublisher, after: 1) { view in - XCTAssertNotNil(try view.find(StopDetailsFilteredView.self)) - XCTAssertNotNil(try view.find(DepartureTile.self)) - } ViewHosting.host(view: sut) - wait(for: [exp], timeout: 30) + sut.updateDepartures() + + try await Task.sleep(for: .seconds(1)) + XCTAssertNotNil(try sut.inspect().find(StopDetailsFilteredView.self)) + XCTAssertNotNil(try sut.inspect().find(DepartureTile.self)) } func testCloseButton() throws { @@ -156,9 +113,6 @@ final class StopDetailsPageTests: XCTestCase { let viewportProvider: ViewportProvider = .init(viewport: .followPuck(zoom: 1)) let stopFilter: StopDetailsFilter? = .init(routeId: route.id, directionId: 0) let joinExpectation = expectation(description: "joins predictions") - joinExpectation.expectedFulfillmentCount = 2 - joinExpectation.assertForOverFulfill = true - let leaveExpectation = expectation(description: "leaves predictions") let predictionsRepo = MockPredictionsRepository( @@ -196,54 +150,8 @@ final class StopDetailsPageTests: XCTestCase { wait(for: [joinExpectation], timeout: 1) } - func testLeavesAndJoinsPredictionsOnStopChange() throws { - let objects = ObjectCollectionBuilder() - let route = objects.route() - let stop = objects.stop { _ in } - - let viewportProvider: ViewportProvider = .init(viewport: .followPuck(zoom: 1)) - let stopFilter: StopDetailsFilter? = .init(routeId: route.id, directionId: 0) - let leaveExpectation = expectation(description: "leaves predictions") - leaveExpectation.expectedFulfillmentCount = 1 - - let joinExpectation = expectation(description: "joins predictions") - joinExpectation.expectedFulfillmentCount = 2 - joinExpectation.assertForOverFulfill = true - - let predictionsRepo = MockPredictionsRepository( - onConnect: {}, - onConnectV2: { _ in joinExpectation.fulfill() }, - onDisconnect: { leaveExpectation.fulfill() }, - connectOutcome: nil, - connectV2Outcome: nil - ) - - let stopDetailsVM = StopDetailsViewModel( - predictionsRepository: predictionsRepo, - schedulesRepository: MockScheduleRepository() - ) - - let sut = StopDetailsPage( - stopId: stop.id, - stopFilter: stopFilter, - tripFilter: nil, - errorBannerVM: .init(), - nearbyVM: .init(combinedStopAndTrip: true), - mapVM: .init(), - stopDetailsVM: stopDetailsVM, - viewportProvider: viewportProvider - ) - - ViewHosting.host(view: sut) - - try sut.inspect().find(StopDetailsView.self).callOnChange(newValue: stop.id) - - wait(for: [leaveExpectation], timeout: 1) - wait(for: [joinExpectation], timeout: 1) - } - @MainActor - func testUpdatesDeparturesOnPredictionsChange() throws { + func testUpdatesDeparturesOnPredictionsChange() async throws { let objects = ObjectCollectionBuilder() let route = objects.route() let stop = objects.stop { _ in } @@ -269,8 +177,7 @@ final class StopDetailsPageTests: XCTestCase { schedulesRepository: MockScheduleRepository() ) stopDetailsVM.global = .init(objects: objects, patternIdsByStop: [stop.id: [pattern.id]]) - stopDetailsVM.predictionsByStop = .init(objects: objects) - stopDetailsVM.schedulesResponse = .init(objects: objects) + stopDetailsVM.stopData = nil let sut = StopDetailsPage( stopId: stop.id, @@ -285,18 +192,25 @@ final class StopDetailsPageTests: XCTestCase { XCTAssertNil(nearbyVM.departures) - let hasSetDepartures = sut.inspection.inspect { view in + ViewHosting.host(view: sut) + stopDetailsVM.stopData = .init( + stopId: stop.id, + schedules: .init(objects: objects), + predictionsByStop: .init(objects: objects), + predictionsLoaded: true + ) + + let hasSetDepartures = sut.inspection.inspect(after: 1) { view in XCTAssertNotNil(nearbyVM.departures) // Keeps internal departures in sync with VM departures XCTAssertEqual(try view.actualView().internalDepartures, nearbyVM.departures) } - ViewHosting.host(view: sut) - wait(for: [hasSetDepartures], timeout: 2) + await fulfillment(of: [hasSetDepartures], timeout: 2) } @MainActor - func testAppliesStopFilterAutomatically() throws { + func testAppliesStopFilterAutomatically() async throws { let objects = ObjectCollectionBuilder() let route = objects.route() @@ -320,8 +234,6 @@ final class StopDetailsPageTests: XCTestCase { schedule.departureTime = (Date.now + 10 * 60).toKotlinInstant() } - let schedulesLoadedPublisher = PassthroughSubject() - let viewportProvider: ViewportProvider = .init(viewport: .followPuck(zoom: 1)) let nearbyVM: NearbyViewModel = .init( navigationStack: [.stopDetails(stopId: stop.id, stopFilter: nil, tripFilter: nil)], @@ -334,10 +246,18 @@ final class StopDetailsPageTests: XCTestCase { predictionsRepository: MockPredictionsRepository(connectV2Response: .init(objects: objects)), schedulesRepository: MockScheduleRepository( scheduleResponse: .init(objects: objects), - callback: { _ in schedulesLoadedPublisher.send() } + callback: { _ in } ) ) + stopDetailsVM.global = .init(objects: objects) + stopDetailsVM.stopData = .init( + stopId: stop.id, + schedules: .init(objects: objects), + predictionsByStop: .init(objects: objects), + predictionsLoaded: true + ) + let sut = StopDetailsPage( stopId: stop.id, stopFilter: nil, @@ -348,20 +268,19 @@ final class StopDetailsPageTests: XCTestCase { stopDetailsVM: stopDetailsVM, viewportProvider: viewportProvider ) + ViewHosting.host(view: sut) - let stopFilterExp = sut.inspection.inspect(onReceive: schedulesLoadedPublisher) { _ in - XCTAssertEqual( - nearbyVM.navigationStack.lastStopDetailsFilter, - StopDetailsFilter(routeId: route.id, directionId: routePattern.directionId) - ) - } + sut.updateDepartures() - ViewHosting.host(view: sut) - wait(for: [stopFilterExp], timeout: 2) + try await Task.sleep(for: .seconds(1)) + XCTAssertEqual( + nearbyVM.navigationStack.lastStopDetailsFilter, + StopDetailsFilter(routeId: route.id, directionId: routePattern.directionId) + ) } @MainActor - func testAppliesTripFilterAutomatically() throws { + func testAppliesTripFilterAutomatically() async throws { let objects = ObjectCollectionBuilder() let route = objects.route() @@ -388,8 +307,6 @@ final class StopDetailsPageTests: XCTestCase { prediction.departureTime = (Date.now + 10 * 60).toKotlinInstant() } - let schedulesLoadedPublisher = PassthroughSubject() - let stopFilter: StopDetailsFilter = .init(routeId: route.id, directionId: 0) let viewportProvider: ViewportProvider = .init(viewport: .followPuck(zoom: 1)) let nearbyVM: NearbyViewModel = .init( @@ -401,10 +318,15 @@ final class StopDetailsPageTests: XCTestCase { let stopDetailsVM = StopDetailsViewModel( globalRepository: MockGlobalRepository(response: .init(objects: objects)), predictionsRepository: MockPredictionsRepository(connectV2Response: .init(objects: objects)), - schedulesRepository: MockScheduleRepository( - scheduleResponse: .init(objects: objects), - callback: { _ in schedulesLoadedPublisher.send() } - ) + schedulesRepository: MockScheduleRepository(scheduleResponse: .init(objects: objects), callback: { _ in }) + ) + + stopDetailsVM.global = .init(objects: objects) + stopDetailsVM.stopData = .init( + stopId: stop.id, + schedules: .init(objects: objects), + predictionsByStop: .init(objects: objects), + predictionsLoaded: true ) let sut = StopDetailsPage( @@ -418,13 +340,13 @@ final class StopDetailsPageTests: XCTestCase { viewportProvider: viewportProvider ) - let vehicleFilterExp = sut.inspection.inspect(onReceive: schedulesLoadedPublisher) { _ in - XCTAssertEqual( - nearbyVM.navigationStack.lastTripDetailsFilter, - TripDetailsFilter(tripId: trip.id, vehicleId: nil, stopSequence: 0, selectionLock: false) - ) - } ViewHosting.host(view: sut) - wait(for: [vehicleFilterExp], timeout: 2) + sut.updateDepartures() + try await Task.sleep(for: .seconds(1)) + + XCTAssertEqual( + nearbyVM.navigationStack.lastTripDetailsFilter, + TripDetailsFilter(tripId: trip.id, vehicleId: nil, stopSequence: 0, selectionLock: false) + ) } } diff --git a/iosApp/iosAppTests/Pages/StopDetails/TripDetailsViewTests.swift b/iosApp/iosAppTests/Pages/StopDetails/TripDetailsViewTests.swift index 85d2784c6..237a2f32a 100644 --- a/iosApp/iosAppTests/Pages/StopDetails/TripDetailsViewTests.swift +++ b/iosApp/iosAppTests/Pages/StopDetails/TripDetailsViewTests.swift @@ -56,8 +56,13 @@ final class TripDetailsViewTests: XCTestCase { vehicleRepository: MockVehicleRepository(outcome: ApiResultOk(data: .init(vehicle: vehicle))) ) stopDetailsVM.global = .init(objects: objects) - stopDetailsVM.predictionsByStop = .init(objects: objects) stopDetailsVM.pinnedRoutes = .init() + stopDetailsVM.stopData = .init( + stopId: targetStop.id, + schedules: .init(objects: objects), + predictionsByStop: .init(objects: objects), + predictionsLoaded: true + ) stopDetailsVM.tripData = TripData( tripFilter: .init(tripId: trip.id, vehicleId: vehicle.id, stopSequence: 0, selectionLock: false), trip: trip, @@ -119,8 +124,13 @@ final class TripDetailsViewTests: XCTestCase { vehicleRepository: MockVehicleRepository(outcome: ApiResultOk(data: .init(vehicle: vehicle))) ) stopDetailsVM.global = .init(objects: objects) - stopDetailsVM.predictionsByStop = .init(objects: objects) stopDetailsVM.pinnedRoutes = .init() + stopDetailsVM.stopData = .init( + stopId: targetStop.id, + schedules: .init(objects: objects), + predictionsByStop: .init(objects: objects), + predictionsLoaded: true + ) stopDetailsVM.tripData = TripData( tripFilter: .init( tripId: trip.id, diff --git a/iosApp/iosAppTests/ViewModels/StopDetailsViewModelTests.swift b/iosApp/iosAppTests/ViewModels/StopDetailsViewModelTests.swift index 8e4042c18..4b3c14c20 100644 --- a/iosApp/iosAppTests/ViewModels/StopDetailsViewModelTests.swift +++ b/iosApp/iosAppTests/ViewModels/StopDetailsViewModelTests.swift @@ -22,20 +22,58 @@ final class StopDetailsViewModelTests: XCTestCase { let stopDetailsVM = StopDetailsViewModel( globalRepository: MockGlobalRepository(response: global, onGet: { exp.fulfill() }) ) - Task { await stopDetailsVM.activateGlobalListener() } - _ = try await stopDetailsVM.globalRepository.getGlobalData() + _ = stopDetailsVM.loadGlobalData() await fulfillment(of: [exp], timeout: 1) try await Task.sleep(for: .seconds(1)) XCTAssertEqual(stopDetailsVM.global, global) } + func testHandleStopChange() async throws { + let objects = ObjectCollectionBuilder() + let stop = objects.stop { _ in } + + let leaveExpectation = expectation(description: "leaves predictions") + let joinExpectation = expectation(description: "joins predictions") + + let predictionsRepo = MockPredictionsRepository( + onConnect: {}, + onConnectV2: { _ in joinExpectation.fulfill() }, + onDisconnect: { leaveExpectation.fulfill() }, + connectOutcome: nil, + connectV2Outcome: ApiResultOk(data: .init(objects: objects)) + ) + + let scheduleExpectation = expectation(description: "schedules loaded") + + let stopDetailsVM = StopDetailsViewModel( + predictionsRepository: predictionsRepo, + schedulesRepository: MockScheduleRepository( + scheduleResponse: .init(objects: objects), + callback: { _ in scheduleExpectation.fulfill() } + ) + ) + stopDetailsVM.stopData = .init(stopId: "old id", schedules: .init(objects: .init())) + + await stopDetailsVM.handleStopChange(stop.id) + + wait(for: [leaveExpectation, joinExpectation, scheduleExpectation], timeout: 1) + try await Task.sleep(for: .seconds(1)) + XCTAssertEqual( + StopData( + stopId: stop.id, + schedules: .init(objects: objects), + predictionsByStop: .init(objects: objects), + predictionsLoaded: true + ), + stopDetailsVM.stopData + ) + } + func testLoadPredictions() async throws { let objects = ObjectCollectionBuilder() let stop = objects.stop { _ in } let predictions = PredictionsByStopJoinResponse(objects: objects) let connectExp = expectation(description: "predictions are connected") - let successExp = expectation(description: "prediction success callback was called") - let completeExp = expectation(description: "prediction complete callback was called") let disconnectExp = expectation(description: "predictions are disconnected") let stopDetailsVM = StopDetailsViewModel( predictionsRepository: MockPredictionsRepository( @@ -44,15 +82,17 @@ final class StopDetailsViewModelTests: XCTestCase { connectV2Response: predictions ) ) - - stopDetailsVM.joinPredictions( - stop.id, - onSuccess: { successExp.fulfill() }, - onComplete: { completeExp.fulfill() } + stopDetailsVM.stopData = .init( + stopId: stop.id, + schedules: .init(objects: objects), + predictionsByStop: nil, + predictionsLoaded: false ) - await fulfillment(of: [connectExp, successExp, completeExp], timeout: 2) - XCTAssertEqual(stopDetailsVM.predictionsByStop, predictions) - stopDetailsVM.leavePredictions() + + stopDetailsVM.joinStopPredictions(stop.id) + await fulfillment(of: [connectExp], timeout: 2) + XCTAssertEqual(stopDetailsVM.stopData?.predictionsByStop, predictions) + stopDetailsVM.leaveStopPredictions() await fulfillment(of: [disconnectExp], timeout: 1) } @@ -111,8 +151,12 @@ final class StopDetailsViewModelTests: XCTestCase { let stopDetailsVM = StopDetailsViewModel() stopDetailsVM.global = .init(objects: objects, patternIdsByStop: [stop.id: [pattern0.id, pattern1.id]]) - stopDetailsVM.predictionsByStop = .init(objects: objects) - stopDetailsVM.schedulesResponse = .init(objects: objects) + stopDetailsVM.stopData = .init( + stopId: stop.id, + schedules: .init(objects: objects), + predictionsByStop: .init(objects: objects), + predictionsLoaded: true + ) let departures = stopDetailsVM.getDepartures( stopId: stop.id,