Files
letro-ios/UnitTests/Sources/LocationSharingScreenViewModelTests.swift

483 lines
25 KiB
Swift

//
// Copyright 2025 Element Creations Ltd.
// Copyright 2023-2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Combine
import CoreLocation
@testable import ElementX
import Testing
@MainActor
struct LocationSharingScreenViewModelTests {
private var timelineProxy: TimelineProxyMock!
private var viewModel: LocationSharingScreenViewModel!
private var context: LocationSharingScreenViewModel.Context {
viewModel.context
}
@Test
mutating func userDidPan() {
setupViewModel()
#expect(context.viewState.isSharingUserLocation)
#expect(context.showsUserLocationMode == .showAndFollow)
context.send(viewAction: .userDidPan)
#expect(!context.viewState.isSharingUserLocation)
#expect(context.showsUserLocationMode == .show)
}
@Test
mutating func centerOnUser() {
setupViewModel()
#expect(context.viewState.isSharingUserLocation)
context.showsUserLocationMode = .show
#expect(!context.viewState.isSharingUserLocation)
context.send(viewAction: .centerToUser)
#expect(context.viewState.isSharingUserLocation)
#expect(context.showsUserLocationMode == .showAndFollow)
}
@Test
mutating func centerOnUserWithoutAuthorization() {
setupViewModel()
context.showsUserLocationMode = .hide
context.isLocationAuthorized = nil
context.send(viewAction: .centerToUser)
#expect(context.showsUserLocationMode == .showAndFollow)
}
@Test
mutating func centerOnUserWithDeniedAuthorization() {
setupViewModel()
context.isLocationAuthorized = false
context.showsUserLocationMode = .hide
context.send(viewAction: .centerToUser)
#expect(context.showsUserLocationMode != .showAndFollow)
#expect(context.alertInfo != nil)
}
@Test
mutating func errorMapping() {
setupViewModel()
let mapError = AlertInfo(alertID: .mapError(.failedLoadingMap))
#expect(mapError.title == L10n.errorFailedLoadingMap(InfoPlistReader.main.bundleDisplayName))
let locationError = AlertInfo(alertID: .mapError(.failedLocatingUser))
#expect(locationError.title == L10n.errorFailedLocatingUser(InfoPlistReader.main.bundleDisplayName))
let AuthorizationError = AlertInfo(alertID: .missingAuthorization)
#expect(AuthorizationError.message == L10n.dialogPermissionLocationDescriptionIos(InfoPlistReader.main.bundleDisplayName))
}
@Test
mutating func sendUserLocation() async throws {
setupViewModel()
context.mapCenterLocation = .init(latitude: 0, longitude: 0)
context.geolocationUncertainty = 10
let deferred = deferFulfillment(viewModel.actions) { $0 == .close }
try await confirmation { confirmation in
timelineProxy.sendLocationBodyGeoURIDescriptionZoomLevelAssetTypeClosure = { _, geoURI, _, _, assetType in
#expect(geoURI.uncertainty == 10)
#expect(assetType == .sender)
confirmation()
return .success(())
}
context.send(viewAction: .selectLocation)
try await deferred.fulfill()
}
}
@Test
mutating func sendPickedLocation() async throws {
setupViewModel()
context.mapCenterLocation = .init(latitude: 0, longitude: 0)
context.isLocationAuthorized = nil
context.geolocationUncertainty = 10
let deferred = deferFulfillment(viewModel.actions) { $0 == .close }
try await confirmation { confirmation in
timelineProxy.sendLocationBodyGeoURIDescriptionZoomLevelAssetTypeClosure = { _, geoURI, _, _, assetType in
#expect(geoURI.uncertainty == nil)
#expect(assetType == .pin)
confirmation()
return .success(())
}
context.send(viewAction: .selectLocation)
try await deferred.fulfill()
}
}
// MARK: - isLocationLoading Tests
@Test
mutating func isLocationLoadingInPickerModeWithAuthorizationNotDetermined() {
setupViewModel()
context.isLocationAuthorized = nil
context.hasLoadedUserLocation = false
#expect(context.viewState.isLocationLoading)
}
@Test
mutating func isLocationLoadingInPickerModeWithAuthorizationGranted() {
setupViewModel()
context.isLocationAuthorized = true
context.hasLoadedUserLocation = false
#expect(context.viewState.isLocationLoading)
}
@Test
mutating func isLocationNotLoadingInPickerModeWhenLocationLoaded() {
setupViewModel()
context.isLocationAuthorized = true
context.hasLoadedUserLocation = true
#expect(!context.viewState.isLocationLoading)
}
@Test
mutating func isLocationNotLoadingInPickerModeWhenAuthorizationDenied() {
setupViewModel()
context.isLocationAuthorized = false
context.hasLoadedUserLocation = false
#expect(!context.viewState.isLocationLoading)
}
@Test
mutating func isLocationNotLoadingInNonPickerModeWithAuthorizationNotDetermined() {
let aliceShare = makeLiveLocationShare(userID: "@alice:matrix.org")
let sender = TimelineItemSender(id: "@alice:matrix.org", displayName: "Alice")
let liveLocationsSubject = CurrentValueSubject<[LiveLocationShare], Never>([aliceShare])
setupViewModelForViewLive(sender: sender, initialShare: aliceShare, liveLocationsSubject: liveLocationsSubject)
context.isLocationAuthorized = nil
context.hasLoadedUserLocation = false
#expect(!context.viewState.isLocationLoading)
}
@Test
mutating func isLocationLoadingInNonPickerModeWithAuthorizationGiven() {
let aliceShare = makeLiveLocationShare(userID: "@alice:matrix.org")
let sender = TimelineItemSender(id: "@alice:matrix.org", displayName: "Alice")
let liveLocationsSubject = CurrentValueSubject<[LiveLocationShare], Never>([aliceShare])
setupViewModelForViewLive(sender: sender, initialShare: aliceShare, liveLocationsSubject: liveLocationsSubject)
context.isLocationAuthorized = true
context.hasLoadedUserLocation = false
#expect(context.viewState.isLocationLoading)
}
// MARK: - Live Location Permission Tests
@Test
mutating func startLiveLocationWithoutPermission() {
setupViewModel(liveLocationManagerConfiguration: .init(authorizationStatus: .authorizedAlways),
members: .allMembers)
context.send(viewAction: .startLiveLocation)
#expect(context.alertInfo?.id == .missingLiveLocationSharingPermission)
}
// MARK: - Live Location Authorization Tests
@Test
mutating func startLiveLocationWithDeniedAuthorization() {
setupViewModel(liveLocationManagerConfiguration: .init(authorizationStatus: .denied))
context.send(viewAction: .startLiveLocation)
#expect(context.alertInfo?.id == .missingAlwaysAuthorization)
}
@Test
mutating func startLiveLocationWithRestrictedAuthorization() {
setupViewModel(liveLocationManagerConfiguration: .init(authorizationStatus: .restricted))
context.send(viewAction: .startLiveLocation)
#expect(context.alertInfo?.id == .missingAlwaysAuthorization)
}
@Test
mutating func startLiveLocationWithWhenInUseAuthorizationAlreadyRequested() {
setupViewModel(liveLocationManagerConfiguration: .init(authorizationStatus: .authorizedWhenInUse,
requestAlwaysAuthorizationIfPossibleReturnValue: false))
context.send(viewAction: .startLiveLocation)
#expect(context.alertInfo?.id == .missingAlwaysAuthorization)
}
@Test
mutating func startLiveLocationWithWhenInUseAuthorizationNotYetRequested() {
setupViewModel(liveLocationManagerConfiguration: .init(authorizationStatus: .authorizedWhenInUse,
requestAlwaysAuthorizationIfPossibleReturnValue: true))
context.send(viewAction: .startLiveLocation)
// The request can be made, so no alert should be shown it waits for the user to respond to the system prompt
#expect(context.alertInfo == nil)
}
@Test
mutating func startLiveLocationWithNotDeterminedAuthorizationTransitionsToWhenInUse() async {
let authorizationStatusSubject = CurrentValueSubject<CLAuthorizationStatus, Never>(.notDetermined)
let liveLocationManagerMock = LiveLocationManagerMock()
liveLocationManagerMock.underlyingAuthorizationStatus = .init(authorizationStatusSubject)
liveLocationManagerMock.requestAlwaysAuthorizationIfPossibleReturnValue = true
setupViewModel(liveLocationManagerMock: liveLocationManagerMock)
context.send(viewAction: .startLiveLocation)
// No alert yet waiting for MapLibre to resolve the status to whenInUse
#expect(context.alertInfo == nil)
// Simulate MapLibre resolving the Authorization to whenInUse, and confirm that the ViewModel
// recurses and calls requestAlwaysAuthorizationIfPossible as a result
await waitForConfirmation { confirmation in
liveLocationManagerMock.requestAlwaysAuthorizationIfPossibleClosure = {
confirmation()
return true
}
authorizationStatusSubject.send(.authorizedWhenInUse)
}
// The request was made, so no alert waiting for the always Authorization prompt response
#expect(context.alertInfo == nil)
}
// MARK: - Live Location Start Flow Tests
@Test
mutating func startLiveLocationShowsDisclaimer() {
setupViewModel(liveLocationManagerConfiguration: .init(authorizationStatus: .authorizedAlways))
context.send(viewAction: .startLiveLocation)
#expect(context.alertInfo?.id == .liveLocationDisclaimer)
}
@Test
mutating func startLiveLocationDisclaimerDeclineSkipsStart() {
let liveLocationManagerMock = LiveLocationManagerMock(.init(authorizationStatus: .authorizedAlways))
setupViewModel(liveLocationManagerMock: liveLocationManagerMock)
context.send(viewAction: .startLiveLocation)
context.alertInfo?.primaryButton.action?()
#expect(!liveLocationManagerMock.startLiveLocationRoomIDDurationCalled)
}
@Test
mutating func startLiveLocationDisclaimerAcceptShowsDurationPicker() async throws {
setupViewModel(liveLocationManagerConfiguration: .init(authorizationStatus: .authorizedAlways))
context.send(viewAction: .startLiveLocation)
#expect(context.alertInfo?.id == .liveLocationDisclaimer)
let deferred = deferFulfillment(context.observe(\.alertInfo)) { $0?.id == .liveLocationDurationSelection }
context.alertInfo?.secondaryButton?.action?()
try await deferred.fulfill()
}
@Test
mutating func startLiveLocationDurationPickerCancelSkipsStart() async throws {
let liveLocationManagerMock = LiveLocationManagerMock(.init(authorizationStatus: .authorizedAlways))
setupViewModel(liveLocationManagerMock: liveLocationManagerMock)
context.send(viewAction: .startLiveLocation)
let deferred = deferFulfillment(context.observe(\.alertInfo)) { $0?.id == .liveLocationDurationSelection }
context.alertInfo?.secondaryButton?.action?()
try await deferred.fulfill()
context.alertInfo?.primaryButton.action?()
#expect(!liveLocationManagerMock.startLiveLocationRoomIDDurationCalled)
}
@Test
mutating func startLiveLocationSuccess() async throws {
let liveLocationManagerMock = LiveLocationManagerMock(.init(authorizationStatus: .authorizedAlways))
setupViewModel(liveLocationManagerMock: liveLocationManagerMock)
context.send(viewAction: .startLiveLocation)
let durationPicker = deferFulfillment(context.observe(\.alertInfo)) { $0?.id == .liveLocationDurationSelection }
context.alertInfo?.secondaryButton?.action?()
try await durationPicker.fulfill()
let deferred = deferFulfillment(viewModel.actions) { $0 == .close }
context.alertInfo?.verticalButtons?.first?.action?()
try await deferred.fulfill()
#expect(liveLocationManagerMock.startLiveLocationRoomIDDurationCalled)
let arguments = try #require(liveLocationManagerMock.startLiveLocationRoomIDDurationReceivedArguments)
#expect(arguments.duration == .seconds(60 * 15))
}
@Test
mutating func startLiveLocationFailureDoesNotClose() async throws {
let liveLocationManagerMock = LiveLocationManagerMock(.init(authorizationStatus: .authorizedAlways))
liveLocationManagerMock.startLiveLocationRoomIDDurationReturnValue = .failure(.startFailed)
setupViewModel(liveLocationManagerMock: liveLocationManagerMock)
context.send(viewAction: .startLiveLocation)
let durationPicker = deferFulfillment(context.observe(\.alertInfo)) { $0?.id == .liveLocationDurationSelection }
context.alertInfo?.secondaryButton?.action?()
try await durationPicker.fulfill()
let deferredFailure = deferFailure(viewModel.actions, timeout: .seconds(1)) { $0 == .close }
context.alertInfo?.verticalButtons?.first?.action?()
try await deferredFailure.fulfill()
}
// MARK: - Live Location Share Update Tests
@Test
mutating func viewLiveInitialSenderShownCorrectly() {
let aliceShare = makeLiveLocationShare(userID: "@alice:matrix.org", latitude: 51.5, longitude: -0.1)
let sender = TimelineItemSender(id: "@alice:matrix.org", displayName: "Alice")
let liveLocationsSubject = CurrentValueSubject<[LiveLocationShare], Never>([aliceShare])
setupViewModelForViewLive(sender: sender, initialShare: aliceShare, liveLocationsSubject: liveLocationsSubject)
// Initial state is synchronously set from the interaction mode before the async subscription runs.
let annotations = context.viewState.annotations
#expect(annotations.count == 1)
let annotation = annotations.first
#expect(annotation?.id == "@alice:matrix.org")
#expect(annotation?.coordinate.latitude == 51.5)
#expect(annotation?.coordinate.longitude == -0.1)
#expect(annotation?.kind == .liveUser(.init(userID: "@alice:matrix.org", displayName: "Alice")))
}
@Test
mutating func viewLiveReceivesAdditionalLocationUpdates() async throws {
let aliceShare = makeLiveLocationShare(userID: "@alice:matrix.org", latitude: 51.5, longitude: -0.1)
let sender = TimelineItemSender(id: "@alice:matrix.org", displayName: "Alice")
let liveLocationsSubject = CurrentValueSubject<[LiveLocationShare], Never>([aliceShare])
setupViewModelForViewLive(sender: sender, initialShare: aliceShare, liveLocationsSubject: liveLocationsSubject)
let bobShare = makeLiveLocationShare(userID: "@bob:matrix.org", latitude: 48.8, longitude: 2.3)
let charlieShare = makeLiveLocationShare(userID: "@charlie:matrix.org", latitude: 40.7, longitude: -74.0)
let deferred = deferFulfillment(context.observe(\.viewState.annotations)) { $0.count == 3 }
liveLocationsSubject.send([aliceShare, bobShare, charlieShare])
try await deferred.fulfill()
let annotations = context.viewState.annotations
#expect(annotations.count == 3)
let annotationIDs = Set(annotations.map(\.id))
#expect(annotationIDs == ["@alice:matrix.org", "@bob:matrix.org", "@charlie:matrix.org"])
#expect(annotations.first { $0.id == "@alice:matrix.org" }?.coordinate.latitude == 51.5)
#expect(annotations.first { $0.id == "@bob:matrix.org" }?.coordinate.latitude == 48.8)
#expect(annotations.first { $0.id == "@charlie:matrix.org" }?.coordinate.latitude == 40.7)
}
@Test
mutating func viewLiveProfilesResolvedFromRoomMembers() async throws {
let aliceShare = makeLiveLocationShare(userID: "@alice:matrix.org", latitude: 51.5, longitude: -0.1)
let sender = TimelineItemSender(id: "@alice:matrix.org", displayName: "Alice")
let liveLocationsSubject = CurrentValueSubject<[LiveLocationShare], Never>([aliceShare])
setupViewModelForViewLive(sender: sender, initialShare: aliceShare, liveLocationsSubject: liveLocationsSubject)
let bobShare = makeLiveLocationShare(userID: "@bob:matrix.org", latitude: 48.8, longitude: 2.3)
let charlieShare = makeLiveLocationShare(userID: "@charlie:matrix.org", latitude: 40.7, longitude: -74.0)
let deferred = deferFulfillment(context.observe(\.viewState.annotations)) { $0.count == 3 }
liveLocationsSubject.send([aliceShare, bobShare, charlieShare])
try await deferred.fulfill()
// Annotation marker kinds should carry profiles resolved from room members.
let annotations = context.viewState.annotations
#expect(annotations.first { $0.id == "@alice:matrix.org" }?.kind == .liveUser(.init(userID: "@alice:matrix.org", displayName: "Alice")))
#expect(annotations.first { $0.id == "@bob:matrix.org" }?.kind == .liveUser(.init(userID: "@bob:matrix.org", displayName: "Bob")))
#expect(annotations.first { $0.id == "@charlie:matrix.org" }?.kind == .liveUser(.init(userID: "@charlie:matrix.org", displayName: "Charlie")))
}
@Test
mutating func viewLiveFromBannerAwaitsFirstShareThenCentersOnIt() async throws {
// Simulates opening from the banner: no sender info and no initial share are available yet.
// The VM should wait for the first live location update and then center on the first share,
// which is assumed to belong to the own user.
let liveLocationsSubject = CurrentValueSubject<[LiveLocationShare], Never>([])
let liveLocationServiceMock = RoomLiveLocationServiceMock()
liveLocationServiceMock.liveLocationsPublisher = liveLocationsSubject.asCurrentValuePublisher()
let roomProxyMock = JoinedRoomProxyMock(.init(members: .allMembers))
roomProxyMock.makeLiveLocationServiceReturnValue = liveLocationServiceMock
viewModel = LocationSharingScreenViewModel(interactionMode: .viewLive(sender: nil, initialLiveLocationShare: nil),
mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration,
roomProxy: roomProxyMock,
timelineController: MockTimelineController(timelineProxy: TimelineProxyMock(.init())),
liveLocationManager: LiveLocationManagerMock(.init()),
analytics: ServiceLocator.shared.analytics,
userIndicatorController: UserIndicatorControllerMock(),
mediaProvider: MediaProviderMock(configuration: .init()))
// Initially no annotations and no map center since sender and share are both nil.
#expect(context.viewState.annotations.isEmpty)
#expect(context.mapCenterLocation == nil)
// Once the first update arrives, the VM populates annotations and centers the map on the first share.
let ownUserShare = makeLiveLocationShare(userID: RoomMemberProxyMock.mockMe.userID, latitude: 51.5, longitude: -0.1)
let deferred = deferFulfillment(context.observe(\.viewState.annotations)) { !$0.isEmpty }
liveLocationsSubject.send([ownUserShare])
try await deferred.fulfill()
#expect(context.viewState.annotations.count == 1)
#expect(context.viewState.annotations.first?.id == RoomMemberProxyMock.mockMe.userID)
#expect(context.viewState.annotations.first?.coordinate.latitude == 51.5)
#expect(context.viewState.annotations.first?.coordinate.longitude == -0.1)
// The map should have been centered on the first received share's coordinates.
#expect(context.mapCenterLocation?.latitude == 51.5)
#expect(context.mapCenterLocation?.longitude == -0.1)
}
// MARK: - Private
private mutating func setupViewModel(liveLocationManagerConfiguration: LiveLocationManagerMock.Configuration = .init(),
members: [RoomMemberProxyMock] = .allMembersAsAdmin) {
timelineProxy = TimelineProxyMock(.init())
viewModel = LocationSharingScreenViewModel(interactionMode: .picker,
mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration,
roomProxy: JoinedRoomProxyMock(.init(members: members)),
timelineController: MockTimelineController(timelineProxy: timelineProxy),
liveLocationManager: LiveLocationManagerMock(liveLocationManagerConfiguration),
analytics: ServiceLocator.shared.analytics,
userIndicatorController: UserIndicatorControllerMock(),
mediaProvider: MediaProviderMock(configuration: .init()))
viewModel.state.bindings.isLocationAuthorized = true
}
private mutating func setupViewModel(liveLocationManagerMock: LiveLocationManagerMock,
members: [RoomMemberProxyMock] = .allMembersAsAdmin) {
timelineProxy = TimelineProxyMock(.init())
viewModel = LocationSharingScreenViewModel(interactionMode: .picker,
mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration,
roomProxy: JoinedRoomProxyMock(.init(members: members)),
timelineController: MockTimelineController(timelineProxy: timelineProxy),
liveLocationManager: liveLocationManagerMock,
analytics: ServiceLocator.shared.analytics,
userIndicatorController: UserIndicatorControllerMock(),
mediaProvider: MediaProviderMock(configuration: .init()))
viewModel.state.bindings.isLocationAuthorized = true
}
private mutating func setupViewModelForViewLive(sender: TimelineItemSender,
initialShare: LiveLocationShare,
liveLocationsSubject: CurrentValueSubject<[LiveLocationShare], Never>,
members: [RoomMemberProxyMock] = .allMembers) {
let liveLocationServiceMock = RoomLiveLocationServiceMock()
liveLocationServiceMock.liveLocationsPublisher = liveLocationsSubject.asCurrentValuePublisher()
let roomProxyMock = JoinedRoomProxyMock(.init(members: members))
roomProxyMock.makeLiveLocationServiceReturnValue = liveLocationServiceMock
viewModel = LocationSharingScreenViewModel(interactionMode: .viewLive(sender: sender, initialLiveLocationShare: initialShare),
mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration,
roomProxy: roomProxyMock,
timelineController: MockTimelineController(timelineProxy: TimelineProxyMock(.init())),
liveLocationManager: LiveLocationManagerMock(.init()),
analytics: ServiceLocator.shared.analytics,
userIndicatorController: UserIndicatorControllerMock(),
mediaProvider: MediaProviderMock(configuration: .init()))
}
private func makeLiveLocationShare(userID: String, latitude: Double = 0.0, longitude: Double = 0.0) -> LiveLocationShare {
LiveLocationShare(userID: userID,
geoURI: .init(latitude: latitude, longitude: longitude),
timestamp: .distantPast,
timeoutDate: .distantFuture)
}
}