// // 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 context: HomeScreenViewModelType.Context! { viewModel.context } var clientProxy: ClientProxyMock! var roomSummaryProvider: RoomSummaryProviderMock! var appSettings: AppSettings! var cancellables = Set() override func setUp() { cancellables.removeAll() AppSettings.resetAllSettings() appSettings = AppSettings() ServiceLocator.shared.register(appSettings: appSettings) } 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) } func testInviteUnreadBadge() async throws { setupViewModel(withInvites: true) var invites = context.viewState.rooms.invites XCTAssertEqual(invites.count, 2) for invite in invites { XCTAssertTrue(invite.badges.isDotShown) } let deferred = deferFulfillment(context.$viewState) { state in state.rooms.contains { room in room.roomID == invites[0].roomID && room.badges.isDotShown == false } } appSettings.seenInvites = Set(invites.compactMap(\.roomID)) try await deferred.fulfill() invites = context.viewState.rooms.invites for invite in invites { XCTAssertFalse(invite.badges.isDotShown) } } func testAcceptInvite() async throws { setupViewModel(withInvites: true) let invitedRoomIDs = context.viewState.rooms.invites.compactMap(\.roomID) appSettings.seenInvites = Set(invitedRoomIDs) XCTAssertEqual(invitedRoomIDs.count, 2) let deferred = deferFulfillment(viewModel.actions) { $0 == .presentRoom(roomIdentifier: invitedRoomIDs[0]) } context.send(viewAction: .acceptInvite(roomIdentifier: invitedRoomIDs[0])) try await deferred.fulfill() XCTAssertEqual(appSettings.seenInvites, [invitedRoomIDs[1]]) } func testDeclineInvite() async throws { setupViewModel(withInvites: true) let invitedRoomIDs = context.viewState.rooms.invites.compactMap(\.roomID) appSettings.seenInvites = Set(invitedRoomIDs) XCTAssertEqual(invitedRoomIDs.count, 2) let deferred = deferFulfillment(context.$viewState) { $0.bindings.alertInfo != nil } context.send(viewAction: .declineInvite(roomIdentifier: invitedRoomIDs[0])) try await deferred.fulfill() let rejectExpectation = expectation(description: "Expected rejectInvitation to be called.") clientProxy.roomForIdentifierClosure = { _ in let roomProxy = InvitedRoomProxyMock(.init()) roomProxy.rejectInvitationClosure = { rejectExpectation.fulfill() return .success(()) } return .invited(roomProxy) } context.viewState.bindings.alertInfo?.primaryButton.action?() await fulfillment(of: [rejectExpectation], timeout: 1.0) XCTAssertEqual(appSettings.seenInvites, [invitedRoomIDs[1]]) } // MARK: - Helpers private func setupViewModel(securityStatePublisher: CurrentValuePublisher? = nil, withInvites: Bool = false) { var rooms: [RoomSummary] = .mockRooms if withInvites { rooms += .mockInvites } roomSummaryProvider = RoomSummaryProviderMock(.init(state: .loaded(rooms))) clientProxy = ClientProxyMock(.init(userID: "@mock:client.com", roomSummaryProvider: roomSummaryProvider)) if withInvites { clientProxy.joinRoomViaReturnValue = .success(()) clientProxy.joinRoomAliasReturnValue = .success(()) clientProxy.roomForIdentifierClosure = { _ in .invited(InvitedRoomProxyMock(.init())) } } let userSession = UserSessionMock(.init(clientProxy: clientProxy)) if let securityStatePublisher { userSession.sessionSecurityStatePublisher = securityStatePublisher } viewModel = HomeScreenViewModel(userSession: userSession, analyticsService: ServiceLocator.shared.analytics, appSettings: appSettings, selectedRoomPublisher: CurrentValueSubject(nil).asCurrentValuePublisher(), userIndicatorController: ServiceLocator.shared.userIndicatorController) } } private extension [HomeScreenRoom] { var invites: [HomeScreenRoom] { filter { room in if case .invite = room.type { true } else { false } } } }