diff --git a/app/src/androidTest/java/com/android/messaging/ui/blockedparticipants/screen/support/BlockedParticipantsTestData.kt b/app/src/androidTest/java/com/android/messaging/ui/blockedparticipants/screen/support/BlockedParticipantsTestData.kt index da879e07e..a695f6cf7 100644 --- a/app/src/androidTest/java/com/android/messaging/ui/blockedparticipants/screen/support/BlockedParticipantsTestData.kt +++ b/app/src/androidTest/java/com/android/messaging/ui/blockedparticipants/screen/support/BlockedParticipantsTestData.kt @@ -79,6 +79,7 @@ internal fun participant( displayName: String = DISPLAY_NAME_1, destination: String? = DESTINATION_1, details: String? = destination, + canShowContact: Boolean = true, ): BlockedParticipantUiState { return BlockedParticipantUiState( participantId = participantId, @@ -90,6 +91,7 @@ internal fun participant( lookupKey = null, normalizedDestination = destination, canCall = false, + canShowContact = canShowContact, isContactSaved = false, ) } diff --git a/app/src/androidTest/java/com/android/messaging/ui/conversation/ConversationUserFlowTest.kt b/app/src/androidTest/java/com/android/messaging/ui/conversation/ConversationUserFlowTest.kt index 37a65dcd6..f68a27fc7 100644 --- a/app/src/androidTest/java/com/android/messaging/ui/conversation/ConversationUserFlowTest.kt +++ b/app/src/androidTest/java/com/android/messaging/ui/conversation/ConversationUserFlowTest.kt @@ -4,20 +4,16 @@ import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule +import androidx.compose.ui.test.onChildren +import androidx.compose.ui.test.onFirst import androidx.compose.ui.test.onNodeWithTag -import androidx.recyclerview.widget.RecyclerView +import androidx.compose.ui.test.performClick import androidx.test.core.app.ActivityScenario -import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.action.ViewActions.click -import androidx.test.espresso.assertion.ViewAssertions.matches -import androidx.test.espresso.contrib.RecyclerViewActions -import androidx.test.espresso.matcher.ViewMatchers.isDisplayed -import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.ext.junit.runners.AndroidJUnit4 import com.android.common.test.rules.AppTestRule import com.android.common.test.rules.MessagingTestRule -import com.android.messaging.R import com.android.messaging.ui.conversationlist.ConversationListActivity +import com.android.messaging.ui.conversationlist.ui.CONVERSATION_LIST_TEST_TAG import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -42,19 +38,20 @@ class ConversationUserFlowTest { ) scenario.use { - onView(withId(android.R.id.list)) - .check(matches(isDisplayed())) + composeRule.waitUntilAtLeastOneExists( + matcher = hasTestTag(testTag = CONVERSATION_LIST_TEST_TAG), + timeoutMillis = TEST_WAIT_TIMEOUT_MILLIS, + ) - onView(withId(R.id.start_new_conversation_button)) - .check(matches(isDisplayed())) + composeRule + .onNodeWithTag(testTag = CONVERSATION_LIST_TEST_TAG) + .assertIsDisplayed() - onView(withId(android.R.id.list)) - .perform( - RecyclerViewActions.actionOnItemAtPosition( - 0, - click(), - ), - ) + composeRule + .onNodeWithTag(testTag = CONVERSATION_LIST_TEST_TAG) + .onChildren() + .onFirst() + .performClick() composeRule.waitUntilAtLeastOneExists( matcher = hasTestTag(testTag = CONVERSATION_TEXT_FIELD_TEST_TAG), diff --git a/app/src/test/kotlin/com/android/messaging/data/conversation/store/ConversationPinStoreTest.kt b/app/src/test/kotlin/com/android/messaging/data/conversation/store/ConversationPinStoreTest.kt new file mode 100644 index 000000000..0e3b20025 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/data/conversation/store/ConversationPinStoreTest.kt @@ -0,0 +1,109 @@ +package com.android.messaging.data.conversation.store + +import com.android.messaging.datamodel.BugleDatabaseOperations +import com.android.messaging.datamodel.DataModel +import com.android.messaging.datamodel.DatabaseWrapper +import com.android.messaging.datamodel.MessagingContentProvider +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.runs +import io.mockk.unmockkAll +import io.mockk.verify +import io.mockk.verifyOrder +import org.junit.After +import org.junit.Assert.assertSame +import org.junit.Assert.assertThrows +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationPinStoreTest { + + private val databaseWrapper = mockk(relaxed = true) + private val dataModel = mockk() + + private val store = ConversationPinStoreImpl() + + @Before + fun setUp() { + mockkStatic(DataModel::class) + mockkStatic(BugleDatabaseOperations::class) + mockkStatic(MessagingContentProvider::class) + + every { DataModel.get() } returns dataModel + every { dataModel.database } returns databaseWrapper + every { + BugleDatabaseOperations.updateConversationPinStatusInTransaction(any(), any(), any()) + } just runs + every { MessagingContentProvider.notifyConversationListChanged() } just runs + every { MessagingContentProvider.notifyConversationMetadataChanged(any()) } just runs + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun pinConversation_updatesPinStatusToTrueInsideTransactionAndNotifies() { + store.pinConversation(CONVERSATION_ID) + + verifyOrder { + databaseWrapper.beginTransaction() + BugleDatabaseOperations.updateConversationPinStatusInTransaction( + databaseWrapper, + CONVERSATION_ID, + true, + ) + databaseWrapper.setTransactionSuccessful() + databaseWrapper.endTransaction() + } + verify(exactly = 1) { + MessagingContentProvider.notifyConversationListChanged() + } + verify(exactly = 1) { + MessagingContentProvider.notifyConversationMetadataChanged(CONVERSATION_ID) + } + } + + @Test + fun unpinConversation_updatesPinStatusToFalse() { + store.unpinConversation(conversationId = CONVERSATION_ID) + + verify(exactly = 1) { + BugleDatabaseOperations.updateConversationPinStatusInTransaction( + databaseWrapper, + CONVERSATION_ID, + false, + ) + } + } + + @Test + fun pinConversation_updateFails_endsTransactionWithoutNotifying() { + val failure = IllegalStateException("update failed") + every { + BugleDatabaseOperations.updateConversationPinStatusInTransaction(any(), any(), any()) + } throws failure + + val thrown = assertThrows(IllegalStateException::class.java) { + store.pinConversation(CONVERSATION_ID) + } + + assertSame(failure, thrown) + verify(exactly = 1) { databaseWrapper.endTransaction() } + verify(exactly = 0) { databaseWrapper.setTransactionSuccessful() } + verify(exactly = 0) { MessagingContentProvider.notifyConversationListChanged() } + verify(exactly = 0) { + MessagingContentProvider.notifyConversationMetadataChanged(any()) + } + } + + private companion object { + private const val CONVERSATION_ID = "conversation-42" + } +} diff --git a/app/src/test/kotlin/com/android/messaging/data/conversation/store/ConversationReadStoreTest.kt b/app/src/test/kotlin/com/android/messaging/data/conversation/store/ConversationReadStoreTest.kt new file mode 100644 index 000000000..c90155ad9 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/data/conversation/store/ConversationReadStoreTest.kt @@ -0,0 +1,147 @@ +package com.android.messaging.data.conversation.store + +import android.content.ContentValues +import android.database.sqlite.SQLiteStatement +import com.android.messaging.datamodel.BugleDatabaseOperations +import com.android.messaging.datamodel.BugleNotifications +import com.android.messaging.datamodel.DataModel +import com.android.messaging.datamodel.DatabaseHelper +import com.android.messaging.datamodel.DatabaseWrapper +import com.android.messaging.datamodel.MessagingContentProvider +import com.android.messaging.sms.MmsUtils +import com.android.messaging.util.PendingIntentConstants +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.runs +import io.mockk.slot +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationReadStoreTest { + + private val database = mockk(relaxed = true) + private val dataModel = mockk() + private val latestMessageStatement = mockk() + + private val store = ConversationReadStoreImpl() + + @Before + fun setUp() { + mockkStatic(DataModel::class) + mockkStatic(BugleDatabaseOperations::class) + mockkStatic(BugleNotifications::class) + mockkStatic(MessagingContentProvider::class) + mockkStatic(MmsUtils::class) + + every { DataModel.get() } returns dataModel + every { dataModel.database } returns database + every { BugleDatabaseOperations.getThreadId(any(), any()) } returns THREAD_ID + every { + BugleDatabaseOperations.getQueryConversationsLatestMessageStatement(any(), any()) + } returns latestMessageStatement + every { latestMessageStatement.simpleQueryForString() } returns MESSAGE_ID + every { database.update(any(), any(), any(), any()) } returns 1 + every { MmsUtils.updateSmsReadStatus(any(), any()) } just runs + every { MessagingContentProvider.notifyMessagesChanged(any()) } just runs + every { BugleNotifications.cancel(any(), any()) } just runs + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun markConversationRead_updatesMessagesAndCancelsNotification() { + val values = slot() + + store.markConversationRead(CONVERSATION_ID) + + verify { MmsUtils.updateSmsReadStatus(THREAD_ID, Long.MAX_VALUE) } + verify { + database.update( + DatabaseHelper.MESSAGES_TABLE, + capture(values), + any(), + match { arguments -> arguments.contentEquals(arrayOf(CONVERSATION_ID)) }, + ) + } + + assertEquals(1, values.captured.getAsInteger(DatabaseHelper.MessageColumns.READ)) + assertEquals(1, values.captured.getAsInteger(DatabaseHelper.MessageColumns.SEEN)) + + verify { MessagingContentProvider.notifyMessagesChanged(CONVERSATION_ID) } + verify { + BugleNotifications.cancel( + PendingIntentConstants.SMS_NOTIFICATION_ID, + CONVERSATION_ID, + ) + } + } + + @Test + fun markConversationRead_nothingUpdated_skipsTelephonyAndContentNotifications() { + every { BugleDatabaseOperations.getThreadId(any(), any()) } returns -1L + every { database.update(any(), any(), any(), any()) } returns 0 + + store.markConversationRead(CONVERSATION_ID) + + verify(exactly = 0) { MmsUtils.updateSmsReadStatus(any(), any()) } + verify(exactly = 0) { MessagingContentProvider.notifyMessagesChanged(any()) } + verify { + BugleNotifications.cancel( + PendingIntentConstants.SMS_NOTIFICATION_ID, + CONVERSATION_ID, + ) + } + } + + @Test + fun markConversationUnread_updatesLatestMessage() { + val values = slot() + + store.markConversationUnread(CONVERSATION_ID) + + verify { + BugleDatabaseOperations.getQueryConversationsLatestMessageStatement( + database, + CONVERSATION_ID, + ) + } + verify { + database.update( + DatabaseHelper.MESSAGES_TABLE, + capture(values), + any(), + match { arguments -> arguments.contentEquals(arrayOf(MESSAGE_ID)) }, + ) + } + assertEquals(0, values.captured.getAsInteger(DatabaseHelper.MessageColumns.READ)) + verify { MessagingContentProvider.notifyMessagesChanged(CONVERSATION_ID) } + } + + @Test + fun markConversationUnread_noLatestMessage_doesNothing() { + every { latestMessageStatement.simpleQueryForString() } returns null + + store.markConversationUnread(CONVERSATION_ID) + + verify(exactly = 0) { database.update(any(), any(), any(), any()) } + verify(exactly = 0) { MessagingContentProvider.notifyMessagesChanged(any()) } + } + + private companion object { + private const val CONVERSATION_ID = "conversation-42" + private const val MESSAGE_ID = "message-24" + private const val THREAD_ID = 7L + } +} diff --git a/app/src/test/kotlin/com/android/messaging/data/conversationlist/store/ConversationListStatusStoreTest.kt b/app/src/test/kotlin/com/android/messaging/data/conversationlist/store/ConversationListStatusStoreTest.kt new file mode 100644 index 000000000..50439319a --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/data/conversationlist/store/ConversationListStatusStoreTest.kt @@ -0,0 +1,66 @@ +package com.android.messaging.data.conversationlist.store + +import com.android.messaging.datamodel.DataModel +import com.android.messaging.datamodel.SyncManager +import com.android.messaging.receiver.SmsReceiver +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.runs +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.After +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +internal class ConversationListStatusStoreTest { + + private val dataModel = mockk(relaxed = true) + private val syncManager = mockk() + + private val store = ConversationListStatusStoreImpl() + + @Before + fun setUp() { + mockkStatic(DataModel::class) + mockkStatic(SmsReceiver::class) + + every { DataModel.get() } returns dataModel + every { dataModel.syncManager } returns syncManager + every { SmsReceiver.cancelSecondaryUserNotification() } just runs + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun hasFirstSyncCompleted_returnsSyncManagerValue() { + every { syncManager.hasFirstSyncCompleted } returns true + + assertTrue(store.hasFirstSyncCompleted()) + } + + @Test + fun setNewestConversationVisible_visible_updatesStatusAndCancelsSecondaryNotification() { + store.setNewestConversationVisible(isVisible = true) + + verify { dataModel.isConversationListScrolledToNewestConversation = true } + verify { SmsReceiver.cancelSecondaryUserNotification() } + } + + @Test + fun setNewestConversationVisible_notVisible_updatesStatusWithoutCancellingNotification() { + store.setNewestConversationVisible(isVisible = false) + + verify(exactly = 1) { + dataModel.isConversationListScrolledToNewestConversation = false + } + verify(exactly = 0) { + SmsReceiver.cancelSecondaryUserNotification() + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/ActivityPermissionGateTest.kt b/app/src/test/kotlin/com/android/messaging/ui/ActivityPermissionGateTest.kt new file mode 100644 index 000000000..012364890 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/ActivityPermissionGateTest.kt @@ -0,0 +1,94 @@ +package com.android.messaging.ui + +import com.android.messaging.ui.conversation.LaunchConversationActivity +import com.android.messaging.ui.license.LicenseActivity +import com.android.messaging.ui.permissioncheck.PermissionCheckActivity +import com.android.messaging.ui.photoviewer.BuglePhotoViewActivity +import java.io.File +import javax.xml.parsers.DocumentBuilderFactory +import org.junit.Assert.assertTrue +import org.junit.Test +import org.w3c.dom.Element + +internal class ActivityPermissionGateTest { + + private val applicationId = "com.android.messaging" + + private val gatingBases = setOf( + BugleComponentActivity::class.java, + BugleActionBarActivity::class.java, + BaseBugleActivity::class.java, + BaseBugleFragmentActivity::class.java, + ) + + private val intentionallyUngated = setOf( + PermissionCheckActivity::class.java, + LaunchConversationActivity::class.java, + BuglePhotoViewActivity::class.java, + LicenseActivity::class.java, + TestActivity::class.java, + ClassZeroActivity::class.java, + ) + + @Test + fun everyManifestActivityIsPermissionGated() { + val activityNames = manifestActivityNames() + + assertTrue( + "No entries parsed from the manifest; the gate test is not exercising anything", + activityNames.isNotEmpty(), + ) + + val offenders = activityNames + .map { name -> Class.forName(name) } + .filterNot { activityClass -> activityClass in intentionallyUngated } + .filterNot { activityClass -> isGated(activityClass) } + .map { it.name } + + assertTrue( + "Activities not extending a permission-gated base. Extend BugleComponentActivity, " + + "or add to the allowlist in this test with a reason: $offenders", + offenders.isEmpty(), + ) + } + + private fun isGated(activityClass: Class<*>): Boolean { + return gatingBases.any { base -> base.isAssignableFrom(activityClass) } + } + + private fun manifestActivityNames(): List { + val document = DocumentBuilderFactory.newInstance() + .newDocumentBuilder() + .parse(manifestFile()) + + val activities = document.getElementsByTagName("activity") + return (0 until activities.length) + .map { index -> activities.item(index) as Element } + .map { element -> element.getAttribute("android:name") } + .filter { name -> name.isNotEmpty() } + .map { name -> resolveClassName(name) } + } + + private fun resolveClassName(name: String): String { + return when { + name.startsWith(".") -> applicationId + name + !name.contains(".") -> "$applicationId.$name" + else -> name + } + } + + private fun manifestFile(): File { + val workingDir = requireNotNull(System.getProperty("user.dir")) + var directory: File? = File(workingDir) + + while (directory != null) { + val candidate = File(directory, "AndroidManifest.xml") + if (candidate.exists()) { + return candidate + } + directory = directory.parentFile + } + + error("Could not locate AndroidManifest.xml from $workingDir") + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/common/components/reorder/OverlayReorderAnimationControllerTest.kt b/app/src/test/kotlin/com/android/messaging/ui/common/components/reorder/OverlayReorderAnimationControllerTest.kt new file mode 100644 index 000000000..234219d62 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/common/components/reorder/OverlayReorderAnimationControllerTest.kt @@ -0,0 +1,79 @@ +package com.android.messaging.ui.common.components.reorder + +import androidx.compose.ui.geometry.Rect +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class OverlayReorderAnimationControllerTest { + + private val controller = OverlayReorderAnimationController( + key = { it }, + isSettled = { _, _ -> true }, + ) + + @Test + fun prepare_withKnownItemAndBounds_createsHiddenPendingAnimation() { + seedItem("a") + + controller.prepare( + keys = listOf("a"), + anchorToTop = true, + transform = { it }, + ) + + val animation = controller.animations.single() + assertEquals("a", animation.key) + assertFalse(animation.isCommitted) + assertFalse(animation.isStarted) + assertTrue(controller.isItemHidden("a")) + } + + @Test + fun startAnimation_onlySucceedsOnceAndAfterCommit() { + seedItem("a") + controller.prepare( + keys = listOf("a"), + anchorToTop = true, + transform = { it }, + ) + + val animationId = controller.animations.single().animationId + assertNull(controller.startAnimation(animationId)) + + controller.markCommitted() + + assertEquals(true, controller.startAnimation(animationId)?.isStarted) + assertNull(controller.startAnimation(animationId)) + } + + @Test + fun finish_removesAnimationAndUnhidesItem() { + seedItem("a") + controller.prepare( + keys = listOf("a"), + anchorToTop = true, + transform = { it }, + ) + + val animationId = controller.animations.single().animationId + controller.finish(animationId) + + assertTrue(controller.animations.isEmpty()) + assertFalse(controller.isItemHidden("a")) + } + + private fun seedItem(itemKey: String) { + controller.updateItems(listOf(itemKey)) + controller.updateContainerBounds(Rect(left = 0f, top = 0f, right = 100f, bottom = 1_000f)) + controller.updateItemBounds( + itemKey = itemKey, + boundsInRoot = Rect(left = 0f, top = 0f, right = 100f, bottom = 50f), + isPhysicallyVisible = true, + firstVisibleItemIndex = 0, + lastVisibleItemIndex = 0, + ) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/common/components/reorder/OverlayReorderGeometryTest.kt b/app/src/test/kotlin/com/android/messaging/ui/common/components/reorder/OverlayReorderGeometryTest.kt new file mode 100644 index 000000000..a7393aea9 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/common/components/reorder/OverlayReorderGeometryTest.kt @@ -0,0 +1,178 @@ +package com.android.messaging.ui.common.components.reorder + +import androidx.compose.ui.geometry.Rect +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +internal class OverlayReorderGeometryTest { + + private val geometry = OverlayReorderGeometry() + + @Test + fun isAcceptableTarget_notCommitted_isRejected() { + assertFalse(isAcceptableTarget(isCommitted = false)) + } + + @Test + fun isAcceptableTarget_alreadyStarted_isRejected() { + assertFalse(isAcceptableTarget(isStarted = true)) + } + + @Test + fun isAcceptableTarget_notPhysicallyVisible_isRejected() { + assertFalse(isAcceptableTarget(isPhysicallyVisible = false)) + } + + @Test + fun isAcceptableTarget_notLogicallyVisible_isRejected() { + assertFalse(isAcceptableTarget(isLogicallyVisible = false)) + } + + @Test + fun isAcceptableTarget_modelNotYetSettled_isRejected() { + assertFalse(isAcceptableTarget(isModelSettled = false)) + } + + @Test + fun isAcceptableTarget_movesUpToHigherPosition_isAccepted() { + assertTrue( + isAcceptableTarget( + sourceIndex = 3, + sourceTop = 300f, + candidateTop = 0f, + targetIndex = 0, + ), + ) + } + + @Test + fun isAcceptableTarget_movesUpButCandidateNotAboveSource_isRejected() { + assertFalse( + isAcceptableTarget( + sourceIndex = 3, + sourceTop = 300f, + candidateTop = 300f, + targetIndex = 0, + ), + ) + } + + @Test + fun isAcceptableTarget_movesDownToLowerPosition_isAccepted() { + assertTrue( + isAcceptableTarget( + sourceIndex = 0, + sourceTop = 0f, + candidateTop = 400f, + targetIndex = 4, + ), + ) + } + + @Test + fun isAcceptableTarget_movesDownButShiftWithinEpsilon_isRejected() { + assertFalse( + isAcceptableTarget( + sourceIndex = 0, + sourceTop = 0f, + candidateTop = TARGET_POSITION_EPSILON_PX, + targetIndex = 4, + ), + ) + } + + @Test + fun isAcceptableTarget_movesDownJustBeyondEpsilon_isAccepted() { + assertTrue( + isAcceptableTarget( + sourceIndex = 0, + sourceTop = 0f, + candidateTop = TARGET_POSITION_EPSILON_PX + 0.5f, + targetIndex = 4, + ), + ) + } + + @Test + fun isAcceptableTarget_movesUpButShiftWithinEpsilon_isRejected() { + assertFalse( + isAcceptableTarget( + sourceIndex = 3, + sourceTop = 300f, + candidateTop = 300f - TARGET_POSITION_EPSILON_PX, + targetIndex = 0, + ), + ) + } + + @Test + fun isAcceptableTarget_sameIndex_isAccepted() { + assertTrue( + isAcceptableTarget( + sourceIndex = 2, + sourceTop = 200f, + candidateTop = 200f, + targetIndex = 2, + ), + ) + } + + @Test + fun fallbackTarget_anchorToTop_movesToContentTopWithinContainer() { + val source = Rect(left = 10f, top = 500f, right = 110f, bottom = 560f) + + val target = geometry.fallbackTarget( + sourceBounds = source, + anchorToTop = true, + contentTopInRoot = 80f, + containerBounds = Rect(left = 0f, top = 30f, right = 200f, bottom = 1_000f), + ) + + assertEquals(50f, target.top) + assertEquals(source.left, target.left) + assertEquals(source.right, target.right) + assertEquals(source.height, target.height) + } + + @Test + fun fallbackTarget_notAnchorToTop_movesBelowContainerBottom() { + val source = Rect(left = 10f, top = 100f, right = 110f, bottom = 160f) + val containerBounds = Rect(left = 0f, top = 0f, right = 200f, bottom = 1_000f) + + val target = geometry.fallbackTarget( + sourceBounds = source, + anchorToTop = false, + contentTopInRoot = 0f, + containerBounds = containerBounds, + ) + + assertTrue(target.top > containerBounds.height) + assertEquals(source.height, target.height) + } + + private fun isAcceptableTarget( + isCommitted: Boolean = true, + isStarted: Boolean = false, + sourceIndex: Int = 3, + sourceTop: Float = 300f, + candidateTop: Float = 0f, + targetIndex: Int = 0, + isPhysicallyVisible: Boolean = true, + isLogicallyVisible: Boolean = true, + isModelSettled: Boolean = true, + ): Boolean { + return geometry.isAcceptableTarget( + isCommitted = isCommitted, + isStarted = isStarted, + sourceIndex = sourceIndex, + sourceTop = sourceTop, + candidateTop = candidateTop, + targetIndex = targetIndex, + isPhysicallyVisible = isPhysicallyVisible, + isLogicallyVisible = isLogicallyVisible, + isModelSettled = isModelSettled, + ) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/ConversationViewModelSimSelectionTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/ConversationViewModelSimSelectionTest.kt index f8b9cda95..e13c32b68 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/ConversationViewModelSimSelectionTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/ConversationViewModelSimSelectionTest.kt @@ -6,9 +6,9 @@ import com.android.messaging.data.subscription.model.Subscription import com.android.messaging.data.subscription.repository.ConversationSimSelectionRepository import com.android.messaging.data.subscription.repository.SubscriptionsRepository import com.android.messaging.domain.conversation.usecase.action.CreateDefaultSmsRoleRequest +import com.android.messaging.domain.conversation.usecase.participant.CanAddContact import com.android.messaging.domain.conversation.usecase.participant.CanAddMoreConversationParticipants -import com.android.messaging.domain.conversation.usecase.telephony.IsDeviceVoiceCapable -import com.android.messaging.domain.conversation.usecase.telephony.IsEmergencyPhoneNumber +import com.android.messaging.domain.conversation.usecase.telephony.CanPlacePhoneCall import com.android.messaging.testutil.MainDispatcherRule import com.android.messaging.ui.conversation.audio.delegate.ConversationAudioRecordingDelegate import com.android.messaging.ui.conversation.audio.model.ConversationAudioRecordingUiState @@ -66,8 +66,8 @@ internal class ConversationViewModelSimSelectionTest { private val subscriptionsRepository = mockk() private val canAddMoreConversationParticipants = mockk() private val createDefaultSmsRoleRequest = mockk() - private val isDeviceVoiceCapable = mockk() - private val isEmergencyPhoneNumber = mockk() + private val canAddContact = mockk() + private val canPlacePhoneCall = mockk() @Before fun setUp() { @@ -293,8 +293,8 @@ internal class ConversationViewModelSimSelectionTest { simSelectionRepository = simSelectionRepository, canAddMoreConversationParticipants = canAddMoreConversationParticipants, createDefaultSmsRoleRequest = createDefaultSmsRoleRequest, - isDeviceVoiceCapable = isDeviceVoiceCapable, - isEmergencyPhoneNumber = isEmergencyPhoneNumber, + canAddContact = canAddContact, + canPlacePhoneCall = canPlacePhoneCall, defaultDispatcher = mainDispatcherRule.testDispatcher, savedStateHandle = SavedStateHandle(), ) diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/ConversationListTestFixtures.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/ConversationListTestFixtures.kt new file mode 100644 index 000000000..70c91d284 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/ConversationListTestFixtures.kt @@ -0,0 +1,83 @@ +package com.android.messaging.ui.conversationlist + +import com.android.messaging.data.conversationlist.model.ConversationListDraft +import com.android.messaging.data.conversationlist.model.ConversationListItem +import com.android.messaging.data.conversationlist.model.ConversationListLatestMessage +import com.android.messaging.data.conversationlist.model.ConversationListMessageStatus +import com.android.messaging.data.conversationlist.model.ConversationListNotification +import com.android.messaging.data.conversationlist.model.ConversationListParticipant +import com.android.messaging.data.conversationlist.model.ConversationListSnapshot +import com.android.messaging.data.conversationsettings.model.SNOOZE_NEVER_EXPIRES +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf + +internal fun snapshotOfIds(vararg conversationIds: String): ConversationListSnapshot { + return snapshotOfItems(*conversationIds.map(::conversationItem).toTypedArray()) +} + +internal fun snapshotOf(vararg items: ConversationListItem): ConversationListSnapshot { + return snapshotOfItems(*items) +} + +internal fun snapshotOfItems(vararg items: ConversationListItem): ConversationListSnapshot { + return ConversationListSnapshot( + items = persistentListOf(*items), + blockedDestinations = persistentSetOf(), + hasFirstSyncCompleted = true, + ) +} + +internal fun conversationItem( + conversationId: String, + isArchived: Boolean = false, + isPinned: Boolean = false, + isSnoozed: Boolean = false, + isRead: Boolean = true, + timestamp: Long = 1_000L, + senderName: String? = null, + contactId: Long = -1L, + lookupKey: String? = null, + isDraftVisible: Boolean = false, + draftSnippet: String? = null, + draftSubject: String? = null, +): ConversationListItem { + return ConversationListItem( + conversationId = conversationId, + title = "Title $conversationId", + icon = null, + subject = null, + isArchived = isArchived, + isPinned = isPinned, + participant = ConversationListParticipant( + contactId = contactId, + lookupKey = lookupKey, + otherNormalizedDestination = "+1555000$conversationId", + isGroup = false, + isEnterprise = false, + ), + latestMessage = ConversationListLatestMessage( + isRead = isRead, + timestamp = timestamp, + snippetText = "Snippet $conversationId", + previewUri = null, + previewContentType = null, + status = ConversationListMessageStatus.Normal, + isIncoming = true, + senderName = senderName, + ), + draft = ConversationListDraft( + isVisible = isDraftVisible, + snippetText = draftSnippet, + previewUri = null, + previewContentType = null, + subject = draftSubject, + ), + notification = ConversationListNotification( + isEnabled = true, + snoozedUntilMillis = when { + isSnoozed -> SNOOZE_NEVER_EXPIRES + else -> ConversationListNotification.SNOOZE_NOT_SET + }, + ), + ) +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/ConversationListViewModelTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/ConversationListViewModelTest.kt new file mode 100644 index 000000000..2b81d80a2 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/ConversationListViewModelTest.kt @@ -0,0 +1,240 @@ +package com.android.messaging.ui.conversationlist + +import app.cash.turbine.test +import com.android.messaging.data.conversationlist.model.ConversationListSnapshot +import com.android.messaging.data.conversationlist.repository.ConversationListRepository +import com.android.messaging.data.debug.DebugFeaturesProvider +import com.android.messaging.testutil.MainDispatcherRule +import com.android.messaging.ui.conversationlist.delegate.ConversationListActionsDelegate +import com.android.messaging.ui.conversationlist.delegate.ConversationListOptimisticSnapshotDelegate +import com.android.messaging.ui.conversationlist.delegate.ConversationListSelectionDelegate +import com.android.messaging.ui.conversationlist.mapper.ConversationListUiStateMapper +import com.android.messaging.ui.conversationlist.model.ConversationListAction as Action +import com.android.messaging.ui.conversationlist.model.ConversationListEffect as Effect +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ConversationListViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val repository = mockk() + private val uiStateMapper = mockk() + private val selectionDelegate = mockk() + private val actionsDelegate = mockk() + private val optimisticSnapshotDelegate = mockk() + private val debugFeaturesProvider = mockk() + + private val snapshotFlow = MutableStateFlow(null) + private val selectedIdsFlow = MutableStateFlow>(persistentListOf()) + + @Test + fun init_bindsDelegates() { + createViewModel() + + verify(exactly = 1) { optimisticSnapshotDelegate.bind(any()) } + verify(exactly = 1) { selectionDelegate.bind(any(), snapshotFlow) } + verify(exactly = 1) { actionsDelegate.bind(any()) } + } + + @Test + fun archiveClicked_archivesOptimisticallyAndPersistsWithSnackbar() { + selectedIdsFlow.value = persistentListOf("a") + snapshotFlow.value = snapshotOf(conversationItem("a")) + + val viewModel = createViewModel() + viewModel.onAction(Action.ArchiveClicked) + + verify { optimisticSnapshotDelegate.archive(listOf("a")) } + verify { + actionsDelegate.setArchived( + conversationIds = listOf("a"), + isArchived = true, + shouldShowSnackbar = true, + ) + } + verify { selectionDelegate.clear() } + } + + @Test + fun swipeToggleRead_marksUnreadConversationReadOptimisticallyAndPersists() { + snapshotFlow.value = snapshotOf(conversationItem("a", isRead = false)) + + val viewModel = createViewModel() + viewModel.onAction(Action.ConversationSwipedToToggleRead("a")) + + verify { optimisticSnapshotDelegate.markRead(listOf("a"), isRead = true) } + verify { actionsDelegate.setRead(listOf("a"), isRead = true) } + } + + @Test + fun pinClicked_emitsPrepareAnimationEffectForSelection() { + runTest(context = mainDispatcherRule.testDispatcher) { + selectedIdsFlow.value = persistentListOf("a") + snapshotFlow.value = snapshotOf(conversationItem("a")) + + val viewModel = createViewModel() + viewModel.effects.test { + viewModel.onAction(Action.PinClicked) + + assertEquals( + Effect.PreparePinAnimation( + conversationIds = persistentListOf("a"), + isPinned = true, + ), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun pinAnimationPrepared_commitsPinChangeAndClearsSelection() { + val viewModel = createViewModel() + viewModel.onAction( + Action.PinAnimationPrepared( + conversationIds = persistentListOf("a"), + isPinned = true, + ), + ) + + verify { optimisticSnapshotDelegate.pin(listOf("a"), isPinned = true) } + verify { actionsDelegate.setPinned(listOf("a"), isPinned = true) } + verify { selectionDelegate.clear() } + } + + @Test + fun conversationClicked_withoutSelection_emitsOpenConversation() { + runTest(context = mainDispatcherRule.testDispatcher) { + snapshotFlow.value = snapshotOf(conversationItem("a")) + + val viewModel = createViewModel() + viewModel.effects.test { + viewModel.onAction(Action.ConversationClicked("a")) + + assertEquals(Effect.OpenConversation("a"), awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun startChatClicked_emitsStartChatEffect() { + runTest(context = mainDispatcherRule.testDispatcher) { + val viewModel = createViewModel() + viewModel.effects.test { + viewModel.onAction(Action.StartChatClicked) + + assertEquals(Effect.StartChat, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun archiveSnackbarDismissed_discardsArchivedItems() { + val viewModel = createViewModel() + viewModel.onAction( + Action.ArchiveSnackbarDismissed( + conversationIds = persistentListOf("a", "b"), + ), + ) + + verify { optimisticSnapshotDelegate.discardArchived(listOf("a", "b")) } + } + + @Test + fun archiveUndoClicked_restoresItemsAndPersistsUnarchivedState() { + val viewModel = createViewModel() + viewModel.onAction( + Action.ArchiveUndoClicked( + conversationIds = persistentListOf("a"), + isArchived = true, + ), + ) + + verify { optimisticSnapshotDelegate.restoreArchived(listOf("a")) } + verify { + actionsDelegate.setArchived( + conversationIds = listOf("a"), + isArchived = false, + shouldShowSnackbar = false, + ) + } + } + + @Test + fun unarchiveUndoClicked_archivesItemsAndPersistsArchivedState() { + val viewModel = createViewModel() + viewModel.onAction( + Action.ArchiveUndoClicked( + conversationIds = persistentListOf("a"), + isArchived = false, + ), + ) + + verify { optimisticSnapshotDelegate.archive(listOf("a")) } + verify { + actionsDelegate.setArchived( + conversationIds = listOf("a"), + isArchived = true, + shouldShowSnackbar = false, + ) + } + } + + private fun createViewModel(): ConversationListViewModel { + every { repository.refresh() } just runs + every { repository.setNewestConversationVisible(any()) } just runs + + every { optimisticSnapshotDelegate.snapshot } returns snapshotFlow + every { optimisticSnapshotDelegate.bind(any()) } just runs + every { optimisticSnapshotDelegate.archive(any()) } just runs + every { optimisticSnapshotDelegate.discardArchived(any()) } just runs + every { optimisticSnapshotDelegate.restoreArchived(any()) } just runs + every { optimisticSnapshotDelegate.markRead(any(), any()) } just runs + every { optimisticSnapshotDelegate.pin(any(), any()) } just runs + + every { selectionDelegate.selectedIds } returns selectedIdsFlow + every { selectionDelegate.bind(any(), any()) } just runs + every { selectionDelegate.toggle(any()) } just runs + every { selectionDelegate.clear() } just runs + + every { actionsDelegate.effects } returns emptyFlow() + every { actionsDelegate.bind(any()) } just runs + every { actionsDelegate.setArchived(any(), any(), any()) } just runs + every { actionsDelegate.setPinned(any(), any()) } just runs + every { actionsDelegate.setRead(any(), any()) } just runs + every { actionsDelegate.snooze(any(), any()) } just runs + every { actionsDelegate.unsnooze(any()) } just runs + every { actionsDelegate.delete(any()) } just runs + every { actionsDelegate.block(any(), any()) } just runs + every { actionsDelegate.unblock(any(), any()) } just runs + + every { debugFeaturesProvider.isEnabled() } returns false + + return ConversationListViewModel( + repository = repository, + uiStateMapper = uiStateMapper, + selectionDelegate = selectionDelegate, + actionsDelegate = actionsDelegate, + optimisticSnapshotDelegate = optimisticSnapshotDelegate, + debugFeaturesProvider = debugFeaturesProvider, + ) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListActionsDelegateImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListActionsDelegateImplTest.kt new file mode 100644 index 000000000..cde713008 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListActionsDelegateImplTest.kt @@ -0,0 +1,308 @@ +package com.android.messaging.ui.conversationlist.delegate + +import app.cash.turbine.test +import com.android.messaging.data.blockedparticipants.repository.BlockedParticipantsRepository +import com.android.messaging.data.conversation.repository.ConversationsRepository +import com.android.messaging.data.conversationlist.repository.ConversationListRepository +import com.android.messaging.data.conversationsettings.model.SnoozeOption +import com.android.messaging.ui.conversationlist.conversationItem +import com.android.messaging.ui.conversationlist.model.ConversationListEffect +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.verify +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +internal class ConversationListActionsDelegateImplTest { + + @Test + fun setPinned_pinsEachDistinctNonBlankConversation() = runTest { + val harness = createHarness() + + harness.delegate.setPinned( + conversationIds = listOf("a", "", " ", "b", "a"), + isPinned = true, + ) + runCurrent() + + coVerify(exactly = 1) { harness.conversationsRepository.pinConversation("a") } + coVerify(exactly = 1) { harness.conversationsRepository.pinConversation("b") } + coVerify(exactly = 0) { harness.conversationsRepository.pinConversation("") } + coVerify(exactly = 0) { harness.conversationsRepository.pinConversation(" ") } + } + + @Test + fun setPinned_unpinsWhenNotPinned() = runTest { + val harness = createHarness() + + harness.delegate.setPinned( + conversationIds = listOf("a"), + isPinned = false, + ) + runCurrent() + + coVerify(exactly = 1) { harness.conversationsRepository.unpinConversation("a") } + coVerify(exactly = 0) { harness.conversationsRepository.pinConversation(any()) } + } + + @Test + fun setPinned_emptyIds_doesNothing() = runTest { + val harness = createHarness() + + harness.delegate.setPinned( + conversationIds = emptyList(), + isPinned = true, + ) + runCurrent() + + coVerify(exactly = 0) { harness.conversationsRepository.pinConversation(any()) } + coVerify(exactly = 0) { harness.conversationsRepository.unpinConversation(any()) } + } + + @Test + fun setPinned_blankOnlyIds_doesNothing() = runTest { + val harness = createHarness() + + harness.delegate.setPinned( + conversationIds = listOf("", " "), + isPinned = true, + ) + runCurrent() + + coVerify(exactly = 0) { harness.conversationsRepository.pinConversation(any()) } + } + + @Test + fun setRead_marksEachDistinctNonBlankConversation() = runTest { + val harness = createHarness() + + harness.delegate.setRead( + conversationIds = listOf("a", "", " ", "b", "a"), + isRead = true, + ) + runCurrent() + + coVerify(exactly = 1) { harness.conversationsRepository.markConversationRead("a") } + coVerify(exactly = 1) { harness.conversationsRepository.markConversationRead("b") } + coVerify(exactly = 0) { harness.conversationsRepository.markConversationUnread(any()) } + } + + @Test + fun setRead_marksUnreadWhenNotRead() = runTest { + val harness = createHarness() + + harness.delegate.setRead( + conversationIds = listOf("a"), + isRead = false, + ) + runCurrent() + + coVerify(exactly = 1) { harness.conversationsRepository.markConversationUnread("a") } + coVerify(exactly = 0) { harness.conversationsRepository.markConversationRead(any()) } + } + + @Test + fun snooze_snoozesEachDistinctNonBlankConversation() = runTest { + val harness = createHarness() + + harness.delegate.snooze( + conversationIds = listOf("a", "", " ", "b", "a"), + option = SnoozeOption.OneHour, + ) + + verify(exactly = 1) { + harness.conversationListRepository.snooze("a", SnoozeOption.OneHour) + } + verify(exactly = 1) { + harness.conversationListRepository.snooze("b", SnoozeOption.OneHour) + } + } + + @Test + fun unsnooze_clearsEachDistinctNonBlankConversation() = runTest { + val harness = createHarness() + + harness.delegate.unsnooze(listOf("a", "", " ", "b", "a")) + + verify(exactly = 1) { harness.conversationListRepository.clearSnooze("a") } + verify(exactly = 1) { harness.conversationListRepository.clearSnooze("b") } + } + + @Test + fun setArchived_withSnackbar_archivesAndEmitsStatusEffect() = runTest { + val harness = createHarness() + + harness.delegate.effects.test { + harness.delegate.setArchived( + conversationIds = listOf("a", "", "a", "b"), + isArchived = true, + shouldShowSnackbar = true, + ) + + verify(exactly = 1) { harness.conversationsRepository.archiveConversation("a") } + verify(exactly = 1) { harness.conversationsRepository.archiveConversation("b") } + assertEquals( + ConversationListEffect.ArchiveStatusChanged( + conversationIds = persistentListOf("a", "b"), + isArchived = true, + ), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun setArchived_withoutSnackbar_archivesWithoutEmittingEffect() = runTest { + val harness = createHarness() + + harness.delegate.effects.test { + harness.delegate.setArchived( + conversationIds = listOf("a"), + isArchived = true, + shouldShowSnackbar = false, + ) + + verify(exactly = 1) { harness.conversationsRepository.archiveConversation("a") } + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun setArchived_unarchivesAndEmitsStatusEffect() = runTest { + val harness = createHarness() + + harness.delegate.effects.test { + harness.delegate.setArchived( + conversationIds = listOf("a"), + isArchived = false, + shouldShowSnackbar = true, + ) + + verify(exactly = 1) { harness.conversationsRepository.unarchiveConversation("a") } + assertEquals( + ConversationListEffect.ArchiveStatusChanged( + conversationIds = persistentListOf("a"), + isArchived = false, + ), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun block_emitsResultFromRepository() = runTest { + val harness = createHarness() + coEvery { + harness.blockedParticipantsRepository.setDestinationBlocked( + destination = "+15551234", + conversationId = "conv", + isBlocked = true, + ) + } returns true + + harness.delegate.effects.test { + harness.delegate.block(conversationId = "conv", destination = "+15551234") + + assertEquals( + ConversationListEffect.ConversationBlocked( + conversationId = "conv", + destination = "+15551234", + success = true, + ), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun block_blankDestination_doesNothing() = runTest { + val harness = createHarness() + + harness.delegate.effects.test { + harness.delegate.block(conversationId = "conv", destination = " ") + + coVerify(exactly = 0) { + harness.blockedParticipantsRepository.setDestinationBlocked(any(), any(), any()) + } + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun unblock_clearsBlockedState() = runTest { + val harness = createHarness() + coEvery { + harness.blockedParticipantsRepository.setDestinationBlocked( + destination = "+15551234", + conversationId = "conv", + isBlocked = false, + ) + } returns true + + harness.delegate.unblock(conversationId = "conv", destination = "+15551234") + runCurrent() + + coVerify(exactly = 1) { + harness.blockedParticipantsRepository.setDestinationBlocked( + destination = "+15551234", + conversationId = "conv", + isBlocked = false, + ) + } + } + + @Test + fun delete_routesEachItemWithItsLatestMessageTimestamp() = runTest { + val harness = createHarness() + + harness.delegate.delete( + listOf( + conversationItem("a", timestamp = 5_000L), + conversationItem("b", timestamp = 7_000L), + ), + ) + + verify(exactly = 1) { harness.conversationsRepository.deleteConversation("a", 5_000L) } + verify(exactly = 1) { harness.conversationsRepository.deleteConversation("b", 7_000L) } + } + + private fun TestScope.createHarness(): Harness { + val conversationsRepository = mockk(relaxed = true) + val conversationListRepository = mockk(relaxed = true) + val blockedParticipantsRepository = + mockk(relaxed = true) + val delegate = ConversationListActionsDelegateImpl( + conversationsRepository = conversationsRepository, + conversationListRepository = conversationListRepository, + blockedParticipantsRepository = blockedParticipantsRepository, + ).apply { + bind(backgroundScope) + } + + return Harness( + conversationsRepository = conversationsRepository, + conversationListRepository = conversationListRepository, + blockedParticipantsRepository = blockedParticipantsRepository, + delegate = delegate, + ) + } + + private class Harness( + val conversationsRepository: ConversationsRepository, + val conversationListRepository: ConversationListRepository, + val blockedParticipantsRepository: BlockedParticipantsRepository, + val delegate: ConversationListActionsDelegateImpl, + ) +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticReducerTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticReducerTest.kt new file mode 100644 index 000000000..f0982510d --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticReducerTest.kt @@ -0,0 +1,252 @@ +package com.android.messaging.ui.conversationlist.delegate + +import com.android.messaging.data.conversationlist.model.ConversationListItem +import com.android.messaging.ui.conversationlist.conversationItem +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +internal class ConversationListOptimisticReducerTest { + + private val reducer = ConversationListOptimisticReducer() + + @Test + fun apply_emptyOverrides_returnsItemsUnchanged() { + val items = persistentListOf( + conversationItem("a"), + conversationItem("b"), + ) + + val result = reducer.apply( + items = items, + overrides = ConversationListOptimisticOverrides(), + ) + + assertEquals(items, result) + } + + @Test + fun apply_archivedOverride_removesItem() { + val archivedItem = conversationItem("a") + val items = persistentListOf( + archivedItem, + conversationItem("b"), + ) + + val result = reducer.apply( + items = items, + overrides = ConversationListOptimisticOverrides( + archiveById = persistentMapOf( + "a" to ConversationArchiveOverride.Archived(archivedItem), + ), + ), + ) + + assertEquals(listOf("b"), result.conversationIds()) + } + + @Test + fun apply_readOverride_updatesStateWithoutReordering() { + val items = persistentListOf( + conversationItem("a", isRead = false, timestamp = 1_000L), + conversationItem("b", timestamp = 2_000L), + ) + + val result = reducer.apply( + items = items, + overrides = ConversationListOptimisticOverrides( + readById = persistentMapOf("a" to true), + ), + ) + + assertEquals(listOf("a", "b"), result.conversationIds()) + assertTrue(result.first().latestMessage.isRead) + } + + @Test + fun apply_pinOverride_reordersByPinThenTimestamp() { + val items = persistentListOf( + conversationItem("a", timestamp = 3_000L), + conversationItem("b", isPinned = true, timestamp = 2_000L), + conversationItem("c", timestamp = 1_000L), + ) + + val result = reducer.apply( + items = items, + overrides = ConversationListOptimisticOverrides( + pinnedById = persistentMapOf("c" to true), + ), + ) + + assertEquals(listOf("b", "c", "a"), result.conversationIds()) + assertTrue(result.first { it.conversationId == "c" }.isPinned) + } + + @Test + fun apply_unpinOverride_movesItemIntoTimestampOrder() { + val items = persistentListOf( + conversationItem("a", isPinned = true, timestamp = 1_000L), + conversationItem("b", timestamp = 3_000L), + ) + + val result = reducer.apply( + items = items, + overrides = ConversationListOptimisticOverrides( + pinnedById = persistentMapOf("a" to false), + ), + ) + + assertEquals(listOf("b", "a"), result.conversationIds()) + assertFalse(result.last().isPinned) + } + + @Test + fun apply_restoringItemMissingFromRawSnapshot_keepsItVisibleAndOrdered() { + val restoringItem = conversationItem( + conversationId = "c", + isPinned = true, + isRead = false, + timestamp = 2_000L, + ) + + val result = reducer.apply( + items = persistentListOf( + conversationItem("a", timestamp = 3_000L), + conversationItem("b", timestamp = 1_000L), + ), + overrides = ConversationListOptimisticOverrides( + archiveById = persistentMapOf( + "c" to ConversationArchiveOverride.Restoring( + item = restoringItem, + awaitingRemoval = false, + ), + ), + readById = persistentMapOf("c" to true), + ), + ) + + assertEquals(listOf("c", "a", "b"), result.conversationIds()) + assertTrue(result.first().latestMessage.isRead) + } + + @Test + fun apply_restoringItemAlreadyInRawSnapshot_doesNotDuplicateCachedItem() { + val cachedItem = conversationItem("a", isRead = false) + val rawItem = conversationItem("a", isRead = true) + + val result = reducer.apply( + items = persistentListOf(rawItem), + overrides = ConversationListOptimisticOverrides( + archiveById = persistentMapOf( + "a" to ConversationArchiveOverride.Restoring( + item = cachedItem, + awaitingRemoval = false, + ), + ), + ), + ) + + assertEquals(listOf("a"), result.conversationIds()) + assertTrue(result.single().latestMessage.isRead) + } + + @Test + fun prune_archivedItemMissingFromRawSnapshot_keepsItForUndo() { + val archivedItem = conversationItem("a") + val archivedOverride = ConversationArchiveOverride.Archived(archivedItem) + + val pruned = reducer.prune( + items = persistentListOf(), + overrides = ConversationListOptimisticOverrides( + archiveById = persistentMapOf("a" to archivedOverride), + ), + ) + + assertEquals(archivedOverride, pruned.archiveById["a"]) + } + + @Test + fun prune_restoreRace_retainsOverridesUntilRawSnapshotCatchesUp() { + val cachedItem = conversationItem( + conversationId = "a", + isPinned = false, + isRead = false, + ) + var overrides = ConversationListOptimisticOverrides( + archiveById = persistentMapOf( + "a" to ConversationArchiveOverride.Restoring( + item = cachedItem, + awaitingRemoval = true, + ), + ), + readById = persistentMapOf("a" to true), + pinnedById = persistentMapOf("a" to true), + ) + + overrides = reducer.prune( + items = persistentListOf(cachedItem), + overrides = overrides, + ) + assertEquals( + ConversationArchiveOverride.Restoring( + item = cachedItem, + awaitingRemoval = true, + ), + overrides.archiveById["a"], + ) + + overrides = reducer.prune( + items = persistentListOf(), + overrides = overrides, + ) + assertEquals( + ConversationArchiveOverride.Restoring( + item = cachedItem, + awaitingRemoval = false, + ), + overrides.archiveById["a"], + ) + assertTrue(overrides.readById.getValue("a")) + assertTrue(overrides.pinnedById.getValue("a")) + + overrides = reducer.prune( + items = persistentListOf(cachedItem), + overrides = overrides, + ) + assertFalse("a" in overrides.archiveById) + assertTrue(overrides.readById.getValue("a")) + assertTrue(overrides.pinnedById.getValue("a")) + + overrides = reducer.prune( + items = persistentListOf( + conversationItem( + conversationId = "a", + isPinned = true, + isRead = true, + ), + ), + overrides = overrides, + ) + assertTrue(overrides.isEmpty) + } + + @Test + fun prune_itemMissingAndNotRestoring_dropsReadAndPinOverrides() { + val pruned = reducer.prune( + items = persistentListOf(), + overrides = ConversationListOptimisticOverrides( + readById = persistentMapOf("a" to true), + pinnedById = persistentMapOf("a" to true), + ), + ) + + assertTrue(pruned.isEmpty) + } + + private fun List.conversationIds(): List { + return map(ConversationListItem::conversationId) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticSnapshotDelegateImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticSnapshotDelegateImplTest.kt new file mode 100644 index 000000000..32fa9d34d --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticSnapshotDelegateImplTest.kt @@ -0,0 +1,223 @@ +package com.android.messaging.ui.conversationlist.delegate + +import com.android.messaging.data.conversationlist.model.ConversationListItem +import com.android.messaging.data.conversationlist.model.ConversationListSnapshot +import com.android.messaging.data.conversationlist.repository.ConversationListRepository +import com.android.messaging.ui.conversationlist.conversationItem +import com.android.messaging.ui.conversationlist.snapshotOfIds +import com.android.messaging.ui.conversationlist.snapshotOfItems +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +internal class ConversationListOptimisticSnapshotDelegateImplTest { + + @Test + fun archive_removesItemFromEffectiveSnapshot() = runTest { + val delegate = bindDelegate(snapshotOfIds("a", "b")) + + delegate.archive(listOf("a")) + + assertEquals(listOf("b"), delegate.conversationIds()) + } + + @Test + fun pin_reordersEffectiveSnapshotToTop() = runTest { + val delegate = bindDelegate( + snapshotOfItems( + conversationItem("a", timestamp = 3_000L), + conversationItem("b", timestamp = 2_000L), + conversationItem("c", timestamp = 1_000L), + ), + ) + + delegate.pin( + conversationIds = listOf("c"), + isPinned = true, + ) + + assertEquals(listOf("c", "a", "b"), delegate.conversationIds()) + } + + @Test + fun markRead_overridesReadStateInEffectiveSnapshot() = runTest { + val delegate = bindDelegate( + snapshotOfItems( + conversationItem( + conversationId = "a", + isRead = false, + ), + ), + ) + + delegate.markRead( + conversationIds = listOf("a"), + isRead = true, + ) + + val item = requireNotNull(delegate.snapshot.value).items.single() + assertTrue(item.latestMessage.isRead) + } + + @Test + fun restoreArchived_afterArchive_bringsItemBack() = runTest { + val delegate = bindDelegate(snapshotOfIds("a", "b")) + + delegate.archive(listOf("a")) + delegate.restoreArchived(listOf("a")) + + assertTrue("a" in delegate.conversationIds()) + } + + @Test + fun readOverrideIsDropped_afterDatabaseCatchesUp() = runTest { + val rawSnapshot = MutableStateFlow( + snapshotOfItems( + conversationItem( + conversationId = "a", + isRead = false, + ), + ), + ) + + val delegate = bindDelegate(rawSnapshot) + delegate.markRead( + conversationIds = listOf("a"), + isRead = true, + ) + + rawSnapshot.value = snapshotOfItems( + conversationItem( + conversationId = "a", + isRead = true, + ), + ) + runCurrent() + + rawSnapshot.value = snapshotOfItems( + conversationItem( + conversationId = "a", + isRead = false, + ), + ) + runCurrent() + + val item = requireNotNull(delegate.snapshot.value).items.single() + assertFalse(item.latestMessage.isRead) + } + + @Test + fun discardArchived_afterDatabaseRemovesItem_keepsItemHidden() = runTest { + val rawSnapshot = MutableStateFlow(snapshotOfIds("a", "b")) + val delegate = bindDelegate(rawSnapshot) + + delegate.archive(listOf("a")) + rawSnapshot.value = snapshotOfIds("b") + runCurrent() + + delegate.discardArchived(listOf("a")) + + assertEquals(listOf("b"), delegate.conversationIds()) + } + + @Test + fun restoreThenDiscard_keepsRestoredItemVisible() = runTest { + val delegate = bindDelegate(snapshotOfIds("a", "b")) + + delegate.archive(listOf("a")) + delegate.restoreArchived(listOf("a")) + delegate.discardArchived(listOf("a")) + + assertTrue("a" in delegate.conversationIds()) + } + + @Test + fun pinOverrideIsDropped_afterDatabaseCatchesUp() = runTest { + val rawSnapshot = MutableStateFlow( + snapshotOfItems( + conversationItem("a", isPinned = false, timestamp = 1_000L), + conversationItem("b", timestamp = 2_000L), + ), + ) + val delegate = bindDelegate(rawSnapshot) + + delegate.pin( + conversationIds = listOf("a"), + isPinned = true, + ) + assertEquals(listOf("a", "b"), delegate.conversationIds()) + + rawSnapshot.value = snapshotOfItems( + conversationItem("a", isPinned = true, timestamp = 1_000L), + conversationItem("b", timestamp = 2_000L), + ) + runCurrent() + + rawSnapshot.value = snapshotOfItems( + conversationItem("b", timestamp = 2_000L), + conversationItem("a", isPinned = false, timestamp = 1_000L), + ) + runCurrent() + + val pinnedItem = delegate.snapshot.value + ?.items + ?.first { item -> item.conversationId == "a" } + + assertFalse(requireNotNull(pinnedItem).isPinned) + assertEquals(listOf("b", "a"), delegate.conversationIds()) + } + + @Test + fun bind_isIdempotent() = runTest { + val repository = mockk() + val delegate = ConversationListOptimisticSnapshotDelegateImpl( + repository = repository, + reducer = ConversationListOptimisticReducer(), + ) + every { repository.observeInboxSnapshot() } returns MutableStateFlow(snapshotOfIds("a")) + + delegate.bind(backgroundScope) + delegate.bind(backgroundScope) + runCurrent() + + verify(exactly = 1) { repository.observeInboxSnapshot() } + } + + private fun TestScope.bindDelegate( + rawSnapshot: ConversationListSnapshot, + ): ConversationListOptimisticSnapshotDelegateImpl { + return bindDelegate(MutableStateFlow(rawSnapshot)) + } + + private fun TestScope.bindDelegate( + rawSnapshot: MutableStateFlow, + ): ConversationListOptimisticSnapshotDelegateImpl { + val repository = mockk() + every { repository.observeInboxSnapshot() } returns rawSnapshot + + return ConversationListOptimisticSnapshotDelegateImpl( + repository = repository, + reducer = ConversationListOptimisticReducer(), + ).apply { + bind(backgroundScope) + runCurrent() + } + } + + private fun ConversationListOptimisticSnapshotDelegateImpl.conversationIds(): List { + return snapshot.value + ?.items + ?.map(ConversationListItem::conversationId) + .orEmpty() + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListSelectionDelegateImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListSelectionDelegateImplTest.kt new file mode 100644 index 000000000..9dd302513 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListSelectionDelegateImplTest.kt @@ -0,0 +1,61 @@ +package com.android.messaging.ui.conversationlist.delegate + +import com.android.messaging.data.conversationlist.model.ConversationListSnapshot +import com.android.messaging.ui.conversationlist.snapshotOfIds +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ConversationListSelectionDelegateImplTest { + + private val delegate = ConversationListSelectionDelegateImpl() + + @Test + fun toggle_addsThenRemovesConversation() { + delegate.toggle("a") + delegate.toggle("b") + + assertEquals(listOf("a", "b"), delegate.selectedIds.value) + + delegate.toggle("a") + + assertEquals(listOf("b"), delegate.selectedIds.value) + } + + @Test + fun toggle_blankConversationId_isIgnored() { + delegate.toggle(" ") + + assertTrue(delegate.selectedIds.value.isEmpty()) + } + + @Test + fun clear_removesAllSelection() { + delegate.toggle("a") + delegate.toggle("b") + + delegate.clear() + + assertTrue(delegate.selectedIds.value.isEmpty()) + } + + @Test + fun bind_dropsSelectionForConversationsMissingFromSnapshot() = runTest { + val snapshot = MutableStateFlow(snapshotOfIds("a", "b")) + delegate.bind(backgroundScope, snapshot) + runCurrent() + + delegate.toggle("a") + delegate.toggle("b") + + snapshot.value = snapshotOfIds("a") + runCurrent() + + assertEquals(listOf("a"), delegate.selectedIds.value) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/mapper/ConversationListUiStateMapperImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/mapper/ConversationListUiStateMapperImplTest.kt new file mode 100644 index 000000000..f595baab4 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/mapper/ConversationListUiStateMapperImplTest.kt @@ -0,0 +1,287 @@ +package com.android.messaging.ui.conversationlist.mapper + +import android.content.Context +import com.android.messaging.data.conversationlist.model.ConversationListMessageStatus +import com.android.messaging.data.conversationlist.model.ConversationListSnapshot +import com.android.messaging.domain.conversation.usecase.avatar.ResolveAvatarUri +import com.android.messaging.domain.conversation.usecase.participant.CanAddContact +import com.android.messaging.domain.conversation.usecase.participant.CanShowOrAddContact +import com.android.messaging.domain.conversation.usecase.participant.IsContactSaved +import com.android.messaging.domain.conversation.usecase.telephony.CanPlacePhoneCall +import com.android.messaging.ui.conversationlist.conversationItem +import com.android.messaging.ui.conversationlist.model.ConversationListContentUiState +import com.android.messaging.ui.conversationlist.model.ConversationListItemUiModel +import com.android.messaging.ui.conversationlist.model.ConversationListUiState +import com.android.messaging.ui.conversationlist.snapshotOf +import io.mockk.every +import io.mockk.mockk +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +internal class ConversationListUiStateMapperImplTest { + + private val canAddContact = mockk(relaxed = true) + private val canPlacePhoneCall = mockk(relaxed = true) + private val canShowOrAddContact = mockk(relaxed = true) + private val isContactSaved = mockk(relaxed = true) + private val resolveAvatarUri = mockk(relaxed = true) + + private val mapper = ConversationListUiStateMapperImpl( + context = mockk(relaxed = true), + canAddContact = canAddContact, + canPlacePhoneCall = canPlacePhoneCall, + canShowOrAddContact = canShowOrAddContact, + isContactSaved = isContactSaved, + resolveAvatarUri = resolveAvatarUri, + ) + + @Test + fun map_pinnedConversation_marksItemPinned() { + val state = mapper.map( + snapshot = snapshotOf( + conversationItem( + conversationId = "pinned", + isPinned = true, + ), + ), + selectedConversationIds = persistentListOf(), + isScrollToTopVisible = false, + isDebugEnabled = false, + ) + + assertTrue(singleItem(state).isPinned) + } + + @Test + fun map_unpinnedConversation_marksItemNotPinned() { + val state = mapper.map( + snapshot = snapshotOf( + conversationItem( + conversationId = "plain", + isPinned = false, + ), + ), + selectedConversationIds = persistentListOf(), + isScrollToTopVisible = false, + isDebugEnabled = false, + ) + + assertFalse(singleItem(state).isPinned) + } + + @Test + fun map_noSelection_leavesToggleStatesNull() { + val state = mapper.map( + snapshot = snapshotOf(conversationItem(conversationId = "a")), + selectedConversationIds = persistentListOf(), + isScrollToTopVisible = false, + isDebugEnabled = false, + ) + + val actions = state.selection.actions + assertEquals(0, state.selection.selectedCount) + assertNull(actions.firstSelectedIsPinned) + assertNull(actions.firstSelectedIsSnoozed) + assertNull(actions.firstSelectedIsUnread) + } + + @Test + fun map_singleSelectedPinnedSnoozedUnread_derivesToggleStatesFromSelection() { + val state = mapper.map( + snapshot = snapshotOf( + conversationItem( + conversationId = "selected", + isPinned = true, + isSnoozed = true, + isRead = false, + ), + ), + selectedConversationIds = persistentListOf("selected"), + isScrollToTopVisible = false, + isDebugEnabled = false, + ) + + val actions = state.selection.actions + assertTrue(requireNotNull(actions.firstSelectedIsPinned)) + assertTrue(requireNotNull(actions.firstSelectedIsSnoozed)) + assertTrue(requireNotNull(actions.firstSelectedIsUnread)) + } + + @Test + fun map_mixedSelection_togglesFollowFirstSelectedConversation() { + val state = mapper.map( + snapshot = snapshotOf( + conversationItem( + conversationId = "first", + isPinned = false, + isSnoozed = false, + ), + conversationItem( + conversationId = "second", + isPinned = true, + isSnoozed = true, + ), + ), + selectedConversationIds = persistentListOf("first", "second"), + isScrollToTopVisible = false, + isDebugEnabled = false, + ) + + val actions = state.selection.actions + assertFalse(requireNotNull(actions.firstSelectedIsPinned)) + assertFalse(requireNotNull(actions.firstSelectedIsSnoozed)) + } + + @Test + fun map_selection_exposesSelectedCount() { + val state = mapper.map( + snapshot = snapshotOf( + conversationItem(conversationId = "a"), + conversationItem(conversationId = "b"), + ), + selectedConversationIds = persistentListOf("a", "b"), + isScrollToTopVisible = false, + isDebugEnabled = false, + ) + + assertEquals(2, state.selection.selectedCount) + } + + @Test + fun map_senderName_preservesItForSnippetAccessibility() { + val state = mapper.map( + snapshot = snapshotOf( + conversationItem( + conversationId = "group", + senderName = "Jane", + ), + ), + selectedConversationIds = persistentListOf(), + isScrollToTopVisible = false, + isDebugEnabled = false, + ) + + assertEquals("Jane", singleItem(state).snippet.senderName) + } + + @Test + fun map_itemsPresent_producesContentItems() { + val state = mapper.map( + snapshot = snapshotOf(conversationItem(conversationId = "a")), + selectedConversationIds = persistentListOf(), + isScrollToTopVisible = false, + isDebugEnabled = false, + ) + + assertTrue(state.content is ConversationListContentUiState.Items) + } + + @Test + fun map_emptyAfterFirstSync_producesEmptyContent() { + val state = mapper.map( + snapshot = snapshotOf(), + selectedConversationIds = persistentListOf(), + isScrollToTopVisible = false, + isDebugEnabled = false, + ) + + assertEquals(ConversationListContentUiState.Empty, state.content) + } + + @Test + fun map_emptyBeforeFirstSync_producesWaitingForSync() { + val state = mapper.map( + snapshot = ConversationListSnapshot( + items = persistentListOf(), + blockedDestinations = persistentSetOf(), + hasFirstSyncCompleted = false, + ), + selectedConversationIds = persistentListOf(), + isScrollToTopVisible = false, + isDebugEnabled = false, + ) + + assertEquals(ConversationListContentUiState.WaitingForSync, state.content) + } + + @Test + fun map_visibleDraft_takesPrecedenceOverLatestMessage() { + val state = mapper.map( + snapshot = snapshotOf( + conversationItem( + conversationId = "a", + isDraftVisible = true, + draftSnippet = "Draft body", + draftSubject = "Draft subject", + ), + ), + selectedConversationIds = persistentListOf(), + isScrollToTopVisible = false, + isDebugEnabled = false, + ) + + val item = singleItem(state) + assertTrue(item.snippet.isDraft) + assertEquals("Draft body", item.snippet.text) + assertEquals("Draft subject", item.subject) + assertEquals(ConversationListMessageStatus.Draft, item.status) + assertTrue(item.isOutgoing) + } + + @Test + fun map_callableSavedContact_populatesAvatarCapabilities() { + every { canPlacePhoneCall(any()) } returns true + every { canShowOrAddContact(any(), any(), any(), any()) } returns true + every { isContactSaved(any(), any()) } returns true + + val state = mapper.map( + snapshot = snapshotOf( + conversationItem( + conversationId = "a", + contactId = 42L, + lookupKey = "lookup", + ), + ), + selectedConversationIds = persistentListOf(), + isScrollToTopVisible = false, + isDebugEnabled = false, + ) + + val avatar = singleItem(state).avatar + assertTrue(avatar.canCall) + assertTrue(avatar.canShowContact) + assertTrue(avatar.isContactSaved) + assertEquals("+1555000a", avatar.normalizedDestination) + } + + @Test + fun map_selectedBlockedConversation_propagatesScreenState() { + val state = mapper.map( + snapshot = ConversationListSnapshot( + items = persistentListOf(conversationItem(conversationId = "a")), + blockedDestinations = persistentSetOf("+1555000a"), + hasFirstSyncCompleted = true, + ), + selectedConversationIds = persistentListOf("a"), + isScrollToTopVisible = true, + isDebugEnabled = true, + ) + + assertTrue(singleItem(state).isSelected) + assertTrue(state.isScrollToTopVisible) + assertTrue(state.hasBlockedParticipants) + assertTrue(state.isDebugEnabled) + assertFalse(state.selection.actions.canBlock) + } + + private fun singleItem( + state: ConversationListUiState, + ): ConversationListItemUiModel { + return (state.content as ConversationListContentUiState.Items).items.single() + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/ui/AppearanceAnimationTrackerTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/ui/AppearanceAnimationTrackerTest.kt new file mode 100644 index 000000000..09bf4ad22 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/ui/AppearanceAnimationTrackerTest.kt @@ -0,0 +1,124 @@ +package com.android.messaging.ui.conversationlist.ui + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class AppearanceAnimationTrackerTest { + + private val tracker = AppearanceAnimationTracker() + + @Test + fun computeEntering_firstFrame_hasNoEnteringConversations() { + val entering = tracker.computeEntering( + setOf("a", "b"), + isListAtTop = true, + excludedConversationIds = emptySet(), + ) + + assertTrue(entering.isEmpty()) + } + + @Test + fun computeEntering_afterCommit_marksOnlyAddedConversations() { + tracker.commitFrame(setOf("a", "b")) + + val entering = tracker.computeEntering( + setOf("a", "b", "c"), + isListAtTop = true, + excludedConversationIds = emptySet(), + ) + + assertEquals(setOf("c"), entering.keys) + } + + @Test + fun computeEntering_whenListNotAtTop_marksNoEnteringConversations() { + tracker.commitFrame(setOf("a", "b")) + + val entering = tracker.computeEntering( + setOf("a", "b", "c"), + isListAtTop = false, + excludedConversationIds = emptySet(), + ) + + assertTrue(entering.isEmpty()) + } + + @Test + fun computeEntering_excludedConversation_marksNoEnteringToken() { + tracker.commitFrame(setOf("a", "b")) + + val entering = tracker.computeEntering( + setOf("a", "b", "c"), + isListAtTop = true, + excludedConversationIds = setOf("c"), + ) + + assertTrue(entering.isEmpty()) + } + + @Test + fun onAnimationFinished_withActiveToken_clearsToken() { + tracker.commitFrame(setOf("a")) + + val token = tracker.commitFrame(setOf("a", "b")).getValue("b") + tracker.onAnimationFinished("b", token) + + assertNull(tracker.tokenFor("b", emptyMap())) + } + + @Test + fun tokenFor_afterFinish_doesNotReplayWhileStillEntering() { + tracker.commitFrame(setOf("a")) + + val entering = tracker.commitFrame(setOf("a", "b")) + val token = entering.getValue("b") + tracker.onAnimationFinished("b", token) + + assertNull(tracker.tokenFor("b", entering)) + } + + @Test + fun tokenFor_afterReentry_returnsFreshToken() { + tracker.commitFrame(setOf("a")) + + val firstToken = tracker.commitFrame(setOf("a", "b")).getValue("b") + tracker.onAnimationFinished("b", firstToken) + tracker.commitFrame(setOf("a")) + + val reentering = tracker.commitFrame(setOf("a", "b")) + val secondToken = reentering.getValue("b") + assertSame(secondToken, tracker.tokenFor("b", reentering)) + } + + @Test + fun onAnimationFinished_withStaleToken_keepsActiveToken() { + tracker.commitFrame(setOf("a")) + + val staleToken = tracker.commitFrame(setOf("a", "b")).getValue("b") + tracker.commitFrame(setOf("a")) + + val activeToken = tracker.commitFrame(setOf("a", "b")).getValue("b") + tracker.onAnimationFinished("b", staleToken) + + assertSame(activeToken, tracker.tokenFor("b", emptyMap())) + } + + private fun AppearanceAnimationTracker.commitFrame( + conversationIds: Set, + ): Map { + val entering = computeEntering( + conversationIds, + isListAtTop = true, + excludedConversationIds = emptySet(), + ) + commit( + currentConversationIds = conversationIds, + enteringTokens = entering, + ) + return entering + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/ui/ConversationListContentTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/ui/ConversationListContentTest.kt new file mode 100644 index 000000000..7d100a9e0 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/ui/ConversationListContentTest.kt @@ -0,0 +1,167 @@ +package com.android.messaging.ui.conversationlist.ui + +import com.android.messaging.ui.conversationlist.model.ConversationListItemUiModel +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ConversationListContentTest { + + @Test + fun resolvePinChangeScrollRequest_noPinChange_returnsNull() { + val items = listOf(item("a"), item("b")) + + val result = resolvePinChangeScrollRequest( + previousItems = items, + currentItems = items, + restoredConversationIds = emptySet(), + firstVisibleConversationId = "b", + firstVisibleItemIndex = 1, + firstVisibleItemScrollOffset = 12, + ) + + assertNull(result) + } + + @Test + fun resolvePinChangeScrollRequest_conversationSetChanged_returnsNull() { + val result = resolvePinChangeScrollRequest( + previousItems = listOf(item("a"), item("b")), + currentItems = listOf(item("a", isPinned = true), item("c")), + restoredConversationIds = emptySet(), + firstVisibleConversationId = "a", + firstVisibleItemIndex = 0, + firstVisibleItemScrollOffset = 0, + ) + + assertNull(result) + } + + @Test + fun resolvePinChangeScrollRequest_newTopItemWhileAtStart_requestsFirstItem() { + val result = resolvePinChangeScrollRequest( + previousItems = listOf(item("a"), item("b")), + currentItems = listOf(item("c"), item("a"), item("b")), + restoredConversationIds = emptySet(), + firstVisibleConversationId = "a", + firstVisibleItemIndex = 0, + firstVisibleItemScrollOffset = 0, + ) + + assertEquals( + ConversationListScrollRequest( + index = 0, + scrollOffset = 0, + ), + result, + ) + } + + @Test + fun resolvePinChangeScrollRequest_newTopItemWhileScrolled_returnsNull() { + val result = resolvePinChangeScrollRequest( + previousItems = listOf(item("a"), item("b")), + currentItems = listOf(item("c"), item("a"), item("b")), + restoredConversationIds = emptySet(), + firstVisibleConversationId = "b", + firstVisibleItemIndex = 2, + firstVisibleItemScrollOffset = 10, + ) + + assertNull(result) + } + + @Test + fun resolvePinChangeScrollRequest_restoredTopItemWhileAtStart_returnsNull() { + val result = resolvePinChangeScrollRequest( + previousItems = listOf(item("a"), item("b")), + currentItems = listOf(item("c"), item("a"), item("b")), + restoredConversationIds = setOf("c"), + firstVisibleConversationId = "a", + firstVisibleItemIndex = 0, + firstVisibleItemScrollOffset = 0, + ) + + assertNull(result) + } + + @Test + fun resolvePinChangeScrollRequest_atStart_requestsFirstItem() { + val result = resolvePinChangeScrollRequest( + previousItems = listOf(item("a"), item("b")), + currentItems = listOf(item("b", isPinned = true), item("a")), + restoredConversationIds = emptySet(), + firstVisibleConversationId = "a", + firstVisibleItemIndex = 0, + firstVisibleItemScrollOffset = 0, + ) + + assertEquals( + ConversationListScrollRequest( + index = 0, + scrollOffset = 0, + ), + result, + ) + } + + @Test + fun resolvePinChangeScrollRequest_firstVisibleItemPinned_preservesPreviousPosition() { + val result = resolvePinChangeScrollRequest( + previousItems = listOf(item("a"), item("b"), item("c")), + currentItems = listOf(item("b", isPinned = true), item("a"), item("c")), + restoredConversationIds = emptySet(), + firstVisibleConversationId = "b", + firstVisibleItemIndex = 1, + firstVisibleItemScrollOffset = 24, + ) + + assertEquals( + ConversationListScrollRequest( + index = 1, + scrollOffset = 24, + ), + result, + ) + } + + @Test + fun resolvePinChangeScrollRequest_otherItemPinned_returnsNull() { + val result = resolvePinChangeScrollRequest( + previousItems = listOf(item("a"), item("b"), item("c")), + currentItems = listOf(item("a", isPinned = true), item("b"), item("c")), + restoredConversationIds = emptySet(), + firstVisibleConversationId = "b", + firstVisibleItemIndex = 1, + firstVisibleItemScrollOffset = 24, + ) + + assertNull(result) + } + + @Test + fun resolvePinChangeScrollRequest_firstVisibleItemUnknown_returnsNull() { + val result = resolvePinChangeScrollRequest( + previousItems = listOf(item("a"), item("b")), + currentItems = listOf(item("b", isPinned = true), item("a")), + restoredConversationIds = emptySet(), + firstVisibleConversationId = null, + firstVisibleItemIndex = 1, + firstVisibleItemScrollOffset = 24, + ) + + assertNull(result) + } + + private fun item( + conversationId: String, + isPinned: Boolean = false, + ): ConversationListItemUiModel { + return previewConversationListItem( + conversationId = conversationId, + title = conversationId, + snippetText = conversationId, + isPinned = isPinned, + ) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationpicker/mapper/TargetUiStateMapperImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationpicker/mapper/TargetUiStateMapperImplTest.kt index 3c51cf13d..444dc2f2c 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversationpicker/mapper/TargetUiStateMapperImplTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationpicker/mapper/TargetUiStateMapperImplTest.kt @@ -3,6 +3,7 @@ package com.android.messaging.ui.conversationpicker.mapper import android.net.Uri import com.android.messaging.data.contact.formatter.ContactDestinationFormatter import com.android.messaging.data.conversationpicker.model.TargetConversation +import com.android.messaging.domain.conversation.usecase.avatar.ResolveAvatarUri import com.android.messaging.ui.conversationpicker.formatter.TargetTextFormatter import com.android.messaging.ui.conversationpicker.model.TargetUiState import com.android.messaging.util.PhoneUtils @@ -14,6 +15,9 @@ import io.mockk.verify import kotlinx.collections.immutable.persistentListOf import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -35,8 +39,11 @@ internal class TargetUiStateMapperImplTest { } } + private val resolveAvatarUri = mockk() + private val mapper = TargetUiStateMapperImpl( contactDestinationFormatter = contactDestinationFormatter, + resolveAvatarUri = resolveAvatarUri, textFormatter = textFormatter, ) @@ -47,6 +54,7 @@ internal class TargetUiStateMapperImplTest { every { phoneUtilsInstance.formatForDisplay(any()) } answers { "formatted:${firstArg()}" } + every { resolveAvatarUri(any()) } returns null } @After @@ -74,7 +82,7 @@ internal class TargetUiStateMapperImplTest { assertEquals("wrapped:Name", conversation.displayName) assertEquals("canonical:+15550100", conversation.normalizedDestination) assertEquals("details:formatted:+15550100", conversation.details) - assertEquals(false, conversation.isGroup) + assertFalse(conversation.isGroup) } @Test @@ -91,9 +99,9 @@ internal class TargetUiStateMapperImplTest { ).single() val conversation = result as TargetUiState.Conversation - assertEquals(true, conversation.isGroup) - assertEquals(null, conversation.normalizedDestination) - assertEquals(null, conversation.details) + assertTrue(conversation.isGroup) + assertNull(conversation.normalizedDestination) + assertNull(conversation.details) } @Test @@ -110,7 +118,7 @@ internal class TargetUiStateMapperImplTest { ).single() val conversation = result as TargetUiState.Conversation - assertEquals(null, conversation.normalizedDestination) + assertNull(conversation.normalizedDestination) } @Test @@ -122,13 +130,14 @@ internal class TargetUiStateMapperImplTest { ).single() val conversation = result as TargetUiState.Conversation - assertEquals(null, conversation.normalizedDestination) - assertEquals(null, conversation.details) + assertNull(conversation.normalizedDestination) + assertNull(conversation.details) } @Test fun map_resolvesPrimaryUriWhenIconIsAvatarUri() { val avatarIcon = avatarUri(primaryUri = "content://primary") + every { resolveAvatarUri(avatarIcon) } returns "content://primary" val result = mapper.map( persistentListOf(conversation(icon = avatarIcon)), @@ -145,11 +154,13 @@ internal class TargetUiStateMapperImplTest { persistentListOf(conversation(icon = avatarIcon)), ).single() - assertEquals(null, result.avatarUri) + assertNull(result.avatarUri) } @Test fun map_usesRawIconWhenIconIsNotAvatarUri() { + every { resolveAvatarUri("content://plain") } returns "content://plain" + val result = mapper.map( persistentListOf(conversation(icon = "content://plain")), ).single() @@ -163,7 +174,7 @@ internal class TargetUiStateMapperImplTest { persistentListOf(conversation(icon = null)), ).single() - assertEquals(null, result.avatarUri) + assertNull(result.avatarUri) } @Test @@ -172,7 +183,7 @@ internal class TargetUiStateMapperImplTest { persistentListOf(conversation(icon = " ")), ).single() - assertEquals(null, result.avatarUri) + assertNull(result.avatarUri) } @Test @@ -188,7 +199,7 @@ internal class TargetUiStateMapperImplTest { ), ).single() - assertEquals(null, result.details) + assertNull(result.details) } @Test diff --git a/res/layout/conversation_list_activity.xml b/res/layout/conversation_list_activity.xml deleted file mode 100644 index 48f3b157d..000000000 --- a/res/layout/conversation_list_activity.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - \ No newline at end of file diff --git a/res/layout/debug_sms_mms_from_dump_file_dialog.xml b/res/layout/debug_sms_mms_from_dump_file_dialog.xml deleted file mode 100644 index 342f489dd..000000000 --- a/res/layout/debug_sms_mms_from_dump_file_dialog.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - diff --git a/res/layout/sms_mms_dump_file_list_item.xml b/res/layout/sms_mms_dump_file_list_item.xml deleted file mode 100644 index a939db2b0..000000000 --- a/res/layout/sms_mms_dump_file_list_item.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - diff --git a/res/menu/conversation_list_fragment_menu.xml b/res/menu/conversation_list_fragment_menu.xml deleted file mode 100644 index b6648464d..000000000 --- a/res/menu/conversation_list_fragment_menu.xml +++ /dev/null @@ -1,47 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/res/values/strings.xml b/res/values/strings.xml index 04b84cb68..188d6e347 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -57,10 +57,7 @@ Send to %s Choose media - - %d selected - %d selected - + %d Record audio Choose a contact Add (%1$d) @@ -271,6 +268,10 @@ Archive Unarchive + + Pin + + Unpin Turn off notifications @@ -757,6 +758,15 @@ Loading conversations… + + Start chat + + Scroll to top + + Not sent + + Not downloaded + Picture @@ -1151,4 +1161,5 @@ Alerts Mark as read + Mark as unread diff --git a/res/values/versions.xml b/res/values/versions.xml index 14c6b4753..a02137223 100644 --- a/res/values/versions.xml +++ b/res/values/versions.xml @@ -16,7 +16,7 @@ --> - 2 + 3