Files
letro-ios/UnitTests/Sources/ComposerToolbarViewModelTests.swift
Copilot 4834f453ef Finish migration of UnitTests target from XCTestCase to Swift Testing (#5129)
* Initial plan

* Migrate 3 test files from XCTest to Swift Testing

- MediaUploadPreviewScreenViewModelTests: @MainActor @Suite struct with init(),
  BundleFinder class for Bundle(for:), mutating test/setup functions,
  [self] capture replacing [weak self] in closures
- NotificationManagerTests: @MainActor @Suite final class with init()/deinit,
  expectation/fulfillment(of:) replaced with confirmation(...), test_ prefix stripped
- NotificationSettingsScreenViewModelTests: @MainActor @Suite struct with
  init() throws, non-optional stored properties, test prefix stripped

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Migrate 3 XCTest files to Swift Testing

- NotificationSettingsEditScreenViewModelTests: @MainActor @Suite struct with init() throws, mutating test methods
- TimelineViewModelTests: @MainActor @Suite final class with init() async throws + deinit
- AttributedStringBuilderTests: @Suite struct with init() async throws

All XCT assertions replaced with #expect/#require/Issue.record

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Migrate 4 test files from XCTest to Swift Testing

- TimelineMediaPreviewViewModelTests: @Suite struct, mutating @Test funcs,
  testLoadingItem renamed to loadingItem (called internally by other tests)
- ServerConfirmationScreenViewModelTests: @Suite final class with init()/deinit
- CompletionSuggestionServiceTests: @Suite struct with init()
- RoomFlowCoordinatorTests: @Suite final class with deinit

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Migrate 4 test files from XCTest to Swift Testing

- VoiceMessageRecorderTests: @Suite struct with init() async throws,
  added BundleFinder class for Bundle lookup, migrated all assertions
- SpaceScreenViewModelTests: @Suite struct, private mutating setupViewModel,
  all test funcs mutating, XCTestExpectation → confirmation
- RoomNotificationSettingsScreenViewModelTests: @Suite struct with
  init() throws, cancellable tests marked mutating
- JoinRoomScreenViewModelTests: @Suite final class with init()/deinit,
  XCTestExpectation → confirmation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Migrate 6 test files from XCTestCase to Swift Testing

Co-authored-by: pixlwave <6060466+pixlwave@users.noreply.github.com>

* Fix trailing blank line in RoomPollsHistoryScreenViewModelTests

Co-authored-by: pixlwave <6060466+pixlwave@users.noreply.github.com>

* Migrate 3 test files from XCTest to Swift Testing

- MediaUploadingPreprocessorTests: @Suite final class with init()/deinit,
  removed executionTimeAllowance, XCTAssertEqual(accuracy:) → abs(Double)
- SecurityAndPrivacyScreenViewModelTests: @MainActor @Suite final class,
  5 expectation+fulfillment → await confirmation(...)
- CreateRoomViewModelTests: @MainActor @Suite final class,
  4 expectation+fulfillment → await confirmation(...)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Migrate RoomScreenViewModelTests and RoomDetailsScreenViewModelTests to Swift Testing

- Replace XCTest with Testing framework
- RoomScreenViewModelTests: final class with init() async throws + deinit
- RoomDetailsScreenViewModelTests: struct with init() and mutating funcs
- Convert XCT assertions to #expect / Issue.record
- Convert XCTestExpectation patterns to confirmation { confirm in }
- Strip 'test' prefix from all test function names

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Migrate ComposerToolbarViewModelTests from XCTest to Swift Testing

- Replace import XCTest with import Testing
- Convert XCTestCase class to @MainActor @Suite final class
- Replace setUp()/tearDown() with init()/deinit
- Strip 'test' prefix from all 41 test method names and add @Test
- Replace XCTAssert* with #expect()/#require()
- Replace try XCTUnwrap() with try #require()
- Convert expectation+wait patterns to deferFulfillment with PassthroughSubject
- Convert isInverted expectation to boolean flag checked after await
- Use deferFulfillment on $viewState for state-transition tests

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address comments with Copilot.

* Fix the failing tests.

* Fixed flaky tests (#5137)

resolved flaky tests

* Tweaks and fixes.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: pixlwave <6060466+pixlwave@users.noreply.github.com>
Co-authored-by: Doug <douglase@element.io>
Co-authored-by: Mauro <34335419+Velin92@users.noreply.github.com>
2026-02-24 12:20:01 +00:00

827 lines
39 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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
@Suite
@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
}
}