// // 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 Combine @testable import ElementX import Foundation import MatrixRustSDK import Testing import WysiwygComposer @MainActor final class ComposerToolbarViewModelTests { private var appSettings: AppSettings! private var wysiwygViewModel: WysiwygComposerViewModel! private var viewModel: ComposerToolbarViewModel! private var completionSuggestionServiceMock: CompletionSuggestionServiceMock! private var draftServiceMock: ComposerDraftServiceMock! init() { AppSettings.resetAllSettings() appSettings = AppSettings() ServiceLocator.shared.register(appSettings: appSettings) setUpViewModel() } deinit { AppSettings.resetAllSettings() } @Test func composerFocus() { viewModel.process(timelineAction: .setMode(mode: .edit(originalEventOrTransactionID: .eventID("mock"), type: .default))) #expect(viewModel.state.bindings.composerFocused) viewModel.process(timelineAction: .removeFocus) #expect(!viewModel.state.bindings.composerFocused) } @Test func composerMode() { let mode: ComposerMode = .edit(originalEventOrTransactionID: .eventID("mock"), type: .default) viewModel.process(timelineAction: .setMode(mode: mode)) #expect(viewModel.state.composerMode == mode) viewModel.process(timelineAction: .clear) #expect(viewModel.state.composerMode == .default) } @Test func composerModeIsPublished() async throws { let mode: ComposerMode = .edit(originalEventOrTransactionID: .eventID("mock"), type: .default) let deferred = deferFulfillment(viewModel.context.$viewState.map(\.composerMode).removeDuplicates().dropFirst()) { $0 == mode } viewModel.process(timelineAction: .setMode(mode: mode)) try await deferred.fulfill() } @Test func handleKeyCommand() { #expect(viewModel.context.viewState.keyCommands.count == 1) } @Test func composerFocusAfterEnablingRTE() { viewModel.process(viewAction: .enableTextFormatting) #expect(viewModel.state.bindings.composerFocused) } @Test func rteEnabledAfterSendingMessage() { viewModel.process(viewAction: .enableTextFormatting) #expect(viewModel.state.bindings.composerFocused) viewModel.state.composerEmpty = false viewModel.process(viewAction: .sendMessage) #expect(viewModel.state.bindings.composerFormattingEnabled) } @Test func alertIsShownAfterLinkAction() { #expect(viewModel.state.bindings.alertInfo == nil) viewModel.process(viewAction: .enableTextFormatting) viewModel.process(viewAction: .composerAction(action: .link)) #expect(viewModel.state.bindings.alertInfo != nil) } @Test func suggestions() { let suggestions: [SuggestionItem] = [.init(suggestionType: .user(.init(id: "@user_mention_1:matrix.org", displayName: "User 1", avatarURL: nil)), range: .init(), rawSuggestionText: ""), .init(suggestionType: .user(.init(id: "@user_mention_2:matrix.org", displayName: "User 2", avatarURL: nil)), range: .init(), rawSuggestionText: "")] let mockCompletionSuggestionService = CompletionSuggestionServiceMock(configuration: .init(suggestions: suggestions)) viewModel = ComposerToolbarViewModel(roomProxy: JoinedRoomProxyMock(.init()), wysiwygViewModel: wysiwygViewModel, completionSuggestionService: mockCompletionSuggestionService, mediaProvider: MediaProviderMock(configuration: .init()), mentionDisplayHelper: ComposerMentionDisplayHelper.mock, appSettings: ServiceLocator.shared.settings, analyticsService: ServiceLocator.shared.analytics, composerDraftService: draftServiceMock) #expect(viewModel.state.suggestions == suggestions) } @Test func suggestionTrigger() async throws { let deferred = deferFulfillment(wysiwygViewModel.$attributedContent) { $0.plainText == "#room-alias-test" } wysiwygViewModel.setMarkdownContent("@user-test") wysiwygViewModel.setMarkdownContent("#room-alias-test") try await deferred.fulfill() // The first one is nil because when initialised the view model is empty #expect(completionSuggestionServiceMock.setSuggestionTriggerReceivedInvocations == [nil, .init(type: .user, text: "user-test", range: .init(location: 0, length: 10)), .init(type: .room, text: "room-alias-test", range: .init(location: 0, length: 16))]) } @Test func selectedUserSuggestion() { let suggestion = SuggestionItem(suggestionType: .user(.init(id: "@test:matrix.org", displayName: "Test", avatarURL: nil)), range: .init(), rawSuggestionText: "") viewModel.context.send(viewAction: .selectedSuggestion(suggestion)) // The display name can be used for HTML injection in the rich text editor and it's useless anyway as the clients don't use it when resolving display names #expect(wysiwygViewModel.content.html == "@test:matrix.org ") } @Test func selectedRoomSuggestion() { let suggestion = SuggestionItem(suggestionType: .room(.init(id: "!room:matrix.org", canonicalAlias: "#room-alias:matrix.org", name: "Room", avatar: .room(id: "!room:matrix.org", name: "Room", avatarURL: nil))), range: .init(), rawSuggestionText: "") viewModel.context.send(viewAction: .selectedSuggestion(suggestion)) // The display name can be used for HTML injection in the rich text editor and it's useless anyway as the clients don't use it when resolving display names #expect(wysiwygViewModel.content.html == "#room-alias:matrix.org ") } @Test func allUsersSuggestion() throws { let suggestion = SuggestionItem(suggestionType: .allUsers(.room(id: "", name: nil, avatarURL: nil)), range: .init(), rawSuggestionText: "") viewModel.context.send(viewAction: .selectedSuggestion(suggestion)) var string = "@room" try string.unicodeScalars.append(#require(UnicodeScalar(String.nbsp))) #expect(wysiwygViewModel.content.html == string) } @Test func userMentionPillInRTE() async { viewModel.context.send(viewAction: .composerAppeared) await Task.yield() let userID = "@test:matrix.org" let suggestion = SuggestionItem(suggestionType: .user(.init(id: userID, displayName: "Test", avatarURL: nil)), range: .init(), rawSuggestionText: "") viewModel.context.send(viewAction: .selectedSuggestion(suggestion)) let attachment = wysiwygViewModel.textView.attributedText.attribute(.attachment, at: 0, effectiveRange: nil) as? PillTextAttachment #expect(attachment?.pillData?.type == .user(userID: userID)) } @Test func roomMentionPillInRTE() async { viewModel.context.send(viewAction: .composerAppeared) await Task.yield() let roomAlias = "#test:matrix.org" let suggestion = SuggestionItem(suggestionType: .room(.init(id: "room-id", canonicalAlias: roomAlias, name: "Room", avatar: .room(id: "room-id", name: "Room", avatarURL: nil))), range: .init(), rawSuggestionText: "") viewModel.context.send(viewAction: .selectedSuggestion(suggestion)) let attachment = wysiwygViewModel.textView.attributedText.attribute(.attachment, at: 0, effectiveRange: nil) as? PillTextAttachment #expect(attachment?.pillData?.type == .roomAlias(roomAlias)) } @Test func allUsersMentionPillInRTE() async { viewModel.context.send(viewAction: .composerAppeared) await Task.yield() let suggestion = SuggestionItem(suggestionType: .allUsers(.room(id: "", name: nil, avatarURL: nil)), range: .init(), rawSuggestionText: "") viewModel.context.send(viewAction: .selectedSuggestion(suggestion)) let attachment = wysiwygViewModel.textView.attributedText.attribute(.attachment, at: 0, effectiveRange: nil) as? PillTextAttachment #expect(attachment?.pillData?.type == .allUsers) } @Test func intentionalMentions() async throws { wysiwygViewModel.setHtmlContent("""

