Move the Settings flow from the Chats flow up one level to the UserSession flow. (#4390)

* Add the basis for a state machine to UserSessionFlowCoordinator.

* Move the Settings flow from the Chats flow to UserSession flow.

It now works from the Spaces tab.

* Add a StateMachineFactory and use it to publish the state in the tests.
This commit is contained in:
Doug
2025-08-07 13:13:02 +01:00
committed by GitHub
parent c47ec8a7cc
commit c5cd3ea79e
10 changed files with 388 additions and 164 deletions
@@ -17,6 +17,7 @@ class ChatsFlowCoordinatorTests: XCTestCase {
var chatsFlowCoordinator: ChatsFlowCoordinator!
var splitCoordinator: NavigationSplitCoordinator!
var notificationManager: NotificationManagerMock!
let stateMachineFactory = PublishedStateMachineFactory()
var cancellables = Set<AnyCancellable>()
@@ -33,6 +34,7 @@ class ChatsFlowCoordinatorTests: XCTestCase {
notificationManager = NotificationManagerMock()
chatsFlowCoordinator = ChatsFlowCoordinator(userSession: UserSessionMock(.init(clientProxy: clientProxy)),
isNewLogin: false,
navigationSplitCoordinator: splitCoordinator,
appLockService: AppLockServiceMock(),
bugReportService: BugReportServiceMock(.init()),
@@ -43,9 +45,9 @@ class ChatsFlowCoordinatorTests: XCTestCase {
appHooks: AppHooks(),
analytics: ServiceLocator.shared.analytics,
notificationManager: notificationManager,
isNewLogin: false)
stateMachineFactory: stateMachineFactory)
let deferred = deferFulfillment(chatsFlowCoordinator.statePublisher) { $0 == .roomList(roomListSelectedRoomID: nil) }
let deferred = deferFulfillment(stateMachineFactory.chatsFlowStatePublisher) { $0 == .roomList(roomListSelectedRoomID: nil) }
chatsFlowCoordinator.start()
try await deferred.fulfill()
}
@@ -127,7 +129,7 @@ class ChatsFlowCoordinatorTests: XCTestCase {
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator)
XCTAssertNotNil(detailCoordinator)
let unexpectedFulfillment = deferFailure(chatsFlowCoordinator.statePublisher, timeout: 1) { _ in true }
let unexpectedFulfillment = deferFailure(stateMachineFactory.chatsFlowStatePublisher, timeout: 1) { _ in true }
chatsFlowCoordinator.handleAppRoute(.roomDetails(roomID: "1"), animated: true)
try await unexpectedFulfillment.fulfill()
@@ -150,7 +152,7 @@ class ChatsFlowCoordinatorTests: XCTestCase {
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertNotNil(detailCoordinator)
let unexpectedFulfillment = deferFailure(chatsFlowCoordinator.statePublisher, timeout: 1) { _ in true }
let unexpectedFulfillment = deferFailure(stateMachineFactory.chatsFlowStatePublisher, timeout: 1) { _ in true }
chatsFlowCoordinator.handleAppRoute(.roomDetails(roomID: "1"), animated: true)
try await unexpectedFulfillment.fulfill()
@@ -241,17 +243,6 @@ class ChatsFlowCoordinatorTests: XCTestCase {
"A new timeline should be created for the same room ID, so that the screen isn't stale while loading.")
}
func testShareMediaRouteWithoutRoom() async throws {
try await process(route: .settings, expectedState: .settingsScreen(roomListSelectedRoomID: nil))
XCTAssertTrue((splitCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is SettingsScreenCoordinator)
let sharePayload: ShareExtensionPayload = .mediaFiles(roomID: nil, mediaFiles: [.init(url: .picturesDirectory, suggestedName: nil)])
try await process(route: .share(sharePayload),
expectedState: .shareExtensionRoomList(sharePayload: sharePayload))
XCTAssertTrue((splitCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is RoomSelectionScreenCoordinator)
}
func testShareMediaRouteWithRoom() async throws {
try await process(route: .event(eventID: "1", roomID: "1", via: []), expectedState: .roomList(roomListSelectedRoomID: "1"))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
@@ -264,17 +255,6 @@ class ChatsFlowCoordinatorTests: XCTestCase {
XCTAssertTrue((splitCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is MediaUploadPreviewScreenCoordinator)
}
func testShareTextRouteWithoutRoom() async throws {
try await process(route: .settings, expectedState: .settingsScreen(roomListSelectedRoomID: nil))
XCTAssertTrue((splitCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is SettingsScreenCoordinator)
let sharePayload: ShareExtensionPayload = .text(roomID: nil, text: "Important Text")
try await process(route: .share(sharePayload),
expectedState: .shareExtensionRoomList(sharePayload: sharePayload))
XCTAssertTrue((splitCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is RoomSelectionScreenCoordinator)
}
func testShareTextRouteWithRoom() async throws {
try await process(route: .event(eventID: "1", roomID: "1", via: []), expectedState: .roomList(roomListSelectedRoomID: "1"))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
@@ -291,7 +271,7 @@ class ChatsFlowCoordinatorTests: XCTestCase {
private func process(route: AppRoute, expectedState: ChatsFlowCoordinatorStateMachine.State) async throws {
// Sometimes the state machine's state changes before the coordinators have updated the stack.
let delayedPublisher = chatsFlowCoordinator.statePublisher.delay(for: .milliseconds(100), scheduler: DispatchQueue.main)
let delayedPublisher = stateMachineFactory.chatsFlowStatePublisher.delay(for: .milliseconds(100), scheduler: DispatchQueue.main)
let deferred = deferFulfillment(delayedPublisher) { $0 == expectedState }
chatsFlowCoordinator.handleAppRoute(route, animated: true)
@@ -0,0 +1,152 @@
//
// 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 clientProxy: ClientProxyMock!
var timelineControllerFactory: TimelineControllerFactoryMock!
var userSessionFlowCoordinator: UserSessionFlowCoordinator!
var rootCoordinator: NavigationRootCoordinator!
var notificationManager: NotificationManagerMock!
let stateMachineFactory = PublishedStateMachineFactory()
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()
clientProxy = ClientProxyMock(.init(userID: "hi@bob", roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms)))))
timelineControllerFactory = TimelineControllerFactoryMock(.init())
rootCoordinator = NavigationRootCoordinator()
notificationManager = NotificationManagerMock()
userSessionFlowCoordinator = UserSessionFlowCoordinator(userSession: UserSessionMock(.init(clientProxy: clientProxy)),
isNewLogin: false,
navigationRootCoordinator: rootCoordinator,
appLockService: AppLockServiceMock(),
bugReportService: BugReportServiceMock(.init()),
elementCallService: ElementCallServiceMock(.init()),
timelineControllerFactory: timelineControllerFactory,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
appHooks: AppHooks(),
analytics: ServiceLocator.shared.analytics,
notificationManager: notificationManager,
stateMachineFactory: stateMachineFactory)
userSessionFlowCoordinator.start()
}
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 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: - 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()
}
}