For a whole host of reasons we don't support air-gapped operation anyway and as the SDK is super slow to tell us about the homeserver's reachability the indicator wasn't really working.
247 lines
14 KiB
Swift
247 lines
14 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 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(detailState: .room(roomID: "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(detailState: .room(roomID: "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(detailState: .room(roomID: "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(detailState: .room(roomID: "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(detailState: .room(roomID: "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(detailState: .room(roomID: "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(detailState: .room(roomID: "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 there should still be an offline indicator (as we don't yet support air-gapped servers on iOS).
|
|
XCTAssertEqual(userIndicatorController.submitIndicatorDelayCallsCount, 3)
|
|
XCTAssertEqual(userIndicatorController.submitIndicatorDelayReceivedArguments?.indicator.title, L10n.commonOffline)
|
|
XCTAssertEqual(retractReachabilityIndicatorCallsCount, 1)
|
|
|
|
// When the network becomes reachable again.
|
|
networkReachabilitySubject.send(.reachable)
|
|
try await Task.sleep(for: .milliseconds(100))
|
|
|
|
// Then the indicator should be hidden now as everything is back to normal
|
|
XCTAssertEqual(userIndicatorController.submitIndicatorDelayCallsCount, 3)
|
|
XCTAssertEqual(retractReachabilityIndicatorCallsCount, 2)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|