Hello @room \ and especially hello to Test

""") let deferred = deferFulfillment(viewModel.actions) { action in switch action { case let .sendMessage(_, _, _, intentionalMentions): return intentionalMentions == IntentionalMentions(userIDs: ["@test:matrix.org"], atRoom: true) default: return false } } viewModel.context.send(viewAction: .sendMessage) try await deferred.fulfill() } // MARK: - Draft @Test func saveDraftPlainText() async throws { viewModel.context.composerFormattingEnabled = false viewModel.context.plainComposerText = .init(string: "Hello world!") var capturedDraft: ComposerDraftProxy? await waitForConfirmation("Save draft") { confirmation in draftServiceMock.saveDraftClosure = { draft in capturedDraft = draft confirmation() return .success(()) } viewModel.saveDraft() } let draft = try #require(capturedDraft) #expect(draft.plainText == "Hello world!") #expect(draft.htmlText == nil) #expect(draft.draftType == .newMessage) #expect(draftServiceMock.saveDraftCallsCount == 1) #expect(!draftServiceMock.clearDraftCalled) #expect(!draftServiceMock.loadDraftCalled) } @Test func saveDraftFormattedText() async throws { viewModel.context.composerFormattingEnabled = true wysiwygViewModel.setHtmlContent("Hello world!") var capturedDraft: ComposerDraftProxy? await waitForConfirmation("Save draft") { confirmation in draftServiceMock.saveDraftClosure = { draft in capturedDraft = draft confirmation() return .success(()) } viewModel.saveDraft() } let draft = try #require(capturedDraft) #expect(draft.plainText == "__Hello__ world!") #expect(draft.htmlText == "Hello world!") #expect(draft.draftType == .newMessage) #expect(draftServiceMock.saveDraftCallsCount == 1) #expect(!draftServiceMock.clearDraftCalled) #expect(!draftServiceMock.loadDraftCalled) } @Test func saveDraftEdit() async throws { viewModel.context.composerFormattingEnabled = false viewModel.process(timelineAction: .setMode(mode: .edit(originalEventOrTransactionID: .eventID("testID"), type: .default))) viewModel.context.plainComposerText = .init(string: "Hello world!") var capturedDraft: ComposerDraftProxy? await waitForConfirmation("Save draft") { confirmation in draftServiceMock.saveDraftClosure = { draft in capturedDraft = draft confirmation() return .success(()) } viewModel.saveDraft() } let draft = try #require(capturedDraft) #expect(draft.plainText == "Hello world!") #expect(draft.htmlText == nil) #expect(draft.draftType == .edit(eventID: "testID")) #expect(draftServiceMock.saveDraftCallsCount == 1) #expect(!draftServiceMock.clearDraftCalled) #expect(!draftServiceMock.loadDraftCalled) } @Test func saveDraftReply() async throws { viewModel.context.composerFormattingEnabled = false viewModel.process(timelineAction: .setMode(mode: .reply(eventID: "testID", replyDetails: .loaded(sender: .init(id: ""), eventID: "testID", eventContent: .message(.text(.init(body: "reply text")))), isThread: false))) viewModel.context.plainComposerText = .init(string: "Hello world!") var capturedDraft: ComposerDraftProxy? await waitForConfirmation("Save draft") { confirmation in draftServiceMock.saveDraftClosure = { draft in capturedDraft = draft confirmation() return .success(()) } viewModel.saveDraft() } let draft = try #require(capturedDraft) #expect(draft.plainText == "Hello world!") #expect(draft.htmlText == nil) #expect(draft.draftType == .reply(eventID: "testID")) #expect(draftServiceMock.saveDraftCallsCount == 1) #expect(!draftServiceMock.clearDraftCalled) #expect(!draftServiceMock.loadDraftCalled) } @Test func saveDraftWhenEmptyReply() async throws { viewModel.context.composerFormattingEnabled = false viewModel.process(timelineAction: .setMode(mode: .reply(eventID: "testID", replyDetails: .loaded(sender: .init(id: ""), eventID: "testID", eventContent: .message(.text(.init(body: "reply text")))), isThread: false))) var capturedDraft: ComposerDraftProxy? await waitForConfirmation("Save draft") { confirmation in draftServiceMock.saveDraftClosure = { draft in capturedDraft = draft confirmation() return .success(()) } viewModel.saveDraft() } let draft = try #require(capturedDraft) #expect(draft.plainText == "") #expect(draft.htmlText == nil) #expect(draft.draftType == .reply(eventID: "testID")) #expect(draftServiceMock.saveDraftCallsCount == 1) #expect(!draftServiceMock.clearDraftCalled) #expect(!draftServiceMock.loadDraftCalled) } @Test func clearDraftWhenEmptyNormalMessage() async { viewModel.context.composerFormattingEnabled = false await waitForConfirmation("Clear draft") { confirmation in draftServiceMock.clearDraftClosure = { confirmation() return .success(()) } viewModel.saveDraft() } #expect(!draftServiceMock.saveDraftCalled) #expect(draftServiceMock.clearDraftCallsCount == 1) #expect(!draftServiceMock.loadDraftCalled) } @Test func clearDraftForNonTextMode() async { viewModel.context.composerFormattingEnabled = false let waveformData: [Float] = Array(repeating: 1.0, count: 1000) viewModel.context.plainComposerText = .init(string: "Hello world!") viewModel.process(timelineAction: .setMode(mode: .previewVoiceMessage(state: AudioPlayerState(id: .recorderPreview, title: "", duration: 10.0), waveform: .data(waveformData), isUploading: false))) await waitForConfirmation("Clear draft") { confirmation in draftServiceMock.clearDraftClosure = { confirmation() return .success(()) } viewModel.saveDraft() } #expect(!draftServiceMock.saveDraftCalled) #expect(draftServiceMock.clearDraftCallsCount == 1) #expect(!draftServiceMock.loadDraftCalled) } @Test func nothingToRestore() async { viewModel.context.composerFormattingEnabled = false draftServiceMock.loadDraftClosure = { .success(nil) } await viewModel.loadDraft() #expect(!viewModel.context.composerFormattingEnabled) #expect(viewModel.state.composerEmpty) #expect(viewModel.state.composerMode == .default) } @Test func restoreNormalPlainTextMessage() async { viewModel.context.composerFormattingEnabled = false draftServiceMock.loadDraftClosure = { .success(.init(plainText: "Hello world!", htmlText: nil, draftType: .newMessage)) } await viewModel.loadDraft() #expect(!viewModel.context.composerFormattingEnabled) #expect(viewModel.state.composerMode == .default) #expect(viewModel.context.plainComposerText == NSAttributedString(string: "Hello world!")) } @Test func restoreNormalFormattedTextMessage() async throws { viewModel.context.composerFormattingEnabled = false try await confirmation { confirmation in draftServiceMock.loadDraftClosure = { defer { confirmation() } return .success(.init(plainText: "__Hello__ world!", htmlText: "Hello world!", draftType: .newMessage)) } let deferred = deferFulfillment(wysiwygViewModel.$isContentEmpty) { !$0 } await viewModel.loadDraft() try await deferred.fulfill() } #expect(viewModel.context.composerFormattingEnabled) #expect(viewModel.state.composerMode == .default) #expect(wysiwygViewModel.content.html == "Hello world!") #expect(wysiwygViewModel.content.markdown == "__Hello__ world!") } @Test func restoreEdit() async { viewModel.context.composerFormattingEnabled = false draftServiceMock.loadDraftClosure = { .success(.init(plainText: "Hello world!", htmlText: nil, draftType: .edit(eventID: "testID"))) } await viewModel.loadDraft() #expect(!viewModel.context.composerFormattingEnabled) #expect(viewModel.state.composerMode == .edit(originalEventOrTransactionID: .eventID("testID"), type: .default)) #expect(viewModel.context.plainComposerText == NSAttributedString(string: "Hello world!")) } @Test func restoreReply() async throws { let testEventID = "testID" let text = "Hello world!" let loadedReply = TimelineItemReplyDetails.loaded(sender: .init(id: "userID", displayName: "Username"), eventID: testEventID, eventContent: .message(.text(.init(body: "Reply text")))) viewModel.context.composerFormattingEnabled = false draftServiceMock.loadDraftClosure = { .success(.init(plainText: text, htmlText: nil, draftType: .reply(eventID: testEventID))) } let deferredReplyLoaded = deferFulfillment(viewModel.context.$viewState) { $0.composerMode == .reply(eventID: testEventID, replyDetails: loadedReply, isThread: true) } draftServiceMock.getReplyEventIDClosure = { eventID in #expect(eventID == testEventID) try? await Task.sleep(for: .seconds(1)) return .success(.init(details: loadedReply, isThreaded: true)) } await viewModel.loadDraft() #expect(!viewModel.context.composerFormattingEnabled) // Testing the loading state first #expect(viewModel.state.composerMode == .reply(eventID: testEventID, replyDetails: .loading(eventID: testEventID), isThread: false)) #expect(viewModel.context.plainComposerText == NSAttributedString(string: text)) try await deferredReplyLoaded.fulfill() #expect(viewModel.state.composerMode == .reply(eventID: testEventID, replyDetails: loadedReply, isThread: true)) } @Test func restoreReplyAndCancelReplyMode() async throws { let testEventID = "testID" let text = "Hello world!" let loadedReply = TimelineItemReplyDetails.loaded(sender: .init(id: "userID", displayName: "Username"), eventID: testEventID, eventContent: .message(.text(.init(body: "Reply text")))) viewModel.context.composerFormattingEnabled = false draftServiceMock.loadDraftClosure = { .success(.init(plainText: text, htmlText: nil, draftType: .reply(eventID: testEventID))) } let replyLoadedSubject = PassthroughSubject() let deferredReplyLoaded = deferFulfillment(replyLoadedSubject) { _ in true } draftServiceMock.getReplyEventIDClosure = { eventID in defer { replyLoadedSubject.send(()) } #expect(eventID == testEventID) try? await Task.sleep(for: .seconds(1)) return .success(.init(details: loadedReply, isThreaded: true)) } await viewModel.loadDraft() #expect(!viewModel.context.composerFormattingEnabled) // Testing the loading state first #expect(viewModel.state.composerMode == .reply(eventID: testEventID, replyDetails: .loading(eventID: testEventID), isThread: false)) #expect(viewModel.context.plainComposerText == NSAttributedString(string: text)) // Now we change the state to cancel the reply mode update viewModel.process(viewAction: .cancelReply) try await deferredReplyLoaded.fulfill() #expect(viewModel.state.composerMode == .default) } @Test func saveVolatileDraftWhenEditing() { viewModel.context.composerFormattingEnabled = false viewModel.context.plainComposerText = .init(string: "Hello world!") viewModel.process(timelineAction: .setMode(mode: .edit(originalEventOrTransactionID: .eventID(UUID().uuidString), type: .default))) let draft = draftServiceMock.saveVolatileDraftReceivedDraft #expect(draft != nil) #expect(draft?.plainText == "Hello world!") #expect(draft?.htmlText == nil) #expect(draft?.draftType == .newMessage) } @Test func restoreVolatileDraftWhenCancellingEdit() async { await waitForConfirmation("Volatile draft loaded") { confirmation in draftServiceMock.loadVolatileDraftClosure = { defer { confirmation() } return .init(plainText: "Hello world", htmlText: nil, draftType: .newMessage) } DispatchQueue.main.async { self.viewModel.process(viewAction: .cancelEdit) } } #expect(viewModel.context.plainComposerText == NSAttributedString(string: "Hello world")) } @Test func restoreVolatileDraftWhenClearing() async { await waitForConfirmation("Volatile draft loaded and cleared", expectedCount: 2) { confirmation in draftServiceMock.loadVolatileDraftClosure = { defer { confirmation() } return .init(plainText: "Hello world", htmlText: nil, draftType: .newMessage) } draftServiceMock.clearVolatileDraftClosure = { confirmation() } DispatchQueue.main.async { self.viewModel.process(timelineAction: .clear) } } #expect(viewModel.context.plainComposerText == NSAttributedString(string: "Hello world")) } @Test func restoreVolatileDraftDoubleClear() async { await waitForConfirmation("Volatile draft loaded and cleared", expectedCount: 2) { confirmation in draftServiceMock.loadVolatileDraftClosure = { defer { confirmation() } return .init(plainText: "Hello world", htmlText: nil, draftType: .newMessage) } draftServiceMock.clearVolatileDraftClosure = { confirmation() } DispatchQueue.main.async { self.viewModel.process(timelineAction: .clear) } } #expect(viewModel.context.plainComposerText == NSAttributedString(string: "Hello world")) } @Test func restoreUserMentionInPlainText() async throws { viewModel.context.composerFormattingEnabled = false let text = "Hello [TestName](https://matrix.to/#/@test:matrix.org)!" viewModel.process(timelineAction: .setText(plainText: text, htmlText: nil)) let deferred = deferFulfillment(viewModel.actions) { action in switch action { case let .sendMessage(plainText, _, _, intentionalMentions): // As of right now the markdown loses the display name when restored return plainText == "Hello [@test:matrix.org](https://matrix.to/#/@test:matrix.org)!" && intentionalMentions == IntentionalMentions(userIDs: ["@test:matrix.org"], atRoom: false) default: return false } } viewModel.process(viewAction: .sendMessage) try await deferred.fulfill() } @Test func restoreAllUsersMentionInPlainText() async throws { viewModel.context.composerFormattingEnabled = false let text = "Hello @room" viewModel.process(timelineAction: .setText(plainText: text, htmlText: nil)) let deferred = deferFulfillment(viewModel.actions) { action in switch action { case let .sendMessage(plainText, _, _, intentionalMentions): return plainText == "Hello @room" && intentionalMentions == IntentionalMentions(userIDs: [], atRoom: true) default: return false } } viewModel.process(viewAction: .sendMessage) try await deferred.fulfill() } @Test func restoreMixedMentionsInPlainText() async throws { viewModel.context.composerFormattingEnabled = false let text = "Hello [User1](https://matrix.to/#/@user1:matrix.org), [User2](https://matrix.to/#/@user2:matrix.org) and @room" viewModel.process(timelineAction: .setText(plainText: text, htmlText: nil)) let deferred = deferFulfillment(viewModel.actions) { action in switch action { case let .sendMessage(plainText, _, _, intentionalMentions): // As of right now the markdown loses the display name when restored return plainText == "Hello [@user1:matrix.org](https://matrix.to/#/@user1:matrix.org), [@user2:matrix.org](https://matrix.to/#/@user2:matrix.org) and @room" && intentionalMentions == IntentionalMentions(userIDs: ["@user1:matrix.org", "@user2:matrix.org"], atRoom: true) default: return false } } viewModel.process(viewAction: .sendMessage) try await deferred.fulfill() } @Test func restoreAmbiguousMention() async throws { viewModel.context.composerFormattingEnabled = false let text = "Hello [User1](https://matrix.to/#/@roomuser:matrix.org)" viewModel.process(timelineAction: .setText(plainText: text, htmlText: nil)) let deferred = deferFulfillment(viewModel.actions) { action in switch action { case let .sendMessage(plainText, _, _, intentionalMentions): // As of right now the markdown loses the display name when restored return plainText == "Hello [@roomuser:matrix.org](https://matrix.to/#/@roomuser:matrix.org)" && intentionalMentions == IntentionalMentions(userIDs: ["@roomuser:matrix.org"], atRoom: false) default: return false } } viewModel.process(viewAction: .sendMessage) try await deferred.fulfill() } @Test func restoreDoesntOverwriteInitialText() async { let sharedText = "Some shared text" var draftLoadCalled = false setUpViewModel(initialText: sharedText) { draftLoadCalled = true return .success(.init(plainText: "Hello world!", htmlText: nil, draftType: .newMessage)) } viewModel.context.composerFormattingEnabled = false await viewModel.loadDraft() #expect(!draftLoadCalled) #expect(!viewModel.context.composerFormattingEnabled) #expect(viewModel.state.composerMode == .default) #expect(viewModel.context.plainComposerText == NSAttributedString(string: sharedText)) } // MARK: - Identity Violation @Test func verificationViolationDisablesComposer() async throws { let mockCompletionSuggestionService = CompletionSuggestionServiceMock(configuration: .init()) let roomProxyMock = JoinedRoomProxyMock(.init(name: "Test")) let roomMemberProxyMock = RoomMemberProxyMock(with: .init(userID: "@alice:localhost", membership: .join)) roomProxyMock.getMemberUserIDClosure = { _ in .success(roomMemberProxyMock) } let mockSubject = CurrentValueSubject<[IdentityStatusChange], Never>([]) roomProxyMock.underlyingIdentityStatusChangesPublisher = mockSubject.asCurrentValuePublisher() viewModel = ComposerToolbarViewModel(roomProxy: roomProxyMock, wysiwygViewModel: wysiwygViewModel, completionSuggestionService: mockCompletionSuggestionService, mediaProvider: MediaProviderMock(configuration: .init()), mentionDisplayHelper: ComposerMentionDisplayHelper.mock, appSettings: ServiceLocator.shared.settings, analyticsService: ServiceLocator.shared.analytics, composerDraftService: draftServiceMock) var fulfillment = deferFulfillment(viewModel.context.$viewState, message: "Composer is disabled") { $0.canSend == false } mockSubject.send([IdentityStatusChange(userId: "@alice:localhost", changedTo: .verificationViolation)]) try await fulfillment.fulfill() fulfillment = deferFulfillment(viewModel.context.$viewState, message: "Composer is enabled") { $0.canSend == true } mockSubject.send([IdentityStatusChange(userId: "@alice:localhost", changedTo: .pinned)]) try await fulfillment.fulfill() } @Test func multipleViolation() async throws { let mockCompletionSuggestionService = CompletionSuggestionServiceMock(configuration: .init()) let roomProxyMock = JoinedRoomProxyMock(.init(name: "Test")) let aliceRoomMemberProxyMock = RoomMemberProxyMock(with: .init(userID: "@alice:localhost", membership: .join)) let bobRoomMemberProxyMock = RoomMemberProxyMock(with: .init(userID: "@bob:localhost", membership: .join)) roomProxyMock.getMemberUserIDClosure = { userId in if userId == "@alice:localhost" { return .success(aliceRoomMemberProxyMock) } else if userId == "@bob:localhost" { return .success(bobRoomMemberProxyMock) } else { return .failure(.sdkError(ClientProxyMockError.generic)) } } // There are 2 violations, ensure that resolving the first one is not enough let mockSubject = CurrentValueSubject<[IdentityStatusChange], Never>([ IdentityStatusChange(userId: "@alice:localhost", changedTo: .verificationViolation), IdentityStatusChange(userId: "@bob:localhost", changedTo: .verificationViolation) ]) roomProxyMock.underlyingIdentityStatusChangesPublisher = mockSubject.asCurrentValuePublisher() viewModel = ComposerToolbarViewModel(roomProxy: roomProxyMock, wysiwygViewModel: wysiwygViewModel, completionSuggestionService: mockCompletionSuggestionService, mediaProvider: MediaProviderMock(configuration: .init()), mentionDisplayHelper: ComposerMentionDisplayHelper.mock, appSettings: ServiceLocator.shared.settings, analyticsService: ServiceLocator.shared.analytics, composerDraftService: draftServiceMock) var fulfillment = deferFulfillment(viewModel.context.$viewState, message: "Composer is disabled") { $0.canSend == false } mockSubject.send([IdentityStatusChange(userId: "@alice:localhost", changedTo: .verificationViolation)]) try await fulfillment.fulfill() fulfillment = deferFulfillment(viewModel.context.$viewState, message: "Composer is still disabled") { $0.canSend == false } mockSubject.send([IdentityStatusChange(userId: "@alice:localhost", changedTo: .pinned)]) try await fulfillment.fulfill() fulfillment = deferFulfillment(viewModel.context.$viewState, message: "Composer is now enabled") { $0.canSend == true } mockSubject.send([IdentityStatusChange(userId: "@bob:localhost", changedTo: .pinned)]) try await fulfillment.fulfill() } @Test func pinViolationDoesNotDisableComposer() async throws { let mockCompletionSuggestionService = CompletionSuggestionServiceMock(configuration: .init()) let roomProxyMock = JoinedRoomProxyMock(.init(name: "Test")) let roomMemberProxyMock = RoomMemberProxyMock(with: .init(userID: "@alice:localhost", membership: .join)) roomProxyMock.getMemberUserIDClosure = { _ in .success(roomMemberProxyMock) } roomProxyMock.underlyingIdentityStatusChangesPublisher = CurrentValueSubject([IdentityStatusChange(userId: "@alice:localhost", changedTo: .pinViolation)]).asCurrentValuePublisher() viewModel = ComposerToolbarViewModel(roomProxy: roomProxyMock, wysiwygViewModel: wysiwygViewModel, completionSuggestionService: mockCompletionSuggestionService, mediaProvider: MediaProviderMock(configuration: .init()), mentionDisplayHelper: ComposerMentionDisplayHelper.mock, appSettings: ServiceLocator.shared.settings, analyticsService: ServiceLocator.shared.analytics, composerDraftService: draftServiceMock) let deferred = deferFulfillment(viewModel.context.$viewState, message: "Composer should be enabled") { $0.canSend == true } try await deferred.fulfill() } // MARK: - Helpers private func setUpViewModel(initialText: String? = nil, loadDraftClosure: (() async -> Result)? = nil) { wysiwygViewModel = WysiwygComposerViewModel() completionSuggestionServiceMock = CompletionSuggestionServiceMock(configuration: .init()) draftServiceMock = ComposerDraftServiceMock(.init()) if let loadDraftClosure { draftServiceMock.loadDraftClosure = loadDraftClosure } viewModel = ComposerToolbarViewModel(initialText: initialText, roomProxy: JoinedRoomProxyMock(.init()), wysiwygViewModel: wysiwygViewModel, completionSuggestionService: completionSuggestionServiceMock, mediaProvider: MediaProviderMock(configuration: .init()), mentionDisplayHelper: ComposerMentionDisplayHelper.mock, appSettings: ServiceLocator.shared.settings, analyticsService: ServiceLocator.shared.analytics, composerDraftService: draftServiceMock) viewModel.context.composerFormattingEnabled = true } }