2023-08-08 14:25:23 +02:00
//
2024-09-06 16:34:30 +03:00
// C o p y r i g h t 2 0 2 3 , 2 0 2 4 N e w V e c t o r L t d .
2023-08-08 14:25:23 +02:00
//
2025-01-06 11:27:37 +01:00
// S P D X - L i c e n s e - I d e n t i f i e r : A G P L - 3 . 0 - o n l y O R L i c e n s e R e f - E l e m e n t - C o m m e r c i a l
// P l e a s e s e e L I C E N S E f i l e s i n t h e r e p o s i t o r y r o o t f o r f u l l d e t a i l s .
2023-08-08 14:25:23 +02:00
//
2023-10-06 15:47:31 +02:00
import Combine
2023-08-08 14:25:23 +02:00
@ testable import ElementX
2025-01-20 17:29:34 +01:00
import MatrixRustSDK
2023-08-08 14:25:23 +02:00
import XCTest
2023-10-06 15:47:31 +02:00
import WysiwygComposer
2023-08-08 14:25:23 +02:00
@ MainActor
class ComposerToolbarViewModelTests : XCTestCase {
2023-09-05 17:39:54 +02:00
private var appSettings : AppSettings !
private var wysiwygViewModel : WysiwygComposerViewModel !
private var viewModel : ComposerToolbarViewModel !
2023-10-06 15:47:31 +02:00
private var completionSuggestionServiceMock : CompletionSuggestionServiceMock !
2024-06-13 14:19:38 +02:00
private var draftServiceMock : ComposerDraftServiceMock !
2023-09-05 17:39:54 +02:00
override func setUp ( ) {
2024-03-21 14:01:23 +02:00
AppSettings . resetAllSettings ( )
2023-09-05 17:39:54 +02:00
appSettings = AppSettings ( )
ServiceLocator . shared . register ( appSettings : appSettings )
2024-11-21 14:48:38 +00:00
setUpViewModel ( )
2023-09-05 17:39:54 +02:00
}
2023-10-16 17:28:06 +02:00
override func tearDown ( ) {
2024-03-21 14:01:23 +02:00
AppSettings . resetAllSettings ( )
2023-10-16 17:28:06 +02:00
}
2023-09-05 17:39:54 +02:00
2023-08-08 14:25:23 +02:00
func testComposerFocus ( ) {
2025-02-04 09:50:46 +00:00
viewModel . process ( timelineAction : . setMode ( mode : . edit ( originalEventOrTransactionID : . eventID ( " mock " ) , type : . default ) ) )
2023-08-08 14:25:23 +02:00
XCTAssertTrue ( viewModel . state . bindings . composerFocused )
2024-08-13 13:36:40 +02:00
viewModel . process ( timelineAction : . removeFocus )
2023-08-08 14:25:23 +02:00
XCTAssertFalse ( viewModel . state . bindings . composerFocused )
}
func testComposerMode ( ) {
2025-02-04 09:50:46 +00:00
let mode : ComposerMode = . edit ( originalEventOrTransactionID : . eventID ( " mock " ) , type : . default )
2024-08-13 13:36:40 +02:00
viewModel . process ( timelineAction : . setMode ( mode : mode ) )
2023-08-08 14:25:23 +02:00
XCTAssertEqual ( viewModel . state . composerMode , mode )
2024-08-13 13:36:40 +02:00
viewModel . process ( timelineAction : . clear )
2023-08-08 14:25:23 +02:00
XCTAssertEqual ( viewModel . state . composerMode , . default )
}
func testComposerModeIsPublished ( ) {
2025-02-04 09:50:46 +00:00
let mode : ComposerMode = . edit ( originalEventOrTransactionID : . eventID ( " mock " ) , type : . default )
2023-08-08 14:25:23 +02:00
let expectation = expectation ( description : " Composer mode is published " )
let cancellable = viewModel
. context
. $ viewState
. map ( \ . composerMode )
. removeDuplicates ( )
. dropFirst ( )
2024-12-06 16:58:14 +02:00
. sink { composerMode in
2023-08-08 14:25:23 +02:00
XCTAssertEqual ( composerMode , mode )
expectation . fulfill ( )
2024-12-06 16:58:14 +02:00
}
2023-08-08 14:25:23 +02:00
2024-08-13 13:36:40 +02:00
viewModel . process ( timelineAction : . setMode ( mode : mode ) )
2023-08-08 14:25:23 +02:00
wait ( for : [ expectation ] , timeout : 2.0 )
cancellable . cancel ( )
}
2023-08-29 14:37:13 +02:00
func testHandleKeyCommand ( ) {
2023-12-15 10:04:51 +01:00
XCTAssertTrue ( viewModel . keyCommands . count = = 1 )
2023-08-29 14:37:13 +02:00
}
2023-09-05 17:39:54 +02:00
func testComposerFocusAfterEnablingRTE ( ) {
viewModel . process ( viewAction : . enableTextFormatting )
XCTAssertTrue ( viewModel . state . bindings . composerFocused )
}
2023-09-11 09:54:37 +02:00
func testRTEEnabledAfterSendingMessage ( ) {
2023-09-05 17:39:54 +02:00
viewModel . process ( viewAction : . enableTextFormatting )
XCTAssertTrue ( viewModel . state . bindings . composerFocused )
viewModel . state . composerEmpty = false
viewModel . process ( viewAction : . sendMessage )
2024-05-08 17:57:32 +03:00
XCTAssertTrue ( viewModel . state . bindings . composerFormattingEnabled )
2023-09-05 17:39:54 +02:00
}
func testAlertIsShownAfterLinkAction ( ) {
XCTAssertNil ( viewModel . state . bindings . alertInfo )
viewModel . process ( viewAction : . enableTextFormatting )
viewModel . process ( viewAction : . composerAction ( action : . link ) )
XCTAssertNotNil ( viewModel . state . bindings . alertInfo )
}
2023-10-06 15:47:31 +02:00
func testSuggestions ( ) {
2025-03-06 11:32:37 +01:00
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 : " " ) ]
2023-10-06 15:47:31 +02:00
let mockCompletionSuggestionService = CompletionSuggestionServiceMock ( configuration : . init ( suggestions : suggestions ) )
2025-01-20 17:29:34 +01:00
viewModel = ComposerToolbarViewModel ( roomProxy : JoinedRoomProxyMock ( . init ( ) ) ,
wysiwygViewModel : wysiwygViewModel ,
2023-10-06 15:47:31 +02:00
completionSuggestionService : mockCompletionSuggestionService ,
2024-10-02 17:41:08 +01:00
mediaProvider : MediaProviderMock ( configuration : . init ( ) ) ,
2024-05-30 14:10:51 +03:00
mentionDisplayHelper : ComposerMentionDisplayHelper . mock ,
2024-06-13 14:19:38 +02:00
analyticsService : ServiceLocator . shared . analytics ,
composerDraftService : draftServiceMock )
2023-10-06 15:47:31 +02:00
XCTAssertEqual ( viewModel . state . suggestions , suggestions )
}
2024-09-26 16:09:01 +01:00
func testSuggestionTrigger ( ) async throws {
2025-03-06 11:32:37 +01:00
let deferred = deferFulfillment ( wysiwygViewModel . $ attributedContent ) { $0 . plainText = = " #room-alias-test " }
wysiwygViewModel . setMarkdownContent ( " @user-test " )
wysiwygViewModel . setMarkdownContent ( " #room-alias-test " )
2024-09-26 16:09:01 +01:00
try await deferred . fulfill ( )
2024-06-13 13:21:47 +03:00
2023-10-06 15:47:31 +02:00
// T h e f i r s t o n e i s n i l b e c a u s e w h e n i n i t i a l i s e d t h e v i e w m o d e l i s e m p t y
2025-03-06 11:32:37 +01:00
XCTAssertEqual ( 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 ) ) ] )
2023-10-06 15:47:31 +02:00
}
2023-10-09 14:26:53 +02:00
2023-10-12 18:15:47 +02:00
func testSelectedUserSuggestion ( ) {
2025-03-06 11:32:37 +01:00
let suggestion = SuggestionItem ( suggestionType : . user ( . init ( id : " @test:matrix.org " , displayName : " Test " , avatarURL : nil ) ) , range : . init ( ) , rawSuggestionText : " " )
2023-10-09 14:26:53 +02:00
viewModel . context . send ( viewAction : . selectedSuggestion ( suggestion ) )
2024-06-17 09:31:33 +03:00
// T h e d i s p l a y n a m e c a n b e u s e d f o r H T M L i n j e c t i o n i n t h e r i c h t e x t e d i t o r a n d i t ' s u s e l e s s a n y w a y a s t h e c l i e n t s d o n ' t u s e i t w h e n r e s o l v i n g d i s p l a y n a m e s
XCTAssertEqual ( wysiwygViewModel . content . html , " <a href= \" https://matrix.to/#/@test:matrix.org \" >@test:matrix.org</a> " )
2023-10-09 14:26:53 +02:00
}
2023-10-11 16:40:52 +02:00
2025-03-06 11:32:37 +01:00
func testSelectedRoomSuggestion ( ) {
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 ) )
// T h e d i s p l a y n a m e c a n b e u s e d f o r H T M L i n j e c t i o n i n t h e r i c h t e x t e d i t o r a n d i t ' s u s e l e s s a n y w a y a s t h e c l i e n t s d o n ' t u s e i t w h e n r e s o l v i n g d i s p l a y n a m e s
XCTAssertEqual ( wysiwygViewModel . content . html , " <a href= \" https://matrix.to/#/%23room-alias:matrix.org \" >#room-alias:matrix.org</a> " )
}
2023-10-12 18:15:47 +02:00
func testAllUsersSuggestion ( ) {
2025-03-06 11:32:37 +01:00
let suggestion = SuggestionItem ( suggestionType : . allUsers ( . room ( id : " " , name : nil , avatarURL : nil ) ) , range : . init ( ) , rawSuggestionText : " " )
2023-10-12 18:15:47 +02:00
viewModel . context . send ( viewAction : . selectedSuggestion ( suggestion ) )
var string = " @room "
// s w i f t l i n t : d i s a b l e : n e x t f o r c e _ u n w r a p p i n g
string . unicodeScalars . append ( UnicodeScalar ( String . nbsp ) ! )
XCTAssertEqual ( wysiwygViewModel . content . html , string )
}
2024-02-08 18:07:14 +01:00
func testUserMentionPillInRTE ( ) async {
viewModel . context . send ( viewAction : . composerAppeared )
await Task . yield ( )
2023-10-11 16:40:52 +02:00
let userID = " @test:matrix.org "
2025-03-06 11:32:37 +01:00
let suggestion = SuggestionItem ( suggestionType : . user ( . init ( id : userID , displayName : " Test " , avatarURL : nil ) ) , range : . init ( ) , rawSuggestionText : " " )
2023-10-11 16:40:52 +02:00
viewModel . context . send ( viewAction : . selectedSuggestion ( suggestion ) )
2024-02-09 17:00:32 +01:00
let attachment = wysiwygViewModel . textView . attributedText . attribute ( . attachment , at : 0 , effectiveRange : nil ) as ? PillTextAttachment
2023-10-11 16:40:52 +02:00
XCTAssertEqual ( attachment ? . pillData ? . type , . user ( userID : userID ) )
}
2023-10-12 18:15:47 +02:00
2025-03-06 11:32:37 +01:00
func testRoomMentionPillInRTE ( ) 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
XCTAssertEqual ( attachment ? . pillData ? . type , . roomAlias ( roomAlias ) )
}
2024-02-08 18:07:14 +01:00
func testAllUsersMentionPillInRTE ( ) async {
viewModel . context . send ( viewAction : . composerAppeared )
await Task . yield ( )
2025-03-06 11:32:37 +01:00
let suggestion = SuggestionItem ( suggestionType : . allUsers ( . room ( id : " " , name : nil , avatarURL : nil ) ) , range : . init ( ) , rawSuggestionText : " " )
2023-10-12 18:15:47 +02:00
viewModel . context . send ( viewAction : . selectedSuggestion ( suggestion ) )
2024-02-09 17:00:32 +01:00
let attachment = wysiwygViewModel . textView . attributedText . attribute ( . attachment , at : 0 , effectiveRange : nil ) as ? PillTextAttachment
2023-10-12 18:15:47 +02:00
XCTAssertEqual ( attachment ? . pillData ? . type , . allUsers )
}
2023-10-20 15:51:25 +02:00
func testIntentionalMentions ( ) 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 ( )
}
2024-06-13 14:19:38 +02:00
// MARK: - D r a f t
func testSaveDraftPlainText ( ) async {
let expectation = expectation ( description : " Wait for draft to be saved " )
draftServiceMock . saveDraftClosure = { draft in
XCTAssertEqual ( draft . plainText , " Hello world! " )
XCTAssertNil ( draft . htmlText )
XCTAssertEqual ( draft . draftType , . newMessage )
defer { expectation . fulfill ( ) }
return . success ( ( ) )
}
viewModel . context . composerFormattingEnabled = false
viewModel . context . plainComposerText = . init ( string : " Hello world! " )
2024-08-13 13:36:40 +02:00
viewModel . saveDraft ( )
2024-06-13 14:19:38 +02:00
await fulfillment ( of : [ expectation ] , timeout : 10 )
XCTAssertEqual ( draftServiceMock . saveDraftCallsCount , 1 )
XCTAssertFalse ( draftServiceMock . clearDraftCalled )
XCTAssertFalse ( draftServiceMock . loadDraftCalled )
}
func testSaveDraftFormattedText ( ) async {
let expectation = expectation ( description : " Wait for draft to be saved " )
draftServiceMock . saveDraftClosure = { draft in
XCTAssertEqual ( draft . plainText , " __Hello__ world! " )
XCTAssertEqual ( draft . htmlText , " <strong>Hello</strong> world! " )
XCTAssertEqual ( draft . draftType , . newMessage )
defer { expectation . fulfill ( ) }
return . success ( ( ) )
}
viewModel . context . composerFormattingEnabled = true
wysiwygViewModel . setHtmlContent ( " <strong>Hello</strong> world! " )
2024-08-13 13:36:40 +02:00
viewModel . saveDraft ( )
2024-06-13 14:19:38 +02:00
await fulfillment ( of : [ expectation ] , timeout : 10 )
XCTAssertEqual ( draftServiceMock . saveDraftCallsCount , 1 )
XCTAssertFalse ( draftServiceMock . clearDraftCalled )
XCTAssertFalse ( draftServiceMock . loadDraftCalled )
}
func testSaveDraftEdit ( ) async {
let expectation = expectation ( description : " Wait for draft to be saved " )
draftServiceMock . saveDraftClosure = { draft in
XCTAssertEqual ( draft . plainText , " Hello world! " )
XCTAssertNil ( draft . htmlText )
XCTAssertEqual ( draft . draftType , . edit ( eventID : " testID " ) )
defer { expectation . fulfill ( ) }
return . success ( ( ) )
}
viewModel . context . composerFormattingEnabled = false
2025-02-04 09:50:46 +00:00
viewModel . process ( timelineAction : . setMode ( mode : . edit ( originalEventOrTransactionID : . eventID ( " testID " ) , type : . default ) ) )
2024-06-13 14:19:38 +02:00
viewModel . context . plainComposerText = . init ( string : " Hello world! " )
2024-08-13 13:36:40 +02:00
viewModel . saveDraft ( )
2024-06-13 14:19:38 +02:00
await fulfillment ( of : [ expectation ] , timeout : 10 )
XCTAssertEqual ( draftServiceMock . saveDraftCallsCount , 1 )
XCTAssertFalse ( draftServiceMock . clearDraftCalled )
XCTAssertFalse ( draftServiceMock . loadDraftCalled )
}
func testSaveDraftReply ( ) async {
let expectation = expectation ( description : " Wait for draft to be saved " )
draftServiceMock . saveDraftClosure = { draft in
XCTAssertEqual ( draft . plainText , " Hello world! " )
XCTAssertNil ( draft . htmlText )
XCTAssertEqual ( draft . draftType , . reply ( eventID : " testID " ) )
defer { expectation . fulfill ( ) }
return . success ( ( ) )
}
viewModel . context . composerFormattingEnabled = false
2024-10-16 14:07:54 +03:00
viewModel . process ( timelineAction : . setMode ( mode : . reply ( eventID : " testID " ,
2024-10-01 18:50:11 +03:00
replyDetails : . loaded ( sender : . init ( id : " " ) ,
eventID : " testID " ,
eventContent : . message ( . text ( . init ( body : " reply text " ) ) ) ) ,
isThread : false ) ) )
2024-06-13 14:19:38 +02:00
viewModel . context . plainComposerText = . init ( string : " Hello world! " )
2024-08-13 13:36:40 +02:00
viewModel . saveDraft ( )
2024-06-13 14:19:38 +02:00
await fulfillment ( of : [ expectation ] , timeout : 10 )
XCTAssertEqual ( draftServiceMock . saveDraftCallsCount , 1 )
XCTAssertFalse ( draftServiceMock . clearDraftCalled )
XCTAssertFalse ( draftServiceMock . loadDraftCalled )
}
func testSaveDraftWhenEmptyReply ( ) async {
let expectation = expectation ( description : " Wait for draft to be saved " )
draftServiceMock . saveDraftClosure = { draft in
XCTAssertEqual ( draft . plainText , " " )
XCTAssertNil ( draft . htmlText )
XCTAssertEqual ( draft . draftType , . reply ( eventID : " testID " ) )
defer { expectation . fulfill ( ) }
return . success ( ( ) )
}
viewModel . context . composerFormattingEnabled = false
2024-10-16 14:07:54 +03:00
viewModel . process ( timelineAction : . setMode ( mode : . reply ( eventID : " testID " ,
2024-10-01 18:50:11 +03:00
replyDetails : . loaded ( sender : . init ( id : " " ) ,
eventID : " testID " ,
eventContent : . message ( . text ( . init ( body : " reply text " ) ) ) ) ,
isThread : false ) ) )
2024-08-13 13:36:40 +02:00
viewModel . saveDraft ( )
2024-06-13 14:19:38 +02:00
await fulfillment ( of : [ expectation ] , timeout : 10 )
XCTAssertEqual ( draftServiceMock . saveDraftCallsCount , 1 )
XCTAssertFalse ( draftServiceMock . clearDraftCalled )
XCTAssertFalse ( draftServiceMock . loadDraftCalled )
}
func testClearDraftWhenEmptyNormalMessage ( ) async {
let expectation = expectation ( description : " Wait for draft to be cleared " )
draftServiceMock . clearDraftClosure = {
defer { expectation . fulfill ( ) }
return . success ( ( ) )
}
viewModel . context . composerFormattingEnabled = false
2024-08-13 13:36:40 +02:00
viewModel . saveDraft ( )
2024-06-13 14:19:38 +02:00
await fulfillment ( of : [ expectation ] , timeout : 10 )
XCTAssertFalse ( draftServiceMock . saveDraftCalled )
XCTAssertEqual ( draftServiceMock . clearDraftCallsCount , 1 )
XCTAssertFalse ( draftServiceMock . loadDraftCalled )
}
func testClearDraftForNonTextMode ( ) async {
let expectation = expectation ( description : " Wait for draft to be cleared " )
draftServiceMock . clearDraftClosure = {
defer { expectation . fulfill ( ) }
return . success ( ( ) )
}
viewModel . context . composerFormattingEnabled = false
let waveformData : [ Float ] = Array ( repeating : 1.0 , count : 1000 )
viewModel . context . plainComposerText = . init ( string : " Hello world! " )
2024-09-06 12:57:20 +03:00
viewModel . process ( timelineAction : . setMode ( mode : . previewVoiceMessage ( state : AudioPlayerState ( id : . recorderPreview , title : " " , duration : 10.0 ) ,
waveform : . data ( waveformData ) ,
isUploading : false ) ) )
2024-08-13 13:36:40 +02:00
viewModel . saveDraft ( )
2024-06-13 14:19:38 +02:00
await fulfillment ( of : [ expectation ] , timeout : 10 )
XCTAssertFalse ( draftServiceMock . saveDraftCalled )
XCTAssertEqual ( draftServiceMock . clearDraftCallsCount , 1 )
XCTAssertFalse ( draftServiceMock . loadDraftCalled )
}
func testNothingToRestore ( ) async {
viewModel . context . composerFormattingEnabled = false
let expectation = expectation ( description : " Wait for draft to be restored " )
draftServiceMock . loadDraftClosure = {
defer { expectation . fulfill ( ) }
return . success ( nil )
}
2024-11-21 14:48:38 +00:00
await viewModel . loadDraft ( )
2024-06-13 14:19:38 +02:00
await fulfillment ( of : [ expectation ] , timeout : 10 )
XCTAssertFalse ( viewModel . context . composerFormattingEnabled )
XCTAssertTrue ( viewModel . state . composerEmpty )
XCTAssertEqual ( viewModel . state . composerMode , . default )
}
func testRestoreNormalPlainTextMessage ( ) async {
viewModel . context . composerFormattingEnabled = false
let expectation = expectation ( description : " Wait for draft to be restored " )
draftServiceMock . loadDraftClosure = {
defer { expectation . fulfill ( ) }
return . success ( . init ( plainText : " Hello world! " ,
htmlText : nil ,
draftType : . newMessage ) )
}
2024-11-21 14:48:38 +00:00
await viewModel . loadDraft ( )
2024-06-13 14:19:38 +02:00
await fulfillment ( of : [ expectation ] , timeout : 10 )
XCTAssertFalse ( viewModel . context . composerFormattingEnabled )
XCTAssertEqual ( viewModel . state . composerMode , . default )
XCTAssertEqual ( viewModel . context . plainComposerText , NSAttributedString ( string : " Hello world! " ) )
}
func testRestoreNormalFormattedTextMessage ( ) async {
viewModel . context . composerFormattingEnabled = false
let expectation = expectation ( description : " Wait for draft to be restored " )
draftServiceMock . loadDraftClosure = {
defer { expectation . fulfill ( ) }
return . success ( . init ( plainText : " __Hello__ world! " ,
htmlText : " <strong>Hello</strong> world! " ,
draftType : . newMessage ) )
}
2024-11-21 14:48:38 +00:00
await viewModel . loadDraft ( )
2024-06-13 14:19:38 +02:00
await fulfillment ( of : [ expectation ] , timeout : 10 )
XCTAssertTrue ( viewModel . context . composerFormattingEnabled )
XCTAssertEqual ( viewModel . state . composerMode , . default )
XCTAssertEqual ( wysiwygViewModel . content . html , " <strong>Hello</strong> world! " )
XCTAssertEqual ( wysiwygViewModel . content . markdown , " __Hello__ world! " )
}
func testRestoreEdit ( ) async {
viewModel . context . composerFormattingEnabled = false
let expectation = expectation ( description : " Wait for draft to be restored " )
draftServiceMock . loadDraftClosure = {
defer { expectation . fulfill ( ) }
return . success ( . init ( plainText : " Hello world! " ,
htmlText : nil ,
draftType : . edit ( eventID : " testID " ) ) )
}
2024-11-21 14:48:38 +00:00
await viewModel . loadDraft ( )
2024-06-13 14:19:38 +02:00
await fulfillment ( of : [ expectation ] , timeout : 10 )
XCTAssertFalse ( viewModel . context . composerFormattingEnabled )
2025-02-04 09:50:46 +00:00
XCTAssertEqual ( viewModel . state . composerMode , . edit ( originalEventOrTransactionID : . eventID ( " testID " ) , type : . default ) )
2024-06-13 14:19:38 +02:00
XCTAssertEqual ( viewModel . context . plainComposerText , NSAttributedString ( string : " Hello world! " ) )
}
func testRestoreReply ( ) async {
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
let draftExpectation = expectation ( description : " Wait for draft to be restored " )
draftServiceMock . loadDraftClosure = {
defer { draftExpectation . fulfill ( ) }
return . success ( . init ( plainText : text ,
htmlText : nil ,
draftType : . reply ( eventID : testEventID ) ) )
}
let loadReplyExpectation = expectation ( description : " Wait for reply to be loaded " )
draftServiceMock . getReplyEventIDClosure = { eventID in
defer { loadReplyExpectation . fulfill ( ) }
XCTAssertEqual ( eventID , testEventID )
try ? await Task . sleep ( for : . seconds ( 1 ) )
return . success ( . init ( details : loadedReply ,
isThreaded : true ) )
}
2024-11-21 14:48:38 +00:00
await viewModel . loadDraft ( )
2024-06-13 14:19:38 +02:00
await fulfillment ( of : [ draftExpectation ] , timeout : 10 )
XCTAssertFalse ( viewModel . context . composerFormattingEnabled )
// T e s t i n g t h e l o a d i n g s t a t e f i r s t
2024-10-16 14:07:54 +03:00
XCTAssertEqual ( viewModel . state . composerMode , . reply ( eventID : testEventID ,
2024-06-13 14:19:38 +02:00
replyDetails : . loading ( eventID : testEventID ) ,
isThread : false ) )
XCTAssertEqual ( viewModel . context . plainComposerText , NSAttributedString ( string : text ) )
await fulfillment ( of : [ loadReplyExpectation ] , timeout : 10 )
2024-10-16 14:07:54 +03:00
XCTAssertEqual ( viewModel . state . composerMode , . reply ( eventID : testEventID ,
2024-06-13 14:19:38 +02:00
replyDetails : loadedReply ,
isThread : true ) )
}
func testRestoreReplyAndCancelReplyMode ( ) async {
let testEventID = " testID "
let text = " Hello world! "
2024-10-01 18:50:11 +03:00
let loadedReply = TimelineItemReplyDetails . loaded ( sender : . init ( id : " userID " , displayName : " Username " ) ,
2024-06-13 14:19:38 +02:00
eventID : testEventID ,
eventContent : . message ( . text ( . init ( body : " Reply text " ) ) ) )
viewModel . context . composerFormattingEnabled = false
let draftExpectation = expectation ( description : " Wait for draft to be restored " )
draftServiceMock . loadDraftClosure = {
defer { draftExpectation . fulfill ( ) }
return . success ( . init ( plainText : text ,
htmlText : nil ,
draftType : . reply ( eventID : testEventID ) ) )
}
let loadReplyExpectation = expectation ( description : " Wait for reply to be loaded " )
draftServiceMock . getReplyEventIDClosure = { eventID in
defer { loadReplyExpectation . fulfill ( ) }
XCTAssertEqual ( eventID , testEventID )
try ? await Task . sleep ( for : . seconds ( 1 ) )
return . success ( . init ( details : loadedReply ,
isThreaded : true ) )
}
2024-11-21 14:48:38 +00:00
await viewModel . loadDraft ( )
2024-06-13 14:19:38 +02:00
await fulfillment ( of : [ draftExpectation ] , timeout : 10 )
XCTAssertFalse ( viewModel . context . composerFormattingEnabled )
// T e s t i n g t h e l o a d i n g s t a t e f i r s t
2024-10-16 14:07:54 +03:00
XCTAssertEqual ( viewModel . state . composerMode , . reply ( eventID : testEventID ,
2024-06-13 14:19:38 +02:00
replyDetails : . loading ( eventID : testEventID ) ,
isThread : false ) )
XCTAssertEqual ( viewModel . context . plainComposerText , NSAttributedString ( string : text ) )
// N o w w e c h a n g e t h e s t a t e t o c a n c e l t h e r e p l y m o d e u p d a t e
viewModel . process ( viewAction : . cancelReply )
await fulfillment ( of : [ loadReplyExpectation ] , timeout : 10 )
XCTAssertEqual ( viewModel . state . composerMode , . default )
}
2024-07-02 16:48:50 +02:00
func testSaveVolatileDraftWhenEditing ( ) {
viewModel . context . composerFormattingEnabled = false
viewModel . context . plainComposerText = . init ( string : " Hello world! " )
2025-02-04 09:50:46 +00:00
viewModel . process ( timelineAction : . setMode ( mode : . edit ( originalEventOrTransactionID : . eventID ( UUID ( ) . uuidString ) , type : . default ) ) )
2024-07-02 16:48:50 +02:00
let draft = draftServiceMock . saveVolatileDraftReceivedDraft
XCTAssertNotNil ( draft )
XCTAssertEqual ( draft ? . plainText , " Hello world! " )
XCTAssertNil ( draft ? . htmlText )
XCTAssertEqual ( draft ? . draftType , . newMessage )
}
func testRestoreVolatileDraftWhenCancellingEdit ( ) async {
let expectation = expectation ( description : " Wait for draft to be restored " )
draftServiceMock . loadVolatileDraftClosure = {
defer { expectation . fulfill ( ) }
return . init ( plainText : " Hello world " ,
htmlText : nil ,
draftType : . newMessage )
}
viewModel . process ( viewAction : . cancelEdit )
await fulfillment ( of : [ expectation ] )
XCTAssertEqual ( viewModel . context . plainComposerText , NSAttributedString ( string : " Hello world " ) )
}
func testRestoreVolatileDraftWhenClearing ( ) async {
let expectation1 = expectation ( description : " Wait for draft to be restored " )
draftServiceMock . loadVolatileDraftClosure = {
defer { expectation1 . fulfill ( ) }
return . init ( plainText : " Hello world " ,
htmlText : nil ,
draftType : . newMessage )
}
let expectation2 = expectation ( description : " The draft should also be cleared after being loaded " )
draftServiceMock . clearVolatileDraftClosure = {
expectation2 . fulfill ( )
}
2024-08-13 13:36:40 +02:00
viewModel . process ( timelineAction : . clear )
2024-07-02 16:48:50 +02:00
await fulfillment ( of : [ expectation1 , expectation2 ] )
XCTAssertEqual ( viewModel . context . plainComposerText , NSAttributedString ( string : " Hello world " ) )
}
func testRestoreVolatileDraftDoubleClear ( ) async {
let expectation1 = expectation ( description : " Wait for draft to be restored " )
draftServiceMock . loadVolatileDraftClosure = {
defer { expectation1 . fulfill ( ) }
return . init ( plainText : " Hello world " ,
htmlText : nil ,
draftType : . newMessage )
}
let expectation2 = expectation ( description : " The draft should also be cleared after being loaded " )
draftServiceMock . clearVolatileDraftClosure = {
expectation2 . fulfill ( )
}
2024-08-13 13:36:40 +02:00
viewModel . process ( timelineAction : . clear )
2024-07-02 16:48:50 +02:00
await fulfillment ( of : [ expectation1 , expectation2 ] )
XCTAssertEqual ( viewModel . context . plainComposerText , NSAttributedString ( string : " Hello world " ) )
}
2024-07-09 13:02:46 +02:00
func testRestoreUserMentionInPlainText ( ) async throws {
viewModel . context . composerFormattingEnabled = false
let text = " Hello [TestName](https://matrix.to/#/@test:matrix.org)! "
2024-08-13 13:36:40 +02:00
viewModel . process ( timelineAction : . setText ( plainText : text , htmlText : nil ) )
2024-07-09 13:02:46 +02:00
let deferred = deferFulfillment ( viewModel . actions ) { action in
switch action {
case let . sendMessage ( plainText , _ , _ , intentionalMentions ) :
// A s o f r i g h t n o w t h e m a r k d o w n l o s e s t h e d i s p l a y n a m e w h e n r e s t o r e d
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 ( )
}
func testRestoreAllUsersMentionInPlainText ( ) async throws {
viewModel . context . composerFormattingEnabled = false
let text = " Hello @room "
2024-08-13 13:36:40 +02:00
viewModel . process ( timelineAction : . setText ( plainText : text , htmlText : nil ) )
2024-07-09 13:02:46 +02:00
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 ( )
}
func testRestoreMixedMentionsInPlainText ( ) 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 "
2024-08-13 13:36:40 +02:00
viewModel . process ( timelineAction : . setText ( plainText : text , htmlText : nil ) )
2024-07-09 13:02:46 +02:00
let deferred = deferFulfillment ( viewModel . actions ) { action in
switch action {
case let . sendMessage ( plainText , _ , _ , intentionalMentions ) :
// A s o f r i g h t n o w t h e m a r k d o w n l o s e s t h e d i s p l a y n a m e w h e n r e s t o r e d
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 ( )
}
func testRestoreAmbiguousMention ( ) async throws {
viewModel . context . composerFormattingEnabled = false
let text = " Hello [User1](https://matrix.to/#/@roomuser:matrix.org) "
2024-08-13 13:36:40 +02:00
viewModel . process ( timelineAction : . setText ( plainText : text , htmlText : nil ) )
2024-07-09 13:02:46 +02:00
let deferred = deferFulfillment ( viewModel . actions ) { action in
switch action {
case let . sendMessage ( plainText , _ , _ , intentionalMentions ) :
// A s o f r i g h t n o w t h e m a r k d o w n l o s e s t h e d i s p l a y n a m e w h e n r e s t o r e d
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 ( )
}
2024-11-21 14:48:38 +00:00
func testRestoreDoesntOverwriteInitialText ( ) async {
let sharedText = " Some shared text "
let expectation = expectation ( description : " Wait for draft to be restored " )
expectation . isInverted = true
setUpViewModel ( initialText : sharedText ) {
defer { expectation . fulfill ( ) }
return . success ( . init ( plainText : " Hello world! " ,
htmlText : nil ,
draftType : . newMessage ) )
}
viewModel . context . composerFormattingEnabled = false
await viewModel . loadDraft ( )
await fulfillment ( of : [ expectation ] , timeout : 1 )
XCTAssertFalse ( viewModel . context . composerFormattingEnabled )
XCTAssertEqual ( viewModel . state . composerMode , . default )
XCTAssertEqual ( viewModel . context . plainComposerText , NSAttributedString ( string : sharedText ) )
}
2025-01-20 17:29:34 +01:00
// MARK: - I d e n t i t y V i o l a t i o n
func testVerificationViolationDisablesComposer ( ) 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 ,
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 ( )
}
func testMultipleViolation ( ) 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 ) )
}
}
// T h e r e a r e 2 v i o l a t i o n s , e n s u r e t h a t r e s o l v i n g t h e f i r s t o n e i s n o t e n o u g h
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 ,
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 ( )
}
func testPinViolationDoesNotDisableComposer ( ) {
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 ,
analyticsService : ServiceLocator . shared . analytics ,
composerDraftService : draftServiceMock )
let expectation = expectation ( description : " Composer should be enabled " )
let cancellable = viewModel
. context
. $ viewState
. map ( \ . canSend )
. sink { canSend in
if canSend {
expectation . fulfill ( )
}
}
wait ( for : [ expectation ] , timeout : 2.0 )
cancellable . cancel ( )
}
2024-11-21 14:48:38 +00:00
// MARK: - H e l p e r s
private func setUpViewModel ( initialText : String ? = nil , loadDraftClosure : ( ( ) async -> Result < ComposerDraftProxy ? , ComposerDraftServiceError > ) ? = nil ) {
wysiwygViewModel = WysiwygComposerViewModel ( )
completionSuggestionServiceMock = CompletionSuggestionServiceMock ( configuration : . init ( ) )
draftServiceMock = ComposerDraftServiceMock ( )
if let loadDraftClosure {
draftServiceMock . loadDraftClosure = loadDraftClosure
}
viewModel = ComposerToolbarViewModel ( initialText : initialText ,
2025-01-20 17:29:34 +01:00
roomProxy : JoinedRoomProxyMock ( . init ( ) ) ,
2024-11-21 14:48:38 +00:00
wysiwygViewModel : wysiwygViewModel ,
completionSuggestionService : completionSuggestionServiceMock ,
mediaProvider : MediaProviderMock ( configuration : . init ( ) ) ,
mentionDisplayHelper : ComposerMentionDisplayHelper . mock ,
analyticsService : ServiceLocator . shared . analytics ,
composerDraftService : draftServiceMock )
viewModel . context . composerFormattingEnabled = true
}
2024-05-03 12:06:16 +03:00
}