[SES-2018] Refactor mention (#1510)

* Refactor mention

* Fixes robolectric test problem

* Fixes tests

* Naming and comments

* Naming

* Dispatcher

---------

Co-authored-by: fanchao <git@fanchao.dev>
This commit is contained in:
Fanchao Liu
2024-07-01 17:31:03 +10:00
committed by GitHub
parent a260717d42
commit fec67e282a
24 changed files with 992 additions and 337 deletions

View File

@@ -0,0 +1,115 @@
package org.thoughtcrime.securesms.conversation.v2
import android.text.Editable
import android.text.Selection
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.thoughtcrime.securesms.conversation.v2.mention.MentionEditable
import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel
@RunWith(RobolectricTestRunner::class)
class MentionEditableTest {
private lateinit var mentionEditable: MentionEditable
@Before
fun setUp() {
mentionEditable = MentionEditable()
}
@Test
fun `should not have query when there is no 'at' symbol`() = runTest {
mentionEditable.observeMentionSearchQuery().test {
assertThat(awaitItem()).isNull()
mentionEditable.simulateTyping("Some text")
expectNoEvents()
}
}
@Test
fun `should have empty query after typing 'at' symbol`() = runTest {
mentionEditable.observeMentionSearchQuery().test {
assertThat(awaitItem()).isNull()
mentionEditable.simulateTyping("Some text")
expectNoEvents()
mentionEditable.simulateTyping("@")
assertThat(awaitItem())
.isEqualTo(MentionEditable.SearchQuery(9, ""))
}
}
@Test
fun `should have some query after typing words following 'at' symbol`() = runTest {
mentionEditable.observeMentionSearchQuery().test {
assertThat(awaitItem()).isNull()
mentionEditable.simulateTyping("Some text")
expectNoEvents()
mentionEditable.simulateTyping("@words")
assertThat(awaitItem())
.isEqualTo(MentionEditable.SearchQuery(9, "words"))
}
}
@Test
fun `should cancel query after a whitespace or another 'at' is typed`() = runTest {
mentionEditable.observeMentionSearchQuery().test {
assertThat(awaitItem()).isNull()
mentionEditable.simulateTyping("@words")
assertThat(awaitItem())
.isEqualTo(MentionEditable.SearchQuery(0, "words"))
mentionEditable.simulateTyping(" ")
assertThat(awaitItem())
.isNull()
mentionEditable.simulateTyping("@query@")
assertThat(awaitItem())
.isEqualTo(MentionEditable.SearchQuery(13, ""))
}
}
@Test
fun `should move pass the whole span while moving cursor around mentioned block `() {
mentionEditable.append("Mention @user here")
mentionEditable.addMention(MentionViewModel.Member("user", "User", false), 8..14)
// Put cursor right before @user, it should then select nothing
Selection.setSelection(mentionEditable, 8)
assertThat(mentionEditable.selection()).isEqualTo(intArrayOf(8, 8))
// Put cursor right after '@', it should then select the whole @user
Selection.setSelection(mentionEditable, 9)
assertThat(mentionEditable.selection()).isEqualTo(intArrayOf(8, 13))
// Put cursor right after @user, it should then select nothing
Selection.setSelection(mentionEditable, 13)
assertThat(mentionEditable.selection()).isEqualTo(intArrayOf(13, 13))
}
@Test
fun `should delete the whole mention block while deleting only part of it`() {
mentionEditable.append("Mention @user here")
mentionEditable.addMention(MentionViewModel.Member("user", "User", false), 8..14)
mentionEditable.delete(8, 9)
assertThat(mentionEditable.toString()).isEqualTo("Mention here")
}
}
private fun CharSequence.selection(): IntArray {
return intArrayOf(Selection.getSelectionStart(this), Selection.getSelectionEnd(this))
}
private fun Editable.simulateTyping(text: String) {
this.append(text)
Selection.setSelection(this, this.length)
}

View File

@@ -0,0 +1,185 @@
package org.thoughtcrime.securesms.conversation.v2
import android.text.Selection
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.doAnswer
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.eq
import org.mockito.kotlin.mock
import org.robolectric.RobolectricTestRunner
import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.open_groups.GroupMemberRole
import org.session.libsession.messaging.open_groups.OpenGroup
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.MainCoroutineRule
import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel
@RunWith(RobolectricTestRunner::class)
class MentionViewModelTest {
@OptIn(ExperimentalCoroutinesApi::class)
@get:Rule
val mainCoroutineRule = MainCoroutineRule()
private lateinit var mentionViewModel: MentionViewModel
private val threadID = 123L
private data class MemberInfo(
val name: String,
val pubKey: String,
val roles: List<GroupMemberRole>
)
private val threadMembers = listOf(
MemberInfo("Alice", "pubkey1", listOf(GroupMemberRole.ADMIN)),
MemberInfo("Bob", "pubkey2", listOf(GroupMemberRole.STANDARD)),
MemberInfo("Charlie", "pubkey3", listOf(GroupMemberRole.MODERATOR)),
MemberInfo("David", "pubkey4", listOf(GroupMemberRole.HIDDEN_ADMIN)),
MemberInfo("Eve", "pubkey5", listOf(GroupMemberRole.HIDDEN_MODERATOR)),
MemberInfo("李云海", "pubkey6", listOf(GroupMemberRole.ZOOMBIE)),
)
private val memberContacts = threadMembers.map { m ->
Contact(m.pubKey).also {
it.name = m.name
}
}
private val openGroup = OpenGroup(
server = "",
room = "",
id = "open_group_id_1",
name = "Open Group",
publicKey = "",
imageId = null,
infoUpdates = 0,
canWrite = true
)
@Before
fun setUp() {
@Suppress("UNCHECKED_CAST")
mentionViewModel = MentionViewModel(
threadID,
contentResolver = mock { },
threadDatabase = mock {
on { getRecipientForThreadId(threadID) } doAnswer {
mock<Recipient> {
on { isClosedGroupRecipient } doReturn false
on { isCommunityRecipient } doReturn true
on { isContactRecipient } doReturn false
}
}
},
groupDatabase = mock {
},
mmsDatabase = mock {
on { getRecentChatMemberIDs(eq(threadID), any()) } doAnswer {
val limit = it.arguments[1] as Int
threadMembers.take(limit).map { m -> m.pubKey }
}
},
contactDatabase = mock {
on { getContacts(any()) } doAnswer {
val ids = it.arguments[0] as Collection<String>
memberContacts.filter { contact -> contact.sessionID in ids }
}
},
memberDatabase = mock {
on { getGroupMembersRoles(eq(openGroup.id), any()) } doAnswer {
val memberIDs = it.arguments[1] as Collection<String>
memberIDs.associateWith { id ->
threadMembers.first { m -> m.pubKey == id }.roles
}
}
},
storage = mock {
on { getOpenGroup(threadID) } doReturn openGroup
},
dispatcher = StandardTestDispatcher()
)
}
@Test
fun `should show candidates after 'at' symbol`() = runTest {
mentionViewModel.autoCompleteState.test {
assertThat(awaitItem())
.isEqualTo(MentionViewModel.AutoCompleteState.Idle)
val editable = mentionViewModel.editableFactory.newEditable("")
editable.append("Hello @")
expectNoEvents() // Nothing should happen before cursor is put after @
Selection.setSelection(editable, editable.length)
assertThat(awaitItem())
.isEqualTo(MentionViewModel.AutoCompleteState.Loading)
// Should show all the candidates
awaitItem().let { result ->
assertThat(result)
.isInstanceOf(MentionViewModel.AutoCompleteState.Result::class.java)
result as MentionViewModel.AutoCompleteState.Result
assertThat(result.members).isEqualTo(threadMembers.mapIndexed { index, m ->
val name =
memberContacts[index].displayName(Contact.ContactContext.OPEN_GROUP).orEmpty()
MentionViewModel.Candidate(
MentionViewModel.Member(m.pubKey, name, m.roles.any { it.isModerator }),
name,
0
)
})
}
// Continue typing to filter candidates
editable.append("li")
Selection.setSelection(editable, editable.length)
// Should show only Alice and Charlie
awaitItem().let { result ->
assertThat(result)
.isInstanceOf(MentionViewModel.AutoCompleteState.Result::class.java)
result as MentionViewModel.AutoCompleteState.Result
assertThat(result.members[0].member.name).isEqualTo("Alice (pubk...key1)")
assertThat(result.members[1].member.name).isEqualTo("Charlie (pubk...key3)")
}
}
}
@Test
fun `should have normalised message with candidates selected`() = runTest {
mentionViewModel.autoCompleteState.test {
assertThat(awaitItem())
.isEqualTo(MentionViewModel.AutoCompleteState.Idle)
val editable = mentionViewModel.editableFactory.newEditable("")
editable.append("Hi @")
Selection.setSelection(editable, editable.length)
assertThat(awaitItem())
.isEqualTo(MentionViewModel.AutoCompleteState.Loading)
// Select a candidate now
assertThat(awaitItem())
.isInstanceOf(MentionViewModel.AutoCompleteState.Result::class.java)
mentionViewModel.onCandidateSelected("pubkey1")
// Should have normalised message with selected candidate
assertThat(mentionViewModel.normalizeMessageBody())
.isEqualTo("Hi @pubkey1 ")
}
}
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8" ?>
<manifest>
</manifest>

View File

@@ -0,0 +1,3 @@
manifest=TestAndroidManifest.xml
sdk=34
application=android.app.Application