245 lines
13 KiB
Swift
245 lines
13 KiB
Swift
//
|
|
// Copyright 2023, 2024 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 XCTest
|
|
|
|
import Combine
|
|
@testable import ElementX
|
|
|
|
@MainActor
|
|
class UserSessionFlowCoordinatorTests: XCTestCase {
|
|
var userSessionFlowCoordinator: UserSessionFlowCoordinator!
|
|
var rootCoordinator: NavigationRootCoordinator!
|
|
var userIndicatorController: UserIndicatorControllerMock!
|
|
let stateMachineFactory = PublishedStateMachineFactory()
|
|
|
|
let networkReachabilitySubject: CurrentValueSubject<NetworkMonitorReachability, Never> = .init(.reachable)
|
|
let homeserverReachabilitySubject: CurrentValueSubject<NetworkMonitorReachability, Never> = .init(.reachable)
|
|
var cancellables = Set<AnyCancellable>()
|
|
|
|
var tabCoordinator: NavigationTabCoordinator<UserSessionFlowCoordinator.HomeTab>? { rootCoordinator?.rootCoordinator as? NavigationTabCoordinator }
|
|
var chatsSplitCoordinator: NavigationSplitCoordinator? { tabCoordinator?.tabCoordinators.first as? NavigationSplitCoordinator }
|
|
var detailCoordinator: CoordinatorProtocol? { chatsSplitCoordinator?.detailCoordinator }
|
|
var detailNavigationStack: NavigationStackCoordinator? { detailCoordinator as? NavigationStackCoordinator }
|
|
|
|
override func setUp() async throws {
|
|
cancellables.removeAll()
|
|
|
|
rootCoordinator = NavigationRootCoordinator()
|
|
|
|
let clientProxy = ClientProxyMock(.init(userID: "hi@bob", roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms)))))
|
|
clientProxy.homeserverReachabilityPublisher = homeserverReachabilitySubject.asCurrentValuePublisher()
|
|
|
|
let networkMonitor = NetworkMonitorMock.default
|
|
networkMonitor.reachabilityPublisher = networkReachabilitySubject.asCurrentValuePublisher()
|
|
let appMediator = AppMediatorMock.default
|
|
appMediator.networkMonitor = networkMonitor
|
|
|
|
userIndicatorController = UserIndicatorControllerMock()
|
|
|
|
let flowParameters = CommonFlowParameters(userSession: UserSessionMock(.init(clientProxy: clientProxy)),
|
|
bugReportService: BugReportServiceMock(.init()),
|
|
elementCallService: ElementCallServiceMock(.init()),
|
|
timelineControllerFactory: TimelineControllerFactoryMock(.init()),
|
|
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
|
|
linkMetadataProvider: LinkMetadataProvider(),
|
|
appMediator: appMediator,
|
|
appSettings: ServiceLocator.shared.settings,
|
|
appHooks: AppHooks(),
|
|
analytics: ServiceLocator.shared.analytics,
|
|
userIndicatorController: userIndicatorController,
|
|
notificationManager: NotificationManagerMock(),
|
|
stateMachineFactory: stateMachineFactory)
|
|
|
|
userSessionFlowCoordinator = UserSessionFlowCoordinator(isNewLogin: false,
|
|
navigationRootCoordinator: rootCoordinator,
|
|
appLockService: AppLockServiceMock(),
|
|
flowParameters: flowParameters)
|
|
|
|
userSessionFlowCoordinator.start()
|
|
}
|
|
|
|
// MARK: Navigation
|
|
|
|
func testInitialState() async throws {
|
|
XCTAssertNotNil(chatsSplitCoordinator)
|
|
XCTAssertNil(detailCoordinator)
|
|
}
|
|
|
|
func testSettingsPresentation() async throws {
|
|
try await process(route: .settings, expectedUserSessionState: .settingsScreen)
|
|
XCTAssertTrue((tabCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is SettingsScreenCoordinator)
|
|
}
|
|
|
|
func testRoomPresentation() async throws {
|
|
try await process(route: .room(roomID: "1", via: []), expectedChatsState: .roomList(roomListSelectedRoomID: "1"))
|
|
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
|
|
XCTAssertNotNil(detailCoordinator)
|
|
}
|
|
|
|
func testRoomPresentationClearsSettings() async throws {
|
|
try await process(route: .settings, expectedUserSessionState: .settingsScreen)
|
|
XCTAssertTrue((tabCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is SettingsScreenCoordinator)
|
|
XCTAssertNil(detailCoordinator)
|
|
|
|
try await process(route: .room(roomID: "1", via: []), expectedChatsState: .roomList(roomListSelectedRoomID: "1"))
|
|
XCTAssertNil((tabCoordinator?.sheetCoordinator))
|
|
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
|
|
XCTAssertNotNil(detailCoordinator)
|
|
}
|
|
|
|
func testChildRoomPresentation() async throws {
|
|
try await process(route: .room(roomID: "1", via: []), expectedChatsState: .roomList(roomListSelectedRoomID: "1"))
|
|
let detailNavigationStack = try XCTUnwrap(detailNavigationStack, "There must be a navigation stack.")
|
|
XCTAssertTrue(detailNavigationStack.rootCoordinator is RoomScreenCoordinator)
|
|
XCTAssertNotNil(detailCoordinator)
|
|
|
|
let deferred = deferFulfillment(detailNavigationStack.observe(\.stackCoordinators.count)) { $0 == 1 }
|
|
try await process(route: .childRoom(roomID: "2", via: []))
|
|
try await deferred.fulfill()
|
|
XCTAssertTrue(detailNavigationStack.rootCoordinator is RoomScreenCoordinator)
|
|
XCTAssertNotNil(detailCoordinator)
|
|
XCTAssertEqual(detailNavigationStack.stackCoordinators.count, 1)
|
|
XCTAssertTrue(detailNavigationStack.stackCoordinators.first is RoomScreenCoordinator)
|
|
}
|
|
|
|
func testShareMediaRouteWithoutRoom() async throws {
|
|
try await process(route: .settings, expectedUserSessionState: .settingsScreen)
|
|
XCTAssertTrue((tabCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is SettingsScreenCoordinator)
|
|
XCTAssertNil(chatsSplitCoordinator?.sheetCoordinator)
|
|
|
|
let sharePayload: ShareExtensionPayload = .mediaFiles(roomID: nil, mediaFiles: [.init(url: .picturesDirectory, suggestedName: nil)])
|
|
try await process(route: .share(sharePayload),
|
|
expectedUserSessionState: .tabBar,
|
|
expectedChatsState: .shareExtensionRoomList(sharePayload: sharePayload))
|
|
XCTAssertNil(tabCoordinator?.sheetCoordinator)
|
|
XCTAssertTrue((chatsSplitCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is RoomSelectionScreenCoordinator)
|
|
}
|
|
|
|
func testShareMediaRouteWithRoom() async throws {
|
|
try await process(route: .event(eventID: "1", roomID: "1", via: []), expectedChatsState: .roomList(roomListSelectedRoomID: "1"))
|
|
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
|
|
XCTAssertNil(tabCoordinator?.sheetCoordinator)
|
|
XCTAssertNil(chatsSplitCoordinator?.sheetCoordinator)
|
|
|
|
let sharePayload: ShareExtensionPayload = .mediaFiles(roomID: "2", mediaFiles: [.init(url: .picturesDirectory, suggestedName: nil)])
|
|
try await process(route: .share(sharePayload),
|
|
expectedChatsState: .roomList(roomListSelectedRoomID: "2"))
|
|
|
|
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
|
|
XCTAssertNil(tabCoordinator?.sheetCoordinator)
|
|
XCTAssertTrue((chatsSplitCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is MediaUploadPreviewScreenCoordinator)
|
|
}
|
|
|
|
func testShareTextRouteWithoutRoom() async throws {
|
|
try await process(route: .settings, expectedUserSessionState: .settingsScreen)
|
|
XCTAssertTrue((tabCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is SettingsScreenCoordinator)
|
|
XCTAssertNil(chatsSplitCoordinator?.sheetCoordinator)
|
|
|
|
let sharePayload: ShareExtensionPayload = .text(roomID: nil, text: "Important Text")
|
|
try await process(route: .share(sharePayload),
|
|
expectedUserSessionState: .tabBar,
|
|
expectedChatsState: .shareExtensionRoomList(sharePayload: sharePayload))
|
|
XCTAssertNil(tabCoordinator?.sheetCoordinator)
|
|
XCTAssertTrue((chatsSplitCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is RoomSelectionScreenCoordinator)
|
|
}
|
|
|
|
func testShareTextRouteWithRoom() async throws {
|
|
try await process(route: .event(eventID: "1", roomID: "1", via: []), expectedChatsState: .roomList(roomListSelectedRoomID: "1"))
|
|
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
|
|
XCTAssertNil(tabCoordinator?.sheetCoordinator)
|
|
XCTAssertNil(chatsSplitCoordinator?.sheetCoordinator)
|
|
|
|
let sharePayload: ShareExtensionPayload = .text(roomID: "2", text: "Important text")
|
|
try await process(route: .share(sharePayload),
|
|
expectedChatsState: .roomList(roomListSelectedRoomID: "2"))
|
|
|
|
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
|
|
XCTAssertNil(tabCoordinator?.sheetCoordinator)
|
|
XCTAssertNil(chatsSplitCoordinator?.sheetCoordinator, "The media upload sheet shouldn't be shown when sharing text.")
|
|
}
|
|
|
|
// MARK: Indicators
|
|
|
|
func testReachabilityIndicators() async throws {
|
|
// Given a flow in its initial state.
|
|
try await Task.sleep(for: .milliseconds(100))
|
|
|
|
// Then no reachability indicators should be shown.
|
|
XCTAssertFalse(userIndicatorController.submitIndicatorDelayCalled)
|
|
XCTAssertEqual(retractReachabilityIndicatorCallsCount, 1) // The initial state removes the indicator.
|
|
|
|
// When the homeserver becomes unreachable.
|
|
homeserverReachabilitySubject.send(.unreachable)
|
|
try await Task.sleep(for: .milliseconds(100))
|
|
|
|
// Then a server unreachable indicator should be shown.
|
|
XCTAssertEqual(userIndicatorController.submitIndicatorDelayCallsCount, 1)
|
|
XCTAssertEqual(userIndicatorController.submitIndicatorDelayReceivedArguments?.indicator.title, L10n.commonServerUnreachable)
|
|
XCTAssertEqual(retractReachabilityIndicatorCallsCount, 1)
|
|
|
|
// When the network also becomes unreachable.
|
|
networkReachabilitySubject.send(.unreachable)
|
|
try await Task.sleep(for: .milliseconds(100))
|
|
|
|
// Then the server unreachable indicator should be replaced with an offline indicator.
|
|
XCTAssertEqual(userIndicatorController.submitIndicatorDelayCallsCount, 2)
|
|
XCTAssertEqual(userIndicatorController.submitIndicatorDelayReceivedArguments?.indicator.title, L10n.commonOffline)
|
|
XCTAssertEqual(retractReachabilityIndicatorCallsCount, 1)
|
|
|
|
// When the homeserver becomes reachable again.
|
|
homeserverReachabilitySubject.send(.reachable)
|
|
try await Task.sleep(for: .milliseconds(100))
|
|
|
|
// Then the indicator should be hidden even if the network isn't reachable.
|
|
XCTAssertEqual(userIndicatorController.submitIndicatorDelayCallsCount, 2)
|
|
XCTAssertEqual(retractReachabilityIndicatorCallsCount, 2)
|
|
|
|
// When the network becomes reachable again.
|
|
networkReachabilitySubject.send(.reachable)
|
|
try await Task.sleep(for: .milliseconds(100))
|
|
|
|
// Then nothing else should happen.
|
|
XCTAssertEqual(userIndicatorController.submitIndicatorDelayCallsCount, 2)
|
|
XCTAssertEqual(retractReachabilityIndicatorCallsCount, 3)
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func process(route: AppRoute,
|
|
expectedUserSessionState: UserSessionFlowCoordinator.State? = nil,
|
|
expectedChatsState: ChatsFlowCoordinatorStateMachine.State? = nil) async throws {
|
|
let deferredUserSession: DeferredFulfillment? = if let expectedUserSessionState {
|
|
deferFulfillment(stateMachineFactory.userSessionFlowStatePublisher.delay(for: .milliseconds(100), scheduler: DispatchQueue.main)) {
|
|
$0 == expectedUserSessionState
|
|
}
|
|
} else {
|
|
nil
|
|
}
|
|
|
|
let deferredChatsState: DeferredFulfillment? = if let expectedChatsState {
|
|
deferFulfillment(stateMachineFactory.chatsFlowStatePublisher.delay(for: .milliseconds(100), scheduler: DispatchQueue.main)) {
|
|
$0 == expectedChatsState
|
|
}
|
|
} else {
|
|
nil
|
|
}
|
|
|
|
userSessionFlowCoordinator.handleAppRoute(route, animated: true)
|
|
try await deferredUserSession?.fulfill()
|
|
try await deferredChatsState?.fulfill()
|
|
}
|
|
|
|
/// Other services retract indicators, so this filters based on the reachability ID.
|
|
private var retractReachabilityIndicatorCallsCount: Int {
|
|
userIndicatorController
|
|
.retractIndicatorWithIdReceivedInvocations
|
|
.filter { $0 == "io.element.elementx.reachability.notification" }
|
|
.count
|
|
}
|
|
}
|