// // Copyright 2022-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 Combine import XCTest @testable import ElementX @MainActor class HomeScreenViewModelTests: XCTestCase { var viewModel: HomeScreenViewModelProtocol! var clientProxy: ClientProxyMock! var context: HomeScreenViewModelType.Context! { viewModel.context } var cancellables = Set() var roomSummaryProvider: RoomSummaryProviderMock! override func setUpWithError() throws { cancellables.removeAll() } override func tearDown() { AppSettings.resetAllSettings() } func testSelectRoom() async throws { setupViewModel() let mockRoomId = "mock_room_id" var correctResult = false var selectedRoomId = "" viewModel.actions .sink { action in switch action { case .presentRoom(let roomId): correctResult = true selectedRoomId = roomId default: break } } .store(in: &cancellables) context.send(viewAction: .selectRoom(roomIdentifier: mockRoomId)) await Task.yield() XCTAssert(correctResult) XCTAssertEqual(mockRoomId, selectedRoomId) } func testTapUserAvatar() async throws { setupViewModel() var correctResult = false viewModel.actions .sink { action in switch action { case .presentSettingsScreen: correctResult = true default: break } } .store(in: &cancellables) context.send(viewAction: .showSettings) await Task.yield() XCTAssert(correctResult) } func testLeaveRoomAlert() async throws { setupViewModel() let mockRoomId = "1" clientProxy.roomForIdentifierClosure = { _ in .joined(JoinedRoomProxyMock(.init(id: mockRoomId, name: "Some room"))) } let deferred = deferFulfillment(context.$viewState) { value in value.bindings.leaveRoomAlertItem != nil } context.send(viewAction: .leaveRoom(roomIdentifier: mockRoomId)) try await deferred.fulfill() XCTAssertEqual(context.leaveRoomAlertItem?.roomID, mockRoomId) } func testLeaveRoomError() async throws { setupViewModel() let mockRoomId = "1" let room = JoinedRoomProxyMock(.init(id: mockRoomId, name: "Some room")) room.leaveRoomClosure = { .failure(.sdkError(ClientProxyMockError.generic)) } clientProxy.roomForIdentifierClosure = { _ in .joined(room) } let deferred = deferFulfillment(context.$viewState) { value in value.bindings.alertInfo != nil } context.send(viewAction: .confirmLeaveRoom(roomIdentifier: mockRoomId)) try await deferred.fulfill() XCTAssertNotNil(context.alertInfo) } func testLeaveRoomSuccess() async throws { setupViewModel() let mockRoomId = "1" var correctResult = false let expectation = expectation(description: #function) viewModel.actions .sink { action in switch action { case .roomLeft(let roomIdentifier): correctResult = roomIdentifier == mockRoomId default: break } expectation.fulfill() } .store(in: &cancellables) let room = JoinedRoomProxyMock(.init(id: mockRoomId, name: "Some room")) room.leaveRoomClosure = { .success(()) } clientProxy.roomForIdentifierClosure = { _ in .joined(room) } context.send(viewAction: .confirmLeaveRoom(roomIdentifier: mockRoomId)) await fulfillment(of: [expectation]) XCTAssertNil(context.alertInfo) XCTAssertTrue(correctResult) } func testShowRoomDetails() async throws { setupViewModel() let mockRoomId = "1" var correctResult = false viewModel.actions .sink { action in switch action { case .presentRoomDetails(let roomIdentifier): correctResult = roomIdentifier == mockRoomId default: break } } .store(in: &cancellables) context.send(viewAction: .showRoomDetails(roomIdentifier: mockRoomId)) await Task.yield() XCTAssertNil(context.alertInfo) XCTAssertTrue(correctResult) } func testFilters() async throws { setupViewModel() context.filtersState.activateFilter(.people) try await Task.sleep(for: .milliseconds(100)) XCTAssertEqual(roomSummaryProvider.roomListPublisher.value.count, 2) XCTAssertEqual(roomSummaryProvider.roomListPublisher.value.first?.name, "Foundation and Earth") } func testSearch() async throws { setupViewModel() context.isSearchFieldFocused = true context.searchQuery = "lude to Found" try await Task.sleep(for: .milliseconds(100)) XCTAssertEqual(roomSummaryProvider.roomListPublisher.value.first?.name, "Prelude to Foundation") XCTAssertEqual(roomSummaryProvider.roomListPublisher.value.count, 1) } func testFiltersEmptyState() async throws { setupViewModel() context.filtersState.activateFilter(.people) context.filtersState.activateFilter(.favourites) try await Task.sleep(for: .milliseconds(100)) XCTAssertTrue(context.viewState.shouldShowEmptyFilterState) context.isSearchFieldFocused = true XCTAssertFalse(context.viewState.shouldShowEmptyFilterState) } func testSetUpRecoveryBannerState() async throws { // Given a view model without a visible security banner. let securityStateStateSubject = CurrentValueSubject(.init(verificationState: .verified, recoveryState: .unknown)) setupViewModel(securityStatePublisher: securityStateStateSubject.asCurrentValuePublisher()) XCTAssertEqual(context.viewState.securityBannerMode, .none) // When the recovery state comes through as disabled. var deferred = deferFulfillment(context.$viewState) { $0.requiresExtraAccountSetup == true } securityStateStateSubject.send(.init(verificationState: .verified, recoveryState: .disabled)) try await deferred.fulfill() // Then the banner should be shown to set up recovery. XCTAssertEqual(context.viewState.securityBannerMode, .show(.setUpRecovery)) // When the recovery is enabled. deferred = deferFulfillment(context.$viewState) { $0.requiresExtraAccountSetup == false } securityStateStateSubject.send(.init(verificationState: .verified, recoveryState: .enabled)) try await deferred.fulfill() // Then the banner should no longer be shown. XCTAssertEqual(context.viewState.securityBannerMode, .none) } func testDismissSetUpRecoveryBannerState() async throws { // Given a view model with the setup recovery banner shown. let securityStateStateSubject = CurrentValueSubject(.init(verificationState: .verified, recoveryState: .unknown)) setupViewModel(securityStatePublisher: securityStateStateSubject.asCurrentValuePublisher()) var deferred = deferFulfillment(context.$viewState) { $0.securityBannerMode == .show(.setUpRecovery) } securityStateStateSubject.send(.init(verificationState: .verified, recoveryState: .disabled)) try await deferred.fulfill() // When the banner is dismissed. deferred = deferFulfillment(context.$viewState) { $0.securityBannerMode == .dismissed } context.send(viewAction: .skipRecoveryKeyConfirmation) // Then the banner should no longer be shown. try await deferred.fulfill() // And when the recovery state comes through a second time the banner should still not be shown. let failure = deferFailure(context.$viewState, timeout: 1) { $0.securityBannerMode != .dismissed } securityStateStateSubject.send(.init(verificationState: .verified, recoveryState: .disabled)) try await failure.fulfill() } func testOutOfSyncRecoveryBannerState() async throws { // Given a view model without a visible security banner. let securityStateStateSubject = CurrentValueSubject(.init(verificationState: .verified, recoveryState: .unknown)) setupViewModel(securityStatePublisher: securityStateStateSubject.asCurrentValuePublisher()) XCTAssertEqual(context.viewState.securityBannerMode, .none) // When the recovery state comes through as incomplete. var deferred = deferFulfillment(context.$viewState) { $0.requiresExtraAccountSetup == true } securityStateStateSubject.send(.init(verificationState: .verified, recoveryState: .incomplete)) try await deferred.fulfill() // Then the banner should be shown for out of sync recovery. XCTAssertEqual(context.viewState.securityBannerMode, .show(.recoveryOutOfSync)) // When the recovery is enabled. deferred = deferFulfillment(context.$viewState) { $0.requiresExtraAccountSetup == false } securityStateStateSubject.send(.init(verificationState: .verified, recoveryState: .enabled)) try await deferred.fulfill() // Then the banner should no longer be shown. XCTAssertEqual(context.viewState.securityBannerMode, .none) } // MARK: - Helpers private func setupViewModel(securityStatePublisher: CurrentValuePublisher? = nil) { roomSummaryProvider = RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))) clientProxy = ClientProxyMock(.init(userID: "@mock:client.com", roomSummaryProvider: roomSummaryProvider)) let userSession = UserSessionMock(.init(clientProxy: clientProxy)) if let securityStatePublisher { userSession.sessionSecurityStatePublisher = securityStatePublisher } viewModel = HomeScreenViewModel(userSession: userSession, analyticsService: ServiceLocator.shared.analytics, appSettings: ServiceLocator.shared.settings, selectedRoomPublisher: CurrentValueSubject(nil).asCurrentValuePublisher(), userIndicatorController: ServiceLocator.shared.userIndicatorController) } }