826 lines
39 KiB
Swift
826 lines
39 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 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 == "<a href=\"https://matrix.to/#/@test:matrix.org\">@test:matrix.org</a> ")
|
||
}
|
||
|
||
@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 == "<a href=\"https://matrix.to/#/%23room-alias:matrix.org\">#room-alias:matrix.org</a> ")
|
||
}
|
||
|
||
@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("""
|
||
<p>Hello @room \
|
||
and especially hello to <a href=\"https://matrix.to/#/@test:matrix.org\">Test</a></p>
|
||
""")
|
||
|
||
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("<strong>Hello</strong> 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 == "<strong>Hello</strong> 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: "<strong>Hello</strong> 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 == "<strong>Hello</strong> 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<Void, Never>()
|
||
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<ComposerDraftProxy?, ComposerDraftServiceError>)? = 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
|
||
}
|
||
}
|